feat: refactor JSON template parser and add date filter tests

This commit is contained in:
Sheldon Guo 2025-03-07 08:25:08 +08:00
parent 98e659b115
commit b35bc82639
6 changed files with 224 additions and 179 deletions

View File

@ -0,0 +1,32 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { escape, revertEscape } from '../escape';
import { createJSONTemplateParser } from '../parser';
const parser = createJSONTemplateParser();
describe('ctx function', () => {
it('should escape array', () => {
const template = {
user: '{{$user.id}}-{{$user.name}}',
state: {
userId: 1,
},
};
const data = {
$user({ fields, context }) {
if (context.state.userId) {
return (field) => 1;
} else return (field) => 2;
},
};
const parsed = parser.parse(template);
const result = parsed(data);
});
});

View File

@ -7,5 +7,4 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
export { parse } from './parse';
export { createJSONTemplateParser, JSONTemplateParser } from './json-template-parser';

View File

@ -7,9 +7,10 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Liquid } from 'liquidjs';
import { Liquid, TokenKind } from 'liquidjs';
import { get } from 'lodash';
import { variableFilters, filterGroups } from '../filters';
import { escape } from '../escape';
import { escape, revertEscape } from '../escape';
type FilterGroup = {
name: string;
title: string;
@ -26,12 +27,12 @@ type Filter = {
};
export class JSONTemplateParser {
engine: Liquid;
private _engine: Liquid;
private _filterGroups: Array<FilterGroup>;
private _filters: Array<Filter>;
constructor() {
this.engine = new Liquid();
this._engine = new Liquid();
this._filterGroups = [];
this._filters = [];
}
@ -40,6 +41,10 @@ export class JSONTemplateParser {
return this._filters;
}
get engine(): Liquid {
return this._engine;
}
get filterGroups(): Array<
FilterGroup & {
filters: Array<Filter>;
@ -56,12 +61,167 @@ export class JSONTemplateParser {
}
registerFilter(filter: Filter): void {
this._filters.push(filter);
this.engine.registerFilter(filter.name, filter.handler);
this._engine.registerFilter(filter.name, filter.handler);
}
render(template: string, data: any = {}): any {
return this.engine.parseAndRenderSync(escape(template), escape(data));
const scopeFieldsMap = new Map<string, string[]>();
Object.keys(data).forEach((key) => {
if (key.startsWith('$') || typeof data[key] === 'function') {
scopeFieldsMap.set(key, []);
}
});
const parsed = this.parse(template, { scopeFieldsMap });
return parsed(data);
}
parse = (value: any, opts?: { scopeFieldsMap: Map<string, string[]> }) => {
const engine = this.engine;
function type(value) {
let valueType: string = typeof value;
if (Array.isArray(value)) {
valueType = 'array';
} else if (value instanceof Date) {
valueType = 'date';
} else if (value === null) {
valueType = 'null';
}
return valueType;
}
function Template(fn, parameters) {
fn.parameters = Array.from(new Map(parameters.map((parameter) => [parameter.key, parameter])).values());
return fn;
}
// This regular expression detects instances of the
// template parameter syntax such as {{foo}} or {{foo:someDefault}}.
const parseString = (() => {
// This regular expression detects instances of the
// template parameter syntax such as {{foo}} or {{foo:someDefault}}.
return (str) => {
const escapeStr = escape(str);
const rawTemplates = engine.parse(escapeStr);
const templates = rawTemplates
.filter((rawTemplate) => rawTemplate.token.kind === TokenKind.Output)
.map((rawTemplate) => {
if (rawTemplate.token.kind === TokenKind.Output) {
// @ts-ignore
const fullVariables = engine.fullVariablesSync(rawTemplate.token.content);
const variableName = fullVariables[0];
// @ts-ignore
const variableSegments = (engine.variableSegmentsSync(rawTemplate.token.content)[0] ?? []) as string[];
/* collect scope fields to map
eg: '{{ $user.name }} - {{$user.id}}'
fieldsMap = {'$user': ['name', 'id']}
*/
if (
opts?.scopeFieldsMap &&
variableName.startsWith('$') &&
variableSegments.length > 1 &&
opts.scopeFieldsMap.has(variableSegments[0])
) {
opts.scopeFieldsMap.set(variableName, variableSegments.slice(1));
}
return {
// @ts-ignore
variableName: fullVariables[0],
variableSegments,
tokenKind: rawTemplate.token.kind,
tokenBegin: rawTemplate.token.begin,
tokenEnd: rawTemplate.token.end,
// @ts-ignore
filters: rawTemplate.value?.filters.map(({ name, handler, args }) => ({
name,
handler,
args: args.map((arg) => arg.content),
})),
};
} else {
return {
tokenKind: rawTemplate.token.kind,
tokenBegin: rawTemplate.token.begin,
tokenEnd: rawTemplate.token.end,
// @ts-ignore
content: rawTemplate.token.content,
};
}
});
const templateFn = (context) => {
const escapedContext = escape(context);
if (templates.length === 1 && templates[0].tokenBegin === 0 && templates[0].tokenEnd === escapeStr.length) {
let value = get(escapedContext, templates[0].variableName);
if (typeof value === 'function') {
value = value();
}
if (Array.isArray(templates[0].filters)) {
return templates[0].filters.reduce((acc, filter) => filter.handler(...[acc, ...filter.args]), value);
}
} else {
return revertEscape(engine.renderSync(rawTemplates, escapedContext));
}
templates.map((template) => {
if (template.tokenKind === TokenKind.Output) {
const value = get(escapedContext, template.variableName);
}
});
};
// Accommodate non-string as original values.
const parameters = templates.map((template) => ({ key: revertEscape(template.variableName) }));
return Template(templateFn, parameters);
};
})();
function parseObject(object) {
const children = Object.keys(object).map((key) => ({
keyTemplate: parseString(key),
valueTemplate: _parse(object[key]),
}));
const templateParameters = children.reduce(
(parameters, child) => parameters.concat(child.valueTemplate.parameters, child.keyTemplate.parameters),
[],
);
const templateFn = (context) => {
return children.reduce((newObject, child) => {
newObject[child.keyTemplate(context)] = child.valueTemplate(context);
return newObject;
}, {});
};
return Template(templateFn, templateParameters);
}
// Parses non-leaf-nodes in the template object that are arrays.
function parseArray(array) {
const templates = array.map(_parse);
const templateParameters = templates.reduce((parameters, template) => parameters.concat(template.parameters), []);
const templateFn = (context) => templates.map((template) => template(context));
return Template(templateFn, templateParameters);
}
function _parse(value) {
switch (type(value)) {
case 'string':
return parseString(value);
case 'object':
return parseObject(value);
case 'array':
return parseArray(value);
default:
return Template(function () {
return value;
}, []);
}
}
return _parse(value);
};
}
const parser = new JSONTemplateParser();

View File

@ -1,140 +0,0 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
// json-templates
// Simple templating within JSON structures.
//
// Created by Curran Kelleher and Chrostophe Serafin.
// Contributions from Paul Brewer and Javier Blanco Martinez.
import { get } from 'lodash';
import { TokenKind } from 'liquidjs';
import { escape, revertEscape } from '../escape';
import { createJSONTemplateParser } from './json-template-parser';
// An enhanced version of `typeof` that handles arrays and dates as well.
const parser = createJSONTemplateParser();
const liquidjsEngine = parser.engine;
function type(value) {
let valueType: string = typeof value;
if (Array.isArray(value)) {
valueType = 'array';
} else if (value instanceof Date) {
valueType = 'date';
} else if (value === null) {
valueType = 'null';
}
return valueType;
}
// Constructs a template function with deduped `parameters` property.
function Template(fn, parameters) {
fn.parameters = Array.from(new Map(parameters.map((parameter) => [parameter.key, parameter])).values());
return fn;
}
// Parses the given template object.
//
// Returns a function `template(context)` that will "fill in" the template
// with the context object passed to it.
//
// The returned function has a `parameters` property,
// which is an array of parameter descriptor objects,
// each of which has a `key` property and possibly a `defaultValue` property.
export function parse(value) {
switch (type(value)) {
case 'string':
return parseString(value);
case 'object':
return parseObject(value);
case 'array':
return parseArray(value);
default:
return Template(function () {
return value;
}, []);
}
}
// Parses leaf nodes of the template object that are strings.
// Also used for parsing keys that contain templates.
const parseString = (() => {
// This regular expression detects instances of the
// template parameter syntax such as {{foo}} or {{foo:someDefault}}.
return (str) => {
const escapeStr = escape(str);
const rawTemplates = liquidjsEngine.parse(escapeStr);
const templates = rawTemplates
.filter((rawTemplate) => rawTemplate.token.kind === TokenKind.Output)
.map((rawTemplate) => {
const fullVariables = liquidjsEngine.fullVariablesSync(rawTemplate.token.input);
return {
// @ts-ignore
variableName: fullVariables[0],
tokenBegin: rawTemplate.token.begin,
tokenEnd: rawTemplate.token.end,
// @ts-ignore
filters: rawTemplate.value?.filters.map(({ name, handler, args }) => ({
name,
handler,
args: args.map((arg) => arg.content),
})),
};
});
const templateFn = (context) => {
const escapedContext = escape(context);
if (templates.length === 1 && templates[0].tokenBegin === 0 && templates[0].tokenEnd === escapeStr.length) {
let value = get(escapedContext, templates[0].variableName);
if (typeof value === 'function') {
value = value();
}
if (Array.isArray(templates[0].filters)) {
return templates[0].filters.reduce((acc, filter) => filter.handler(...[acc, ...filter.args]), value);
}
}
return revertEscape(liquidjsEngine.renderSync(rawTemplates, escapedContext));
};
// Accommodate non-string as original values.
const parameters = templates.map((template) => ({ key: revertEscape(template.variableName) }));
return Template(templateFn, parameters);
};
})();
// Parses non-leaf-nodes in the template object that are objects.
function parseObject(object) {
const children = Object.keys(object).map((key) => ({
keyTemplate: parseString(key),
valueTemplate: parse(object[key]),
}));
const templateParameters = children.reduce(
(parameters, child) => parameters.concat(child.valueTemplate.parameters, child.keyTemplate.parameters),
[],
);
const templateFn = (context) => {
return children.reduce((newObject, child) => {
newObject[child.keyTemplate(context)] = child.valueTemplate(context);
return newObject;
}, {});
};
return Template(templateFn, templateParameters);
}
// Parses non-leaf-nodes in the template object that are arrays.
function parseArray(array) {
const templates = array.map(parse);
const templateParameters = templates.reduce((parameters, template) => parameters.concat(template.parameters), []);
const templateFn = (context) => templates.map((template) => template(context));
return Template(templateFn, templateParameters);
}

View File

@ -9,6 +9,10 @@
import lodash from 'lodash';
import { dayjs } from './dayjs';
import { createJSONTemplateParser } from '@nocobase/json-template-parser';
const parser = createJSONTemplateParser();
const parse = parser.parse;
export { parse };
export * from './assign';
export * from './collections-graph';
@ -19,7 +23,6 @@ export * from './forEach';
export * from './fs-exists';
export * from './handlebars';
export * from './isValidFilter';
export { parse } from '@nocobase/json-template-parser';
export * from './koa-multer';
export * from './measure-execution-time';
export * from './merge';

View File

@ -1,31 +1,22 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import Database, { Repository } from '@nocobase/database';
import { createMockServer, MockServer } from '@nocobase/test';
import { parse } from '../parser';
describe('json-templates', () => {
it('parse json with string template', async () => {
const template = {
name: '{{id}}-{{name}}.',
age: 18,
};
const result = parse(template)({
name: 'test',
id: 1,
});
expect(result).toEqual({
name: '1-test.',
age: 18,
});
});
});
describe('date filters', async () => {
let app: MockServer;
let db: Database;
let repo: Repository;
let agent;
let parse;
describe('json-templates filters', () => {
beforeAll(async () => {
app = await createMockServer({
plugins: ['field-sort', 'auth', 'variable-filters'],
});
db = app.db;
repo = db.getRepository('authenticators');
agent = app.agent();
parse = app.jsonTemplateParser.parse;
});
it('date filters', async () => {
const template = {
now: '{{now}}',
@ -33,8 +24,8 @@ describe('json-templates filters', () => {
yesterday: '{{now | date_subtract: 1, "day" | date_format: "YYYY-MM-DD"}}',
};
const parsed = parse(template);
const now = new Date('2025-01-01: 12:00:00');
const parsed = app.jsonTemplateParser.parse(template);
const now = new Date('2025-01-01 12:00:00');
const result = parsed({
now,
});
@ -51,7 +42,7 @@ describe('json-templates filters', () => {
firstOfArray1: '{{array.0}}',
firstOfArray2: '{{array[0]}}',
};
const result = parse(template)({
const result = app.jsonTemplateParser.parse(template)({
user: { name: 'john' },
array: ['first', 'second'],
});
@ -69,7 +60,7 @@ describe('json-templates filters', () => {
form: '{{form}}',
$form: '{{$form}}',
};
const result = parse(template)({
const result = app.jsonTemplateParser.parse(template)({
form,
$form: form,
});
@ -84,7 +75,7 @@ describe('json-templates filters', () => {
key1: '{{current.key1}}',
key2: '{{current.key2}}',
};
const result = parse(template)({
const result = app.jsonTemplateParser.parse(template)({
current: { key1: 'value1' },
});
expect(result).toEqual({
@ -100,7 +91,7 @@ describe('json-templates filters', () => {
$yesterday: '{{ $now | date_subtract: 1, "day" | date_format: "YYYY-MM-DD" }}',
};
const parsed = parse(template);
const parsed = app.jsonTemplateParser.parse(template);
const $now = new Date('2025-01-01: 12:00:00');
const result = parsed({
$now,