From ed403fd79daebbb8649566c5d10dfd7bbe1192a1 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Fri, 8 Dec 2023 13:45:51 +0000 Subject: [PATCH 001/148] WIP --- packages/builder/src/builderStore/store/frontend.js | 8 +++++++- .../components/common/bindings/BindingPanel.svelte | 3 +++ .../components/common/bindings/BindingPicker.svelte | 13 +++++++++---- .../common/bindings/ClientBindingPanel.svelte | 3 ++- .../common/bindings/DrawerBindableInput.svelte | 4 +++- .../design/settings/controls/PropertyControl.svelte | 2 ++ .../Component/ComponentSettingsSection.svelte | 9 +++++++++ .../design/[screenId]/_components/AppPreview.svelte | 10 ++++++++++ packages/client/src/index.js | 6 ++++++ 9 files changed, 51 insertions(+), 7 deletions(-) diff --git a/packages/builder/src/builderStore/store/frontend.js b/packages/builder/src/builderStore/store/frontend.js index 2e22276e50..83f72669ee 100644 --- a/packages/builder/src/builderStore/store/frontend.js +++ b/packages/builder/src/builderStore/store/frontend.js @@ -88,7 +88,7 @@ const INITIAL_FRONTEND_STATE = { selectedLayoutId: null, // Client state - selectedComponentInstance: null, + selectedComponentContext: null, // Onboarding onboarding: false, @@ -504,6 +504,12 @@ export const getFrontendStore = () => { return state }) }, + setSelectedComponentContext: context => { + store.update(state => { + state.selectedComponentContext = context + return state + }) + }, }, layouts: { select: layoutId => { diff --git a/packages/builder/src/components/common/bindings/BindingPanel.svelte b/packages/builder/src/components/common/bindings/BindingPanel.svelte index 4df26c5d03..927b5cb82a 100644 --- a/packages/builder/src/components/common/bindings/BindingPanel.svelte +++ b/packages/builder/src/components/common/bindings/BindingPanel.svelte @@ -45,6 +45,7 @@ export let valid export let allowJS = false export let allowHelpers = true + export let context = null const drawerActions = getContext("drawer-actions") const bindingDrawerActions = getContext("binding-drawer-actions") @@ -250,6 +251,7 @@ import groupBy from "lodash/fp/groupBy" - import { convertToJS } from "@budibase/string-templates" + import { convertToJS, processStringSync } from "@budibase/string-templates" import { Input, Layout, ActionButton, Icon, Popover } from "@budibase/bbui" import { handlebarsCompletions } from "constants/completions" @@ -9,6 +9,7 @@ export let bindings export let mode export let allowHelpers + export let context = null let search = "" let popover @@ -95,6 +96,9 @@ {#if hoverTarget.example}
{hoverTarget.example}
{/if} + {#if hoverTarget.val} +
{hoverTarget.val}
+ {/if} @@ -165,13 +169,14 @@
  • { + const hbs = `{{ ${binding.runtimeBinding} }}` + const val = processStringSync(hbs, context) + console.log(binding.runtimeBinding, val) popoverAnchor = e.target - if (!binding.description) { - return - } hoverTarget = { title: binding.display?.name || binding.fieldSchema?.name, description: binding.description, + val, } popover.show() e.stopPropagation() diff --git a/packages/builder/src/components/common/bindings/ClientBindingPanel.svelte b/packages/builder/src/components/common/bindings/ClientBindingPanel.svelte index 74e14574ab..a46c18af27 100644 --- a/packages/builder/src/components/common/bindings/ClientBindingPanel.svelte +++ b/packages/builder/src/components/common/bindings/ClientBindingPanel.svelte @@ -6,7 +6,7 @@ export let value = "" export let allowJS = false export let allowHelpers = true - + export let context = null $: enrichedBindings = enrichBindings(bindings) // Ensure bindings have the correct categories @@ -24,6 +24,7 @@ (tempValue = event.detail)} + {context} {bindings} {allowJS} {allowHelpers} diff --git a/packages/builder/src/components/design/settings/controls/PropertyControl.svelte b/packages/builder/src/components/design/settings/controls/PropertyControl.svelte index a6f3d1b218..2195e8a1b9 100644 --- a/packages/builder/src/components/design/settings/controls/PropertyControl.svelte +++ b/packages/builder/src/components/design/settings/controls/PropertyControl.svelte @@ -24,6 +24,7 @@ export let propertyFocus = false export let info = null export let disableBindings = false + export let context = null $: nullishValue = value == null || value === "" $: allBindings = getAllBindings(bindings, componentBindings, nested) @@ -97,6 +98,7 @@ onChange={handleChange} bindings={allBindings} name={key} + {context} {nested} {key} {type} diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte index 6093d2a45e..eb69756626 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte @@ -8,6 +8,7 @@ import { getComponentForSetting } from "components/design/settings/componentSettings" import InfoDisplay from "./InfoDisplay.svelte" import analytics, { Events } from "analytics" + import { onMount } from "svelte" export let componentDefinition export let componentInstance @@ -26,6 +27,7 @@ tag, includeHidden ) + $: context = $store.selectedComponentContext const getSections = (instance, definition, isScreen, tag, includeHidden) => { const settings = definition?.settings ?? [] @@ -145,6 +147,12 @@ } return shouldDisplay(instance, setting) } + + onMount(() => { + store.actions.preview.sendEvent("request-context") + }) + + $: console.log(context) {#each sections as section, idx (section.name)} @@ -191,6 +199,7 @@ min: setting.min ?? null, max: setting.max ?? null, }} + {context} {bindings} {componentBindings} {componentInstance} diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/AppPreview.svelte b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/AppPreview.svelte index 45fe005ceb..a6820cb551 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/AppPreview.svelte +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/AppPreview.svelte @@ -181,6 +181,16 @@ } else if (type === "add-parent-component") { const { componentId, parentType } = data await store.actions.components.addParent(componentId, parentType) + } else if (type === "provide-context") { + let context = data?.context + if (context) { + try { + context = JSON.parse(context) + } catch (error) { + context = null + } + } + store.actions.preview.setSelectedComponentContext(context) } else { console.warn(`Client sent unknown event type: ${type}`) } diff --git a/packages/client/src/index.js b/packages/client/src/index.js index 415d9fa5f2..2d2419eeca 100644 --- a/packages/client/src/index.js +++ b/packages/client/src/index.js @@ -75,6 +75,12 @@ const loadBudibase = async () => { } else { dndStore.actions.reset() } + } else if (type === "request-context") { + const { selectedComponentInstance } = get(componentStore) + const context = selectedComponentInstance?.getDataContext() + eventStore.actions.dispatchEvent("provide-context", { + context: JSON.stringify(context), + }) } } From 06556325a1595389faf5db9d829b0e3d14782c4a Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Thu, 1 Feb 2024 14:45:16 +0000 Subject: [PATCH 002/148] Remove proxying of context changes up the chain --- .../src/components/context/Provider.svelte | 4 +- packages/client/src/stores/context.js | 53 +++++-------------- 2 files changed, 15 insertions(+), 42 deletions(-) diff --git a/packages/client/src/components/context/Provider.svelte b/packages/client/src/components/context/Provider.svelte index 1b6a073512..ad5b580c4f 100644 --- a/packages/client/src/components/context/Provider.svelte +++ b/packages/client/src/components/context/Provider.svelte @@ -33,7 +33,7 @@ const provideData = newData => { const dataKey = JSON.stringify(newData) if (dataKey !== lastDataKey) { - context.actions.provideData(providerKey, newData, scope) + context.actions.provideData(providerKey, newData) lastDataKey = dataKey } } @@ -43,7 +43,7 @@ if (actionsKey !== lastActionsKey) { lastActionsKey = actionsKey newActions?.forEach(({ type, callback, metadata }) => { - context.actions.provideAction(providerKey, type, callback, scope) + context.actions.provideAction(providerKey, type, callback) // Register any "refresh datasource" actions with a singleton store // so we can easily refresh data at all levels for any datasource diff --git a/packages/client/src/stores/context.js b/packages/client/src/stores/context.js index e54c773591..c1ec18ef13 100644 --- a/packages/client/src/stores/context.js +++ b/packages/client/src/stores/context.js @@ -1,5 +1,4 @@ import { writable, derived } from "svelte/store" -import { ContextScopes } from "constants" export const createContextStore = parentContext => { const context = writable({}) @@ -20,60 +19,34 @@ export const createContextStore = parentContext => { } // Provide some data in context - const provideData = (providerId, data, scope = ContextScopes.Global) => { + const provideData = (providerId, data) => { if (!providerId || data === undefined) { return } - // Proxy message up the chain if we have a parent and are providing global - // context - if (scope === ContextScopes.Global && parentContext) { - parentContext.actions.provideData(providerId, data, scope) - } - // Otherwise this is either the context root, or we're providing a local // context override, so we need to update the local context instead - else { - context.update(state => { - state[providerId] = data - return state - }) - broadcastChange(providerId) - } + context.update(state => { + state[providerId] = data + return state + }) + broadcastChange(providerId) } // Provides some action in context - const provideAction = ( - providerId, - actionType, - callback, - scope = ContextScopes.Global - ) => { + const provideAction = (providerId, actionType, callback) => { if (!providerId || !actionType) { return } - // Proxy message up the chain if we have a parent and are providing global - // context - if (scope === ContextScopes.Global && parentContext) { - parentContext.actions.provideAction( - providerId, - actionType, - callback, - scope - ) - } - // Otherwise this is either the context root, or we're providing a local // context override, so we need to update the local context instead - else { - const key = `${providerId}_${actionType}` - context.update(state => { - state[key] = callback - return state - }) - broadcastChange(key) - } + const key = `${providerId}_${actionType}` + context.update(state => { + state[key] = callback + return state + }) + broadcastChange(key) } const observeChanges = callback => { From a880c5e62a5aceb3c4a207ad55cedaee9d6834d8 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Fri, 2 Feb 2024 10:27:23 +0000 Subject: [PATCH 003/148] Update outside popover styles --- packages/bbui/src/Actions/position_dropdown.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/bbui/src/Actions/position_dropdown.js b/packages/bbui/src/Actions/position_dropdown.js index cc169eac09..14bb209b97 100644 --- a/packages/bbui/src/Actions/position_dropdown.js +++ b/packages/bbui/src/Actions/position_dropdown.js @@ -38,8 +38,9 @@ export default function positionDropdown(element, opts) { styles = customUpdate(anchorBounds, elementBounds, styles) } else { // Determine vertical styles - if (align === "right-outside") { - styles.top = anchorBounds.top + if (align === "right-outside" || align === "left-outside") { + styles.top = anchorBounds.bottom - elementBounds.height + styles.maxHeight = maxHeight } else if ( window.innerHeight - anchorBounds.bottom < (maxHeight || 100) From daec133f792e89499700510949a0d3b15aac6262 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Fri, 2 Feb 2024 10:27:29 +0000 Subject: [PATCH 004/148] Add live eval of bindings --- .../common/bindings/BindingPanel.svelte | 37 +++++++- .../common/bindings/BindingPicker.svelte | 91 +++++++++---------- .../Component/ComponentSettingsSection.svelte | 2 - 3 files changed, 79 insertions(+), 51 deletions(-) diff --git a/packages/builder/src/components/common/bindings/BindingPanel.svelte b/packages/builder/src/components/common/bindings/BindingPanel.svelte index 1dba55c733..3b64c918d0 100644 --- a/packages/builder/src/components/common/bindings/BindingPanel.svelte +++ b/packages/builder/src/components/common/bindings/BindingPanel.svelte @@ -15,6 +15,7 @@ decodeJSBinding, encodeJSBinding, convertToJS, + processStringSync, } from "@budibase/string-templates" import { readableToRuntimeBinding, @@ -59,15 +60,21 @@ let hbsValue = initialValueJS ? null : value let sidebar = true let targetMode = null + let expressionResult $: usingJS = mode === "JavaScript" - $: editorMode = mode == "JavaScript" ? EditorModes.JS : EditorModes.Handlebars + $: editorMode = + mode === "JavaScript" ? EditorModes.JS : EditorModes.Handlebars $: bindingCompletions = bindingsToCompletions(bindings, editorMode) + $: runtimeExpression = readableToRuntimeBinding(bindings, value) + $: expressionResult = processStringSync(runtimeExpression || "", context) const updateValue = val => { - valid = isValid(readableToRuntimeBinding(bindings, val)) + const runtimeExpression = readableToRuntimeBinding(bindings, val) + valid = isValid(runtimeExpression) if (valid) { dispatch("change", val) + expressionResult = processStringSync(runtimeExpression || "", context) } } @@ -114,7 +121,7 @@ } const switchMode = () => { - if (targetMode == "Text") { + if (targetMode === "Text") { jsValue = null updateValue(jsValue) } else { @@ -204,6 +211,11 @@ autofocus={autofocusEditor} /> + {#if expressionResult} +
    + {expressionResult} +
    + {/if}
  • { - const hbs = `{{ ${binding.runtimeBinding} }}` - const val = processStringSync(hbs, context) - console.log(binding.runtimeBinding, val) + let val = getBindingValue(binding) + if (val === "") { + val = " " + } popoverAnchor = e.target hoverTarget = { - title: binding.display?.name || binding.fieldSchema?.name, - description: binding.description, - val, + code: val, } popover.show() e.stopPropagation() @@ -224,19 +215,15 @@ {#each filteredHelpers as helper}
  • addHelper(helper, mode.name == "javascript")} + on:click={() => addHelper(helper, mode.name === "javascript")} on:mouseenter={e => { popoverAnchor = e.target if (!helper.displayText && helper.description) { return } hoverTarget = { - title: helper.displayText, description: helper.description, - example: getHelperExample( - helper, - mode.name == "javascript" - ), + code: getHelperExample(helper, mode.name === "javascript"), } popover.show() e.stopPropagation() @@ -397,4 +384,16 @@ margin-left: 2px; font-weight: 600; } + + .helper pre { + padding: 0; + margin: 0; + font-size: 12px; + white-space: pre-wrap; + word-break: break-all; + } + .helper :global(p) { + padding: 0; + margin: 0; + } diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte index a18f21ae1d..c096ef58ec 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte @@ -150,8 +150,6 @@ onMount(() => { store.actions.preview.sendEvent("request-context") }) - - $: console.log(context) {#each sections as section, idx (section.name)} From f3f5532ad630e9560063f968d35cbe635cd63f37 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Wed, 14 Feb 2024 16:32:28 +0000 Subject: [PATCH 005/148] Fix merge issues --- .../_components/Component/ComponentSettingsSection.svelte | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte index 36ef6a7526..c7fe7b6b33 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte @@ -1,7 +1,7 @@ From 461418390d6a72bbb012d88d85543c6b7d18979b Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Fri, 16 Feb 2024 12:36:03 +0000 Subject: [PATCH 006/148] Change how context is passed through to binding drawers to ensure it's always available --- .../common/bindings/BindingPanel.svelte | 20 +++++++++---------- .../common/bindings/BindingPicker.svelte | 17 ++++++++-------- .../common/bindings/ClientBindingPanel.svelte | 4 ++-- .../bindings/DrawerBindableInput.svelte | 2 -- .../ButtonConfiguration.svelte | 1 + .../settings/controls/PropertyControl.svelte | 2 -- .../Component/ComponentSettingsSection.svelte | 2 -- 7 files changed, 20 insertions(+), 28 deletions(-) diff --git a/packages/builder/src/components/common/bindings/BindingPanel.svelte b/packages/builder/src/components/common/bindings/BindingPanel.svelte index cb564593a0..d7b0e6e65c 100644 --- a/packages/builder/src/components/common/bindings/BindingPanel.svelte +++ b/packages/builder/src/components/common/bindings/BindingPanel.svelte @@ -135,13 +135,10 @@ selected={mode} on:select={onChangeMode} beforeSwitch={selectedMode => { - if (selectedMode == mode) { + if (selectedMode === mode) { return true } - - //Get the current mode value const editorValue = usingJS ? decodeJSBinding(jsValue) : hbsValue - if (editorValue) { targetMode = selectedMode return false @@ -204,9 +201,9 @@
    Current Handlebars syntax is invalid, please check the guide - here + + here + for more details.
    {:else} @@ -523,9 +520,10 @@ border-radius: var(--border-radius-s); font-family: monospace; border: 1px solid var(--spectrum-global-color-gray-300); - max-height: 200px; - overflow: auto; - white-space: pre; - word-wrap: anywhere; + overflow-y: scroll; + overflow-x: hidden; + white-space: pre-wrap; + word-wrap: break-word; + max-height: 92px; } diff --git a/packages/builder/src/components/common/bindings/BindingPicker.svelte b/packages/builder/src/components/common/bindings/BindingPicker.svelte index 8ae236a7ab..cb990b277a 100644 --- a/packages/builder/src/components/common/bindings/BindingPicker.svelte +++ b/packages/builder/src/components/common/bindings/BindingPicker.svelte @@ -10,7 +10,6 @@ export let mode export let allowHelpers export let context = null - export let noPaddingTop = false let search = "" let popover @@ -70,6 +69,8 @@ return names } + $: console.log(context) + const getBindingValue = binding => { const hbs = `{{ ${binding.runtimeBinding} }}` return processStringSync(hbs, context) @@ -165,15 +166,13 @@ class="binding" on:mouseenter={e => { let val = getBindingValue(binding) - if (val === "") { - val = " " + if (val !== "") { + popoverAnchor = e.target + hoverTarget = { + code: val, + } + popover.show() } - popoverAnchor = e.target - hoverTarget = { - code: val, - } - popover.show() - e.stopPropagation() }} on:mouseleave={() => { popover.hide() diff --git a/packages/builder/src/components/common/bindings/ClientBindingPanel.svelte b/packages/builder/src/components/common/bindings/ClientBindingPanel.svelte index 4bf05190db..39d8aaf2f5 100644 --- a/packages/builder/src/components/common/bindings/ClientBindingPanel.svelte +++ b/packages/builder/src/components/common/bindings/ClientBindingPanel.svelte @@ -1,12 +1,12 @@ import { helpers } from "@budibase/shared-core" import { DetailSummary, notifications } from "@budibase/bbui" - import { componentStore, previewStore } from "stores/builder" + import { componentStore } from "stores/builder" import PropertyControl from "components/design/settings/controls/PropertyControl.svelte" import ResetFieldsButton from "components/design/settings/controls/ResetFieldsButton.svelte" import EjectBlockButton from "components/design/settings/controls/EjectBlockButton.svelte" import { getComponentForSetting } from "components/design/settings/componentSettings" import InfoDisplay from "./InfoDisplay.svelte" import analytics, { Events } from "analytics" - import { onMount } from "svelte" export let componentDefinition export let componentInstance @@ -145,10 +144,6 @@ } return shouldDisplay(instance, setting) } - - onMount(() => { - previewStore.sendEvent("request-context") - }) {#each sections as section, idx (section.name)} diff --git a/packages/builder/src/stores/builder/preview.js b/packages/builder/src/stores/builder/preview.js index eca69d56d7..4923185ee7 100644 --- a/packages/builder/src/stores/builder/preview.js +++ b/packages/builder/src/stores/builder/preview.js @@ -60,6 +60,10 @@ export const createPreviewStore = () => { }) } + const requestComponentContext = () => { + sendEvent("request-context") + } + return { subscribe: store.subscribe, setDevice, @@ -69,6 +73,7 @@ export const createPreviewStore = () => { stopDrag, showPreview, setSelectedComponentContext, + requestComponentContext, } } diff --git a/packages/client/src/components/app/GridBlock.svelte b/packages/client/src/components/app/GridBlock.svelte index c2bc9f3989..46a507387d 100644 --- a/packages/client/src/components/app/GridBlock.svelte +++ b/packages/client/src/components/app/GridBlock.svelte @@ -53,7 +53,7 @@ export const getAdditionalDataContext = () => { const rows = get(grid?.getContext()?.rows) const goldenRow = generateGoldenSample(rows) - const id = [get(component).id] + const id = get(component).id return { [id]: goldenRow, eventContext: { diff --git a/packages/client/src/components/app/blocks/CardsBlock.svelte b/packages/client/src/components/app/blocks/CardsBlock.svelte index 008fa7e730..bd2b69d352 100644 --- a/packages/client/src/components/app/blocks/CardsBlock.svelte +++ b/packages/client/src/components/app/blocks/CardsBlock.svelte @@ -4,6 +4,7 @@ import BlockComponent from "components/BlockComponent.svelte" import { makePropSafe as safe } from "@budibase/string-templates" import { enrichSearchColumns, enrichFilter } from "utils/blocks.js" + import { get } from "svelte/store" export let title export let dataSource @@ -31,7 +32,9 @@ export let linkColumn export let noRowsMessage - const { fetchDatasourceSchema } = getContext("sdk") + const context = getContext("context") + const { fetchDatasourceSchema, generateGoldenSample } = getContext("sdk") + const component = getContext("component") let formId let dataProviderId @@ -62,6 +65,16 @@ }, ] + // Provide additional data context for live binding eval + export const getAdditionalDataContext = () => { + const rows = get(context)[dataProviderId]?.rows || [] + const goldenRow = generateGoldenSample(rows) + const id = get(component).id + return { + [`${id}-repeater`]: goldenRow, + } + } + // Builds a full details page URL for the card title const buildFullCardUrl = (link, url, repeaterId, linkColumn) => { if (!link || !url || !repeaterId) { From 5c4e797251a0ceca04ed1735688536d012652ea5 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Fri, 16 Feb 2024 14:23:17 +0000 Subject: [PATCH 009/148] Provide additional context from repeater blocks --- .../src/components/app/blocks/RepeaterBlock.svelte | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/client/src/components/app/blocks/RepeaterBlock.svelte b/packages/client/src/components/app/blocks/RepeaterBlock.svelte index 30fbdddcdc..878b827c78 100644 --- a/packages/client/src/components/app/blocks/RepeaterBlock.svelte +++ b/packages/client/src/components/app/blocks/RepeaterBlock.svelte @@ -4,6 +4,7 @@ import Placeholder from "components/app/Placeholder.svelte" import { getContext } from "svelte" import { makePropSafe as safe } from "@budibase/string-templates" + import { get } from "svelte/store" export let dataSource export let filter @@ -18,8 +19,20 @@ export let gap const component = getContext("component") + const context = getContext("context") + const { generateGoldenSample } = getContext("sdk") let providerId + + // Provide additional data context for live binding eval + export const getAdditionalDataContext = () => { + const rows = get(context)[providerId]?.rows || [] + const goldenRow = generateGoldenSample(rows) + const id = get(component).id + return { + [`${id}-repeater`]: goldenRow, + } + } From 840f499b47d110ebc77726d95b14835ef9df5370 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Fri, 16 Feb 2024 14:33:40 +0000 Subject: [PATCH 010/148] Provide additional data context from form blocks --- .../app/blocks/MultiStepFormblock.svelte | 14 ++- .../app/blocks/form/FormBlock.svelte | 88 +++++++++++-------- 2 files changed, 62 insertions(+), 40 deletions(-) diff --git a/packages/client/src/components/app/blocks/MultiStepFormblock.svelte b/packages/client/src/components/app/blocks/MultiStepFormblock.svelte index b24c2f418a..1a0fbbd50d 100644 --- a/packages/client/src/components/app/blocks/MultiStepFormblock.svelte +++ b/packages/client/src/components/app/blocks/MultiStepFormblock.svelte @@ -5,7 +5,7 @@ import { builderStore } from "stores" import { Utils } from "@budibase/frontend-core" import FormBlockWrapper from "./form/FormBlockWrapper.svelte" - import { writable } from "svelte/store" + import { get, writable } from "svelte/store" export let actionType export let rowId @@ -15,7 +15,7 @@ export let buttonPosition = "bottom" export let size - const { fetchDatasourceSchema } = getContext("sdk") + const { fetchDatasourceSchema, generateGoldenSample } = getContext("sdk") const component = getContext("component") const context = getContext("context") @@ -45,6 +45,16 @@ $: enrichedSteps = enrichSteps(steps, schema, $component.id, $currentStep) $: updateCurrentStep(enrichedSteps, $builderStore, $component) + // Provide additional data context for live binding eval + export const getAdditionalDataContext = () => { + const id = get(component).id + const rows = get(context)[`${id}-provider`]?.rows || [] + const goldenRow = generateGoldenSample(rows) + return { + [`${id}-repeater`]: goldenRow, + } + } + const updateCurrentStep = (steps, builderStore, component) => { const { componentId, step } = builderStore.metadata || {} diff --git a/packages/client/src/components/app/blocks/form/FormBlock.svelte b/packages/client/src/components/app/blocks/form/FormBlock.svelte index cdf1a05628..8c084a71ab 100644 --- a/packages/client/src/components/app/blocks/form/FormBlock.svelte +++ b/packages/client/src/components/app/blocks/form/FormBlock.svelte @@ -3,6 +3,7 @@ import InnerFormBlock from "./InnerFormBlock.svelte" import { Utils } from "@budibase/frontend-core" import FormBlockWrapper from "./FormBlockWrapper.svelte" + import { get } from "svelte/store" export let actionType export let dataSource @@ -11,7 +12,6 @@ export let fields export let buttons export let buttonPosition - export let title export let description export let rowId @@ -25,8 +25,56 @@ export let saveButtonLabel export let deleteButtonLabel - const { fetchDatasourceSchema } = getContext("sdk") + const { fetchDatasourceSchema, generateGoldenSample } = getContext("sdk") const component = getContext("component") + const context = getContext("context") + + let schema + + $: formattedFields = convertOldFieldFormat(fields) + $: fieldsOrDefault = getDefaultFields(formattedFields, schema) + $: fetchSchema(dataSource) + // We could simply spread $$props into the inner form and append our + // additions, but that would create svelte warnings about unused props and + // make maintenance in future more confusing as we typically always have a + // proper mapping of schema settings to component exports, without having to + // search multiple files + $: innerProps = { + dataSource, + actionUrl, + actionType, + size, + disabled, + fields: fieldsOrDefault, + title, + description, + schema, + notificationOverride, + buttons: + buttons || + Utils.buildFormBlockButtonConfig({ + _id: $component.id, + showDeleteButton, + showSaveButton, + saveButtonLabel, + deleteButtonLabel, + notificationOverride, + actionType, + actionUrl, + dataSource, + }), + buttonPosition: buttons ? buttonPosition : "top", + } + + // Provide additional data context for live binding eval + export const getAdditionalDataContext = () => { + const id = get(component).id + const rows = get(context)[`${id}-provider`]?.rows || [] + const goldenRow = generateGoldenSample(rows) + return { + [`${id}-repeater`]: goldenRow, + } + } const convertOldFieldFormat = fields => { if (!fields) { @@ -68,42 +116,6 @@ return [...fields, ...defaultFields].filter(field => field.active) } - let schema - - $: formattedFields = convertOldFieldFormat(fields) - $: fieldsOrDefault = getDefaultFields(formattedFields, schema) - $: fetchSchema(dataSource) - // We could simply spread $$props into the inner form and append our - // additions, but that would create svelte warnings about unused props and - // make maintenance in future more confusing as we typically always have a - // proper mapping of schema settings to component exports, without having to - // search multiple files - $: innerProps = { - dataSource, - actionUrl, - actionType, - size, - disabled, - fields: fieldsOrDefault, - title, - description, - schema, - notificationOverride, - buttons: - buttons || - Utils.buildFormBlockButtonConfig({ - _id: $component.id, - showDeleteButton, - showSaveButton, - saveButtonLabel, - deleteButtonLabel, - notificationOverride, - actionType, - actionUrl, - dataSource, - }), - buttonPosition: buttons ? buttonPosition : "top", - } const fetchSchema = async () => { schema = (await fetchDatasourceSchema(dataSource)) || {} } From 53bb890d3d9f8ec922b3f9c07261e02e08c989a3 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Fri, 16 Feb 2024 14:36:28 +0000 Subject: [PATCH 011/148] Provide additional context from row explorer block --- .../src/components/app/blocks/RowExplorer.svelte | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/client/src/components/app/blocks/RowExplorer.svelte b/packages/client/src/components/app/blocks/RowExplorer.svelte index 8fadcb5006..1e2357713a 100644 --- a/packages/client/src/components/app/blocks/RowExplorer.svelte +++ b/packages/client/src/components/app/blocks/RowExplorer.svelte @@ -3,25 +3,35 @@ import BlockComponent from "components/BlockComponent.svelte" import { makePropSafe as safe } from "@budibase/string-templates" import { generate } from "shortid" + import { get } from "svelte/store" + import { getContext } from "svelte" export let dataSource export let height - export let cardTitle export let cardSubtitle export let cardDescription export let cardImageURL export let cardSearchField - export let detailFields export let detailTitle - export let noRowsMessage const stateKey = generate() + const context = getContext("context") + const { generateGoldenSample } = getContext("sdk") let listDataProviderId let listRepeaterId + + // Provide additional data context for live binding eval + export const getAdditionalDataContext = () => { + const rows = get(context)[listDataProviderId]?.rows || [] + const goldenRow = generateGoldenSample(rows) + return { + [listRepeaterId]: goldenRow, + } + } From 86695c0ee49fb41cf6117c48e9a1388d757f0ad0 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Fri, 16 Feb 2024 16:24:31 +0000 Subject: [PATCH 012/148] Add syntax highlighting to live binding eval --- packages/builder/package.json | 1 + .../common/bindings/BindingPanel.svelte | 25 +++++++++++++-- .../common/bindings/BindingPicker.svelte | 31 +++++++++++++++---- 3 files changed, 48 insertions(+), 9 deletions(-) diff --git a/packages/builder/package.json b/packages/builder/package.json index 52db8e11bc..3bf9ab4442 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -70,6 +70,7 @@ "dayjs": "^1.10.8", "downloadjs": "1.4.7", "fast-json-patch": "^3.1.1", + "json-format-highlight": "^1.0.4", "lodash": "4.17.21", "posthog-js": "^1.36.0", "remixicon": "2.5.0", diff --git a/packages/builder/src/components/common/bindings/BindingPanel.svelte b/packages/builder/src/components/common/bindings/BindingPanel.svelte index d7b0e6e65c..f0ded4a67b 100644 --- a/packages/builder/src/components/common/bindings/BindingPanel.svelte +++ b/packages/builder/src/components/common/bindings/BindingPanel.svelte @@ -33,6 +33,7 @@ } from "../CodeEditor" import BindingPicker from "./BindingPicker.svelte" import { BindingHelpers } from "./utils" + import formatHighlight from "json-format-highlight" const dispatch = createEventDispatcher() @@ -123,6 +124,24 @@ onSelectBinding("", { forceJS: true }) } + const highlight = json => { + // Attempt to parse and then stringify, in case this is valid JSON + try { + json = JSON.stringify(JSON.parse(json), null, 2) + } catch (err) { + // Ignore + } + + return formatHighlight(json, { + keyColor: "#e06c75", + numberColor: "#e5c07b", + stringColor: "#98c379", + trueColor: "#d19a66", + falseColor: "#d19a66", + nullColor: "#c678dd", + }) + } + onMount(() => { valid = isValid(readableToRuntimeBinding(bindings, value)) }) @@ -192,7 +211,7 @@ {#if expressionResult}
    - {expressionResult} + {@html highlight(expressionResult)}
    {/if} {#if expressionResult}
    - {expressionResult} + {@html highlight(expressionResult)}
    {/if} {/if} {#if hoverTarget.code} -
    {hoverTarget.code}
    +
    {@html highlight(hoverTarget.code)}
    {/if} @@ -387,8 +400,14 @@ padding: 0; margin: 0; font-size: 12px; - white-space: pre-wrap; - word-break: break-all; + white-space: pre; + text-overflow: ellipsis; + overflow: hidden; + } + .helper pre :global(span) { + overflow: hidden !important; + text-overflow: ellipsis !important; + white-space: nowrap !important; } .helper :global(p) { padding: 0; From 14b2bfa8d690802877058b3b5b1faf7b6ad2582d Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Fri, 16 Feb 2024 16:24:46 +0000 Subject: [PATCH 013/148] Update lock --- yarn.lock | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/yarn.lock b/yarn.lock index 1937482837..3e147eed34 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13765,6 +13765,11 @@ json-buffer@3.0.1: resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== +json-format-highlight@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/json-format-highlight/-/json-format-highlight-1.0.4.tgz#2e44277edabcec79a3d2c84e984c62e2258037b9" + integrity sha512-RqenIjKr1I99XfXPAml9G7YlEZg/GnsH7emWyWJh2yuGXqHW8spN7qx6/ME+MoIBb35/fxrMC9Jauj6nvGe4Mg== + json-parse-better-errors@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9" From 433c3a6306fdd161da70d145b4ac73885f215757 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Mon, 19 Feb 2024 16:22:23 +0000 Subject: [PATCH 014/148] Debounce hiding binding values to enable interacting with them --- packages/bbui/src/Popover/Popover.svelte | 2 + .../common/bindings/BindingPicker.svelte | 51 +++++++++++++------ 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/packages/bbui/src/Popover/Popover.svelte b/packages/bbui/src/Popover/Popover.svelte index 5066e3aa05..7ff5ae5f03 100644 --- a/packages/bbui/src/Popover/Popover.svelte +++ b/packages/bbui/src/Popover/Popover.svelte @@ -101,6 +101,8 @@ role="presentation" style="height: {customHeight}; --customZindex: {customZindex};" transition:fly|local={{ y: -20, duration: animate ? 200 : 0 }} + on:mouseenter + on:mouseleave > diff --git a/packages/builder/src/components/common/bindings/BindingPicker.svelte b/packages/builder/src/components/common/bindings/BindingPicker.svelte index 4f08cd5306..342855f427 100644 --- a/packages/builder/src/components/common/bindings/BindingPicker.svelte +++ b/packages/builder/src/components/common/bindings/BindingPicker.svelte @@ -18,6 +18,7 @@ let hoverTarget let helpers = handlebarsCompletions() let selectedCategory + let hideTimeout $: bindingIcons = bindings?.reduce((acc, ele) => { if (ele.icon) { @@ -86,6 +87,37 @@ nullColor: "#c678dd", }) } + + const showPopover = (target, binding) => { + if (hideTimeout) { + clearTimeout(hideTimeout) + hideTimeout = null + } + let val = getBindingValue(binding) + if (val !== "") { + popoverAnchor = target + hoverTarget = { + code: val, + } + popover.show() + } + } + + const hidePopover = () => { + hideTimeout = setTimeout(() => { + popover.hide() + popoverAnchor = null + hoverTarget = null + hideTimeout = null + }, 100) + } + + const stopHiding = () => { + if (hideTimeout) { + clearTimeout(hideTimeout) + hideTimeout = null + } + }
    @@ -175,21 +209,8 @@ {#each category.bindings as binding}
  • { - let val = getBindingValue(binding) - if (val !== "") { - popoverAnchor = e.target - hoverTarget = { - code: val, - } - popover.show() - } - }} - on:mouseleave={() => { - popover.hide() - popoverAnchor = null - hoverTarget = null - }} + on:mouseenter={e => showPopover(e.target, binding)} + on:mouseleave={hidePopover} on:focus={() => {}} on:blur={() => {}} on:click={() => addBinding(binding)} From 602f35537d9f0438014c0600ae3afcb89f6a94d8 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Mon, 19 Feb 2024 16:28:23 +0000 Subject: [PATCH 015/148] Support custom min widths for popovers --- packages/bbui/src/Actions/position_dropdown.js | 3 ++- packages/bbui/src/Popover/Popover.svelte | 3 ++- .../src/components/common/bindings/BindingPicker.svelte | 4 ++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/bbui/src/Actions/position_dropdown.js b/packages/bbui/src/Actions/position_dropdown.js index 14bb209b97..31a1044ba4 100644 --- a/packages/bbui/src/Actions/position_dropdown.js +++ b/packages/bbui/src/Actions/position_dropdown.js @@ -15,6 +15,7 @@ export default function positionDropdown(element, opts) { align, maxHeight, maxWidth, + minWidth, useAnchorWidth, offset = 5, customUpdate, @@ -28,7 +29,7 @@ export default function positionDropdown(element, opts) { const elementBounds = element.getBoundingClientRect() let styles = { maxHeight: null, - minWidth: null, + minWidth, maxWidth, left: null, top: null, diff --git a/packages/bbui/src/Popover/Popover.svelte b/packages/bbui/src/Popover/Popover.svelte index 7ff5ae5f03..407fb0b153 100644 --- a/packages/bbui/src/Popover/Popover.svelte +++ b/packages/bbui/src/Popover/Popover.svelte @@ -12,6 +12,7 @@ export let anchor export let align = "right" export let portalTarget + export let minWidth export let maxWidth export let maxHeight export let open = false @@ -21,7 +22,6 @@ export let customHeight export let animate = true export let customZindex - export let handlePostionUpdate export let showPopover = true export let clickOutsideOverride = false @@ -86,6 +86,7 @@ align, maxHeight, maxWidth, + minWidth, useAnchorWidth, offset, customUpdate: handlePostionUpdate, diff --git a/packages/builder/src/components/common/bindings/BindingPicker.svelte b/packages/builder/src/components/common/bindings/BindingPicker.svelte index 342855f427..9c0522ce5a 100644 --- a/packages/builder/src/components/common/bindings/BindingPicker.svelte +++ b/packages/builder/src/components/common/bindings/BindingPicker.svelte @@ -124,11 +124,11 @@ align="left-outside" bind:this={popover} anchor={popoverAnchor} - maxWidth={600} + minWidth={0} + maxWidth={480} maxHeight={300} dismissible={false} on:mouseenter={stopHiding} - on:mouseleave={hidePopover} >
    From 7a278234b55d82b4407e51edc4d46b0b7c342eb5 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Mon, 19 Feb 2024 16:30:29 +0000 Subject: [PATCH 016/148] Suppress warning --- .../src/components/common/bindings/BindingPicker.svelte | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/builder/src/components/common/bindings/BindingPicker.svelte b/packages/builder/src/components/common/bindings/BindingPicker.svelte index 9c0522ce5a..60ca6abf7c 100644 --- a/packages/builder/src/components/common/bindings/BindingPicker.svelte +++ b/packages/builder/src/components/common/bindings/BindingPicker.svelte @@ -139,7 +139,10 @@
    {/if} {#if hoverTarget.code} -
    {@html highlight(hoverTarget.code)}
    +
    +          
    +          {@html highlight(hoverTarget.code)}
    +        
    {/if} From ca3f464523f4f57351db2f979c6392da4297756e Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Tue, 20 Feb 2024 10:11:27 +0000 Subject: [PATCH 017/148] Tidy up logic for showing and hiding popovers for bindings and helpers --- .../common/CodeEditor/CodeEditor.svelte | 22 ++++- .../src/components/common/CodeEditor/index.js | 5 + .../common/bindings/BindingPanel.svelte | 45 +++++++-- .../common/bindings/BindingPicker.svelte | 93 +++++++------------ 4 files changed, 91 insertions(+), 74 deletions(-) diff --git a/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte b/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte index f4fa762bce..b169cd2f7c 100644 --- a/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte +++ b/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte @@ -117,7 +117,7 @@ const indentWithTabCustom = { key: "Tab", run: view => { - if (completionStatus(view.state) == "active") { + if (completionStatus(view.state) === "active") { acceptCompletion(view) return true } @@ -131,7 +131,7 @@ } const buildKeymap = () => { - const baseMap = [ + return [ ...closeBracketsKeymap, ...defaultKeymap, ...historyKeymap, @@ -139,7 +139,6 @@ ...completionKeymap, indentWithTabCustom, ] - return baseMap } const buildBaseExtensions = () => { @@ -215,7 +214,7 @@ ) } - if (mode.name == "javascript") { + if (mode.name === "javascript") { complete.push(javascript()) complete.push(highlightWhitespace()) complete.push(lineNumbers()) @@ -321,4 +320,19 @@ border-radius: var(--border-radius-s); padding: 4px 6px; } + + .code-editor :global(.binding__example) { + padding: 0; + margin: 0; + font-size: 12px; + white-space: pre; + text-overflow: ellipsis; + overflow: hidden; + max-height: 480px; + } + .code-editor :global(.binding__example span) { + overflow: hidden !important; + text-overflow: ellipsis !important; + white-space: nowrap !important; + } diff --git a/packages/builder/src/components/common/CodeEditor/index.js b/packages/builder/src/components/common/CodeEditor/index.js index 0d71a475f0..6f55f42169 100644 --- a/packages/builder/src/components/common/CodeEditor/index.js +++ b/packages/builder/src/components/common/CodeEditor/index.js @@ -255,6 +255,11 @@ export const buildBindingInfoNode = (completion, binding) => { const ele = document.createElement("div") ele.classList.add("info-bubble") + if (binding.valueHTML) { + ele.innerHTML = `
    ${binding.valueHTML}
    ` + return ele + } + const exampleNodeHtml = binding.readableBinding ? `
    {{ ${binding.readableBinding} }}
    ` : "" diff --git a/packages/builder/src/components/common/bindings/BindingPanel.svelte b/packages/builder/src/components/common/bindings/BindingPanel.svelte index f0ded4a67b..3708f83c61 100644 --- a/packages/builder/src/components/common/bindings/BindingPanel.svelte +++ b/packages/builder/src/components/common/bindings/BindingPanel.svelte @@ -62,16 +62,45 @@ let targetMode = null let expressionResult + $: enrichedBindings = enrichBindings(bindings, context) $: usingJS = mode === "JavaScript" $: editorMode = mode === "JavaScript" ? EditorModes.JS : EditorModes.Handlebars - $: bindingCompletions = bindingsToCompletions(bindings, editorMode) - $: runtimeExpression = readableToRuntimeBinding(bindings, value) + $: bindingCompletions = bindingsToCompletions(enrichedBindings, editorMode) + $: runtimeExpression = readableToRuntimeBinding(enrichedBindings, value) $: expressionResult = processStringSync(runtimeExpression || "", context) $: bindingHelpers = new BindingHelpers(getCaretPosition, insertAtPos) + const getBindingValue = (binding, context) => { + const hbs = `{{ literal ${binding.runtimeBinding} }}` + const res = processStringSync(hbs, context) + return JSON.stringify(res, null, 2) + } + + const highlightJSON = json => { + return formatHighlight(json, { + keyColor: "#e06c75", + numberColor: "#e5c07b", + stringColor: "#98c379", + trueColor: "#d19a66", + falseColor: "#d19a66", + nullColor: "#c678dd", + }) + } + + const enrichBindings = (bindings, context) => { + return bindings.map(binding => { + const value = getBindingValue(binding, context) + return { + ...binding, + value, + valueHTML: highlightJSON(value), + } + }) + } + const updateValue = val => { - const runtimeExpression = readableToRuntimeBinding(bindings, val) + const runtimeExpression = readableToRuntimeBinding(enrichedBindings, val) valid = isValid(runtimeExpression) if (valid) { dispatch("change", val) @@ -116,9 +145,9 @@ } const convert = () => { - const runtime = readableToRuntimeBinding(bindings, hbsValue) + const runtime = readableToRuntimeBinding(enrichedBindings, hbsValue) const runtimeJs = encodeJSBinding(convertToJS(runtime)) - jsValue = runtimeToReadableBinding(bindings, runtimeJs) + jsValue = runtimeToReadableBinding(enrichedBindings, runtimeJs) hbsValue = null mode = "JavaScript" onSelectBinding("", { forceJS: true }) @@ -143,7 +172,7 @@ } onMount(() => { - valid = isValid(readableToRuntimeBinding(bindings, value)) + valid = isValid(readableToRuntimeBinding(enrichedBindings, value)) }) @@ -261,7 +290,7 @@ {#if sidebar}
    { - const hbs = `{{ literal ${binding.runtimeBinding} }}` - const res = processStringSync(hbs, context) - return JSON.stringify(res, null, 2) + const showBindingPopover = (binding, target) => { + stopHidingPopover() + popoverAnchor = target + hoverTarget = { + code: binding.valueHTML, + } + popover.show() } - const highlight = json => { - return formatHighlight(json, { - keyColor: "#e06c75", - numberColor: "#e5c07b", - stringColor: "#98c379", - trueColor: "#d19a66", - falseColor: "#d19a66", - nullColor: "#c678dd", - }) - } - - const showPopover = (target, binding) => { - if (hideTimeout) { - clearTimeout(hideTimeout) - hideTimeout = null + const showHelperPopover = (helper, target) => { + stopHidingPopover() + if (!helper.displayText && helper.description) { + return } - let val = getBindingValue(binding) - if (val !== "") { - popoverAnchor = target - hoverTarget = { - code: val, - } - popover.show() + popoverAnchor = target + hoverTarget = { + description: helper.description, + code: getHelperExample(helper, mode.name === "javascript"), } + popover.show() } const hidePopover = () => { @@ -112,7 +102,7 @@ }, 100) } - const stopHiding = () => { + const stopHidingPopover = () => { if (hideTimeout) { clearTimeout(hideTimeout) hideTimeout = null @@ -126,9 +116,10 @@ anchor={popoverAnchor} minWidth={0} maxWidth={480} - maxHeight={300} + maxHeight={480} dismissible={false} - on:mouseenter={stopHiding} + on:mouseenter={stopHidingPopover} + on:mouseleave={hidePopover} >
    @@ -139,10 +130,8 @@
    {/if} {#if hoverTarget.code} -
    -          
    -          {@html highlight(hoverTarget.code)}
    -        
    + +
    {@html hoverTarget.code}
    {/if}
    @@ -212,10 +201,8 @@ {#each category.bindings as binding}
  • showPopover(e.target, binding)} + on:mouseenter={e => showBindingPopover(binding, e.target)} on:mouseleave={hidePopover} - on:focus={() => {}} - on:blur={() => {}} on:click={() => addBinding(binding)} > @@ -227,7 +214,6 @@ {binding.readableBinding} {/if} - {#if binding.display?.type || binding.fieldSchema?.type} @@ -250,26 +236,9 @@ {#each filteredHelpers as helper}
  • showHelperPopover(helper, e.target)} + on:mouseleave={hidePopover} on:click={() => addHelper(helper, mode.name === "javascript")} - on:mouseenter={e => { - popoverAnchor = e.target - if (!helper.displayText && helper.description) { - return - } - hoverTarget = { - description: helper.description, - code: getHelperExample(helper, mode.name === "javascript"), - } - popover.show() - e.stopPropagation() - }} - on:mouseleave={() => { - popover.hide() - popoverAnchor = null - hoverTarget = null - }} - on:focus={() => {}} - on:blur={() => {}} > {helper.displayText} @@ -287,16 +256,16 @@ 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/common/bindings/BindingPicker.svelte b/packages/builder/src/components/common/bindings/BindingSidePanel.svelte similarity index 66% rename from packages/builder/src/components/common/bindings/BindingPicker.svelte rename to packages/builder/src/components/common/bindings/BindingSidePanel.svelte index d1baa03999..b74f3c6fcd 100644 --- a/packages/builder/src/components/common/bindings/BindingPicker.svelte +++ b/packages/builder/src/components/common/bindings/BindingSidePanel.svelte @@ -137,123 +137,129 @@ - - {#if selectedCategory} -
    - { - selectedCategory = null - }} - > - Back - -
    - {/if} - - {#if !selectedCategory} - - {/if} - - {#if !selectedCategory && !search} -
      - {#each categoryNames as categoryName} -
    • + + {#if selectedCategory} +
      + { - selectedCategory = categoryName + selectedCategory = null }} > - - {categoryName} - -
    • - {/each} -
    - {/if} + Back + + + {/if} - {#if selectedCategory || search} - {#each filteredCategories as category} - {#if category.bindings?.length} -
    -
    - {category.name} -
    -
      - {#each category.bindings as binding} -
    • showBindingPopover(binding, e.target)} - on:mouseleave={hidePopover} - on:click={() => addBinding(binding)} - > - - {#if binding.display?.name} - {binding.display.name} - {:else if binding.fieldSchema?.name} - {binding.fieldSchema?.name} - {:else} - {binding.readableBinding} - {/if} - - {#if binding.display?.type || binding.fieldSchema?.type} - - - {binding.display?.type || binding.fieldSchema?.type} - + {#if !selectedCategory} + + {/if} + + {#if !selectedCategory && !search} +
        + {#each categoryNames as categoryName} +
      • { + selectedCategory = categoryName + }} + > + + {categoryName} + +
      • + {/each} +
      + {/if} + + {#if selectedCategory || search} + {#each filteredCategories as category} + {#if category.bindings?.length} +
      +
      + {category.name} +
      +
        + {#each category.bindings as binding} +
      • showBindingPopover(binding, e.target)} + on:mouseleave={hidePopover} + on:click={() => addBinding(binding)} + > + + {#if binding.display?.name} + {binding.display.name} + {:else if binding.fieldSchema?.name} + {binding.fieldSchema?.name} + {:else} + {binding.readableBinding} + {/if} - {/if} -
      • - {/each} -
      -
      - {/if} - {/each} + {#if binding.display?.type || binding.fieldSchema?.type} + + + {binding.display?.type || binding.fieldSchema?.type} + + + {/if} +
    • + {/each} +
    +
    + {/if} + {/each} - {#if selectedCategory === "Helpers" || search} - {#if filteredHelpers?.length} -
    -
    Helpers
    -
      - {#each filteredHelpers as helper} -
    • showHelperPopover(helper, e.target)} - on:mouseleave={hidePopover} - on:click={() => addHelper(helper, mode.name === "javascript")} - > - {helper.displayText} - - function - -
    • - {/each} -
    -
    + {#if selectedCategory === "Helpers" || search} + {#if filteredHelpers?.length} +
    +
    Helpers
    +
      + {#each filteredHelpers as helper} +
    • showHelperPopover(helper, e.target)} + on:mouseleave={hidePopover} + on:click={() => addHelper(helper, mode.name === "javascript")} + > + {helper.displayText} + + function + +
    • + {/each} +
    +
    + {/if} {/if} {/if} - {/if} -
    + + From c0d1b31ba2e4acef0bc6303db188df7667991bd5 Mon Sep 17 00:00:00 2001 From: Dean Date: Thu, 22 Feb 2024 15:19:17 +0000 Subject: [PATCH 020/148] Lint --- packages/builder/src/components/deploy/DeleteModal.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/builder/src/components/deploy/DeleteModal.svelte b/packages/builder/src/components/deploy/DeleteModal.svelte index 293d0adf60..e1653f5f7b 100644 --- a/packages/builder/src/components/deploy/DeleteModal.svelte +++ b/packages/builder/src/components/deploy/DeleteModal.svelte @@ -30,7 +30,7 @@ const deleteApp = async () => { if (!appId) { - console.log("No app id provided") + console.error("No app id provided") return } deleting = true From 2bcf5ebe3a8f9b939d060e4a0874ecbccbb9b827 Mon Sep 17 00:00:00 2001 From: Dean Date: Thu, 22 Feb 2024 15:36:54 +0000 Subject: [PATCH 021/148] Lint --- packages/builder/src/components/deploy/DeleteModal.svelte | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/builder/src/components/deploy/DeleteModal.svelte b/packages/builder/src/components/deploy/DeleteModal.svelte index e1653f5f7b..26a13623b5 100644 --- a/packages/builder/src/components/deploy/DeleteModal.svelte +++ b/packages/builder/src/components/deploy/DeleteModal.svelte @@ -46,6 +46,7 @@ } + (deletionConfirmationAppName = null)} disabled={deletionConfirmationAppName !== appName || deleting} > - Are you sure you want to delete - {appName}? + + {appName} + ?
    Please enter the app name below to confirm.

    From 6f269d631c203c5e05957cbb5b08aae38f971277 Mon Sep 17 00:00:00 2001 From: Dean Date: Thu, 22 Feb 2024 16:14:57 +0000 Subject: [PATCH 022/148] Bump account portal --- packages/account-portal | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/account-portal b/packages/account-portal index a851eeacab..ab324e35d8 160000 --- a/packages/account-portal +++ b/packages/account-portal @@ -1 +1 @@ -Subproject commit a851eeacabfaad8bff6e781f5e5a62063cbc31f3 +Subproject commit ab324e35d855012bd0f49caa53c6dd765223c6fa From 13e294b70887b3218b1e0368939f59dbe6f56dc5 Mon Sep 17 00:00:00 2001 From: Dean Date: Thu, 22 Feb 2024 16:19:06 +0000 Subject: [PATCH 023/148] Removed references to unused picker --- packages/bbui/src/Form/Core/index.js | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/bbui/src/Form/Core/index.js b/packages/bbui/src/Form/Core/index.js index 3e5befca0b..b0edf52748 100644 --- a/packages/bbui/src/Form/Core/index.js +++ b/packages/bbui/src/Form/Core/index.js @@ -14,4 +14,3 @@ 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" From 8320c50c9686602084240fcc8adb2b449d840dd8 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Thu, 22 Feb 2024 16:44:18 +0000 Subject: [PATCH 024/148] Fix multiple style issues with codemirror --- packages/builder/package.json | 2 +- .../common/CodeEditor/CodeEditor.svelte | 44 ++++++++++++++++++- .../src/components/common/CodeEditor/index.js | 13 ------ 3 files changed, 44 insertions(+), 15 deletions(-) diff --git a/packages/builder/package.json b/packages/builder/package.json index 3bf9ab4442..4871833d48 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -66,7 +66,7 @@ "@spectrum-css/page": "^3.0.1", "@spectrum-css/vars": "^3.0.1", "@zerodevx/svelte-json-view": "^1.0.7", - "codemirror": "^5.59.0", + "codemirror": "^6.0.1", "dayjs": "^1.10.8", "downloadjs": "1.4.7", "fast-json-patch": "^3.1.1", diff --git a/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte b/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte index 0d8798d0f2..9341711022 100644 --- a/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte +++ b/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte @@ -299,12 +299,53 @@ diff --git a/packages/builder/src/components/common/bindings/BindingSidePanel.svelte b/packages/builder/src/components/common/bindings/BindingSidePanel.svelte index d68857b420..2c8c6144fb 100644 --- a/packages/builder/src/components/common/bindings/BindingSidePanel.svelte +++ b/packages/builder/src/components/common/bindings/BindingSidePanel.svelte @@ -140,21 +140,19 @@
    {#if selectedCategory} -
    - { - selectedCategory = null - }} - > - Back - +
    + (selectedCategory = null)} + /> + {selectedCategory}
    {/if} {#if !selectedCategory} -
    - {#if expressionResult} - {@html highlightedResult} - {:else} + {#if empty} Your expression will be evaluated here + {:else} + {@html highlightedResult} {/if}
    From e53676791a8d8b9dfd47759bb02242287ca3be7f Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Thu, 29 Feb 2024 17:04:01 +0000 Subject: [PATCH 056/148] Fix font size of code block in helper popovers --- .../src/components/common/bindings/BindingSidePanel.svelte | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/builder/src/components/common/bindings/BindingSidePanel.svelte b/packages/builder/src/components/common/bindings/BindingSidePanel.svelte index 87fcb7cb5b..4815353cff 100644 --- a/packages/builder/src/components/common/bindings/BindingSidePanel.svelte +++ b/packages/builder/src/components/common/bindings/BindingSidePanel.svelte @@ -123,7 +123,6 @@ maxHeight={480} dismissible={false} on:mouseenter={stopHidingPopover} - on:mouseleave={hidePopover} >
    {#if hoverTarget.description} @@ -414,4 +413,7 @@ padding: 0; margin: 0; } + .binding-popover.helper :global(code) { + font-size: 12px; + } From b159258fb5c814bb9c512973a73e9832c6682068 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Thu, 29 Feb 2024 17:04:39 +0000 Subject: [PATCH 057/148] Restore mouse functionality --- .../src/components/common/bindings/BindingSidePanel.svelte | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/builder/src/components/common/bindings/BindingSidePanel.svelte b/packages/builder/src/components/common/bindings/BindingSidePanel.svelte index 4815353cff..3a3c3c4034 100644 --- a/packages/builder/src/components/common/bindings/BindingSidePanel.svelte +++ b/packages/builder/src/components/common/bindings/BindingSidePanel.svelte @@ -123,6 +123,7 @@ maxHeight={480} dismissible={false} on:mouseenter={stopHidingPopover} + on:mouseleave={hidePopover} >
    {#if hoverTarget.description} From 3aed79ad03a5f36d1e9d0c7646dea82289e29365 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Thu, 29 Feb 2024 19:08:29 +0000 Subject: [PATCH 058/148] Remove helpers subheading --- .../src/components/common/bindings/BindingSidePanel.svelte | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/builder/src/components/common/bindings/BindingSidePanel.svelte b/packages/builder/src/components/common/bindings/BindingSidePanel.svelte index 3a3c3c4034..1595aed3b5 100644 --- a/packages/builder/src/components/common/bindings/BindingSidePanel.svelte +++ b/packages/builder/src/components/common/bindings/BindingSidePanel.svelte @@ -237,7 +237,6 @@ {#if selectedCategory === "Helpers" || search} {#if filteredHelpers?.length}
    -
    Helpers
      {#each filteredHelpers as helper}
    • Date: Thu, 29 Feb 2024 19:40:21 +0000 Subject: [PATCH 059/148] Lint, remove drawer modal border, bump account portal --- packages/account-portal | 2 +- packages/bbui/src/Drawer/Drawer.svelte | 1 + packages/builder/src/components/common/CodeEditor/index.js | 1 - .../builder/src/components/common/bindings/BindingPanel.svelte | 2 -- .../[application]/design/[screenId]/_components/AppPanel.svelte | 2 +- 5 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/account-portal b/packages/account-portal index 8c446c4ba3..19f7a5829f 160000 --- a/packages/account-portal +++ b/packages/account-portal @@ -1 +1 @@ -Subproject commit 8c446c4ba385592127fa31755d3b64467b291882 +Subproject commit 19f7a5829f4d23cbc694136e45d94482a59a475a diff --git a/packages/bbui/src/Drawer/Drawer.svelte b/packages/bbui/src/Drawer/Drawer.svelte index 118a7dcd8e..606b4e28cd 100644 --- a/packages/bbui/src/Drawer/Drawer.svelte +++ b/packages/bbui/src/Drawer/Drawer.svelte @@ -211,6 +211,7 @@ width: 70vw; bottom: 15vh; height: 70vh; + border: none; } .drawer.stacked { transform: translateY(calc(-1 * 1024px * (1 - var(--scale-factor)))) diff --git a/packages/builder/src/components/common/CodeEditor/index.js b/packages/builder/src/components/common/CodeEditor/index.js index 962e0bd393..82c8ddf647 100644 --- a/packages/builder/src/components/common/CodeEditor/index.js +++ b/packages/builder/src/components/common/CodeEditor/index.js @@ -1,4 +1,3 @@ -import { EditorView } from "@codemirror/view" import { getManifest } from "@budibase/string-templates" import sanitizeHtml from "sanitize-html" import { groupBy } from "lodash" diff --git a/packages/builder/src/components/common/bindings/BindingPanel.svelte b/packages/builder/src/components/common/bindings/BindingPanel.svelte index 2e4a7a63a8..a3d57e1d5c 100644 --- a/packages/builder/src/components/common/bindings/BindingPanel.svelte +++ b/packages/builder/src/components/common/bindings/BindingPanel.svelte @@ -225,7 +225,6 @@ ...getHelperCompletions(editorMode), ]), ]} - height="100%" autofocus={autofocusEditor} placeholder="Add bindings by typing {{ or use the menu on the right" /> @@ -242,7 +241,6 @@ mode={EditorModes.JS} bind:getCaretPosition bind:insertAtPos - height="100%" autofocus={autofocusEditor} placeholder="Add bindings by typing $ or use the menu on the right" /> diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/AppPanel.svelte b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/AppPanel.svelte index c90c4ea599..4617814485 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/AppPanel.svelte +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/AppPanel.svelte @@ -33,7 +33,7 @@ flex-direction: column; justify-content: flex-start; align-items: stretch; - padding: 9px var(--spacing-m); + padding: 9px 10px 12px 10px; position: relative; transition: width 360ms ease-out; } From 10b5ab34cfa10cb1ad6c278879fc66b6815c6628 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Thu, 29 Feb 2024 19:51:25 +0000 Subject: [PATCH 060/148] Re-add border for drawer modals --- packages/bbui/src/Drawer/Drawer.svelte | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/bbui/src/Drawer/Drawer.svelte b/packages/bbui/src/Drawer/Drawer.svelte index 606b4e28cd..118a7dcd8e 100644 --- a/packages/bbui/src/Drawer/Drawer.svelte +++ b/packages/bbui/src/Drawer/Drawer.svelte @@ -211,7 +211,6 @@ width: 70vw; bottom: 15vh; height: 70vh; - border: none; } .drawer.stacked { transform: translateY(calc(-1 * 1024px * (1 - var(--scale-factor)))) From d2cdee13aec50d912fea881cf90ad8562a38c088 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Thu, 29 Feb 2024 20:18:20 +0000 Subject: [PATCH 061/148] Update automation code editors to work with new components --- .../SetupPanel/AutomationBlockSetup.svelte | 24 ++++--------------- .../common/CodeEditor/CodeEditor.svelte | 15 ++++++++---- .../common/bindings/BindingPanel.svelte | 9 ------- .../common/bindings/BindingSidePanel.svelte | 6 ++--- 4 files changed, 17 insertions(+), 37 deletions(-) diff --git a/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte b/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte index 2434002d52..aff9e43aa7 100644 --- a/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte +++ b/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte @@ -547,7 +547,7 @@ {:else if value.customType === "code"}
      -
      +
      { @@ -560,20 +560,10 @@ autocompleteEnabled={codeMode !== EditorModes.JS} bind:getCaretPosition bind:insertAtPos - height={500} + placeholder={codeMode === EditorModes.Handlebars + ? "Add bindings by typing {{" + : null} /> -
      - {#if codeMode === EditorModes.Handlebars} - -
      -
      - Add available bindings by typing - }} - -
      -
      - {/if} -
      {#if editingJs}
      @@ -650,11 +640,6 @@ width: 320px; } - .messaging { - display: flex; - align-items: center; - margin-top: var(--spacing-xl); - } .fields { display: flex; flex-direction: column; @@ -666,7 +651,6 @@ .block-field { display: flex; /* Use Flexbox */ justify-content: space-between; - align-items: center; flex-direction: row; /* Arrange label and field side by side */ align-items: center; /* Align vertically in the center */ gap: 10px; /* Add some space between label and field */ diff --git a/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte b/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte index 58942e5091..080476a9b7 100644 --- a/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte +++ b/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte @@ -47,7 +47,6 @@ export let label export let completions = [] - export let height = 200 export let resize = "none" export let mode = EditorModes.Handlebars export let value = "" @@ -241,8 +240,6 @@ } } - $: editorHeight = typeof height === "number" ? `${height}px` : height - // Init when all elements are ready $: if (mounted && !isEditorInitialised) { isEditorInitialised = true @@ -284,14 +281,22 @@ 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 076/148] 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 077/148] 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 @@ - - - -
      (showTooltip = true)} - on:focus={() => (showTooltip = true)} - on:mouseleave={() => (showTooltip = false)} - on:click={() => (showTooltip = false)} + - - - - {#if tooltip && showTooltip} -
      - -
      - {/if} -
      +
      + + + +
      + diff --git a/packages/builder/src/pages/builder/portal/apps/_layout.svelte b/packages/builder/src/pages/builder/portal/apps/_layout.svelte index 8810edca9c..00719dc6d5 100644 --- a/packages/builder/src/pages/builder/portal/apps/_layout.svelte +++ b/packages/builder/src/pages/builder/portal/apps/_layout.svelte @@ -2,7 +2,7 @@ import { notifications } from "@budibase/bbui" import { admin, - apps, + appsStore, templates, licensing, groups, @@ -14,7 +14,7 @@ import PortalSideBar from "./_components/PortalSideBar.svelte" // Don't block loading if we've already hydrated state - let loaded = !!$apps?.length + let loaded = !!$appsStore.apps?.length onMount(async () => { try { @@ -34,7 +34,10 @@ } // Go to new app page if no apps exists - if (!$apps.length && sdk.users.hasBuilderPermissions($auth.user)) { + if ( + !$appsStore.apps.length && + sdk.users.hasBuilderPermissions($auth.user) + ) { $redirect("./onboarding") } } catch (error) { @@ -46,7 +49,7 @@ {#if loaded}
      - {#if $apps.length > 0} + {#if $appsStore.apps.length > 0} {/if} diff --git a/packages/builder/src/pages/builder/portal/apps/create.svelte b/packages/builder/src/pages/builder/portal/apps/create.svelte index 1f2c579071..1248c41cf8 100644 --- a/packages/builder/src/pages/builder/portal/apps/create.svelte +++ b/packages/builder/src/pages/builder/portal/apps/create.svelte @@ -5,7 +5,7 @@ import CreateAppModal from "components/start/CreateAppModal.svelte" import TemplateDisplay from "components/common/TemplateDisplay.svelte" import AppLimitModal from "components/portal/licensing/AppLimitModal.svelte" - import { apps, templates, licensing } from "stores/portal" + import { appsStore, templates, licensing } from "stores/portal" import { Breadcrumbs, Breadcrumb, Header } from "components/portal/page" let template @@ -35,7 +35,7 @@ } -{#if !$apps.length} +{#if !$appsStore.apps.length} {:else} diff --git a/packages/builder/src/pages/builder/portal/apps/index.svelte b/packages/builder/src/pages/builder/portal/apps/index.svelte index a1aa242a36..c087e3cc86 100644 --- a/packages/builder/src/pages/builder/portal/apps/index.svelte +++ b/packages/builder/src/pages/builder/portal/apps/index.svelte @@ -19,10 +19,16 @@ import { automationStore, initialise } from "stores/builder" import { API } from "api" import { onMount } from "svelte" - import { apps, auth, admin, licensing, environment } from "stores/portal" + import { + appsStore, + auth, + admin, + licensing, + environment, + enriched as enrichedApps, + } from "stores/portal" import { goto } from "@roxi/routify" import AppRow from "components/start/AppRow.svelte" - import { AppStatus } from "constants" import Logo from "assets/bb-space-man.svg" let sortBy = "name" @@ -33,56 +39,27 @@ let searchTerm = "" let creatingFromTemplate = false let automationErrors - let accessFilterList = null $: welcomeHeader = `Welcome ${$auth?.user?.firstName || "back"}` - $: enrichedApps = enrichApps($apps, $auth.user, sortBy) - $: filteredApps = enrichedApps.filter( - app => - (searchTerm - ? app?.name?.toLowerCase().includes(searchTerm.toLowerCase()) - : true) && - (accessFilterList !== null - ? accessFilterList?.includes( - `${app?.type}_${app?.tenantId}_${app?.appId}` - ) - : true) - ) - $: automationErrors = getAutomationErrors(enrichedApps) + $: filteredApps = filterApps($enrichedApps, searchTerm) + $: automationErrors = getAutomationErrors(filteredApps || []) $: isOwner = $auth.accountPortalAccess && $admin.cloud + const filterApps = (apps, searchTerm) => { + return apps?.filter(app => { + const query = searchTerm?.trim()?.replace(/\s/g, "") + if (query) { + return app?.name?.toLowerCase().includes(query.toLowerCase()) + } else { + return true + } + }) + } + const usersLimitLockAction = $licensing?.errUserLimit ? () => accountLockedModal.show() : null - const enrichApps = (apps, user, sortBy) => { - const enrichedApps = apps.map(app => ({ - ...app, - deployed: app.status === AppStatus.DEPLOYED, - lockedYou: app.lockedBy && app.lockedBy.email === user?.email, - lockedOther: app.lockedBy && app.lockedBy.email !== user?.email, - })) - - if (sortBy === "status") { - return enrichedApps.sort((a, b) => { - if (a.status === b.status) { - return a.name?.toLowerCase() < b.name?.toLowerCase() ? -1 : 1 - } - return a.status === AppStatus.DEPLOYED ? -1 : 1 - }) - } else if (sortBy === "updated") { - return enrichedApps.sort((a, b) => { - const aUpdated = a.updatedAt || "9999" - const bUpdated = b.updatedAt || "9999" - return aUpdated < bUpdated ? 1 : -1 - }) - } else { - return enrichedApps.sort((a, b) => { - return a.name?.toLowerCase() < b.name?.toLowerCase() ? -1 : 1 - }) - } - } - const getAutomationErrors = apps => { const automationErrors = {} for (let app of apps) { @@ -117,7 +94,7 @@ const initiateAppCreation = async () => { if ($licensing?.usageMetrics?.apps >= 100) { appLimitModal.show() - } else if ($apps?.length) { + } else if ($appsStore.apps?.length) { $goto("/builder/portal/apps/create") } else { template = null @@ -136,7 +113,7 @@ const templateKey = template.key.split("/")[1] let appName = templateKey.replace(/-/g, " ") - const appsWithSameName = $apps.filter(app => + const appsWithSameName = $appsStore.apps.filter(app => app.name?.startsWith(appName) ) appName = `${appName} ${appsWithSameName.length + 1}` @@ -217,7 +194,7 @@ : "View error"} on:dismiss={async () => { await automationStore.actions.clearLogErrors({ appId }) - await apps.load() + await appsStore.load() }} message={automationErrorMessage(appId)} /> @@ -233,7 +210,7 @@
      - {#if enrichedApps.length} + {#if $appsStore.apps.length}
      {#if $auth.user && sdk.users.canCreateApps($auth.user)} @@ -245,7 +222,7 @@ > Create new app - {#if $apps?.length > 0 && !$admin.offlineMode} + {#if $appsStore.apps?.length > 0 && !$admin.offlineMode}
      +
      + {#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 083/148] 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 084/148] 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 085/148] 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 @@
      + {#if hoverTarget.binding} +
      {getReadableBindingLabel(hoverTarget.binding)}
      + {/if} {#if hoverTarget.description}
      @@ -246,7 +260,7 @@ class="binding" on:mouseenter={e => showHelperPopover(helper, e.target)} on:mouseleave={hidePopover} - on:click={() => addHelper(helper, mode.name === "javascript")} + on:click={() => addHelper(helper, js)} > {helper.displayText} @@ -404,7 +418,8 @@ text-overflow: ellipsis; overflow: hidden; } - .binding-popover.helper pre { + .binding-popover.helper pre, + .binding-popover pre.binding { color: var(--spectrum-global-color-blue-700); } .binding-popover pre :global(span) { From 07ea080ab888a50cd983c32da63b8b1ec762ab99 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Mon, 11 Mar 2024 12:07:25 +0000 Subject: [PATCH 097/148] Update binding popovers to remove example bindings and therefore make consistent across the board --- .../common/CodeEditor/CodeEditor.svelte | 5 ++-- .../src/components/common/CodeEditor/index.js | 22 +++----------- .../common/bindings/BindingPanel.svelte | 7 ++++- .../common/bindings/BindingSidePanel.svelte | 29 +++++-------------- 4 files changed, 19 insertions(+), 44 deletions(-) diff --git a/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte b/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte index 88661467be..5d9b84fde5 100644 --- a/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte +++ b/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte @@ -47,7 +47,6 @@ export let label export let completions = [] - export let resize = "none" export let mode = EditorModes.Handlebars export let value = "" export let placeholder = null @@ -55,6 +54,8 @@ export let autofocus = false export let jsBindingWrapping = true + const dispatch = createEventDispatcher() + // Export a function to expose caret position export const getCaretPosition = () => { const selection_range = editor.state.selection.ranges[0] @@ -106,8 +107,6 @@ } ) - const dispatch = createEventDispatcher() - // Theming! let currentTheme = $themeStore?.theme let isDark = !currentTheme.includes("light") diff --git a/packages/builder/src/components/common/CodeEditor/index.js b/packages/builder/src/components/common/CodeEditor/index.js index bd55c371e6..c104267aa4 100644 --- a/packages/builder/src/components/common/CodeEditor/index.js +++ b/packages/builder/src/components/common/CodeEditor/index.js @@ -163,26 +163,12 @@ export const jsAutocomplete = baseCompletions => { } export const buildBindingInfoNode = (completion, binding) => { + if (!binding.valueHTML || binding.value == null) { + return null + } const ele = document.createElement("div") ele.classList.add("info-bubble") - - if (binding.value != null && binding.valueHTML) { - ele.innerHTML = `
      ${binding.valueHTML}
      ` - return ele - } - - const exampleNodeHtml = binding.readableBinding - ? `
      {{ ${binding.readableBinding} }}
      ` - : "" - - const descriptionNodeHtml = binding.description - ? `
      ${binding.description}
      ` - : "" - - ele.innerHTML = ` - ${exampleNodeHtml} - ${descriptionNodeHtml} - ` + ele.innerHTML = `
      ${binding.valueHTML}
      ` return ele } diff --git a/packages/builder/src/components/common/bindings/BindingPanel.svelte b/packages/builder/src/components/common/bindings/BindingPanel.svelte index e8ad96ea69..9543c97791 100644 --- a/packages/builder/src/components/common/bindings/BindingPanel.svelte +++ b/packages/builder/src/components/common/bindings/BindingPanel.svelte @@ -108,6 +108,9 @@ const enrichBindings = (bindings, context) => { return bindings.map(binding => { + if (!context) { + return binding + } const value = getBindingValue(binding, context) return { ...binding, @@ -227,6 +230,7 @@ ]} autofocus={autofocusEditor} placeholder="Add bindings by typing {{ or use the menu on the right" + jsBindingWrapping={false} /> {:else if mode === Modes.JavaScript} {/if} {#if targetMode} @@ -279,7 +284,7 @@ {context} addHelper={onSelectHelper} addBinding={onSelectBinding} - js={editorMode === EditorModes.JS} + {mode} /> {:else if sidePanel === SidePanels.Evaluation} { - let code = binding.valueHTML if (!context || !binding.value || binding.value === "") { - code = null + return } stopHidingPopover() popoverAnchor = target hoverTarget = { helper: false, - code, - binding: binding.readableBinding, + code: binding.valueHTML, } popover.show() } @@ -94,7 +92,7 @@ hoverTarget = { helper: true, description: helper.description, - code: getHelperExample(helper, js), + code: getHelperExample(helper, mode.name === "javascript"), } popover.show() } @@ -114,15 +112,6 @@ hideTimeout = null } } - - const getReadableBindingLabel = readableBinding => { - console.log(js) - if (js) { - return `$("${readableBinding}")` - } else { - return `{{ ${readableBinding} }}` - } - }
      - {#if hoverTarget.binding} -
      {getReadableBindingLabel(hoverTarget.binding)}
      - {/if} {#if hoverTarget.description}
      @@ -260,7 +246,7 @@ class="binding" on:mouseenter={e => showHelperPopover(helper, e.target)} on:mouseleave={hidePopover} - on:click={() => addHelper(helper, js)} + on:click={() => addHelper(helper, mode.name === "javascript")} > {helper.displayText} @@ -418,8 +404,7 @@ text-overflow: ellipsis; overflow: hidden; } - .binding-popover.helper pre, - .binding-popover pre.binding { + .binding-popover.helper pre { color: var(--spectrum-global-color-blue-700); } .binding-popover pre :global(span) { From 8c90d422c297d37c2b49ef76a7f02a1c3eb0c5c5 Mon Sep 17 00:00:00 2001 From: Dean Date: Mon, 11 Mar 2024 12:21:59 +0000 Subject: [PATCH 098/148] Update to update self endpoint to make remove distinct flow for appFavourites --- .../worker/src/api/controllers/global/self.ts | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/worker/src/api/controllers/global/self.ts b/packages/worker/src/api/controllers/global/self.ts index 15fe84b021..a3c001de03 100644 --- a/packages/worker/src/api/controllers/global/self.ts +++ b/packages/worker/src/api/controllers/global/self.ts @@ -164,23 +164,21 @@ export async function updateSelf( const update = ctx.request.body let user = await userSdk.db.getUser(ctx.user._id!) + let requestAppFavourites: string[] = [...(update.appFavourites || [])] + let updatedAppFavourites: string[] | undefined if ("appFavourites" in update) { const appIds: string[] = processUserAppFavourites( user, - update.appFavourites + requestAppFavourites ) - const validAppIds: string[] = await syncAppFavourites(appIds) + updatedAppFavourites = await syncAppFavourites(appIds) + } - user = { - ...user, - appFavourites: validAppIds, - } - } else { - user = { - ...user, - ...update, - } + user = { + ...user, + ...update, + ...(updatedAppFavourites ? { appFavourites: updatedAppFavourites } : {}), } user = await userSdk.db.save(user, { requirePassword: false }) From a3b1062d09c12cc4ae5ed38754ca33b72e19cd0b Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Mon, 11 Mar 2024 13:18:53 +0000 Subject: [PATCH 099/148] Fix existing issue of CodeEditor completions not being reactive and allow saving invalid HBS bindings --- .../common/bindings/BindingPanel.svelte | 68 ++++++++++--------- .../common/bindings/ClientBindingPanel.svelte | 2 - .../bindings/DrawerBindableCombobox.svelte | 6 +- .../bindings/DrawerBindableInput.svelte | 6 +- .../common/bindings/DrawerBindableSlot.svelte | 6 +- .../common/bindings/ServerBindingPanel.svelte | 2 - .../DataSourceSelect/DataSourceSelect.svelte | 6 +- 7 files changed, 41 insertions(+), 55 deletions(-) diff --git a/packages/builder/src/components/common/bindings/BindingPanel.svelte b/packages/builder/src/components/common/bindings/BindingPanel.svelte index 9543c97791..1872ee2e5f 100644 --- a/packages/builder/src/components/common/bindings/BindingPanel.svelte +++ b/packages/builder/src/components/common/bindings/BindingPanel.svelte @@ -77,6 +77,18 @@ $: runtimeExpression = readableToRuntimeBinding(enrichedBindings, value) $: requestUpdateEvaluation(runtimeExpression, context) $: bindingHelpers = new BindingHelpers(getCaretPosition, insertAtPos) + $: hbsCompletions = [ + hbAutocomplete([ + ...bindingCompletions, + ...getHelperCompletions(EditorModes.Handlebars), + ]), + ] + $: jsCompletions = [ + jsAutocomplete([ + ...bindingCompletions, + ...getHelperCompletions(EditorModes.JS), + ]), + ] const debouncedUpdateEvaluation = Utils.debounce((expression, context) => { expressionResult = processStringSync(expression || "", context) @@ -217,38 +229,32 @@
      {#if mode === Modes.Text} - + {#key hbsCompletions} + + {/key} {:else if mode === Modes.JavaScript} - + {#key jsCompletions} + + {/key} {/if} {#if targetMode}
      diff --git a/packages/builder/src/components/common/bindings/ClientBindingPanel.svelte b/packages/builder/src/components/common/bindings/ClientBindingPanel.svelte index 97cb097a51..843dec8c89 100644 --- a/packages/builder/src/components/common/bindings/ClientBindingPanel.svelte +++ b/packages/builder/src/components/common/bindings/ClientBindingPanel.svelte @@ -4,7 +4,6 @@ import { onMount } from "svelte" export let bindings = [] - export let valid export let value = "" export let allowJS = false export let allowHelpers = true @@ -27,7 +26,6 @@ - + (tempValue = event.detail)} {bindings} {allowJS} diff --git a/packages/builder/src/components/common/bindings/DrawerBindableInput.svelte b/packages/builder/src/components/common/bindings/DrawerBindableInput.svelte index 12aa4edf2e..d11ebcf87a 100644 --- a/packages/builder/src/components/common/bindings/DrawerBindableInput.svelte +++ b/packages/builder/src/components/common/bindings/DrawerBindableInput.svelte @@ -28,7 +28,6 @@ const dispatch = createEventDispatcher() let bindingDrawer - let valid = true let currentVal = value $: readableValue = runtimeToReadableBinding(bindings, value) @@ -93,13 +92,10 @@ title={title ?? placeholder ?? "Bindings"} {forceModal} > - + (tempValue = event.detail)} {bindings} diff --git a/packages/builder/src/components/common/bindings/DrawerBindableSlot.svelte b/packages/builder/src/components/common/bindings/DrawerBindableSlot.svelte index 84045e786a..8ce9dda209 100644 --- a/packages/builder/src/components/common/bindings/DrawerBindableSlot.svelte +++ b/packages/builder/src/components/common/bindings/DrawerBindableSlot.svelte @@ -25,7 +25,6 @@ const dispatch = createEventDispatcher() let bindingDrawer - let valid = true let currentVal = value $: readableValue = runtimeToReadableBinding(bindings, value) @@ -176,13 +175,10 @@ title={title ?? placeholder ?? "Bindings"} left={drawerLeft} > - + (tempValue = event.detail)} {bindings} diff --git a/packages/builder/src/components/common/bindings/ServerBindingPanel.svelte b/packages/builder/src/components/common/bindings/ServerBindingPanel.svelte index fdc8254a18..5718d080f2 100644 --- a/packages/builder/src/components/common/bindings/ServerBindingPanel.svelte +++ b/packages/builder/src/components/common/bindings/ServerBindingPanel.svelte @@ -2,7 +2,6 @@ import BindingPanel from "./BindingPanel.svelte" export let bindings = [] - export let valid export let value = "" export let allowJS = false export let context = null @@ -20,7 +19,6 @@
      - +
      Provide a JSON array to use as data
      (tmpCustomData = event.detail)} {bindings} From bb953d3bc4f7e664beaf5c1c779cd1c54a8c5fe1 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Mon, 11 Mar 2024 13:32:27 +0000 Subject: [PATCH 100/148] Shorten duplicate key value --- packages/client/src/components/Component.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/src/components/Component.svelte b/packages/client/src/components/Component.svelte index e7024ac17f..3d267ec623 100644 --- a/packages/client/src/components/Component.svelte +++ b/packages/client/src/components/Component.svelte @@ -592,7 +592,7 @@ component: instance._component, getSettings: () => cachedSettings, getRawSettings: () => ({ ...staticSettings, ...dynamicSettings }), - getDataContext: getDataContext, + getDataContext, reload: () => initialise(instance, true), setEphemeralStyles: styles => (ephemeralStyles = styles), state: store, From 20c8191323598a6d65c8664d697d4cc2b7cf1696 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Mon, 11 Mar 2024 14:00:42 +0000 Subject: [PATCH 101/148] Fix bgu --- .../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 1872ee2e5f..327a2b5eda 100644 --- a/packages/builder/src/components/common/bindings/BindingPanel.svelte +++ b/packages/builder/src/components/common/bindings/BindingPanel.svelte @@ -290,7 +290,7 @@ {context} addHelper={onSelectHelper} addBinding={onSelectBinding} - {mode} + mode={editorMode} /> {:else if sidePanel === SidePanels.Evaluation} Date: Mon, 11 Mar 2024 14:22:41 +0000 Subject: [PATCH 102/148] PR feedback and bug fix for server tests being completely skipped --- .../server/src/api/controllers/application.ts | 1 - .../src/api/routes/tests/application.spec.ts | 196 +++++++++--------- packages/types/src/api/web/application.ts | 1 - 3 files changed, 99 insertions(+), 99 deletions(-) diff --git a/packages/server/src/api/controllers/application.ts b/packages/server/src/api/controllers/application.ts index 47cc08c738..0a84451bac 100644 --- a/packages/server/src/api/controllers/application.ts +++ b/packages/server/src/api/controllers/application.ts @@ -697,7 +697,6 @@ export async function duplicateApp( } ctx.body = { - message: "app duplicated", duplicateAppId: newApplication?.appId, sourceAppId, } diff --git a/packages/server/src/api/routes/tests/application.spec.ts b/packages/server/src/api/routes/tests/application.spec.ts index 2a9e723423..04edc91bf6 100644 --- a/packages/server/src/api/routes/tests/application.spec.ts +++ b/packages/server/src/api/routes/tests/application.spec.ts @@ -34,6 +34,96 @@ describe("/applications", () => { jest.clearAllMocks() }) + // These need to go first for the app totals to make sense + describe("permissions", () => { + it("should only return apps a user has access to", async () => { + let user = await config.createUser({ + builder: { global: false }, + admin: { global: false }, + }) + + await config.withUser(user, async () => { + const apps = await config.api.application.fetch() + expect(apps).toHaveLength(0) + }) + + user = await config.globalUser({ + ...user, + builder: { + apps: [config.getProdAppId()], + }, + }) + + await config.withUser(user, async () => { + const apps = await config.api.application.fetch() + expect(apps).toHaveLength(1) + }) + }) + + it("should only return apps a user has access to through a custom role", async () => { + let user = await config.createUser({ + builder: { global: false }, + admin: { global: false }, + }) + + await config.withUser(user, async () => { + const apps = await config.api.application.fetch() + expect(apps).toHaveLength(0) + }) + + const role = await config.api.roles.save({ + name: "Test", + inherits: "PUBLIC", + permissionId: "read_only", + version: "name", + }) + + user = await config.globalUser({ + ...user, + roles: { + [config.getProdAppId()]: role.name, + }, + }) + + await config.withUser(user, async () => { + const apps = await config.api.application.fetch() + expect(apps).toHaveLength(1) + }) + }) + + it("should only return apps a user has access to through a custom role on a group", async () => { + let user = await config.createUser({ + builder: { global: false }, + admin: { global: false }, + }) + + await config.withUser(user, async () => { + const apps = await config.api.application.fetch() + expect(apps).toHaveLength(0) + }) + + const roleName = uuid.v4().replace(/-/g, "") + const role = await config.api.roles.save({ + name: roleName, + inherits: "PUBLIC", + permissionId: "read_only", + version: "name", + }) + + const group = await config.createGroup(role._id!) + + user = await config.globalUser({ + ...user, + userGroups: [group._id!], + }) + + await config.withUser(user, async () => { + const apps = await config.api.application.fetch() + expect(apps).toHaveLength(1) + }) + }) + }) + describe("create", () => { it("creates empty app", async () => { const app = await config.api.application.create({ name: utils.newid() }) @@ -96,13 +186,16 @@ describe("/applications", () => { }) it("should reject with a known name", async () => { - await config.api.application.create({ name: app.name }, { status: 400 }) + await config.api.application.create( + { name: app.name }, + { body: { message: "App name is already in use." }, status: 400 } + ) }) it("should reject with a known url", async () => { await config.api.application.create( { name: "made up", url: app?.url! }, - { status: 400 } + { body: { message: "App URL is already in use." }, status: 400 } ) }) }) @@ -279,10 +372,9 @@ describe("/applications", () => { name: app.name, url: "/known-name", }, - { status: 400 } + { body: { message: "App name is already in use." }, status: 400 } ) - - expect(resp.message).toEqual("App name is already in use.") + expect(events.app.duplicated).not.toBeCalled() }) it("should reject with a known url", async () => { @@ -292,10 +384,9 @@ describe("/applications", () => { name: "this is fine", url: app.url, }, - { status: 400 } + { body: { message: "App URL is already in use." }, status: 400 } ) - - expect(resp.message).toEqual("App URL is already in use.") + expect(events.app.duplicated).not.toBeCalled() }) }) @@ -319,93 +410,4 @@ describe("/applications", () => { expect(devLogs.data.length).toBe(0) }) }) - - describe("permissions", () => { - it("should only return apps a user has access to", async () => { - let user = await config.createUser({ - builder: { global: false }, - admin: { global: false }, - }) - - await config.withUser(user, async () => { - const apps = await config.api.application.fetch() - expect(apps).toHaveLength(0) - }) - - user = await config.globalUser({ - ...user, - builder: { - apps: [config.getProdAppId()], - }, - }) - - await config.withUser(user, async () => { - const apps = await config.api.application.fetch() - expect(apps).toHaveLength(1) - }) - }) - - it("should only return apps a user has access to through a custom role", async () => { - let user = await config.createUser({ - builder: { global: false }, - admin: { global: false }, - }) - - await config.withUser(user, async () => { - const apps = await config.api.application.fetch() - expect(apps).toHaveLength(0) - }) - - const role = await config.api.roles.save({ - name: "Test", - inherits: "PUBLIC", - permissionId: "read_only", - version: "name", - }) - - user = await config.globalUser({ - ...user, - roles: { - [config.getProdAppId()]: role.name, - }, - }) - - await config.withUser(user, async () => { - const apps = await config.api.application.fetch() - expect(apps).toHaveLength(1) - }) - }) - - it.only("should only return apps a user has access to through a custom role on a group", async () => { - let user = await config.createUser({ - builder: { global: false }, - admin: { global: false }, - }) - - await config.withUser(user, async () => { - const apps = await config.api.application.fetch() - expect(apps).toHaveLength(0) - }) - - const roleName = uuid.v4().replace(/-/g, "") - const role = await config.api.roles.save({ - name: roleName, - inherits: "PUBLIC", - permissionId: "read_only", - version: "name", - }) - - const group = await config.createGroup(role._id!) - - user = await config.globalUser({ - ...user, - userGroups: [group._id!], - }) - - await config.withUser(user, async () => { - const apps = await config.api.application.fetch() - expect(apps).toHaveLength(1) - }) - }) - }) }) diff --git a/packages/types/src/api/web/application.ts b/packages/types/src/api/web/application.ts index b77e81880b..68c349be5a 100644 --- a/packages/types/src/api/web/application.ts +++ b/packages/types/src/api/web/application.ts @@ -22,7 +22,6 @@ export interface DuplicateAppRequest { export interface DuplicateAppResponse { duplicateAppId: string sourceAppId: string - message: string } export interface FetchAppDefinitionResponse { From a806dff3f4add6d246c550a635a870e04e5dcd27 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Mon, 11 Mar 2024 16:18:37 +0000 Subject: [PATCH 103/148] Remove concept of validity from binding panel and allowing saving anything --- .../components/common/bindings/BindingPanel.svelte | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/packages/builder/src/components/common/bindings/BindingPanel.svelte b/packages/builder/src/components/common/bindings/BindingPanel.svelte index 327a2b5eda..d0f88f8029 100644 --- a/packages/builder/src/components/common/bindings/BindingPanel.svelte +++ b/packages/builder/src/components/common/bindings/BindingPanel.svelte @@ -9,7 +9,6 @@ } from "@budibase/bbui" import { createEventDispatcher, getContext, onMount } from "svelte" import { - isValid, decodeJSBinding, encodeJSBinding, processStringSync, @@ -35,7 +34,6 @@ export let bindings export let value = "" - export let valid export let allowJS = false export let allowHelpers = true export let context = null @@ -134,11 +132,8 @@ const updateValue = val => { const runtimeExpression = readableToRuntimeBinding(enrichedBindings, val) - valid = isValid(runtimeExpression) - if (valid) { - dispatch("change", val) - requestUpdateEvaluation(runtimeExpression, context) - } + dispatch("change", val) + requestUpdateEvaluation(runtimeExpression, context) } const onSelectHelper = (helper, js) => { @@ -182,10 +177,6 @@ jsValue = encodeJSBinding(e.detail) updateValue(jsValue) } - - onMount(() => { - valid = isValid(readableToRuntimeBinding(enrichedBindings, value)) - }) From cef0911950ca8fdf6c6846428ef709aac1e0ff83 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Mon, 11 Mar 2024 16:21:07 +0000 Subject: [PATCH 104/148] Fix merge --- .../common/bindings/BindingPanel.svelte | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/packages/builder/src/components/common/bindings/BindingPanel.svelte b/packages/builder/src/components/common/bindings/BindingPanel.svelte index 67d7e578b1..96a2187755 100644 --- a/packages/builder/src/components/common/bindings/BindingPanel.svelte +++ b/packages/builder/src/components/common/bindings/BindingPanel.svelte @@ -172,16 +172,8 @@ const updateValue = val => { const runtimeExpression = readableToRuntimeBinding(enrichedBindings, val) -<<<<<<< HEAD - valid = isValid(runtimeExpression) - if (valid) { - dispatch("change", val) - requestEval(runtimeExpression, context, snippets) - } -======= dispatch("change", val) - requestUpdateEvaluation(runtimeExpression, context) ->>>>>>> fe12a8606438d41437e21729068b91038ba6df37 + requestEval(runtimeExpression, context, snippets) } const onSelectHelper = (helper, js) => { @@ -232,7 +224,6 @@ jsValue = encodeJSBinding(e.detail) updateValue(jsValue) } -<<<<<<< HEAD onMount(() => { // Set the initial mode appropriately @@ -245,12 +236,7 @@ // Set the initial side panel sidePanel = sidePanelOptions[0] - - // Determine if our initial value is valid - valid = isValid(readableToRuntimeBinding(enrichedBindings, value)) }) -======= ->>>>>>> fe12a8606438d41437e21729068b91038ba6df37 From 79ae159329718780cf755225b4d2cbabc2ab51fb Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Mon, 11 Mar 2024 21:10:53 +0000 Subject: [PATCH 105/148] 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 106/148] 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 107/148] 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 108/148] 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 109/148] 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 110/148] 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 111/148] 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 112/148] 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 113/148] 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 114/148] 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 115/148] 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 8eee45b44ac4035b91d8f54e2082d2bafbd7a6e5 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Wed, 13 Mar 2024 10:23:52 +0000 Subject: [PATCH 116/148] Lint --- .../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 d0f88f8029..aa765c03f6 100644 --- a/packages/builder/src/components/common/bindings/BindingPanel.svelte +++ b/packages/builder/src/components/common/bindings/BindingPanel.svelte @@ -7,7 +7,7 @@ Body, Button, } from "@budibase/bbui" - import { createEventDispatcher, getContext, onMount } from "svelte" + import { createEventDispatcher, getContext } from "svelte" import { decodeJSBinding, encodeJSBinding, From 2d12a1a8fa2aeca8e68d9643c17d971eac7792cd Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Wed, 13 Mar 2024 11:48:17 +0000 Subject: [PATCH 117/148] 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 118/148] 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 119/148] 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 120/148] 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 121/148] 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 122/148] 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 123/148] 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 124/148] 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 125/148] 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 126/148] 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 127/148] 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 128/148] 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 129/148] 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 131/148] 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 132/148] 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 133/148] 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 134/148] 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 135/148] 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 136/148] 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 137/148] 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 138/148] 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 144/148] 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 145/148] 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 @@