@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 +41 -12
- package/package.json +3 -2
- package/src/index.js +4 -1
- package/src/processors/groups.js +13 -0
- package/src/processors/sequence.js +138 -89
- package/src/sanitize.js +556 -0
- package/src/utils/mime.js +90 -0
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
|
|
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
|
-
|
|
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 (
|
|
213
|
-
// Output: "This is <span
|
|
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
|
|
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: {
|
|
235
|
+
{ type: "span", attrs: { highlight: true, id: "note-1" } }
|
|
223
236
|
|
|
224
237
|
// Output HTML
|
|
225
|
-
'<span
|
|
238
|
+
'<span id="note-1" highlight="true">text</span>'
|
|
226
239
|
```
|
|
227
240
|
|
|
228
|
-
|
|
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/
|
|
263
|
+
`@uniweb/core` when it builds a block from content holding
|
|
234
264
|
`{k=v}`, `[text](@Component){k=v}`, `[@key]{k=v}`,
|
|
235
|
-
or `[#id]{k=v}` references
|
|
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.
|
|
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
|
-
|
|
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 };
|
package/src/processors/groups.js
CHANGED
|
@@ -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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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
|
-
|
|
426
|
-
|
|
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="${
|
|
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
|
-
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
} else {
|
|
524
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 `` 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
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1311
|
-
|
|
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:
|
|
1394
|
+
text: anchor(href, target, textContent),
|
|
1346
1395
|
attrs: item.attrs,
|
|
1347
1396
|
};
|
|
1348
1397
|
}
|
package/src/sanitize.js
ADDED
|
@@ -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, "&")
|
|
30
|
+
.replace(/</g, "<")
|
|
31
|
+
.replace(/>/g, ">");
|
|
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, """);
|
|
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 ` `). 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
|
+
}
|