From 496ae44e89271abe590cdcfe0cdc6ab21cb98b70 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] 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/nunjucksHighlighter.ts | 182 ++++++++++++++++++ .../grammar/highlighterOperation.ts | 6 +- 5 files changed, 244 insertions(+), 31 deletions(-) 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(); - // Seems to be the only way to render handlebars into DOM. Don't like it but what can we do. - el.innerHTML = config.template({ + const parser = new ParseResults( + cellParser!, + (el) => new nunjucks.runtime.SafeString(el.outerHTML), + ); + + 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/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,