From 4f2a623eb0319e320acc65e3eaeb9dc8ad690f1f Mon Sep 17 00:00:00 2001 From: mike12345567 Date: Thu, 28 Jul 2022 20:20:53 +0100 Subject: [PATCH] Initial attempt at transpiling HBS to JS. --- .../common/bindings/BindingPanel.svelte | 29 ++++++- .../string-templates/src/conversion/index.js | 87 +++++++++++++++++++ .../string-templates/src/helpers/external.js | 3 + .../string-templates/src/helpers/index.js | 3 + .../src/helpers/javascript.js | 2 + packages/string-templates/src/helpers/list.js | 19 ++++ packages/string-templates/src/index.cjs | 1 + packages/string-templates/src/index.js | 29 +++++++ packages/string-templates/src/index.mjs | 1 + .../string-templates/test/hbsToJs.spec.js | 84 ++++++++++++++++++ 10 files changed, 255 insertions(+), 3 deletions(-) create mode 100644 packages/string-templates/src/conversion/index.js create mode 100644 packages/string-templates/src/helpers/list.js create mode 100644 packages/string-templates/test/hbsToJs.spec.js diff --git a/packages/builder/src/components/common/bindings/BindingPanel.svelte b/packages/builder/src/components/common/bindings/BindingPanel.svelte index f05f935226..6f0bc4615a 100644 --- a/packages/builder/src/components/common/bindings/BindingPanel.svelte +++ b/packages/builder/src/components/common/bindings/BindingPanel.svelte @@ -8,6 +8,7 @@ Tab, Body, Layout, + Button, } from "@budibase/bbui" import { createEventDispatcher, onMount } from "svelte" import { @@ -15,10 +16,14 @@ decodeJSBinding, encodeJSBinding, } from "@budibase/string-templates" - import { readableToRuntimeBinding } from "builderStore/dataBinding" + import { + readableToRuntimeBinding, + runtimeToReadableBinding, + } from "builderStore/dataBinding" import { handlebarsCompletions } from "constants/completions" import { addHBSBinding, addJSBinding } from "./utils" import CodeMirrorEditor from "components/common/CodeMirrorEditor.svelte" + import { convertToJS } from "@budibase/string-templates" const dispatch = createEventDispatcher() @@ -57,6 +62,7 @@ const updateValue = val => { valid = isValid(readableToRuntimeBinding(bindings, val)) + console.log(decodeJSBinding(readableToRuntimeBinding(bindings, val))) if (valid) { dispatch("change", val) } @@ -69,8 +75,8 @@ } // Adds a data binding to the expression - const addBinding = binding => { - if (usingJS) { + const addBinding = (binding, { forceJS } = {}) => { + if (usingJS || forceJS) { let js = decodeJSBinding(jsValue) js = addJSBinding(js, getCaretPosition(), binding.readableBinding) jsValue = encodeJSBinding(js) @@ -100,6 +106,16 @@ updateValue(jsValue) } + const convert = () => { + const runtime = readableToRuntimeBinding(bindings, hbsValue) + console.log(runtime) + const runtimeJs = encodeJSBinding(convertToJS(runtime)) + jsValue = runtimeToReadableBinding(bindings, runtimeJs) + hbsValue = null + mode = "JavaScript" + addBinding("", { forceJS: true }) + } + onMount(() => { valid = isValid(readableToRuntimeBinding(bindings, value)) }) @@ -172,6 +188,9 @@ for more details.

