@uniweb/semantic-parser 1.6.0 → 1.8.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
@@ -74,8 +74,8 @@ result = {
74
74
  // Body fields
75
75
  paragraphs: ["Get started today."],
76
76
  links: [], // All links (including buttons)
77
- images: [],
78
- videos: [],
77
+ images: [], // { url, alt, caption, role, placement?, author?, … }
78
+ videos: [], // { src, poster?, caption, placement?, … }
79
79
  documents: [], // Files marked {role=pdf} — { url, name, mime, size?, alt,
80
80
  // caption, preview?, … }
81
81
  icons: [],
@@ -87,7 +87,8 @@ result = {
87
87
  snippets: [], // Fenced code blocks — { language, code }
88
88
  data: {}, // Structured data (tagged data blocks, forms, cards)
89
89
  headings: [], // Only from nested content (quote/list bodies)
90
- // tables: [], // Only when the content has one — { rows, attrs }
90
+ // tables: [], // Only when the content has one — { rows, attrs } — attrs:
91
+ // caption, id, placement, from its caption line
91
92
  // math: [], // Only when the content has one — { id, latex, mathml }
92
93
 
93
94
  // Additional content groups (from headings after content)
@@ -116,7 +117,7 @@ const content = parseContent(doc);
116
117
 
117
118
  const title = content.title;
118
119
  const description = content.paragraphs.join(" ");
119
- const image = content.images[0]?.src;
120
+ const image = content.images[0]?.url;
120
121
  ```
121
122
 
122
123
  ### Processing Content Sections
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/semantic-parser",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "description": "Semantic parser for ProseMirror/TipTap content structures",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Reverse conversion: content structure → TipTap document.
2
+ * Reverse conversion: content structure → a ProseMirror document in the
3
+ * rich-text vocabulary (`@uniweb/content-reader`'s schema).
3
4
  *
4
5
  * Mirrors the forward parser (processors/sequence.js + processors/groups.js)
5
6
  * so that parseContent(buildDoc(content)) roundtrips cleanly.
@@ -7,6 +8,16 @@
7
8
  * Starter content uses plain strings (no HTML marks), so the conversion
8
9
  * is straightforward — no need to reverse inline HTML formatting.
9
10
  *
11
+ * ## ⭐ IT WRITES THE VOCABULARY, IN THE SHAPES MARKDOWN READS TO
12
+ *
13
+ * Every node is one content-reader writes for the same markdown: a picture, a
14
+ * video and a document are an `image` with its `role`, an icon an `image` with
15
+ * `role: 'icon'` inside a paragraph, a divider a `divider`. What this returns is
16
+ * stored as it is and opened like any stored document, so it holds what every
17
+ * reader of a stored document reads — never one editor's own node names.
18
+ * ⛔ Until 2026-10-15 it wrote an editor's TipTap nodes (`ImageBlock`,
19
+ * `UniwebIcon`, `Video`, `DividerBlock`), which the vocabulary does not hold.
20
+ *
10
21
  * ## ⭐ IT ACCEPTS THE PARSER'S OWN VOCABULARY TOO — and that is a contract
11
22
  *
12
23
  * This builder was written with its own spelling for several concepts, and
@@ -20,7 +31,7 @@
20
31
  * most naturally reach for:
21
32
  *
22
33
  * { links: [{ label, href }] } → buildDoc returns **null** outright
23
- * { images: [{ url, alt }] } → an ImageBlock with NO url attr
34
+ * { images: [{ url, alt }] } → an image node with NO address
24
35
  * { videos: [{ coverImg: '/p.jpg' }] } → the cover parses back as ""
25
36
  * { lists: parsed.lists } → a text node whose `text` is an
26
37
  * OBJECT — an invalid document
@@ -36,7 +47,7 @@
36
47
  * differs from the parser's needs the same treatment, or it joins this list.
37
48
  */
38
49
 
39
- // --- TipTap node builders ---
50
+ // --- Node builders ---
40
51
 
41
52
  function textNode(text) {
42
53
  return { type: 'text', text }
@@ -103,17 +114,23 @@ function linkParagraph({ text, label, href, target }) {
103
114
  }
104
115
  }
105
116
 
106
- function imageBlock({ src, url, alt = '', caption = '', direction, role, width, height }) {
107
- // The node attr is `url` and so is the parsed field; `src` is this builder's
108
- // original spelling. Either one in, `url` out.
109
- const attrs = { url: src || url, alt }
117
+ /** The attributes an image may hold beside its address, copied when they hold a value. */
118
+ const IMAGE_EXTRAS = ['href', 'target', 'author', 'id', 'fit', 'position', 'loading']
119
+
120
+ function imageNode(image) {
121
+ const { src, url, alt = '', caption = '', placement, role, width, height } = image
122
+ // The node's attr is `src`, this builder's original spelling; the parsed
123
+ // field is `url`. Either one in, `src` out.
124
+ const attrs = { src: src || url, alt, role: role || 'image' }
110
125
  if (caption) attrs.caption = caption
111
- if (direction) attrs.direction = direction
112
- if (role) attrs.role = role
113
- if (width && height) {
114
- attrs.aspect_ratio = { width, height, ratio: (height / width) * 100 }
115
- }
116
- return { type: 'ImageBlock', attrs }
126
+ // The picture's own size. Its proportions are these two, so nothing else
127
+ // carries them — ⛔ until 2026-10-15 this wrote an `aspect_ratio` computed
128
+ // from them, which the vocabulary does not hold.
129
+ if (width) attrs.width = width
130
+ if (height) attrs.height = height
131
+ if (placement) attrs.placement = placement
132
+ for (const key of IMAGE_EXTRAS) if (image[key]) attrs[key] = image[key]
133
+ return { type: 'image', attrs }
117
134
  }
118
135
 
119
136
  // A document is STORED as the image row with `role: 'pdf'` — the node the
@@ -128,46 +145,58 @@ function documentNode({ url, src, alt = '', caption = '', preview, author, descr
128
145
  return { type: 'image', attrs }
129
146
  }
130
147
 
131
- function iconNode({ src, url, svg, library, name, size, color }) {
132
- // UniwebIcon supports multiple source types.
133
- //
134
- // The family rides INSIDE `name` as `family:id`. The editor's node declares
135
- // `{ name, svg, url, size, color, preserveColors, info }` and no `library`,
136
- // and ProseMirror silently drops undeclared attrs on `fromJSON` — so emitting
137
- // a separate `library` lost the family without a trace, on the path that
138
- // builds starter content for every new section.
139
- //
140
- // `svg` is MARKUP and `url` is a URL — on both sides. A file-sourced icon
141
- // arrives here as `src` and belongs in `url`, not in the markup slot; putting
142
- // a path where a consumer expects `<svg …>` renders nothing. (Fixed
143
- // 2026-07-30; `url` is declared on the editor's node, so it crosses intact.)
144
- const attrs = {}
145
- if (svg) attrs.svg = svg
146
- // Either spelling in, one spelling out — `parseUniwebIcon` now emits `url`
147
- // for a file-sourced icon, and this builder's documented invariant is that
148
- // parseContent(buildDoc(x)) round-trips.
149
- if (url || src) attrs.url = url || src
150
- if (name) attrs.name = library ? `${library}:${name}` : name
148
+ /**
149
+ * An icon, as markdown's `![](lu-house)` reads: an `image` with `role: 'icon'`
150
+ * inside a paragraph — from a library by `library` and `name`, or from a file
151
+ * by its address.
152
+ *
153
+ * `svg` is MARKUP, and a stored document holds none (the vocabulary has no
154
+ * attribute for it), so an icon given only as markup is left out: the builder
155
+ * returns null rather than a node that names no picture. A file-sourced icon
156
+ * arrives as `url` (what the parser emits) or `src`.
157
+ */
158
+ function iconNode({ src, url, library, name, size, color }) {
159
+ const attrs = { role: 'icon' }
160
+ if (src || url) {
161
+ attrs.src = src || url
162
+ } else if (name) {
163
+ // A parsed editor node may carry the family inside the name, `family:id`.
164
+ // The vocabulary holds the two apart, as `![](lu-house)` reads.
165
+ if (!library && typeof name === 'string' && name.includes(':')) {
166
+ const [family, ...id] = name.split(':')
167
+ library = family
168
+ name = id.join(':')
169
+ }
170
+ if (library) attrs.library = library
171
+ attrs.name = name
172
+ } else {
173
+ return null
174
+ }
151
175
  if (size) attrs.size = size
152
176
  if (color) attrs.color = color
153
- return { type: 'UniwebIcon', attrs }
177
+ return { type: 'paragraph', content: [{ type: 'image', attrs }] }
154
178
  }
155
179
 
156
- function videoNode({ src, url, caption, direction, coverImg }) {
157
- const attrs = { src: src || url }
180
+ const PLAYBACK_FLAGS = ['autoplay', 'muted', 'loop', 'controls']
181
+
182
+ function videoNode(video) {
183
+ const { src, url, alt, caption, placement, poster, coverImg } = video
184
+ const attrs = { src: src || url, role: 'video' }
185
+ if (alt) attrs.alt = alt
158
186
  if (caption) attrs.caption = caption
159
- if (direction) attrs.direction = direction
160
- // ⛔ The editor's `Video` node holds `coverImg` as an OBJECT — the reader is
161
- // `makeAssetUrl`, which looks for `.src` / `.url` / `.identifier`. A bare
162
- // string passed straight through parses back as "": `info?.src` on a string
163
- // is undefined. Normalize here so a caller may write either.
164
- const cover = typeof coverImg === 'string' ? { src: coverImg } : coverImg
165
- if (cover && (cover.src || cover.url || cover.identifier)) attrs.coverImg = cover
166
- return { type: 'Video', attrs }
187
+ // The cover is `poster`, what the parser emits for a video and what a
188
+ // component reads. `coverImg` is an editor's spelling — a string, or an
189
+ // object whose `src` or `url` is the address; an `identifier` alone names
190
+ // no address, so it gives none.
191
+ const cover = poster || (typeof coverImg === 'string' ? coverImg : coverImg?.src || coverImg?.url)
192
+ if (cover) attrs.poster = cover
193
+ if (placement) attrs.placement = placement
194
+ for (const flag of PLAYBACK_FLAGS) if (typeof video[flag] === 'boolean') attrs[flag] = video[flag]
195
+ return { type: 'image', attrs }
167
196
  }
168
197
 
169
- function dividerBlock() {
170
- return { type: 'DividerBlock' }
198
+ function dividerNode() {
199
+ return { type: 'divider' }
171
200
  }
172
201
 
173
202
  /**
@@ -242,11 +271,11 @@ function listItemText(item) {
242
271
  // --- Group builder ---
243
272
 
244
273
  /**
245
- * Build TipTap nodes from a content group (main or item).
274
+ * Build the nodes of a content group (main or item).
246
275
  *
247
276
  * @param {Object} group - Content structure: { pretitle, title, subtitle, paragraphs, images, ... }
248
277
  * @param {number} titleLevel - Heading level for title (1 for main, 2 for items)
249
- * @returns {Array} Array of TipTap nodes
278
+ * @returns {Array} the group's nodes, in the vocabulary
250
279
  */
251
280
  function buildGroupNodes(group, titleLevel = 1, options = {}) {
252
281
  const nodes = []
@@ -282,7 +311,7 @@ function buildGroupNodes(group, titleLevel = 1, options = {}) {
282
311
 
283
312
  if (group.images) {
284
313
  for (const img of group.images) {
285
- nodes.push(imageBlock(img))
314
+ nodes.push(imageNode(img))
286
315
  }
287
316
  }
288
317
 
@@ -295,7 +324,8 @@ function buildGroupNodes(group, titleLevel = 1, options = {}) {
295
324
 
296
325
  if (group.icons) {
297
326
  for (const icon of group.icons) {
298
- nodes.push(iconNode(icon))
327
+ const node = iconNode(icon)
328
+ if (node) nodes.push(node)
299
329
  }
300
330
  }
301
331
 
@@ -347,10 +377,10 @@ function buildGroupNodes(group, titleLevel = 1, options = {}) {
347
377
  // --- Main export ---
348
378
 
349
379
  /**
350
- * Build a TipTap document from a content structure.
380
+ * Build a document in the vocabulary from a content structure.
351
381
  *
352
382
  * This is the reverse of parseContent(): given a flat content object
353
- * (title, paragraphs, items, etc.), produce a TipTap document that
383
+ * (title, paragraphs, items, etc.), produce a ProseMirror document that
354
384
  * roundtrips through parseContent() to yield the same structure.
355
385
  *
356
386
  * @param {Object} content - Content structure (same shape as parseContent output / starter)
@@ -358,7 +388,7 @@ function buildGroupNodes(group, titleLevel = 1, options = {}) {
358
388
  * @param {string} [options.dataLanguage='yaml'] - the fence a tagged data block
359
389
  * is written as. It is recorded on the node because content-writer reads it
360
390
  * back to choose the fence, and a parsed value cannot say which one it was.
361
- * @returns {Object|null} TipTap document { type: 'doc', content: [...] }, or null if empty
391
+ * @returns {Object|null} a document { type: 'doc', content: [...] }, or null if empty
362
392
  */
363
393
  function buildDoc(content, options = {}) {
364
394
  if (!content) return null
@@ -368,10 +398,10 @@ function buildDoc(content, options = {}) {
368
398
  // Main group content (title level 1)
369
399
  nodes.push(...buildGroupNodes(content, 1, options))
370
400
 
371
- // Items: separated by DividerBlock (mirrors divider-based grouping in groups.js)
401
+ // Items: separated by a divider (mirrors divider-based grouping in groups.js)
372
402
  if (content.items && content.items.length > 0) {
373
403
  for (const item of content.items) {
374
- nodes.push(dividerBlock())
404
+ nodes.push(dividerNode())
375
405
  // Item headings use level 2 (one below main H1)
376
406
  nodes.push(...buildGroupNodes(item, 2, options))
377
407
  }
@@ -861,8 +861,8 @@ function parseCardBlock(itemAttrs) {
861
861
  //
862
862
  // Read through `parseImgBlock`, which resolves the address and the preview's the
863
863
  // way every image's are, and then narrowed to what a document IS: where the file
864
- // is, what it is called, and the resource's own metadata. The image layout keys
865
- // (direction, size, credit) describe a picture, not a file, and are left out.
864
+ // is, what it is called, and the resource's own metadata. Where a picture sits
865
+ // in a flow (`placement`) is not a file's, and is left out.
866
866
  function parseMarkdownDocument(itemAttrs, options) {
867
867
  const { url, alt, caption, role, href, target, id, preview, author, description } =
868
868
  parseImgBlock(itemAttrs || {}, options);
@@ -1016,47 +1016,47 @@ function parseImgBlock(itemAttrs, options) {
1016
1016
  info: imgInfo,
1017
1017
  targetId,
1018
1018
  caption = "",
1019
- direction,
1020
- filter,
1021
1019
  alt = "",
1022
1020
  src,
1023
1021
  url,
1024
1022
  href = "",
1025
1023
  target = "",
1026
- theme,
1027
1024
  role,
1028
- credit = "",
1025
+ credit, // the editor's word for `author` — delivered as `author`
1029
1026
  id, // {#fig-id} cross-reference label — preserved so Press
1030
1027
  // adapters can emit \label{id} (LaTeX) / <id> (Typst).
1031
1028
  // ── Markdown-authored attributes ────────────────────────────────
1032
1029
  // This function was written for the EDITOR's `ImageBlock` node and
1033
1030
  // later reused for the markdown `image` node (see `case "image"`), so
1034
- // its vocabulary is the editor's: contentType, direction, filter,
1035
- // theme, credit. Everything markdown declares and the editor does not
1036
- // was therefore tokenized, declared, and then dropped one layer before
1037
- // delivery — the published "Image Attributes" table
1038
- // (docs/reference/content-structure.md) reached no component, and
1039
- // neither did the `role=pdf` set added 2026-07-29 for the editor's
1040
- // `document` fold-in. Carried 2026-07-30.
1031
+ // its vocabulary was the editor's. Everything markdown declares and
1032
+ // the editor does not was therefore tokenized, declared, and then
1033
+ // dropped one layer before delivery — the published "Image
1034
+ // Attributes" table (docs/reference/content-structure.md) reached no
1035
+ // component, and neither did the `role=pdf` set added 2026-07-29 for
1036
+ // the editor's `document` fold-in. Carried 2026-07-30.
1037
+ //
1038
+ // ⭐ An element now holds the vocabulary's words, whichever node it
1039
+ // came from. ⛔ Until 2026-10-15 it also held the editor's `direction`
1040
+ // and two things derived from it — `imgPos` (left/right) and a
1041
+ // layout `size` (basic/lg/full, `basic` for every image without one) —
1042
+ // plus `filter` and `theme`, none of which a stored document holds. A
1043
+ // component handing the element to kit's <Image> passed that `size` as
1044
+ // Image's avatar preset, so a "wide" image drew as a 96px square.
1041
1045
  width,
1042
1046
  height,
1043
1047
  loading,
1044
1048
  fit,
1045
1049
  position,
1050
+ placement, // where it sits in a flow: left, right, wide, full
1046
1051
  preview, // role=pdf — the preview image
1047
- author, // role=pdf — resource metadata, rendered beside the preview
1052
+ author, // the image's attribution; for role=pdf, the resource's
1048
1053
  description, // role=pdf — describes the RESOURCE, not the image (≠ alt)
1049
1054
  } = itemAttrs;
1050
1055
 
1051
1056
  let { contentType, viewType, contentId, identifier } = imgInfo || {};
1052
1057
 
1053
- const sizes = {
1054
- center: "basic",
1055
- wide: "lg",
1056
- fill: "full",
1057
- };
1058
-
1059
1058
  caption = stripTags(caption);
1059
+ const attribution = author || credit;
1060
1060
 
1061
1061
  // Reference precedence. Standard ProseMirror `image` nodes use `src`; the
1062
1062
  // custom `ImageBlock` node uses `url`. Honor either so callers don't have to
@@ -1088,19 +1088,9 @@ function parseImgBlock(itemAttrs, options) {
1088
1088
  value: identifier || "",
1089
1089
  alt: alt || caption,
1090
1090
  caption,
1091
- direction,
1092
- filter,
1093
- imgPos: direction === "left" || direction === "right" ? direction : "",
1094
- // NOTE: `size` here is the direction-derived LAYOUT size
1095
- // (basic/lg/full), not the author's `{size=…}` — that attribute is
1096
- // icon-only and is read by parseUniwebIcon. Do not wire `{size=…}`
1097
- // through here; the two meanings collide on one key.
1098
- size: sizes[direction] || "basic",
1099
1091
  href,
1100
1092
  target,
1101
- theme,
1102
1093
  role,
1103
- credit,
1104
1094
  id: id || undefined,
1105
1095
  // Omitted when absent, so an existing consumer sees no new empty keys.
1106
1096
  ...(width !== undefined && width !== null && { width }),
@@ -1108,12 +1098,13 @@ function parseImgBlock(itemAttrs, options) {
1108
1098
  ...(loading && { loading }),
1109
1099
  ...(fit && { fit }),
1110
1100
  ...(position && { position }),
1101
+ ...(placement && { placement }),
1111
1102
  ...((() => {
1112
1103
  // Same rule for a document's preview image.
1113
1104
  const p = secondaryAssetUrl(itemAttrs, options, "preview");
1114
1105
  return p ? { preview: p } : {};
1115
1106
  })()),
1116
- ...(author && { author }),
1107
+ ...(attribution && { author: attribution }),
1117
1108
  ...(description && { description }),
1118
1109
  };
1119
1110
  }
@@ -1142,6 +1133,7 @@ function parseMarkdownVideo(itemAttrs, options) {
1142
1133
  target = "",
1143
1134
  role,
1144
1135
  id, // {#fig-id} cross-reference label, same as parseImgBlock
1136
+ placement, // where it sits in a flow, same as parseImgBlock
1145
1137
  } = itemAttrs || {};
1146
1138
 
1147
1139
  // Reference precedence, now genuinely identical to parseImgBlock: a
@@ -1175,6 +1167,7 @@ function parseMarkdownVideo(itemAttrs, options) {
1175
1167
  if (loop !== undefined) video.loop = loop;
1176
1168
  if (controls !== undefined) video.controls = controls;
1177
1169
  if (id) video.id = id;
1170
+ if (placement) video.placement = placement;
1178
1171
 
1179
1172
  return video;
1180
1173
  }
@@ -1183,12 +1176,12 @@ function parseVideoBlock(itemAttrs, options) {
1183
1176
  let {
1184
1177
  src,
1185
1178
  caption = "",
1186
- direction,
1187
1179
  info = {},
1188
1180
  coverImg = {},
1189
1181
  alt,
1190
1182
  href = "",
1191
1183
  target = "",
1184
+ placement, // where it sits in a flow; the editor's `direction` is not read
1192
1185
  } = itemAttrs;
1193
1186
 
1194
1187
  // Same precedence as parseImgBlock / parseMarkdownVideo: a resolvable
@@ -1198,11 +1191,11 @@ function parseVideoBlock(itemAttrs, options) {
1198
1191
  return {
1199
1192
  src: video,
1200
1193
  caption,
1201
- direction,
1202
1194
  coverImg: makeAssetUrl(coverImg),
1203
1195
  alt,
1204
1196
  href,
1205
1197
  target,
1198
+ ...(placement && { placement }),
1206
1199
  };
1207
1200
  }
1208
1201