diff --git a/packages/core/json-template-parser/src/__tests__/ctx-func.test.ts b/packages/core/json-template-parser/src/__tests__/ctx-func.test.ts new file mode 100644 index 0000000000..04aba5ff30 --- /dev/null +++ b/packages/core/json-template-parser/src/__tests__/ctx-func.test.ts @@ -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); + }); +}); diff --git a/packages/core/json-template-parser/src/parser/index.ts b/packages/core/json-template-parser/src/parser/index.ts index 486942bdd5..ff67548964 100644 --- a/packages/core/json-template-parser/src/parser/index.ts +++ b/packages/core/json-template-parser/src/parser/index.ts @@ -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'; diff --git a/packages/core/json-template-parser/src/parser/json-template-parser.ts b/packages/core/json-template-parser/src/parser/json-template-parser.ts index cf7eb46162..bb8c12c2bd 100644 --- a/packages/core/json-template-parser/src/parser/json-template-parser.ts +++ b/packages/core/json-template-parser/src/parser/json-template-parser.ts @@ -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; private _filters: Array; 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; @@ -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(); + 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 }) => { + 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(); diff --git a/packages/core/json-template-parser/src/parser/parse.ts b/packages/core/json-template-parser/src/parser/parse.ts deleted file mode 100644 index 16cd5c0a6d..0000000000 --- a/packages/core/json-template-parser/src/parser/parse.ts +++ /dev/null @@ -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); -} diff --git a/packages/core/utils/src/index.ts b/packages/core/utils/src/index.ts index 3073b19e3d..b2e3258752 100644 --- a/packages/core/utils/src/index.ts +++ b/packages/core/utils/src/index.ts @@ -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'; diff --git a/packages/core/json-template-parser/src/__tests__/parser.test.ts b/packages/plugins/@nocobase/plugin-variable-filters/src/server/__tests__/date.test.ts similarity index 67% rename from packages/core/json-template-parser/src/__tests__/parser.test.ts rename to packages/plugins/@nocobase/plugin-variable-filters/src/server/__tests__/date.test.ts index dae019e528..7e73f5ab56 100644 --- a/packages/core/json-template-parser/src/__tests__/parser.test.ts +++ b/packages/plugins/@nocobase/plugin-variable-filters/src/server/__tests__/date.test.ts @@ -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,