Created
May 10, 2026 05:01
-
-
Save gordonbrander/631e868e469f83971e5eff3cda44968a to your computer and use it in GitHub Desktop.
html tagged template literals for server side
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { describe, it } from "node:test"; | |
| import assert from "node:assert/strict"; | |
| import { html, render, unescaped } from "./ssr-html.ts"; | |
| describe("render", () => { | |
| it("returns the static string for a template with no holes", () => { | |
| assert.equal(render(html`<p>hello</p>`), "<p>hello</p>"); | |
| }); | |
| it("returns an empty string for an empty template", () => { | |
| assert.equal(render(html``), ""); | |
| }); | |
| it("does not escape characters in static template parts", () => { | |
| assert.equal(render(html`<p>a & b</p>`), "<p>a & b</p>"); | |
| }); | |
| }); | |
| describe("escaping", () => { | |
| it("escapes &, <, >, \", and ' in interpolated strings", () => { | |
| const value = `&<>"'`; | |
| assert.equal( | |
| render(html`<p>${value}</p>`), | |
| "<p>&<>"'</p>", | |
| ); | |
| }); | |
| it("escapes ampersands first to avoid double-escaping entities", () => { | |
| assert.equal(render(html`<p>${"<"}</p>`), "<p>&lt;</p>"); | |
| }); | |
| it("coerces numbers to escaped strings", () => { | |
| assert.equal(render(html`<p>${42}</p>`), "<p>42</p>"); | |
| }); | |
| it("coerces booleans to escaped strings", () => { | |
| assert.equal(render(html`<p>${true}</p>`), "<p>true</p>"); | |
| assert.equal(render(html`<p>${false}</p>`), "<p>false</p>"); | |
| }); | |
| it("coerces bigints to escaped strings", () => { | |
| assert.equal(render(html`<p>${10n}</p>`), "<p>10</p>"); | |
| }); | |
| it("escapes coerced object string forms", () => { | |
| const obj = { toString: (): string => "<x>" }; | |
| assert.equal(render(html`<p>${obj}</p>`), "<p><x></p>"); | |
| }); | |
| }); | |
| describe("nullish holes", () => { | |
| it("renders null as empty string", () => { | |
| assert.equal(render(html`<p>${null}</p>`), "<p></p>"); | |
| }); | |
| it("renders undefined as empty string", () => { | |
| assert.equal(render(html`<p>${undefined}</p>`), "<p></p>"); | |
| }); | |
| }); | |
| describe("nested templates", () => { | |
| it("renders a nested template without escaping its markup", () => { | |
| const inner = html`<em>${"<x>"}</em>`; | |
| assert.equal( | |
| render(html`<p>${inner}</p>`), | |
| "<p><em><x></em></p>", | |
| ); | |
| }); | |
| it("supports multiple levels of nesting", () => { | |
| const a = html`<a>${"&"}</a>`; | |
| const b = html`<b>${a}</b>`; | |
| const c = html`<c>${b}</c>`; | |
| assert.equal(render(c), "<c><b><a>&</a></b></c>"); | |
| }); | |
| }); | |
| describe("array holes", () => { | |
| it("renders an array of strings joined with no separator, escaping each", () => { | |
| assert.equal( | |
| render(html`<p>${["a", "<b>", "c"]}</p>`), | |
| "<p>a<b>c</p>", | |
| ); | |
| }); | |
| it("renders an array of templates concatenated", () => { | |
| const items = ["a", "b", "c"].map((i) => html`<li>${i}</li>`); | |
| assert.equal( | |
| render(html`<ul>${items}</ul>`), | |
| "<ul><li>a</li><li>b</li><li>c</li></ul>", | |
| ); | |
| }); | |
| it("handles mixed arrays of strings, templates, and nullish values", () => { | |
| const mixed = ["a", html`<b>${"<x>"}</b>`, null, undefined, "c"]; | |
| assert.equal( | |
| render(html`<p>${mixed}</p>`), | |
| "<p>a<b><x></b>c</p>", | |
| ); | |
| }); | |
| it("renders an empty array as empty string", () => { | |
| assert.equal(render(html`<p>${[]}</p>`), "<p></p>"); | |
| }); | |
| }); | |
| describe("unescaped", () => { | |
| it("inserts the value verbatim", () => { | |
| assert.equal( | |
| render(html`<p>${unescaped("<b>ok</b>")}</p>`), | |
| "<p><b>ok</b></p>", | |
| ); | |
| }); | |
| it("does not escape ampersands or angle brackets", () => { | |
| assert.equal( | |
| render(html`<p>${unescaped("a & <b>")}</p>`), | |
| "<p>a & <b></p>", | |
| ); | |
| }); | |
| it("works inside arrays alongside escaped values", () => { | |
| const parts = ["<x>", unescaped("<b>ok</b>")]; | |
| assert.equal( | |
| render(html`<p>${parts}</p>`), | |
| "<p><x><b>ok</b></p>", | |
| ); | |
| }); | |
| }); | |
| describe("multiple holes", () => { | |
| it("renders multiple holes without gaps between adjacent values", () => { | |
| assert.equal(render(html`${"a"}${"b"}${"c"}`), "abc"); | |
| }); | |
| it("renders holes interleaved with static parts", () => { | |
| assert.equal( | |
| render(html`<a>${1}</a><b>${2}</b><c>${3}</c>`), | |
| "<a>1</a><b>2</b><c>3</c>", | |
| ); | |
| }); | |
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| const TEMPLATE = Symbol("ssr-html.template"); | |
| const UNESCAPED = Symbol("ssr-html.unescaped"); | |
| /** | |
| * An opaque, lazy representation of an HTML template produced by the {@link html} | |
| * tag. Pass it to {@link render} to obtain a string, or interpolate it into | |
| * another `html` template to nest it. | |
| */ | |
| export type Template = { | |
| readonly [TEMPLATE]: true; | |
| readonly strings: TemplateStringsArray; | |
| readonly values: readonly unknown[]; | |
| }; | |
| /** | |
| * A marker indicating that a string should be inserted into a template without | |
| * HTML-escaping. Construct with {@link unescaped}. Only use for content you | |
| * trust — interpolating untrusted input as `unescaped` is an XSS vulnerability. | |
| */ | |
| export type Unescaped = { | |
| readonly [UNESCAPED]: true; | |
| readonly value: string; | |
| }; | |
| /** | |
| * Tagged template literal that builds an HTML {@link Template}. Interpolated | |
| * values are HTML-escaped at render time. Holes accept primitives, nested | |
| * `html` templates, {@link unescaped} values, arrays of any of the above, and | |
| * `null` / `undefined` (rendered as empty string). | |
| * | |
| * @example | |
| * const name = "<world>"; | |
| * render(html`<h1>Hello, ${name}!</h1>`); | |
| * // => "<h1>Hello, <world>!</h1>" | |
| */ | |
| export const html = ( | |
| strings: TemplateStringsArray, | |
| ...values: readonly unknown[] | |
| ): Template => ({ [TEMPLATE]: true, strings, values }); | |
| /** | |
| * Wraps a string so that {@link render} inserts it verbatim, bypassing HTML | |
| * escaping. Use for trusted, pre-rendered fragments (e.g. a doctype, an SVG | |
| * blob). Never pass untrusted input — doing so allows XSS. | |
| * | |
| * @example | |
| * render(html`<p>${unescaped("<b>bold</b>")}</p>`); | |
| * // => "<p><b>bold</b></p>" | |
| */ | |
| export const unescaped = (value: string): Unescaped => ({ | |
| [UNESCAPED]: true, | |
| value, | |
| }); | |
| const escape = (s: string): string => | |
| s | |
| .replace(/&/g, "&") | |
| .replace(/</g, "<") | |
| .replace(/>/g, ">") | |
| .replace(/"/g, """) | |
| .replace(/'/g, "'"); | |
| const isTemplate = (v: unknown): v is Template => | |
| typeof v === "object" && v !== null && (v as Record<symbol, unknown>)[TEMPLATE] === true; | |
| const isUnescaped = (v: unknown): v is Unescaped => | |
| typeof v === "object" && v !== null && (v as Record<symbol, unknown>)[UNESCAPED] === true; | |
| const renderValue = (value: unknown): string => { | |
| if (value == null) return ""; | |
| if (isTemplate(value)) return render(value); | |
| if (isUnescaped(value)) return value.value; | |
| if (Array.isArray(value)) return value.map(renderValue).join(""); | |
| return escape(String(value)); | |
| }; | |
| /** | |
| * Renders a {@link Template} to an HTML string. Interpolated values are | |
| * escaped, nested templates are rendered recursively, arrays are concatenated | |
| * with no separator, and `null` / `undefined` become empty strings. Values | |
| * wrapped with {@link unescaped} are inserted verbatim. | |
| */ | |
| export const render = (template: Template): string => { | |
| const { strings, values } = template; | |
| let out = strings[0]; | |
| for (let i = 0; i < values.length; i++) { | |
| out += renderValue(values[i]) + strings[i + 1]; | |
| } | |
| return out; | |
| }; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment