From f42835e86bd59a0aed3549dc84789f08503f01f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Victor=20Ara=C3=BAjo?= Date: Wed, 18 Feb 2026 11:11:40 -0300 Subject: [PATCH 1/2] feat: replace Handlebars with Nunjucks in TEMPLATE renderer Swap template engine from Handlebars to Nunjucks for richer template capabilities. Nunjucks provides native groupby filter, macros, set/variables, and recursive calls without SQL workarounds. Changes: - TemplateRenderer.ts: use nunjucks.compile/render, add groupby/unique filters - nunjucksHighlighter.ts: syntax highlighting for Nunjucks tags/expressions - parser.ts: rename handlebarsTemplate -> nunjucksTemplate in grammar - package.json: swap handlebars dep for nunjucks --- package.json | 2 +- src/modules/editor/parser.ts | 2 +- .../editor/renderer/TemplateRenderer.ts | 83 ++++-- .../highlighter/handlebarsHighlighter.ts | 243 ------------------ .../highlighter/nunjucksHighlighter.ts | 182 +++++++++++++ .../grammar/highlighterOperation.ts | 6 +- 6 files changed, 244 insertions(+), 274 deletions(-) delete mode 100644 src/modules/syntaxHighlight/grammar/highlighter/handlebarsHighlighter.ts create mode 100644 src/modules/syntaxHighlight/grammar/highlighter/nunjucksHighlighter.ts diff --git a/package.json b/package.json index 72e2560..5bf4889 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "esbuild-plugin-polyfill-node": "^0.3.0", "esprima": "^4.0.1", "estraverse": "^5.3.0", - "handlebars": "^4.7.8", + "nunjucks": "^3.2.4", "json5": "^2.2.3", "jsonpath": "^1.1.1", "lodash": "^4.17.21", diff --git a/src/modules/editor/parser.ts b/src/modules/editor/parser.ts index 9d5f08a..2e1a978 100644 --- a/src/modules/editor/parser.ts +++ b/src/modules/editor/parser.ts @@ -51,7 +51,7 @@ export const SQLSealLangDefinition = (views: ViewDefinition[], flags: readonly F ViewExpression = ${viewsDefinitions} ExtraFlags = ${flagsDefinitions} anyObject = "{" (~selectKeyword any)* - handlebarsTemplate = (~selectKeyword any)* + nunjucksTemplate = (~selectKeyword any)* javascriptTemplate = (~selectKeyword any)* selectKeyword = caseInsensitive<"WITH"> | caseInsensitive<"SELECT"> tableKeyword = caseInsensitive<"TABLE"> diff --git a/src/modules/editor/renderer/TemplateRenderer.ts b/src/modules/editor/renderer/TemplateRenderer.ts index c3927a4..7407f6b 100644 --- a/src/modules/editor/renderer/TemplateRenderer.ts +++ b/src/modules/editor/renderer/TemplateRenderer.ts @@ -1,60 +1,91 @@ -// This is renderer for a very basic List view. import { App } from "obsidian"; import { RendererConfig, RendererContext } from "./rendererRegistry"; import { displayError } from "../../../utils/ui"; -import Handlebars from "handlebars"; +import nunjucks from "nunjucks"; import { ViewDefinition } from "../parser"; import { ParseResults } from "../../syntaxHighlight/cellParser/parseResults"; +const env = new nunjucks.Environment(null, { autoescape: false }); + +// Register custom filters +env.addFilter("groupby", (arr: any[], key: string) => { + const groups: Record = {}; + for (const item of arr) { + const groupKey = String(item[key] ?? ""); + groups[groupKey] ??= []; + groups[groupKey].push(item); + } + return Object.entries(groups).map(([k, items]) => ({ + grouper: k, + list: items, + })); +}); + +env.addFilter("unique", (arr: any[], key?: string) => { + if (!key) return [...new Set(arr)]; + const seen = new Set(); + return arr.filter((item) => { + const val = String(item[key] ?? ""); + if (seen.has(val)) return false; + seen.add(val); + return true; + }); +}); + interface TemplateRendererConfig { - template: HandlebarsTemplateDelegate + template: nunjucks.Template; } export class TemplateRenderer implements RendererConfig { - constructor(private readonly app: App) { - } + constructor(private readonly app: App) {} get rendererKey() { - return 'template' + return "template"; } get viewDefinition(): ViewDefinition { return { name: this.rendererKey, - argument: 'handlebarsTemplate?', - singleLine: false - } + argument: "nunjucksTemplate?", + singleLine: false, + }; } validateConfig(config: string): TemplateRendererConfig { if (!config) { return { - template: Handlebars.compile('No template Provided') - } + template: nunjucks.compile("No template provided", env), + }; } return { - template: Handlebars.compile(config) - } + template: nunjucks.compile(config, env), + }; } - render(config: TemplateRendererConfig, el: HTMLElement, { cellParser }: RendererContext) { + render( + config: TemplateRendererConfig, + el: HTMLElement, + { cellParser }: RendererContext, + ) { return { render: ({ columns, data, frontmatter }: any) => { - el.empty() - - const parser = new ParseResults(cellParser!, (el) => new Handlebars.SafeString(el.outerHTML)) + el.empty(); + + const parser = new ParseResults( + cellParser!, + (el) => new nunjucks.runtime.SafeString(el.outerHTML), + ); - // Seems to be the only way to render handlebars into DOM. Don't like it but what can we do. - el.innerHTML = config.template({ + el.innerHTML = config.template.render({ data: parser.parse(data, columns), columns, - properties: frontmatter - }) - parser.initialise(el) + properties: frontmatter, + }); + parser.initialise(el); }, error: (error: string) => { - displayError(el, error) - } - } + displayError(el, error); + }, + }; } -} \ No newline at end of file +} diff --git a/src/modules/syntaxHighlight/grammar/highlighter/handlebarsHighlighter.ts b/src/modules/syntaxHighlight/grammar/highlighter/handlebarsHighlighter.ts deleted file mode 100644 index 11cabb4..0000000 --- a/src/modules/syntaxHighlight/grammar/highlighter/handlebarsHighlighter.ts +++ /dev/null @@ -1,243 +0,0 @@ -interface Decorator { - type: 'identifier' | 'literal' | 'parameter' | 'comment' | 'keyword' | 'function' | 'error' | 'template-keyword'; - start: number; - end: number; - } - - /** - * Highlights Handlebars syntax in a template string using regex patterns - * @param template - The Handlebars template to highlight - * @returns Array of decorator objects for syntax highlighting - */ - function highlightHandlebars(template: string): Decorator[] { - const decorators: Decorator[] = []; - - // Define Handlebars keywords - const keywords = ['if', 'else', 'unless', 'each', 'with', 'as', 'in', 'let', 'log', 'lookup']; - - // Helper function to add a decorator - function addDecorator(type: Decorator['type'], start: number, end: number): void { - // Skip if out of bounds - if (start < 0 || end > template.length || start >= end) return; - - decorators.push({ type, start, end }); - } - - // 1. Find all block comments: {{!-- comment --}} - const blockCommentRegex = /{{!--([\s\S]*?)--}}/g; - let match: RegExpExecArray | null; - - while ((match = blockCommentRegex.exec(template)) !== null) { - addDecorator('comment', match.index, match.index + match[0].length); - } - - // 2. Find all inline comments: {{! comment }} - const inlineCommentRegex = /{{!((?!--)[^}])*}}/g; - - while ((match = inlineCommentRegex.exec(template)) !== null) { - addDecorator('comment', match.index, match.index + match[0].length); - } - - // 3. Find all triple mustaches: {{{unescaped}}} - const tripleRegex = /{{{([^}]+)}}}/g; - - while ((match = tripleRegex.exec(template)) !== null) { - // Highlight the opening and closing braces as keywords - addDecorator('template-keyword', match.index, match.index + 3); - addDecorator('template-keyword', match.index + match[0].length - 3, match.index + match[0].length); - - // Highlight the content as an identifier - const contentStart = match.index + 3; - const contentEnd = match.index + match[0].length - 3; - addDecorator('identifier', contentStart, contentEnd); - } - - // 4. Find all block expressions: {{#helper}}...{{/helper}} - const blockStartRegex = /{{#([^}]+)}}/g; - - while ((match = blockStartRegex.exec(template)) !== null) { - // Highlight the opening braces and # as keywords - addDecorator('template-keyword', match.index, match.index + 2); // {{ - addDecorator('template-keyword', match.index + 2, match.index + 3); // # - addDecorator('template-keyword', match.index + match[0].length - 2, match.index + match[0].length); // }} - - // Process the helper content - const helperContent = match[1].trim(); - const helperStart = match.index + 3; - - // Identify helper name - const helperNameMatch = /^([^\s]+)/.exec(helperContent); - if (helperNameMatch) { - const helperName = helperNameMatch[1]; - const helperNameEnd = helperStart + helperName.length; - - // Check if it's a keyword or function - if (keywords.includes(helperName)) { - addDecorator('template-keyword', helperStart, helperNameEnd); - } else { - addDecorator('function', helperStart, helperNameEnd); - } - - // Process params - this is simplified - const paramsStr = helperContent.substring(helperName.length).trim(); - if (paramsStr.length > 0) { - const paramStart = helperNameEnd + (helperContent.substring(helperName.length).length - paramsStr.length); - processParams(paramsStr, paramStart, decorators); - } - } - } - - // 5. Find all closing block expressions: {{/helper}} - const blockEndRegex = /{{\/([^}]+)}}/g; - - while ((match = blockEndRegex.exec(template)) !== null) { - // Highlight the opening braces and / as keywords - addDecorator('template-keyword', match.index, match.index + 2); // {{ - addDecorator('template-keyword', match.index + 2, match.index + 3); // / - addDecorator('template-keyword', match.index + match[0].length - 2, match.index + match[0].length); // }} - - // Highlight the helper name - const helperName = match[1].trim(); - const helperStart = match.index + 3; - const helperEnd = helperStart + helperName.length; - - if (keywords.includes(helperName)) { - addDecorator('template-keyword', helperStart, helperEnd); - } else { - addDecorator('function', helperStart, helperEnd); - } - } - - // 6. Find all partials: {{> partial}} - const partialRegex = /{{>([^}]+)}}/g; - - while ((match = partialRegex.exec(template)) !== null) { - // Highlight the opening braces and > as keywords - addDecorator('template-keyword', match.index, match.index + 2); // {{ - addDecorator('template-keyword', match.index + 2, match.index + 3); // > - addDecorator('template-keyword', match.index + match[0].length - 2, match.index + match[0].length); // }} - - // Highlight the partial name - const partialContent = match[1].trim(); - const partialStart = match.index + 3; - - // Identify partial name - const partialNameMatch = /^([^\s]+)/.exec(partialContent); - if (partialNameMatch) { - const partialName = partialNameMatch[1]; - const partialNameEnd = partialStart + partialName.length; - - addDecorator('identifier', partialStart, partialNameEnd); - - // Process params - this is simplified - const paramsStr = partialContent.substring(partialName.length).trim(); - if (paramsStr.length > 0) { - const paramStart = partialNameEnd + (partialContent.substring(partialName.length).length - paramsStr.length); - processParams(paramsStr, paramStart, decorators); - } - } - } - - // 7. Find all regular mustaches: {{expression}} - const regularRegex = /{{([^#/!>]([^}]+))}}/g; - - while ((match = regularRegex.exec(template)) !== null) { - // Highlight the opening and closing braces as keywords - addDecorator('template-keyword', match.index, match.index + 2); - addDecorator('template-keyword', match.index + match[0].length - 2, match.index + match[0].length); - - // Process the content - const expressionContent = match[1].trim(); - const expressionStart = match.index + 2; - - // Simple expression like {{variable}} - if (!/\s/.test(expressionContent)) { - addDecorator('identifier', expressionStart, expressionStart + expressionContent.length); - } else { - // Handle helpers with params - const helperMatch = /^([^\s]+)/.exec(expressionContent); - if (helperMatch) { - const helperName = helperMatch[1]; - const helperNameEnd = expressionStart + helperName.length; - - if (keywords.includes(helperName)) { - addDecorator('template-keyword', expressionStart, helperNameEnd); - } else { - addDecorator('identifier', expressionStart, helperNameEnd); - } - - // Process params - const paramsStr = expressionContent.substring(helperName.length).trim(); - if (paramsStr.length > 0) { - const paramStart = helperNameEnd + (expressionContent.substring(helperName.length).length - paramsStr.length); - processParams(paramsStr, paramStart, decorators); - } - } - } - } - - // Sort decorators by start position - decorators.sort((a, b) => a.start - b.start); - - return decorators; - } - - /** - * Process parameters in an expression - */ - function processParams(paramsStr: string, startPos: number, decorators: Decorator[]): void { - // Helper function to add a decorator - function addDecorator(type: Decorator['type'], start: number, end: number): void { - decorators.push({ type, start, end }); - } - - // Find string literals - const stringRegex = /"([^"\\]*(\\.[^"\\]*)*)"|'([^'\\]*(\\.[^'\\]*)*)'/g; - let match: RegExpExecArray | null; - let lastPos = 0; - - // First pass: Handle string literals - while ((match = stringRegex.exec(paramsStr)) !== null) { - const literalStart = startPos + match.index; - const literalEnd = literalStart + match[0].length; - - addDecorator('literal', literalStart, literalEnd); - lastPos = match.index + match[0].length; - } - - // Second pass: Handle parameters - const paramRegex = /\s+([^\s"'=]+)=?/g; - - while ((match = paramRegex.exec(paramsStr)) !== null) { - const paramStart = startPos + match.index + match[0].indexOf(match[1]); - const paramEnd = paramStart + match[1].length; - - // Don't highlight if this is inside a previously detected string literal - const isInLiteral = decorators.some(d => - d.type === 'literal' && paramStart >= d.start && paramEnd <= d.end - ); - - if (!isInLiteral) { - addDecorator('parameter', paramStart, paramEnd); - } - } - - // Find non-literal parameters (simplified) - const simpleParamRegex = /([^\s"'=]+)/g; - - while ((match = simpleParamRegex.exec(paramsStr)) !== null) { - const paramStart = startPos + match.index; - const paramEnd = paramStart + match[0].length; - - // Don't highlight if this is inside a previously detected string literal or parameter - const isAlreadyHighlighted = decorators.some(d => - paramStart >= d.start && paramEnd <= d.end - ); - - if (!isAlreadyHighlighted) { - addDecorator('parameter', paramStart, paramEnd); - } - } - } - - export default highlightHandlebars; \ No newline at end of file diff --git a/src/modules/syntaxHighlight/grammar/highlighter/nunjucksHighlighter.ts b/src/modules/syntaxHighlight/grammar/highlighter/nunjucksHighlighter.ts new file mode 100644 index 0000000..ad46d5a --- /dev/null +++ b/src/modules/syntaxHighlight/grammar/highlighter/nunjucksHighlighter.ts @@ -0,0 +1,182 @@ +interface Decorator { + type: + | "identifier" + | "literal" + | "parameter" + | "comment" + | "keyword" + | "function" + | "error" + | "template-keyword"; + start: number; + end: number; +} + +/** + * Highlights Nunjucks syntax in a template string using regex patterns. + * Supports: {{ expressions }}, {% tags %}, {# comments #}, and | filters. + */ +function highlightNunjucks(template: string): Decorator[] { + const decorators: Decorator[] = []; + let match: RegExpExecArray | null; + + const keywords = [ + "if", + "elif", + "else", + "endif", + "for", + "endfor", + "in", + "block", + "endblock", + "extends", + "include", + "import", + "from", + "macro", + "endmacro", + "call", + "endcall", + "set", + "endset", + "filter", + "endfilter", + "raw", + "endraw", + "as", + "with", + "not", + "and", + "or", + "is", + "true", + "false", + "none", + "null", + ]; + + function add( + type: Decorator["type"], + start: number, + end: number, + ): void { + if (start >= 0 && end <= template.length && start < end) { + decorators.push({ type, start, end }); + } + } + + // 1. Comments: {# ... #} + const commentRegex = /\{#[\s\S]*?#\}/g; + while ((match = commentRegex.exec(template)) !== null) { + add("comment", match.index, match.index + match[0].length); + } + + // 2. Block tags: {% ... %} + const blockTagRegex = /\{%[-~]?\s*([\s\S]*?)\s*[-~]?%\}/g; + while ((match = blockTagRegex.exec(template)) !== null) { + const fullStart = match.index; + const fullEnd = fullStart + match[0].length; + + // Delimiters + add("template-keyword", fullStart, fullStart + 2); + add("template-keyword", fullEnd - 2, fullEnd); + + // Tag content + const content = match[1]; + const contentStart = + fullStart + match[0].indexOf(content); + + // First word is the tag keyword + const tagMatch = /^([^\s]+)/.exec(content); + if (tagMatch) { + const tagName = tagMatch[1]; + const tagEnd = contentStart + tagName.length; + + if (keywords.includes(tagName)) { + add("template-keyword", contentStart, tagEnd); + } else { + add("function", contentStart, tagEnd); + } + + // Process remaining content + const rest = content.substring(tagName.length).trim(); + if (rest.length > 0) { + const restStart = + contentStart + + content.indexOf(rest, tagName.length); + processExpression(rest, restStart); + } + } + } + + // 3. Expression tags: {{ ... }} + const exprRegex = /\{\{[-~]?\s*([\s\S]*?)\s*[-~]?\}\}/g; + while ((match = exprRegex.exec(template)) !== null) { + const fullStart = match.index; + const fullEnd = fullStart + match[0].length; + + // Delimiters + add("template-keyword", fullStart, fullStart + 2); + add("template-keyword", fullEnd - 2, fullEnd); + + // Expression content + const content = match[1]; + const contentStart = + fullStart + match[0].indexOf(content); + processExpression(content, contentStart); + } + + function processExpression(expr: string, offset: number): void { + // String literals + const stringRegex = + /"([^"\\]*(\\.[^"\\]*)*)"|'([^'\\]*(\\.[^'\\]*)*)'/g; + let m: RegExpExecArray | null; + while ((m = stringRegex.exec(expr)) !== null) { + add("literal", offset + m.index, offset + m.index + m[0].length); + } + + // Number literals + const numRegex = /\b(\d+(?:\.\d+)?)\b/g; + while ((m = numRegex.exec(expr)) !== null) { + add("literal", offset + m.index, offset + m.index + m[0].length); + } + + // Filter pipe operator and filter names + const filterRegex = /\|\s*(\w+)/g; + while ((m = filterRegex.exec(expr)) !== null) { + // Highlight the pipe + add("template-keyword", offset + m.index, offset + m.index + 1); + // Highlight the filter name + const filterStart = offset + m.index + m[0].indexOf(m[1]); + add("function", filterStart, filterStart + m[1].length); + } + + // Keywords within expressions + const wordRegex = /\b(\w+)\b/g; + while ((m = wordRegex.exec(expr)) !== null) { + const word = m[1]; + const wordStart = offset + m.index; + const wordEnd = wordStart + word.length; + + // Skip if already decorated + const alreadyDecorated = decorators.some( + (d) => wordStart >= d.start && wordEnd <= d.end, + ); + if (alreadyDecorated) continue; + + if (keywords.includes(word)) { + add("template-keyword", wordStart, wordEnd); + } else if (/^\d/.test(word)) { + // Skip numbers (already handled) + } else { + add("identifier", wordStart, wordEnd); + } + } + } + + decorators.sort((a, b) => a.start - b.start); + return decorators; +} + +export default highlightNunjucks; diff --git a/src/modules/syntaxHighlight/grammar/highlighterOperation.ts b/src/modules/syntaxHighlight/grammar/highlighterOperation.ts index da22a1c..7a0445c 100644 --- a/src/modules/syntaxHighlight/grammar/highlighterOperation.ts +++ b/src/modules/syntaxHighlight/grammar/highlighterOperation.ts @@ -1,6 +1,6 @@ import * as ohm from 'ohm-js'; import { cstVisitor, Literal, parse } from 'sql-parser-cst'; -import highlightHandlebars from './highlighter/handlebarsHighlighter'; +import highlightNunjucks from './highlighter/nunjucksHighlighter'; import { highlightJavaScript } from './highlighter/jsHighlighter'; const nodes = new Map([ @@ -91,10 +91,10 @@ export const highlighterOperation = (grammar: ohm.Grammar) => { end: node.source.endIdx }] }, - handlebarsTemplate(_node) { + nunjucksTemplate(_node) { try { const template = this.source.contents - const sections = highlightHandlebars(template) + const sections = highlightNunjucks(template) const offset = this.source.startIdx return sections.map(s => ({ ...s, From e488c22278573c55d13c8c9a40736585fe7b7091 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Victor=20Ara=C3=BAjo?= Date: Tue, 24 Feb 2026 02:09:41 -0300 Subject: [PATCH 2/2] chore: add changeset for Nunjucks migration --- .changeset/nunjucks-template-renderer.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/nunjucks-template-renderer.md diff --git a/.changeset/nunjucks-template-renderer.md b/.changeset/nunjucks-template-renderer.md new file mode 100644 index 0000000..0929e6e --- /dev/null +++ b/.changeset/nunjucks-template-renderer.md @@ -0,0 +1,12 @@ +--- +"sqlseal": minor +--- + +Replace Handlebars with Nunjucks in TEMPLATE renderer + +Swap the template engine from Handlebars to Nunjucks for richer template capabilities. +Nunjucks provides native groupby/unique filters, macros, template inheritance, +set/variables, and include/import directives without SQL workarounds. + +Breaking change: existing templates using Handlebars syntax ({{#each}}, {{#if}}, {{#unless}}) +must be updated to Nunjucks equivalents ({% for %}, {% if %}, {% set %}).