From 18f09f4e13818ef0e9455488270524178a1b0ca3 Mon Sep 17 00:00:00 2001 From: Dean Date: Thu, 22 Feb 2024 15:00:16 +0000 Subject: [PATCH 01/74] Duplicate app behaviour and test updates --- .../backend-core/src/events/publishers/app.ts | 13 ++ .../tests/core/utilities/mocks/events.ts | 1 + packages/bbui/src/Form/Core/index.js | 1 + .../src/components/deploy/DeleteModal.svelte | 40 ++++- .../src/components/start/AppRow.svelte | 7 +- .../src/components/start/AppRowContext.svelte | 71 ++++++++ .../components/start/DuplicateAppModal.svelte | 156 ++++++++++++++++++ .../components/start/ExportAppModal.svelte | 1 + .../app/[application]/settings/_layout.svelte | 8 +- packages/frontend-core/src/api/app.js | 12 ++ .../server/src/api/controllers/application.ts | 74 ++++++++- packages/server/src/api/routes/application.ts | 5 + .../src/api/routes/tests/application.spec.ts | 95 +++++++++++ .../server/src/sdk/app/backups/imports.ts | 2 +- .../src/tests/utilities/TestConfiguration.ts | 8 +- packages/types/src/sdk/events/app.ts | 8 + packages/types/src/sdk/events/event.ts | 2 + 17 files changed, 484 insertions(+), 20 deletions(-) create mode 100644 packages/builder/src/components/start/AppRowContext.svelte create mode 100644 packages/builder/src/components/start/DuplicateAppModal.svelte diff --git a/packages/backend-core/src/events/publishers/app.ts b/packages/backend-core/src/events/publishers/app.ts index d08d59b5f1..af26b09e72 100644 --- a/packages/backend-core/src/events/publishers/app.ts +++ b/packages/backend-core/src/events/publishers/app.ts @@ -13,6 +13,7 @@ import { AppVersionRevertedEvent, AppRevertedEvent, AppExportedEvent, + AppDuplicatedEvent, } from "@budibase/types" const created = async (app: App, timestamp?: string | number) => { @@ -77,6 +78,17 @@ async function fileImported(app: App) { await publishEvent(Event.APP_FILE_IMPORTED, properties) } +async function duplicated(app: App, duplicateAppId: string) { + const properties: AppDuplicatedEvent = { + duplicateAppId, + appId: app.appId, + audited: { + name: app.name, + }, + } + await publishEvent(Event.APP_DUPLICATED, properties) +} + async function templateImported(app: App, templateKey: string) { const properties: AppTemplateImportedEvent = { appId: app.appId, @@ -147,6 +159,7 @@ export default { published, unpublished, fileImported, + duplicated, templateImported, versionUpdated, versionReverted, diff --git a/packages/backend-core/tests/core/utilities/mocks/events.ts b/packages/backend-core/tests/core/utilities/mocks/events.ts index fef730768a..96f351de10 100644 --- a/packages/backend-core/tests/core/utilities/mocks/events.ts +++ b/packages/backend-core/tests/core/utilities/mocks/events.ts @@ -15,6 +15,7 @@ beforeAll(async () => { jest.spyOn(events.app, "created") jest.spyOn(events.app, "updated") + jest.spyOn(events.app, "duplicated") jest.spyOn(events.app, "deleted") jest.spyOn(events.app, "published") jest.spyOn(events.app, "unpublished") diff --git a/packages/bbui/src/Form/Core/index.js b/packages/bbui/src/Form/Core/index.js index b0edf52748..3e5befca0b 100644 --- a/packages/bbui/src/Form/Core/index.js +++ b/packages/bbui/src/Form/Core/index.js @@ -14,3 +14,4 @@ export { default as CoreStepper } from "./Stepper.svelte" export { default as CoreRichTextField } from "./RichTextField.svelte" export { default as CoreSlider } from "./Slider.svelte" export { default as CoreFile } from "./File.svelte" +export { default as CoreEnv } from "./EnvSwitch.svelte" diff --git a/packages/builder/src/components/deploy/DeleteModal.svelte b/packages/builder/src/components/deploy/DeleteModal.svelte index 855f6a0757..293d0adf60 100644 --- a/packages/builder/src/components/deploy/DeleteModal.svelte +++ b/packages/builder/src/components/deploy/DeleteModal.svelte @@ -3,9 +3,16 @@ import { goto } from "@roxi/routify" import ConfirmDialog from "components/common/ConfirmDialog.svelte" import { apps } from "stores/portal" - import { appStore } from "stores/builder" import { API } from "api" + export let appId + export let appName + export let onDeleteSuccess = () => { + $goto("/builder") + } + + let deleting = false + export const show = () => { deletionModal.show() } @@ -17,14 +24,24 @@ let deletionModal let deletionConfirmationAppName + const copyName = () => { + deletionConfirmationAppName = appName + } + const deleteApp = async () => { + if (!appId) { + console.log("No app id provided") + return + } + deleting = true try { - await API.deleteApp($appStore.appId) + await API.deleteApp(appId) apps.load() notifications.success("App deleted successfully") - $goto("/builder") + onDeleteSuccess() } catch (err) { notifications.error("Error deleting app") + deleting = false } } @@ -35,14 +52,19 @@ okText="Delete" onOk={deleteApp} onCancel={() => (deletionConfirmationAppName = null)} - disabled={deletionConfirmationAppName !== $appStore.name} + disabled={deletionConfirmationAppName !== appName || deleting} > - Are you sure you want to delete {$appStore.name}? + + Are you sure you want to delete + {appName}?
Please enter the app name below to confirm.

- + + + diff --git a/packages/builder/src/components/start/AppRow.svelte b/packages/builder/src/components/start/AppRow.svelte index c05ae4c624..dd23487870 100644 --- a/packages/builder/src/components/start/AppRow.svelte +++ b/packages/builder/src/components/start/AppRow.svelte @@ -5,6 +5,7 @@ import { goto } from "@roxi/routify" import { UserAvatars } from "@budibase/frontend-core" import { sdk } from "@budibase/shared-core" + import AppRowContext from "./AppRowContext.svelte" export let app export let lockedAction @@ -74,12 +75,10 @@ {#if isBuilder}
- - +
{:else if app.deployed} diff --git a/packages/builder/src/components/start/AppRowContext.svelte b/packages/builder/src/components/start/AppRowContext.svelte new file mode 100644 index 0000000000..f2b9d2e04c --- /dev/null +++ b/packages/builder/src/components/start/AppRowContext.svelte @@ -0,0 +1,71 @@ + + + {}} +/> + + + + + + + + + + +
+ +
+ { + duplicateModal.show() + }} + > + Duplicate + + { + exportPublishedVersion = false + exportModal.show() + }} + > + Export latest edited app + + {#if app.deployed} + { + exportPublishedVersion = true + exportModal.show() + }} + > + Export latest published app + + {/if} + { + deleteModal.show() + }} + > + Delete + +
diff --git a/packages/builder/src/components/start/DuplicateAppModal.svelte b/packages/builder/src/components/start/DuplicateAppModal.svelte new file mode 100644 index 0000000000..9a83f57215 --- /dev/null +++ b/packages/builder/src/components/start/DuplicateAppModal.svelte @@ -0,0 +1,156 @@ + + + { + validation.check({ + ...$values, + }) + if ($validation.valid) { + await duplicateApp() + } else { + return keepOpen + } + }} +> + + ($validation.touched.name = true)} + on:change={nameToUrl($values.name)} + label="Name" + placeholder={defaultAppName} + /> + + ($validation.touched.url = true)} + on:change={tidyUrl($values.url)} + label="URL" + placeholder={$values.url + ? $values.url + : `/${resolveAppUrl($values.name)}`} + /> + {#if $values.url && $values.url !== "" && !$validation.errors.url} +
+ {appUrl} +
+ {/if} +
+
+
+ + diff --git a/packages/builder/src/components/start/ExportAppModal.svelte b/packages/builder/src/components/start/ExportAppModal.svelte index 734e4448a1..ec0cf42fe0 100644 --- a/packages/builder/src/components/start/ExportAppModal.svelte +++ b/packages/builder/src/components/start/ExportAppModal.svelte @@ -121,6 +121,7 @@ @@ -67,7 +67,11 @@ - + diff --git a/packages/builder/src/components/portal/licensing/AppLimitModal.svelte b/packages/builder/src/components/portal/licensing/AppLimitModal.svelte index 39f553517e..bdecbcab3d 100644 --- a/packages/builder/src/components/portal/licensing/AppLimitModal.svelte +++ b/packages/builder/src/components/portal/licensing/AppLimitModal.svelte @@ -31,17 +31,11 @@ : null} > - You are currently on our Free plan. Upgrade - to our Pro plan to get unlimited apps and additional features. + You have exceeded the app limit for your current plan. Upgrade to get + unlimited apps and additional features! {#if !$auth.user.accountPortalAccess} Please contact the account holder to upgrade. {/if} - - diff --git a/packages/builder/src/components/start/AppRowContext.svelte b/packages/builder/src/components/start/AppRowContext.svelte index 3d24aeb542..f7986bcfa5 100644 --- a/packages/builder/src/components/start/AppRowContext.svelte +++ b/packages/builder/src/components/start/AppRowContext.svelte @@ -1,8 +1,10 @@ {}} + onDeleteSuccess={async () => { + await licensing.init() + }} /> + + - + { + await licensing.init() + }} + /> @@ -35,7 +48,11 @@ { - duplicateModal.show() + if ($licensing?.usageMetrics?.apps < 100) { + duplicateModal.show() + } else { + appLimitModal.show() + } }} > Duplicate diff --git a/packages/builder/src/components/start/DuplicateAppModal.svelte b/packages/builder/src/components/start/DuplicateAppModal.svelte index 9a83f57215..d7b600cfeb 100644 --- a/packages/builder/src/components/start/DuplicateAppModal.svelte +++ b/packages/builder/src/components/start/DuplicateAppModal.svelte @@ -15,6 +15,7 @@ export let appId export let appName + export let onDuplicateSuccess = () => {} const validation = createValidationStore() const values = writable({ name: appName + " copy", url: null }) @@ -68,6 +69,7 @@ try { await API.duplicateApp(data, appId) apps.load() + onDuplicateSuccess() notifications.success("App duplicated successfully") } catch (err) { notifications.error("Error duplicating app") diff --git a/packages/builder/src/pages/builder/app/[application]/settings/_layout.svelte b/packages/builder/src/pages/builder/app/[application]/settings/_layout.svelte index 67befddcb9..2e90faf3a9 100644 --- a/packages/builder/src/pages/builder/app/[application]/settings/_layout.svelte +++ b/packages/builder/src/pages/builder/app/[application]/settings/_layout.svelte @@ -4,6 +4,7 @@ import { url, isActive } from "@roxi/routify" import DeleteModal from "components/deploy/DeleteModal.svelte" import { isOnlyUser, appStore } from "stores/builder" + import { auth } from "stores/portal" let deleteModal diff --git a/packages/builder/src/pages/builder/portal/apps/_components/PortalSideBar.svelte b/packages/builder/src/pages/builder/portal/apps/_components/PortalSideBar.svelte index b6f66ff8e7..e487713257 100644 --- a/packages/builder/src/pages/builder/portal/apps/_components/PortalSideBar.svelte +++ b/packages/builder/src/pages/builder/portal/apps/_components/PortalSideBar.svelte @@ -1,10 +1,11 @@ From 65ca394f61b7a09935a1f013dfb80e48aa1e7470 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Tue, 5 Mar 2024 16:56:55 +0000 Subject: [PATCH 18/74] Add snippets panel --- .../common/bindings/SnippetSidePanel.svelte | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 packages/builder/src/components/common/bindings/SnippetSidePanel.svelte diff --git a/packages/builder/src/components/common/bindings/SnippetSidePanel.svelte b/packages/builder/src/components/common/bindings/SnippetSidePanel.svelte new file mode 100644 index 0000000000..6a62e7a318 --- /dev/null +++ b/packages/builder/src/components/common/bindings/SnippetSidePanel.svelte @@ -0,0 +1,186 @@ + + + + +
{@html hoveredSnippet.code}
+
+ +
+ +
+ {#if searching} +
+ +
+ + {:else} +
Snippets
+ + + {/if} +
+ +
+ {#each filteredSnippets as snippet} +
+
showSnippet(snippet, e.target)} + on:mouseleave={hidePopover} + on:click={() => addSnippet(snippet)} + > + {snippet.name} +
+
+ {/each} +
+
+
+ + From 5b3280832cadc4c480b6f5680f6189b6c848fe32 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Tue, 5 Mar 2024 18:38:48 +0000 Subject: [PATCH 19/74] Improve logic around swapping binding panel tabs --- .../common/CodeEditor/CodeEditor.svelte | 51 +++++++----- .../common/bindings/BindingPanel.svelte | 56 ++++++++++--- .../common/bindings/SnippetSidePanel.svelte | 81 +++++++++++++------ .../src/components/common/bindings/utils.js | 9 +++ .../src/helpers/javascript.js | 14 ---- 5 files changed, 141 insertions(+), 70 deletions(-) diff --git a/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte b/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte index 88661467be..90188d3094 100644 --- a/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte +++ b/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte @@ -40,20 +40,20 @@ indentMore, indentLess, } from "@codemirror/commands" - import { Compartment } from "@codemirror/state" + import { Compartment, EditorState } from "@codemirror/state" import { javascript } from "@codemirror/lang-javascript" import { EditorModes } from "./" import { themeStore } from "stores/portal" export let label export let completions = [] - export let resize = "none" export let mode = EditorModes.Handlebars export let value = "" export let placeholder = null export let autocompleteEnabled = true export let autofocus = false export let jsBindingWrapping = true + export let readonly = false // Export a function to expose caret position export const getCaretPosition = () => { @@ -143,32 +143,21 @@ const buildBaseExtensions = () => { return [ ...(mode.name === "handlebars" ? [plugin] : []), - history(), drawSelection(), dropCursor(), bracketMatching(), closeBrackets(), - highlightActiveLine(), syntaxHighlighting(oneDarkHighlightStyle, { fallback: true }), - highlightActiveLineGutter(), highlightSpecialChars(), - lineNumbers(), - foldGutter(), EditorView.lineWrapping, - EditorView.updateListener.of(v => { - const docStr = v.state.doc?.toString() - if (docStr === value) { - return - } - dispatch("change", docStr) - }), - keymap.of(buildKeymap()), themeConfig.of([...(isDark ? [oneDark] : [])]), ] } + // None of this is reactive, but it never has been, so we just assume most + // config flags aren't changed at runtime const buildExtensions = base => { - const complete = [...base] + let complete = [...base] if (autocompleteEnabled) { complete.push( @@ -210,12 +199,36 @@ if (mode.name === "javascript") { complete.push(javascript()) - complete.push(highlightWhitespace()) + if (!readonly) { + complete.push(highlightWhitespace()) + } } if (placeholder) { complete.push(placeholderFn(placeholder)) } + + if (readonly) { + complete.push(EditorState.readOnly.of(true)) + } else { + complete = [ + ...complete, + history(), + highlightActiveLine(), + highlightActiveLineGutter(), + lineNumbers(), + foldGutter(), + keymap.of(buildKeymap()), + EditorView.updateListener.of(v => { + const docStr = v.state.doc?.toString() + if (docStr === value) { + return + } + dispatch("change", docStr) + }), + ] + } + return complete } @@ -301,7 +314,6 @@ /* Active line */ .code-editor :global(.cm-line) { - height: 16px; padding: 0 var(--spacing-s); color: var(--spectrum-alias-text-color); } @@ -319,6 +331,9 @@ background: var(--spectrum-global-color-gray-100) !important; z-index: -2; } + .code-editor :global(.cm-highlightSpace:before) { + color: var(--spectrum-global-color-gray-500); + } /* Code selection */ .code-editor :global(.cm-selectionBackground) { diff --git a/packages/builder/src/components/common/bindings/BindingPanel.svelte b/packages/builder/src/components/common/bindings/BindingPanel.svelte index b40ce1aa6a..8951ba1207 100644 --- a/packages/builder/src/components/common/bindings/BindingPanel.svelte +++ b/packages/builder/src/components/common/bindings/BindingPanel.svelte @@ -25,6 +25,7 @@ } from "../CodeEditor" import BindingSidePanel from "./BindingSidePanel.svelte" import EvaluationSidePanel from "./EvaluationSidePanel.svelte" + import SnippetSidePanel from "./SnippetSidePanel.svelte" import { BindingHelpers } from "./utils" import formatHighlight from "json-format-highlight" import { capitalise } from "helpers" @@ -38,6 +39,7 @@ export let valid export let allowJS = false export let allowHelpers = true + export let allowSnippets = true export let context = null export let autofocusEditor = false @@ -49,6 +51,7 @@ const SidePanels = { Bindings: "FlashOn", Evaluation: "Play", + Snippets: "Code", } let initialValueJS = value?.startsWith?.("{{ js ") @@ -64,10 +67,8 @@ let evaluating = false $: drawerContext?.modal.subscribe(val => (drawerIsModal = val)) - $: editorTabs = allowJS ? [Modes.Text, Modes.JavaScript] : [Modes.Text] - $: sideTabs = context - ? [SidePanels.Evaluation, SidePanels.Bindings] - : [SidePanels.Bindings] + $: editorModeOptions = allowJS ? [Modes.Text, Modes.JavaScript] : [Modes.Text] + $: sidePanelOptions = getSidePanelOptions(context, allowSnippets, mode) $: enrichedBindings = enrichBindings(bindings, context) $: usingJS = mode === Modes.JavaScript $: editorMode = @@ -77,6 +78,22 @@ $: runtimeExpression = readableToRuntimeBinding(enrichedBindings, value) $: requestUpdateEvaluation(runtimeExpression, context) $: bindingHelpers = new BindingHelpers(getCaretPosition, insertAtPos) + $: { + if (!sidePanelOptions.includes(sidePanel)) { + sidePanel = SidePanels.Bindings + } + } + + const getSidePanelOptions = (context, allowSnippets, mode) => { + let options = [SidePanels.Bindings] + if (context) { + options.unshift(SidePanels.Evaluation) + } + if (allowSnippets && mode === Modes.JavaScript) { + options.push(SidePanels.Snippets) + } + return options + } const debouncedUpdateEvaluation = Utils.debounce((expression, context) => { expressionResult = processStringSync(expression || "", context) @@ -135,11 +152,22 @@ bindingHelpers.onSelectBinding(js ? jsValue : hbsValue, binding, { js }) } + const onSelectSnippet = snippet => { + bindingHelpers.onSelectSnippet(jsValue, snippet) + } + const changeMode = newMode => { if (targetMode || newMode === mode) { return } - if (editorValue) { + + // Get the raw editor value to see if we are abandoning changes + let rawValue = editorValue + if (mode === Modes.JavaScript) { + rawValue = decodeJSBinding(rawValue) + } + + if (rawValue?.length) { targetMode = newMode } else { mode = newMode @@ -178,26 +206,26 @@
- {#each editorTabs as tab} + {#each editorModeOptions as editorMode} changeMode(tab)} + selected={mode === editorMode} + on:click={() => changeMode(editorMode)} > - {capitalise(tab)} + {capitalise(editorMode)} {/each}
- {#each sideTabs as tab} + {#each sidePanelOptions as panel} changeSidePanel(tab)} + selected={sidePanel === panel} + on:click={() => changeSidePanel(panel)} > - + {/each} {#if drawerContext && get(drawerContext.resizable)} @@ -287,6 +315,8 @@ {evaluating} expression={editorValue} /> + {:else if sidePanel === SidePanels.Snippets} + {/if}
diff --git a/packages/builder/src/components/common/bindings/SnippetSidePanel.svelte b/packages/builder/src/components/common/bindings/SnippetSidePanel.svelte index 6a62e7a318..0539c4a8f5 100644 --- a/packages/builder/src/components/common/bindings/SnippetSidePanel.svelte +++ b/packages/builder/src/components/common/bindings/SnippetSidePanel.svelte @@ -1,5 +1,7 @@ @@ -254,7 +288,8 @@ ]), ]} autofocus={autofocusEditor} - placeholder="Add bindings by typing {{ or use the menu on the right" + placeholder={placeholder || + "Add bindings by typing {{ or use the menu on the right"} /> {:else if mode === Modes.JavaScript} {/if} {#if targetMode} diff --git a/packages/builder/src/components/common/bindings/SnippetDrawer.svelte b/packages/builder/src/components/common/bindings/SnippetDrawer.svelte new file mode 100644 index 0000000000..7e959053fa --- /dev/null +++ b/packages/builder/src/components/common/bindings/SnippetDrawer.svelte @@ -0,0 +1,53 @@ + + + + + {#if snippet} + + {/if} + + + + {#key key} + (code = e.detail)} + /> + {/key} + + diff --git a/packages/builder/src/components/common/bindings/SnippetSidePanel.svelte b/packages/builder/src/components/common/bindings/SnippetSidePanel.svelte index c1f3ca1531..592ca7cfcd 100644 --- a/packages/builder/src/components/common/bindings/SnippetSidePanel.svelte +++ b/packages/builder/src/components/common/bindings/SnippetSidePanel.svelte @@ -2,6 +2,7 @@ import { Input, Layout, Icon, Popover } from "@budibase/bbui" import CodeEditor from "components/common/CodeEditor/CodeEditor.svelte" import { EditorModes } from "components/common/CodeEditor" + import SnippetDrawer from "./SnippetDrawer.svelte" export let addSnippet export let snippets @@ -12,6 +13,8 @@ let popoverAnchor let hoveredSnippet let hideTimeout + let snippetDrawer + let editableSnippet $: filteredSnippets = getFilteredSnippets(snippets, search) @@ -60,9 +63,61 @@ search = "" } - const openSnippetModal = () => {} + const createSnippet = () => { + editableSnippet = null + snippetDrawer.show() + } + + const editSnippet = (e, snippet) => { + e.preventDefault() + e.stopPropagation() + editableSnippet = snippet + snippetDrawer.show() + } + + +
+ +
+ {#if searching} +
+ +
+ + {:else} +
Snippets
+ + + {/if} +
+
+ {#each filteredSnippets as snippet} +
showSnippet(snippet, e.target)} + on:mouseleave={hidePopover} + on:click={() => addSnippet(snippet)} + > + {snippet.name} + editSnippet(e, snippet)} + color="var(--spectrum-global-color-gray-700)" + /> +
+ {/each} +
+
+
+ -
- -
- {#if searching} -
- -
- - {:else} -
Snippets
- - - {/if} -
- -
- {#each filteredSnippets as snippet} -
showSnippet(snippet, e.target)} - on:mouseleave={hidePopover} - on:click={() => addSnippet(snippet)} - > - {snippet.name} -
- {/each} -
-
-
+ diff --git a/packages/builder/src/components/common/bindings/BindingPanel.svelte b/packages/builder/src/components/common/bindings/BindingPanel.svelte index 232aabc21c..bba3832efd 100644 --- a/packages/builder/src/components/common/bindings/BindingPanel.svelte +++ b/packages/builder/src/components/common/bindings/BindingPanel.svelte @@ -30,7 +30,6 @@ import formatHighlight from "json-format-highlight" import { capitalise } from "helpers" import { Utils } from "@budibase/frontend-core" - import { get } from "svelte/store" const dispatch = createEventDispatcher() @@ -46,7 +45,6 @@ export let autofocusEditor = false export let placeholder = null - const drawerContext = getContext("drawer") const Modes = { Text: "Text", JavaScript: "JavaScript", @@ -66,10 +64,8 @@ let insertAtPos let targetMode = null let expressionResult - let drawerIsModal let evaluating = false - $: drawerContext?.modal.subscribe(val => (drawerIsModal = val)) $: editorModeOptions = getModeOptions(allowHBS, allowJS) $: sidePanelOptions = getSidePanelOptions( bindings, @@ -239,18 +235,22 @@
-
- {#each editorModeOptions as editorMode} - changeMode(editorMode)} - > - {capitalise(editorMode)} - - {/each} -
+ {#if $$slots.tabs} + + {:else} +
+ {#each editorModeOptions as editorMode} + changeMode(editorMode)} + > + {capitalise(editorMode)} + + {/each} +
+ {/if}
{#each sidePanelOptions as panel} {/each} - {#if drawerContext && get(drawerContext.resizable)} - drawerContext.modal.set(!drawerIsModal)} - > - - - {/if}
diff --git a/packages/builder/src/components/common/bindings/SnippetDrawer.svelte b/packages/builder/src/components/common/bindings/SnippetDrawer.svelte index 7e959053fa..05c2b9b000 100644 --- a/packages/builder/src/components/common/bindings/SnippetDrawer.svelte +++ b/packages/builder/src/components/common/bindings/SnippetDrawer.svelte @@ -1,5 +1,5 @@ - + + + {#if snippet} + {snippet.name} + {:else} +
+ Name + + {#if !nameValid} + + + + {/if} +
+ {/if} +
{#if snippet} {/if} - + {#key key} @@ -44,6 +97,7 @@ allowHBS={false} allowJS allowSnippets={false} + showTabBar={false} placeholder="return function(input) ❴ ... ❵" value={code} on:change={e => (code = e.detail)} @@ -55,3 +109,19 @@ {/key}
+ + From 01679fbd0133deed9c0c49ef299e6d4cf34a87e7 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Wed, 6 Mar 2024 18:36:22 +0000 Subject: [PATCH 24/74] Add name validation to snippets --- .../common/bindings/SnippetDrawer.svelte | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/packages/builder/src/components/common/bindings/SnippetDrawer.svelte b/packages/builder/src/components/common/bindings/SnippetDrawer.svelte index f3a605bed4..d660f295c0 100644 --- a/packages/builder/src/components/common/bindings/SnippetDrawer.svelte +++ b/packages/builder/src/components/common/bindings/SnippetDrawer.svelte @@ -17,6 +17,7 @@ export const hide = () => drawer.hide() const roughValidNameRegex = /^[_$A-Z\xA0-\uFFFF][_$A-Z0-9\xA0-\uFFFF]*$/i + const firstCharNumberRegex = /^[0-9].*$/ let drawer let name = "" @@ -26,7 +27,7 @@ $: name = snippet?.name || "MySnippet" $: code = snippet?.code ? encodeJSBinding(snippet.code) : "" $: rawJS = decodeJSBinding(code) - $: nameValid = validateName(name) + $: nameError = validateName(name) const saveSnippet = async () => { await snippetStore.saveSnippet({ @@ -48,14 +49,20 @@ // try executing it and see if it's valid JS. The initial regex prevents // against any potential XSS attacks here. const validateName = name => { + if (!name?.length) { + return "Name is required" + } + if (firstCharNumberRegex.test(name)) { + return "Can't start with a number" + } if (!roughValidNameRegex.test(name)) { - return false + return "No special characters or spaces" } const js = `(function ${name}(){return true})()` try { - return eval(js) === true + return eval(js) === true ? null : "Invalid name" } catch (error) { - return false + return "Invalid name" } } @@ -65,14 +72,11 @@ {#if snippet} {snippet.name} {:else} -
+
Name - {#if !nameValid} - + {#if nameError} + Delete {/if} - @@ -117,9 +121,12 @@ align-items: center; position: relative; } - .name.invalid :global(input) { + .name :global(input) { width: 200px; } + .name.invalid :global(input) { + padding-right: 32px; + } .name :global(.icon) { position: absolute; right: 10px; From 4d271ccb5395986167cd76e4dd4ef4d6c155e615 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Wed, 6 Mar 2024 19:07:16 +0000 Subject: [PATCH 25/74] Add real snippet saving and fix snippet evaluation in client apps --- .../common/bindings/SnippetDrawer.svelte | 42 +++++++++--- .../builder/src/stores/builder/snippets.js | 67 ++++++------------- .../client/src/stores/derived/snippets.js | 6 +- 3 files changed, 55 insertions(+), 60 deletions(-) diff --git a/packages/builder/src/components/common/bindings/SnippetDrawer.svelte b/packages/builder/src/components/common/bindings/SnippetDrawer.svelte index d660f295c0..05a397a3c7 100644 --- a/packages/builder/src/components/common/bindings/SnippetDrawer.svelte +++ b/packages/builder/src/components/common/bindings/SnippetDrawer.svelte @@ -6,6 +6,7 @@ Icon, AbsTooltip, TooltipType, + notifications, } from "@budibase/bbui" import BindingPanel from "components/common/bindings/BindingPanel.svelte" import { decodeJSBinding, encodeJSBinding } from "@budibase/string-templates" @@ -22,24 +23,38 @@ let drawer let name = "" let code = "" + let loading = false $: key = snippet?.name $: name = snippet?.name || "MySnippet" $: code = snippet?.code ? encodeJSBinding(snippet.code) : "" $: rawJS = decodeJSBinding(code) - $: nameError = validateName(name) + $: nameError = validateName(name, $snippetStore) const saveSnippet = async () => { - await snippetStore.saveSnippet({ - name, - code: rawJS, - }) - drawer.hide() + loading = true + try { + await snippetStore.saveSnippet({ + name, + code: rawJS, + }) + drawer.hide() + notifications.success(`Snippet ${name} saved`) + } catch (error) { + notifications.error("Error saving snippet") + } + loading = false } const deleteSnippet = async () => { - await snippetStore.deleteSnippet(snippet.name) - drawer.hide() + loading = true + try { + await snippetStore.deleteSnippet(snippet.name) + drawer.hide() + } catch (error) { + notifications.error("Error deleting snippet") + } + loading = false } // Validating function names is not as easy as you think. A simple regex does @@ -48,7 +63,7 @@ // Instead, we can run a simple regex to roughly validate it, then basically // try executing it and see if it's valid JS. The initial regex prevents // against any potential XSS attacks here. - const validateName = name => { + const validateName = (name, snippets) => { if (!name?.length) { return "Name is required" } @@ -58,6 +73,9 @@ if (!roughValidNameRegex.test(name)) { return "No special characters or spaces" } + if (snippets.some(snippet => snippet.name === name)) { + return "That name is already in use" + } const js = `(function ${name}(){return true})()` try { return eval(js) === true ? null : "Invalid name" @@ -89,9 +107,11 @@ {#if snippet} - + {/if} - diff --git a/packages/builder/src/stores/builder/snippets.js b/packages/builder/src/stores/builder/snippets.js index 9c28536298..624721317a 100644 --- a/packages/builder/src/stores/builder/snippets.js +++ b/packages/builder/src/stores/builder/snippets.js @@ -1,58 +1,33 @@ -import { writable } from "svelte/store" - -const EXAMPLE_SNIPPETS = [ - { - name: "Square", - code: ` - return function(num) { - return num * num - } - `, - }, - { - name: "HelloWorld", - code: ` - return "Hello, world!" - `, - }, - { - name: "Colorful", - code: ` - let a = null - let b = "asdasd" - let c = 123123 - let d = undefined - let e = [1, 2, 3] - let f = { foo: "bar" } - let g = Math.round(1.234) - if (a === b) { - return c ?? e - } - return d || f - // comment - let h = 1 + 2 + 3 * 3 - let i = true - let j = false - `, - }, -] +import { writable, get } from "svelte/store" +import { API } from "api" +import { appStore } from "./app" const createSnippetStore = () => { - const store = writable(EXAMPLE_SNIPPETS) + const store = writable([]) const syncMetadata = metadata => { - store.set(metadata?.snippets || EXAMPLE_SNIPPETS) + store.set(metadata?.snippets || []) } - const saveSnippet = updatedSnippet => { - store.update(state => [ - ...state.filter(snippet => snippet.name !== updatedSnippet.name), + const saveSnippet = async updatedSnippet => { + const snippets = [ + ...get(store).filter(snippet => snippet.name !== updatedSnippet.name), updatedSnippet, - ]) + ] + const app = await API.saveAppMetadata({ + appId: get(appStore).appId, + metadata: { snippets }, + }) + syncMetadata(app) } - const deleteSnippet = snippetName => { - store.update(state => state.filter(snippet => snippet.name !== snippetName)) + const deleteSnippet = async snippetName => { + const snippets = get(store).filter(snippet => snippet.name !== snippetName) + const app = await API.saveAppMetadata({ + appId: get(appStore).appId, + metadata: { snippets }, + }) + syncMetadata(app) } return { diff --git a/packages/client/src/stores/derived/snippets.js b/packages/client/src/stores/derived/snippets.js index 55e7276edc..3f11c040bd 100644 --- a/packages/client/src/stores/derived/snippets.js +++ b/packages/client/src/stores/derived/snippets.js @@ -1,6 +1,6 @@ import { derived } from "svelte/store" import { appStore } from "../app.js" -export const snippets = derived(appStore, $appStore => $appStore.snippets) - -snippets.subscribe(console.log) +export const snippets = derived(appStore, $appStore => { + return $appStore?.application?.snippets || [] +}) From cb7f33de77006c7db9a2b040c8894b65bfad3a74 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Wed, 6 Mar 2024 20:27:46 +0000 Subject: [PATCH 26/74] Add automatic naming of snippets --- .../common/bindings/ClientBindingPanel.svelte | 4 +- .../common/bindings/ServerBindingPanel.svelte | 4 +- .../common/bindings/SnippetDrawer.svelte | 12 ++-- packages/builder/src/helpers/duplicate.js | 50 +++++++++++++++ .../src/helpers/tests/duplicate.test.js | 63 ++++++++++++++++++- packages/builder/src/stores/builder/index.js | 6 +- .../builder/src/stores/builder/snippets.js | 4 +- .../builder/src/stores/builder/websocket.js | 4 +- 8 files changed, 130 insertions(+), 17 deletions(-) diff --git a/packages/builder/src/components/common/bindings/ClientBindingPanel.svelte b/packages/builder/src/components/common/bindings/ClientBindingPanel.svelte index d3e80cf696..efdaa51dba 100644 --- a/packages/builder/src/components/common/bindings/ClientBindingPanel.svelte +++ b/packages/builder/src/components/common/bindings/ClientBindingPanel.svelte @@ -1,6 +1,6 @@ From 79ae159329718780cf755225b4d2cbabc2ab51fb Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Mon, 11 Mar 2024 21:10:53 +0000 Subject: [PATCH 33/74] Add code mirror completions for snippets --- .../common/CodeEditor/CodeEditor.svelte | 67 +++++++++++++++---- .../src/components/common/CodeEditor/index.js | 38 +++++++++++ .../common/bindings/BindingPanel.svelte | 2 + 3 files changed, 94 insertions(+), 13 deletions(-) diff --git a/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte b/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte index b63c646ed4..1ddc3d802a 100644 --- a/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte +++ b/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte @@ -83,8 +83,8 @@ }) } - // For handlebars only. - const bindStyle = new MatchDecorator({ + // Match decoration for HBS bindings + const hbsMatchDeco = new MatchDecorator({ regexp: FIND_ANY_HBS_REGEX, decoration: () => { return Decoration.mark({ @@ -95,12 +95,35 @@ }) }, }) - - let plugin = ViewPlugin.define( + const hbsMatchDecoPlugin = ViewPlugin.define( view => ({ - decorations: bindStyle.createDeco(view), + decorations: hbsMatchDeco.createDeco(view), update(u) { - this.decorations = bindStyle.updateDeco(u, this.decorations) + this.decorations = hbsMatchDeco.updateDeco(u, this.decorations) + }, + }), + { + decorations: v => v.decorations, + } + ) + + // Match decoration for snippets + const snippetMatchDeco = new MatchDecorator({ + regexp: /snippets.[^\s(]+/g, + decoration: () => { + return Decoration.mark({ + tag: "span", + attributes: { + class: "snippet-wrap", + }, + }) + }, + }) + const snippetMatchDecoPlugin = ViewPlugin.define( + view => ({ + decorations: snippetMatchDeco.createDeco(view), + update(u) { + this.decorations = snippetMatchDeco.updateDeco(u, this.decorations) }, }), { @@ -142,7 +165,6 @@ const buildBaseExtensions = () => { return [ - ...(mode.name === "handlebars" ? [plugin] : []), drawSelection(), dropCursor(), bracketMatching(), @@ -165,7 +187,10 @@ override: [...completions], closeOnBlur: true, icons: false, - optionClass: () => "autocomplete-option", + optionClass: completion => + completion.simple + ? "autocomplete-option-simple" + : "autocomplete-option", }) ) complete.push( @@ -191,18 +216,23 @@ view.dispatch(tr) return true } - return false }) ) } + // JS only plugins if (mode.name === "javascript") { + complete.push(snippetMatchDecoPlugin) complete.push(javascript()) if (!readonly) { complete.push(highlightWhitespace()) } } + // HBS only plugins + else { + complete.push(hbsMatchDecoPlugin) + } if (placeholder) { complete.push(placeholderFn(placeholder)) @@ -376,9 +406,12 @@ font-style: italic; } - /* Highlight bindings */ + /* Highlight bindings and snippets */ .code-editor :global(.binding-wrap) { - color: var(--spectrum-global-color-blue-700); + color: var(--spectrum-global-color-blue-700) !important; + } + .code-editor :global(.snippet-wrap *) { + color: #61afef !important; } /* Completion popover */ @@ -407,7 +440,8 @@ } /* Completion item container */ - .code-editor :global(.autocomplete-option) { + .code-editor :global(.autocomplete-option), + .code-editor :global(.autocomplete-option-simple) { padding: var(--spacing-s) var(--spacing-m) !important; padding-left: calc(16px + 2 * var(--spacing-m)) !important; display: flex; @@ -415,9 +449,13 @@ align-items: center; color: var(--spectrum-alias-text-color); } + .code-editor :global(.autocomplete-option-simple) { + padding-left: var(--spacing-s) !important; + } /* Highlighted completion item */ - .code-editor :global(.autocomplete-option[aria-selected]) { + .code-editor :global(.autocomplete-option[aria-selected]), + .code-editor :global(.autocomplete-option-simple[aria-selected]) { background: var(--spectrum-global-color-blue-400); color: white; } @@ -433,6 +471,9 @@ font-family: var(--font-sans); text-transform: capitalize; } + .code-editor :global(.autocomplete-option-simple .cm-completionLabel) { + text-transform: none; + } /* Completion item type */ .code-editor :global(.autocomplete-option .cm-completionDetail) { diff --git a/packages/builder/src/components/common/CodeEditor/index.js b/packages/builder/src/components/common/CodeEditor/index.js index c104267aa4..14c0084f3a 100644 --- a/packages/builder/src/components/common/CodeEditor/index.js +++ b/packages/builder/src/components/common/CodeEditor/index.js @@ -102,6 +102,29 @@ export const getHelperCompletions = mode => { }, []) } +export const snippetAutoComplete = snippets => { + return function myCompletions(context) { + if (!snippets?.length) { + return null + } + const word = context.matchBefore(/\w*/) + if (word.from == word.to && !context.explicit) { + return null + } + return { + from: word.from, + options: snippets.map(snippet => ({ + label: `snippets.${snippet.name}`, + type: "text", + simple: true, + apply: (view, completion, from, to) => { + insertSnippet(view, from, to, completion.label) + }, + })), + } + } +} + const bindingFilter = (options, query) => { return options.filter(completion => { const section_parsed = completion.section.name.toLowerCase() @@ -247,6 +270,21 @@ export const insertBinding = (view, from, to, text, mode) => { }) } +export const insertSnippet = (view, from, to, text, mode) => { + const parsedInsert = `${text}()` + let cursorPos = from + parsedInsert.length - 1 + view.dispatch({ + changes: { + from, + to, + insert: parsedInsert, + }, + selection: { + anchor: cursorPos, + }, + }) +} + export const bindingsToCompletions = (bindings, mode) => { const bindingByCategory = groupBy(bindings, "category") const categoryMeta = bindings?.reduce((acc, ele) => { diff --git a/packages/builder/src/components/common/bindings/BindingPanel.svelte b/packages/builder/src/components/common/bindings/BindingPanel.svelte index 96a2187755..01c2f5d55b 100644 --- a/packages/builder/src/components/common/bindings/BindingPanel.svelte +++ b/packages/builder/src/components/common/bindings/BindingPanel.svelte @@ -19,6 +19,7 @@ getHelperCompletions, jsAutocomplete, hbAutocomplete, + snippetAutoComplete, EditorModes, bindingsToCompletions, } from "../CodeEditor" @@ -98,6 +99,7 @@ ...bindingCompletions, ...getHelperCompletions(EditorModes.JS), ]), + snippetAutoComplete(snippets), ] const getModeOptions = (allowHBS, allowJS) => { From 10c581c3be7960aa3c063ec490589802f4e01f12 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Tue, 12 Mar 2024 15:39:26 +0000 Subject: [PATCH 34/74] Fetch snippets from app doc when creating a new isolate --- .../jsRunner/bundles/snippets.ivm.bundle.js | 4 +- .../server/src/jsRunner/bundles/snippets.ts | 7 ++- packages/server/src/jsRunner/index.ts | 47 +++++++++++++------ .../server/src/jsRunner/vm/isolated-vm.ts | 12 +++-- packages/types/src/documents/app/app.ts | 3 +- packages/types/src/documents/app/index.ts | 1 + packages/types/src/documents/app/snippet.ts | 4 ++ 7 files changed, 52 insertions(+), 26 deletions(-) create mode 100644 packages/types/src/documents/app/snippet.ts diff --git a/packages/server/src/jsRunner/bundles/snippets.ivm.bundle.js b/packages/server/src/jsRunner/bundles/snippets.ivm.bundle.js index fc49a121a4..5adb19eaf7 100644 --- a/packages/server/src/jsRunner/bundles/snippets.ivm.bundle.js +++ b/packages/server/src/jsRunner/bundles/snippets.ivm.bundle.js @@ -1,3 +1,3 @@ -"use strict";var snippets=(()=>{var u=Object.create;var i=Object.defineProperty;var c=Object.getOwnPropertyDescriptor;var d=Object.getOwnPropertyNames;var m=Object.getPrototypeOf,x=Object.prototype.hasOwnProperty;var l=(e,n)=>()=>(n||e((n={exports:{}}).exports,n),n.exports),W=(e,n)=>{for(var r in n)i(e,r,{get:n[r],enumerable:!0})},o=(e,n,r,p)=>{if(n&&typeof n=="object"||typeof n=="function")for(let t of d(n))!x.call(e,t)&&t!==r&&i(e,t,{get:()=>n[t],enumerable:!(p=c(n,t))||p.enumerable});return e};var g=(e,n,r)=>(r=e!=null?u(m(e)):{},o(n||!e||!e.__esModule?i(r,"default",{value:e,enumerable:!0}):r,e)),v=e=>o(i({},"__esModule",{value:!0}),e);var a=l((_,f)=>{f.exports.iifeWrapper=e=>`(function(){ +"use strict";var snippets=(()=>{var u=Object.create;var p=Object.defineProperty;var c=Object.getOwnPropertyDescriptor;var d=Object.getOwnPropertyNames;var m=Object.getPrototypeOf,x=Object.prototype.hasOwnProperty;var l=(e,n)=>()=>(n||e((n={exports:{}}).exports,n),n.exports),W=(e,n)=>{for(var i in n)p(e,i,{get:n[i],enumerable:!0})},o=(e,n,i,t)=>{if(n&&typeof n=="object"||typeof n=="function")for(let r of d(n))!x.call(e,r)&&r!==i&&p(e,r,{get:()=>n[r],enumerable:!(t=c(n,r))||t.enumerable});return e};var g=(e,n,i)=>(i=e!=null?u(m(e)):{},o(n||!e||!e.__esModule?p(i,"default",{value:e,enumerable:!0}):i,e)),v=e=>o(p({},"__esModule",{value:!0}),e);var a=l((P,f)=>{f.exports.iifeWrapper=e=>`(function(){ ${e} -})();`});var y={};W(y,{default:()=>w});var s=g(a()),w=new Proxy({},{get:function(e,n){let r=($("snippets")||[]).find(p=>p.name===n);return[eval][0]((0,s.iifeWrapper)(r.code))}});return v(y);})(); +})();`});var y={};W(y,{default:()=>w});var s=g(a()),w=new Proxy({},{get:function(e,n){let i=(snippetDefinitions||[]).find(t=>t.name===n);return[eval][0]((0,s.iifeWrapper)(i.code))}});return v(y);})(); diff --git a/packages/server/src/jsRunner/bundles/snippets.ts b/packages/server/src/jsRunner/bundles/snippets.ts index 861bacaec5..f473aaf7b4 100644 --- a/packages/server/src/jsRunner/bundles/snippets.ts +++ b/packages/server/src/jsRunner/bundles/snippets.ts @@ -6,14 +6,13 @@ export default new Proxy( {}, { get: function (_, name) { - // Get snippet definitions from global context, get the correct snippet - // then eval the JS. This will error if the snippet doesn't exist, but - // that's intended. + // Snippet definitions are injected to the isolate global scope before + // this bundle is loaded, so we can access it from there. // https://esbuild.github.io/content-types/#direct-eval for info on why // eval is being called this way. // @ts-ignore // eslint-disable-next-line no-undef - const snippet = ($("snippets") || []).find(x => x.name === name) + const snippet = (snippetDefinitions || []).find(x => x.name === name) return [eval][0](iifeWrapper(snippet.code)) }, } diff --git a/packages/server/src/jsRunner/index.ts b/packages/server/src/jsRunner/index.ts index 67aaffae7f..d97fa4cc94 100644 --- a/packages/server/src/jsRunner/index.ts +++ b/packages/server/src/jsRunner/index.ts @@ -8,29 +8,48 @@ import { import { context, logging } from "@budibase/backend-core" import tracer from "dd-trace" import { IsolatedVM } from "./vm" +import { App, DocumentType, Snippet, VM } from "@budibase/types" + +async function getIsolate(ctx: any): Promise { + // Reuse the existing isolate if one exists + if (ctx?.vm) { + return ctx.vm + } + + // Get snippets to build into new isolate, if inside app context + let snippets: Snippet[] | undefined + const db = context.getAppDB() + if (db) { + console.log("READ APP METADATA") + const app = await db.get(DocumentType.APP_METADATA) + snippets = app.snippets + } + + // Build a new isolate + return new IsolatedVM({ + memoryLimit: env.JS_RUNNER_MEMORY_LIMIT, + invocationTimeout: env.JS_PER_INVOCATION_TIMEOUT_MS, + isolateAccumulatedTimeout: env.JS_PER_REQUEST_TIMEOUT_MS, + }) + .withHelpers() + .withSnippets(snippets) +} export function init() { setJSRunner((js: string, ctx: Record) => { - return tracer.trace("runJS", {}, span => { + return tracer.trace("runJS", {}, async span => { try { + // Reuse an existing isolate from context, or make a new one const bbCtx = context.getCurrentContext() - - const vm = bbCtx?.vm - ? bbCtx.vm - : new IsolatedVM({ - memoryLimit: env.JS_RUNNER_MEMORY_LIMIT, - invocationTimeout: env.JS_PER_INVOCATION_TIMEOUT_MS, - isolateAccumulatedTimeout: env.JS_PER_REQUEST_TIMEOUT_MS, - }) - .withHelpers() - .withSnippets() - + const vm = await getIsolate(bbCtx) if (bbCtx) { - // If we have a context, we want to persist it to reuse the isolate bbCtx.vm = vm } + + // Strip helpers (an array) and snippets (a proxy isntance) as these + // will not survive the isolated-vm barrier const { helpers, snippets, ...rest } = ctx - return vm.withContext(rest, () => vm.execute(js)) + return vm.withContext(rest, () => vm!.execute(js)) } catch (error: any) { if (error.message === "Script execution timed out.") { throw new JsErrorTimeout() diff --git a/packages/server/src/jsRunner/vm/isolated-vm.ts b/packages/server/src/jsRunner/vm/isolated-vm.ts index fb45abf5df..f18888895f 100644 --- a/packages/server/src/jsRunner/vm/isolated-vm.ts +++ b/packages/server/src/jsRunner/vm/isolated-vm.ts @@ -6,7 +6,7 @@ import crypto from "crypto" import querystring from "querystring" import { BundleType, loadBundle } from "../bundles" -import { VM } from "@budibase/types" +import { Snippet, VM } from "@budibase/types" import { iifeWrapper } from "@budibase/string-templates" import environment from "../../environment" @@ -98,11 +98,13 @@ export class IsolatedVM implements VM { return this } - withSnippets() { + withSnippets(snippets?: Snippet[]) { const snippetsSource = loadBundle(BundleType.SNIPPETS) - const script = this.isolate.compileScriptSync( - `${snippetsSource};snippets=snippets.default;` - ) + const script = this.isolate.compileScriptSync(` + const snippetDefinitions = ${JSON.stringify(snippets || [])}; + ${snippetsSource}; + snippets = snippets.default; + `) script.runSync(this.vm, { timeout: this.invocationTimeout, release: false }) new Promise(() => { script.release() diff --git a/packages/types/src/documents/app/app.ts b/packages/types/src/documents/app/app.ts index ae4f3fa6da..3b7f481253 100644 --- a/packages/types/src/documents/app/app.ts +++ b/packages/types/src/documents/app/app.ts @@ -1,4 +1,4 @@ -import { User, Document, Plugin } from "../" +import { User, Document, Plugin, Snippet } from "../" import { SocketSession } from "../../sdk" export type AppMetadataErrors = { [key: string]: string[] } @@ -26,6 +26,7 @@ export interface App extends Document { automations?: AutomationSettings usedPlugins?: Plugin[] upgradableVersion?: string + snippets?: Snippet[] } export interface AppInstance { diff --git a/packages/types/src/documents/app/index.ts b/packages/types/src/documents/app/index.ts index b81c9e36ac..a58b708de3 100644 --- a/packages/types/src/documents/app/index.ts +++ b/packages/types/src/documents/app/index.ts @@ -14,3 +14,4 @@ export * from "./backup" export * from "./webhook" export * from "./links" export * from "./component" +export * from "./snippet" diff --git a/packages/types/src/documents/app/snippet.ts b/packages/types/src/documents/app/snippet.ts new file mode 100644 index 0000000000..1b8433b32e --- /dev/null +++ b/packages/types/src/documents/app/snippet.ts @@ -0,0 +1,4 @@ +export interface Snippet { + name: string + code: string +} From 16ce5ac65e4c9347d2329a4adf3ba9f3ae731821 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Tue, 12 Mar 2024 17:02:01 +0000 Subject: [PATCH 35/74] Update how snippets are fetched and enriched into context, because HBS helpers can't be async --- .../backend-core/src/context/mainContext.ts | 23 +++++++++- packages/backend-core/src/context/types.ts | 3 +- .../api/controllers/row/ExternalRequest.ts | 23 ++++++---- .../src/api/controllers/row/staticFormula.ts | 6 +-- packages/server/src/db/linkedRows/index.ts | 3 +- packages/server/src/jsRunner/index.ts | 42 ++++++------------- .../src/utilities/rowProcessor/index.ts | 2 +- .../src/utilities/rowProcessor/utils.ts | 11 +++-- 8 files changed, 66 insertions(+), 47 deletions(-) diff --git a/packages/backend-core/src/context/mainContext.ts b/packages/backend-core/src/context/mainContext.ts index ae86695168..c45536c2e2 100644 --- a/packages/backend-core/src/context/mainContext.ts +++ b/packages/backend-core/src/context/mainContext.ts @@ -10,7 +10,7 @@ import { StaticDatabases, DEFAULT_TENANT_ID, } from "../constants" -import { Database, IdentityContext } from "@budibase/types" +import { Database, IdentityContext, Snippet, App } from "@budibase/types" import { ContextMap } from "./types" let TEST_APP_ID: string | null = null @@ -281,6 +281,27 @@ export function doInScimContext(task: any) { return newContext(updates, task) } +export async function ensureSnippetContext() { + const ctx = getCurrentContext() + + // If we've already added snippets to context, continue + if (!ctx || ctx.snippets) { + return + } + + // Otherwise get snippets for this app and update context + let snippets: Snippet[] | undefined + const db = getAppDB() + if (db) { + const app = await db.get(DocumentType.APP_METADATA) + snippets = app.snippets + } + + // Always set snippets to a non-null value so that we can tell we've attempted + // to load snippets + ctx.snippets = snippets || [] +} + export function getEnvironmentVariables() { const context = Context.get() if (!context.environmentVariables) { diff --git a/packages/backend-core/src/context/types.ts b/packages/backend-core/src/context/types.ts index 6fb9f44fad..3d5e106ed3 100644 --- a/packages/backend-core/src/context/types.ts +++ b/packages/backend-core/src/context/types.ts @@ -1,4 +1,4 @@ -import { IdentityContext, VM } from "@budibase/types" +import { IdentityContext, Snippet, VM } from "@budibase/types" import { ExecutionTimeTracker } from "../timers" // keep this out of Budibase types, don't want to expose context info @@ -12,4 +12,5 @@ export type ContextMap = { isMigrating?: boolean jsExecutionTracker?: ExecutionTimeTracker vm?: VM + snippets?: Snippet[] } diff --git a/packages/server/src/api/controllers/row/ExternalRequest.ts b/packages/server/src/api/controllers/row/ExternalRequest.ts index 685af4e98e..814b57567f 100644 --- a/packages/server/src/api/controllers/row/ExternalRequest.ts +++ b/packages/server/src/api/controllers/row/ExternalRequest.ts @@ -437,11 +437,11 @@ export class ExternalRequest { return { row: newRow, manyRelationships } } - processRelationshipFields( + async processRelationshipFields( table: Table, row: Row, relationships: RelationshipsJson[] - ): Row { + ): Promise { for (let relationship of relationships) { const linkedTable = this.tables[relationship.tableName] if (!linkedTable || !row[relationship.column]) { @@ -457,7 +457,7 @@ export class ExternalRequest { } // process additional types relatedRow = processDates(table, relatedRow) - relatedRow = processFormulas(linkedTable, relatedRow) + relatedRow = await processFormulas(linkedTable, relatedRow) row[relationship.column][key] = relatedRow } } @@ -521,7 +521,7 @@ export class ExternalRequest { return rows } - outputProcessing( + async outputProcessing( rows: Row[] = [], table: Table, relationships: RelationshipsJson[] @@ -561,9 +561,12 @@ export class ExternalRequest { } // make sure all related rows are correct - let finalRowArray = Object.values(finalRows).map(row => - this.processRelationshipFields(table, row, relationships) - ) + let finalRowArray = [] + for (let row of Object.values(finalRows)) { + finalRowArray.push( + await this.processRelationshipFields(table, row, relationships) + ) + } // process some additional types finalRowArray = processDates(table, finalRowArray) @@ -934,7 +937,11 @@ export class ExternalRequest { processed.manyRelationships ) } - const output = this.outputProcessing(responseRows, table, relationships) + const output = await this.outputProcessing( + responseRows, + table, + relationships + ) // if reading it'll just be an array of rows, return whole thing if (operation === Operation.READ) { return ( diff --git a/packages/server/src/api/controllers/row/staticFormula.ts b/packages/server/src/api/controllers/row/staticFormula.ts index 0ea8b3560e..a75a6cd2cc 100644 --- a/packages/server/src/api/controllers/row/staticFormula.ts +++ b/packages/server/src/api/controllers/row/staticFormula.ts @@ -110,7 +110,7 @@ export async function updateAllFormulasInTable(table: Table) { (enriched: Row) => enriched._id === row._id ) if (enrichedRow) { - const processed = processFormulas(table, cloneDeep(row), { + const processed = await processFormulas(table, cloneDeep(row), { dynamic: false, contextRows: [enrichedRow], }) @@ -143,7 +143,7 @@ export async function finaliseRow( squash: false, })) as Row // use enriched row to generate formulas for saving, specifically only use as context - row = processFormulas(table, row, { + row = await processFormulas(table, row, { dynamic: false, contextRows: [enrichedRow], }) @@ -179,7 +179,7 @@ export async function finaliseRow( const response = await db.put(row) // for response, calculate the formulas for the enriched row enrichedRow._rev = response.rev - enrichedRow = processFormulas(table, enrichedRow, { + enrichedRow = await processFormulas(table, enrichedRow, { dynamic: false, }) // this updates the related formulas in other rows based on the relations to this row diff --git a/packages/server/src/db/linkedRows/index.ts b/packages/server/src/db/linkedRows/index.ts index 7af3f9392f..513e0c0df2 100644 --- a/packages/server/src/db/linkedRows/index.ts +++ b/packages/server/src/db/linkedRows/index.ts @@ -202,7 +202,8 @@ export async function attachFullLinkedDocs( table => table._id === linkedTableId ) if (linkedTable) { - row[link.fieldName].push(processFormulas(linkedTable, linkedRow)) + const processed = await processFormulas(linkedTable, linkedRow) + row[link.fieldName].push(processed) } } } diff --git a/packages/server/src/jsRunner/index.ts b/packages/server/src/jsRunner/index.ts index d97fa4cc94..ff39ce0666 100644 --- a/packages/server/src/jsRunner/index.ts +++ b/packages/server/src/jsRunner/index.ts @@ -8,40 +8,24 @@ import { import { context, logging } from "@budibase/backend-core" import tracer from "dd-trace" import { IsolatedVM } from "./vm" -import { App, DocumentType, Snippet, VM } from "@budibase/types" - -async function getIsolate(ctx: any): Promise { - // Reuse the existing isolate if one exists - if (ctx?.vm) { - return ctx.vm - } - - // Get snippets to build into new isolate, if inside app context - let snippets: Snippet[] | undefined - const db = context.getAppDB() - if (db) { - console.log("READ APP METADATA") - const app = await db.get(DocumentType.APP_METADATA) - snippets = app.snippets - } - - // Build a new isolate - return new IsolatedVM({ - memoryLimit: env.JS_RUNNER_MEMORY_LIMIT, - invocationTimeout: env.JS_PER_INVOCATION_TIMEOUT_MS, - isolateAccumulatedTimeout: env.JS_PER_REQUEST_TIMEOUT_MS, - }) - .withHelpers() - .withSnippets(snippets) -} export function init() { setJSRunner((js: string, ctx: Record) => { - return tracer.trace("runJS", {}, async span => { + return tracer.trace("runJS", {}, span => { try { // Reuse an existing isolate from context, or make a new one const bbCtx = context.getCurrentContext() - const vm = await getIsolate(bbCtx) + const vm = + bbCtx?.vm || + new IsolatedVM({ + memoryLimit: env.JS_RUNNER_MEMORY_LIMIT, + invocationTimeout: env.JS_PER_INVOCATION_TIMEOUT_MS, + isolateAccumulatedTimeout: env.JS_PER_REQUEST_TIMEOUT_MS, + }) + .withHelpers() + .withSnippets(bbCtx?.snippets) + + // Persist isolate in context so we can reuse it if (bbCtx) { bbCtx.vm = vm } @@ -49,7 +33,7 @@ export function init() { // Strip helpers (an array) and snippets (a proxy isntance) as these // will not survive the isolated-vm barrier const { helpers, snippets, ...rest } = ctx - return vm.withContext(rest, () => vm!.execute(js)) + return vm.withContext(rest, () => vm.execute(js)) } catch (error: any) { if (error.message === "Script execution timed out.") { throw new JsErrorTimeout() diff --git a/packages/server/src/utilities/rowProcessor/index.ts b/packages/server/src/utilities/rowProcessor/index.ts index e3ca576fb7..d956a94d0b 100644 --- a/packages/server/src/utilities/rowProcessor/index.ts +++ b/packages/server/src/utilities/rowProcessor/index.ts @@ -245,7 +245,7 @@ export async function outputProcessing( } // process formulas after the complex types had been processed - enriched = processFormulas(table, enriched, { dynamic: true }) + enriched = await processFormulas(table, enriched, { dynamic: true }) if (opts.squash) { enriched = (await linkRows.squashLinksToPrimaryDisplay( diff --git a/packages/server/src/utilities/rowProcessor/utils.ts b/packages/server/src/utilities/rowProcessor/utils.ts index d0fc82b3ee..7292886084 100644 --- a/packages/server/src/utilities/rowProcessor/utils.ts +++ b/packages/server/src/utilities/rowProcessor/utils.ts @@ -10,6 +10,8 @@ import { FieldType, } from "@budibase/types" import tracer from "dd-trace" +import { context } from "@budibase/backend-core" +import { getCurrentContext } from "@budibase/backend-core/src/context" interface FormulaOpts { dynamic?: boolean @@ -44,16 +46,19 @@ export function fixAutoColumnSubType( /** * Looks through the rows provided and finds formulas - which it then processes. */ -export function processFormulas( +export async function processFormulas( table: Table, inputRows: T, { dynamic, contextRows }: FormulaOpts = { dynamic: true } -): T { - return tracer.trace("processFormulas", {}, span => { +): Promise { + return tracer.trace("processFormulas", {}, async span => { const numRows = Array.isArray(inputRows) ? inputRows.length : 1 span?.addTags({ table_id: table._id, dynamic, numRows }) const rows = Array.isArray(inputRows) ? inputRows : [inputRows] if (rows) { + // Ensure we have snippet context + await context.ensureSnippetContext() + for (let [column, schema] of Object.entries(table.schema)) { if (schema.type !== FieldType.FORMULA) { continue From fda71de7c2147e93277f7bacc84cf65ac30b5d25 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Tue, 12 Mar 2024 19:07:38 +0000 Subject: [PATCH 36/74] Remove unused import --- packages/builder/src/components/common/CodeEditor/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/builder/src/components/common/CodeEditor/index.js b/packages/builder/src/components/common/CodeEditor/index.js index 14c0084f3a..b93c95b944 100644 --- a/packages/builder/src/components/common/CodeEditor/index.js +++ b/packages/builder/src/components/common/CodeEditor/index.js @@ -270,7 +270,7 @@ export const insertBinding = (view, from, to, text, mode) => { }) } -export const insertSnippet = (view, from, to, text, mode) => { +export const insertSnippet = (view, from, to, text) => { const parsedInsert = `${text}()` let cursorPos = from + parsedInsert.length - 1 view.dispatch({ From 28d938ba3e19c67470a4dea108287e2f6c7dc0f5 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Tue, 12 Mar 2024 19:09:32 +0000 Subject: [PATCH 37/74] Lint --- packages/server/src/utilities/rowProcessor/utils.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/server/src/utilities/rowProcessor/utils.ts b/packages/server/src/utilities/rowProcessor/utils.ts index 7292886084..8201680f13 100644 --- a/packages/server/src/utilities/rowProcessor/utils.ts +++ b/packages/server/src/utilities/rowProcessor/utils.ts @@ -11,7 +11,6 @@ import { } from "@budibase/types" import tracer from "dd-trace" import { context } from "@budibase/backend-core" -import { getCurrentContext } from "@budibase/backend-core/src/context" interface FormulaOpts { dynamic?: boolean From 3b54daf2c8d798c44b8fced765c5c6a70ba96649 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Tue, 12 Mar 2024 21:40:48 +0000 Subject: [PATCH 38/74] Add snippet context before executing automations --- packages/backend-core/src/context/mainContext.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/backend-core/src/context/mainContext.ts b/packages/backend-core/src/context/mainContext.ts index c45536c2e2..f575d6950d 100644 --- a/packages/backend-core/src/context/mainContext.ts +++ b/packages/backend-core/src/context/mainContext.ts @@ -129,7 +129,10 @@ export async function doInAutomationContext(params: { appId: params.appId, automationId: params.automationId, }, - params.task + async () => { + await ensureSnippetContext() + return await params.task() + } ) } From 20f4c5a77d993a38c16daabc6cc0cb9398668695 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Tue, 12 Mar 2024 21:41:00 +0000 Subject: [PATCH 39/74] Add snippet context before testing automations manually --- packages/server/src/automations/triggers.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/server/src/automations/triggers.ts b/packages/server/src/automations/triggers.ts index 08e3199a11..3336211c5d 100644 --- a/packages/server/src/automations/triggers.ts +++ b/packages/server/src/automations/triggers.ts @@ -112,6 +112,7 @@ export async function externalTrigger( const data: AutomationData = { automation, event: params as any } if (getResponses) { + await context.ensureSnippetContext() data.event = { ...data.event, appId: context.getAppId(), From 70821182fe1b26673a9fb289295ef478d3f8a7ae Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Wed, 13 Mar 2024 09:15:33 +0000 Subject: [PATCH 40/74] Update automation context to simplify applying snippet context --- packages/backend-core/src/context/mainContext.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/backend-core/src/context/mainContext.ts b/packages/backend-core/src/context/mainContext.ts index f575d6950d..9d4cc9096d 100644 --- a/packages/backend-core/src/context/mainContext.ts +++ b/packages/backend-core/src/context/mainContext.ts @@ -122,17 +122,14 @@ export async function doInAutomationContext(params: { automationId: string task: () => T }): Promise { - const tenantId = getTenantIDFromAppID(params.appId) + await ensureSnippetContext() return newContext( { - tenantId, + tenantId: getTenantIDFromAppID(params.appId), appId: params.appId, automationId: params.automationId, }, - async () => { - await ensureSnippetContext() - return await params.task() - } + params.task ) } From 1eafd5e8437ca66096f54ff017f62530528f3bb7 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Wed, 13 Mar 2024 09:22:13 +0000 Subject: [PATCH 41/74] Fix issue with drawer positioning when nesting drawers with no target --- packages/bbui/src/Drawer/Drawer.svelte | 66 ++++++++++++-------------- 1 file changed, 30 insertions(+), 36 deletions(-) diff --git a/packages/bbui/src/Drawer/Drawer.svelte b/packages/bbui/src/Drawer/Drawer.svelte index 8bb11b833a..04e678c4e5 100644 --- a/packages/bbui/src/Drawer/Drawer.svelte +++ b/packages/bbui/src/Drawer/Drawer.svelte @@ -172,43 +172,37 @@ {#if visible} -
-
-
0} - class:modal={$modal} - transition:drawerSlide|local - {style} - > -
- {#if $$slots.title} - - {:else} -
{title || "Bindings"}
+
+
0} + class:modal={$modal} + transition:drawerSlide|local + {style} + > +
+ {#if $$slots.title} + + {:else} +
{title || "Bindings"}
+ {/if} +
+ + + {#if $resizable} + modal.set(!$modal)} + > + + {/if} -
- - - {#if $resizable} - modal.set(!$modal)} - > - - - {/if} -
-
- -
-
+
+
+ +
{/if} From c25ea7a9d7e3e3225735aad706207163777408ee Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Wed, 13 Mar 2024 09:42:31 +0000 Subject: [PATCH 42/74] Fix external triggers not getting snippet context --- packages/server/src/automations/triggers.ts | 1 - packages/server/src/threads/automation.ts | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/src/automations/triggers.ts b/packages/server/src/automations/triggers.ts index 3336211c5d..08e3199a11 100644 --- a/packages/server/src/automations/triggers.ts +++ b/packages/server/src/automations/triggers.ts @@ -112,7 +112,6 @@ export async function externalTrigger( const data: AutomationData = { automation, event: params as any } if (getResponses) { - await context.ensureSnippetContext() data.event = { ...data.event, appId: context.getAppId(), diff --git a/packages/server/src/threads/automation.ts b/packages/server/src/threads/automation.ts index a4938bb138..4e33fadce6 100644 --- a/packages/server/src/threads/automation.ts +++ b/packages/server/src/threads/automation.ts @@ -625,6 +625,7 @@ export async function executeInThread(job: Job) { }) return await context.doInAppContext(appId, async () => { + await context.ensureSnippetContext() const envVars = await sdkUtils.getEnvironmentVariables() // put into automation thread for whole context return await context.doInEnvironmentContext(envVars, async () => { From 0ddf48f7ffc39de96c3ae1f8082a7574b8291ae2 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Wed, 13 Mar 2024 09:54:27 +0000 Subject: [PATCH 43/74] Update automation page to use full width drawers like the design section does --- .../pages/builder/app/[application]/automation/_layout.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/builder/src/pages/builder/app/[application]/automation/_layout.svelte b/packages/builder/src/pages/builder/app/[application]/automation/_layout.svelte index c4ee060149..57180625b1 100644 --- a/packages/builder/src/pages/builder/app/[application]/automation/_layout.svelte +++ b/packages/builder/src/pages/builder/app/[application]/automation/_layout.svelte @@ -40,7 +40,7 @@
-
+
{#if $automationStore.automations?.length} {:else} From 2d12a1a8fa2aeca8e68d9643c17d971eac7792cd Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Wed, 13 Mar 2024 11:48:17 +0000 Subject: [PATCH 44/74] Add server-side validation for snippet names --- .../common/bindings/SnippetDrawer.svelte | 21 +++++++------------ .../server/src/api/routes/utils/validators.ts | 17 +++++++++++++++ packages/shared-core/src/constants/index.ts | 1 + 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/packages/builder/src/components/common/bindings/SnippetDrawer.svelte b/packages/builder/src/components/common/bindings/SnippetDrawer.svelte index e7dd5c7a22..3badf0d8c3 100644 --- a/packages/builder/src/components/common/bindings/SnippetDrawer.svelte +++ b/packages/builder/src/components/common/bindings/SnippetDrawer.svelte @@ -13,13 +13,13 @@ import { snippets } from "stores/builder" import { getSequentialName } from "helpers/duplicate" import ConfirmDialog from "components/common/ConfirmDialog.svelte" + import { ValidSnippetNameRegex } from "@budibase/shared-core" export let snippet export const show = () => drawer.show() export const hide = () => drawer.hide() - const roughValidNameRegex = /^[_$A-Z\xA0-\uFFFF][_$A-Z0-9\xA0-\uFFFF]*$/i const firstCharNumberRegex = /^[0-9].*$/ let drawer @@ -43,7 +43,7 @@ drawer.hide() notifications.success(`Snippet ${newSnippet.name} saved`) } catch (error) { - notifications.error("Error saving snippet") + notifications.error(error.message || "Error saving snippet") } loading = false } @@ -69,21 +69,16 @@ if (!name?.length) { return "Name is required" } - if (firstCharNumberRegex.test(name)) { - return "Can't start with a number" - } - if (!roughValidNameRegex.test(name)) { - return "No special characters or spaces" - } if (snippets.some(snippet => snippet.name === name)) { return "That name is already in use" } - const js = `(function ${name}(){return true})()` - try { - return eval(js) === true ? null : "Invalid name" - } catch (error) { - return "Invalid name" + if (firstCharNumberRegex.test(name)) { + return "Can't start with a number" } + if (!ValidSnippetNameRegex.test(name)) { + return "No special characters or spaces" + } + return null } diff --git a/packages/server/src/api/routes/utils/validators.ts b/packages/server/src/api/routes/utils/validators.ts index 55766cd120..424d0d6c79 100644 --- a/packages/server/src/api/routes/utils/validators.ts +++ b/packages/server/src/api/routes/utils/validators.ts @@ -2,6 +2,7 @@ import { auth, permissions } from "@budibase/backend-core" import { DataSourceOperation } from "../../../constants" import { WebhookActionType } from "@budibase/types" import Joi from "joi" +import { ValidSnippetNameRegex } from "@budibase/shared-core" const OPTIONAL_STRING = Joi.string().optional().allow(null).allow("") const OPTIONAL_NUMBER = Joi.number().optional().allow(null) @@ -226,6 +227,21 @@ export function applicationValidator(opts = { isCreate: true }) { base.name = appNameValidator.optional() } + const snippetValidator = Joi.array() + .optional() + .items( + Joi.object({ + name: Joi.string() + .pattern(new RegExp(ValidSnippetNameRegex)) + .error( + new Error( + "Snippet name cannot include spaces or special characters, and cannot start with a number" + ) + ), + code: OPTIONAL_STRING, + }) + ) + return auth.joiValidator.body( Joi.object({ _id: OPTIONAL_STRING, @@ -235,6 +251,7 @@ export function applicationValidator(opts = { isCreate: true }) { template: Joi.object({ templateString: OPTIONAL_STRING, }).unknown(true), + snippets: snippetValidator, }).unknown(true) ) } diff --git a/packages/shared-core/src/constants/index.ts b/packages/shared-core/src/constants/index.ts index 99fb5c2a73..b5b651a3da 100644 --- a/packages/shared-core/src/constants/index.ts +++ b/packages/shared-core/src/constants/index.ts @@ -98,6 +98,7 @@ export enum BuilderSocketEvent { export const SocketSessionTTL = 60 export const ValidQueryNameRegex = /^[^()]*$/ export const ValidColumnNameRegex = /^[_a-zA-Z0-9\s]*$/g +export const ValidSnippetNameRegex = /^[a-z-_][a-z0-9-_]*$/i export const REBOOT_CRON = "@reboot" From a1186cd6d30845ab66d0a268b9a86b1001e41f33 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Wed, 13 Mar 2024 12:01:26 +0000 Subject: [PATCH 45/74] Remove testing snippet code --- packages/string-templates/package.json | 3 +-- packages/string-templates/src/helpers/snippet.js | 2 -- 2 files changed, 1 insertion(+), 4 deletions(-) delete mode 100644 packages/string-templates/src/helpers/snippet.js diff --git a/packages/string-templates/package.json b/packages/string-templates/package.json index 1f3e1b618a..340d74ef8a 100644 --- a/packages/string-templates/package.json +++ b/packages/string-templates/package.json @@ -13,8 +13,7 @@ }, "./package.json": "./package.json", "./test/utils": "./test/utils.js", - "./iife": "./src/iife.js", - "./snippet": "./src/helpers/snippet.js" + "./iife": "./src/iife.js" }, "files": [ "dist", diff --git a/packages/string-templates/src/helpers/snippet.js b/packages/string-templates/src/helpers/snippet.js deleted file mode 100644 index b7269b56cc..0000000000 --- a/packages/string-templates/src/helpers/snippet.js +++ /dev/null @@ -1,2 +0,0 @@ -module.exports.CrazyLongSnippet = - '/**\n * marked - a markdown parser\n * Copyright (c) 2011-2022, Christopher Jeffrey. (MIT Licensed)\n * https://github.com/markedjs/marked\n */\n\n/**\n * DO NOT EDIT THIS FILE\n * The code in this file is generated from files in ./src/\n */\n\nfunction getDefaults() {\n return {\n baseUrl: null,\n breaks: false,\n extensions: null,\n gfm: true,\n headerIds: true,\n headerPrefix: "",\n highlight: null,\n langPrefix: "language-",\n mangle: true,\n pedantic: false,\n renderer: null,\n sanitize: false,\n sanitizer: null,\n silent: false,\n smartLists: false,\n smartypants: false,\n tokenizer: null,\n walkTokens: null,\n xhtml: false,\n }\n}\n\nlet defaults = getDefaults()\n\nfunction changeDefaults(newDefaults) {\n defaults = newDefaults\n}\n\n/**\n * Helpers\n */\nconst escapeTest = /[&<>"\']/\nconst escapeReplace = /[&<>"\']/g\nconst escapeTestNoEncode = /[<>"\']|&(?!#?\\w+;)/\nconst escapeReplaceNoEncode = /[<>"\']|&(?!#?\\w+;)/g\nconst escapeReplacements = {\n "&": "&",\n "<": "<",\n ">": ">",\n \'"\': """,\n "\'": "'",\n}\nconst getEscapeReplacement = ch => escapeReplacements[ch]\nfunction escape(html, encode) {\n if (encode) {\n if (escapeTest.test(html)) {\n return html.replace(escapeReplace, getEscapeReplacement)\n }\n } else {\n if (escapeTestNoEncode.test(html)) {\n return html.replace(escapeReplaceNoEncode, getEscapeReplacement)\n }\n }\n\n return html\n}\n\nconst unescapeTest = /&(#(?:\\d+)|(?:#x[0-9A-Fa-f]+)|(?:\\w+));?/gi\n\n/**\n * @param {string} html\n */\nfunction unescape(html) {\n // explicitly match decimal, hex, and named HTML entities\n return html.replace(unescapeTest, (_, n) => {\n n = n.toLowerCase()\n if (n === "colon") return ":"\n if (n.charAt(0) === "#") {\n return n.charAt(1) === "x"\n ? String.fromCharCode(parseInt(n.substring(2), 16))\n : String.fromCharCode(+n.substring(1))\n }\n return ""\n })\n}\n\nconst caret = /(^|[^\\[])\\^/g\n\n/**\n * @param {string | RegExp} regex\n * @param {string} opt\n */\nfunction edit(regex, opt) {\n regex = typeof regex === "string" ? regex : regex.source\n opt = opt || ""\n const obj = {\n replace: (name, val) => {\n val = val.source || val\n val = val.replace(caret, "$1")\n regex = regex.replace(name, val)\n return obj\n },\n getRegex: () => {\n return new RegExp(regex, opt)\n },\n }\n return obj\n}\n\nconst nonWordAndColonTest = /[^\\w:]/g\nconst originIndependentUrl = /^$|^[a-z][a-z0-9+.-]*:|^[?#]/i\n\n/**\n * @param {boolean} sanitize\n * @param {string} base\n * @param {string} href\n */\nfunction cleanUrl(sanitize, base, href) {\n if (sanitize) {\n let prot\n try {\n prot = decodeURIComponent(unescape(href))\n .replace(nonWordAndColonTest, "")\n .toLowerCase()\n } catch (e) {\n return null\n }\n if (\n prot.indexOf("javascript:") === 0 ||\n prot.indexOf("vbscript:") === 0 ||\n prot.indexOf("data:") === 0\n ) {\n return null\n }\n }\n if (base && !originIndependentUrl.test(href)) {\n href = resolveUrl(base, href)\n }\n try {\n href = encodeURI(href).replace(/%25/g, "%")\n } catch (e) {\n return null\n }\n return href\n}\n\nconst baseUrls = {}\nconst justDomain = /^[^:]+:\\/*[^/]*$/\nconst protocol = /^([^:]+:)[\\s\\S]*$/\nconst domain = /^([^:]+:\\/*[^/]*)[\\s\\S]*$/\n\n/**\n * @param {string} base\n * @param {string} href\n */\nfunction resolveUrl(base, href) {\n if (!baseUrls[" " + base]) {\n // we can ignore everything in base after the last slash of its path component,\n // but we might need to add _that_\n // https://tools.ietf.org/html/rfc3986#section-3\n if (justDomain.test(base)) {\n baseUrls[" " + base] = base + "/"\n } else {\n baseUrls[" " + base] = rtrim(base, "/", true)\n }\n }\n base = baseUrls[" " + base]\n const relativeBase = base.indexOf(":") === -1\n\n if (href.substring(0, 2) === "//") {\n if (relativeBase) {\n return href\n }\n return base.replace(protocol, "$1") + href\n } else if (href.charAt(0) === "/") {\n if (relativeBase) {\n return href\n }\n return base.replace(domain, "$1") + href\n } else {\n return base + href\n }\n}\n\nconst noopTest = { exec: function noopTest() {} }\n\nfunction merge(obj) {\n let i = 1,\n target,\n key\n\n for (; i < arguments.length; i++) {\n target = arguments[i]\n for (key in target) {\n if (Object.prototype.hasOwnProperty.call(target, key)) {\n obj[key] = target[key]\n }\n }\n }\n\n return obj\n}\n\nfunction splitCells(tableRow, count) {\n // ensure that every cell-delimiting pipe has a space\n // before it to distinguish it from an escaped pipe\n const row = tableRow.replace(/\\|/g, (match, offset, str) => {\n let escaped = false,\n curr = offset\n while (--curr >= 0 && str[curr] === "\\\\") escaped = !escaped\n if (escaped) {\n // odd number of slashes means | is escaped\n // so we leave it alone\n return "|"\n } else {\n // add space before unescaped |\n return " |"\n }\n }),\n cells = row.split(/ \\|/)\n let i = 0\n\n // First/last cell in a row cannot be empty if it has no leading/trailing pipe\n if (!cells[0].trim()) {\n cells.shift()\n }\n if (cells.length > 0 && !cells[cells.length - 1].trim()) {\n cells.pop()\n }\n\n if (cells.length > count) {\n cells.splice(count)\n } else {\n while (cells.length < count) cells.push("")\n }\n\n for (; i < cells.length; i++) {\n // leading or trailing whitespace is ignored per the gfm spec\n cells[i] = cells[i].trim().replace(/\\\\\\|/g, "|")\n }\n return cells\n}\n\n/**\n * Remove trailing \'c\'s. Equivalent to str.replace(/c*$/, \'\').\n * /c*$/ is vulnerable to REDOS.\n *\n * @param {string} str\n * @param {string} c\n * @param {boolean} invert Remove suffix of non-c chars instead. Default falsey.\n */\nfunction rtrim(str, c, invert) {\n const l = str.length\n if (l === 0) {\n return ""\n }\n\n // Length of suffix matching the invert condition.\n let suffLen = 0\n\n // Step left until we fail to match the invert condition.\n while (suffLen < l) {\n const currChar = str.charAt(l - suffLen - 1)\n if (currChar === c && !invert) {\n suffLen++\n } else if (currChar !== c && invert) {\n suffLen++\n } else {\n break\n }\n }\n\n return str.slice(0, l - suffLen)\n}\n\nfunction findClosingBracket(str, b) {\n if (str.indexOf(b[1]) === -1) {\n return -1\n }\n const l = str.length\n let level = 0,\n i = 0\n for (; i < l; i++) {\n if (str[i] === "\\\\") {\n i++\n } else if (str[i] === b[0]) {\n level++\n } else if (str[i] === b[1]) {\n level--\n if (level < 0) {\n return i\n }\n }\n }\n return -1\n}\n\nfunction checkSanitizeDeprecation(opt) {\n if (opt && opt.sanitize && !opt.silent) {\n console.warn(\n "marked(): sanitize and sanitizer parameters are deprecated since version 0.7.0, should not be used and will be removed in the future. Read more here: https://marked.js.org/#/USING_ADVANCED.md#options"\n )\n }\n}\n\n// copied from https://stackoverflow.com/a/5450113/806777\n/**\n * @param {string} pattern\n * @param {number} count\n */\nfunction repeatString(pattern, count) {\n if (count < 1) {\n return ""\n }\n let result = ""\n while (count > 1) {\n if (count & 1) {\n result += pattern\n }\n count >>= 1\n pattern += pattern\n }\n return result + pattern\n}\n\nfunction outputLink(cap, link, raw, lexer) {\n const href = link.href\n const title = link.title ? escape(link.title) : null\n const text = cap[1].replace(/\\\\([\\[\\]])/g, "$1")\n\n if (cap[0].charAt(0) !== "!") {\n lexer.state.inLink = true\n const token = {\n type: "link",\n raw,\n href,\n title,\n text,\n tokens: lexer.inlineTokens(text, []),\n }\n lexer.state.inLink = false\n return token\n }\n return {\n type: "image",\n raw,\n href,\n title,\n text: escape(text),\n }\n}\n\nfunction indentCodeCompensation(raw, text) {\n const matchIndentToCode = raw.match(/^(\\s+)(?:```)/)\n\n if (matchIndentToCode === null) {\n return text\n }\n\n const indentToCode = matchIndentToCode[1]\n\n return text\n .split("\\n")\n .map(node => {\n const matchIndentInNode = node.match(/^\\s+/)\n if (matchIndentInNode === null) {\n return node\n }\n\n const [indentInNode] = matchIndentInNode\n\n if (indentInNode.length >= indentToCode.length) {\n return node.slice(indentToCode.length)\n }\n\n return node\n })\n .join("\\n")\n}\n\n/**\n * Tokenizer\n */\nclass Tokenizer {\n constructor(options) {\n this.options = options || defaults\n }\n\n space(src) {\n const cap = this.rules.block.newline.exec(src)\n if (cap && cap[0].length > 0) {\n return {\n type: "space",\n raw: cap[0],\n }\n }\n }\n\n code(src) {\n const cap = this.rules.block.code.exec(src)\n if (cap) {\n const text = cap[0].replace(/^ {1,4}/gm, "")\n return {\n type: "code",\n raw: cap[0],\n codeBlockStyle: "indented",\n text: !this.options.pedantic ? rtrim(text, "\\n") : text,\n }\n }\n }\n\n fences(src) {\n const cap = this.rules.block.fences.exec(src)\n if (cap) {\n const raw = cap[0]\n const text = indentCodeCompensation(raw, cap[3] || "")\n\n return {\n type: "code",\n raw,\n lang: cap[2] ? cap[2].trim() : cap[2],\n text,\n }\n }\n }\n\n heading(src) {\n const cap = this.rules.block.heading.exec(src)\n if (cap) {\n let text = cap[2].trim()\n\n // remove trailing #s\n if (/#$/.test(text)) {\n const trimmed = rtrim(text, "#")\n if (this.options.pedantic) {\n text = trimmed.trim()\n } else if (!trimmed || / $/.test(trimmed)) {\n // CommonMark requires space before trailing #s\n text = trimmed.trim()\n }\n }\n\n const token = {\n type: "heading",\n raw: cap[0],\n depth: cap[1].length,\n text,\n tokens: [],\n }\n this.lexer.inline(token.text, token.tokens)\n return token\n }\n }\n\n hr(src) {\n const cap = this.rules.block.hr.exec(src)\n if (cap) {\n return {\n type: "hr",\n raw: cap[0],\n }\n }\n }\n\n blockquote(src) {\n const cap = this.rules.block.blockquote.exec(src)\n if (cap) {\n const text = cap[0].replace(/^ *>[ \\t]?/gm, "")\n\n return {\n type: "blockquote",\n raw: cap[0],\n tokens: this.lexer.blockTokens(text, []),\n text,\n }\n }\n }\n\n list(src) {\n let cap = this.rules.block.list.exec(src)\n if (cap) {\n let raw,\n istask,\n ischecked,\n indent,\n i,\n blankLine,\n endsWithBlankLine,\n line,\n nextLine,\n rawLine,\n itemContents,\n endEarly\n\n let bull = cap[1].trim()\n const isordered = bull.length > 1\n\n const list = {\n type: "list",\n raw: "",\n ordered: isordered,\n start: isordered ? +bull.slice(0, -1) : "",\n loose: false,\n items: [],\n }\n\n bull = isordered ? `\\\\d{1,9}\\\\${bull.slice(-1)}` : `\\\\${bull}`\n\n if (this.options.pedantic) {\n bull = isordered ? bull : "[*+-]"\n }\n\n // Get next list item\n const itemRegex = new RegExp(\n `^( {0,3}${bull})((?:[\\t ][^\\\\n]*)?(?:\\\\n|$))`\n )\n\n // Check if current bullet point can start a new List Item\n while (src) {\n endEarly = false\n if (!(cap = itemRegex.exec(src))) {\n break\n }\n\n if (this.rules.block.hr.test(src)) {\n // End list if bullet was actually HR (possibly move into itemRegex?)\n break\n }\n\n raw = cap[0]\n src = src.substring(raw.length)\n\n line = cap[2].split("\\n", 1)[0]\n nextLine = src.split("\\n", 1)[0]\n\n if (this.options.pedantic) {\n indent = 2\n itemContents = line.trimLeft()\n } else {\n indent = cap[2].search(/[^ ]/) // Find first non-space char\n indent = indent > 4 ? 1 : indent // Treat indented code blocks (> 4 spaces) as having only 1 indent\n itemContents = line.slice(indent)\n indent += cap[1].length\n }\n\n blankLine = false\n\n if (!line && /^ *$/.test(nextLine)) {\n // Items begin with at most one blank line\n raw += nextLine + "\\n"\n src = src.substring(nextLine.length + 1)\n endEarly = true\n }\n\n if (!endEarly) {\n const nextBulletRegex = new RegExp(\n `^ {0,${Math.min(\n 3,\n indent - 1\n )}}(?:[*+-]|\\\\d{1,9}[.)])((?: [^\\\\n]*)?(?:\\\\n|$))`\n )\n const hrRegex = new RegExp(\n `^ {0,${Math.min(\n 3,\n indent - 1\n )}}((?:- *){3,}|(?:_ *){3,}|(?:\\\\* *){3,})(?:\\\\n+|$)`\n )\n\n // Check if following lines should be included in List Item\n while (src) {\n rawLine = src.split("\\n", 1)[0]\n line = rawLine\n\n // Re-align to follow commonmark nesting rules\n if (this.options.pedantic) {\n line = line.replace(/^ {1,4}(?=( {4})*[^ ])/g, " ")\n }\n\n // End list item if found start of new bullet\n if (nextBulletRegex.test(line)) {\n break\n }\n\n // Horizontal rule found\n if (hrRegex.test(src)) {\n break\n }\n\n if (line.search(/[^ ]/) >= indent || !line.trim()) {\n // Dedent if possible\n itemContents += "\\n" + line.slice(indent)\n } else if (!blankLine) {\n // Until blank line, item doesn\'t need indentation\n itemContents += "\\n" + line\n } else {\n // Otherwise, improper indentation ends this item\n break\n }\n\n if (!blankLine && !line.trim()) {\n // Check if current line is blank\n blankLine = true\n }\n\n raw += rawLine + "\\n"\n src = src.substring(rawLine.length + 1)\n }\n }\n\n if (!list.loose) {\n // If the previous item ended with a blank line, the list is loose\n if (endsWithBlankLine) {\n list.loose = true\n } else if (/\\n *\\n *$/.test(raw)) {\n endsWithBlankLine = true\n }\n }\n\n // Check for task list items\n if (this.options.gfm) {\n istask = /^\\[[ xX]\\] /.exec(itemContents)\n if (istask) {\n ischecked = istask[0] !== "[ ] "\n itemContents = itemContents.replace(/^\\[[ xX]\\] +/, "")\n }\n }\n\n list.items.push({\n type: "list_item",\n raw,\n task: !!istask,\n checked: ischecked,\n loose: false,\n text: itemContents,\n })\n\n list.raw += raw\n }\n\n // Do not consume newlines at end of final item. Alternatively, make itemRegex *start* with any newlines to simplify/speed up endsWithBlankLine logic\n list.items[list.items.length - 1].raw = raw.trimRight()\n list.items[list.items.length - 1].text = itemContents.trimRight()\n list.raw = list.raw.trimRight()\n\n const l = list.items.length\n\n // Item child tokens handled here at end because we needed to have the final item to trim it first\n for (i = 0; i < l; i++) {\n this.lexer.state.top = false\n list.items[i].tokens = this.lexer.blockTokens(list.items[i].text, [])\n const spacers = list.items[i].tokens.filter(t => t.type === "space")\n const hasMultipleLineBreaks = spacers.every(t => {\n const chars = t.raw.split("")\n let lineBreaks = 0\n for (const char of chars) {\n if (char === "\\n") {\n lineBreaks += 1\n }\n if (lineBreaks > 1) {\n return true\n }\n }\n\n return false\n })\n\n if (!list.loose && spacers.length && hasMultipleLineBreaks) {\n // Having a single line break doesn\'t mean a list is loose. A single line break is terminating the last list item\n list.loose = true\n list.items[i].loose = true\n }\n }\n\n return list\n }\n }\n\n html(src) {\n const cap = this.rules.block.html.exec(src)\n if (cap) {\n const token = {\n type: "html",\n raw: cap[0],\n pre:\n !this.options.sanitizer &&\n (cap[1] === "pre" || cap[1] === "script" || cap[1] === "style"),\n text: cap[0],\n }\n if (this.options.sanitize) {\n token.type = "paragraph"\n token.text = this.options.sanitizer\n ? this.options.sanitizer(cap[0])\n : escape(cap[0])\n token.tokens = []\n this.lexer.inline(token.text, token.tokens)\n }\n return token\n }\n }\n\n def(src) {\n const cap = this.rules.block.def.exec(src)\n if (cap) {\n if (cap[3]) cap[3] = cap[3].substring(1, cap[3].length - 1)\n const tag = cap[1].toLowerCase().replace(/\\s+/g, " ")\n return {\n type: "def",\n tag,\n raw: cap[0],\n href: cap[2],\n title: cap[3],\n }\n }\n }\n\n table(src) {\n const cap = this.rules.block.table.exec(src)\n if (cap) {\n const item = {\n type: "table",\n header: splitCells(cap[1]).map(c => {\n return { text: c }\n }),\n align: cap[2].replace(/^ *|\\| *$/g, "").split(/ *\\| */),\n rows:\n cap[3] && cap[3].trim()\n ? cap[3].replace(/\\n[ \\t]*$/, "").split("\\n")\n : [],\n }\n\n if (item.header.length === item.align.length) {\n item.raw = cap[0]\n\n let l = item.align.length\n let i, j, k, row\n for (i = 0; i < l; i++) {\n if (/^ *-+: *$/.test(item.align[i])) {\n item.align[i] = "right"\n } else if (/^ *:-+: *$/.test(item.align[i])) {\n item.align[i] = "center"\n } else if (/^ *:-+ *$/.test(item.align[i])) {\n item.align[i] = "left"\n } else {\n item.align[i] = null\n }\n }\n\n l = item.rows.length\n for (i = 0; i < l; i++) {\n item.rows[i] = splitCells(item.rows[i], item.header.length).map(c => {\n return { text: c }\n })\n }\n\n // parse child tokens inside headers and cells\n\n // header child tokens\n l = item.header.length\n for (j = 0; j < l; j++) {\n item.header[j].tokens = []\n this.lexer.inline(item.header[j].text, item.header[j].tokens)\n }\n\n // cell child tokens\n l = item.rows.length\n for (j = 0; j < l; j++) {\n row = item.rows[j]\n for (k = 0; k < row.length; k++) {\n row[k].tokens = []\n this.lexer.inline(row[k].text, row[k].tokens)\n }\n }\n\n return item\n }\n }\n }\n\n lheading(src) {\n const cap = this.rules.block.lheading.exec(src)\n if (cap) {\n const token = {\n type: "heading",\n raw: cap[0],\n depth: cap[2].charAt(0) === "=" ? 1 : 2,\n text: cap[1],\n tokens: [],\n }\n this.lexer.inline(token.text, token.tokens)\n return token\n }\n }\n\n paragraph(src) {\n const cap = this.rules.block.paragraph.exec(src)\n if (cap) {\n const token = {\n type: "paragraph",\n raw: cap[0],\n text:\n cap[1].charAt(cap[1].length - 1) === "\\n"\n ? cap[1].slice(0, -1)\n : cap[1],\n tokens: [],\n }\n this.lexer.inline(token.text, token.tokens)\n return token\n }\n }\n\n text(src) {\n const cap = this.rules.block.text.exec(src)\n if (cap) {\n const token = {\n type: "text",\n raw: cap[0],\n text: cap[0],\n tokens: [],\n }\n this.lexer.inline(token.text, token.tokens)\n return token\n }\n }\n\n escape(src) {\n const cap = this.rules.inline.escape.exec(src)\n if (cap) {\n return {\n type: "escape",\n raw: cap[0],\n text: escape(cap[1]),\n }\n }\n }\n\n tag(src) {\n const cap = this.rules.inline.tag.exec(src)\n if (cap) {\n if (!this.lexer.state.inLink && /^/i.test(cap[0])) {\n this.lexer.state.inLink = false\n }\n if (\n !this.lexer.state.inRawBlock &&\n /^<(pre|code|kbd|script)(\\s|>)/i.test(cap[0])\n ) {\n this.lexer.state.inRawBlock = true\n } else if (\n this.lexer.state.inRawBlock &&\n /^<\\/(pre|code|kbd|script)(\\s|>)/i.test(cap[0])\n ) {\n this.lexer.state.inRawBlock = false\n }\n\n return {\n type: this.options.sanitize ? "text" : "html",\n raw: cap[0],\n inLink: this.lexer.state.inLink,\n inRawBlock: this.lexer.state.inRawBlock,\n text: this.options.sanitize\n ? this.options.sanitizer\n ? this.options.sanitizer(cap[0])\n : escape(cap[0])\n : cap[0],\n }\n }\n }\n\n link(src) {\n const cap = this.rules.inline.link.exec(src)\n if (cap) {\n const trimmedUrl = cap[2].trim()\n if (!this.options.pedantic && /^$/.test(trimmedUrl)) {\n return\n }\n\n // ending angle bracket cannot be escaped\n const rtrimSlash = rtrim(trimmedUrl.slice(0, -1), "\\\\")\n if ((trimmedUrl.length - rtrimSlash.length) % 2 === 0) {\n return\n }\n } else {\n // find closing parenthesis\n const lastParenIndex = findClosingBracket(cap[2], "()")\n if (lastParenIndex > -1) {\n const start = cap[0].indexOf("!") === 0 ? 5 : 4\n const linkLen = start + cap[1].length + lastParenIndex\n cap[2] = cap[2].substring(0, lastParenIndex)\n cap[0] = cap[0].substring(0, linkLen).trim()\n cap[3] = ""\n }\n }\n let href = cap[2]\n let title = ""\n if (this.options.pedantic) {\n // split pedantic href and title\n const link = /^([^\'"]*[^\\s])\\s+([\'"])(.*)\\2/.exec(href)\n\n if (link) {\n href = link[1]\n title = link[3]\n }\n } else {\n title = cap[3] ? cap[3].slice(1, -1) : ""\n }\n\n href = href.trim()\n if (/^$/.test(trimmedUrl)) {\n // pedantic allows starting angle bracket without ending angle bracket\n href = href.slice(1)\n } else {\n href = href.slice(1, -1)\n }\n }\n return outputLink(\n cap,\n {\n href: href ? href.replace(this.rules.inline._escapes, "$1") : href,\n title: title\n ? title.replace(this.rules.inline._escapes, "$1")\n : title,\n },\n cap[0],\n this.lexer\n )\n }\n }\n\n reflink(src, links) {\n let cap\n if (\n (cap = this.rules.inline.reflink.exec(src)) ||\n (cap = this.rules.inline.nolink.exec(src))\n ) {\n let link = (cap[2] || cap[1]).replace(/\\s+/g, " ")\n link = links[link.toLowerCase()]\n if (!link || !link.href) {\n const text = cap[0].charAt(0)\n return {\n type: "text",\n raw: text,\n text,\n }\n }\n return outputLink(cap, link, cap[0], this.lexer)\n }\n }\n\n emStrong(src, maskedSrc, prevChar = "") {\n let match = this.rules.inline.emStrong.lDelim.exec(src)\n if (!match) return\n\n // _ can\'t be between two alphanumerics. \\p{L}\\p{N} includes non-english alphabet/numbers as well\n if (match[3] && prevChar.match(/[\\p{L}\\p{N}]/u)) return\n\n const nextChar = match[1] || match[2] || ""\n\n if (\n !nextChar ||\n (nextChar &&\n (prevChar === "" || this.rules.inline.punctuation.exec(prevChar)))\n ) {\n const lLength = match[0].length - 1\n let rDelim,\n rLength,\n delimTotal = lLength,\n midDelimTotal = 0\n\n const endReg =\n match[0][0] === "*"\n ? this.rules.inline.emStrong.rDelimAst\n : this.rules.inline.emStrong.rDelimUnd\n endReg.lastIndex = 0\n\n // Clip maskedSrc to same section of string as src (move to lexer?)\n maskedSrc = maskedSrc.slice(-1 * src.length + lLength)\n\n while ((match = endReg.exec(maskedSrc)) != null) {\n rDelim =\n match[1] || match[2] || match[3] || match[4] || match[5] || match[6]\n\n if (!rDelim) continue // skip single * in __abc*abc__\n\n rLength = rDelim.length\n\n if (match[3] || match[4]) {\n // found another Left Delim\n delimTotal += rLength\n continue\n } else if (match[5] || match[6]) {\n // either Left or Right Delim\n if (lLength % 3 && !((lLength + rLength) % 3)) {\n midDelimTotal += rLength\n continue // CommonMark Emphasis Rules 9-10\n }\n }\n\n delimTotal -= rLength\n\n if (delimTotal > 0) continue // Haven\'t found enough closing delimiters\n\n // Remove extra characters. *a*** -> *a*\n rLength = Math.min(rLength, rLength + delimTotal + midDelimTotal)\n\n // Create `em` if smallest delimiter has odd char count. *a***\n if (Math.min(lLength, rLength) % 2) {\n const text = src.slice(1, lLength + match.index + rLength)\n return {\n type: "em",\n raw: src.slice(0, lLength + match.index + rLength + 1),\n text,\n tokens: this.lexer.inlineTokens(text, []),\n }\n }\n\n // Create \'strong\' if smallest delimiter has even char count. **a***\n const text = src.slice(2, lLength + match.index + rLength - 1)\n return {\n type: "strong",\n raw: src.slice(0, lLength + match.index + rLength + 1),\n text,\n tokens: this.lexer.inlineTokens(text, []),\n }\n }\n }\n }\n\n codespan(src) {\n const cap = this.rules.inline.code.exec(src)\n if (cap) {\n let text = cap[2].replace(/\\n/g, " ")\n const hasNonSpaceChars = /[^ ]/.test(text)\n const hasSpaceCharsOnBothEnds = /^ /.test(text) && / $/.test(text)\n if (hasNonSpaceChars && hasSpaceCharsOnBothEnds) {\n text = text.substring(1, text.length - 1)\n }\n text = escape(text, true)\n return {\n type: "codespan",\n raw: cap[0],\n text,\n }\n }\n }\n\n br(src) {\n const cap = this.rules.inline.br.exec(src)\n if (cap) {\n return {\n type: "br",\n raw: cap[0],\n }\n }\n }\n\n del(src) {\n const cap = this.rules.inline.del.exec(src)\n if (cap) {\n return {\n type: "del",\n raw: cap[0],\n text: cap[2],\n tokens: this.lexer.inlineTokens(cap[2], []),\n }\n }\n }\n\n autolink(src, mangle) {\n const cap = this.rules.inline.autolink.exec(src)\n if (cap) {\n let text, href\n if (cap[2] === "@") {\n text = escape(this.options.mangle ? mangle(cap[1]) : cap[1])\n href = "mailto:" + text\n } else {\n text = escape(cap[1])\n href = text\n }\n\n return {\n type: "link",\n raw: cap[0],\n text,\n href,\n tokens: [\n {\n type: "text",\n raw: text,\n text,\n },\n ],\n }\n }\n }\n\n url(src, mangle) {\n let cap\n if ((cap = this.rules.inline.url.exec(src))) {\n let text, href\n if (cap[2] === "@") {\n text = escape(this.options.mangle ? mangle(cap[0]) : cap[0])\n href = "mailto:" + text\n } else {\n // do extended autolink path validation\n let prevCapZero\n do {\n prevCapZero = cap[0]\n cap[0] = this.rules.inline._backpedal.exec(cap[0])[0]\n } while (prevCapZero !== cap[0])\n text = escape(cap[0])\n if (cap[1] === "www.") {\n href = "http://" + text\n } else {\n href = text\n }\n }\n return {\n type: "link",\n raw: cap[0],\n text,\n href,\n tokens: [\n {\n type: "text",\n raw: text,\n text,\n },\n ],\n }\n }\n }\n\n inlineText(src, smartypants) {\n const cap = this.rules.inline.text.exec(src)\n if (cap) {\n let text\n if (this.lexer.state.inRawBlock) {\n text = this.options.sanitize\n ? this.options.sanitizer\n ? this.options.sanitizer(cap[0])\n : escape(cap[0])\n : cap[0]\n } else {\n text = escape(this.options.smartypants ? smartypants(cap[0]) : cap[0])\n }\n return {\n type: "text",\n raw: cap[0],\n text,\n }\n }\n }\n}\n\n/**\n * Block-Level Grammar\n */\nconst block = {\n newline: /^(?: *(?:\\n|$))+/,\n code: /^( {4}[^\\n]+(?:\\n(?: *(?:\\n|$))*)?)+/,\n fences:\n /^ {0,3}(`{3,}(?=[^`\\n]*\\n)|~{3,})([^\\n]*)\\n(?:|([\\s\\S]*?)\\n)(?: {0,3}\\1[~`]* *(?=\\n|$)|$)/,\n hr: /^ {0,3}((?:-[\\t ]*){3,}|(?:_[ \\t]*){3,}|(?:\\*[ \\t]*){3,})(?:\\n+|$)/,\n heading: /^ {0,3}(#{1,6})(?=\\s|$)(.*)(?:\\n+|$)/,\n blockquote: /^( {0,3}> ?(paragraph|[^\\n]*)(?:\\n|$))+/,\n list: /^( {0,3}bull)([ \\t][^\\n]+?)?(?:\\n|$)/,\n html:\n "^ {0,3}(?:" + // optional indentation\n "<(script|pre|style|textarea)[\\\\s>][\\\\s\\\\S]*?(?:[^\\\\n]*\\\\n+|$)" + // (1)\n "|comment[^\\\\n]*(\\\\n+|$)" + // (2)\n "|<\\\\?[\\\\s\\\\S]*?(?:\\\\?>\\\\n*|$)" + // (3)\n "|\\\\n*|$)" + // (4)\n "|\\\\n*|$)" + // (5)\n "|)[\\\\s\\\\S]*?(?:(?:\\\\n *)+\\\\n|$)" + // (6)\n "|<(?!script|pre|style|textarea)([a-z][\\\\w-]*)(?:attribute)*? */?>(?=[ \\\\t]*(?:\\\\n|$))[\\\\s\\\\S]*?(?:(?:\\\\n *)+\\\\n|$)" + // (7) open tag\n "|(?=[ \\\\t]*(?:\\\\n|$))[\\\\s\\\\S]*?(?:(?:\\\\n *)+\\\\n|$)" + // (7) closing tag\n ")",\n def: /^ {0,3}\\[(label)\\]: *(?:\\n *)?]+)>?(?:(?: +(?:\\n *)?| *\\n *)(title))? *(?:\\n+|$)/,\n table: noopTest,\n lheading: /^([^\\n]+)\\n {0,3}(=+|-+) *(?:\\n+|$)/,\n // regex template, placeholders will be replaced according to different paragraph\n // interruption rules of commonmark and the original markdown spec:\n _paragraph:\n /^([^\\n]+(?:\\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\\n)[^\\n]+)*)/,\n text: /^[^\\n]+/,\n}\n\nblock._label = /(?!\\s*\\])(?:\\\\.|[^\\[\\]\\\\])+/\nblock._title = /(?:"(?:\\\\"?|[^"\\\\])*"|\'[^\'\\n]*(?:\\n[^\'\\n]+)*\\n?\'|\\([^()]*\\))/\nblock.def = edit(block.def)\n .replace("label", block._label)\n .replace("title", block._title)\n .getRegex()\n\nblock.bullet = /(?:[*+-]|\\d{1,9}[.)])/\nblock.listItemStart = edit(/^( *)(bull) */)\n .replace("bull", block.bullet)\n .getRegex()\n\nblock.list = edit(block.list)\n .replace(/bull/g, block.bullet)\n .replace(\n "hr",\n "\\\\n+(?=\\\\1?(?:(?:- *){3,}|(?:_ *){3,}|(?:\\\\* *){3,})(?:\\\\n+|$))"\n )\n .replace("def", "\\\\n+(?=" + block.def.source + ")")\n .getRegex()\n\nblock._tag =\n "address|article|aside|base|basefont|blockquote|body|caption" +\n "|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption" +\n "|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe" +\n "|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option" +\n "|p|param|section|source|summary|table|tbody|td|tfoot|th|thead|title|tr" +\n "|track|ul"\nblock._comment = /\x3C!--(?!-?>)[\\s\\S]*?(?:-->|$)/\nblock.html = edit(block.html, "i")\n .replace("comment", block._comment)\n .replace("tag", block._tag)\n .replace(\n "attribute",\n / +[a-zA-Z:_][\\w.:-]*(?: *= *"[^"\\n]*"| *= *\'[^\'\\n]*\'| *= *[^\\s"\'=<>`]+)?/\n )\n .getRegex()\n\nblock.paragraph = edit(block._paragraph)\n .replace("hr", block.hr)\n .replace("heading", " {0,3}#{1,6} ")\n .replace("|lheading", "") // setex headings don\'t interrupt commonmark paragraphs\n .replace("|table", "")\n .replace("blockquote", " {0,3}>")\n .replace("fences", " {0,3}(?:`{3,}(?=[^`\\\\n]*\\\\n)|~{3,})[^\\\\n]*\\\\n")\n .replace("list", " {0,3}(?:[*+-]|1[.)]) ") // only lists starting from 1 can interrupt\n .replace(\n "html",\n ")|<(?:script|pre|style|textarea|!--)"\n )\n .replace("tag", block._tag) // pars can be interrupted by type (6) html blocks\n .getRegex()\n\nblock.blockquote = edit(block.blockquote)\n .replace("paragraph", block.paragraph)\n .getRegex()\n\n/**\n * Normal Block Grammar\n */\n\nblock.normal = merge({}, block)\n\n/**\n * GFM Block Grammar\n */\n\nblock.gfm = merge({}, block.normal, {\n table:\n "^ *([^\\\\n ].*\\\\|.*)\\\\n" + // Header\n " {0,3}(?:\\\\| *)?(:?-+:? *(?:\\\\| *:?-+:? *)*)(?:\\\\| *)?" + // Align\n "(?:\\\\n((?:(?! *\\\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\\\n|$))*)\\\\n*|$)", // Cells\n})\n\nblock.gfm.table = edit(block.gfm.table)\n .replace("hr", block.hr)\n .replace("heading", " {0,3}#{1,6} ")\n .replace("blockquote", " {0,3}>")\n .replace("code", " {4}[^\\\\n]")\n .replace("fences", " {0,3}(?:`{3,}(?=[^`\\\\n]*\\\\n)|~{3,})[^\\\\n]*\\\\n")\n .replace("list", " {0,3}(?:[*+-]|1[.)]) ") // only lists starting from 1 can interrupt\n .replace(\n "html",\n ")|<(?:script|pre|style|textarea|!--)"\n )\n .replace("tag", block._tag) // tables can be interrupted by type (6) html blocks\n .getRegex()\n\nblock.gfm.paragraph = edit(block._paragraph)\n .replace("hr", block.hr)\n .replace("heading", " {0,3}#{1,6} ")\n .replace("|lheading", "") // setex headings don\'t interrupt commonmark paragraphs\n .replace("table", block.gfm.table) // interrupt paragraphs with table\n .replace("blockquote", " {0,3}>")\n .replace("fences", " {0,3}(?:`{3,}(?=[^`\\\\n]*\\\\n)|~{3,})[^\\\\n]*\\\\n")\n .replace("list", " {0,3}(?:[*+-]|1[.)]) ") // only lists starting from 1 can interrupt\n .replace(\n "html",\n ")|<(?:script|pre|style|textarea|!--)"\n )\n .replace("tag", block._tag) // pars can be interrupted by type (6) html blocks\n .getRegex()\n/**\n * Pedantic grammar (original John Gruber\'s loose markdown specification)\n */\n\nblock.pedantic = merge({}, block.normal, {\n html: edit(\n "^ *(?:comment *(?:\\\\n|\\\\s*$)" +\n "|<(tag)[\\\\s\\\\S]+? *(?:\\\\n{2,}|\\\\s*$)" + // closed tag\n "|\\\\s]*)*?/?> *(?:\\\\n{2,}|\\\\s*$))"\n )\n .replace("comment", block._comment)\n .replace(\n /tag/g,\n "(?!(?:" +\n "a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub" +\n "|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)" +\n "\\\\b)\\\\w+(?!:|[^\\\\w\\\\s@]*@)\\\\b"\n )\n .getRegex(),\n def: /^ *\\[([^\\]]+)\\]: *]+)>?(?: +(["(][^\\n]+[")]))? *(?:\\n+|$)/,\n heading: /^(#{1,6})(.*)(?:\\n+|$)/,\n fences: noopTest, // fences not supported\n paragraph: edit(block.normal._paragraph)\n .replace("hr", block.hr)\n .replace("heading", " *#{1,6} *[^\\n]")\n .replace("lheading", block.lheading)\n .replace("blockquote", " {0,3}>")\n .replace("|fences", "")\n .replace("|list", "")\n .replace("|html", "")\n .getRegex(),\n})\n\n/**\n * Inline-Level Grammar\n */\nconst inline = {\n escape: /^\\\\([!"#$%&\'()*+,\\-./:;<=>?@\\[\\]\\\\^_`{|}~])/,\n autolink: /^<(scheme:[^\\s\\x00-\\x1f<>]*|email)>/,\n url: noopTest,\n tag:\n "^comment" +\n "|^" + // self-closing tag\n "|^<[a-zA-Z][\\\\w-]*(?:attribute)*?\\\\s*/?>" + // open tag\n "|^<\\\\?[\\\\s\\\\S]*?\\\\?>" + // processing instruction, e.g. \n "|^" + // declaration, e.g. \n "|^", // CDATA section\n link: /^!?\\[(label)\\]\\(\\s*(href)(?:\\s+(title))?\\s*\\)/,\n reflink: /^!?\\[(label)\\]\\[(ref)\\]/,\n nolink: /^!?\\[(ref)\\](?:\\[\\])?/,\n reflinkSearch: "reflink|nolink(?!\\\\()",\n emStrong: {\n lDelim: /^(?:\\*+(?:([punct_])|[^\\s*]))|^_+(?:([punct*])|([^\\s_]))/,\n // (1) and (2) can only be a Right Delimiter. (3) and (4) can only be Left. (5) and (6) can be either Left or Right.\n // () Skip orphan inside strong () Consume to delim (1) #*** (2) a***#, a*** (3) #***a, ***a (4) ***# (5) #***# (6) a***a\n rDelimAst:\n /^[^_*]*?\\_\\_[^_*]*?\\*[^_*]*?(?=\\_\\_)|[^*]+(?=[^*])|[punct_](\\*+)(?=[\\s]|$)|[^punct*_\\s](\\*+)(?=[punct_\\s]|$)|[punct_\\s](\\*+)(?=[^punct*_\\s])|[\\s](\\*+)(?=[punct_])|[punct_](\\*+)(?=[punct_])|[^punct*_\\s](\\*+)(?=[^punct*_\\s])/,\n rDelimUnd:\n /^[^_*]*?\\*\\*[^_*]*?\\_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|[punct*](\\_+)(?=[\\s]|$)|[^punct*_\\s](\\_+)(?=[punct*\\s]|$)|[punct*\\s](\\_+)(?=[^punct*_\\s])|[\\s](\\_+)(?=[punct*])|[punct*](\\_+)(?=[punct*])/, // ^- Not allowed for _\n },\n code: /^(`+)([^`]|[^`][\\s\\S]*?[^`])\\1(?!`)/,\n br: /^( {2,}|\\\\)\\n(?!\\s*$)/,\n del: noopTest,\n text: /^(`+|[^`])(?:(?= {2,}\\n)|[\\s\\S]*?(?:(?=[\\\\?@\\\\[\\\\]`^{|}~"\ninline.punctuation = edit(inline.punctuation)\n .replace(/punctuation/g, inline._punctuation)\n .getRegex()\n\n// sequences em should skip over [title](link), `code`, \ninline.blockSkip = /\\[[^\\]]*?\\]\\([^\\)]*?\\)|`[^`]*?`|<[^>]*?>/g\ninline.escapedEmSt = /\\\\\\*|\\\\_/g\n\ninline._comment = edit(block._comment).replace("(?:-->|$)", "-->").getRegex()\n\ninline.emStrong.lDelim = edit(inline.emStrong.lDelim)\n .replace(/punct/g, inline._punctuation)\n .getRegex()\n\ninline.emStrong.rDelimAst = edit(inline.emStrong.rDelimAst, "g")\n .replace(/punct/g, inline._punctuation)\n .getRegex()\n\ninline.emStrong.rDelimUnd = edit(inline.emStrong.rDelimUnd, "g")\n .replace(/punct/g, inline._punctuation)\n .getRegex()\n\ninline._escapes = /\\\\([!"#$%&\'()*+,\\-./:;<=>?@\\[\\]\\\\^_`{|}~])/g\n\ninline._scheme = /[a-zA-Z][a-zA-Z0-9+.-]{1,31}/\ninline._email =\n /[a-zA-Z0-9.!#$%&\'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/\ninline.autolink = edit(inline.autolink)\n .replace("scheme", inline._scheme)\n .replace("email", inline._email)\n .getRegex()\n\ninline._attribute =\n /\\s+[a-zA-Z:_][\\w.:-]*(?:\\s*=\\s*"[^"]*"|\\s*=\\s*\'[^\']*\'|\\s*=\\s*[^\\s"\'=<>`]+)?/\n\ninline.tag = edit(inline.tag)\n .replace("comment", inline._comment)\n .replace("attribute", inline._attribute)\n .getRegex()\n\ninline._label = /(?:\\[(?:\\\\.|[^\\[\\]\\\\])*\\]|\\\\.|`[^`]*`|[^\\[\\]\\\\`])*?/\ninline._href = /<(?:\\\\.|[^\\n<>\\\\])+>|[^\\s\\x00-\\x1f]*/\ninline._title = /"(?:\\\\"?|[^"\\\\])*"|\'(?:\\\\\'?|[^\'\\\\])*\'|\\((?:\\\\\\)?|[^)\\\\])*\\)/\n\ninline.link = edit(inline.link)\n .replace("label", inline._label)\n .replace("href", inline._href)\n .replace("title", inline._title)\n .getRegex()\n\ninline.reflink = edit(inline.reflink)\n .replace("label", inline._label)\n .replace("ref", block._label)\n .getRegex()\n\ninline.nolink = edit(inline.nolink).replace("ref", block._label).getRegex()\n\ninline.reflinkSearch = edit(inline.reflinkSearch, "g")\n .replace("reflink", inline.reflink)\n .replace("nolink", inline.nolink)\n .getRegex()\n\n/**\n * Normal Inline Grammar\n */\n\ninline.normal = merge({}, inline)\n\n/**\n * Pedantic Inline Grammar\n */\n\ninline.pedantic = merge({}, inline.normal, {\n strong: {\n start: /^__|\\*\\*/,\n middle: /^__(?=\\S)([\\s\\S]*?\\S)__(?!_)|^\\*\\*(?=\\S)([\\s\\S]*?\\S)\\*\\*(?!\\*)/,\n endAst: /\\*\\*(?!\\*)/g,\n endUnd: /__(?!_)/g,\n },\n em: {\n start: /^_|\\*/,\n middle: /^()\\*(?=\\S)([\\s\\S]*?\\S)\\*(?!\\*)|^_(?=\\S)([\\s\\S]*?\\S)_(?!_)/,\n endAst: /\\*(?!\\*)/g,\n endUnd: /_(?!_)/g,\n },\n link: edit(/^!?\\[(label)\\]\\((.*?)\\)/)\n .replace("label", inline._label)\n .getRegex(),\n reflink: edit(/^!?\\[(label)\\]\\s*\\[([^\\]]*)\\]/)\n .replace("label", inline._label)\n .getRegex(),\n})\n\n/**\n * GFM Inline Grammar\n */\n\ninline.gfm = merge({}, inline.normal, {\n escape: edit(inline.escape).replace("])", "~|])").getRegex(),\n _extended_email:\n /[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/,\n url: /^((?:ftp|https?):\\/\\/|www\\.)(?:[a-zA-Z0-9\\-]+\\.?)+[^\\s<]*|^email/,\n _backpedal:\n /(?:[^?!.,:;*_~()&]+|\\([^)]*\\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_~)]+(?!$))+/,\n del: /^(~~?)(?=[^\\s~])([\\s\\S]*?[^\\s~])\\1(?=[^~]|$)/,\n text: /^([`~]+|[^`~])(?:(?= {2,}\\n)|(?=[a-zA-Z0-9.!#$%&\'*+\\/=?_`{\\|}~-]+@)|[\\s\\S]*?(?:(?=[\\\\ 0.5) {\n ch = "x" + ch.toString(16)\n }\n out += "&#" + ch + ";"\n }\n\n return out\n}\n\n/**\n * Block Lexer\n */\nclass Lexer {\n constructor(options) {\n this.tokens = []\n this.tokens.links = Object.create(null)\n this.options = options || defaults\n this.options.tokenizer = this.options.tokenizer || new Tokenizer()\n this.tokenizer = this.options.tokenizer\n this.tokenizer.options = this.options\n this.tokenizer.lexer = this\n this.inlineQueue = []\n this.state = {\n inLink: false,\n inRawBlock: false,\n top: true,\n }\n\n const rules = {\n block: block.normal,\n inline: inline.normal,\n }\n\n if (this.options.pedantic) {\n rules.block = block.pedantic\n rules.inline = inline.pedantic\n } else if (this.options.gfm) {\n rules.block = block.gfm\n if (this.options.breaks) {\n rules.inline = inline.breaks\n } else {\n rules.inline = inline.gfm\n }\n }\n this.tokenizer.rules = rules\n }\n\n /**\n * Expose Rules\n */\n static get rules() {\n return {\n block,\n inline,\n }\n }\n\n /**\n * Static Lex Method\n */\n static lex(src, options) {\n const lexer = new Lexer(options)\n return lexer.lex(src)\n }\n\n /**\n * Static Lex Inline Method\n */\n static lexInline(src, options) {\n const lexer = new Lexer(options)\n return lexer.inlineTokens(src)\n }\n\n /**\n * Preprocessing\n */\n lex(src) {\n src = src.replace(/\\r\\n|\\r/g, "\\n")\n\n this.blockTokens(src, this.tokens)\n\n let next\n while ((next = this.inlineQueue.shift())) {\n this.inlineTokens(next.src, next.tokens)\n }\n\n return this.tokens\n }\n\n /**\n * Lexing\n */\n blockTokens(src, tokens = []) {\n if (this.options.pedantic) {\n src = src.replace(/\\t/g, " ").replace(/^ +$/gm, "")\n } else {\n src = src.replace(/^( *)(\\t+)/gm, (_, leading, tabs) => {\n return leading + " ".repeat(tabs.length)\n })\n }\n\n let token, lastToken, cutSrc, lastParagraphClipped\n\n while (src) {\n if (\n this.options.extensions &&\n this.options.extensions.block &&\n this.options.extensions.block.some(extTokenizer => {\n if ((token = extTokenizer.call({ lexer: this }, src, tokens))) {\n src = src.substring(token.raw.length)\n tokens.push(token)\n return true\n }\n return false\n })\n ) {\n continue\n }\n\n // newline\n if ((token = this.tokenizer.space(src))) {\n src = src.substring(token.raw.length)\n if (token.raw.length === 1 && tokens.length > 0) {\n // if there\'s a single \\n as a spacer, it\'s terminating the last line,\n // so move it there so that we don\'t get unecessary paragraph tags\n tokens[tokens.length - 1].raw += "\\n"\n } else {\n tokens.push(token)\n }\n continue\n }\n\n // code\n if ((token = this.tokenizer.code(src))) {\n src = src.substring(token.raw.length)\n lastToken = tokens[tokens.length - 1]\n // An indented code block cannot interrupt a paragraph.\n if (\n lastToken &&\n (lastToken.type === "paragraph" || lastToken.type === "text")\n ) {\n lastToken.raw += "\\n" + token.raw\n lastToken.text += "\\n" + token.text\n this.inlineQueue[this.inlineQueue.length - 1].src = lastToken.text\n } else {\n tokens.push(token)\n }\n continue\n }\n\n // fences\n if ((token = this.tokenizer.fences(src))) {\n src = src.substring(token.raw.length)\n tokens.push(token)\n continue\n }\n\n // heading\n if ((token = this.tokenizer.heading(src))) {\n src = src.substring(token.raw.length)\n tokens.push(token)\n continue\n }\n\n // hr\n if ((token = this.tokenizer.hr(src))) {\n src = src.substring(token.raw.length)\n tokens.push(token)\n continue\n }\n\n // blockquote\n if ((token = this.tokenizer.blockquote(src))) {\n src = src.substring(token.raw.length)\n tokens.push(token)\n continue\n }\n\n // list\n if ((token = this.tokenizer.list(src))) {\n src = src.substring(token.raw.length)\n tokens.push(token)\n continue\n }\n\n // html\n if ((token = this.tokenizer.html(src))) {\n src = src.substring(token.raw.length)\n tokens.push(token)\n continue\n }\n\n // def\n if ((token = this.tokenizer.def(src))) {\n src = src.substring(token.raw.length)\n lastToken = tokens[tokens.length - 1]\n if (\n lastToken &&\n (lastToken.type === "paragraph" || lastToken.type === "text")\n ) {\n lastToken.raw += "\\n" + token.raw\n lastToken.text += "\\n" + token.raw\n this.inlineQueue[this.inlineQueue.length - 1].src = lastToken.text\n } else if (!this.tokens.links[token.tag]) {\n this.tokens.links[token.tag] = {\n href: token.href,\n title: token.title,\n }\n }\n continue\n }\n\n // table (gfm)\n if ((token = this.tokenizer.table(src))) {\n src = src.substring(token.raw.length)\n tokens.push(token)\n continue\n }\n\n // lheading\n if ((token = this.tokenizer.lheading(src))) {\n src = src.substring(token.raw.length)\n tokens.push(token)\n continue\n }\n\n // top-level paragraph\n // prevent paragraph consuming extensions by clipping \'src\' to extension start\n cutSrc = src\n if (this.options.extensions && this.options.extensions.startBlock) {\n let startIndex = Infinity\n const tempSrc = src.slice(1)\n let tempStart\n this.options.extensions.startBlock.forEach(function (getStartIndex) {\n tempStart = getStartIndex.call({ lexer: this }, tempSrc)\n if (typeof tempStart === "number" && tempStart >= 0) {\n startIndex = Math.min(startIndex, tempStart)\n }\n })\n if (startIndex < Infinity && startIndex >= 0) {\n cutSrc = src.substring(0, startIndex + 1)\n }\n }\n if (this.state.top && (token = this.tokenizer.paragraph(cutSrc))) {\n lastToken = tokens[tokens.length - 1]\n if (lastParagraphClipped && lastToken.type === "paragraph") {\n lastToken.raw += "\\n" + token.raw\n lastToken.text += "\\n" + token.text\n this.inlineQueue.pop()\n this.inlineQueue[this.inlineQueue.length - 1].src = lastToken.text\n } else {\n tokens.push(token)\n }\n lastParagraphClipped = cutSrc.length !== src.length\n src = src.substring(token.raw.length)\n continue\n }\n\n // text\n if ((token = this.tokenizer.text(src))) {\n src = src.substring(token.raw.length)\n lastToken = tokens[tokens.length - 1]\n if (lastToken && lastToken.type === "text") {\n lastToken.raw += "\\n" + token.raw\n lastToken.text += "\\n" + token.text\n this.inlineQueue.pop()\n this.inlineQueue[this.inlineQueue.length - 1].src = lastToken.text\n } else {\n tokens.push(token)\n }\n continue\n }\n\n if (src) {\n const errMsg = "Infinite loop on byte: " + src.charCodeAt(0)\n if (this.options.silent) {\n console.error(errMsg)\n break\n } else {\n throw new Error(errMsg)\n }\n }\n }\n\n this.state.top = true\n return tokens\n }\n\n inline(src, tokens) {\n this.inlineQueue.push({ src, tokens })\n }\n\n /**\n * Lexing/Compiling\n */\n inlineTokens(src, tokens = []) {\n let token, lastToken, cutSrc\n\n // String with links masked to avoid interference with em and strong\n let maskedSrc = src\n let match\n let keepPrevChar, prevChar\n\n // Mask out reflinks\n if (this.tokens.links) {\n const links = Object.keys(this.tokens.links)\n if (links.length > 0) {\n while (\n (match = this.tokenizer.rules.inline.reflinkSearch.exec(maskedSrc)) !=\n null\n ) {\n if (\n links.includes(match[0].slice(match[0].lastIndexOf("[") + 1, -1))\n ) {\n maskedSrc =\n maskedSrc.slice(0, match.index) +\n "[" +\n repeatString("a", match[0].length - 2) +\n "]" +\n maskedSrc.slice(\n this.tokenizer.rules.inline.reflinkSearch.lastIndex\n )\n }\n }\n }\n }\n // Mask out other blocks\n while (\n (match = this.tokenizer.rules.inline.blockSkip.exec(maskedSrc)) != null\n ) {\n maskedSrc =\n maskedSrc.slice(0, match.index) +\n "[" +\n repeatString("a", match[0].length - 2) +\n "]" +\n maskedSrc.slice(this.tokenizer.rules.inline.blockSkip.lastIndex)\n }\n\n // Mask out escaped em & strong delimiters\n while (\n (match = this.tokenizer.rules.inline.escapedEmSt.exec(maskedSrc)) != null\n ) {\n maskedSrc =\n maskedSrc.slice(0, match.index) +\n "++" +\n maskedSrc.slice(this.tokenizer.rules.inline.escapedEmSt.lastIndex)\n }\n\n while (src) {\n if (!keepPrevChar) {\n prevChar = ""\n }\n keepPrevChar = false\n\n // extensions\n if (\n this.options.extensions &&\n this.options.extensions.inline &&\n this.options.extensions.inline.some(extTokenizer => {\n if ((token = extTokenizer.call({ lexer: this }, src, tokens))) {\n src = src.substring(token.raw.length)\n tokens.push(token)\n return true\n }\n return false\n })\n ) {\n continue\n }\n\n // escape\n if ((token = this.tokenizer.escape(src))) {\n src = src.substring(token.raw.length)\n tokens.push(token)\n continue\n }\n\n // tag\n if ((token = this.tokenizer.tag(src))) {\n src = src.substring(token.raw.length)\n lastToken = tokens[tokens.length - 1]\n if (lastToken && token.type === "text" && lastToken.type === "text") {\n lastToken.raw += token.raw\n lastToken.text += token.text\n } else {\n tokens.push(token)\n }\n continue\n }\n\n // link\n if ((token = this.tokenizer.link(src))) {\n src = src.substring(token.raw.length)\n tokens.push(token)\n continue\n }\n\n // reflink, nolink\n if ((token = this.tokenizer.reflink(src, this.tokens.links))) {\n src = src.substring(token.raw.length)\n lastToken = tokens[tokens.length - 1]\n if (lastToken && token.type === "text" && lastToken.type === "text") {\n lastToken.raw += token.raw\n lastToken.text += token.text\n } else {\n tokens.push(token)\n }\n continue\n }\n\n // em & strong\n if ((token = this.tokenizer.emStrong(src, maskedSrc, prevChar))) {\n src = src.substring(token.raw.length)\n tokens.push(token)\n continue\n }\n\n // code\n if ((token = this.tokenizer.codespan(src))) {\n src = src.substring(token.raw.length)\n tokens.push(token)\n continue\n }\n\n // br\n if ((token = this.tokenizer.br(src))) {\n src = src.substring(token.raw.length)\n tokens.push(token)\n continue\n }\n\n // del (gfm)\n if ((token = this.tokenizer.del(src))) {\n src = src.substring(token.raw.length)\n tokens.push(token)\n continue\n }\n\n // autolink\n if ((token = this.tokenizer.autolink(src, mangle))) {\n src = src.substring(token.raw.length)\n tokens.push(token)\n continue\n }\n\n // url (gfm)\n if (!this.state.inLink && (token = this.tokenizer.url(src, mangle))) {\n src = src.substring(token.raw.length)\n tokens.push(token)\n continue\n }\n\n // text\n // prevent inlineText consuming extensions by clipping \'src\' to extension start\n cutSrc = src\n if (this.options.extensions && this.options.extensions.startInline) {\n let startIndex = Infinity\n const tempSrc = src.slice(1)\n let tempStart\n this.options.extensions.startInline.forEach(function (getStartIndex) {\n tempStart = getStartIndex.call({ lexer: this }, tempSrc)\n if (typeof tempStart === "number" && tempStart >= 0) {\n startIndex = Math.min(startIndex, tempStart)\n }\n })\n if (startIndex < Infinity && startIndex >= 0) {\n cutSrc = src.substring(0, startIndex + 1)\n }\n }\n if ((token = this.tokenizer.inlineText(cutSrc, smartypants))) {\n src = src.substring(token.raw.length)\n if (token.raw.slice(-1) !== "_") {\n // Track prevChar before string of ____ started\n prevChar = token.raw.slice(-1)\n }\n keepPrevChar = true\n lastToken = tokens[tokens.length - 1]\n if (lastToken && lastToken.type === "text") {\n lastToken.raw += token.raw\n lastToken.text += token.text\n } else {\n tokens.push(token)\n }\n continue\n }\n\n if (src) {\n const errMsg = "Infinite loop on byte: " + src.charCodeAt(0)\n if (this.options.silent) {\n console.error(errMsg)\n break\n } else {\n throw new Error(errMsg)\n }\n }\n }\n\n return tokens\n }\n}\n\n/**\n * Renderer\n */\nclass Renderer {\n constructor(options) {\n this.options = options || defaults\n }\n\n code(code, infostring, escaped) {\n const lang = (infostring || "").match(/\\S*/)[0]\n if (this.options.highlight) {\n const out = this.options.highlight(code, lang)\n if (out != null && out !== code) {\n escaped = true\n code = out\n }\n }\n\n code = code.replace(/\\n$/, "") + "\\n"\n\n if (!lang) {\n return (\n "
" +\n        (escaped ? code : escape(code, true)) +\n        "
\\n"\n )\n }\n\n return (\n \'
\' +\n      (escaped ? code : escape(code, true)) +\n      "
\\n"\n )\n }\n\n /**\n * @param {string} quote\n */\n blockquote(quote) {\n return `
\\n${quote}
\\n`\n }\n\n html(html) {\n return html\n }\n\n /**\n * @param {string} text\n * @param {string} level\n * @param {string} raw\n * @param {any} slugger\n */\n heading(text, level, raw, slugger) {\n if (this.options.headerIds) {\n const id = this.options.headerPrefix + slugger.slug(raw)\n return `${text}\\n`\n }\n\n // ignore IDs\n return `${text}\\n`\n }\n\n hr() {\n return this.options.xhtml ? "
\\n" : "
\\n"\n }\n\n list(body, ordered, start) {\n const type = ordered ? "ol" : "ul",\n startatt = ordered && start !== 1 ? \' start="\' + start + \'"\' : ""\n return "<" + type + startatt + ">\\n" + body + "\\n"\n }\n\n /**\n * @param {string} text\n */\n listitem(text) {\n return `
  • ${text}
  • \\n`\n }\n\n checkbox(checked) {\n return (\n " "\n )\n }\n\n /**\n * @param {string} text\n */\n paragraph(text) {\n return `

    ${text}

    \\n`\n }\n\n /**\n * @param {string} header\n * @param {string} body\n */\n table(header, body) {\n if (body) body = `${body}`\n\n return (\n "\\n" + "\\n" + header + "\\n" + body + "
    \\n"\n )\n }\n\n /**\n * @param {string} content\n */\n tablerow(content) {\n return `\\n${content}\\n`\n }\n\n tablecell(content, flags) {\n const type = flags.header ? "th" : "td"\n const tag = flags.align ? `<${type} align="${flags.align}">` : `<${type}>`\n return tag + content + `\\n`\n }\n\n /**\n * span level renderer\n * @param {string} text\n */\n strong(text) {\n return `${text}`\n }\n\n /**\n * @param {string} text\n */\n em(text) {\n return `${text}`\n }\n\n /**\n * @param {string} text\n */\n codespan(text) {\n return `${text}`\n }\n\n br() {\n return this.options.xhtml ? "
    " : "
    "\n }\n\n /**\n * @param {string} text\n */\n del(text) {\n return `${text}`\n }\n\n /**\n * @param {string} href\n * @param {string} title\n * @param {string} text\n */\n link(href, title, text) {\n href = cleanUrl(this.options.sanitize, this.options.baseUrl, href)\n if (href === null) {\n return text\n }\n let out = \'
    "\n return out\n }\n\n /**\n * @param {string} href\n * @param {string} title\n * @param {string} text\n */\n image(href, title, text) {\n href = cleanUrl(this.options.sanitize, this.options.baseUrl, href)\n if (href === null) {\n return text\n }\n\n let out = `${text}" : ">"\n return out\n }\n\n text(text) {\n return text\n }\n}\n\n/**\n * TextRenderer\n * returns only the textual part of the token\n */\nclass TextRenderer {\n // no need for block level renderers\n strong(text) {\n return text\n }\n\n em(text) {\n return text\n }\n\n codespan(text) {\n return text\n }\n\n del(text) {\n return text\n }\n\n html(text) {\n return text\n }\n\n text(text) {\n return text\n }\n\n link(href, title, text) {\n return "" + text\n }\n\n image(href, title, text) {\n return "" + text\n }\n\n br() {\n return ""\n }\n}\n\n/**\n * Slugger generates header id\n */\nclass Slugger {\n constructor() {\n this.seen = {}\n }\n\n /**\n * @param {string} value\n */\n serialize(value) {\n return (\n value\n .toLowerCase()\n .trim()\n // remove html tags\n .replace(/<[!\\/a-z].*?>/gi, "")\n // remove unwanted chars\n .replace(\n /[\\u2000-\\u206F\\u2E00-\\u2E7F\\\\\'!"#$%&()*+,./:;<=>?@[\\]^`{|}~]/g,\n ""\n )\n .replace(/\\s/g, "-")\n )\n }\n\n /**\n * Finds the next safe (unique) slug to use\n * @param {string} originalSlug\n * @param {boolean} isDryRun\n */\n getNextSafeSlug(originalSlug, isDryRun) {\n let slug = originalSlug\n let occurenceAccumulator = 0\n if (this.seen.hasOwnProperty(slug)) {\n occurenceAccumulator = this.seen[originalSlug]\n do {\n occurenceAccumulator++\n slug = originalSlug + "-" + occurenceAccumulator\n } while (this.seen.hasOwnProperty(slug))\n }\n if (!isDryRun) {\n this.seen[originalSlug] = occurenceAccumulator\n this.seen[slug] = 0\n }\n return slug\n }\n\n /**\n * Convert string to unique id\n * @param {object} [options]\n * @param {boolean} [options.dryrun] Generates the next unique slug without\n * updating the internal accumulator.\n */\n slug(value, options = {}) {\n const slug = this.serialize(value)\n return this.getNextSafeSlug(slug, options.dryrun)\n }\n}\n\n/**\n * Parsing & Compiling\n */\nclass Parser {\n constructor(options) {\n this.options = options || defaults\n this.options.renderer = this.options.renderer || new Renderer()\n this.renderer = this.options.renderer\n this.renderer.options = this.options\n this.textRenderer = new TextRenderer()\n this.slugger = new Slugger()\n }\n\n /**\n * Static Parse Method\n */\n static parse(tokens, options) {\n const parser = new Parser(options)\n return parser.parse(tokens)\n }\n\n /**\n * Static Parse Inline Method\n */\n static parseInline(tokens, options) {\n const parser = new Parser(options)\n return parser.parseInline(tokens)\n }\n\n /**\n * Parse Loop\n */\n parse(tokens, top = true) {\n let out = "",\n i,\n j,\n k,\n l2,\n l3,\n row,\n cell,\n header,\n body,\n token,\n ordered,\n start,\n loose,\n itemBody,\n item,\n checked,\n task,\n checkbox,\n ret\n\n const l = tokens.length\n for (i = 0; i < l; i++) {\n token = tokens[i]\n\n // Run any renderer extensions\n if (\n this.options.extensions &&\n this.options.extensions.renderers &&\n this.options.extensions.renderers[token.type]\n ) {\n ret = this.options.extensions.renderers[token.type].call(\n { parser: this },\n token\n )\n if (\n ret !== false ||\n ![\n "space",\n "hr",\n "heading",\n "code",\n "table",\n "blockquote",\n "list",\n "html",\n "paragraph",\n "text",\n ].includes(token.type)\n ) {\n out += ret || ""\n continue\n }\n }\n\n switch (token.type) {\n case "space": {\n continue\n }\n case "hr": {\n out += this.renderer.hr()\n continue\n }\n case "heading": {\n out += this.renderer.heading(\n this.parseInline(token.tokens),\n token.depth,\n unescape(this.parseInline(token.tokens, this.textRenderer)),\n this.slugger\n )\n continue\n }\n case "code": {\n out += this.renderer.code(token.text, token.lang, token.escaped)\n continue\n }\n case "table": {\n header = ""\n\n // header\n cell = ""\n l2 = token.header.length\n for (j = 0; j < l2; j++) {\n cell += this.renderer.tablecell(\n this.parseInline(token.header[j].tokens),\n { header: true, align: token.align[j] }\n )\n }\n header += this.renderer.tablerow(cell)\n\n body = ""\n l2 = token.rows.length\n for (j = 0; j < l2; j++) {\n row = token.rows[j]\n\n cell = ""\n l3 = row.length\n for (k = 0; k < l3; k++) {\n cell += this.renderer.tablecell(this.parseInline(row[k].tokens), {\n header: false,\n align: token.align[k],\n })\n }\n\n body += this.renderer.tablerow(cell)\n }\n out += this.renderer.table(header, body)\n continue\n }\n case "blockquote": {\n body = this.parse(token.tokens)\n out += this.renderer.blockquote(body)\n continue\n }\n case "list": {\n ordered = token.ordered\n start = token.start\n loose = token.loose\n l2 = token.items.length\n\n body = ""\n for (j = 0; j < l2; j++) {\n item = token.items[j]\n checked = item.checked\n task = item.task\n\n itemBody = ""\n if (item.task) {\n checkbox = this.renderer.checkbox(checked)\n if (loose) {\n if (\n item.tokens.length > 0 &&\n item.tokens[0].type === "paragraph"\n ) {\n item.tokens[0].text = checkbox + " " + item.tokens[0].text\n if (\n item.tokens[0].tokens &&\n item.tokens[0].tokens.length > 0 &&\n item.tokens[0].tokens[0].type === "text"\n ) {\n item.tokens[0].tokens[0].text =\n checkbox + " " + item.tokens[0].tokens[0].text\n }\n } else {\n item.tokens.unshift({\n type: "text",\n text: checkbox,\n })\n }\n } else {\n itemBody += checkbox\n }\n }\n\n itemBody += this.parse(item.tokens, loose)\n body += this.renderer.listitem(itemBody, task, checked)\n }\n\n out += this.renderer.list(body, ordered, start)\n continue\n }\n case "html": {\n // TODO parse inline content if parameter markdown=1\n out += this.renderer.html(token.text)\n continue\n }\n case "paragraph": {\n out += this.renderer.paragraph(this.parseInline(token.tokens))\n continue\n }\n case "text": {\n body = token.tokens ? this.parseInline(token.tokens) : token.text\n while (i + 1 < l && tokens[i + 1].type === "text") {\n token = tokens[++i]\n body +=\n "\\n" +\n (token.tokens ? this.parseInline(token.tokens) : token.text)\n }\n out += top ? this.renderer.paragraph(body) : body\n continue\n }\n\n default: {\n const errMsg = \'Token with "\' + token.type + \'" type was not found.\'\n if (this.options.silent) {\n console.error(errMsg)\n return\n } else {\n throw new Error(errMsg)\n }\n }\n }\n }\n\n return out\n }\n\n /**\n * Parse Inline Tokens\n */\n parseInline(tokens, renderer) {\n renderer = renderer || this.renderer\n let out = "",\n i,\n token,\n ret\n\n const l = tokens.length\n for (i = 0; i < l; i++) {\n token = tokens[i]\n\n // Run any renderer extensions\n if (\n this.options.extensions &&\n this.options.extensions.renderers &&\n this.options.extensions.renderers[token.type]\n ) {\n ret = this.options.extensions.renderers[token.type].call(\n { parser: this },\n token\n )\n if (\n ret !== false ||\n ![\n "escape",\n "html",\n "link",\n "image",\n "strong",\n "em",\n "codespan",\n "br",\n "del",\n "text",\n ].includes(token.type)\n ) {\n out += ret || ""\n continue\n }\n }\n\n switch (token.type) {\n case "escape": {\n out += renderer.text(token.text)\n break\n }\n case "html": {\n out += renderer.html(token.text)\n break\n }\n case "link": {\n out += renderer.link(\n token.href,\n token.title,\n this.parseInline(token.tokens, renderer)\n )\n break\n }\n case "image": {\n out += renderer.image(token.href, token.title, token.text)\n break\n }\n case "strong": {\n out += renderer.strong(this.parseInline(token.tokens, renderer))\n break\n }\n case "em": {\n out += renderer.em(this.parseInline(token.tokens, renderer))\n break\n }\n case "codespan": {\n out += renderer.codespan(token.text)\n break\n }\n case "br": {\n out += renderer.br()\n break\n }\n case "del": {\n out += renderer.del(this.parseInline(token.tokens, renderer))\n break\n }\n case "text": {\n out += renderer.text(token.text)\n break\n }\n default: {\n const errMsg = \'Token with "\' + token.type + \'" type was not found.\'\n if (this.options.silent) {\n console.error(errMsg)\n return\n } else {\n throw new Error(errMsg)\n }\n }\n }\n }\n return out\n }\n}\n\n/**\n * Marked\n */\nfunction marked(src, opt, callback) {\n // throw error in case of non string input\n if (typeof src === "undefined" || src === null) {\n throw new Error("marked(): input parameter is undefined or null")\n }\n if (typeof src !== "string") {\n throw new Error(\n "marked(): input parameter is of type " +\n Object.prototype.toString.call(src) +\n ", string expected"\n )\n }\n\n if (typeof opt === "function") {\n callback = opt\n opt = null\n }\n\n opt = merge({}, marked.defaults, opt || {})\n checkSanitizeDeprecation(opt)\n\n if (callback) {\n const highlight = opt.highlight\n let tokens\n\n try {\n tokens = Lexer.lex(src, opt)\n } catch (e) {\n return callback(e)\n }\n\n const done = function (err) {\n let out\n\n if (!err) {\n try {\n if (opt.walkTokens) {\n marked.walkTokens(tokens, opt.walkTokens)\n }\n out = Parser.parse(tokens, opt)\n } catch (e) {\n err = e\n }\n }\n\n opt.highlight = highlight\n\n return err ? callback(err) : callback(null, out)\n }\n\n if (!highlight || highlight.length < 3) {\n return done()\n }\n\n delete opt.highlight\n\n if (!tokens.length) return done()\n\n let pending = 0\n marked.walkTokens(tokens, function (token) {\n if (token.type === "code") {\n pending++\n setTimeout(() => {\n highlight(token.text, token.lang, function (err, code) {\n if (err) {\n return done(err)\n }\n if (code != null && code !== token.text) {\n token.text = code\n token.escaped = true\n }\n\n pending--\n if (pending === 0) {\n done()\n }\n })\n }, 0)\n }\n })\n\n if (pending === 0) {\n done()\n }\n\n return\n }\n\n try {\n const tokens = Lexer.lex(src, opt)\n if (opt.walkTokens) {\n marked.walkTokens(tokens, opt.walkTokens)\n }\n return Parser.parse(tokens, opt)\n } catch (e) {\n e.message += "\\nPlease report this to https://github.com/markedjs/marked."\n if (opt.silent) {\n return (\n "

    An error occurred:

    " +\n        escape(e.message + "", true) +\n        "
    "\n )\n }\n throw e\n }\n}\n\n/**\n * Options\n */\n\nmarked.options = marked.setOptions = function (opt) {\n merge(marked.defaults, opt)\n changeDefaults(marked.defaults)\n return marked\n}\n\nmarked.getDefaults = getDefaults\n\nmarked.defaults = defaults\n\n/**\n * Use Extension\n */\n\nmarked.use = function (...args) {\n const opts = merge({}, ...args)\n const extensions = marked.defaults.extensions || {\n renderers: {},\n childTokens: {},\n }\n let hasExtensions\n\n args.forEach(pack => {\n // ==-- Parse "addon" extensions --== //\n if (pack.extensions) {\n hasExtensions = true\n pack.extensions.forEach(ext => {\n if (!ext.name) {\n throw new Error("extension name required")\n }\n if (ext.renderer) {\n // Renderer extensions\n const prevRenderer = extensions.renderers\n ? extensions.renderers[ext.name]\n : null\n if (prevRenderer) {\n // Replace extension with func to run new extension but fall back if false\n extensions.renderers[ext.name] = function (...args) {\n let ret = ext.renderer.apply(this, args)\n if (ret === false) {\n ret = prevRenderer.apply(this, args)\n }\n return ret\n }\n } else {\n extensions.renderers[ext.name] = ext.renderer\n }\n }\n if (ext.tokenizer) {\n // Tokenizer Extensions\n if (!ext.level || (ext.level !== "block" && ext.level !== "inline")) {\n throw new Error("extension level must be \'block\' or \'inline\'")\n }\n if (extensions[ext.level]) {\n extensions[ext.level].unshift(ext.tokenizer)\n } else {\n extensions[ext.level] = [ext.tokenizer]\n }\n if (ext.start) {\n // Function to check for start of token\n if (ext.level === "block") {\n if (extensions.startBlock) {\n extensions.startBlock.push(ext.start)\n } else {\n extensions.startBlock = [ext.start]\n }\n } else if (ext.level === "inline") {\n if (extensions.startInline) {\n extensions.startInline.push(ext.start)\n } else {\n extensions.startInline = [ext.start]\n }\n }\n }\n }\n if (ext.childTokens) {\n // Child tokens to be visited by walkTokens\n extensions.childTokens[ext.name] = ext.childTokens\n }\n })\n }\n\n // ==-- Parse "overwrite" extensions --== //\n if (pack.renderer) {\n const renderer = marked.defaults.renderer || new Renderer()\n for (const prop in pack.renderer) {\n const prevRenderer = renderer[prop]\n // Replace renderer with func to run extension, but fall back if false\n renderer[prop] = (...args) => {\n let ret = pack.renderer[prop].apply(renderer, args)\n if (ret === false) {\n ret = prevRenderer.apply(renderer, args)\n }\n return ret\n }\n }\n opts.renderer = renderer\n }\n if (pack.tokenizer) {\n const tokenizer = marked.defaults.tokenizer || new Tokenizer()\n for (const prop in pack.tokenizer) {\n const prevTokenizer = tokenizer[prop]\n // Replace tokenizer with func to run extension, but fall back if false\n tokenizer[prop] = (...args) => {\n let ret = pack.tokenizer[prop].apply(tokenizer, args)\n if (ret === false) {\n ret = prevTokenizer.apply(tokenizer, args)\n }\n return ret\n }\n }\n opts.tokenizer = tokenizer\n }\n\n // ==-- Parse WalkTokens extensions --== //\n if (pack.walkTokens) {\n const walkTokens = marked.defaults.walkTokens\n opts.walkTokens = function (token) {\n pack.walkTokens.call(this, token)\n if (walkTokens) {\n walkTokens.call(this, token)\n }\n }\n }\n\n if (hasExtensions) {\n opts.extensions = extensions\n }\n\n marked.setOptions(opts)\n })\n}\n\n/**\n * Run callback for every token\n */\n\nmarked.walkTokens = function (tokens, callback) {\n for (const token of tokens) {\n callback.call(marked, token)\n switch (token.type) {\n case "table": {\n for (const cell of token.header) {\n marked.walkTokens(cell.tokens, callback)\n }\n for (const row of token.rows) {\n for (const cell of row) {\n marked.walkTokens(cell.tokens, callback)\n }\n }\n break\n }\n case "list": {\n marked.walkTokens(token.items, callback)\n break\n }\n default: {\n if (\n marked.defaults.extensions &&\n marked.defaults.extensions.childTokens &&\n marked.defaults.extensions.childTokens[token.type]\n ) {\n // Walk any extensions\n marked.defaults.extensions.childTokens[token.type].forEach(function (\n childTokens\n ) {\n marked.walkTokens(token[childTokens], callback)\n })\n } else if (token.tokens) {\n marked.walkTokens(token.tokens, callback)\n }\n }\n }\n }\n}\n\n/**\n * Parse Inline\n * @param {string} src\n */\nmarked.parseInline = function (src, opt) {\n // throw error in case of non string input\n if (typeof src === "undefined" || src === null) {\n throw new Error(\n "marked.parseInline(): input parameter is undefined or null"\n )\n }\n if (typeof src !== "string") {\n throw new Error(\n "marked.parseInline(): input parameter is of type " +\n Object.prototype.toString.call(src) +\n ", string expected"\n )\n }\n\n opt = merge({}, marked.defaults, opt || {})\n checkSanitizeDeprecation(opt)\n\n try {\n const tokens = Lexer.lexInline(src, opt)\n if (opt.walkTokens) {\n marked.walkTokens(tokens, opt.walkTokens)\n }\n return Parser.parseInline(tokens, opt)\n } catch (e) {\n e.message += "\\nPlease report this to https://github.com/markedjs/marked."\n if (opt.silent) {\n return (\n "

    An error occurred:

    " +\n        escape(e.message + "", true) +\n        "
    "\n )\n }\n throw e\n }\n}\n\n/**\n * Expose\n */\nmarked.Parser = Parser\nmarked.parser = Parser.parse\nmarked.Renderer = Renderer\nmarked.TextRenderer = TextRenderer\nmarked.Lexer = Lexer\nmarked.lexer = Lexer.lex\nmarked.Tokenizer = Tokenizer\nmarked.Slugger = Slugger\nmarked.parse = marked\n\nconst options = marked.options\nconst setOptions = marked.setOptions\nconst use = marked.use\nconst walkTokens = marked.walkTokens\nconst parseInline = marked.parseInline\nconst parse = marked\nconst parser = Parser.parse\nconst lexer = Lexer.lex\n\nconst email = trigger.row\nreturn marked(email.Message)' From 208464a158031be8374f684a25045448f32586fc Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Wed, 13 Mar 2024 12:02:37 +0000 Subject: [PATCH 46/74] Fix snippet decorator regex --- .../builder/src/components/common/bindings/SnippetDrawer.svelte | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/builder/src/components/common/bindings/SnippetDrawer.svelte b/packages/builder/src/components/common/bindings/SnippetDrawer.svelte index 3badf0d8c3..0989432735 100644 --- a/packages/builder/src/components/common/bindings/SnippetDrawer.svelte +++ b/packages/builder/src/components/common/bindings/SnippetDrawer.svelte @@ -80,6 +80,8 @@ } return null } + + $: console.log(nameError) From 8a455781d4a63cb3ef12f62bd786c8b647dccc64 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Wed, 13 Mar 2024 12:03:57 +0000 Subject: [PATCH 47/74] Fix regex. Wrong file before --- .../builder/src/components/common/CodeEditor/CodeEditor.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte b/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte index 1ddc3d802a..5eefe11e58 100644 --- a/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte +++ b/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte @@ -109,7 +109,7 @@ // Match decoration for snippets const snippetMatchDeco = new MatchDecorator({ - regexp: /snippets.[^\s(]+/g, + regexp: /snippets\.[^\s(]+/g, decoration: () => { return Decoration.mark({ tag: "span", From c9c0384c96e261079506ef6aec32d7e79a744f73 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Wed, 13 Mar 2024 12:05:21 +0000 Subject: [PATCH 48/74] Fix being unable to edit snippets --- .../src/components/common/bindings/SnippetDrawer.svelte | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/builder/src/components/common/bindings/SnippetDrawer.svelte b/packages/builder/src/components/common/bindings/SnippetDrawer.svelte index 0989432735..a8973c753b 100644 --- a/packages/builder/src/components/common/bindings/SnippetDrawer.svelte +++ b/packages/builder/src/components/common/bindings/SnippetDrawer.svelte @@ -80,8 +80,6 @@ } return null } - - $: console.log(nameError) @@ -114,7 +112,11 @@ Delete {/if} - From 886929b8bc82706e6360394db91ad219d8984853 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Wed, 13 Mar 2024 12:06:42 +0000 Subject: [PATCH 49/74] Fix being unable to hide side panels in the binding editor again. Already fixed this but got lost in a merge --- .../builder/src/components/common/bindings/BindingPanel.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/builder/src/components/common/bindings/BindingPanel.svelte b/packages/builder/src/components/common/bindings/BindingPanel.svelte index 01c2f5d55b..7a29e20700 100644 --- a/packages/builder/src/components/common/bindings/BindingPanel.svelte +++ b/packages/builder/src/components/common/bindings/BindingPanel.svelte @@ -84,7 +84,7 @@ $: bindingHelpers = new BindingHelpers(getCaretPosition, insertAtPos) $: { // Ensure a valid side panel option is always selected - if (!sidePanelOptions.includes(sidePanel)) { + if (sidePanel && !sidePanelOptions.includes(sidePanel)) { sidePanel = sidePanelOptions[0] } } From 64855bbdf03d0ce3e7cfeec103d4236940d64766 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Wed, 13 Mar 2024 12:11:09 +0000 Subject: [PATCH 50/74] Optimise cloneDeep usage in string templates --- packages/string-templates/src/helpers/javascript.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/string-templates/src/helpers/javascript.js b/packages/string-templates/src/helpers/javascript.js index 76ef19ef2e..26c2753295 100644 --- a/packages/string-templates/src/helpers/javascript.js +++ b/packages/string-templates/src/helpers/javascript.js @@ -54,8 +54,9 @@ module.exports.processJS = (handlebars, context) => { // Our $ context function gets a value from context. // We clone the context to avoid mutation in the binding affecting real // app context. + const clonedContext = cloneDeep(context) const sandboxContext = { - $: path => getContextValue(path, cloneDeep(context)), + $: path => getContextValue(path, clonedContext), helpers: getJsHelperList(), // Proxy to evaluate snippets when running in the browser From 861d48dbf32aa08d6e582c7951fef4d4fb45f73d Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Wed, 13 Mar 2024 12:37:49 +0000 Subject: [PATCH 51/74] Transform snippets into a map in the browser for faster access --- packages/string-templates/src/helpers/javascript.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/string-templates/src/helpers/javascript.js b/packages/string-templates/src/helpers/javascript.js index 26c2753295..e38f9b5651 100644 --- a/packages/string-templates/src/helpers/javascript.js +++ b/packages/string-templates/src/helpers/javascript.js @@ -51,10 +51,16 @@ module.exports.processJS = (handlebars, context) => { // This is required to allow the final `return` statement to be valid. const js = iifeWrapper(atob(handlebars)) + // Transform snippets into an object for faster access + let snippetMap = {} + for (let snippet of context.snippets || []) { + snippetMap[snippet.name] = snippet.code + } + // Our $ context function gets a value from context. // We clone the context to avoid mutation in the binding affecting real // app context. - const clonedContext = cloneDeep(context) + const clonedContext = cloneDeep({ ...context, snippets: null }) const sandboxContext = { $: path => getContextValue(path, clonedContext), helpers: getJsHelperList(), @@ -64,9 +70,7 @@ module.exports.processJS = (handlebars, context) => { {}, { get: function (_, name) { - // This will error if the snippet doesn't exist, but that's intended - const snippet = (context.snippets || []).find(x => x.name === name) - return eval(iifeWrapper(snippet.code)) + return eval(iifeWrapper(snippetMap[name])) }, } ), From 663abde785a6af0294a5e561d979c687badcbc5c Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Wed, 13 Mar 2024 12:48:55 +0000 Subject: [PATCH 52/74] Optimise isolated-vm snippet performance by using a map and by caching evaluated snippets --- .../jsRunner/bundles/snippets.ivm.bundle.js | 6 +++--- .../server/src/jsRunner/bundles/snippets.ts | 19 ++++++++++++------- .../server/src/jsRunner/vm/isolated-vm.ts | 8 +++++++- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/packages/server/src/jsRunner/bundles/snippets.ivm.bundle.js b/packages/server/src/jsRunner/bundles/snippets.ivm.bundle.js index 5adb19eaf7..85fb8bbd5a 100644 --- a/packages/server/src/jsRunner/bundles/snippets.ivm.bundle.js +++ b/packages/server/src/jsRunner/bundles/snippets.ivm.bundle.js @@ -1,3 +1,3 @@ -"use strict";var snippets=(()=>{var u=Object.create;var p=Object.defineProperty;var c=Object.getOwnPropertyDescriptor;var d=Object.getOwnPropertyNames;var m=Object.getPrototypeOf,x=Object.prototype.hasOwnProperty;var l=(e,n)=>()=>(n||e((n={exports:{}}).exports,n),n.exports),W=(e,n)=>{for(var i in n)p(e,i,{get:n[i],enumerable:!0})},o=(e,n,i,t)=>{if(n&&typeof n=="object"||typeof n=="function")for(let r of d(n))!x.call(e,r)&&r!==i&&p(e,r,{get:()=>n[r],enumerable:!(t=c(n,r))||t.enumerable});return e};var g=(e,n,i)=>(i=e!=null?u(m(e)):{},o(n||!e||!e.__esModule?p(i,"default",{value:e,enumerable:!0}):i,e)),v=e=>o(p({},"__esModule",{value:!0}),e);var a=l((P,f)=>{f.exports.iifeWrapper=e=>`(function(){ -${e} -})();`});var y={};W(y,{default:()=>w});var s=g(a()),w=new Proxy({},{get:function(e,n){let i=(snippetDefinitions||[]).find(t=>t.name===n);return[eval][0]((0,s.iifeWrapper)(i.code))}});return v(y);})(); +"use strict";var snippets=(()=>{var a=Object.create;var r=Object.defineProperty;var c=Object.getOwnPropertyDescriptor;var h=Object.getOwnPropertyNames;var l=Object.getPrototypeOf,x=Object.prototype.hasOwnProperty;var C=(i,e)=>()=>(e||i((e={exports:{}}).exports,e),e.exports),y=(i,e)=>{for(var n in e)r(i,n,{get:e[n],enumerable:!0})},f=(i,e,n,t)=>{if(e&&typeof e=="object"||typeof e=="function")for(let p of h(e))!x.call(i,p)&&p!==n&&r(i,p,{get:()=>e[p],enumerable:!(t=c(e,p))||t.enumerable});return i};var W=(i,e,n)=>(n=i!=null?a(l(i)):{},f(e||!i||!i.__esModule?r(n,"default",{value:i,enumerable:!0}):n,i)),d=i=>f(r({},"__esModule",{value:!0}),i);var s=C((D,o)=>{o.exports.iifeWrapper=i=>`(function(){ +${i} +})();`});var v={};y(v,{default:()=>g});var u=W(s()),g=new Proxy({},{get:function(i,e){if(!(e in snippetCache))snippetCache[e]=[eval][0]((0,u.iifeWrapper)(snippetDefinitions[e]));else return n=>n*2;return snippetCache[e]}});return d(v);})(); diff --git a/packages/server/src/jsRunner/bundles/snippets.ts b/packages/server/src/jsRunner/bundles/snippets.ts index f473aaf7b4..258d501a27 100644 --- a/packages/server/src/jsRunner/bundles/snippets.ts +++ b/packages/server/src/jsRunner/bundles/snippets.ts @@ -6,14 +6,19 @@ export default new Proxy( {}, { get: function (_, name) { - // Snippet definitions are injected to the isolate global scope before - // this bundle is loaded, so we can access it from there. - // https://esbuild.github.io/content-types/#direct-eval for info on why - // eval is being called this way. + // Both snippetDefinitions and snippetCache are injected to the isolate + // global scope before this bundle is loaded, so we can access it from + // there. + // See https://esbuild.github.io/content-types/#direct-eval for info on + // why eval is being called this way. + // Snippets are cached and reused once they have been evaluated. // @ts-ignore - // eslint-disable-next-line no-undef - const snippet = (snippetDefinitions || []).find(x => x.name === name) - return [eval][0](iifeWrapper(snippet.code)) + if (!(name in snippetCache)) { + // @ts-ignore + snippetCache[name] = [eval][0](iifeWrapper(snippetDefinitions[name])) + } + // @ts-ignore + return snippetCache[name] }, } ) diff --git a/packages/server/src/jsRunner/vm/isolated-vm.ts b/packages/server/src/jsRunner/vm/isolated-vm.ts index 64e68c296d..e89d420ec5 100644 --- a/packages/server/src/jsRunner/vm/isolated-vm.ts +++ b/packages/server/src/jsRunner/vm/isolated-vm.ts @@ -99,9 +99,15 @@ export class IsolatedVM implements VM { } withSnippets(snippets?: Snippet[]) { + // Transform snippets into a map for faster access + let snippetMap: Record = {} + for (let snippet of snippets || []) { + snippetMap[snippet.name] = snippet.code + } const snippetsSource = loadBundle(BundleType.SNIPPETS) const script = this.isolate.compileScriptSync(` - const snippetDefinitions = ${JSON.stringify(snippets || [])}; + const snippetDefinitions = ${JSON.stringify(snippetMap)}; + const snippetCache = {}; ${snippetsSource}; snippets = snippets.default; `) From 30622a56ca0e3546d7ab79809602c33ce4a574cc Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Wed, 13 Mar 2024 12:50:26 +0000 Subject: [PATCH 53/74] Add updated snippets IVM bundle --- packages/server/src/jsRunner/bundles/snippets.ivm.bundle.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/server/src/jsRunner/bundles/snippets.ivm.bundle.js b/packages/server/src/jsRunner/bundles/snippets.ivm.bundle.js index 85fb8bbd5a..bad9049af0 100644 --- a/packages/server/src/jsRunner/bundles/snippets.ivm.bundle.js +++ b/packages/server/src/jsRunner/bundles/snippets.ivm.bundle.js @@ -1,3 +1,3 @@ -"use strict";var snippets=(()=>{var a=Object.create;var r=Object.defineProperty;var c=Object.getOwnPropertyDescriptor;var h=Object.getOwnPropertyNames;var l=Object.getPrototypeOf,x=Object.prototype.hasOwnProperty;var C=(i,e)=>()=>(e||i((e={exports:{}}).exports,e),e.exports),y=(i,e)=>{for(var n in e)r(i,n,{get:e[n],enumerable:!0})},f=(i,e,n,t)=>{if(e&&typeof e=="object"||typeof e=="function")for(let p of h(e))!x.call(i,p)&&p!==n&&r(i,p,{get:()=>e[p],enumerable:!(t=c(e,p))||t.enumerable});return i};var W=(i,e,n)=>(n=i!=null?a(l(i)):{},f(e||!i||!i.__esModule?r(n,"default",{value:i,enumerable:!0}):n,i)),d=i=>f(r({},"__esModule",{value:!0}),i);var s=C((D,o)=>{o.exports.iifeWrapper=i=>`(function(){ +"use strict";var snippets=(()=>{var u=Object.create;var n=Object.defineProperty;var a=Object.getOwnPropertyDescriptor;var h=Object.getOwnPropertyNames;var x=Object.getPrototypeOf,C=Object.prototype.hasOwnProperty;var l=(i,e)=>()=>(e||i((e={exports:{}}).exports,e),e.exports),W=(i,e)=>{for(var p in e)n(i,p,{get:e[p],enumerable:!0})},f=(i,e,p,r)=>{if(e&&typeof e=="object"||typeof e=="function")for(let t of h(e))!C.call(i,t)&&t!==p&&n(i,t,{get:()=>e[t],enumerable:!(r=a(e,t))||r.enumerable});return i};var d=(i,e,p)=>(p=i!=null?u(x(i)):{},f(e||!i||!i.__esModule?n(p,"default",{value:i,enumerable:!0}):p,i)),g=i=>f(n({},"__esModule",{value:!0}),i);var s=l((D,o)=>{o.exports.iifeWrapper=i=>`(function(){ ${i} -})();`});var v={};y(v,{default:()=>g});var u=W(s()),g=new Proxy({},{get:function(i,e){if(!(e in snippetCache))snippetCache[e]=[eval][0]((0,u.iifeWrapper)(snippetDefinitions[e]));else return n=>n*2;return snippetCache[e]}});return d(v);})(); +})();`});var w={};W(w,{default:()=>v});var c=d(s()),v=new Proxy({},{get:function(i,e){return e in snippetCache||(snippetCache[e]=[eval][0]((0,c.iifeWrapper)(snippetDefinitions[e]))),snippetCache[e]}});return g(w);})(); From 95f71efdab75c8f8e6806d09a83131d3e3258d00 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Wed, 13 Mar 2024 12:52:36 +0000 Subject: [PATCH 54/74] Cache snippet evaluations in the browser --- packages/string-templates/src/helpers/javascript.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/string-templates/src/helpers/javascript.js b/packages/string-templates/src/helpers/javascript.js index e38f9b5651..5be2619463 100644 --- a/packages/string-templates/src/helpers/javascript.js +++ b/packages/string-templates/src/helpers/javascript.js @@ -51,8 +51,10 @@ module.exports.processJS = (handlebars, context) => { // This is required to allow the final `return` statement to be valid. const js = iifeWrapper(atob(handlebars)) - // Transform snippets into an object for faster access + // Transform snippets into an object for faster access, and cache previously + // evaluated snippets let snippetMap = {} + let snippetCache = {} for (let snippet of context.snippets || []) { snippetMap[snippet.name] = snippet.code } @@ -70,7 +72,10 @@ module.exports.processJS = (handlebars, context) => { {}, { get: function (_, name) { - return eval(iifeWrapper(snippetMap[name])) + if (!(name in snippetCache)) { + snippetCache[name] = eval(iifeWrapper(snippetMap[name])) + } + return snippetCache[name] }, } ), From 5666a965e068a12c5086a92ab1d8cdce9e557172 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Wed, 13 Mar 2024 13:01:44 +0000 Subject: [PATCH 55/74] Fix issue with click_outside and drawers --- packages/bbui/src/Actions/click_outside.js | 4 +- packages/bbui/src/Drawer/Drawer.svelte | 67 ++++++++++++---------- 2 files changed, 39 insertions(+), 32 deletions(-) diff --git a/packages/bbui/src/Actions/click_outside.js b/packages/bbui/src/Actions/click_outside.js index 62416ae88d..12c4c4d002 100644 --- a/packages/bbui/src/Actions/click_outside.js +++ b/packages/bbui/src/Actions/click_outside.js @@ -33,8 +33,8 @@ const handleClick = event => { } // Ignore clicks for drawers, unless the handler is registered from a drawer - const sourceInDrawer = handler.anchor.closest(".drawer-container") != null - const clickInDrawer = event.target.closest(".drawer-container") != null + const sourceInDrawer = handler.anchor.closest(".drawer-wrapper") != null + const clickInDrawer = event.target.closest(".drawer-wrapper") != null if (clickInDrawer && !sourceInDrawer) { return } diff --git a/packages/bbui/src/Drawer/Drawer.svelte b/packages/bbui/src/Drawer/Drawer.svelte index 04e678c4e5..89ee92726d 100644 --- a/packages/bbui/src/Drawer/Drawer.svelte +++ b/packages/bbui/src/Drawer/Drawer.svelte @@ -172,37 +172,44 @@ {#if visible} -
    -
    0} - class:modal={$modal} - transition:drawerSlide|local - {style} - > -
    - {#if $$slots.title} - - {:else} -
    {title || "Bindings"}
    - {/if} -
    - - - {#if $resizable} - modal.set(!$modal)} - > - - + +
    +
    +
    0} + class:modal={$modal} + transition:drawerSlide|local + {style} + > +
    + {#if $$slots.title} + + {:else} +
    {title || "Bindings"}
    {/if} -
    -
    - -
    +
    + + + {#if $resizable} + modal.set(!$modal)} + > + + + {/if} +
    + + +
    +
    {/if} From 6d53b0676211b6a997325a0db1968f2ab4a65ab0 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Wed, 13 Mar 2024 13:23:48 +0000 Subject: [PATCH 56/74] Fix typo in automations placeholder --- .../automation/AutomationPanel/AutomationPanel.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/builder/src/components/automation/AutomationPanel/AutomationPanel.svelte b/packages/builder/src/components/automation/AutomationPanel/AutomationPanel.svelte index 582db950fe..ac1c4f91cb 100644 --- a/packages/builder/src/components/automation/AutomationPanel/AutomationPanel.svelte +++ b/packages/builder/src/components/automation/AutomationPanel/AutomationPanel.svelte @@ -49,7 +49,7 @@ + - - { - search = null - }} - class:searching={search} - > - - + {:else} +
    Bindings
    + + {/if}
    {/if} - {#if !selectedCategory && !search}
      {#each categoryNames as categoryName} @@ -281,18 +303,15 @@ background: var(--background); z-index: 1; } - .header :global(input) { border: none; border-radius: 0; background: none; padding: 0; } - .search-input { - flex: 1; - } - .search-input-icon.searching { - cursor: pointer; + .search-input, + .title { + flex: 1 1 auto; } ul.category-list { diff --git a/packages/builder/src/components/common/bindings/SnippetSidePanel.svelte b/packages/builder/src/components/common/bindings/SnippetSidePanel.svelte index 592ca7cfcd..72c9b2f44c 100644 --- a/packages/builder/src/components/common/bindings/SnippetSidePanel.svelte +++ b/packages/builder/src/components/common/bindings/SnippetSidePanel.svelte @@ -89,11 +89,29 @@ bind:value={search} />
    - + {:else}
    Snippets
    - - + + {/if}
    @@ -108,9 +126,9 @@ editSnippet(e, snippet)} - color="var(--spectrum-global-color-gray-700)" />
    {/each} From 567cbf3ef89feaffc809d385da5229b0bcbfec7c Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Wed, 13 Mar 2024 13:58:42 +0000 Subject: [PATCH 58/74] More icon updates for consistency --- packages/bbui/src/Icon/Icon.svelte | 10 +++++++++- .../common/bindings/EvaluationSidePanel.svelte | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/bbui/src/Icon/Icon.svelte b/packages/bbui/src/Icon/Icon.svelte index 275c339bf4..13452cf981 100644 --- a/packages/bbui/src/Icon/Icon.svelte +++ b/packages/bbui/src/Icon/Icon.svelte @@ -14,6 +14,7 @@ export let disabled = false export let color export let tooltip + export let newStyles = false $: rotation = getRotation(direction) @@ -28,6 +29,7 @@
    (showTooltip = true)} on:focus={() => (showTooltip = true)} on:mouseleave={() => (showTooltip = false)} @@ -60,6 +62,9 @@ display: grid; place-items: center; } + .newStyles { + color: var(--spectrum-global-color-gray-700); + } svg.hoverable { pointer-events: all; @@ -72,7 +77,10 @@ svg.hoverable:active { color: var(--spectrum-global-color-blue-400) !important; } - + .newStyles svg.hoverable:hover, + .newStyles svg.hoverable:active { + color: var(--spectrum-global-color-gray-900) !important; + } svg.disabled { color: var(--spectrum-global-color-gray-500) !important; pointer-events: none !important; diff --git a/packages/builder/src/components/common/bindings/EvaluationSidePanel.svelte b/packages/builder/src/components/common/bindings/EvaluationSidePanel.svelte index 82c3f80a6f..1bd55b4e21 100644 --- a/packages/builder/src/components/common/bindings/EvaluationSidePanel.svelte +++ b/packages/builder/src/components/common/bindings/EvaluationSidePanel.svelte @@ -65,7 +65,7 @@ {/if} {#if !empty} - + {/if} {/if}
    From 138cd39c36d8e905fb9ca50fc9fc2a183c564e3c Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Wed, 13 Mar 2024 13:59:14 +0000 Subject: [PATCH 59/74] Autofocus search inputs --- .../src/components/common/bindings/SnippetSidePanel.svelte | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/builder/src/components/common/bindings/SnippetSidePanel.svelte b/packages/builder/src/components/common/bindings/SnippetSidePanel.svelte index 72c9b2f44c..c82eed7a23 100644 --- a/packages/builder/src/components/common/bindings/SnippetSidePanel.svelte +++ b/packages/builder/src/components/common/bindings/SnippetSidePanel.svelte @@ -87,6 +87,7 @@ placeholder="Search for snippets" autocomplete="off" bind:value={search} + autofocus />
    Date: Wed, 13 Mar 2024 14:29:50 +0000 Subject: [PATCH 60/74] Update packages/shared-core/src/constants/index.ts Co-authored-by: Sam Rose --- packages/shared-core/src/constants/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared-core/src/constants/index.ts b/packages/shared-core/src/constants/index.ts index b5b651a3da..633fd36e45 100644 --- a/packages/shared-core/src/constants/index.ts +++ b/packages/shared-core/src/constants/index.ts @@ -98,7 +98,7 @@ export enum BuilderSocketEvent { export const SocketSessionTTL = 60 export const ValidQueryNameRegex = /^[^()]*$/ export const ValidColumnNameRegex = /^[_a-zA-Z0-9\s]*$/g -export const ValidSnippetNameRegex = /^[a-z-_][a-z0-9-_]*$/i +export const ValidSnippetNameRegex = /^[a-z_][a-z0-9_]*$/i export const REBOOT_CRON = "@reboot" From 47925e394d8c22baf90c09bdef3da87ddaad4025 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Wed, 13 Mar 2024 16:20:18 +0000 Subject: [PATCH 61/74] Lint and remove outdated comment --- .../src/components/common/bindings/BindingSidePanel.svelte | 2 -- .../src/components/common/bindings/SnippetDrawer.svelte | 6 ------ 2 files changed, 8 deletions(-) diff --git a/packages/builder/src/components/common/bindings/BindingSidePanel.svelte b/packages/builder/src/components/common/bindings/BindingSidePanel.svelte index 4c54ef2698..f364b39ba9 100644 --- a/packages/builder/src/components/common/bindings/BindingSidePanel.svelte +++ b/packages/builder/src/components/common/bindings/BindingSidePanel.svelte @@ -3,7 +3,6 @@ import { convertToJS } from "@budibase/string-templates" import { Input, Layout, Icon, Popover } from "@budibase/bbui" import { handlebarsCompletions } from "constants/completions" - import { tick } from "svelte" export let addHelper export let addBinding @@ -154,7 +153,6 @@ -
    {#if selectedCategory} diff --git a/packages/builder/src/components/common/bindings/SnippetDrawer.svelte b/packages/builder/src/components/common/bindings/SnippetDrawer.svelte index a8973c753b..d6b6f92b17 100644 --- a/packages/builder/src/components/common/bindings/SnippetDrawer.svelte +++ b/packages/builder/src/components/common/bindings/SnippetDrawer.svelte @@ -59,12 +59,6 @@ loading = false } - // Validating function names is not as easy as you think. A simple regex does - // not work, as there are a bunch of reserved words. The correct regex for - // this is about 12K characters long. - // Instead, we can run a simple regex to roughly validate it, then basically - // try executing it and see if it's valid JS. The initial regex prevents - // against any potential XSS attacks here. const validateName = (name, snippets) => { if (!name?.length) { return "Name is required" From 8b4ce703e97657e97e01a9d13c579e6da6cc49a1 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Wed, 13 Mar 2024 17:01:09 +0000 Subject: [PATCH 62/74] Try to fix tests --- packages/backend-core/src/context/mainContext.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend-core/src/context/mainContext.ts b/packages/backend-core/src/context/mainContext.ts index 9d4cc9096d..6cea7efeba 100644 --- a/packages/backend-core/src/context/mainContext.ts +++ b/packages/backend-core/src/context/mainContext.ts @@ -292,7 +292,7 @@ export async function ensureSnippetContext() { // Otherwise get snippets for this app and update context let snippets: Snippet[] | undefined const db = getAppDB() - if (db) { + if (db && !env.isTest()) { const app = await db.get(DocumentType.APP_METADATA) snippets = app.snippets } From f8690a6bd95f45a76ffac597e50bb1cacd3f9a15 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Thu, 14 Mar 2024 12:08:03 +0000 Subject: [PATCH 63/74] Update comment --- packages/builder/src/components/common/bindings/utils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/builder/src/components/common/bindings/utils.js b/packages/builder/src/components/common/bindings/utils.js index 4ff99aa6bb..c60374f0f7 100644 --- a/packages/builder/src/components/common/bindings/utils.js +++ b/packages/builder/src/components/common/bindings/utils.js @@ -39,7 +39,7 @@ export class BindingHelpers { } } - // Adds a JS/HBS helper to the expression + // Adds a snippet to the expression onSelectSnippet(snippet) { const pos = this.getCaretPosition() const { start, end } = pos From 0e94caafcbfd5103cd551d43fdb4d7a4c5f6621c Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Thu, 14 Mar 2024 14:10:37 +0000 Subject: [PATCH 64/74] Update snippet insertion to not insert parenthesis --- packages/builder/src/components/common/CodeEditor/index.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/builder/src/components/common/CodeEditor/index.js b/packages/builder/src/components/common/CodeEditor/index.js index b93c95b944..f66c84adce 100644 --- a/packages/builder/src/components/common/CodeEditor/index.js +++ b/packages/builder/src/components/common/CodeEditor/index.js @@ -271,13 +271,12 @@ export const insertBinding = (view, from, to, text, mode) => { } export const insertSnippet = (view, from, to, text) => { - const parsedInsert = `${text}()` - let cursorPos = from + parsedInsert.length - 1 + let cursorPos = from + text.length view.dispatch({ changes: { from, to, - insert: parsedInsert, + insert: text, }, selection: { anchor: cursorPos, From 049c2b989ba8422765ee706ce8bafceecef5fb7e Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Thu, 14 Mar 2024 14:29:13 +0000 Subject: [PATCH 65/74] Soft paywall snippets --- .../common/bindings/SnippetSidePanel.svelte | 150 ++++++++++++------ 1 file changed, 100 insertions(+), 50 deletions(-) diff --git a/packages/builder/src/components/common/bindings/SnippetSidePanel.svelte b/packages/builder/src/components/common/bindings/SnippetSidePanel.svelte index c82eed7a23..4495e0d3e4 100644 --- a/packages/builder/src/components/common/bindings/SnippetSidePanel.svelte +++ b/packages/builder/src/components/common/bindings/SnippetSidePanel.svelte @@ -1,8 +1,19 @@ + + + + diff --git a/packages/client/src/stores/derived/snippets.js b/packages/client/src/stores/derived/snippets.js index 806ff85c4a..74b2797643 100644 --- a/packages/client/src/stores/derived/snippets.js +++ b/packages/client/src/stores/derived/snippets.js @@ -1,8 +1,8 @@ -import { derived } from "svelte/store" import { appStore } from "../app.js" import { builderStore } from "../builder.js" +import { derivedMemo } from "@budibase/frontend-core" -export const snippets = derived( +export const snippets = derivedMemo( [appStore, builderStore], ([$appStore, $builderStore]) => { return $builderStore?.snippets || $appStore?.application?.snippets || [] diff --git a/packages/client/src/utils/enrichDataBinding.js b/packages/client/src/utils/enrichDataBinding.js index 0068a3241c..3756d8789a 100644 --- a/packages/client/src/utils/enrichDataBinding.js +++ b/packages/client/src/utils/enrichDataBinding.js @@ -1,14 +1,10 @@ import { Helpers } from "@budibase/bbui" import { processObjectSync } from "@budibase/string-templates" -import { snippets } from "../stores" -import { get } from "svelte/store" /** * Recursively enriches all props in a props object and returns the new props. * Props are deeply cloned so that no mutation is done to the source object. */ export const enrichDataBindings = (props, context) => { - const totalContext = { ...context, snippets: get(snippets) } - const opts = { cache: true } - return processObjectSync(Helpers.cloneDeep(props), totalContext, opts) + return processObjectSync(Helpers.cloneDeep(props), context, { cache: true }) } From 23a91bcd2317f792fc80b1ea96290af4dd65c134 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Thu, 14 Mar 2024 16:16:37 +0000 Subject: [PATCH 71/74] Update snippet empty state --- packages/bbui/src/Drawer/DrawerContent.svelte | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/bbui/src/Drawer/DrawerContent.svelte b/packages/bbui/src/Drawer/DrawerContent.svelte index 7974e1e1bf..490dfecc31 100644 --- a/packages/bbui/src/Drawer/DrawerContent.svelte +++ b/packages/bbui/src/Drawer/DrawerContent.svelte @@ -19,7 +19,6 @@ .drawer-contents { overflow-y: auto; flex: 1 1 auto; - height: 0; } .container { height: 100%; From a0e3a8f56c1128063c00847d89e5df75a8e8d1de Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Thu, 14 Mar 2024 16:16:58 +0000 Subject: [PATCH 72/74] Update drawer styles to fix issue with filter modal --- .../common/bindings/SnippetSidePanel.svelte | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/builder/src/components/common/bindings/SnippetSidePanel.svelte b/packages/builder/src/components/common/bindings/SnippetSidePanel.svelte index 1c6e443fb8..c68699fc0f 100644 --- a/packages/builder/src/components/common/bindings/SnippetSidePanel.svelte +++ b/packages/builder/src/components/common/bindings/SnippetSidePanel.svelte @@ -1,5 +1,14 @@