@uniweb/semantic-parser 1.5.1 → 1.7.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
@@ -74,8 +74,8 @@ result = {
74
74
  // Body fields
75
75
  paragraphs: ["Get started today."],
76
76
  links: [], // All links (including buttons)
77
- images: [],
78
- videos: [],
77
+ images: [], // { url, alt, caption, role, placement?, author?, … }
78
+ videos: [], // { src, poster?, caption, placement?, … }
79
79
  documents: [], // Files marked {role=pdf} — { url, name, mime, size?, alt,
80
80
  // caption, preview?, … }
81
81
  icons: [],
@@ -87,7 +87,8 @@ result = {
87
87
  snippets: [], // Fenced code blocks — { language, code }
88
88
  data: {}, // Structured data (tagged data blocks, forms, cards)
89
89
  headings: [], // Only from nested content (quote/list bodies)
90
- // tables: [], // Only when the content has one — { rows, attrs }
90
+ // tables: [], // Only when the content has one — { rows, attrs } — attrs:
91
+ // caption, id, placement, from its caption line
91
92
  // math: [], // Only when the content has one — { id, latex, mathml }
92
93
 
93
94
  // Additional content groups (from headings after content)
@@ -116,7 +117,7 @@ const content = parseContent(doc);
116
117
 
117
118
  const title = content.title;
118
119
  const description = content.paragraphs.join(" ");
119
- const image = content.images[0]?.src;
120
+ const image = content.images[0]?.url;
120
121
  ```
121
122
 
122
123
  ### Processing Content Sections
@@ -203,7 +204,8 @@ A horizontal rule (`---`) explicitly closes the current group and starts a new o
203
204
 
204
205
  ## Text Formatting
205
206
 
206
- Inline formatting is preserved as HTML tags:
207
+ A text node holds text, and its characters are escaped in the HTML strings the
208
+ parser builds — a `<` someone typed shows as `<`. Marks become tags:
207
209
 
208
210
  ```js
209
211
  // Input: Text with bold mark
@@ -213,33 +215,55 @@ Inline formatting is preserved as HTML tags:
213
215
  // Output: "Text with <em>emphasis</em>"
214
216
 
215
217
  // Input: Link mark
216
- // Output: "Click <a href=\"/docs\">here</a>"
218
+ // Output: "Click <a href=\"/docs\" target=\"_self\">here</a>"
217
219
 
218
- // Input: Span mark (bracketed spans)
219
- // Output: "This is <span class=\"highlight\">highlighted</span> text"
220
+ // Input: Span mark (a named style, `[text]{highlight}`)
221
+ // Output: "This is <span highlight=\"true\">highlighted</span> text"
220
222
  ```
221
223
 
224
+ A link is drawn only when its URL may be followed — relative, or `http:`,
225
+ `https:`, `mailto:`, `tel:`, `page:` or `topic:`. Any other, such as
226
+ `javascript:`, leaves its text unlinked. A `button` mark is a link whose `role`
227
+ is `button`.
228
+
222
229
  ### Span Marks
223
230
 
224
- Bracketed spans (`[text]{.class}`) are converted to `<span>` elements with their attributes:
231
+ Bracketed spans are converted to `<span>` elements. A span's style names become
232
+ attributes, which a theme styles by name:
225
233
 
226
234
  ```js
227
235
  // Input mark
228
- { type: "span", attrs: { class: "highlight", id: "note-1" } }
236
+ { type: "span", attrs: { highlight: true, id: "note-1" } }
229
237
 
230
238
  // Output HTML
231
- '<span class="highlight" id="note-1">text</span>'
239
+ '<span id="note-1" highlight="true">text</span>'
232
240
  ```
233
241
 
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>`.
242
+ A span keeps `class`, `id`, `color` and `bg` (CSS colours, as an inline style),
243
+ and style names — a letter, then letters, digits, `_` or `-`, never starting with
244
+ `on`. Anything else is dropped. Spans combine with other marks — a span with bold
245
+ becomes `<strong><span …>text</span></strong>`.
246
+
247
+ ### Safety — `@uniweb/semantic-parser/sanitize`
248
+
249
+ The rules above, exported for anything that builds or checks content:
250
+
251
+ | export | what it does |
252
+ |---|---|
253
+ | `escapeText`, `escapeAttribute` | escape a string for a text position or a double-quoted attribute |
254
+ | `isSafeLink(url)` | whether a link may be followed |
255
+ | `isLinkTarget(target)` | one of `_self`, `_blank`, `_parent`, `_top` |
256
+ | `isStyleName(name)`, `isCssColor(value)` | what a span may carry |
257
+ | `sanitizeMathML(mathml)`, `sanitizeSVG(svg)` | rebuild markup content carries by design — a formula, an icon — from an allowlist |
258
+ | `inspectMathML(mathml)` | what `sanitizeMathML` would leave out, reported instead of removed |
259
+ | `allowlists()` | the same rules as data, for an implementation in another language |
235
260
 
236
261
  ### Inline Insets and Cross-references
237
262
 
238
263
  The parser handles inline `inset_placeholder` nodes — generated by
239
- `@uniweb/build`'s content collector when it pulls
264
+ `@uniweb/core` when it builds a block from content holding
240
265
  `![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:
266
+ or `[#id]{k=v}` references. Two artifacts ship to consumers:
243
267
 
244
268
  1. **`children` carries an `inset` entry per placeholder** — each
245
269
  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.7.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",
@@ -103,12 +103,16 @@ function linkParagraph({ text, label, href, target }) {
103
103
  }
104
104
  }
105
105
 
106
- function imageBlock({ src, url, alt = '', caption = '', direction, role, width, height }) {
106
+ function imageBlock({ src, url, alt = '', caption = '', placement, role, width, height }) {
107
107
  // The node attr is `url` and so is the parsed field; `src` is this builder's
108
108
  // original spelling. Either one in, `url` out.
109
109
  const attrs = { url: src || url, alt }
110
110
  if (caption) attrs.caption = caption
111
- if (direction) attrs.direction = direction
111
+ // The parsed field's own word, so parseContent(buildDoc(x)) keeps it. The
112
+ // editor's node calls it `direction` and drops an attr it does not declare,
113
+ // so the editor reads none until it saves the vocabulary; no starter content
114
+ // sets one. ⛔ Until 2026-10-15 this took and wrote `direction`.
115
+ if (placement) attrs.placement = placement
112
116
  if (role) attrs.role = role
113
117
  if (width && height) {
114
118
  attrs.aspect_ratio = { width, height, ratio: (height / width) * 100 }
@@ -153,10 +157,10 @@ function iconNode({ src, url, svg, library, name, size, color }) {
153
157
  return { type: 'UniwebIcon', attrs }
154
158
  }
155
159
 
156
- function videoNode({ src, url, caption, direction, coverImg }) {
160
+ function videoNode({ src, url, caption, placement, coverImg }) {
157
161
  const attrs = { src: src || url }
158
162
  if (caption) attrs.caption = caption
159
- if (direction) attrs.direction = direction
163
+ if (placement) attrs.placement = placement // as an image's, above
160
164
  // ⛔ The editor's `Video` node holds `coverImg` as an OBJECT — the reader is
161
165
  // `makeAssetUrl`, which looks for `.src` / `.url` / `.identifier`. A bare
162
166
  // string passed straight through parses back as "": `info?.src` on a string
@@ -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: {
@@ -825,8 +861,8 @@ function parseCardBlock(itemAttrs) {
825
861
  //
826
862
  // Read through `parseImgBlock`, which resolves the address and the preview's the
827
863
  // way every image's are, and then narrowed to what a document IS: where the file
828
- // is, what it is called, and the resource's own metadata. The image layout keys
829
- // (direction, size, credit) describe a picture, not a file, and are left out.
864
+ // is, what it is called, and the resource's own metadata. Where a picture sits
865
+ // in a flow (`placement`) is not a file's, and is left out.
830
866
  function parseMarkdownDocument(itemAttrs, options) {
831
867
  const { url, alt, caption, role, href, target, id, preview, author, description } =
832
868
  parseImgBlock(itemAttrs || {}, options);
@@ -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;
@@ -979,47 +1016,47 @@ function parseImgBlock(itemAttrs, options) {
979
1016
  info: imgInfo,
980
1017
  targetId,
981
1018
  caption = "",
982
- direction,
983
- filter,
984
1019
  alt = "",
985
1020
  src,
986
1021
  url,
987
1022
  href = "",
988
1023
  target = "",
989
- theme,
990
1024
  role,
991
- credit = "",
1025
+ credit, // the editor's word for `author` — delivered as `author`
992
1026
  id, // {#fig-id} cross-reference label — preserved so Press
993
1027
  // adapters can emit \label{id} (LaTeX) / <id> (Typst).
994
1028
  // ── Markdown-authored attributes ────────────────────────────────
995
1029
  // This function was written for the EDITOR's `ImageBlock` node and
996
1030
  // later reused for the markdown `image` node (see `case "image"`), so
997
- // its vocabulary is the editor's: contentType, direction, filter,
998
- // theme, credit. Everything markdown declares and the editor does not
999
- // was therefore tokenized, declared, and then dropped one layer before
1000
- // delivery — the published "Image Attributes" table
1001
- // (docs/reference/content-structure.md) reached no component, and
1002
- // neither did the `role=pdf` set added 2026-07-29 for the editor's
1003
- // `document` fold-in. Carried 2026-07-30.
1031
+ // its vocabulary was the editor's. Everything markdown declares and
1032
+ // the editor does not was therefore tokenized, declared, and then
1033
+ // dropped one layer before delivery — the published "Image
1034
+ // Attributes" table (docs/reference/content-structure.md) reached no
1035
+ // component, and neither did the `role=pdf` set added 2026-07-29 for
1036
+ // the editor's `document` fold-in. Carried 2026-07-30.
1037
+ //
1038
+ // ⭐ An element now holds the vocabulary's words, whichever node it
1039
+ // came from. ⛔ Until 2026-10-15 it also held the editor's `direction`
1040
+ // and two things derived from it — `imgPos` (left/right) and a
1041
+ // layout `size` (basic/lg/full, `basic` for every image without one) —
1042
+ // plus `filter` and `theme`, none of which a stored document holds. A
1043
+ // component handing the element to kit's <Image> passed that `size` as
1044
+ // Image's avatar preset, so a "wide" image drew as a 96px square.
1004
1045
  width,
1005
1046
  height,
1006
1047
  loading,
1007
1048
  fit,
1008
1049
  position,
1050
+ placement, // where it sits in a flow: left, right, wide, full
1009
1051
  preview, // role=pdf — the preview image
1010
- author, // role=pdf — resource metadata, rendered beside the preview
1052
+ author, // the image's attribution; for role=pdf, the resource's
1011
1053
  description, // role=pdf — describes the RESOURCE, not the image (≠ alt)
1012
1054
  } = itemAttrs;
1013
1055
 
1014
1056
  let { contentType, viewType, contentId, identifier } = imgInfo || {};
1015
1057
 
1016
- const sizes = {
1017
- center: "basic",
1018
- wide: "lg",
1019
- fill: "full",
1020
- };
1021
-
1022
1058
  caption = stripTags(caption);
1059
+ const attribution = author || credit;
1023
1060
 
1024
1061
  // Reference precedence. Standard ProseMirror `image` nodes use `src`; the
1025
1062
  // custom `ImageBlock` node uses `url`. Honor either so callers don't have to
@@ -1051,19 +1088,9 @@ function parseImgBlock(itemAttrs, options) {
1051
1088
  value: identifier || "",
1052
1089
  alt: alt || caption,
1053
1090
  caption,
1054
- direction,
1055
- filter,
1056
- imgPos: direction === "left" || direction === "right" ? direction : "",
1057
- // NOTE: `size` here is the direction-derived LAYOUT size
1058
- // (basic/lg/full), not the author's `{size=…}` — that attribute is
1059
- // icon-only and is read by parseUniwebIcon. Do not wire `{size=…}`
1060
- // through here; the two meanings collide on one key.
1061
- size: sizes[direction] || "basic",
1062
1091
  href,
1063
1092
  target,
1064
- theme,
1065
1093
  role,
1066
- credit,
1067
1094
  id: id || undefined,
1068
1095
  // Omitted when absent, so an existing consumer sees no new empty keys.
1069
1096
  ...(width !== undefined && width !== null && { width }),
@@ -1071,12 +1098,13 @@ function parseImgBlock(itemAttrs, options) {
1071
1098
  ...(loading && { loading }),
1072
1099
  ...(fit && { fit }),
1073
1100
  ...(position && { position }),
1101
+ ...(placement && { placement }),
1074
1102
  ...((() => {
1075
1103
  // Same rule for a document's preview image.
1076
1104
  const p = secondaryAssetUrl(itemAttrs, options, "preview");
1077
1105
  return p ? { preview: p } : {};
1078
1106
  })()),
1079
- ...(author && { author }),
1107
+ ...(attribution && { author: attribution }),
1080
1108
  ...(description && { description }),
1081
1109
  };
1082
1110
  }
@@ -1105,6 +1133,7 @@ function parseMarkdownVideo(itemAttrs, options) {
1105
1133
  target = "",
1106
1134
  role,
1107
1135
  id, // {#fig-id} cross-reference label, same as parseImgBlock
1136
+ placement, // where it sits in a flow, same as parseImgBlock
1108
1137
  } = itemAttrs || {};
1109
1138
 
1110
1139
  // Reference precedence, now genuinely identical to parseImgBlock: a
@@ -1138,6 +1167,7 @@ function parseMarkdownVideo(itemAttrs, options) {
1138
1167
  if (loop !== undefined) video.loop = loop;
1139
1168
  if (controls !== undefined) video.controls = controls;
1140
1169
  if (id) video.id = id;
1170
+ if (placement) video.placement = placement;
1141
1171
 
1142
1172
  return video;
1143
1173
  }
@@ -1146,12 +1176,12 @@ function parseVideoBlock(itemAttrs, options) {
1146
1176
  let {
1147
1177
  src,
1148
1178
  caption = "",
1149
- direction,
1150
1179
  info = {},
1151
1180
  coverImg = {},
1152
1181
  alt,
1153
1182
  href = "",
1154
1183
  target = "",
1184
+ placement, // where it sits in a flow; the editor's `direction` is not read
1155
1185
  } = itemAttrs;
1156
1186
 
1157
1187
  // Same precedence as parseImgBlock / parseMarkdownVideo: a resolvable
@@ -1161,11 +1191,11 @@ function parseVideoBlock(itemAttrs, options) {
1161
1191
  return {
1162
1192
  src: video,
1163
1193
  caption,
1164
- direction,
1165
1194
  coverImg: makeAssetUrl(coverImg),
1166
1195
  alt,
1167
1196
  href,
1168
1197
  target,
1198
+ ...(placement && { placement }),
1169
1199
  };
1170
1200
  }
1171
1201
 
@@ -1216,43 +1246,39 @@ function isLink(item, options = {}) {
1216
1246
 
1217
1247
  if (textContent.length === 1) {
1218
1248
  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
- }
1249
+ const mark = linkMarkOf(contentItem?.marks);
1250
+
1251
+ if (mark) {
1252
+ // Find the position of the link text in the original content
1253
+ const linkIndex = originalContent.findIndex(
1254
+ (c) => c.type === "text" && c.text === contentItem.text
1255
+ );
1256
+
1257
+ // Collect icons before and after the link text
1258
+ let iconBefore = null;
1259
+ let iconAfter = null;
1260
+
1261
+ for (let i = 0; i < originalContent.length; i++) {
1262
+ if (isIconNode(originalContent[i])) {
1263
+ const iconAttrs = parseUniwebIcon(originalContent[i].attrs);
1264
+ if (i < linkIndex) {
1265
+ // Take the last icon before the link
1266
+ iconBefore = iconAttrs;
1267
+ } else if (i > linkIndex) {
1268
+ // Take the first icon after the link
1269
+ if (!iconAfter) iconAfter = iconAttrs;
1244
1270
  }
1245
1271
  }
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
1272
  }
1273
+
1274
+ return {
1275
+ ...mark?.attrs, // Preserve all link attributes (role, target, etc.)
1276
+ label: contentItem?.text || "",
1277
+ iconBefore,
1278
+ iconAfter,
1279
+ // Preserve all inline elements for advanced rendering
1280
+ children: processInlineElements(originalContent, options),
1281
+ };
1256
1282
  }
1257
1283
  }
1258
1284
  }
@@ -1292,8 +1318,7 @@ function isOnlyLinks(item) {
1292
1318
  // Check if ALL remaining content items are text nodes with link marks
1293
1319
  const allLinks = textContent.every((c) => {
1294
1320
  if (c.type !== "text") return false;
1295
- const hasLinkMark = c.marks?.some((m) => m.type === "link");
1296
- return hasLinkMark;
1321
+ return Boolean(linkMarkOf(c.marks));
1297
1322
  });
1298
1323
 
1299
1324
  if (!allLinks) return false;
@@ -1301,7 +1326,7 @@ function isOnlyLinks(item) {
1301
1326
  // Extract links with all their attributes
1302
1327
  // Icons in this paragraph go to body.icons separately (no association)
1303
1328
  return textContent.map((c) => {
1304
- const linkMark = c.marks.find((m) => m.type === "link");
1329
+ const linkMark = linkMarkOf(c.marks);
1305
1330
  return {
1306
1331
  type: "link",
1307
1332
  attrs: {
@@ -1331,18 +1356,11 @@ function isStyledLink(item, options = {}) {
1331
1356
  });
1332
1357
 
1333
1358
  // 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;
1359
+ let firstLinkMark = linkMarkOf(content[0]?.marks);
1360
+ if (!firstLinkMark?.attrs) return false;
1338
1361
  if (
1339
1362
  !content.every(
1340
- (c) =>
1341
- c?.marks?.some(
1342
- (mark) =>
1343
- mark.type === "link" &&
1344
- mark.attrs?.href === firstLinkMark.attrs?.href
1345
- ) || false
1363
+ (c) => linkMarkOf(c?.marks)?.attrs?.href === firstLinkMark.attrs?.href
1346
1364
  )
1347
1365
  )
1348
1366
  return false;
@@ -1352,7 +1370,7 @@ function isStyledLink(item, options = {}) {
1352
1370
  const cleanedContent = content.map((c) => {
1353
1371
  // remove link marks from content items
1354
1372
  const cleanedMarks =
1355
- c.marks?.filter((mark) => mark.type !== "link") || [];
1373
+ c.marks?.filter((mark) => mark.type !== "link" && mark.type !== "button") || [];
1356
1374
  return {
1357
1375
  ...c,
1358
1376
  marks: cleanedMarks,
@@ -1366,7 +1384,7 @@ function isStyledLink(item, options = {}) {
1366
1384
  return {
1367
1385
  type: "paragraph",
1368
1386
  children: processInlineElements(item.content, options),
1369
- text: `<a target="${target}" href="${href}">${textContent}</a>`,
1387
+ text: anchor(href, target, textContent),
1370
1388
  attrs: item.attrs,
1371
1389
  };
1372
1390
  }
@@ -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
+ }