@uniweb/semantic-parser 1.5.0 → 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
@@ -73,16 +73,22 @@ result = {
73
73
 
74
74
  // Body fields
75
75
  paragraphs: ["Get started today."],
76
- links: [], // All links (including buttons, documents)
76
+ links: [], // All links (including buttons)
77
77
  images: [],
78
78
  videos: [],
79
+ documents: [], // Files marked {role=pdf} — { url, name, mime, size?, alt,
80
+ // caption, preview?, … }
79
81
  icons: [],
80
82
  lists: [],
81
83
  quotes: [],
82
84
  insets: [], // Inline @Component references — { refId }
85
+ media: [], // Images, videos and insets on their own line, in the
86
+ // order written — each with its `kind`
83
87
  snippets: [], // Fenced code blocks — { language, code }
84
88
  data: {}, // Structured data (tagged data blocks, forms, cards)
85
89
  headings: [], // Only from nested content (quote/list bodies)
90
+ // tables: [], // Only when the content has one — { rows, attrs }
91
+ // math: [], // Only when the content has one — { id, latex, mathml }
86
92
 
87
93
  // Additional content groups (from headings after content)
88
94
  // Each item has the SAME flat structure as the top level — title,
@@ -197,7 +203,8 @@ A horizontal rule (`---`) explicitly closes the current group and starts a new o
197
203
 
198
204
  ## Text Formatting
199
205
 
200
- 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:
201
208
 
202
209
  ```js
203
210
  // Input: Text with bold mark
@@ -207,33 +214,55 @@ Inline formatting is preserved as HTML tags:
207
214
  // Output: "Text with <em>emphasis</em>"
208
215
 
209
216
  // Input: Link mark
210
- // Output: "Click <a href=\"/docs\">here</a>"
217
+ // Output: "Click <a href=\"/docs\" target=\"_self\">here</a>"
211
218
 
212
- // Input: Span mark (bracketed spans)
213
- // 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"
214
221
  ```
215
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
+
216
228
  ### Span Marks
217
229
 
218
- 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:
219
232
 
220
233
  ```js
221
234
  // Input mark
222
- { type: "span", attrs: { class: "highlight", id: "note-1" } }
235
+ { type: "span", attrs: { highlight: true, id: "note-1" } }
223
236
 
224
237
  // Output HTML
225
- '<span class="highlight" id="note-1">text</span>'
238
+ '<span id="note-1" highlight="true">text</span>'
226
239
  ```
227
240
 
228
- 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 |
229
259
 
230
260
  ### Inline Insets and Cross-references
231
261
 
232
262
  The parser handles inline `inset_placeholder` nodes — generated by
233
- `@uniweb/build`'s content collector when it pulls
263
+ `@uniweb/core` when it builds a block from content holding
234
264
  `![alt](@Component){k=v}`, `[text](@Component){k=v}`, `[@key]{k=v}`,
235
- or `[#id]{k=v}` references out of paragraph content. Two artifacts
236
- ship to consumers:
265
+ or `[#id]{k=v}` references. Two artifacts ship to consumers:
237
266
 
238
267
  1. **`children` carries an `inset` entry per placeholder** — each
239
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.0",
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",
package/src/index.js CHANGED
@@ -2,6 +2,7 @@ import { processSequence, resolveAssetUrl, ASSET_SLOTS } from "./processors/sequ
2
2
  import { processGroups } from "./processors/groups.js";
3
3
  import { buildDoc } from "./builders/doc.js";
4
4
  import { lintContent } from "./lint.js";
5
+ import { MIME_TYPES, mimeFor } from "./utils/mime.js";
5
6
 
6
7
  /**
7
8
  * Parse ProseMirror/TipTap content into semantic structure
@@ -54,4 +55,6 @@ function parseContent(doc, options = {}) {
54
55
  // `lintContent` diagnoses grouping near-misses (see src/lint.js). It never
55
56
  // affects a parse — parsing stays deterministic; the judgment lives here so
56
57
  // the CLI, the dev server, and an editor surface the same findings.
57
- export { parseContent, buildDoc, resolveAssetUrl, ASSET_SLOTS, lintContent };
58
+ // `MIME_TYPES` / `mimeFor` are the one extension → media-type table: a document's
59
+ // `mime` here, a file's in the build (src/utils/mime.js).
60
+ export { parseContent, buildDoc, resolveAssetUrl, ASSET_SLOTS, lintContent, MIME_TYPES, mimeFor };
@@ -17,6 +17,7 @@ function flattenGroup(group) {
17
17
  videos: group.body.videos || [],
18
18
  documents: group.body.documents || [],
19
19
  insets: group.body.insets || [],
20
+ media: group.body.media || [],
20
21
  snippets: group.body.snippets || [],
21
22
  data: group.body.data || {},
22
23
  quotes: group.body.quotes || [],
@@ -69,6 +70,7 @@ function processGroups(sequence, options = {}) {
69
70
  lists: [],
70
71
  videos: [],
71
72
  insets: [],
73
+ media: [],
72
74
  snippets: [],
73
75
  data: {},
74
76
  quotes: [],
@@ -113,6 +115,7 @@ function processGroups(sequence, options = {}) {
113
115
  lists: [],
114
116
  videos: [],
115
117
  insets: [],
118
+ media: [],
116
119
  data: {},
117
120
  quotes: [],
118
121
  headings: [],
@@ -306,6 +309,13 @@ function processGroupContent(elements, options = {}) {
306
309
  videos: [],
307
310
  documents: [],
308
311
  insets: [],
312
+ // ⭐ The group's visual media — each block-level image, video and inset, in the
313
+ // order written, tagged with its `kind`: what a media slot holds. `images`,
314
+ // `videos` and `insets` keep what they hold, and keep the author's order only
315
+ // within one kind; across kinds only this does (and `sequence`). An image
316
+ // inside a sentence belongs to the text, so it is not here — nor is a
317
+ // document, which is not visual media.
318
+ media: [],
309
319
  snippets: [],
310
320
  paragraphs: [],
311
321
  links: [],
@@ -382,11 +392,13 @@ function processGroupContent(elements, options = {}) {
382
392
  body.icons.push(element.attrs);
383
393
  } else {
384
394
  body.images.push(preserveProps);
395
+ body.media.push({ ...preserveProps, kind: "image" });
385
396
  }
386
397
  break;
387
398
 
388
399
  case "video":
389
400
  body.videos.push(preserveProps);
401
+ body.media.push({ ...preserveProps, kind: "video" });
390
402
  break;
391
403
 
392
404
  case "document":
@@ -499,6 +511,7 @@ function processGroupContent(elements, options = {}) {
499
511
 
500
512
  case "inset":
501
513
  body.insets.push({ refId: element.refId });
514
+ body.media.push({ refId: element.refId, kind: "inset" });
502
515
  break;
503
516
 
504
517
  case "form":
@@ -1,19 +1,22 @@
1
- /**
2
- * HTML-attribute encoding for values placed inside double-quoted
3
- * attributes. Backslashes and braces (common in LaTeX) pass through
4
- * unchanged; only the four characters that are unsafe in this context
5
- * need replacing.
6
- *
7
- * @param {string} s
8
- * @returns {string}
9
- */
10
- function escapeAttr(s) {
11
- return String(s)
12
- .replace(/&/g, "&amp;")
13
- .replace(/"/g, "&quot;")
14
- .replace(/</g, "&lt;")
15
- .replace(/>/g, "&gt;");
16
- }
1
+ import { mimeFor, fileNameOf } from "../utils/mime.js";
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`.
17
20
 
18
21
  /**
19
22
  * Check if an inline node is an icon.
@@ -57,9 +60,11 @@ function createSequenceElement(node, options = {}) {
57
60
  const attrs = node.attrs;
58
61
  const content = node.content;
59
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.
60
65
  const linkVal = isLink(node, options);
61
66
 
62
- if (linkVal) {
67
+ if (linkVal && isSafeLink(linkVal.href)) {
63
68
  return {
64
69
  type: "link",
65
70
  attrs: linkVal, //label, href
@@ -68,7 +73,7 @@ function createSequenceElement(node, options = {}) {
68
73
 
69
74
  // Check for paragraph containing only multiple links (no other text)
70
75
  const multipleLinks = isOnlyLinks(node);
71
- if (multipleLinks) {
76
+ if (multipleLinks && multipleLinks.every((l) => isSafeLink(l.attrs?.href))) {
72
77
  return multipleLinks; // Returns array of link elements
73
78
  }
74
79
 
@@ -199,7 +204,7 @@ function createSequenceElement(node, options = {}) {
199
204
  display: true,
200
205
  id: node.attrs?.id || null,
201
206
  latex: node.attrs?.latex || "",
202
- mathml: node.attrs?.mathml || "",
207
+ mathml: sanitizeMathML(node.attrs?.mathml || ""),
203
208
  attrs,
204
209
  };
205
210
  case "ImageBlock":
@@ -399,11 +404,29 @@ const FILE_LINK_EXTENSIONS = [
399
404
  "mp4", "mp3", "wav", "mov", "zip",
400
405
  ];
401
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
+
402
425
  /**
403
426
  * Find a node's link mark, if it carries one.
404
427
  */
405
428
  function getLinkMark(node) {
406
- return (node?.marks || []).find((mark) => mark.type === "link") || null;
429
+ return linkMarkOf(node?.marks);
407
430
  }
408
431
 
409
432
  /**
@@ -422,12 +445,21 @@ function sameLink(a, b) {
422
445
  * Wrap already-styled inline content in an anchor.
423
446
  */
424
447
  function wrapLink(inner, linkMark) {
425
- const href = linkMark.attrs.href;
426
- 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";
427
459
  const extension = href.split(".").pop()?.toLowerCase();
428
460
  const isFileLink = FILE_LINK_EXTENSIONS.includes(extension);
429
461
 
430
- return `<a href="${href}" target="${target}"${isFileLink ? " download" : ""}>${inner}</a>`;
462
+ return `<a href="${escapeAttr(href)}" target="${t}"${isFileLink ? " download" : ""}>${inner}</a>`;
431
463
  }
432
464
 
433
465
  function getTextContent(content, options = {}) {
@@ -483,7 +515,7 @@ function renderInlineNode(curr, iconOrdinals) {
483
515
  const { type, marks = [], text } = curr;
484
516
 
485
517
  if (type === "text") {
486
- let styledText = text || "";
518
+ let styledText = escapeText(text || "");
487
519
 
488
520
  // Apply marks in order: textStyle, highlight, bold, italic, link
489
521
  // This ensures proper nesting
@@ -493,7 +525,8 @@ function renderInlineNode(curr, iconOrdinals) {
493
525
  const color = marks.find(
494
526
  (mark) => mark.type === "textStyle"
495
527
  )?.attrs?.color;
496
- 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)) {
497
530
  styledText = `<span style="color: var(--${color})">${styledText}</span>`;
498
531
  }
499
532
  }
@@ -510,18 +543,23 @@ function renderInlineNode(curr, iconOrdinals) {
510
543
  const attrParts = [];
511
544
  const styleParts = [];
512
545
 
513
- if (attrs.class) attrParts.push(`class="${attrs.class}"`);
514
- 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)}"`);
515
548
 
516
549
  for (const [key, value] of Object.entries(attrs)) {
517
550
  if (key === 'class' || key === 'id') continue;
518
551
  // Convert color/bg to inline styles
519
- if (key === 'color') {
520
- styleParts.push(`color: ${value}`);
521
- } else if (key === 'bg') {
522
- styleParts.push(`background: ${value}`);
523
- } else {
524
- 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)}"`);
525
563
  }
