feat(filters): Add base64_encode and base64_decode filters for Shopify compatibility (#828)

* feat(filters): add base64 encode and decode

* fix: use Object.defineProperty for cross-platform btoa/atob mocking

* docs(filters): update docs

* docs(filters): update version
This commit is contained in:
Omri Rosner
2025-10-27 22:40:31 +08:00
committed by GitHub
parent 5d953132e8
commit 86fc135d9e
10 changed files with 282 additions and 0 deletions
+27
View File
@@ -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! @#$%
```
+27
View File
@@ -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
```
+1
View File
@@ -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 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 Date | date, date_to_xmlschema, date_to_rfc822, date_to_string, date_to_long_string
Misc | default, json, jsonify, inspect, raw, to_integer Misc | default, json, jsonify, inspect, raw, to_integer
Base64 | base64_encode, base64_decode
[shopify/liquid]: https://github.com/Shopify/liquid [shopify/liquid]: https://github.com/Shopify/liquid
+8
View File
@@ -45,6 +45,11 @@ const browserFS = {
delimiters: ['', ''], delimiters: ['', ''],
'./fs/fs-impl': './build/fs-impl-browser' './fs/fs-impl': './build/fs-impl-browser'
} }
const browserBase64 = {
include: './src/filters/base64.ts',
delimiters: ['', ''],
'./base64-impl': '../build/base64-impl-browser'
}
const browserStream = { const browserStream = {
include: './src/emitters/index.ts', include: './src/emitters/index.ts',
delimiters: ['', ''], delimiters: ['', ''],
@@ -94,6 +99,7 @@ const browserEsm = {
plugins: [ plugins: [
versionInjection, versionInjection,
replace(browserFS), replace(browserFS),
replace(browserBase64),
replace(browserStream), replace(browserStream),
typescript(tsconfig('es6')) typescript(tsconfig('es6'))
], ],
@@ -112,6 +118,7 @@ const browserUmd = {
plugins: [ plugins: [
versionInjection, versionInjection,
replace(browserFS), replace(browserFS),
replace(browserBase64),
replace(browserStream), replace(browserStream),
typescript(tsconfig('es5')) typescript(tsconfig('es5'))
], ],
@@ -130,6 +137,7 @@ const browserMin = {
plugins: [ plugins: [
versionInjection, versionInjection,
replace(browserFS), replace(browserFS),
replace(browserBase64),
replace(browserStream), replace(browserStream),
typescript(tsconfig('es5')), typescript(tsconfig('es5')),
uglify() uglify()
+101
View File
@@ -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)
})
})
})
+10
View File
@@ -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))
)
}
+7
View File
@@ -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')
}
+21
View File
@@ -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)
}
+2
View File
@@ -4,6 +4,7 @@ import * as urlFilters from './url'
import * as arrayFilters from './array' import * as arrayFilters from './array'
import * as dateFilters from './date' import * as dateFilters from './date'
import * as stringFilters from './string' import * as stringFilters from './string'
import * as base64Filters from './base64'
import misc from './misc' import misc from './misc'
import { FilterImplOptions } from '../template' import { FilterImplOptions } from '../template'
@@ -14,5 +15,6 @@ export const filters: Record<string, FilterImplOptions> = {
...arrayFilters, ...arrayFilters,
...dateFilters, ...dateFilters,
...stringFilters, ...stringFilters,
...base64Filters,
...misc ...misc
} }
+78
View File
@@ -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)
})
})
})