@uniweb/semantic-parser 1.8.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/semantic-parser",
3
- "version": "1.8.1",
3
+ "version": "1.9.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);
@@ -515,7 +501,7 @@ function processGroupContent(elements, options = {}) {
515
501
  break;
516
502
 
517
503
  case "form":
518
- // Route FormBlock data by the schema id it targets, so it
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 === "UniwebIcon" || (node.type === "image" && node.attrs?.role === "icon");
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). Note this does NOT route to
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 - 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
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
- // 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.
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: 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;