@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.
package/README.md ADDED
@@ -0,0 +1,212 @@
1
+ # @marianmeres/safe-html
2
+
3
+ [![NPM](https://img.shields.io/npm/v/@marianmeres/safe-html)](https://www.npmjs.com/package/@marianmeres/safe-html)
4
+ [![JSR](https://jsr.io/badges/@marianmeres/safe-html)](https://jsr.io/@marianmeres/safe-html)
5
+ [![License](https://img.shields.io/npm/l/@marianmeres/safe-html)](LICENSE)
6
+
7
+ HTML tagged templates with **contextual escaping**. Every interpolated value is escaped for the
8
+ place it appears — element text, attribute value, URL attribute, `<script>`, `<style>` — as
9
+ worked out once per call site from the template's static markup. Slot positions that can never
10
+ be made safe (unquoted attributes, event handlers, tag names, comments) are rejected the first
11
+ time the template renders, whatever the data.
12
+
13
+ Zero dependencies. Runtime-agnostic ESM (Deno, Node, Bun, browsers). For servers that render
14
+ HTML strings without a framework.
15
+
16
+ ## Installation
17
+
18
+ ```sh
19
+ deno add jsr:@marianmeres/safe-html
20
+ ```
21
+
22
+ ```sh
23
+ npm install @marianmeres/safe-html
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ <!-- deno-fmt-ignore-start -->
29
+ ```ts
30
+ import { html, jsonScript } from "@marianmeres/safe-html";
31
+
32
+ interface Link {
33
+ label: string;
34
+ href: string;
35
+ }
36
+
37
+ const LinkItem = (link: Link) => html`<li><a href="${link.href}">${link.label}</a></li>`;
38
+
39
+ export const page = (title: string, links: Link[], ld: object) =>
40
+ html`<!doctype html>
41
+ <html lang="en">
42
+ <head>
43
+ <meta charset="utf-8">
44
+ <title>${title}</title>
45
+ <script type="application/ld+json">${jsonScript(ld)}</script>
46
+ </head>
47
+ <body>
48
+ <h1>${title}</h1>
49
+ ${links.length > 0 ? html`<ul>${links.map(LinkItem)}</ul>` : html`<p>No links.</p>`}
50
+ </body>
51
+ </html>`;
52
+
53
+ // String(page(…)) is the document.
54
+ // label "<img src=x onerror=alert(1)>" → rendered as text
55
+ // href "javascript:alert(1)" → rendered as href="about:invalid#blocked"
56
+ ```
57
+ <!-- deno-fmt-ignore-end -->
58
+
59
+ Components are plain functions returning `html`. Conditionals, lists and nesting are plain
60
+ expressions:
61
+
62
+ <!-- deno-fmt-ignore-start -->
63
+ ```ts
64
+ import { attrs, html, join, scriptText, srcset, styleText, url } from "@marianmeres/safe-html";
65
+
66
+ html`<p>${user.name}</p>`; // text: escaped
67
+ html`<p title="${note}" class="card ${kind}">…</p>`; // quoted attribute: escaped
68
+ html`<a href="${link}">…</a>`; // URL: scheme allowlist, then escaped
69
+ html`<img src="${cdn}/img/${file}.png">`; // URL slot followed by "/", "?" or "#"
70
+ html`<a href="/items/${id}?tab=${tab}">…</a>`; // static prefix settles the scheme
71
+ html`<button ${attrs({ type: "submit", disabled: busy })}>`; // attribute list
72
+ html`<img srcset="${srcset([{ url: a }, { url: b, descriptor: "2x" }])}">`;
73
+ html`<script>const data = ${jsonScript(data)};</script>`; // data island
74
+ html`<script>${scriptText(source)}</script>`; // inline code
75
+ html`<style>${styleText(css)}</style>`; // inline CSS
76
+ html`<p>${join(tags, ", ")}</p>`; // separated list
77
+ html`<ul>${items.map((i) => html`<li>${i}</li>`)}</ul>`; // arrays render item by item
78
+ html`${loggedIn && html`<a href="/logout">Log out</a>`}`; // false/null/undefined render nothing
79
+ html`<img src="${url(dataUri, { schemes: ["data"] })}">`; // allow a scheme for one value
80
+ ```
81
+ <!-- deno-fmt-ignore-end -->
82
+
83
+ ## What is rejected
84
+
85
+ <!-- deno-fmt-ignore-start -->
86
+ ```ts
87
+ html`<a href=${u}>`; // HtmlTemplateError: unquoted attribute value
88
+ html`<button onclick="${js}">`; // HtmlTemplateError: slot in event-handler attribute
89
+ html`<${tag}>`; // HtmlTemplateError: slot in tag name
90
+ html`<a href="${base}${path}">`; // HtmlTemplateError: URL slot must be alone (or continue with / ? #)
91
+ html`<a href="http${s}://x">`; // HtmlTemplateError: ambiguous scheme
92
+ html`<a href="javascript:go('${id}')">`; // HtmlTemplateError: static scheme not allowed
93
+ html`<!-- ${note} -->`; // HtmlTemplateError: slot in comment
94
+ html`<div class="x">${a}<b title="`; // HtmlTemplateError: template ends inside a tag
95
+ html`<svg><style>${styleText(css)}</style></svg>`; // HtmlTemplateError: not raw text inside SVG
96
+ html`<script>let n = ${n};</script>`; // HtmlValueError: script takes jsonScript()/scriptText()
97
+ html`<p title="${html`<b>x</b>`}">`; // HtmlValueError: html fragment in an attribute value
98
+ html`<p>${new Date()}</p>`; // HtmlValueError (and a type error)
99
+ ```
100
+ <!-- deno-fmt-ignore-end -->
101
+
102
+ Template errors depend only on the template, never on data, so rendering each template once in
103
+ a test surfaces them all. Value errors depend on a value's _type_, never on string content:
104
+ **a crafted string in your database can't turn a page into a 500**. Error messages never
105
+ include values.
106
+
107
+ ## Safety
108
+
109
+ **Guaranteed.** Given templates that pass analysis and no `unsafeRaw()`, no string value can:
110
+
111
+ - create an element, attribute, comment or declaration;
112
+ - close an element or attribute value that the template opened;
113
+ - inject script through an event-handler attribute (those slots are rejected);
114
+ - put a URL with a scheme outside the allowlist into a URL attribute (default: `http`,
115
+ `https`, `mailto`, `tel`), including through `srcset` or `attrs()`;
116
+ - break out of `<script>`, `<style>`, `<title>` or `<textarea>` content;
117
+ - change how SVG/MathML content parses.
118
+
119
+ This is tested against [parse5](https://github.com/inikulin/parse5), a spec-compliant HTML
120
+ parser: templates rendered with adversarial strings (XSS filter-evasion vectors plus seeded
121
+ fuzzing) must parse to the same tree as with benign values.
122
+
123
+ **Not guaranteed:**
124
+
125
+ - CSS injection inside `style="…"` or `styleText()`. A value can't break out, but it can
126
+ restyle the page or load a `url(…)`. Build style values from validated tokens.
127
+ - Semantic misuse: `<meta http-equiv="refresh" content="${x}">`, `<base href>` pointing at a
128
+ hostile `https:` host, open redirects, and `//host` protocol-relative URLs (relative, so
129
+ allowed).
130
+ - Attributes that other code later treats as URLs or code: `data-src`, `data-href`, and
131
+ client-side frameworks that evaluate attributes or text (Alpine `x-*`, htmx `hx-on*`,
132
+ Vue/Angular template syntax in server-rendered markup). Don't interpolate data there.
133
+ - DOM clobbering through data-controlled `id` or `name` values.
134
+ - A slot inside a JavaScript string literal in a `<script>`. `jsonScript()` output must be
135
+ used as a whole JS expression.
136
+ - URL component encoding: `href="/search?q=${q}"` is HTML-escaped, not
137
+ `encodeURIComponent`-ed. That is your job.
138
+ - Anything passed through `unsafeRaw()` — it means _trusted_, never _cleaned_. This is **not an
139
+ HTML sanitizer**: to accept HTML from users, sanitize it elsewhere first.
140
+ - Character encoding. Serve `content-type: text/html; charset=utf-8` and emit
141
+ `<meta charset="utf-8">`.
142
+
143
+ The full rules are in [docs/design.md](docs/design.md).
144
+
145
+ ## URL policy
146
+
147
+ The default exports use a default kit. Create your own for a different policy:
148
+
149
+ ```ts
150
+ import { createHtml, DEFAULT_URL_SCHEMES } from "@marianmeres/safe-html";
151
+
152
+ export const { html, attrs, url, srcset } = createHtml({
153
+ urlSchemes: [...DEFAULT_URL_SCHEMES, "sms"],
154
+ blockedUrl: "#blocked",
155
+ onBlockedUrl: ({ url, tag, attribute }) =>
156
+ log.warn("blocked URL", { tag, attribute }),
157
+ });
158
+ ```
159
+
160
+ ## Porting from Svelte templates
161
+
162
+ | Svelte | Here |
163
+ | ------------------------------ | -------------------------------------------------- |
164
+ | `{expr}` | `${expr}` |
165
+ | `{#if c}A{:else}B{/if}` | ``${c ? html`A` : html`B`}`` |
166
+ | `{#if c}A{/if}` | ``${c && html`A`}`` (careful when `c` is a number) |
167
+ | `{#each xs as x}…{/each}` | ``${xs.map((x) => html`…`)}`` |
168
+ | `{#snippet row(x)}…{/snippet}` | `` const row = (x: T) => html`…` `` / `${row(x)}` |
169
+ | `<Card {...props} />` | `${Card(props)}` |
170
+ | `<div {...rest}>` | `<div ${attrs(rest)}>` |
171
+ | `class:active={on}` | `class="${on ? "active" : ""}"` |
172
+ | `{@html jsonLd}` in `<script>` | `${jsonScript(data)}` |
173
+ | `{@html css}` in `<style>` | `${styleText(css)}` |
174
+ | `{@html anythingElse}` | `${unsafeRaw(s)}` (review every one) |
175
+ | `{#await}` | not supported; await the data before rendering |
176
+
177
+ ## Notes
178
+
179
+ - **Booleans render nothing, `true` included** (as in JSX). Numbers render, so
180
+ ``${count && html`…`}`` prints `0` for zero: write `count > 0 && …`.
181
+ - An **untagged** template literal around a fragment (`` `<p>${frag}</p>` ``) makes a plain
182
+ string, which `html` escapes: visibly double-escaped, never an injection.
183
+ - Every attribute value that contains a slot must be quoted.
184
+ - `attrs()` output starts with a space. When an attribute appears both statically and in
185
+ `attrs()`, the first one in the tag wins (HTML parsing rules).
186
+ - Whitespace is preserved exactly; nothing is minified.
187
+ - **`deno fmt` formats the contents of `html`-tagged templates** (whitespace, `/>`, line
188
+ breaks), which changes your output. Add `// deno-fmt-ignore` above a statement (or
189
+ `// deno-fmt-ignore-file`) where exact output matters.
190
+ - `unsafeRaw` is long and loud on purpose, so every use is greppable. A codebase can assert
191
+ in a test how many uses it has.
192
+
193
+ ## Performance
194
+
195
+ Analysis runs once per call site and is cached. A render is one pass of concatenation plus
196
+ escaping. Steady state on an Apple M2 (Deno 2.9, `deno task bench`):
197
+
198
+ | Benchmark | hand-written concatenation + manual escaping | safe-html | ratio |
199
+ | ------------------------- | -------------------------------------------- | --------- | ----- |
200
+ | 1 000-row table | 141 µs | 264 µs | ~1.9× |
201
+ | small page (31 templates) | 2.7 µs | 5.5 µs | ~2.0× |
202
+
203
+ The safe-html side does more work: every URL is scheme-checked and every fragment is a
204
+ branded value.
205
+
206
+ ## API
207
+
208
+ See [API.md](API.md) for the complete API.
209
+
210
+ ## License
211
+
212
+ [MIT](LICENSE)
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Errors. Messages never contain an interpolated value, so a thrown error can't leak data into
3
+ * logs. Template excerpts contain only static, developer-written text.
4
+ */
5
+ import type { SlotContext } from "./types.js";
6
+ /**
7
+ * A template the scanner refuses: a slot in a position that can never be made safe, markup the
8
+ * scanner does not model, or a call that is not a genuine tagged template. Depends on the
9
+ * template only, never on data, so rendering each template once in a test surfaces them all.
10
+ */
11
+ export declare class HtmlTemplateError extends Error {
12
+ name: string;
13
+ /** Index of the offending slot, when the error is about a slot. */
14
+ readonly slot?: number;
15
+ /** The static template text, slots shown as `${…}`, the error position as `⟨here⟩`. */
16
+ readonly excerpt: string;
17
+ constructor(message: string, excerpt?: string, slot?: number);
18
+ }
19
+ /**
20
+ * A value that has no rendering in the context it was placed in (depends on the value's type or
21
+ * trusted kind), or an invalid attribute name / srcset descriptor (content that is expected to
22
+ * come from code).
23
+ */
24
+ export declare class HtmlValueError extends TypeError {
25
+ name: string;
26
+ /** Index of the slot, when raised while rendering a template. */
27
+ readonly slot?: number;
28
+ /** Context of the slot, when raised while rendering a template or `join()`. */
29
+ readonly context?: SlotContext;
30
+ /** Type or trusted kind of the value, e.g. `"object"`, `"Promise"`, `"Trusted<html>"`. */
31
+ readonly received: string;
32
+ constructor(message: string, details: {
33
+ slot?: number;
34
+ context?: SlotContext;
35
+ received: string;
36
+ });
37
+ }
38
+ /** Describes a value by type or kind, never by content. */
39
+ export declare function describe(v: unknown): string;
package/dist/errors.js ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * A template the scanner refuses: a slot in a position that can never be made safe, markup the
3
+ * scanner does not model, or a call that is not a genuine tagged template. Depends on the
4
+ * template only, never on data, so rendering each template once in a test surfaces them all.
5
+ */
6
+ export class HtmlTemplateError extends Error {
7
+ name = "HtmlTemplateError";
8
+ /** Index of the offending slot, when the error is about a slot. */
9
+ slot;
10
+ /** The static template text, slots shown as `${…}`, the error position as `⟨here⟩`. */
11
+ excerpt;
12
+ constructor(message, excerpt = "", slot) {
13
+ super(excerpt
14
+ ? `${message}\n ${slot === undefined ? "in" : `slot ${slot} in`}: ${excerpt}`
15
+ : message);
16
+ this.excerpt = excerpt;
17
+ this.slot = slot;
18
+ }
19
+ }
20
+ /**
21
+ * A value that has no rendering in the context it was placed in (depends on the value's type or
22
+ * trusted kind), or an invalid attribute name / srcset descriptor (content that is expected to
23
+ * come from code).
24
+ */
25
+ export class HtmlValueError extends TypeError {
26
+ name = "HtmlValueError";
27
+ /** Index of the slot, when raised while rendering a template. */
28
+ slot;
29
+ /** Context of the slot, when raised while rendering a template or `join()`. */
30
+ context;
31
+ /** Type or trusted kind of the value, e.g. `"object"`, `"Promise"`, `"Trusted<html>"`. */
32
+ received;
33
+ constructor(message, details) {
34
+ const where = [
35
+ details.slot !== undefined ? `slot ${details.slot}` : "",
36
+ details.context ? `context ${details.context}` : "",
37
+ `received ${details.received}`,
38
+ ].filter(Boolean).join(", ");
39
+ super(`${message} (${where})`);
40
+ this.slot = details.slot;
41
+ this.context = details.context;
42
+ this.received = details.received;
43
+ }
44
+ }
45
+ /** Describes a value by type or kind, never by content. */
46
+ export function describe(v) {
47
+ if (v === null)
48
+ return "null";
49
+ const t = typeof v;
50
+ if (t !== "object")
51
+ return t;
52
+ // deno-lint-ignore no-explicit-any
53
+ const kind = v?.[Symbol.for("@marianmeres/safe-html/brand")] === true
54
+ // deno-lint-ignore no-explicit-any
55
+ ? v.kind
56
+ : undefined;
57
+ if (typeof kind === "string")
58
+ return `Trusted<${kind}>`;
59
+ if (Array.isArray(v))
60
+ return "array";
61
+ const tag = Object.prototype.toString.call(v).slice(8, -1);
62
+ return tag === "Object" ? "object" : tag;
63
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * HTML escaping: correct in text, RCDATA, and double- or single-quoted attribute values.
3
+ * Not enough for unquoted values, which is why those are template errors.
4
+ */
5
+ /** Escapes `& < > " '`. Returns the input unchanged when there is nothing to escape. */
6
+ export declare function escapeHtml(value: string): string;
package/dist/escape.js ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * HTML escaping: correct in text, RCDATA, and double- or single-quoted attribute values.
3
+ * Not enough for unquoted values, which is why those are template errors.
4
+ */
5
+ const TEST = /[&<>"']/;
6
+ /** Escapes `& < > " '`. Returns the input unchanged when there is nothing to escape. */
7
+ export function escapeHtml(value) {
8
+ const s = typeof value === "string" ? value : String(value);
9
+ if (!TEST.test(s))
10
+ return s;
11
+ let out = "";
12
+ let last = 0;
13
+ for (let i = 0; i < s.length; i++) {
14
+ let rep;
15
+ switch (s.charCodeAt(i)) {
16
+ case 38: // &
17
+ rep = "&amp;";
18
+ break;
19
+ case 60: // <
20
+ rep = "&lt;";
21
+ break;
22
+ case 62: // >
23
+ rep = "&gt;";
24
+ break;
25
+ case 34: // "
26
+ rep = "&quot;";
27
+ break;
28
+ case 39: // '
29
+ rep = "&#39;";
30
+ break;
31
+ default:
32
+ continue;
33
+ }
34
+ if (last !== i)
35
+ out += s.slice(last, i);
36
+ out += rep;
37
+ last = i + 1;
38
+ }
39
+ return out + s.slice(last);
40
+ }
@@ -0,0 +1,35 @@
1
+ import type { Renderable, SafeHtml, Trusted } from "./types.js";
2
+ /**
3
+ * `JSON.stringify(value, null, space)` made safe for `<script>` raw text: `<`, `>`, `&`,
4
+ * U+2028 and U+2029 become `\uXXXX` escapes. The result is valid JSON decoding to the same
5
+ * value, and a valid JavaScript expression. Accepted in `script` context only.
6
+ *
7
+ * Throws `HtmlValueError` when there is no JSON representation (a function, symbol or
8
+ * `undefined` at the root). `JSON.stringify`'s own errors (cycles, BigInt) propagate.
9
+ */
10
+ export declare function jsonScript(value: unknown, space?: number): Trusted<"json">;
11
+ /**
12
+ * Inline JavaScript that is code, not data. Every case-insensitive `</script` becomes
13
+ * `<\/script`, every `<!--` becomes `\x3C!--`, and a `<` that ends the source (alone or
14
+ * followed by the start of `/script` or `!--`) becomes `\x3C`, so the value can't complete
15
+ * those sequences with the static text after it. Accepted in `script` context only.
16
+ */
17
+ export declare function scriptText(source: string): Trusted<"js">;
18
+ /**
19
+ * Inline CSS. Every case-insensitive `</style` becomes `<\/style`, and a `<` that ends the
20
+ * source (alone or followed by the start of `/style`) becomes `\00003C`. Other `<` are kept
21
+ * (media-query ranges use them). Prevents breakout, not CSS injection. Accepted in `style`
22
+ * context only.
23
+ */
24
+ export declare function styleText(source: string): Trusted<"css">;
25
+ /**
26
+ * The escape hatch: inserted verbatim in any context, with no checks. Means *trusted*, never
27
+ * *cleaned*.
28
+ */
29
+ export declare function unsafeRaw(value: string): Trusted<"unsafe">;
30
+ /**
31
+ * Renders each value under the `text` rules, separated by `separator` (a string is escaped,
32
+ * `html` is inserted as is). `null`, `undefined` and booleans are skipped, so
33
+ * `join([a, cond && b], " · ")` leaves no stray separator.
34
+ */
35
+ export declare function join(values: readonly Renderable[], separator?: string | SafeHtml): SafeHtml;
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Policy-free helpers: jsonScript, scriptText, styleText, unsafeRaw, join.
3
+ */
4
+ import { describe, HtmlValueError } from "./errors.js";
5
+ import { escapeHtml } from "./escape.js";
6
+ import { renderValue } from "./render.js";
7
+ import { slotInfo } from "./scanner.js";
8
+ import { isNeutral, isTrusted, makeTrusted, trustedString } from "./trusted.js";
9
+ const JSON_ESCAPES = {
10
+ "<": "\\u003c",
11
+ ">": "\\u003e",
12
+ "&": "\\u0026",
13
+ "\u2028": "\\u2028",
14
+ "\u2029": "\\u2029",
15
+ };
16
+ /**
17
+ * `JSON.stringify(value, null, space)` made safe for `<script>` raw text: `<`, `>`, `&`,
18
+ * U+2028 and U+2029 become `\uXXXX` escapes. The result is valid JSON decoding to the same
19
+ * value, and a valid JavaScript expression. Accepted in `script` context only.
20
+ *
21
+ * Throws `HtmlValueError` when there is no JSON representation (a function, symbol or
22
+ * `undefined` at the root). `JSON.stringify`'s own errors (cycles, BigInt) propagate.
23
+ */
24
+ export function jsonScript(value, space) {
25
+ if (space !== undefined && typeof space !== "number") {
26
+ throw new HtmlValueError("jsonScript(): space must be a number", {
27
+ received: describe(space),
28
+ });
29
+ }
30
+ const json = JSON.stringify(value, null, space);
31
+ if (json === undefined) {
32
+ throw new HtmlValueError("jsonScript(): the value has no JSON representation", {
33
+ received: describe(value),
34
+ });
35
+ }
36
+ return makeTrusted("json", json.replace(/[<>&\u2028\u2029]/g, (c) => JSON_ESCAPES[c]));
37
+ }
38
+ // "<" followed by a proper prefix of "/script" or "!--" at the very end
39
+ const SCRIPT_TAIL = /<(?:\/(?:s(?:c(?:r(?:i(?:p)?)?)?)?)?|!-?)?$/i;
40
+ // "<" followed by a proper prefix of "/style" at the very end
41
+ const STYLE_TAIL = /<(?:\/(?:s(?:t(?:y(?:l)?)?)?)?)?$/i;
42
+ /**
43
+ * Inline JavaScript that is code, not data. Every case-insensitive `</script` becomes
44
+ * `<\/script`, every `<!--` becomes `\x3C!--`, and a `<` that ends the source (alone or
45
+ * followed by the start of `/script` or `!--`) becomes `\x3C`, so the value can't complete
46
+ * those sequences with the static text after it. Accepted in `script` context only.
47
+ */
48
+ export function scriptText(source) {
49
+ if (typeof source !== "string") {
50
+ throw new HtmlValueError("scriptText() takes a string", {
51
+ received: describe(source),
52
+ });
53
+ }
54
+ const s = source
55
+ .replace(/<\/(script)/gi, "<\\/$1")
56
+ .replace(/<!--/g, "\\x3C!--")
57
+ .replace(SCRIPT_TAIL, (m) => "\\x3C" + m.slice(1));
58
+ return makeTrusted("js", s);
59
+ }
60
+ /**
61
+ * Inline CSS. Every case-insensitive `</style` becomes `<\/style`, and a `<` that ends the
62
+ * source (alone or followed by the start of `/style`) becomes `\00003C`. Other `<` are kept
63
+ * (media-query ranges use them). Prevents breakout, not CSS injection. Accepted in `style`
64
+ * context only.
65
+ */
66
+ export function styleText(source) {
67
+ if (typeof source !== "string") {
68
+ throw new HtmlValueError("styleText() takes a string", {
69
+ received: describe(source),
70
+ });
71
+ }
72
+ const s = source
73
+ .replace(/<\/(style)/gi, "<\\/$1")
74
+ .replace(STYLE_TAIL, (m) => "\\00003C" + m.slice(1));
75
+ return makeTrusted("css", s);
76
+ }
77
+ /**
78
+ * The escape hatch: inserted verbatim in any context, with no checks. Means *trusted*, never
79
+ * *cleaned*.
80
+ */
81
+ export function unsafeRaw(value) {
82
+ if (typeof value !== "string") {
83
+ throw new HtmlValueError("unsafeRaw() takes a string", {
84
+ received: describe(value),
85
+ });
86
+ }
87
+ return makeTrusted("unsafe", value);
88
+ }
89
+ const TEXT_SLOT = slotInfo("text");
90
+ /**
91
+ * Renders each value under the `text` rules, separated by `separator` (a string is escaped,
92
+ * `html` is inserted as is). `null`, `undefined` and booleans are skipped, so
93
+ * `join([a, cond && b], " · ")` leaves no stray separator.
94
+ */
95
+ export function join(values, separator = "") {
96
+ if (!Array.isArray(values)) {
97
+ throw new HtmlValueError("join() takes an array", { received: describe(values) });
98
+ }
99
+ const st = { neutral: true };
100
+ let sep;
101
+ if (typeof separator === "string")
102
+ sep = escapeHtml(separator);
103
+ else if (isTrusted(separator, "html")) {
104
+ sep = trustedString(separator);
105
+ if (!isNeutral(separator))
106
+ st.neutral = false;
107
+ }
108
+ else {
109
+ throw new HtmlValueError("join(): the separator must be a string or html``", {
110
+ received: describe(separator),
111
+ });
112
+ }
113
+ let out = "";
114
+ let first = true;
115
+ for (let i = 0; i < values.length; i++) {
116
+ const v = values[i];
117
+ if (v === null || v === undefined || typeof v === "boolean")
118
+ continue;
119
+ const r = renderValue(v, TEXT_SLOT, undefined, undefined, st);
120
+ out += first ? r : sep + r;
121
+ first = false;
122
+ }
123
+ return makeTrusted("html", out, st.neutral);
124
+ }
package/dist/kit.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { HtmlKit, HtmlOptions } from "./types.js";
2
+ /**
3
+ * Creates a kit whose `html`, `attrs`, `url` and `srcset` use the given URL policy.
4
+ * Throws `TypeError` on malformed options.
5
+ */
6
+ export declare function createHtml(options?: HtmlOptions): HtmlKit;
7
+ /** The template tag of the default kit. Every interpolated value is escaped for its context. */
8
+ export declare const html: HtmlKit["html"];
9
+ /** `attrs()` of the default kit. */
10
+ export declare const attrs: HtmlKit["attrs"];
11
+ /** `url()` of the default kit. */
12
+ export declare const url: HtmlKit["url"];
13
+ /** `srcset()` of the default kit. */
14
+ export declare const srcset: HtmlKit["srcset"];