Skip to content

Commit 86fc135

Browse files
authored
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
1 parent 5d95313 commit 86fc135

10 files changed

Lines changed: 282 additions & 0 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
title: base64_decode
3+
---
4+
5+
{% since %}v10.24.0{% endsince %}
6+
7+
Decodes a Base64-formatted string back to its original text.
8+
9+
Input
10+
```liquid
11+
{{ "b25lIHR3byB0aHJlZQ==" | base64_decode }}
12+
```
13+
14+
Output
15+
```text
16+
one two three
17+
```
18+
19+
Input
20+
```liquid
21+
{{ "SGVsbG8sIFdvcmxkISBAIyQl" | base64_decode }}
22+
```
23+
24+
Output
25+
```text
26+
Hello, World! @#$%
27+
```
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
title: base64_encode
3+
---
4+
5+
{% since %}v10.24.0{% endsince %}
6+
7+
Encodes a string into Base64 format.
8+
9+
Input
10+
```liquid
11+
{{ "one two three" | base64_encode }}
12+
```
13+
14+
Output
15+
```text
16+
b25lIHR3byB0aHJlZQ==
17+
```
18+
19+
Input
20+
```liquid
21+
{{ "Hello, World! @#$%" | base64_encode }}
22+
```
23+
24+
Output
25+
```text
26+
SGVsbG8sIFdvcmxkISBAIyQl
27+
```

docs/source/filters/overview.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,6 @@ HTML/URI | escape, escape_once, url_encode, url_decode, strip_html, newline_to_b
1515
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
1616
Date | date, date_to_xmlschema, date_to_rfc822, date_to_string, date_to_long_string
1717
Misc | default, json, jsonify, inspect, raw, to_integer
18+
Base64 | base64_encode, base64_decode
1819

1920
[shopify/liquid]: https://github.com/Shopify/liquid

