Skip to content

Commit 199847c

Browse files
committed
feat: async operations
1 parent 6bb8663 commit 199847c

File tree

5 files changed

+91
-26
lines changed

5 files changed

+91
-26
lines changed

src/builtin-filters.js

+5
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,8 @@ export function htmlentities(str) {
100100
export function urlencode(str) {
101101
return encodeURIComponent(str);
102102
}
103+
104+
export async function async(asyncInput) {
105+
const result = await asyncInput;
106+
return (typeof result === 'function') ? result() : result;
107+
}

src/transforms/template-data.js

+14-8
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import vm from 'node:vm';
33
import { frontmatter } from './frontmatter.js';
44
import { resolve } from '../resolver.js';
55
import { SissiConfig } from "../sissi-config.js";
6+
import { replaceAsync } from '../utils/replace-async.js';
67

78
const TEMPLATE_REGEX = /\{\{\s*(.+?)\s*\}\}/g;
89

@@ -47,16 +48,16 @@ export function parseFilterExpression(expr, ctx) {
4748
* Poor girl's handlebars
4849
*
4950
* @param {string} str the template content
50-
* @returns {(data: any, filters: Map<string, function>) => string} a function that takes a data object and returns the processed template
51+
* @returns {Promise<(data: any, filters: Map<string, function>) => string>} a function that takes a data object and returns the processed template
5152
*/
5253
export function template(str) {
5354
const defaultFilters = new Map();
5455
let isSafe = false;
5556
defaultFilters.set('safe', (input) => { isSafe = true; return input; })
56-
return (data, providedFilters) => {
57+
return async (data, providedFilters) => {
5758
const context = vm.createContext({...data});
5859
const filters = mergeMaps(defaultFilters || new Map(), providedFilters || new Map())
59-
return str.replace(TEMPLATE_REGEX, (_, templateString) => {
60+
return replaceAsync(str, TEMPLATE_REGEX, async (_, templateString) => {
6061
const expressions = templateString.split('|').map(e => e.trim());
6162
const mainExpression = expressions[0];
6263
const filterExpressions = expressions.slice(1);
@@ -76,8 +77,14 @@ export function template(str) {
7677
}
7778

7879
result = args ? filters.get(filter)(result, ...args) : filters.get(filter)(result);
80+
if (result instanceof Promise) {
81+
result = await result;
82+
}
7983
}
8084

85+
if (result instanceof Promise) {
86+
result = await result;
87+
}
8188
return isSafe ? result : htmlEscape(result);
8289
});
8390
}
@@ -125,16 +132,15 @@ export async function handleTemplateFile(config, data, inputFile) {
125132

126133

127134
const { data: matterData, body } = frontmatter(content);
128-
const fileData = Object.assign({}, structuredClone(data), matterData);
129-
if (fileData.page && typeof fileData.page === 'object') {
130-
Object.assign(fileData.page, page);
131-
} else {
135+
const fileData = Object.assign({}, data, matterData);
136+
137+
if (! fileData.page) {
132138
fileData.page = page;
133139
}
134140

135141
const processor = await plugin.compile(body, inputFile);
136142

137-
let fileContent = template(await processor(fileData))(fileData, config.filters);
143+
let fileContent = await (template(await processor(fileData))(fileData, config.filters));
138144

139145
if (fileData.layout) {
140146
const layoutFilePath = path.normalize(path.join(config.dir.layouts, fileData.layout));

src/utils/replace-async.js

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export async function replaceAsync(string, regexp, replacerFunction) {
2+
const replacements = await Promise.all(
3+
Array.from(string.matchAll(regexp),
4+
match => replacerFunction(...match)));
5+
let i = 0;
6+
return string.replace(regexp, () => replacements[i++]);
7+
}

tests/transforms/template-data.test.js

+28-18
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { createContext } from 'node:vm';
77
import { handleTemplateFile, parseFilterExpression, template } from '../../src/transforms/template-data.js';
88
import { SissiConfig } from '../../src/sissi-config.js';
99
import md from '../../src/md.js';
10+
import * as builtinFilters from '../../src/builtin-filters.js';
1011

1112
const TEST_DATA = {
1213
'title': 'This is a title',
@@ -71,56 +72,65 @@ describe('parseFilterExpression function', () => {
7172
});
7273

7374
describe('template function', () => {
74-
it('should insert data into the placeholders wrapped in double curly brackets', () => {
75-
assert.equal(template(TEST_TEMPLATE)(TEST_DATA), TEST_TEMPLATE_EXPECTED);
75+
it('should insert data into the placeholders wrapped in double curly brackets', async () => {
76+
assert.equal(await template(TEST_TEMPLATE)(TEST_DATA), TEST_TEMPLATE_EXPECTED);
7677
});
7778

78-
it('should be able to invoke functions', () => {
79-
assert.equal(template(TEST_TEMPLATE_2)(TEST_DATA), TEST_TEMPLATE_EXPECTED_2);
80-
assert.equal(template(TEST_TEMPLATE_3)(TEST_DATA), TEST_TEMPLATE_EXPECTED_3);
79+
it('should be able to invoke functions', async () => {
80+
assert.equal(await template(TEST_TEMPLATE_2)(TEST_DATA), TEST_TEMPLATE_EXPECTED_2);
81+
assert.equal(await template(TEST_TEMPLATE_3)(TEST_DATA), TEST_TEMPLATE_EXPECTED_3);
8182
});
8283

83-
it('should be able to apply a filter', () => {
84+
it('should be able to apply a filter', async () => {
8485
const filters = new Map();
8586
filters.set('shout', (str) => (str||'').toUpperCase());
86-
const result = template('{{greeting | shout }}')({greeting: "Hello"}, filters);
87+
const result = await template('{{greeting | shout }}')({greeting: "Hello"}, filters);
8788

8889
assert.equal(result, "HELLO");
8990
});
9091

91-
it('should escape angle brackets and ampersands by default', () => {
92-
const result = template('{{ content }}')({content: '<h1>Hello</h1>'});
92+
it('should escape angle brackets and ampersands by default', async () => {
93+
const result = await template('{{ content }}')({content: '<h1>Hello</h1>'});
9394

9495
assert.equal(result, '&lt;h1&gt;Hello&lt;/h1&gt;')
9596
});
9697

97-
it('should not escape angle brackets and ampersands when marked safe', () => {
98-
const result = template('{{ content | safe }}')({content: '<h1>Hello</h1>'});
98+
it('should not escape angle brackets and ampersands when marked safe', async () => {
99+
const result = await template('{{ content | safe }}')({content: '<h1>Hello</h1>'});
99100

100101
assert.equal(result, '<h1>Hello</h1>')
101102
});
102103

103-
it('should be able to apply a filter with additional parameters', () => {
104+
it('should be able to apply a filter with additional parameters', async () => {
104105
const data = { greeting: 'Hello Lea' }
105106
const filters = new Map();
106107
filters.set('piratify', (str, prefix = 'Yo-ho-ho', suffix = 'yarrr') => `${prefix}! ${str}, ${suffix}!`);
107108

108-
assert.equal(template('{{ greeting | piratify }}')(data, filters), 'Yo-ho-ho! Hello Lea, yarrr!');
109-
assert.equal(template('{{ greeting | piratify: "AYE" }}')(data, filters), 'AYE! Hello Lea, yarrr!');
110-
assert.equal(template('{{ greeting | piratify: "Ahoy", "matey" }}')(data, filters), 'Ahoy! Hello Lea, matey!');
109+
assert.equal(await template('{{ greeting | piratify }}')(data, filters), 'Yo-ho-ho! Hello Lea, yarrr!');
110+
assert.equal(await template('{{ greeting | piratify: "AYE" }}')(data, filters), 'AYE! Hello Lea, yarrr!');
111+
assert.equal(await template('{{ greeting | piratify: "Ahoy", "matey" }}')(data, filters), 'Ahoy! Hello Lea, matey!');
111112
});
112113

113-
it('should be able to chain filters', () => {
114+
it('should be able to chain filters', async () => {
114115
const filters = new Map();
115116
filters.set('shout', (str) => (str||'').toUpperCase());
116117
filters.set('piratify', (str, prefix = 'Yo-ho-ho', suffix = 'yarrr') => `${prefix}! ${str}, ${suffix}!`);
117118

118119
const data = { greeting: 'Hello Lea' };
119-
assert.equal(template('{{ greeting | piratify | shout }}')(data, filters), 'YO-HO-HO! HELLO LEA, YARRR!');
120+
assert.equal(await template('{{ greeting | piratify | shout }}')(data, filters), 'YO-HO-HO! HELLO LEA, YARRR!');
120121

121122
// order matters
122-
assert.equal(template('{{ greeting | shout | piratify }}')(data, filters), 'Yo-ho-ho! HELLO LEA, yarrr!');
123+
assert.equal(await template('{{ greeting | shout | piratify }}')(data, filters), 'Yo-ho-ho! HELLO LEA, yarrr!');
123124
});
125+
126+
it('should be able to do async operations', async () => {
127+
const filters = new Map();
128+
filters.set('async', builtinFilters.async);
129+
const data = { answer: () => Promise.resolve(42) };
130+
131+
assert.equal(await (template('{{ answer | async }}')(data, filters)), '42');
132+
});
133+
124134
});
125135

126136
describe('handleTemplateFile function', () => {

tests/utils/replace-async.test.js

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { describe, it } from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { replaceAsync } from '../../src/utils/replace-async.js';
4+
5+
describe('replaceAsync', () => {
6+
7+
it('should do nothing when there is no match', async () => {
8+
const testString = 'There is no planet B.';
9+
10+
const result = await replaceAsync(testString, /\{\}/g, () => {
11+
return Promise.reject('this should not happen');
12+
});
13+
14+
assert.equal(result, testString);
15+
});
16+
17+
it('should reject when an inner result rejects', async () => {
18+
const testString = 'Crash {}';
19+
20+
await assert.rejects(async () => {
21+
await replaceAsync(testString, /\{\}/g, () => Promise.reject(new Error('boom')));
22+
}, {
23+
message: 'boom'
24+
});
25+
});
26+
27+
it('should be able to process asynchronous replacements', async () => {
28+
const replacements = ['life', 'universe', 'rest', '42'];
29+
30+
const result = await replaceAsync('The answer to {0}, {1} and the {2} is {3}.', /\{(\d+)\}/g, (_, x) => {
31+
const idx = (x|0);
32+
return Promise.resolve(replacements[idx]);
33+
});
34+
assert.equal(result, 'The answer to life, universe and the rest is 42.');
35+
});
36+
37+
});

0 commit comments

Comments
 (0)