@uniweb/semantic-parser 1.5.1 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -15
- package/package.json +3 -2
- package/src/builders/doc.js +8 -4
- package/src/processors/sequence.js +140 -122
- package/src/sanitize.js +556 -0
package/README.md
CHANGED
|
@@ -74,8 +74,8 @@ result = {
|
|
|
74
74
|
// Body fields
|
|
75
75
|
paragraphs: ["Get started today."],
|
|
76
76
|
links: [], // All links (including buttons)
|
|
77
|
-
images: [],
|
|
78
|
-
videos: [],
|
|
77
|
+
images: [], // { url, alt, caption, role, placement?, author?, … }
|
|
78
|
+
videos: [], // { src, poster?, caption, placement?, … }
|
|
79
79
|
documents: [], // Files marked {role=pdf} — { url, name, mime, size?, alt,
|
|
80
80
|
// caption, preview?, … }
|
|
81
81
|
icons: [],
|
|
@@ -87,7 +87,8 @@ result = {
|
|
|
87
87
|
snippets: [], // Fenced code blocks — { language, code }
|
|
88
88
|
data: {}, // Structured data (tagged data blocks, forms, cards)
|
|
89
89
|
headings: [], // Only from nested content (quote/list bodies)
|
|
90
|
-
// tables: [], // Only when the content has one — { rows, attrs }
|
|
90
|
+
// tables: [], // Only when the content has one — { rows, attrs } — attrs:
|
|
91
|
+
// caption, id, placement, from its caption line
|
|
91
92
|
// math: [], // Only when the content has one — { id, latex, mathml }
|
|
92
93
|
|
|
93
94
|
// Additional content groups (from headings after content)
|
|
@@ -116,7 +117,7 @@ const content = parseContent(doc);
|
|
|
116
117
|
|
|
117
118
|
const title = content.title;
|
|
118
119
|
const description = content.paragraphs.join(" ");
|
|
119
|
-
const image = content.images[0]?.
|
|
120
|
+
const image = content.images[0]?.url;
|
|
120
121
|
```
|
|
121
122
|
|
|
122
123
|
### Processing Content Sections
|
|
@@ -203,7 +204,8 @@ A horizontal rule (`---`) explicitly closes the current group and starts a new o
|
|
|
203
204
|
|
|
204
205
|
## Text Formatting
|
|
205
206
|
|
|
206
|
-
|
|
207
|
+
A text node holds text, and its characters are escaped in the HTML strings the
|
|
208
|
+
parser builds — a `<` someone typed shows as `<`. Marks become tags:
|
|
207
209
|
|
|
208
210
|
```js
|
|
209
211
|
// Input: Text with bold mark
|
|
@@ -213,33 +215,55 @@ Inline formatting is preserved as HTML tags:
|
|
|
213
215
|
// Output: "Text with <em>emphasis</em>"
|
|
214
216
|
|
|
215
217
|
// Input: Link mark
|
|
216
|
-
// Output: "Click <a href=\"/docs\">here</a>"
|
|
218
|
+
// Output: "Click <a href=\"/docs\" target=\"_self\">here</a>"
|
|
217
219
|
|
|
218
|
-
// Input: Span mark (
|
|
219
|
-
// Output: "This is <span
|
|
220
|
+
// Input: Span mark (a named style, `[text]{highlight}`)
|
|
221
|
+
// Output: "This is <span highlight=\"true\">highlighted</span> text"
|
|
220
222
|
```
|
|
221
223
|
|
|
224
|
+
A link is drawn only when its URL may be followed — relative, or `http:`,
|
|
225
|
+
`https:`, `mailto:`, `tel:`, `page:` or `topic:`. Any other, such as
|
|
226
|
+
`javascript:`, leaves its text unlinked. A `button` mark is a link whose `role`
|
|
227
|
+
is `button`.
|
|
228
|
+
|
|
222
229
|
### Span Marks
|
|
223
230
|
|
|
224
|
-
Bracketed spans
|
|
231
|
+
Bracketed spans are converted to `<span>` elements. A span's style names become
|
|
232
|
+
attributes, which a theme styles by name:
|
|
225
233
|
|
|
226
234
|
```js
|
|
227
235
|
// Input mark
|
|
228
|
-
{ type: "span", attrs: {
|
|
236
|
+
{ type: "span", attrs: { highlight: true, id: "note-1" } }
|
|
229
237
|
|
|
230
238
|
// Output HTML
|
|
231
|
-
'<span
|
|
239
|
+
'<span id="note-1" highlight="true">text</span>'
|
|
232
240
|
```
|
|
233
241
|
|
|
234
|
-
|
|
242
|
+
A span keeps `class`, `id`, `color` and `bg` (CSS colours, as an inline style),
|
|
243
|
+
and style names — a letter, then letters, digits, `_` or `-`, never starting with
|
|
244
|
+
`on`. Anything else is dropped. Spans combine with other marks — a span with bold
|
|
245
|
+
becomes `<strong><span …>text</span></strong>`.
|
|
246
|
+
|
|
247
|
+
### Safety — `@uniweb/semantic-parser/sanitize`
|
|
248
|
+
|
|
249
|
+
The rules above, exported for anything that builds or checks content:
|
|
250
|
+
|
|
251
|
+
| export | what it does |
|
|
252
|
+
|---|---|
|
|
253
|
+
| `escapeText`, `escapeAttribute` | escape a string for a text position or a double-quoted attribute |
|
|
254
|
+
| `isSafeLink(url)` | whether a link may be followed |
|
|
255
|
+
| `isLinkTarget(target)` | one of `_self`, `_blank`, `_parent`, `_top` |
|
|
256
|
+
| `isStyleName(name)`, `isCssColor(value)` | what a span may carry |
|
|
257
|
+
| `sanitizeMathML(mathml)`, `sanitizeSVG(svg)` | rebuild markup content carries by design — a formula, an icon — from an allowlist |
|
|
258
|
+
| `inspectMathML(mathml)` | what `sanitizeMathML` would leave out, reported instead of removed |
|
|
259
|
+
| `allowlists()` | the same rules as data, for an implementation in another language |
|
|
235
260
|
|
|
236
261
|
### Inline Insets and Cross-references
|
|
237
262
|
|
|
238
263
|
The parser handles inline `inset_placeholder` nodes — generated by
|
|
239
|
-
`@uniweb/
|
|
264
|
+
`@uniweb/core` when it builds a block from content holding
|
|
240
265
|
`{k=v}`, `[text](@Component){k=v}`, `[@key]{k=v}`,
|
|
241
|
-
or `[#id]{k=v}` references
|
|
242
|
-
ship to consumers:
|
|
266
|
+
or `[#id]{k=v}` references. Two artifacts ship to consumers:
|
|
243
267
|
|
|
244
268
|
1. **`children` carries an `inset` entry per placeholder** — each
|
|
245
269
|
carrying the `refId` and `embedKind` (`'text'` for textual sugars
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/semantic-parser",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "Semantic parser for ProseMirror/TipTap content structures",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"exports": {
|
|
8
|
-
".": "./src/index.js"
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./sanitize": "./src/sanitize.js"
|
|
9
10
|
},
|
|
10
11
|
"keywords": [
|
|
11
12
|
"prosemirror",
|
package/src/builders/doc.js
CHANGED
|
@@ -103,12 +103,16 @@ function linkParagraph({ text, label, href, target }) {
|
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
function imageBlock({ src, url, alt = '', caption = '',
|
|
106
|
+
function imageBlock({ src, url, alt = '', caption = '', placement, role, width, height }) {
|
|
107
107
|
// The node attr is `url` and so is the parsed field; `src` is this builder's
|
|
108
108
|
// original spelling. Either one in, `url` out.
|
|
109
109
|
const attrs = { url: src || url, alt }
|
|
110
110
|
if (caption) attrs.caption = caption
|
|
111
|
-
|
|
111
|
+
// The parsed field's own word, so parseContent(buildDoc(x)) keeps it. The
|
|
112
|
+
// editor's node calls it `direction` and drops an attr it does not declare,
|
|
113
|
+
// so the editor reads none until it saves the vocabulary; no starter content
|
|
114
|
+
// sets one. ⛔ Until 2026-10-15 this took and wrote `direction`.
|
|
115
|
+
if (placement) attrs.placement = placement
|
|
112
116
|
if (role) attrs.role = role
|
|
113
117
|
if (width && height) {
|
|
114
118
|
attrs.aspect_ratio = { width, height, ratio: (height / width) * 100 }
|
|
@@ -153,10 +157,10 @@ function iconNode({ src, url, svg, library, name, size, color }) {
|
|
|
153
157
|
return { type: 'UniwebIcon', attrs }
|
|
154
158
|
}
|
|
155
159
|
|
|
156
|
-
function videoNode({ src, url, caption,
|
|
160
|
+
function videoNode({ src, url, caption, placement, coverImg }) {
|
|
157
161
|
const attrs = { src: src || url }
|
|
158
162
|
if (caption) attrs.caption = caption
|
|
159
|
-
if (
|
|
163
|
+
if (placement) attrs.placement = placement // as an image's, above
|
|
160
164
|
// ⛔ The editor's `Video` node holds `coverImg` as an OBJECT — the reader is
|
|
161
165
|
// `makeAssetUrl`, which looks for `.src` / `.url` / `.identifier`. A bare
|
|
162
166
|
// string passed straight through parses back as "": `info?.src` on a string
|
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
import { mimeFor, fileNameOf } from "../utils/mime.js";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
2
|
+
import {
|
|
3
|
+
escapeText,
|
|
4
|
+
escapeAttribute as escapeAttr,
|
|
5
|
+
sanitizeMathML,
|
|
6
|
+
sanitizeSVG,
|
|
7
|
+
isSafeLink,
|
|
8
|
+
isLinkTarget,
|
|
9
|
+
isStyleName,
|
|
10
|
+
isCssColor,
|
|
11
|
+
} from "../sanitize.js";
|
|
12
|
+
|
|
13
|
+
// ⭐ A text node holds TEXT. Every string this file builds for kit's HTML
|
|
14
|
+
// renderers escapes it, and every attribute value, so content cannot carry a
|
|
15
|
+
// tag, a handler or a script URL into a page. The only markup that passes is
|
|
16
|
+
// rebuilt from an allowlist: a math node's MathML and an icon's SVG
|
|
17
|
+
// (`../sanitize.js`). ⛔ Until 2026-10-14 text was pasted in as stored, on the
|
|
18
|
+
// rule that a text node held HTML — so `<img src=x onerror=…>` written in
|
|
19
|
+
// content ran on the published page, and `if a<b then` showed as `if a`.
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* Check if an inline node is an icon.
|
|
@@ -59,9 +60,11 @@ function createSequenceElement(node, options = {}) {
|
|
|
59
60
|
const attrs = node.attrs;
|
|
60
61
|
const content = node.content;
|
|
61
62
|
|
|
63
|
+
// A link whose URL may not be followed is not a link: the paragraph falls
|
|
64
|
+
// through to ordinary text, where `anchor` leaves its label unlinked.
|
|
62
65
|
const linkVal = isLink(node, options);
|
|
63
66
|
|
|
64
|
-
if (linkVal) {
|
|
67
|
+
if (linkVal && isSafeLink(linkVal.href)) {
|
|
65
68
|
return {
|
|
66
69
|
type: "link",
|
|
67
70
|
attrs: linkVal, //label, href
|
|
@@ -70,7 +73,7 @@ function createSequenceElement(node, options = {}) {
|
|
|
70
73
|
|
|
71
74
|
// Check for paragraph containing only multiple links (no other text)
|
|
72
75
|
const multipleLinks = isOnlyLinks(node);
|
|
73
|
-
if (multipleLinks) {
|
|
76
|
+
if (multipleLinks && multipleLinks.every((l) => isSafeLink(l.attrs?.href))) {
|
|
74
77
|
return multipleLinks; // Returns array of link elements
|
|
75
78
|
}
|
|
76
79
|
|
|
@@ -201,7 +204,7 @@ function createSequenceElement(node, options = {}) {
|
|
|
201
204
|
display: true,
|
|
202
205
|
id: node.attrs?.id || null,
|
|
203
206
|
latex: node.attrs?.latex || "",
|
|
204
|
-
mathml: node.attrs?.mathml || "",
|
|
207
|
+
mathml: sanitizeMathML(node.attrs?.mathml || ""),
|
|
205
208
|
attrs,
|
|
206
209
|
};
|
|
207
210
|
case "ImageBlock":
|
|
@@ -401,11 +404,29 @@ const FILE_LINK_EXTENSIONS = [
|
|
|
401
404
|
"mp4", "mp3", "wav", "mov", "zip",
|
|
402
405
|
];
|
|
403
406
|
|
|
407
|
+
/**
|
|
408
|
+
* A text's link, if it carries one: its `link` mark, or its `button` mark read
|
|
409
|
+
* as a link whose `role` is `button` — a button is a link, with a hint for how
|
|
410
|
+
* a component may draw it (`[Go](/x){button}`, documented in
|
|
411
|
+
* docs/reference/content-structure.md). ⛔ Until 2026-10-14 nothing here read
|
|
412
|
+
* the `button` mark: its label reached the page as plain text and the link was
|
|
413
|
+
* lost, wherever it sat.
|
|
414
|
+
*/
|
|
415
|
+
function linkMarkOf(marks) {
|
|
416
|
+
for (const mark of marks || []) {
|
|
417
|
+
if (mark?.type === "link") return mark;
|
|
418
|
+
if (mark?.type === "button") {
|
|
419
|
+
return { type: "link", attrs: { ...mark.attrs, role: mark.attrs?.role ?? "button" } };
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
|
|
404
425
|
/**
|
|
405
426
|
* Find a node's link mark, if it carries one.
|
|
406
427
|
*/
|
|
407
428
|
function getLinkMark(node) {
|
|
408
|
-
return (node?.marks
|
|
429
|
+
return linkMarkOf(node?.marks);
|
|
409
430
|
}
|
|
410
431
|
|
|
411
432
|
/**
|
|
@@ -424,12 +445,21 @@ function sameLink(a, b) {
|
|
|
424
445
|
* Wrap already-styled inline content in an anchor.
|
|
425
446
|
*/
|
|
426
447
|
function wrapLink(inner, linkMark) {
|
|
427
|
-
|
|
428
|
-
|
|
448
|
+
return anchor(linkMark.attrs.href, linkMark.attrs.target, inner);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* An anchor around already-escaped inner HTML. A link whose URL may not be
|
|
453
|
+
* followed (`javascript:`, `data:`, any scheme `isSafeLink` refuses) is not
|
|
454
|
+
* drawn as a link: its label stays, as text.
|
|
455
|
+
*/
|
|
456
|
+
function anchor(href, target, inner) {
|
|
457
|
+
if (typeof href !== "string" || !isSafeLink(href)) return inner;
|
|
458
|
+
const t = isLinkTarget(target) ? target : "_self";
|
|
429
459
|
const extension = href.split(".").pop()?.toLowerCase();
|
|
430
460
|
const isFileLink = FILE_LINK_EXTENSIONS.includes(extension);
|
|
431
461
|
|
|
432
|
-
return `<a href="${href}" target="${
|
|
462
|
+
return `<a href="${escapeAttr(href)}" target="${t}"${isFileLink ? " download" : ""}>${inner}</a>`;
|
|
433
463
|
}
|
|
434
464
|
|
|
435
465
|
function getTextContent(content, options = {}) {
|
|
@@ -485,7 +515,7 @@ function renderInlineNode(curr, iconOrdinals) {
|
|
|
485
515
|
const { type, marks = [], text } = curr;
|
|
486
516
|
|
|
487
517
|
if (type === "text") {
|
|
488
|
-
let styledText = text || "";
|
|
518
|
+
let styledText = escapeText(text || "");
|
|
489
519
|
|
|
490
520
|
// Apply marks in order: textStyle, highlight, bold, italic, link
|
|
491
521
|
// This ensures proper nesting
|
|
@@ -495,7 +525,8 @@ function renderInlineNode(curr, iconOrdinals) {
|
|
|
495
525
|
const color = marks.find(
|
|
496
526
|
(mark) => mark.type === "textStyle"
|
|
497
527
|
)?.attrs?.color;
|
|
498
|
-
|
|
528
|
+
// A theme token's name, or nothing — it lands inside `var(--…)`.
|
|
529
|
+
if (typeof color === "string" && /^[a-zA-Z0-9-]+$/.test(color)) {
|
|
499
530
|
styledText = `<span style="color: var(--${color})">${styledText}</span>`;
|
|
500
531
|
}
|
|
501
532
|
}
|
|
@@ -512,18 +543,23 @@ function renderInlineNode(curr, iconOrdinals) {
|
|
|
512
543
|
const attrParts = [];
|
|
513
544
|
const styleParts = [];
|
|
514
545
|
|
|
515
|
-
if (attrs.class) attrParts.push(`class="${attrs.class}"`);
|
|
516
|
-
if (attrs.id) attrParts.push(`id="${attrs.id}"`);
|
|
546
|
+
if (attrs.class) attrParts.push(`class="${escapeAttr(attrs.class)}"`);
|
|
547
|
+
if (attrs.id) attrParts.push(`id="${escapeAttr(attrs.id)}"`);
|
|
517
548
|
|
|
518
549
|
for (const [key, value] of Object.entries(attrs)) {
|
|
519
550
|
if (key === 'class' || key === 'id') continue;
|
|
520
551
|
// Convert color/bg to inline styles
|
|
521
|
-
if (key === 'color') {
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
} else {
|
|
526
|
-
|
|
552
|
+
if (key === 'color' || key === 'bg') {
|
|
553
|
+
if (isCssColor(value)) {
|
|
554
|
+
styleParts.push(`${key === 'color' ? 'color' : 'background'}: ${value}`);
|
|
555
|
+
}
|
|
556
|
+
} else if (isStyleName(key)) {
|
|
557
|
+
// A named inline style becomes an attribute of its
|
|
558
|
+
// span — `[text]{accent}` is `<span accent="true">`
|
|
559
|
+
// — so the name is held to a style name: never a
|
|
560
|
+
// handler, a URL or a style attribute, nothing a
|
|
561
|
+
// pasted value can turn into one.
|
|
562
|
+
attrParts.push(`${key}="${escapeAttr(value)}"`);
|
|
527
563
|
}
|
|
528
564
|
}
|
|
529
565
|
|
|
@@ -568,7 +604,7 @@ function renderInlineNode(curr, iconOrdinals) {
|
|
|
568
604
|
// The wrapper is visually invisible; the inner <math>
|
|
569
605
|
// still renders as MathML in the browser.
|
|
570
606
|
const latex = escapeAttr(curr.attrs?.latex || "");
|
|
571
|
-
const mathml = curr.attrs?.mathml || "";
|
|
607
|
+
const mathml = sanitizeMathML(curr.attrs?.mathml || "");
|
|
572
608
|
return (
|
|
573
609
|
prev +
|
|
574
610
|
`<span data-type="math" data-latex="${latex}" data-display="false">${mathml}</span>`
|
|
@@ -583,7 +619,7 @@ function renderInlineNode(curr, iconOrdinals) {
|
|
|
583
619
|
// have an alternative path.
|
|
584
620
|
const refId = curr.attrs?.refId;
|
|
585
621
|
if (!refId) return prev;
|
|
586
|
-
return prev + `<uniweb-inset data-ref-id="${refId}"></uniweb-inset>`;
|
|
622
|
+
return prev + `<uniweb-inset data-ref-id="${escapeAttr(refId)}"></uniweb-inset>`;
|
|
587
623
|
} else if (type === "hardBreak") {
|
|
588
624
|
return prev + "<br>";
|
|
589
625
|
} else if (isIconNode(curr)) {
|
|
@@ -653,8 +689,8 @@ function processInlineElements(content, options = {}) {
|
|
|
653
689
|
});
|
|
654
690
|
} else if (item.type === "text" && item.marks) {
|
|
655
691
|
// Extract links from text nodes with link marks
|
|
656
|
-
const linkMark = item.marks
|
|
657
|
-
if (linkMark) {
|
|
692
|
+
const linkMark = linkMarkOf(item.marks);
|
|
693
|
+
if (linkMark && isSafeLink(linkMark.attrs?.href)) {
|
|
658
694
|
items.push({
|
|
659
695
|
type: "link",
|
|
660
696
|
attrs: {
|
|
@@ -825,8 +861,8 @@ function parseCardBlock(itemAttrs) {
|
|
|
825
861
|
//
|
|
826
862
|
// Read through `parseImgBlock`, which resolves the address and the preview's the
|
|
827
863
|
// way every image's are, and then narrowed to what a document IS: where the file
|
|
828
|
-
// is, what it is called, and the resource's own metadata.
|
|
829
|
-
//
|
|
864
|
+
// is, what it is called, and the resource's own metadata. Where a picture sits
|
|
865
|
+
// in a flow (`placement`) is not a file's, and is left out.
|
|
830
866
|
function parseMarkdownDocument(itemAttrs, options) {
|
|
831
867
|
const { url, alt, caption, role, href, target, id, preview, author, description } =
|
|
832
868
|
parseImgBlock(itemAttrs || {}, options);
|
|
@@ -920,7 +956,8 @@ function parseUniwebIcon(itemAttrs) {
|
|
|
920
956
|
// Markdown pipeline: library + name (resolved at runtime via CDN)
|
|
921
957
|
// OR a file, which arrives as `src`
|
|
922
958
|
const icon = {};
|
|
923
|
-
|
|
959
|
+
// Markup from content, so rebuilt from the SVG allowlist (`../sanitize.js`).
|
|
960
|
+
if (svg) icon.svg = sanitizeSVG(svg);
|
|
924
961
|
// Standard ProseMirror `image` nodes use `src`; the editor's `UniwebIcon`
|
|
925
962
|
// uses `url`. Honor either, exactly as parseImgBlock does below — a
|
|
926
963
|
// file-sourced icon is authored `` or
|
|
@@ -968,7 +1005,7 @@ function parseIconBlock(itemAttrs) {
|
|
|
968
1005
|
const { svg, theme } = itemAttrs || {};
|
|
969
1006
|
|
|
970
1007
|
const icon = {};
|
|
971
|
-
if (svg) icon.svg = svg;
|
|
1008
|
+
if (svg) icon.svg = sanitizeSVG(svg);
|
|
972
1009
|
if (theme) icon.theme = theme;
|
|
973
1010
|
|
|
974
1011
|
return icon;
|
|
@@ -979,47 +1016,47 @@ function parseImgBlock(itemAttrs, options) {
|
|
|
979
1016
|
info: imgInfo,
|
|
980
1017
|
targetId,
|
|
981
1018
|
caption = "",
|
|
982
|
-
direction,
|
|
983
|
-
filter,
|
|
984
1019
|
alt = "",
|
|
985
1020
|
src,
|
|
986
1021
|
url,
|
|
987
1022
|
href = "",
|
|
988
1023
|
target = "",
|
|
989
|
-
theme,
|
|
990
1024
|
role,
|
|
991
|
-
credit
|
|
1025
|
+
credit, // the editor's word for `author` — delivered as `author`
|
|
992
1026
|
id, // {#fig-id} cross-reference label — preserved so Press
|
|
993
1027
|
// adapters can emit \label{id} (LaTeX) / <id> (Typst).
|
|
994
1028
|
// ── Markdown-authored attributes ────────────────────────────────
|
|
995
1029
|
// This function was written for the EDITOR's `ImageBlock` node and
|
|
996
1030
|
// later reused for the markdown `image` node (see `case "image"`), so
|
|
997
|
-
// its vocabulary
|
|
998
|
-
//
|
|
999
|
-
//
|
|
1000
|
-
//
|
|
1001
|
-
//
|
|
1002
|
-
//
|
|
1003
|
-
//
|
|
1031
|
+
// its vocabulary was the editor's. Everything markdown declares and
|
|
1032
|
+
// the editor does not was therefore tokenized, declared, and then
|
|
1033
|
+
// dropped one layer before delivery — the published "Image
|
|
1034
|
+
// Attributes" table (docs/reference/content-structure.md) reached no
|
|
1035
|
+
// component, and neither did the `role=pdf` set added 2026-07-29 for
|
|
1036
|
+
// the editor's `document` fold-in. Carried 2026-07-30.
|
|
1037
|
+
//
|
|
1038
|
+
// ⭐ An element now holds the vocabulary's words, whichever node it
|
|
1039
|
+
// came from. ⛔ Until 2026-10-15 it also held the editor's `direction`
|
|
1040
|
+
// and two things derived from it — `imgPos` (left/right) and a
|
|
1041
|
+
// layout `size` (basic/lg/full, `basic` for every image without one) —
|
|
1042
|
+
// plus `filter` and `theme`, none of which a stored document holds. A
|
|
1043
|
+
// component handing the element to kit's <Image> passed that `size` as
|
|
1044
|
+
// Image's avatar preset, so a "wide" image drew as a 96px square.
|
|
1004
1045
|
width,
|
|
1005
1046
|
height,
|
|
1006
1047
|
loading,
|
|
1007
1048
|
fit,
|
|
1008
1049
|
position,
|
|
1050
|
+
placement, // where it sits in a flow: left, right, wide, full
|
|
1009
1051
|
preview, // role=pdf — the preview image
|
|
1010
|
-
author, // role=pdf
|
|
1052
|
+
author, // the image's attribution; for role=pdf, the resource's
|
|
1011
1053
|
description, // role=pdf — describes the RESOURCE, not the image (≠ alt)
|
|
1012
1054
|
} = itemAttrs;
|
|
1013
1055
|
|
|
1014
1056
|
let { contentType, viewType, contentId, identifier } = imgInfo || {};
|
|
1015
1057
|
|
|
1016
|
-
const sizes = {
|
|
1017
|
-
center: "basic",
|
|
1018
|
-
wide: "lg",
|
|
1019
|
-
fill: "full",
|
|
1020
|
-
};
|
|
1021
|
-
|
|
1022
1058
|
caption = stripTags(caption);
|
|
1059
|
+
const attribution = author || credit;
|
|
1023
1060
|
|
|
1024
1061
|
// Reference precedence. Standard ProseMirror `image` nodes use `src`; the
|
|
1025
1062
|
// custom `ImageBlock` node uses `url`. Honor either so callers don't have to
|
|
@@ -1051,19 +1088,9 @@ function parseImgBlock(itemAttrs, options) {
|
|
|
1051
1088
|
value: identifier || "",
|
|
1052
1089
|
alt: alt || caption,
|
|
1053
1090
|
caption,
|
|
1054
|
-
direction,
|
|
1055
|
-
filter,
|
|
1056
|
-
imgPos: direction === "left" || direction === "right" ? direction : "",
|
|
1057
|
-
// NOTE: `size` here is the direction-derived LAYOUT size
|
|
1058
|
-
// (basic/lg/full), not the author's `{size=…}` — that attribute is
|
|
1059
|
-
// icon-only and is read by parseUniwebIcon. Do not wire `{size=…}`
|
|
1060
|
-
// through here; the two meanings collide on one key.
|
|
1061
|
-
size: sizes[direction] || "basic",
|
|
1062
1091
|
href,
|
|
1063
1092
|
target,
|
|
1064
|
-
theme,
|
|
1065
1093
|
role,
|
|
1066
|
-
credit,
|
|
1067
1094
|
id: id || undefined,
|
|
1068
1095
|
// Omitted when absent, so an existing consumer sees no new empty keys.
|
|
1069
1096
|
...(width !== undefined && width !== null && { width }),
|
|
@@ -1071,12 +1098,13 @@ function parseImgBlock(itemAttrs, options) {
|
|
|
1071
1098
|
...(loading && { loading }),
|
|
1072
1099
|
...(fit && { fit }),
|
|
1073
1100
|
...(position && { position }),
|
|
1101
|
+
...(placement && { placement }),
|
|
1074
1102
|
...((() => {
|
|
1075
1103
|
// Same rule for a document's preview image.
|
|
1076
1104
|
const p = secondaryAssetUrl(itemAttrs, options, "preview");
|
|
1077
1105
|
return p ? { preview: p } : {};
|
|
1078
1106
|
})()),
|
|
1079
|
-
...(
|
|
1107
|
+
...(attribution && { author: attribution }),
|
|
1080
1108
|
...(description && { description }),
|
|
1081
1109
|
};
|
|
1082
1110
|
}
|
|
@@ -1105,6 +1133,7 @@ function parseMarkdownVideo(itemAttrs, options) {
|
|
|
1105
1133
|
target = "",
|
|
1106
1134
|
role,
|
|
1107
1135
|
id, // {#fig-id} cross-reference label, same as parseImgBlock
|
|
1136
|
+
placement, // where it sits in a flow, same as parseImgBlock
|
|
1108
1137
|
} = itemAttrs || {};
|
|
1109
1138
|
|
|
1110
1139
|
// Reference precedence, now genuinely identical to parseImgBlock: a
|
|
@@ -1138,6 +1167,7 @@ function parseMarkdownVideo(itemAttrs, options) {
|
|
|
1138
1167
|
if (loop !== undefined) video.loop = loop;
|
|
1139
1168
|
if (controls !== undefined) video.controls = controls;
|
|
1140
1169
|
if (id) video.id = id;
|
|
1170
|
+
if (placement) video.placement = placement;
|
|
1141
1171
|
|
|
1142
1172
|
return video;
|
|
1143
1173
|
}
|
|
@@ -1146,12 +1176,12 @@ function parseVideoBlock(itemAttrs, options) {
|
|
|
1146
1176
|
let {
|
|
1147
1177
|
src,
|
|
1148
1178
|
caption = "",
|
|
1149
|
-
direction,
|
|
1150
1179
|
info = {},
|
|
1151
1180
|
coverImg = {},
|
|
1152
1181
|
alt,
|
|
1153
1182
|
href = "",
|
|
1154
1183
|
target = "",
|
|
1184
|
+
placement, // where it sits in a flow; the editor's `direction` is not read
|
|
1155
1185
|
} = itemAttrs;
|
|
1156
1186
|
|
|
1157
1187
|
// Same precedence as parseImgBlock / parseMarkdownVideo: a resolvable
|
|
@@ -1161,11 +1191,11 @@ function parseVideoBlock(itemAttrs, options) {
|
|
|
1161
1191
|
return {
|
|
1162
1192
|
src: video,
|
|
1163
1193
|
caption,
|
|
1164
|
-
direction,
|
|
1165
1194
|
coverImg: makeAssetUrl(coverImg),
|
|
1166
1195
|
alt,
|
|
1167
1196
|
href,
|
|
1168
1197
|
target,
|
|
1198
|
+
...(placement && { placement }),
|
|
1169
1199
|
};
|
|
1170
1200
|
}
|
|
1171
1201
|
|
|
@@ -1216,43 +1246,39 @@ function isLink(item, options = {}) {
|
|
|
1216
1246
|
|
|
1217
1247
|
if (textContent.length === 1) {
|
|
1218
1248
|
let contentItem = textContent[0];
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
} else if (i > linkIndex) {
|
|
1241
|
-
// Take the first icon after the link
|
|
1242
|
-
if (!iconAfter) iconAfter = iconAttrs;
|
|
1243
|
-
}
|
|
1249
|
+
const mark = linkMarkOf(contentItem?.marks);
|
|
1250
|
+
|
|
1251
|
+
if (mark) {
|
|
1252
|
+
// Find the position of the link text in the original content
|
|
1253
|
+
const linkIndex = originalContent.findIndex(
|
|
1254
|
+
(c) => c.type === "text" && c.text === contentItem.text
|
|
1255
|
+
);
|
|
1256
|
+
|
|
1257
|
+
// Collect icons before and after the link text
|
|
1258
|
+
let iconBefore = null;
|
|
1259
|
+
let iconAfter = null;
|
|
1260
|
+
|
|
1261
|
+
for (let i = 0; i < originalContent.length; i++) {
|
|
1262
|
+
if (isIconNode(originalContent[i])) {
|
|
1263
|
+
const iconAttrs = parseUniwebIcon(originalContent[i].attrs);
|
|
1264
|
+
if (i < linkIndex) {
|
|
1265
|
+
// Take the last icon before the link
|
|
1266
|
+
iconBefore = iconAttrs;
|
|
1267
|
+
} else if (i > linkIndex) {
|
|
1268
|
+
// Take the first icon after the link
|
|
1269
|
+
if (!iconAfter) iconAfter = iconAttrs;
|
|
1244
1270
|
}
|
|
1245
1271
|
}
|
|
1246
|
-
|
|
1247
|
-
return {
|
|
1248
|
-
...mark?.attrs, // Preserve all link attributes (role, target, etc.)
|
|
1249
|
-
label: contentItem?.text || "",
|
|
1250
|
-
iconBefore,
|
|
1251
|
-
iconAfter,
|
|
1252
|
-
// Preserve all inline elements for advanced rendering
|
|
1253
|
-
children: processInlineElements(originalContent, options),
|
|
1254
|
-
};
|
|
1255
1272
|
}
|
|
1273
|
+
|
|
1274
|
+
return {
|
|
1275
|
+
...mark?.attrs, // Preserve all link attributes (role, target, etc.)
|
|
1276
|
+
label: contentItem?.text || "",
|
|
1277
|
+
iconBefore,
|
|
1278
|
+
iconAfter,
|
|
1279
|
+
// Preserve all inline elements for advanced rendering
|
|
1280
|
+
children: processInlineElements(originalContent, options),
|
|
1281
|
+
};
|
|
1256
1282
|
}
|
|
1257
1283
|
}
|
|
1258
1284
|
}
|
|
@@ -1292,8 +1318,7 @@ function isOnlyLinks(item) {
|
|
|
1292
1318
|
// Check if ALL remaining content items are text nodes with link marks
|
|
1293
1319
|
const allLinks = textContent.every((c) => {
|
|
1294
1320
|
if (c.type !== "text") return false;
|
|
1295
|
-
|
|
1296
|
-
return hasLinkMark;
|
|
1321
|
+
return Boolean(linkMarkOf(c.marks));
|
|
1297
1322
|
});
|
|
1298
1323
|
|
|
1299
1324
|
if (!allLinks) return false;
|
|
@@ -1301,7 +1326,7 @@ function isOnlyLinks(item) {
|
|
|
1301
1326
|
// Extract links with all their attributes
|
|
1302
1327
|
// Icons in this paragraph go to body.icons separately (no association)
|
|
1303
1328
|
return textContent.map((c) => {
|
|
1304
|
-
const linkMark = c.marks
|
|
1329
|
+
const linkMark = linkMarkOf(c.marks);
|
|
1305
1330
|
return {
|
|
1306
1331
|
type: "link",
|
|
1307
1332
|
attrs: {
|
|
@@ -1331,18 +1356,11 @@ function isStyledLink(item, options = {}) {
|
|
|
1331
1356
|
});
|
|
1332
1357
|
|
|
1333
1358
|
// check if all content items have the same link mark
|
|
1334
|
-
let firstLinkMark = content[0]?.marks
|
|
1335
|
-
|
|
1336
|
-
);
|
|
1337
|
-
if (!firstLinkMark) return false;
|
|
1359
|
+
let firstLinkMark = linkMarkOf(content[0]?.marks);
|
|
1360
|
+
if (!firstLinkMark?.attrs) return false;
|
|
1338
1361
|
if (
|
|
1339
1362
|
!content.every(
|
|
1340
|
-
(c) =>
|
|
1341
|
-
c?.marks?.some(
|
|
1342
|
-
(mark) =>
|
|
1343
|
-
mark.type === "link" &&
|
|
1344
|
-
mark.attrs?.href === firstLinkMark.attrs?.href
|
|
1345
|
-
) || false
|
|
1363
|
+
(c) => linkMarkOf(c?.marks)?.attrs?.href === firstLinkMark.attrs?.href
|
|
1346
1364
|
)
|
|
1347
1365
|
)
|
|
1348
1366
|
return false;
|
|
@@ -1352,7 +1370,7 @@ function isStyledLink(item, options = {}) {
|
|
|
1352
1370
|
const cleanedContent = content.map((c) => {
|
|
1353
1371
|
// remove link marks from content items
|
|
1354
1372
|
const cleanedMarks =
|
|
1355
|
-
c.marks?.filter((mark) => mark.type !== "link") || [];
|
|
1373
|
+
c.marks?.filter((mark) => mark.type !== "link" && mark.type !== "button") || [];
|
|
1356
1374
|
return {
|
|
1357
1375
|
...c,
|
|
1358
1376
|
marks: cleanedMarks,
|
|
@@ -1366,7 +1384,7 @@ function isStyledLink(item, options = {}) {
|
|
|
1366
1384
|
return {
|
|
1367
1385
|
type: "paragraph",
|
|
1368
1386
|
children: processInlineElements(item.content, options),
|
|
1369
|
-
text:
|
|
1387
|
+
text: anchor(href, target, textContent),
|
|
1370
1388
|
attrs: item.attrs,
|
|
1371
1389
|
};
|
|
1372
1390
|
}
|
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
|
+
}
|