Lightweight HTML Templating with JavaScript

Sometimes I need to generate HTML without relying on external dependencies.

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("&", "&amp;");
    if(isAttribute) {
        return str.replaceAll('"', "&quot;").
            replaceAll("'", "&#x27;");
    }
    return str.replaceAll("<", "&lt;").
        replaceAll(">", "&gt;");
}

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("&", "&amp;");
    if(isAttribute) {
        return str.replaceAll('"', "&quot;").
            replaceAll("'", "&#x27;");
    }
    return str.replaceAll("<", "&lt;").
        replaceAll(">", "&gt;");
}

/**
 * @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
 */