mirror of
https://gitee.com/nocobase/nocobase.git
synced 2025-05-09 15:39:24 +08:00
feat: refactor JSON template parser and add date filter tests
This commit is contained in:
parent
98e659b115
commit
b35bc82639
@ -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);
|
||||
});
|
||||
});
|
@ -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';
|
||||
|
@ -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();
|
||||
|
@ -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);
|
||||
}
|
@ -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';
|
||||
|
@ -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;
|
||||
|
||||
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;
|
||||
});
|
||||
});
|
||||
|
||||
describe('json-templates filters', () => {
|
||||
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,
|
Loading…
x
Reference in New Issue
Block a user