@uniweb/semantic-parser 1.5.1 → 1.6.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 CHANGED
@@ -203,7 +203,8 @@ A horizontal rule (`---`) explicitly closes the current group and starts a new o
203
203
 
204
204
  ## Text Formatting
205
205
 
206
- Inline formatting is preserved as HTML tags:
206
+ A text node holds text, and its characters are escaped in the HTML strings the
207
+ parser builds — a `<` someone typed shows as `<`. Marks become tags:
207
208
 
208
209
  ```js
209
210
  // Input: Text with bold mark
@@ -213,33 +214,55 @@ Inline formatting is preserved as HTML tags:
213
214
  // Output: "Text with <em>emphasis</em>"
214
215
 
215
216
  // Input: Link mark
216
- // Output: "Click <a href=\"/docs\">here</a>"
217
+ // Output: "Click <a href=\"/docs\" target=\"_self\">here</a>"
217
218
 
218
- // Input: Span mark (bracketed spans)
219
- // Output: "This is <span class=\"highlight\">highlighted</span> text"
219
+ // Input: Span mark (a named style, `[text]{highlight}`)
220
+ // Output: "This is <span highlight=\"true\">highlighted</span> text"
220
221
  ```
221
222
 
223
+ A link is drawn only when its URL may be followed — relative, or `http:`,
224
+ `https:`, `mailto:`, `tel:`, `page:` or `topic:`. Any other, such as
225
+ `javascript:`, leaves its text unlinked. A `button` mark is a link whose `role`
226
+ is `button`.
227
+
222
228
  ### Span Marks
223
229
 
224
- Bracketed spans (`[text]{.class}`) are converted to `<span>` elements with their attributes:
230
+ Bracketed spans are converted to `<span>` elements. A span's style names become
231
+ attributes, which a theme styles by name:
225
232
 
226
233
  ```js
227
234
  // Input mark
228
- { type: "span", attrs: { class: "highlight", id: "note-1" } }
235
+ { type: "span", attrs: { highlight: true, id: "note-1" } }
229
236
 
230
237
  // Output HTML
231
- '<span class="highlight" id="note-1">text</span>'
238
+ '<span id="note-1" highlight="true">text</span>'
232
239
  ```
233
240
 
234
- Spans can have classes, IDs, and custom attributes. They combine with other marks—a span with bold becomes `<strong><span class="...">text</span></strong>`.
241
+ A span keeps `class`, `id`, `color` and `bg` (CSS colours, as an inline style),
242
+ and style names — a letter, then letters, digits, `_` or `-`, never starting with
243
+ `on`. Anything else is dropped. Spans combine with other marks — a span with bold
244
+ becomes `<strong><span …>text</span></strong>`.
245
+
246
+ ### Safety — `@uniweb/semantic-parser/sanitize`
247
+
248
+ The rules above, exported for anything that builds or checks content:
249
+
250
+ | export | what it does |
251
+ |---|---|
252
+ | `escapeText`, `escapeAttribute` | escape a string for a text position or a double-quoted attribute |
253
+ | `isSafeLink(url)` | whether a link may be followed |
254
+ | `isLinkTarget(target)` | one of `_self`, `_blank`, `_parent`, `_top` |
255
+ | `isStyleName(name)`, `isCssColor(value)` | what a span may carry |
256
+ | `sanitizeMathML(mathml)`, `sanitizeSVG(svg)` | rebuild markup content carries by design — a formula, an icon — from an allowlist |
257
+ | `inspectMathML(mathml)` | what `sanitizeMathML` would leave out, reported instead of removed |
258
+ | `allowlists()` | the same rules as data, for an implementation in another language |
235
259
 
236
260
  ### Inline Insets and Cross-references
237
261
 
238
262
  The parser handles inline `inset_placeholder` nodes — generated by
239
- `@uniweb/build`'s content collector when it pulls
263
+ `@uniweb/core` when it builds a block from content holding
240
264
  `![alt](@Component){k=v}`, `[text](@Component){k=v}`, `[@key]{k=v}`,
241
- or `[#id]{k=v}` references out of paragraph content. Two artifacts
242
- ship to consumers:
265
+ or `[#id]{k=v}` references. Two artifacts ship to consumers:
243
266
 
244
267
  1. **`children` carries an `inset` entry per placeholder** — each
