Lightweight HTML Templating with JavaScript
In such cases, I might resort to the simplest thing that could possibly work: string stitching.
let link = `<a href="${uri}">${caption}</a>`;Of course that’s a surefire way to risk code injection: Even if we trust the
purveyor of uri and caption, it becomes that source’s responsibility to
sanitize those values.
Consequently, I’ve been carrying around this little function for escaping HTML:
// adapted from TiddlyWiki <https://tiddlywiki.com> and Python 3's `html` module
function encodeHTML(str, isAttribute) {
str = str.replaceAll("&", "&");
if(isAttribute) {
return str.replaceAll('"', """).
replaceAll("'", "'");
}
return str.replaceAll("<", "<").
replaceAll(">", ">");
}We can use that for safer stitches:
let link = `<a href="${encodeHTML(uri, true)}">${encodeHTML(caption)}</a>`;That’s better, but still makes escaping the respective author’s responsibility; such things are very easy to forget or overlook. Relying on individuals’ discipline is rarely a robust strategy.
We can employ tagged templates to ensure dynamic values – i.e. interpolated parts of our template strings – are always encoded:
let link = html`<a href=${uri}>${caption}</a>`;
function html(strings, ...values) {
let i = 0;
let res = [strings[i]];
for(let value of values) {
i++;
res.push(encodeHTML(value, true));
res.push(strings[i]);
}
return res.join("");
}That’s a lot safer and more convenient, but it doesn’t distinguish between attributes and content, resulting in some unnecessary encoding.
How about this instead:
let link = html`<a${{ href: uri }}>${caption}</a>`;
function html(strings, ...values) {
let i = 0;
let res = [strings[i]];
for(let value of values) {
i++;
res.push(typeof value === "string" ? encodeHTML(value) :
serializeAttributes(value));
res.push(strings[i]);
}
return res.join("");
}
function serializeAttributes(attribs) {
let res = Object.entries(attribs).reduce((memo, [name, value]) => {
return value ? memo.concat(`${name}="${encodeHTML(value, true)}"`) : memo;
}, []);
return res.length === 0 ? "" : [""].concat(res).join(" ");
}Here we use the respective value’s type to distinguish content (strings) from attributes (objects) – which also makes for declarative authoring syntax.
Caveat: This implementation doesn’t encode attribute names, though I’m not sure whether that’d ever be a sensible thing to do.
We also don’t support consciously injecting raw HTML. Let’s remedy that:
let RAW = Symbol("raw HTML");
let link = html`<a${{ href: uri }}>${{
[RAW]: "my <em>other</em> website"
}}</a>`;
function html(strings, ...values) {
let i = 0;
let res = [strings[i]];
for(let value of values) {
i++;
if(typeof value === "string") {
res.push(encodeHTML(value));
} else if(value) {
res.push(value[RAW] || serializeAttributes(value));
}
res.push(strings[i]);
}
return res.join("");
}Note that we snuck in a convenience feature: If a value is
falsy, nothing will
be emitted there – which allows for shorthand expressions like
${condition && value}. In fact, serializeAttributes already ignores falsy
attributes.
One could even imagine an asynchronous version which supports promises; that would be particularly handy for streaming HTML. A colleague created Staggard for that exact purpose.
Here’s the final implementation (augmented with static types, just in case).
let RAW = Symbol("raw HTML");
/**
* @param {string} str
* @returns {TrustedContent}
*/
export let trustedHTML = str => ({
[RAW]: str,
toString: () => str,
});
/**
* @param {TemplateStringsArray} strings
* @param {...(HTMLContent | Iterable<HTMLContent>)} values
* @returns {TrustedContent}
*/
export function html(strings, ...values) {
let i = 0;
let res = [strings[i]];
for(let value of values) {
i++;
if(typeof value === "number") {
value = value.toString();
}
if(typeof value === "string") {
res.push(encodeHTML(value));
} else if(value === false || value === null || value === undefined) {
// no-op
} else if(RAW in value) {
res.push(/** @type {TrustedContent} */ (value)[RAW]);
} else if(/** @type {Iterable<HTMLContent>} */ (value)[Symbol.iterator]) {
for(let entry of /** @type {Iterable<HTMLContent>} */ (value)) {
res.push(html`${entry}`[RAW]);
}
} else {
res.push(serializeAttributes(/** @type {Attributes} */ (value)));
}
res.push(strings[i]);
}
return trustedHTML(res.join("").trim());
}
/** @param {Attributes} attribs */
function serializeAttributes(attribs) {
let res = Object.entries(attribs).reduce((memo, [name, value]) => {
if(typeof value === "number") {
value = value.toString();
}
switch(value) {
case false:
case null:
case undefined:
return memo;
case true:
value = "";
break;
}
return memo.concat(`${name}="${encodeHTML(value, true)}"`);
}, /** @type {string[]} */ ([]));
return res.length === 0 ? "" : [""].concat(res).join(" ");
}
/** @param {string} str */
function encodeHTML(str, isAttribute = false) {
str = str.replaceAll("&", "&");
if(isAttribute) {
return str.replaceAll('"', """).
replaceAll("'", "'");
}
return str.replaceAll("<", "<").
replaceAll(">", ">");
}
/**
* @typedef {ScalarContent | false | TrustedContent | Attributes} HTMLContent
* @typedef {Record<string, ScalarContent | boolean>} Attributes
* @typedef {Record<typeof RAW, string> & { toString: () => string }} TrustedContent
* @typedef {string | number | null | undefined} ScalarContent
*/