@uniweb/semantic-parser 1.2.1 → 1.2.3

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.2.1",
3
+ "version": "1.2.3",
4
4
  "description": "Semantic parser for ProseMirror/TipTap content structures",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { processSequence } from "./processors/sequence.js";
1
+ import { processSequence, resolveAssetUrl, ASSET_SLOTS } from "./processors/sequence.js";
2
2
  import { processGroups } from "./processors/groups.js";
3
3
  import { buildDoc } from "./builders/doc.js";
4
4
 
@@ -46,4 +46,8 @@ function parseContent(doc, options = {}) {
46
46
  };
47
47
  }
48
48
 
49
- export { parseContent, buildDoc };
49
+ // `resolveAssetUrl` is exported so a consumer that needs an asset URL outside a
50
+ // parse (the editor, a kit component) reaches THIS implementation rather than
51
+ // writing a second one. A duplicated selector is this repo's most-repeated
52
+ // defect; one exported function is the cheapest guard against it.
53
+ export { parseContent, buildDoc, resolveAssetUrl, ASSET_SLOTS };
@@ -106,7 +106,7 @@ function createSequenceElement(node, options = {}) {
106
106
  const attrs = node.attrs;
107
107
  const content = node.content;
108
108
 
109
- const linkVal = isLink(node);
109
+ const linkVal = isLink(node, options);
110
110
 
111
111
  if (linkVal) {
112
112
  return {
@@ -121,7 +121,7 @@ function createSequenceElement(node, options = {}) {
121
121
  return multipleLinks; // Returns array of link elements
122
122
  }
123
123
 
124
- const styledLink = isStyledLink(node);
124
+ const styledLink = isStyledLink(node, options);
125
125
 
126
126
  if (styledLink) return styledLink;
127
127
 
@@ -131,7 +131,7 @@ function createSequenceElement(node, options = {}) {
131
131
  type: "heading",
132
132
  level: node.attrs.level,
133
133
  text: getTextContent(content, options),
134
- children: processInlineElements(content),
134
+ children: processInlineElements(content, options),
135
135
  attrs,
136
136
  };
137
137
 
@@ -141,16 +141,14 @@ function createSequenceElement(node, options = {}) {
141
141
  return {
142
142
  type: "paragraph",
143
143
  text: textContent,
144
- children: processInlineElements(content),
144
+ children: processInlineElements(content, options),
145
145
  attrs,
146
146
  };
147
147
  }
148
148
  case "blockquote":
149
149
  return {
150
150
  type: "blockquote",
151
- children: processSequence({
152
- content,
153
- }),
151
+ children: processSequence({ content }, options),
154
152
  attrs,
155
153
  };
156
154
 
@@ -164,7 +162,7 @@ function createSequenceElement(node, options = {}) {
164
162
  type: "inset_block",
165
163
  component,
166
164
  params,
167
- children: processSequence({ content }),
165
+ children: processSequence({ content }, options),
168
166
  };
169
167
  }
170
168
 
@@ -178,9 +176,11 @@ function createSequenceElement(node, options = {}) {
178
176
  // put content-reader inside the runtime. The reader already built
179
177
  // this node; the job here is only to WALK it.
180
178
  //
181
- // `options` rides along, unlike the `inset_block` and `blockquote`
182
- // cases above, so nested content is sequenced under the same
183
- // options as its parent.
179
+ // `options` rides along so nested content is sequenced under the
180
+ // same options as its parent. (`inset_block` and `blockquote` used
181
+ // to drop it and now do the same — harmless while nothing here read
182
+ // `options`, but an asset inside a blockquote would have lost its
183
+ // resolution pattern once one did.)
184
184
  return {
185
185
  type: "concept_block",
186
186
  tag: attrs?.tag,
@@ -242,7 +242,7 @@ function createSequenceElement(node, options = {}) {
242
242
  case "ImageBlock":
243
243
  return {
244
244
  type: "image",
245
- attrs: parseImgBlock(attrs),
245
+ attrs: parseImgBlock(attrs, options),
246
246
  };
247
247
  case "image":
248
248
  // `role` decides which content array this lands in — the contract
@@ -260,7 +260,7 @@ function createSequenceElement(node, options = {}) {
260
260
  if (attrs?.role === "video") {
261
261
  return {
262
262
  type: "video",
263
- attrs: parseMarkdownVideo(attrs),
263
+ attrs: parseMarkdownVideo(attrs, options),
264
264
  };
265
265
  }
266
266
  // Standard ProseMirror image node - resolve attrs the same way as
@@ -269,12 +269,12 @@ function createSequenceElement(node, options = {}) {
269
269
  // wouldn't be populated and components render an empty slot.
270
270
  return {
271
271
  type: "image",
272
- attrs: parseImgBlock(attrs || {}),
272
+ attrs: parseImgBlock(attrs || {}, options),
273
273
  };
274
274
  case "Video":
275
275
  return {
276
276
  type: "video",
277
- attrs: parseVideoBlock(attrs),
277
+ attrs: parseVideoBlock(attrs, options),
278
278
  };
279
279
  case "bulletList":
280
280
  case "orderedList": {
@@ -391,7 +391,7 @@ function createSequenceElement(node, options = {}) {
391
391
  return {
392
392
  type: "button",
393
393
  text: textContent,
394
- children: processInlineElements(content),
394
+ children: processInlineElements(content, options),
395
395
  attrs,
396
396
  };
397
397
  }
@@ -470,10 +470,23 @@ function getTextContent(content, options = {}) {
470
470
  }
471
471
  }
472
472
 
473
+ // Icons are emitted as positional markers that a renderer swaps for a real
474
+ // component (see renderInlineNode). The marker carries the icon's ordinal
475
+ // among the icons of THIS content array, which is the same order
476
+ // processInlineElements() puts them in `children` — so a consumer resolves
477
+ // one with `children.filter(c => c.type === 'icon')[index]`. Referencing by
478
+ // ordinal rather than serializing the attrs keeps an inline `svg` blob out
479
+ // of the text stream and leaves `children` the single source of truth.
480
+ const iconOrdinals = new Map();
481
+ let nextIcon = 0;
482
+ for (const node of content) {
483
+ if (isIconNode(node)) iconOrdinals.set(node, nextIcon++);
484
+ }
485
+
473
486
  return groups
474
487
  .reduce((prev, group) => {
475
488
  const inner = group.nodes.reduce(
476
- (acc, node) => acc + renderInlineNode(node),
489
+ (acc, node) => acc + renderInlineNode(node, iconOrdinals),
477
490
  "",
478
491
  );
479
492
  if (!inner && !group.link) return prev;
@@ -486,7 +499,7 @@ function getTextContent(content, options = {}) {
486
499
  * Render one inline node to HTML, applying every mark EXCEPT link — the
487
500
  * anchor is applied per group by getTextContent, since it may span nodes.
488
501
  */
489
- function renderInlineNode(curr) {
502
+ function renderInlineNode(curr, iconOrdinals) {
490
503
  return [curr]
491
504
  .reduce((prev, curr) => {
492
505
  const { type, marks = [], text } = curr;
@@ -593,6 +606,21 @@ function renderInlineNode(curr) {
593
606
  return prev + `<uniweb-inset data-ref-id="${refId}"></uniweb-inset>`;
594
607
  } else if (type === "hardBreak") {
595
608
  return prev + "<br>";
609
+ } else if (isIconNode(curr)) {
610
+ // An icon sitting IN the prose — `Click the ![](lu-save) button`.
611
+ // It rides as a positional marker for the same reason an inline
612
+ // inset does: the paragraph's text is an HTML string, and an
613
+ // icon needs a component (kit's <Icon>, which resolves the
614
+ // library+name against the icon CDN at render time).
615
+ //
616
+ // Until 2026-08-12 this fell through to the catch-all below and
617
+ // the icon vanished — the text kept a gap where it had been, the
618
+ // entry stayed in `children` and in `content.icons`, and nothing
619
+ // put it back. Every other inline atom (math, insets) already
620
+ // had a marker; this was the one that did not.
621
+ const index = iconOrdinals?.get(curr);
622
+ if (index === undefined) return prev;
623
+ return prev + `<uniweb-icon data-index="${index}"></uniweb-icon>`;
596
624
  } else {
597
625
  // console.warn(`unhandled text content type: ${type}`, curr);
598
626
  return prev;
@@ -600,7 +628,7 @@ function renderInlineNode(curr) {
600
628
  }, "");
601
629
  }
602
630
 
603
- function processInlineElements(content) {
631
+ function processInlineElements(content, options = {}) {
604
632
  if (!content) return [];
605
633
 
606
634
  const items = [];
@@ -626,8 +654,8 @@ function processInlineElements(content) {
626
654
  items.push({
627
655
  type: isVideo ? "video" : "image",
628
656
  attrs: isVideo
629
- ? parseMarkdownVideo(item.attrs)
630
- : parseImgBlock(item.attrs || {}),
657
+ ? parseMarkdownVideo(item.attrs, options)
658
+ : parseImgBlock(item.attrs || {}, options),
631
659
  });
632
660
  } else if (item.type === "math-inline" || item.type === "math_inline") {
633
661
  items.push(item);
@@ -659,6 +687,99 @@ function processInlineElements(content) {
659
687
  return items;
660
688
  }
661
689
 
690
+ /**
691
+ * Resolve a STORE-HELD asset (`assetId` + `assetExt`) through the host's
692
+ * URL pattern — the current mechanism, and the one to reach for.
693
+ *
694
+ * The host declares `config.assets.url` on the published payload; the runtime
695
+ * hands it here as `options.assets.url`. We substitute `{id}` and `{ext}` and
696
+ * do nothing else: the pattern names a host and carries the store's whole path
697
+ * layout, so a framework package never composes a serve location or guesses an
698
+ * origin — a host may move its assets without a framework release.
699
+ *
700
+ * ⛔ Three ways this returns '' — "unresolved", never a guess:
701
+ * - **no pattern** (the host declared none) — absent ⇒ absent
702
+ * - **an unknown placeholder** — never emit a half-substituted URL
703
+ * - **`{ext}` with no `assetExt`** — `base.` is a broken URL that reads like
704
+ * a typo rather than a missing field
705
+ *
706
+ * Callers treat '' as "fall through to the next reference form", which is what
707
+ * makes an id-bearing node safe to write before any deployment emits a pattern.
708
+ *
709
+ * @param {string} assetId - the store's opaque id (backend mints a sha256)
710
+ * @param {string} assetExt - the extension, minted separately (never derived
711
+ * from the id, which carries no extension)
712
+ * @param {string} pattern - `config.assets.url`, e.g. `https://cdn/x/{id}/base.{ext}`
713
+ * @returns {string} the resolved URL, or '' when it cannot be resolved
714
+ */
715
+ /**
716
+ * The asset-bearing slots on a media node — the ONE definition, exported so no
717
+ * consumer keeps its own.
718
+ *
719
+ * ⭐ A node carries more than one asset reference. An image has `src`; a video
720
+ * adds a `poster`; a document embed adds a `preview`. Identity therefore cannot
721
+ * be a single pair of attrs — it has to name WHICH reference it identifies, or a
722
+ * producer cannot stamp a poster without inventing vocabulary.
723
+ *
724
+ * The identity attrs are **flat and declared**, not a nested `assets: {…}` map,
725
+ * and that is deliberate: a nested map puts identity inside a value the schema
726
+ * sweep cannot see, which is exactly the class of defect that makes a reference
727
+ * buried in an opaque field unenumerable and un-re-pointable. Flat attrs stay
728
+ * visible to `getBaseSchema()`, to the parity sweep, and to anything generating
729
+ * an adapter from the declared schema.
730
+ *
731
+ * `assetId`/`assetExt` are unprefixed because `src` is the PRIMARY reference —
732
+ * the one every fallback path already resolves to. The convention is: unprefixed
733
+ * identifies the primary; a prefix names the attr it belongs to.
734
+ *
735
+ * `url` rides in the primary slot because the editor's `ImageBlock` uses it where
736
+ * markdown's `image` uses `src`; both mean the same reference.
737
+ */
738
+ export const ASSET_SLOTS = [
739
+ { urls: ["src", "url"], id: "assetId", ext: "assetExt" },
740
+ { urls: ["poster"], id: "posterAssetId", ext: "posterAssetExt" },
741
+ { urls: ["preview"], id: "previewAssetId", ext: "previewAssetExt" },
742
+ ];
743
+
744
+ function resolveAssetUrl(assetId, assetExt, pattern) {
745
+ if (!assetId || typeof assetId !== "string") return "";
746
+ if (!pattern || typeof pattern !== "string") return "";
747
+ let resolvable = true;
748
+ const url = pattern.replace(/\{(\w+)\}/g, (match, name) => {
749
+ if (name === "id") return assetId;
750
+ if (name === "ext" && assetExt) return assetExt;
751
+ resolvable = false;
752
+ return match;
753
+ });
754
+ return resolvable ? url : "";
755
+ }
756
+
757
+ /** `resolveAssetUrl` against a node's attrs + the parse options, for one slot. */
758
+ function assetUrlFromAttrs(attrs, options, slot = ASSET_SLOTS[0]) {
759
+ return resolveAssetUrl(attrs?.[slot.id], attrs?.[slot.ext], options?.assets?.url);
760
+ }
761
+
762
+ /** The slot whose identity attrs name a given url attr (`poster` → posterAsset*). */
763
+ const slotFor = (urlAttr) => ASSET_SLOTS.find((s) => s.urls.includes(urlAttr));
764
+
765
+ /**
766
+ * Resolve a secondary reference (`poster`, `preview`) the same way the primary
767
+ * one resolves: identity wins when it resolves, else the stored URL stands.
768
+ * A poster is an asset like any other — it just is not the node's primary.
769
+ */
770
+ function secondaryAssetUrl(attrs, options, urlAttr) {
771
+ const slot = slotFor(urlAttr);
772
+ const resolved = slot ? assetUrlFromAttrs(attrs, options, slot) : "";
773
+ return resolved || attrs?.[urlAttr] || "";
774
+ }
775
+
776
+ // ⛔ LEGACY BELOW — the PHP-estate identifier path. Do not extend it.
777
+ //
778
+ // `{version}/{filename}` identifiers have no current producer — the CLI emits
779
+ // `src`, and editors store the URL their host hands them. Only content authored
780
+ // against the original hosted service still carries one, and the hardcoded host
781
+ // below cannot be correct for any other deployment: the CLI can be pointed at
782
+ // any host. New references use `assetId`/`assetExt` above.
662
783
  const ASSET_BASE_URL = "https://assets.uniweb.app/";
663
784
 
664
785
  /**
@@ -667,6 +788,20 @@ const ASSET_BASE_URL = "https://assets.uniweb.app/";
667
788
  */
668
789
  function resolveAssetIdentifier(identifier) {
669
790
  if (!identifier || typeof identifier !== "string") return "";
791
+ // ⛔ An identifier that is ALREADY a URL or a rooted path is not an
792
+ // identifier — it is a serve URL sitting in the identifier slot, and it is
793
+ // usable exactly as it stands. Composing it against the legacy base
794
+ // destroys it: `split("/")` on `/gateway/asset/dist/{id}/base.png` takes
795
+ // `""` as the version and `"gateway"` as the filename, yielding
796
+ // `https://assets.uniweb.app/dist//base.gateway` — not a 404 and not a
797
+ // validation failure, a URL-shaped string that renders a broken image and
798
+ // reads like a missing file.
799
+ //
800
+ // The legacy grammar is `{version}/{filename}` with no leading slash, so
801
+ // nothing legitimate is caught here. Passing it through is not constructing
802
+ // a serve location: it is declining to construct one over a value that is
803
+ // already an address. (Measured 2026-08-17 against a real producer.)
804
+ if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|\/)/i.test(identifier)) return identifier;
670
805
  const [version, filename] = identifier.split("/");
671
806
  if (!filename) return "";
672
807
  const ext = filename.substring(filename.lastIndexOf(".") + 1);
@@ -787,7 +922,7 @@ function parseIconBlock(itemAttrs) {
787
922
  return icon;
788
923
  }
789
924
 
790
- function parseImgBlock(itemAttrs) {
925
+ function parseImgBlock(itemAttrs, options) {
791
926
  let {
792
927
  info: imgInfo,
793
928
  targetId,
@@ -834,12 +969,23 @@ function parseImgBlock(itemAttrs) {
834
969
 
835
970
  caption = stripTags(caption);
836
971
 
837
- // Standard ProseMirror `image` nodes use `src`; the custom
838
- // `ImageBlock` node uses `url`. Honor either so callers don't have
839
- // to pre-normalize. `identifier` (CDN-resolvable asset id) wins
840
- // when present so an editor-deployed image with a stale `src`
841
- // doesn't shadow the CDN copy.
842
- if (identifier) {
972
+ // Reference precedence. Standard ProseMirror `image` nodes use `src`; the
973
+ // custom `ImageBlock` node uses `url`. Honor either so callers don't have to
974
+ // pre-normalize.
975
+ //
976
+ // ⭐ A store-held asset (`assetId`) wins WHEN IT RESOLVES — not merely when
977
+ // it is present. That distinction is the whole reason a producer can write
978
+ // `assetId` alongside `src` before any deployment declares
979
+ // `config.assets.url`: until one does, resolution yields '' and the node
980
+ // renders through `src` exactly as it does today. Content written now stays
981
+ // correct whichever order the halves arrive in.
982
+ //
983
+ // `identifier` is the LEGACY estate path and keeps its old precedence over a
984
+ // stale `src`.
985
+ const assetUrl = assetUrlFromAttrs(itemAttrs, options);
986
+ if (assetUrl) {
987
+ url = assetUrl;
988
+ } else if (identifier) {
843
989
  url = makeAssetUrl(imgInfo);
844
990
  } else if (!url && src) {
845
991
  url = src;
@@ -873,7 +1019,11 @@ function parseImgBlock(itemAttrs) {
873
1019
  ...(loading && { loading }),
874
1020
  ...(fit && { fit }),
875
1021
  ...(position && { position }),
876
- ...(preview && { preview }),
1022
+ ...((() => {
1023
+ // Same rule for a document's preview image.
1024
+ const p = secondaryAssetUrl(itemAttrs, options, "preview");
1025
+ return p ? { preview: p } : {};
1026
+ })()),
877
1027
  ...(author && { author }),
878
1028
  ...(description && { description }),
879
1029
  };
@@ -887,7 +1037,7 @@ function parseImgBlock(itemAttrs) {
887
1037
  // attribute set that docs/reference/content-structure.md publishes for the
888
1038
  // video role, and the shape it documents: `{ src, href, target }` plus the
889
1039
  // playback flags.
890
- function parseMarkdownVideo(itemAttrs) {
1040
+ function parseMarkdownVideo(itemAttrs, options) {
891
1041
  const {
892
1042
  src,
893
1043
  url,
@@ -905,11 +1055,18 @@ function parseMarkdownVideo(itemAttrs) {
905
1055
  id, // {#fig-id} cross-reference label, same as parseImgBlock
906
1056
  } = itemAttrs || {};
907
1057
 
1058
+ // Reference precedence, now genuinely identical to parseImgBlock: a
1059
+ // resolvable store-held asset, then the legacy identifier, then the authored
1060
+ // src/url.
1061
+ //
1062
+ // ⚠️ The comment here used to claim identifier-precedence "matching
1063
+ // parseImgBlock" while the code did the opposite — it passed `src` INTO
1064
+ // `makeAssetUrl`, which returns a src/url before it ever looks at
1065
+ // `identifier`. So image and video resolved differently for years and the
1066
+ // comment asserted they did not. Aligned to the documented intent (2026-08-17).
1067
+ const assetUrl = assetUrlFromAttrs(itemAttrs, options);
908
1068
  const video = {
909
- // `identifier` (a CDN-resolvable asset id) wins when present, matching
910
- // parseImgBlock — an editor-deployed asset with a stale src must not
911
- // shadow the CDN copy.
912
- src: makeAssetUrl({ src: url || src, ...info }),
1069
+ src: assetUrl || makeAssetUrl(info) || url || src || "",
913
1070
  alt,
914
1071
  caption: stripTags(caption),
915
1072
  role,
@@ -920,7 +1077,10 @@ function parseMarkdownVideo(itemAttrs) {
920
1077
  // Playback attributes are omitted rather than defaulted: a component can
921
1078
  // then tell "the author said nothing" from "the author said false", and
922
1079
  // kit's <Media> supplies its own defaults (controls on, the rest off).
923
- if (poster) video.poster = poster;
1080
+ // A poster is an asset like any other — identity resolves it when it can,
1081
+ // and the stored URL stands when it cannot. Same rule as the primary `src`.
1082
+ const posterUrl = secondaryAssetUrl(itemAttrs, options, "poster");
1083
+ if (posterUrl) video.poster = posterUrl;
924
1084
  if (autoplay !== undefined) video.autoplay = autoplay;
925
1085
  if (muted !== undefined) video.muted = muted;
926
1086
  if (loop !== undefined) video.loop = loop;
@@ -930,7 +1090,7 @@ function parseMarkdownVideo(itemAttrs) {
930
1090
  return video;
931
1091
  }
932
1092
 
933
- function parseVideoBlock(itemAttrs) {
1093
+ function parseVideoBlock(itemAttrs, options) {
934
1094
  let {
935
1095
  src,
936
1096
  caption = "",
@@ -942,10 +1102,9 @@ function parseVideoBlock(itemAttrs) {
942
1102
  target = "",
943
1103
  } = itemAttrs;
944
1104
 
945
- let video = makeAssetUrl({
946
- src,
947
- ...info,
948
- });
1105
+ // Same precedence as parseImgBlock / parseMarkdownVideo: a resolvable
1106
+ // store-held asset, then the legacy identifier, then the authored src.
1107
+ const video = assetUrlFromAttrs(itemAttrs, options) || makeAssetUrl(info) || src || "";
949
1108
 
950
1109
  return {
951
1110
  src: video,
@@ -981,7 +1140,7 @@ function stripTags(htmlString) {
981
1140
  .replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCharCode(parseInt(n, 16)));
982
1141
  }
983
1142
 
984
- function isLink(item) {
1143
+ function isLink(item, options = {}) {
985
1144
  // Detect paragraphs/headings that are semantically "just a link"
986
1145
  // (single link text, possibly with decorative icons)
987
1146
  //
@@ -1039,7 +1198,7 @@ function isLink(item) {
1039
1198
  iconBefore,
1040
1199
  iconAfter,
1041
1200
  // Preserve all inline elements for advanced rendering
1042
- children: processInlineElements(originalContent),
1201
+ children: processInlineElements(originalContent, options),
1043
1202
  };
1044
1203
  }
1045
1204
  }
@@ -1104,7 +1263,7 @@ function isOnlyLinks(item) {
1104
1263
  // method to check if given item has multiple content parts and each of them has the same link attrs with different inline style (plain, em, strong, u)
1105
1264
  // if so, it will return the link attrs and all the content parts whose link mark has been removed
1106
1265
  // warning: This method will not work if the any of the content parts are not link marks
1107
- function isStyledLink(item) {
1266
+ function isStyledLink(item, options = {}) {
1108
1267
  if (!["paragraph", "heading"].includes(item.type)) return false;
1109
1268
 
1110
1269
  let content = item?.content || [];
@@ -1154,10 +1313,10 @@ function isStyledLink(item) {
1154
1313
 
1155
1314
  return {
1156
1315
  type: "paragraph",
1157
- children: processInlineElements(item.content),
1316
+ children: processInlineElements(item.content, options),
1158
1317
  text: `<a target="${target}" href="${href}">${textContent}</a>`,
1159
1318
  attrs: item.attrs,
1160
1319
  };
1161
1320
  }
1162
1321
 
1163
- export { processSequence };
1322
+ export { processSequence, resolveAssetUrl };