@marianmeres/safe-html 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The brand. Trusted values carry `Symbol.for` keys, so JSON or database rows can never pass as
3
+ * trusted, and values stay recognized across duplicate copies of the package in one process.
4
+ */
5
+ import type { Trusted, TrustedKind } from "./types.js";
6
+ declare const VALUE: unique symbol;
7
+ declare const NEUTRAL: unique symbol;
8
+ /**
9
+ * The implementation. State lives in private fields behind getters, so a value is immutable
10
+ * without the cost of `Object.freeze` (there is no setter, and nothing outside this class can
11
+ * reach the fields). Exported for fast `instanceof` checks; values from another copy of the
12
+ * package are recognized through the `Symbol.for` keys instead.
13
+ */
14
+ export declare class TrustedValue<K extends TrustedKind = TrustedKind> implements Trusted<K> {
15
+ #private;
16
+ constructor(kind: K, value: string, neutral: boolean);
17
+ get kind(): K;
18
+ get [VALUE](): string;
19
+ get [NEUTRAL](): boolean;
20
+ toString(): string;
21
+ toJSON(): string;
22
+ [Symbol.toPrimitive](): string;
23
+ }
24
+ /**
25
+ * Creates a trusted value. `neutral` (html only) marks markup that parses the same inside
26
+ * `<svg>`/`<math>` as in HTML (see scanner.ts).
27
+ */
28
+ export declare function makeTrusted<K extends TrustedKind>(kind: K, value: string, neutral?: boolean): Trusted<K>;
29
+ /** Is `v` a value produced by this package (optionally, of the given kind)? */
30
+ export declare function isTrusted<K extends TrustedKind>(v: unknown, kind?: K): v is Trusted<K>;
31
+ /** The string of a value already known to be trusted. */
32
+ export declare function trustedString(v: Trusted): string;
33
+ /** Whether trusted markup may be placed into SVG/MathML text. */
34
+ export declare function isNeutral(v: Trusted): boolean;
35
+ export {};
@@ -0,0 +1,68 @@
1
+ const BRAND = Symbol.for("@marianmeres/safe-html/brand");
2
+ const VALUE = Symbol.for("@marianmeres/safe-html/value");
3
+ const NEUTRAL = Symbol.for("@marianmeres/safe-html/neutral");
4
+ /**
5
+ * The implementation. State lives in private fields behind getters, so a value is immutable
6
+ * without the cost of `Object.freeze` (there is no setter, and nothing outside this class can
7
+ * reach the fields). Exported for fast `instanceof` checks; values from another copy of the
8
+ * package are recognized through the `Symbol.for` keys instead.
9
+ */
10
+ export class TrustedValue {
11
+ #kind;
12
+ #value;
13
+ #neutral;
14
+ constructor(kind, value, neutral) {
15
+ this.#kind = kind;
16
+ this.#value = value;
17
+ this.#neutral = neutral;
18
+ }
19
+ get kind() {
20
+ return this.#kind;
21
+ }
22
+ get [VALUE]() {
23
+ return this.#value;
24
+ }
25
+ get [NEUTRAL]() {
26
+ return this.#neutral;
27
+ }
28
+ toString() {
29
+ return this.#value;
30
+ }
31
+ toJSON() {
32
+ return this.#value;
33
+ }
34
+ [Symbol.toPrimitive]() {
35
+ return this.#value;
36
+ }
37
+ }
38
+ Object.defineProperty(TrustedValue.prototype, BRAND, { value: true });
39
+ Object.freeze(TrustedValue.prototype);
40
+ /**
41
+ * Creates a trusted value. `neutral` (html only) marks markup that parses the same inside
42
+ * `<svg>`/`<math>` as in HTML (see scanner.ts).
43
+ */
44
+ export function makeTrusted(kind, value, neutral = false) {
45
+ return new TrustedValue(kind, value, neutral);
46
+ }
47
+ /** Is `v` a value produced by this package (optionally, of the given kind)? */
48
+ export function isTrusted(v, kind) {
49
+ if (v instanceof TrustedValue)
50
+ return kind === undefined || v.kind === kind;
51
+ return (typeof v === "object" &&
52
+ v !== null &&
53
+ // deno-lint-ignore no-explicit-any
54
+ v[BRAND] === true &&
55
+ // deno-lint-ignore no-explicit-any
56
+ typeof v[VALUE] === "string" &&
57
+ (kind === undefined || v.kind === kind));
58
+ }
59
+ /** The string of a value already known to be trusted. */
60
+ export function trustedString(v) {
61
+ // deno-lint-ignore no-explicit-any
62
+ return v[VALUE];
63
+ }
64
+ /** Whether trusted markup may be placed into SVG/MathML text. */
65
+ export function isNeutral(v) {
66
+ // deno-lint-ignore no-explicit-any
67
+ return v[NEUTRAL] === true;
68
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Shared public types.
3
+ */
4
+ /** The kinds of trusted value this package produces. */
5
+ export type TrustedKind = "html" | "attrs" | "url" | "srcset" | "json" | "js" | "css" | "unsafe";
6
+ /**
7
+ * An immutable, branded value produced by this package. `String(v)`, `v.toString()` and
8
+ * `JSON.stringify(v)` give its string.
9
+ */
10
+ export interface Trusted<K extends TrustedKind = TrustedKind> {
11
+ /** Which helper produced the value; decides where it may be interpolated. */
12
+ readonly kind: K;
13
+ /** The string. */
14
+ toString(): string;
15
+ /** The string, so `JSON.stringify` serializes the value as a plain string. */
16
+ toJSON(): string;
17
+ }
18
+ /** Markup built by `html` or `join()`. */
19
+ export type SafeHtml = Trusted<"html">;
20
+ /** An attribute list built by `attrs()`. */
21
+ export type SafeAttrs = Trusted<"attrs">;
22
+ /** A scheme-vetted (not escaped) URL built by `url()`. */
23
+ export type SafeUrl = Trusted<"url">;
24
+ /** A vetted (not escaped) `srcset` value built by `srcset()`. */
25
+ export type SafeSrcset = Trusted<"srcset">;
26
+ /** What may be interpolated into `html`. Anything else is a type error and a runtime error. */
27
+ export type Renderable = Trusted | string | number | bigint | boolean | null | undefined | readonly Renderable[];
28
+ /** A value accepted by `attrs()`. */
29
+ export type AttrValue = string | number | bigint | boolean | null | undefined | Trusted<"url" | "srcset" | "unsafe">;
30
+ /** One `srcset` candidate. */
31
+ export interface SrcsetCandidate {
32
+ /** Checked against the URL policy unless it is a `SafeUrl`. */
33
+ url: string | SafeUrl;
34
+ /** `"2x"`, `"1.5x"`, `"800w"`. Omitted means `1x`. */
35
+ descriptor?: string;
36
+ }
37
+ /**
38
+ * Where a slot sits in the template, as worked out from the static markup.
39
+ *
40
+ * - `text`: element content.
41
+ * - `rcdata`: content of `<title>` or `<textarea>` (text only, no markup).
42
+ * - `attr-list`: inside a start tag, where an attribute may begin.
43
+ * - `attr-value`: inside a quoted attribute value.
44
+ * - `url`: a URL attribute value that the slot starts.
45
+ * - `srcset`: a `srcset` / `imagesrcset` value.
46
+ * - `script`: `<script>` raw text.
47
+ * - `style`: `<style>` raw text.
48
+ */
49
+ export type SlotContext = "text" | "rcdata" | "attr-list" | "attr-value" | "url" | "srcset" | "script" | "style";
50
+ /** Passed to `HtmlOptions.onBlockedUrl`. */
51
+ export interface BlockedUrlInfo {
52
+ /** The URL as given. */
53
+ url: string;
54
+ /** Lowercased tag name, when known. */
55
+ tag?: string;
56
+ /** Lowercased attribute name, when known. */
57
+ attribute?: string;
58
+ }
59
+ /** URL policy of a kit (see `createHtml`). */
60
+ export interface HtmlOptions {
61
+ /**
62
+ * Allowed URL schemes, case-insensitive, without the colon.
63
+ * Default: `["http", "https", "mailto", "tel"]`.
64
+ */
65
+ urlSchemes?: readonly string[];
66
+ /** What a blocked URL renders as. Default: `"about:invalid#blocked"`. */
67
+ blockedUrl?: string;
68
+ /** Called once per blocked URL, e.g. to log a probe. May throw; that propagates. */
69
+ onBlockedUrl?: (info: BlockedUrlInfo) => void;
70
+ }
71
+ /** The URL-policy-bound part of the API. `createHtml()` returns one. */
72
+ export interface HtmlKit {
73
+ /** The template tag. Every interpolated value is escaped for the place it appears. */
74
+ html(strings: TemplateStringsArray, ...values: Renderable[]): SafeHtml;
75
+ /** An attribute list for a slot in attribute-list position: `<button ${attrs({...})}>`. */
76
+ attrs(record: Readonly<Record<string, AttrValue>>): SafeAttrs;
77
+ /** Checks `value` against the scheme allowlist. `schemes` replaces the kit's list. */
78
+ url(value: string, options?: {
79
+ schemes?: readonly string[];
80
+ }): SafeUrl;
81
+ /** A `srcset` value. Blocked candidates are dropped. */
82
+ srcset(candidates: readonly SrcsetCandidate[]): SafeSrcset;
83
+ }
package/dist/types.js ADDED
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Shared public types.
3
+ */
4
+ export {};
package/dist/url.d.ts ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * URL policy: scheme allowlist. Blocked URLs are replaced, never thrown, so data can't make a
3
+ * render fail.
4
+ */
5
+ import type { BlockedUrlInfo, HtmlOptions } from "./types.js";
6
+ /** The default scheme allowlist. */
7
+ export declare const DEFAULT_URL_SCHEMES: readonly string[];
8
+ /** The default replacement for a blocked URL. */
9
+ export declare const DEFAULT_BLOCKED_URL = "about:invalid#blocked";
10
+ /** A kit's resolved URL policy. */
11
+ export interface UrlPolicy {
12
+ readonly schemes: ReadonlySet<string>;
13
+ readonly blockedUrl: string;
14
+ readonly onBlockedUrl?: (info: BlockedUrlInfo) => void;
15
+ }
16
+ /** Leading C0 controls and space, which the URL parser strips. */
17
+ export declare const LEADING_C0_SPACE: RegExp;
18
+ /** Only C0 controls and space (stripped by the URL parser at either end). */
19
+ export declare const ONLY_C0_SPACE: RegExp;
20
+ /** Normalizes and validates a scheme list. Throws `TypeError` on a malformed entry (config). */
21
+ export declare function normalizeSchemes(list: readonly string[]): ReadonlySet<string>;
22
+ /** Resolves kit options into a policy. */
23
+ export declare function createPolicy(options?: HtmlOptions): UrlPolicy;
24
+ /** The lowercased scheme of `value` as a browser would parse it, or `null` if relative. */
25
+ export declare function urlScheme(value: string): string | null;
26
+ /**
27
+ * Returns `value` if relative or its scheme is allowed, otherwise `null` after reporting it.
28
+ * The result is not escaped.
29
+ */
30
+ export declare function vetUrl(value: string, policy: UrlPolicy, tag?: string, attribute?: string, schemes?: ReadonlySet<string>): string | null;
package/dist/url.js ADDED
@@ -0,0 +1,93 @@
1
+ /** The default scheme allowlist. */
2
+ export const DEFAULT_URL_SCHEMES = Object.freeze([
3
+ "http",
4
+ "https",
5
+ "mailto",
6
+ "tel",
7
+ ]);
8
+ /** The default replacement for a blocked URL. */
9
+ export const DEFAULT_BLOCKED_URL = "about:invalid#blocked";
10
+ const SCHEME_NAME = /^[a-z][a-z0-9+.-]*$/;
11
+ /** Leading C0 controls and space, which the URL parser strips. */
12
+ // deno-lint-ignore no-control-regex
13
+ export const LEADING_C0_SPACE = /^[\x00-\x20]+/;
14
+ /** Only C0 controls and space (stripped by the URL parser at either end). */
15
+ // deno-lint-ignore no-control-regex
16
+ export const ONLY_C0_SPACE = /^[\x00-\x20]*$/;
17
+ /** Normalizes and validates a scheme list. Throws `TypeError` on a malformed entry (config). */
18
+ export function normalizeSchemes(list) {
19
+ if (!Array.isArray(list)) {
20
+ throw new TypeError("urlSchemes must be an array of strings");
21
+ }
22
+ const out = new Set();
23
+ for (const entry of list) {
24
+ const s = typeof entry === "string"
25
+ ? entry.trim().toLowerCase().replace(/:$/, "")
26
+ : "";
27
+ if (!SCHEME_NAME.test(s)) {
28
+ throw new TypeError(`Invalid URL scheme in urlSchemes: ${JSON.stringify(entry)}`);
29
+ }
30
+ out.add(s);
31
+ }
32
+ return out;
33
+ }
34
+ /** Resolves kit options into a policy. */
35
+ export function createPolicy(options = {}) {
36
+ const blockedUrl = options.blockedUrl ?? DEFAULT_BLOCKED_URL;
37
+ if (typeof blockedUrl !== "string") {
38
+ throw new TypeError("blockedUrl must be a string");
39
+ }
40
+ if (options.onBlockedUrl !== undefined && typeof options.onBlockedUrl !== "function") {
41
+ throw new TypeError("onBlockedUrl must be a function");
42
+ }
43
+ return Object.freeze({
44
+ schemes: normalizeSchemes(options.urlSchemes ?? DEFAULT_URL_SCHEMES),
45
+ blockedUrl,
46
+ onBlockedUrl: options.onBlockedUrl,
47
+ });
48
+ }
49
+ /** The lowercased scheme of `value` as a browser would parse it, or `null` if relative. */
50
+ export function urlScheme(value) {
51
+ // the URL parser strips leading C0 controls and space, and removes tab/LF/CR anywhere;
52
+ // any other character ends the scheme
53
+ const n = value.length;
54
+ let i = 0;
55
+ while (i < n && value.charCodeAt(i) <= 0x20)
56
+ i++;
57
+ const start = i;
58
+ if (i >= n || !isAlpha(value.charCodeAt(i)))
59
+ return null;
60
+ let dirty = false;
61
+ for (i++; i < n; i++) {
62
+ const c = value.charCodeAt(i);
63
+ if (c === 58) { // ":"
64
+ const s = value.slice(start, i);
65
+ return (dirty ? s.replace(/[\t\n\r]/g, "") : s).toLowerCase();
66
+ }
67
+ if (c === 9 || c === 10 || c === 13)
68
+ dirty = true;
69
+ else if (!isAlpha(c) && !(c >= 48 && c <= 57) && c !== 43 && c !== 45 && c !== 46) {
70
+ return null; // not [A-Za-z0-9+.-]
71
+ }
72
+ }
73
+ return null;
74
+ }
75
+ const isAlpha = (c) => (c >= 65 && c <= 90) || (c >= 97 && c <= 122);
76
+ /**
77
+ * Returns `value` if relative or its scheme is allowed, otherwise `null` after reporting it.
78
+ * The result is not escaped.
79
+ */
80
+ export function vetUrl(value, policy, tag, attribute, schemes = policy.schemes) {
81
+ const scheme = urlScheme(value);
82
+ if (scheme === null || schemes.has(scheme))
83
+ return value;
84
+ if (policy.onBlockedUrl) {
85
+ const info = { url: value };
86
+ if (tag)
87
+ info.tag = tag;
88
+ if (attribute)
89
+ info.attribute = attribute;
90
+ policy.onBlockedUrl(info);
91
+ }
92
+ return null;
93
+ }
package/docs/design.md ADDED
@@ -0,0 +1,346 @@
1
+ # Design
2
+
3
+ How `@marianmeres/safe-html` decides what is safe. This is the normative description of the
4
+ implemented behavior; [API.md](../API.md) documents the functions.
5
+
6
+ ## 1. Model
7
+
8
+ An `html` tagged template returns a trusted `SafeHtml`. Every interpolated value (a _slot_) is
9
+ escaped for the place it appears. The place (the slot's _context_) is worked out from the
10
+ template's **static strings only**, once per call site, and cached by template object. Slot
11
+ positions that can never be made safe are rejected the first time the template renders,
12
+ whatever the data.
13
+
14
+ Two phases:
15
+
16
+ 1. **Analysis** (`src/scanner.ts`): a small model of the HTML tokenizer runs over the static
17
+ text and classifies every slot, or throws `HtmlTemplateError`. Depends on the template
18
+ only.
19
+ 2. **Rendering** (`src/render.ts`, `src/kit.ts`): each value is rendered under its slot's
20
+ context (§4), or `HtmlValueError` is thrown. Depends on the value's _type or trusted kind_,
21
+ never on the content of a string.
22
+
23
+ ## 2. Trust model
24
+
25
+ - The static parts of a template are trusted: only code writes them, and `html` refuses
26
+ anything that is not a genuine (frozen) template object.
27
+ - Interpolated values are untrusted unless they carry the package's brand: values made by
28
+ `html`, `join`, `attrs`, `url`, `srcset`, `jsonScript`, `scriptText`, `styleText` or
29
+ `unsafeRaw`.
30
+ - The brand is a `Symbol.for(…)` key. JSON and database rows can't carry symbols, so parsed
31
+ or stored data can't pass as trusted. Code can forge a value, but code can call
32
+ `unsafeRaw()` anyway. `Symbol.for` keeps values recognized across duplicate copies of the
33
+ package in one process.
34
+ - Trusted values are immutable: state lives in private class fields behind getters.
35
+
36
+ **Guaranteed.** Given templates that pass analysis and no `unsafeRaw()`, no string value can:
37
+
38
+ - create an element, attribute, comment or declaration;
39
+ - close an element or attribute value that the template opened;
40
+ - inject script through an event-handler attribute (those slots are rejected);
41
+ - put a URL with a scheme outside the allowlist into a URL attribute (default: `http`,
42
+ `https`, `mailto`, `tel`), including through `srcset` or `attrs()`;
43
+ - break out of `<script>`, `<style>`, `<title>` or `<textarea>` content;
44
+ - change how SVG/MathML content parses.
45
+
46
+ **Not guaranteed:**
47
+
48
+ - CSS injection inside `style="…"` or `styleText()`. A value can't break out, but it can
49
+ restyle the page or load a `url(…)`. Build style values from validated tokens.
50
+ - Semantic misuse: `<meta http-equiv="refresh" content="${x}">`, `<base href>` pointing at a
51
+ hostile `https:` host, open redirects, and `//host` protocol-relative URLs (relative, so
52
+ allowed).
53
+ - Attributes that other code later treats as URLs or code: `data-src`, `data-href`, and
54
+ client-side frameworks that evaluate attributes or text (Alpine `x-*`, htmx `hx-on*`,
55
+ Vue/Angular template syntax in server-rendered markup). Don't interpolate data there.
56
+ - DOM clobbering through data-controlled `id` or `name` values.
57
+ - A slot inside a JavaScript string literal in a `<script>`. The scanner does not parse
58
+ JavaScript; `jsonScript()` output must be used as a whole JS expression.
59
+ - URL component encoding: `href="/search?q=${q}"` is HTML-escaped, not
60
+ `encodeURIComponent`-ed. That is the caller's job.
61
+ - Anything passed through `unsafeRaw()`.
62
+ - Character encoding. Serve `content-type: text/html; charset=utf-8` and emit
63
+ `<meta charset="utf-8">`.
64
+
65
+ ## 3. Analysis
66
+
67
+ The scan starts in text. A slot takes the context of the scanner state at that boundary; the
68
+ scan then continues from the same state, because §4 guarantees no value can change it.
69
+
70
+ ### 3.1 States → contexts
71
+
72
+ | Scanner state at the slot | Example | Context |
73
+ | ----------------------------------------------------------------------- | -------------------------------------- | -------------------- |
74
+ | Text content | `<p>${…}</p>` | `text` |
75
+ | `<title>`, `<textarea>` content (RCDATA) | `<title>${…}</title>` | `rcdata` |
76
+ | In a start tag where an attribute may begin | `<div ${…}>`, `<a href="x"${…}>` | `attr-list` |
77
+ | Just after `<` or `</`, or in a tag name | `<${…}>`, `<h${…}>` | error |
78
+ | In an attribute name, or right after one with no whitespace | `<div data-${…}="1">`, `<input a${…}>` | error |
79
+ | After `=`, or in an unquoted value | `<a href=${…}>`, `<a href=x${…}>` | error |
80
+ | In a `"…"` or `'…'` value | `<p title="${…}">` | per attribute (§3.3) |
81
+ | `<script>` raw text | `<script>${…}</script>` | `script` |
82
+ | `<style>` raw text | `<style>${…}</style>` | `style` |
83
+ | `iframe`, `noembed`, `noframes`, `noscript`, `xmp`, `plaintext` content | `<noscript>${…}</noscript>` | error |
84
+ | Comment, doctype, `<!…>`, `<?…>`, `</ …>` | `<!-- ${…} -->` | error |
85
+ | Inside an end tag | `</div ${…}>` | error |
86
+ | Right after `/` in a tag | `<br/${…}>` | error |
87
+
88
+ Tokenizer details:
89
+
90
+ - Tag and attribute names match case-insensitively and are stored lowercased.
91
+ - `<script>`, `<style>`, `<title>`, `<textarea>` and the unsupported raw-text elements are
92
+ scanned to their end tag (`</name` followed by whitespace, `/` or `>`, case-insensitive),
93
+ including after `<script/>` (the self-closing flag is ignored on non-void elements).
94
+ - A comment runs from `<!--` to `-->` or `--!>`; `<!-->` and `<!--->` are complete comments.
95
+ `<!doctype …>`, other `<!…>`, `<?…>` and `</` + non-letter run to the next `>`.
96
+ - `<![CDATA[` anywhere is an error.
97
+ - A `<` followed by anything but a letter, `/`, `!` or `?` is text.
98
+ - After an `attr-list` slot, the next static chunk must be empty (another slot follows) or
99
+ start with whitespace, `>` or `/`, and its first non-whitespace character must not be `=`.
100
+ Otherwise whether the static text binds to a preceding attribute would depend on what the
101
+ slot rendered.
102
+ - `attr-list` slots are refused on SVG `<animate>` and `<set>` (see §3.6).
103
+
104
+ ### 3.2 Raw text boundaries
105
+
106
+ A value inserted into raw text must not be able to complete `</script`, `</style`,
107
+ `</title`, `</textarea` or `<!--` together with the static text around it:
108
+
109
+ - **Before a slot**: the static text in a raw-text or RCDATA element must not end with a
110
+ non-empty prefix of the element's closing sequence (`<`, `</`, `</s`, … `</script`) or, in
111
+ `<script>`, of `<!--` (`<`, `<!`, `<!-`). Error: "add a space". So `if (a <${x})` is refused,
112
+ `if (a < ${x})` is fine.
113
+ - **After a slot**: `scriptText()` and `styleText()` output never ends with such a prefix
114
+ (§5.3, §5.4); `jsonScript()` output contains no `<`; RCDATA values are escaped.
115
+ - **Static `<!--` inside `<script>` is an error.** It enters the tokenizer's script-data
116
+ escaped states, where `<script>` inside can make the real end tag not end the element. The
117
+ scanner does not model those states.
118
+
119
+ ### 3.3 Quoted attribute values
120
+
121
+ By lowercased tag and attribute name:
122
+
123
+ 1. `on*` (event handlers): error. Write handlers literally, or use `scriptText()` in a
124
+ `<script>`.
125
+ 2. `srcdoc`: error (HTML inside an attribute).
126
+ 3. `ping`: error (a URL list; not modeled).
127
+ 4. `to`, `from`, `by`, `values`, `attributename` on SVG `<animate>` / `<set>`: error (§3.6).
128
+ 5. `srcset`, `imagesrcset`: context `srcset`. The slot must be the whole value (whitespace
129
+ around it only).
130
+ 6. URL attributes: §3.4.
131
+ 7. Everything else, `style` included: `attr-value`.
132
+
133
+ ### 3.4 URL attributes
134
+
135
+ `href`, `src`, `action`, `formaction`, `poster`, `cite`, `background`, `longdesc`,
136
+ `manifest`, `codebase`, `icon`, `xlink:href`, and `data` on `<object>`.
137
+
138
+ Only the **first** slot in a URL value decides; later slots in the same value are
139
+ `attr-value`, because the first one settled the scheme. For the first slot, let **P** be the
140
+ static text of the value before it, with tab/LF/CR removed and leading C0 controls and space
141
+ stripped (what the URL parser ignores).
142
+
143
+ - **P is empty** → context `url` (the value goes through the policy). The static text after
144
+ the slot (tab/LF/CR removed), up to the closing quote or the next slot, must be either
145
+ whitespace to the closing quote, or start with `/`, `?` or `#`. So `href="${u}"`,
146
+ `src="${cdn}/img/${name}.png"` and `href="${base}?q=${q}"` work, while `href="${a}${b}"`,
147
+ `href="${a}:x"` and `href="${a}x"` are errors: in those, each piece could pass the policy
148
+ while the joined value is `javascript:…`. After a vetted value, a `/`, `?` or `#` can't
149
+ change its scheme.
150
+ - **P settles the scheme** → context `attr-value` (escaping only). Let Q be P up to its
151
+ first `&` (a character reference could decode to scheme characters). P settles the scheme
152
+ when Q's first character is not an ASCII letter (relative: `/`, `./`, `#`, `?`, `//`), or Q
153
+ contains a character outside `[A-Za-z0-9+.-]`. If that character is `:`, Q wrote a
154
+ **static scheme**, which must be in the kit's allowlist: checked at render, an
155
+ `HtmlTemplateError` otherwise. So `href="mailto:${a}"` works, while
156
+ `href="javascript:go('${id}')"` and (with the default kit) `src="data:image/png;base64,${b}"`
157
+ are errors. For the latter, build the whole URL and pass `url(value, { schemes: ["data"] })`.
158
+ - **Otherwise** (Q is empty or could still grow into a scheme: `href="java${x}"`,
159
+ `href="http${s}://x"`, `href="&#x6a;ava${x}"`) → error: ambiguous scheme.
160
+
161
+ ### 3.5 End of template
162
+
163
+ After the last static chunk the scanner must be back in text: outside any tag, comment,
164
+ declaration, raw text, RCDATA, and outside `<svg>`/`<math>`. Unclosed normal elements are
165
+ fine. A stray `</svg>` or `</math>` is an error.
166
+
167
+ This makes a `SafeHtml` fragment safe to drop into another template's `text` slot: it starts
168
+ and ends in text and every value inside was rendered for its own context.
169
+
170
+ ### 3.6 SVG and MathML (foreign content)
171
+
172
+ Inside `<svg>` and `<math>` the HTML parser does not switch the tokenizer for `<script>`,
173
+ `<style>`, `<title>` or `<textarea>`: their content is markup. Treating them as raw text, as
174
+ in HTML, would let `styleText("<img src=x onerror=…>")` inside an SVG `<style>` create an
175
+ element, and would classify `<svg><title><a href="${u}">` as text (no URL policy). So:
176
+
177
+ - The scanner tracks open `<svg>`/`<math>` elements (a stack; an end tag pops to the nearest
178
+ element of that name). `<svg/>` has no content.
179
+ - In foreign content no tag switches the tokenizer. A slot in the content of `<script>` or
180
+ `<style>` there is an error; their static content is scanned as markup.
181
+ - HTML elements that end foreign content (`<p>`, `<div>`, `<img>`, `<b>`, `<br>`, `<table>`,
182
+ `<font>`, … and `</p>`, `</br>`) are errors: close the `<svg>`/`<math>` first.
183
+ - Integration points (SVG `foreignObject`, `desc`, `title`; MathML `mi`, `mo`, `mn`, `ms`,
184
+ `mtext`, `annotation-xml`) may contain text, comments and slots only; a start tag inside is
185
+ an error. Slots in SVG integration points are HTML text; in MathML ones they are foreign
186
+ text.
187
+ - SVG `<animate>`/`<set>` can set another attribute (such as `href`) to any value, so slots
188
+ in their `to`, `from`, `by`, `values`, `attributeName` and `attr-list` slots are errors.
189
+ - `text` slots in foreign content (outside SVG integration points) accept an `html`
190
+ fragment only if it is **neutral**.
191
+
192
+ **Neutrality.** Every template is scanned twice: once starting in HTML, once starting inside
193
+ a generic foreign element (no integration points, a stray `</svg>` is an error). The
194
+ template is neutral if both scans succeed and agree on every slot (same context, tag,
195
+ attribute and static scheme; `rcdata` in HTML may be `text` in foreign content, since
196
+ `rcdata` accepts a subset of `text` and renders it identically). A rendered fragment is
197
+ neutral if its template is and every fragment rendered into its text slots is. `<circle
198
+ r="${r}"/>`, `<g>…</g>`, `<title>${t}</title>` and static `<style>` are neutral; `<p>`,
199
+ `<style>${styleText(…)}</style>` and `<textarea><a href="${u}"></textarea>` are not.
200
+
201
+ ### 3.7 Strictness
202
+
203
+ Anything the scanner does not model is an error, never a guess. Analysis errors depend on the
204
+ template only, so a test that renders every template once surfaces them all. Relaxing a rule
205
+ later is a minor release; tightening one breaks templates.
206
+
207
+ ### 3.8 Excerpts
208
+
209
+ `HtmlTemplateError.excerpt` is the static text with slots shown as `${…}`, the failing slot
210
+ as `${⟨here⟩}` (or `⟨here⟩` at an offset for errors in static text), whitespace collapsed,
211
+ trimmed to about 90 characters. It never contains a value.
212
+
213
+ ## 4. Rendering: value × context
214
+
215
+ ✗ = `HtmlValueError` (slot index, context and received type/kind; never the value). E =
216
+ §5.1 escape. P = §5.2 policy.
217
+
218
+ | Value | text | rcdata | attr-list | attr-value | url | srcset | script | style |
219
+ | --------------------------------------- | --------- | -------- | --------- | ---------- | -------- | -------- | -------- | -------- |
220
+ | `string` | E | E | ✗ | E | P, E | ✗ | ✗ | ✗ |
221
+ | `number`, `bigint` | `String` | same | ✗ | `String` | P, E | ✗ | ✗ | ✗ |
222
+ | `null`, `undefined`, `false`, `true` | `""` | `""` | `""` | `""` | `""` | `""` | `""` | `""` |
223
+ | array | items | items | items | ✗ | ✗ | ✗ | ✗ | ✗ |
224
+ | `html` (`html`, `join`) | verbatim¹ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ |
225
+ | `attrs` | ✗ | ✗ | verbatim | ✗ | ✗ | ✗ | ✗ | ✗ |
226
+ | `url` | E | E | ✗ | E | E (no P) | ✗ | ✗ | ✗ |
227
+ | `srcset` | ✗ | ✗ | ✗ | E | ✗ | E | ✗ | ✗ |
228
+ | `json`, `js` | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | verbatim | ✗ |
229
+ | `css` | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | verbatim |
230
+ | `unsafe` | verbatim | verbatim | verbatim | verbatim | verbatim | verbatim | verbatim | verbatim |
231
+ | object, function, symbol, date, promise | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ |
232
+
233
+ ¹ In SVG/MathML text slots only neutral fragments (§3.6).
234
+
235
+ - **Booleans render nothing, `true` included** (as in JSX), so ``${cond && html`…`}`` works.
236
+ Numbers render, so ``${count && html`…`}`` prints `0` when the count is zero: write
237
+ `count > 0 && …`.
238
+ - **`script` and `style` accept only their serializers**, not even numbers: data goes into a
239
+ script through `jsonScript`.
240
+ - **An `html` fragment in an attribute value is an error**, not escaped: escaping would
241
+ silently change what trusted markup means.
242
+ - **`html` in `rcdata` is an error**: `<title>` and `<textarea>` hold text, and trusted
243
+ markup containing `</title>` would end the element.
244
+ - **`url` and `srcset` values are vetted, not escaped**: they are escaped wherever rendered
245
+ (`srcset` is also accepted in plain attribute values, e.g. a lazy loader's
246
+ `data-srcset`).
247
+ - **Arrays** render item by item under the slot's rules; nesting deeper than 100 is an error
248
+ (a cycle).
249
+ - **Promises and thenables** get a dedicated message: await before rendering.
250
+
251
+ ## 5. Algorithms
252
+
253
+ ### 5.1 HTML escaping
254
+
255
+ `&` → `&amp;`, `<` → `&lt;`, `>` → `&gt;`, `"` → `&quot;`, `'` → `&#39;`. Correct in text,
256
+ RCDATA and both quoted attribute forms (not in unquoted values, which are analysis errors).
257
+ Strings with none of these are returned unchanged.
258
+
259
+ ### 5.2 URL policy
260
+
261
+ 1. Detect the scheme as the URL parser would: skip leading C0 controls and space, ignore
262
+ tab/LF/CR anywhere, then `[A-Za-z][A-Za-z0-9+.-]*` followed by `:`. Anything else ends the
263
+ scheme: the URL is relative.
264
+ 2. Relative, or a lowercased scheme in the allowlist → allowed: the **original** string.
265
+ 3. Otherwise → blocked: `onBlockedUrl({ url, tag?, attribute? })` is called, and the kit's
266
+ `blockedUrl` (default `about:invalid#blocked`) is used instead (a blocked `srcset`
267
+ candidate is dropped).
268
+
269
+ Values are escaped after the policy, so entity tricks (`jav&#x09;ascript:`) reach the
270
+ browser as literal `&`, `#`, `x`, which is not a scheme.
271
+
272
+ ### 5.3 `jsonScript(value, space?)`
273
+
274
+ `JSON.stringify(value, null, space)`, then `<` → `\u003c`, `>` → `\u003e`,
275
+ `&` → `\u0026`, U+2028 → `\u2028`, U+2029 → `\u2029`. Valid JSON decoding to
276
+ the same value, and a valid JS expression; contains no `<`, so no `</script` or `<!--`. `HtmlValueError` if there is no JSON
277
+ representation; `JSON.stringify`'s own errors (cycles, BigInt) propagate.
278
+
279
+ ### 5.4 `scriptText(source)`
280
+
281
+ Every case-insensitive `</script` → `<\/script`; every `<!--` → `\x3C!--` (not `<\!--`: `\!`
282
+ is a SyntaxError in a `u`/`v` regex); and a `<` that ends the source, alone or followed by a
283
+ proper prefix of `/script` or `!--`, → `\x3C`. The first two keep the meaning inside JS
284
+ strings, template literals and regexes (and `</script` is invalid elsewhere). The third only
285
+ affects sources that end mid-token, which a whole script never does.
286
+
287
+ ### 5.5 `styleText(source)`
288
+
289
+ Every case-insensitive `</style` → `<\/style`, and a `<` that ends the source, alone or
290
+ followed by a proper prefix of `/style`, → `\00003C`. Other `<` are kept (media-query ranges
291
+ use them). Prevents breakout, not CSS injection.
292
+
293
+ ### 5.6 `srcset(candidates)`
294
+
295
+ Each URL goes through the policy unless it is a `SafeUrl`; blocked and empty candidates are
296
+ dropped. ASCII whitespace in a URL and leading/trailing commas are percent-encoded (the only
297
+ things that break `srcset` parsing). Descriptors must match `/^\d+(\.\d+)?x$|^\d+w$/`, else
298
+ `HtmlValueError` (checked even when the URL is blocked). Output: `url descriptor, …`,
299
+ unescaped; escaped where rendered.
300
+
301
+ ## 6. Errors: "data can't make a render throw"
302
+
303
+ | Error | When | Depends on |
304
+ | ------------------- | ------------------------------------------------------------------------------- | ----------------------------------- |
305
+ | `HtmlTemplateError` | not a template object, invalid escape, any §3 error, a disallowed static scheme | the template (and kit config) |
306
+ | `HtmlValueError` | ✗ cells in §4, `jsonScript` of `undefined`, nested arrays > 100 | the value's type, kind or structure |
307
+ | `HtmlValueError` | invalid attribute **name** in `attrs()`, invalid `srcset` **descriptor** | content expected to come from code |
308
+ | (none) | blocked URLs, `</script` in `scriptText`, odd characters anywhere | content: handled, never thrown |
309
+
310
+ `onBlockedUrl` may throw; that propagates (it is the caller's choice).
311
+
312
+ ## 7. Performance
313
+
314
+ Analysis is O(static text), twice per call site (HTML and foreign scans), cached in a
315
+ `WeakMap` keyed by the template object; a cache hit is one lookup. Rendering is one pass
316
+ with string concatenation (ropes, so nesting does not copy quadratically). See
317
+ `bench/render.bench.ts` and the README for numbers.
318
+
319
+ ## 8. Changes from the original draft
320
+
321
+ The first draft of this design was revised during implementation:
322
+
323
+ 1. **SVG/MathML are modeled** (§3.6). The draft scanned them as HTML and called that
324
+ "stricter than necessary for `<script>`/`<style>` inside SVG". It is the opposite: their
325
+ content is markup there, so `styleText()` could create elements, and SVG `<title>` was
326
+ misread as RCDATA.
327
+ 2. **Raw-text boundaries** (§3.2). The draft's `scriptText` only rewrote `</script` inside
328
+ its own output, so `a <${scriptText("/script>…")}` or a value ending in `</scr` followed
329
+ by static `ipt>` could close the element. Static `<!--` in scripts is now refused.
330
+ 3. **Static schemes are checked** (§3.4). `href="javascript:go('${id}')"` was accepted as
331
+ "prefix settles the scheme". Character references in the prefix are now handled
332
+ (`href="&#x6a;ava${x}"` was accepted).
333
+ 4. **URL slots may continue with `/`, `?`, `#`** (§3.4), which is provably safe and makes
334
+ `src="${cdn}/x.png"` work. The draft required the slot to be the whole value.
335
+ 5. **`rcdata` is its own context** that refuses `html` fragments (§4).
336
+ 6. **`attr-list` can't be followed by `=`** (§3.1), and `ping` is refused in `attrs()` as in
337
+ templates. `data` is URL-checked in `attrs()` (it can't know the tag).
338
+ 7. **SVG animation attributes** are refused (§3.3).
339
+ 8. **`SafeSrcset` stores the unescaped value** (like `SafeUrl`) and is escaped where
340
+ rendered; it is also accepted in plain attribute values.
341
+ 9. **`join()` skips `null`, `undefined` and booleans**, so conditional items leave no stray
342
+ separator.
343
+ 10. **Immutability without `Object.freeze`** (private fields): freezing cost more than the
344
+ rest of a small render.
345
+ 11. **Tests** use a seeded generator instead of `fast-check` (one dev dependency, `parse5`,
346
+ and deterministic runs), plus value round-trips through `parse5`.