@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.
- package/package.json +1 -1
- package/src/builders/doc.js +84 -58
package/package.json
CHANGED
package/src/builders/doc.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Reverse conversion: content structure →
|
|
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
|
|
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
|
-
// ---
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
|
|
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 (
|
|
117
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if (
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
148
|
+
/**
|
|
149
|
+
* An icon, as markdown's `` 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 `` 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: '
|
|
177
|
+
return { type: 'paragraph', content: [{ type: 'image', attrs }] }
|
|
158
178
|
}
|
|
159
179
|
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
if (
|
|
170
|
-
|
|
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
|
|
174
|
-
return { type: '
|
|
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
|
|
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}
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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}
|
|
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
|
|
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(
|
|
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
|
}
|