@uniweb/semantic-parser 1.12.0 → 1.12.2

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.12.0",
3
+ "version": "1.12.2",
4
4
  "description": "Semantic parser for ProseMirror/TipTap content structures",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -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
  }
@@ -161,7 +161,7 @@ function createSequenceElement(node, options = {}) {
161
161
  };
162
162
 
163
163
  case "codeBlock": {
164
- const codeText = getTextContent(content, options);
164
+ const codeText = codeTextOf(content);
165
165
  // Tagged code blocks are semantically data blocks, not code — the
166
166
  // tag is what routes them to `data[tag]`, so the routing holds even
167
167
  // though the payload here is raw text.
@@ -374,22 +374,43 @@ function sameLink(a, b) {
374
374
  /**
375
375
  * Wrap already-styled inline content in an anchor.
376
376
  */
377
+ /**
378
+ * A code block's code, as written — the characters a reader sees, not HTML.
379
+ *
380
+ * Every consumer draws it as text: kit's `Code` (and the highlighter, which
381
+ * escapes as it colors), press's `CodeBlock`, a foundation's `{snippet.code}`.
382
+ * ⛔ Until 2026-10-19 it went through `getTextContent`, which escapes text for
383
+ * the HTML strings a paragraph carries, so a `<` arrived as `&lt;`, was escaped
384
+ * again where it was drawn, and a page showed `&lt;p&gt;` for `<p>`.
385
+ */
386
+ function codeTextOf(content) {
387
+ return (content || [])
388
+ .map((node) => (node?.type === "text" ? node.text || "" : node?.type === "hardBreak" ? "\n" : ""))
389
+ .join("");
390
+ }
391
+
377
392
  function wrapLink(inner, linkMark) {
378
- return anchor(linkMark.attrs.href, linkMark.attrs.target, inner);
393
+ return anchor(linkMark.attrs.href, linkMark.attrs.target, inner, linkMark.attrs.role);
379
394
  }
380
395
 
381
396
  /**
382
397
  * An anchor around already-escaped inner HTML. A link whose URL may not be
383
398
  * followed (`javascript:`, `data:`, any scheme `isSafeLink` refuses) is not
384
399
  * drawn as a link: its label stays, as text.
400
+ *
401
+ * Its `role` — `button` for a `[Go](/x){button}`, or an authored hint such as
402
+ * `nav` — rides as `data-role`, a style name or nothing, so a stylesheet can
403
+ * tell a button from a link. ⛔ Until 2026-10-19 the role was dropped here, and
404
+ * a button reached the page as a plain link.
385
405
  */
386
- function anchor(href, target, inner) {
406
+ function anchor(href, target, inner, role) {
387
407
  if (typeof href !== "string" || !isSafeLink(href)) return inner;
388
408
  const t = isLinkTarget(target) ? target : "_self";
389
409
  const extension = href.split(".").pop()?.toLowerCase();
390
410
  const isFileLink = FILE_LINK_EXTENSIONS.includes(extension);
411
+ const roleAttr = isStyleName(role) ? ` data-role="${escapeAttr(role)}"` : "";
391
412
 
392
- return `<a href="${escapeAttr(href)}" target="${t}"${isFileLink ? " download" : ""}>${inner}</a>`;
413
+ return `<a href="${escapeAttr(href)}" target="${t}"${isFileLink ? " download" : ""}${roleAttr}>${inner}</a>`;
393
414
  }
394
415
 
395
416
  function getTextContent(content, options = {}) {
@@ -453,8 +474,8 @@ function renderInlineNode(curr, iconOrdinals, pictureOrdinals) {
453
474
  if (type === "text") {
454
475
  let styledText = escapeText(text || "");
455
476
 
456
- // Apply marks in order: span, code, bold, italic — the link is
457
- // applied per group by getTextContent. This ensures proper nesting.
477
+ // Apply marks in order: span, code, bold, italic, strike — the link
478
+ // is applied per group by getTextContent. This ensures proper nesting.
458
479
  //
459
480
  // ⛔ Until 2026-10-18 the editor's `textStyle` (a theme color) and
460
481
  // `highlight` marks were applied first. The editor saves both as
@@ -513,6 +534,12 @@ function renderInlineNode(curr, iconOrdinals, pictureOrdinals) {
513
534
  styledText = `<em>${styledText}</em>`;
514
535
  }
515
536
 
537
+ // strike — `~~text~~`. ⛔ Until 2026-10-19 nothing applied the
538
+ // mark, so struck text reached the page as ordinary text.
539
+ if (marks.some((mark) => mark.type === "strike")) {
540
+ styledText = `<s>${styledText}</s>`;
541
+ }
542
+
516
543
  // The link mark is deliberately NOT applied here — it is the
517
544
  // outermost wrapper and may span several nodes, so
518
545
  // getTextContent applies it once per group.
@@ -585,8 +612,20 @@ function processInlineElements(content, options = {}) {
585
612
  if (!content) return [];
586
613
 
587
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;
588
620
 
589
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
+
590
629
  if (isIconNode(item)) {
591
630
  items.push({
592
631
  type: "icon",
@@ -624,18 +663,17 @@ function processInlineElements(content, options = {}) {
624
663
  refId: item.attrs?.refId,
625
664
  embedKind: item.attrs?.embedKind || "visual",
626
665
  });
627
- } else if (item.type === "text" && item.marks) {
666
+ } else if (runLink && isSafeLink(runLink.attrs?.href)) {
628
667
  // Extract links from text nodes with link marks
629
- const linkMark = linkMarkOf(item.marks);
630
- if (linkMark && isSafeLink(linkMark.attrs?.href)) {
631
- items.push({
632
- type: "link",
633
- attrs: {
634
- ...linkMark.attrs, // Preserve all link attributes (role, target, etc.)
635
- label: item.text || "",
636
- },
637
- });
638
- }
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 };
639
677
  }
640
678
  }
641
679
 
@@ -1018,9 +1056,10 @@ function isLink(item, options = {}) {
1018
1056
  * Markdown treats these as a single paragraph, but semantically they're separate links.
1019
1057
  *
1020
1058
  * @param {Object} item - Sequence item (paragraph)
1059
+ * @param {Object} [options] - Parse options, for the one-link case's `children`
1021
1060
  * @returns {Array|false} Array of link objects or false
1022
1061
  */
1023
- function isOnlyLinks(item) {
1062
+ function isOnlyLinks(item, options = {}) {
1024
1063
  if (item.type !== "paragraph") return false;
1025
1064
 
1026
1065
  const content = item?.content || [];
@@ -1043,18 +1082,54 @@ function isOnlyLinks(item) {
1043
1082
 
1044
1083
  if (!allLinks) return false;
1045
1084
 
1046
- // Extract links with all their attributes
1047
- // Icons in this paragraph go to body.icons separately (no association)
1048
- return textContent.map((c) => {
1049
- const linkMark = linkMarkOf(c.marks);
1050
- return {
1085
+ // One link per anchor, as `getTextContent` draws them: consecutive runs that
1086
+ // carry the same link are ONE link whose label has several runs
1087
+ // (`[**Go** now](/go)`), and anything between two links — the space a line
1088
+ // break becomes, an icon — keeps them apart. ⛔ Until 2026-10-19 every run
1089
+ // was a link of its own, so a button labelled in two styles became two
1090
+ // buttons, "Go" and " now".
1091
+ const groups = [];
1092
+ let current = null;
1093
+ content.forEach((c, i) => {
1094
+ const mark = c.type === "text" ? linkMarkOf(c.marks) : null;
1095
+ if (mark && current && sameLink(current.mark, mark)) {
1096
+ current.label += c.text || "";
1097
+ current.last = i;
1098
+ } else if (mark) {
1099
+ current = { mark, label: c.text || "", first: i, last: i };
1100
+ groups.push(current);
1101
+ } else {
1102
+ current = null;
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 [{
1051
1114
  type: "link",
1052
1115
  attrs: {
1053
- ...linkMark?.attrs, // Preserve all link attributes (role, target, etc.)
1054
- label: c.text || "",
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),
1055
1121
  },
1056
- };
1057
- });
1122
+ }];
1123
+ }
1124
+
1125
+ // Several links. An icon among them belongs to no one link, and is not carried.
1126
+ return groups.map(({ mark, label }) => ({
1127
+ type: "link",
1128
+ attrs: {
1129
+ ...mark?.attrs, // Preserve all link attributes (role, target, etc.)
1130
+ label,
1131
+ },
1132
+ }));
1058
1133
  }
1059
1134
 
1060
1135
  // 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)
@@ -1085,7 +1160,7 @@ function isStyledLink(item, options = {}) {
1085
1160
  )
1086
1161
  return false;
1087
1162
 
1088
- const { href, target } = firstLinkMark.attrs;
1163
+ const { href, target, role } = firstLinkMark.attrs;
1089
1164
 
1090
1165
  const cleanedContent = content.map((c) => {
1091
1166
  // remove link marks from content items
@@ -1104,7 +1179,7 @@ function isStyledLink(item, options = {}) {
1104
1179
  return {
1105
1180
  type: "paragraph",
1106
1181
  children: processInlineElements(item.content, options),
1107
- text: anchor(href, target, textContent),
1182
+ text: anchor(href, target, textContent, role),
1108
1183
  attrs: item.attrs,
1109
1184
  };
1110
1185
  }