rollup.config.mjs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ const browserFS = {
4545
delimiters: ['', ''],
4646
'./fs/fs-impl': './build/fs-impl-browser'
4747
}
48+
const browserBase64 = {
49+
include: './src/filters/base64.ts',
50+
delimiters: ['', ''],
51+
'./base64-impl': '../build/base64-impl-browser'
52+
}
4853
const browserStream = {
4954
include: './src/emitters/index.ts',
5055
delimiters: ['', ''],
@@ -94,6 +99,7 @@ const browserEsm = {
9499
plugins: [
95100
versionInjection,
96101
replace(browserFS),
102+
replace(browserBase64),
97103
replace(browserStream),
98104
typescript(tsconfig('es6'))
99105
],
@@ -112,6 +118,7 @@ const browserUmd = {
112118
plugins: [
113119
versionInjection,
114120
replace(browserFS),
121+
replace(browserBase64),
115122
replace(browserStream),
116123
typescript(tsconfig('es5'))
117124
],
@@ -130,6 +137,7 @@ const browserMin = {
130137
plugins: [
131138
versionInjection,
132139
replace(browserFS),
140+
replace(browserBase64),
133141
replace(browserStream),
134142
typescript(tsconfig('es5')),
135143
uglify()
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import * as base64 from './base64-impl-browser'
2+
import { JSDOM } from 'jsdom'
3+
4+
describe('base64-impl/browser', function () {
5+
if (+(process.version.match(/^v(\d+)/) as RegExpMatchArray)[1] < 8) {
6+
console.info('jsdom not supported, skipping base64-impl-browser...')
7+
return
8+
}
9+
10+
beforeEach(function () {
11+
const dom = new JSDOM(``, {
12+
url: 'https://example.com/',
13+
contentType: 'text/html',
14+
includeNodeLocations: true
15+
})
16+
17+
// Mock btoa and atob on global object
18+
Object.defineProperty(global, 'btoa', {
19+
value: dom.window.btoa,
20+
writable: true,
21+
configurable: true
22+
})
23+
Object.defineProperty(global, 'atob', {
24+
value: dom.window.atob,
25+
writable: true,
26+
configurable: true
27+
})
28+
})
29+
30+
afterEach(function () {
31+
delete (global as any).btoa
32+
delete (global as any).atob
33+
})
34+
35+
describe('#base64Encode()', function () {
36+
it('should encode a simple string', function () {
37+
expect(base64.base64Encode('one two three')).toBe('b25lIHR3byB0aHJlZQ==')
38+
})
39+
40+
it('should encode an empty string', function () {
41+
expect(base64.base64Encode('')).toBe('')
42+
})
43+
44+
it('should encode a string with special characters', function () {
45+
expect(base64.base64Encode('Hello, World! @#$%')).toBe('SGVsbG8sIFdvcmxkISBAIyQl')
46+
})
47+
48+
it('should encode numeric strings', function () {
49+
expect(base64.base64Encode('123')).toBe('MTIz')
50+
})
51+
52+
it('should encode boolean strings', function () {
53+
expect(base64.base64Encode('true')).toBe('dHJ1ZQ==')
54+
})
55+
})
56+
57+
describe('#base64Decode()', function () {
58+
it('should decode a simple string', function () {
59+
expect(base64.base64Decode('b25lIHR3byB0aHJlZQ==')).toBe('one two three')
60+
})
61+
62+
it('should decode an empty string', function () {
63+
expect(base64.base64Decode('')).toBe('')
64+
})
65+
66+
it('should decode a string with special characters', function () {
67+
expect(base64.base64Decode('SGVsbG8sIFdvcmxkISBAIyQl')).toBe('Hello, World! @#$%')
68+
})
69+
70+
it('should decode numeric strings', function () {
71+
expect(base64.base64Decode('MTIz')).toBe('123')
72+
})
73+
74+
it('should decode boolean strings', function () {
75+
expect(base64.base64Decode('dHJ1ZQ==')).toBe('true')
76+
})
77+
})
78+
79+
describe('round-trip encoding/decoding', function () {
80+
it('should encode and decode back to original', function () {
81+
const original = 'Hello, World!'
82+
const encoded = base64.base64Encode(original)
83+
const decoded = base64.base64Decode(encoded)
84+
expect(decoded).toBe(original)
85+
})
86+
87+
it('should handle complex strings with special characters', function () {
88+
const original = 'Special chars: !@#$%^&*()_+-=[]{}|;:,.<>?'
89+
const encoded = base64.base64Encode(original)
90+
const decoded = base64.base64Decode(encoded)
91+
expect(decoded).toBe(original)
92+
})
93+
94+
it('should handle mixed unicode and ASCII', function () {
95+
const original = 'Hello 🌍'
96+
const encoded = base64.base64Encode(original)
97+
const decoded = base64.base64Decode(encoded)
98+
expect(decoded).toBe(original)
99+
})
100+
})
101+
})

src/build/base64-impl-browser.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
2+
export function base64Encode (str: string): string {
3+
return btoa(String.fromCharCode(...new TextEncoder().encode(str)))
4+
}
5+
6+
export function base64Decode (str: string): string {
7+
return new TextDecoder().decode(
8+
Uint8Array.from(atob(str), c => c.charCodeAt(0))
9+
)
10+
}

src/filters/base64-impl.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export function base64Encode (str: string): string {
2+
return Buffer.from(str, 'utf8').toString('base64')
3+
}
4+
5+
export function base64Decode (str: string): string {
6+
return Buffer.from(str, 'base64').toString('utf8')
7+
}

src/filters/base64.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/**
2+
* Base64 related filters
3+
*
4+
* Implements base64_encode and base64_decode filters for Shopify compatibility
5+
*/
6+
7+
import { FilterImpl } from '../template'
8+
import { stringify } from '../util'
9+
import { base64Encode, base64Decode } from './base64-impl'
10+
11+
export function base64_encode (this: FilterImpl, value: string): string {
12+
const str = stringify(value)
13+
this.context.memoryLimit.use(str.length)
14+
return base64Encode(str)
15+
}
16+
17+
export function base64_decode (this: FilterImpl, value: string): string {
18+
const str = stringify(value)
19+
this.context.memoryLimit.use(str.length)
20+
return base64Decode(str)
21+
}

src/filters/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import * as urlFilters from './url'
44
import * as arrayFilters from './array'
55
import * as dateFilters from './date'
66
import * as stringFilters from './string'
7+
import * as base64Filters from './base64'
78
import misc from './misc'
89
import { FilterImplOptions } from '../template'
910

@@ -14,5 +15,6 @@ export const filters: Record<string, FilterImplOptions> = {
1415
...arrayFilters,
1516
...dateFilters,
1617
...stringFilters,
18+
...base64Filters,
1719
...misc
1820
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { test } from '../../stub/render'
2+
3+
describe('filters/base64', function () {
4+
describe('base64_encode', function () {
5+
it('should encode a simple string', () => {
6+
return test('{{ "one two three" | base64_encode }}', 'b25lIHR3byB0aHJlZQ==')
7+
})
8+
9+
it('should encode an empty string', () => {
10+
return test('{{ "" | base64_encode }}', '')
11+
})
12+
13+
it('should encode a string with special characters', () => {
14+
return test('{{ "Hello, World! @#$%" | base64_encode }}', 'SGVsbG8sIFdvcmxkISBAIyQl')
15+
})
16+
17+
it('should encode unicode characters', () => {
18+
return test('{{ "你好世界" | base64_encode }}', '5L2g5aW95LiW55WM')
19+
})
20+
21+
it('should handle undefined input', () => {
22+
return test('{{ foo | base64_encode }}', '')
23+
})
24+
25+
it('should handle null input', () => {
26+
return test('{{ null | base64_encode }}', '')
27+
})
28+
29+
it('should handle numeric input', () => {
30+
return test('{{ 123 | base64_encode }}', 'MTIz')
31+
})
32+
33+
it('should handle boolean input', () => {
34+
return test('{{ true | base64_encode }}', 'dHJ1ZQ==')
35+
})
36+
})
37+
38+
describe('base64_decode', function () {
39+
it('should decode a simple string', () => {
40+
return test('{{ "b25lIHR3byB0aHJlZQ==" | base64_decode }}', 'one two three')
41+
})
42+
43+
it('should decode an empty string', () => {
44+
return test('{{ "" | base64_decode }}', '')
45+
})
46+
47+
it('should decode a string with special characters', () => {
48+
return test('{{ "SGVsbG8sIFdvcmxkISBAIyQl" | base64_decode }}', 'Hello, World! @#$%')
49+
})
50+
51+
it('should handle undefined input', () => {
52+
return test('{{ foo | base64_decode }}', '')
53+
})
54+
55+
it('should handle null input', () => {
56+
return test('{{ null | base64_decode }}', '')
57+
})
58+
59+
it('should handle numeric input', () => {
60+
return test('{{ "MTIz" | base64_decode }}', '123')
61+
})
62+
63+
it('should handle boolean input', () => {
64+
return test('{{ "dHJ1ZQ==" | base64_decode }}', 'true')
65+
})
66+
})
67+
68+
describe('base64 round-trip', function () {
69+
it('should encode and decode back to original', () => {
70+
return test('{{ "Hello, World!" | base64_encode | base64_decode }}', 'Hello, World!')
71+
})
72+
73+
it('should handle complex strings', () => {
74+
const complexString = 'Special chars: !@#$%^&*()_+-=[]{}|;:,.<>?'
75+
return test(`{{ "${complexString}" | base64_encode | base64_decode }}`, complexString)
76+
})
77+
})
78+
})

0 commit comments

Comments
 (0)