245
268
  carrying the `refId` and `embedKind` (`'text'` for textual sugars
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@uniweb/semantic-parser",
3
- "version": "1.5.1",
3
+ "version": "1.6.0",
4
4
  "description": "Semantic parser for ProseMirror/TipTap content structures",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "exports": {
8
- ".": "./src/index.js"
8
+ ".": "./src/index.js",
9
+ "./sanitize": "./src/sanitize.js"
9
10
  },
10
11
  "keywords": [
11
12
  "prosemirror",
@@ -1,21 +1,22 @@
1
1
  import { mimeFor, fileNameOf } from "../utils/mime.js";
2
-
3
- /**
4
- * HTML-attribute encoding for values placed inside double-quoted
5
- * attributes. Backslashes and braces (common in LaTeX) pass through
6
- * unchanged; only the four characters that are unsafe in this context
7
- * need replacing.
8
- *
9
- * @param {string} s
10
- * @returns {string}
11
- */
12
- function escapeAttr(s) {
13
- return String(s)
14
- .replace(/&/g, "&amp;")
15
- .replace(/"/g, "&quot;")
16
- .replace(/</g, "&lt;")
17
- .replace(/>/g, "&gt;");
18
- }
2
+ import {
3
+ escapeText,
4
+ escapeAttribute as escapeAttr,
5
+ sanitizeMathML,
6
+ sanitizeSVG,
7
+ isSafeLink,
8
+ isLinkTarget,
9
+ isStyleName,
10
+ isCssColor,
11
+ } from "../sanitize.js";
12
+
13
+ // ⭐ A text node holds TEXT. Every string this file builds for kit's HTML
14
+ // renderers escapes it, and every attribute value, so content cannot carry a
15
+ // tag, a handler or a script URL into a page. The only markup that passes is
16
+ // rebuilt from an allowlist: a math node's MathML and an icon's SVG
17
+ // (`../sanitize.js`). ⛔ Until 2026-10-14 text was pasted in as stored, on the
18
+ // rule that a text node held HTML — so `<img src=x onerror=…>` written in
19
+ // content ran on the published page, and `if a<b then` showed as `if a`.
19
20
 
20
21
  /**
21
22
  * Check if an inline node is an icon.
@@ -59,9 +60,11 @@ function createSequenceElement(node, options = {}) {
59
60
  const attrs = node.attrs;
60
61
  const content = node.content;
61
62
 
63
+ // A link whose URL may not be followed is not a link: the paragraph falls
64
+ // through to ordinary text, where `anchor` leaves its label unlinked.
62
65
  const linkVal = isLink(node, options);
63
66
 
64
- if (linkVal) {
67
+ if (linkVal && isSafeLink(linkVal.href)) {
65
68
  return {
66
69
  type: "link",
67
70
  attrs: linkVal, //label, href
@@ -70,7 +73,7 @@ function createSequenceElement(node, options = {}) {
70
73
 
71
74
  // Check for paragraph containing only multiple links (no other text)
72
75
  const multipleLinks = isOnlyLinks(node);
73
- if (multipleLinks) {
76
+ if (multipleLinks && multipleLinks.every((l) => isSafeLink(l.attrs?.href))) {
74
77
  return multipleLinks; // Returns array of link elements
75
78
  }
76
79
 
@@ -201,7 +204,7 @@ function createSequenceElement(node, options = {}) {
201
204
  display: true,
202
205
  id: node.attrs?.id || null,
203
206
  latex: node.attrs?.latex || "",
204
- mathml: node.attrs?.mathml || "",
207
+ mathml: sanitizeMathML(node.attrs?.mathml || ""),
205
208
  attrs,
206
209
  };
207
210
  case "ImageBlock":
@@ -401,11 +404,29 @@ const FILE_LINK_EXTENSIONS = [
401
404
  "mp4", "mp3", "wav", "mov", "zip",
402
405
  ];
403
406
 
407
+ /**
408
+ * A text's link, if it carries one: its `link` mark, or its `button` mark read
409
+ * as a link whose `role` is `button` — a button is a link, with a hint for how
410
+ * a component may draw it (`[Go](/x){button}`, documented in
411
+ * docs/reference/content-structure.md). ⛔ Until 2026-10-14 nothing here read
412
+ * the `button` mark: its label reached the page as plain text and the link was
413
+ * lost, wherever it sat.
414
+ */
415
+ function linkMarkOf(marks) {
416
+ for (const mark of marks || []) {
417
+ if (mark?.type === "link") return mark;
418
+ if (mark?.type === "button") {
419
+ return { type: "link", attrs: { ...mark.attrs, role: mark.attrs?.role ?? "button" } };
420
+ }
421
+ }
422
+ return null;
423
+ }
424
+
404
425
  /**
405
426
  * Find a node's link mark, if it carries one.
406
427
  */
407
428
  function getLinkMark(node) {
408
- return (node?.marks || []).find((mark) => mark.type === "link") || null;
429
+ return linkMarkOf(node?.marks);
409
430
  }
410
431
 
411
432
  /**
@@ -424,12 +445,21 @@ function sameLink(a, b) {
424
445
  * Wrap already-styled inline content in an anchor.
425
446
  */
426
447
  function wrapLink(inner, linkMark) {
427
- const href = linkMark.attrs.href;
428
- const target = linkMark.attrs.target || "_self";
448
+ return anchor(linkMark.attrs.href, linkMark.attrs.target, inner);
449
+ }
450
+
451
+ /**
452
+ * An anchor around already-escaped inner HTML. A link whose URL may not be
453
+ * followed (`javascript:`, `data:`, any scheme `isSafeLink` refuses) is not
454
+ * drawn as a link: its label stays, as text.
455
+ */
456
+ function anchor(href, target, inner) {
457
+ if (typeof href !== "string" || !isSafeLink(href)) return inner;
458
+ const t = isLinkTarget(target) ? target : "_self";
429
459
  const extension = href.split(".").pop()?.toLowerCase();
430
460
  const isFileLink = FILE_LINK_EXTENSIONS.includes(extension);
431
461
 
432
- return `<a href="${href}" target="${target}"${isFileLink ? " download" : ""}>${inner}</a>`;
462
+ return `<a href="${escapeAttr(href)}" target="${t}"${isFileLink ? " download" : ""}>${inner}</a>`;
433
463
  }
434
464
 
435
465
  function getTextContent(content, options = {}) {
@@ -485,7 +515,7 @@ function renderInlineNode(curr, iconOrdinals) {
485
515
  const { type, marks = [], text } = curr;
486
516
 
487
517
  if (type === "text") {
488
- let styledText = text || "";
518
+ let styledText = escapeText(text || "");
489
519
 
490
520
  // Apply marks in order: textStyle, highlight, bold, italic, link
491
521
  // This ensures proper nesting
@@ -495,7 +525,8 @@ function renderInlineNode(curr, iconOrdinals) {
495
525
  const color = marks.find(
496
526
  (mark) => mark.type === "textStyle"
497
527
  )?.attrs?.color;
498
- if (color) {
528
+ // A theme token's name, or nothing — it lands inside `var(--…)`.
529
+ if (typeof color === "string" && /^[a-zA-Z0-9-]+$/.test(color)) {
499
530
  styledText = `<span style="color: var(--${color})">${styledText}</span>`;
500
531
  }
501
532
  }
@@ -512,18 +543,23 @@ function renderInlineNode(curr, iconOrdinals) {
512
543
  const attrParts = [];
513
544
  const styleParts = [];
514
545
 
515
- if (attrs.class) attrParts.push(`class="${attrs.class}"`);
516
- if (attrs.id) attrParts.push(`id="${attrs.id}"`);
546
+ if (attrs.class) attrParts.push(`class="${escapeAttr(attrs.class)}"`);
547
+ if (attrs.id) attrParts.push(`id="${escapeAttr(attrs.id)}"`);
517
548
 
518
549
  for (const [key, value] of Object.entries(attrs)) {
519
550
  if (key === 'class' || key === 'id') continue;
520
551
  // Convert color/bg to inline styles
521
- if (key === 'color') {
522
- styleParts.push(`color: ${value}`);
523
- } else if (key === 'bg') {
524
- styleParts.push(`background: ${value}`);
525
- } else {
526
- attrParts.push(`${key}="${value}"`);
552
+ if (key === 'color' || key === 'bg') {
553
+ if (isCssColor(value)) {
554
+ styleParts.push(`${key === 'color' ? 'color' : 'background'}: ${value}`);
555
+ }
556
+ } else if (isStyleName(key)) {
557
+ // A named inline style becomes an attribute of its
558
+ // span — `[text]{accent}` is `<span accent="true">`
559
+ // — so the name is held to a style name: never a
560
+ // handler, a URL or a style attribute, nothing a
561
+ // pasted value can turn into one.
562
+ attrParts.push(`${key}="${escapeAttr(value)}"`);
527
563
  }
528
564
  }
529
565
 
@@ -568,7 +604,7 @@ function renderInlineNode(curr, iconOrdinals) {
568
604
  // The wrapper is visually invisible; the inner <math>
569
605
  // still renders as MathML in the browser.
570
606
  const latex = escapeAttr(curr.attrs?.latex || "");
571
- const mathml = curr.attrs?.mathml || "";
607
+ const mathml = sanitizeMathML(curr.attrs?.mathml || "");
572
608
  return (
573
609
  prev +
574
610
  `<span data-type="math" data-latex="${latex}" data-display="false">${mathml}</span>`
@@ -583,7 +619,7 @@ function renderInlineNode(curr, iconOrdinals) {
583
619
  // have an alternative path.
584
620
  const refId = curr.attrs?.refId;
585
621
  if (!refId) return prev;
586
- return prev + `<uniweb-inset data-ref-id="${refId}"></uniweb-inset>`;
622
+ return prev + `<uniweb-inset data-ref-id="${escapeAttr(refId)}"></uniweb-inset>`;
587
623
  } else if (type === "hardBreak") {
588
624
  return prev + "<br>";
589
625
  } else if (isIconNode(curr)) {
@@ -653,8 +689,8 @@ function processInlineElements(content, options = {}) {
653
689
  });
654
690
  } else if (item.type === "text" && item.marks) {
655
691
  // Extract links from text nodes with link marks
656
- const linkMark = item.marks.find((m) => m.type === "link");
657
- if (linkMark) {
692
+ const linkMark = linkMarkOf(item.marks);
693
+ if (linkMark && isSafeLink(linkMark.attrs?.href)) {
658
694
  items.push({
659
695
  type: "link",
660
696
  attrs: {
@@ -920,7 +956,8 @@ function parseUniwebIcon(itemAttrs) {
920
956
  // Markdown pipeline: library + name (resolved at runtime via CDN)
921
957
  // OR a file, which arrives as `src`
922
958
  const icon = {};
923
- if (svg) icon.svg = svg;
959
+ // Markup from content, so rebuilt from the SVG allowlist (`../sanitize.js`).
960
+ if (svg) icon.svg = sanitizeSVG(svg);
924
961
  // Standard ProseMirror `image` nodes use `src`; the editor's `UniwebIcon`
925
962
  // uses `url`. Honor either, exactly as parseImgBlock does below — a
926
963
  // file-sourced icon is authored `![Logo](icon:./logo.svg)` or
@@ -968,7 +1005,7 @@ function parseIconBlock(itemAttrs) {
968
1005
  const { svg, theme } = itemAttrs || {};
969
1006
 
970
1007
  const icon = {};
971
- if (svg) icon.svg = svg;
1008
+ if (svg) icon.svg = sanitizeSVG(svg);
972
1009
  if (theme) icon.theme = theme;
973
1010
 
974
1011
  return icon;
@@ -1216,43 +1253,39 @@ function isLink(item, options = {}) {
1216
1253
 
1217
1254
  if (textContent.length === 1) {
1218
1255
  let contentItem = textContent[0];
1219
- let marks = contentItem?.marks || [];
1220
-
1221
- for (let l = 0; l < marks.length; l++) {
1222
- let mark = marks[l];
1223
-
1224
- if (mark?.type === "link") {
1225
- // Find the position of the link text in the original content
1226
- const linkIndex = originalContent.findIndex(
1227
- (c) => c.type === "text" && c.text === contentItem.text
1228
- );
1229
-
1230
- // Collect icons before and after the link text
1231
- let iconBefore = null;
1232
- let iconAfter = null;
1233
-
1234
- for (let i = 0; i < originalContent.length; i++) {
1235
- if (isIconNode(originalContent[i])) {
1236
- const iconAttrs = parseUniwebIcon(originalContent[i].attrs);
1237
- if (i < linkIndex) {
1238
- // Take the last icon before the link
1239
- iconBefore = iconAttrs;
1240
- } else if (i > linkIndex) {
1241
- // Take the first icon after the link
1242
- if (!iconAfter) iconAfter = iconAttrs;
1243
- }
1256
+ const mark = linkMarkOf(contentItem?.marks);
1257
+
1258
+ if (mark) {
1259
+ // Find the position of the link text in the original content
1260
+ const linkIndex = originalContent.findIndex(
1261
+ (c) => c.type === "text" && c.text === contentItem.text
1262
+ );
1263
+
1264
+ // Collect icons before and after the link text
1265
+ let iconBefore = null;
1266
+ let iconAfter = null;
1267
+
1268
+ for (let i = 0; i < originalContent.length; i++) {
1269
+ if (isIconNode(originalContent[i])) {
1270
+ const iconAttrs = parseUniwebIcon(originalContent[i].attrs);
1271
+ if (i < linkIndex) {
1272
+ // Take the last icon before the link
1273
+ iconBefore = iconAttrs;
1274
+ } else if (i > linkIndex) {
1275
+ // Take the first icon after the link
1276
+ if (!iconAfter) iconAfter = iconAttrs;
1244
1277
  }
1245
1278
  }
1246
-
1247
- return {
1248
- ...mark?.attrs, // Preserve all link attributes (role, target, etc.)
1249
- label: contentItem?.text || "",
1250
- iconBefore,
1251
- iconAfter,
1252
- // Preserve all inline elements for advanced rendering
1253
- children: processInlineElements(originalContent, options),
1254
- };
1255
1279
  }
1280
+
1281
+ return {
1282
+ ...mark?.attrs, // Preserve all link attributes (role, target, etc.)
1283
+ label: contentItem?.text || "",
1284
+ iconBefore,
1285
+ iconAfter,
1286
+ // Preserve all inline elements for advanced rendering
1287
+ children: processInlineElements(originalContent, options),
1288
+ };
1256
1289
  }
1257
1290
  }
1258
1291
  }
@@ -1292,8 +1325,7 @@ function isOnlyLinks(item) {
1292
1325
  // Check if ALL remaining content items are text nodes with link marks
1293
1326
  const allLinks = textContent.every((c) => {
1294
1327
  if (c.type !== "text") return false;
1295
- const hasLinkMark = c.marks?.some((m) => m.type === "link");
1296
- return hasLinkMark;
1328
+ return Boolean(linkMarkOf(c.marks));
1297
1329
  });
1298
1330
 
1299
1331
  if (!allLinks) return false;
@@ -1301,7 +1333,7 @@ function isOnlyLinks(item) {
1301
1333
  // Extract links with all their attributes
1302
1334
  // Icons in this paragraph go to body.icons separately (no association)
1303
1335
  return textContent.map((c) => {
1304
- const linkMark = c.marks.find((m) => m.type === "link");
1336
+ const linkMark = linkMarkOf(c.marks);
1305
1337
  return {
1306
1338
  type: "link",
1307
1339
  attrs: {
@@ -1331,18 +1363,11 @@ function isStyledLink(item, options = {}) {
1331
1363
  });
1332
1364
 
1333
1365
  // check if all content items have the same link mark
1334
- let firstLinkMark = content[0]?.marks?.find(
1335
- (mark) => mark.type === "link" && mark.attrs
1336
- );
1337
- if (!firstLinkMark) return false;
1366
+ let firstLinkMark = linkMarkOf(content[0]?.marks);
1367
+ if (!firstLinkMark?.attrs) return false;
1338
1368
  if (
1339
1369
  !content.every(
1340
- (c) =>
1341
- c?.marks?.some(
1342
- (mark) =>
1343
- mark.type === "link" &&
1344
- mark.attrs?.href === firstLinkMark.attrs?.href
1345
- ) || false
1370
+ (c) => linkMarkOf(c?.marks)?.attrs?.href === firstLinkMark.attrs?.href
1346
1371
  )
1347
1372
  )
1348
1373
  return false;
@@ -1352,7 +1377,7 @@ function isStyledLink(item, options = {}) {
1352
1377
  const cleanedContent = content.map((c) => {
1353
1378
  // remove link marks from content items
1354
1379
  const cleanedMarks =
1355
- c.marks?.filter((mark) => mark.type !== "link") || [];
1380
+ c.marks?.filter((mark) => mark.type !== "link" && mark.type !== "button") || [];
1356
1381
  return {
1357
1382
  ...c,
1358
1383
  marks: cleanedMarks,
@@ -1366,7 +1391,7 @@ function isStyledLink(item, options = {}) {
1366
1391
  return {
1367
1392
  type: "paragraph",
1368
1393
  children: processInlineElements(item.content, options),
1369
- text: `<a target="${target}" href="${href}">${textContent}</a>`,
1394
+ text: anchor(href, target, textContent),
1370
1395
  attrs: item.attrs,
1371
1396
  };
1372
1397
  }
@@ -0,0 +1,556 @@
1
+ /**
2
+ * Markup that content may carry, rebuilt from an allowlist.
3
+ *
4
+ * Content never carries HTML: a text node holds text, and the parser escapes it
5
+ * when it builds a string. Two kinds of markup still ride in content, because
6
+ * they are formats rather than formatting — a math node's MathML, compiled from
7
+ * the author's LaTeX, and an SVG icon. Neither can be escaped, since the markup
8
+ * is the point, so each is rebuilt instead: the input is read as tokens, and
9
+ * only allowed elements and attributes are written back out, with every text
10
+ * run and attribute value escaped.
11
+ *
12
+ * Nothing from the input reaches the output unparsed. A construct this reader
13
+ * understands differently from a browser can lose content; it cannot carry a
14
+ * tag through, because every tag in the output was written here.
15
+ *
16
+ * @module @uniweb/semantic-parser/sanitize
17
+ */
18
+
19
+ // ─── Escaping ────────────────────────────────────────────────────────────────
20
+
21
+ /**
22
+ * Escape a string for an HTML text position.
23
+ *
24
+ * @param {string} s
25
+ * @returns {string}
26
+ */
27
+ export function escapeText(s) {
28
+ return String(s)
29
+ .replace(/&/g, "&amp;")
30
+ .replace(/</g, "&lt;")
31
+ .replace(/>/g, "&gt;");
32
+ }
33
+
34
+ /**
35
+ * Escape a string for a double-quoted HTML attribute value.
36
+ *
37
+ * @param {string} s
38
+ * @returns {string}
39
+ */
40
+ export function escapeAttribute(s) {
41
+ return escapeText(s).replace(/"/g, "&quot;");
42
+ }
43
+
44
+ const NAMED = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " };
45
+
46
+ /**
47
+ * Decode the character references a serializer emits: numeric references and
48
+ * the five XML entities (plus `&nbsp;`). Anything else is left as written, and
49
+ * is then escaped on the way out — it shows as text, never as markup.
50
+ *
51
+ * @param {string} s
52
+ * @returns {string}
53
+ */
54
+ function decodeReferences(s) {
55
+ if (!s.includes("&")) return s;
56
+ return s.replace(/&(#x[0-9a-fA-F]+|#[0-9]+|[a-zA-Z]+);/g, (match, ref) => {
57
+ if (ref[0] !== "#") return NAMED[ref] ?? match;
58
+ const code = ref[1] === "x" || ref[1] === "X"
59
+ ? parseInt(ref.slice(2), 16)
60
+ : parseInt(ref.slice(1), 10);
61
+ const valid = code > 0 && code <= 0x10ffff && !(code >= 0xd800 && code <= 0xdfff);
62
+ return valid ? String.fromCodePoint(code) : "�";
63
+ });
64
+ }
65
+
66
+ // ─── Reading tokens ──────────────────────────────────────────────────────────
67
+
68
+ const TAG = /<(\/?)([A-Za-z][A-Za-z0-9:_.-]*)((?:\s+[^\s"'>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*)\s*(\/?)>/y;
69
+ const ATTRIBUTE = /([^\s"'>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
70
+
71
+ function readAttributes(source) {
72
+ const attributes = [];
73
+ for (const m of source.matchAll(ATTRIBUTE)) {
74
+ attributes.push([m[1], m[2] ?? m[3] ?? m[4] ?? ""]);
75
+ }
76
+ return attributes;
77
+ }
78
+
79
+ /**
80
+ * Split markup into text runs, start tags and end tags. Comments, doctypes and
81
+ * processing instructions come out as `skipped` tokens, which a rebuild leaves
82
+ * out; CDATA is text; a `<` that opens none of these is text.
83
+ */
84
+ function* readTokens(input) {
85
+ let i = 0;
86
+ while (i < input.length) {
87
+ const lt = input.indexOf("<", i);
88
+ if (lt === -1) {
89
+ yield { type: "text", value: input.slice(i) };
90
+ return;
91
+ }
92
+ if (lt > i) yield { type: "text", value: input.slice(i, lt) };
93
+
94
+ if (input.startsWith("<!--", lt)) {
95
+ const end = input.indexOf("-->", lt + 4);
96
+ i = end === -1 ? input.length : end + 3;
97
+ yield { type: "skipped", value: input.slice(lt, i) };
98
+ continue;
99
+ }
100
+ // CDATA is character data: its content is text, escaped like any other.
101
+ if (input.startsWith("<![CDATA[", lt)) {
102
+ const end = input.indexOf("]]>", lt + 9);
103
+ yield { type: "text", value: input.slice(lt + 9, end === -1 ? input.length : end), cdata: true };
104
+ i = end === -1 ? input.length : end + 3;
105
+ continue;
106
+ }
107
+ if (input[lt + 1] === "!" || input[lt + 1] === "?") {
108
+ const end = input.indexOf(">", lt);
109
+ i = end === -1 ? input.length : end + 1;
110
+ yield { type: "skipped", value: input.slice(lt, i) };
111
+ continue;
112
+ }
113
+
114
+ TAG.lastIndex = lt;
115
+ const m = TAG.exec(input);
116
+ if (!m) {
117
+ yield { type: "text", value: "<" };
118
+ i = lt + 1;
119
+ continue;
120
+ }
121
+ yield {
122
+ type: m[1] ? "end" : "start",
123
+ name: m[2],
124
+ attributes: m[1] ? [] : readAttributes(m[3]),
125
+ selfClosing: Boolean(m[4]),
126
+ };
127
+ i = TAG.lastIndex;
128
+ }
129
+ }
130
+
131
+ // ─── Values ──────────────────────────────────────────────────────────────────
132
+
133
+ const UNSAFE_VALUE = /javascript:|vbscript:|data:text|expression\s*\(|@import|behavior\s*:|-moz-binding/i;
134
+
135
+ /** `url(…)` is allowed only as a reference within the same document. */
136
+ function onlyLocalUrls(value) {
137
+ const urls = value.match(/url\s*\(/gi);
138
+ if (!urls) return true;
139
+ const local = value.match(/url\s*\(\s*['"]?#[A-Za-z][\w.:-]*['"]?\s*\)/gi) || [];
140
+ return local.length === urls.length;
141
+ }
142
+
143
+ /**
144
+ * A `style` attribute, declaration by declaration: a property name and a value
145
+ * with no URL other than a local reference, and nothing that can open another
146
+ * construct. Blank declarations (`a:b;;`, a trailing `;`) are neither.
147
+ */
148
+ function readStyle(value) {
149
+ const kept = [];
150
+ const dropped = [];
151
+ for (const declaration of value.split(";")) {
152
+ if (!declaration.trim()) continue;
153
+ const colon = declaration.indexOf(":");
154
+ const property = colon === -1 ? "" : declaration.slice(0, colon).trim().toLowerCase();
155
+ const v = colon === -1 ? "" : declaration.slice(colon + 1).trim();
156
+ if (
157
+ !/^-?[a-z][a-z-]*$/.test(property) || !v ||
158
+ /[\\<>{}]|\/\*/.test(v) || UNSAFE_VALUE.test(v) || !onlyLocalUrls(v)
159
+ ) {
160
+ dropped.push(declaration.trim());
161
+ continue;
162
+ }
163
+ kept.push(`${property}:${v}`);
164
+ }
165
+ return { kept, dropped };
166
+ }
167
+
168
+ function cleanStyle(value) {
169
+ const { kept } = readStyle(value);
170
+ return kept.length ? kept.join(";") + ";" : null;
171
+ }
172
+
173
+ /**
174
+ * The schemes a link may use: the web, mail and phone, and the framework's own
175
+ * references to a page (`page:`, and the older `topic:`), which kit resolves to
176
+ * a route at render.
177
+ */
178
+ const LINK_SCHEMES = new Set(["http", "https", "mailto", "tel", "page", "topic"]);
179
+
180
+ /** A link a document may follow: relative, or one of LINK_SCHEMES. */
181
+ function isSafeUrl(value) {
182
+ const v = value.replace(/[\u0000- \u007f]/g, "");
183
+ const scheme = v.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/);
184
+ if (!scheme) return true;
185
+ return LINK_SCHEMES.has(scheme[1].toLowerCase());
186
+ }
187
+
188
+ const RASTER_DATA = /^data:image\/(?:png|jpeg|gif|webp|avif);base64,[A-Za-z0-9+/=\s]+$/i;
189
+
190
+ // ─── Policies ────────────────────────────────────────────────────────────────
191
+
192
+ /** Elements whose content is dropped along with them. */
193
+ const DROP_WITH_CONTENT = new Set([
194
+ "script", "style", "foreignobject", "iframe", "object", "embed", "noscript",
195
+ "template", "textarea", "xmp", "noembed", "noframes", "annotation-xml",
196
+ "animate", "set", "animatemotion", "animatetransform", "mglyph", "maction",
197
+ ]);
198
+
199
+ /** Attributes either policy accepts on any allowed element. */
200
+ function isCommonAttribute(name) {
201
+ return name === "class" || name === "id" || name === "role" || name === "dir" ||
202
+ /^aria-[a-z]+$/.test(name) || /^data-[a-z0-9-]+$/.test(name);
203
+ }
204
+
205
+ function byLowercase(names) {
206
+ return new Map(names.map((n) => [n.toLowerCase(), n]));
207
+ }
208
+
209
+ const MATHML_ELEMENTS = byLowercase([
210
+ "math", "semantics", "annotation", "mrow", "mi", "mn", "mo", "ms", "mtext",
211
+ "mspace", "mfrac", "msqrt", "mroot", "mstyle", "merror", "mpadded", "mphantom",
212
+ "menclose", "msub", "msup", "msubsup", "munder", "mover", "munderover",
213
+ "mmultiscripts", "mprescripts", "none", "mtable", "mtr", "mtd", "mlabeledtr",
214
+ // temml wraps LaTeX it cannot compile in a span carrying the source.
215
+ "span",
216
+ ]);
217
+
218
+ const MATHML_ATTRIBUTES = byLowercase([
219
+ "xmlns", "display", "displaystyle", "scriptlevel", "mathvariant", "mathcolor",
220
+ "mathbackground", "mathsize", "stretchy", "fence", "separator", "form",
221
+ "lspace", "rspace", "largeop", "movablelimits", "accent", "accentunder",
222
+ "symmetric", "minsize", "maxsize", "width", "height", "depth", "voffset",
223
+ "linethickness", "numalign", "denomalign", "bevelled", "notation",
224
+ "columnalign", "rowalign", "columnspacing", "rowspacing", "columnlines",
225
+ "rowlines", "columnwidth", "frame", "framespacing", "equalrows",
226
+ "equalcolumns", "rowspan", "columnspan", "side", "encoding", "intent", "arg",
227
+ "style",
228
+ ]);
229
+
230
+ const SVG_ELEMENTS = byLowercase([
231
+ "svg", "g", "path", "circle", "ellipse", "line", "polyline", "polygon", "rect",
232
+ "defs", "linearGradient", "radialGradient", "stop", "clipPath", "mask",
233
+ "pattern", "symbol", "use", "title", "desc", "text", "tspan", "image",
234
+ "marker", "filter", "feGaussianBlur", "feOffset", "feBlend", "feColorMatrix",
235
+ "feComposite", "feFlood", "feMerge", "feMergeNode", "feDropShadow",
236
+ "feMorphology",
237
+ ]);
238
+
239
+ const SVG_ATTRIBUTES = byLowercase([
240
+ "xmlns", "xmlns:xlink", "xml:space", "version", "viewBox",
241
+ "preserveAspectRatio", "width", "height", "x", "y", "x1", "y1", "x2", "y2",
242
+ "cx", "cy", "r", "rx", "ry", "fx", "fy", "d", "points", "pathLength",
243
+ "transform", "fill", "fill-opacity", "fill-rule", "stroke", "stroke-width",
244
+ "stroke-linecap", "stroke-linejoin", "stroke-miterlimit", "stroke-dasharray",
245
+ "stroke-dashoffset", "stroke-opacity", "opacity", "clip-path", "clip-rule",
246
+ "mask", "color", "display", "visibility", "overflow", "vector-effect",
247
+ "shape-rendering", "text-rendering", "font-family", "font-size",
248
+ "font-weight", "font-style", "text-anchor", "dominant-baseline",
249
+ "alignment-baseline", "letter-spacing", "word-spacing", "dx", "dy", "rotate",
250
+ "textLength", "lengthAdjust", "offset", "stop-color", "stop-opacity",
251
+ "gradientUnits", "gradientTransform", "spreadMethod", "patternUnits",
252
+ "patternContentUnits", "patternTransform", "clipPathUnits", "maskUnits",
253
+ "maskContentUnits", "markerWidth", "markerHeight", "markerUnits", "refX",
254
+ "refY", "orient", "marker-start", "marker-mid", "marker-end", "filter",
255
+ "filterUnits", "primitiveUnits", "in", "in2", "result", "stdDeviation",
256
+ "mode", "operator", "k1", "k2", "k3", "k4", "values", "type", "flood-color",
257
+ "flood-opacity", "radius", "color-interpolation-filters", "focusable",
258
+ "style", "href", "xlink:href",
259
+ ]);
260
+
261
+ /** Where an SVG element may point: a fragment, or for an image a safe URL. */
262
+ function svgReference(element, value) {
263
+ const v = value.trim();
264
+ if (v.startsWith("#")) return /^#[A-Za-z][\w.:-]*$/.test(v) ? v : null;
265
+ if (element === "image") return RASTER_DATA.test(v) || (isSafeUrl(v) && !/^data:/i.test(v)) ? v : null;
266
+ return null;
267
+ }
268
+
269
+ function makePolicy(elements, attributes, reference) {
270
+ return {
271
+ element(name) {
272
+ return elements.get(name.toLowerCase()) ?? null;
273
+ },
274
+ attribute(element, name, value) {
275
+ const lower = name.toLowerCase();
276
+ if (lower.startsWith("on")) return null;
277
+ if (lower === "href" || lower === "xlink:href" || lower === "src") {
278
+ if (!attributes.has(lower)) return null;
279
+ const target = reference ? reference(element, value) : null;
280
+ return target === null ? null : [attributes.get(lower), target];
281
+ }
282
+ if (lower === "style") {
283
+ const style = cleanStyle(value);
284
+ return style === null ? null : ["style", style];
285
+ }
286
+ const canonical = attributes.get(lower) ?? (isCommonAttribute(lower) ? lower : null);
287
+ if (!canonical) return null;
288
+ if (UNSAFE_VALUE.test(value) || !onlyLocalUrls(value)) return null;
289
+ return [canonical, value];
290
+ },
291
+ };
292
+ }
293
+
294
+ const MATHML = makePolicy(MATHML_ELEMENTS, MATHML_ATTRIBUTES, null);
295
+ const SVG = makePolicy(SVG_ELEMENTS, SVG_ATTRIBUTES, svgReference);
296
+
297
+ // ─── Rebuilding ──────────────────────────────────────────────────────────────
298
+
299
+ function rebuild(input, policy) {
300
+ if (typeof input !== "string" || !input) return "";
301
+ let out = "";
302
+ let dropping = null;
303
+ let depth = 0;
304
+
305
+ for (const token of readTokens(input)) {
306
+ if (dropping) {
307
+ const name = token.name?.toLowerCase();
308
+ if (token.type === "start" && name === dropping && !token.selfClosing) depth++;
309
+ if (token.type === "end" && name === dropping && --depth === 0) dropping = null;
310
+ continue;
311
+ }
312
+ if (token.type === "skipped") continue;
313
+ if (token.type === "text") {
314
+ out += escapeText(token.cdata ? token.value : decodeReferences(token.value));
315
+ continue;
316
+ }
317
+
318
+ const lower = token.name.toLowerCase();
319
+ if (DROP_WITH_CONTENT.has(lower)) {
320
+ if (token.type === "start" && !token.selfClosing) {
321
+ dropping = lower;
322
+ depth = 1;
323
+ }
324
+ continue;
325
+ }
326
+
327
+ const element = policy.element(token.name);
328
+ if (!element) continue; // the tag goes; what it held stays
329
+
330
+ if (token.type === "end") {
331
+ out += `</${element}>`;
332
+ continue;
333
+ }
334
+
335
+ let attributes = "";
336
+ const seen = new Set();
337
+ for (const [name, raw] of token.attributes) {
338
+ const kept = policy.attribute(element, name, decodeReferences(raw));
339
+ if (!kept || seen.has(kept[0])) continue;
340
+ seen.add(kept[0]);
341
+ attributes += ` ${kept[0]}="${escapeAttribute(kept[1])}"`;
342
+ }
343
+ out += token.selfClosing ? `<${element}${attributes}/>` : `<${element}${attributes}>`;
344
+ }
345
+ return out;
346
+ }
347
+
348
+ /**
349
+ * What a rebuild would leave out of `input`, item by item — the same walk as
350
+ * `rebuild`, reporting instead of writing. Empty when the markup is within the
351
+ * policy, which is when a rebuild changes nothing but spelling (quotes,
352
+ * escapes, a style's spacing).
353
+ */
354
+ function inspect(input, policy) {
355
+ const outside = [];
356
+ let dropping = null;
357
+ let depth = 0;
358
+
359
+ for (const token of readTokens(input)) {
360
+ if (dropping) {
361
+ const name = token.name?.toLowerCase();
362
+ if (token.type === "start" && name === dropping && !token.selfClosing) depth++;
363
+ if (token.type === "end" && name === dropping && --depth === 0) dropping = null;
364
+ continue;
365
+ }
366
+ if (token.type === "text") continue;
367
+ if (token.type === "skipped") {
368
+ outside.push({ reason: "markup", value: token.value.slice(0, 40) });
369
+ continue;
370
+ }
371
+ if (token.type === "end") continue; // a start tag already answered for it
372
+
373
+ const lower = token.name.toLowerCase();
374
+ const element = DROP_WITH_CONTENT.has(lower) ? null : policy.element(token.name);
375
+ if (!element) {
376
+ outside.push({ reason: "element", element: token.name });
377
+ if (DROP_WITH_CONTENT.has(lower) && !token.selfClosing) {
378
+ dropping = lower;
379
+ depth = 1;
380
+ }
381
+ continue;
382
+ }
383
+ for (const [name, raw] of token.attributes) {
384
+ const value = decodeReferences(raw);
385
+ if (name.toLowerCase() === "style") {
386
+ const { dropped } = readStyle(value);
387
+ if (dropped.length) {
388
+ outside.push({ reason: "style", element, attribute: name, value: dropped.join("; ") });
389
+ }
390
+ continue;
391
+ }
392
+ if (!policy.attribute(element, name, value)) {
393
+ outside.push({ reason: "attribute", element, attribute: name });
394
+ }
395
+ }
396
+ }
397
+ return outside;
398
+ }
399
+
400
+ /**
401
+ * Rebuild MathML from its allowlist — MathML Core, the presentation elements a
402
+ * LaTeX compiler emits, and temml's error span. No element can link, load or
403
+ * run anything.
404
+ *
405
+ * @param {string} mathml
406
+ * @returns {string} the rebuilt markup; empty when nothing survives
407
+ */
408
+ export function sanitizeMathML(mathml) {
409
+ return rebuild(mathml, MATHML);
410
+ }
411
+
412
+ /**
413
+ * What in a MathML string falls outside the allowlist `sanitizeMathML` rebuilds
414
+ * from: each element and attribute a rebuild drops, each style declaration it
415
+ * drops, and any comment, doctype or processing instruction. Empty when the
416
+ * markup is within it.
417
+ *
418
+ * This is how content is checked rather than cleaned — a writer refused with
419
+ * what it sent can fix it, where a cleaned value changes without a word.
420
+ *
421
+ * @param {string} mathml
422
+ * @returns {Array<{reason: 'element'|'attribute'|'style'|'markup', element?: string, attribute?: string, value?: string}>}
423
+ */
424
+ export function inspectMathML(mathml) {
425
+ if (typeof mathml !== "string" || !mathml) return [];
426
+ return inspect(mathml, MATHML);
427
+ }
428
+
429
+ /**
430
+ * Rebuild SVG from its allowlist — shapes, gradients, masks, text and simple
431
+ * filters. A reference may only point inside the document, and an `image` only
432
+ * at a raster image. Scripts, event handlers, `foreignObject` and animations,
433
+ * which can rewrite an attribute after the fact, are dropped.
434
+ *
435
+ * Accepts a whole `<svg>` or a fragment of its content.
436
+ *
437
+ * @param {string} svg
438
+ * @returns {string} the rebuilt markup; empty when nothing survives
439
+ */
440
+ export function sanitizeSVG(svg) {
441
+ return rebuild(svg, SVG);
442
+ }
443
+
444
+ /**
445
+ * Whether a link may be followed: a relative URL, or `http:`, `https:`,
446
+ * `mailto:`, `tel:`, or the framework's `page:` / `topic:` references. Control
447
+ * characters and spaces are ignored when reading the scheme, as a browser
448
+ * ignores them, so `java\tscript:` is read as `javascript:`.
449
+ *
450
+ * @param {string} url
451
+ * @returns {boolean}
452
+ */
453
+ export function isSafeLink(url) {
454
+ return typeof url === "string" && isSafeUrl(url);
455
+ }
456
+
457
+ // ─── Values content may hold ─────────────────────────────────────────────────
458
+ //
459
+ // The rules below decide what a text's marks may put into the markup the parser
460
+ // builds. The parser applies them at render, and a writer of content can check
461
+ // against the same functions (`@uniweb/content-reader/vocabulary`), so the two
462
+ // never disagree about what a value may be.
463
+
464
+ const LINK_TARGETS = ["_self", "_blank", "_parent", "_top"];
465
+ const LINK_TARGET_SET = new Set(LINK_TARGETS);
466
+
467
+ /**
468
+ * Whether a link may open in this target: the four keywords a browser defines.
469
+ * A frame name is not one — content does not know the page's frames.
470
+ *
471
+ * @param {string} target
472
+ * @returns {boolean}
473
+ */
474
+ export function isLinkTarget(target) {
475
+ return LINK_TARGET_SET.has(target);
476
+ }
477
+
478
+ const STYLE_NAME = /^[A-Za-z][A-Za-z0-9_-]*$/;
479
+
480
+ /** Attribute names a browser reads as a URL, a style or an element's identity. */
481
+ const NOT_A_STYLE_NAME = [
482
+ "href", "src", "srcset", "srcdoc", "action", "formaction", "style",
483
+ "background", "poster", "xmlns", "is", "slot",
484
+ ];
485
+ const NOT_A_STYLE_NAME_SET = new Set(NOT_A_STYLE_NAME);
486
+
487
+ /**
488
+ * Whether a name may be a style name — the attribute NAME a bracketed span
489
+ * carries (`[text]{accent}` is `<span accent="true">`), which a theme binds by
490
+ * name (`span[accent]`, from theme.yml's `inline:`). A letter, then letters,
491
+ * digits, `_` and `-`. Never a name a browser would read as an event handler —
492
+ * anything starting with `on`, in any case, since HTML lowercases attribute
493
+ * names — nor one it reads as a URL, a style or an element's identity.
494
+ *
495
+ * @param {string} name
496
+ * @returns {boolean}
497
+ */
498
+ export function isStyleName(name) {
499
+ if (typeof name !== "string" || !STYLE_NAME.test(name)) return false;
500
+ const lower = name.toLowerCase();
501
+ return !lower.startsWith("on") && !NOT_A_STYLE_NAME_SET.has(lower);
502
+ }
503
+
504
+ const CSS_COLOR = /^[#a-zA-Z0-9(),.%\s+-]+$/;
505
+ const NOT_A_COLOR = /url\s*\(|expression\s*\(/i;
506
+
507
+ /**
508
+ * Whether a value is a CSS colour — a name, a hex, a colour function, or
509
+ * `var(--token)` — what a span's `color` and `bg` may set. No `;`, quote or
510
+ * URL, so it cannot end the declaration it sits in or load anything.
511
+ *
512
+ * @param {string} value
513
+ * @returns {boolean}
514
+ */
515
+ export function isCssColor(value) {
516
+ return typeof value === "string" && CSS_COLOR.test(value) && !NOT_A_COLOR.test(value);
517
+ }
518
+
519
+ /**
520
+ * The same rules, as data — for a reader that implements them in another
521
+ * language, such as a backend checking content when it is written. Every list
522
+ * here is the one the functions above use.
523
+ *
524
+ * @returns {Object}
525
+ */
526
+ export function allowlists() {
527
+ return {
528
+ link: {
529
+ schemes: [...LINK_SCHEMES],
530
+ relative: "a URL with no scheme",
531
+ readingTheScheme: "ASCII control characters, space and DEL are ignored, as a browser ignores them",
532
+ targets: [...LINK_TARGETS],
533
+ },
534
+ styleName: {
535
+ pattern: STYLE_NAME.source,
536
+ notStartingWith: "on, in any case",
537
+ not: [...NOT_A_STYLE_NAME],
538
+ },
539
+ color: {
540
+ pattern: CSS_COLOR.source,
541
+ notMatching: NOT_A_COLOR.source,
542
+ flags: "i",
543
+ },
544
+ mathml: {
545
+ elements: [...MATHML_ELEMENTS.values()],
546
+ attributes: [...MATHML_ATTRIBUTES.values()],
547
+ commonAttributes: "class, id, role, dir, aria-<letters>, data-<letters, digits and ->",
548
+ droppedWithContent: [...DROP_WITH_CONTENT],
549
+ neverAttributes: "on…, in any case; href, xlink:href and src",
550
+ unsafeValue: UNSAFE_VALUE.source,
551
+ urlInAValue: "only url(#id), a reference within the document",
552
+ style: "declaration by declaration: a property of letters and -, a value with none of \\ < > { } /* and no unsafe value",
553
+ markup: "no comment, doctype or processing instruction; CDATA is text",
554
+ },
555
+ };
556
+ }