Merge pull request #15552 from Budibase/BUDI-9038/validate-hbs

Validate handlebars
This commit is contained in:
Adria Navarro 2025-02-17 14:10:35 +01:00 committed by GitHub
commit da2a7ab83f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 355 additions and 59 deletions

View File

@ -386,7 +386,7 @@
editableColumn.relationshipType = RelationshipType.MANY_TO_MANY editableColumn.relationshipType = RelationshipType.MANY_TO_MANY
} else if (editableColumn.type === FieldType.FORMULA) { } else if (editableColumn.type === FieldType.FORMULA) {
editableColumn.formulaType = "dynamic" editableColumn.formulaType = "dynamic"
editableColumn.responseType = field.responseType || FIELDS.STRING.type editableColumn.responseType = field?.responseType || FIELDS.STRING.type
} }
} }

View File

@ -40,16 +40,19 @@
indentMore, indentMore,
indentLess, indentLess,
} from "@codemirror/commands" } from "@codemirror/commands"
import { setDiagnostics } from "@codemirror/lint"
import { Compartment, EditorState } from "@codemirror/state" import { Compartment, EditorState } from "@codemirror/state"
import type { Extension } from "@codemirror/state"
import { javascript } from "@codemirror/lang-javascript" import { javascript } from "@codemirror/lang-javascript"
import { EditorModes } from "./" import { EditorModes } from "./"
import { themeStore } from "@/stores/portal" import { themeStore } from "@/stores/portal"
import type { EditorMode } from "@budibase/types" import type { EditorMode } from "@budibase/types"
import type { BindingCompletion } from "@/types" import type { BindingCompletion, CodeValidator } from "@/types"
import { validateHbsTemplate } from "./validator/hbs"
export let label: string | undefined = undefined export let label: string | undefined = undefined
// TODO: work out what best type fits this
export let completions: BindingCompletion[] = [] export let completions: BindingCompletion[] = []
export let validations: CodeValidator | null = null
export let mode: EditorMode = EditorModes.Handlebars export let mode: EditorMode = EditorModes.Handlebars
export let value: string | null = "" export let value: string | null = ""
export let placeholder: string | null = null export let placeholder: string | null = null
@ -248,7 +251,7 @@
// None of this is reactive, but it never has been, so we just assume most // None of this is reactive, but it never has been, so we just assume most
// config flags aren't changed at runtime // config flags aren't changed at runtime
// TODO: work out type for base // TODO: work out type for base
const buildExtensions = (base: any[]) => { const buildExtensions = (base: Extension[]) => {
let complete = [...base] let complete = [...base]
if (autocompleteEnabled) { if (autocompleteEnabled) {
@ -340,6 +343,24 @@
return complete return complete
} }
function validate(
value: string | null,
editor: EditorView | undefined,
mode: EditorMode,
validations: CodeValidator | null
) {
if (!value || !validations || !editor) {
return
}
if (mode === EditorModes.Handlebars) {
const diagnostics = validateHbsTemplate(value, validations)
editor.dispatch(setDiagnostics(editor.state, diagnostics))
}
}
$: validate(value, editor, mode, validations)
const initEditor = () => { const initEditor = () => {
const baseExtensions = buildBaseExtensions() const baseExtensions = buildBaseExtensions()
@ -366,7 +387,6 @@
<Label size="S">{label}</Label> <Label size="S">{label}</Label>
</div> </div>
{/if} {/if}
<div class={`code-editor ${mode?.name || ""}`}> <div class={`code-editor ${mode?.name || ""}`}>
<div tabindex="-1" bind:this={textarea} /> <div tabindex="-1" bind:this={textarea} />
</div> </div>

View File

@ -83,6 +83,8 @@ const helpersToCompletion = (
const helper = helpers[helperName] const helper = helpers[helperName]
return { return {
label: helperName, label: helperName,
args: helper.args,
requiresBlock: helper.requiresBlock,
info: () => buildHelperInfoNode(helper), info: () => buildHelperInfoNode(helper),
type: "helper", type: "helper",
section: helperSection, section: helperSection,
@ -136,9 +138,13 @@ export const hbAutocomplete = (
baseCompletions: BindingCompletionOption[] baseCompletions: BindingCompletionOption[]
): BindingCompletion => { ): BindingCompletion => {
function coreCompletion(context: CompletionContext) { function coreCompletion(context: CompletionContext) {
let bindingStart = context.matchBefore(EditorModes.Handlebars.match) if (!baseCompletions.length) {
return null
}
let options = baseCompletions || [] const bindingStart = context.matchBefore(EditorModes.Handlebars.match)
const options = baseCompletions
if (!bindingStart) { if (!bindingStart) {
return null return null
@ -149,7 +155,7 @@ export const hbAutocomplete = (
return null return null
} }
const query = bindingStart.text.replace(match[0], "") const query = bindingStart.text.replace(match[0], "")
let filtered = bindingFilter(options, query) const filtered = bindingFilter(options, query)
return { return {
from: bindingStart.from + match[0].length, from: bindingStart.from + match[0].length,
@ -169,8 +175,12 @@ export const jsAutocomplete = (
baseCompletions: BindingCompletionOption[] baseCompletions: BindingCompletionOption[]
): BindingCompletion => { ): BindingCompletion => {
function coreCompletion(context: CompletionContext) { function coreCompletion(context: CompletionContext) {
let jsBinding = wrappedAutocompleteMatch(context) if (!baseCompletions.length) {
let options = baseCompletions || [] return null
}
const jsBinding = wrappedAutocompleteMatch(context)
const options = baseCompletions
if (jsBinding) { if (jsBinding) {
// Accommodate spaces // Accommodate spaces
@ -209,6 +219,10 @@ function setAutocomplete(
options: BindingCompletionOption[] options: BindingCompletionOption[]
): BindingCompletion { ): BindingCompletion {
return function (context: CompletionContext) { return function (context: CompletionContext) {
if (!options.length) {
return null
}
if (wrappedAutocompleteMatch(context)) { if (wrappedAutocompleteMatch(context)) {
return null return null
} }

View File

@ -0,0 +1,113 @@
/* global hbs */
import Handlebars from "handlebars"
import type { Diagnostic } from "@codemirror/lint"
import { CodeValidator } from "@/types"
function isMustacheStatement(
node: hbs.AST.Statement
): node is hbs.AST.MustacheStatement {
return node.type === "MustacheStatement"
}
function isBlockStatement(
node: hbs.AST.Statement
): node is hbs.AST.BlockStatement {
return node.type === "BlockStatement"
}
function isPathExpression(
node: hbs.AST.Statement
): node is hbs.AST.PathExpression {
return node.type === "PathExpression"
}
export function validateHbsTemplate(
text: string,
validations: CodeValidator
): Diagnostic[] {
const diagnostics: Diagnostic[] = []
try {
const ast = Handlebars.parse(text, {})
const lineOffsets: number[] = []
let offset = 0
for (const line of text.split("\n")) {
lineOffsets.push(offset)
offset += line.length + 1 // +1 for newline character
}
function traverseNodes(
nodes: hbs.AST.Statement[],
options?: {
ignoreMissing?: boolean
}
) {
const ignoreMissing = options?.ignoreMissing || false
nodes.forEach(node => {
if (isMustacheStatement(node) && isPathExpression(node.path)) {
const helperName = node.path.original
const from =
lineOffsets[node.loc.start.line - 1] + node.loc.start.column
const to = lineOffsets[node.loc.end.line - 1] + node.loc.end.column
if (!(helperName in validations)) {
if (!ignoreMissing) {
diagnostics.push({
from,
to,
severity: "warning",
message: `"${helperName}" handler does not exist.`,
})
}
return
}
const { arguments: expectedArguments = [], requiresBlock } =
validations[helperName]
if (requiresBlock && !isBlockStatement(node)) {
diagnostics.push({
from,
to,
severity: "error",
message: `Helper "${helperName}" requires a body:\n{{#${helperName} ...}} [body] {{/${helperName}}}`,
})
return
}
const providedParams = node.params
if (providedParams.length !== expectedArguments.length) {
diagnostics.push({
from,
to,
severity: "error",
message: `Helper "${helperName}" expects ${
expectedArguments.length
} parameters (${expectedArguments.join(", ")}), but got ${
providedParams.length
}.`,
})
}
}
if (isBlockStatement(node)) {
traverseNodes(node.program.body, { ignoreMissing: true })
}
})
}
traverseNodes(ast.body, { ignoreMissing: true })
} catch (e: any) {
diagnostics.push({
from: 0,
to: text.length,
severity: "error",
message: `The handlebars code is not valid:\n${e.message}`,
})
}
return diagnostics
}

View File

@ -0,0 +1,142 @@
import { validateHbsTemplate } from "../hbs"
import { CodeValidator } from "@/types"
describe("hbs validator", () => {
it("validate empty strings", () => {
const text = ""
const validators = {}
const result = validateHbsTemplate(text, validators)
expect(result).toHaveLength(0)
})
it("validate strings without hbs expressions", () => {
const text = "first line\nand another one"
const validators = {}
const result = validateHbsTemplate(text, validators)
expect(result).toHaveLength(0)
})
describe("basic expressions", () => {
const validators = {
fieldName: {},
}
it("validate valid expressions", () => {
const text = "{{ fieldName }}"
const result = validateHbsTemplate(text, validators)
expect(result).toHaveLength(0)
})
it("does not throw on missing validations", () => {
const text = "{{ anotherFieldName }}"
const result = validateHbsTemplate(text, validators)
expect(result).toHaveLength(0)
})
// Waiting for missing fields validation
it.skip("throws on untrimmed invalid expressions", () => {
const text = " {{ anotherFieldName }}"
const result = validateHbsTemplate(text, validators)
expect(result).toEqual([
{
from: 4,
message: `"anotherFieldName" handler does not exist.`,
severity: "warning",
to: 26,
},
])
})
// Waiting for missing fields validation
it.skip("throws on invalid expressions between valid lines", () => {
const text =
"literal expression\nthe value is {{ anotherFieldName }}\nanother expression"
const result = validateHbsTemplate(text, validators)
expect(result).toEqual([
{
from: 32,
message: `"anotherFieldName" handler does not exist.`,
severity: "warning",
to: 54,
},
])
})
describe("expressions with whitespaces", () => {
const validators = {
[`field name`]: {},
}
it("validates expressions with whitespaces", () => {
const text = `{{ [field name] }}`
const result = validateHbsTemplate(text, validators)
expect(result).toHaveLength(0)
})
// Waiting for missing fields validation
it.skip("throws if not wrapped between brackets", () => {
const text = `{{ field name }}`
const result = validateHbsTemplate(text, validators)
expect(result).toEqual([
{
from: 0,
message: `"field" handler does not exist.`,
severity: "warning",
to: 16,
},
])
})
})
})
describe("expressions with parameters", () => {
const validators: CodeValidator = {
helperFunction: {
arguments: ["a", "b", "c"],
},
}
it("validate valid params", () => {
const text = "{{ helperFunction 1 99 'a' }}"
const result = validateHbsTemplate(text, validators)
expect(result).toHaveLength(0)
})
it("throws on too few params", () => {
const text = "{{ helperFunction 100 }}"
const result = validateHbsTemplate(text, validators)
expect(result).toEqual([
{
from: 0,
message: `Helper "helperFunction" expects 3 parameters (a, b, c), but got 1.`,
severity: "error",
to: 24,
},
])
})
it("throws on too many params", () => {
const text = "{{ helperFunction 1 99 'a' 100 }}"
const result = validateHbsTemplate(text, validators)
expect(result).toEqual([
{
from: 0,
message: `Helper "helperFunction" expects 3 parameters (a, b, c), but got 4.`,
severity: "error",
to: 34,
},
])
})
})
})

View File

@ -42,7 +42,7 @@
JSONValue, JSONValue,
} from "@budibase/types" } from "@budibase/types"
import type { Log } from "@budibase/string-templates" import type { Log } from "@budibase/string-templates"
import type { BindingCompletion, BindingCompletionOption } from "@/types" import type { CodeValidator } from "@/types"
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -58,7 +58,7 @@
export let placeholder = null export let placeholder = null
export let showTabBar = true export let showTabBar = true
let mode: BindingMode | null let mode: BindingMode
let sidePanel: SidePanel | null let sidePanel: SidePanel | null
let initialValueJS = value?.startsWith?.("{{ js ") let initialValueJS = value?.startsWith?.("{{ js ")
let jsValue: string | null = initialValueJS ? value : null let jsValue: string | null = initialValueJS ? value : null
@ -88,13 +88,37 @@
| null | null
$: runtimeExpression = readableToRuntimeBinding(enrichedBindings, value) $: runtimeExpression = readableToRuntimeBinding(enrichedBindings, value)
$: requestEval(runtimeExpression, context, snippets) $: requestEval(runtimeExpression, context, snippets)
$: bindingCompletions = bindingsToCompletions(enrichedBindings, editorMode)
$: bindingHelpers = new BindingHelpers(getCaretPosition, insertAtPos) $: bindingHelpers = new BindingHelpers(getCaretPosition, insertAtPos)
$: hbsCompletions = getHBSCompletions(bindingCompletions)
$: jsCompletions = getJSCompletions(bindingCompletions, snippets, { $: bindingOptions = bindingsToCompletions(bindings, editorMode)
useHelpers: allowHelpers, $: helperOptions = allowHelpers ? getHelperCompletions(editorMode) : []
useSnippets, $: snippetsOptions =
}) usingJS && useSnippets && snippets?.length ? snippets : []
$: completions = !usingJS
? [hbAutocomplete([...bindingOptions, ...helperOptions])]
: [
jsAutocomplete(bindingOptions),
jsHelperAutocomplete(helperOptions),
snippetAutoComplete(snippetsOptions),
]
$: validations = {
...bindingOptions.reduce<CodeValidator>((validations, option) => {
validations[option.label] = {
arguments: [],
}
return validations
}, {}),
...helperOptions.reduce<CodeValidator>((validations, option) => {
validations[option.label] = {
arguments: option.args,
requiresBlock: option.requiresBlock,
}
return validations
}, {}),
}
$: { $: {
// Ensure a valid side panel option is always selected // Ensure a valid side panel option is always selected
if (sidePanel && !sidePanelOptions.includes(sidePanel)) { if (sidePanel && !sidePanelOptions.includes(sidePanel)) {
@ -102,38 +126,6 @@
} }
} }
const getHBSCompletions = (bindingCompletions: BindingCompletionOption[]) => {
return [
hbAutocomplete([
...bindingCompletions,
...getHelperCompletions(EditorModes.Handlebars),
]),
]
}
const getJSCompletions = (
bindingCompletions: BindingCompletionOption[],
snippets: Snippet[] | null,
config: {
useHelpers: boolean
useSnippets: boolean
}
) => {
const completions: BindingCompletion[] = []
if (bindingCompletions.length) {
completions.push(jsAutocomplete([...bindingCompletions]))
}
if (config.useHelpers) {
completions.push(
jsHelperAutocomplete([...getHelperCompletions(EditorModes.JS)])
)
}
if (config.useSnippets && snippets) {
completions.push(snippetAutoComplete(snippets))
}
return completions
}
const getModeOptions = (allowHBS: boolean, allowJS: boolean) => { const getModeOptions = (allowHBS: boolean, allowJS: boolean) => {
let options = [] let options = []
if (allowHBS) { if (allowHBS) {
@ -213,7 +205,7 @@
bindings: EnrichedBinding[], bindings: EnrichedBinding[],
context: any, context: any,
snippets: Snippet[] | null snippets: Snippet[] | null
) => { ): EnrichedBinding[] => {
// Create a single big array to enrich in one go // Create a single big array to enrich in one go
const bindingStrings = bindings.map(binding => { const bindingStrings = bindings.map(binding => {
if (binding.runtimeBinding.startsWith('trim "')) { if (binding.runtimeBinding.startsWith('trim "')) {
@ -290,7 +282,7 @@
jsValue = null jsValue = null
hbsValue = null hbsValue = null
updateValue(null) updateValue(null)
mode = targetMode mode = targetMode!
targetMode = null targetMode = null
} }
@ -365,13 +357,14 @@
{/if} {/if}
<div class="editor"> <div class="editor">
{#if mode === BindingMode.Text} {#if mode === BindingMode.Text}
{#key hbsCompletions} {#key completions}
<CodeEditor <CodeEditor
value={hbsValue} value={hbsValue}
on:change={onChangeHBSValue} on:change={onChangeHBSValue}
bind:getCaretPosition bind:getCaretPosition
bind:insertAtPos bind:insertAtPos
completions={hbsCompletions} {completions}
{validations}
autofocus={autofocusEditor} autofocus={autofocusEditor}
placeholder={placeholder || placeholder={placeholder ||
"Add bindings by typing {{ or use the menu on the right"} "Add bindings by typing {{ or use the menu on the right"}
@ -379,18 +372,18 @@
/> />
{/key} {/key}
{:else if mode === BindingMode.JavaScript} {:else if mode === BindingMode.JavaScript}
{#key jsCompletions} {#key completions}
<CodeEditor <CodeEditor
value={jsValue ? decodeJSBinding(jsValue) : jsValue} value={jsValue ? decodeJSBinding(jsValue) : jsValue}
on:change={onChangeJSValue} on:change={onChangeJSValue}
completions={jsCompletions} {completions}
mode={EditorModes.JS} mode={EditorModes.JS}
bind:getCaretPosition bind:getCaretPosition
bind:insertAtPos bind:insertAtPos
autofocus={autofocusEditor} autofocus={autofocusEditor}
placeholder={placeholder || placeholder={placeholder ||
"Add bindings by typing $ or use the menu on the right"} "Add bindings by typing $ or use the menu on the right"}
jsBindingWrapping={bindingCompletions.length > 0} jsBindingWrapping={completions.length > 0}
/> />
{/key} {/key}
{/if} {/if}

View File

@ -5,4 +5,15 @@ export type BindingCompletion = (context: CompletionContext) => {
options: Completion[] options: Completion[]
} | null } | null
export type BindingCompletionOption = Completion export interface BindingCompletionOption extends Completion {
args?: any[]
requiresBlock?: boolean
}
export type CodeValidator = Record<
string,
{
arguments?: any[]
requiresBlock?: boolean
}
>

View File

@ -14,5 +14,6 @@
"assets/*": ["assets/*"], "assets/*": ["assets/*"],
"@/*": ["src/*"] "@/*": ["src/*"]
} }
} },
"exclude": []
} }

View File

@ -1,4 +1,6 @@
export interface Helper { export interface Helper {
example: string example: string
description: string description: string
args: any[]
requiresBlock?: boolean
} }