diff options
Diffstat (limited to 'dev')
59 files changed, 7399 insertions, 1114 deletions
| diff --git a/dev/bin/build-libs.js b/dev/bin/build-libs.js new file mode 100644 index 00000000..07d27188 --- /dev/null +++ b/dev/bin/build-libs.js @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2023  Yomitan Authors + * Copyright (C) 2020-2022  Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program.  If not, see <https://www.gnu.org/licenses/>. + */ + +import {buildLibs} from '../build-libs.js'; + +buildLibs(); diff --git a/dev/build.js b/dev/bin/build.js index 24b1e2d0..282f0414 100644 --- a/dev/build.js +++ b/dev/bin/build.js @@ -16,15 +16,17 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -const fs = require('fs'); -const path = require('path'); -const assert = require('assert'); -const readline = require('readline'); -const childProcess = require('child_process'); -const util = require('./util'); -const {getAllFiles, getArgs, testMain} = util; -const {ManifestUtil} = require('./manifest-util'); - +import assert from 'assert'; +import childProcess from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import readline from 'readline'; +import {fileURLToPath} from 'url'; +import {buildLibs} from '../build-libs.js'; +import {ManifestUtil} from '../manifest-util.js'; +import {getAllFiles, getArgs, testMain} from '../util.js'; + +const dirname = path.dirname(fileURLToPath(import.meta.url));  async function createZip(directory, excludeFiles, outputFileName, sevenZipExes, onUpdate, dryRun) {      try { @@ -59,7 +61,7 @@ async function createZip(directory, excludeFiles, outputFileName, sevenZipExes,  }  async function createJSZip(directory, excludeFiles, outputFileName, onUpdate, dryRun) { -    const JSZip = util.JSZip; +    const JSZip = null;      const files = getAllFiles(directory);      removeItemsFromArray(files, excludeFiles);      const zip = new JSZip(); @@ -178,7 +180,7 @@ function ensureFilesExist(directory, files) {  } -async function main(argv) { +export async function main(argv) {      const args = getArgs(argv, new Map([          ['all', false],          ['default', false], @@ -195,12 +197,13 @@ async function main(argv) {      const manifestUtil = new ManifestUtil(); -    const rootDir = path.join(__dirname, '..'); +    const rootDir = path.join(dirname, '..', '..');      const extDir = path.join(rootDir, 'ext');      const buildDir = path.join(rootDir, 'builds');      const manifestPath = path.join(extDir, 'manifest.json');      try { +        await buildLibs();          const variantNames = (              argv.length === 0 || args.get('all') ?              manifestUtil.getVariants().filter(({buildable}) => buildable !== false).map(({name}) => name) : @@ -218,12 +221,4 @@ async function main(argv) {      }  } - -if (require.main === module) { -    testMain(main, process.argv.slice(2)); -} - - -module.exports = { -    main -}; +testMain(main, process.argv.slice(2)); diff --git a/dev/bin/dictionary-validate.js b/dev/bin/dictionary-validate.js new file mode 100644 index 00000000..78ad5198 --- /dev/null +++ b/dev/bin/dictionary-validate.js @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2023  Yomitan Authors + * Copyright (C) 2020-2022  Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program.  If not, see <https://www.gnu.org/licenses/>. + */ + +import {testDictionaryFiles} from '../dictionary-validate.js'; + +async function main() { +    const dictionaryFileNames = process.argv.slice(2); +    if (dictionaryFileNames.length === 0) { +        console.log([ +            'Usage:', +            '  node dictionary-validate [--ajv] <dictionary-file-names>...' +        ].join('\n')); +        return; +    } + +    let mode = null; +    if (dictionaryFileNames[0] === '--ajv') { +        mode = 'ajv'; +        dictionaryFileNames.splice(0, 1); +    } + +    await testDictionaryFiles(mode, dictionaryFileNames); +} + +main(); diff --git a/dev/bin/generate-css-json.js b/dev/bin/generate-css-json.js new file mode 100644 index 00000000..48b42c65 --- /dev/null +++ b/dev/bin/generate-css-json.js @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2023  Yomitan Authors + * Copyright (C) 2020-2022  Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program.  If not, see <https://www.gnu.org/licenses/>. + */ + +import fs from 'fs'; +import {formatRulesJson, generateRules, getTargets} from '../generate-css-json.js'; + +function main() { +    for (const {cssFile, overridesCssFile, outputPath} of getTargets()) { +        const json = formatRulesJson(generateRules(cssFile, overridesCssFile)); +        fs.writeFileSync(outputPath, json, {encoding: 'utf8'}); +    } +} + +main(); diff --git a/dev/bin/schema-validate.js b/dev/bin/schema-validate.js new file mode 100644 index 00000000..86cfebae --- /dev/null +++ b/dev/bin/schema-validate.js @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2023  Yomitan Authors + * Copyright (C) 2020-2022  Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program.  If not, see <https://www.gnu.org/licenses/>. + */ + +import fs from 'fs'; +import performance from 'perf_hooks'; +import {createJsonSchema} from '../util.js'; + +function main() { +    const args = process.argv.slice(2); +    if (args.length < 2) { +        console.log([ +            'Usage:', +            '  node schema-validate [--ajv] <schema-file-name> <data-file-names>...' +        ].join('\n')); +        return; +    } + +    let mode = null; +    if (args[0] === '--ajv') { +        mode = 'ajv'; +        args.splice(0, 1); +    } + +    const schemaSource = fs.readFileSync(args[0], {encoding: 'utf8'}); +    const schema = JSON.parse(schemaSource); + +    for (const dataFileName of args.slice(1)) { +        const start = performance.now(); +        try { +            console.log(`Validating ${dataFileName}...`); +            const dataSource = fs.readFileSync(dataFileName, {encoding: 'utf8'}); +            const data = JSON.parse(dataSource); +            createJsonSchema(mode, schema).validate(data); +            const end = performance.now(); +            console.log(`No issues detected (${((end - start) / 1000).toFixed(2)}s)`); +        } catch (e) { +            const end = performance.now(); +            console.log(`Encountered an error (${((end - start) / 1000).toFixed(2)}s)`); +            console.warn(e); +        } +    } +} + + +main(); diff --git a/dev/build-libs.js b/dev/build-libs.js index 36c07edd..8320a947 100644 --- a/dev/build-libs.js +++ b/dev/build-libs.js @@ -16,51 +16,48 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -const fs = require('fs'); -const path = require('path'); -const browserify = require('browserify'); +import Ajv from 'ajv'; +import standaloneCode from 'ajv/dist/standalone/index.js'; +import esbuild from 'esbuild'; +import fs from 'fs'; +import path from 'path'; +import {fileURLToPath} from 'url'; -async function buildParse5() { -    const parse5Path = require.resolve('parse5'); -    const cwd = process.cwd(); -    try { -        const baseDir = path.dirname(parse5Path); -        process.chdir(baseDir); // This is necessary to ensure relative source map file names are consistent -        return await new Promise((resolve, reject) => { -            browserify({ -                entries: [parse5Path], -                standalone: 'parse5', -                debug: true, -                baseDir -            }).bundle((error, result) => { -                if (error) { -                    reject(error); -                } else { -                    resolve(result); -                } -            }); -        }); -    } finally { -        process.chdir(cwd); -    } -} +const dirname = path.dirname(fileURLToPath(import.meta.url)); +const extDir = path.join(dirname, '..', 'ext'); -function getBuildTargets() { -    const extLibPath = path.join(__dirname, '..', 'ext', 'lib'); -    return [ -        {path: path.join(extLibPath, 'parse5.js'), build: buildParse5} -    ]; +async function buildLib(p) { +    await esbuild.build({ +        entryPoints: [p], +        bundle: true, +        minify: false, +        sourcemap: true, +        target: 'es2020', +        format: 'esm', +        outfile: path.join(extDir, 'lib', path.basename(p)), +        external: ['fs'] +    });  } -async function main() { -    for (const {path: path2, build} of getBuildTargets()) { -        const content = await build(); -        fs.writeFileSync(path2, content); +export async function buildLibs() { +    const devLibPath = path.join(dirname, 'lib'); +    const files = await fs.promises.readdir(devLibPath, { +        withFileTypes: true +    }); +    for (const f of files) { +        if (f.isFile()) { +            await buildLib(path.join(devLibPath, f.name)); +        }      } -} -if (require.main === module) { main(); } +    const schemaDir = path.join(extDir, 'data/schemas/'); +    const schemaFileNames = fs.readdirSync(schemaDir); +    const schemas = schemaFileNames.map((schemaFileName) => JSON.parse(fs.readFileSync(path.join(schemaDir, schemaFileName)))); +    const ajv = new Ajv({schemas: schemas, code: {source: true, esm: true}}); +    const moduleCode = standaloneCode(ajv); -module.exports = { -    getBuildTargets -}; +    // https://github.com/ajv-validator/ajv/issues/2209 +    const patchedModuleCode = "import {ucs2length} from './ucs2length.js';" + moduleCode.replaceAll('require("ajv/dist/runtime/ucs2length").default', 'ucs2length'); + +    fs.writeFileSync(path.join(extDir, 'lib/validate-schemas.js'), patchedModuleCode); +} diff --git a/dev/css-to-json-util.js b/dev/css-to-json-util.js deleted file mode 100644 index 79aae3c9..00000000 --- a/dev/css-to-json-util.js +++ /dev/null @@ -1,172 +0,0 @@ -/* - * Copyright (C) 2023  Yomitan Authors - * Copyright (C) 2021-2022  Yomichan Authors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program.  If not, see <https://www.gnu.org/licenses/>. - */ - -const fs = require('fs'); -const css = require('css'); - -function indexOfRule(rules, selectors) { -    const jj = selectors.length; -    for (let i = 0, ii = rules.length; i < ii; ++i) { -        const ruleSelectors = rules[i].selectors; -        if (ruleSelectors.length !== jj) { continue; } -        let okay = true; -        for (let j = 0; j < jj; ++j) { -            if (selectors[j] !== ruleSelectors[j]) { -                okay = false; -                break; -            } -        } -        if (okay) { return i; } -    } -    return -1; -} - -function removeProperty(styles, property, removedProperties) { -    let removeCount = removedProperties.get(property); -    if (typeof removeCount !== 'undefined') { return removeCount; } -    removeCount = 0; -    for (let i = 0, ii = styles.length; i < ii; ++i) { -        const key = styles[i][0]; -        if (key !== property) { continue; } -        styles.splice(i, 1); -        --i; -        --ii; -        ++removeCount; -    } -    removedProperties.set(property, removeCount); -    return removeCount; -} - -function formatRulesJson(rules) { -    // Manually format JSON, for improved compactness -    // return JSON.stringify(rules, null, 4); -    const indent1 = '    '; -    const indent2 = indent1.repeat(2); -    const indent3 = indent1.repeat(3); -    let result = ''; -    result += '['; -    let index1 = 0; -    for (const {selectors, styles} of rules) { -        if (index1 > 0) { result += ','; } -        result += `\n${indent1}{\n${indent2}"selectors": `; -        if (selectors.length === 1) { -            result += `[${JSON.stringify(selectors[0], null, 4)}]`; -        } else { -            result += JSON.stringify(selectors, null, 4).replace(/\n/g, '\n' + indent2); -        } -        result += `,\n${indent2}"styles": [`; -        let index2 = 0; -        for (const [key, value] of styles) { -            if (index2 > 0) { result += ','; } -            result += `\n${indent3}[${JSON.stringify(key)}, ${JSON.stringify(value)}]`; -            ++index2; -        } -        if (index2 > 0) { result += `\n${indent2}`; } -        result += `]\n${indent1}}`; -        ++index1; -    } -    if (index1 > 0) { result += '\n'; } -    result += ']'; -    return result; -} - -function generateRules(cssFile, overridesCssFile) { -    const content1 = fs.readFileSync(cssFile, {encoding: 'utf8'}); -    const content2 = fs.readFileSync(overridesCssFile, {encoding: 'utf8'}); -    const stylesheet1 = css.parse(content1, {}).stylesheet; -    const stylesheet2 = css.parse(content2, {}).stylesheet; - -    const removePropertyPattern = /^remove-property\s+([\w\W]+)$/; -    const removeRulePattern = /^remove-rule$/; -    const propertySeparator = /\s+/; - -    const rules = []; - -    // Default stylesheet -    for (const rule of stylesheet1.rules) { -        if (rule.type !== 'rule') { continue; } -        const {selectors, declarations} = rule; -        const styles = []; -        for (const declaration of declarations) { -            if (declaration.type !== 'declaration') { console.log(declaration); continue; } -            const {property, value} = declaration; -            styles.push([property, value]); -        } -        if (styles.length > 0) { -            rules.push({selectors, styles}); -        } -    } - -    // Overrides -    for (const rule of stylesheet2.rules) { -        if (rule.type !== 'rule') { continue; } -        const {selectors, declarations} = rule; -        const removedProperties = new Map(); -        for (const declaration of declarations) { -            switch (declaration.type) { -                case 'declaration': -                    { -                        const index = indexOfRule(rules, selectors); -                        let entry; -                        if (index >= 0) { -                            entry = rules[index]; -                        } else { -                            entry = {selectors, styles: []}; -                            rules.push(entry); -                        } -                        const {property, value} = declaration; -                        removeProperty(entry.styles, property, removedProperties); -                        entry.styles.push([property, value]); -                    } -                    break; -                case 'comment': -                    { -                        const index = indexOfRule(rules, selectors); -                        if (index < 0) { throw new Error('Could not find rule with matching selectors'); } -                        const comment = declaration.comment.trim(); -                        let m; -                        if ((m = removePropertyPattern.exec(comment)) !== null) { -                            for (const property of m[1].split(propertySeparator)) { -                                const removeCount = removeProperty(rules[index].styles, property, removedProperties); -                                if (removeCount === 0) { throw new Error(`Property removal is unnecessary; ${property} does not exist`); } -                            } -                        } else if (removeRulePattern.test(comment)) { -                            rules.splice(index, 1); -                        } -                    } -                    break; -            } -        } -    } - -    // Remove empty -    for (let i = 0, ii = rules.length; i < ii; ++i) { -        if (rules[i].styles.length > 0) { continue; } -        rules.splice(i, 1); -        --i; -        --ii; -    } - -    return rules; -} - - -module.exports = { -    formatRulesJson, -    generateRules -}; diff --git a/dev/data/manifest-variants.json b/dev/data/manifest-variants.json index 1eae2112..e6113b75 100644 --- a/dev/data/manifest-variants.json +++ b/dev/data/manifest-variants.json @@ -28,7 +28,8 @@              "default_popup": "action-popup.html"          },          "background": { -            "service_worker": "sw.js" +            "service_worker": "sw.js", +            "type": "module"          },          "content_scripts": [              { @@ -41,28 +42,7 @@                  "match_about_blank": true,                  "all_frames": true,                  "js": [ -                    "js/core.js", -                    "js/yomichan.js", -                    "js/app/frontend.js", -                    "js/app/popup.js", -                    "js/app/popup-factory.js", -                    "js/app/popup-proxy.js", -                    "js/app/popup-window.js", -                    "js/app/theme-controller.js", -                    "js/comm/api.js", -                    "js/comm/cross-frame-api.js", -                    "js/comm/frame-ancestry-handler.js", -                    "js/comm/frame-client.js", -                    "js/comm/frame-offset-forwarder.js", -                    "js/data/sandbox/string-util.js", -                    "js/dom/dom-text-scanner.js", -                    "js/dom/document-util.js", -                    "js/dom/text-source-element.js", -                    "js/dom/text-source-range.js", -                    "js/input/hotkey-handler.js", -                    "js/language/text-scanner.js", -                    "js/script/dynamic-loader.js", -                    "js/app/content-script-main.js" +                    "js/app/content-script-wrapper.js"                  ]              }          ], @@ -118,7 +98,8 @@              {                  "resources": [                      "popup.html", -                    "template-renderer.html" +                    "template-renderer.html", +                    "js/*"                  ],                  "matches": [                      "<all_urls>" @@ -141,8 +122,7 @@              "inherit": "base",              "fileName": "yomitan-chrome.zip",              "excludeFiles": [ -                "background.html", -                "js/dom/native-simple-dom-parser.js" +                "background.html"              ]          },          { @@ -187,7 +167,9 @@                      "path": [                          "permissions"                      ], -                    "items": ["clipboardRead"] +                    "items": [ +                        "clipboardRead" +                    ]                  }              ]          }, @@ -204,6 +186,13 @@                      ]                  },                  { +                    "action": "delete", +                    "path": [ +                        "background", +                        "type" +                    ] +                }, +                {                      "action": "set",                      "path": [                          "background", @@ -268,9 +257,7 @@                  "sw.js",                  "offscreen.html",                  "js/background/offscreen.js", -                "js/background/offscreen-main.js", -                "js/dom/simple-dom-parser.js", -                "lib/parse5.js" +                "js/background/offscreen-main.js"              ]          },          { @@ -319,9 +306,7 @@                  "sw.js",                  "offscreen.html",                  "js/background/offscreen.js", -                "js/background/offscreen-main.js", -                "js/dom/simple-dom-parser.js", -                "lib/parse5.js" +                "js/background/offscreen-main.js"              ]          },          { @@ -368,9 +353,7 @@                  "sw.js",                  "offscreen.html",                  "js/background/offscreen.js", -                "js/background/offscreen-main.js", -                "js/dom/simple-dom-parser.js", -                "lib/parse5.js" +                "js/background/offscreen-main.js"              ]          }      ] diff --git a/dev/database-vm.js b/dev/database-vm.js deleted file mode 100644 index d5570691..00000000 --- a/dev/database-vm.js +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright (C) 2023  Yomitan Authors - * Copyright (C) 2020-2022  Yomichan Authors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program.  If not, see <https://www.gnu.org/licenses/>. - */ - -const fs = require('fs'); -const url = require('url'); -const path = require('path'); -const {JSZip} = require('./util'); -const {VM} = require('./vm'); -require('fake-indexeddb/auto'); - -const chrome = { -    runtime: { -        getURL: (path2) => { -            return url.pathToFileURL(path.join(__dirname, '..', 'ext', path2.replace(/^\//, ''))).href; -        } -    } -}; - -async function fetch(url2) { -    const extDir = path.join(__dirname, '..', 'ext'); -    let filePath; -    try { -        filePath = url.fileURLToPath(url2); -    } catch (e) { -        filePath = path.resolve(extDir, url2.replace(/^[/\\]/, '')); -    } -    await Promise.resolve(); -    const content = fs.readFileSync(filePath, {encoding: null}); -    return { -        ok: true, -        status: 200, -        statusText: 'OK', -        text: async () => Promise.resolve(content.toString('utf8')), -        json: async () => Promise.resolve(JSON.parse(content.toString('utf8'))) -    }; -} - -function atob(data) { -    return Buffer.from(data, 'base64').toString('ascii'); -} - -class DatabaseVM extends VM { -    constructor(globals={}) { -        super(Object.assign({ -            chrome, -            fetch, -            indexedDB: global.indexedDB, -            IDBKeyRange: global.IDBKeyRange, -            JSZip, -            atob -        }, globals)); -        this.context.window = this.context; -        this.indexedDB = global.indexedDB; -    } -} - -class DatabaseVMDictionaryImporterMediaLoader { -    async getImageDetails(content) { -        // Placeholder values -        return {content, width: 100, height: 100}; -    } -} - -module.exports = { -    DatabaseVM, -    DatabaseVMDictionaryImporterMediaLoader -}; diff --git a/dev/dictionary-validate.js b/dev/dictionary-validate.js index 0c926acc..eb40beda 100644 --- a/dev/dictionary-validate.js +++ b/dev/dictionary-validate.js @@ -16,12 +16,11 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -const fs = require('fs'); -const path = require('path'); -const {performance} = require('perf_hooks'); -const {JSZip} = require('./util'); -const {createJsonSchema} = require('./schema-validate'); - +import fs from 'fs'; +import JSZip from 'jszip'; +import path from 'path'; +import {performance} from 'perf_hooks'; +import {createJsonSchema} from './schema-validate.js';  function readSchema(relativeFileName) {      const fileName = path.join(__dirname, relativeFileName); @@ -29,7 +28,6 @@ function readSchema(relativeFileName) {      return JSON.parse(source);  } -  async function validateDictionaryBanks(mode, zip, fileNameFormat, schema) {      let jsonSchema;      try { @@ -57,7 +55,7 @@ async function validateDictionaryBanks(mode, zip, fileNameFormat, schema) {      }  } -async function validateDictionary(mode, archive, schemas) { +export async function validateDictionary(mode, archive, schemas) {      const fileName = 'index.json';      const indexFile = archive.files[fileName];      if (!indexFile) { @@ -82,7 +80,7 @@ async function validateDictionary(mode, archive, schemas) {      await validateDictionaryBanks(mode, archive, 'tag_bank_?.json', schemas.tagBankV3);  } -function getSchemas() { +export function getSchemas() {      return {          index: readSchema('../ext/data/schemas/dictionary-index-schema.json'),          kanjiBankV1: readSchema('../ext/data/schemas/dictionary-kanji-bank-v1-schema.json'), @@ -95,8 +93,7 @@ function getSchemas() {      };  } - -async function testDictionaryFiles(mode, dictionaryFileNames) { +export async function testDictionaryFiles(mode, dictionaryFileNames) {      const schemas = getSchemas();      for (const dictionaryFileName of dictionaryFileNames) { @@ -115,33 +112,3 @@ async function testDictionaryFiles(mode, dictionaryFileNames) {          }      }  } - - -async function main() { -    const dictionaryFileNames = process.argv.slice(2); -    if (dictionaryFileNames.length === 0) { -        console.log([ -            'Usage:', -            '  node dictionary-validate [--ajv] <dictionary-file-names>...' -        ].join('\n')); -        return; -    } - -    let mode = null; -    if (dictionaryFileNames[0] === '--ajv') { -        mode = 'ajv'; -        dictionaryFileNames.splice(0, 1); -    } - -    await testDictionaryFiles(mode, dictionaryFileNames); -} - - -if (require.main === module) { main(); } - - -module.exports = { -    getSchemas, -    validateDictionary, -    testDictionaryFiles -}; diff --git a/dev/generate-css-json.js b/dev/generate-css-json.js index 787173ab..914c1452 100644 --- a/dev/generate-css-json.js +++ b/dev/generate-css-json.js @@ -16,12 +16,10 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -const fs = require('fs'); -const path = require('path'); -const {testMain} = require('./util'); -const {formatRulesJson, generateRules} = require('./css-to-json-util'); +import fs from 'fs'; +import path from 'path'; -function getTargets() { +export function getTargets() {      return [          {              cssFile: path.join(__dirname, '..', 'ext/css/structured-content.css'), @@ -36,19 +34,150 @@ function getTargets() {      ];  } -function main() { -    for (const {cssFile, overridesCssFile, outputPath} of getTargets()) { -        const json = formatRulesJson(generateRules(cssFile, overridesCssFile)); -        fs.writeFileSync(outputPath, json, {encoding: 'utf8'}); +import css from 'css'; + +function indexOfRule(rules, selectors) { +    const jj = selectors.length; +    for (let i = 0, ii = rules.length; i < ii; ++i) { +        const ruleSelectors = rules[i].selectors; +        if (ruleSelectors.length !== jj) { continue; } +        let okay = true; +        for (let j = 0; j < jj; ++j) { +            if (selectors[j] !== ruleSelectors[j]) { +                okay = false; +                break; +            } +        } +        if (okay) { return i; }      } +    return -1;  } +function removeProperty(styles, property, removedProperties) { +    let removeCount = removedProperties.get(property); +    if (typeof removeCount !== 'undefined') { return removeCount; } +    removeCount = 0; +    for (let i = 0, ii = styles.length; i < ii; ++i) { +        const key = styles[i][0]; +        if (key !== property) { continue; } +        styles.splice(i, 1); +        --i; +        --ii; +        ++removeCount; +    } +    removedProperties.set(property, removeCount); +    return removeCount; +} -if (require.main === module) { -    testMain(main, process.argv.slice(2)); +export function formatRulesJson(rules) { +    // Manually format JSON, for improved compactness +    // return JSON.stringify(rules, null, 4); +    const indent1 = '    '; +    const indent2 = indent1.repeat(2); +    const indent3 = indent1.repeat(3); +    let result = ''; +    result += '['; +    let index1 = 0; +    for (const {selectors, styles} of rules) { +        if (index1 > 0) { result += ','; } +        result += `\n${indent1}{\n${indent2}"selectors": `; +        if (selectors.length === 1) { +            result += `[${JSON.stringify(selectors[0], null, 4)}]`; +        } else { +            result += JSON.stringify(selectors, null, 4).replace(/\n/g, '\n' + indent2); +        } +        result += `,\n${indent2}"styles": [`; +        let index2 = 0; +        for (const [key, value] of styles) { +            if (index2 > 0) { result += ','; } +            result += `\n${indent3}[${JSON.stringify(key)}, ${JSON.stringify(value)}]`; +            ++index2; +        } +        if (index2 > 0) { result += `\n${indent2}`; } +        result += `]\n${indent1}}`; +        ++index1; +    } +    if (index1 > 0) { result += '\n'; } +    result += ']'; +    return result;  } +export function generateRules(cssFile, overridesCssFile) { +    const content1 = fs.readFileSync(cssFile, {encoding: 'utf8'}); +    const content2 = fs.readFileSync(overridesCssFile, {encoding: 'utf8'}); +    const stylesheet1 = css.parse(content1, {}).stylesheet; +    const stylesheet2 = css.parse(content2, {}).stylesheet; + +    const removePropertyPattern = /^remove-property\s+([\w\W]+)$/; +    const removeRulePattern = /^remove-rule$/; +    const propertySeparator = /\s+/; -module.exports = { -    getTargets -}; +    const rules = []; + +    // Default stylesheet +    for (const rule of stylesheet1.rules) { +        if (rule.type !== 'rule') { continue; } +        const {selectors, declarations} = rule; +        const styles = []; +        for (const declaration of declarations) { +            if (declaration.type !== 'declaration') { console.log(declaration); continue; } +            const {property, value} = declaration; +            styles.push([property, value]); +        } +        if (styles.length > 0) { +            rules.push({selectors, styles}); +        } +    } + +    // Overrides +    for (const rule of stylesheet2.rules) { +        if (rule.type !== 'rule') { continue; } +        const {selectors, declarations} = rule; +        const removedProperties = new Map(); +        for (const declaration of declarations) { +            switch (declaration.type) { +                case 'declaration': +                    { +                        const index = indexOfRule(rules, selectors); +                        let entry; +                        if (index >= 0) { +                            entry = rules[index]; +                        } else { +                            entry = {selectors, styles: []}; +                            rules.push(entry); +                        } +                        const {property, value} = declaration; +                        removeProperty(entry.styles, property, removedProperties); +                        entry.styles.push([property, value]); +                    } +                    break; +                case 'comment': +                    { +                        const index = indexOfRule(rules, selectors); +                        if (index < 0) { throw new Error('Could not find rule with matching selectors'); } +                        const comment = declaration.comment.trim(); +                        let m; +                        if ((m = removePropertyPattern.exec(comment)) !== null) { +                            for (const property of m[1].split(propertySeparator)) { +                                const removeCount = removeProperty(rules[index].styles, property, removedProperties); +                                if (removeCount === 0) { throw new Error(`Property removal is unnecessary; ${property} does not exist`); } +                            } +                        } else if (removeRulePattern.test(comment)) { +                            rules.splice(index, 1); +                        } +                    } +                    break; +            } +        } +    } + +    // Remove empty +    for (let i = 0, ii = rules.length; i < ii; ++i) { +        if (rules[i].styles.length > 0) { continue; } +        rules.splice(i, 1); +        --i; +        --ii; +    } + +    return rules; +} diff --git a/dev/lib/dexie-export-import.js b/dev/lib/dexie-export-import.js new file mode 100644 index 00000000..8d2ec206 --- /dev/null +++ b/dev/lib/dexie-export-import.js @@ -0,0 +1,17 @@ +/* + * Copyright (C) 2023  Yomitan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program.  If not, see <https://www.gnu.org/licenses/>. + */ +export * from 'dexie-export-import'; diff --git a/dev/lib/dexie.js b/dev/lib/dexie.js new file mode 100644 index 00000000..aa3f2b7d --- /dev/null +++ b/dev/lib/dexie.js @@ -0,0 +1,17 @@ +/* + * Copyright (C) 2023  Yomitan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program.  If not, see <https://www.gnu.org/licenses/>. + */ +export * from 'dexie'; diff --git a/dev/lib/handlebars.js b/dev/lib/handlebars.js new file mode 100644 index 00000000..5b57efdd --- /dev/null +++ b/dev/lib/handlebars.js @@ -0,0 +1,18 @@ +/* + * Copyright (C) 2023  Yomitan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program.  If not, see <https://www.gnu.org/licenses/>. + */ +export {Handlebars} from './handlebars/src/handlebars.js'; + diff --git a/dev/lib/handlebars/LICENSE b/dev/lib/handlebars/LICENSE new file mode 100644 index 00000000..5d971a17 --- /dev/null +++ b/dev/lib/handlebars/LICENSE @@ -0,0 +1,29 @@ +The MIT License (MIT) + +Copyright (c) Elasticsearch BV +Copyright (c) Copyright (C) 2011-2019 by Yehuda Katz + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +This software consists of voluntary contributions made by many +individuals. For exact contribution history, see the revision history +available at the following locations: + - https://github.com/handlebars-lang/handlebars.js + - https://github.com/elastic/kibana/tree/main/packages/kbn-handlebars diff --git a/dev/lib/handlebars/README.md b/dev/lib/handlebars/README.md new file mode 100644 index 00000000..cc151645 --- /dev/null +++ b/dev/lib/handlebars/README.md @@ -0,0 +1,164 @@ +# @kbn/handlebars + +A custom version of the handlebars package which, to improve security, does not use `eval` or `new Function`. This means that templates can't be compiled into JavaScript functions in advance and hence, rendering the templates is a lot slower. + +## Limitations + +- Only the following compile options are supported: +  - `data` +  - `knownHelpers` +  - `knownHelpersOnly` +  - `noEscape` +  - `strict` +  - `assumeObjects` +  - `preventIndent` +  - `explicitPartialContext` + +- Only the following runtime options are supported: +  - `data` +  - `helpers` +  - `partials` +  - `decorators` (not documented in the official Handlebars [runtime options documentation](https://handlebarsjs.com/api-reference/runtime-options.html)) +  - `blockParams` (not documented in the official Handlebars [runtime options documentation](https://handlebarsjs.com/api-reference/runtime-options.html)) + +## Implementation differences + +The standard `handlebars` implementation: + +1. When given a template string, e.g. `Hello {{x}}`, return a "render" function which takes an "input" object, e.g. `{ x: 'World' }`. +1. The first time the "render" function is called the following happens: +   1. Turn the template string into an Abstract Syntax Tree (AST). +   1. Convert the AST into a hyper optimized JavaScript function which takes the input object as an argument. +   1. Call the generate JavaScript function with the given "input" object to produce and return the final output string (`Hello World`). +1. Subsequent calls to the "render" function will re-use the already generated JavaScript function. + +The custom `@kbn/handlebars` implementation: + +1. When given a template string, e.g. `Hello {{x}}`, return a "render" function which takes an "input" object, e.g. `{ x: 'World' }`. +1. The first time the "render" function is called the following happens: +   1. Turn the template string into an Abstract Syntax Tree (AST). +   1. Process the AST with the given "input" object to produce and return the final output string (`Hello World`). +1. Subsequent calls to the "render" function will re-use the already generated AST. + +_Note: Not parsing of the template string until the first call to the "render" function is deliberate as it mimics the original `handlebars` implementation. This means that any errors that occur due to an invalid template string will not be thrown until the first call to the "render" function._ + +## Technical details + +The `handlebars` library exposes the API for both [generating the AST](https://github.com/handlebars-lang/handlebars.js/blob/master/docs/compiler-api.md#ast) and walking it by implementing the [Visitor API](https://github.com/handlebars-lang/handlebars.js/blob/master/docs/compiler-api.md#ast-visitor). We can leverage that to our advantage and create our own "render" function, which internally calls this API to generate the AST and then the API to walk the AST. + +The `@kbn/handlebars` implementation of the `Visitor` class implements all the necessary methods called by the parent `Visitor` code when instructed to walk the AST. They all start with an upppercase letter, e.g. `MustacheStatement` or `SubExpression`. We call this class `ElasticHandlebarsVisitor`. + +To parse the template string to an AST representation, we call `Handlebars.parse(templateString)`, which returns an AST object. + +The AST object contains a bunch of nodes, one for each element of the template string, all arranged in a tree-like structure. The root of the AST object is a node of type `Program`. This is a special node, which we do not need to worry about, but each of its direct children has a type named like the method which will be called when the walking algorithm reaches that node, e.g. `ContentStatement` or `BlockStatement`. These are the methods that our `Visitor` implementation implements. + +To instruct our `ElasticHandlebarsVisitor` class to start walking the AST object, we call the `accept()` method inherited from the parent `Visitor` class with the main AST object. The `Visitor` will walk each node in turn that is directly attached to the root `Program` node. For each node it traverses, it will call the matching method in our `ElasticHandlebarsVisitor` class. + +To instruct the `Visitor` code to traverse any child nodes of a given node, our implementation needs to manually call `accept(childNode)`, `acceptArray(arrayOfChildNodes)`, `acceptKey(node, childKeyName)`, or `acceptRequired(node, childKeyName)` from within any of the "node" methods, otherwise the child nodes are ignored. + +### State + +We keep state internally in the `ElasticHandlebarsVisitor` object using the following private properties: + +- `contexts`: An array (stack) of `context` objects. In a simple template this array will always only contain a single element: The main `context` object. In more complicated scenarios, new `context` objects will be pushed and popped to and from the `contexts` stack as needed. +- `output`: An array containing the "rendered" output of each node (normally just one element per node). In the most simple template, this is simply joined together into a the final output string after the AST has been traversed. In more complicated templates, we use this array temporarily to collect parameters to give to helper functions (see the `getParams` function). + +## Testing + +The tests for `@kbn/handlebars` are integrated into the regular test suite of Kibana and are all jest tests. To run them all, simply do: + +```sh +node scripts/jest packages/kbn-handlebars +``` + +By default, each test will run both the original `handlebars` code and the modified `@kbn/handlebars` code to compare if the output of the two are identical. To isolate a test run to just one or the other, you can use the following environment variables: + +- `EVAL=1` - Set to only run the original `handlebars` implementation that uses `eval`. +- `AST=1` - Set to only run the modified `@kbn/handlebars` implementation that doesn't use `eval`. + +## Development + +Some of the tests have been copied from the upstream `handlebars` project and modified to fit our use-case, test-suite, and coding conventions. They are all located under the `packages/kbn-handlebars/src/spec` directory. To check if any of the copied files have received updates upstream that we might want to include in our copies, you can run the following script: + +```sh +./packages/kbn-handlebars/scripts/check_for_upstream_updates.sh +``` + +_Note: This will look for changes in the `4.x` branch of the `handlebars.js` repo only. Changes in the `master` branch are ignored._ + +Once all updates have been manually merged with our versions of the files, run the following script to "lock" us into the new updates: + +```sh +./packages/kbn-handlebars/scripts/update_upstream_git_hash.sh +``` + +This will update file `packages/kbn-handlebars/src/spec/.upstream_git_hash`. Make sure to commit changes to this file as well. + +## Debugging + +### Print AST + +To output the generated AST object structure in a somewhat readable form, use the following script: + +```sh +./packages/kbn-handlebars/scripts/print_ast.js +``` + +Example: + +```sh +./packages/kbn-handlebars/scripts/print_ast.js '{{value}}' +``` + +Output: + +```js +{ +  type: 'Program', +  body: [ +    { +      type: 'MustacheStatement', +      path: { +        type: 'PathExpression', +        data: false, +        depth: 0, +        parts: [ 'value' ], +        original: 'value' +      }, +      params: [], +      hash: undefined, +      escaped: true +    } +  ] +} +``` + +By default certain properties will be hidden in the output. +For more control over the output, check out the options by running the script without any arguments. + +### Print generated code + +It's possible to see the generated JavaScript code that `handlebars` create for a given template using the following command line tool: + +```sh +./node_modules/handlebars/print-script <template> [options] +``` + +Options: + +- `-v`: Enable verbose mode. + +Example: + +```sh +./node_modules/handlebars/print-script '{{value}}' -v +``` + +You can pretty print just the generated code using this command: + +```sh +./node_modules/handlebars/print-script '{{value}}' | \ +  node -e 'process.stdin.on(`data`, c => console.log(`(${eval(`(${c})`).code})`))' | \ +  npx prettier --write --stdin-filepath template.js | \ +  npx cli-highlight -l javascript +``` diff --git a/dev/lib/handlebars/__snapshots__/index.test.ts.snap b/dev/lib/handlebars/__snapshots__/index.test.ts.snap new file mode 100644 index 00000000..b9a8c27e --- /dev/null +++ b/dev/lib/handlebars/__snapshots__/index.test.ts.snap @@ -0,0 +1,91 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Handlebars.create 1`] = ` +HandlebarsEnvironment { +  "AST": Object { +    "helpers": Object { +      "helperExpression": [Function], +      "scopedId": [Function], +      "simpleId": [Function], +    }, +  }, +  "COMPILER_REVISION": 8, +  "Compiler": [Function], +  "Exception": [Function], +  "HandlebarsEnvironment": [Function], +  "JavaScriptCompiler": [Function], +  "LAST_COMPATIBLE_COMPILER_REVISION": 7, +  "Parser": Object { +    "yy": Object {}, +  }, +  "REVISION_CHANGES": Object { +    "1": "<= 1.0.rc.2", +    "2": "== 1.0.0-rc.3", +    "3": "== 1.0.0-rc.4", +    "4": "== 1.x.x", +    "5": "== 2.0.0-alpha.x", +    "6": ">= 2.0.0-beta.1", +    "7": ">= 4.0.0 <4.3.0", +    "8": ">= 4.3.0", +  }, +  "SafeString": [Function], +  "Utils": Object { +    "__esModule": true, +    "appendContextPath": [Function], +    "blockParams": [Function], +    "createFrame": [Function], +    "escapeExpression": [Function], +    "extend": [Function], +    "indexOf": [Function], +    "isArray": [Function], +    "isEmpty": [Function], +    "isFunction": [Function], +    "toString": [Function], +  }, +  "VERSION": "4.7.7", +  "VM": Object { +    "__esModule": true, +    "checkRevision": [Function], +    "invokePartial": [Function], +    "noop": [Function], +    "resolvePartial": [Function], +    "template": [Function], +    "wrapProgram": [Function], +  }, +  "__esModule": true, +  "compile": [Function], +  "compileAST": [Function], +  "createFrame": [Function], +  "decorators": Object { +    "inline": [Function], +  }, +  "escapeExpression": [Function], +  "helpers": Object { +    "blockHelperMissing": [Function], +    "each": [Function], +    "helperMissing": [Function], +    "if": [Function], +    "log": [Function], +    "lookup": [Function], +    "unless": [Function], +    "with": [Function], +  }, +  "log": [Function], +  "logger": Object { +    "level": "info", +    "log": [Function], +    "lookupLevel": [Function], +    "methodMap": Array [ +      "debug", +      "info", +      "warn", +      "error", +    ], +  }, +  "parse": [Function], +  "parseWithoutProcessing": [Function], +  "partials": Object {}, +  "precompile": [Function], +  "template": [Function], +} +`; diff --git a/dev/lib/handlebars/index.test.ts b/dev/lib/handlebars/index.test.ts new file mode 100644 index 00000000..ed607db1 --- /dev/null +++ b/dev/lib/handlebars/index.test.ts @@ -0,0 +1,567 @@ +/* + * Elasticsearch B.V licenses this file to you under the MIT License. + * See `packages/kbn-handlebars/LICENSE` for more information. + */ + +/** + * ABOUT THIS FILE: + * + * This file is for tests not copied from the upstream handlebars project, but + * tests that we feel are needed in order to fully cover our use-cases. + */ + +import Handlebars from '.'; +import type { HelperOptions, TemplateDelegate } from './src/types'; +import { expectTemplate, forEachCompileFunctionName } from './src/__jest__/test_bench'; + +it('Handlebars.create', () => { +  expect(Handlebars.create()).toMatchSnapshot(); +}); + +describe('Handlebars.compileAST', () => { +  describe('compiler options', () => { +    it('noEscape', () => { +      expectTemplate('{{value}}').withInput({ value: '<foo>' }).toCompileTo('<foo>'); + +      expectTemplate('{{value}}') +        .withCompileOptions({ noEscape: false }) +        .withInput({ value: '<foo>' }) +        .toCompileTo('<foo>'); + +      expectTemplate('{{value}}') +        .withCompileOptions({ noEscape: true }) +        .withInput({ value: '<foo>' }) +        .toCompileTo('<foo>'); +    }); +  }); + +  it('invalid template', () => { +    expectTemplate('{{value').withInput({ value: 42 }).toThrow(`Parse error on line 1: +{{value +--^ +Expecting 'ID', 'STRING', 'NUMBER', 'BOOLEAN', 'UNDEFINED', 'NULL', 'DATA', got 'INVALID'`); +  }); + +  if (!process.env.EVAL) { +    it('reassign', () => { +      const fn = Handlebars.compileAST; +      expect(fn('{{value}}')({ value: 42 })).toEqual('42'); +    }); +  } +}); + +// Extra "helpers" tests +describe('helpers', () => { +  it('Only provide options.fn/inverse to block helpers', () => { +    function toHaveProperties(...args: any[]) { +      toHaveProperties.calls++; +      const options = args[args.length - 1]; +      expect(options).toHaveProperty('fn'); +      expect(options).toHaveProperty('inverse'); +      return 42; +    } +    toHaveProperties.calls = 0; + +    function toNotHaveProperties(...args: any[]) { +      toNotHaveProperties.calls++; +      const options = args[args.length - 1]; +      expect(options).not.toHaveProperty('fn'); +      expect(options).not.toHaveProperty('inverse'); +      return 42; +    } +    toNotHaveProperties.calls = 0; + +    const nonBlockTemplates = ['{{foo}}', '{{foo 1 2}}']; +    const blockTemplates = ['{{#foo}}42{{/foo}}', '{{#foo 1 2}}42{{/foo}}']; + +    for (const template of nonBlockTemplates) { +      expectTemplate(template) +        .withInput({ +          foo: toNotHaveProperties, +        }) +        .toCompileTo('42'); + +      expectTemplate(template).withHelper('foo', toNotHaveProperties).toCompileTo('42'); +    } + +    for (const template of blockTemplates) { +      expectTemplate(template) +        .withInput({ +          foo: toHaveProperties, +        }) +        .toCompileTo('42'); + +      expectTemplate(template).withHelper('foo', toHaveProperties).toCompileTo('42'); +    } + +    const factor = process.env.AST || process.env.EVAL ? 1 : 2; +    expect(toNotHaveProperties.calls).toEqual(nonBlockTemplates.length * 2 * factor); +    expect(toHaveProperties.calls).toEqual(blockTemplates.length * 2 * factor); +  }); + +  it('should pass expected "this" to helper functions (without input)', () => { +    expectTemplate('{{hello "world" 12 true false}}') +      .withHelper('hello', function (this: any, ...args: any[]) { +        expect(this).toMatchInlineSnapshot(`Object {}`); +      }) +      .toCompileTo(''); +  }); + +  it('should pass expected "this" to helper functions (with input)', () => { +    expectTemplate('{{hello "world" 12 true false}}') +      .withHelper('hello', function (this: any, ...args: any[]) { +        expect(this).toMatchInlineSnapshot(` +          Object { +            "people": Array [ +              Object { +                "id": 1, +                "name": "Alan", +              }, +              Object { +                "id": 2, +                "name": "Yehuda", +              }, +            ], +          } +        `); +      }) +      .withInput({ +        people: [ +          { name: 'Alan', id: 1 }, +          { name: 'Yehuda', id: 2 }, +        ], +      }) +      .toCompileTo(''); +  }); + +  it('should pass expected "this" and arguments to helper functions (non-block helper)', () => { +    expectTemplate('{{hello "world" 12 true false}}') +      .withHelper('hello', function (this: any, ...args: any[]) { +        expect(args).toMatchInlineSnapshot(` +          Array [ +            "world", +            12, +            true, +            false, +            Object { +              "data": Object { +                "root": Object { +                  "people": Array [ +                    Object { +                      "id": 1, +                      "name": "Alan", +                    }, +                    Object { +                      "id": 2, +                      "name": "Yehuda", +                    }, +                  ], +                }, +              }, +              "hash": Object {}, +              "loc": Object { +                "end": Object { +                  "column": 31, +                  "line": 1, +                }, +                "start": Object { +                  "column": 0, +                  "line": 1, +                }, +              }, +              "lookupProperty": [Function], +              "name": "hello", +            }, +          ] +        `); +      }) +      .withInput({ +        people: [ +          { name: 'Alan', id: 1 }, +          { name: 'Yehuda', id: 2 }, +        ], +      }) +      .toCompileTo(''); +  }); + +  it('should pass expected "this" and arguments to helper functions (block helper)', () => { +    expectTemplate('{{#hello "world" 12 true false}}{{/hello}}') +      .withHelper('hello', function (this: any, ...args: any[]) { +        expect(args).toMatchInlineSnapshot(` +          Array [ +            "world", +            12, +            true, +            false, +            Object { +              "data": Object { +                "root": Object { +                  "people": Array [ +                    Object { +                      "id": 1, +                      "name": "Alan", +                    }, +                    Object { +                      "id": 2, +                      "name": "Yehuda", +                    }, +                  ], +                }, +              }, +              "fn": [Function], +              "hash": Object {}, +              "inverse": [Function], +              "loc": Object { +                "end": Object { +                  "column": 42, +                  "line": 1, +                }, +                "start": Object { +                  "column": 0, +                  "line": 1, +                }, +              }, +              "lookupProperty": [Function], +              "name": "hello", +            }, +          ] +        `); +      }) +      .withInput({ +        people: [ +          { name: 'Alan', id: 1 }, +          { name: 'Yehuda', id: 2 }, +        ], +      }) +      .toCompileTo(''); +  }); +}); + +// Extra "blocks" tests +describe('blocks', () => { +  describe('decorators', () => { +    it('should only call decorator once', () => { +      let calls = 0; +      const callsExpected = process.env.AST || process.env.EVAL ? 1 : 2; +      expectTemplate('{{#helper}}{{*decorator}}{{/helper}}') +        .withHelper('helper', () => {}) +        .withDecorator('decorator', () => { +          calls++; +        }) +        .toCompileTo(''); +      expect(calls).toEqual(callsExpected); +    }); + +    forEachCompileFunctionName((compileName) => { +      it(`should call decorator again if render function is called again for #${compileName}`, () => { +        global.kbnHandlebarsEnv = Handlebars.create(); + +        kbnHandlebarsEnv!.registerDecorator('decorator', () => { +          calls++; +        }); + +        const compile = kbnHandlebarsEnv![compileName].bind(kbnHandlebarsEnv); +        const render = compile('{{*decorator}}'); + +        let calls = 0; +        expect(render()).toEqual(''); +        expect(calls).toEqual(1); + +        calls = 0; +        expect(render()).toEqual(''); +        expect(calls).toEqual(1); + +        global.kbnHandlebarsEnv = null; +      }); +    }); + +    it('should pass expected options to nested decorator', () => { +      expectTemplate('{{#helper}}{{*decorator foo}}{{/helper}}') +        .withHelper('helper', () => {}) +        .withDecorator('decorator', function (fn, props, container, options) { +          expect(options).toMatchInlineSnapshot(` +            Object { +              "args": Array [ +                "bar", +              ], +              "data": Object { +                "root": Object { +                  "foo": "bar", +                }, +              }, +              "hash": Object {}, +              "loc": Object { +                "end": Object { +                  "column": 29, +                  "line": 1, +                }, +                "start": Object { +                  "column": 11, +                  "line": 1, +                }, +              }, +              "name": "decorator", +            } +          `); +        }) +        .withInput({ foo: 'bar' }) +        .toCompileTo(''); +    }); + +    it('should pass expected options to root decorator with no args', () => { +      expectTemplate('{{*decorator}}') +        .withDecorator('decorator', function (fn, props, container, options) { +          expect(options).toMatchInlineSnapshot(` +            Object { +              "args": Array [], +              "data": Object { +                "root": Object { +                  "foo": "bar", +                }, +              }, +              "hash": Object {}, +              "loc": Object { +                "end": Object { +                  "column": 14, +                  "line": 1, +                }, +                "start": Object { +                  "column": 0, +                  "line": 1, +                }, +              }, +              "name": "decorator", +            } +          `); +        }) +        .withInput({ foo: 'bar' }) +        .toCompileTo(''); +    }); + +    it('should pass expected options to root decorator with one arg', () => { +      expectTemplate('{{*decorator foo}}') +        .withDecorator('decorator', function (fn, props, container, options) { +          expect(options).toMatchInlineSnapshot(` +            Object { +              "args": Array [ +                undefined, +              ], +              "data": Object { +                "root": Object { +                  "foo": "bar", +                }, +              }, +              "hash": Object {}, +              "loc": Object { +                "end": Object { +                  "column": 18, +                  "line": 1, +                }, +                "start": Object { +                  "column": 0, +                  "line": 1, +                }, +              }, +              "name": "decorator", +            } +          `); +        }) +        .withInput({ foo: 'bar' }) +        .toCompileTo(''); +    }); + +    describe('return values', () => { +      for (const [desc, template, result] of [ +        ['non-block', '{{*decorator}}cont{{*decorator}}ent', 'content'], +        ['block', '{{#*decorator}}con{{/decorator}}tent', 'tent'], +      ]) { +        describe(desc, () => { +          const falsy = [undefined, null, false, 0, '']; +          const truthy = [true, 42, 'foo', {}]; + +          // Falsy return values from decorators are simply ignored and the +          // execution falls back to default behavior which is to render the +          // other parts of the template. +          for (const value of falsy) { +            it(`falsy value (type ${typeof value}): ${JSON.stringify(value)}`, () => { +              expectTemplate(template) +                .withDecorator('decorator', () => value) +                .toCompileTo(result); +            }); +          } + +          // Truthy return values from decorators are expected to be functions +          // and the program will attempt to call them. We expect an error to +          // be thrown in this case. +          for (const value of truthy) { +            it(`non-falsy value (type ${typeof value}): ${JSON.stringify(value)}`, () => { +              expectTemplate(template) +                .withDecorator('decorator', () => value) +                .toThrow('is not a function'); +            }); +          } + +          // If the decorator return value is a custom function, its return +          // value will be the final content of the template. +          for (const value of [...falsy, ...truthy]) { +            it(`function returning ${typeof value}: ${JSON.stringify(value)}`, () => { +              expectTemplate(template) +                .withDecorator('decorator', () => () => value) +                .toCompileTo(value as string); +            }); +          } +        }); +      } +    }); + +    describe('custom return function should be called with expected arguments and its return value should be rendered in the template', () => { +      it('root decorator', () => { +        expectTemplate('{{*decorator}}world') +          .withInput({ me: 'my' }) +          .withDecorator( +            'decorator', +            (fn): TemplateDelegate => +              (context, options) => { +                expect(context).toMatchInlineSnapshot(` +              Object { +                "me": "my", +              } +            `); +                expect(options).toMatchInlineSnapshot(` +              Object { +                "decorators": Object { +                  "decorator": [Function], +                }, +                "helpers": Object {}, +                "partials": Object {}, +              } +            `); +                return `hello ${context.me} ${fn()}!`; +              } +          ) +          .toCompileTo('hello my world!'); +      }); + +      it('decorator nested inside of array-helper', () => { +        expectTemplate('{{#arr}}{{*decorator}}world{{/arr}}') +          .withInput({ arr: ['my'] }) +          .withDecorator( +            'decorator', +            (fn): TemplateDelegate => +              (context, options) => { +                expect(context).toMatchInlineSnapshot(`"my"`); +                expect(options).toMatchInlineSnapshot(` +              Object { +                "blockParams": Array [ +                  "my", +                  0, +                ], +                "data": Object { +                  "_parent": Object { +                    "root": Object { +                      "arr": Array [ +                        "my", +                      ], +                    }, +                  }, +                  "first": true, +                  "index": 0, +                  "key": 0, +                  "last": true, +                  "root": Object { +                    "arr": Array [ +                      "my", +                    ], +                  }, +                }, +              } +            `); +                return `hello ${context} ${fn()}!`; +              } +          ) +          .toCompileTo('hello my world!'); +      }); + +      it('decorator nested inside of custom helper', () => { +        expectTemplate('{{#helper}}{{*decorator}}world{{/helper}}') +          .withHelper('helper', function (options: HelperOptions) { +            return options.fn('my', { foo: 'bar' } as any); +          }) +          .withDecorator( +            'decorator', +            (fn): TemplateDelegate => +              (context, options) => { +                expect(context).toMatchInlineSnapshot(`"my"`); +                expect(options).toMatchInlineSnapshot(` +              Object { +                "foo": "bar", +              } +            `); +                return `hello ${context} ${fn()}!`; +              } +          ) +          .toCompileTo('hello my world!'); +      }); +    }); + +    it('should call multiple decorators in the same program body in the expected order and get the expected output', () => { +      let decoratorCall = 0; +      let progCall = 0; +      expectTemplate('{{*decorator}}con{{*decorator}}tent', { +        beforeRender() { +          // ensure the counters are reset between EVAL/AST render calls +          decoratorCall = 0; +          progCall = 0; +        }, +      }) +        .withInput({ +          decoratorCall: 0, +          progCall: 0, +        }) +        .withDecorator('decorator', (fn) => { +          const decoratorCallOrder = ++decoratorCall; +          const ret: TemplateDelegate = () => { +            const progCallOrder = ++progCall; +            return `(decorator: ${decoratorCallOrder}, prog: ${progCallOrder}, fn: "${fn()}")`; +          }; +          return ret; +        }) +        .toCompileTo('(decorator: 2, prog: 1, fn: "(decorator: 1, prog: 2, fn: "content")")'); +    }); + +    describe('registration', () => { +      beforeEach(() => { +        global.kbnHandlebarsEnv = Handlebars.create(); +      }); + +      afterEach(() => { +        global.kbnHandlebarsEnv = null; +      }); + +      it('should be able to call decorators registered using the `registerDecorator` function', () => { +        let calls = 0; +        const callsExpected = process.env.AST || process.env.EVAL ? 1 : 2; + +        kbnHandlebarsEnv!.registerDecorator('decorator', () => { +          calls++; +        }); + +        expectTemplate('{{*decorator}}').toCompileTo(''); +        expect(calls).toEqual(callsExpected); +      }); + +      it('should not be able to call decorators unregistered using the `unregisterDecorator` function', () => { +        let calls = 0; + +        kbnHandlebarsEnv!.registerDecorator('decorator', () => { +          calls++; +        }); + +        kbnHandlebarsEnv!.unregisterDecorator('decorator'); + +        expectTemplate('{{*decorator}}').toThrow('lookupProperty(...) is not a function'); +        expect(calls).toEqual(0); +      }); +    }); +  }); +}); diff --git a/dev/lib/handlebars/index.ts b/dev/lib/handlebars/index.ts new file mode 100644 index 00000000..16030445 --- /dev/null +++ b/dev/lib/handlebars/index.ts @@ -0,0 +1,33 @@ +/* + * Elasticsearch B.V licenses this file to you under the MIT License. + * See `packages/kbn-handlebars/LICENSE` for more information. + */ + +import { Handlebars } from './src/handlebars'; +import { allowUnsafeEval } from './src/utils'; + +// The handlebars module uses `export =`, so it can't be re-exported using `export *`. +// However, because of Babel, we're not allowed to use `export =` ourselves. +// So we have to resort to using `exports default` even though eslint doesn't like it. +// +// eslint-disable-next-line import/no-default-export +globalThis.Handlebars = Handlebars; + +/** + * If the `unsafe-eval` CSP is set, this string constant will be `compile`, + * otherwise `compileAST`. + * + * This can be used to call the more optimized `compile` function in + * environments that support it, or fall back to `compileAST` on environments + * that don't. + */ +globalThis.handlebarsCompileFnName = allowUnsafeEval() ? 'compile' : 'compileAST'; + +export type { +  CompileOptions, +  RuntimeOptions, +  HelperDelegate, +  TemplateDelegate, +  DecoratorDelegate, +  HelperOptions, +} from './src/types'; diff --git a/dev/lib/handlebars/jest.config.js b/dev/lib/handlebars/jest.config.js new file mode 100644 index 00000000..feb9f905 --- /dev/null +++ b/dev/lib/handlebars/jest.config.js @@ -0,0 +1,10 @@ +/* + * Elasticsearch B.V licenses this file to you under the MIT License. + * See `packages/kbn-handlebars/LICENSE` for more information. + */ + +module.exports = { +  preset: '@kbn/test', +  rootDir: '../..', +  roots: ['<rootDir>/packages/kbn-handlebars'], +}; diff --git a/dev/lib/handlebars/kibana.jsonc b/dev/lib/handlebars/kibana.jsonc new file mode 100644 index 00000000..59b3c28d --- /dev/null +++ b/dev/lib/handlebars/kibana.jsonc @@ -0,0 +1,5 @@ +{ +  "type": "shared-common", +  "id": "@kbn/handlebars", +  "owner": "@elastic/kibana-security" +} diff --git a/dev/lib/handlebars/package.json b/dev/lib/handlebars/package.json new file mode 100644 index 00000000..46ca823a --- /dev/null +++ b/dev/lib/handlebars/package.json @@ -0,0 +1,6 @@ +{ +  "name": "@kbn/handlebars", +  "version": "1.0.0", +  "private": true, +  "license": "MIT" +}
\ No newline at end of file diff --git a/dev/lib/handlebars/scripts/check_for_upstream_updates.sh b/dev/lib/handlebars/scripts/check_for_upstream_updates.sh new file mode 100755 index 00000000..73f7376a --- /dev/null +++ b/dev/lib/handlebars/scripts/check_for_upstream_updates.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash + +set -e + +TMP=.tmp-handlebars +HASH_FILE=packages/kbn-handlebars/src/spec/.upstream_git_hash + +function cleanup { +  rm -fr $TMP +} + +trap cleanup EXIT + +rm -fr $TMP +mkdir $TMP + +echo "Cloning handlebars repo..." +git clone -q --depth 1 https://github.com/handlebars-lang/handlebars.js.git -b 4.x $TMP + +echo "Looking for updates..." +hash=$(git -C $TMP rev-parse HEAD) +expected_hash=$(cat $HASH_FILE) + +if [ "$hash" = "$expected_hash" ]; then +  echo "You're all up to date :)" +else +  echo +  echo "New changes has been committed to the '4.x' branch in the upstream git repository" +  echo +  echo "To resolve this issue, do the following:" +  echo +  echo "  1. Investigate the commits in the '4.x' branch of the upstream git repository." +  echo "     If files inside the 'spec' folder has been updated, sync those updates with" +  echo "     our local versions of these files (located in" +  echo "     'packages/kbn-handlebars/src/spec')." +  echo +  echo "     https://github.com/handlebars-lang/handlebars.js/compare/$hash...4.x" +  echo +  echo "  2. Execute the following script and commit the updated '$HASH_FILE'" +  echo "     file including any changes you made to our own spec files." +  echo +  echo "     ./packages/kbn-handlebars/scripts/update_upstream_git_hash.sh" +  echo +  exit 1 +fi diff --git a/dev/lib/handlebars/scripts/print_ast.js b/dev/lib/handlebars/scripts/print_ast.js new file mode 100755 index 00000000..b97fb5a6 --- /dev/null +++ b/dev/lib/handlebars/scripts/print_ast.js @@ -0,0 +1,64 @@ +#!/usr/bin/env node +/* + * Elasticsearch B.V licenses this file to you under the MIT License. + * See `packages/kbn-handlebars/LICENSE` for more information. + */ +'use strict'; // eslint-disable-line strict + +const { relative } = require('path'); +const { inspect } = require('util'); + +const { parse } = require('handlebars'); +const argv = require('minimist')(process.argv.slice(2)); + +const DEFAULT_FILTER = 'loc,strip,openStrip,inverseStrip,closeStrip'; + +const filter = argv['show-all'] ? [''] : (argv.filter || DEFAULT_FILTER).split(','); +const hideEmpty = argv['hide-empty'] || false; +const template = argv._[0]; + +if (template === undefined) { +  const script = relative(process.cwd(), process.argv[1]); +  console.log(`Usage: ${script} [options] <template>`); +  console.log(); +  console.log('Options:'); +  console.log('  --filter=...  A comma separated list of keys to filter from the output.'); +  console.log(`                Default: ${DEFAULT_FILTER}`); +  console.log('  --hide-empty  Do not display empty properties.'); +  console.log('  --show-all    Do not filter out any properties. Equivalent to --filter="".'); +  console.log(); +  console.log('Example:'); +  console.log(`  ${script} --hide-empty -- 'hello {{name}}'`); +  console.log(); +  process.exit(1); +} + +console.log(inspect(reduce(parse(template, filter)), { colors: true, depth: null })); + +function reduce(ast) { +  if (Array.isArray(ast)) { +    for (let i = 0; i < ast.length; i++) { +      ast[i] = reduce(ast[i]); +    } +  } else { +    for (const k of filter) { +      delete ast[k]; +    } + +    if (hideEmpty) { +      for (const [k, v] of Object.entries(ast)) { +        if (v === undefined || v === null || (Array.isArray(v) && v.length === 0)) { +          delete ast[k]; +        } +      } +    } + +    for (const [k, v] of Object.entries(ast)) { +      if (typeof v === 'object' && v !== null) { +        ast[k] = reduce(v); +      } +    } +  } + +  return ast; +} diff --git a/dev/lib/handlebars/scripts/update_upstream_git_hash.sh b/dev/lib/handlebars/scripts/update_upstream_git_hash.sh new file mode 100755 index 00000000..52cc39e0 --- /dev/null +++ b/dev/lib/handlebars/scripts/update_upstream_git_hash.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash + +set -e + +TMP=.tmp-handlebars +HASH_FILE=packages/kbn-handlebars/src/spec/.upstream_git_hash + +function cleanup { +  rm -fr $TMP +} + +trap cleanup EXIT + +rm -fr $TMP +mkdir $TMP + +echo "Cloning handlebars repo..." +git clone -q --depth 1 https://github.com/handlebars-lang/handlebars.js.git -b 4.x $TMP + +echo "Updating hash file..." +git -C $TMP rev-parse HEAD | tr -d '\n' > $HASH_FILE +git add $HASH_FILE + +echo "Done! - Don't forget to commit any changes to $HASH_FILE" diff --git a/dev/lib/handlebars/src/__jest__/test_bench.ts b/dev/lib/handlebars/src/__jest__/test_bench.ts new file mode 100644 index 00000000..d17f7f12 --- /dev/null +++ b/dev/lib/handlebars/src/__jest__/test_bench.ts @@ -0,0 +1,207 @@ +/* + * Elasticsearch B.V licenses this file to you under the MIT License. + * See `packages/kbn-handlebars/LICENSE` for more information. + */ + +import Handlebars, { +  type CompileOptions, +  type DecoratorDelegate, +  type HelperDelegate, +  type RuntimeOptions, +} from '../..'; +import type { DecoratorsHash, HelpersHash, PartialsHash, Template } from '../types'; + +type CompileFns = 'compile' | 'compileAST'; +const compileFns: CompileFns[] = ['compile', 'compileAST']; +if (process.env.AST) compileFns.splice(0, 1); +else if (process.env.EVAL) compileFns.splice(1, 1); + +declare global { +  var kbnHandlebarsEnv: typeof Handlebars | null; // eslint-disable-line no-var +} + +global.kbnHandlebarsEnv = null; + +interface TestOptions { +  beforeEach?: Function; +  beforeRender?: Function; +} + +export function expectTemplate(template: string, options?: TestOptions) { +  return new HandlebarsTestBench(template, options); +} + +export function forEachCompileFunctionName( +  cb: (compileName: CompileFns, index: number, array: CompileFns[]) => void +) { +  compileFns.forEach(cb); +} + +class HandlebarsTestBench { +  private template: string; +  private options: TestOptions; +  private compileOptions?: CompileOptions; +  private runtimeOptions?: RuntimeOptions; +  private helpers: HelpersHash = {}; +  private partials: PartialsHash = {}; +  private decorators: DecoratorsHash = {}; +  private input: any = {}; + +  constructor(template: string, options: TestOptions = {}) { +    this.template = template; +    this.options = options; +  } + +  withCompileOptions(compileOptions?: CompileOptions) { +    this.compileOptions = compileOptions; +    return this; +  } + +  withRuntimeOptions(runtimeOptions?: RuntimeOptions) { +    this.runtimeOptions = runtimeOptions; +    return this; +  } + +  withInput(input: any) { +    this.input = input; +    return this; +  } + +  withHelper<F extends HelperDelegate>(name: string, helper: F) { +    this.helpers[name] = helper; +    return this; +  } + +  withHelpers<F extends HelperDelegate>(helperFunctions: Record<string, F>) { +    for (const [name, helper] of Object.entries(helperFunctions)) { +      this.withHelper(name, helper); +    } +    return this; +  } + +  withPartial(name: string | number, partial: Template) { +    this.partials[name] = partial; +    return this; +  } + +  withPartials(partials: Record<string, Template>) { +    for (const [name, partial] of Object.entries(partials)) { +      this.withPartial(name, partial); +    } +    return this; +  } + +  withDecorator(name: string, decoratorFunction: DecoratorDelegate) { +    this.decorators[name] = decoratorFunction; +    return this; +  } + +  withDecorators(decoratorFunctions: Record<string, DecoratorDelegate>) { +    for (const [name, decoratorFunction] of Object.entries(decoratorFunctions)) { +      this.withDecorator(name, decoratorFunction); +    } +    return this; +  } + +  toCompileTo(outputExpected: string) { +    const { outputEval, outputAST } = this.compileAndExecute(); +    if (process.env.EVAL) { +      expect(outputEval).toEqual(outputExpected); +    } else if (process.env.AST) { +      expect(outputAST).toEqual(outputExpected); +    } else { +      expect(outputAST).toEqual(outputExpected); +      expect(outputAST).toEqual(outputEval); +    } +  } + +  toThrow(error?: string | RegExp | jest.Constructable | Error | undefined) { +    if (process.env.EVAL) { +      expect(() => { +        this.compileAndExecuteEval(); +      }).toThrowError(error); +    } else if (process.env.AST) { +      expect(() => { +        this.compileAndExecuteAST(); +      }).toThrowError(error); +    } else { +      expect(() => { +        this.compileAndExecuteEval(); +      }).toThrowError(error); +      expect(() => { +        this.compileAndExecuteAST(); +      }).toThrowError(error); +    } +  } + +  private compileAndExecute() { +    if (process.env.EVAL) { +      return { +        outputEval: this.compileAndExecuteEval(), +      }; +    } else if (process.env.AST) { +      return { +        outputAST: this.compileAndExecuteAST(), +      }; +    } else { +      return { +        outputEval: this.compileAndExecuteEval(), +        outputAST: this.compileAndExecuteAST(), +      }; +    } +  } + +  private compileAndExecuteEval() { +    const renderEval = this.compileEval(); + +    const runtimeOptions: RuntimeOptions = { +      helpers: this.helpers, +      partials: this.partials, +      decorators: this.decorators, +      ...this.runtimeOptions, +    }; + +    this.execBeforeRender(); + +    return renderEval(this.input, runtimeOptions); +  } + +  private compileAndExecuteAST() { +    const renderAST = this.compileAST(); + +    const runtimeOptions: RuntimeOptions = { +      helpers: this.helpers, +      partials: this.partials, +      decorators: this.decorators, +      ...this.runtimeOptions, +    }; + +    this.execBeforeRender(); + +    return renderAST(this.input, runtimeOptions); +  } + +  private compileEval(handlebarsEnv = getHandlebarsEnv()) { +    this.execBeforeEach(); +    return handlebarsEnv.compile(this.template, this.compileOptions); +  } + +  private compileAST(handlebarsEnv = getHandlebarsEnv()) { +    this.execBeforeEach(); +    return handlebarsEnv.compileAST(this.template, this.compileOptions); +  } + +  private execBeforeRender() { +    this.options.beforeRender?.(); +  } + +  private execBeforeEach() { +    if (this.options.beforeEach) { +      this.options.beforeEach(); +    } +  } +} + +function getHandlebarsEnv() { +  return kbnHandlebarsEnv || Handlebars.create(); +} diff --git a/dev/lib/handlebars/src/handlebars.ts b/dev/lib/handlebars/src/handlebars.ts new file mode 100644 index 00000000..358d1b73 --- /dev/null +++ b/dev/lib/handlebars/src/handlebars.ts @@ -0,0 +1,47 @@ +/* + * Elasticsearch B.V licenses this file to you under the MIT License. + * See `packages/kbn-handlebars/LICENSE` for more information. + */ + +// The handlebars module uses `export =`, so we should technically use `import Handlebars = require('handlebars')`, but Babel will not allow this: +// https://www.typescriptlang.org/docs/handbook/modules.html#export--and-import--require +import Handlebars = require('handlebars'); + +import type { CompileOptions, RuntimeOptions, TemplateDelegate } from './types'; +import { ElasticHandlebarsVisitor } from './visitor'; + +const originalCreate = Handlebars.create; + +export { Handlebars }; + +/** + * Creates an isolated Handlebars environment. + * + * Each environment has its own helpers. + * This is only necessary for use cases that demand distinct helpers. + * Most use cases can use the root Handlebars environment directly. + * + * @returns A sandboxed/scoped version of the @kbn/handlebars module + */ +Handlebars.create = function (): typeof Handlebars { +  const SandboxedHandlebars = originalCreate.call(Handlebars) as typeof Handlebars; +  // When creating new Handlebars environments, ensure the custom compileAST function is present in the new environment as well +  SandboxedHandlebars.compileAST = Handlebars.compileAST; +  return SandboxedHandlebars; +}; + +Handlebars.compileAST = function ( +  input: string | hbs.AST.Program, +  options?: CompileOptions +): TemplateDelegate { +  if (input == null || (typeof input !== 'string' && input.type !== 'Program')) { +    throw new Handlebars.Exception( +      `You must pass a string or Handlebars AST to Handlebars.compileAST. You passed ${input}` +    ); +  } + +  // If `Handlebars.compileAST` is reassigned, `this` will be undefined. +  const visitor = new ElasticHandlebarsVisitor(this ?? Handlebars, input, options); + +  return (context: any, runtimeOptions?: RuntimeOptions) => visitor.render(context, runtimeOptions); +}; diff --git a/dev/lib/handlebars/src/spec/.upstream_git_hash b/dev/lib/handlebars/src/spec/.upstream_git_hash new file mode 100644 index 00000000..5a6b1831 --- /dev/null +++ b/dev/lib/handlebars/src/spec/.upstream_git_hash @@ -0,0 +1 @@ +c65c6cce3f626e4896a9d59250f0908be695adae
\ No newline at end of file diff --git a/dev/lib/handlebars/src/spec/index.basic.test.ts b/dev/lib/handlebars/src/spec/index.basic.test.ts new file mode 100644 index 00000000..6acf3ae9 --- /dev/null +++ b/dev/lib/handlebars/src/spec/index.basic.test.ts @@ -0,0 +1,481 @@ +/* + * This file is forked from the handlebars project (https://github.com/handlebars-lang/handlebars.js), + * and may include modifications made by Elasticsearch B.V. + * Elasticsearch B.V. licenses this file to you under the MIT License. + * See `packages/kbn-handlebars/LICENSE` for more information. + */ + +import Handlebars from '../..'; +import { expectTemplate } from '../__jest__/test_bench'; + +describe('basic context', () => { +  it('most basic', () => { +    expectTemplate('{{foo}}').withInput({ foo: 'foo' }).toCompileTo('foo'); +  }); + +  it('escaping', () => { +    expectTemplate('\\{{foo}}').withInput({ foo: 'food' }).toCompileTo('{{foo}}'); +    expectTemplate('content \\{{foo}}').withInput({ foo: 'food' }).toCompileTo('content {{foo}}'); +    expectTemplate('\\\\{{foo}}').withInput({ foo: 'food' }).toCompileTo('\\food'); +    expectTemplate('content \\\\{{foo}}').withInput({ foo: 'food' }).toCompileTo('content \\food'); +    expectTemplate('\\\\ {{foo}}').withInput({ foo: 'food' }).toCompileTo('\\\\ food'); +  }); + +  it('compiling with a basic context', () => { +    expectTemplate('Goodbye\n{{cruel}}\n{{world}}!') +      .withInput({ +        cruel: 'cruel', +        world: 'world', +      }) +      .toCompileTo('Goodbye\ncruel\nworld!'); +  }); + +  it('compiling with a string context', () => { +    expectTemplate('{{.}}{{length}}').withInput('bye').toCompileTo('bye3'); +  }); + +  it('compiling with an undefined context', () => { +    expectTemplate('Goodbye\n{{cruel}}\n{{world.bar}}!') +      .withInput(undefined) +      .toCompileTo('Goodbye\n\n!'); + +    expectTemplate('{{#unless foo}}Goodbye{{../test}}{{test2}}{{/unless}}') +      .withInput(undefined) +      .toCompileTo('Goodbye'); +  }); + +  it('comments', () => { +    expectTemplate('{{! Goodbye}}Goodbye\n{{cruel}}\n{{world}}!') +      .withInput({ +        cruel: 'cruel', +        world: 'world', +      }) +      .toCompileTo('Goodbye\ncruel\nworld!'); + +    expectTemplate('    {{~! comment ~}}      blah').toCompileTo('blah'); +    expectTemplate('    {{~!-- long-comment --~}}      blah').toCompileTo('blah'); +    expectTemplate('    {{! comment ~}}      blah').toCompileTo('    blah'); +    expectTemplate('    {{!-- long-comment --~}}      blah').toCompileTo('    blah'); +    expectTemplate('    {{~! comment}}      blah').toCompileTo('      blah'); +    expectTemplate('    {{~!-- long-comment --}}      blah').toCompileTo('      blah'); +  }); + +  it('boolean', () => { +    const string = '{{#goodbye}}GOODBYE {{/goodbye}}cruel {{world}}!'; +    expectTemplate(string) +      .withInput({ +        goodbye: true, +        world: 'world', +      }) +      .toCompileTo('GOODBYE cruel world!'); + +    expectTemplate(string) +      .withInput({ +        goodbye: false, +        world: 'world', +      }) +      .toCompileTo('cruel world!'); +  }); + +  it('zeros', () => { +    expectTemplate('num1: {{num1}}, num2: {{num2}}') +      .withInput({ +        num1: 42, +        num2: 0, +      }) +      .toCompileTo('num1: 42, num2: 0'); + +    expectTemplate('num: {{.}}').withInput(0).toCompileTo('num: 0'); + +    expectTemplate('num: {{num1/num2}}') +      .withInput({ num1: { num2: 0 } }) +      .toCompileTo('num: 0'); +  }); + +  it('false', () => { +    /* eslint-disable no-new-wrappers */ +    expectTemplate('val1: {{val1}}, val2: {{val2}}') +      .withInput({ +        val1: false, +        val2: new Boolean(false), +      }) +      .toCompileTo('val1: false, val2: false'); + +    expectTemplate('val: {{.}}').withInput(false).toCompileTo('val: false'); + +    expectTemplate('val: {{val1/val2}}') +      .withInput({ val1: { val2: false } }) +      .toCompileTo('val: false'); + +    expectTemplate('val1: {{{val1}}}, val2: {{{val2}}}') +      .withInput({ +        val1: false, +        val2: new Boolean(false), +      }) +      .toCompileTo('val1: false, val2: false'); + +    expectTemplate('val: {{{val1/val2}}}') +      .withInput({ val1: { val2: false } }) +      .toCompileTo('val: false'); +    /* eslint-enable */ +  }); + +  it('should handle undefined and null', () => { +    expectTemplate('{{awesome undefined null}}') +      .withInput({ +        awesome(_undefined: any, _null: any, options: any) { +          return (_undefined === undefined) + ' ' + (_null === null) + ' ' + typeof options; +        }, +      }) +      .toCompileTo('true true object'); + +    expectTemplate('{{undefined}}') +      .withInput({ +        undefined() { +          return 'undefined!'; +        }, +      }) +      .toCompileTo('undefined!'); + +    expectTemplate('{{null}}') +      .withInput({ +        null() { +          return 'null!'; +        }, +      }) +      .toCompileTo('null!'); +  }); + +  it('newlines', () => { +    expectTemplate("Alan's\nTest").toCompileTo("Alan's\nTest"); +    expectTemplate("Alan's\rTest").toCompileTo("Alan's\rTest"); +  }); + +  it('escaping text', () => { +    expectTemplate("Awesome's").toCompileTo("Awesome's"); +    expectTemplate('Awesome\\').toCompileTo('Awesome\\'); +    expectTemplate('Awesome\\\\ foo').toCompileTo('Awesome\\\\ foo'); +    expectTemplate('Awesome {{foo}}').withInput({ foo: '\\' }).toCompileTo('Awesome \\'); +    expectTemplate(" ' ' ").toCompileTo(" ' ' "); +  }); + +  it('escaping expressions', () => { +    expectTemplate('{{{awesome}}}').withInput({ awesome: "&'\\<>" }).toCompileTo("&'\\<>"); + +    expectTemplate('{{&awesome}}').withInput({ awesome: "&'\\<>" }).toCompileTo("&'\\<>"); + +    expectTemplate('{{awesome}}') +      .withInput({ awesome: '&"\'`\\<>' }) +      .toCompileTo('&"'`\\<>'); + +    expectTemplate('{{awesome}}') +      .withInput({ awesome: 'Escaped, <b> looks like: <b>' }) +      .toCompileTo('Escaped, <b> looks like: &lt;b&gt;'); +  }); + +  it("functions returning safestrings shouldn't be escaped", () => { +    expectTemplate('{{awesome}}') +      .withInput({ +        awesome() { +          return new Handlebars.SafeString("&'\\<>"); +        }, +      }) +      .toCompileTo("&'\\<>"); +  }); + +  it('functions', () => { +    expectTemplate('{{awesome}}') +      .withInput({ +        awesome() { +          return 'Awesome'; +        }, +      }) +      .toCompileTo('Awesome'); + +    expectTemplate('{{awesome}}') +      .withInput({ +        awesome() { +          return this.more; +        }, +        more: 'More awesome', +      }) +      .toCompileTo('More awesome'); +  }); + +  it('functions with context argument', () => { +    expectTemplate('{{awesome frank}}') +      .withInput({ +        awesome(context: any) { +          return context; +        }, +        frank: 'Frank', +      }) +      .toCompileTo('Frank'); +  }); + +  it('pathed functions with context argument', () => { +    expectTemplate('{{bar.awesome frank}}') +      .withInput({ +        bar: { +          awesome(context: any) { +            return context; +          }, +        }, +        frank: 'Frank', +      }) +      .toCompileTo('Frank'); +  }); + +  it('depthed functions with context argument', () => { +    expectTemplate('{{#with frank}}{{../awesome .}}{{/with}}') +      .withInput({ +        awesome(context: any) { +          return context; +        }, +        frank: 'Frank', +      }) +      .toCompileTo('Frank'); +  }); + +  it('block functions with context argument', () => { +    expectTemplate('{{#awesome 1}}inner {{.}}{{/awesome}}') +      .withInput({ +        awesome(context: any, options: any) { +          return options.fn(context); +        }, +      }) +      .toCompileTo('inner 1'); +  }); + +  it('depthed block functions with context argument', () => { +    expectTemplate('{{#with value}}{{#../awesome 1}}inner {{.}}{{/../awesome}}{{/with}}') +      .withInput({ +        value: true, +        awesome(context: any, options: any) { +          return options.fn(context); +        }, +      }) +      .toCompileTo('inner 1'); +  }); + +  it('block functions without context argument', () => { +    expectTemplate('{{#awesome}}inner{{/awesome}}') +      .withInput({ +        awesome(options: any) { +          return options.fn(this); +        }, +      }) +      .toCompileTo('inner'); +  }); + +  it('pathed block functions without context argument', () => { +    expectTemplate('{{#foo.awesome}}inner{{/foo.awesome}}') +      .withInput({ +        foo: { +          awesome() { +            return this; +          }, +        }, +      }) +      .toCompileTo('inner'); +  }); + +  it('depthed block functions without context argument', () => { +    expectTemplate('{{#with value}}{{#../awesome}}inner{{/../awesome}}{{/with}}') +      .withInput({ +        value: true, +        awesome() { +          return this; +        }, +      }) +      .toCompileTo('inner'); +  }); + +  it('paths with hyphens', () => { +    expectTemplate('{{foo-bar}}').withInput({ 'foo-bar': 'baz' }).toCompileTo('baz'); + +    expectTemplate('{{foo.foo-bar}}') +      .withInput({ foo: { 'foo-bar': 'baz' } }) +      .toCompileTo('baz'); + +    expectTemplate('{{foo/foo-bar}}') +      .withInput({ foo: { 'foo-bar': 'baz' } }) +      .toCompileTo('baz'); +  }); + +  it('nested paths', () => { +    expectTemplate('Goodbye {{alan/expression}} world!') +      .withInput({ alan: { expression: 'beautiful' } }) +      .toCompileTo('Goodbye beautiful world!'); +  }); + +  it('nested paths with empty string value', () => { +    expectTemplate('Goodbye {{alan/expression}} world!') +      .withInput({ alan: { expression: '' } }) +      .toCompileTo('Goodbye  world!'); +  }); + +  it('literal paths', () => { +    expectTemplate('Goodbye {{[@alan]/expression}} world!') +      .withInput({ '@alan': { expression: 'beautiful' } }) +      .toCompileTo('Goodbye beautiful world!'); + +    expectTemplate('Goodbye {{[foo bar]/expression}} world!') +      .withInput({ 'foo bar': { expression: 'beautiful' } }) +      .toCompileTo('Goodbye beautiful world!'); +  }); + +  it('literal references', () => { +    expectTemplate('Goodbye {{[foo bar]}} world!') +      .withInput({ 'foo bar': 'beautiful' }) +      .toCompileTo('Goodbye beautiful world!'); + +    expectTemplate('Goodbye {{"foo bar"}} world!') +      .withInput({ 'foo bar': 'beautiful' }) +      .toCompileTo('Goodbye beautiful world!'); + +    expectTemplate("Goodbye {{'foo bar'}} world!") +      .withInput({ 'foo bar': 'beautiful' }) +      .toCompileTo('Goodbye beautiful world!'); + +    expectTemplate('Goodbye {{"foo[bar"}} world!') +      .withInput({ 'foo[bar': 'beautiful' }) +      .toCompileTo('Goodbye beautiful world!'); + +    expectTemplate('Goodbye {{"foo\'bar"}} world!') +      .withInput({ "foo'bar": 'beautiful' }) +      .toCompileTo('Goodbye beautiful world!'); + +    expectTemplate("Goodbye {{'foo\"bar'}} world!") +      .withInput({ 'foo"bar': 'beautiful' }) +      .toCompileTo('Goodbye beautiful world!'); +  }); + +  it("that current context path ({{.}}) doesn't hit helpers", () => { +    expectTemplate('test: {{.}}') +      .withInput(null) +      // @ts-expect-error Setting the helper to a string instead of a function doesn't make sense normally, but here it doesn't matter +      .withHelpers({ helper: 'awesome' }) +      .toCompileTo('test: '); +  }); + +  it('complex but empty paths', () => { +    expectTemplate('{{person/name}}') +      .withInput({ person: { name: null } }) +      .toCompileTo(''); + +    expectTemplate('{{person/name}}').withInput({ person: {} }).toCompileTo(''); +  }); + +  it('this keyword in paths', () => { +    expectTemplate('{{#goodbyes}}{{this}}{{/goodbyes}}') +      .withInput({ goodbyes: ['goodbye', 'Goodbye', 'GOODBYE'] }) +      .toCompileTo('goodbyeGoodbyeGOODBYE'); + +    expectTemplate('{{#hellos}}{{this/text}}{{/hellos}}') +      .withInput({ +        hellos: [{ text: 'hello' }, { text: 'Hello' }, { text: 'HELLO' }], +      }) +      .toCompileTo('helloHelloHELLO'); +  }); + +  it('this keyword nested inside path', () => { +    expectTemplate('{{#hellos}}{{text/this/foo}}{{/hellos}}').toThrow( +      'Invalid path: text/this - 1:13' +    ); + +    expectTemplate('{{[this]}}').withInput({ this: 'bar' }).toCompileTo('bar'); + +    expectTemplate('{{text/[this]}}') +      .withInput({ text: { this: 'bar' } }) +      .toCompileTo('bar'); +  }); + +  it('this keyword in helpers', () => { +    const helpers = { +      foo(value: any) { +        return 'bar ' + value; +      }, +    }; + +    expectTemplate('{{#goodbyes}}{{foo this}}{{/goodbyes}}') +      .withInput({ goodbyes: ['goodbye', 'Goodbye', 'GOODBYE'] }) +      .withHelpers(helpers) +      .toCompileTo('bar goodbyebar Goodbyebar GOODBYE'); + +    expectTemplate('{{#hellos}}{{foo this/text}}{{/hellos}}') +      .withInput({ +        hellos: [{ text: 'hello' }, { text: 'Hello' }, { text: 'HELLO' }], +      }) +      .withHelpers(helpers) +      .toCompileTo('bar hellobar Hellobar HELLO'); +  }); + +  it('this keyword nested inside helpers param', () => { +    expectTemplate('{{#hellos}}{{foo text/this/foo}}{{/hellos}}').toThrow( +      'Invalid path: text/this - 1:17' +    ); + +    expectTemplate('{{foo [this]}}') +      .withInput({ +        foo(value: any) { +          return value; +        }, +        this: 'bar', +      }) +      .toCompileTo('bar'); + +    expectTemplate('{{foo text/[this]}}') +      .withInput({ +        foo(value: any) { +          return value; +        }, +        text: { this: 'bar' }, +      }) +      .toCompileTo('bar'); +  }); + +  it('pass string literals', () => { +    expectTemplate('{{"foo"}}').toCompileTo(''); +    expectTemplate('{{"foo"}}').withInput({ foo: 'bar' }).toCompileTo('bar'); + +    expectTemplate('{{#"foo"}}{{.}}{{/"foo"}}') +      .withInput({ +        foo: ['bar', 'baz'], +      }) +      .toCompileTo('barbaz'); +  }); + +  it('pass number literals', () => { +    expectTemplate('{{12}}').toCompileTo(''); +    expectTemplate('{{12}}').withInput({ '12': 'bar' }).toCompileTo('bar'); +    expectTemplate('{{12.34}}').toCompileTo(''); +    expectTemplate('{{12.34}}').withInput({ '12.34': 'bar' }).toCompileTo('bar'); +    expectTemplate('{{12.34 1}}') +      .withInput({ +        '12.34'(arg: any) { +          return 'bar' + arg; +        }, +      }) +      .toCompileTo('bar1'); +  }); + +  it('pass boolean literals', () => { +    expectTemplate('{{true}}').toCompileTo(''); +    expectTemplate('{{true}}').withInput({ '': 'foo' }).toCompileTo(''); +    expectTemplate('{{false}}').withInput({ false: 'foo' }).toCompileTo('foo'); +  }); + +  it('should handle literals in subexpression', () => { +    expectTemplate('{{foo (false)}}') +      .withInput({ +        false() { +          return 'bar'; +        }, +      }) +      .withHelper('foo', function (arg) { +        return arg; +      }) +      .toCompileTo('bar'); +  }); +}); diff --git a/dev/lib/handlebars/src/spec/index.blocks.test.ts b/dev/lib/handlebars/src/spec/index.blocks.test.ts new file mode 100644 index 00000000..2d9a8707 --- /dev/null +++ b/dev/lib/handlebars/src/spec/index.blocks.test.ts @@ -0,0 +1,366 @@ +/* + * This file is forked from the handlebars project (https://github.com/handlebars-lang/handlebars.js), + * and may include modifications made by Elasticsearch B.V. + * Elasticsearch B.V. licenses this file to you under the MIT License. + * See `packages/kbn-handlebars/LICENSE` for more information. + */ + +import Handlebars, { type HelperOptions } from '../..'; +import { expectTemplate } from '../__jest__/test_bench'; + +describe('blocks', () => { +  it('array', () => { +    const string = '{{#goodbyes}}{{text}}! {{/goodbyes}}cruel {{world}}!'; + +    expectTemplate(string) +      .withInput({ +        goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }], +        world: 'world', +      }) +      .toCompileTo('goodbye! Goodbye! GOODBYE! cruel world!'); + +    expectTemplate(string) +      .withInput({ +        goodbyes: [], +        world: 'world', +      }) +      .toCompileTo('cruel world!'); +  }); + +  it('array without data', () => { +    expectTemplate('{{#goodbyes}}{{text}}{{/goodbyes}} {{#goodbyes}}{{text}}{{/goodbyes}}') +      .withInput({ +        goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }], +        world: 'world', +      }) +      .toCompileTo('goodbyeGoodbyeGOODBYE goodbyeGoodbyeGOODBYE'); +  }); + +  it('array with @index', () => { +    expectTemplate('{{#goodbyes}}{{@index}}. {{text}}! {{/goodbyes}}cruel {{world}}!') +      .withInput({ +        goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }], +        world: 'world', +      }) +      .toCompileTo('0. goodbye! 1. Goodbye! 2. GOODBYE! cruel world!'); +  }); + +  it('empty block', () => { +    const string = '{{#goodbyes}}{{/goodbyes}}cruel {{world}}!'; + +    expectTemplate(string) +      .withInput({ +        goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }], +        world: 'world', +      }) +      .toCompileTo('cruel world!'); + +    expectTemplate(string) +      .withInput({ +        goodbyes: [], +        world: 'world', +      }) +      .toCompileTo('cruel world!'); +  }); + +  it('block with complex lookup', () => { +    expectTemplate('{{#goodbyes}}{{text}} cruel {{../name}}! {{/goodbyes}}') +      .withInput({ +        name: 'Alan', +        goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }], +      }) +      .toCompileTo('goodbye cruel Alan! Goodbye cruel Alan! GOODBYE cruel Alan! '); +  }); + +  it('multiple blocks with complex lookup', () => { +    expectTemplate('{{#goodbyes}}{{../name}}{{../name}}{{/goodbyes}}') +      .withInput({ +        name: 'Alan', +        goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }], +      }) +      .toCompileTo('AlanAlanAlanAlanAlanAlan'); +  }); + +  it('block with complex lookup using nested context', () => { +    expectTemplate('{{#goodbyes}}{{text}} cruel {{foo/../name}}! {{/goodbyes}}').toThrow(Error); +  }); + +  it('block with deep nested complex lookup', () => { +    expectTemplate( +      '{{#outer}}Goodbye {{#inner}}cruel {{../sibling}} {{../../omg}}{{/inner}}{{/outer}}' +    ) +      .withInput({ +        omg: 'OMG!', +        outer: [{ sibling: 'sad', inner: [{ text: 'goodbye' }] }], +      }) +      .toCompileTo('Goodbye cruel sad OMG!'); +  }); + +  it('works with cached blocks', () => { +    expectTemplate('{{#each person}}{{#with .}}{{first}} {{last}}{{/with}}{{/each}}') +      .withCompileOptions({ data: false }) +      .withInput({ +        person: [ +          { first: 'Alan', last: 'Johnson' }, +          { first: 'Alan', last: 'Johnson' }, +        ], +      }) +      .toCompileTo('Alan JohnsonAlan Johnson'); +  }); + +  describe('inverted sections', () => { +    it('inverted sections with unset value', () => { +      expectTemplate( +        '{{#goodbyes}}{{this}}{{/goodbyes}}{{^goodbyes}}Right On!{{/goodbyes}}' +      ).toCompileTo('Right On!'); +    }); + +    it('inverted section with false value', () => { +      expectTemplate('{{#goodbyes}}{{this}}{{/goodbyes}}{{^goodbyes}}Right On!{{/goodbyes}}') +        .withInput({ goodbyes: false }) +        .toCompileTo('Right On!'); +    }); + +    it('inverted section with empty set', () => { +      expectTemplate('{{#goodbyes}}{{this}}{{/goodbyes}}{{^goodbyes}}Right On!{{/goodbyes}}') +        .withInput({ goodbyes: [] }) +        .toCompileTo('Right On!'); +    }); + +    it('block inverted sections', () => { +      expectTemplate('{{#people}}{{name}}{{^}}{{none}}{{/people}}') +        .withInput({ none: 'No people' }) +        .toCompileTo('No people'); +    }); + +    it('chained inverted sections', () => { +      expectTemplate('{{#people}}{{name}}{{else if none}}{{none}}{{/people}}') +        .withInput({ none: 'No people' }) +        .toCompileTo('No people'); + +      expectTemplate( +        '{{#people}}{{name}}{{else if nothere}}fail{{else unless nothere}}{{none}}{{/people}}' +      ) +        .withInput({ none: 'No people' }) +        .toCompileTo('No people'); + +      expectTemplate('{{#people}}{{name}}{{else if none}}{{none}}{{else}}fail{{/people}}') +        .withInput({ none: 'No people' }) +        .toCompileTo('No people'); +    }); + +    it('chained inverted sections with mismatch', () => { +      expectTemplate('{{#people}}{{name}}{{else if none}}{{none}}{{/if}}').toThrow(Error); +    }); + +    it('block inverted sections with empty arrays', () => { +      expectTemplate('{{#people}}{{name}}{{^}}{{none}}{{/people}}') +        .withInput({ +          none: 'No people', +          people: [], +        }) +        .toCompileTo('No people'); +    }); +  }); + +  describe('standalone sections', () => { +    it('block standalone else sections', () => { +      expectTemplate('{{#people}}\n{{name}}\n{{^}}\n{{none}}\n{{/people}}\n') +        .withInput({ none: 'No people' }) +        .toCompileTo('No people\n'); + +      expectTemplate('{{#none}}\n{{.}}\n{{^}}\n{{none}}\n{{/none}}\n') +        .withInput({ none: 'No people' }) +        .toCompileTo('No people\n'); + +      expectTemplate('{{#people}}\n{{name}}\n{{^}}\n{{none}}\n{{/people}}\n') +        .withInput({ none: 'No people' }) +        .toCompileTo('No people\n'); +    }); + +    it('block standalone chained else sections', () => { +      expectTemplate('{{#people}}\n{{name}}\n{{else if none}}\n{{none}}\n{{/people}}\n') +        .withInput({ none: 'No people' }) +        .toCompileTo('No people\n'); + +      expectTemplate('{{#people}}\n{{name}}\n{{else if none}}\n{{none}}\n{{^}}\n{{/people}}\n') +        .withInput({ none: 'No people' }) +        .toCompileTo('No people\n'); +    }); + +    it('should handle nesting', () => { +      expectTemplate('{{#data}}\n{{#if true}}\n{{.}}\n{{/if}}\n{{/data}}\nOK.') +        .withInput({ +          data: [1, 3, 5], +        }) +        .toCompileTo('1\n3\n5\nOK.'); +    }); +  }); + +  describe('decorators', () => { +    it('should apply mustache decorators', () => { +      expectTemplate('{{#helper}}{{*decorator}}{{/helper}}') +        .withHelper('helper', function (options: HelperOptions) { +          return (options.fn as any).run; +        }) +        .withDecorator('decorator', function (fn) { +          (fn as any).run = 'success'; +          return fn; +        }) +        .toCompileTo('success'); +    }); + +    it('should apply allow undefined return', () => { +      expectTemplate('{{#helper}}{{*decorator}}suc{{/helper}}') +        .withHelper('helper', function (options: HelperOptions) { +          return options.fn() + (options.fn as any).run; +        }) +        .withDecorator('decorator', function (fn) { +          (fn as any).run = 'cess'; +        }) +        .toCompileTo('success'); +    }); + +    it('should apply block decorators', () => { +      expectTemplate('{{#helper}}{{#*decorator}}success{{/decorator}}{{/helper}}') +        .withHelper('helper', function (options: HelperOptions) { +          return (options.fn as any).run; +        }) +        .withDecorator('decorator', function (fn, props, container, options) { +          (fn as any).run = options.fn(); +          return fn; +        }) +        .toCompileTo('success'); +    }); + +    it('should support nested decorators', () => { +      expectTemplate( +        '{{#helper}}{{#*decorator}}{{#*nested}}suc{{/nested}}cess{{/decorator}}{{/helper}}' +      ) +        .withHelper('helper', function (options: HelperOptions) { +          return (options.fn as any).run; +        }) +        .withDecorators({ +          decorator(fn, props, container, options) { +            (fn as any).run = options.fn.nested + options.fn(); +            return fn; +          }, +          nested(fn, props, container, options) { +            props.nested = options.fn(); +          }, +        }) +        .toCompileTo('success'); +    }); + +    it('should apply multiple decorators', () => { +      expectTemplate( +        '{{#helper}}{{#*decorator}}suc{{/decorator}}{{#*decorator}}cess{{/decorator}}{{/helper}}' +      ) +        .withHelper('helper', function (options: HelperOptions) { +          return (options.fn as any).run; +        }) +        .withDecorator('decorator', function (fn, props, container, options) { +          (fn as any).run = ((fn as any).run || '') + options.fn(); +          return fn; +        }) +        .toCompileTo('success'); +    }); + +    it('should access parent variables', () => { +      expectTemplate('{{#helper}}{{*decorator foo}}{{/helper}}') +        .withHelper('helper', function (options: HelperOptions) { +          return (options.fn as any).run; +        }) +        .withDecorator('decorator', function (fn, props, container, options) { +          (fn as any).run = options.args; +          return fn; +        }) +        .withInput({ foo: 'success' }) +        .toCompileTo('success'); +    }); + +    it('should work with root program', () => { +      let run; +      expectTemplate('{{*decorator "success"}}') +        .withDecorator('decorator', function (fn, props, container, options) { +          expect(options.args[0]).toEqual('success'); +          run = true; +          return fn; +        }) +        .withInput({ foo: 'success' }) +        .toCompileTo(''); +      expect(run).toEqual(true); +    }); + +    it('should fail when accessing variables from root', () => { +      let run; +      expectTemplate('{{*decorator foo}}') +        .withDecorator('decorator', function (fn, props, container, options) { +          expect(options.args[0]).toBeUndefined(); +          run = true; +          return fn; +        }) +        .withInput({ foo: 'fail' }) +        .toCompileTo(''); +      expect(run).toEqual(true); +    }); + +    describe('registration', () => { +      beforeEach(() => { +        global.kbnHandlebarsEnv = Handlebars.create(); +      }); + +      afterEach(() => { +        global.kbnHandlebarsEnv = null; +      }); + +      it('unregisters', () => { +        // @ts-expect-error: Cannot assign to 'decorators' because it is a read-only property. +        kbnHandlebarsEnv!.decorators = {}; + +        kbnHandlebarsEnv!.registerDecorator('foo', function () { +          return 'fail'; +        }); + +        expect(!!kbnHandlebarsEnv!.decorators.foo).toEqual(true); +        kbnHandlebarsEnv!.unregisterDecorator('foo'); +        expect(kbnHandlebarsEnv!.decorators.foo).toBeUndefined(); +      }); + +      it('allows multiple globals', () => { +        // @ts-expect-error: Cannot assign to 'decorators' because it is a read-only property. +        kbnHandlebarsEnv!.decorators = {}; + +        // @ts-expect-error: Expected 2 arguments, but got 1. +        kbnHandlebarsEnv!.registerDecorator({ +          foo() {}, +          bar() {}, +        }); + +        expect(!!kbnHandlebarsEnv!.decorators.foo).toEqual(true); +        expect(!!kbnHandlebarsEnv!.decorators.bar).toEqual(true); +        kbnHandlebarsEnv!.unregisterDecorator('foo'); +        kbnHandlebarsEnv!.unregisterDecorator('bar'); +        expect(kbnHandlebarsEnv!.decorators.foo).toBeUndefined(); +        expect(kbnHandlebarsEnv!.decorators.bar).toBeUndefined(); +      }); + +      it('fails with multiple and args', () => { +        expect(() => { +          kbnHandlebarsEnv!.registerDecorator( +            // @ts-expect-error: Argument of type '{ world(): string; testHelper(): string; }' is not assignable to parameter of type 'string'. +            { +              world() { +                return 'world!'; +              }, +              testHelper() { +                return 'found it!'; +              }, +            }, +            {} +          ); +        }).toThrow('Arg not supported with multiple decorators'); +      }); +    }); +  }); +}); diff --git a/dev/lib/handlebars/src/spec/index.builtins.test.ts b/dev/lib/handlebars/src/spec/index.builtins.test.ts new file mode 100644 index 00000000..c47ec29f --- /dev/null +++ b/dev/lib/handlebars/src/spec/index.builtins.test.ts @@ -0,0 +1,676 @@ +/* + * This file is forked from the handlebars project (https://github.com/handlebars-lang/handlebars.js), + * and may include modifications made by Elasticsearch B.V. + * Elasticsearch B.V. licenses this file to you under the MIT License. + * See `packages/kbn-handlebars/LICENSE` for more information. + */ + +/* eslint-disable max-classes-per-file */ + +import Handlebars from '../..'; +import { expectTemplate } from '../__jest__/test_bench'; + +describe('builtin helpers', () => { +  describe('#if', () => { +    it('if', () => { +      const string = '{{#if goodbye}}GOODBYE {{/if}}cruel {{world}}!'; + +      expectTemplate(string) +        .withInput({ +          goodbye: true, +          world: 'world', +        }) +        .toCompileTo('GOODBYE cruel world!'); + +      expectTemplate(string) +        .withInput({ +          goodbye: 'dummy', +          world: 'world', +        }) +        .toCompileTo('GOODBYE cruel world!'); + +      expectTemplate(string) +        .withInput({ +          goodbye: false, +          world: 'world', +        }) +        .toCompileTo('cruel world!'); + +      expectTemplate(string).withInput({ world: 'world' }).toCompileTo('cruel world!'); + +      expectTemplate(string) +        .withInput({ +          goodbye: ['foo'], +          world: 'world', +        }) +        .toCompileTo('GOODBYE cruel world!'); + +      expectTemplate(string) +        .withInput({ +          goodbye: [], +          world: 'world', +        }) +        .toCompileTo('cruel world!'); + +      expectTemplate(string) +        .withInput({ +          goodbye: 0, +          world: 'world', +        }) +        .toCompileTo('cruel world!'); + +      expectTemplate('{{#if goodbye includeZero=true}}GOODBYE {{/if}}cruel {{world}}!') +        .withInput({ +          goodbye: 0, +          world: 'world', +        }) +        .toCompileTo('GOODBYE cruel world!'); +    }); + +    it('if with function argument', () => { +      const string = '{{#if goodbye}}GOODBYE {{/if}}cruel {{world}}!'; + +      expectTemplate(string) +        .withInput({ +          goodbye() { +            return true; +          }, +          world: 'world', +        }) +        .toCompileTo('GOODBYE cruel world!'); + +      expectTemplate(string) +        .withInput({ +          goodbye() { +            return this.world; +          }, +          world: 'world', +        }) +        .toCompileTo('GOODBYE cruel world!'); + +      expectTemplate(string) +        .withInput({ +          goodbye() { +            return false; +          }, +          world: 'world', +        }) +        .toCompileTo('cruel world!'); + +      expectTemplate(string) +        .withInput({ +          goodbye() { +            return this.foo; +          }, +          world: 'world', +        }) +        .toCompileTo('cruel world!'); +    }); + +    it('should not change the depth list', () => { +      expectTemplate('{{#with foo}}{{#if goodbye}}GOODBYE cruel {{../world}}!{{/if}}{{/with}}') +        .withInput({ +          foo: { goodbye: true }, +          world: 'world', +        }) +        .toCompileTo('GOODBYE cruel world!'); +    }); +  }); + +  describe('#with', () => { +    it('with', () => { +      expectTemplate('{{#with person}}{{first}} {{last}}{{/with}}') +        .withInput({ +          person: { +            first: 'Alan', +            last: 'Johnson', +          }, +        }) +        .toCompileTo('Alan Johnson'); +    }); + +    it('with with function argument', () => { +      expectTemplate('{{#with person}}{{first}} {{last}}{{/with}}') +        .withInput({ +          person() { +            return { +              first: 'Alan', +              last: 'Johnson', +            }; +          }, +        }) +        .toCompileTo('Alan Johnson'); +    }); + +    it('with with else', () => { +      expectTemplate( +        '{{#with person}}Person is present{{else}}Person is not present{{/with}}' +      ).toCompileTo('Person is not present'); +    }); + +    it('with provides block parameter', () => { +      expectTemplate('{{#with person as |foo|}}{{foo.first}} {{last}}{{/with}}') +        .withInput({ +          person: { +            first: 'Alan', +            last: 'Johnson', +          }, +        }) +        .toCompileTo('Alan Johnson'); +    }); + +    it('works when data is disabled', () => { +      expectTemplate('{{#with person as |foo|}}{{foo.first}} {{last}}{{/with}}') +        .withInput({ person: { first: 'Alan', last: 'Johnson' } }) +        .withCompileOptions({ data: false }) +        .toCompileTo('Alan Johnson'); +    }); +  }); + +  describe('#each', () => { +    it('each', () => { +      const string = '{{#each goodbyes}}{{text}}! {{/each}}cruel {{world}}!'; + +      expectTemplate(string) +        .withInput({ +          goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }], +          world: 'world', +        }) +        .toCompileTo('goodbye! Goodbye! GOODBYE! cruel world!'); + +      expectTemplate(string) +        .withInput({ +          goodbyes: [], +          world: 'world', +        }) +        .toCompileTo('cruel world!'); +    }); + +    it('each without data', () => { +      expectTemplate('{{#each goodbyes}}{{text}}! {{/each}}cruel {{world}}!') +        .withInput({ +          goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }], +          world: 'world', +        }) +        .withRuntimeOptions({ data: false }) +        .withCompileOptions({ data: false }) +        .toCompileTo('goodbye! Goodbye! GOODBYE! cruel world!'); + +      expectTemplate('{{#each .}}{{.}}{{/each}}') +        .withInput({ goodbyes: 'cruel', world: 'world' }) +        .withRuntimeOptions({ data: false }) +        .withCompileOptions({ data: false }) +        .toCompileTo('cruelworld'); +    }); + +    it('each without context', () => { +      expectTemplate('{{#each goodbyes}}{{text}}! {{/each}}cruel {{world}}!') +        .withInput(undefined) +        .toCompileTo('cruel !'); +    }); + +    it('each with an object and @key', () => { +      const string = '{{#each goodbyes}}{{@key}}. {{text}}! {{/each}}cruel {{world}}!'; + +      function Clazz(this: any) { +        this['<b>#1</b>'] = { text: 'goodbye' }; +        this[2] = { text: 'GOODBYE' }; +      } +      Clazz.prototype.foo = 'fail'; +      const hash = { goodbyes: new (Clazz as any)(), world: 'world' }; + +      // Object property iteration order is undefined according to ECMA spec, +      // so we need to check both possible orders +      // @see http://stackoverflow.com/questions/280713/elements-order-in-a-for-in-loop +      try { +        expectTemplate(string) +          .withInput(hash) +          .toCompileTo('<b>#1</b>. goodbye! 2. GOODBYE! cruel world!'); +      } catch (e) { +        expectTemplate(string) +          .withInput(hash) +          .toCompileTo('2. GOODBYE! <b>#1</b>. goodbye! cruel world!'); +      } + +      expectTemplate(string) +        .withInput({ +          goodbyes: {}, +          world: 'world', +        }) +        .toCompileTo('cruel world!'); +    }); + +    it('each with @index', () => { +      expectTemplate('{{#each goodbyes}}{{@index}}. {{text}}! {{/each}}cruel {{world}}!') +        .withInput({ +          goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }], +          world: 'world', +        }) +        .toCompileTo('0. goodbye! 1. Goodbye! 2. GOODBYE! cruel world!'); +    }); + +    it('each with nested @index', () => { +      expectTemplate( +        '{{#each goodbyes}}{{@index}}. {{text}}! {{#each ../goodbyes}}{{@index}} {{/each}}After {{@index}} {{/each}}{{@index}}cruel {{world}}!' +      ) +        .withInput({ +          goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }], +          world: 'world', +        }) +        .toCompileTo( +          '0. goodbye! 0 1 2 After 0 1. Goodbye! 0 1 2 After 1 2. GOODBYE! 0 1 2 After 2 cruel world!' +        ); +    }); + +    it('each with block params', () => { +      expectTemplate( +        '{{#each goodbyes as |value index|}}{{index}}. {{value.text}}! {{#each ../goodbyes as |childValue childIndex|}} {{index}} {{childIndex}}{{/each}} After {{index}} {{/each}}{{index}}cruel {{world}}!' +      ) +        .withInput({ +          goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }], +          world: 'world', +        }) +        .toCompileTo('0. goodbye!  0 0 0 1 After 0 1. Goodbye!  1 0 1 1 After 1 cruel world!'); +    }); + +    // TODO: This test has been added to the `4.x` branch of the handlebars.js repo along with a code-fix, +    // but a new version of the handlebars package containing this fix has not yet been published to npm. +    // +    // Before enabling this code, a new version of handlebars needs to be released and the corresponding +    // updates needs to be applied to this implementation. +    // +    // See: https://github.com/handlebars-lang/handlebars.js/commit/30dbf0478109ded8f12bb29832135d480c17e367 +    it.skip('each with block params and strict compilation', () => { +      expectTemplate('{{#each goodbyes as |value index|}}{{index}}. {{value.text}}!{{/each}}') +        .withCompileOptions({ strict: true }) +        .withInput({ goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }] }) +        .toCompileTo('0. goodbye!1. Goodbye!'); +    }); + +    it('each object with @index', () => { +      expectTemplate('{{#each goodbyes}}{{@index}}. {{text}}! {{/each}}cruel {{world}}!') +        .withInput({ +          goodbyes: { +            a: { text: 'goodbye' }, +            b: { text: 'Goodbye' }, +            c: { text: 'GOODBYE' }, +          }, +          world: 'world', +        }) +        .toCompileTo('0. goodbye! 1. Goodbye! 2. GOODBYE! cruel world!'); +    }); + +    it('each with @first', () => { +      expectTemplate('{{#each goodbyes}}{{#if @first}}{{text}}! {{/if}}{{/each}}cruel {{world}}!') +        .withInput({ +          goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }], +          world: 'world', +        }) +        .toCompileTo('goodbye! cruel world!'); +    }); + +    it('each with nested @first', () => { +      expectTemplate( +        '{{#each goodbyes}}({{#if @first}}{{text}}! {{/if}}{{#each ../goodbyes}}{{#if @first}}{{text}}!{{/if}}{{/each}}{{#if @first}} {{text}}!{{/if}}) {{/each}}cruel {{world}}!' +      ) +        .withInput({ +          goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }], +          world: 'world', +        }) +        .toCompileTo('(goodbye! goodbye! goodbye!) (goodbye!) (goodbye!) cruel world!'); +    }); + +    it('each object with @first', () => { +      expectTemplate('{{#each goodbyes}}{{#if @first}}{{text}}! {{/if}}{{/each}}cruel {{world}}!') +        .withInput({ +          goodbyes: { foo: { text: 'goodbye' }, bar: { text: 'Goodbye' } }, +          world: 'world', +        }) +        .toCompileTo('goodbye! cruel world!'); +    }); + +    it('each with @last', () => { +      expectTemplate('{{#each goodbyes}}{{#if @last}}{{text}}! {{/if}}{{/each}}cruel {{world}}!') +        .withInput({ +          goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }], +          world: 'world', +        }) +        .toCompileTo('GOODBYE! cruel world!'); +    }); + +    it('each object with @last', () => { +      expectTemplate('{{#each goodbyes}}{{#if @last}}{{text}}! {{/if}}{{/each}}cruel {{world}}!') +        .withInput({ +          goodbyes: { foo: { text: 'goodbye' }, bar: { text: 'Goodbye' } }, +          world: 'world', +        }) +        .toCompileTo('Goodbye! cruel world!'); +    }); + +    it('each with nested @last', () => { +      expectTemplate( +        '{{#each goodbyes}}({{#if @last}}{{text}}! {{/if}}{{#each ../goodbyes}}{{#if @last}}{{text}}!{{/if}}{{/each}}{{#if @last}} {{text}}!{{/if}}) {{/each}}cruel {{world}}!' +      ) +        .withInput({ +          goodbyes: [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }], +          world: 'world', +        }) +        .toCompileTo('(GOODBYE!) (GOODBYE!) (GOODBYE! GOODBYE! GOODBYE!) cruel world!'); +    }); + +    it('each with function argument', () => { +      const string = '{{#each goodbyes}}{{text}}! {{/each}}cruel {{world}}!'; + +      expectTemplate(string) +        .withInput({ +          goodbyes() { +            return [{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }]; +          }, +          world: 'world', +        }) +        .toCompileTo('goodbye! Goodbye! GOODBYE! cruel world!'); + +      expectTemplate(string) +        .withInput({ +          goodbyes: [], +          world: 'world', +        }) +        .toCompileTo('cruel world!'); +    }); + +    it('each object when last key is an empty string', () => { +      expectTemplate('{{#each goodbyes}}{{@index}}. {{text}}! {{/each}}cruel {{world}}!') +        .withInput({ +          goodbyes: { +            a: { text: 'goodbye' }, +            b: { text: 'Goodbye' }, +            '': { text: 'GOODBYE' }, +          }, +          world: 'world', +        }) +        .toCompileTo('0. goodbye! 1. Goodbye! 2. GOODBYE! cruel world!'); +    }); + +    it('data passed to helpers', () => { +      expectTemplate('{{#each letters}}{{this}}{{detectDataInsideEach}}{{/each}}') +        .withInput({ letters: ['a', 'b', 'c'] }) +        .withHelper('detectDataInsideEach', function (options) { +          return options.data && options.data.exclaim; +        }) +        .withRuntimeOptions({ +          data: { +            exclaim: '!', +          }, +        }) +        .toCompileTo('a!b!c!'); +    }); + +    it('each on implicit context', () => { +      expectTemplate('{{#each}}{{text}}! {{/each}}cruel world!').toThrow(Handlebars.Exception); +    }); + +    it('each on iterable', () => { +      class Iterator { +        private arr: any[]; +        private index: number = 0; + +        constructor(arr: any[]) { +          this.arr = arr; +        } + +        next() { +          const value = this.arr[this.index]; +          const done = this.index === this.arr.length; +          if (!done) { +            this.index++; +          } +          return { value, done }; +        } +      } + +      class Iterable { +        private arr: any[]; + +        constructor(arr: any[]) { +          this.arr = arr; +        } + +        [Symbol.iterator]() { +          return new Iterator(this.arr); +        } +      } + +      const string = '{{#each goodbyes}}{{text}}! {{/each}}cruel {{world}}!'; + +      expectTemplate(string) +        .withInput({ +          goodbyes: new Iterable([{ text: 'goodbye' }, { text: 'Goodbye' }, { text: 'GOODBYE' }]), +          world: 'world', +        }) +        .toCompileTo('goodbye! Goodbye! GOODBYE! cruel world!'); + +      expectTemplate(string) +        .withInput({ +          goodbyes: new Iterable([]), +          world: 'world', +        }) +        .toCompileTo('cruel world!'); +    }); +  }); + +  describe('#log', function () { +    /* eslint-disable no-console */ +    let $log: typeof console.log; +    let $info: typeof console.info; +    let $error: typeof console.error; + +    beforeEach(function () { +      $log = console.log; +      $info = console.info; +      $error = console.error; + +      global.kbnHandlebarsEnv = Handlebars.create(); +    }); + +    afterEach(function () { +      console.log = $log; +      console.info = $info; +      console.error = $error; + +      global.kbnHandlebarsEnv = null; +    }); + +    it('should call logger at default level', function () { +      let levelArg; +      let logArg; +      kbnHandlebarsEnv!.log = function (level, arg) { +        levelArg = level; +        logArg = arg; +      }; + +      expectTemplate('{{log blah}}').withInput({ blah: 'whee' }).toCompileTo(''); +      expect(1).toEqual(levelArg); +      expect('whee').toEqual(logArg); +    }); + +    it('should call logger at data level', function () { +      let levelArg; +      let logArg; +      kbnHandlebarsEnv!.log = function (level, arg) { +        levelArg = level; +        logArg = arg; +      }; + +      expectTemplate('{{log blah}}') +        .withInput({ blah: 'whee' }) +        .withRuntimeOptions({ data: { level: '03' } }) +        .withCompileOptions({ data: true }) +        .toCompileTo(''); +      expect('03').toEqual(levelArg); +      expect('whee').toEqual(logArg); +    }); + +    it('should output to info', function () { +      let calls = 0; +      const callsExpected = process.env.AST || process.env.EVAL ? 1 : 2; + +      console.info = function (info) { +        expect('whee').toEqual(info); +        calls++; +        if (calls === callsExpected) { +          console.info = $info; +          console.log = $log; +        } +      }; +      console.log = function (log) { +        expect('whee').toEqual(log); +        calls++; +        if (calls === callsExpected) { +          console.info = $info; +          console.log = $log; +        } +      }; + +      expectTemplate('{{log blah}}').withInput({ blah: 'whee' }).toCompileTo(''); +      expect(calls).toEqual(callsExpected); +    }); + +    it('should log at data level', function () { +      let calls = 0; +      const callsExpected = process.env.AST || process.env.EVAL ? 1 : 2; + +      console.error = function (log) { +        expect('whee').toEqual(log); +        calls++; +        if (calls === callsExpected) console.error = $error; +      }; + +      expectTemplate('{{log blah}}') +        .withInput({ blah: 'whee' }) +        .withRuntimeOptions({ data: { level: '03' } }) +        .withCompileOptions({ data: true }) +        .toCompileTo(''); +      expect(calls).toEqual(callsExpected); +    }); + +    it('should handle missing logger', function () { +      let calls = 0; +      const callsExpected = process.env.AST || process.env.EVAL ? 1 : 2; + +      // @ts-expect-error +      console.error = undefined; +      console.log = function (log) { +        expect('whee').toEqual(log); +        calls++; +        if (calls === callsExpected) console.log = $log; +      }; + +      expectTemplate('{{log blah}}') +        .withInput({ blah: 'whee' }) +        .withRuntimeOptions({ data: { level: '03' } }) +        .withCompileOptions({ data: true }) +        .toCompileTo(''); +      expect(calls).toEqual(callsExpected); +    }); + +    it('should handle string log levels', function () { +      let calls = 0; +      const callsExpected = process.env.AST || process.env.EVAL ? 1 : 2; + +      console.error = function (log) { +        expect('whee').toEqual(log); +        calls++; +      }; + +      expectTemplate('{{log blah}}') +        .withInput({ blah: 'whee' }) +        .withRuntimeOptions({ data: { level: 'error' } }) +        .withCompileOptions({ data: true }) +        .toCompileTo(''); +      expect(calls).toEqual(callsExpected); + +      calls = 0; + +      expectTemplate('{{log blah}}') +        .withInput({ blah: 'whee' }) +        .withRuntimeOptions({ data: { level: 'ERROR' } }) +        .withCompileOptions({ data: true }) +        .toCompileTo(''); +      expect(calls).toEqual(callsExpected); +    }); + +    it('should handle hash log levels [1]', function () { +      let calls = 0; +      const callsExpected = process.env.AST || process.env.EVAL ? 1 : 2; + +      console.error = function (log) { +        expect('whee').toEqual(log); +        calls++; +      }; + +      expectTemplate('{{log blah level="error"}}').withInput({ blah: 'whee' }).toCompileTo(''); +      expect(calls).toEqual(callsExpected); +    }); + +    it('should handle hash log levels [2]', function () { +      let called = false; + +      console.info = +        console.log = +        console.error = +        console.debug = +          function () { +            called = true; +            console.info = console.log = console.error = console.debug = $log; +          }; + +      expectTemplate('{{log blah level="debug"}}').withInput({ blah: 'whee' }).toCompileTo(''); +      expect(false).toEqual(called); +    }); + +    it('should pass multiple log arguments', function () { +      let calls = 0; +      const callsExpected = process.env.AST || process.env.EVAL ? 1 : 2; + +      console.info = console.log = function (log1, log2, log3) { +        expect('whee').toEqual(log1); +        expect('foo').toEqual(log2); +        expect(1).toEqual(log3); +        calls++; +        if (calls === callsExpected) console.log = $log; +      }; + +      expectTemplate('{{log blah "foo" 1}}').withInput({ blah: 'whee' }).toCompileTo(''); +      expect(calls).toEqual(callsExpected); +    }); + +    it('should pass zero log arguments', function () { +      let calls = 0; +      const callsExpected = process.env.AST || process.env.EVAL ? 1 : 2; + +      console.info = console.log = function () { +        expect(arguments.length).toEqual(0); +        calls++; +        if (calls === callsExpected) console.log = $log; +      }; + +      expectTemplate('{{log}}').withInput({ blah: 'whee' }).toCompileTo(''); +      expect(calls).toEqual(callsExpected); +    }); +    /* eslint-enable no-console */ +  }); + +  describe('#lookup', () => { +    it('should lookup arbitrary content', () => { +      expectTemplate('{{#each goodbyes}}{{lookup ../data .}}{{/each}}') +        .withInput({ goodbyes: [0, 1], data: ['foo', 'bar'] }) +        .toCompileTo('foobar'); +    }); + +    it('should not fail on undefined value', () => { +      expectTemplate('{{#each goodbyes}}{{lookup ../bar .}}{{/each}}') +        .withInput({ goodbyes: [0, 1], data: ['foo', 'bar'] }) +        .toCompileTo(''); +    }); +  }); +}); diff --git a/dev/lib/handlebars/src/spec/index.compiler.test.ts b/dev/lib/handlebars/src/spec/index.compiler.test.ts new file mode 100644 index 00000000..ef5c55f2 --- /dev/null +++ b/dev/lib/handlebars/src/spec/index.compiler.test.ts @@ -0,0 +1,86 @@ +/* + * This file is forked from the handlebars project (https://github.com/handlebars-lang/handlebars.js), + * and may include modifications made by Elasticsearch B.V. + * Elasticsearch B.V. licenses this file to you under the MIT License. + * See `packages/kbn-handlebars/LICENSE` for more information. + */ + +import Handlebars from '../..'; +import { forEachCompileFunctionName } from '../__jest__/test_bench'; + +describe('compiler', () => { +  forEachCompileFunctionName((compileName) => { +    const compile = Handlebars[compileName].bind(Handlebars); + +    describe(`#${compileName}`, () => { +      it('should fail with invalid input', () => { +        expect(function () { +          compile(null); +        }).toThrow( +          `You must pass a string or Handlebars AST to Handlebars.${compileName}. You passed null` +        ); + +        expect(function () { +          compile({}); +        }).toThrow( +          `You must pass a string or Handlebars AST to Handlebars.${compileName}. You passed [object Object]` +        ); +      }); + +      it('should include the location in the error (row and column)', () => { +        try { +          compile(' \n  {{#if}}\n{{/def}}')(); +          expect(true).toEqual(false); +        } catch (err) { +          expect(err.message).toEqual("if doesn't match def - 2:5"); +          if (Object.getOwnPropertyDescriptor(err, 'column')!.writable) { +            // In Safari 8, the column-property is read-only. This means that even if it is set with defineProperty, +            // its value won't change (https://github.com/jquery/esprima/issues/1290#issuecomment-132455482) +            // Since this was neither working in Handlebars 3 nor in 4.0.5, we only check the column for other browsers. +            expect(err.column).toEqual(5); +          } +          expect(err.lineNumber).toEqual(2); +        } +      }); + +      it('should include the location as enumerable property', () => { +        try { +          compile(' \n  {{#if}}\n{{/def}}')(); +          expect(true).toEqual(false); +        } catch (err) { +          expect(Object.prototype.propertyIsEnumerable.call(err, 'column')).toEqual(true); +        } +      }); + +      it('can utilize AST instance', () => { +        expect( +          compile({ +            type: 'Program', +            body: [{ type: 'ContentStatement', value: 'Hello' }], +          })() +        ).toEqual('Hello'); +      }); + +      it('can pass through an empty string', () => { +        expect(compile('')()).toEqual(''); +      }); + +      it('should not modify the options.data property(GH-1327)', () => { +        // The `data` property is supposed to be a boolean, but in this test we want to ignore that +        const options = { data: [{ a: 'foo' }, { a: 'bar' }] as unknown as boolean }; +        compile('{{#each data}}{{@index}}:{{a}} {{/each}}', options)(); +        expect(JSON.stringify(options, null, 2)).toEqual( +          JSON.stringify({ data: [{ a: 'foo' }, { a: 'bar' }] }, null, 2) +        ); +      }); + +      it('should not modify the options.knownHelpers property(GH-1327)', () => { +        const options = { knownHelpers: {} }; +        compile('{{#each data}}{{@index}}:{{a}} {{/each}}', options)(); +        expect(JSON.stringify(options, null, 2)).toEqual( +          JSON.stringify({ knownHelpers: {} }, null, 2) +        ); +      }); +    }); +  }); +}); diff --git a/dev/lib/handlebars/src/spec/index.data.test.ts b/dev/lib/handlebars/src/spec/index.data.test.ts new file mode 100644 index 00000000..94d3b51c --- /dev/null +++ b/dev/lib/handlebars/src/spec/index.data.test.ts @@ -0,0 +1,269 @@ +/* + * This file is forked from the handlebars project (https://github.com/handlebars-lang/handlebars.js), + * and may include modifications made by Elasticsearch B.V. + * Elasticsearch B.V. licenses this file to you under the MIT License. + * See `packages/kbn-handlebars/LICENSE` for more information. + */ + +import Handlebars, { type HelperOptions } from '../..'; +import { expectTemplate } from '../__jest__/test_bench'; + +describe('data', () => { +  it('passing in data to a compiled function that expects data - works with helpers', () => { +    expectTemplate('{{hello}}') +      .withCompileOptions({ data: true }) +      .withHelper('hello', function (this: any, options) { +        return options.data.adjective + ' ' + this.noun; +      }) +      .withRuntimeOptions({ data: { adjective: 'happy' } }) +      .withInput({ noun: 'cat' }) +      .toCompileTo('happy cat'); +  }); + +  it('data can be looked up via @foo', () => { +    expectTemplate('{{@hello}}') +      .withRuntimeOptions({ data: { hello: 'hello' } }) +      .toCompileTo('hello'); +  }); + +  it('deep @foo triggers automatic top-level data', () => { +    global.kbnHandlebarsEnv = Handlebars.create(); +    const helpers = Handlebars.createFrame(kbnHandlebarsEnv!.helpers); + +    helpers.let = function (options: HelperOptions) { +      const frame = Handlebars.createFrame(options.data); + +      for (const prop in options.hash) { +        if (prop in options.hash) { +          frame[prop] = options.hash[prop]; +        } +      } +      return options.fn(this, { data: frame }); +    }; + +    expectTemplate( +      '{{#let world="world"}}{{#if foo}}{{#if foo}}Hello {{@world}}{{/if}}{{/if}}{{/let}}' +    ) +      .withInput({ foo: true }) +      .withHelpers(helpers) +      .toCompileTo('Hello world'); + +    global.kbnHandlebarsEnv = null; +  }); + +  it('parameter data can be looked up via @foo', () => { +    expectTemplate('{{hello @world}}') +      .withRuntimeOptions({ data: { world: 'world' } }) +      .withHelper('hello', function (noun) { +        return 'Hello ' + noun; +      }) +      .toCompileTo('Hello world'); +  }); + +  it('hash values can be looked up via @foo', () => { +    expectTemplate('{{hello noun=@world}}') +      .withRuntimeOptions({ data: { world: 'world' } }) +      .withHelper('hello', function (options) { +        return 'Hello ' + options.hash.noun; +      }) +      .toCompileTo('Hello world'); +  }); + +  it('nested parameter data can be looked up via @foo.bar', () => { +    expectTemplate('{{hello @world.bar}}') +      .withRuntimeOptions({ data: { world: { bar: 'world' } } }) +      .withHelper('hello', function (noun) { +        return 'Hello ' + noun; +      }) +      .toCompileTo('Hello world'); +  }); + +  it('nested parameter data does not fail with @world.bar', () => { +    expectTemplate('{{hello @world.bar}}') +      .withRuntimeOptions({ data: { foo: { bar: 'world' } } }) +      .withHelper('hello', function (noun) { +        return 'Hello ' + noun; +      }) +      .toCompileTo('Hello undefined'); +  }); + +  it('parameter data throws when using complex scope references', () => { +    expectTemplate('{{#goodbyes}}{{text}} cruel {{@foo/../name}}! {{/goodbyes}}').toThrow(Error); +  }); + +  it('data can be functions', () => { +    expectTemplate('{{@hello}}') +      .withRuntimeOptions({ +        data: { +          hello() { +            return 'hello'; +          }, +        }, +      }) +      .toCompileTo('hello'); +  }); + +  it('data can be functions with params', () => { +    expectTemplate('{{@hello "hello"}}') +      .withRuntimeOptions({ +        data: { +          hello(arg: any) { +            return arg; +          }, +        }, +      }) +      .toCompileTo('hello'); +  }); + +  it('data is inherited downstream', () => { +    expectTemplate( +      '{{#let foo=1 bar=2}}{{#let foo=bar.baz}}{{@bar}}{{@foo}}{{/let}}{{@foo}}{{/let}}' +    ) +      .withInput({ bar: { baz: 'hello world' } }) +      .withCompileOptions({ data: true }) +      .withHelper('let', function (this: any, options) { +        const frame = Handlebars.createFrame(options.data); +        for (const prop in options.hash) { +          if (prop in options.hash) { +            frame[prop] = options.hash[prop]; +          } +        } +        return options.fn(this, { data: frame }); +      }) +      .withRuntimeOptions({ data: {} }) +      .toCompileTo('2hello world1'); +  }); + +  it('passing in data to a compiled function that expects data - works with helpers in partials', () => { +    expectTemplate('{{>myPartial}}') +      .withCompileOptions({ data: true }) +      .withPartial('myPartial', '{{hello}}') +      .withHelper('hello', function (this: any, options: HelperOptions) { +        return options.data.adjective + ' ' + this.noun; +      }) +      .withInput({ noun: 'cat' }) +      .withRuntimeOptions({ data: { adjective: 'happy' } }) +      .toCompileTo('happy cat'); +  }); + +  it('passing in data to a compiled function that expects data - works with helpers and parameters', () => { +    expectTemplate('{{hello world}}') +      .withCompileOptions({ data: true }) +      .withHelper('hello', function (this: any, noun, options) { +        return options.data.adjective + ' ' + noun + (this.exclaim ? '!' : ''); +      }) +      .withInput({ exclaim: true, world: 'world' }) +      .withRuntimeOptions({ data: { adjective: 'happy' } }) +      .toCompileTo('happy world!'); +  }); + +  it('passing in data to a compiled function that expects data - works with block helpers', () => { +    expectTemplate('{{#hello}}{{world}}{{/hello}}') +      .withCompileOptions({ +        data: true, +      }) +      .withHelper('hello', function (this: any, options) { +        return options.fn(this); +      }) +      .withHelper('world', function (this: any, options) { +        return options.data.adjective + ' world' + (this.exclaim ? '!' : ''); +      }) +      .withInput({ exclaim: true }) +      .withRuntimeOptions({ data: { adjective: 'happy' } }) +      .toCompileTo('happy world!'); +  }); + +  it('passing in data to a compiled function that expects data - works with block helpers that use ..', () => { +    expectTemplate('{{#hello}}{{world ../zomg}}{{/hello}}') +      .withCompileOptions({ data: true }) +      .withHelper('hello', function (options) { +        return options.fn({ exclaim: '?' }); +      }) +      .withHelper('world', function (this: any, thing, options) { +        return options.data.adjective + ' ' + thing + (this.exclaim || ''); +      }) +      .withInput({ exclaim: true, zomg: 'world' }) +      .withRuntimeOptions({ data: { adjective: 'happy' } }) +      .toCompileTo('happy world?'); +  }); + +  it('passing in data to a compiled function that expects data - data is passed to with block helpers where children use ..', () => { +    expectTemplate('{{#hello}}{{world ../zomg}}{{/hello}}') +      .withCompileOptions({ data: true }) +      .withHelper('hello', function (options) { +        return options.data.accessData + ' ' + options.fn({ exclaim: '?' }); +      }) +      .withHelper('world', function (this: any, thing, options) { +        return options.data.adjective + ' ' + thing + (this.exclaim || ''); +      }) +      .withInput({ exclaim: true, zomg: 'world' }) +      .withRuntimeOptions({ data: { adjective: 'happy', accessData: '#win' } }) +      .toCompileTo('#win happy world?'); +  }); + +  it('you can override inherited data when invoking a helper', () => { +    expectTemplate('{{#hello}}{{world zomg}}{{/hello}}') +      .withCompileOptions({ data: true }) +      .withHelper('hello', function (options) { +        return options.fn({ exclaim: '?', zomg: 'world' }, { data: { adjective: 'sad' } }); +      }) +      .withHelper('world', function (this: any, thing, options) { +        return options.data.adjective + ' ' + thing + (this.exclaim || ''); +      }) +      .withInput({ exclaim: true, zomg: 'planet' }) +      .withRuntimeOptions({ data: { adjective: 'happy' } }) +      .toCompileTo('sad world?'); +  }); + +  it('you can override inherited data when invoking a helper with depth', () => { +    expectTemplate('{{#hello}}{{world ../zomg}}{{/hello}}') +      .withCompileOptions({ data: true }) +      .withHelper('hello', function (options) { +        return options.fn({ exclaim: '?' }, { data: { adjective: 'sad' } }); +      }) +      .withHelper('world', function (this: any, thing, options) { +        return options.data.adjective + ' ' + thing + (this.exclaim || ''); +      }) +      .withInput({ exclaim: true, zomg: 'world' }) +      .withRuntimeOptions({ data: { adjective: 'happy' } }) +      .toCompileTo('sad world?'); +  }); + +  describe('@root', () => { +    it('the root context can be looked up via @root', () => { +      expectTemplate('{{@root.foo}}') +        .withInput({ foo: 'hello' }) +        .withRuntimeOptions({ data: {} }) +        .toCompileTo('hello'); + +      expectTemplate('{{@root.foo}}').withInput({ foo: 'hello' }).toCompileTo('hello'); +    }); + +    it('passed root values take priority', () => { +      expectTemplate('{{@root.foo}}') +        .withInput({ foo: 'should not be used' }) +        .withRuntimeOptions({ data: { root: { foo: 'hello' } } }) +        .toCompileTo('hello'); +    }); +  }); + +  describe('nesting', () => { +    it('the root context can be looked up via @root', () => { +      expectTemplate( +        '{{#helper}}{{#helper}}{{@./depth}} {{@../depth}} {{@../../depth}}{{/helper}}{{/helper}}' +      ) +        .withInput({ foo: 'hello' }) +        .withHelper('helper', function (this: any, options) { +          const frame = Handlebars.createFrame(options.data); +          frame.depth = options.data.depth + 1; +          return options.fn(this, { data: frame }); +        }) +        .withRuntimeOptions({ +          data: { +            depth: 0, +          }, +        }) +        .toCompileTo('2 1 0'); +    }); +  }); +}); diff --git a/dev/lib/handlebars/src/spec/index.helpers.test.ts b/dev/lib/handlebars/src/spec/index.helpers.test.ts new file mode 100644 index 00000000..4cfa39bb --- /dev/null +++ b/dev/lib/handlebars/src/spec/index.helpers.test.ts @@ -0,0 +1,958 @@ +/* + * This file is forked from the handlebars project (https://github.com/handlebars-lang/handlebars.js), + * and may include modifications made by Elasticsearch B.V. + * Elasticsearch B.V. licenses this file to you under the MIT License. + * See `packages/kbn-handlebars/LICENSE` for more information. + */ + +import Handlebars, { type HelperOptions } from '../..'; +import { expectTemplate } from '../__jest__/test_bench'; + +beforeEach(() => { +  global.kbnHandlebarsEnv = Handlebars.create(); +}); + +afterEach(() => { +  global.kbnHandlebarsEnv = null; +}); + +describe('helpers', () => { +  it('helper with complex lookup$', () => { +    expectTemplate('{{#goodbyes}}{{{link ../prefix}}}{{/goodbyes}}') +      .withInput({ +        prefix: '/root', +        goodbyes: [{ text: 'Goodbye', url: 'goodbye' }], +      }) +      .withHelper('link', function (this: any, prefix) { +        return '<a href="' + prefix + '/' + this.url + '">' + this.text + '</a>'; +      }) +      .toCompileTo('<a href="/root/goodbye">Goodbye</a>'); +  }); + +  it('helper for raw block gets raw content', () => { +    expectTemplate('{{{{raw}}}} {{test}} {{{{/raw}}}}') +      .withInput({ test: 'hello' }) +      .withHelper('raw', function (options: HelperOptions) { +        return options.fn(); +      }) +      .toCompileTo(' {{test}} '); +  }); + +  it('helper for raw block gets parameters', () => { +    expectTemplate('{{{{raw 1 2 3}}}} {{test}} {{{{/raw}}}}') +      .withInput({ test: 'hello' }) +      .withHelper('raw', function (a, b, c, options: HelperOptions) { +        const ret = options.fn() + a + b + c; +        return ret; +      }) +      .toCompileTo(' {{test}} 123'); +  }); + +  describe('raw block parsing (with identity helper-function)', () => { +    function runWithIdentityHelper(template: string, expected: string) { +      expectTemplate(template) +        .withHelper('identity', function (options: HelperOptions) { +          return options.fn(); +        }) +        .toCompileTo(expected); +    } + +    it('helper for nested raw block gets raw content', () => { +      runWithIdentityHelper( +        '{{{{identity}}}} {{{{b}}}} {{{{/b}}}} {{{{/identity}}}}', +        ' {{{{b}}}} {{{{/b}}}} ' +      ); +    }); + +    it('helper for nested raw block works with empty content', () => { +      runWithIdentityHelper('{{{{identity}}}}{{{{/identity}}}}', ''); +    }); + +    it.skip('helper for nested raw block works if nested raw blocks are broken', () => { +      // This test was introduced in 4.4.4, but it was not the actual problem that lead to the patch release +      // The test is deactivated, because in 3.x this template cases an exception and it also does not work in 4.4.3 +      // If anyone can make this template work without breaking everything else, then go for it, +      // but for now, this is just a known bug, that will be documented. +      runWithIdentityHelper( +        '{{{{identity}}}} {{{{a}}}} {{{{ {{{{/ }}}} }}}} {{{{/identity}}}}', +        ' {{{{a}}}} {{{{ {{{{/ }}}} }}}} ' +      ); +    }); + +    it('helper for nested raw block closes after first matching close', () => { +      runWithIdentityHelper( +        '{{{{identity}}}}abc{{{{/identity}}}} {{{{identity}}}}abc{{{{/identity}}}}', +        'abc abc' +      ); +    }); + +    it('helper for nested raw block throw exception when with missing closing braces', () => { +      const string = '{{{{a}}}} {{{{/a'; +      expectTemplate(string).toThrow(); +    }); +  }); + +  it('helper block with identical context', () => { +    expectTemplate('{{#goodbyes}}{{name}}{{/goodbyes}}') +      .withInput({ name: 'Alan' }) +      .withHelper('goodbyes', function (this: any, options: HelperOptions) { +        let out = ''; +        const byes = ['Goodbye', 'goodbye', 'GOODBYE']; +        for (let i = 0, j = byes.length; i < j; i++) { +          out += byes[i] + ' ' + options.fn(this) + '! '; +        } +        return out; +      }) +      .toCompileTo('Goodbye Alan! goodbye Alan! GOODBYE Alan! '); +  }); + +  it('helper block with complex lookup expression', () => { +    expectTemplate('{{#goodbyes}}{{../name}}{{/goodbyes}}') +      .withInput({ name: 'Alan' }) +      .withHelper('goodbyes', function (options: HelperOptions) { +        let out = ''; +        const byes = ['Goodbye', 'goodbye', 'GOODBYE']; +        for (let i = 0, j = byes.length; i < j; i++) { +          out += byes[i] + ' ' + options.fn({}) + '! '; +        } +        return out; +      }) +      .toCompileTo('Goodbye Alan! goodbye Alan! GOODBYE Alan! '); +  }); + +  it('helper with complex lookup and nested template', () => { +    expectTemplate('{{#goodbyes}}{{#link ../prefix}}{{text}}{{/link}}{{/goodbyes}}') +      .withInput({ +        prefix: '/root', +        goodbyes: [{ text: 'Goodbye', url: 'goodbye' }], +      }) +      .withHelper('link', function (this: any, prefix, options: HelperOptions) { +        return '<a href="' + prefix + '/' + this.url + '">' + options.fn(this) + '</a>'; +      }) +      .toCompileTo('<a href="/root/goodbye">Goodbye</a>'); +  }); + +  it('helper with complex lookup and nested template in VM+Compiler', () => { +    expectTemplate('{{#goodbyes}}{{#link ../prefix}}{{text}}{{/link}}{{/goodbyes}}') +      .withInput({ +        prefix: '/root', +        goodbyes: [{ text: 'Goodbye', url: 'goodbye' }], +      }) +      .withHelper('link', function (this: any, prefix, options: HelperOptions) { +        return '<a href="' + prefix + '/' + this.url + '">' + options.fn(this) + '</a>'; +      }) +      .toCompileTo('<a href="/root/goodbye">Goodbye</a>'); +  }); + +  it('helper returning undefined value', () => { +    expectTemplate(' {{nothere}}') +      .withHelpers({ +        nothere() {}, +      }) +      .toCompileTo(' '); + +    expectTemplate(' {{#nothere}}{{/nothere}}') +      .withHelpers({ +        nothere() {}, +      }) +      .toCompileTo(' '); +  }); + +  it('block helper', () => { +    expectTemplate('{{#goodbyes}}{{text}}! {{/goodbyes}}cruel {{world}}!') +      .withInput({ world: 'world' }) +      .withHelper('goodbyes', function (options: HelperOptions) { +        return options.fn({ text: 'GOODBYE' }); +      }) +      .toCompileTo('GOODBYE! cruel world!'); +  }); + +  it('block helper staying in the same context', () => { +    expectTemplate('{{#form}}<p>{{name}}</p>{{/form}}') +      .withInput({ name: 'Yehuda' }) +      .withHelper('form', function (this: any, options: HelperOptions) { +        return '<form>' + options.fn(this) + '</form>'; +      }) +      .toCompileTo('<form><p>Yehuda</p></form>'); +  }); + +  it('block helper should have context in this', () => { +    function link(this: any, options: HelperOptions) { +      return '<a href="/people/' + this.id + '">' + options.fn(this) + '</a>'; +    } + +    expectTemplate('<ul>{{#people}}<li>{{#link}}{{name}}{{/link}}</li>{{/people}}</ul>') +      .withInput({ +        people: [ +          { name: 'Alan', id: 1 }, +          { name: 'Yehuda', id: 2 }, +        ], +      }) +      .withHelper('link', link) +      .toCompileTo( +        '<ul><li><a href="/people/1">Alan</a></li><li><a href="/people/2">Yehuda</a></li></ul>' +      ); +  }); + +  it('block helper for undefined value', () => { +    expectTemplate("{{#empty}}shouldn't render{{/empty}}").toCompileTo(''); +  }); + +  it('block helper passing a new context', () => { +    expectTemplate('{{#form yehuda}}<p>{{name}}</p>{{/form}}') +      .withInput({ yehuda: { name: 'Yehuda' } }) +      .withHelper('form', function (context, options: HelperOptions) { +        return '<form>' + options.fn(context) + '</form>'; +      }) +      .toCompileTo('<form><p>Yehuda</p></form>'); +  }); + +  it('block helper passing a complex path context', () => { +    expectTemplate('{{#form yehuda/cat}}<p>{{name}}</p>{{/form}}') +      .withInput({ yehuda: { name: 'Yehuda', cat: { name: 'Harold' } } }) +      .withHelper('form', function (context, options: HelperOptions) { +        return '<form>' + options.fn(context) + '</form>'; +      }) +      .toCompileTo('<form><p>Harold</p></form>'); +  }); + +  it('nested block helpers', () => { +    expectTemplate('{{#form yehuda}}<p>{{name}}</p>{{#link}}Hello{{/link}}{{/form}}') +      .withInput({ +        yehuda: { name: 'Yehuda' }, +      }) +      .withHelper('link', function (this: any, options: HelperOptions) { +        return '<a href="' + this.name + '">' + options.fn(this) + '</a>'; +      }) +      .withHelper('form', function (context, options: HelperOptions) { +        return '<form>' + options.fn(context) + '</form>'; +      }) +      .toCompileTo('<form><p>Yehuda</p><a href="Yehuda">Hello</a></form>'); +  }); + +  it('block helper inverted sections', () => { +    const string = "{{#list people}}{{name}}{{^}}<em>Nobody's here</em>{{/list}}"; +    function list(this: any, context: any, options: HelperOptions) { +      if (context.length > 0) { +        let out = '<ul>'; +        for (let i = 0, j = context.length; i < j; i++) { +          out += '<li>'; +          out += options.fn(context[i]); +          out += '</li>'; +        } +        out += '</ul>'; +        return out; +      } else { +        return '<p>' + options.inverse(this) + '</p>'; +      } +    } + +    // the meaning here may be kind of hard to catch, but list.not is always called, +    // so we should see the output of both +    expectTemplate(string) +      .withInput({ people: [{ name: 'Alan' }, { name: 'Yehuda' }] }) +      .withHelpers({ list }) +      .toCompileTo('<ul><li>Alan</li><li>Yehuda</li></ul>'); + +    expectTemplate(string) +      .withInput({ people: [] }) +      .withHelpers({ list }) +      .toCompileTo("<p><em>Nobody's here</em></p>"); + +    expectTemplate('{{#list people}}Hello{{^}}{{message}}{{/list}}') +      .withInput({ +        people: [], +        message: "Nobody's here", +      }) +      .withHelpers({ list }) +      .toCompileTo('<p>Nobody's here</p>'); +  }); + +  it('pathed lambas with parameters', () => { +    const hash = { +      helper: () => 'winning', +    }; +    // @ts-expect-error +    hash.hash = hash; + +    const helpers = { +      './helper': () => 'fail', +    }; + +    expectTemplate('{{./helper 1}}').withInput(hash).withHelpers(helpers).toCompileTo('winning'); +    expectTemplate('{{hash/helper 1}}').withInput(hash).withHelpers(helpers).toCompileTo('winning'); +  }); + +  describe('helpers hash', () => { +    it('providing a helpers hash', () => { +      expectTemplate('Goodbye {{cruel}} {{world}}!') +        .withInput({ cruel: 'cruel' }) +        .withHelpers({ +          world() { +            return 'world'; +          }, +        }) +        .toCompileTo('Goodbye cruel world!'); + +      expectTemplate('Goodbye {{#iter}}{{cruel}} {{world}}{{/iter}}!') +        .withInput({ iter: [{ cruel: 'cruel' }] }) +        .withHelpers({ +          world() { +            return 'world'; +          }, +        }) +        .toCompileTo('Goodbye cruel world!'); +    }); + +    it('in cases of conflict, helpers win', () => { +      expectTemplate('{{{lookup}}}') +        .withInput({ lookup: 'Explicit' }) +        .withHelpers({ +          lookup() { +            return 'helpers'; +          }, +        }) +        .toCompileTo('helpers'); + +      expectTemplate('{{lookup}}') +        .withInput({ lookup: 'Explicit' }) +        .withHelpers({ +          lookup() { +            return 'helpers'; +          }, +        }) +        .toCompileTo('helpers'); +    }); + +    it('the helpers hash is available is nested contexts', () => { +      expectTemplate('{{#outer}}{{#inner}}{{helper}}{{/inner}}{{/outer}}') +        .withInput({ outer: { inner: { unused: [] } } }) +        .withHelpers({ +          helper() { +            return 'helper'; +          }, +        }) +        .toCompileTo('helper'); +    }); + +    it('the helper hash should augment the global hash', () => { +      kbnHandlebarsEnv!.registerHelper('test_helper', function () { +        return 'found it!'; +      }); + +      expectTemplate('{{test_helper}} {{#if cruel}}Goodbye {{cruel}} {{world}}!{{/if}}') +        .withInput({ cruel: 'cruel' }) +        .withHelpers({ +          world() { +            return 'world!'; +          }, +        }) +        .toCompileTo('found it! Goodbye cruel world!!'); +    }); +  }); + +  describe('registration', () => { +    it('unregisters', () => { +      deleteAllKeys(kbnHandlebarsEnv!.helpers); + +      kbnHandlebarsEnv!.registerHelper('foo', function () { +        return 'fail'; +      }); +      expect(kbnHandlebarsEnv!.helpers.foo).toBeDefined(); +      kbnHandlebarsEnv!.unregisterHelper('foo'); +      expect(kbnHandlebarsEnv!.helpers.foo).toBeUndefined(); +    }); + +    it('allows multiple globals', () => { +      const ifHelper = kbnHandlebarsEnv!.helpers.if; +      deleteAllKeys(kbnHandlebarsEnv!.helpers); + +      kbnHandlebarsEnv!.registerHelper({ +        if: ifHelper, +        world() { +          return 'world!'; +        }, +        testHelper() { +          return 'found it!'; +        }, +      }); + +      expectTemplate('{{testHelper}} {{#if cruel}}Goodbye {{cruel}} {{world}}!{{/if}}') +        .withInput({ cruel: 'cruel' }) +        .toCompileTo('found it! Goodbye cruel world!!'); +    }); + +    it('fails with multiple and args', () => { +      expect(() => { +        kbnHandlebarsEnv!.registerHelper( +          // @ts-expect-error TypeScript is complaining about the invalid input just as the thrown error +          { +            world() { +              return 'world!'; +            }, +            testHelper() { +              return 'found it!'; +            }, +          }, +          {} +        ); +      }).toThrow('Arg not supported with multiple helpers'); +    }); +  }); + +  it('decimal number literals work', () => { +    expectTemplate('Message: {{hello -1.2 1.2}}') +      .withHelper('hello', function (times, times2) { +        if (typeof times !== 'number') { +          times = 'NaN'; +        } +        if (typeof times2 !== 'number') { +          times2 = 'NaN'; +        } +        return 'Hello ' + times + ' ' + times2 + ' times'; +      }) +      .toCompileTo('Message: Hello -1.2 1.2 times'); +  }); + +  it('negative number literals work', () => { +    expectTemplate('Message: {{hello -12}}') +      .withHelper('hello', function (times) { +        if (typeof times !== 'number') { +          times = 'NaN'; +        } +        return 'Hello ' + times + ' times'; +      }) +      .toCompileTo('Message: Hello -12 times'); +  }); + +  describe('String literal parameters', () => { +    it('simple literals work', () => { +      expectTemplate('Message: {{hello "world" 12 true false}}') +        .withHelper('hello', function (param, times, bool1, bool2) { +          if (typeof times !== 'number') { +            times = 'NaN'; +          } +          if (typeof bool1 !== 'boolean') { +            bool1 = 'NaB'; +          } +          if (typeof bool2 !== 'boolean') { +            bool2 = 'NaB'; +          } +          return 'Hello ' + param + ' ' + times + ' times: ' + bool1 + ' ' + bool2; +        }) +        .toCompileTo('Message: Hello world 12 times: true false'); +    }); + +    it('using a quote in the middle of a parameter raises an error', () => { +      expectTemplate('Message: {{hello wo"rld"}}').toThrow(Error); +    }); + +    it('escaping a String is possible', () => { +      expectTemplate('Message: {{{hello "\\"world\\""}}}') +        .withHelper('hello', function (param) { +          return 'Hello ' + param; +        }) +        .toCompileTo('Message: Hello "world"'); +    }); + +    it("it works with ' marks", () => { +      expectTemplate('Message: {{{hello "Alan\'s world"}}}') +        .withHelper('hello', function (param) { +          return 'Hello ' + param; +        }) +        .toCompileTo("Message: Hello Alan's world"); +    }); +  }); + +  describe('multiple parameters', () => { +    it('simple multi-params work', () => { +      expectTemplate('Message: {{goodbye cruel world}}') +        .withInput({ cruel: 'cruel', world: 'world' }) +        .withHelper('goodbye', function (cruel, world) { +          return 'Goodbye ' + cruel + ' ' + world; +        }) +        .toCompileTo('Message: Goodbye cruel world'); +    }); + +    it('block multi-params work', () => { +      expectTemplate('Message: {{#goodbye cruel world}}{{greeting}} {{adj}} {{noun}}{{/goodbye}}') +        .withInput({ cruel: 'cruel', world: 'world' }) +        .withHelper('goodbye', function (cruel, world, options: HelperOptions) { +          return options.fn({ greeting: 'Goodbye', adj: cruel, noun: world }); +        }) +        .toCompileTo('Message: Goodbye cruel world'); +    }); +  }); + +  describe('hash', () => { +    it('helpers can take an optional hash', () => { +      expectTemplate('{{goodbye cruel="CRUEL" world="WORLD" times=12}}') +        .withHelper('goodbye', function (options: HelperOptions) { +          return ( +            'GOODBYE ' + +            options.hash.cruel + +            ' ' + +            options.hash.world + +            ' ' + +            options.hash.times + +            ' TIMES' +          ); +        }) +        .toCompileTo('GOODBYE CRUEL WORLD 12 TIMES'); +    }); + +    it('helpers can take an optional hash with booleans', () => { +      function goodbye(options: HelperOptions) { +        if (options.hash.print === true) { +          return 'GOODBYE ' + options.hash.cruel + ' ' + options.hash.world; +        } else if (options.hash.print === false) { +          return 'NOT PRINTING'; +        } else { +          return 'THIS SHOULD NOT HAPPEN'; +        } +      } + +      expectTemplate('{{goodbye cruel="CRUEL" world="WORLD" print=true}}') +        .withHelper('goodbye', goodbye) +        .toCompileTo('GOODBYE CRUEL WORLD'); + +      expectTemplate('{{goodbye cruel="CRUEL" world="WORLD" print=false}}') +        .withHelper('goodbye', goodbye) +        .toCompileTo('NOT PRINTING'); +    }); + +    it('block helpers can take an optional hash', () => { +      expectTemplate('{{#goodbye cruel="CRUEL" times=12}}world{{/goodbye}}') +        .withHelper('goodbye', function (this: any, options: HelperOptions) { +          return ( +            'GOODBYE ' + +            options.hash.cruel + +            ' ' + +            options.fn(this) + +            ' ' + +            options.hash.times + +            ' TIMES' +          ); +        }) +        .toCompileTo('GOODBYE CRUEL world 12 TIMES'); +    }); + +    it('block helpers can take an optional hash with single quoted stings', () => { +      expectTemplate('{{#goodbye cruel="CRUEL" times=12}}world{{/goodbye}}') +        .withHelper('goodbye', function (this: any, options: HelperOptions) { +          return ( +            'GOODBYE ' + +            options.hash.cruel + +            ' ' + +            options.fn(this) + +            ' ' + +            options.hash.times + +            ' TIMES' +          ); +        }) +        .toCompileTo('GOODBYE CRUEL world 12 TIMES'); +    }); + +    it('block helpers can take an optional hash with booleans', () => { +      function goodbye(this: any, options: HelperOptions) { +        if (options.hash.print === true) { +          return 'GOODBYE ' + options.hash.cruel + ' ' + options.fn(this); +        } else if (options.hash.print === false) { +          return 'NOT PRINTING'; +        } else { +          return 'THIS SHOULD NOT HAPPEN'; +        } +      } + +      expectTemplate('{{#goodbye cruel="CRUEL" print=true}}world{{/goodbye}}') +        .withHelper('goodbye', goodbye) +        .toCompileTo('GOODBYE CRUEL world'); + +      expectTemplate('{{#goodbye cruel="CRUEL" print=false}}world{{/goodbye}}') +        .withHelper('goodbye', goodbye) +        .toCompileTo('NOT PRINTING'); +    }); +  }); + +  describe('helperMissing', () => { +    it('if a context is not found, helperMissing is used', () => { +      expectTemplate('{{hello}} {{link_to world}}').toThrow(/Missing helper: "link_to"/); +    }); + +    it('if a context is not found, custom helperMissing is used', () => { +      expectTemplate('{{hello}} {{link_to world}}') +        .withInput({ hello: 'Hello', world: 'world' }) +        .withHelper('helperMissing', function (mesg, options: HelperOptions) { +          if (options.name === 'link_to') { +            return new Handlebars.SafeString('<a>' + mesg + '</a>'); +          } +        }) +        .toCompileTo('Hello <a>world</a>'); +    }); + +    it('if a value is not found, custom helperMissing is used', () => { +      expectTemplate('{{hello}} {{link_to}}') +        .withInput({ hello: 'Hello', world: 'world' }) +        .withHelper('helperMissing', function (options: HelperOptions) { +          if (options.name === 'link_to') { +            return new Handlebars.SafeString('<a>winning</a>'); +          } +        }) +        .toCompileTo('Hello <a>winning</a>'); +    }); +  }); + +  describe('knownHelpers', () => { +    it('Known helper should render helper', () => { +      expectTemplate('{{hello}}') +        .withCompileOptions({ +          knownHelpers: { hello: true }, +        }) +        .withHelper('hello', function () { +          return 'foo'; +        }) +        .toCompileTo('foo'); +    }); + +    it('Unknown helper in knownHelpers only mode should be passed as undefined', () => { +      expectTemplate('{{typeof hello}}') +        .withCompileOptions({ +          knownHelpers: { typeof: true }, +          knownHelpersOnly: true, +        }) +        .withHelper('typeof', function (arg) { +          return typeof arg; +        }) +        .withHelper('hello', function () { +          return 'foo'; +        }) +        .toCompileTo('undefined'); +    }); + +    it('Builtin helpers available in knownHelpers only mode', () => { +      expectTemplate('{{#unless foo}}bar{{/unless}}') +        .withCompileOptions({ +          knownHelpersOnly: true, +        }) +        .toCompileTo('bar'); +    }); + +    it('Field lookup works in knownHelpers only mode', () => { +      expectTemplate('{{foo}}') +        .withCompileOptions({ +          knownHelpersOnly: true, +        }) +        .withInput({ foo: 'bar' }) +        .toCompileTo('bar'); +    }); + +    it('Conditional blocks work in knownHelpers only mode', () => { +      expectTemplate('{{#foo}}bar{{/foo}}') +        .withCompileOptions({ +          knownHelpersOnly: true, +        }) +        .withInput({ foo: 'baz' }) +        .toCompileTo('bar'); +    }); + +    it('Invert blocks work in knownHelpers only mode', () => { +      expectTemplate('{{^foo}}bar{{/foo}}') +        .withCompileOptions({ +          knownHelpersOnly: true, +        }) +        .withInput({ foo: false }) +        .toCompileTo('bar'); +    }); + +    it('Functions are bound to the context in knownHelpers only mode', () => { +      expectTemplate('{{foo}}') +        .withCompileOptions({ +          knownHelpersOnly: true, +        }) +        .withInput({ +          foo() { +            return this.bar; +          }, +          bar: 'bar', +        }) +        .toCompileTo('bar'); +    }); + +    it('Unknown helper call in knownHelpers only mode should throw', () => { +      expectTemplate('{{typeof hello}}') +        .withCompileOptions({ knownHelpersOnly: true }) +        .toThrow(Error); +    }); +  }); + +  describe('blockHelperMissing', () => { +    it('lambdas are resolved by blockHelperMissing, not handlebars proper', () => { +      expectTemplate('{{#truthy}}yep{{/truthy}}') +        .withInput({ +          truthy() { +            return true; +          }, +        }) +        .toCompileTo('yep'); +    }); + +    it('lambdas resolved by blockHelperMissing are bound to the context', () => { +      expectTemplate('{{#truthy}}yep{{/truthy}}') +        .withInput({ +          truthy() { +            return this.truthiness(); +          }, +          truthiness() { +            return false; +          }, +        }) +        .toCompileTo(''); +    }); +  }); + +  describe('name field', () => { +    const helpers = { +      blockHelperMissing(...args: any[]) { +        return 'missing: ' + args[args.length - 1].name; +      }, +      helperMissing(...args: any[]) { +        return 'helper missing: ' + args[args.length - 1].name; +      }, +      helper(...args: any[]) { +        return 'ran: ' + args[args.length - 1].name; +      }, +    }; + +    it('should include in ambiguous mustache calls', () => { +      expectTemplate('{{helper}}').withHelpers(helpers).toCompileTo('ran: helper'); +    }); + +    it('should include in helper mustache calls', () => { +      expectTemplate('{{helper 1}}').withHelpers(helpers).toCompileTo('ran: helper'); +    }); + +    it('should include in ambiguous block calls', () => { +      expectTemplate('{{#helper}}{{/helper}}').withHelpers(helpers).toCompileTo('ran: helper'); +    }); + +    it('should include in simple block calls', () => { +      expectTemplate('{{#./helper}}{{/./helper}}') +        .withHelpers(helpers) +        .toCompileTo('missing: ./helper'); +    }); + +    it('should include in helper block calls', () => { +      expectTemplate('{{#helper 1}}{{/helper}}').withHelpers(helpers).toCompileTo('ran: helper'); +    }); + +    it('should include in known helper calls', () => { +      expectTemplate('{{helper}}') +        .withCompileOptions({ +          knownHelpers: { helper: true }, +          knownHelpersOnly: true, +        }) +        .withHelpers(helpers) +        .toCompileTo('ran: helper'); +    }); + +    it('should include full id', () => { +      expectTemplate('{{#foo.helper}}{{/foo.helper}}') +        .withInput({ foo: {} }) +        .withHelpers(helpers) +        .toCompileTo('missing: foo.helper'); +    }); + +    it('should include full id if a hash is passed', () => { +      expectTemplate('{{#foo.helper bar=baz}}{{/foo.helper}}') +        .withInput({ foo: {} }) +        .withHelpers(helpers) +        .toCompileTo('helper missing: foo.helper'); +    }); +  }); + +  describe('name conflicts', () => { +    it('helpers take precedence over same-named context properties', () => { +      expectTemplate('{{goodbye}} {{cruel world}}') +        .withHelper('goodbye', function (this: any) { +          return this.goodbye.toUpperCase(); +        }) +        .withHelper('cruel', function (world) { +          return 'cruel ' + world.toUpperCase(); +        }) +        .withInput({ +          goodbye: 'goodbye', +          world: 'world', +        }) +        .toCompileTo('GOODBYE cruel WORLD'); +    }); + +    it('helpers take precedence over same-named context properties$', () => { +      expectTemplate('{{#goodbye}} {{cruel world}}{{/goodbye}}') +        .withHelper('goodbye', function (this: any, options: HelperOptions) { +          return this.goodbye.toUpperCase() + options.fn(this); +        }) +        .withHelper('cruel', function (world) { +          return 'cruel ' + world.toUpperCase(); +        }) +        .withInput({ +          goodbye: 'goodbye', +          world: 'world', +        }) +        .toCompileTo('GOODBYE cruel WORLD'); +    }); + +    it('Scoped names take precedence over helpers', () => { +      expectTemplate('{{this.goodbye}} {{cruel world}} {{cruel this.goodbye}}') +        .withHelper('goodbye', function (this: any) { +          return this.goodbye.toUpperCase(); +        }) +        .withHelper('cruel', function (world) { +          return 'cruel ' + world.toUpperCase(); +        }) +        .withInput({ +          goodbye: 'goodbye', +          world: 'world', +        }) +        .toCompileTo('goodbye cruel WORLD cruel GOODBYE'); +    }); + +    it('Scoped names take precedence over block helpers', () => { +      expectTemplate('{{#goodbye}} {{cruel world}}{{/goodbye}} {{this.goodbye}}') +        .withHelper('goodbye', function (this: any, options: HelperOptions) { +          return this.goodbye.toUpperCase() + options.fn(this); +        }) +        .withHelper('cruel', function (world) { +          return 'cruel ' + world.toUpperCase(); +        }) +        .withInput({ +          goodbye: 'goodbye', +          world: 'world', +        }) +        .toCompileTo('GOODBYE cruel WORLD goodbye'); +    }); +  }); + +  describe('block params', () => { +    it('should take presedence over context values', () => { +      expectTemplate('{{#goodbyes as |value|}}{{value}}{{/goodbyes}}{{value}}') +        .withInput({ value: 'foo' }) +        .withHelper('goodbyes', function (options: HelperOptions) { +          expect(options.fn.blockParams).toEqual(1); +          return options.fn({ value: 'bar' }, { blockParams: [1, 2] }); +        }) +        .toCompileTo('1foo'); +    }); + +    it('should take presedence over helper values', () => { +      expectTemplate('{{#goodbyes as |value|}}{{value}}{{/goodbyes}}{{value}}') +        .withHelper('value', function () { +          return 'foo'; +        }) +        .withHelper('goodbyes', function (options: HelperOptions) { +          expect(options.fn.blockParams).toEqual(1); +          return options.fn({}, { blockParams: [1, 2] }); +        }) +        .toCompileTo('1foo'); +    }); + +    it('should not take presedence over pathed values', () => { +      expectTemplate('{{#goodbyes as |value|}}{{./value}}{{/goodbyes}}{{value}}') +        .withInput({ value: 'bar' }) +        .withHelper('value', function () { +          return 'foo'; +        }) +        .withHelper('goodbyes', function (this: any, options: HelperOptions) { +          expect(options.fn.blockParams).toEqual(1); +          return options.fn(this, { blockParams: [1, 2] }); +        }) +        .toCompileTo('barfoo'); +    }); + +    it('should take presednece over parent block params', () => { +      let value: number; +      expectTemplate( +        '{{#goodbyes as |value|}}{{#goodbyes}}{{value}}{{#goodbyes as |value|}}{{value}}{{/goodbyes}}{{/goodbyes}}{{/goodbyes}}{{value}}', +        { +          beforeEach() { +            value = 1; +          }, +        } +      ) +        .withInput({ value: 'foo' }) +        .withHelper('goodbyes', function (options: HelperOptions) { +          return options.fn( +            { value: 'bar' }, +            { +              blockParams: options.fn.blockParams === 1 ? [value++, value++] : undefined, +            } +          ); +        }) +        .toCompileTo('13foo'); +    }); + +    it('should allow block params on chained helpers', () => { +      expectTemplate('{{#if bar}}{{else goodbyes as |value|}}{{value}}{{/if}}{{value}}') +        .withInput({ value: 'foo' }) +        .withHelper('goodbyes', function (options: HelperOptions) { +          expect(options.fn.blockParams).toEqual(1); +          return options.fn({ value: 'bar' }, { blockParams: [1, 2] }); +        }) +        .toCompileTo('1foo'); +    }); +  }); + +  describe('built-in helpers malformed arguments ', () => { +    it('if helper - too few arguments', () => { +      expectTemplate('{{#if}}{{/if}}').toThrow(/#if requires exactly one argument/); +    }); + +    it('if helper - too many arguments, string', () => { +      expectTemplate('{{#if test "string"}}{{/if}}').toThrow(/#if requires exactly one argument/); +    }); + +    it('if helper - too many arguments, undefined', () => { +      expectTemplate('{{#if test undefined}}{{/if}}').toThrow(/#if requires exactly one argument/); +    }); + +    it('if helper - too many arguments, null', () => { +      expectTemplate('{{#if test null}}{{/if}}').toThrow(/#if requires exactly one argument/); +    }); + +    it('unless helper - too few arguments', () => { +      expectTemplate('{{#unless}}{{/unless}}').toThrow(/#unless requires exactly one argument/); +    }); + +    it('unless helper - too many arguments', () => { +      expectTemplate('{{#unless test null}}{{/unless}}').toThrow( +        /#unless requires exactly one argument/ +      ); +    }); + +    it('with helper - too few arguments', () => { +      expectTemplate('{{#with}}{{/with}}').toThrow(/#with requires exactly one argument/); +    }); + +    it('with helper - too many arguments', () => { +      expectTemplate('{{#with test "string"}}{{/with}}').toThrow( +        /#with requires exactly one argument/ +      ); +    }); +  }); + +  describe('the lookupProperty-option', () => { +    it('should be passed to custom helpers', () => { +      expectTemplate('{{testHelper}}') +        .withHelper('testHelper', function testHelper(this: any, options: HelperOptions) { +          return options.lookupProperty(this, 'testProperty'); +        }) +        .withInput({ testProperty: 'abc' }) +        .toCompileTo('abc'); +    }); +  }); +}); + +function deleteAllKeys(obj: { [key: string]: any }) { +  for (const key of Object.keys(obj)) { +    delete obj[key]; +  } +} diff --git a/dev/lib/handlebars/src/spec/index.partials.test.ts b/dev/lib/handlebars/src/spec/index.partials.test.ts new file mode 100644 index 00000000..65930d06 --- /dev/null +++ b/dev/lib/handlebars/src/spec/index.partials.test.ts @@ -0,0 +1,591 @@ +/* + * This file is forked from the handlebars project (https://github.com/handlebars-lang/handlebars.js), + * and may include modifications made by Elasticsearch B.V. + * Elasticsearch B.V. licenses this file to you under the MIT License. + * See `packages/kbn-handlebars/LICENSE` for more information. + */ + +import Handlebars from '../..'; +import { expectTemplate, forEachCompileFunctionName } from '../__jest__/test_bench'; + +describe('partials', () => { +  it('basic partials', () => { +    const string = 'Dudes: {{#dudes}}{{> dude}}{{/dudes}}'; +    const partial = '{{name}} ({{url}}) '; +    const hash = { +      dudes: [ +        { name: 'Yehuda', url: 'http://yehuda' }, +        { name: 'Alan', url: 'http://alan' }, +      ], +    }; + +    expectTemplate(string) +      .withInput(hash) +      .withPartials({ dude: partial }) +      .toCompileTo('Dudes: Yehuda (http://yehuda) Alan (http://alan) '); + +    expectTemplate(string) +      .withInput(hash) +      .withPartials({ dude: partial }) +      .withRuntimeOptions({ data: false }) +      .withCompileOptions({ data: false }) +      .toCompileTo('Dudes: Yehuda (http://yehuda) Alan (http://alan) '); +  }); + +  it('dynamic partials', () => { +    const string = 'Dudes: {{#dudes}}{{> (partial)}}{{/dudes}}'; +    const partial = '{{name}} ({{url}}) '; +    const hash = { +      dudes: [ +        { name: 'Yehuda', url: 'http://yehuda' }, +        { name: 'Alan', url: 'http://alan' }, +      ], +    }; +    const helpers = { +      partial: () => 'dude', +    }; + +    expectTemplate(string) +      .withInput(hash) +      .withHelpers(helpers) +      .withPartials({ dude: partial }) +      .toCompileTo('Dudes: Yehuda (http://yehuda) Alan (http://alan) '); + +    expectTemplate(string) +      .withInput(hash) +      .withHelpers(helpers) +      .withPartials({ dude: partial }) +      .withRuntimeOptions({ data: false }) +      .withCompileOptions({ data: false }) +      .toCompileTo('Dudes: Yehuda (http://yehuda) Alan (http://alan) '); +  }); + +  it('failing dynamic partials', () => { +    expectTemplate('Dudes: {{#dudes}}{{> (partial)}}{{/dudes}}') +      .withInput({ +        dudes: [ +          { name: 'Yehuda', url: 'http://yehuda' }, +          { name: 'Alan', url: 'http://alan' }, +        ], +      }) +      .withHelper('partial', () => 'missing') +      .withPartial('dude', '{{name}} ({{url}}) ') +      .toThrow('The partial missing could not be found'); // TODO: Is there a way we can test that the error is of type `Handlebars.Exception`? +  }); + +  it('partials with context', () => { +    expectTemplate('Dudes: {{>dude dudes}}') +      .withInput({ +        dudes: [ +          { name: 'Yehuda', url: 'http://yehuda' }, +          { name: 'Alan', url: 'http://alan' }, +        ], +      }) +      .withPartial('dude', '{{#this}}{{name}} ({{url}}) {{/this}}') +      .toCompileTo('Dudes: Yehuda (http://yehuda) Alan (http://alan) '); +  }); + +  it('partials with no context', () => { +    const partial = '{{name}} ({{url}}) '; +    const hash = { +      dudes: [ +        { name: 'Yehuda', url: 'http://yehuda' }, +        { name: 'Alan', url: 'http://alan' }, +      ], +    }; + +    expectTemplate('Dudes: {{#dudes}}{{>dude}}{{/dudes}}') +      .withInput(hash) +      .withPartial('dude', partial) +      .withCompileOptions({ explicitPartialContext: true }) +      .toCompileTo('Dudes:  ()  () '); + +    expectTemplate('Dudes: {{#dudes}}{{>dude name="foo"}}{{/dudes}}') +      .withInput(hash) +      .withPartial('dude', partial) +      .withCompileOptions({ explicitPartialContext: true }) +      .toCompileTo('Dudes: foo () foo () '); +  }); + +  it('partials with string context', () => { +    expectTemplate('Dudes: {{>dude "dudes"}}') +      .withPartial('dude', '{{.}}') +      .toCompileTo('Dudes: dudes'); +  }); + +  it('partials with undefined context', () => { +    expectTemplate('Dudes: {{>dude dudes}}') +      .withPartial('dude', '{{foo}} Empty') +      .toCompileTo('Dudes:  Empty'); +  }); + +  it('partials with duplicate parameters', () => { +    expectTemplate('Dudes: {{>dude dudes foo bar=baz}}').toThrow( +      'Unsupported number of partial arguments: 2 - 1:7' +    ); +  }); + +  it('partials with parameters', () => { +    expectTemplate('Dudes: {{#dudes}}{{> dude others=..}}{{/dudes}}') +      .withInput({ +        foo: 'bar', +        dudes: [ +          { name: 'Yehuda', url: 'http://yehuda' }, +          { name: 'Alan', url: 'http://alan' }, +        ], +      }) +      .withPartial('dude', '{{others.foo}}{{name}} ({{url}}) ') +      .toCompileTo('Dudes: barYehuda (http://yehuda) barAlan (http://alan) '); +  }); + +  it('partial in a partial', () => { +    expectTemplate('Dudes: {{#dudes}}{{>dude}}{{/dudes}}') +      .withInput({ +        dudes: [ +          { name: 'Yehuda', url: 'http://yehuda' }, +          { name: 'Alan', url: 'http://alan' }, +        ], +      }) +      .withPartials({ +        dude: '{{name}} {{> url}} ', +        url: '<a href="{{url}}">{{url}}</a>', +      }) +      .toCompileTo( +        'Dudes: Yehuda <a href="http://yehuda">http://yehuda</a> Alan <a href="http://alan">http://alan</a> ' +      ); +  }); + +  it('rendering undefined partial throws an exception', () => { +    expectTemplate('{{> whatever}}').toThrow('The partial whatever could not be found'); +  }); + +  it('registering undefined partial throws an exception', () => { +    global.kbnHandlebarsEnv = Handlebars.create(); + +    expect(() => { +      kbnHandlebarsEnv!.registerPartial('undefined_test', undefined as any); +    }).toThrow('Attempting to register a partial called "undefined_test" as undefined'); + +    global.kbnHandlebarsEnv = null; +  }); + +  it('rendering template partial in vm mode throws an exception', () => { +    expectTemplate('{{> whatever}}').toThrow('The partial whatever could not be found'); +  }); + +  it('rendering function partial in vm mode', () => { +    function partial(context: any) { +      return context.name + ' (' + context.url + ') '; +    } +    expectTemplate('Dudes: {{#dudes}}{{> dude}}{{/dudes}}') +      .withInput({ +        dudes: [ +          { name: 'Yehuda', url: 'http://yehuda' }, +          { name: 'Alan', url: 'http://alan' }, +        ], +      }) +      .withPartial('dude', partial) +      .toCompileTo('Dudes: Yehuda (http://yehuda) Alan (http://alan) '); +  }); + +  it('GH-14: a partial preceding a selector', () => { +    expectTemplate('Dudes: {{>dude}} {{anotherDude}}') +      .withInput({ name: 'Jeepers', anotherDude: 'Creepers' }) +      .withPartial('dude', '{{name}}') +      .toCompileTo('Dudes: Jeepers Creepers'); +  }); + +  it('Partials with slash paths', () => { +    expectTemplate('Dudes: {{> shared/dude}}') +      .withInput({ name: 'Jeepers', anotherDude: 'Creepers' }) +      .withPartial('shared/dude', '{{name}}') +      .toCompileTo('Dudes: Jeepers'); +  }); + +  it('Partials with slash and point paths', () => { +    expectTemplate('Dudes: {{> shared/dude.thing}}') +      .withInput({ name: 'Jeepers', anotherDude: 'Creepers' }) +      .withPartial('shared/dude.thing', '{{name}}') +      .toCompileTo('Dudes: Jeepers'); +  }); + +  it('Global Partials', () => { +    global.kbnHandlebarsEnv = Handlebars.create(); + +    kbnHandlebarsEnv!.registerPartial('globalTest', '{{anotherDude}}'); + +    expectTemplate('Dudes: {{> shared/dude}} {{> globalTest}}') +      .withInput({ name: 'Jeepers', anotherDude: 'Creepers' }) +      .withPartial('shared/dude', '{{name}}') +      .toCompileTo('Dudes: Jeepers Creepers'); + +    kbnHandlebarsEnv!.unregisterPartial('globalTest'); +    expect(kbnHandlebarsEnv!.partials.globalTest).toBeUndefined(); + +    global.kbnHandlebarsEnv = null; +  }); + +  it('Multiple partial registration', () => { +    global.kbnHandlebarsEnv = Handlebars.create(); + +    kbnHandlebarsEnv!.registerPartial({ +      'shared/dude': '{{name}}', +      globalTest: '{{anotherDude}}', +    }); + +    expectTemplate('Dudes: {{> shared/dude}} {{> globalTest}}') +      .withInput({ name: 'Jeepers', anotherDude: 'Creepers' }) +      .withPartial('notused', 'notused') // trick the test bench into running with partials enabled +      .toCompileTo('Dudes: Jeepers Creepers'); + +    global.kbnHandlebarsEnv = null; +  }); + +  it('Partials with integer path', () => { +    expectTemplate('Dudes: {{> 404}}') +      .withInput({ name: 'Jeepers', anotherDude: 'Creepers' }) +      .withPartial(404, '{{name}}') +      .toCompileTo('Dudes: Jeepers'); +  }); + +  it('Partials with complex path', () => { +    expectTemplate('Dudes: {{> 404/asdf?.bar}}') +      .withInput({ name: 'Jeepers', anotherDude: 'Creepers' }) +      .withPartial('404/asdf?.bar', '{{name}}') +      .toCompileTo('Dudes: Jeepers'); +  }); + +  it('Partials with escaped', () => { +    expectTemplate('Dudes: {{> [+404/asdf?.bar]}}') +      .withInput({ name: 'Jeepers', anotherDude: 'Creepers' }) +      .withPartial('+404/asdf?.bar', '{{name}}') +      .toCompileTo('Dudes: Jeepers'); +  }); + +  it('Partials with string', () => { +    expectTemplate("Dudes: {{> '+404/asdf?.bar'}}") +      .withInput({ name: 'Jeepers', anotherDude: 'Creepers' }) +      .withPartial('+404/asdf?.bar', '{{name}}') +      .toCompileTo('Dudes: Jeepers'); +  }); + +  it('should handle empty partial', () => { +    expectTemplate('Dudes: {{#dudes}}{{> dude}}{{/dudes}}') +      .withInput({ +        dudes: [ +          { name: 'Yehuda', url: 'http://yehuda' }, +          { name: 'Alan', url: 'http://alan' }, +        ], +      }) +      .withPartial('dude', '') +      .toCompileTo('Dudes: '); +  }); + +  // Skipping test as this only makes sense when there's no `compile` function (i.e. runtime-only mode). +  // We do not support that mode with `@kbn/handlebars`, so there's no need to test it +  it.skip('throw on missing partial', () => { +    const handlebars = Handlebars.create(); +    (handlebars.compile as any) = undefined; +    const template = handlebars.precompile('{{> dude}}'); +    const render = handlebars.template(eval('(' + template + ')')); // eslint-disable-line no-eval +    expect(() => { +      render( +        {}, +        { +          partials: { +            dude: 'fail', +          }, +        } +      ); +    }).toThrow(/The partial dude could not be compiled/); +  }); + +  describe('partial blocks', () => { +    it('should render partial block as default', () => { +      expectTemplate('{{#> dude}}success{{/dude}}').toCompileTo('success'); +    }); + +    it('should execute default block with proper context', () => { +      expectTemplate('{{#> dude context}}{{value}}{{/dude}}') +        .withInput({ context: { value: 'success' } }) +        .toCompileTo('success'); +    }); + +    it('should propagate block parameters to default block', () => { +      expectTemplate('{{#with context as |me|}}{{#> dude}}{{me.value}}{{/dude}}{{/with}}') +        .withInput({ context: { value: 'success' } }) +        .toCompileTo('success'); +    }); + +    it('should not use partial block if partial exists', () => { +      expectTemplate('{{#> dude}}fail{{/dude}}') +        .withPartials({ dude: 'success' }) +        .toCompileTo('success'); +    }); + +    it('should render block from partial', () => { +      expectTemplate('{{#> dude}}success{{/dude}}') +        .withPartials({ dude: '{{> @partial-block }}' }) +        .toCompileTo('success'); +    }); + +    it('should be able to render the partial-block twice', () => { +      expectTemplate('{{#> dude}}success{{/dude}}') +        .withPartials({ dude: '{{> @partial-block }} {{> @partial-block }}' }) +        .toCompileTo('success success'); +    }); + +    it('should render block from partial with context', () => { +      expectTemplate('{{#> dude}}{{value}}{{/dude}}') +        .withInput({ context: { value: 'success' } }) +        .withPartials({ +          dude: '{{#with context}}{{> @partial-block }}{{/with}}', +        }) +        .toCompileTo('success'); +    }); + +    it('should be able to access the @data frame from a partial-block', () => { +      expectTemplate('{{#> dude}}in-block: {{@root/value}}{{/dude}}') +        .withInput({ value: 'success' }) +        .withPartials({ +          dude: '<code>before-block: {{@root/value}} {{>   @partial-block }}</code>', +        }) +        .toCompileTo('<code>before-block: success in-block: success</code>'); +    }); + +    it('should allow the #each-helper to be used along with partial-blocks', () => { +      expectTemplate('<template>{{#> list value}}value = {{.}}{{/list}}</template>') +        .withInput({ +          value: ['a', 'b', 'c'], +        }) +        .withPartials({ +          list: '<list>{{#each .}}<item>{{> @partial-block}}</item>{{/each}}</list>', +        }) +        .toCompileTo( +          '<template><list><item>value = a</item><item>value = b</item><item>value = c</item></list></template>' +        ); +    }); + +    it('should render block from partial with context (twice)', () => { +      expectTemplate('{{#> dude}}{{value}}{{/dude}}') +        .withInput({ context: { value: 'success' } }) +        .withPartials({ +          dude: '{{#with context}}{{> @partial-block }} {{> @partial-block }}{{/with}}', +        }) +        .toCompileTo('success success'); +    }); + +    it('should render block from partial with context [2]', () => { +      expectTemplate('{{#> dude}}{{../context/value}}{{/dude}}') +        .withInput({ context: { value: 'success' } }) +        .withPartials({ +          dude: '{{#with context}}{{> @partial-block }}{{/with}}', +        }) +        .toCompileTo('success'); +    }); + +    it('should render block from partial with block params', () => { +      expectTemplate('{{#with context as |me|}}{{#> dude}}{{me.value}}{{/dude}}{{/with}}') +        .withInput({ context: { value: 'success' } }) +        .withPartials({ dude: '{{> @partial-block }}' }) +        .toCompileTo('success'); +    }); + +    it('should render nested partial blocks', () => { +      expectTemplate('<template>{{#> outer}}{{value}}{{/outer}}</template>') +        .withInput({ value: 'success' }) +        .withPartials({ +          outer: +            '<outer>{{#> nested}}<outer-block>{{> @partial-block}}</outer-block>{{/nested}}</outer>', +          nested: '<nested>{{> @partial-block}}</nested>', +        }) +        .toCompileTo( +          '<template><outer><nested><outer-block>success</outer-block></nested></outer></template>' +        ); +    }); + +    it('should render nested partial blocks at different nesting levels', () => { +      expectTemplate('<template>{{#> outer}}{{value}}{{/outer}}</template>') +        .withInput({ value: 'success' }) +        .withPartials({ +          outer: +            '<outer>{{#> nested}}<outer-block>{{> @partial-block}}</outer-block>{{/nested}}{{> @partial-block}}</outer>', +          nested: '<nested>{{> @partial-block}}</nested>', +        }) +        .toCompileTo( +          '<template><outer><nested><outer-block>success</outer-block></nested>success</outer></template>' +        ); +    }); + +    it('should render nested partial blocks at different nesting levels (twice)', () => { +      expectTemplate('<template>{{#> outer}}{{value}}{{/outer}}</template>') +        .withInput({ value: 'success' }) +        .withPartials({ +          outer: +            '<outer>{{#> nested}}<outer-block>{{> @partial-block}} {{> @partial-block}}</outer-block>{{/nested}}{{> @partial-block}}+{{> @partial-block}}</outer>', +          nested: '<nested>{{> @partial-block}}</nested>', +        }) +        .toCompileTo( +          '<template><outer><nested><outer-block>success success</outer-block></nested>success+success</outer></template>' +        ); +    }); + +    it('should render nested partial blocks (twice at each level)', () => { +      expectTemplate('<template>{{#> outer}}{{value}}{{/outer}}</template>') +        .withInput({ value: 'success' }) +        .withPartials({ +          outer: +            '<outer>{{#> nested}}<outer-block>{{> @partial-block}} {{> @partial-block}}</outer-block>{{/nested}}</outer>', +          nested: '<nested>{{> @partial-block}}{{> @partial-block}}</nested>', +        }) +        .toCompileTo( +          '<template><outer>' + +            '<nested><outer-block>success success</outer-block><outer-block>success success</outer-block></nested>' + +            '</outer></template>' +        ); +    }); +  }); + +  describe('inline partials', () => { +    it('should define inline partials for template', () => { +      expectTemplate('{{#*inline "myPartial"}}success{{/inline}}{{> myPartial}}').toCompileTo( +        'success' +      ); +    }); + +    it('should overwrite multiple partials in the same template', () => { +      expectTemplate( +        '{{#*inline "myPartial"}}fail{{/inline}}{{#*inline "myPartial"}}success{{/inline}}{{> myPartial}}' +      ).toCompileTo('success'); +    }); + +    it('should define inline partials for block', () => { +      expectTemplate( +        '{{#with .}}{{#*inline "myPartial"}}success{{/inline}}{{> myPartial}}{{/with}}' +      ).toCompileTo('success'); + +      expectTemplate( +        '{{#with .}}{{#*inline "myPartial"}}success{{/inline}}{{/with}}{{> myPartial}}' +      ).toThrow(/myPartial could not/); +    }); + +    it('should override global partials', () => { +      expectTemplate('{{#*inline "myPartial"}}success{{/inline}}{{> myPartial}}') +        .withPartials({ +          myPartial: () => 'fail', +        }) +        .toCompileTo('success'); +    }); + +    it('should override template partials', () => { +      expectTemplate( +        '{{#*inline "myPartial"}}fail{{/inline}}{{#with .}}{{#*inline "myPartial"}}success{{/inline}}{{> myPartial}}{{/with}}' +      ).toCompileTo('success'); +    }); + +    it('should override partials down the entire stack', () => { +      expectTemplate( +        '{{#with .}}{{#*inline "myPartial"}}success{{/inline}}{{#with .}}{{#with .}}{{> myPartial}}{{/with}}{{/with}}{{/with}}' +      ).toCompileTo('success'); +    }); + +    it('should define inline partials for partial call', () => { +      expectTemplate('{{#*inline "myPartial"}}success{{/inline}}{{> dude}}') +        .withPartials({ dude: '{{> myPartial }}' }) +        .toCompileTo('success'); +    }); + +    it('should define inline partials in partial block call', () => { +      expectTemplate('{{#> dude}}{{#*inline "myPartial"}}success{{/inline}}{{/dude}}') +        .withPartials({ dude: '{{> myPartial }}' }) +        .toCompileTo('success'); +    }); + +    it('should render nested inline partials', () => { +      expectTemplate( +        '{{#*inline "outer"}}{{#>inner}}<outer-block>{{>@partial-block}}</outer-block>{{/inner}}{{/inline}}' + +          '{{#*inline "inner"}}<inner>{{>@partial-block}}</inner>{{/inline}}' + +          '{{#>outer}}{{value}}{{/outer}}' +      ) +        .withInput({ value: 'success' }) +        .toCompileTo('<inner><outer-block>success</outer-block></inner>'); +    }); + +    it('should render nested inline partials with partial-blocks on different nesting levels', () => { +      expectTemplate( +        '{{#*inline "outer"}}{{#>inner}}<outer-block>{{>@partial-block}}</outer-block>{{/inner}}{{>@partial-block}}{{/inline}}' + +          '{{#*inline "inner"}}<inner>{{>@partial-block}}</inner>{{/inline}}' + +          '{{#>outer}}{{value}}{{/outer}}' +      ) +        .withInput({ value: 'success' }) +        .toCompileTo('<inner><outer-block>success</outer-block></inner>success'); +    }); + +    it('should render nested inline partials (twice at each level)', () => { +      expectTemplate( +        '{{#*inline "outer"}}{{#>inner}}<outer-block>{{>@partial-block}} {{>@partial-block}}</outer-block>{{/inner}}{{/inline}}' + +          '{{#*inline "inner"}}<inner>{{>@partial-block}}{{>@partial-block}}</inner>{{/inline}}' + +          '{{#>outer}}{{value}}{{/outer}}' +      ) +        .withInput({ value: 'success' }) +        .toCompileTo( +          '<inner><outer-block>success success</outer-block><outer-block>success success</outer-block></inner>' +        ); +    }); +  }); + +  forEachCompileFunctionName((compileName) => { +    it(`should pass compiler flags for ${compileName} function`, () => { +      const env = Handlebars.create(); +      env.registerPartial('partial', '{{foo}}'); +      const compile = env[compileName].bind(env); +      const template = compile('{{foo}} {{> partial}}', { noEscape: true }); +      expect(template({ foo: '<' })).toEqual('< <'); +    }); +  }); + +  describe('standalone partials', () => { +    it('indented partials', () => { +      expectTemplate('Dudes:\n{{#dudes}}\n  {{>dude}}\n{{/dudes}}') +        .withInput({ +          dudes: [ +            { name: 'Yehuda', url: 'http://yehuda' }, +            { name: 'Alan', url: 'http://alan' }, +          ], +        }) +        .withPartial('dude', '{{name}}\n') +        .toCompileTo('Dudes:\n  Yehuda\n  Alan\n'); +    }); + +    it('nested indented partials', () => { +      expectTemplate('Dudes:\n{{#dudes}}\n  {{>dude}}\n{{/dudes}}') +        .withInput({ +          dudes: [ +            { name: 'Yehuda', url: 'http://yehuda' }, +            { name: 'Alan', url: 'http://alan' }, +          ], +        }) +        .withPartials({ +          dude: '{{name}}\n {{> url}}', +          url: '{{url}}!\n', +        }) +        .toCompileTo('Dudes:\n  Yehuda\n   http://yehuda!\n  Alan\n   http://alan!\n'); +    }); + +    it('prevent nested indented partials', () => { +      expectTemplate('Dudes:\n{{#dudes}}\n  {{>dude}}\n{{/dudes}}') +        .withInput({ +          dudes: [ +            { name: 'Yehuda', url: 'http://yehuda' }, +            { name: 'Alan', url: 'http://alan' }, +          ], +        }) +        .withPartials({ +          dude: '{{name}}\n {{> url}}', +          url: '{{url}}!\n', +        }) +        .withCompileOptions({ preventIndent: true }) +        .toCompileTo('Dudes:\n  Yehuda\n http://yehuda!\n  Alan\n http://alan!\n'); +    }); +  }); +}); diff --git a/dev/lib/handlebars/src/spec/index.regressions.test.ts b/dev/lib/handlebars/src/spec/index.regressions.test.ts new file mode 100644 index 00000000..fc2065fe --- /dev/null +++ b/dev/lib/handlebars/src/spec/index.regressions.test.ts @@ -0,0 +1,379 @@ +/* + * This file is forked from the handlebars project (https://github.com/handlebars-lang/handlebars.js), + * and may include modifications made by Elasticsearch B.V. + * Elasticsearch B.V. licenses this file to you under the MIT License. + * See `packages/kbn-handlebars/LICENSE` for more information. + */ + +import Handlebars, { type HelperOptions } from '../..'; +import { expectTemplate, forEachCompileFunctionName } from '../__jest__/test_bench'; + +describe('Regressions', () => { +  it('GH-94: Cannot read property of undefined', () => { +    expectTemplate('{{#books}}{{title}}{{author.name}}{{/books}}') +      .withInput({ +        books: [ +          { +            title: 'The origin of species', +            author: { +              name: 'Charles Darwin', +            }, +          }, +          { +            title: 'Lazarillo de Tormes', +          }, +        ], +      }) +      .toCompileTo('The origin of speciesCharles DarwinLazarillo de Tormes'); +  }); + +  it("GH-150: Inverted sections print when they shouldn't", () => { +    const string = '{{^set}}not set{{/set}} :: {{#set}}set{{/set}}'; +    expectTemplate(string).toCompileTo('not set :: '); +    expectTemplate(string).withInput({ set: undefined }).toCompileTo('not set :: '); +    expectTemplate(string).withInput({ set: false }).toCompileTo('not set :: '); +    expectTemplate(string).withInput({ set: true }).toCompileTo(' :: set'); +  }); + +  it('GH-158: Using array index twice, breaks the template', () => { +    expectTemplate('{{arr.[0]}}, {{arr.[1]}}') +      .withInput({ arr: [1, 2] }) +      .toCompileTo('1, 2'); +  }); + +  it("bug reported by @fat where lambdas weren't being properly resolved", () => { +    const string = +      '<strong>This is a slightly more complicated {{thing}}.</strong>.\n' + +      '{{! Just ignore this business. }}\n' + +      'Check this out:\n' + +      '{{#hasThings}}\n' + +      '<ul>\n' + +      '{{#things}}\n' + +      '<li class={{className}}>{{word}}</li>\n' + +      '{{/things}}</ul>.\n' + +      '{{/hasThings}}\n' + +      '{{^hasThings}}\n' + +      '\n' + +      '<small>Nothing to check out...</small>\n' + +      '{{/hasThings}}'; + +    const data = { +      thing() { +        return 'blah'; +      }, +      things: [ +        { className: 'one', word: '@fat' }, +        { className: 'two', word: '@dhg' }, +        { className: 'three', word: '@sayrer' }, +      ], +      hasThings() { +        return true; +      }, +    }; + +    const output = +      '<strong>This is a slightly more complicated blah.</strong>.\n' + +      'Check this out:\n' + +      '<ul>\n' + +      '<li class=one>@fat</li>\n' + +      '<li class=two>@dhg</li>\n' + +      '<li class=three>@sayrer</li>\n' + +      '</ul>.\n'; + +    expectTemplate(string).withInput(data).toCompileTo(output); +  }); + +  it('GH-408: Multiple loops fail', () => { +    expectTemplate('{{#.}}{{name}}{{/.}}{{#.}}{{name}}{{/.}}{{#.}}{{name}}{{/.}}') +      .withInput([ +        { name: 'John Doe', location: { city: 'Chicago' } }, +        { name: 'Jane Doe', location: { city: 'New York' } }, +      ]) +      .toCompileTo('John DoeJane DoeJohn DoeJane DoeJohn DoeJane Doe'); +  }); + +  it('GS-428: Nested if else rendering', () => { +    const succeedingTemplate = +      '{{#inverse}} {{#blk}} Unexpected {{/blk}} {{else}}  {{#blk}} Expected {{/blk}} {{/inverse}}'; +    const failingTemplate = +      '{{#inverse}} {{#blk}} Unexpected {{/blk}} {{else}} {{#blk}} Expected {{/blk}} {{/inverse}}'; + +    const helpers = { +      blk(block: HelperOptions) { +        return block.fn(''); +      }, +      inverse(block: HelperOptions) { +        return block.inverse(''); +      }, +    }; + +    expectTemplate(succeedingTemplate).withHelpers(helpers).toCompileTo('   Expected  '); +    expectTemplate(failingTemplate).withHelpers(helpers).toCompileTo('  Expected  '); +  }); + +  it('GH-458: Scoped this identifier', () => { +    expectTemplate('{{./foo}}').withInput({ foo: 'bar' }).toCompileTo('bar'); +  }); + +  it('GH-375: Unicode line terminators', () => { +    expectTemplate('\u2028').toCompileTo('\u2028'); +  }); + +  it('GH-534: Object prototype aliases', () => { +    /* eslint-disable no-extend-native */ +    // @ts-expect-error +    Object.prototype[0xd834] = true; + +    expectTemplate('{{foo}}').withInput({ foo: 'bar' }).toCompileTo('bar'); + +    // @ts-expect-error +    delete Object.prototype[0xd834]; +    /* eslint-enable no-extend-native */ +  }); + +  it('GH-437: Matching escaping', () => { +    expectTemplate('{{{a}}').toThrow(/Parse error on/); +    expectTemplate('{{a}}}').toThrow(/Parse error on/); +  }); + +  it('GH-676: Using array in escaping mustache fails', () => { +    const data = { arr: [1, 2] }; +    expectTemplate('{{arr}}').withInput(data).toCompileTo(data.arr.toString()); +  }); + +  it('Mustache man page', () => { +    expectTemplate( +      'Hello {{name}}. You have just won ${{value}}!{{#in_ca}} Well, ${{taxed_value}}, after taxes.{{/in_ca}}' +    ) +      .withInput({ +        name: 'Chris', +        value: 10000, +        taxed_value: 10000 - 10000 * 0.4, +        in_ca: true, +      }) +      .toCompileTo('Hello Chris. You have just won $10000! Well, $6000, after taxes.'); +  }); + +  it('GH-731: zero context rendering', () => { +    expectTemplate('{{#foo}} This is {{bar}} ~ {{/foo}}') +      .withInput({ +        foo: 0, +        bar: 'OK', +      }) +      .toCompileTo(' This is  ~ '); +  }); + +  it('GH-820: zero pathed rendering', () => { +    expectTemplate('{{foo.bar}}').withInput({ foo: 0 }).toCompileTo(''); +  }); + +  it('GH-837: undefined values for helpers', () => { +    expectTemplate('{{str bar.baz}}') +      .withHelpers({ +        str(value) { +          return value + ''; +        }, +      }) +      .toCompileTo('undefined'); +  }); + +  it('GH-926: Depths and de-dupe', () => { +    expectTemplate( +      '{{#if dater}}{{#each data}}{{../name}}{{/each}}{{else}}{{#each notData}}{{../name}}{{/each}}{{/if}}' +    ) +      .withInput({ +        name: 'foo', +        data: [1], +        notData: [1], +      }) +      .toCompileTo('foo'); +  }); + +  it('GH-1021: Each empty string key', () => { +    expectTemplate('{{#each data}}Key: {{@key}}\n{{/each}}') +      .withInput({ +        data: { +          '': 'foo', +          name: 'Chris', +          value: 10000, +        }, +      }) +      .toCompileTo('Key: \nKey: name\nKey: value\n'); +  }); + +  it('GH-1054: Should handle simple safe string responses', () => { +    expectTemplate('{{#wrap}}{{>partial}}{{/wrap}}') +      .withHelpers({ +        wrap(options: HelperOptions) { +          return new Handlebars.SafeString(options.fn()); +        }, +      }) +      .withPartials({ +        partial: '{{#wrap}}<partial>{{/wrap}}', +      }) +      .toCompileTo('<partial>'); +  }); + +  it('GH-1065: Sparse arrays', () => { +    const array = []; +    array[1] = 'foo'; +    array[3] = 'bar'; +    expectTemplate('{{#each array}}{{@index}}{{.}}{{/each}}') +      .withInput({ array }) +      .toCompileTo('1foo3bar'); +  }); + +  it('GH-1093: Undefined helper context', () => { +    expectTemplate('{{#each obj}}{{{helper}}}{{.}}{{/each}}') +      .withInput({ obj: { foo: undefined, bar: 'bat' } }) +      .withHelpers({ +        helper(this: any) { +          // It's valid to execute a block against an undefined context, but +          // helpers can not do so, so we expect to have an empty object here; +          for (const name in this) { +            if (Object.prototype.hasOwnProperty.call(this, name)) { +              return 'found'; +            } +          } +          // And to make IE happy, check for the known string as length is not enumerated. +          return this === 'bat' ? 'found' : 'not'; +        }, +      }) +      .toCompileTo('notfoundbat'); +  }); + +  it('should support multiple levels of inline partials', () => { +    expectTemplate('{{#> layout}}{{#*inline "subcontent"}}subcontent{{/inline}}{{/layout}}') +      .withPartials({ +        doctype: 'doctype{{> content}}', +        layout: '{{#> doctype}}{{#*inline "content"}}layout{{> subcontent}}{{/inline}}{{/doctype}}', +      }) +      .toCompileTo('doctypelayoutsubcontent'); +  }); + +  it('GH-1089: should support failover content in multiple levels of inline partials', () => { +    expectTemplate('{{#> layout}}{{/layout}}') +      .withPartials({ +        doctype: 'doctype{{> content}}', +        layout: +          '{{#> doctype}}{{#*inline "content"}}layout{{#> subcontent}}subcontent{{/subcontent}}{{/inline}}{{/doctype}}', +      }) +      .toCompileTo('doctypelayoutsubcontent'); +  }); + +  it('GH-1099: should support greater than 3 nested levels of inline partials', () => { +    expectTemplate('{{#> layout}}Outer{{/layout}}') +      .withPartials({ +        layout: '{{#> inner}}Inner{{/inner}}{{> @partial-block }}', +        inner: '', +      }) +      .toCompileTo('Outer'); +  }); + +  it('GH-1135 : Context handling within each iteration', () => { +    expectTemplate( +      '{{#each array}}\n' + +        ' 1. IF: {{#if true}}{{../name}}-{{../../name}}-{{../../../name}}{{/if}}\n' + +        ' 2. MYIF: {{#myif true}}{{../name}}={{../../name}}={{../../../name}}{{/myif}}\n' + +        '{{/each}}' +    ) +      .withInput({ array: [1], name: 'John' }) +      .withHelpers({ +        myif(conditional, options: HelperOptions) { +          if (conditional) { +            return options.fn(this); +          } else { +            return options.inverse(this); +          } +        }, +      }) +      .toCompileTo(' 1. IF: John--\n' + ' 2. MYIF: John==\n'); +  }); + +  it('GH-1186: Support block params for existing programs', () => { +    expectTemplate( +      '{{#*inline "test"}}{{> @partial-block }}{{/inline}}' + +        '{{#>test }}{{#each listOne as |item|}}{{ item }}{{/each}}{{/test}}' + +        '{{#>test }}{{#each listTwo as |item|}}{{ item }}{{/each}}{{/test}}' +    ) +      .withInput({ +        listOne: ['a'], +        listTwo: ['b'], +      }) +      .toCompileTo('ab'); +  }); + +  it('GH-1319: "unless" breaks when "each" value equals "null"', () => { +    expectTemplate('{{#each list}}{{#unless ./prop}}parent={{../value}} {{/unless}}{{/each}}') +      .withInput({ +        value: 'parent', +        list: [null, 'a'], +      }) +      .toCompileTo('parent=parent parent=parent '); +  }); + +  it('GH-1341: 4.0.7 release breaks {{#if @partial-block}} usage', () => { +    expectTemplate('template {{>partial}} template') +      .withPartials({ +        partialWithBlock: '{{#if @partial-block}} block {{> @partial-block}} block {{/if}}', +        partial: '{{#> partialWithBlock}} partial {{/partialWithBlock}}', +      }) +      .toCompileTo('template  block  partial  block  template'); +  }); + +  it('should allow hash with protected array names', () => { +    expectTemplate('{{helpa length="foo"}}') +      .withInput({ array: [1], name: 'John' }) +      .withHelpers({ +        helpa(options: HelperOptions) { +          return options.hash.length; +        }, +      }) +      .toCompileTo('foo'); +  }); + +  describe('GH-1598: Performance degradation for partials since v4.3.0', () => { +    let newHandlebarsInstance: typeof Handlebars; +    let spy: jest.SpyInstance; +    beforeEach(() => { +      newHandlebarsInstance = Handlebars.create(); +    }); +    afterEach(() => { +      spy.mockRestore(); +    }); + +    forEachCompileFunctionName((compileName) => { +      it(`should only compile global partials once when calling #${compileName}`, () => { +        const compile = newHandlebarsInstance[compileName].bind(newHandlebarsInstance); +        let calls; +        switch (compileName) { +          case 'compile': +            spy = jest.spyOn(newHandlebarsInstance, 'template'); +            calls = 3; +            break; +          case 'compileAST': +            spy = jest.spyOn(newHandlebarsInstance, 'compileAST'); +            calls = 2; +            break; +        } +        newHandlebarsInstance.registerPartial({ +          dude: 'I am a partial', +        }); +        const string = 'Dudes: {{> dude}} {{> dude}}'; +        compile(string)(); // This should compile template + partial once +        compile(string)(); // This should only compile template +        expect(spy).toHaveBeenCalledTimes(calls); +        spy.mockRestore(); +      }); +    }); +  }); + +  describe("GH-1639: TypeError: Cannot read property 'apply' of undefined\" when handlebars version > 4.6.0 (undocumented, deprecated usage)", () => { +    it('should treat undefined helpers like non-existing helpers', () => { +      expectTemplate('{{foo}}') +        .withHelper('foo', undefined as any) +        .withInput({ foo: 'bar' }) +        .toCompileTo('bar'); +    }); +  }); +}); diff --git a/dev/lib/handlebars/src/spec/index.security.test.ts b/dev/lib/handlebars/src/spec/index.security.test.ts new file mode 100644 index 00000000..878a0931 --- /dev/null +++ b/dev/lib/handlebars/src/spec/index.security.test.ts @@ -0,0 +1,132 @@ +/* + * This file is forked from the handlebars project (https://github.com/handlebars-lang/handlebars.js), + * and may include modifications made by Elasticsearch B.V. + * Elasticsearch B.V. licenses this file to you under the MIT License. + * See `packages/kbn-handlebars/LICENSE` for more information. + */ + +import Handlebars from '../..'; +import { expectTemplate } from '../__jest__/test_bench'; + +describe('security issues', () => { +  describe('GH-1495: Prevent Remote Code Execution via constructor', () => { +    it('should not allow constructors to be accessed', () => { +      expectTemplate('{{lookup (lookup this "constructor") "name"}}').withInput({}).toCompileTo(''); +      expectTemplate('{{constructor.name}}').withInput({}).toCompileTo(''); +    }); + +    it('GH-1603: should not allow constructors to be accessed (lookup via toString)', () => { +      expectTemplate('{{lookup (lookup this (list "constructor")) "name"}}') +        .withInput({}) +        .withHelper('list', function (element) { +          return [element]; +        }) +        .toCompileTo(''); +    }); + +    it('should allow the "constructor" property to be accessed if it is an "ownProperty"', () => { +      expectTemplate('{{constructor.name}}') +        .withInput({ constructor: { name: 'here we go' } }) +        .toCompileTo('here we go'); + +      expectTemplate('{{lookup (lookup this "constructor") "name"}}') +        .withInput({ constructor: { name: 'here we go' } }) +        .toCompileTo('here we go'); +    }); + +    it('should allow the "constructor" property to be accessed if it is an "own property"', () => { +      expectTemplate('{{lookup (lookup this "constructor") "name"}}') +        .withInput({ constructor: { name: 'here we go' } }) +        .toCompileTo('here we go'); +    }); +  }); + +  describe('GH-1558: Prevent explicit call of helperMissing-helpers', () => { +    describe('without the option "allowExplicitCallOfHelperMissing"', () => { +      it('should throw an exception when calling  "{{helperMissing}}" ', () => { +        expectTemplate('{{helperMissing}}').toThrow(Error); +      }); + +      it('should throw an exception when calling  "{{#helperMissing}}{{/helperMissing}}" ', () => { +        expectTemplate('{{#helperMissing}}{{/helperMissing}}').toThrow(Error); +      }); + +      it('should throw an exception when calling  "{{blockHelperMissing "abc" .}}" ', () => { +        const functionCalls = []; +        expect(() => { +          const template = Handlebars.compile('{{blockHelperMissing "abc" .}}'); +          template({ +            fn() { +              functionCalls.push('called'); +            }, +          }); +        }).toThrow(Error); +        expect(functionCalls.length).toEqual(0); +      }); + +      it('should throw an exception when calling  "{{#blockHelperMissing .}}{{/blockHelperMissing}}"', () => { +        expectTemplate('{{#blockHelperMissing .}}{{/blockHelperMissing}}') +          .withInput({ +            fn() { +              return 'functionInData'; +            }, +          }) +          .toThrow(Error); +      }); +    }); +  }); + +  describe('GH-1563', () => { +    it('should not allow to access constructor after overriding via __defineGetter__', () => { +      // @ts-expect-error +      if ({}.__defineGetter__ == null || {}.__lookupGetter__ == null) { +        return; // Browser does not support this exploit anyway +      } +      expectTemplate( +        '{{__defineGetter__ "undefined" valueOf }}' + +          '{{#with __lookupGetter__ }}' + +          '{{__defineGetter__ "propertyIsEnumerable" (this.bind (this.bind 1)) }}' + +          '{{constructor.name}}' + +          '{{/with}}' +      ) +        .withInput({}) +        .toThrow(/Missing helper: "__defineGetter__"/); +    }); +  }); + +  describe('GH-1595: dangerous properties', () => { +    const templates = [ +      '{{constructor}}', +      '{{__defineGetter__}}', +      '{{__defineSetter__}}', +      '{{__lookupGetter__}}', +      '{{__proto__}}', +      '{{lookup this "constructor"}}', +      '{{lookup this "__defineGetter__"}}', +      '{{lookup this "__defineSetter__"}}', +      '{{lookup this "__lookupGetter__"}}', +      '{{lookup this "__proto__"}}', +    ]; + +    templates.forEach((template) => { +      describe('access should be denied to ' + template, () => { +        it('by default', () => { +          expectTemplate(template).withInput({}).toCompileTo(''); +        }); +      }); +    }); +  }); + +  describe('escapes template variables', () => { +    it('in default mode', () => { +      expectTemplate("{{'a\\b'}}").withCompileOptions().withInput({ 'a\\b': 'c' }).toCompileTo('c'); +    }); + +    it('in strict mode', () => { +      expectTemplate("{{'a\\b'}}") +        .withCompileOptions({ strict: true }) +        .withInput({ 'a\\b': 'c' }) +        .toCompileTo('c'); +    }); +  }); +}); diff --git a/dev/lib/handlebars/src/spec/index.strict.test.ts b/dev/lib/handlebars/src/spec/index.strict.test.ts new file mode 100644 index 00000000..a8f294b9 --- /dev/null +++ b/dev/lib/handlebars/src/spec/index.strict.test.ts @@ -0,0 +1,164 @@ +/* + * This file is forked from the handlebars project (https://github.com/handlebars-lang/handlebars.js), + * and may include modifications made by Elasticsearch B.V. + * Elasticsearch B.V. licenses this file to you under the MIT License. + * See `packages/kbn-handlebars/LICENSE` for more information. + */ + +import { expectTemplate } from '../__jest__/test_bench'; + +describe('strict', () => { +  describe('strict mode', () => { +    it('should error on missing property lookup', () => { +      expectTemplate('{{hello}}') +        .withCompileOptions({ strict: true }) +        .toThrow(/"hello" not defined in/); +    }); + +    it('should error on missing child', () => { +      expectTemplate('{{hello.bar}}') +        .withCompileOptions({ strict: true }) +        .withInput({ hello: { bar: 'foo' } }) +        .toCompileTo('foo'); + +      expectTemplate('{{hello.bar}}') +        .withCompileOptions({ strict: true }) +        .withInput({ hello: {} }) +        .toThrow(/"bar" not defined in/); +    }); + +    it('should handle explicit undefined', () => { +      expectTemplate('{{hello.bar}}') +        .withCompileOptions({ strict: true }) +        .withInput({ hello: { bar: undefined } }) +        .toCompileTo(''); +    }); + +    it('should error on missing property lookup in known helpers mode', () => { +      expectTemplate('{{hello}}') +        .withCompileOptions({ +          strict: true, +          knownHelpersOnly: true, +        }) +        .toThrow(/"hello" not defined in/); +    }); + +    it('should error on missing context', () => { +      expectTemplate('{{hello}}').withCompileOptions({ strict: true }).toThrow(Error); +    }); + +    it('should error on missing data lookup', () => { +      const xt = expectTemplate('{{@hello}}').withCompileOptions({ +        strict: true, +      }); + +      xt.toThrow(Error); + +      xt.withRuntimeOptions({ data: { hello: 'foo' } }).toCompileTo('foo'); +    }); + +    it('should not run helperMissing for helper calls', () => { +      expectTemplate('{{hello foo}}') +        .withCompileOptions({ strict: true }) +        .withInput({ foo: true }) +        .toThrow(/"hello" not defined in/); + +      expectTemplate('{{#hello foo}}{{/hello}}') +        .withCompileOptions({ strict: true }) +        .withInput({ foo: true }) +        .toThrow(/"hello" not defined in/); +    }); + +    it('should throw on ambiguous blocks', () => { +      expectTemplate('{{#hello}}{{/hello}}') +        .withCompileOptions({ strict: true }) +        .toThrow(/"hello" not defined in/); + +      expectTemplate('{{^hello}}{{/hello}}') +        .withCompileOptions({ strict: true }) +        .toThrow(/"hello" not defined in/); + +      expectTemplate('{{#hello.bar}}{{/hello.bar}}') +        .withCompileOptions({ strict: true }) +        .withInput({ hello: {} }) +        .toThrow(/"bar" not defined in/); +    }); + +    it('should allow undefined parameters when passed to helpers', () => { +      expectTemplate('{{#unless foo}}success{{/unless}}') +        .withCompileOptions({ strict: true }) +        .toCompileTo('success'); +    }); + +    it('should allow undefined hash when passed to helpers', () => { +      expectTemplate('{{helper value=@foo}}') +        .withCompileOptions({ +          strict: true, +        }) +        .withHelpers({ +          helper(options) { +            expect('value' in options.hash).toEqual(true); +            expect(options.hash.value).toBeUndefined(); +            return 'success'; +          }, +        }) +        .toCompileTo('success'); +    }); + +    it('should show error location on missing property lookup', () => { +      expectTemplate('\n\n\n   {{hello}}') +        .withCompileOptions({ strict: true }) +        .toThrow('"hello" not defined in [object Object] - 4:5'); +    }); + +    it('should error contains correct location properties on missing property lookup', () => { +      try { +        expectTemplate('\n\n\n   {{hello}}') +          .withCompileOptions({ strict: true }) +          .toCompileTo('throw before asserting this'); +      } catch (error) { +        expect(error.lineNumber).toEqual(4); +        expect(error.endLineNumber).toEqual(4); +        expect(error.column).toEqual(5); +        expect(error.endColumn).toEqual(10); +      } +    }); +  }); + +  describe('assume objects', () => { +    it('should ignore missing property', () => { +      expectTemplate('{{hello}}').withCompileOptions({ assumeObjects: true }).toCompileTo(''); +    }); + +    it('should ignore missing child', () => { +      expectTemplate('{{hello.bar}}') +        .withCompileOptions({ assumeObjects: true }) +        .withInput({ hello: {} }) +        .toCompileTo(''); +    }); + +    it('should error on missing object', () => { +      expectTemplate('{{hello.bar}}').withCompileOptions({ assumeObjects: true }).toThrow(Error); +    }); + +    it('should error on missing context', () => { +      expectTemplate('{{hello}}') +        .withCompileOptions({ assumeObjects: true }) +        .withInput(undefined) +        .toThrow(Error); +    }); + +    it('should error on missing data lookup', () => { +      expectTemplate('{{@hello.bar}}') +        .withCompileOptions({ assumeObjects: true }) +        .withInput(undefined) +        .toThrow(Error); +    }); + +    it('should execute blockHelperMissing', () => { +      expectTemplate('{{^hello}}foo{{/hello}}') +        .withCompileOptions({ assumeObjects: true }) +        .toCompileTo('foo'); +    }); +  }); +}); diff --git a/dev/lib/handlebars/src/spec/index.subexpressions.test.ts b/dev/lib/handlebars/src/spec/index.subexpressions.test.ts new file mode 100644 index 00000000..4dee24b7 --- /dev/null +++ b/dev/lib/handlebars/src/spec/index.subexpressions.test.ts @@ -0,0 +1,214 @@ +/* + * This file is forked from the handlebars project (https://github.com/handlebars-lang/handlebars.js), + * and may include modifications made by Elasticsearch B.V. + * Elasticsearch B.V. licenses this file to you under the MIT License. + * See `packages/kbn-handlebars/LICENSE` for more information. + */ + +import Handlebars, { type HelperOptions } from '../..'; +import { expectTemplate } from '../__jest__/test_bench'; + +describe('subexpressions', () => { +  it('arg-less helper', () => { +    expectTemplate('{{foo (bar)}}!') +      .withHelpers({ +        foo(val) { +          return val + val; +        }, +        bar() { +          return 'LOL'; +        }, +      }) +      .toCompileTo('LOLLOL!'); +  }); + +  it('helper w args', () => { +    expectTemplate('{{blog (equal a b)}}') +      .withInput({ bar: 'LOL' }) +      .withHelpers({ +        blog(val) { +          return 'val is ' + val; +        }, +        equal(x, y) { +          return x === y; +        }, +      }) +      .toCompileTo('val is true'); +  }); + +  it('mixed paths and helpers', () => { +    expectTemplate('{{blog baz.bat (equal a b) baz.bar}}') +      .withInput({ bar: 'LOL', baz: { bat: 'foo!', bar: 'bar!' } }) +      .withHelpers({ +        blog(val, that, theOther) { +          return 'val is ' + val + ', ' + that + ' and ' + theOther; +        }, +        equal(x, y) { +          return x === y; +        }, +      }) +      .toCompileTo('val is foo!, true and bar!'); +  }); + +  it('supports much nesting', () => { +    expectTemplate('{{blog (equal (equal true true) true)}}') +      .withInput({ bar: 'LOL' }) +      .withHelpers({ +        blog(val) { +          return 'val is ' + val; +        }, +        equal(x, y) { +          return x === y; +        }, +      }) +      .toCompileTo('val is true'); +  }); + +  it('GH-800 : Complex subexpressions', () => { +    const context = { a: 'a', b: 'b', c: { c: 'c' }, d: 'd', e: { e: 'e' } }; +    const helpers = { +      dash(a: any, b: any) { +        return a + '-' + b; +      }, +      concat(a: any, b: any) { +        return a + b; +      }, +    }; + +    expectTemplate("{{dash 'abc' (concat a b)}}") +      .withInput(context) +      .withHelpers(helpers) +      .toCompileTo('abc-ab'); + +    expectTemplate('{{dash d (concat a b)}}') +      .withInput(context) +      .withHelpers(helpers) +      .toCompileTo('d-ab'); + +    expectTemplate('{{dash c.c (concat a b)}}') +      .withInput(context) +      .withHelpers(helpers) +      .toCompileTo('c-ab'); + +    expectTemplate('{{dash (concat a b) c.c}}') +      .withInput(context) +      .withHelpers(helpers) +      .toCompileTo('ab-c'); + +    expectTemplate('{{dash (concat a e.e) c.c}}') +      .withInput(context) +      .withHelpers(helpers) +      .toCompileTo('ae-c'); +  }); + +  it('provides each nested helper invocation its own options hash', () => { +    let lastOptions: HelperOptions; +    const helpers = { +      equal(x: any, y: any, options: HelperOptions) { +        if (!options || options === lastOptions) { +          throw new Error('options hash was reused'); +        } +        lastOptions = options; +        return x === y; +      }, +    }; +    expectTemplate('{{equal (equal true true) true}}').withHelpers(helpers).toCompileTo('true'); +  }); + +  it('with hashes', () => { +    expectTemplate("{{blog (equal (equal true true) true fun='yes')}}") +      .withInput({ bar: 'LOL' }) +      .withHelpers({ +        blog(val) { +          return 'val is ' + val; +        }, +        equal(x, y) { +          return x === y; +        }, +      }) +      .toCompileTo('val is true'); +  }); + +  it('as hashes', () => { +    expectTemplate("{{blog fun=(equal (blog fun=1) 'val is 1')}}") +      .withHelpers({ +        blog(options) { +          return 'val is ' + options.hash.fun; +        }, +        equal(x, y) { +          return x === y; +        }, +      }) +      .toCompileTo('val is true'); +  }); + +  it('multiple subexpressions in a hash', () => { +    expectTemplate('{{input aria-label=(t "Name") placeholder=(t "Example User")}}') +      .withHelpers({ +        input(options) { +          const hash = options.hash; +          const ariaLabel = Handlebars.Utils.escapeExpression(hash['aria-label']); +          const placeholder = Handlebars.Utils.escapeExpression(hash.placeholder); +          return new Handlebars.SafeString( +            '<input aria-label="' + ariaLabel + '" placeholder="' + placeholder + '" />' +          ); +        }, +        t(defaultString) { +          return new Handlebars.SafeString(defaultString); +        }, +      }) +      .toCompileTo('<input aria-label="Name" placeholder="Example User" />'); +  }); + +  it('multiple subexpressions in a hash with context', () => { +    expectTemplate('{{input aria-label=(t item.field) placeholder=(t item.placeholder)}}') +      .withInput({ +        item: { +          field: 'Name', +          placeholder: 'Example User', +        }, +      }) +      .withHelpers({ +        input(options) { +          const hash = options.hash; +          const ariaLabel = Handlebars.Utils.escapeExpression(hash['aria-label']); +          const placeholder = Handlebars.Utils.escapeExpression(hash.placeholder); +          return new Handlebars.SafeString( +            '<input aria-label="' + ariaLabel + '" placeholder="' + placeholder + '" />' +          ); +        }, +        t(defaultString) { +          return new Handlebars.SafeString(defaultString); +        }, +      }) +      .toCompileTo('<input aria-label="Name" placeholder="Example User" />'); +  }); + +  it('subexpression functions on the context', () => { +    expectTemplate('{{foo (bar)}}!') +      .withInput({ +        bar() { +          return 'LOL'; +        }, +      }) +      .withHelpers({ +        foo(val) { +          return val + val; +        }, +      }) +      .toCompileTo('LOLLOL!'); +  }); + +  it("subexpressions can't just be property lookups", () => { +    expectTemplate('{{foo (bar)}}!') +      .withInput({ +        bar: 'LOL', +      }) +      .withHelpers({ +        foo(val) { +          return val + val; +        }, +      }) +      .toThrow(); +  }); +}); diff --git a/dev/lib/handlebars/src/spec/index.utils.test.ts b/dev/lib/handlebars/src/spec/index.utils.test.ts new file mode 100644 index 00000000..6350bc7c --- /dev/null +++ b/dev/lib/handlebars/src/spec/index.utils.test.ts @@ -0,0 +1,24 @@ +/* + * This file is forked from the handlebars project (https://github.com/handlebars-lang/handlebars.js), + * and may include modifications made by Elasticsearch B.V. + * Elasticsearch B.V. licenses this file to you under the MIT License. + * See `packages/kbn-handlebars/LICENSE` for more information. + */ + +import Handlebars from '../..'; +import { expectTemplate } from '../__jest__/test_bench'; + +describe('utils', function () { +  describe('#SafeString', function () { +    it('constructing a safestring from a string and checking its type', function () { +      const safe = new Handlebars.SafeString('testing 1, 2, 3'); +      expect(safe).toBeInstanceOf(Handlebars.SafeString); +      expect(safe.toString()).toEqual('testing 1, 2, 3'); +    }); + +    it('it should not escape SafeString properties', function () { +      const name = new Handlebars.SafeString('<em>Sean O'Malley</em>'); +      expectTemplate('{{name}}').withInput({ name }).toCompileTo('<em>Sean O'Malley</em>'); +    }); +  }); +}); diff --git a/dev/lib/handlebars/src/spec/index.whitespace_control.test.ts b/dev/lib/handlebars/src/spec/index.whitespace_control.test.ts new file mode 100644 index 00000000..1f7cf019 --- /dev/null +++ b/dev/lib/handlebars/src/spec/index.whitespace_control.test.ts @@ -0,0 +1,88 @@ +/* + * This file is forked from the handlebars project (https://github.com/handlebars-lang/handlebars.js), + * and may include modifications made by Elasticsearch B.V. + * Elasticsearch B.V. licenses this file to you under the MIT License. + * See `packages/kbn-handlebars/LICENSE` for more information. + */ + +import { expectTemplate } from '../__jest__/test_bench'; + +describe('whitespace control', () => { +  it('should strip whitespace around mustache calls', () => { +    const hash = { foo: 'bar<' }; +    expectTemplate(' {{~foo~}} ').withInput(hash).toCompileTo('bar<'); +    expectTemplate(' {{~foo}} ').withInput(hash).toCompileTo('bar< '); +    expectTemplate(' {{foo~}} ').withInput(hash).toCompileTo(' bar<'); +    expectTemplate(' {{~&foo~}} ').withInput(hash).toCompileTo('bar<'); +    expectTemplate(' {{~{foo}~}} ').withInput(hash).toCompileTo('bar<'); +    expectTemplate('1\n{{foo~}} \n\n 23\n{{bar}}4').toCompileTo('1\n23\n4'); +  }); + +  describe('blocks', () => { +    it('should strip whitespace around simple block calls', () => { +      const hash = { foo: 'bar<' }; + +      expectTemplate(' {{~#if foo~}} bar {{~/if~}} ').withInput(hash).toCompileTo('bar'); +      expectTemplate(' {{#if foo~}} bar {{/if~}} ').withInput(hash).toCompileTo(' bar '); +      expectTemplate(' {{~#if foo}} bar {{~/if}} ').withInput(hash).toCompileTo(' bar '); +      expectTemplate(' {{#if foo}} bar {{/if}} ').withInput(hash).toCompileTo('  bar  '); + +      expectTemplate(' \n\n{{~#if foo~}} \n\nbar \n\n{{~/if~}}\n\n ') +        .withInput(hash) +        .toCompileTo('bar'); + +      expectTemplate(' a\n\n{{~#if foo~}} \n\nbar \n\n{{~/if~}}\n\na ') +        .withInput(hash) +        .toCompileTo(' abara '); +    }); + +    it('should strip whitespace around inverse block calls', () => { +      expectTemplate(' {{~^if foo~}} bar {{~/if~}} ').toCompileTo('bar'); +      expectTemplate(' {{^if foo~}} bar {{/if~}} ').toCompileTo(' bar '); +      expectTemplate(' {{~^if foo}} bar {{~/if}} ').toCompileTo(' bar '); +      expectTemplate(' {{^if foo}} bar {{/if}} ').toCompileTo('  bar  '); +      expectTemplate(' \n\n{{~^if foo~}} \n\nbar \n\n{{~/if~}}\n\n ').toCompileTo('bar'); +    }); + +    it('should strip whitespace around complex block calls', () => { +      const hash = { foo: 'bar<' }; + +      expectTemplate('{{#if foo~}} bar {{~^~}} baz {{~/if}}').withInput(hash).toCompileTo('bar'); +      expectTemplate('{{#if foo~}} bar {{^~}} baz {{/if}}').withInput(hash).toCompileTo('bar '); +      expectTemplate('{{#if foo}} bar {{~^~}} baz {{~/if}}').withInput(hash).toCompileTo(' bar'); +      expectTemplate('{{#if foo}} bar {{^~}} baz {{/if}}').withInput(hash).toCompileTo(' bar '); +      expectTemplate('{{#if foo~}} bar {{~else~}} baz {{~/if}}').withInput(hash).toCompileTo('bar'); + +      expectTemplate('\n\n{{~#if foo~}} \n\nbar \n\n{{~^~}} \n\nbaz \n\n{{~/if~}}\n\n') +        .withInput(hash) +        .toCompileTo('bar'); + +      expectTemplate('\n\n{{~#if foo~}} \n\n{{{foo}}} \n\n{{~^~}} \n\nbaz \n\n{{~/if~}}\n\n') +        .withInput(hash) +        .toCompileTo('bar<'); + +      expectTemplate('{{#if foo~}} bar {{~^~}} baz {{~/if}}').toCompileTo('baz'); +      expectTemplate('{{#if foo}} bar {{~^~}} baz {{/if}}').toCompileTo('baz '); +      expectTemplate('{{#if foo~}} bar {{~^}} baz {{~/if}}').toCompileTo(' baz'); +      expectTemplate('{{#if foo~}} bar {{~^}} baz {{/if}}').toCompileTo(' baz '); +      expectTemplate('{{#if foo~}} bar {{~else~}} baz {{~/if}}').toCompileTo('baz'); +      expectTemplate('\n\n{{~#if foo~}} \n\nbar \n\n{{~^~}} \n\nbaz \n\n{{~/if~}}\n\n').toCompileTo( +        'baz' +      ); +    }); +  }); + +  it('should strip whitespace around partials', () => { +    expectTemplate('foo {{~> dude~}} ').withPartials({ dude: 'bar' }).toCompileTo('foobar'); +    expectTemplate('foo {{> dude~}} ').withPartials({ dude: 'bar' }).toCompileTo('foo bar'); +    expectTemplate('foo {{> dude}} ').withPartials({ dude: 'bar' }).toCompileTo('foo bar '); +    expectTemplate('foo\n {{~> dude}} ').withPartials({ dude: 'bar' }).toCompileTo('foobar'); +    expectTemplate('foo\n {{> dude}} ').withPartials({ dude: 'bar' }).toCompileTo('foo\n bar'); +  }); + +  it('should only strip whitespace once', () => { +    expectTemplate(' {{~foo~}} {{foo}} {{foo}} ') +      .withInput({ foo: 'bar' }) +      .toCompileTo('barbar bar '); +  }); +}); diff --git a/dev/lib/handlebars/src/symbols.ts b/dev/lib/handlebars/src/symbols.ts new file mode 100644 index 00000000..85a8f2f3 --- /dev/null +++ b/dev/lib/handlebars/src/symbols.ts @@ -0,0 +1,8 @@ +/* + * Elasticsearch B.V licenses this file to you under the MIT License. + * See `packages/kbn-handlebars/LICENSE` for more information. + */ + +export const kHelper = Symbol('helper'); +export const kAmbiguous = Symbol('ambiguous'); +export const kSimple = Symbol('simple'); diff --git a/dev/lib/handlebars/src/types.ts b/dev/lib/handlebars/src/types.ts new file mode 100644 index 00000000..583170cb --- /dev/null +++ b/dev/lib/handlebars/src/types.ts @@ -0,0 +1,225 @@ +/* + * Elasticsearch B.V licenses this file to you under the MIT License. + * See `packages/kbn-handlebars/LICENSE` for more information. + */ + +import { kHelper, kAmbiguous, kSimple } from './symbols'; + +// Unexported `CompileOptions` lifted from node_modules/handlebars/types/index.d.ts +// While it could also be extracted using `NonNullable<Parameters<typeof Handlebars.compile>[1]>`, this isn't possible since we declare the handlebars module below +interface HandlebarsCompileOptions { +  data?: boolean; +  compat?: boolean; +  knownHelpers?: KnownHelpers; +  knownHelpersOnly?: boolean; +  noEscape?: boolean; +  strict?: boolean; +  assumeObjects?: boolean; +  preventIndent?: boolean; +  ignoreStandalone?: boolean; +  explicitPartialContext?: boolean; +} + +/** + * A custom version of the Handlebars module with an extra `compileAST` function and fixed typings. + */ +declare module 'handlebars' { +  /** +   * Compiles the given Handlebars template without the use of `eval`. +   * +   * @returns A render function with the same API as the return value from the regular Handlebars `compile` function. +   */ +  export function compileAST( +    input: string | hbs.AST.Program, +    options?: CompileOptions +  ): TemplateDelegateFixed; + +  // -------------------------------------------------------- +  // Override/Extend inherited funcions and interfaces below that are incorrect. +  // +  // Any exported `const` or `type` types can't be overwritten, so we'll just +  // have to live with those and cast them to the correct types in our code. +  // Some of these fixed types, we'll instead export outside the scope of this +  // 'handlebars' module so consumers of @kbn/handlebars at least have a way to +  // access the correct types. +  // -------------------------------------------------------- + +  /** +   * A {@link https://handlebarsjs.com/api-reference/helpers.html helper-function} type. +   * +   * When registering a helper function, it should be of this type. +   */ +  export interface HelperDelegate extends HelperDelegateFixed {} // eslint-disable-line @typescript-eslint/no-empty-interface + +  /** +   * A template-function type. +   * +   * This type is primarily used for the return value of by calls to +   * {@link https://handlebarsjs.com/api-reference/compilation.html#handlebars-compile-template-options Handlebars.compile}, +   * Handlebars.compileAST and {@link https://handlebarsjs.com/api-reference/compilation.html#handlebars-precompile-template-options Handlebars.template}. +   */ +  export interface TemplateDelegate<T = any> extends TemplateDelegateFixed<T> {} // eslint-disable-line @typescript-eslint/no-empty-interface + +  /** +   * Register one or more {@link https://handlebarsjs.com/api-reference/runtime.html#handlebars-registerpartial-name-partial partials}. +   * +   * @param spec A key/value object where each key is the name of a partial (a string) and each value is the partial (either a string or a partial function). +   */ +  export function registerPartial(spec: Record<string, TemplateFixed>): void; // Ensure `spec` object values can be strings +} + +/** + * Supported Handlebars compile options. + * + * This is a subset of all the compile options supported by the upstream + * Handlebars module. + */ +export type CompileOptions = Pick< +  HandlebarsCompileOptions, +  | 'data' +  | 'knownHelpers' +  | 'knownHelpersOnly' +  | 'noEscape' +  | 'strict' +  | 'assumeObjects' +  | 'preventIndent' +  | 'explicitPartialContext' +>; + +/** + * Supported Handlebars runtime options + * + * This is a subset of all the runtime options supported by the upstream + * Handlebars module. + */ +export interface RuntimeOptions extends Pick<Handlebars.RuntimeOptions, 'data' | 'blockParams'> { +  // The upstream `helpers` property is too loose and allows all functions. +  helpers?: HelpersHash; +  // The upstream `partials` property is incorrectly typed and doesn't allow +  // partials to be strings. +  partials?: PartialsHash; +  // The upstream `decorators` property is too loose and allows all functions. +  decorators?: DecoratorsHash; +} + +/** + * The last argument being passed to a helper function is a an {@link https://handlebarsjs.com/api-reference/helpers.html#the-options-parameter options object}. + */ +export interface HelperOptions extends Omit<Handlebars.HelperOptions, 'fn' | 'inverse'> { +  name: string; +  fn: TemplateDelegateFixed; +  inverse: TemplateDelegateFixed; +  loc: { start: hbs.AST.SourceLocation['start']; end: hbs.AST.SourceLocation['end'] }; +  lookupProperty: LookupProperty; +} + +// Use the post-fix `Fixed` to allow us to acces it inside the 'handlebars' module declared above +/** + * A {@link https://handlebarsjs.com/api-reference/helpers.html helper-function} type. + * + * When registering a helper function, it should be of this type. + */ +interface HelperDelegateFixed { +  // eslint-disable-next-line @typescript-eslint/prefer-function-type +  (...params: any[]): any; +} +export type { HelperDelegateFixed as HelperDelegate }; + +// Use the post-fix `Fixed` to allow us to acces it inside the 'handlebars' module declared above +/** + * A template-function type. + * + * This type is primarily used for the return value of by calls to + * {@link https://handlebarsjs.com/api-reference/compilation.html#handlebars-compile-template-options Handlebars.compile}, + * Handlebars.compileAST and {@link https://handlebarsjs.com/api-reference/compilation.html#handlebars-precompile-template-options Handlebars.template}. + */ +interface TemplateDelegateFixed<T = any> { +  (context?: T, options?: RuntimeOptions): string; // Override to ensure `context` is optional +  blockParams?: number; // TODO: Can this really be optional? +  partials?: PartialsHash; +} +export type { TemplateDelegateFixed as TemplateDelegate }; + +// According to the decorator docs +// (https://github.com/handlebars-lang/handlebars.js/blob/4.x/docs/decorators-api.md) +// a decorator will be called with a different set of arugments than what's +// actually happening in the upstream code. So here I assume that the docs are +// wrong and that the upstream code is correct. In reality, `context` is the +// last 4 documented arguments rolled into one object. +/** + * A {@link https://github.com/handlebars-lang/handlebars.js/blob/master/docs/decorators-api.md decorator-function} type. + * + * When registering a decorator function, it should be of this type. + */ +export type DecoratorDelegate = ( +  prog: TemplateDelegateFixed, +  props: Record<string, any>, +  container: Container, +  options: any +) => any; + +// ----------------------------------------------------------------------------- +// INTERNAL TYPES +// ----------------------------------------------------------------------------- + +export type NodeType = typeof kHelper | typeof kAmbiguous | typeof kSimple; + +type LookupProperty = <T = any>(parent: Record<string, any>, propertyName: string) => T; + +export type NonBlockHelperOptions = Omit<HelperOptions, 'fn' | 'inverse'>; +export type AmbiguousHelperOptions = HelperOptions | NonBlockHelperOptions; + +export type ProcessableStatementNode = +  | hbs.AST.MustacheStatement +  | hbs.AST.PartialStatement +  | hbs.AST.SubExpression; +export type ProcessableBlockStatementNode = hbs.AST.BlockStatement | hbs.AST.PartialBlockStatement; +export type ProcessableNode = ProcessableStatementNode | ProcessableBlockStatementNode; +export type ProcessableNodeWithPathParts = ProcessableNode & { path: hbs.AST.PathExpression }; +export type ProcessableNodeWithPathPartsOrLiteral = ProcessableNode & { +  path: hbs.AST.PathExpression | hbs.AST.Literal; +}; + +export type HelpersHash = Record<string, HelperDelegateFixed>; +export type PartialsHash = Record<string, TemplateFixed>; +export type DecoratorsHash = Record<string, DecoratorDelegate>; + +// Use the post-fix `Fixed` to allow us to acces it inside the 'handlebars' module declared above +type TemplateFixed = TemplateDelegateFixed | string; +export type { TemplateFixed as Template }; + +export interface DecoratorOptions extends Omit<HelperOptions, 'lookupProperties'> { +  args?: any[]; +} + +export interface VisitorHelper { +  fn?: HelperDelegateFixed; +  context: any[]; +  params: any[]; +  options: AmbiguousHelperOptions; +} + +export interface ResolvePartialOptions +  extends Omit<Handlebars.ResolvePartialOptions, 'helpers' | 'partials' | 'decorators'> { +  // The upstream `helpers` property is too loose and allows all functions. +  helpers?: HelpersHash; +  // The upstream `partials` property is incorrectly typed and doesn't allow +  // partials to be strings. +  partials?: PartialsHash; +  // The upstream `decorators` property is too loose and allows all functions. +  decorators?: DecoratorsHash; +} + +export interface Container { +  helpers: HelpersHash; +  partials: PartialsHash; +  decorators: DecoratorsHash; +  strict: (obj: Record<string, any>, name: string, loc: hbs.AST.SourceLocation) => any; +  lookupProperty: LookupProperty; +  lambda: (current: any, context: any) => any; +  data: (value: any, depth: number) => any; +  hooks: { +    helperMissing?: HelperDelegateFixed; +    blockHelperMissing?: HelperDelegateFixed; +  }; +} diff --git a/dev/lib/handlebars/src/utils.ts b/dev/lib/handlebars/src/utils.ts new file mode 100644 index 00000000..f55bd98a --- /dev/null +++ b/dev/lib/handlebars/src/utils.ts @@ -0,0 +1,69 @@ +/* + * Elasticsearch B.V licenses this file to you under the MIT License. + * See `packages/kbn-handlebars/LICENSE` for more information. + */ + +// @ts-expect-error: Could not find a declaration file for module +import { createFrame } from 'handlebars/dist/cjs/handlebars/utils'; + +import type { AmbiguousHelperOptions, DecoratorOptions } from './types'; + +export function isBlock(node: hbs.AST.Node): node is hbs.AST.BlockStatement { +  return 'program' in node || 'inverse' in node; +} + +export function isDecorator( +  node: hbs.AST.Node +): node is hbs.AST.Decorator | hbs.AST.DecoratorBlock { +  return node.type === 'Decorator' || node.type === 'DecoratorBlock'; +} + +export function toDecoratorOptions(options: AmbiguousHelperOptions) { +  // There's really no tests/documentation on this, but to match the upstream codebase we'll remove `lookupProperty` from the decorator context +  delete (options as any).lookupProperty; + +  return options as DecoratorOptions; +} + +export function noop() { +  return ''; +} + +// liftet from handlebars lib/handlebars/runtime.js +export function initData(context: any, data: any) { +  if (!data || !('root' in data)) { +    data = data ? createFrame(data) : {}; +    data.root = context; +  } +  return data; +} + +// liftet from handlebars lib/handlebars/compiler/compiler.js +export function transformLiteralToPath(node: { path: hbs.AST.PathExpression | hbs.AST.Literal }) { +  const pathIsLiteral = 'parts' in node.path === false; + +  if (pathIsLiteral) { +    const literal = node.path; +    // @ts-expect-error: Not all `hbs.AST.Literal` sub-types has an `original` property, but that's ok, in that case we just want `undefined` +    const original = literal.original; +    // Casting to string here to make false and 0 literal values play nicely with the rest +    // of the system. +    node.path = { +      type: 'PathExpression', +      data: false, +      depth: 0, +      parts: [original + ''], +      original: original + '', +      loc: literal.loc, +    }; +  } +} + +export function allowUnsafeEval() { +  try { +    new Function(); +    return true; +  } catch (e) { +    return false; +  } +} diff --git a/dev/lib/handlebars/src/visitor.ts b/dev/lib/handlebars/src/visitor.ts new file mode 100644 index 00000000..1842c8e5 --- /dev/null +++ b/dev/lib/handlebars/src/visitor.ts @@ -0,0 +1,778 @@ +/* + * Elasticsearch B.V licenses this file to you under the MIT License. + * See `packages/kbn-handlebars/LICENSE` for more information. + */ + +import Handlebars from 'handlebars'; +import { +  createProtoAccessControl, +  resultIsAllowed, +  // @ts-expect-error: Could not find a declaration file for module +} from 'handlebars/dist/cjs/handlebars/internal/proto-access'; +// @ts-expect-error: Could not find a declaration file for module +import AST from 'handlebars/dist/cjs/handlebars/compiler/ast'; +// @ts-expect-error: Could not find a declaration file for module +import { indexOf, createFrame } from 'handlebars/dist/cjs/handlebars/utils'; +// @ts-expect-error: Could not find a declaration file for module +import { moveHelperToHooks } from 'handlebars/dist/cjs/handlebars/helpers'; + +import type { +  AmbiguousHelperOptions, +  CompileOptions, +  Container, +  DecoratorDelegate, +  DecoratorsHash, +  HelperOptions, +  NodeType, +  NonBlockHelperOptions, +  ProcessableBlockStatementNode, +  ProcessableNode, +  ProcessableNodeWithPathParts, +  ProcessableNodeWithPathPartsOrLiteral, +  ProcessableStatementNode, +  ResolvePartialOptions, +  RuntimeOptions, +  Template, +  TemplateDelegate, +  VisitorHelper, +} from './types'; +import { kAmbiguous, kHelper, kSimple } from './symbols'; +import { +  initData, +  isBlock, +  isDecorator, +  noop, +  toDecoratorOptions, +  transformLiteralToPath, +} from './utils'; + +export class ElasticHandlebarsVisitor extends Handlebars.Visitor { +  private env: typeof Handlebars; +  private contexts: any[] = []; +  private output: any[] = []; +  private template?: string; +  private compileOptions: CompileOptions; +  private runtimeOptions?: RuntimeOptions; +  private blockParamNames: any[][] = []; +  private blockParamValues: any[][] = []; +  private ast?: hbs.AST.Program; +  private container: Container; +  private defaultHelperOptions: Pick<NonBlockHelperOptions, 'lookupProperty'>; +  private processedRootDecorators = false; // Root decorators should not have access to input arguments. This flag helps us detect them. +  private processedDecoratorsForProgram = new Set(); // It's important that a given program node only has its decorators run once, we use this Map to keep track of them + +  constructor( +    env: typeof Handlebars, +    input: string | hbs.AST.Program, +    options: CompileOptions = {} +  ) { +    super(); + +    this.env = env; + +    if (typeof input !== 'string' && input.type === 'Program') { +      this.ast = input; +    } else { +      this.template = input as string; +    } + +    this.compileOptions = { data: true, ...options }; +    this.compileOptions.knownHelpers = Object.assign( +      Object.create(null), +      { +        helperMissing: true, +        blockHelperMissing: true, +        each: true, +        if: true, +        unless: true, +        with: true, +        log: true, +        lookup: true, +      }, +      this.compileOptions.knownHelpers +    ); + +    const protoAccessControl = createProtoAccessControl({}); + +    const container: Container = (this.container = { +      helpers: {}, +      partials: {}, +      decorators: {}, +      strict(obj, name, loc) { +        if (!obj || !(name in obj)) { +          throw new Handlebars.Exception('"' + name + '" not defined in ' + obj, { +            loc, +          } as hbs.AST.Node); +        } +        return container.lookupProperty(obj, name); +      }, +      // this function is lifted from the handlebars source and slightly modified (lib/handlebars/runtime.js) +      lookupProperty(parent, propertyName) { +        const result = parent[propertyName]; +        if (result == null) { +          return result; +        } +        if (Object.prototype.hasOwnProperty.call(parent, propertyName)) { +          return result; +        } + +        if (resultIsAllowed(result, protoAccessControl, propertyName)) { +          return result; +        } +        return undefined; +      }, +      // this function is lifted from the handlebars source and slightly modified (lib/handlebars/runtime.js) +      lambda(current, context) { +        return typeof current === 'function' ? current.call(context) : current; +      }, +      data(value: any, depth: number) { +        while (value && depth--) { +          value = value._parent; +        } +        return value; +      }, +      hooks: {}, +    }); + +    this.defaultHelperOptions = { +      lookupProperty: container.lookupProperty, +    }; +  } + +  render(context: any, options: RuntimeOptions = {}): string { +    this.contexts = [context]; +    this.output = []; +    this.runtimeOptions = { ...options }; +    this.container.helpers = { ...this.env.helpers, ...options.helpers }; +    this.container.partials = { ...this.env.partials, ...options.partials }; +    this.container.decorators = { +      ...(this.env.decorators as DecoratorsHash), +      ...options.decorators, +    }; +    this.container.hooks = {}; +    this.processedRootDecorators = false; +    this.processedDecoratorsForProgram.clear(); + +    if (this.compileOptions.data) { +      this.runtimeOptions.data = initData(context, this.runtimeOptions.data); +    } + +    const keepHelperInHelpers = false; +    moveHelperToHooks(this.container, 'helperMissing', keepHelperInHelpers); +    moveHelperToHooks(this.container, 'blockHelperMissing', keepHelperInHelpers); + +    if (!this.ast) { +      this.ast = Handlebars.parse(this.template!); +    } + +    // The `defaultMain` function contains the default behavior: +    // +    // Generate a "program" function based on the root `Program` in the AST and +    // call it. This will start the processing of all the child nodes in the +    // AST. +    const defaultMain: TemplateDelegate = (_context) => { +      const prog = this.generateProgramFunction(this.ast!); +      return prog(_context, this.runtimeOptions); +    }; + +    // Run any decorators that might exist on the root: +    // +    // The `defaultMain` function is passed in, and if there are no root +    // decorators, or if the decorators chooses to do so, the same function is +    // returned from `processDecorators` and the default behavior is retained. +    // +    // Alternatively any of the root decorators might call the `defaultMain` +    // function themselves, process its return value, and return a completely +    // different `main` function. +    const main = this.processDecorators(this.ast, defaultMain); +    this.processedRootDecorators = true; + +    // Call the `main` function and add the result to the final output. +    const result = main(this.context, options); + +    if (main === defaultMain) { +      this.output.push(result); +      return this.output.join(''); +    } else { +      // We normally expect the return value of `main` to be a string. However, +      // if a decorator is used to override the `defaultMain` function, the +      // return value can be any type. To match the upstream handlebars project +      // behavior, we want the result of rendering the template to be the +      // literal value returned by the decorator. +      // +      // Since the output array in this case always will be empty, we just +      // return that single value instead of attempting to join all the array +      // elements as strings. +      return result; +    } +  } + +  // ********************************************** // +  // ***    Visitor AST Traversal Functions     *** // +  // ********************************************** // + +  Program(program: hbs.AST.Program) { +    this.blockParamNames.unshift(program.blockParams); +    super.Program(program); +    this.blockParamNames.shift(); +  } + +  MustacheStatement(mustache: hbs.AST.MustacheStatement) { +    this.processStatementOrExpression(mustache); +  } + +  BlockStatement(block: hbs.AST.BlockStatement) { +    this.processStatementOrExpression(block); +  } + +  PartialStatement(partial: hbs.AST.PartialStatement) { +    this.invokePartial(partial); +  } + +  PartialBlockStatement(partial: hbs.AST.PartialBlockStatement) { +    this.invokePartial(partial); +  } + +  // This space is intentionally left blank: We want to override the Visitor +  // class implementation of this method, but since we handle decorators +  // separately before traversing the nodes, we just want to make this a no-op. +  DecoratorBlock(decorator: hbs.AST.DecoratorBlock) {} + +  // This space is intentionally left blank: We want to override the Visitor +  // class implementation of this method, but since we handle decorators +  // separately before traversing the nodes, we just want to make this a no-op. +  Decorator(decorator: hbs.AST.Decorator) {} + +  SubExpression(sexpr: hbs.AST.SubExpression) { +    this.processStatementOrExpression(sexpr); +  } + +  PathExpression(path: hbs.AST.PathExpression) { +    const blockParamId = +      !path.depth && !AST.helpers.scopedId(path) && this.blockParamIndex(path.parts[0]); + +    let result; +    if (blockParamId) { +      result = this.lookupBlockParam(blockParamId, path); +    } else if (path.data) { +      result = this.lookupData(this.runtimeOptions!.data, path); +    } else { +      result = this.resolvePath(this.contexts[path.depth], path); +    } + +    this.output.push(result); +  } + +  ContentStatement(content: hbs.AST.ContentStatement) { +    this.output.push(content.value); +  } + +  StringLiteral(string: hbs.AST.StringLiteral) { +    this.output.push(string.value); +  } + +  NumberLiteral(number: hbs.AST.NumberLiteral) { +    this.output.push(number.value); +  } + +  BooleanLiteral(bool: hbs.AST.BooleanLiteral) { +    this.output.push(bool.value); +  } + +  UndefinedLiteral() { +    this.output.push(undefined); +  } + +  NullLiteral() { +    this.output.push(null); +  } + +  // ********************************************** // +  // ***      Visitor AST Helper Functions      *** // +  // ********************************************** // + +  /** +   * Special code for decorators, since they have to be executed ahead of time (before the wrapping program). +   * So we have to look into the program AST body and see if it contains any decorators that we have to process +   * before we can finish processing of the wrapping program. +   */ +  private processDecorators(program: hbs.AST.Program, prog: TemplateDelegate) { +    if (!this.processedDecoratorsForProgram.has(program)) { +      this.processedDecoratorsForProgram.add(program); +      const props = {}; +      for (const node of program.body) { +        if (isDecorator(node)) { +          prog = this.processDecorator(node, prog, props); +        } +      } +    } + +    return prog; +  } + +  private processDecorator( +    decorator: hbs.AST.DecoratorBlock | hbs.AST.Decorator, +    prog: TemplateDelegate, +    props: Record<string, any> +  ) { +    const options = this.setupDecoratorOptions(decorator); + +    const result = this.container.lookupProperty<DecoratorDelegate>( +      this.container.decorators, +      options.name +    )(prog, props, this.container, options); + +    return Object.assign(result || prog, props); +  } + +  private processStatementOrExpression(node: ProcessableNodeWithPathPartsOrLiteral) { +    // Calling `transformLiteralToPath` has side-effects! +    // It converts a node from type `ProcessableNodeWithPathPartsOrLiteral` to `ProcessableNodeWithPathParts` +    transformLiteralToPath(node); + +    switch (this.classifyNode(node as ProcessableNodeWithPathParts)) { +      case kSimple: +        this.processSimpleNode(node as ProcessableNodeWithPathParts); +        break; +      case kHelper: +        this.processHelperNode(node as ProcessableNodeWithPathParts); +        break; +      case kAmbiguous: +        this.processAmbiguousNode(node as ProcessableNodeWithPathParts); +        break; +    } +  } + +  // Liftet from lib/handlebars/compiler/compiler.js (original name: classifySexpr) +  private classifyNode(node: { path: hbs.AST.PathExpression }): NodeType { +    const isSimple = AST.helpers.simpleId(node.path); +    const isBlockParam = isSimple && !!this.blockParamIndex(node.path.parts[0]); + +    // a mustache is an eligible helper if: +    // * its id is simple (a single part, not `this` or `..`) +    let isHelper = !isBlockParam && AST.helpers.helperExpression(node); + +    // if a mustache is an eligible helper but not a definite +    // helper, it is ambiguous, and will be resolved in a later +    // pass or at runtime. +    let isEligible = !isBlockParam && (isHelper || isSimple); + +    // if ambiguous, we can possibly resolve the ambiguity now +    // An eligible helper is one that does not have a complex path, i.e. `this.foo`, `../foo` etc. +    if (isEligible && !isHelper) { +      const name = node.path.parts[0]; +      const options = this.compileOptions; +      if (options.knownHelpers && options.knownHelpers[name]) { +        isHelper = true; +      } else if (options.knownHelpersOnly) { +        isEligible = false; +      } +    } + +    if (isHelper) { +      return kHelper; +    } else if (isEligible) { +      return kAmbiguous; +    } else { +      return kSimple; +    } +  } + +  // Liftet from lib/handlebars/compiler/compiler.js +  private blockParamIndex(name: string): [number, any] | undefined { +    for (let depth = 0, len = this.blockParamNames.length; depth < len; depth++) { +      const blockParams = this.blockParamNames[depth]; +      const param = blockParams && indexOf(blockParams, name); +      if (blockParams && param >= 0) { +        return [depth, param]; +      } +    } +  } + +  // Looks up the value of `parts` on the given block param and pushes +  // it onto the stack. +  private lookupBlockParam(blockParamId: [number, any], path: hbs.AST.PathExpression) { +    const value = this.blockParamValues[blockParamId[0]][blockParamId[1]]; +    return this.resolvePath(value, path, 1); +  } + +  // Push the data lookup operator +  private lookupData(data: any, path: hbs.AST.PathExpression) { +    if (path.depth) { +      data = this.container.data(data, path.depth); +    } + +    return this.resolvePath(data, path); +  } + +  private processSimpleNode(node: ProcessableNodeWithPathParts) { +    const path = node.path; +    // @ts-expect-error strict is not a valid property on PathExpression, but we used in the same way it's also used in the original handlebars +    path.strict = true; +    const result = this.resolveNodes(path)[0]; +    const lambdaResult = this.container.lambda(result, this.context); + +    if (isBlock(node)) { +      this.blockValue(node, lambdaResult); +    } else { +      this.output.push(lambdaResult); +    } +  } + +  // The purpose of this opcode is to take a block of the form +  // `{{#this.foo}}...{{/this.foo}}`, resolve the value of `foo`, and +  // replace it on the stack with the result of properly +  // invoking blockHelperMissing. +  private blockValue(node: hbs.AST.BlockStatement, value: any) { +    const name = node.path.original; +    const options = this.setupParams(node, name); + +    const result = this.container.hooks.blockHelperMissing!.call(this.context, value, options); + +    this.output.push(result); +  } + +  private processHelperNode(node: ProcessableNodeWithPathParts) { +    const path = node.path; +    const name = path.parts[0]; + +    if (this.compileOptions.knownHelpers && this.compileOptions.knownHelpers[name]) { +      this.invokeKnownHelper(node); +    } else if (this.compileOptions.knownHelpersOnly) { +      throw new Handlebars.Exception( +        'You specified knownHelpersOnly, but used the unknown helper ' + name, +        node +      ); +    } else { +      this.invokeHelper(node); +    } +  } + +  // This operation is used when the helper is known to exist, +  // so a `helperMissing` fallback is not required. +  private invokeKnownHelper(node: ProcessableNodeWithPathParts) { +    const name = node.path.parts[0]; +    const helper = this.setupHelper(node, name); +    // TypeScript: `helper.fn` might be `undefined` at this point, but to match the upstream behavior we call it without any guards +    const result = helper.fn!.call(helper.context, ...helper.params, helper.options); +    this.output.push(result); +  } + +  // Pops off the helper's parameters, invokes the helper, +  // and pushes the helper's return value onto the stack. +  // +  // If the helper is not found, `helperMissing` is called. +  private invokeHelper(node: ProcessableNodeWithPathParts) { +    const path = node.path; +    const name = path.original; +    const isSimple = AST.helpers.simpleId(path); +    const helper = this.setupHelper(node, name); + +    const loc = isSimple && helper.fn ? node.loc : path.loc; +    helper.fn = (isSimple && helper.fn) || this.resolveNodes(path)[0]; + +    if (!helper.fn) { +      if (this.compileOptions.strict) { +        helper.fn = this.container.strict(helper.context, name, loc); +      } else { +        helper.fn = this.container.hooks.helperMissing; +      } +    } + +    // TypeScript: `helper.fn` might be `undefined` at this point, but to match the upstream behavior we call it without any guards +    const result = helper.fn!.call(helper.context, ...helper.params, helper.options); + +    this.output.push(result); +  } + +  private invokePartial(partial: hbs.AST.PartialStatement | hbs.AST.PartialBlockStatement) { +    const { params } = partial; +    if (params.length > 1) { +      throw new Handlebars.Exception( +        `Unsupported number of partial arguments: ${params.length}`, +        partial +      ); +    } + +    const isDynamic = partial.name.type === 'SubExpression'; +    const name = isDynamic +      ? this.resolveNodes(partial.name).join('') +      : (partial.name as hbs.AST.PathExpression).original; + +    const options: AmbiguousHelperOptions & ResolvePartialOptions = this.setupParams(partial, name); +    options.helpers = this.container.helpers; +    options.partials = this.container.partials; +    options.decorators = this.container.decorators; + +    let partialBlock; +    if ('fn' in options && options.fn !== noop) { +      const { fn } = options; +      const currentPartialBlock = options.data?.['partial-block']; +      options.data = createFrame(options.data); + +      // Wrapper function to get access to currentPartialBlock from the closure +      partialBlock = options.data['partial-block'] = function partialBlockWrapper( +        context: any, +        wrapperOptions: { data?: HelperOptions['data'] } = {} +      ) { +        // Restore the partial-block from the closure for the execution of the block +        // i.e. the part inside the block of the partial call. +        wrapperOptions.data = createFrame(wrapperOptions.data); +        wrapperOptions.data['partial-block'] = currentPartialBlock; +        return fn(context, wrapperOptions); +      }; + +      if (fn.partials) { +        options.partials = { ...options.partials, ...fn.partials }; +      } +    } + +    let context = {}; +    if (params.length === 0 && !this.compileOptions.explicitPartialContext) { +      context = this.context; +    } else if (params.length === 1) { +      context = this.resolveNodes(params[0])[0]; +    } + +    if (Object.keys(options.hash).length > 0) { +      // TODO: context can be an array, but maybe never when we have a hash??? +      context = Object.assign({}, context, options.hash); +    } + +    const partialTemplate: Template | undefined = +      this.container.partials[name] ?? +      partialBlock ?? +      // TypeScript note: We extend ResolvePartialOptions in our types.ts file +      // to fix an error in the upstream type. When calling back into the +      // upstream code, we just cast back to the non-extended type +      Handlebars.VM.resolvePartial( +        undefined, +        undefined, +        options as Handlebars.ResolvePartialOptions +      ); + +    if (partialTemplate === undefined) { +      throw new Handlebars.Exception(`The partial ${name} could not be found`); +    } + +    let render; +    if (typeof partialTemplate === 'string') { +      render = this.env.compileAST(partialTemplate, this.compileOptions); +      if (name in this.container.partials) { +        this.container.partials[name] = render; +      } +    } else { +      render = partialTemplate; +    } + +    let result = render(context, options); + +    if ('indent' in partial) { +      result = +        partial.indent + +        (this.compileOptions.preventIndent +          ? result +          : result.replace(/\n(?!$)/g, `\n${partial.indent}`)); // indent each line, ignoring any trailing linebreak +    } + +    this.output.push(result); +  } + +  private processAmbiguousNode(node: ProcessableNodeWithPathParts) { +    const name = node.path.parts[0]; +    const helper = this.setupHelper(node, name); +    let { fn: helperFn } = helper; + +    const loc = helperFn ? node.loc : node.path.loc; +    helperFn = helperFn ?? this.resolveNodes(node.path)[0]; + +    if (helperFn === undefined) { +      if (this.compileOptions.strict) { +        helperFn = this.container.strict(helper.context, name, loc); +      } else { +        helperFn = +          helper.context != null +            ? this.container.lookupProperty(helper.context, name) +            : helper.context; +        if (helperFn == null) helperFn = this.container.hooks.helperMissing; +      } +    } + +    const helperResult = +      typeof helperFn === 'function' +        ? helperFn.call(helper.context, ...helper.params, helper.options) +        : helperFn; + +    if (isBlock(node)) { +      const result = helper.fn +        ? helperResult +        : this.container.hooks.blockHelperMissing!.call(this.context, helperResult, helper.options); +      if (result != null) { +        this.output.push(result); +      } +    } else { +      if ( +        (node as hbs.AST.MustacheStatement).escaped === false || +        this.compileOptions.noEscape === true || +        typeof helperResult !== 'string' +      ) { +        this.output.push(helperResult); +      } else { +        this.output.push(Handlebars.escapeExpression(helperResult)); +      } +    } +  } + +  private setupHelper(node: ProcessableNode, helperName: string): VisitorHelper { +    return { +      fn: this.container.lookupProperty(this.container.helpers, helperName), +      context: this.context, +      params: this.resolveNodes(node.params), +      options: this.setupParams(node, helperName), +    }; +  } + +  private setupDecoratorOptions(decorator: hbs.AST.Decorator | hbs.AST.DecoratorBlock) { +    // TypeScript: The types indicate that `decorator.path` technically can be an `hbs.AST.Literal`. However, the upstream codebase always treats it as an `hbs.AST.PathExpression`, so we do too. +    const name = (decorator.path as hbs.AST.PathExpression).original; +    const options = toDecoratorOptions(this.setupParams(decorator, name)); + +    if (decorator.params.length > 0) { +      if (!this.processedRootDecorators) { +        // When processing the root decorators, temporarily remove the root context so it's not accessible to the decorator +        const context = this.contexts.shift(); +        options.args = this.resolveNodes(decorator.params); +        this.contexts.unshift(context); +      } else { +        options.args = this.resolveNodes(decorator.params); +      } +    } else { +      options.args = []; +    } + +    return options; +  } + +  private setupParams(node: ProcessableBlockStatementNode, name: string): HelperOptions; +  private setupParams(node: ProcessableStatementNode, name: string): NonBlockHelperOptions; +  private setupParams(node: ProcessableNode, name: string): AmbiguousHelperOptions; +  private setupParams(node: ProcessableNode, name: string) { +    const options: AmbiguousHelperOptions = { +      name, +      hash: this.getHash(node), +      data: this.runtimeOptions!.data, +      loc: { start: node.loc.start, end: node.loc.end }, +      ...this.defaultHelperOptions, +    }; + +    if (isBlock(node)) { +      (options as HelperOptions).fn = node.program +        ? this.processDecorators(node.program, this.generateProgramFunction(node.program)) +        : noop; +      (options as HelperOptions).inverse = node.inverse +        ? this.processDecorators(node.inverse, this.generateProgramFunction(node.inverse)) +        : noop; +    } + +    return options; +  } + +  private generateProgramFunction(program: hbs.AST.Program) { +    if (!program) return noop; + +    const prog: TemplateDelegate = (nextContext: any, runtimeOptions: RuntimeOptions = {}) => { +      runtimeOptions = { ...runtimeOptions }; + +      // inherit data in blockParams from parent program +      runtimeOptions.data = runtimeOptions.data || this.runtimeOptions!.data; +      if (runtimeOptions.blockParams) { +        runtimeOptions.blockParams = runtimeOptions.blockParams.concat( +          this.runtimeOptions!.blockParams +        ); +      } + +      // inherit partials from parent program +      runtimeOptions.partials = runtimeOptions.partials || this.runtimeOptions!.partials; + +      // stash parent program data +      const tmpRuntimeOptions = this.runtimeOptions; +      this.runtimeOptions = runtimeOptions; +      const shiftContext = nextContext !== this.context; +      if (shiftContext) this.contexts.unshift(nextContext); +      this.blockParamValues.unshift(runtimeOptions.blockParams || []); + +      // execute child program +      const result = this.resolveNodes(program).join(''); + +      // unstash parent program data +      this.blockParamValues.shift(); +      if (shiftContext) this.contexts.shift(); +      this.runtimeOptions = tmpRuntimeOptions; + +      // return result of child program +      return result; +    }; + +    prog.blockParams = program.blockParams?.length ?? 0; +    return prog; +  } + +  private getHash(statement: { hash?: hbs.AST.Hash }) { +    const result: { [key: string]: any } = {}; +    if (!statement.hash) return result; +    for (const { key, value } of statement.hash.pairs) { +      result[key] = this.resolveNodes(value)[0]; +    } +    return result; +  } + +  private resolvePath(obj: any, path: hbs.AST.PathExpression, index = 0) { +    if (this.compileOptions.strict || this.compileOptions.assumeObjects) { +      return this.strictLookup(obj, path); +    } + +    for (; index < path.parts.length; index++) { +      if (obj == null) return; +      obj = this.container.lookupProperty(obj, path.parts[index]); +    } + +    return obj; +  } + +  private strictLookup(obj: any, path: hbs.AST.PathExpression) { +    // @ts-expect-error strict is not a valid property on PathExpression, but we used in the same way it's also used in the original handlebars +    const requireTerminal = this.compileOptions.strict && path.strict; +    const len = path.parts.length - (requireTerminal ? 1 : 0); + +    for (let i = 0; i < len; i++) { +      obj = this.container.lookupProperty(obj, path.parts[i]); +    } + +    if (requireTerminal) { +      return this.container.strict(obj, path.parts[len], path.loc); +    } else { +      return obj; +    } +  } + +  private resolveNodes(nodes: hbs.AST.Node | hbs.AST.Node[]): any[] { +    const currentOutput = this.output; +    this.output = []; + +    if (Array.isArray(nodes)) { +      this.acceptArray(nodes); +    } else { +      this.accept(nodes); +    } + +    const result = this.output; + +    this.output = currentOutput; + +    return result; +  } + +  private get context() { +    return this.contexts[0]; +  } +} diff --git a/dev/lib/handlebars/tsconfig.json b/dev/lib/handlebars/tsconfig.json new file mode 100644 index 00000000..3f139716 --- /dev/null +++ b/dev/lib/handlebars/tsconfig.json @@ -0,0 +1,15 @@ +{ +  "compilerOptions": { +    "outDir": "target/types", +    "types": [ +      "jest", +      "node" +    ] +  }, +  "include": [ +    "**/*.ts" +  ], +  "exclude": [ +    "target/**/*", +  ] +} diff --git a/dev/lib/parse5.js b/dev/lib/parse5.js new file mode 100644 index 00000000..c7b2c838 --- /dev/null +++ b/dev/lib/parse5.js @@ -0,0 +1,17 @@ +/* + * Copyright (C) 2023  Yomitan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program.  If not, see <https://www.gnu.org/licenses/>. + */ +export * from 'parse5'; diff --git a/dev/lib/ucs2length.js b/dev/lib/ucs2length.js new file mode 100644 index 00000000..3b370493 --- /dev/null +++ b/dev/lib/ucs2length.js @@ -0,0 +1,20 @@ +/* + * Copyright (C) 2023  Yomitan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program.  If not, see <https://www.gnu.org/licenses/>. + */ +import ucs2length from 'ajv/dist/runtime/ucs2length.js'; +const ucs2length2 = ucs2length.default; +export {ucs2length2 as ucs2length}; + diff --git a/dev/lib/wanakana.js b/dev/lib/wanakana.js new file mode 100644 index 00000000..dca70729 --- /dev/null +++ b/dev/lib/wanakana.js @@ -0,0 +1,17 @@ +/* + * Copyright (C) 2023  Yomitan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program.  If not, see <https://www.gnu.org/licenses/>. + */ +export * from 'wanakana'; diff --git a/dev/lib/z-worker.js b/dev/lib/z-worker.js new file mode 100644 index 00000000..f6a95ed3 --- /dev/null +++ b/dev/lib/z-worker.js @@ -0,0 +1,17 @@ +/* + * Copyright (C) 2023  Yomitan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program.  If not, see <https://www.gnu.org/licenses/>. + */ +import '../../node_modules/@zip.js/zip.js/lib/z-worker.js'; diff --git a/dev/lib/zip.js b/dev/lib/zip.js new file mode 100644 index 00000000..b6e85451 --- /dev/null +++ b/dev/lib/zip.js @@ -0,0 +1,17 @@ +/* + * Copyright (C) 2023  Yomitan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program.  If not, see <https://www.gnu.org/licenses/>. + */ +export * from '@zip.js/zip.js/lib/zip.js'; diff --git a/dev/lint/global-declarations.js b/dev/lint/global-declarations.js deleted file mode 100644 index 7f90d227..00000000 --- a/dev/lint/global-declarations.js +++ /dev/null @@ -1,133 +0,0 @@ -/* - * Copyright (C) 2023  Yomitan Authors - * Copyright (C) 2020-2022  Yomichan Authors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program.  If not, see <https://www.gnu.org/licenses/>. - */ - -const fs = require('fs'); -const path = require('path'); -const assert = require('assert'); -const {getAllFiles} = require('../util'); - - -function escapeRegExp(string) { -    return string.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); -} - -function countOccurences(string, pattern) { -    return (string.match(pattern) || []).length; -} - -function getNewline(string) { -    const count1 = countOccurences(string, /(?:^|[^\r])\n/g); -    const count2 = countOccurences(string, /\r\n/g); -    const count3 = countOccurences(string, /\r(?:[^\n]|$)/g); -    if (count2 > count1) { -        return (count3 > count2) ? '\r' : '\r\n'; -    } else { -        return (count3 > count1) ? '\r' : '\n'; -    } -} - -function getSubstringCount(string, substring) { -    let count = 0; -    const pattern = new RegExp(`\\b${escapeRegExp(substring)}\\b`, 'g'); -    while (true) { -        const match = pattern.exec(string); -        if (match === null) { break; } -        ++count; -    } -    return count; -} - - -function validateGlobals(fileName, fix) { -    const pattern = /\/\*\s*global\s+([\w\W]*?)\*\//g; -    const trimPattern = /^[\s,*]+|[\s,*]+$/g; -    const splitPattern = /[\s,*]+/; -    const source = fs.readFileSync(fileName, {encoding: 'utf8'}); -    let match; -    let first = true; -    let endIndex = 0; -    let newSource = ''; -    const allGlobals = []; -    const newline = getNewline(source); -    while ((match = pattern.exec(source)) !== null) { -        if (!first) { -            console.error(`Encountered more than one global declaration in ${fileName}`); -            return false; -        } -        first = false; - -        const parts = match[1].replace(trimPattern, '').split(splitPattern); -        parts.sort(); - -        const actual = match[0]; -        const expected = `/* global${parts.map((v) => `${newline} * ${v}`).join('')}${newline} */`; - -        try { -            assert.strictEqual(actual, expected); -        } catch (e) { -            console.error(`Global declaration error encountered in ${fileName}:`); -            console.error(e.message); -            if (!fix) { -                return false; -            } -        } - -        newSource += source.substring(0, match.index); -        newSource += expected; -        endIndex = match.index + match[0].length; - -        allGlobals.push(...parts); -    } - -    newSource += source.substring(endIndex); - -    // This is an approximate check to see if a global variable is unused. -    // If the global appears in a comment, string, or similar, the check will pass. -    let errorCount = 0; -    for (const global of allGlobals) { -        if (getSubstringCount(newSource, global) <= 1) { -            console.error(`Global variable ${global} appears to be unused in ${fileName}`); -            ++errorCount; -        } -    } - -    if (fix) { -        fs.writeFileSync(fileName, newSource, {encoding: 'utf8'}); -    } - -    return errorCount === 0; -} - - -function main() { -    const fix = (process.argv.length >= 2 && process.argv[2] === '--fix'); -    const directory = path.resolve(__dirname, '..', '..', 'ext'); -    const pattern = /\.js$/; -    const ignorePattern = /^lib[\\/]/; -    const fileNames = getAllFiles(directory, (f) => pattern.test(f) && !ignorePattern.test(f)); -    for (const fileName of fileNames) { -        if (!validateGlobals(path.join(directory, fileName), fix)) { -            process.exit(-1); -            return; -        } -    } -    process.exit(0); -} - - -if (require.main === module) { main(); } diff --git a/dev/lint/html-scripts.js b/dev/lint/html-scripts.js deleted file mode 100644 index db6e6ca4..00000000 --- a/dev/lint/html-scripts.js +++ /dev/null @@ -1,173 +0,0 @@ -/* - * Copyright (C) 2023  Yomitan Authors - * Copyright (C) 2020-2022  Yomichan Authors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program.  If not, see <https://www.gnu.org/licenses/>. - */ - -const fs = require('fs'); -const path = require('path'); -const assert = require('assert'); -const {JSDOM} = require('jsdom'); -const {getAllFiles} = require('../util'); - - -function lstatSyncSafe(fileName) { -    try { -        return fs.lstatSync(fileName); -    } catch (e) { -        return null; -    } -} - -function validatePath(src, fileName, extDir) { -    assert.ok(typeof src === 'string', `<script> missing src attribute in ${fileName}`); -    assert.ok(src.startsWith('/'), `<script> src attribute is not absolute in ${fileName} (src=${JSON.stringify(src)})`); -    const relativeSrc = src.substring(1); -    assert.ok(!path.isAbsolute(relativeSrc), `<script> src attribute is invalid in ${fileName} (src=${JSON.stringify(src)})`); -    const fullSrc = path.join(extDir, relativeSrc); -    const stats = lstatSyncSafe(fullSrc); -    assert.ok(stats !== null, `<script> src file not found in ${fileName} (src=${JSON.stringify(src)})`); -    assert.ok(stats.isFile(), `<script> src file invalid in ${fileName} (src=${JSON.stringify(src)})`); -} - -function getSubstringCount(string, pattern) { -    let count = 0; -    while (true) { -        const match = pattern.exec(string); -        if (match === null) { break; } -        ++count; -    } -    return count; -} - -function getSortedScriptPaths(scriptPaths) { -    // Sort file names without the extension -    const extensionPattern = /\.[^.]*$/; -    scriptPaths = scriptPaths.map((value) => { -        const match = extensionPattern.exec(value); -        let ext = ''; -        if (match !== null) { -            ext = match[0]; -            value = value.substring(0, value.length - ext.length); -        } -        return {value, ext}; -    }); - -    const stringComparer = new Intl.Collator('en-US'); // Invariant locale -    scriptPaths.sort((a, b) => stringComparer.compare(a.value, b.value)); - -    scriptPaths = scriptPaths.map(({value, ext}) => `${value}${ext}`); -    return scriptPaths; -} - -function validateScriptOrder(fileName, window) { -    const {document, Node: {ELEMENT_NODE, TEXT_NODE}, NodeFilter} = window; - -    const scriptElements = document.querySelectorAll('script'); -    if (scriptElements.length === 0) { return; } - -    // Assert all scripts are siblings -    const scriptContainerElement = scriptElements[0].parentNode; -    for (const element of scriptElements) { -        if (element.parentNode !== scriptContainerElement) { -            assert.fail('All script nodes are not contained within the same element'); -        } -    } - -    // Get script groupings and order -    const scriptGroups = []; -    const newlinePattern = /\n/g; -    let separatingText = ''; -    const walker = document.createTreeWalker(scriptContainerElement, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT); -    walker.firstChild(); -    for (let node = walker.currentNode; node !== null; node = walker.nextSibling()) { -        switch (node.nodeType) { -            case ELEMENT_NODE: -                if (node.tagName.toLowerCase() === 'script') { -                    let scriptGroup; -                    if (scriptGroups.length === 0 || getSubstringCount(separatingText, newlinePattern) >= 2) { -                        scriptGroup = []; -                        scriptGroups.push(scriptGroup); -                    } else { -                        scriptGroup = scriptGroups[scriptGroups.length - 1]; -                    } -                    scriptGroup.push(node.src); -                    separatingText = ''; -                } -                break; -            case TEXT_NODE: -                separatingText += node.nodeValue; -                break; -        } -    } - -    // Ensure core.js is first (if it is present) -    const ignorePattern = /^\/lib\//; -    const index = scriptGroups.flat() -        .filter((value) => !ignorePattern.test(value)) -        .findIndex((value) => (value === '/js/core.js')); -    assert.ok(index <= 0, 'core.js is not the first included script'); - -    // Check script order -    for (let i = 0, ii = scriptGroups.length; i < ii; ++i) { -        const scriptGroup = scriptGroups[i]; -        try { -            assert.deepStrictEqual(scriptGroup, getSortedScriptPaths(scriptGroup)); -        } catch (e) { -            console.error(`Script order for group ${i + 1} in file ${fileName} is not correct:`); -            throw e; -        } -    } -} - -function validateHtmlScripts(fileName, extDir) { -    const fullFileName = path.join(extDir, fileName); -    const domSource = fs.readFileSync(fullFileName, {encoding: 'utf8'}); -    const dom = new JSDOM(domSource); -    const {window} = dom; -    const {document} = window; -    try { -        for (const {src} of document.querySelectorAll('script')) { -            validatePath(src, fullFileName, extDir); -        } -        for (const {href} of document.querySelectorAll('link')) { -            validatePath(href, fullFileName, extDir); -        } -        validateScriptOrder(fileName, window); -    } finally { -        window.close(); -    } -} - - -function main() { -    try { -        const extDir = path.resolve(__dirname, '..', '..', 'ext'); -        const pattern = /\.html$/; -        const ignorePattern = /^lib[\\/]/; -        const fileNames = getAllFiles(extDir, (f) => pattern.test(f) && !ignorePattern.test(f)); -        for (const fileName of fileNames) { -            validateHtmlScripts(fileName, extDir); -        } -    } catch (e) { -        console.error(e); -        process.exit(-1); -        return; -    } -    process.exit(0); -} - - -if (require.main === module) { main(); } diff --git a/dev/manifest-util.js b/dev/manifest-util.js index 082cf57c..15175e7f 100644 --- a/dev/manifest-util.js +++ b/dev/manifest-util.js @@ -16,19 +16,21 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -const fs = require('fs'); -const path = require('path'); -const childProcess = require('child_process'); +import childProcess from 'child_process'; +import fs from 'fs'; +import {fileURLToPath} from 'node:url'; +import path from 'path'; +const dirname = path.dirname(fileURLToPath(import.meta.url));  function clone(value) {      return JSON.parse(JSON.stringify(value));  } -class ManifestUtil { +export class ManifestUtil {      constructor() { -        const fileName = path.join(__dirname, 'data', 'manifest-variants.json'); +        const fileName = path.join(dirname, 'data', 'manifest-variants.json');          const {manifest, variants, defaultVariant} = JSON.parse(fs.readFileSync(fileName));          this._manifest = manifest;          this._variants = variants; @@ -74,7 +76,7 @@ class ManifestUtil {      _evaluateModificationCommand(data) {          const {command, args, trim} = data;          const {stdout, stderr, status} = childProcess.spawnSync(command, args, { -            cwd: __dirname, +            cwd: dirname,              stdio: 'pipe',              shell: false          }); @@ -263,7 +265,3 @@ class ManifestUtil {      }  } - -module.exports = { -    ManifestUtil -}; diff --git a/dev/patch-dependencies.js b/dev/patch-dependencies.js deleted file mode 100644 index 81572c5c..00000000 --- a/dev/patch-dependencies.js +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright (C) 2023  Yomitan Authors - * Copyright (C) 2021-2022  Yomichan Authors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program.  If not, see <https://www.gnu.org/licenses/>. - */ - -const fs = require('fs'); -const assert = require('assert'); - -/** - * This function patches the following bug: - * - https://github.com/jsdom/jsdom/issues/3211 - * - https://github.com/dperini/nwsapi/issues/48 - */ -function patchNwsapi() { -    const nwsapiPath = require.resolve('nwsapi'); -    const nwsapiSource = fs.readFileSync(nwsapiPath, {encoding: 'utf8'}); -    const pattern = /(if|while)(\()(?:e&&)?(\(e=e\.parentElement\)\)\{)/g; -    let modifications = 0; -    const nwsapiSourceNew = nwsapiSource.replace(pattern, (g0, g1, g2, g3) => { -        ++modifications; -        return `${g1}${g2}e&&${g3}`; -    }); -    assert.strictEqual(modifications, 2); -    fs.writeFileSync(nwsapiPath, nwsapiSourceNew, {encoding: 'utf8'}); -    // nwsapi is used by JSDOM -    const {testJSDOM} = require('../test/test-jsdom'); -    testJSDOM(); -} - -function main() { -    patchNwsapi(); -} - -if (require.main === module) { main(); } diff --git a/dev/schema-validate.js b/dev/schema-validate.js index 1d7607b7..fbd6b06a 100644 --- a/dev/schema-validate.js +++ b/dev/schema-validate.js @@ -16,21 +16,11 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -const fs = require('fs'); -const {performance} = require('perf_hooks'); -const {VM} = require('./vm'); - -const vm = new VM(); -vm.execute([ -    'js/core.js', -    'js/general/cache-map.js', -    'js/data/json-schema.js' -]); -const JsonSchema = vm.get('JsonSchema'); +import Ajv from 'ajv'; +import {JsonSchema} from '../ext/js/data/json-schema.js';  class JsonSchemaAjv {      constructor(schema) { -        const Ajv = require('ajv');          const ajv = new Ajv({              meta: false,              strictTuples: false, @@ -49,53 +39,9 @@ class JsonSchemaAjv {      }  } -function createJsonSchema(mode, schema) { +export function createJsonSchema(mode, schema) {      switch (mode) {          case 'ajv': return new JsonSchemaAjv(schema);          default: return new JsonSchema(schema);      }  } - -function main() { -    const args = process.argv.slice(2); -    if (args.length < 2) { -        console.log([ -            'Usage:', -            '  node schema-validate [--ajv] <schema-file-name> <data-file-names>...' -        ].join('\n')); -        return; -    } - -    let mode = null; -    if (args[0] === '--ajv') { -        mode = 'ajv'; -        args.splice(0, 1); -    } - -    const schemaSource = fs.readFileSync(args[0], {encoding: 'utf8'}); -    const schema = JSON.parse(schemaSource); - -    for (const dataFileName of args.slice(1)) { -        const start = performance.now(); -        try { -            console.log(`Validating ${dataFileName}...`); -            const dataSource = fs.readFileSync(dataFileName, {encoding: 'utf8'}); -            const data = JSON.parse(dataSource); -            createJsonSchema(mode, schema).validate(data); -            const end = performance.now(); -            console.log(`No issues detected (${((end - start) / 1000).toFixed(2)}s)`); -        } catch (e) { -            const end = performance.now(); -            console.log(`Encountered an error (${((end - start) / 1000).toFixed(2)}s)`); -            console.warn(e); -        } -    } -} - - -if (require.main === module) { main(); } - - -module.exports = { -    createJsonSchema -}; diff --git a/dev/translator-vm.js b/dev/translator-vm.js index 2a51ab8c..9f14523e 100644 --- a/dev/translator-vm.js +++ b/dev/translator-vm.js @@ -16,19 +16,32 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -const fs = require('fs'); -const path = require('path'); -const assert = require('assert'); -const {DatabaseVM, DatabaseVMDictionaryImporterMediaLoader} = require('./database-vm'); -const {createDictionaryArchive} = require('./util'); - -function clone(value) { -    return JSON.parse(JSON.stringify(value)); -} +import fs from 'fs'; +import url, {fileURLToPath} from 'node:url'; +import path from 'path'; +import {expect, vi} from 'vitest'; +import {AnkiNoteDataCreator} from '../ext/js/data/sandbox/anki-note-data-creator.js'; +import {DictionaryDatabase} from '../ext/js/language/dictionary-database.js'; +import {DictionaryImporterMediaLoader} from '../ext/js/language/dictionary-importer-media-loader.js'; +import {DictionaryImporter} from '../ext/js/language/dictionary-importer.js'; +import {JapaneseUtil} from '../ext/js/language/sandbox/japanese-util.js'; +import {Translator} from '../ext/js/language/translator.js'; +import {createDictionaryArchive} from './util.js'; + +vi.mock('../ext/js/language/dictionary-importer-media-loader.js'); + +const dirname = path.dirname(fileURLToPath(import.meta.url)); + +export class TranslatorVM { +    constructor() { +        global.chrome = { +            runtime: { +                getURL: (path2) => { +                    return url.pathToFileURL(path.join(dirname, '..', 'ext', path2.replace(/^\//, ''))).href; +                } +            } +        }; -class TranslatorVM extends DatabaseVM { -    constructor(globals) { -        super(globals);          this._japaneseUtil = null;          this._translator = null;          this._ankiNoteDataCreator = null; @@ -40,43 +53,14 @@ class TranslatorVM extends DatabaseVM {      }      async prepare(dictionaryDirectory, dictionaryName) { -        this.execute([ -            'js/core.js', -            'js/data/sandbox/anki-note-data-creator.js', -            'js/data/database.js', -            'js/data/json-schema.js', -            'js/general/cache-map.js', -            'js/general/regex-util.js', -            'js/general/text-source-map.js', -            'js/language/deinflector.js', -            'js/language/sandbox/dictionary-data-util.js', -            'js/language/dictionary-importer.js', -            'js/language/dictionary-database.js', -            'js/language/sandbox/japanese-util.js', -            'js/language/translator.js', -            'js/media/media-util.js' -        ]); -        const [ -            DictionaryImporter, -            DictionaryDatabase, -            JapaneseUtil, -            Translator, -            AnkiNoteDataCreator -        ] = this.get([ -            'DictionaryImporter', -            'DictionaryDatabase', -            'JapaneseUtil', -            'Translator', -            'AnkiNoteDataCreator' -        ]); -          // Dictionary          this._dictionaryName = dictionaryName;          const testDictionary = createDictionaryArchive(dictionaryDirectory, dictionaryName); +        // const testDictionaryContent = await testDictionary.arrayBuffer();          const testDictionaryContent = await testDictionary.generateAsync({type: 'arraybuffer'});          // Setup database -        const dictionaryImporterMediaLoader = new DatabaseVMDictionaryImporterMediaLoader(); +        const dictionaryImporterMediaLoader = new DictionaryImporterMediaLoader();          const dictionaryImporter = new DictionaryImporter(dictionaryImporterMediaLoader, null);          const dictionaryDatabase = new DictionaryDatabase();          await dictionaryDatabase.prepare(); @@ -87,7 +71,9 @@ class TranslatorVM extends DatabaseVM {              {prefixWildcardsSupported: true}          ); -        assert.deepStrictEqual(errors.length, 0); +        expect(errors.length).toEqual(0); + +        const myDirname = path.dirname(fileURLToPath(import.meta.url));          // Setup translator          this._japaneseUtil = new JapaneseUtil(null); @@ -95,7 +81,7 @@ class TranslatorVM extends DatabaseVM {              japaneseUtil: this._japaneseUtil,              database: dictionaryDatabase          }); -        const deinflectionReasons = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'ext', 'data/deinflect.json'))); +        const deinflectionReasons = JSON.parse(fs.readFileSync(path.join(myDirname, '..', 'ext', 'data/deinflect.json')));          this._translator.prepare(deinflectionReasons);          // Assign properties @@ -132,10 +118,10 @@ class TranslatorVM extends DatabaseVM {                      if (!Object.prototype.hasOwnProperty.call(optionsPresets, entry)) {                          throw new Error('Invalid options preset');                      } -                    Object.assign(options, clone(optionsPresets[entry])); +                    Object.assign(options, structuredClone(optionsPresets[entry]));                      break;                  case 'object': -                    Object.assign(options, clone(entry)); +                    Object.assign(options, structuredClone(entry));                      break;                  default:                      throw new Error('Invalid options type'); @@ -177,7 +163,3 @@ class TranslatorVM extends DatabaseVM {          return options;      }  } - -module.exports = { -    TranslatorVM -}; diff --git a/dev/util.js b/dev/util.js index 65b1d982..cabc40aa 100644 --- a/dev/util.js +++ b/dev/util.js @@ -16,24 +16,11 @@   * along with this program.  If not, see <https://www.gnu.org/licenses/>.   */ -const fs = require('fs'); -const path = require('path'); +import fs from 'fs'; +import JSZip from 'jszip'; +import path from 'path'; - -let JSZip = null; - - -function getJSZip() { -    if (JSZip === null) { -        process.noDeprecation = true; // Suppress a warning about JSZip -        JSZip = require(path.join(__dirname, '../ext/lib/jszip.min.js')); -        process.noDeprecation = false; -    } -    return JSZip; -} - - -function getArgs(args, argMap) { +export function getArgs(args, argMap) {      let key = null;      let canKey = true;      let onKey = false; @@ -77,7 +64,7 @@ function getArgs(args, argMap) {      return argMap;  } -function getAllFiles(baseDirectory, predicate=null) { +export function getAllFiles(baseDirectory, predicate=null) {      const results = [];      const directories = [baseDirectory];      while (directories.length > 0) { @@ -99,11 +86,12 @@ function getAllFiles(baseDirectory, predicate=null) {      return results;  } -function createDictionaryArchive(dictionaryDirectory, dictionaryName) { +export function createDictionaryArchive(dictionaryDirectory, dictionaryName) {      const fileNames = fs.readdirSync(dictionaryDirectory); -    const JSZip2 = getJSZip(); -    const archive = new JSZip2(); +    // const zipFileWriter = new BlobWriter(); +    // const zipWriter = new ZipWriter(zipFileWriter); +    const archive = new JSZip();      for (const fileName of fileNames) {          if (/\.json$/.test(fileName)) { @@ -113,17 +101,31 @@ function createDictionaryArchive(dictionaryDirectory, dictionaryName) {                  json.title = dictionaryName;              }              archive.file(fileName, JSON.stringify(json, null, 0)); + +            // await zipWriter.add(fileName, new TextReader(JSON.stringify(json, null, 0)));          } else {              const content = fs.readFileSync(path.join(dictionaryDirectory, fileName), {encoding: null});              archive.file(fileName, content); + +            // console.log('adding'); +            // const r = new TextReader(content); +            // console.log(r.readUint8Array(0, 10)); +            // console.log('reader done'); +            // await zipWriter.add(fileName, r); +            // console.log('??');          }      } +    // await zipWriter.close(); +    // Retrieves the Blob object containing the zip content into `zipFileBlob`. It +    // is also returned by zipWriter.close() for more convenience. +    // const zipFileBlob = await zipFileWriter.getData();      return archive; -} +    // return zipFileBlob; +} -async function testMain(func, ...args) { +export async function testMain(func, ...args) {      try {          await func(...args);      } catch (e) { @@ -131,12 +133,3 @@ async function testMain(func, ...args) {          process.exit(-1);      }  } - - -module.exports = { -    get JSZip() { return getJSZip(); }, -    getArgs, -    getAllFiles, -    createDictionaryArchive, -    testMain -}; diff --git a/dev/vm.js b/dev/vm.js deleted file mode 100644 index c3266443..00000000 --- a/dev/vm.js +++ /dev/null @@ -1,204 +0,0 @@ -/* - * Copyright (C) 2023  Yomitan Authors - * Copyright (C) 2020-2022  Yomichan Authors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program.  If not, see <https://www.gnu.org/licenses/>. - */ - -const fs = require('fs'); -const vm = require('vm'); -const path = require('path'); -const assert = require('assert'); -const crypto = require('crypto'); - - -function getContextEnvironmentRecords(context, names) { -    // Enables export of values from the declarative environment record -    if (!Array.isArray(names) || names.length === 0) { -        return []; -    } - -    let scriptSource = '(() => {\n    "use strict";\n    const results = [];'; -    for (const name of names) { -        scriptSource += `\n    try { results.push(${name}); } catch (e) { results.push(void 0); }`; -    } -    scriptSource += '\n    return results;\n})();'; - -    const script = new vm.Script(scriptSource, {filename: 'getContextEnvironmentRecords'}); - -    const contextHasNames = Object.prototype.hasOwnProperty.call(context, 'names'); -    const contextNames = context.names; -    context.names = names; - -    const results = script.runInContext(context, {}); - -    if (contextHasNames) { -        context.names = contextNames; -    } else { -        delete context.names; -    } - -    return Array.from(results); -} - -function isDeepStrictEqual(val1, val2) { -    if (val1 === val2) { return true; } - -    if (Array.isArray(val1)) { -        if (Array.isArray(val2)) { -            return isArrayDeepStrictEqual(val1, val2); -        } -    } else if (typeof val1 === 'object' && val1 !== null) { -        if (typeof val2 === 'object' && val2 !== null) { -            return isObjectDeepStrictEqual(val1, val2); -        } -    } - -    return false; -} - -function isArrayDeepStrictEqual(val1, val2) { -    const ii = val1.length; -    if (ii !== val2.length) { return false; } - -    for (let i = 0; i < ii; ++i) { -        if (!isDeepStrictEqual(val1[i], val2[i])) { -            return false; -        } -    } - -    return true; -} - -function isObjectDeepStrictEqual(val1, val2) { -    const keys1 = Object.keys(val1); -    const keys2 = Object.keys(val2); - -    if (keys1.length !== keys2.length) { return false; } - -    const keySet = new Set(keys1); -    for (const key of keys2) { -        if (!keySet.delete(key)) { return false; } -    } - -    for (const key of keys1) { -        if (!isDeepStrictEqual(val1[key], val2[key])) { -            return false; -        } -    } - -    const tag1 = Object.prototype.toString.call(val1); -    const tag2 = Object.prototype.toString.call(val2); -    if (tag1 !== tag2) { return false; } - -    return true; -} - -function deepStrictEqual(actual, expected) { -    try { -        // This will fail on prototype === comparison on cross context objects -        assert.deepStrictEqual(actual, expected); -    } catch (e) { -        if (!isDeepStrictEqual(actual, expected)) { -            throw e; -        } -    } -} - - -function createURLClass() { -    const BaseURL = URL; -    const result = function URL(url) { -        const u = new BaseURL(url); -        this.hash = u.hash; -        this.host = u.host; -        this.hostname = u.hostname; -        this.href = u.href; -        this.origin = u.origin; -        this.password = u.password; -        this.pathname = u.pathname; -        this.port = u.port; -        this.protocol = u.protocol; -        this.search = u.search; -        this.searchParams = u.searchParams; -        this.username = u.username; -    }; -    return result; -} - - -class VM { -    constructor(context={}) { -        context.URL = createURLClass(); -        context.crypto = { -            getRandomValues: (array) => { -                const buffer = crypto.randomBytes(array.byteLength); -                buffer.copy(array); -                return array; -            } -        }; -        this._context = vm.createContext(context); -        this._assert = { -            deepStrictEqual -        }; -    } - -    get context() { -        return this._context; -    } - -    get assert() { -        return this._assert; -    } - -    get(names) { -        if (typeof names === 'string') { -            return getContextEnvironmentRecords(this._context, [names])[0]; -        } else if (Array.isArray(names)) { -            return getContextEnvironmentRecords(this._context, names); -        } else { -            throw new Error('Invalid argument'); -        } -    } - -    set(values) { -        if (typeof values === 'object' && values !== null) { -            Object.assign(this._context, values); -        } else { -            throw new Error('Invalid argument'); -        } -    } - -    execute(fileNames) { -        const single = !Array.isArray(fileNames); -        if (single) { -            fileNames = [fileNames]; -        } - -        const results = []; -        for (const fileName of fileNames) { -            const absoluteFileName = path.resolve(__dirname, '..', 'ext', fileName); -            const source = fs.readFileSync(absoluteFileName, {encoding: 'utf8'}); -            const script = new vm.Script(source, {filename: absoluteFileName}); -            results.push(script.runInContext(this._context, {})); -        } - -        return single ? results[0] : results; -    } -} - - -module.exports = { -    VM -}; |