Skip to content

feat: Add suport for custom tag types#364

Open
Tikolu wants to merge 1 commit intobgub:mainfrom
Tikolu:main
Open

feat: Add suport for custom tag types#364
Tikolu wants to merge 1 commit intobgub:mainfrom
Tikolu:main

Conversation

@Tikolu
Copy link
Copy Markdown

@Tikolu Tikolu commented Feb 27, 2026

Added very basic support for custom tag types.

Example - a "comment" tag <%# ... %> which does nothing, and a localisation tag <%* ... %> which looks up strings in a dictionary.

const translations = {
	en: {greeting: "Hello!"},
	pl: {greeting: "Cześć!"}
}

const eta = new Eta({
	customTags: {
		"#": () => "",
		"*": (key, data) => translations[data.lang][key],
	},
	views: "views"
})

console.log(eta.render("index.html", {
	lang: "en"
}))

And then used in HTML:

<html>
	<%# this is a comment %>
	<p><%* greeting %></p>
</html>

Outputs:

<html>
	<p>Hello!</p>
</html>

@Tikolu Tikolu changed the title Add suport for custom tag types feat: Add suport for custom tag types Feb 27, 2026
Copy link
Copy Markdown
Owner

@bgub bgub left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the contribution! Custom tag types are a nice extensibility feature. However, there are a few issues that need to be addressed before this can be merged:

Critical

  • Code injection vulnerability: The content from templates is interpolated directly into a single-quoted string in the generated function code (compile-string.ts:128). A template like <%* foo'; malicious(); ' %> would allow arbitrary code execution. The content needs to be escaped or passed by reference rather than inlined.
  • Unused variable: customTag is assigned but never used on line 127.

Important

  • Type safety regression: Removing TagType and widening t to string loses type safety for all consumers. Consider using TagType = "r" | "e" | "i" | "" | (string & {}) instead.
  • No prefix collision validation: Custom tag prefixes could collide with built-in prefixes ("", "=", "~") or trim indicators ("-", "_"), causing silent bugs.
  • Breaking change: customTags is a required property on EtaConfig, which breaks anyone implementing the interface. Consider making it optional.

Minor

  • Code style inconsistencies (missing spaces after if/else if, missing semicolons).
  • No tests for the new feature.
  • Typo in PR title: "suport" → "support".

Generated by Claude Code

Comment thread src/compile-string.ts
returnStr += content + "\n";

} else if (type in config.customTags) {
const customTag = config.customTags[type];
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security: Code injection via template content

The content variable is interpolated directly into a single-quoted string literal without any escaping:

returnStr += `__eta.res += this.config.customTags['${type}']('${content}', ${config.varName});\n`;

If a template contains something like <%* foo'; console.log('pwned'); ' %>, the single quote in content will break out of the string and allow arbitrary code execution in the compiled template function.

content needs to be properly escaped (at minimum, single quotes and backslashes), or passed through a mechanism that doesn't involve string interpolation into generated code. Consider passing content as a variable reference rather than inlining it as a string literal, similar to how the built-in tag types handle their content.


Generated by Claude Code

Comment thread src/compile-string.ts
// execute
returnStr += content + "\n";

} else if (type in config.customTags) {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

customTag is assigned but never used. This line can be removed.


Generated by Claude Code

Comment thread src/compile-string.ts
returnStr += content + "\n";

} else if (type in config.customTags) {
const customTag = config.customTags[type];
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also worth noting: there's no validation that type (the custom tag key) doesn't contain characters that would break out of the bracket notation string (e.g., a key containing '). While users control the keys via customTags config, it's still worth sanitizing or validating tag prefix characters.


Generated by Claude Code

Comment thread src/parse.ts

export type TagType = "r" | "e" | "i" | "";

export interface TemplateObject {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing the TagType union and widening t to string is a type safety regression that affects all consumers of TemplateObject (including the plugin API's processAST).

Consider keeping the named type and extending it instead:

export type TagType = "r" | "e" | "i" | "" | (string & {});

This preserves autocomplete for the built-in types while still allowing arbitrary custom tag strings.


Generated by Claude Code

Comment thread src/parse.ts
@@ -40,6 +38,8 @@ export function parse(this: Eta, str: string): Array<AstObject> {
let lastIndex = 0;
const parseOptions = config.parse;
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no validation that custom tag prefixes don't collide with the built-in prefixes ("", "=", "~") or with the trim indicators ("-", "_"). A collision would cause silent, confusing bugs. Consider adding a check here that warns or throws if a custom prefix conflicts with built-in ones.


Generated by Claude Code

Comment thread src/parse.ts
Comment on lines +142 to 148
let currentType = "";
if(prefix === config.parse.exec) currentType = "e";
else if(prefix === config.parse.interpolate) currentType = "i";
else if(prefix === config.parse.raw) currentType = "r";
// custom tags
else if(customTagPrefixes.includes(prefix)) currentType = prefix;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: missing spaces after if/else if — the rest of the codebase uses if ( with a space. Also missing semicolon on line 39.

Suggested change
let currentType = "";
if(prefix === config.parse.exec) currentType = "e";
else if(prefix === config.parse.interpolate) currentType = "i";
else if(prefix === config.parse.raw) currentType = "r";
// custom tags
else if(customTagPrefixes.includes(prefix)) currentType = prefix;
let currentType = "";
if (prefix === config.parse.exec) currentType = "e";
else if (prefix === config.parse.interpolate) currentType = "i";
else if (prefix === config.parse.raw) currentType = "r";
// custom tags
else if (customTagPrefixes.includes(prefix)) currentType = prefix;

Generated by Claude Code

Comment thread src/config.ts
/** 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. */
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Making customTags a required property on EtaConfig is a breaking change for anyone implementing or extending the interface directly. Consider making it optional (customTags?:) to maintain backwards compatibility.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants