@marianmeres/safe-html 0.2.0 → 0.3.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/AGENTS.md CHANGED
@@ -18,11 +18,14 @@ src/types.ts public types (Trusted, Renderable, SlotContext, HtmlKit, …)
18
18
  src/trusted.ts TrustedValue (brand via Symbol.for keys, private fields), isTrusted
19
19
  src/escape.ts escapeHtml
20
20
  src/url.ts URL policy: urlScheme (URL-parser-accurate), vetUrl, createPolicy
21
- src/scanner.ts context analysis: tokenizer model, slot rules, neutrality, WeakMap cache
21
+ src/scanner.ts context analysis: tokenizer model, slot rules, neutrality, <noscript>,
22
+ <select>, cache
22
23
  src/render.ts value × context rules (renderValue)
23
24
  src/helpers.ts policy-free helpers: jsonScript, scriptText, styleText, unsafeRaw, join
24
25
  src/kit.ts createHtml (html, attrs, url, srcset bound to a policy) + default kit
25
- tests/ golden scanner tests, render table, helpers, SVG/MathML, parse5 invariance
26
+ tests/ golden scanner tests, render table, helpers, SVG/MathML, <noscript>, <select>,
27
+ parse5
28
+ invariance (parsed with scripting enabled and disabled)
26
29
  bench/ deno bench vs hand-written concatenation
27
30
  docs/design.md normative rules (read before changing scanner or render behavior)
28
31
  ```
@@ -66,10 +69,19 @@ docs/design.md normative rules (read before changing scanner or render behavio
66
69
  1. Read docs/design.md §3; locate the state in `Scanner.chunk()`, the slot rule in
67
70
  `Scanner.slot()` / `valueSlot()`, tag effects in `emitTag()` / `endTag()`.
68
71
  2. Add golden tests (context or exact error substring), and a corpus template in
69
- `tests/invariance.test.ts` if a new context shape is involved.
72
+ `tests/invariance.test.ts` if a new context shape is involved. Check the fuzz alphabet
73
+ (`PIECES`, `VECTORS` in `tests/_util.ts`) can spell the breakout, and that the vector is in
74
+ the nesting test's sample: break the rule on purpose and see the suite fail.
70
75
  3. Consider the neutrality scan (`new Scanner(strings, true)`): a rule that differs between
71
- HTML and foreign content must make the two scans disagree, not silently match.
72
- 4. Update docs/design.md (and §8 if it departs from earlier behavior).
76
+ HTML and foreign content must make the two scans disagree, not silently match. Consider
77
+ `<noscript>` too: its content must be safe both as markup and as raw text up to the first
78
+ `</noscript` (design §3.7). And tree-construction modes that ignore start tags (older
79
+ `<select>` parsing, design §3.8): parse5 parses that way, newer browsers don't.
80
+ 4. A fragment property that a nested fragment can break (neutral, noscript-safe, select-safe)
81
+ is a flag on the trusted value, computed per template in `compute()` and propagated
82
+ through `RenderState` in `kit.ts` and `join()`. A new flag gets a new `Symbol.for` key; a
83
+ value from an older copy of the package must read as the refusing default.
84
+ 5. Update docs/design.md (and §8 if it departs from earlier behavior).
73
85
 
74
86
  ### Change the value × context table
75
87
 
package/API.md CHANGED
@@ -39,6 +39,13 @@ for the context its slot sits in, as worked out once per call site from the stat
39
39
  Everywhere: `null`, `undefined`, `false` and `true` render nothing; `unsafeRaw()` is inserted
40
40
  verbatim. Anything else throws `HtmlValueError`.
41
41
 
42
+ Three places restrict which `html` fragments a `text` slot accepts: inside `<svg>`/`<math>`
43
+ only fragments that parse the same there; inside `<noscript>` only fragments without `script`
44
+ or `style` slots or a `</noscript`; inside `<select>` only fragments without the tags that older
45
+ parsers ignore there (`<title>`, `<style>`, `<svg>`, …) (design.md §3.6–§3.8). The property
46
+ carries through nesting and `join()`. `<noscript>` content also refuses `script` and `style`
47
+ slots of its own, and `<select>` content those tags.
48
+
42
49
  **Throws:** `HtmlTemplateError` if the template is refused (slot in an unquoted attribute,
43
50
  event handler, tag name, comment, …) or `html` is not called as a tagged template;
44
51
  `HtmlValueError` if a value has no rendering in its context.
package/README.md CHANGED
@@ -23,6 +23,9 @@ deno add jsr:@marianmeres/safe-html
23
23
  npm install @marianmeres/safe-html
24
24
  ```
25
25
 
26
+ `deno fmt` reformats the contents of `html`-tagged templates, which changes your output. Start
27
+ template files with `// deno-fmt-ignore-file` (see [Notes](#notes)).
28
+
26
29
  ## Usage
27
30
 
28
31
  <!-- deno-fmt-ignore-start -->
@@ -93,6 +96,8 @@ html`<a href="javascript:go('${id}')">`; // HtmlTemplateError: static scheme n
93
96
  html`<!-- ${note} -->`; // HtmlTemplateError: slot in comment
94
97
  html`<div class="x">${a}<b title="`; // HtmlTemplateError: template ends inside a tag
95
98
  html`<svg><style>${styleText(css)}</style></svg>`; // HtmlTemplateError: not raw text inside SVG
99
+ html`<noscript><script>${jsonScript(d)}</script></noscript>`; // HtmlTemplateError: script slot in <noscript>
100
+ html`<select><style>…</style></select>`; // HtmlTemplateError: older parsers ignore <style> there
96
101
  html`<script>let n = ${n};</script>`; // HtmlValueError: script takes jsonScript()/scriptText()
97
102
  html`<p title="${html`<b>x</b>`}">`; // HtmlValueError: html fragment in an attribute value
98
103
  html`<p>${new Date()}</p>`; // HtmlValueError (and a type error)
@@ -113,7 +118,7 @@ include values.
113
118
  - inject script through an event-handler attribute (those slots are rejected);
114
119
  - put a URL with a scheme outside the allowlist into a URL attribute (default: `http`,
115
120
  `https`, `mailto`, `tel`), including through `srcset` or `attrs()`;
116
- - break out of `<script>`, `<style>`, `<title>` or `<textarea>` content;
121
+ - break out of `<script>`, `<style>`, `<title>`, `<textarea>` or `<noscript>` content;
117
122
  - change how SVG/MathML content parses.
118
123
 
119
124
  This is tested against [parse5](https://github.com/inikulin/parse5), a spec-compliant HTML
@@ -174,6 +179,18 @@ export const { html, attrs, url, srcset } = createHtml({
174
179
  | `{@html anythingElse}` | `${unsafeRaw(s)}` (review every one) |
175
180
  | `{#await}` | not supported; await the data before rendering |
176
181
 
182
+ Differences in the output:
183
+
184
+ - **HTML comments are emitted.** Svelte strips `<!-- … -->` from SSR output; `html` keeps
185
+ them, so a comment in a template ships to every visitor. Move rationale into a JS comment
186
+ (e.g. the docblock of the function that returns the fragment).
187
+ - **Boolean attributes**: Svelte writes `selected=""`, `attrs({ selected: true })` writes
188
+ `selected`. Same DOM, different bytes.
189
+ - **Quotes in text**: Svelte writes `'` and `"` in element text as is; here they are `&#39;`
190
+ and `&quot;` in every context (see [docs/design.md](docs/design.md) §5.1). Same DOM.
191
+
192
+ Tests that pin markup bytes need adjusting for these; tests that compare parsed text don't.
193
+
177
194
  ## Notes
178
195
 
179
196
  - **Booleans render nothing, `true` included** (as in JSX). Numbers render, so
@@ -181,6 +198,12 @@ export const { html, attrs, url, srcset } = createHtml({
181
198
  - An **untagged** template literal around a fragment (`` `<p>${frag}</p>` ``) makes a plain
182
199
  string, which `html` escapes: visibly double-escaped, never an injection.
183
200
  - Every attribute value that contains a slot must be quoted.
201
+ - **`<noscript>`** content works like any other markup, except that it can't hold `<script>`
202
+ or `<style>` slots, or `html` fragments that have them (with scripting enabled, the element
203
+ ends at the first `</noscript`, even one inside a script string).
204
+ - **`<select>`** can't contain `<title>`, `<style>`, `<svg>`, `<math>`, `<template>` or the
205
+ other tags that older parsers ignore there (their content would be parsed as markup), nor
206
+ `html` fragments that have them. Options, optgroups, text and `<script>` are fine.
184
207
  - `attrs()` output starts with a space. When an attribute appears both statically and in
185
208
  `attrs()`, the first one in the tag wins (HTML parsing rules).
186
209
  - Whitespace is preserved exactly; nothing is minified.
package/dist/helpers.js CHANGED
@@ -5,7 +5,7 @@ import { describe, HtmlValueError } from "./errors.js";
5
5
  import { escapeHtml } from "./escape.js";
6
6
  import { renderValue } from "./render.js";
7
7
  import { slotInfo } from "./scanner.js";
8
- import { isNeutral, isTrusted, makeTrusted, trustedString } from "./trusted.js";
8
+ import { isNeutral, isNoscriptSafe, isSelectSafe, isTrusted, makeTrusted, trustedString, } from "./trusted.js";
9
9
  const JSON_ESCAPES = {
10
10
  "<": "\\u003c",
11
11
  ">": "\\u003e",
@@ -96,7 +96,7 @@ export function join(values, separator = "") {
96
96
  if (!Array.isArray(values)) {
97
97
  throw new HtmlValueError("join() takes an array", { received: describe(values) });
98
98
  }
99
- const st = { neutral: true };
99
+ const st = { neutral: true, noscript: true, select: true };
100
100
  let sep;
101
101
  if (typeof separator === "string")
102
102
  sep = escapeHtml(separator);
@@ -104,6 +104,10 @@ export function join(values, separator = "") {
104
104
  sep = trustedString(separator);
105
105
  if (!isNeutral(separator))
106
106
  st.neutral = false;
107
+ if (!isNoscriptSafe(separator))
108
+ st.noscript = false;
109
+ if (!isSelectSafe(separator))
110
+ st.select = false;
107
111
  }
108
112
  else {
109
113
  throw new HtmlValueError("join(): the separator must be a string or html``", {
@@ -120,5 +124,5 @@ export function join(values, separator = "") {
120
124
  out += first ? r : sep + r;
121
125
  first = false;
122
126
  }
123
- return makeTrusted("html", out, st.neutral);
127
+ return makeTrusted("html", out, st.neutral, st.noscript, st.select);
124
128
  }
package/dist/kit.js CHANGED
@@ -29,7 +29,11 @@ export function createHtml(options = {}) {
29
29
  if (values.length !== slots.length) {
30
30
  throw templateError(strings, "html must be called as a tagged template: html`…`");
31
31
  }
32
- const st = { neutral: a.neutral };
32
+ const st = {
33
+ neutral: a.neutral,
34
+ noscript: a.noscript,
35
+ select: a.select,
36
+ };
33
37
  let out = strings[0];
34
38
  for (let i = 0; i < slots.length; i++) {
35
39
  const slot = slots[i];
@@ -38,7 +42,7 @@ export function createHtml(options = {}) {
38
42
  }
39
43
  out += renderValue(values[i], slot, i, policy, st) + strings[i + 1];
40
44
  }
41
- return makeTrusted("html", out, st.neutral);
45
+ return makeTrusted("html", out, st.neutral, st.noscript, st.select);
42
46
  }
43
47
  function attrValue(lower, value, n) {
44
48
  if (lower === "srcset" || lower === "imagesrcset") {
package/dist/render.d.ts CHANGED
@@ -4,6 +4,10 @@ import { type UrlPolicy } from "./url.js";
4
4
  export interface RenderState {
5
5
  /** Stays true while every HTML fragment placed in a text slot is neutral. */
6
6
  neutral: boolean;
7
+ /** Stays true while every HTML fragment placed in a text slot is noscript-safe. */
8
+ noscript: boolean;
9
+ /** Stays true while every HTML fragment placed in a text slot is select-safe. */
10
+ select: boolean;
7
11
  }
8
12
  /** Renders one interpolated value for a slot. */
9
13
  export declare function renderValue(v: unknown, slot: SlotInfo, index: number | undefined, policy: UrlPolicy | undefined, st: RenderState, depth?: number): string;
package/dist/render.js CHANGED
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { describe, HtmlValueError } from "./errors.js";
6
6
  import { escapeHtml } from "./escape.js";
7
- import { isNeutral, isTrusted, trustedString } from "./trusted.js";
7
+ import { isNeutral, isNoscriptSafe, isSelectSafe, isTrusted, trustedString, } from "./trusted.js";
8
8
  import { vetUrl } from "./url.js";
9
9
  /** Guards against cyclic arrays. */
10
10
  const MAX_DEPTH = 100;
@@ -75,6 +75,16 @@ function renderTrusted(v, slot, st) {
75
75
  return undefined;
76
76
  st.neutral = false;
77
77
  }
78
+ if (!isNoscriptSafe(v)) {
79
+ if (slot.noscript)
80
+ return undefined;
81
+ st.noscript = false;
82
+ }
83
+ if (!isSelectSafe(v)) {
84
+ if (slot.select)
85
+ return undefined;
86
+ st.select = false;
87
+ }
78
88
  return s;
79
89
  case "attrs":
80
90
  return ctx === "attr-list" ? s : undefined;
@@ -107,8 +117,11 @@ function refuse(v, slot, index) {
107
117
  }
108
118
  else if (isTrusted(v)) {
109
119
  if (v.kind === "html" && ctx === "text") {
110
- msg =
111
- "this HTML fragment can't go inside <svg>/<math>: it contains markup that parses differently there (e.g. <style>, <p>)";
120
+ msg = slot.foreign && !isNeutral(v)
121
+ ? "this HTML fragment can't go inside <svg>/<math>: it contains markup that parses differently there (e.g. <style>, <p>)"
122
+ : slot.noscript && !isNoscriptSafe(v)
123
+ ? 'this HTML fragment can\'t go inside <noscript>: it has a <script>/<style> slot or a "</noscript" that would end the element'
124
+ : "this HTML fragment can't go inside <select>: it contains a tag that older parsers ignore there (e.g. <style>, <title>, <svg>)";
112
125
  }
113
126
  else if (v.kind === "html" && (ctx === "attr-value" || ctx === "url")) {
114
127
  msg = "an html fragment can't be an attribute value";
package/dist/scanner.d.ts CHANGED
@@ -13,6 +13,12 @@
13
13
  * content. That makes a fragment safe to drop into another template's text slot.
14
14
  * - Foreign content: a fragment is "neutral" when a second scan that starts inside SVG/MathML
15
15
  * agrees with the HTML scan; only neutral fragments may go into SVG/MathML text slots.
16
+ * - `<noscript>` content is scanned as markup (scripting disabled) and must end where the
17
+ * raw-text reading (scripting enabled) ends it: at its first `</noscript`. Only fragments
18
+ * that can't produce a `</noscript` may go into its text slots.
19
+ * - Inside `<select>`, older parsers ignore the start tags in `SELECT_REFUSED` and parse their
20
+ * content as markup, so those are refused there, and only fragments without them may go
21
+ * into its text slots.
16
22
  */
17
23
  import { HtmlTemplateError } from "./errors.js";
18
24
  import type { SlotContext } from "./types.js";
@@ -27,14 +33,22 @@ export interface SlotInfo {
27
33
  readonly scheme: string;
28
34
  /** A `text` slot inside SVG/MathML: HTML fragments must be neutral. */
29
35
  readonly foreign: boolean;
36
+ /** A `text` slot inside `<noscript>`: HTML fragments must be noscript-safe. */
37
+ readonly noscript: boolean;
38
+ /** A `text` slot inside `<select>`: HTML fragments must be select-safe. */
39
+ readonly select: boolean;
30
40
  }
31
41
  /** Creates a slot description (always the same shape, which keeps rendering monomorphic). */
32
- export declare function slotInfo(context: SlotContext, tag?: string, attr?: string, scheme?: string, foreign?: boolean): SlotInfo;
42
+ export declare function slotInfo(context: SlotContext, tag?: string, attr?: string, scheme?: string, foreign?: boolean, noscript?: boolean, select?: boolean): SlotInfo;
33
43
  /** The cached result for one call site. */
34
44
  export interface Analysis {
35
45
  readonly slots: readonly SlotInfo[];
36
46
  /** Parses the same inside SVG/MathML as in HTML (see module doc). */
37
47
  readonly neutral: boolean;
48
+ /** May go inside `<noscript>`: can't produce a `</noscript` (see `noscriptSafe`). */
49
+ readonly noscript: boolean;
50
+ /** May go inside `<select>`: has no start tag from `SELECT_REFUSED`. */
51
+ readonly select: boolean;
38
52
  }
39
53
  /** Attributes whose value is a URL (plus `data` on `<object>`). */
40
54
  export declare const URL_ATTRIBUTES: ReadonlySet<string>;
package/dist/scanner.js CHANGED
@@ -13,19 +13,28 @@
13
13
  * content. That makes a fragment safe to drop into another template's text slot.
14
14
  * - Foreign content: a fragment is "neutral" when a second scan that starts inside SVG/MathML
15
15
  * agrees with the HTML scan; only neutral fragments may go into SVG/MathML text slots.
16
+ * - `<noscript>` content is scanned as markup (scripting disabled) and must end where the
17
+ * raw-text reading (scripting enabled) ends it: at its first `</noscript`. Only fragments
18
+ * that can't produce a `</noscript` may go into its text slots.
19
+ * - Inside `<select>`, older parsers ignore the start tags in `SELECT_REFUSED` and parse their
20
+ * content as markup, so those are refused there, and only fragments without them may go
21
+ * into its text slots.
16
22
  */
17
23
  import { HtmlTemplateError } from "./errors.js";
18
24
  import { LEADING_C0_SPACE, ONLY_C0_SPACE } from "./url.js";
19
25
  /** Creates a slot description (always the same shape, which keeps rendering monomorphic). */
20
- export function slotInfo(context, tag = "", attr = "", scheme = "", foreign = false) {
21
- return Object.freeze({ context, tag, attr, scheme, foreign });
26
+ export function slotInfo(context, tag = "", attr = "", scheme = "", foreign = false, noscript = false, select = false) {
27
+ return Object.freeze({ context, tag, attr, scheme, foreign, noscript, select });
22
28
  }
23
29
  // ---- character classes ------------------------------------------------------------------
24
30
  const isWs = (c) => c === " " || c === "\t" || c === "\n" || c === "\f" || c === "\r";
25
31
  const isAlpha = (c) => (c >= "a" && c <= "z") || (c >= "A" && c <= "Z");
26
32
  const isSchemeChar = (c) => isAlpha(c) || (c >= "0" && c <= "9") || c === "+" || c === "." || c === "-";
27
33
  const WS_ONLY = /^[\t\n\f\r ]*$/;
28
- /** Elements whose start tag switches the tokenizer (HTML mode only). */
34
+ /**
35
+ * Elements whose start tag switches the tokenizer (HTML mode only). `<noscript>` is not here:
36
+ * it is handled on its own (see `Scanner.noscript`).
37
+ */
29
38
  const RAW_KIND = new Map([
30
39
  ["script", "script"],
31
40
  ["style", "style"],
@@ -34,10 +43,28 @@ const RAW_KIND = new Map([
34
43
  ["iframe", "unsupported"],
35
44
  ["noembed", "unsupported"],
36
45
  ["noframes", "unsupported"],
37
- ["noscript", "unsupported"],
38
46
  ["xmp", "unsupported"],
39
47
  ["plaintext", "plaintext"],
40
48
  ]);
49
+ /**
50
+ * Start tags that older parsers ("in select" insertion mode) ignore inside `<select>`, parsing
51
+ * their content as markup: raw text, RCDATA and foreign content that the scanner would model
52
+ * otherwise. `<template>` is processed there, but a `</select>` inside it is ignored, so the
53
+ * scanner would leave the `<select>` too early. (`<script>` and `<textarea>` parse the same.)
54
+ */
55
+ const SELECT_REFUSED = new Set([
56
+ "title",
57
+ "style",
58
+ "xmp",
59
+ "iframe",
60
+ "noembed",
61
+ "noframes",
62
+ "noscript",
63
+ "plaintext",
64
+ "svg",
65
+ "math",
66
+ "template",
67
+ ]);
41
68
  /** Start tags that end SVG/MathML foreign content (`font` only with some attributes; always here). */
42
69
  const BREAKOUT = new Set(("b big blockquote body br center code dd div dl dt em embed font h1 h2 h3 h4 h5 h6 head " +
43
70
  "hr i img li listing menu meta nobr ol p pre ruby s small span strong strike sub sup " +
@@ -124,6 +151,13 @@ function endsWithPartial(text, seq) {
124
151
  const lt = text.lastIndexOf("<");
125
152
  return lt !== -1 && seq.startsWith(text.slice(lt).toLowerCase());
126
153
  }
154
+ /** Index of the first `</noscript` end tag in `s` at or after `from`, else `Infinity`. */
155
+ function noscriptEnd(s, from) {
156
+ const re = closerRe("noscript");
157
+ re.lastIndex = from;
158
+ const m = re.exec(s);
159
+ return m ? m.index : Infinity;
160
+ }
127
161
  /**
128
162
  * The static text before the first slot of a URL attribute (normalized, non-empty):
129
163
  * returns the lowercased scheme it writes, `""` if it settles the URL as relative, or `false`
@@ -163,6 +197,15 @@ class Scanner {
163
197
  raw = "";
164
198
  rawKind = "script";
165
199
  rawStart = 0;
200
+ // <noscript> (HTML mode). Its content is scanned as markup, which is how it parses with
201
+ // scripting disabled. With scripting enabled it is raw text up to the first "</noscript",
202
+ // so that must be where the markup reading ends it too: nsEnd is the index of that
203
+ // "</noscript" in the current chunk, and the scan must reach it in text.
204
+ noscript = false;
205
+ nsEnd = Infinity;
206
+ // <select> (HTML mode), and whether the template has a start tag refused inside it
207
+ select = false;
208
+ selectUnsafe = false;
166
209
  // foreign content: ns is the namespace of the outermost <svg>/<math> ("generic" for the
167
210
  // virtual one a neutrality scan starts inside); stack holds the open svg/math elements
168
211
  ns = "";
@@ -192,6 +235,8 @@ class Scanner {
192
235
  }
193
236
  chunk(s) {
194
237
  this.rawStart = 0;
238
+ if (this.noscript)
239
+ this.nsEnd = noscriptEnd(s, 0);
195
240
  const len = s.length;
196
241
  let i = 0;
197
242
  while (i < len) {
@@ -202,6 +247,8 @@ class Scanner {
202
247
  if (j === -1)
203
248
  i = len;
204
249
  else {
250
+ if (j === this.nsEnd)
251
+ this.nsEnd = Infinity; // reached in text: the end tag
205
252
  this.state = TAG_OPEN;
206
253
  i = j + 1;
207
254
  }
@@ -369,6 +416,9 @@ class Scanner {
369
416
  default: // COMMENT, MARKUP_DECL: unterminated in this chunk
370
417
  i = len;
371
418
  }
419
+ if (i > this.nsEnd) {
420
+ this.fail('"</noscript" inside <noscript> that is not its end tag: with scripting enabled, it would end the element there', this.nsEnd);
421
+ }
372
422
  }
373
423
  }
374
424
  startTag(c, isEnd) {
@@ -459,6 +509,12 @@ class Scanner {
459
509
  }
460
510
  return i + 1;
461
511
  }
512
+ if (SELECT_REFUSED.has(tag)) {
513
+ if (this.select) {
514
+ this.fail(`<${tag}> inside <select> is not supported: older parsers ignore it there and parse its content as markup`, i);
515
+ }
516
+ this.selectUnsafe = true;
517
+ }
462
518
  if (tag === "svg" || tag === "math") {
463
519
  if (!this.selfClosing) {
464
520
  this.ns = tag;
@@ -466,7 +522,18 @@ class Scanner {
466
522
  }
467
523
  return i + 1;
468
524
  }
469
- const kind = RAW_KIND.get(tag); // the self-closing flag is ignored here, as in HTML
525
+ // the self-closing flag is ignored below, as in HTML
526
+ if (tag === "select")
527
+ this.select = true;
528
+ if (tag === "noscript") {
529
+ if (this.noscript) {
530
+ this.fail("<noscript> inside <noscript> is not supported", i);
531
+ }
532
+ this.noscript = true;
533
+ this.nsEnd = noscriptEnd(this.strings[this.k], i + 1);
534
+ return i + 1;
535
+ }
536
+ const kind = RAW_KIND.get(tag);
470
537
  if (kind) {
471
538
  this.raw = tag;
472
539
  this.rawKind = kind;
@@ -487,8 +554,15 @@ class Scanner {
487
554
  if (tag === "svg" || tag === "math") {
488
555
  this.fail(`</${tag}> without a matching <${tag}> in the same template`, i);
489
556
  }
557
+ if (tag === "noscript")
558
+ this.noscript = false;
559
+ else if (tag === "select")
560
+ this.select = false;
490
561
  return;
491
562
  }
563
+ if (tag === "noscript" && this.noscript) {
564
+ this.fail("</noscript> inside <svg>/<math>: close the <svg>/<math> first", i);
565
+ }
492
566
  if (tag === "br" || tag === "p") {
493
567
  this.fail(`</${tag}> inside <svg>/<math> ends the foreign content`, i);
494
568
  }
@@ -515,16 +589,21 @@ class Scanner {
515
589
  if (this.foreignRaw) {
516
590
  fail(`slot inside <${this.foreignRaw}> within <svg>/<math> is not supported`);
517
591
  }
518
- this.slots.push(slotInfo("text", "", "", "", this.integration ? !this.integrationHtml : this.ns !== ""));
592
+ this.slots.push(slotInfo("text", "", "", "", this.integration ? !this.integrationHtml : this.ns !== "", this.noscript, this.select));
519
593
  return;
520
594
  case RAW: {
521
595
  const kind = this.rawKind;
522
596
  if (kind === "unsupported" || kind === "plaintext") {
523
597
  fail(`slot inside <${this.raw}> is not supported`);
524
598
  }
599
+ if (this.noscript && (kind === "script" || kind === "style")) {
600
+ // scriptText()/styleText() may emit "</noscript", which ends the <noscript>
601
+ fail(`slot inside <${this.raw}> within <noscript> is not supported`);
602
+ }
525
603
  const before = this.strings[k].slice(this.rawStart);
526
604
  if (endsWithPartial(before, "</" + this.raw) ||
527
- (kind === "script" && endsWithPartial(before, "<!--"))) {
605
+ (kind === "script" && endsWithPartial(before, "<!--")) ||
606
+ (this.noscript && endsWithPartial(before, "</noscript"))) {
528
607
  fail(`slot right after "<" inside <${this.raw}>: add a space between them`);
529
608
  }
530
609
  this.slots.push(slotInfo(kind, this.raw));
@@ -583,6 +662,9 @@ class Scanner {
583
662
  if (SVG_ANIMATION.has(tag) && SVG_ANIMATION_ATTRS.has(attr)) {
584
663
  fail(`slot in "${attr}" on <${tag}> is not supported (SVG animation can set href)`);
585
664
  }
665
+ if (this.noscript && endsWithPartial(this.valueStatic, "</noscript")) {
666
+ fail('slot right after "<" inside <noscript>: add a space between them');
667
+ }
586
668
  const q = next.indexOf(this.quote);
587
669
  const after = q === -1 ? null : next.slice(0, q); // null: another slot comes first
588
670
  let info;
@@ -642,6 +724,12 @@ class Scanner {
642
724
  }
643
725
  if (this.integration)
644
726
  fail(`template ends inside <${this.integration}>`);
727
+ if (this.noscript) {
728
+ fail("template ends inside <noscript>: close it in the same template");
729
+ }
730
+ if (this.select) {
731
+ fail("template ends inside <select>: close it in the same template");
732
+ }
645
733
  if (this.foreignRaw)
646
734
  fail(`template ends inside <${this.foreignRaw}>`);
647
735
  if (this.stack.length) {
@@ -656,6 +744,24 @@ let analyses = 0;
656
744
  export function analysisCount() {
657
745
  return analyses;
658
746
  }
747
+ /**
748
+ * May the template's markup go inside `<noscript>`? With scripting enabled, everything there up
749
+ * to the first `</noscript` is text, so the template must not contain one (a nested
750
+ * `<noscript>` included), must not be able to form one with a value (a chunk ending with a
751
+ * prefix of it before a slot), and must not have script or style slots (`scriptText()` and
752
+ * `styleText()` may emit one). Other slots render no `<`.
753
+ */
754
+ function noscriptSafe(strings, slots) {
755
+ const re = closerRe("noscript");
756
+ for (let k = 0; k < strings.length; k++) {
757
+ re.lastIndex = 0;
758
+ if (re.test(strings[k]))
759
+ return false;
760
+ if (k < slots.length && endsWithPartial(strings[k], "</noscript"))
761
+ return false;
762
+ }
763
+ return slots.every((s) => s.context !== "script" && s.context !== "style");
764
+ }
659
765
  function compute(strings) {
660
766
  for (let i = 0; i < strings.length; i++) {
661
767
  if (typeof strings[i] !== "string") {
@@ -663,7 +769,8 @@ function compute(strings) {
663
769
  }
664
770
  }
665
771
  try {
666
- const slots = new Scanner(strings, false).run();
772
+ const scan = new Scanner(strings, false);
773
+ const slots = scan.run();
667
774
  let neutral = false;
668
775
  try {
669
776
  const f = new Scanner(strings, true).run();
@@ -680,7 +787,12 @@ function compute(strings) {
680
787
  if (!(e instanceof ScanError))
681
788
  throw e;
682
789
  }
683
- return { slots, neutral };
790
+ return {
791
+ slots,
792
+ neutral,
793
+ noscript: noscriptSafe(strings, slots),
794
+ select: !scan.selectUnsafe,
795
+ };
684
796
  }
685
797
  catch (e) {
686
798
  if (e instanceof ScanError)
package/dist/trusted.d.ts CHANGED
@@ -5,6 +5,8 @@
5
5
  import type { Trusted, TrustedKind } from "./types.js";
6
6
  declare const VALUE: unique symbol;
7
7
  declare const NEUTRAL: unique symbol;
8
+ declare const NOSCRIPT: unique symbol;
9
+ declare const SELECT: unique symbol;
8
10
  /**
9
11
  * The implementation. State lives in private fields behind getters, so a value is immutable
10
12
  * without the cost of `Object.freeze` (there is no setter, and nothing outside this class can
@@ -13,23 +15,33 @@ declare const NEUTRAL: unique symbol;
13
15
  */
14
16
  export declare class TrustedValue<K extends TrustedKind = TrustedKind> implements Trusted<K> {
15
17
  #private;
16
- constructor(kind: K, value: string, neutral: boolean);
18
+ constructor(kind: K, value: string, neutral: boolean, noscript: boolean, select: boolean);
17
19
  get kind(): K;
18
20
  get [VALUE](): string;
19
21
  get [NEUTRAL](): boolean;
22
+ get [NOSCRIPT](): boolean;
23
+ get [SELECT](): boolean;
20
24
  toString(): string;
21
25
  toJSON(): string;
22
26
  [Symbol.toPrimitive](): string;
23
27
  }
24
28
  /**
25
- * Creates a trusted value. `neutral` (html only) marks markup that parses the same inside
26
- * `<svg>`/`<math>` as in HTML (see scanner.ts).
29
+ * Creates a trusted value. For html only: `neutral` marks markup that parses the same inside
30
+ * `<svg>`/`<math>` as in HTML, `noscript` markup that may go inside `<noscript>`, `select`
31
+ * markup that may go inside `<select>` (see scanner.ts).
27
32
  */
28
- export declare function makeTrusted<K extends TrustedKind>(kind: K, value: string, neutral?: boolean): Trusted<K>;
33
+ export declare function makeTrusted<K extends TrustedKind>(kind: K, value: string, neutral?: boolean, noscript?: boolean, select?: boolean): Trusted<K>;
29
34
  /** Is `v` a value produced by this package (optionally, of the given kind)? */
30
35
  export declare function isTrusted<K extends TrustedKind>(v: unknown, kind?: K): v is Trusted<K>;
31
36
  /** The string of a value already known to be trusted. */
32
37
  export declare function trustedString(v: Trusted): string;
33
38
  /** Whether trusted markup may be placed into SVG/MathML text. */
34
39
  export declare function isNeutral(v: Trusted): boolean;
40
+ /**
41
+ * Whether trusted markup may be placed into `<noscript>` text. Values from a copy of the
42
+ * package that predates the flag read as `false` (refused there).
43
+ */
44
+ export declare function isNoscriptSafe(v: Trusted): boolean;
45
+ /** Whether trusted markup may be placed into `<select>` text (older copies: `false`). */
46
+ export declare function isSelectSafe(v: Trusted): boolean;
35
47
  export {};
package/dist/trusted.js CHANGED
@@ -1,6 +1,8 @@
1
1
  const BRAND = Symbol.for("@marianmeres/safe-html/brand");
2
2
  const VALUE = Symbol.for("@marianmeres/safe-html/value");
3
3
  const NEUTRAL = Symbol.for("@marianmeres/safe-html/neutral");
4
+ const NOSCRIPT = Symbol.for("@marianmeres/safe-html/noscript");
5
+ const SELECT = Symbol.for("@marianmeres/safe-html/select");
4
6
  /**
5
7
  * The implementation. State lives in private fields behind getters, so a value is immutable
6
8
  * without the cost of `Object.freeze` (there is no setter, and nothing outside this class can
@@ -11,10 +13,14 @@ export class TrustedValue {
11
13
  #kind;
12
14
  #value;
13
15
  #neutral;
14
- constructor(kind, value, neutral) {
16
+ #noscript;
17
+ #select;
18
+ constructor(kind, value, neutral, noscript, select) {
15
19
  this.#kind = kind;
16
20
  this.#value = value;
17
21
  this.#neutral = neutral;
22
+ this.#noscript = noscript;
23
+ this.#select = select;
18
24
  }
19
25
  get kind() {
20
26
  return this.#kind;
@@ -25,6 +31,12 @@ export class TrustedValue {
25
31
  get [NEUTRAL]() {
26
32
  return this.#neutral;
27
33
  }
34
+ get [NOSCRIPT]() {
35
+ return this.#noscript;
36
+ }
37
+ get [SELECT]() {
38
+ return this.#select;
39
+ }
28
40
  toString() {
29
41
  return this.#value;
30
42
  }
@@ -38,11 +50,12 @@ export class TrustedValue {
38
50
  Object.defineProperty(TrustedValue.prototype, BRAND, { value: true });
39
51
  Object.freeze(TrustedValue.prototype);
40
52
  /**
41
- * Creates a trusted value. `neutral` (html only) marks markup that parses the same inside
42
- * `<svg>`/`<math>` as in HTML (see scanner.ts).
53
+ * Creates a trusted value. For html only: `neutral` marks markup that parses the same inside
54
+ * `<svg>`/`<math>` as in HTML, `noscript` markup that may go inside `<noscript>`, `select`
55
+ * markup that may go inside `<select>` (see scanner.ts).
43
56
  */
44
- export function makeTrusted(kind, value, neutral = false) {
45
- return new TrustedValue(kind, value, neutral);
57
+ export function makeTrusted(kind, value, neutral = false, noscript = false, select = false) {
58
+ return new TrustedValue(kind, value, neutral, noscript, select);
46
59
  }
47
60
  /** Is `v` a value produced by this package (optionally, of the given kind)? */
48
61
  export function isTrusted(v, kind) {
@@ -66,3 +79,16 @@ export function isNeutral(v) {
66
79
  // deno-lint-ignore no-explicit-any
67
80
  return v[NEUTRAL] === true;
68
81
  }
82
+ /**
83
+ * Whether trusted markup may be placed into `<noscript>` text. Values from a copy of the
84
+ * package that predates the flag read as `false` (refused there).
85
+ */
86
+ export function isNoscriptSafe(v) {
87
+ // deno-lint-ignore no-explicit-any
88
+ return v[NOSCRIPT] === true;
89
+ }
90
+ /** Whether trusted markup may be placed into `<select>` text (older copies: `false`). */
91
+ export function isSelectSafe(v) {
92
+ // deno-lint-ignore no-explicit-any
93
+ return v[SELECT] === true;
94
+ }
package/docs/design.md CHANGED
@@ -40,7 +40,7 @@ Two phases:
40
40
  - inject script through an event-handler attribute (those slots are rejected);
41
41
  - put a URL with a scheme outside the allowlist into a URL attribute (default: `http`,
42
42
  `https`, `mailto`, `tel`), including through `srcset` or `attrs()`;
43
- - break out of `<script>`, `<style>`, `<title>` or `<textarea>` content;
43
+ - break out of `<script>`, `<style>`, `<title>`, `<textarea>` or `<noscript>` content;
44
44
  - change how SVG/MathML content parses.
45
45
 
46
46
  **Not guaranteed:**
@@ -69,21 +69,22 @@ scan then continues from the same state, because §4 guarantees no value can cha
69
69
 
70
70
  ### 3.1 States → contexts
71
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 |
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
+ | `<noscript>` content | `<noscript>${…}</noscript>` | as outside (§3.7) |
84
+ | `iframe`, `noembed`, `noframes`, `xmp`, `plaintext` content | `<iframe>${…}</iframe>` | error |
85
+ | Comment, doctype, `<!…>`, `<?…>`, `</ …>` | `<!-- ${…} -->` | error |
86
+ | Inside an end tag | `</div ${…}>` | error |
87
+ | Right after `/` in a tag | `<br/${…}>` | error |
87
88
 
88
89
  Tokenizer details:
89
90
 
@@ -91,6 +92,8 @@ Tokenizer details:
91
92
  - `<script>`, `<style>`, `<title>`, `<textarea>` and the unsupported raw-text elements are
92
93
  scanned to their end tag (`</name` followed by whitespace, `/` or `>`, case-insensitive),
93
94
  including after `<script/>` (the self-closing flag is ignored on non-void elements).
95
+ `<noscript>` content is scanned as markup (§3.7). Inside `<select>`, some start tags are
96
+ refused (§3.8).
94
97
  - A comment runs from `<!--` to `-->` or `--!>`; `<!-->` and `<!--->` are complete comments.
95
98
  `<!doctype …>`, other `<!…>`, `<?…>` and `</` + non-letter run to the next `>`.
96
99
  - `<![CDATA[` anywhere is an error.
@@ -161,8 +164,8 @@ stripped (what the URL parser ignores).
161
164
  ### 3.5 End of template
162
165
 
163
166
  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.
167
+ declaration, raw text, RCDATA, `<noscript>`, `<select>`, and outside `<svg>`/`<math>`.
168
+ Unclosed normal elements are fine. A stray `</svg>` or `</math>` is an error.
166
169
 
167
170
  This makes a `SafeHtml` fragment safe to drop into another template's `text` slot: it starts
168
171
  and ends in text and every value inside was rendered for its own context.
@@ -198,13 +201,61 @@ neutral if its template is and every fragment rendered into its text slots is. `
198
201
  r="${r}"/>`, `<g>…</g>`, `<title>${t}</title>` and static `<style>` are neutral; `<p>`,
199
202
  `<style>${styleText(…)}</style>` and `<textarea><a href="${u}"></textarea>` are not.
200
203
 
201
- ### 3.7 Strictness
204
+ ### 3.7 `<noscript>`
205
+
206
+ `<noscript>` parses two ways. With scripting enabled (every browser, by default) its content
207
+ is raw text up to the first `</noscript` (followed by whitespace, `/` or `>`,
208
+ case-insensitive). With scripting disabled it is ordinary markup. A template must be safe
209
+ under both, so:
210
+
211
+ - The content is scanned as markup, and slots get the contexts they would get outside.
212
+ - The first `</noscript` after the start tag must be the element's end tag in the markup
213
+ reading too: reached in text, outside any tag, comment, raw text or RCDATA, and outside
214
+ `<svg>`/`<math>`. A `</noscript` in an attribute value, comment, `<textarea>` or static
215
+ script is an error, with or without slots.
216
+ - `script` and `style` slots are errors: `scriptText()` and `styleText()` may emit
217
+ `</noscript`, which would end the element in the raw-text reading.
218
+ - Static text before a slot must not end with a non-empty prefix of `</noscript` (§3.2):
219
+ `title="a <${x}"` is refused inside it.
220
+ - `<noscript>` inside `<noscript>` is an error. Inside SVG/MathML, `<noscript>` is an ordinary
221
+ foreign element and none of this applies.
222
+
223
+ Every other value renders without a `<`, so in the raw-text reading everything up to the real
224
+ end tag stays text, and in the markup reading the usual rules hold.
225
+
226
+ **Fragments.** An `html` fragment in a `text` slot inside `<noscript>` must be
227
+ **noscript-safe**: its template has no `script` or `style` slot, contains no `</noscript`, and
228
+ has no chunk ending with a prefix of it before a slot; and every fragment rendered into its text
229
+ slots is noscript-safe (as for neutrality, §3.6). `join()` propagates the property. A fragment
230
+ can't end with such a prefix, since it ends in text (§3.5).
231
+
232
+ The two readings also build different trees: with scripting enabled, `<noscript>` holds one
233
+ text node. That is the template's static choice, not something a value can change.
234
+
235
+ ### 3.8 `<select>`
236
+
237
+ Older parsers (and parse5) parse `<select>` content in the "in select" insertion mode, which
238
+ ignores most start tags. An ignored `<title>` or `<style>` does not switch the tokenizer, so
239
+ its content is markup there: `<select><title><script>${x}</script></title></select>` runs
240
+ `x`, and `styleText()` inside `<select><style>` can create elements. Newer parsers switch the
241
+ tokenizer as usual. A template must be safe under both, so inside `<select>` (HTML mode,
242
+ until the first `</select>`) these start tags are errors: `title`, `style`, `xmp`, `iframe`,
243
+ `noembed`, `noframes`, `noscript`, `plaintext`, `svg`, `math`, and `template` (processed
244
+ there, but a `</select>` inside it is ignored, so the scanner would leave the `<select>` too
245
+ early). `<script>`, `<textarea>`, `<option>`, `<optgroup>` and other elements parse the same
246
+ in both.
247
+
248
+ **Fragments.** An `html` fragment in a `text` slot inside `<select>` must be **select-safe**:
249
+ its template has none of these start tags (anywhere, in HTML mode), and every fragment
250
+ rendered into its text slots is select-safe. `join()` propagates the property.
251
+
252
+ ### 3.9 Strictness
202
253
 
203
254
  Anything the scanner does not model is an error, never a guess. Analysis errors depend on the
204
255
  template only, so a test that renders every template once surfaces them all. Relaxing a rule
205
256
  later is a minor release; tightening one breaks templates.
206
257
 
207
- ### 3.8 Excerpts
258
+ ### 3.10 Excerpts
208
259
 
209
260
  `HtmlTemplateError.excerpt` is the static text with slots shown as `${…}`, the failing slot
210
261
  as `${⟨here⟩}` (or `⟨here⟩` at an offset for errors in static text), whitespace collapsed,
@@ -230,7 +281,8 @@ trimmed to about 90 characters. It never contains a value.
230
281
  | `unsafe` | verbatim | verbatim | verbatim | verbatim | verbatim | verbatim | verbatim | verbatim |
231
282
  | object, function, symbol, date, promise | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ |
232
283
 
233
- ¹ In SVG/MathML text slots only neutral fragments (§3.6).
284
+ ¹ In SVG/MathML text slots only neutral fragments (§3.6); in `<noscript>` text slots only
285
+ noscript-safe ones (§3.7); in `<select>` text slots only select-safe ones (§3.8).
234
286
 
235
287
  - **Booleans render nothing, `true` included** (as in JSX), so ``${cond && html`…`}`` works.
236
288
  Numbers render, so ``${count && html`…`}`` prints `0` when the count is zero: write
@@ -256,6 +308,11 @@ trimmed to about 90 characters. It never contains a value.
256
308
  RCDATA and both quoted attribute forms (not in unquoted values, which are analysis errors).
257
309
  Strings with none of these are returned unchanged.
258
310
 
311
+ Quotes are escaped in text and RCDATA too, where only `&` and `<` are needed. It costs a few
312
+ bytes (and differs from Svelte's output) but is a second line of defense: should a parser ever
313
+ read a text slot as part of a quoted attribute value, in disagreement with the scanner's model,
314
+ the value still can't close it.
315
+
259
316
  ### 5.2 URL policy
260
317
 
261
318
  1. Detect the scheme as the URL parser would: skip leading C0 controls and space, ignore
@@ -344,3 +401,15 @@ The first draft of this design was revised during implementation:
344
401
  rest of a small render.
345
402
  11. **Tests** use a seeded generator instead of `fast-check` (one dev dependency, `parse5`,
346
403
  and deterministic runs), plus value round-trips through `parse5`.
404
+
405
+ Changes after the first release:
406
+
407
+ 12. **`<noscript>` content is modeled** (§3.7), after a port hit it with a localized
408
+ fallback button. It was an unsupported raw-text element: no slots, content scanned as raw
409
+ text only. That also accepted a static `</noscript` in an attribute value or comment inside
410
+ it, where the two readings disagree; that is now an error. The invariance tests parse
411
+ every template with scripting enabled and disabled.
412
+ 13. **`<select>` is modeled** (§3.8). Earlier versions accepted `<title>`, `<style>` and
413
+ `<svg>` inside it, where older parsers ignore the tag and parse its content as markup:
414
+ `<select><title><script>${x}</script></title></select>` ran `x`. Those tags are now
415
+ errors inside `<select>`, and a template must close its `<select>`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/safe-html",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "main": "dist/mod.js",
6
6
  "types": "dist/mod.d.ts",