@uniweb/semantic-parser 1.7.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/builders/doc.js +84 -58
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/semantic-parser",
3
- "version": "1.7.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,21 +114,23 @@ function linkParagraph({ text, label, href, target }) {
103
114
  }
104
115
  }
105
116
 
106
- function imageBlock({ src, url, alt = '', caption = '', placement, 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
- // The parsed field's own word, so parseContent(buildDoc(x)) keeps it. The
112
- // editor's node calls it `direction` and drops an attr it does not declare,
113
- // so the editor reads none until it saves the vocabulary; no starter content
114
- // sets one. ⛔ Until 2026-10-15 this took and wrote `direction`.
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
115
131
  if (placement) attrs.placement = placement
116
- if (role) attrs.role = role
117
- if (width && height) {
118
- attrs.aspect_ratio = { width, height, ratio: (height / width) * 100 }
119
- }
120
- return { type: 'ImageBlock', attrs }
132
+ for (const key of IMAGE_EXTRAS) if (image[key]) attrs[key] = image[key]
133
+ return { type: 'image', attrs }
121
134
  }
122
135
 
123
136
  // A document is STORED as the image row with `role: 'pdf'` — the node the
@@ -132,46 +145,58 @@ function documentNode({ url, src, alt = '', caption = '', preview, author, descr
132
145
  return { type: 'image', attrs }
133
146
  }
134
147
 
135
- function iconNode({ src, url, svg, library, name, size, color }) {
136
- // UniwebIcon supports multiple source types.
137
- //
138
- // The family rides INSIDE `name` as `family:id`. The editor's node declares
139
- // `{ name, svg, url, size, color, preserveColors, info }` and no `library`,
140
- // and ProseMirror silently drops undeclared attrs on `fromJSON` — so emitting
141
- // a separate `library` lost the family without a trace, on the path that
142
- // builds starter content for every new section.
143
- //
144
- // `svg` is MARKUP and `url` is a URL — on both sides. A file-sourced icon
145
- // arrives here as `src` and belongs in `url`, not in the markup slot; putting
146
- // a path where a consumer expects `<svg …>` renders nothing. (Fixed
147
- // 2026-07-30; `url` is declared on the editor's node, so it crosses intact.)
148
- const attrs = {}
149
- if (svg) attrs.svg = svg
150
- // Either spelling in, one spelling out — `parseUniwebIcon` now emits `url`
151
- // for a file-sourced icon, and this builder's documented invariant is that
152
- // parseContent(buildDoc(x)) round-trips.
153
- if (url || src) attrs.url = url || src
154
- 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
+ }
155
175
  if (size) attrs.size = size
156
176
  if (color) attrs.color = color
157
- return { type: 'UniwebIcon', attrs }
177
+ return { type: 'paragraph', content: [{ type: 'image', attrs }] }
158
178
  }
159
179
 
160
- function videoNode({ src, url, caption, placement, coverImg }) {
161
- 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
162
186
  if (caption) attrs.caption = caption
163
- if (placement) attrs.placement = placement // as an image's, above
164
- // ⛔ The editor's `Video` node holds `coverImg` as an OBJECT — the reader is
165
- // `makeAssetUrl`, which looks for `.src` / `.url` / `.identifier`. A bare
166
- // string passed straight through parses back as "": `info?.src` on a string
167
- // is undefined. Normalize here so a caller may write either.
168
- const cover = typeof coverImg === 'string' ? { src: coverImg } : coverImg
169
- if (cover && (cover.src || cover.url || cover.identifier)) attrs.coverImg = cover
170
- 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 }
171
196
  }
172
197
 
173
- function dividerBlock() {
174
- return { type: 'DividerBlock' }
198
+ function dividerNode() {
199
+ return { type: 'divider' }
175
200
  }
176
201
 
177
202
  /**
@@ -246,11 +271,11 @@ function listItemText(item) {
246
271
  // --- Group builder ---
247
272
 
248
273
  /**
249
- * Build TipTap nodes from a content group (main or item).
274
+ * Build the nodes of a content group (main or item).
250
275
  *
251
276
  * @param {Object} group - Content structure: { pretitle, title, subtitle, paragraphs, images, ... }
252
277
  * @param {number} titleLevel - Heading level for title (1 for main, 2 for items)
253
- * @returns {Array} Array of TipTap nodes
278
+ * @returns {Array} the group's nodes, in the vocabulary
254
279
  */
255
280
  function buildGroupNodes(group, titleLevel = 1, options = {}) {
256
281
  const nodes = []
@@ -286,7 +311,7 @@ function buildGroupNodes(group, titleLevel = 1, options = {}) {
286
311
 
287
312
  if (group.images) {
288
313
  for (const img of group.images) {
289
- nodes.push(imageBlock(img))
314
+ nodes.push(imageNode(img))
290
315
  }
291
316
  }
292
317
 
@@ -299,7 +324,8 @@ function buildGroupNodes(group, titleLevel = 1, options = {}) {
299
324
 
300
325
  if (group.icons) {
301
326
  for (const icon of group.icons) {
302
- nodes.push(iconNode(icon))
327
+ const node = iconNode(icon)
328
+ if (node) nodes.push(node)
303
329
  }
304
330
  }
305
331
 
@@ -351,10 +377,10 @@ function buildGroupNodes(group, titleLevel = 1, options = {}) {
351
377
  // --- Main export ---
352
378
 
353
379
  /**
354
- * Build a TipTap document from a content structure.
380
+ * Build a document in the vocabulary from a content structure.
355
381
  *
356
382
  * This is the reverse of parseContent(): given a flat content object
357
- * (title, paragraphs, items, etc.), produce a TipTap document that
383
+ * (title, paragraphs, items, etc.), produce a ProseMirror document that
358
384
  * roundtrips through parseContent() to yield the same structure.
359
385
  *
360
386
  * @param {Object} content - Content structure (same shape as parseContent output / starter)
@@ -362,7 +388,7 @@ function buildGroupNodes(group, titleLevel = 1, options = {}) {
362
388
  * @param {string} [options.dataLanguage='yaml'] - the fence a tagged data block
363
389
  * is written as. It is recorded on the node because content-writer reads it
364
390
  * back to choose the fence, and a parsed value cannot say which one it was.
365
- * @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
366
392
  */
367
393
  function buildDoc(content, options = {}) {
368
394
  if (!content) return null
@@ -372,10 +398,10 @@ function buildDoc(content, options = {}) {
372
398
  // Main group content (title level 1)
373
399
  nodes.push(...buildGroupNodes(content, 1, options))
374
400
 
375
- // Items: separated by DividerBlock (mirrors divider-based grouping in groups.js)
401
+ // Items: separated by a divider (mirrors divider-based grouping in groups.js)
376
402
  if (content.items && content.items.length > 0) {
377
403
  for (const item of content.items) {
378
- nodes.push(dividerBlock())
404
+ nodes.push(dividerNode())
379
405
  // Item headings use level 2 (one below main H1)
380
406
  nodes.push(...buildGroupNodes(item, 2, options))
381
407
  }