@uniweb/semantic-parser 1.8.1 → 1.10.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/AGENTS.md CHANGED
@@ -165,7 +165,7 @@ Key patterns — the staircase rule (each heading relates to the one before it):
165
165
  - **Subtitle**: one step below the title, directly adjacent — and each further one-step descent (or same-size repeat) is another subtitle line. String or array.
166
166
  - **Items**: a heading two or more steps below the previous one, a step back up, or any heading after body content starts a new group.
167
167
  - **Banner Pattern**: an image (with banner role or followed by a heading) at the start of the first group stays with the headline.
168
- - **Dividers**: a `horizontalRule` explicitly closes the current group; it composes with the heading rules rather than replacing them.
168
+ - **Dividers**: a `divider` (`---` in markdown) explicitly closes the current group; it composes with the heading rules rather than replacing them.
169
169
  - **Main Content**: the first group is main if it's the only group, has a more important heading level than the second group, or has no heading at all.
170
170
  - **Body Headings**: `body.headings` fills only from nested content (blockquote children, list items) — a section's headline never spills there.
171
171
 
package/README.md CHANGED
@@ -280,6 +280,24 @@ or `[#id]{k=v}` references. Two artifacts ship to consumers:
280
280
  The two views stay in lockstep — both record every inline inset the
281
281
  parser sees, in source order.
282
282
 
283
+ ### Inline Icons and Pictures
284
+
285
+ An icon or a picture beside text stays in its paragraph, and it is carried
286
+ the same two ways:
287
+
288
+ 1. **`children` carries an `icon` or an `image` entry** — the icon's library
289
+ and name, the picture's resolved `url` and its `alt`.
290
+
291
+ 2. **`text` carries a marker at its position** —
292
+ `<uniweb-icon data-index="N"></uniweb-icon>` or
293
+ `<uniweb-image data-index="N"></uniweb-image>`, where `N` is its ordinal
294
+ among the paragraph's icons, or among its pictures
295
+ (`children.filter(c => c.type === 'image')[N]`).
296
+
297
+ A renderer that reads `text` as HTML and does not know a marker draws
298
+ nothing for it, as for any empty element it does not know. kit's `<Render>`
299
+ draws an icon with `<Icon>` and a picture with `<Image>`.
300
+
283
301
  ## Use Cases
284
302
 
285
303
  - **Component-based websites**: Extract structured data for React/Vue components
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/semantic-parser",
3
- "version": "1.8.1",
3
+ "version": "1.10.0",
4
4
  "description": "Semantic parser for ProseMirror/TipTap content structures",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -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, coverImg } = video
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. `coverImg` is an editor's spelling — a string, or an
189
- // object whose `src` or `url` is the address; an `identifier` alone names
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 }
@@ -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);
@@ -514,31 +500,10 @@ function processGroupContent(elements, options = {}) {
514
500
  body.media.push({ refId: element.refId, kind: "inset" });
515
501
  break;
516
502
 
517
- case "form":
518
- // Route FormBlock data by the schema id it targets, so it
519
- // shares a namespace with tagged markdown data blocks
520
- // (```yaml:<id>``` also lands at data[<id>]). When no
521
- // schemaId is present (legacy content), fall back to the
522
- // literal key "form" — eventual deprecation.
523
- {
524
- const payload = element.data || element.attrs;
525
- const key = element.schemaId || "form";
526
- body.data[key] = payload;
527
- }
528
- break;
529
-
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
-
503
+ // ⛔ A `form` element was routed here until 2026-10-18 — the
504
+ // editor's form node, its data at `data[<schema id>]`, else at
505
+ // `data.form`. The node is read nowhere now: a form's data is a
506
+ // tagged data block, which lands at `data[<tag>]` above.
542
507
  }
543
508
  }
544
509
  }
@@ -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,20 @@ 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
 
20
+ /** Check if an inline node is an icon: an `image` with role "icon". */
21
+ function isIconNode(node) {
22
+ return node.type === "image" && node.attrs?.role === "icon";
23
+ }
24
+
21
25
  /**
22
- * Check if an inline node is an icon.
23
- * TipTap editor uses type "UniwebIcon"; markdown pipeline uses type "image" with role "icon".
26
+ * Is an inline node a picture — an `image` that is not an icon, a video or a
27
+ * file? These are the nodes processInlineElements() delivers as `image`
28
+ * children, so the marker's ordinal (getTextContent) counts exactly them.
24
29
  */
25
- function isIconNode(node) {
26
- return node.type === "UniwebIcon" || (node.type === "image" && node.attrs?.role === "icon");
30
+ function isInlinePicture(node) {
31
+ if (node?.type !== "image" || isIconNode(node)) return false;
32
+ const role = node.attrs?.role;
33
+ return role !== "video" && role !== "pdf";
27
34
  }
28
35
 
29
36
  /**
@@ -207,11 +214,6 @@ function createSequenceElement(node, options = {}) {
207
214
  mathml: sanitizeMathML(node.attrs?.mathml || ""),
208
215
  attrs,
209
216
  };
210
- case "ImageBlock":
211
- return {
212
- type: "image",
213
- attrs: parseImgBlock(attrs, options),
214
- };
215
217
  case "image":
216
218
  // `role` decides which content array this lands in — the contract
217
219
  // published in docs/reference/content-structure.md ("Media Assets"):
@@ -234,28 +236,20 @@ function createSequenceElement(node, options = {}) {
234
236
  // poster/autoplay/muted/loop/controls, so the author's declaration
235
237
  // was demoted to an image AND its playback attrs were dropped —
236
238
  // while the build had already generated a poster for it
237
- // (@uniweb/build site/assets.js). Note this does NOT route to
238
- // parseVideoBlock: that reader speaks the editor's dialect
239
- // (coverImg, info), not the markdown one.
239
+ // (@uniweb/build site/assets.js).
240
240
  if (attrs?.role === "video") {
241
241
  return {
242
242
  type: "video",
243
243
  attrs: parseMarkdownVideo(attrs, options),
244
244
  };
245
245
  }
246
- // Standard ProseMirror image node - resolve attrs the same way as
247
- // ImageBlock so info.identifier lands as a CDN URL. CLI-deployed
248
- // content uses this node type; without parseImgBlock the url
246
+ // Standard ProseMirror image node - read through parseImgBlock, so
247
+ // its address lands in `url`. Without parseImgBlock the url
249
248
  // wouldn't be populated and components render an empty slot.
250
249
  return {
251
250
  type: "image",
252
251
  attrs: parseImgBlock(attrs || {}, options),
253
252
  };
254
- case "Video":
255
- return {
256
- type: "video",
257
- attrs: parseVideoBlock(attrs, options),
258
- };
259
253
  case "bulletList":
260
254
  case "orderedList": {
261
255
  const listItems = content
@@ -305,90 +299,26 @@ function createSequenceElement(node, options = {}) {
305
299
  return { type: "table", rows, attrs };
306
300
  }
307
301
 
308
- // `divider` is what content-reader emits; `horizontalRule` and
309
- // `DividerBlock` are the editor's spellings. All three are one element.
310
- //
311
302
  // The reader's own name was NOT listed here until 2026-07-30 — markdown
312
303
  // dividers reached consumers only by falling through `default:`, which
313
304
  // happens to produce `{ type: 'divider', content: '' }` and happens to
314
305
  // match what everything downstream keys on. It worked by coincidence,
315
306
  // and the coincidence would have broken the moment `default:` changed.
307
+ //
308
+ // ⭐ The vocabulary's names are the only ones read. ⛔ Until 2026-10-18 the
309
+ // editor's spellings were read too: `DividerBlock` and `horizontalRule` as
310
+ // a divider, and its form node — `StructuredContent`, and `FormBlock`, its
311
+ // name before 2026-07-31 — as a `form` element whose data landed at
312
+ // `content.data[<schema id>]`. The editor writes `divider` and `dataBlock`
313
+ // itself now, and no writer produces the others, as none does its
314
+ // `card-group`, `document-group`, `ImageBlock`, `Video`, `UniwebIcon`,
315
+ // `Icon` and `button` nodes.
316
316
  case "divider":
317
- case "DividerBlock":
318
- case "horizontalRule":
319
317
  return {
320
318
  type: "divider",
321
319
  attrs,
322
320
  };
323
321
 
324
- // Custom TipTap elements
325
- case "card-group":
326
- return {
327
- type: "card-group",
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.
350
- case "StructuredContent":
351
- case "FormBlock":
352
- // Parse form data (can be JSON string or object)
353
- let formData = attrs?.data;
354
- if (typeof formData === "string") {
355
- try {
356
- formData = JSON.parse(formData);
357
- } catch (err) {
358
- // Keep as string
359
- }
360
- }
361
-
362
- return {
363
- type: "form",
364
- schemaId: attrs?.activeSchemaId || null,
365
- data: formData,
366
- attrs,
367
- };
368
-
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
322
  default:
393
323
  return {
394
324
  type: node.type,
@@ -487,16 +417,22 @@ function getTextContent(content, options = {}) {
487
417
  // one with `children.filter(c => c.type === 'icon')[index]`. Referencing by
488
418
  // ordinal rather than serializing the attrs keeps an inline `svg` blob out
489
419
  // of the text stream and leaves `children` the single source of truth.
420
+ //
421
+ // A picture beside text is marked the same way, by its ordinal among the
422
+ // pictures of this array — `children.filter(c => c.type === 'image')[index]`.
490
423
  const iconOrdinals = new Map();
424
+ const pictureOrdinals = new Map();
491
425
  let nextIcon = 0;
426
+ let nextPicture = 0;
492
427
  for (const node of content) {
493
428
  if (isIconNode(node)) iconOrdinals.set(node, nextIcon++);
429
+ else if (isInlinePicture(node)) pictureOrdinals.set(node, nextPicture++);
494
430
  }
495
431
 
496
432
  return groups
497
433
  .reduce((prev, group) => {
498
434
  const inner = group.nodes.reduce(
499
- (acc, node) => acc + renderInlineNode(node, iconOrdinals),
435
+ (acc, node) => acc + renderInlineNode(node, iconOrdinals, pictureOrdinals),
500
436
  "",
501
437
  );
502
438
  if (!inner && !group.link) return prev;
@@ -509,7 +445,7 @@ function getTextContent(content, options = {}) {
509
445
  * Render one inline node to HTML, applying every mark EXCEPT link — the
510
446
  * anchor is applied per group by getTextContent, since it may span nodes.
511
447
  */
512
- function renderInlineNode(curr, iconOrdinals) {
448
+ function renderInlineNode(curr, iconOrdinals, pictureOrdinals) {
513
449
  return [curr]
514
450
  .reduce((prev, curr) => {
515
451
  const { type, marks = [], text } = curr;
@@ -517,24 +453,13 @@ function renderInlineNode(curr, iconOrdinals) {
517
453
  if (type === "text") {
518
454
  let styledText = escapeText(text || "");
519
455
 
520
- // Apply marks in order: textStyle, highlight, bold, italic, link
521
- // This ensures proper nesting
522
-
523
- // textStyle (color)
524
- if (marks.some((mark) => mark.type === "textStyle")) {
525
- const color = marks.find(
526
- (mark) => mark.type === "textStyle"
527
- )?.attrs?.color;
528
- // A theme token's name, or nothing — it lands inside `var(--…)`.
529
- if (typeof color === "string" && /^[a-zA-Z0-9-]+$/.test(color)) {
530
- styledText = `<span style="color: var(--${color})">${styledText}</span>`;
531
- }
532
- }
533
-
534
- // highlight
535
- if (marks.some((mark) => mark.type === "highlight")) {
536
- styledText = `<span style="background-color: var(--highlight)">${styledText}</span>`;
537
- }
456
+ // Apply marks in order: span, code, bold, italic — the link is
457
+ // applied per group by getTextContent. This ensures proper nesting.
458
+ //
459
+ // ⛔ Until 2026-10-18 the editor's `textStyle` (a theme color) and
460
+ // `highlight` marks were applied first. The editor saves both as
461
+ // the vocabulary's `span` — `[x]{accent}`, `[x]{highlight}` — so
462
+ // neither is read now.
538
463
 
539
464
  // span (bracketed spans with class/id/attributes)
540
465
  if (marks.some((mark) => mark.type === "span")) {
@@ -637,6 +562,18 @@ function renderInlineNode(curr, iconOrdinals) {
637
562
  const index = iconOrdinals?.get(curr);
638
563
  if (index === undefined) return prev;
639
564
  return prev + `<uniweb-icon data-index="${index}"></uniweb-icon>`;
565
+ } else if (isInlinePicture(curr)) {
566
+ // A picture sitting IN the prose — `as the figure ![](./cells.jpg)
567
+ // shows` — marked for the icon's reason: a renderer draws it with a
568
+ // component (kit's <Image>), from the entry processInlineElements()
569
+ // puts in `children`, its address resolved there.
570
+ //
571
+ // ⛔ Until 2026-10-18 it fell through to the catch-all below, as an
572
+ // icon did before 2026-08-12: the text kept a gap, the picture stayed
573
+ // in `children` and `content.images`, and a walker drew nothing.
574
+ const index = pictureOrdinals?.get(curr);
575
+ if (index === undefined) return prev;
576
+ return prev + `<uniweb-image data-index="${index}"></uniweb-image>`;
640
577
  } else {
641
578
  // console.warn(`unhandled text content type: ${type}`, curr);
642
579
  return prev;
@@ -653,9 +590,9 @@ function processInlineElements(content, options = {}) {
653
590
  if (isIconNode(item)) {
654
591
  items.push({
655
592
  type: "icon",
656
- attrs: parseUniwebIcon(item.attrs),
593
+ attrs: parseIcon(item.attrs),
657
594
  });
658
- } else if (item.type === "image" || item.type === "ImageBlock") {
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` uses it where
754
- * markdown's `image` uses `src`; both mean the same reference.
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: `![Annual report](./report.pdf){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
- // The editor's older `document` node — its `title`, `coverImg` and `src` (or
904
- // asset identifier) — read into the same shape a markdown document has.
905
- function documentFromEditorNode(itemAttrs) {
906
- const {
907
- href,
908
- downloadUrl,
909
- title = "",
910
- alt = "",
911
- coverImg,
912
- author,
913
- description,
914
- fileType,
915
- } = parseDocumentBlock(itemAttrs || {});
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`) — `![Logo](icon:./logo.svg)` or
783
+ * `![Logo](./logo.svg){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
- // Markup from content, so rebuilt from the SVG allowlist (`../sanitize.js`).
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 `![Logo](icon:./logo.svg)` or
964
- // `![Logo](./logo.svg){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
- // The editor carries the family inside `name` as `family:id`; the markdown
976
- // pipeline carries them separately. Normalize to the separate form, which
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
- // ── Markdown-authored attributes ────────────────────────────────
1029
- // This function was written for the EDITOR's `ImageBlock` node and
1030
- // later reused for the markdown `image` node (see `case "image"`), so
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
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
- ...(attribution && { author: attribution }),
864
+ ...(author && { author }),
1108
865
  ...(description && { description }),
1109
866
  };
1110
867
  }
1111
868
 
1112
869
  // A video authored in markdown: `![alt](./demo.mp4){role=video …}`.
1113
870
  //
1114
- // Distinct from parseVideoBlock below, which reads the EDITOR's `Video` node
1115
- // (`coverImg`, `info`). The two surfaces name the same concepts differently and
1116
- // collapsing them would drop one side's spelling; this reader carries the
1117
- // attribute set that docs/reference/content-structure.md publishes for the
1118
- // video role, and the shape it documents: `{ src, href, target }` plus the
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, now genuinely identical to parseImgBlock: a
1140
- // resolvable store-held asset, then the legacy identifier, then the authored
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 || makeAssetUrl(info) || url || src || "",
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 = parseUniwebIcon(originalContent[i].attrs);
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;