From 72faf3201179052c49304e584b77a19b33f6411a Mon Sep 17 00:00:00 2001 From: dtoro Date: Sat, 7 Mar 2026 15:01:00 +0100 Subject: [PATCH] variables and templates --- package-lock.json | 47 ++++++++++++++ package.json | 3 +- src/components/graph/ConfigNode.tsx | 19 +++++- src/components/graph/RenderingNode.tsx | 42 ++++++++++--- src/lib/nunjucksAutocomplete.ts | 86 ++++++++++++++++++++++++++ src/lib/plantumlLanguage.ts | 40 +++++++++++- 6 files changed, 227 insertions(+), 10 deletions(-) create mode 100644 src/lib/nunjucksAutocomplete.ts diff --git a/package-lock.json b/package-lock.json index 46ca205..eca1d98 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.577.0", + "nunjucks": "^3.2.4", "react": "18.2.0", "react-dom": "18.2.0", "reactflow": "^11.0.0", @@ -3397,6 +3398,12 @@ "d3-zoom": "^3.0.0" } }, + "node_modules/a-sync-waterfall": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/a-sync-waterfall/-/a-sync-waterfall-1.0.1.tgz", + "integrity": "sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA==", + "license": "MIT" + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -3551,6 +3558,12 @@ "node": ">=10" } }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "license": "MIT" + }, "node_modules/ast-types": { "version": "0.16.1", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", @@ -6102,6 +6115,40 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/nunjucks": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/nunjucks/-/nunjucks-3.2.4.tgz", + "integrity": "sha512-26XRV6BhkgK0VOxfbU5cQI+ICFUtMLixv1noZn1tGU38kQH5A5nmmbk/O45xdyBhD1esk47nKrY0mvQpZIhRjQ==", + "license": "BSD-2-Clause", + "dependencies": { + "a-sync-waterfall": "^1.0.0", + "asap": "^2.0.3", + "commander": "^5.1.0" + }, + "bin": { + "nunjucks-precompile": "bin/precompile" + }, + "engines": { + "node": ">= 6.9.0" + }, + "peerDependencies": { + "chokidar": "^3.3.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/nunjucks/node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", diff --git a/package.json b/package.json index 156eafd..7a4f1af 100644 --- a/package.json +++ b/package.json @@ -9,17 +9,18 @@ }, "dependencies": { "@codemirror/lang-javascript": "^6.2.2", - "@uiw/react-codemirror": "^4.25.7", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-menubar": "^1.1.16", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.6", + "@uiw/react-codemirror": "^4.25.7", "@xyflow/react": "^12.10.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.577.0", + "nunjucks": "^3.2.4", "react": "18.2.0", "react-dom": "18.2.0", "reactflow": "^11.0.0", diff --git a/src/components/graph/ConfigNode.tsx b/src/components/graph/ConfigNode.tsx index b1c9a7a..fb94239 100644 --- a/src/components/graph/ConfigNode.tsx +++ b/src/components/graph/ConfigNode.tsx @@ -1,7 +1,9 @@ import React, { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' +import { autocompletion } from '@codemirror/autocomplete' import CodeMirror from '@uiw/react-codemirror' import FlowContext from '../../lib/flowContext' import { useResizeHeight } from '../../hooks/useResizeHeight' +import { nunjucksCompletionSource } from '../../lib/nunjucksAutocomplete' import { plantumlLanguage } from '../../lib/plantumlLanguage' import { useTheme } from '../../lib/themeContext' import { @@ -116,7 +118,22 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }: [insertAt] ) - const extensions = useMemo(() => [plantumlLanguage.extension], []) + const variableIds = useMemo(() => connectedVariableNodes.map((n: any) => n.id), [connectedVariableNodes]) + const functionIds = useMemo(() => connectedFunctionNodes.map((n: any) => n.id), [connectedFunctionNodes]) + const configTitles = useMemo( + () => connectedConfigNodes.map((n: any) => n.data?.title ?? n.id), + [connectedConfigNodes], + ) + const extensions = useMemo( + () => [ + plantumlLanguage.extension, + autocompletion({ + override: [nunjucksCompletionSource(variableIds, configTitles, functionIds)], + activateOnTyping: true, + }), + ], + [variableIds, functionIds, configTitles], + ) const [editorHeight, editorContainerRef] = useResizeHeight(180) const dimensions = diff --git a/src/components/graph/RenderingNode.tsx b/src/components/graph/RenderingNode.tsx index 3009060..a9681ed 100644 --- a/src/components/graph/RenderingNode.tsx +++ b/src/components/graph/RenderingNode.tsx @@ -1,4 +1,5 @@ import { memo, useContext, useEffect, useMemo, useRef, useState } from 'react' +import nunjucks from 'nunjucks' import FlowContext from '../../lib/flowContext' import { useTheme } from '../../lib/themeContext' import { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia } from '../ui/empty' @@ -188,19 +189,46 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }: if (srcId && srcNode?.type === 'config') configIdsUsed.add(srcId) - const resolvedPlantuml = resolveIncludes(plantumlText) + const resolvedIncludes = resolveIncludes(plantumlText) const varMap: Record = {} + // Use null prototype so keys like "var", "constructor", "toString" don't collide with Object.prototype + const nunjucksContext = Object.create(null) as Record + for (const e of edges) { - if (configIdsUsed.has(e.target)) { - const src = nodes.find((n: any) => n.id === e.source) - if (src?.type === 'variable') { - const v = src.data?.value - varMap[src.id] = v === undefined || v === null ? '' : String(v) + if (!configIdsUsed.has(e.target)) continue + const src = nodes.find((n: any) => n.id === e.source) + if (src?.type === 'variable') { + const v = src.data?.value + const str = v === undefined || v === null ? '' : String(v) + varMap[src.id] = str + // Keep booleans/numbers for {% if %} etc.; Nunjucks treats "false" as truthy + nunjucksContext[src.id] = + v === undefined || v === null ? '' : typeof v === 'boolean' || typeof v === 'number' ? v : str + } else if (src?.type === 'function') { + const body = src.data?.body ?? 'return args[0];' + nunjucksContext[src.id] = (...args: unknown[]) => { + try { + const fn = new Function('args', body) + const result = fn(args) + if (result === undefined || result === null) return '' + if (typeof result === 'string' || typeof result === 'number' || typeof result === 'boolean') return result + return String(result) + } catch { + return '' + } } } } + let afterNunjucks: string + try { + const env = new nunjucks.Environment([], { autoescape: false }) + afterNunjucks = env.renderString(resolvedIncludes, nunjucksContext) + } catch (nunjucksErr: any) { + throw new Error(`Nunjucks: ${nunjucksErr?.message ?? String(nunjucksErr)}`) + } + const resolveFunctionCalls = (text: string): string => { const fnCallRegex = /\$\{([\w-]+)\s*\(([^)]*)\)\}/g return text.replace(fnCallRegex, (match, funcId: string, argsStr: string) => { @@ -224,7 +252,7 @@ export const RenderingNode = memo(function RenderingNode({ id, width, height }: const resolveVariables = (text: string): string => text.replace(/\$\{([^}]+)\}/g, (_, varId: string) => varMap[varId.trim()] ?? '') - const afterFunctions = resolveFunctionCalls(resolvedPlantuml) + const afterFunctions = resolveFunctionCalls(afterNunjucks) let resolvedWithVars = resolveVariables(afterFunctions) if (theme === 'dark') { resolvedWithVars = resolvedWithVars.replace( diff --git a/src/lib/nunjucksAutocomplete.ts b/src/lib/nunjucksAutocomplete.ts new file mode 100644 index 0000000..1bd6515 --- /dev/null +++ b/src/lib/nunjucksAutocomplete.ts @@ -0,0 +1,86 @@ +import { CompletionContext, CompletionResult } from '@codemirror/autocomplete' + +const NUNJUCKS_KEYWORDS = [ + 'if', 'endif', 'elif', 'else', 'for', 'endfor', 'in', 'and', 'or', 'not', + 'true', 'false', 'none', 'macro', 'endmacro', 'set', 'endset', 'block', 'endblock', + 'extends', 'include', 'import', 'with', 'endwith', 'filter', 'endfilter', 'raw', 'endraw', +] + +const NUNJUCKS_FILTERS = [ + 'default', 'length', 'upper', 'lower', 'title', 'trim', 'join', 'replace', + 'first', 'last', 'round', 'int', 'float', 'string', 'list', 'sort', 'groupby', + 'trim', 'escape', 'safe', 'striptags', 'capitalize', 'reverse', 'batch', 'slice', +] + +/** Detect if position is inside {{ or {% from the start of the line */ +function insideNunjucks(doc: { getLine: (n: number) => string }, lineNo: number, pos: number): boolean { + const line = doc.getLine(lineNo) + const before = line.slice(0, pos) + const openVar = before.lastIndexOf('{{') + const openTag = before.lastIndexOf('{%') + const closeVar = before.lastIndexOf('}}') + const closeTag = before.lastIndexOf('%}') + if (openVar > -1 && (closeVar === -1 || closeVar < openVar)) return true + if (openTag > -1 && (closeTag === -1 || closeTag < openTag)) return true + return false +} + +/** Get the word fragment before the cursor for matching */ +function wordBefore(doc: { getLine: (n: number) => string }, lineNo: number, pos: number): string { + const line = doc.getLine(lineNo) + let start = pos + while (start > 0 && /[\w.-]/.test(line[start - 1])) start -= 1 + return line.slice(start, pos) +} + +export function nunjucksCompletionSource( + variableIds: string[], + configTitles?: string[], + functionIds?: string[], +): (context: CompletionContext) => CompletionResult | null { + return (context: CompletionContext) => { + const { state, pos } = context + const line = state.doc.lineAt(pos) + if (!insideNunjucks(state.doc, line.number - 1, pos - line.from)) return null + + const word = wordBefore(state.doc, line.number - 1, pos - line.from) + const from = pos - word.length + + const options: { label: string; type?: string; info?: string }[] = [] + + for (const id of variableIds) { + if (!word || id.toLowerCase().startsWith(word.toLowerCase())) { + options.push({ label: id, type: 'variable', info: 'Variable' }) + } + } + for (const id of functionIds ?? []) { + if (!word || id.toLowerCase().startsWith(word.toLowerCase())) { + options.push({ label: id, type: 'function', info: 'Function (e.g. ' + id + '(var, 4))' }) + } + } + for (const kw of NUNJUCKS_KEYWORDS) { + if (!word || kw.startsWith(word.toLowerCase())) { + options.push({ label: kw, type: 'keyword', info: 'Nunjucks keyword' }) + } + } + for (const f of NUNJUCKS_FILTERS) { + if (!word || f.startsWith(word.toLowerCase())) { + options.push({ label: `${f}`, type: 'function', info: `Filter: ${f}` }) + } + } + if (configTitles?.length) { + for (const t of configTitles) { + if (!word || t.toLowerCase().startsWith(word.toLowerCase())) { + options.push({ label: t, type: 'variable', info: 'Config' }) + } + } + } + + if (options.length === 0) return null + return { + from, + options: options.slice(0, 50), + validFor: /^[\w.-]*$/, + } + } +} diff --git a/src/lib/plantumlLanguage.ts b/src/lib/plantumlLanguage.ts index b26d1bd..7c05cd6 100644 --- a/src/lib/plantumlLanguage.ts +++ b/src/lib/plantumlLanguage.ts @@ -1,9 +1,47 @@ import { StreamLanguage } from '@codemirror/language' -/** Simple PlantUML stream parser for syntax highlighting in CodeMirror */ +/** Nunjucks block comment {# ... #} */ +function tokenNunjucksComment(stream: { match: (re: RegExp) => string | null; next: () => string; eol: () => boolean }) { + if (stream.match(/^\{#/)) { + while (!stream.eol()) { + if (stream.match(/#\}/)) return 'comment' + stream.next() + } + return 'comment' + } + return null +} + +/** Nunjucks variable {{ ... }} or tag {% ... %} - tokenize the whole block */ +function tokenNunjucksBlock(stream: { match: (re: RegExp) => string | null; next: () => string; eol: () => boolean }) { + if (stream.match(/^\{\{/)) { + while (!stream.eol()) { + if (stream.match(/\}\}/)) return 'variableName.special' + stream.next() + } + return 'variableName.special' + } + if (stream.match(/^\{\%/)) { + while (!stream.eol()) { + if (stream.match(/%\}/)) return 'keyword' + stream.next() + } + return 'keyword' + } + return null +} + +/** Simple PlantUML + Nunjucks stream parser for syntax highlighting in CodeMirror */ const plantumlParser = StreamLanguage.define({ name: 'plantuml', token(stream) { + // Nunjucks {# ... #} comment + const nunjucksComment = tokenNunjucksComment(stream) + if (nunjucksComment) return nunjucksComment + // Nunjucks {{ }} and {% %} + const nunjucksBlock = tokenNunjucksBlock(stream) + if (nunjucksBlock) return nunjucksBlock + // Single-quote line comment (PlantUML) if (stream.match(/^'/)) { stream.skipToEnd()