@uniweb/semantic-parser 1.1.20 → 1.2.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.1.20",
3
+ "version": "1.2.0",
4
4
  "description": "Semantic parser for ProseMirror/TipTap content structures",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -56,7 +56,7 @@ function imageBlock({ src, alt = '', caption = '', direction, role, width, heigh
56
56
  return { type: 'ImageBlock', attrs }
57
57
  }
58
58
 
59
- function iconNode({ src, svg, library, name, size, color }) {
59
+ function iconNode({ src, url, svg, library, name, size, color }) {
60
60
  // UniwebIcon supports multiple source types.
61
61
  //
62
62
  // The family rides INSIDE `name` as `family:id`. The editor's node declares
@@ -64,8 +64,17 @@ function iconNode({ src, svg, library, name, size, color }) {
64
64
  // and ProseMirror silently drops undeclared attrs on `fromJSON` — so emitting
65
65
  // a separate `library` lost the family without a trace, on the path that
66
66
  // builds starter content for every new section.
67
+ //
68
+ // `svg` is MARKUP and `url` is a URL — on both sides. A file-sourced icon
69
+ // arrives here as `src` and belongs in `url`, not in the markup slot; putting
70
+ // a path where a consumer expects `<svg …>` renders nothing. (Fixed
71
+ // 2026-07-30; `url` is declared on the editor's node, so it crosses intact.)
67
72
  const attrs = {}
68
- if (svg || src) attrs.svg = svg || src
73
+ if (svg) attrs.svg = svg
74
+ // Either spelling in, one spelling out — `parseUniwebIcon` now emits `url`
75
+ // for a file-sourced icon, and this builder's documented invariant is that
76
+ // parseContent(buildDoc(x)) round-trips.
77
+ if (url || src) attrs.url = url || src
69
78
  if (name) attrs.name = library ? `${library}:${name}` : name
70
79
  if (size) attrs.size = size
71
80
  if (color) attrs.color = color
package/src/index.js CHANGED
@@ -4,9 +4,25 @@ import { buildDoc } from "./builders/doc.js";
4
4
 
5
5
  /**
6
6
  * Parse ProseMirror/TipTap content into semantic structure
7
+ *
8
+ * This is the PUBLIC entry point for deriving the flat shape, and every
9
+ * consumer calls it: the runtime derives a block's content this way, and an
10
+ * editor drawing a surface over the same document derives it the same way. That
11
+ * both sides run one implementation is what makes their readings agree — so a
12
+ * derivation rule must be reachable from here rather than applied privately by
13
+ * whoever produced the ProseMirror.
14
+ *
15
+ * Nothing derived is stored. This runs JIT on each side, every time, which is
16
+ * also why changing a grouping rule re-reads every document that already
17
+ * exists — see `alwaysItems` below.
18
+ *
7
19
  * @param {Object} doc - ProseMirror document
8
20
  * @param {Object} options - Parsing options
9
21
  * @param {boolean} options.parseCodeAsJson - Parse code blocks as JSON. Default: false
22
+ * @param {boolean} options.alwaysItems - Every heading group becomes an item:
23
+ * no title promotion, no same-level heading merge. The shape a tagged concept
24
+ * block (```md:faq) always takes, fixed by its fence. Default: false, and the
25
+ * default path is pinned by tests/grouping-default-path.test.js.
10
26
  * @returns {Object} Flat content structure with sequence for ordered access
11
27
  */
12
28
  function parseContent(doc, options = {}) {
@@ -25,13 +25,33 @@ function flattenGroup(group) {
25
25
  // on every page (math is <1% of content in practice; authors who need
26
26
  // ordering should use content.sequence anyway).
27
27
  if (group.body.math && group.body.math.length) flat.math = group.body.math;
28
+ if (group.body.tables && group.body.tables.length) flat.tables = group.body.tables;
28
29
  return flat;
29
30
  }
30
31
 
31
32
  /**
32
33
  * Transform a sequence into content groups with semantic structure
34
+ *
35
+ * `options.alwaysItems` makes every group an ITEM: it turns off the two rules
36
+ * that otherwise collapse a short document into a titled main block —
37
+ * title promotion (`identifyMainContent`) and same-level heading merge
38
+ * (`readHeadingGroup`'s Case 3). Used for a tagged concept block, whose shape is
39
+ * fixed by its fence rather than by what it happens to contain.
40
+ *
41
+ * It names a GROUPING BEHAVIOUR, not a concept. Nothing here knows what `faq` or
42
+ * `warning` mean, and nothing should: a framework-side registry of concept names
43
+ * is the failure the whole design avoids.
44
+ *
45
+ * DEFAULT OFF, and that matters more than it looks. This parser runs JIT — at
46
+ * render time and at editor time — and nothing derived is ever stored, so a
47
+ * change to a grouping rule retroactively re-reads every document that already
48
+ * exists. Leaking these suppressions into the default path would silently
49
+ * reshape every section body ever authored. `tests/grouping-default-path.test.js`
50
+ * pins the default against that.
51
+ *
33
52
  * @param {Array} sequence Flat sequence of elements
34
53
  * @param {Object} options Parsing options
54
+ * @param {boolean} [options.alwaysItems] Every group is an item; no main block
35
55
  * @returns {Object} Flat content object with items array
36
56
  */
37
57
  function processGroups(sequence, options = {}) {
@@ -56,7 +76,7 @@ function processGroups(sequence, options = {}) {
56
76
  };
57
77
  }
58
78
 
59
- const groups = splitBySlices(sequence);
79
+ const groups = splitBySlices(sequence, options);
60
80
 
61
81
  // Process each group's structure (still nested internally)
62
82
  const processedGroups = groups.map((group) => processGroupContent(group));
@@ -65,7 +85,12 @@ function processGroups(sequence, options = {}) {
65
85
  let mainGroup = null;
66
86
  let itemGroups = [];
67
87
 
68
- const shouldBeMain = identifyMainContent(processedGroups);
88
+ // Suppression 1 of 2: title promotion. Without it a lone heading+body pair
89
+ // is the whole "main" block and yields NO items — the single-question FAQ,
90
+ // and every callout, would arrive empty.
91
+ const shouldBeMain = options.alwaysItems
92
+ ? false
93
+ : identifyMainContent(processedGroups);
69
94
  if (shouldBeMain) {
70
95
  mainGroup = processedGroups[0];
71
96
  itemGroups = processedGroups.slice(1);
@@ -99,7 +124,7 @@ function processGroups(sequence, options = {}) {
99
124
  };
100
125
  }
101
126
 
102
- function splitBySlices(sequence) {
127
+ function splitBySlices(sequence, options = {}) {
103
128
  const groups = [];
104
129
  let currentGroup = [];
105
130
 
@@ -132,7 +157,7 @@ function splitBySlices(sequence) {
132
157
 
133
158
  // Consume the entire semantic heading block (Title + Subtitles)
134
159
  // We reuse your smart readHeadingGroup logic here!
135
- const headingBlock = readHeadingGroup(sequence, i);
160
+ const headingBlock = readHeadingGroup(sequence, i, options);
136
161
  currentGroup.push(...headingBlock);
137
162
 
138
163
  // Advance the index by the number of headings consumed
@@ -175,7 +200,7 @@ function isBannerImage(sequence, i) {
175
200
  );
176
201
  }
177
202
 
178
- function readHeadingGroup(sequence, startIdx) {
203
+ function readHeadingGroup(sequence, startIdx, options = {}) {
179
204
  const elements = [sequence[startIdx]];
180
205
  let hasGoneDeeper = false;
181
206
 
@@ -211,7 +236,18 @@ function readHeadingGroup(sequence, startIdx) {
211
236
  // same-level headings are new sections, not continuations.
212
237
  // e.g. H1 -> H1 → merged into title array
213
238
  // but H1 -> H2 -> H2 → second H2 starts a new group (items)
214
- if (element.level === previousElement.level && !hasGoneDeeper) {
239
+ //
240
+ // Suppression 2 of 2 (`alwaysItems`): in a tagged concept block two
241
+ // same-level headings are two ITEMS, not one heading split across
242
+ // visual lines. Merging them is why bodiless headings collapse into a
243
+ // single group carrying a title ARRAY — and no amount of suppressing
244
+ // title promotion downstream can split what was never split here, which
245
+ // is what makes this the second half of a fix that looks like one line.
246
+ if (
247
+ element.level === previousElement.level &&
248
+ !hasGoneDeeper &&
249
+ !options.alwaysItems
250
+ ) {
215
251
  elements.push(element);
216
252
  continue;
217
253
  }
@@ -366,6 +402,32 @@ function processGroupContent(elements) {
366
402
  body.quotes.push(quoteContent.body);
367
403
  break;
368
404
 
405
+ case "concept_block": {
406
+ // A tagged prose fence lands under its tag, sharing the
407
+ // namespace tagged DATA blocks use (```yaml:nav also lands
408
+ // at data[nav]) — one place a component looks, whichever
409
+ // form the author reached for.
410
+ //
411
+ // Both views, because two consumers need different ones and
412
+ // `parseContent` computes them in one pass anyway. `items`
413
+ // is what an accordion or a step list renders. `sequence` is
414
+ // what anything rendering a concept it does not RECOGNIZE
415
+ // has to use: `items` is a bucketed flattening that discards
416
+ // ordering, and it collapses to [] exactly where `sequence`
417
+ // survives — two bodiless headings give items 0, sequence 2.
418
+ //
419
+ // `alwaysItems` is hard-coded rather than inherited, and
420
+ // that is the scoping: a concept block's shape is fixed by
421
+ // its fence no matter how the surrounding document was
422
+ // parsed, and the suppressions never reach the default path.
423
+ const children = element.children || [];
424
+ body.data[element.tag] = {
425
+ items: processGroups(children, { alwaysItems: true }).items,
426
+ sequence: children,
427
+ };
428
+ break;
429
+ }
430
+
369
431
  case "dataBlock":
370
432
  // Pre-parsed structured data from content-reader
371
433
  body.data[element.tag] = element.data;
@@ -400,6 +462,18 @@ function processGroupContent(elements) {
400
462
  });
401
463
  break;
402
464
 
465
+ case "table":
466
+ // Lazily attached, like `math`: most content has no table,
467
+ // and a component that cares about ordering should read
468
+ // `content.sequence` anyway. Before 2026-07-30 the sequence
469
+ // did not carry tables at all, so there was nothing here to
470
+ // collect.
471
+ (body.tables ||= []).push({
472
+ rows: element.rows || [],
473
+ attrs: element.attrs || {},
474
+ });
475
+ break;
476
+
403
477
  case "inset":
404
478
  body.insets.push({ refId: element.refId });
405
479
  break;
@@ -492,6 +566,11 @@ function processInlineElements(children, body) {
492
566
  } else {
493
567
  body.images.push(item.attrs);
494
568
  }
569
+ } else if (item.type === "video") {
570
+ // Mirrors the top-level `case "video"` partition — an inline
571
+ // `{role=video}` belongs in videos[] for the same reason a hoisted
572
+ // one does.
573
+ body.videos.push(item.attrs);
495
574
  } else if (item.type === "link") {
496
575
  // Handle inline links extracted from paragraph text nodes
497
576
  body.links.push(item.attrs);
@@ -168,6 +168,26 @@ function createSequenceElement(node, options = {}) {
168
168
  };
169
169
  }
170
170
 
171
+ case "concept_block": {
172
+ // A tagged prose fence (```md:faq). Its body is real block content,
173
+ // recursed like a container's — the default branch would flatten it
174
+ // to a text string and lose the author's prose.
175
+ //
176
+ // No markdown is parsed here and none may ever be: this package
177
+ // depends on `yaml` alone, and reaching for a markdown parser would
178
+ // put content-reader inside the runtime. The reader already built
179
+ // this node; the job here is only to WALK it.
180
+ //
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.
184
+ return {
185
+ type: "concept_block",
186
+ tag: attrs?.tag,
187
+ children: processSequence({ content }, options),
188
+ };
189
+ }
190
+
171
191
  case "dataBlock":
172
192
  // Pre-parsed structured data from content-reader
173
193
  return {
@@ -225,6 +245,24 @@ function createSequenceElement(node, options = {}) {
225
245
  attrs: parseImgBlock(attrs),
226
246
  };
227
247
  case "image":
248
+ // `role` decides which content array this lands in — the contract
249
+ // published in docs/reference/content-structure.md ("Media Assets"):
250
+ // icon → icons, video → videos, everything else → images.
251
+ //
252
+ // The video branch was missing until 2026-07-30: every
253
+ // `{role=video}` node ran parseImgBlock, which does not carry
254
+ // poster/autoplay/muted/loop/controls, so the author's declaration
255
+ // was demoted to an image AND its playback attrs were dropped —
256
+ // while the build had already generated a poster for it
257
+ // (@uniweb/build site/assets.js). Note this does NOT route to
258
+ // parseVideoBlock: that reader speaks the editor's dialect
259
+ // (coverImg, info), not the markdown one.
260
+ if (attrs?.role === "video") {
261
+ return {
262
+ type: "video",
263
+ attrs: parseMarkdownVideo(attrs),
264
+ };
265
+ }
228
266
  // Standard ProseMirror image node - resolve attrs the same way as
229
267
  // ImageBlock so info.identifier lands as a CDN URL. CLI-deployed
230
268
  // content uses this node type; without parseImgBlock the url
@@ -258,10 +296,49 @@ function createSequenceElement(node, options = {}) {
258
296
  };
259
297
  }
260
298
 
299
+ case "table": {
300
+ // Rows of cells, each cell a nested SEQUENCE — the same recursion a
301
+ // blockquote or a list item gets, so a cell may hold anything a
302
+ // block can.
303
+ //
304
+ // Added 2026-07-30. Before this a table fell to the `default:`
305
+ // branch and became `{ type: 'table', content: '' }` — not merely
306
+ // unrendered but DESTROYED: `getTextContent` walks for text nodes
307
+ // and a table's children are rows, so even the cell text came back
308
+ // empty. Every consumer of `content.sequence` therefore lost tables
309
+ // entirely, which read as "the renderer has no table case" and was
310
+ // really the vocabulary not carrying one.
311
+ const rows = (content || [])
312
+ .filter((row) => row.type === "tableRow")
313
+ .map((row) => ({
314
+ cells: (row.content || [])
315
+ .filter((cell) => cell.type === "tableCell")
316
+ .map((cell) => ({
317
+ children: processSequence({ content: cell.content }, options),
318
+ header: cell.attrs?.header === true,
319
+ align: cell.attrs?.align || null,
320
+ colspan: cell.attrs?.colspan || 1,
321
+ rowspan: cell.attrs?.rowspan || 1,
322
+ })),
323
+ }));
324
+
325
+ return { type: "table", rows, attrs };
326
+ }
327
+
328
+ // `divider` is what content-reader emits; `horizontalRule` and
329
+ // `DividerBlock` are the editor's spellings. All three are one element.
330
+ //
331
+ // The reader's own name was NOT listed here until 2026-07-30 — markdown
332
+ // dividers reached consumers only by falling through `default:`, which
333
+ // happens to produce `{ type: 'divider', content: '' }` and happens to
334
+ // match what everything downstream keys on. It worked by coincidence,
335
+ // and the coincidence would have broken the moment `default:` changed.
336
+ case "divider":
261
337
  case "DividerBlock":
262
338
  case "horizontalRule":
263
339
  return {
264
340
  type: "divider",
341
+ attrs,
265
342
  };
266
343
 
267
344
  // Custom TipTap elements
@@ -529,6 +606,24 @@ function processInlineElements(content) {
529
606
  type: "icon",
530
607
  attrs: parseUniwebIcon(item.attrs),
531
608
  });
609
+ } else if (item.type === "image" || item.type === "ImageBlock") {
610
+ // A media node that stayed inside its paragraph — content-reader
611
+ // hoists a standalone one to the document root, but an image sitting
612
+ // beside text does not qualify (parser/block.js `isBlockEligible`).
613
+ //
614
+ // Without this branch such a node reached NOTHING: getTextContent
615
+ // skips it, so it was absent from the paragraph's `text`, absent
616
+ // from `children`, and absent from `body.images` — the author's
617
+ // image silently deleted. groups.js has carried the receiving
618
+ // branch (and a comment describing exactly this failure) since it
619
+ // was written, but nothing ever emitted the item it reads.
620
+ const isVideo = item.attrs?.role === "video";
621
+ items.push({
622
+ type: isVideo ? "video" : "image",
623
+ attrs: isVideo
624
+ ? parseMarkdownVideo(item.attrs)
625
+ : parseImgBlock(item.attrs || {}),
626
+ });
532
627
  } else if (item.type === "math-inline" || item.type === "math_inline") {
533
628
  items.push(item);
534
629
  } else if (item.type === "inset_placeholder") {
@@ -626,14 +721,23 @@ function parseDocumentBlock(itemAttrs) {
626
721
  }
627
722
 
628
723
  function parseUniwebIcon(itemAttrs) {
629
- const { svg, url, size, color, preserveColors, href, target, library, name } = itemAttrs || {};
724
+ const { svg, url, src, size, color, preserveColors, href, target, library, name } = itemAttrs || {};
630
725
 
631
726
  // Build object with only defined fields — icon source varies:
632
727
  // TipTap editor: svg/url (resolved inline)
633
728
  // Markdown pipeline: library + name (resolved at runtime via CDN)
729
+ // OR a file, which arrives as `src`
634
730
  const icon = {};
635
731
  if (svg) icon.svg = svg;
636
- if (url) icon.url = url;
732
+ // Standard ProseMirror `image` nodes use `src`; the editor's `UniwebIcon`
733
+ // uses `url`. Honor either, exactly as parseImgBlock does below — a
734
+ // file-sourced icon is authored `![Logo](icon:./logo.svg)` or
735
+ // `![Logo](./logo.svg){role=icon}` (both documented in AGENTS.md and
736
+ // docs/reference/content-structure.md), and reaches here as `src` with no
737
+ // library/name. Until 2026-07-30 this reader dropped it, so every
738
+ // documented file-sourced icon delivered `{}` and rendered nothing.
739
+ // `url` is the key kit's <Icon> fetches from.
740
+ if (url || src) icon.url = url || src;
637
741
  if (size) icon.size = size;
638
742
  if (color) icon.color = color;
639
743
  if (preserveColors) icon.preserveColors = preserveColors;
@@ -655,10 +759,27 @@ function parseUniwebIcon(itemAttrs) {
655
759
  return icon;
656
760
  }
657
761
 
762
+ // The editor's `Icon` node carries an inline SVG blob plus a theme. Return an
763
+ // ATTRS OBJECT, like every other parser in this file — not the bare `svg`
764
+ // string it used to return.
765
+ //
766
+ // The string form was a live bug, not just an inconsistency: kit renders a
767
+ // sequence icon with `<Icon {...element.attrs} />` (styled/Prose/index.jsx),
768
+ // and spreading a string yields indexed character props — `{0:'<', 1:'s', …}`
769
+ // — so the icon rendered as nothing and React saw a wall of unknown props. It
770
+ // failed silently because `attrs.svg` on a string is `undefined`, never an
771
+ // error.
772
+ //
773
+ // Keys are omitted when absent so a consumer can distinguish "not provided"
774
+ // from "empty", matching parseUniwebIcon's shape above.
658
775
  function parseIconBlock(itemAttrs) {
659
- let { svg } = itemAttrs;
776
+ const { svg, theme } = itemAttrs || {};
777
+
778
+ const icon = {};
779
+ if (svg) icon.svg = svg;
780
+ if (theme) icon.theme = theme;
660
781
 
661
- return svg;
782
+ return icon;
662
783
  }
663
784
 
664
785
  function parseImgBlock(itemAttrs) {
@@ -678,6 +799,24 @@ function parseImgBlock(itemAttrs) {
678
799
  credit = "",
679
800
  id, // {#fig-id} cross-reference label — preserved so Press
680
801
  // adapters can emit \label{id} (LaTeX) / <id> (Typst).
802
+ // ── Markdown-authored attributes ────────────────────────────────
803
+ // This function was written for the EDITOR's `ImageBlock` node and
804
+ // later reused for the markdown `image` node (see `case "image"`), so
805
+ // its vocabulary is the editor's: contentType, direction, filter,
806
+ // theme, credit. Everything markdown declares and the editor does not
807
+ // was therefore tokenized, declared, and then dropped one layer before
808
+ // delivery — the published "Image Attributes" table
809
+ // (docs/reference/content-structure.md) reached no component, and
810
+ // neither did the `role=pdf` set added 2026-07-29 for the editor's
811
+ // `document` fold-in. Carried 2026-07-30.
812
+ width,
813
+ height,
814
+ loading,
815
+ fit,
816
+ position,
817
+ preview, // role=pdf — the preview image
818
+ author, // role=pdf — resource metadata, rendered beside the preview
819
+ description, // role=pdf — describes the RESOURCE, not the image (≠ alt)
681
820
  } = itemAttrs;
682
821
 
683
822
  let { contentType, viewType, contentId, identifier } = imgInfo || {};
@@ -712,6 +851,10 @@ function parseImgBlock(itemAttrs) {
712
851
  direction,
713
852
  filter,
714
853
  imgPos: direction === "left" || direction === "right" ? direction : "",
854
+ // NOTE: `size` here is the direction-derived LAYOUT size
855
+ // (basic/lg/full), not the author's `{size=…}` — that attribute is
856
+ // icon-only and is read by parseUniwebIcon. Do not wire `{size=…}`
857
+ // through here; the two meanings collide on one key.
715
858
  size: sizes[direction] || "basic",
716
859
  href,
717
860
  target,
@@ -719,7 +862,67 @@ function parseImgBlock(itemAttrs) {
719
862
  role,
720
863
  credit,
721
864
  id: id || undefined,
865
+ // Omitted when absent, so an existing consumer sees no new empty keys.
866
+ ...(width !== undefined && width !== null && { width }),
867
+ ...(height !== undefined && height !== null && { height }),
868
+ ...(loading && { loading }),
869
+ ...(fit && { fit }),
870
+ ...(position && { position }),
871
+ ...(preview && { preview }),
872
+ ...(author && { author }),
873
+ ...(description && { description }),
874
+ };
875
+ }
876
+
877
+ // A video authored in markdown: `![alt](./demo.mp4){role=video …}`.
878
+ //
879
+ // Distinct from parseVideoBlock below, which reads the EDITOR's `Video` node
880
+ // (`coverImg`, `info`). The two surfaces name the same concepts differently and
881
+ // collapsing them would drop one side's spelling; this reader carries the
882
+ // attribute set that docs/reference/content-structure.md publishes for the
883
+ // video role, and the shape it documents: `{ src, href, target }` plus the
884
+ // playback flags.
885
+ function parseMarkdownVideo(itemAttrs) {
886
+ const {
887
+ src,
888
+ url,
889
+ info,
890
+ poster,
891
+ alt = "",
892
+ caption = "",
893
+ autoplay,
894
+ muted,
895
+ loop,
896
+ controls,
897
+ href = "",
898
+ target = "",
899
+ role,
900
+ id, // {#fig-id} cross-reference label, same as parseImgBlock
901
+ } = itemAttrs || {};
902
+
903
+ const video = {
904
+ // `identifier` (a CDN-resolvable asset id) wins when present, matching
905
+ // parseImgBlock — an editor-deployed asset with a stale src must not
906
+ // shadow the CDN copy.
907
+ src: makeAssetUrl({ src: url || src, ...info }),
908
+ alt,
909
+ caption: stripTags(caption),
910
+ role,
911
+ href,
912
+ target,
722
913
  };
914
+
915
+ // Playback attributes are omitted rather than defaulted: a component can
916
+ // then tell "the author said nothing" from "the author said false", and
917
+ // kit's <Media> supplies its own defaults (controls on, the rest off).
918
+ if (poster) video.poster = poster;
919
+ if (autoplay !== undefined) video.autoplay = autoplay;
920
+ if (muted !== undefined) video.muted = muted;
921
+ if (loop !== undefined) video.loop = loop;
922
+ if (controls !== undefined) video.controls = controls;
923
+ if (id) video.id = id;
924
+
925
+ return video;
723
926
  }
724
927
 
725
928
  function parseVideoBlock(itemAttrs) {