@uniweb/semantic-parser 1.5.1 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -11
- package/package.json +3 -2
- package/src/processors/sequence.js +114 -89
- package/src/sanitize.js +556 -0
package/README.md
CHANGED
|
@@ -203,7 +203,8 @@ A horizontal rule (`---`) explicitly closes the current group and starts a new o
|
|
|
203
203
|
|
|
204
204
|
## Text Formatting
|
|
205
205
|
|
|
206
|
-
|
|
206
|
+
A text node holds text, and its characters are escaped in the HTML strings the
|
|
207
|
+
parser builds — a `<` someone typed shows as `<`. Marks become tags:
|
|
207
208
|
|
|
208
209
|
```js
|
|
209
210
|
// Input: Text with bold mark
|
|
@@ -213,33 +214,55 @@ Inline formatting is preserved as HTML tags:
|
|
|
213
214
|
// Output: "Text with <em>emphasis</em>"
|
|
214
215
|
|
|
215
216
|
// Input: Link mark
|
|
216
|
-
// Output: "Click <a href=\"/docs\">here</a>"
|
|
217
|
+
// Output: "Click <a href=\"/docs\" target=\"_self\">here</a>"
|
|
217
218
|
|
|
218
|
-
// Input: Span mark (
|
|
219
|
-
// Output: "This is <span
|
|
219
|
+
// Input: Span mark (a named style, `[text]{highlight}`)
|
|
220
|
+
// Output: "This is <span highlight=\"true\">highlighted</span> text"
|
|
220
221
|
```
|
|
221
222
|
|
|
223
|
+
A link is drawn only when its URL may be followed — relative, or `http:`,
|
|
224
|
+
`https:`, `mailto:`, `tel:`, `page:` or `topic:`. Any other, such as
|
|
225
|
+
`javascript:`, leaves its text unlinked. A `button` mark is a link whose `role`
|
|
226
|
+
is `button`.
|
|
227
|
+
|
|
222
228
|
### Span Marks
|
|
223
229
|
|
|
224
|
-
Bracketed spans
|
|
230
|
+
Bracketed spans are converted to `<span>` elements. A span's style names become
|
|
231
|
+
attributes, which a theme styles by name:
|
|
225
232
|
|
|
226
233
|
```js
|
|
227
234
|
// Input mark
|
|
228
|
-
{ type: "span", attrs: {
|
|
235
|
+
{ type: "span", attrs: { highlight: true, id: "note-1" } }
|
|
229
236
|
|
|
230
237
|
// Output HTML
|
|
231
|
-
'<span
|
|
238
|
+
'<span id="note-1" highlight="true">text</span>'
|
|
232
239
|
```
|
|
233
240
|
|
|
234
|
-
|
|
241
|
+
A span keeps `class`, `id`, `color` and `bg` (CSS colours, as an inline style),
|
|
242
|
+
and style names — a letter, then letters, digits, `_` or `-`, never starting with
|
|
243
|
+
`on`. Anything else is dropped. Spans combine with other marks — a span with bold
|
|
244
|
+
becomes `<strong><span …>text</span></strong>`.
|
|
245
|
+
|
|
246
|
+
### Safety — `@uniweb/semantic-parser/sanitize`
|
|
247
|
+
|
|
248
|
+
The rules above, exported for anything that builds or checks content:
|
|
249
|
+
|
|
250
|
+
| export | what it does |
|
|
251
|
+
|---|---|
|
|
252
|
+
| `escapeText`, `escapeAttribute` | escape a string for a text position or a double-quoted attribute |
|
|
253
|
+
| `isSafeLink(url)` | whether a link may be followed |
|
|
254
|
+
| `isLinkTarget(target)` | one of `_self`, `_blank`, `_parent`, `_top` |
|
|
255
|
+
| `isStyleName(name)`, `isCssColor(value)` | what a span may carry |
|
|
256
|
+
| `sanitizeMathML(mathml)`, `sanitizeSVG(svg)` | rebuild markup content carries by design — a formula, an icon — from an allowlist |
|
|
257
|
+
| `inspectMathML(mathml)` | what `sanitizeMathML` would leave out, reported instead of removed |
|
|
258
|
+
| `allowlists()` | the same rules as data, for an implementation in another language |
|
|
235
259
|
|
|
236
260
|
### Inline Insets and Cross-references
|
|
237
261
|
|
|
238
262
|
The parser handles inline `inset_placeholder` nodes — generated by
|
|
239
|
-
`@uniweb/
|
|
263
|
+
`@uniweb/core` when it builds a block from content holding
|
|
240
264
|
`{k=v}`, `[text](@Component){k=v}`, `[@key]{k=v}`,
|
|
241
|
-
or `[#id]{k=v}` references
|
|
242
|
-
ship to consumers:
|
|
265
|
+
or `[#id]{k=v}` references. Two artifacts ship to consumers:
|
|
243
266
|
|
|
244
267
|
1. **`children` carries an `inset` entry per placeholder** — each
|
|
245
268
|
carrying the `refId` and `embedKind` (`'text'` for textual sugars
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/semantic-parser",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "Semantic parser for ProseMirror/TipTap content structures",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"exports": {
|
|
8
|
-
".": "./src/index.js"
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./sanitize": "./src/sanitize.js"
|
|
9
10
|
},
|
|
10
11
|
"keywords": [
|
|
11
12
|
"prosemirror",
|
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
import { mimeFor, fileNameOf } from "../utils/mime.js";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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: {
|
|
@@ -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;
|
|
@@ -1216,43 +1253,39 @@ function isLink(item, options = {}) {
|
|
|
1216
1253
|
|
|
1217
1254
|
if (textContent.length === 1) {
|
|
1218
1255
|
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
|
-
}
|
|
1256
|
+
const mark = linkMarkOf(contentItem?.marks);
|
|
1257
|
+
|
|
1258
|
+
if (mark) {
|
|
1259
|
+
// Find the position of the link text in the original content
|
|
1260
|
+
const linkIndex = originalContent.findIndex(
|
|
1261
|
+
(c) => c.type === "text" && c.text === contentItem.text
|
|
1262
|
+
);
|
|
1263
|
+
|
|
1264
|
+
// Collect icons before and after the link text
|
|
1265
|
+
let iconBefore = null;
|
|
1266
|
+
let iconAfter = null;
|
|
1267
|
+
|
|
1268
|
+
for (let i = 0; i < originalContent.length; i++) {
|
|
1269
|
+
if (isIconNode(originalContent[i])) {
|
|
1270
|
+
const iconAttrs = parseUniwebIcon(originalContent[i].attrs);
|
|
1271
|
+
if (i < linkIndex) {
|
|
1272
|
+
// Take the last icon before the link
|
|
1273
|
+
iconBefore = iconAttrs;
|
|
1274
|
+
} else if (i > linkIndex) {
|
|
1275
|
+
// Take the first icon after the link
|
|
1276
|
+
if (!iconAfter) iconAfter = iconAttrs;
|
|
1244
1277
|
}
|
|
1245
1278
|
}
|
|
1246
|
-
|
|
1247
|
-
return {
|
|
1248
|
-
...mark?.attrs, // Preserve all link attributes (role, target, etc.)
|
|
1249
|
-
label: contentItem?.text || "",
|
|
1250
|
-
iconBefore,
|
|
1251
|
-
iconAfter,
|
|
1252
|
-
// Preserve all inline elements for advanced rendering
|
|
1253
|
-
children: processInlineElements(originalContent, options),
|
|
1254
|
-
};
|
|
1255
1279
|
}
|
|
1280
|
+
|
|
1281
|
+
return {
|
|
1282
|
+
...mark?.attrs, // Preserve all link attributes (role, target, etc.)
|
|
1283
|
+
label: contentItem?.text || "",
|
|
1284
|
+
iconBefore,
|
|
1285
|
+
iconAfter,
|
|
1286
|
+
// Preserve all inline elements for advanced rendering
|
|
1287
|
+
children: processInlineElements(originalContent, options),
|
|
1288
|
+
};
|
|
1256
1289
|
}
|
|
1257
1290
|
}
|
|
1258
1291
|
}
|
|
@@ -1292,8 +1325,7 @@ function isOnlyLinks(item) {
|
|
|
1292
1325
|
// Check if ALL remaining content items are text nodes with link marks
|
|
1293
1326
|
const allLinks = textContent.every((c) => {
|
|
1294
1327
|
if (c.type !== "text") return false;
|
|
1295
|
-
|
|
1296
|
-
return hasLinkMark;
|
|
1328
|
+
return Boolean(linkMarkOf(c.marks));
|
|
1297
1329
|
});
|
|
1298
1330
|
|
|
1299
1331
|
if (!allLinks) return false;
|
|
@@ -1301,7 +1333,7 @@ function isOnlyLinks(item) {
|
|
|
1301
1333
|
// Extract links with all their attributes
|
|
1302
1334
|
// Icons in this paragraph go to body.icons separately (no association)
|
|
1303
1335
|
return textContent.map((c) => {
|
|
1304
|
-
const linkMark = c.marks
|
|
1336
|
+
const linkMark = linkMarkOf(c.marks);
|
|
1305
1337
|
return {
|
|
1306
1338
|
type: "link",
|
|
1307
1339
|
attrs: {
|
|
@@ -1331,18 +1363,11 @@ function isStyledLink(item, options = {}) {
|
|
|
1331
1363
|
});
|
|
1332
1364
|
|
|
1333
1365
|
// check if all content items have the same link mark
|
|
1334
|
-
let firstLinkMark = content[0]?.marks
|
|
1335
|
-
|
|
1336
|
-
);
|
|
1337
|
-
if (!firstLinkMark) return false;
|
|
1366
|
+
let firstLinkMark = linkMarkOf(content[0]?.marks);
|
|
1367
|
+
if (!firstLinkMark?.attrs) return false;
|
|
1338
1368
|
if (
|
|
1339
1369
|
!content.every(
|
|
1340
|
-
(c) =>
|
|
1341
|
-
c?.marks?.some(
|
|
1342
|
-
(mark) =>
|
|
1343
|
-
mark.type === "link" &&
|
|
1344
|
-
mark.attrs?.href === firstLinkMark.attrs?.href
|
|
1345
|
-
) || false
|
|
1370
|
+
(c) => linkMarkOf(c?.marks)?.attrs?.href === firstLinkMark.attrs?.href
|
|
1346
1371
|
)
|
|
1347
1372
|
)
|
|
1348
1373
|
return false;
|
|
@@ -1352,7 +1377,7 @@ function isStyledLink(item, options = {}) {
|
|
|
1352
1377
|
const cleanedContent = content.map((c) => {
|
|
1353
1378
|
// remove link marks from content items
|
|
1354
1379
|
const cleanedMarks =
|
|
1355
|
-
c.marks?.filter((mark) => mark.type !== "link") || [];
|
|
1380
|
+
c.marks?.filter((mark) => mark.type !== "link" && mark.type !== "button") || [];
|
|
1356
1381
|
return {
|
|
1357
1382
|
...c,
|
|
1358
1383
|
marks: cleanedMarks,
|
|
@@ -1366,7 +1391,7 @@ function isStyledLink(item, options = {}) {
|
|
|
1366
1391
|
return {
|
|
1367
1392
|
type: "paragraph",
|
|
1368
1393
|
children: processInlineElements(item.content, options),
|
|
1369
|
-
text:
|
|
1394
|
+
text: anchor(href, target, textContent),
|
|
1370
1395
|
attrs: item.attrs,
|
|
1371
1396
|
};
|
|
1372
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
|
+
}
|