diff --git a/src/compile-string.ts b/src/compile-string.ts index e26249e..90b1bb0 100644 --- a/src/compile-string.ts +++ b/src/compile-string.ts @@ -99,7 +99,7 @@ export function compileBody(this: Eta, buff: Array): string { // we know string exists returnStr += "__eta.res+='" + str + "';\n"; } else { - const type = currentBlock.t; // "r", "e", or "i" + const type = currentBlock.t; // "r", "e", "i" or custom tag name let content = currentBlock.val || ""; if (config.debug) returnStr += "__eta.line=" + currentBlock.lineNo + "\n"; @@ -127,6 +127,8 @@ export function compileBody(this: Eta, buff: Array): string { } else if (type === "e") { // execute returnStr += content + "\n"; + } else if (Object.hasOwn(config.customTags, type)) { + returnStr += `__eta.res+=this.config.customTags[${JSON.stringify(type)}](${JSON.stringify(content)},${config.varName});\n`; } } } diff --git a/src/config.ts b/src/config.ts index af43610..f7ba7b6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -27,6 +27,9 @@ export interface EtaConfig { /** Holds cache of resolved filepaths. Set to `false` to disable. */ cacheFilepaths: boolean; + /** Object specifying custom tags. Keys are tag prefixes, values are functions which take tag content and return a string. */ + customTags: Record string>; + /** Whether to pretty-format error messages (introduces runtime penalties) */ debug: boolean; @@ -89,6 +92,7 @@ const defaultConfig: EtaConfig = { autoTrim: [false, "nl"], cache: false, cacheFilepaths: true, + customTags: {}, debug: false, escapeFunction: XMLEscape, // default filter function (not used unless enables) just stringifies the input diff --git a/src/internal.ts b/src/internal.ts index 584608b..932ee89 100644 --- a/src/internal.ts +++ b/src/internal.ts @@ -20,6 +20,22 @@ export class Eta { } else { this.config = { ...defaultConfig }; } + + const reserved = [ + this.config.parse.exec, + this.config.parse.interpolate, + this.config.parse.raw, + "-", + "_", + ]; + + for (const prefix of Object.keys(this.config.customTags)) { + if (reserved.includes(prefix)) { + throw new EtaError( + `Custom tag prefix "${prefix}" conflicts with a built-in prefix`, + ); + } + } } config: EtaConfig; diff --git a/src/parse.ts b/src/parse.ts index bb74db7..091c22c 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -2,10 +2,8 @@ import { ParseErr } from "./err.ts"; import type { Eta } from "./internal.ts"; import { trimWS } from "./utils.ts"; -export type TagType = "r" | "e" | "i" | ""; - export interface TemplateObject { - t: TagType; + t: string; val: string; lineNo?: number; } @@ -40,6 +38,8 @@ export function parse(this: Eta, str: string): Array { let lastIndex = 0; const parseOptions = config.parse; + const customTagPrefixes = Object.keys(config.customTags); + if (config.plugins) { for (let i = 0; i < config.plugins.length; i++) { const plugin = config.plugins[i]; @@ -90,6 +90,7 @@ export function parse(this: Eta, str: string): Array { parseOptions.exec, parseOptions.interpolate, parseOptions.raw, + ...customTagPrefixes, ].reduce((accumulator, prefix) => { if (accumulator && prefix) { return accumulator + "|" + escapeRegExp(prefix); @@ -138,14 +139,16 @@ export function parse(this: Eta, str: string): Array { trimLeftOfNextStr = closeTag[2]; - const currentType: TagType = + const currentType: string = prefix === parseOptions.exec ? "e" : prefix === parseOptions.raw ? "r" : prefix === parseOptions.interpolate ? "i" - : ""; + : customTagPrefixes.includes(prefix) + ? prefix + : ""; currentObj = { t: currentType, val: content }; break; diff --git a/test/render.spec.ts b/test/render.spec.ts index b043ec1..df612b6 100644 --- a/test/render.spec.ts +++ b/test/render.spec.ts @@ -458,6 +458,69 @@ describe("block", () => { }); }); +describe("customTags", () => { + it("basic custom tag renders output", () => { + const eta = new Eta({ + customTags: { + "*": (content, data) => + (data as Record)[content.trim()], + }, + }); + + const res = eta.renderString("Hello <%* name %>!", { name: "World" }); + expect(res).toEqual("Hello World!"); + }); + + it("comment tag that outputs nothing", () => { + const eta = new Eta({ + customTags: { "#": () => "" }, + }); + + const res = eta.renderString("A<%# this is a comment %>B", {}); + expect(res).toEqual("AB"); + }); + + it("multiple custom tags in one template", () => { + const translations: Record> = { + en: { greeting: "Hello" }, + }; + + const eta = new Eta({ + customTags: { + "#": () => "", + "*": (key, data) => + translations[(data as { lang: string }).lang][key.trim()], + }, + }); + + const res = eta.renderString("<%# comment %>

<%* greeting %>

", { + lang: "en", + }); + expect(res).toEqual("

Hello

"); + }); + + it("throws on conflicting custom tag prefix", () => { + expect(() => new Eta({ customTags: { "=": () => "" } })).toThrow( + /conflicts with a built-in prefix/, + ); + expect(() => new Eta({ customTags: { "-": () => "" } })).toThrow( + /conflicts with a built-in prefix/, + ); + }); + + it("works alongside built-in tags", () => { + const eta = new Eta({ + customTags: { "*": (content) => content.trim().toUpperCase() }, + }); + + const res = eta.renderString("<%= it.a %>|<%* hello %>|<%~ it.b %>", { + a: "A", + b: "", + }); + expect(res).toEqual("A|HELLO|"); + }); +}); + describe("capture", () => { const eta = new Eta();