@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 +5 -4
- package/package.json +1 -1
- package/src/builders/doc.js +85 -55
- package/src/processors/sequence.js +26 -33
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]?.
|
|
120
|
+
const image = content.images[0]?.url;
|
|
120
121
|
```
|
|
121
122
|
|
|
122
123
|
### Processing Content Sections
|
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,17 +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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
if (
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
+
}
|
|
151
175
|
if (size) attrs.size = size
|
|
152
176
|
if (color) attrs.color = color
|
|
153
|
-
return { type: '
|
|
177
|
+
return { type: 'paragraph', content: [{ type: 'image', attrs }] }
|
|
154
178
|
}
|
|
155
179
|
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
if (
|
|
166
|
-
|
|
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
|
|
170
|
-
return { type: '
|
|
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
|
|
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}
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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}
|
|
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
|
|
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(
|
|
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.
|
|
865
|
-
//
|
|
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
|
|
1035
|
-
//
|
|
1036
|
-
//
|
|
1037
|
-
//
|
|
1038
|
-
//
|
|
1039
|
-
//
|
|
1040
|
-
//
|
|
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
|
|
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
|
-
...(
|
|
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
|
|