From 27133ce8d729b744aad9b3cbf19b12d29763e1b3 Mon Sep 17 00:00:00 2001 From: Wibus <62133302+wibus-wee@users.noreply.github.com> Date: Sun, 26 Mar 2023 13:52:02 +0800 Subject: [PATCH] feat: text macro support (#1) --- .github/workflows/ci.yml | 2 +- benchmark/mx.ts | 176 +++++++++++++++++++++++++++++++ benchmark/runner.ts | 34 ++++++ package.json | 6 +- pnpm-lock.yaml | 91 +++++++++++++++- src/functions/macros.md | 110 +++++++++++++++++++ src/functions/macros.ts | 83 +++++++++++++++ src/{utils.ts => utils/index.ts} | 0 src/utils/safe-eval.util.ts | 25 +++++ test/macros.test.ts | 17 +++ 10 files changed, 538 insertions(+), 6 deletions(-) create mode 100644 benchmark/mx.ts create mode 100644 benchmark/runner.ts create mode 100644 src/functions/macros.md create mode 100644 src/functions/macros.ts rename src/{utils.ts => utils/index.ts} (100%) create mode 100644 src/utils/safe-eval.util.ts create mode 100644 test/macros.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b67bad..2eadefd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,7 +51,7 @@ jobs: strategy: matrix: - node: [14.x, 16.x] + node: [16.x] os: [ubuntu-latest] fail-fast: false diff --git a/benchmark/mx.ts b/benchmark/mx.ts new file mode 100644 index 0000000..e14839e --- /dev/null +++ b/benchmark/mx.ts @@ -0,0 +1,176 @@ +import { safeEval } from "../src/utils/safe-eval.util" + +const RegMap = { + '#': /^#(.*?)$/g, + $: /^\$(.*?)$/g, + '?': /^\?\??(.*?)\??\?$/g, +} as const + +class TextMacroService { + + private ifConditionGrammar(text: string, model: T) { + const conditionSplitter = text.split('|') + conditionSplitter.forEach((item: string, index: string | number) => { + conditionSplitter[index as number] = item.replace(/"/g, '') + conditionSplitter[index as number] = conditionSplitter[index as number].replace(/\s/g, '') + conditionSplitter[0] = conditionSplitter[0].replace(/\?/g, '') + conditionSplitter[conditionSplitter.length - 1] = conditionSplitter[ + conditionSplitter.length - 1 + ].replace(/\?/g, '') + }) + + let output: any + const condition = conditionSplitter[0].replace('$', '') + // eslint-disable-next-line no-useless-escape + const operator = condition.match(/>|==|<|\!=/g) + if (!operator) { + throw new Error('Invalid condition') + } + + const left = condition.split(operator[0])[0] + const right = condition.split(operator[0])[1] + const Value = model[left as keyof T] + switch (operator[0]) { + case '>': + output = Value > right ? conditionSplitter[1] : conditionSplitter[2] + break + case '==': + output = Value == right ? conditionSplitter[1] : conditionSplitter[2] + break + case '<': + output = Value < right ? conditionSplitter[1] : conditionSplitter[2] + break + case '!=': + output = Value != right ? conditionSplitter[1] : conditionSplitter[2] + break + case '&&': + output = Value && right ? conditionSplitter[1] : conditionSplitter[2] + break + case '||': + output = Value || right ? conditionSplitter[1] : conditionSplitter[2] + break + default: + output = conditionSplitter[1] + break + } + return output + } + + private generateFunctionContext = (variables: object) => { + return { + // time utils + dayjs: require('dayjs'), + // typography + center: (text: string) => { + return `

${text}

` + }, + right: (text: string) => { + return `

${text}

` + }, + + // styling + opacity: (text: string, opacity = 0.8) => { + return `${text}` + }, + blur: (text: string, blur = 1) => { + return `${text}` + }, + color: (text: string, color = '') => { + return `${text}` + }, + size: (text: string, size = '1em') => { + return `${text}` + }, + + ...variables, + } + } + public async replaceTextMacro( + text: string, + model: T, + + extraContext: Record = {}, + ): Promise { + try { + const matchedReg = /\[\[\s(.*?)\s\]\]/g + + const matched = text.search(matchedReg) != -1 + + if (!matched) { + return text + } + // const ast = marked.lexer(text) + + const cacheMap = {} as Record + + text = text.replace(matchedReg, (match, condition) => { + // FIXME: shallow find, if same text both in code block and paragraph, the macro in paragraph also will not replace + // const isInCodeBlock = ast.some((i) => { + // if (i.type === 'code' || i.type === 'codespan') { + // return i.raw.includes(condition) + // } + // }) + + // if (isInCodeBlock) { + // return match + // } + + condition = condition?.trim() + if (condition.search(RegMap['?']) != -1) { + return this.ifConditionGrammar(condition, model) + } + if (condition.search(RegMap['$']) != -1) { + const variable = condition + .replace(RegMap['$'], '$1') + .replace(/\s/g, '') + return model[variable as keyof T] || match + } + // eslint-disable-next-line no-useless-escape + if (condition.search(RegMap['#']) != -1) { + // eslint-disable-next-line no-useless-escape + const functions = condition.replace(RegMap['#'], '$1') + + if (typeof cacheMap[functions] != 'undefined') { + return cacheMap[functions] + } + + const variables = Object.keys(model).reduce( + (acc, key) => ({ [`$${key}`]: model[key as keyof T], ...acc }), + {}, + ) + + try { + const result = safeEval( + `return ${functions}`, + this.generateFunctionContext({ ...variables, ...extraContext }), + + { timeout: 1000 }, + ) + cacheMap[functions] = result + return result + } catch { + return match + } + } + }) + + return text + } catch (err) { + + return text + } + } +} + +interface Variables { + title: string; + created: string; + slug: string; + nid: string; + _id: string; +} + +export async function textMacro(text: string, record: Variables) { + const service = new TextMacroService(); + return await service.replaceTextMacro(text, record); +} \ No newline at end of file diff --git a/benchmark/runner.ts b/benchmark/runner.ts new file mode 100644 index 0000000..f5af568 --- /dev/null +++ b/benchmark/runner.ts @@ -0,0 +1,34 @@ +import { textMacro } from "../src/functions/macros"; +import { textMacro as mxTextMacro } from "./mx"; + +const Benchmark = require("benchmark"); +const suite = new Benchmark.Suite(); + +const record = { + title: "My First Blog Post", + created: "2020-01-01T00:00:00.000Z", + slug: "my-first-blog-post", + nid: "1", + _id: "abc123", +}; + +const text = ` +[[ $title ]]. The slug is [[ $slug ]] and the nid is [[ $nid ]]. +if _id is abc123, then it will return "yes" [[ #($_id === "abc123" ? "yes" : "no") ]] +`; + +suite + .add("textMacro (Mog)", () => { + textMacro(text, record); + }) + .add("textMacro (Mix)", () => { + mxTextMacro(text, record); + }) + .on("cycle", (event: { target: any; }) => { + console.log(String(event.target)); + }) + .on("complete", function () { + // @ts-ignore + console.log(`Fastest is ${this.filter("fastest").map("name")}. ${(this[0].hz / this[1].hz).toFixed(2)}x faster`); + }) + .run({ async: true }); diff --git a/package.json b/package.json index d3e3564..e3c1a25 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,8 @@ "@innei/eslint-config-ts": "0.9.8", "@types/marked": "^4.0.8", "@types/node": "18.15.8", + "@vitest/ui": "^0.29.7", + "benchmark": "^2.1.4", "bumpp": "8.2.1", "eslint": "8.36.0", "esno": "0.16.3", @@ -77,7 +79,9 @@ "@djot/djot": "^0.2.2", "chalk": "^5.2.0", "consola": "^2.15.3", + "dayjs": "^1.11.7", "marked": "^4.3.0", - "redis": "^4.6.5" + "redis": "^4.6.5", + "vm2": "^3.9.14" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dde70c1..e44d2d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,9 +7,12 @@ specifiers: '@innei/eslint-config-ts': 0.9.8 '@types/marked': ^4.0.8 '@types/node': 18.15.8 + '@vitest/ui': ^0.29.7 + benchmark: ^2.1.4 bumpp: 8.2.1 chalk: ^5.2.0 consola: ^2.15.3 + dayjs: ^1.11.7 eslint: 8.36.0 esno: 0.16.3 husky: 8.0.3 @@ -23,13 +26,16 @@ specifiers: unbuild: 1.0.2 vite: 3.2.5 vitest: 0.25.8 + vm2: ^3.9.14 dependencies: '@djot/djot': 0.2.2 chalk: 5.2.0 consola: 2.15.3 + dayjs: 1.11.7 marked: 4.3.0 redis: 4.6.5 + vm2: 3.9.14 devDependencies: '@innei-util/eslint-config-ts': 0.8.2_typescript@4.9.5 @@ -37,6 +43,8 @@ devDependencies: '@innei/eslint-config-ts': 0.9.8_typescript@4.9.5 '@types/marked': 4.0.8 '@types/node': 18.15.8 + '@vitest/ui': 0.29.7 + benchmark: 2.1.4 bumpp: 8.2.1 eslint: 8.36.0 esno: 0.16.3 @@ -48,7 +56,7 @@ devDependencies: typescript: 4.9.5 unbuild: 1.0.2 vite: 3.2.5_@types+node@18.15.8 - vitest: 0.25.8 + vitest: 0.25.8_@vitest+ui@0.29.7 packages: @@ -509,6 +517,10 @@ packages: fastq: 1.13.0 dev: true + /@polka/url/1.0.0-next.21: + resolution: {integrity: sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==} + dev: true + /@redis/bloom/1.2.0_@redis+client@1.5.6: resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==} peerDependencies: @@ -1035,6 +1047,16 @@ packages: eslint-visitor-keys: 3.3.0 dev: true + /@vitest/ui/0.29.7: + resolution: {integrity: sha512-KeOztcAldlFU5i8DKCQcmGrih1dVowurZy/9iPz5JyQdPJzej+nW1nI4nYvc4ZmUtA8+IAe9uViqnU7IXc1RNw==} + dependencies: + fast-glob: 3.2.12 + flatted: 3.2.7 + pathe: 1.1.0 + picocolors: 1.0.0 + sirv: 2.0.2 + dev: true + /acorn-jsx/5.3.2_acorn@8.8.1: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1046,13 +1068,11 @@ packages: /acorn-walk/8.2.0: resolution: {integrity: sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==} engines: {node: '>=0.4.0'} - dev: true /acorn/8.8.1: resolution: {integrity: sha512-7zFpHzhnqYKrkYdUjF1HI1bzd0VygEGX8lFk4k5zVMqHEoES+P+7TKI+EvLO9WVMJ8eekdO0aDEK044xTXwPPA==} engines: {node: '>=0.4.0'} hasBin: true - dev: true /aggregate-error/3.1.0: resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} @@ -1155,6 +1175,13 @@ packages: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} dev: true + /benchmark/2.1.4: + resolution: {integrity: sha512-l9MlfN4M1K/H2fbhfMy3B7vJd6AGKJVQn2h6Sg/Yx+KckoUA7ewS5Vv6TjSq18ooE1kS9hhAlQRH3AkXIh/aOQ==} + dependencies: + lodash: 4.17.21 + platform: 1.3.6 + dev: true + /brace-expansion/1.1.11: resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} dependencies: @@ -1376,6 +1403,10 @@ packages: which: 2.0.2 dev: true + /dayjs/1.11.7: + resolution: {integrity: sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ==} + dev: false + /debug/2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -2484,6 +2515,17 @@ packages: micromatch: 4.0.5 dev: true + /fast-glob/3.2.12: + resolution: {integrity: sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==} + engines: {node: '>=8.6.0'} + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.5 + dev: true + /fast-json-stable-stringify/2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} dev: true @@ -2539,6 +2581,10 @@ packages: resolution: {integrity: sha512-0sQoMh9s0BYsm+12Huy/rkKxVu4R1+r96YX5cG44rHV0pQ6iC3Q+mkoMFaGWObMFYQxCVT+ssG1ksneA2MI9KQ==} dev: true + /flatted/3.2.7: + resolution: {integrity: sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==} + dev: true + /fs-extra/10.1.0: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} @@ -3230,6 +3276,11 @@ packages: engines: {node: '>=4'} dev: true + /mrmime/1.0.1: + resolution: {integrity: sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==} + engines: {node: '>=10'} + dev: true + /ms/2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} dev: true @@ -3434,6 +3485,10 @@ packages: resolution: {integrity: sha512-nPdMG0Pd09HuSsr7QOKUXO2Jr9eqaDiZvDwdyIhNG5SHYujkQHYKDfGQkulBxvbDHz8oHLsTgKN86LSwYzSHAg==} dev: true + /pathe/1.1.0: + resolution: {integrity: sha512-ODbEPR0KKHqECXW1GoxdDb+AZvULmXjVPy4rt+pGo2+TnjJTIPJQSVS6N63n8T2Ip+syHhbn52OewKicV0373w==} + dev: true + /pathval/1.1.1: resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} dev: true @@ -3461,6 +3516,10 @@ packages: pathe: 1.0.0 dev: true + /platform/1.3.6: + resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} + dev: true + /pnpm/7.17.1: resolution: {integrity: sha512-O76jPxzoeja81Z/8YyTfuXt+f7qkpsyEJsNBreWYBLHY5rJkjvNE/bIUGQ2uD/rcYPEtmrZZYox21OjAMC9EGw==} engines: {node: '>=14.6'} @@ -3674,6 +3733,15 @@ packages: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} dev: true + /sirv/2.0.2: + resolution: {integrity: sha512-4Qog6aE29nIjAOKe/wowFTxOdmbEZKb+3tsLljaBRzJwtqto0BChD2zzH0LhgCSXiI+V7X+Y45v14wBZQ1TK3w==} + engines: {node: '>= 10'} + dependencies: + '@polka/url': 1.0.0-next.21 + mrmime: 1.0.1 + totalist: 3.0.0 + dev: true + /sisteransi/1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} dev: true @@ -3871,6 +3939,11 @@ packages: is-number: 7.0.0 dev: true + /totalist/3.0.0: + resolution: {integrity: sha512-eM+pCBxXO/njtF7vdFsHuqb+ElbxqtI4r5EAvk6grfAFyJ6IvWlSkfZ5T9ozC6xWw3Fj1fGoSmrl0gUs46JVIw==} + engines: {node: '>=6'} + dev: true + /tsconfig-paths/3.14.1: resolution: {integrity: sha512-fxDhWnFSLt3VuTwtvJt5fpwxBHg5AdKWMsgcPOOIilyjymcYVZoCQF8fvFRezCNfblEXmi+PcM1eYHeOAgXCOQ==} dependencies: @@ -4053,7 +4126,7 @@ packages: fsevents: 2.3.2 dev: true - /vitest/0.25.8: + /vitest/0.25.8_@vitest+ui@0.29.7: resolution: {integrity: sha512-X75TApG2wZTJn299E/TIYevr4E9/nBo1sUtZzn0Ci5oK8qnpZAZyhwg0qCeMSakGIWtc6oRwcQFyFfW14aOFWg==} engines: {node: '>=v14.16.0'} hasBin: true @@ -4078,6 +4151,7 @@ packages: '@types/chai': 4.3.4 '@types/chai-subset': 1.3.3 '@types/node': 18.15.8 + '@vitest/ui': 0.29.7 acorn: 8.8.1 acorn-walk: 8.2.0 chai: 4.3.7 @@ -4098,6 +4172,15 @@ packages: - terser dev: true + /vm2/3.9.14: + resolution: {integrity: sha512-HgvPHYHeQy8+QhzlFryvSteA4uQLBCOub02mgqdR+0bN/akRZ48TGB1v0aCv7ksyc0HXx16AZtMHKS38alc6TA==} + engines: {node: '>=6.0'} + hasBin: true + dependencies: + acorn: 8.8.1 + acorn-walk: 8.2.0 + dev: false + /which-boxed-primitive/1.0.2: resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==} dependencies: diff --git a/src/functions/macros.md b/src/functions/macros.md new file mode 100644 index 0000000..a2f351e --- /dev/null +++ b/src/functions/macros.md @@ -0,0 +1,110 @@ +# Text Macros + +Text macros are a simple template system that allows you to inject dynamic content into your text. The basic syntax for a text macro is `[[expression]]`. The expression can be a variable or a function call. The macro processor will replace the expression with the result of the evaluation. + +Text macros are a powerful tool that can save you a lot of time when creating documents. They allow you to define reusable pieces of text that can be inserted into your document by using a simple syntax. + +Using text macros is easy. Here's how you can get started: + +### Example + +For example, I have a paragraph, but there are something is dynamic, so I want to use a macro to replace it. + +```markdown +My First Blog Post was created on Sat, 25 Mar 2023 15:26:37 GMT. +``` + +In this example, the text `My First Blog Post` and the date `Sat, 25 Mar 2023 15:26:37 GMT` are dynamic, so I want to use a macro to replace them. + +```markdown +[[ #blur($title) ]] was created on [[ #dayjs($created).format("MMMMDD, YYYY") ]]. +``` + +## Compare to Mix Space Macro Service + +```bash +textMacro (Mog) x 690 ops/sec ±3.88% (71 runs sampled) 🌟 +textMacro (Mix) x 512 ops/sec ±4.49% (73 runs sampled) +Fastest is textMacro (Mog). 1.35x faster +``` + +## Variables + +You can reference a variable using `$variableName`. The variable name can contain letters, numbers, and underscores. The macro processor will replace the variable with the value of the corresponding variable in the context object. + +Example: `[[ $title ]]` will be replaced with the value of article's `title`. + +### Available variables + +- `$title` - The title of the post or page. +- `$slug` - The slug of the post or page. +- `$created` - The creation date of the post or page. +- `$modified` - The modification date of the post or page. +- `$tags` - The tags of the post or page. +- `$count` - The count of the post or page. + - `read` - The read count of the post or page. + - `like` - The like count of the post or page. +- `$category` - The category of the post or page. + - `name` - The name of the category of the post or page. + - `slug` - The slug of the category of the post or page. + - `description` - The description of the category of the post or page. + - `icon` - The icon of the category of the post or page. +- `$fields` - The fields of the post or page. + +## Functions + +You can call a function using `#functionName(args)`. The function name can contain letters, numbers, and underscores. The macro processor will replace the expression with the result of the function call. + +### Built-in Modules + +#### `#dayjs` + +> **Info**: WIP. + +The `dayjs` module provides a set of functions for working with dates and times. + +Syntax: `#dayjs(args)` + +Example: `[[ #dayjs($created).format("MMMMDD, YYYY") ]]` + +### Built-in functions + +#### `#if` + +The `if` function takes a condition and two values. If the condition is true, it returns the first value. Otherwise, it returns the second value. + +Syntax: `#if(condition, trueValue, falseValue)` + +Example: `[[ #if(isPublished, "Published", "Not published") ]]` + +#### `#join` + +The `join` function takes an array and a separator and returns a string that concatenates the elements of the array separated by the separator. + +Syntax: `#join(array, separator)` + +Example: `[[ #join(tags, ", ") ]]` + +### Custom functions + +You can define your own functions by adding them to the functions object in the context object. + +Example: + +```js +const context = { + functions: { + double: (value) => value * 2 + } +}; +``` + +Syntax: `#functionName(args)` + +Example: `[[ #double($value) ]]` + +## JavaScript expressions + +You can also use JavaScript expressions in your text macros by enclosing them in parentheses. + +Example: `[[ #(2 + 2) ]]` \ No newline at end of file diff --git a/src/functions/macros.ts b/src/functions/macros.ts new file mode 100644 index 0000000..697d86b --- /dev/null +++ b/src/functions/macros.ts @@ -0,0 +1,83 @@ +import { safeEval } from "../utils/safe-eval.util"; + +interface Variables { + [key: string]: any; +} + +interface Functions { + [key: string]: (...args: any[]) => any; +} + +const builtInFunctions: Functions = { + dayjs: require("dayjs"), + blur: (text: string) => { + return `${text}`; + }, +}; + +export function textMacro(text: string, record: Variables) { + const variables: Variables = record; + + const regex = /\[\[ ?(\$|#)(.*?) ?\]\]/g; + return text.replace(regex, (match: string, type: string, value: string) => { + if (type === "$") { + return variables[value as keyof Variables] || ""; + } else if (type === "#") { + if (value.startsWith("(") && value.endsWith(")")) { + const [functionName, ..._args] = value.split("("); + const func = builtInFunctions[functionName as keyof Functions] as any; + if (func) { + const values = value.replace(/\$(\w+)/g, (_, key) => { + return `${key}`; + }); + return safeEval( + `return ${values}`, + { ...variables, ...builtInFunctions }, + { timeout: 1000 } + ); + } + try { + const values = value + .replace(/\$(\w+)/g, (_, key) => { + return `${key}`; + }) + .replace(/^\(|\)$/g, ""); + return safeEval(values, { ...variables }, { timeout: 1000 }); + } catch (error) { + console.error(`Error evaluating JS expression: ${value}`); + return match; + } + } else { + const [functionName, ..._args] = value.split("("); + const func = builtInFunctions[functionName as keyof Functions] as any; + if (func) { + const values = value.replace(/\$(\w+)/g, (_, key) => { + return `${key}`; + }); + return safeEval( + `return ${values}`, + { ...variables, ...builtInFunctions }, + { timeout: 1000 } + ); + } + } + } + return match; + }); +} + +// const record: Variables = { +// title: "My First Blog Post", +// created: "2020-01-01T00:00:00.000Z", +// slug: "my-first-blog-post", +// nid: "1", +// _id: "abc123", +// }; + +// const text = ` +// [[ #blur($title) ]] was created on [[ #dayjs($created).format("MMMMDD, YYYY") ]]. The slug is [[ $slug ]] and the nid is [[ $nid ]]. +// [[ #($slug.slice(0, 5)) ]] +// if _id is abc123, then it will return "yes" [[ #($_id === "abc123" ? "yes" : "no") ]] +// `; + +// console.log(textMacro(text, record)); diff --git a/src/utils.ts b/src/utils/index.ts similarity index 100% rename from src/utils.ts rename to src/utils/index.ts diff --git a/src/utils/safe-eval.util.ts b/src/utils/safe-eval.util.ts new file mode 100644 index 0000000..bbc3c06 --- /dev/null +++ b/src/utils/safe-eval.util.ts @@ -0,0 +1,25 @@ +import vm2 from 'vm2' + +export function safeEval(code: string, context = {}, options?: vm2.VMOptions) { + const sandbox = { + global: {}, + } + + code = `((() => { ${code} })())` + if (context) { + Object.keys(context).forEach((key) => { + // @ts-ignore + sandbox[key] = context[key] + }) + } + + const VM = new vm2.VM({ + timeout: 60_0000, + sandbox, + + eval: false, + ...options, + }) + + return VM.run(code) +} \ No newline at end of file diff --git a/test/macros.test.ts b/test/macros.test.ts new file mode 100644 index 0000000..10e334a --- /dev/null +++ b/test/macros.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest' +import { textMacro } from '../src/functions/macros'; + +describe('test macros function', () => { + it('should running correctly', () => { + const text = 'test mog-render-service.textMacro [[ $title ]] [[#blur($title)]]'; + const record = { + title: 'title', + created: 'created', + slug: 'slug', + nid: 'nid', + _id: '_id', + }; + const result = textMacro(text, record); + expect(result).toBe('test mog-render-service.textMacro title title'); + }) +}) \ No newline at end of file