mirror of
https://github.com/h-sphere/sql-seal.git
synced 2026-07-22 10:10:28 +00:00
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:
parent
bf24096d75
commit
496ae44e89
5 changed files with 244 additions and 31 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue