@uniweb/semantic-parser 1.12.1 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -298,6 +298,13 @@ A renderer that reads `text` as HTML and does not know a marker draws
298
298
  nothing for it, as for any empty element it does not know. kit's `<Render>`
299
299
  draws an icon with `<Icon>` and a picture with `<Image>`.
300
300
 
301
+ An icon **before any text** is the element's own icon, not part of its prose:
302
+ `![](lu-cloud)` on a line of its own, `- ![](lu-globe) Web App`, or
303
+ `## ![](lu-zap) Fast`. The flat fields (`title`, `paragraphs`, a list item's)
304
+ leave out its marker, since a component draws that icon from `icons`, beside
305
+ the words. So a line holding only icons adds no paragraph. The `sequence`
306
+ keeps every marker, for a renderer that draws each icon where it sat.
307
+
301
308
  ## Use Cases
302
309
 
303
310
  - **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.12.1",
3
+ "version": "1.13.0",
4
4
  "description": "Semantic parser for ProseMirror/TipTap content structures",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -286,6 +286,29 @@ function readStack(sequence, startIdx, options = {}) {
286
286
  return { pre, title, sub, length: i - startIdx };
287
287
  }
288
288
 
289
+ /**
290
+ * An element's text as a flat field holds it, without the markers of the icons before its words.
291
+ *
292
+ * ⭐ An icon before any text is the element's own icon, not part of its prose: `![](lu-cloud)` on
293
+ * a line of its own, `- ![](lu-globe) Web App`, `## ![](lu-zap) Fast`. The flat fields (`title`,
294
+ * `paragraphs`, a list item's) hold the text a component draws beside that icon, which it finds
295
+ * in `icons`. So the markers before the words go, and a line holding only icons leaves no
296
+ * paragraph. An icon inside the prose keeps its marker, with its ordinal unchanged, since `icons`
297
+ * still lists the leading ones. The `sequence` keeps every marker: kit's `Render` draws each icon
298
+ * where it sat [Diego, 2026-10-24].
299
+ *
300
+ * ⛔ From 2026-08-12 to 2026-10-24 the flat fields kept the leading markers. An icon-only line
301
+ * became a paragraph of nothing but `<uniweb-icon data-index="0"></uniweb-icon>`, which moved
302
+ * `paragraphs[0]` off the text, and a component drawing a paragraph as text showed the markup.
303
+ *
304
+ * @param {*} text
305
+ * @returns {*} the text without its leading icon markers; anything that is no string, as it came
306
+ */
307
+ function flatText(text) {
308
+ if (typeof text !== "string") return text;
309
+ return text.replace(/^(?:\s*<uniweb-icon data-index="\d+"><\/uniweb-icon>)+\s*/, "");
310
+ }
311
+
289
312
  /** Collapse a slot's texts: none → '', one → string, several → array. */
290
313
  function flattenSlot(texts) {
291
314
  if (texts.length === 0) return "";
@@ -346,9 +369,9 @@ function processGroupContent(elements, options = {}) {
346
369
  let start = 0;
347
370
  if (elements[0]?.type === "heading") {
348
371
  const stack = readStack(elements, 0, options);
349
- header.pretitle = flattenSlot(stack.pre.map((el) => el.text));
350
- header.title = flattenSlot(stack.title.map((el) => el.text));
351
- header.subtitle = flattenSlot(stack.sub.map((el) => el.text));
372
+ header.pretitle = flattenSlot(stack.pre.map((el) => flatText(el.text)));
373
+ header.title = flattenSlot(stack.title.map((el) => flatText(el.text)));
374
+ header.subtitle = flattenSlot(stack.sub.map((el) => flatText(el.text)));
352
375
  metadata.level = stack.title[0]?.level ?? null;
353
376
  for (const el of [...stack.pre, ...stack.title, ...stack.sub]) {
354
377
  if (el.children && Array.isArray(el.children))
@@ -366,7 +389,7 @@ function processGroupContent(elements, options = {}) {
366
389
  // array — section content is split into groups before this runs.
367
390
  if (element.children && Array.isArray(element.children))
368
391
  processInlineElements(element.children, body);
369
- body.headings.push(element.text);
392
+ body.headings.push(flatText(element.text));
370
393
  } else if (element.type === "list") {
371
394
  const listItems = element.children;
372
395
 
@@ -383,7 +406,10 @@ function processGroupContent(elements, options = {}) {
383
406
  if (element.children && Array.isArray(element.children))
384
407
  processInlineElements(element.children, body);
385
408
 
386
- if (element.text) body.paragraphs.push(element.text);
409
+ {
410
+ const flat = flatText(element.text);
411
+ if (flat) body.paragraphs.push(flat);
412
+ }
387
413
  break;
388
414
 
389
415
  case "image":
@@ -79,7 +79,7 @@ function createSequenceElement(node, options = {}) {
79
79
  }
80
80
 
81
81
  // Check for paragraph containing only multiple links (no other text)
82
- const multipleLinks = isOnlyLinks(node);
82
+ const multipleLinks = isOnlyLinks(node, options);
83
83
  if (multipleLinks && multipleLinks.every((l) => isSafeLink(l.attrs?.href))) {
84
84
  return multipleLinks; // Returns array of link elements
85
85
  }
@@ -612,8 +612,20 @@ function processInlineElements(content, options = {}) {
612
612
  if (!content) return [];
613
613
 
614
614
  const items = [];
615
+ // The link the previous run belongs to, while the runs continue it: a label
616
+ // in several styles (`[the *full* guide](/guide)`) is one link, as its anchor
617
+ // is. ⛔ Until 2026-10-19 each run was a link here, so `content.links` held
618
+ // "the ", "full" and " guide".
619
+ let open = null;
615
620
 
616
621
  for (const item of content) {
622
+ const runLink = item.type === "text" ? linkMarkOf(item.marks) : null;
623
+ if (runLink && open && sameLink(open.mark, runLink)) {
624
+ open.link.attrs.label += item.text || "";
625
+ continue;
626
+ }
627
+ open = null;
628
+
617
629
  if (isIconNode(item)) {
618
630
  items.push({
619
631
  type: "icon",
@@ -651,18 +663,17 @@ function processInlineElements(content, options = {}) {
651
663
  refId: item.attrs?.refId,
652
664
  embedKind: item.attrs?.embedKind || "visual",
653
665
  });
654
- } else if (item.type === "text" && item.marks) {
666
+ } else if (runLink && isSafeLink(runLink.attrs?.href)) {
655
667
  // Extract links from text nodes with link marks
656
- const linkMark = linkMarkOf(item.marks);
657
- if (linkMark && isSafeLink(linkMark.attrs?.href)) {
658
- items.push({
659
- type: "link",
660
- attrs: {
661
- ...linkMark.attrs, // Preserve all link attributes (role, target, etc.)
662
- label: item.text || "",
663
- },
664
- });
665
- }
668
+ const link = {
669
+ type: "link",
670
+ attrs: {
671
+ ...runLink.attrs, // Preserve all link attributes (role, target, etc.)
672
+ label: item.text || "",
673
+ },
674
+ };
675
+ items.push(link);
676
+ open = { mark: runLink, link };
666
677
  }
667
678
  }
668
679
 
@@ -1045,9 +1056,10 @@ function isLink(item, options = {}) {
1045
1056
  * Markdown treats these as a single paragraph, but semantically they're separate links.
1046
1057
  *
1047
1058
  * @param {Object} item - Sequence item (paragraph)
1059
+ * @param {Object} [options] - Parse options, for the one-link case's `children`
1048
1060
  * @returns {Array|false} Array of link objects or false
1049
1061
  */
1050
- function isOnlyLinks(item) {
1062
+ function isOnlyLinks(item, options = {}) {
1051
1063
  if (item.type !== "paragraph") return false;
1052
1064
 
1053
1065
  const content = item?.content || [];
@@ -1076,22 +1088,41 @@ function isOnlyLinks(item) {
1076
1088
  // break becomes, an icon — keeps them apart. ⛔ Until 2026-10-19 every run
1077
1089
  // was a link of its own, so a button labelled in two styles became two
1078
1090
  // buttons, "Go" and " now".
1079
- // Icons in this paragraph go to body.icons separately (no association)
1080
1091
  const groups = [];
1081
1092
  let current = null;
1082
- for (const c of content) {
1093
+ content.forEach((c, i) => {
1083
1094
  const mark = c.type === "text" ? linkMarkOf(c.marks) : null;
1084
1095
  if (mark && current && sameLink(current.mark, mark)) {
1085
1096
  current.label += c.text || "";
1097
+ current.last = i;
1086
1098
  } else if (mark) {
1087
- current = { mark, label: c.text || "" };
1099
+ current = { mark, label: c.text || "", first: i, last: i };
1088
1100
  groups.push(current);
1089
1101
  } else {
1090
1102
  current = null;
1091
1103
  }
1104
+ });
1105
+
1106
+ // One link in several runs is a paragraph that is just a link, and takes the
1107
+ // shape `isLink` gives one — its icons, the last before it and the first
1108
+ // after, and its inline elements. ⛔ Until 2026-10-19 its icons were dropped:
1109
+ // `![](lu-star) [**Go** now](/go){button}` lost the star.
1110
+ if (groups.length === 1) {
1111
+ const [{ mark, label, first, last }] = groups;
1112
+ const icons = content.map((c, i) => (isIconNode(c) ? { i, attrs: parseIcon(c.attrs) } : null)).filter(Boolean);
1113
+ return [{
1114
+ type: "link",
1115
+ attrs: {
1116
+ ...mark?.attrs,
1117
+ label,
1118
+ iconBefore: icons.filter(({ i }) => i < first).pop()?.attrs || null,
1119
+ iconAfter: icons.find(({ i }) => i > last)?.attrs || null,
1120
+ children: processInlineElements(content, options),
1121
+ },
1122
+ }];
1092
1123
  }
1093
1124
 
1094
- // Extract links with all their attributes
1125
+ // Several links. An icon among them belongs to no one link, and is not carried.
1095
1126
  return groups.map(({ mark, label }) => ({
1096
1127
  type: "link",
1097
1128
  attrs: {