@uniweb/semantic-parser 1.1.21 → 1.2.1

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.21",
3
+ "version": "1.2.1",
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
@@ -283,6 +360,11 @@ function createSequenceElement(node, options = {}) {
283
360
  .map((doc) => parseDocumentBlock(doc.attrs)) || [],
284
361
  };
285
362
 
363
+ // The editor renamed this node to `StructuredContent` (2026-07-31,
364
+ // editor-internal). Both are accepted: the new name is what the editor
365
+ // emits now, and the old one still arrives from documents authored
366
+ // before the rename, which the framework never rewrites.
367
+ case "StructuredContent":
286
368
  case "FormBlock":
287
369
  // Parse form data (can be JSON string or object)
288
370
  let formData = attrs?.data;
@@ -529,6 +611,24 @@ function processInlineElements(content) {
529
611
  type: "icon",
530
612
  attrs: parseUniwebIcon(item.attrs),
531
613
  });
614
+ } else if (item.type === "image" || item.type === "ImageBlock") {
615
+ // A media node that stayed inside its paragraph — content-reader
616
+ // hoists a standalone one to the document root, but an image sitting
617
+ // beside text does not qualify (parser/block.js `isBlockEligible`).
618
+ //
619
+ // Without this branch such a node reached NOTHING: getTextContent
620
+ // skips it, so it was absent from the paragraph's `text`, absent
621
+ // from `children`, and absent from `body.images` — the author's
622
+ // image silently deleted. groups.js has carried the receiving
623
+ // branch (and a comment describing exactly this failure) since it
624
+ // was written, but nothing ever emitted the item it reads.
625
+ const isVideo = item.attrs?.role === "video";
626
+ items.push({
627
+ type: isVideo ? "video" : "image",
628
+ attrs: isVideo
629
+ ? parseMarkdownVideo(item.attrs)
630
+ : parseImgBlock(item.attrs || {}),
631
+ });
532
632
  } else if (item.type === "math-inline" || item.type === "math_inline") {
533
633
  items.push(item);
534
634
  } else if (item.type === "inset_placeholder") {
@@ -626,14 +726,23 @@ function parseDocumentBlock(itemAttrs) {
626
726
  }
627
727
 
628
728
  function parseUniwebIcon(itemAttrs) {
629
- const { svg, url, size, color, preserveColors, href, target, library, name } = itemAttrs || {};
729
+ const { svg, url, src, size, color, preserveColors, href, target, library, name } = itemAttrs || {};
630
730
 
631
731
  // Build object with only defined fields — icon source varies:
632
732
  // TipTap editor: svg/url (resolved inline)
633
733
  // Markdown pipeline: library + name (resolved at runtime via CDN)
734
+ // OR a file, which arrives as `src`
634
735
  const icon = {};
635
736
  if (svg) icon.svg = svg;
636
- if (url) icon.url = url;
737
+ // Standard ProseMirror `image` nodes use `src`; the editor's `UniwebIcon`
738
+ // uses `url`. Honor either, exactly as parseImgBlock does below — a
739
+ // file-sourced icon is authored `![Logo](icon:./logo.svg)` or
740
+ // `![Logo](./logo.svg){role=icon}` (both documented in AGENTS.md and
741
+ // docs/reference/content-structure.md), and reaches here as `src` with no
742
+ // library/name. Until 2026-07-30 this reader dropped it, so every
743
+ // documented file-sourced icon delivered `{}` and rendered nothing.
744
+ // `url` is the key kit's <Icon> fetches from.
745
+ if (url || src) icon.url = url || src;
637
746
  if (size) icon.size = size;
638
747
  if (color) icon.color = color;
639
748
  if (preserveColors) icon.preserveColors = preserveColors;
@@ -695,6 +804,24 @@ function parseImgBlock(itemAttrs) {
695
804
  credit = "",
696
805
  id, // {#fig-id} cross-reference label — preserved so Press
697
806
  // adapters can emit \label{id} (LaTeX) / <id> (Typst).
807
+ // ── Markdown-authored attributes ────────────────────────────────
808
+ // This function was written for the EDITOR's `ImageBlock` node and
809
+ // later reused for the markdown `image` node (see `case "image"`), so
810
+ // its vocabulary is the editor's: contentType, direction, filter,
811
+ // theme, credit. Everything markdown declares and the editor does not
812
+ // was therefore tokenized, declared, and then dropped one layer before
813
+ // delivery — the published "Image Attributes" table
814
+ // (docs/reference/content-structure.md) reached no component, and
815
+ // neither did the `role=pdf` set added 2026-07-29 for the editor's
816
+ // `document` fold-in. Carried 2026-07-30.
817
+ width,
818
+ height,
819
+ loading,
820
+ fit,
821
+ position,
822
+ preview, // role=pdf — the preview image
823
+ author, // role=pdf — resource metadata, rendered beside the preview
824
+ description, // role=pdf — describes the RESOURCE, not the image (≠ alt)
698
825
  } = itemAttrs;
699
826
 
700
827
  let { contentType, viewType, contentId, identifier } = imgInfo || {};
@@ -729,6 +856,10 @@ function parseImgBlock(itemAttrs) {
729
856
  direction,
730
857
  filter,
731
858
  imgPos: direction === "left" || direction === "right" ? direction : "",
859
+ // NOTE: `size` here is the direction-derived LAYOUT size
860
+ // (basic/lg/full), not the author's `{size=…}` — that attribute is
861
+ // icon-only and is read by parseUniwebIcon. Do not wire `{size=…}`
862
+ // through here; the two meanings collide on one key.
732
863
  size: sizes[direction] || "basic",
733
864
  href,
734
865
  target,
@@ -736,9 +867,69 @@ function parseImgBlock(itemAttrs) {
736
867
  role,
737
868
  credit,
738
869
  id: id || undefined,
870
+ // Omitted when absent, so an existing consumer sees no new empty keys.
871
+ ...(width !== undefined && width !== null && { width }),
872
+ ...(height !== undefined && height !== null && { height }),
873
+ ...(loading && { loading }),
874
+ ...(fit && { fit }),
875
+ ...(position && { position }),
876
+ ...(preview && { preview }),
877
+ ...(author && { author }),
878
+ ...(description && { description }),
739
879
  };
740
880
  }
741
881
 
882
+ // A video authored in markdown: `![alt](./demo.mp4){role=video …}`.
883
+ //
884
+ // Distinct from parseVideoBlock below, which reads the EDITOR's `Video` node
885
+ // (`coverImg`, `info`). The two surfaces name the same concepts differently and
886
+ // collapsing them would drop one side's spelling; this reader carries the
887
+ // attribute set that docs/reference/content-structure.md publishes for the
888
+ // video role, and the shape it documents: `{ src, href, target }` plus the
889
+ // playback flags.
890
+ function parseMarkdownVideo(itemAttrs) {
891
+ const {
892
+ src,
893
+ url,
894
+ info,
895
+ poster,
896
+ alt = "",
897
+ caption = "",
898
+ autoplay,
899
+ muted,
900
+ loop,
901
+ controls,
902
+ href = "",
903
+ target = "",
904
+ role,
905
+ id, // {#fig-id} cross-reference label, same as parseImgBlock
906
+ } = itemAttrs || {};
907
+
908
+ 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 }),
913
+ alt,
914
+ caption: stripTags(caption),
915
+ role,
916
+ href,
917
+ target,
918
+ };
919
+
920
+ // Playback attributes are omitted rather than defaulted: a component can
921
+ // then tell "the author said nothing" from "the author said false", and
922
+ // kit's <Media> supplies its own defaults (controls on, the rest off).
923
+ if (poster) video.poster = poster;
924
+ if (autoplay !== undefined) video.autoplay = autoplay;
925
+ if (muted !== undefined) video.muted = muted;
926
+ if (loop !== undefined) video.loop = loop;
927
+ if (controls !== undefined) video.controls = controls;
928
+ if (id) video.id = id;
929
+
930
+ return video;
931
+ }
932
+
742
933
  function parseVideoBlock(itemAttrs) {
743
934
  let {
744
935
  src,