Skip to content

Commit

Permalink
feat: rework template engine so it supports JS expressions
Browse files Browse the repository at this point in the history
  • Loading branch information
learosema committed Oct 9, 2024
1 parent 565d8ea commit 67fa60d
Show file tree
Hide file tree
Showing 3 changed files with 70 additions and 86 deletions.
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
20
22
93 changes: 37 additions & 56 deletions src/transforms/template-data.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import path from 'node:path';

import vm from 'node:vm';
import { frontmatter } from './frontmatter.js';
import { resolve } from '../resolver.js';
import { SissiConfig } from "../sissi-config.js";

const TEMPLATE_REGEX = /\{\{\s*([\w\.\[\]]+)(?:\((.*)\))?(?:\s*\|\s([a-zA-Z*]\w*)?(?:\s*\:\s*(.+))?)?\s*\}\}/g;
const JSON_PATH_REGEX = /^[a-zA-Z_]\w*((?:\.\w+)|(?:\[\d+\]))*$/
const JSON_PATH_TOKEN = /(^[a-zA-Z_]\w*)|(\.[a-zA-Z_]\w*)|(\[\d+\])/g
const TEMPLATE_REGEX = /\{\{\s*(.+?)\s*\}\}/g;

function mergeMaps(map1, map2) {
return new Map([...map1, ...map2]);
Expand All @@ -16,49 +14,25 @@ function htmlEscape(input) {
return input?.toString().replace(/\&/g, '&amp;').replace(/\</g, '&lt;').replace(/\>/g, '&gt;');
}

/**
* Poor girl's jsonpath
*
* @param {string} path
* @returns {(data:any) => any} a function that returns the specified property
*/
export function dataPath(path) {
if (JSON_PATH_REGEX.test(path) === false) {
throw new Error('invalid json path: ' + path);
}
const matches = Array.from(path.match(JSON_PATH_TOKEN)).map(
m => JSON.parse(m.replace(/(^\.)|\[|\]/g, '').replace(/^([a-zA-Z]\w+)$/, '"$1"'))
);
return (data) => {
let result = data;
for (const match of matches) {
result = result[match];
if (! result) {
return result;
function safeEval(snippet, context) {
const s = new vm.Script(snippet);
let result = context && vm.isContext(context) ? s.runInContext(context) : s.runInNewContext(context || {Object, Array});
return result;
}

export function parseFilterExpression(expr, ctx) {
const colonSyntax = expr.match(/^([a-zA-Z_]\w+?)(?:\: (.+?))?$/);
if (colonSyntax !== null) {
const filter = colonSyntax[1];
const args = colonSyntax[2] ? Array.from(safeEval(`[${colonSyntax[2]}]`, ctx)).map(item => {
if (typeof item === 'function') {
return item();
}
}
return result;
return item;
}) : null;
return [filter, args];
}
}
/**
*
* @param {string} args a string with a comma separated
* @param {any} data data object that is used to fill in data-path parameters
* @returns
*/
export function parseArguments(args, data) {
if (!args) return [];
return args.trim().split(/\s*,\s*/).map(arg => {
if (JSON_PATH_REGEX.test(arg)) {
return dataPath(arg)(data);
}
try {
return JSON.parse(arg)
} catch (err) {
console.error('error parsing JSON:', err.message);
return [];
}
});
throw new Error('filter syntax error');
}

/**
Expand All @@ -72,20 +46,27 @@ export function template(str) {
let isSafe = false;
defaultFilters.set('safe', (input) => { isSafe = true; return input; })
return (data, providedFilters) => {
const context = vm.createContext({...data});
const filters = mergeMaps(defaultFilters || new Map(), providedFilters || new Map())
return str.replace(TEMPLATE_REGEX, (_, expr, params, filter, filterParams) => {
let result = dataPath(expr)(data);
const args = parseArguments(params, data);

if (typeof result === "function") {
result = result(...args);
return str.replace(TEMPLATE_REGEX, (_, templateString) => {
const expressions = templateString.split('|').map(e => e.trim());
const mainExpression = expressions[0];
const filterExpressions = expressions.slice(1);
let result = safeEval(mainExpression, context);
if (typeof result === 'function') {
result = result();
}

if (filter && filters instanceof Map &&
filters.has(filter) && typeof filters.get(filter) === 'function') {
const filterArgs = parseArguments(filterParams, data);
result = filters.get(filter)(result, ...(filterArgs||[]));
for (const filterExpression of filterExpressions) {
const [filter, args] = parseFilterExpression(filterExpression, context);
if (!filter || filters instanceof Map === false || !filters.has(filter) ||
typeof filters.get(filter) !== 'function') {
// TODO: more helpful error message:
throw new Error('unregistered or invalid filter: ' + filter);
}

result = args ? filters.get(filter)(result, ...args) : filters.get(filter)(result);
}

return isSafe ? result : htmlEscape(result);
});
}
Expand Down
61 changes: 32 additions & 29 deletions tests/transforms/template-data.test.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import nonStrictAssert from 'node:assert';
import path from 'node:path';
import { createContext } from 'node:vm';

import { dataPath, handleTemplateFile, parseArguments, template } from '../../src/transforms/template-data.js';
import { handleTemplateFile, parseFilterExpression, template } from '../../src/transforms/template-data.js';
import { SissiConfig } from '../../src/sissi-config.js';
import md from '../../src/md.js';

Expand Down Expand Up @@ -40,43 +42,32 @@ const TEST_TEMPLATE_EXPECTED_2 = '12.03.2024';
const TEST_TEMPLATE_3 = `{{ greet(meta.authors[1]) }}`
const TEST_TEMPLATE_EXPECTED_3 = 'Hello Lea';

describe('dataPath tests', () => {
describe('parseFilterExpression function', () => {
const scope = { meta: { authors: ['Joe', 'Lea'] }, foo: 'bar' };
const context = createContext(scope);

it('creates a function to get data properties', () => {
assert.equal(dataPath('title')(TEST_DATA), TEST_DATA.title);
});
it('should parse a parameterless filter', () => {
const [filter, args] = parseFilterExpression('uppercase', context);

it('creates a function to get an element from an array', () => {
assert.equal(dataPath('tags[2]')(TEST_DATA), TEST_DATA.tags[2]);
assert.equal(filter, 'uppercase');
assert.equal(args, null);
});

it('creates a function to get an element from an array inside an object', () => {
assert.equal(dataPath('meta.authors[1]')(TEST_DATA), TEST_DATA.meta.authors[1]);
});
it('should parse the filter and a list of constant arguments from an expression', () => {
const [filter, args] = parseFilterExpression('language: "de"', context);

it('creates a function to get an element from a nested array', () => {
assert.equal(dataPath('theMatrix[1][2]')(TEST_DATA), TEST_DATA.theMatrix[1][2]);
assert.equal(filter, 'language');
nonStrictAssert.deepEqual(args, ["de"]);
});

it('should fail when using an invalid syntax', () => {
assert.throws(() => dataPath(''));
assert.throws(() => dataPath('.object[1]'));
assert.throws(() => dataPath('object[1].'));
assert.throws(() => dataPath('object.[1]'));
assert.throws(() => dataPath('object..[1]'));
});
});
it('should addionally resolve any variable used', () => {
const [filter, args] = parseFilterExpression('author: meta.authors[1]', context);

describe('parseArguments function', () => {
it('should parse argument lists', () => {
assert.deepEqual(parseArguments('"DE"', {}), ["DE"]);
assert.deepEqual(parseArguments('12.3, "DE"', {}), [12.3, "DE"]);
});

it('should support data path arguments', () => {
assert.deepEqual(parseArguments('meta.author, 12', {meta:{author:'Lea'}}), ['Lea', 12]);
assert.equal(filter, 'author');
nonStrictAssert.deepEqual(args, ['Lea']);
});


});

describe('template function', () => {
Expand Down Expand Up @@ -110,14 +101,26 @@ describe('template function', () => {
});

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

assert.equal(template('{{ greeting | piratify }}')(data, filters), 'Yo-ho-ho! Hello Lea, yarrr!');
assert.equal(template('{{ greeting | piratify: "AYE" }}')(data, filters), 'AYE! Hello Lea, yarrr!');
assert.equal(template('{{ greeting | piratify: "Ahoy", "matey" }}')(data, filters), 'Ahoy! Hello Lea, matey!');
});

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

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

// order matters
assert.equal(template('{{ greeting | shout | piratify }}')(data, filters), 'Yo-ho-ho! HELLO LEA, yarrr!');
});
});

describe('handleTemplateFile function', () => {
Expand Down

0 comments on commit 67fa60d

Please sign in to comment.