diff --git a/docs/source/filters/base64_decode.md b/docs/source/filters/base64_decode.md new file mode 100644 index 000000000..bcccac826 --- /dev/null +++ b/docs/source/filters/base64_decode.md @@ -0,0 +1,27 @@ +--- +title: base64_decode +--- + +{% since %}v10.24.0{% endsince %} + +Decodes a Base64-formatted string back to its original text. + +Input +```liquid +{{ "b25lIHR3byB0aHJlZQ==" | base64_decode }} +``` + +Output +```text +one two three +``` + +Input +```liquid +{{ "SGVsbG8sIFdvcmxkISBAIyQl" | base64_decode }} +``` + +Output +```text +Hello, World! @#$% +``` \ No newline at end of file diff --git a/docs/source/filters/base64_encode.md b/docs/source/filters/base64_encode.md new file mode 100644 index 000000000..2bddde867 --- /dev/null +++ b/docs/source/filters/base64_encode.md @@ -0,0 +1,27 @@ +--- +title: base64_encode +--- + +{% since %}v10.24.0{% endsince %} + +Encodes a string into Base64 format. + +Input +```liquid +{{ "one two three" | base64_encode }} +``` + +Output +```text +b25lIHR3byB0aHJlZQ== +``` + +Input +```liquid +{{ "Hello, World! @#$%" | base64_encode }} +``` + +Output +```text +SGVsbG8sIFdvcmxkISBAIyQl +``` \ No newline at end of file diff --git a/docs/source/filters/overview.md b/docs/source/filters/overview.md index f7aee5af7..eee2cb0e2 100644 --- a/docs/source/filters/overview.md +++ b/docs/source/filters/overview.md @@ -15,5 +15,6 @@ HTML/URI | escape, escape_once, url_encode, url_decode, strip_html, newline_to_b Array | slice, map, sort, sort_natural, uniq, where, where_exp, group_by, group_by_exp, find, find_exp, first, last, join, reverse, concat, compact, size, push, pop, shift, unshift Date | date, date_to_xmlschema, date_to_rfc822, date_to_string, date_to_long_string Misc | default, json, jsonify, inspect, raw, to_integer +Base64 | base64_encode, base64_decode [shopify/liquid]: https://github.com/Shopify/liquid diff --git a/rollup.config.mjs b/rollup.config.mjs index 8d17634f8..5b353886b 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -45,6 +45,11 @@ const browserFS = { delimiters: ['', ''], './fs/fs-impl': './build/fs-impl-browser' } +const browserBase64 = { + include: './src/filters/base64.ts', + delimiters: ['', ''], + './base64-impl': '../build/base64-impl-browser' +} const browserStream = { include: './src/emitters/index.ts', delimiters: ['', ''], @@ -94,6 +99,7 @@ const browserEsm = { plugins: [ versionInjection, replace(browserFS), + replace(browserBase64), replace(browserStream), typescript(tsconfig('es6')) ], @@ -112,6 +118,7 @@ const browserUmd = { plugins: [ versionInjection, replace(browserFS), + replace(browserBase64), replace(browserStream), typescript(tsconfig('es5')) ], @@ -130,6 +137,7 @@ const browserMin = { plugins: [ versionInjection, replace(browserFS), + replace(browserBase64), replace(browserStream), typescript(tsconfig('es5')), uglify() diff --git a/src/build/base64-impl-browser.spec.ts b/src/build/base64-impl-browser.spec.ts new file mode 100644 index 000000000..6078d0c3b --- /dev/null +++ b/src/build/base64-impl-browser.spec.ts @@ -0,0 +1,101 @@ +import * as base64 from './base64-impl-browser' +import { JSDOM } from 'jsdom' + +describe('base64-impl/browser', function () { + if (+(process.version.match(/^v(\d+)/) as RegExpMatchArray)[1] < 8) { + console.info('jsdom not supported, skipping base64-impl-browser...') + return + } + + beforeEach(function () { + const dom = new JSDOM(``, { + url: 'https://example.com/', + contentType: 'text/html', + includeNodeLocations: true + }) + + // Mock btoa and atob on global object + Object.defineProperty(global, 'btoa', { + value: dom.window.btoa, + writable: true, + configurable: true + }) + Object.defineProperty(global, 'atob', { + value: dom.window.atob, + writable: true, + configurable: true + }) + }) + + afterEach(function () { + delete (global as any).btoa + delete (global as any).atob + }) + + describe('#base64Encode()', function () { + it('should encode a simple string', function () { + expect(base64.base64Encode('one two three')).toBe('b25lIHR3byB0aHJlZQ==') + }) + + it('should encode an empty string', function () { + expect(base64.base64Encode('')).toBe('') + }) + + it('should encode a string with special characters', function () { + expect(base64.base64Encode('Hello, World! @#$%')).toBe('SGVsbG8sIFdvcmxkISBAIyQl') + }) + + it('should encode numeric strings', function () { + expect(base64.base64Encode('123')).toBe('MTIz') + }) + + it('should encode boolean strings', function () { + expect(base64.base64Encode('true')).toBe('dHJ1ZQ==') + }) + }) + + describe('#base64Decode()', function () { + it('should decode a simple string', function () { + expect(base64.base64Decode('b25lIHR3byB0aHJlZQ==')).toBe('one two three') + }) + + it('should decode an empty string', function () { + expect(base64.base64Decode('')).toBe('') + }) + + it('should decode a string with special characters', function () { + expect(base64.base64Decode('SGVsbG8sIFdvcmxkISBAIyQl')).toBe('Hello, World! @#$%') + }) + + it('should decode numeric strings', function () { + expect(base64.base64Decode('MTIz')).toBe('123') + }) + + it('should decode boolean strings', function () { + expect(base64.base64Decode('dHJ1ZQ==')).toBe('true') + }) + }) + + describe('round-trip encoding/decoding', function () { + it('should encode and decode back to original', function () { + const original = 'Hello, World!' + const encoded = base64.base64Encode(original) + const decoded = base64.base64Decode(encoded) + expect(decoded).toBe(original) + }) + + it('should handle complex strings with special characters', function () { + const original = 'Special chars: !@#$%^&*()_+-=[]{}|;:,.<>?' + const encoded = base64.base64Encode(original) + const decoded = base64.base64Decode(encoded) + expect(decoded).toBe(original) + }) + + it('should handle mixed unicode and ASCII', function () { + const original = 'Hello 🌍' + const encoded = base64.base64Encode(original) + const decoded = base64.base64Decode(encoded) + expect(decoded).toBe(original) + }) + }) +}) diff --git a/src/build/base64-impl-browser.ts b/src/build/base64-impl-browser.ts new file mode 100644 index 000000000..100cf9f21 --- /dev/null +++ b/src/build/base64-impl-browser.ts @@ -0,0 +1,10 @@ + +export function base64Encode (str: string): string { + return btoa(String.fromCharCode(...new TextEncoder().encode(str))) +} + +export function base64Decode (str: string): string { + return new TextDecoder().decode( + Uint8Array.from(atob(str), c => c.charCodeAt(0)) + ) +} diff --git a/src/filters/base64-impl.ts b/src/filters/base64-impl.ts new file mode 100644 index 000000000..d19a0eb87 --- /dev/null +++ b/src/filters/base64-impl.ts @@ -0,0 +1,7 @@ +export function base64Encode (str: string): string { + return Buffer.from(str, 'utf8').toString('base64') +} + +export function base64Decode (str: string): string { + return Buffer.from(str, 'base64').toString('utf8') +} diff --git a/src/filters/base64.ts b/src/filters/base64.ts new file mode 100644 index 000000000..c0e1a7993 --- /dev/null +++ b/src/filters/base64.ts @@ -0,0 +1,21 @@ +/** + * Base64 related filters + * + * Implements base64_encode and base64_decode filters for Shopify compatibility + */ + +import { FilterImpl } from '../template' +import { stringify } from '../util' +import { base64Encode, base64Decode } from './base64-impl' + +export function base64_encode (this: FilterImpl, value: string): string { + const str = stringify(value) + this.context.memoryLimit.use(str.length) + return base64Encode(str) +} + +export function base64_decode (this: FilterImpl, value: string): string { + const str = stringify(value) + this.context.memoryLimit.use(str.length) + return base64Decode(str) +} diff --git a/src/filters/index.ts b/src/filters/index.ts index caf040c9b..ef245a1c2 100644 --- a/src/filters/index.ts +++ b/src/filters/index.ts @@ -4,6 +4,7 @@ import * as urlFilters from './url' import * as arrayFilters from './array' import * as dateFilters from './date' import * as stringFilters from './string' +import * as base64Filters from './base64' import misc from './misc' import { FilterImplOptions } from '../template' @@ -14,5 +15,6 @@ export const filters: Record = { ...arrayFilters, ...dateFilters, ...stringFilters, + ...base64Filters, ...misc } diff --git a/test/integration/filters/base64.spec.ts b/test/integration/filters/base64.spec.ts new file mode 100644 index 000000000..cf54fc4f6 --- /dev/null +++ b/test/integration/filters/base64.spec.ts @@ -0,0 +1,78 @@ +import { test } from '../../stub/render' + +describe('filters/base64', function () { + describe('base64_encode', function () { + it('should encode a simple string', () => { + return test('{{ "one two three" | base64_encode }}', 'b25lIHR3byB0aHJlZQ==') + }) + + it('should encode an empty string', () => { + return test('{{ "" | base64_encode }}', '') + }) + + it('should encode a string with special characters', () => { + return test('{{ "Hello, World! @#$%" | base64_encode }}', 'SGVsbG8sIFdvcmxkISBAIyQl') + }) + + it('should encode unicode characters', () => { + return test('{{ "你好世界" | base64_encode }}', '5L2g5aW95LiW55WM') + }) + + it('should handle undefined input', () => { + return test('{{ foo | base64_encode }}', '') + }) + + it('should handle null input', () => { + return test('{{ null | base64_encode }}', '') + }) + + it('should handle numeric input', () => { + return test('{{ 123 | base64_encode }}', 'MTIz') + }) + + it('should handle boolean input', () => { + return test('{{ true | base64_encode }}', 'dHJ1ZQ==') + }) + }) + + describe('base64_decode', function () { + it('should decode a simple string', () => { + return test('{{ "b25lIHR3byB0aHJlZQ==" | base64_decode }}', 'one two three') + }) + + it('should decode an empty string', () => { + return test('{{ "" | base64_decode }}', '') + }) + + it('should decode a string with special characters', () => { + return test('{{ "SGVsbG8sIFdvcmxkISBAIyQl" | base64_decode }}', 'Hello, World! @#$%') + }) + + it('should handle undefined input', () => { + return test('{{ foo | base64_decode }}', '') + }) + + it('should handle null input', () => { + return test('{{ null | base64_decode }}', '') + }) + + it('should handle numeric input', () => { + return test('{{ "MTIz" | base64_decode }}', '123') + }) + + it('should handle boolean input', () => { + return test('{{ "dHJ1ZQ==" | base64_decode }}', 'true') + }) + }) + + describe('base64 round-trip', function () { + it('should encode and decode back to original', () => { + return test('{{ "Hello, World!" | base64_encode | base64_decode }}', 'Hello, World!') + }) + + it('should handle complex strings', () => { + const complexString = 'Special chars: !@#$%^&*()_+-=[]{}|;:,.<>?' + return test(`{{ "${complexString}" | base64_encode | base64_decode }}`, complexString) + }) + }) +})