526
564
  }
527
565
 
@@ -566,7 +604,7 @@ function renderInlineNode(curr, iconOrdinals) {
566
604
  // The wrapper is visually invisible; the inner <math>
567
605
  // still renders as MathML in the browser.
568
606
  const latex = escapeAttr(curr.attrs?.latex || "");
569
- const mathml = curr.attrs?.mathml || "";
607
+ const mathml = sanitizeMathML(curr.attrs?.mathml || "");
570
608
  return (
571
609
  prev +
572
610
  `<span data-type="math" data-latex="${latex}" data-display="false">${mathml}</span>`
@@ -581,7 +619,7 @@ function renderInlineNode(curr, iconOrdinals) {
581
619
  // have an alternative path.
582
620
  const refId = curr.attrs?.refId;
583
621
  if (!refId) return prev;
584
- return prev + `<uniweb-inset data-ref-id="${refId}"></uniweb-inset>`;
622
+ return prev + `<uniweb-inset data-ref-id="${escapeAttr(refId)}"></uniweb-inset>`;
585
623
  } else if (type === "hardBreak") {
586
624
  return prev + "<br>";
587
625
  } else if (isIconNode(curr)) {
@@ -651,8 +689,8 @@ function processInlineElements(content, options = {}) {
651
689
  });
652
690
  } else if (item.type === "text" && item.marks) {
653
691
  // Extract links from text nodes with link marks
654
- const linkMark = item.marks.find((m) => m.type === "link");
655
- if (linkMark) {
692
+ const linkMark = linkMarkOf(item.marks);
693
+ if (linkMark && isSafeLink(linkMark.attrs?.href)) {
656
694
  items.push({
657
695
  type: "link",
658
696
  attrs: {
@@ -830,6 +868,7 @@ function parseMarkdownDocument(itemAttrs, options) {
830
868
  parseImgBlock(itemAttrs || {}, options);
831
869
  return {
832
870
  url,
871
+ ...fileFacts(url, itemAttrs),
833
872
  alt,
834
873
  caption,
835
874
  role,
@@ -842,6 +881,25 @@ function parseMarkdownDocument(itemAttrs, options) {
842
881
  };
843
882
  }
844
883
 
884
+ /**
885
+ * A document's file, as a file record's value names it: `name` (the file's name) and
886
+ * `mime` from its address, and `size` — known only where the file is — when a
887
+ * producer stamped one. A stamped `name` or `mime` wins over the address: the build
888
+ * copies a local file under a hashed name, so it stamps the one the author gave it.
889
+ * ✅ The field names are a file record's [Diego, 2026-09-29], so one component can
890
+ * render a downloads list from either.
891
+ */
892
+ function fileFacts(url, attrs = {}) {
893
+ const name = typeof attrs?.name === "string" && attrs.name ? attrs.name : fileNameOf(url);
894
+ const mime = typeof attrs?.mime === "string" && attrs.mime ? attrs.mime : name ? mimeFor(name) : "";
895
+ const size = Number.isFinite(attrs?.size) ? attrs.size : null;
896
+ return {
897
+ ...(name && { name }),
898
+ ...(mime && { mime }),
899
+ ...(size !== null && { size }),
900
+ };
901
+ }
902
+
845
903
  // The editor's older `document` node — its `title`, `coverImg` and `src` (or
846
904
  // asset identifier) — read into the same shape a markdown document has.
847
905
  function documentFromEditorNode(itemAttrs) {
@@ -855,8 +913,10 @@ function documentFromEditorNode(itemAttrs) {
855
913
  description,
856
914
  fileType,
857
915
  } = parseDocumentBlock(itemAttrs || {});
916
+ const url = href || downloadUrl || "";
858
917
  return {
859
- url: href || downloadUrl || "",
918
+ url,
919
+ ...fileFacts(url, itemAttrs),
860
920
  alt: alt || title,
861
921
  caption: title,
862
922
  role: "pdf",
@@ -896,7 +956,8 @@ function parseUniwebIcon(itemAttrs) {
896
956
  // Markdown pipeline: library + name (resolved at runtime via CDN)
897
957
  // OR a file, which arrives as `src`
898
958
  const icon = {};
899
- if (svg) icon.svg = svg;
959
+ // Markup from content, so rebuilt from the SVG allowlist (`../sanitize.js`).
960
+ if (svg) icon.svg = sanitizeSVG(svg);
900
961
  // Standard ProseMirror `image` nodes use `src`; the editor's `UniwebIcon`
901
962
  // uses `url`. Honor either, exactly as parseImgBlock does below — a
902
963
  // file-sourced icon is authored `![Logo](icon:./logo.svg)` or
@@ -944,7 +1005,7 @@ function parseIconBlock(itemAttrs) {
944
1005
  const { svg, theme } = itemAttrs || {};
945
1006
 
946
1007
  const icon = {};
947
- if (svg) icon.svg = svg;
1008
+ if (svg) icon.svg = sanitizeSVG(svg);
948
1009
  if (theme) icon.theme = theme;
949
1010
 
950
1011
  return icon;
@@ -1192,43 +1253,39 @@ function isLink(item, options = {}) {
1192
1253
 
1193
1254
  if (textContent.length === 1) {
1194
1255
  let contentItem = textContent[0];
1195
- let marks = contentItem?.marks || [];
1196
-
1197
- for (let l = 0; l < marks.length; l++) {
1198
- let mark = marks[l];
1199
-
1200
- if (mark?.type === "link") {
1201
- // Find the position of the link text in the original content
1202
- const linkIndex = originalContent.findIndex(
1203
- (c) => c.type === "text" && c.text === contentItem.text
1204
- );
1205
-
1206
- // Collect icons before and after the link text
1207
- let iconBefore = null;
1208
- let iconAfter = null;
1209
-
1210
- for (let i = 0; i < originalContent.length; i++) {
1211
- if (isIconNode(originalContent[i])) {
1212
- const iconAttrs = parseUniwebIcon(originalContent[i].attrs);
1213
- if (i < linkIndex) {
1214
- // Take the last icon before the link
1215
- iconBefore = iconAttrs;
1216
- } else if (i > linkIndex) {
1217
- // Take the first icon after the link
1218
- if (!iconAfter) iconAfter = iconAttrs;
1219
- }
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;
1220
1277
  }
1221
1278
  }
1222
-
1223
- return {
1224
- ...mark?.attrs, // Preserve all link attributes (role, target, etc.)
1225
- label: contentItem?.text || "",
1226
- iconBefore,
1227
- iconAfter,
1228
- // Preserve all inline elements for advanced rendering
1229
- children: processInlineElements(originalContent, options),
1230
- };
1231
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
+ };
1232
1289
  }
1233
1290
  }
1234
1291
  }
@@ -1268,8 +1325,7 @@ function isOnlyLinks(item) {
1268
1325
  // Check if ALL remaining content items are text nodes with link marks
1269
1326
  const allLinks = textContent.every((c) => {
1270
1327
  if (c.type !== "text") return false;
1271
- const hasLinkMark = c.marks?.some((m) => m.type === "link");
1272
- return hasLinkMark;
1328
+ return Boolean(linkMarkOf(c.marks));
1273
1329
  });
1274
1330
 
1275
1331
  if (!allLinks) return false;
@@ -1277,7 +1333,7 @@ function isOnlyLinks(item) {
1277
1333
  // Extract links with all their attributes
1278
1334
  // Icons in this paragraph go to body.icons separately (no association)
1279
1335
  return textContent.map((c) => {
1280
- const linkMark = c.marks.find((m) => m.type === "link");
1336
+ const linkMark = linkMarkOf(c.marks);
1281
1337
  return {
1282
1338
  type: "link",
1283
1339
  attrs: {
@@ -1307,18 +1363,11 @@ function isStyledLink(item, options = {}) {
1307
1363
  });
1308
1364
 
1309
1365
  // check if all content items have the same link mark
1310
- let firstLinkMark = content[0]?.marks?.find(
1311
- (mark) => mark.type === "link" && mark.attrs
1312
- );
1313
- if (!firstLinkMark) return false;
1366
+ let firstLinkMark = linkMarkOf(content[0]?.marks);
1367
+ if (!firstLinkMark?.attrs) return false;
1314
1368
  if (
1315
1369
  !content.every(
1316
- (c) =>
1317
- c?.marks?.some(
1318
- (mark) =>
1319
- mark.type === "link" &&
1320
- mark.attrs?.href === firstLinkMark.attrs?.href
1321
- ) || false
1370
+ (c) => linkMarkOf(c?.marks)?.attrs?.href === firstLinkMark.attrs?.href
1322
1371
  )
1323
1372
  )
1324
1373
  return false;
@@ -1328,7 +1377,7 @@ function isStyledLink(item, options = {}) {
1328
1377
  const cleanedContent = content.map((c) => {
1329
1378
  // remove link marks from content items
1330
1379
  const cleanedMarks =
1331
- c.marks?.filter((mark) => mark.type !== "link") || [];
1380
+ c.marks?.filter((mark) => mark.type !== "link" && mark.type !== "button") || [];
1332
1381
  return {
1333
1382
  ...c,
1334
1383
  marks: cleanedMarks,
@@ -1342,7 +1391,7 @@ function isStyledLink(item, options = {}) {
1342
1391
  return {
1343
1392
  type: "paragraph",
1344
1393
  children: processInlineElements(item.content, options),
1345
- text: `<a target="${target}" href="${href}">${textContent}</a>`,
1394
+ text: anchor(href, target, textContent),
1346
1395
  attrs: item.attrs,
1347
1396
  };
1348
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
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * What a file is, by its extension — the one extension → media-type table.
3
+ *
4
+ * ⭐ ONE TABLE, HERE, because two producers need it and this is the package both can
5
+ * reach: the parser gives a document its `mime` from its address, and the build gives
6
+ * a file record — and a document file it copies — theirs (`@uniweb/build`'s
7
+ * `site/file-records.js` imports `mimeFor` from here, as it imports `ASSET_SLOTS`).
8
+ * The parser has no dependencies, so the table cannot live in the build and be
9
+ * reached from here. ⛔ Do not write a second.
10
+ *
11
+ * Anything not here is `application/octet-stream`.
12
+ */
13
+ export const MIME_TYPES = {
14
+ pdf: "application/pdf",
15
+ doc: "application/msword",
16
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
17
+ xls: "application/vnd.ms-excel",
18
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
19
+ ppt: "application/vnd.ms-powerpoint",
20
+ pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
21
+ odt: "application/vnd.oasis.opendocument.text",
22
+ ods: "application/vnd.oasis.opendocument.spreadsheet",
23
+ odp: "application/vnd.oasis.opendocument.presentation",
24
+ rtf: "application/rtf",
25
+ epub: "application/epub+zip",
26
+ zip: "application/zip",
27
+ gz: "application/gzip",
28
+ tar: "application/x-tar",
29
+ csv: "text/csv",
30
+ tsv: "text/tab-separated-values",
31
+ txt: "text/plain",
32
+ md: "text/markdown",
33
+ json: "application/json",
34
+ xml: "application/xml",
35
+ yml: "application/yaml",
36
+ yaml: "application/yaml",
37
+ html: "text/html",
38
+ png: "image/png",
39
+ jpg: "image/jpeg",
40
+ jpeg: "image/jpeg",
41
+ gif: "image/gif",
42
+ svg: "image/svg+xml",
43
+ webp: "image/webp",
44
+ avif: "image/avif",
45
+ mp3: "audio/mpeg",
46
+ wav: "audio/wav",
47
+ ogg: "audio/ogg",
48
+ m4a: "audio/mp4",
49
+ mp4: "video/mp4",
50
+ webm: "video/webm",
51
+ mov: "video/quicktime",
52
+ };
53
+
54
+ /**
55
+ * The extension of a file name or path, lower-cased, without its dot — `''` for none,
56
+ * and for a dotfile (`.env`), as Node's `extname` reads them.
57
+ */
58
+ function extensionOf(name) {
59
+ const base = String(name || "").split(/[\\/]/).pop();
60
+ const dot = base.lastIndexOf(".");
61
+ return dot > 0 ? base.slice(dot + 1).toLowerCase() : "";
62
+ }
63
+
64
+ /**
65
+ * The media type of a file, by its extension.
66
+ *
67
+ * @param {string} name - a file name or path
68
+ * @returns {string}
69
+ */
70
+ export function mimeFor(name) {
71
+ return MIME_TYPES[extensionOf(name)] || "application/octet-stream";
72
+ }
73
+
74
+ /**
75
+ * The file name an address ends in — `/files/Annual%20report.pdf?v=2` →
76
+ * `Annual report.pdf` — or `''` when it names none.
77
+ *
78
+ * @param {string} url
79
+ * @returns {string}
80
+ */
81
+ export function fileNameOf(url) {
82
+ const text = String(url || "");
83
+ if (/^data:/i.test(text)) return "";
84
+ const segment = text.split(/[?#]/)[0].split("/").pop() || "";
85
+ try {
86
+ return decodeURIComponent(segment);
87
+ } catch {
88
+ return segment;
89
+ }
90
+ }