{/if} +
+ +
{#if allowJS} @@ -306,4 +325,8 @@ color: var(--red); text-decoration: underline; } + + .convert { + padding-top: var(--spacing-m); + } diff --git a/packages/string-templates/src/conversion/index.js b/packages/string-templates/src/conversion/index.js new file mode 100644 index 0000000000..62abdf6f54 --- /dev/null +++ b/packages/string-templates/src/conversion/index.js @@ -0,0 +1,87 @@ +const { getHelperList } = require("../helpers") + +function getLayers(fullBlock) { + let layers = [] + while (fullBlock.length) { + const start = fullBlock.lastIndexOf("("), + end = fullBlock.indexOf(")") + let layer + if (start === -1 || end === -1) { + layer = fullBlock.trim() + fullBlock = "" + } else { + const untrimmed = fullBlock.substring(start, end + 1) + layer = untrimmed.substring(1, untrimmed.length - 1).trim() + fullBlock = + fullBlock.slice(0, start) + + fullBlock.slice(start + untrimmed.length + 1, fullBlock.length) + } + layers.push(layer) + } + return layers +} + +function getVariable(variableName) { + return isNaN(parseFloat(variableName)) ? `$("${variableName}")` : variableName +} + +function buildList(parts, value) { + function build() { + return parts + .map(part => (part.startsWith("helper") ? part : getVariable(part))) + .join(", ") + } + if (!value) { + return parts.length > 1 ? `...[${build()}]` : build() + } else { + return parts.length === 0 ? value : `...[${value}, ${build()}]` + } +} + +function splitBySpace(layer) { + const parts = [] + let started = null, + last = 0 + for (let index = 0; index < layer.length; index++) { + const char = layer[index] + if (char === "[" && started == null) { + started = index + } else if (char === "]" && started != null && layer[index + 1] !== ".") { + parts.push(layer.substring(started, index + 1).trim()) + started = null + last = index + } else if (started == null && char === " ") { + parts.push(layer.substring(last, index).trim()) + last = index + } + } + if (!layer.startsWith("[")) { + parts.push(layer.substring(last, layer.length).trim()) + } + return parts +} + +module.exports.convertHBSBlock = (block, blockNumber) => { + const braceLength = block[2] === "{" ? 3 : 2 + block = block.substring(braceLength, block.length - braceLength).trim() + const layers = getLayers(block) + + let value = null + const list = getHelperList() + for (let layer of layers) { + const parts = splitBySpace(layer) + if (value || parts.length > 1) { + // first of layer should always be the helper + const helper = parts.splice(0, 1) + if (list[helper]) { + value = `helpers.${helper}(${buildList(parts, value)})` + } + } + // no helpers + else { + value = getVariable(parts[0]) + } + } + // split by space will remove square brackets + return { variable: `var${blockNumber}`, value } +} diff --git a/packages/string-templates/src/helpers/external.js b/packages/string-templates/src/helpers/external.js index 0fa7f734d0..f461045f71 100644 --- a/packages/string-templates/src/helpers/external.js +++ b/packages/string-templates/src/helpers/external.js @@ -23,6 +23,9 @@ const ADDED_HELPERS = { duration: duration, } +exports.externalCollections = EXTERNAL_FUNCTION_COLLECTIONS +exports.addedHelpers = ADDED_HELPERS + exports.registerAll = handlebars => { for (let [name, helper] of Object.entries(ADDED_HELPERS)) { handlebars.registerHelper(name, helper) diff --git a/packages/string-templates/src/helpers/index.js b/packages/string-templates/src/helpers/index.js index 76a4c5d2ca..f04fa58399 100644 --- a/packages/string-templates/src/helpers/index.js +++ b/packages/string-templates/src/helpers/index.js @@ -7,6 +7,7 @@ const { HelperFunctionBuiltin, LITERAL_MARKER, } = require("./constants") +const { getHelperList } = require("./list") const HTML_SWAPS = { "<": "<", @@ -91,3 +92,5 @@ module.exports.unregisterAll = handlebars => { // unregister all imported helpers externalHandlebars.unregisterAll(handlebars) } + +module.exports.getHelperList = getHelperList diff --git a/packages/string-templates/src/helpers/javascript.js b/packages/string-templates/src/helpers/javascript.js index 0173be0b54..951a9f534a 100644 --- a/packages/string-templates/src/helpers/javascript.js +++ b/packages/string-templates/src/helpers/javascript.js @@ -1,6 +1,7 @@ const { atob } = require("../utilities") const { cloneDeep } = require("lodash/fp") const { LITERAL_MARKER } = require("../helpers/constants") +const { getHelperList } = require("./list") // The method of executing JS scripts depends on the bundle being built. // This setter is used in the entrypoint (either index.cjs or index.mjs). @@ -45,6 +46,7 @@ module.exports.processJS = (handlebars, context) => { // app context. const sandboxContext = { $: path => getContextValue(path, cloneDeep(context)), + helpers: getHelperList(), } // Create a sandbox with our context and run the JS diff --git a/packages/string-templates/src/helpers/list.js b/packages/string-templates/src/helpers/list.js new file mode 100644 index 0000000000..a309b9e57f --- /dev/null +++ b/packages/string-templates/src/helpers/list.js @@ -0,0 +1,19 @@ +const externalHandlebars = require("./external") +const helperList = require("@budibase/handlebars-helpers") + +module.exports.getHelperList = () => { + let constructed = [] + for (let collection of externalHandlebars.externalCollections) { + constructed.push(helperList[collection]()) + } + const fullMap = {} + for (let collection of constructed) { + for (let [key, func] of Object.entries(collection)) { + fullMap[key] = func + } + } + for (let key of Object.keys(externalHandlebars.addedHelpers)) { + fullMap[key] = externalHandlebars.addedHelpers[key] + } + return fullMap +} diff --git a/packages/string-templates/src/index.cjs b/packages/string-templates/src/index.cjs index d0de680aca..870e14493a 100644 --- a/packages/string-templates/src/index.cjs +++ b/packages/string-templates/src/index.cjs @@ -19,6 +19,7 @@ module.exports.doesContainStrings = templates.doesContainStrings module.exports.doesContainString = templates.doesContainString module.exports.disableEscaping = templates.disableEscaping module.exports.findHBSBlocks = templates.findHBSBlocks +module.exports.convertToJS = templates.convertToJS /** * Use vm2 to run JS scripts in a node env diff --git a/packages/string-templates/src/index.js b/packages/string-templates/src/index.js index f4feceac4b..eae545de14 100644 --- a/packages/string-templates/src/index.js +++ b/packages/string-templates/src/index.js @@ -8,6 +8,7 @@ const { FIND_ANY_HBS_REGEX, findDoubleHbsInstances, } = require("./utilities") +const { convertHBSBlock } = require("./conversion") const hbsInstance = handlebars.create() registerAll(hbsInstance) @@ -342,3 +343,31 @@ module.exports.findHBSBlocks = string => { module.exports.doesContainString = (template, string) => { return exports.doesContainStrings(template, [string]) } + +module.exports.convertToJS = hbs => { + const blocks = exports.findHBSBlocks(hbs) + let js = "return `", + prevBlock = null + const variables = {} + if (blocks.length === 0) { + js += hbs + } + let count = 1 + for (let block of blocks) { + let stringPart = hbs + if (prevBlock) { + stringPart = stringPart.split(prevBlock)[1] + } + stringPart = stringPart.split(block)[0] + prevBlock = block + const { variable, value } = convertHBSBlock(block, count++) + variables[variable] = value + js += `${stringPart.split()}\${${variable}}` + } + let varBlock = "" + for (let [variable, value] of Object.entries(variables)) { + varBlock += `const ${variable} = ${value};\n` + } + js += "`;" + return `${varBlock}${js}` +} diff --git a/packages/string-templates/src/index.mjs b/packages/string-templates/src/index.mjs index 3d115cdec1..34cb90ea34 100644 --- a/packages/string-templates/src/index.mjs +++ b/packages/string-templates/src/index.mjs @@ -19,6 +19,7 @@ export const doesContainStrings = templates.doesContainStrings export const doesContainString = templates.doesContainString export const disableEscaping = templates.disableEscaping export const findHBSBlocks = templates.findHBSBlocks +export const convertToJS = templates.convertToJS /** * Use polyfilled vm to run JS scripts in a browser Env diff --git a/packages/string-templates/test/hbsToJs.spec.js b/packages/string-templates/test/hbsToJs.spec.js new file mode 100644 index 0000000000..1197907b29 --- /dev/null +++ b/packages/string-templates/test/hbsToJs.spec.js @@ -0,0 +1,84 @@ +const { + convertToJS +} = require("../src/index.cjs") + +function checkLines(response, lines) { + const toCheck = response.split("\n") + let count = 0 + for (let line of lines) { + expect(toCheck[count++]).toBe(line) + } +} + +describe("Test that the string processing works correctly", () => { + it("should convert string without HBS", () => { + const response = convertToJS("Hello my name is Michael") + expect(response).toBe("return `Hello my name is Michael`;") + }) + + it("basic example with square brackets", () => { + const response = convertToJS("{{ [query] }}") + checkLines(response, [ + "const var1 = $(\"[query]\");", + "return `${var1}`;", + ]) + }) + + it("should convert some basic HBS strings", () => { + const response = convertToJS("Hello {{ name }}, welcome to {{ company }}!") + checkLines(response, [ + "const var1 = $(\"name\");", + "const var2 = $(\"company\");", + "return `Hello ${var1}, welcome to ${var2}`;", + ]) + }) + + it("should handle a helper block", () => { + const response = convertToJS("This is the average: {{ avg array }}") + checkLines(response, [ + "const var1 = helpers.avg($(\"array\"));", + "return `This is the average: ${var1}`;", + ]) + }) + + it("should handle multi-variable helper", () => { + const response = convertToJS("This is the average: {{ join ( avg val1 val2 val3 ) }}") + checkLines(response, [ + "const var1 = helpers.join(helpers.avg(...[$(\"val1\"), $(\"val2\"), $(\"val3\")]));", + "return `This is the average: ${var1}`;", + ]) + }) + + it("should handle a complex statement", () => { + const response = convertToJS("This is the average: {{ join ( avg val1 val2 val3 ) val4 }}") + checkLines(response, [ + "const var1 = helpers.join(...[helpers.avg(...[$(\"val1\"), $(\"val2\"), $(\"val3\")]), $(\"val4\")]);", + "return `This is the average: ${var1}`;", + ]) + }) + + it("should handle square brackets", () => { + const response = convertToJS("This is: {{ [val thing] }}") + checkLines(response, [ + "const var1 = $(\"[val thing]\");", + "return `This is: ${var1}`;", + ]) + }) + + it("should handle square brackets with properties", () => { + const response = convertToJS("{{ [user].[_id] }}") + checkLines(response, [ + "const var1 = $(\"[user].[_id]\");", + "return `${var1}`;", + ]) + }) + + it("should handle multiple complex statements", () => { + const response = convertToJS("average: {{ avg ( abs val1 ) val2 }} add: {{ add 1 2 }}") + checkLines(response, [ + "const var1 = helpers.avg(...[helpers.abs($(\"val1\")), $(\"val2\")]);", + "const var2 = helpers.add(...[1, 2]);", + "return `average: ${var1} add: ${var2}`;", + ]) + }) +}) \ No newline at end of file