@uniweb/semantic-parser 1.8.0 → 1.9.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/package.json +1 -1
- package/src/builders/doc.js +5 -7
- package/src/processors/groups.js +1 -27
- package/src/processors/sequence.js +51 -331
- package/src/sanitize.js +100 -0
package/package.json
CHANGED
package/src/builders/doc.js
CHANGED
|
@@ -23,8 +23,9 @@
|
|
|
23
23
|
* This builder was written with its own spelling for several concepts, and
|
|
24
24
|
* `parseContent` emits a DIFFERENT one for the same thing: a link's text is
|
|
25
25
|
* `text` here and `label` there; an image's address is `src` here and `url`
|
|
26
|
-
* there; a video's cover is a string here and `{ src | url | identifier }`
|
|
27
26
|
* there; a list item is a plain string here and a whole content GROUP there.
|
|
27
|
+
* (A video's cover was a fourth — an editor's `coverImg` — until the parser
|
|
28
|
+
* stopped reading the editor's `Video`, 2026-10-18; both sides say `poster`.)
|
|
28
29
|
*
|
|
29
30
|
* ⛔ EVERY ONE OF THOSE MISMATCHES FAILED SILENTLY, which is why the rule is
|
|
30
31
|
* now written down. Measured 2026-09-16, all four on the shapes a caller would
|
|
@@ -180,16 +181,13 @@ function iconNode({ src, url, library, name, size, color }) {
|
|
|
180
181
|
const PLAYBACK_FLAGS = ['autoplay', 'muted', 'loop', 'controls']
|
|
181
182
|
|
|
182
183
|
function videoNode(video) {
|
|
183
|
-
const { src, url, alt, caption, placement, poster
|
|
184
|
+
const { src, url, alt, caption, placement, poster } = video
|
|
184
185
|
const attrs = { src: src || url, role: 'video' }
|
|
185
186
|
if (alt) attrs.alt = alt
|
|
186
187
|
if (caption) attrs.caption = caption
|
|
187
188
|
// The cover is `poster`, what the parser emits for a video and what a
|
|
188
|
-
// component reads.
|
|
189
|
-
|
|
190
|
-
// no address, so it gives none.
|
|
191
|
-
const cover = poster || (typeof coverImg === 'string' ? coverImg : coverImg?.src || coverImg?.url)
|
|
192
|
-
if (cover) attrs.poster = cover
|
|
189
|
+
// component reads. ⛔ Until 2026-10-18 an editor's `coverImg` was taken too.
|
|
190
|
+
if (poster) attrs.poster = poster
|
|
193
191
|
if (placement) attrs.placement = placement
|
|
194
192
|
for (const flag of PLAYBACK_FLAGS) if (typeof video[flag] === 'boolean') attrs[flag] = video[flag]
|
|
195
193
|
return { type: 'image', attrs }
|
package/src/processors/groups.js
CHANGED
|
@@ -417,20 +417,6 @@ function processGroupContent(elements, options = {}) {
|
|
|
417
417
|
body.icons.push(element.attrs);
|
|
418
418
|
break;
|
|
419
419
|
|
|
420
|
-
case "button":
|
|
421
|
-
// Map button to link with role
|
|
422
|
-
body.links.push({
|
|
423
|
-
href: element.attrs?.href || '',
|
|
424
|
-
label: element.text || '',
|
|
425
|
-
role: element.attrs?.variant ? `button-${element.attrs.variant}` : 'button',
|
|
426
|
-
variant: element.attrs?.variant || 'primary',
|
|
427
|
-
size: element.attrs?.size,
|
|
428
|
-
icon: element.attrs?.icon,
|
|
429
|
-
target: element.attrs?.target,
|
|
430
|
-
class: element.attrs?.class,
|
|
431
|
-
});
|
|
432
|
-
break;
|
|
433
|
-
|
|
434
420
|
case "blockquote":
|
|
435
421
|
// Process blockquote content recursively
|
|
436
422
|
const quoteContent = processGroupContent(element.children);
|
|
@@ -515,7 +501,7 @@ function processGroupContent(elements, options = {}) {
|
|
|
515
501
|
break;
|
|
516
502
|
|
|
517
503
|
case "form":
|
|
518
|
-
// Route
|
|
504
|
+
// Route a form's data by the schema id it targets, so it
|
|
519
505
|
// shares a namespace with tagged markdown data blocks
|
|
520
506
|
// (```yaml:<id>``` also lands at data[<id>]). When no
|
|
521
507
|
// schemaId is present (legacy content), fall back to the
|
|
@@ -527,18 +513,6 @@ function processGroupContent(elements, options = {}) {
|
|
|
527
513
|
}
|
|
528
514
|
break;
|
|
529
515
|
|
|
530
|
-
case "card-group":
|
|
531
|
-
// Map cards to data by type: data.person = [...], data.event = [...]
|
|
532
|
-
// Each card type becomes a key, with an array of cards of that type
|
|
533
|
-
(element.cards || []).forEach(card => {
|
|
534
|
-
const cardType = card.cardType || 'card';
|
|
535
|
-
if (!body.data[cardType]) body.data[cardType] = [];
|
|
536
|
-
// Remove cardType from the card object since it's now the key
|
|
537
|
-
const { cardType: _, ...cardData } = card;
|
|
538
|
-
body.data[cardType].push(cardData);
|
|
539
|
-
});
|
|
540
|
-
break;
|
|
541
|
-
|
|
542
516
|
}
|
|
543
517
|
}
|
|
544
518
|
}
|
|
@@ -3,7 +3,6 @@ import {
|
|
|
3
3
|
escapeText,
|
|
4
4
|
escapeAttribute as escapeAttr,
|
|
5
5
|
sanitizeMathML,
|
|
6
|
-
sanitizeSVG,
|
|
7
6
|
isSafeLink,
|
|
8
7
|
isLinkTarget,
|
|
9
8
|
isStyleName,
|
|
@@ -18,12 +17,9 @@ import {
|
|
|
18
17
|
// rule that a text node held HTML — so `<img src=x onerror=…>` written in
|
|
19
18
|
// content ran on the published page, and `if a<b then` showed as `if a`.
|
|
20
19
|
|
|
21
|
-
/**
|
|
22
|
-
* Check if an inline node is an icon.
|
|
23
|
-
* TipTap editor uses type "UniwebIcon"; markdown pipeline uses type "image" with role "icon".
|
|
24
|
-
*/
|
|
20
|
+
/** Check if an inline node is an icon: an `image` with role "icon". */
|
|
25
21
|
function isIconNode(node) {
|
|
26
|
-
return node.type === "
|
|
22
|
+
return node.type === "image" && node.attrs?.role === "icon";
|
|
27
23
|
}
|
|
28
24
|
|
|
29
25
|
/**
|
|
@@ -207,11 +203,6 @@ function createSequenceElement(node, options = {}) {
|
|
|
207
203
|
mathml: sanitizeMathML(node.attrs?.mathml || ""),
|
|
208
204
|
attrs,
|
|
209
205
|
};
|
|
210
|
-
case "ImageBlock":
|
|
211
|
-
return {
|
|
212
|
-
type: "image",
|
|
213
|
-
attrs: parseImgBlock(attrs, options),
|
|
214
|
-
};
|
|
215
206
|
case "image":
|
|
216
207
|
// `role` decides which content array this lands in — the contract
|
|
217
208
|
// published in docs/reference/content-structure.md ("Media Assets"):
|
|
@@ -234,28 +225,20 @@ function createSequenceElement(node, options = {}) {
|
|
|
234
225
|
// poster/autoplay/muted/loop/controls, so the author's declaration
|
|
235
226
|
// was demoted to an image AND its playback attrs were dropped —
|
|
236
227
|
// while the build had already generated a poster for it
|
|
237
|
-
// (@uniweb/build site/assets.js).
|
|
238
|
-
// parseVideoBlock: that reader speaks the editor's dialect
|
|
239
|
-
// (coverImg, info), not the markdown one.
|
|
228
|
+
// (@uniweb/build site/assets.js).
|
|
240
229
|
if (attrs?.role === "video") {
|
|
241
230
|
return {
|
|
242
231
|
type: "video",
|
|
243
232
|
attrs: parseMarkdownVideo(attrs, options),
|
|
244
233
|
};
|
|
245
234
|
}
|
|
246
|
-
// Standard ProseMirror image node -
|
|
247
|
-
//
|
|
248
|
-
// content uses this node type; without parseImgBlock the url
|
|
235
|
+
// Standard ProseMirror image node - read through parseImgBlock, so
|
|
236
|
+
// its address lands in `url`. Without parseImgBlock the url
|
|
249
237
|
// wouldn't be populated and components render an empty slot.
|
|
250
238
|
return {
|
|
251
239
|
type: "image",
|
|
252
240
|
attrs: parseImgBlock(attrs || {}, options),
|
|
253
241
|
};
|
|
254
|
-
case "Video":
|
|
255
|
-
return {
|
|
256
|
-
type: "video",
|
|
257
|
-
attrs: parseVideoBlock(attrs, options),
|
|
258
|
-
};
|
|
259
242
|
case "bulletList":
|
|
260
243
|
case "orderedList": {
|
|
261
244
|
const listItems = content
|
|
@@ -321,34 +304,11 @@ function createSequenceElement(node, options = {}) {
|
|
|
321
304
|
attrs,
|
|
322
305
|
};
|
|
323
306
|
|
|
324
|
-
//
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
cards:
|
|
329
|
-
node.content
|
|
330
|
-
?.filter((c) => c.type === "card" && !c.attrs?.hidden)
|
|
331
|
-
.map((card) => parseCardBlock(card.attrs)) || [],
|
|
332
|
-
};
|
|
333
|
-
|
|
334
|
-
case "document-group":
|
|
335
|
-
// The editor's older container: one `document` element per document
|
|
336
|
-
// it holds, so a document is one kind of content whichever node
|
|
337
|
-
// wrote it (`documentFromEditorNode`). Until 2026-09-29 this was a
|
|
338
|
-
// `document-group` element whose documents became `content.links`.
|
|
339
|
-
return (node.content || [])
|
|
340
|
-
.filter((c) => c.type === "document")
|
|
341
|
-
.map((doc) => ({
|
|
342
|
-
type: "document",
|
|
343
|
-
attrs: documentFromEditorNode(doc.attrs),
|
|
344
|
-
}));
|
|
345
|
-
|
|
346
|
-
// The editor renamed this node to `StructuredContent` (2026-07-31,
|
|
347
|
-
// editor-internal). Both are accepted: the new name is what the editor
|
|
348
|
-
// emits now, and the old one still arrives from documents authored
|
|
349
|
-
// before the rename, which the framework never rewrites.
|
|
307
|
+
// The editor's form node. ⛔ Until 2026-10-18 its name before
|
|
308
|
+
// 2026-07-31, `FormBlock`, was read too — and the editor's `card-group`,
|
|
309
|
+
// `document-group`, `ImageBlock`, `Video`, `UniwebIcon`, `Icon` and
|
|
310
|
+
// `button` nodes, which no writer produces now.
|
|
350
311
|
case "StructuredContent":
|
|
351
|
-
case "FormBlock":
|
|
352
312
|
// Parse form data (can be JSON string or object)
|
|
353
313
|
let formData = attrs?.data;
|
|
354
314
|
if (typeof formData === "string") {
|
|
@@ -366,29 +326,6 @@ function createSequenceElement(node, options = {}) {
|
|
|
366
326
|
attrs,
|
|
367
327
|
};
|
|
368
328
|
|
|
369
|
-
case "button": {
|
|
370
|
-
let textContent = getTextContent(content, options);
|
|
371
|
-
|
|
372
|
-
if (!textContent) return null;
|
|
373
|
-
|
|
374
|
-
return {
|
|
375
|
-
type: "button",
|
|
376
|
-
text: textContent,
|
|
377
|
-
children: processInlineElements(content, options),
|
|
378
|
-
attrs,
|
|
379
|
-
};
|
|
380
|
-
}
|
|
381
|
-
case "UniwebIcon":
|
|
382
|
-
return {
|
|
383
|
-
type: "icon",
|
|
384
|
-
attrs: parseUniwebIcon(attrs),
|
|
385
|
-
};
|
|
386
|
-
case "Icon":
|
|
387
|
-
return {
|
|
388
|
-
type: "icon",
|
|
389
|
-
attrs: parseIconBlock(attrs),
|
|
390
|
-
};
|
|
391
|
-
|
|
392
329
|
default:
|
|
393
330
|
return {
|
|
394
331
|
type: node.type,
|
|
@@ -653,9 +590,9 @@ function processInlineElements(content, options = {}) {
|
|
|
653
590
|
if (isIconNode(item)) {
|
|
654
591
|
items.push({
|
|
655
592
|
type: "icon",
|
|
656
|
-
attrs:
|
|
593
|
+
attrs: parseIcon(item.attrs),
|
|
657
594
|
});
|
|
658
|
-
} else if (item.type === "image"
|
|
595
|
+
} else if (item.type === "image") {
|
|
659
596
|
// A media node that stayed inside its paragraph — content-reader
|
|
660
597
|
// hoists a standalone one to the document root, but an image sitting
|
|
661
598
|
// beside text does not qualify (parser/block.js `isBlockEligible`).
|
|
@@ -715,6 +652,11 @@ function processInlineElements(content, options = {}) {
|
|
|
715
652
|
* layout, so a framework package never composes a serve location or guesses an
|
|
716
653
|
* origin — a host may move its assets without a framework release.
|
|
717
654
|
*
|
|
655
|
+
* ⛔ No asset host is built into this package: a reference resolves through this
|
|
656
|
+
* pattern, or is the address the content holds. Until 2026-10-17 an
|
|
657
|
+
* `info.identifier` (`{version}/{filename}`) was composed against a fixed host;
|
|
658
|
+
* it is not read.
|
|
659
|
+
*
|
|
718
660
|
* ⛔ Three ways this returns '' — "unresolved", never a guess:
|
|
719
661
|
* - **no pattern** (the host declared none) — absent ⇒ absent
|
|
720
662
|
* - **an unknown placeholder** — never emit a half-substituted URL
|
|
@@ -750,8 +692,9 @@ function processInlineElements(content, options = {}) {
|
|
|
750
692
|
* the one every fallback path already resolves to. The convention is: unprefixed
|
|
751
693
|
* identifies the primary; a prefix names the attr it belongs to.
|
|
752
694
|
*
|
|
753
|
-
* `url` rides in the primary slot because the editor's `ImageBlock`
|
|
754
|
-
*
|
|
695
|
+
* `url` rides in the primary slot because the editor's `ImageBlock` used it where
|
|
696
|
+
* `image` uses `src`. This parser reads `src` alone since 2026-10-18; the entry
|
|
697
|
+
* stays because this list is exported and read outside it.
|
|
755
698
|
*/
|
|
756
699
|
export const ASSET_SLOTS = [
|
|
757
700
|
{ urls: ["src", "url"], id: "assetId", ext: "assetExt" },
|
|
@@ -791,72 +734,6 @@ function secondaryAssetUrl(attrs, options, urlAttr) {
|
|
|
791
734
|
return resolved || attrs?.[urlAttr] || "";
|
|
792
735
|
}
|
|
793
736
|
|
|
794
|
-
// ⛔ LEGACY BELOW — the PHP-estate identifier path. Do not extend it.
|
|
795
|
-
//
|
|
796
|
-
// `{version}/{filename}` identifiers have no current producer — the CLI emits
|
|
797
|
-
// `src`, and editors store the URL their host hands them. Only content authored
|
|
798
|
-
// against the original hosted service still carries one, and the hardcoded host
|
|
799
|
-
// below cannot be correct for any other deployment: the CLI can be pointed at
|
|
800
|
-
// any host. New references use `assetId`/`assetExt` above.
|
|
801
|
-
const ASSET_BASE_URL = "https://assets.uniweb.app/";
|
|
802
|
-
|
|
803
|
-
/**
|
|
804
|
-
* Resolve an asset identifier ({version}/{filename}) to a direct URL.
|
|
805
|
-
* Assets are hosted at assets.uniweb.app under dist/{version}/base.{ext}.
|
|
806
|
-
*/
|
|
807
|
-
function resolveAssetIdentifier(identifier) {
|
|
808
|
-
if (!identifier || typeof identifier !== "string") return "";
|
|
809
|
-
// ⛔ An identifier that is ALREADY a URL or a rooted path is not an
|
|
810
|
-
// identifier — it is a serve URL sitting in the identifier slot, and it is
|
|
811
|
-
// usable exactly as it stands. Composing it against the legacy base
|
|
812
|
-
// destroys it: `split("/")` on `/gateway/asset/dist/{id}/base.png` takes
|
|
813
|
-
// `""` as the version and `"gateway"` as the filename, yielding
|
|
814
|
-
// `https://assets.uniweb.app/dist//base.gateway` — not a 404 and not a
|
|
815
|
-
// validation failure, a URL-shaped string that renders a broken image and
|
|
816
|
-
// reads like a missing file.
|
|
817
|
-
//
|
|
818
|
-
// The legacy grammar is `{version}/{filename}` with no leading slash, so
|
|
819
|
-
// nothing legitimate is caught here. Passing it through is not constructing
|
|
820
|
-
// a serve location: it is declining to construct one over a value that is
|
|
821
|
-
// already an address. (Measured 2026-08-17 against a real producer.)
|
|
822
|
-
if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|\/)/i.test(identifier)) return identifier;
|
|
823
|
-
const [version, filename] = identifier.split("/");
|
|
824
|
-
if (!filename) return "";
|
|
825
|
-
const ext = filename.substring(filename.lastIndexOf(".") + 1);
|
|
826
|
-
return `${ASSET_BASE_URL}dist/${version}/base.${ext}`;
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
function makeAssetUrl(info) {
|
|
830
|
-
const src = info?.src || info?.url || "";
|
|
831
|
-
if (src) return src;
|
|
832
|
-
if (info?.identifier) return resolveAssetIdentifier(info.identifier);
|
|
833
|
-
return "";
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
function parseCardBlock(itemAttrs) {
|
|
837
|
-
const { address, ...others } = itemAttrs;
|
|
838
|
-
|
|
839
|
-
let parsedAddress = null;
|
|
840
|
-
|
|
841
|
-
try {
|
|
842
|
-
if (address) {
|
|
843
|
-
parsedAddress = JSON.parse(address);
|
|
844
|
-
}
|
|
845
|
-
} catch {}
|
|
846
|
-
|
|
847
|
-
const { coverImg = null, icon } = others;
|
|
848
|
-
|
|
849
|
-
if (icon) {
|
|
850
|
-
others.icon = parseUniwebIcon(icon);
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
return {
|
|
854
|
-
...others,
|
|
855
|
-
address: parsedAddress,
|
|
856
|
-
coverImg: makeAssetUrl(coverImg),
|
|
857
|
-
};
|
|
858
|
-
}
|
|
859
|
-
|
|
860
737
|
// A document authored in markdown: `{role=pdf …}`.
|
|
861
738
|
//
|
|
862
739
|
// Read through `parseImgBlock`, which resolves the address and the preview's the
|
|
@@ -900,146 +777,47 @@ function fileFacts(url, attrs = {}) {
|
|
|
900
777
|
};
|
|
901
778
|
}
|
|
902
779
|
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
} =
|
|
916
|
-
const url = href || downloadUrl || "";
|
|
917
|
-
return {
|
|
918
|
-
url,
|
|
919
|
-
...fileFacts(url, itemAttrs),
|
|
920
|
-
alt: alt || title,
|
|
921
|
-
caption: title,
|
|
922
|
-
role: "pdf",
|
|
923
|
-
...(coverImg && { preview: coverImg }),
|
|
924
|
-
...(author && { author }),
|
|
925
|
-
...(description && { description }),
|
|
926
|
-
...(fileType && { fileType }),
|
|
927
|
-
};
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
function parseDocumentBlock(itemAttrs) {
|
|
931
|
-
const { src, info = {}, coverImg = null, ...others } = itemAttrs;
|
|
932
|
-
|
|
933
|
-
let ele = {
|
|
934
|
-
...others,
|
|
935
|
-
coverImg: makeAssetUrl(coverImg),
|
|
936
|
-
};
|
|
937
|
-
|
|
938
|
-
if (src) {
|
|
939
|
-
ele.href = src;
|
|
940
|
-
} else {
|
|
941
|
-
const { identifier = "" } = info;
|
|
942
|
-
|
|
943
|
-
if (identifier) {
|
|
944
|
-
ele.downloadUrl = resolveAssetIdentifier(identifier);
|
|
945
|
-
}
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
return ele;
|
|
949
|
-
}
|
|
950
|
-
|
|
951
|
-
function parseUniwebIcon(itemAttrs) {
|
|
952
|
-
const { svg, url, src, size, color, preserveColors, href, target, library, name } = itemAttrs || {};
|
|
780
|
+
/**
|
|
781
|
+
* An icon: named (`library` + `name`, resolved at render through the icon
|
|
782
|
+
* corpus) or a file (`src`) — `` or
|
|
783
|
+
* `{role=icon}`, both documented in AGENTS.md and
|
|
784
|
+
* docs/reference/content-structure.md. Until 2026-07-30 a file-sourced icon
|
|
785
|
+
* delivered `{}` and rendered nothing. `url` is the key kit's <Icon> fetches
|
|
786
|
+
* from. Keys are omitted when absent.
|
|
787
|
+
*
|
|
788
|
+
* ⛔ Until 2026-10-18 this also read the editor's `UniwebIcon` (an inline `svg`,
|
|
789
|
+
* a `url`, `preserveColors`, a `family:id` name) and its `Icon` node.
|
|
790
|
+
*/
|
|
791
|
+
function parseIcon(itemAttrs) {
|
|
792
|
+
const { src, size, color, href, target, library, name } = itemAttrs || {};
|
|
953
793
|
|
|
954
|
-
// Build object with only defined fields — icon source varies:
|
|
955
|
-
// TipTap editor: svg/url (resolved inline)
|
|
956
|
-
// Markdown pipeline: library + name (resolved at runtime via CDN)
|
|
957
|
-
// OR a file, which arrives as `src`
|
|
958
794
|
const icon = {};
|
|
959
|
-
|
|
960
|
-
if (svg) icon.svg = sanitizeSVG(svg);
|
|
961
|
-
// Standard ProseMirror `image` nodes use `src`; the editor's `UniwebIcon`
|
|
962
|
-
// uses `url`. Honor either, exactly as parseImgBlock does below — a
|
|
963
|
-
// file-sourced icon is authored `` or
|
|
964
|
-
// `{role=icon}` (both documented in AGENTS.md and
|
|
965
|
-
// docs/reference/content-structure.md), and reaches here as `src` with no
|
|
966
|
-
// library/name. Until 2026-07-30 this reader dropped it, so every
|
|
967
|
-
// documented file-sourced icon delivered `{}` and rendered nothing.
|
|
968
|
-
// `url` is the key kit's <Icon> fetches from.
|
|
969
|
-
if (url || src) icon.url = url || src;
|
|
795
|
+
if (src) icon.url = src;
|
|
970
796
|
if (size) icon.size = size;
|
|
971
797
|
if (color) icon.color = color;
|
|
972
|
-
if (preserveColors) icon.preserveColors = preserveColors;
|
|
973
798
|
if (href) icon.href = href;
|
|
974
799
|
if (target) icon.target = target;
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
// is what `<Icon library name />` consumes.
|
|
978
|
-
let resolvedLibrary = library;
|
|
979
|
-
let resolvedName = name;
|
|
980
|
-
if (!resolvedLibrary && typeof name === "string" && name.includes(":")) {
|
|
981
|
-
const [family, ...rest] = name.split(":");
|
|
982
|
-
resolvedLibrary = family;
|
|
983
|
-
resolvedName = rest.join(":");
|
|
984
|
-
}
|
|
985
|
-
if (resolvedLibrary) icon.library = resolvedLibrary;
|
|
986
|
-
if (resolvedName) icon.name = resolvedName;
|
|
987
|
-
|
|
988
|
-
return icon;
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
// The editor's `Icon` node carries an inline SVG blob plus a theme. Return an
|
|
992
|
-
// ATTRS OBJECT, like every other parser in this file — not the bare `svg`
|
|
993
|
-
// string it used to return.
|
|
994
|
-
//
|
|
995
|
-
// The string form was a live bug, not just an inconsistency: kit renders a
|
|
996
|
-
// sequence icon with `<Icon {...element.attrs} />` (styled/Prose/index.jsx),
|
|
997
|
-
// and spreading a string yields indexed character props — `{0:'<', 1:'s', …}`
|
|
998
|
-
// — so the icon rendered as nothing and React saw a wall of unknown props. It
|
|
999
|
-
// failed silently because `attrs.svg` on a string is `undefined`, never an
|
|
1000
|
-
// error.
|
|
1001
|
-
//
|
|
1002
|
-
// Keys are omitted when absent so a consumer can distinguish "not provided"
|
|
1003
|
-
// from "empty", matching parseUniwebIcon's shape above.
|
|
1004
|
-
function parseIconBlock(itemAttrs) {
|
|
1005
|
-
const { svg, theme } = itemAttrs || {};
|
|
1006
|
-
|
|
1007
|
-
const icon = {};
|
|
1008
|
-
if (svg) icon.svg = sanitizeSVG(svg);
|
|
1009
|
-
if (theme) icon.theme = theme;
|
|
800
|
+
if (library) icon.library = library;
|
|
801
|
+
if (name) icon.name = name;
|
|
1010
802
|
|
|
1011
803
|
return icon;
|
|
1012
804
|
}
|
|
1013
805
|
|
|
1014
806
|
function parseImgBlock(itemAttrs, options) {
|
|
1015
807
|
let {
|
|
1016
|
-
info: imgInfo,
|
|
1017
|
-
targetId,
|
|
1018
808
|
caption = "",
|
|
1019
809
|
alt = "",
|
|
1020
810
|
src,
|
|
1021
|
-
url,
|
|
1022
811
|
href = "",
|
|
1023
812
|
target = "",
|
|
1024
813
|
role,
|
|
1025
|
-
credit, // the editor's word for `author` — delivered as `author`
|
|
1026
814
|
id, // {#fig-id} cross-reference label — preserved so Press
|
|
1027
815
|
// adapters can emit \label{id} (LaTeX) / <id> (Typst).
|
|
1028
|
-
//
|
|
1029
|
-
//
|
|
1030
|
-
//
|
|
1031
|
-
//
|
|
1032
|
-
//
|
|
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
|
|
816
|
+
// ⭐ An element holds the vocabulary's words. ⛔ This function was
|
|
817
|
+
// written for the editor's `ImageBlock` and read its words too — `url`,
|
|
818
|
+
// `credit`, an `info` envelope (`contentType`, `viewType`, `contentId`)
|
|
819
|
+
// and `targetId` — until 2026-10-18; and until 2026-10-15 its
|
|
820
|
+
// `direction`, `imgPos`, a layout `size`, `filter` and `theme`. A
|
|
1043
821
|
// component handing the element to kit's <Image> passed that `size` as
|
|
1044
822
|
// Image's avatar preset, so a "wide" image drew as a 96px square.
|
|
1045
823
|
width,
|
|
@@ -1053,39 +831,18 @@ function parseImgBlock(itemAttrs, options) {
|
|
|
1053
831
|
description, // role=pdf — describes the RESOURCE, not the image (≠ alt)
|
|
1054
832
|
} = itemAttrs;
|
|
1055
833
|
|
|
1056
|
-
let { contentType, viewType, contentId, identifier } = imgInfo || {};
|
|
1057
|
-
|
|
1058
834
|
caption = stripTags(caption);
|
|
1059
|
-
const attribution = author || credit;
|
|
1060
835
|
|
|
1061
|
-
// Reference precedence. Standard ProseMirror `image` nodes use `src`; the
|
|
1062
|
-
// custom `ImageBlock` node uses `url`. Honor either so callers don't have to
|
|
1063
|
-
// pre-normalize.
|
|
1064
|
-
//
|
|
1065
836
|
// ⭐ A store-held asset (`assetId`) wins WHEN IT RESOLVES — not merely when
|
|
1066
837
|
// it is present. That distinction is the whole reason a producer can write
|
|
1067
838
|
// `assetId` alongside `src` before any deployment declares
|
|
1068
839
|
// `config.assets.url`: until one does, resolution yields '' and the node
|
|
1069
840
|
// renders through `src` exactly as it does today. Content written now stays
|
|
1070
841
|
// correct whichever order the halves arrive in.
|
|
1071
|
-
|
|
1072
|
-
// `identifier` is the LEGACY estate path and keeps its old precedence over a
|
|
1073
|
-
// stale `src`.
|
|
1074
|
-
const assetUrl = assetUrlFromAttrs(itemAttrs, options);
|
|
1075
|
-
if (assetUrl) {
|
|
1076
|
-
url = assetUrl;
|
|
1077
|
-
} else if (identifier) {
|
|
1078
|
-
url = makeAssetUrl(imgInfo);
|
|
1079
|
-
} else if (!url && src) {
|
|
1080
|
-
url = src;
|
|
1081
|
-
}
|
|
842
|
+
const url = assetUrlFromAttrs(itemAttrs, options) || src || undefined;
|
|
1082
843
|
|
|
1083
844
|
return {
|
|
1084
|
-
contentType,
|
|
1085
|
-
viewType,
|
|
1086
|
-
contentId: targetId || contentId,
|
|
1087
845
|
url,
|
|
1088
|
-
value: identifier || "",
|
|
1089
846
|
alt: alt || caption,
|
|
1090
847
|
caption,
|
|
1091
848
|
href,
|
|
@@ -1104,24 +861,21 @@ function parseImgBlock(itemAttrs, options) {
|
|
|
1104
861
|
const p = secondaryAssetUrl(itemAttrs, options, "preview");
|
|
1105
862
|
return p ? { preview: p } : {};
|
|
1106
863
|
})()),
|
|
1107
|
-
...(
|
|
864
|
+
...(author && { author }),
|
|
1108
865
|
...(description && { description }),
|
|
1109
866
|
};
|
|
1110
867
|
}
|
|
1111
868
|
|
|
1112
869
|
// A video authored in markdown: `{role=video …}`.
|
|
1113
870
|
//
|
|
1114
|
-
//
|
|
1115
|
-
//
|
|
1116
|
-
//
|
|
1117
|
-
//
|
|
1118
|
-
//
|
|
1119
|
-
// playback flags.
|
|
871
|
+
// This reader carries the attribute set that docs/reference/content-structure.md
|
|
872
|
+
// publishes for the video role, and the shape it documents: `{ src, href,
|
|
873
|
+
// target }` plus the playback flags. ⛔ Until 2026-10-18 the editor's `Video`
|
|
874
|
+
// node had a reader of its own, `parseVideoBlock`, and this one took the
|
|
875
|
+
// editor's `url` and `info` too.
|
|
1120
876
|
function parseMarkdownVideo(itemAttrs, options) {
|
|
1121
877
|
const {
|
|
1122
878
|
src,
|
|
1123
|
-
url,
|
|
1124
|
-
info,
|
|
1125
879
|
poster,
|
|
1126
880
|
alt = "",
|
|
1127
881
|
caption = "",
|
|
@@ -1136,18 +890,11 @@ function parseMarkdownVideo(itemAttrs, options) {
|
|
|
1136
890
|
placement, // where it sits in a flow, same as parseImgBlock
|
|
1137
891
|
} = itemAttrs || {};
|
|
1138
892
|
|
|
1139
|
-
// Reference precedence,
|
|
1140
|
-
//
|
|
1141
|
-
// src/url.
|
|
1142
|
-
//
|
|
1143
|
-
// ⚠️ The comment here used to claim identifier-precedence "matching
|
|
1144
|
-
// parseImgBlock" while the code did the opposite — it passed `src` INTO
|
|
1145
|
-
// `makeAssetUrl`, which returns a src/url before it ever looks at
|
|
1146
|
-
// `identifier`. So image and video resolved differently for years and the
|
|
1147
|
-
// comment asserted they did not. Aligned to the documented intent (2026-08-17).
|
|
893
|
+
// Reference precedence, as parseImgBlock's: a resolvable store-held asset,
|
|
894
|
+
// then the address the node holds.
|
|
1148
895
|
const assetUrl = assetUrlFromAttrs(itemAttrs, options);
|
|
1149
896
|
const video = {
|
|
1150
|
-
src: assetUrl ||
|
|
897
|
+
src: assetUrl || src || "",
|
|
1151
898
|
alt,
|
|
1152
899
|
caption: stripTags(caption),
|
|
1153
900
|
role,
|
|
@@ -1172,33 +919,6 @@ function parseMarkdownVideo(itemAttrs, options) {
|
|
|
1172
919
|
return video;
|
|
1173
920
|
}
|
|
1174
921
|
|
|
1175
|
-
function parseVideoBlock(itemAttrs, options) {
|
|
1176
|
-
let {
|
|
1177
|
-
src,
|
|
1178
|
-
caption = "",
|
|
1179
|
-
info = {},
|
|
1180
|
-
coverImg = {},
|
|
1181
|
-
alt,
|
|
1182
|
-
href = "",
|
|
1183
|
-
target = "",
|
|
1184
|
-
placement, // where it sits in a flow; the editor's `direction` is not read
|
|
1185
|
-
} = itemAttrs;
|
|
1186
|
-
|
|
1187
|
-
// Same precedence as parseImgBlock / parseMarkdownVideo: a resolvable
|
|
1188
|
-
// store-held asset, then the legacy identifier, then the authored src.
|
|
1189
|
-
const video = assetUrlFromAttrs(itemAttrs, options) || makeAssetUrl(info) || src || "";
|
|
1190
|
-
|
|
1191
|
-
return {
|
|
1192
|
-
src: video,
|
|
1193
|
-
caption,
|
|
1194
|
-
coverImg: makeAssetUrl(coverImg),
|
|
1195
|
-
alt,
|
|
1196
|
-
href,
|
|
1197
|
-
target,
|
|
1198
|
-
...(placement && { placement }),
|
|
1199
|
-
};
|
|
1200
|
-
}
|
|
1201
|
-
|
|
1202
922
|
function stripTags(htmlString) {
|
|
1203
923
|
if (!htmlString || typeof htmlString !== "string") return "";
|
|
1204
924
|
|
|
@@ -1260,7 +980,7 @@ function isLink(item, options = {}) {
|
|
|
1260
980
|
|
|
1261
981
|
for (let i = 0; i < originalContent.length; i++) {
|
|
1262
982
|
if (isIconNode(originalContent[i])) {
|
|
1263
|
-
const iconAttrs =
|
|
983
|
+
const iconAttrs = parseIcon(originalContent[i].attrs);
|
|
1264
984
|
if (i < linkIndex) {
|
|
1265
985
|
// Take the last icon before the link
|
|
1266
986
|
iconBefore = iconAttrs;
|
package/src/sanitize.js
CHANGED
|
@@ -441,6 +441,19 @@ export function sanitizeSVG(svg) {
|
|
|
441
441
|
return rebuild(svg, SVG);
|
|
442
442
|
}
|
|
443
443
|
|
|
444
|
+
/**
|
|
445
|
+
* What in an SVG string falls outside the allowlist `sanitizeSVG` rebuilds
|
|
446
|
+
* from, reported as `inspectMathML` reports MathML. Empty when the markup is
|
|
447
|
+
* within it.
|
|
448
|
+
*
|
|
449
|
+
* @param {string} svg
|
|
450
|
+
* @returns {Array<{reason: 'element'|'attribute'|'style'|'markup', element?: string, attribute?: string, value?: string}>}
|
|
451
|
+
*/
|
|
452
|
+
export function inspectSVG(svg) {
|
|
453
|
+
if (typeof svg !== "string" || !svg) return [];
|
|
454
|
+
return inspect(svg, SVG);
|
|
455
|
+
}
|
|
456
|
+
|
|
444
457
|
/**
|
|
445
458
|
* Whether a link may be followed: a relative URL, or `http:`, `https:`,
|
|
446
459
|
* `mailto:`, `tel:`, or the framework's `page:` / `topic:` references. Control
|
|
@@ -454,6 +467,75 @@ export function isSafeLink(url) {
|
|
|
454
467
|
return typeof url === "string" && isSafeUrl(url);
|
|
455
468
|
}
|
|
456
469
|
|
|
470
|
+
/**
|
|
471
|
+
* Whether an address may be loaded as an image: a link a page may follow
|
|
472
|
+
* (`isSafeLink`), or the image itself, as data (`isImageData`).
|
|
473
|
+
*
|
|
474
|
+
* @param {string} url
|
|
475
|
+
* @returns {boolean}
|
|
476
|
+
*/
|
|
477
|
+
export function isImageSource(url) {
|
|
478
|
+
return isSafeLink(url) || isImageData(url);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Whether a `data:` URL holds an image a page may show: a PNG, JPEG, GIF, WebP
|
|
483
|
+
* or AVIF in base64, or SVG — percent-encoded or base64 — whose markup is within
|
|
484
|
+
* the allowlist `sanitizeSVG` rebuilds from. ⭐ Admitted [Diego, 2026-10-16]: a
|
|
485
|
+
* picture an `<img>` loads runs no script, and a built-in starter image is one.
|
|
486
|
+
* The URL is read as a browser reads it: tab and newline removed, leading and
|
|
487
|
+
* trailing spaces and controls trimmed.
|
|
488
|
+
*
|
|
489
|
+
* @param {string} url
|
|
490
|
+
* @returns {boolean}
|
|
491
|
+
*/
|
|
492
|
+
export function isImageData(url) {
|
|
493
|
+
if (typeof url !== "string") return false;
|
|
494
|
+
const address = url.replace(/[\t\n\r]/g, "").replace(/^[\u0000- ]+|[\u0000- ]+$/g, "");
|
|
495
|
+
if (RASTER_DATA.test(address)) return true;
|
|
496
|
+
const svg = svgOfData(address);
|
|
497
|
+
return svg !== null && inspect(svg, SVG).length === 0;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const SVG_DATA = /^data:image\/svg\+xml((?:;[^,;]*)*?)(;base64)?,([\s\S]*)$/i;
|
|
501
|
+
|
|
502
|
+
/** The markup an SVG `data:` URL holds, decoded as a browser decodes it; null for any other URL. */
|
|
503
|
+
function svgOfData(address) {
|
|
504
|
+
const data = SVG_DATA.exec(address);
|
|
505
|
+
if (!data) return null;
|
|
506
|
+
const body = percentDecode(data[3]);
|
|
507
|
+
if (!data[2]) return body;
|
|
508
|
+
try {
|
|
509
|
+
const binary = atob(body.replace(/[\t\n\f\r ]+/g, ""));
|
|
510
|
+
return new TextDecoder().decode(Uint8Array.from(binary, (c) => c.charCodeAt(0)));
|
|
511
|
+
} catch {
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** Percent-decode as a URL is: `%` and two hex digits is a byte, anything else stays, and the bytes are read as UTF-8. */
|
|
517
|
+
function percentDecode(text) {
|
|
518
|
+
if (!text.includes("%")) return text;
|
|
519
|
+
const bytes = [];
|
|
520
|
+
const utf8 = new TextEncoder();
|
|
521
|
+
let run = "";
|
|
522
|
+
const flush = () => {
|
|
523
|
+
for (const byte of utf8.encode(run)) bytes.push(byte);
|
|
524
|
+
run = "";
|
|
525
|
+
};
|
|
526
|
+
for (let i = 0; i < text.length; i++) {
|
|
527
|
+
if (text[i] === "%" && /^[0-9a-f]{2}$/i.test(text.slice(i + 1, i + 3))) {
|
|
528
|
+
flush();
|
|
529
|
+
bytes.push(parseInt(text.slice(i + 1, i + 3), 16));
|
|
530
|
+
i += 2;
|
|
531
|
+
} else {
|
|
532
|
+
run += text[i];
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
flush();
|
|
536
|
+
return new TextDecoder().decode(new Uint8Array(bytes));
|
|
537
|
+
}
|
|
538
|
+
|
|
457
539
|
// ─── Values content may hold ─────────────────────────────────────────────────
|
|
458
540
|
//
|
|
459
541
|
// The rules below decide what a text's marks may put into the markup the parser
|
|
@@ -552,5 +634,23 @@ export function allowlists() {
|
|
|
552
634
|
style: "declaration by declaration: a property of letters and -, a value with none of \\ < > { } /* and no unsafe value",
|
|
553
635
|
markup: "no comment, doctype or processing instruction; CDATA is text",
|
|
554
636
|
},
|
|
637
|
+
imageData: {
|
|
638
|
+
raster: RASTER_DATA.source,
|
|
639
|
+
rasterFlags: "i",
|
|
640
|
+
svg: {
|
|
641
|
+
mediaType: "image/svg+xml, with any parameters, and `;base64` when the body is base64",
|
|
642
|
+
decoding: "the body percent-decoded as a URL is (`%` and two hex digits is a byte, anything else stays), then base64-decoded when marked, read as UTF-8",
|
|
643
|
+
elements: [...SVG_ELEMENTS.values()],
|
|
644
|
+
attributes: [...SVG_ATTRIBUTES.values()],
|
|
645
|
+
commonAttributes: "class, id, role, dir, aria-<letters>, data-<letters, digits and ->",
|
|
646
|
+
droppedWithContent: [...DROP_WITH_CONTENT],
|
|
647
|
+
neverAttributes: "on…, in any case; src",
|
|
648
|
+
references: "href and xlink:href only: a fragment, `#` then a letter then letters, digits, _ . : or -; on `image`, also a raster image matching `raster`, or a URL with no scheme or one of the link schemes",
|
|
649
|
+
unsafeValue: UNSAFE_VALUE.source,
|
|
650
|
+
urlInAValue: "only url(#id), a reference within the document",
|
|
651
|
+
style: "declaration by declaration: a property of letters and -, a value with none of \\ < > { } /* and no unsafe value",
|
|
652
|
+
markup: "no comment, doctype or processing instruction; CDATA is text",
|
|
653
|
+
},
|
|
654
|
+
},
|
|
555
655
|
};
|
|
556
656
|
}
|