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 %}). diff --git a/.changeset/vault-template-loader.md b/.changeset/vault-template-loader.md new file mode 100644 index 0000000..23477e5 --- /dev/null +++ b/.changeset/vault-template-loader.md @@ -0,0 +1,11 @@ +--- +"sqlseal": minor +--- + +Add VaultLoader for reusable Nunjucks templates in TEMPLATE renderer + +The TEMPLATE renderer now supports `{% include %}` and `{% from ... import %}` directives +to load `.njk` template files from the Obsidian vault. This enables users to define reusable +templates and macros once and reference them across multiple code blocks. + +Templates are cached at startup and kept in sync via vault file watchers. diff --git a/jest.config.js b/jest.config.js index 6bef2ba..e2f6491 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,7 +2,10 @@ module.exports = { testEnvironment: "node", transform: { - "^.+.tsx?$": ["ts-jest",{}], - "^.+.ts?$": ["ts-jest",{}], + "^.+.tsx?$": ["ts-jest", { tsconfig: { esModuleInterop: true } }], + "^.+.ts?$": ["ts-jest", { tsconfig: { esModuleInterop: true } }], + }, + moduleNameMapper: { + "^obsidian$": "/src/__mocks__/obsidian.ts", }, }; \ No newline at end of file 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/__mocks__/obsidian.ts b/src/__mocks__/obsidian.ts new file mode 100644 index 0000000..220aabe --- /dev/null +++ b/src/__mocks__/obsidian.ts @@ -0,0 +1,8 @@ +export class App {} +export class Plugin {} +export class TAbstractFile { + path = ""; +} +export class TFile extends TAbstractFile { + extension = ""; +} diff --git a/src/modules/editor/init.ts b/src/modules/editor/init.ts index 2ecb869..2ba14d1 100644 --- a/src/modules/editor/init.ts +++ b/src/modules/editor/init.ts @@ -7,6 +7,7 @@ import { GridRenderer } from "./renderer/GridRenderer"; import { ListRenderer } from "./renderer/ListRenderer"; import { TemplateRenderer } from "./renderer/TemplateRenderer"; import { MarkdownRenderer } from "./renderer/MarkdownRenderer"; +import { VaultLoader } from "./renderer/VaultLoader"; import { Settings } from "../settings/Settings"; import { SqlSealInlineHandler } from "./codeblockHandler/inline/InlineCodeHandler"; import { SqlSealCodeblockHandler } from "./codeblockHandler/SqlSealCodeblockHandler"; @@ -56,6 +57,8 @@ export const editorInit = ( ); }; + const vaultLoader = new VaultLoader(app, plugin); + const registerViews = () => { rendererRegistry.register( "sql-seal-internal-table", @@ -72,7 +75,7 @@ export const editorInit = ( rendererRegistry.register("sql-seal-internal-list", new ListRenderer(app)); rendererRegistry.register( "sql-seal-internal-template", - new TemplateRenderer(app), + new TemplateRenderer(app, vaultLoader), ); }; @@ -80,6 +83,8 @@ export const editorInit = ( registerViews(); app.workspace.onLayoutReady(async () => { + await vaultLoader.loadAll(); + vaultLoader.registerWatchers(); registerInlineCodeblocks(); registerBlockCodeblock(); }); 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..2ca2b40 100644 --- a/src/modules/editor/renderer/TemplateRenderer.ts +++ b/src/modules/editor/renderer/TemplateRenderer.ts @@ -1,60 +1,103 @@ -// 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"; +import { VaultLoader } from "./VaultLoader"; + +function registerFilters(env: nunjucks.Environment): void { + // 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) { + private readonly env: nunjucks.Environment; + + constructor( + private readonly app: App, + loader?: VaultLoader, + ) { + this.env = new nunjucks.Environment( + loader ?? null, + { autoescape: false }, + ); + registerFilters(this.env); } 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", this.env), + }; } return { - template: Handlebars.compile(config) - } + template: nunjucks.compile(config, this.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/editor/renderer/VaultLoader.ts b/src/modules/editor/renderer/VaultLoader.ts new file mode 100644 index 0000000..dd5c4a8 --- /dev/null +++ b/src/modules/editor/renderer/VaultLoader.ts @@ -0,0 +1,93 @@ +import { App, Plugin, TFile } from "obsidian"; + +const TEMPLATE_EXTENSION = "njk"; + +interface LoaderSource { + src: string; + path: string; + noCache: boolean; +} + +export class VaultLoader { + private _templates: Map = new Map(); + + constructor( + private readonly app: App, + private readonly plugin: Plugin, + ) {} + + getSource(name: string): LoaderSource { + let src = this._templates.get(name); + + if (src === undefined && !name.includes(".")) { + src = this._templates.get(name + "." + TEMPLATE_EXTENSION); + } + + if (src === undefined) { + throw new Error(`Template not found: ${name}`); + } + + return { src, path: name, noCache: true }; + } + + async loadAll(): Promise { + const files = this.app.vault + .getFiles() + .filter((f) => f.extension === TEMPLATE_EXTENSION); + + for (const file of files) { + const content = await this.app.vault.cachedRead(file); + this._templates.set(file.path, content); + } + } + + registerWatchers(): void { + this.plugin.registerEvent( + this.app.vault.on("modify", async (file) => { + if ( + file instanceof TFile && + file.extension === TEMPLATE_EXTENSION + ) { + const content = await this.app.vault.cachedRead(file); + this._templates.set(file.path, content); + } + }), + ); + + this.plugin.registerEvent( + this.app.vault.on("delete", (file) => { + if ( + file instanceof TFile && + file.extension === TEMPLATE_EXTENSION + ) { + this._templates.delete(file.path); + } + }), + ); + + this.plugin.registerEvent( + this.app.vault.on("rename", async (file, oldPath) => { + if (file instanceof TFile) { + this._templates.delete(oldPath); + if (file.extension === TEMPLATE_EXTENSION) { + const content = + await this.app.vault.cachedRead(file); + this._templates.set(file.path, content); + } + } + }), + ); + + this.plugin.registerEvent( + this.app.vault.on("create", async (file) => { + if ( + file instanceof TFile && + file.extension === TEMPLATE_EXTENSION + ) { + const content = await this.app.vault.cachedRead(file); + this._templates.set(file.path, content); + } + }), + ); + } +} diff --git a/src/modules/editor/renderer/__tests__/VaultLoader.test.ts b/src/modules/editor/renderer/__tests__/VaultLoader.test.ts new file mode 100644 index 0000000..f1bdf6c --- /dev/null +++ b/src/modules/editor/renderer/__tests__/VaultLoader.test.ts @@ -0,0 +1,299 @@ +import { VaultLoader } from "../VaultLoader"; +import { TFile } from "obsidian"; +import nunjucks from "nunjucks"; + +function createMockFile(path: string, extension: string) { + return Object.assign(new TFile(), { path, extension }); +} + +function createMockApp(files: Array<{ path: string; extension: string }> = []) { + const contents: Record = {}; + const eventHandlers: Record void>> = {}; + + return { + app: { + vault: { + getFiles: () => files, + cachedRead: async (file: { path: string }) => + contents[file.path] ?? "", + on: (event: string, handler: (...args: any[]) => void) => { + eventHandlers[event] ??= []; + eventHandlers[event].push(handler); + return { event, handler }; + }, + }, + } as any, + plugin: { + registerEvent: () => {}, + } as any, + contents, + eventHandlers, + }; +} + +describe("VaultLoader", () => { + describe("getSource", () => { + it("returns cached template by exact path", async () => { + const njkFile = createMockFile("_templates/row.njk", "njk"); + const { app, plugin, contents } = createMockApp([njkFile]); + contents["_templates/row.njk"] = "{{ name }}"; + + const loader = new VaultLoader(app, plugin); + await loader.loadAll(); + + const result = loader.getSource("_templates/row.njk"); + expect(result.src).toBe("{{ name }}"); + expect(result.path).toBe("_templates/row.njk"); + expect(result.noCache).toBe(true); + }); + + it("resolves name without extension by appending .njk", async () => { + const njkFile = createMockFile("_templates/row.njk", "njk"); + const { app, plugin, contents } = createMockApp([njkFile]); + contents["_templates/row.njk"] = "{{ name }}"; + + const loader = new VaultLoader(app, plugin); + await loader.loadAll(); + + const result = loader.getSource("_templates/row"); + expect(result.src).toBe("{{ name }}"); + }); + + it("throws when template is not found", async () => { + const { app, plugin } = createMockApp([]); + const loader = new VaultLoader(app, plugin); + await loader.loadAll(); + + expect(() => loader.getSource("missing.njk")).toThrow( + "Template not found: missing.njk", + ); + }); + }); + + describe("loadAll", () => { + it("loads only .njk files from vault", async () => { + const njkFile = createMockFile("_templates/row.njk", "njk"); + const mdFile = createMockFile("notes/readme.md", "md"); + const { app, plugin, contents } = createMockApp([ + njkFile, + mdFile, + ]); + contents["_templates/row.njk"] = "template content"; + contents["notes/readme.md"] = "markdown content"; + + const loader = new VaultLoader(app, plugin); + await loader.loadAll(); + + expect(() => loader.getSource("_templates/row.njk")).not.toThrow(); + expect(() => loader.getSource("notes/readme.md")).toThrow(); + }); + + it("loads multiple templates", async () => { + const files = [ + createMockFile("_templates/row.njk", "njk"), + createMockFile("_templates/header.njk", "njk"), + ]; + const { app, plugin, contents } = createMockApp(files); + contents["_templates/row.njk"] = "row"; + contents["_templates/header.njk"] = "header"; + + const loader = new VaultLoader(app, plugin); + await loader.loadAll(); + + expect(loader.getSource("_templates/row.njk").src).toBe("row"); + expect(loader.getSource("_templates/header.njk").src).toBe( + "header", + ); + }); + }); + + describe("nunjucks integration", () => { + it("resolves {% include %} via VaultLoader", async () => { + const files = [ + createMockFile("_templates/row.njk", "njk"), + ]; + const { app, plugin, contents } = createMockApp(files); + contents["_templates/row.njk"] = "{{ name }}"; + + const loader = new VaultLoader(app, plugin); + await loader.loadAll(); + + const env = new nunjucks.Environment(loader as any, { + autoescape: false, + }); + const template = nunjucks.compile( + '{% include "_templates/row.njk" %}
', + env, + ); + const result = template.render({ name: "Alice" }); + expect(result).toBe("
Alice
"); + }); + + it("resolves {% from ... import %} macros via VaultLoader", async () => { + const files = [ + createMockFile("_templates/macros.njk", "njk"), + ]; + const { app, plugin, contents } = createMockApp(files); + contents["_templates/macros.njk"] = [ + "{% macro extLink(url, label) %}", + '{{ label }}', + "{% endmacro %}", + ].join("\n"); + + const loader = new VaultLoader(app, plugin); + await loader.loadAll(); + + const env = new nunjucks.Environment(loader as any, { + autoescape: false, + }); + const template = nunjucks.compile( + '{% from "_templates/macros.njk" import extLink %}' + + "{{ extLink('https://example.com', 'Example') }}", + env, + ); + const result = template.render({}); + expect(result).toContain('class="external-link"'); + expect(result).toContain('href="https://example.com"'); + expect(result).toContain(">Example"); + }); + + it("resolves include without .njk extension", async () => { + const files = [ + createMockFile("_templates/row.njk", "njk"), + ]; + const { app, plugin, contents } = createMockApp(files); + contents["_templates/row.njk"] = "hello"; + + const loader = new VaultLoader(app, plugin); + await loader.loadAll(); + + const env = new nunjucks.Environment(loader as any, { + autoescape: false, + }); + const template = nunjucks.compile( + '{% include "_templates/row" %}', + env, + ); + expect(template.render({})).toBe("hello"); + }); + + it("works with nested includes", async () => { + const files = [ + createMockFile("_templates/outer.njk", "njk"), + createMockFile("_templates/inner.njk", "njk"), + ]; + const { app, plugin, contents } = createMockApp(files); + contents["_templates/outer.njk"] = + '
{% include "_templates/inner.njk" %}
'; + contents["_templates/inner.njk"] = "nested"; + + const loader = new VaultLoader(app, plugin); + await loader.loadAll(); + + const env = new nunjucks.Environment(loader as any, { + autoescape: false, + }); + const template = nunjucks.compile( + '{% include "_templates/outer.njk" %}', + env, + ); + expect(template.render({})).toBe( + "
nested
", + ); + }); + }); + + describe("file watchers", () => { + it("registers all four vault events", () => { + const { app, plugin, eventHandlers } = createMockApp([]); + const loader = new VaultLoader(app, plugin); + loader.registerWatchers(); + + expect(eventHandlers["modify"]).toHaveLength(1); + expect(eventHandlers["delete"]).toHaveLength(1); + expect(eventHandlers["rename"]).toHaveLength(1); + expect(eventHandlers["create"]).toHaveLength(1); + }); + + it("updates cache on modify", async () => { + const njkFile = createMockFile("t.njk", "njk"); + const { app, plugin, contents, eventHandlers } = createMockApp([ + njkFile, + ]); + contents["t.njk"] = "v1"; + + const loader = new VaultLoader(app, plugin); + await loader.loadAll(); + loader.registerWatchers(); + + expect(loader.getSource("t.njk").src).toBe("v1"); + + contents["t.njk"] = "v2"; + await eventHandlers["modify"][0](njkFile); + + expect(loader.getSource("t.njk").src).toBe("v2"); + }); + + it("removes from cache on delete", async () => { + const njkFile = createMockFile("t.njk", "njk"); + const { app, plugin, contents, eventHandlers } = createMockApp([ + njkFile, + ]); + contents["t.njk"] = "content"; + + const loader = new VaultLoader(app, plugin); + await loader.loadAll(); + loader.registerWatchers(); + + expect(loader.getSource("t.njk").src).toBe("content"); + + await eventHandlers["delete"][0](njkFile); + + expect(() => loader.getSource("t.njk")).toThrow(); + }); + + it("updates cache on rename", async () => { + const njkFile = createMockFile("new.njk", "njk"); + const { app, plugin, contents, eventHandlers } = createMockApp([ + createMockFile("old.njk", "njk"), + ]); + contents["old.njk"] = "content"; + + const loader = new VaultLoader(app, plugin); + await loader.loadAll(); + loader.registerWatchers(); + + contents["new.njk"] = "content"; + await eventHandlers["rename"][0](njkFile, "old.njk"); + + expect(() => loader.getSource("old.njk")).toThrow(); + expect(loader.getSource("new.njk").src).toBe("content"); + }); + + it("adds to cache on create", async () => { + const { app, plugin, contents, eventHandlers } = createMockApp([]); + const loader = new VaultLoader(app, plugin); + await loader.loadAll(); + loader.registerWatchers(); + + const newFile = createMockFile("new.njk", "njk"); + contents["new.njk"] = "new content"; + await eventHandlers["create"][0](newFile); + + expect(loader.getSource("new.njk").src).toBe("new content"); + }); + + it("ignores non-njk files in watchers", async () => { + const { app, plugin, contents, eventHandlers } = createMockApp([]); + const loader = new VaultLoader(app, plugin); + await loader.loadAll(); + loader.registerWatchers(); + + const mdFile = createMockFile("note.md", "md"); + contents["note.md"] = "markdown"; + await eventHandlers["create"][0](mdFile); + + expect(() => loader.getSource("note.md")).toThrow(); + }); + }); +}); 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,