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
This commit is contained in:
Victor Araújo 2026-02-18 11:11:40 -03:00
parent bf24096d75
commit 496ae44e89
5 changed files with 244 additions and 31 deletions

View file

@ -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",

View file

@ -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">

View file

@ -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<string, any[]> = {};
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<string>();
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);
},
};
}
}
}

View file

@ -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;

View file

@ -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,