@uniweb/semantic-parser 1.4.0 → 1.5.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/AGENTS.md +2 -2
- package/README.md +3 -2
- package/package.json +1 -1
- package/src/builders/doc.js +185 -18
- package/src/processors/groups.js +9 -13
- package/src/processors/sequence.js +80 -15
package/AGENTS.md
CHANGED
|
@@ -53,7 +53,7 @@ The parser returns a flat content structure:
|
|
|
53
53
|
```js
|
|
54
54
|
{
|
|
55
55
|
title: '', // Main heading
|
|
56
|
-
pretitle: '', // `#>` label line(s)
|
|
56
|
+
pretitle: '', // `#>` label line(s); also smaller headings above the title
|
|
57
57
|
subtitle: '', // Line(s) one step below the title (string or array)
|
|
58
58
|
paragraphs: [],
|
|
59
59
|
links: [], // All link-like entities (including buttons, documents)
|
|
@@ -161,7 +161,7 @@ Lists maintain hierarchy through nested structure. The `processListItems()` func
|
|
|
161
161
|
|
|
162
162
|
Key patterns — the staircase rule (each heading relates to the one before it):
|
|
163
163
|
|
|
164
|
-
- **Pretitle**: `#>` label lines (headings with `role: "pretitle"`)
|
|
164
|
+
- **Pretitle**: `#>` label lines (headings with `role: "pretitle"`) — what to write. Also smaller headings stacked directly above a more important one (H3→H1, H6→H5, …), the older spelling, read for compatibility. String or array.
|
|
165
165
|
- **Subtitle**: one step below the title, directly adjacent — and each further one-step descent (or same-size repeat) is another subtitle line. String or array.
|
|
166
166
|
- **Items**: a heading two or more steps below the previous one, a step back up, or any heading after body content starts a new group.
|
|
167
167
|
- **Banner Pattern**: an image (with banner role or followed by a heading) at the start of the first group stays with the headline.
|
package/README.md
CHANGED
|
@@ -66,7 +66,8 @@ Main content fields are at the top level. The `items` array contains additional
|
|
|
66
66
|
result = {
|
|
67
67
|
// Header fields (from headings) — each is a string, or an array when the
|
|
68
68
|
// part spans several lines
|
|
69
|
-
pretitle: "", // `#>` label line(s)
|
|
69
|
+
pretitle: "", // `#>` label line(s) — also smaller headings above
|
|
70
|
+
// the title, the older spelling
|
|
70
71
|
title: "Welcome", // Main heading
|
|
71
72
|
subtitle: "", // Line(s) one step below the title
|
|
72
73
|
|
|
@@ -180,7 +181,7 @@ This is the mechanism that creates repeating content groups (cards, features, FA
|
|
|
180
181
|
Two spellings fill the `pretitle` slot:
|
|
181
182
|
|
|
182
183
|
- **A `#>` label line** — a heading with `role: "pretitle"`. It names the block that starts next, at any depth, with no level arithmetic; the hash count is carried for round-tripping and means nothing.
|
|
183
|
-
- **Position** — smaller headings stacked above the most important heading of a run (H3 before H1, H6 before H5, …), in any order. `#### / ### / # Title` and `## / ### / # Title` both yield a two-line pretitle.
|
|
184
|
+
- **Position** — smaller headings stacked above the most important heading of a run (H3 before H1, H6 before H5, …), in any order. `#### / ### / # Title` and `## / ### / # Title` both yield a two-line pretitle. ⚖️ This is the older spelling and is read for compatibility; a label line is what to WRITE, because it carries its meaning alone rather than by what follows it.
|
|
184
185
|
|
|
185
186
|
### Banner image
|
|
186
187
|
|
package/package.json
CHANGED
package/src/builders/doc.js
CHANGED
|
@@ -6,6 +6,34 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Starter content uses plain strings (no HTML marks), so the conversion
|
|
8
8
|
* is straightforward — no need to reverse inline HTML formatting.
|
|
9
|
+
*
|
|
10
|
+
* ## ⭐ IT ACCEPTS THE PARSER'S OWN VOCABULARY TOO — and that is a contract
|
|
11
|
+
*
|
|
12
|
+
* This builder was written with its own spelling for several concepts, and
|
|
13
|
+
* `parseContent` emits a DIFFERENT one for the same thing: a link's text is
|
|
14
|
+
* `text` here and `label` there; an image's address is `src` here and `url`
|
|
15
|
+
* there; a video's cover is a string here and `{ src | url | identifier }`
|
|
16
|
+
* there; a list item is a plain string here and a whole content GROUP there.
|
|
17
|
+
*
|
|
18
|
+
* ⛔ EVERY ONE OF THOSE MISMATCHES FAILED SILENTLY, which is why the rule is
|
|
19
|
+
* now written down. Measured 2026-09-16, all four on the shapes a caller would
|
|
20
|
+
* most naturally reach for:
|
|
21
|
+
*
|
|
22
|
+
* { links: [{ label, href }] } → buildDoc returns **null** outright
|
|
23
|
+
* { images: [{ url, alt }] } → an ImageBlock with NO url attr
|
|
24
|
+
* { videos: [{ coverImg: '/p.jpg' }] } → the cover parses back as ""
|
|
25
|
+
* { lists: parsed.lists } → a text node whose `text` is an
|
|
26
|
+
* OBJECT — an invalid document
|
|
27
|
+
*
|
|
28
|
+
* None of them threw. A generator author reaches for `label` and `url` because
|
|
29
|
+
* those are the names in `docs/reference/component-metadata.md` and the names a
|
|
30
|
+
* component actually reads off `content` — so the natural spelling was the
|
|
31
|
+
* broken one, and it produced an empty section rather than an error.
|
|
32
|
+
*
|
|
33
|
+
* ⇒ For every concept where the two sides disagree, ACCEPT BOTH SPELLINGS. The
|
|
34
|
+
* emitted document is unchanged — this only widens what may come in — so the
|
|
35
|
+
* round-trip assertions above still pin the output. A new field whose name
|
|
36
|
+
* differs from the parser's needs the same treatment, or it joins this list.
|
|
9
37
|
*/
|
|
10
38
|
|
|
11
39
|
// --- TipTap node builders ---
|
|
@@ -27,6 +55,33 @@ function heading(level, text) {
|
|
|
27
55
|
}
|
|
28
56
|
}
|
|
29
57
|
|
|
58
|
+
/**
|
|
59
|
+
* A label line — `#> Text` — which is how a pretitle is written.
|
|
60
|
+
*
|
|
61
|
+
* ⛔ NOT A SMALLER HEADING BEFORE THE TITLE. That positional form still parses,
|
|
62
|
+
* and it is what this builder emitted until 2026-09-16, but it is the old
|
|
63
|
+
* spelling and it is fragile in a way the label line is not: it means "pretitle"
|
|
64
|
+
* only by sitting before a bigger heading, so the same line moved, or left
|
|
65
|
+
* alone, becomes something else. `#>` says what it is wherever it lands.
|
|
66
|
+
*
|
|
67
|
+
* ⭐ THE `#` COUNT MEANS NOTHING. `#>`, `##>` and `###>` are the same thing —
|
|
68
|
+
* content-reader keeps the authored count in `level` purely so a file
|
|
69
|
+
* round-trips byte-for-byte. We match the level of the title it labels, which is
|
|
70
|
+
* what an author writing by hand does (`##>` above an `##` item title).
|
|
71
|
+
*
|
|
72
|
+
* On the wire it is an ordinary heading carrying `role: 'pretitle'`, so every
|
|
73
|
+
* consumer that already understands headings keeps working.
|
|
74
|
+
*/
|
|
75
|
+
function labelLine(level, text) {
|
|
76
|
+
if (!text) return null
|
|
77
|
+
if (Array.isArray(text)) return text.map(t => labelLine(level, t)).filter(Boolean)
|
|
78
|
+
return {
|
|
79
|
+
type: 'heading',
|
|
80
|
+
attrs: { level, role: 'pretitle' },
|
|
81
|
+
content: [textNode(text)],
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
30
85
|
function paragraph(text) {
|
|
31
86
|
if (!text) return null
|
|
32
87
|
return {
|
|
@@ -35,18 +90,23 @@ function paragraph(text) {
|
|
|
35
90
|
}
|
|
36
91
|
}
|
|
37
92
|
|
|
38
|
-
function linkParagraph({ text, href, target }) {
|
|
39
|
-
|
|
93
|
+
function linkParagraph({ text, label, href, target }) {
|
|
94
|
+
// `label` is what parseContent emits and what a component reads off
|
|
95
|
+
// `content.links[]`; `text` is this builder's original spelling.
|
|
96
|
+
const caption = text || label
|
|
97
|
+
if (!caption || !href) return null
|
|
40
98
|
const mark = { type: 'link', attrs: { href } }
|
|
41
99
|
if (target) mark.attrs.target = target
|
|
42
100
|
return {
|
|
43
101
|
type: 'paragraph',
|
|
44
|
-
content: [{ type: 'text', text, marks: [mark] }],
|
|
102
|
+
content: [{ type: 'text', text: caption, marks: [mark] }],
|
|
45
103
|
}
|
|
46
104
|
}
|
|
47
105
|
|
|
48
|
-
function imageBlock({ src, alt = '', caption = '', direction, role, width, height }) {
|
|
49
|
-
|
|
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 }
|
|
50
110
|
if (caption) attrs.caption = caption
|
|
51
111
|
if (direction) attrs.direction = direction
|
|
52
112
|
if (role) attrs.role = role
|
|
@@ -56,6 +116,18 @@ function imageBlock({ src, alt = '', caption = '', direction, role, width, heigh
|
|
|
56
116
|
return { type: 'ImageBlock', attrs }
|
|
57
117
|
}
|
|
58
118
|
|
|
119
|
+
// A document is STORED as the image row with `role: 'pdf'` — the node the
|
|
120
|
+
// markdown reader writes and the parser reads as a `document` — so that is what
|
|
121
|
+
// this emits. `url` (the parser's spelling) or `src` (this builder's) in.
|
|
122
|
+
function documentNode({ url, src, alt = '', caption = '', preview, author, description }) {
|
|
123
|
+
const attrs = { src: url || src, alt, role: 'pdf' }
|
|
124
|
+
if (caption) attrs.caption = caption
|
|
125
|
+
if (preview) attrs.preview = preview
|
|
126
|
+
if (author) attrs.author = author
|
|
127
|
+
if (description) attrs.description = description
|
|
128
|
+
return { type: 'image', attrs }
|
|
129
|
+
}
|
|
130
|
+
|
|
59
131
|
function iconNode({ src, url, svg, library, name, size, color }) {
|
|
60
132
|
// UniwebIcon supports multiple source types.
|
|
61
133
|
//
|
|
@@ -81,11 +153,16 @@ function iconNode({ src, url, svg, library, name, size, color }) {
|
|
|
81
153
|
return { type: 'UniwebIcon', attrs }
|
|
82
154
|
}
|
|
83
155
|
|
|
84
|
-
function videoNode({ src, caption, direction, coverImg }) {
|
|
85
|
-
const attrs = { src }
|
|
156
|
+
function videoNode({ src, url, caption, direction, coverImg }) {
|
|
157
|
+
const attrs = { src: src || url }
|
|
86
158
|
if (caption) attrs.caption = caption
|
|
87
159
|
if (direction) attrs.direction = direction
|
|
88
|
-
|
|
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
|
|
89
166
|
return { type: 'Video', attrs }
|
|
90
167
|
}
|
|
91
168
|
|
|
@@ -93,17 +170,75 @@ function dividerBlock() {
|
|
|
93
170
|
return { type: 'DividerBlock' }
|
|
94
171
|
}
|
|
95
172
|
|
|
173
|
+
/**
|
|
174
|
+
* A code block for DISPLAY — what lands in `content.snippets`.
|
|
175
|
+
*
|
|
176
|
+
* `parseContent` groups one as `{ language, code }`; the element before
|
|
177
|
+
* grouping carries `{ text, attrs }`. Accept either spelling, and a bare string
|
|
178
|
+
* for a snippet with no language.
|
|
179
|
+
*/
|
|
180
|
+
function codeBlock(snippet) {
|
|
181
|
+
const spec = typeof snippet === 'string' ? { code: snippet } : snippet
|
|
182
|
+
if (!spec || typeof spec !== 'object') return null
|
|
183
|
+
const text = spec.code ?? spec.text
|
|
184
|
+
if (typeof text !== 'string' || text === '') return null
|
|
185
|
+
const attrs = {}
|
|
186
|
+
if (spec.language) attrs.language = spec.language
|
|
187
|
+
if (spec.filename) attrs.filename = spec.filename
|
|
188
|
+
return { type: 'codeBlock', attrs, content: [textNode(text)] }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* A tagged data block — what lands in `content.data[tag]`.
|
|
193
|
+
*
|
|
194
|
+
* ⛔ IT IS A `dataBlock`, NOT A TAGGED `codeBlock`, and the difference is not
|
|
195
|
+
* cosmetic. content-reader emits `dataBlock` when a tagged fence PARSED, with
|
|
196
|
+
* the parsed value on `attrs.data`; it falls back to a tagged `codeBlock`
|
|
197
|
+
* carrying raw TEXT only when parsing failed. A builder handed a real object
|
|
198
|
+
* has parsed data by definition, so emitting the failure shape would hand a
|
|
199
|
+
* component a string where it expects a record.
|
|
200
|
+
*
|
|
201
|
+
* ⭐ `language` IS LOAD-BEARING, not decoration. content-writer reads it back to
|
|
202
|
+
* choose the fence, and the parsed value alone cannot say whether the author
|
|
203
|
+
* wrote YAML or JSON — without it an author's YAML silently becomes JSON on the
|
|
204
|
+
* next editor sync. A builder has no author to echo, so it states one.
|
|
205
|
+
*/
|
|
206
|
+
function dataBlock(tag, value, language) {
|
|
207
|
+
// A null value is the same absence as no key at all — a component reading
|
|
208
|
+
// `content.data[tag]` gets null either way — so emit no fence rather than one
|
|
209
|
+
// whose whole payload is `null`.
|
|
210
|
+
if (!tag || value === undefined || value === null) return null
|
|
211
|
+
return { type: 'dataBlock', attrs: { tag, language, data: value } }
|
|
212
|
+
}
|
|
213
|
+
|
|
96
214
|
function bulletList(items) {
|
|
97
215
|
if (!items || !items.length) return null
|
|
216
|
+
const entries = items.map(listItemText).filter(Boolean)
|
|
217
|
+
if (!entries.length) return null
|
|
98
218
|
return {
|
|
99
219
|
type: 'bulletList',
|
|
100
|
-
content:
|
|
220
|
+
content: entries.map(text => ({
|
|
101
221
|
type: 'listItem',
|
|
102
|
-
content: [paragraph(
|
|
222
|
+
content: [paragraph(text)].filter(Boolean),
|
|
103
223
|
})),
|
|
104
224
|
}
|
|
105
225
|
}
|
|
106
226
|
|
|
227
|
+
/**
|
|
228
|
+
* One list entry's text. A generator writes plain strings; `parseContent`
|
|
229
|
+
* emits a full content GROUP per entry ({ paragraphs, links, … }), and feeding
|
|
230
|
+
* that back produced a text node whose `text` was an object — an invalid
|
|
231
|
+
* document that threw nowhere.
|
|
232
|
+
*/
|
|
233
|
+
function listItemText(item) {
|
|
234
|
+
if (typeof item === 'string') return item
|
|
235
|
+
if (!item || typeof item !== 'object') return ''
|
|
236
|
+
if (Array.isArray(item.paragraphs) && item.paragraphs.length) {
|
|
237
|
+
return item.paragraphs.join(' ')
|
|
238
|
+
}
|
|
239
|
+
return item.title || ''
|
|
240
|
+
}
|
|
241
|
+
|
|
107
242
|
// --- Group builder ---
|
|
108
243
|
|
|
109
244
|
/**
|
|
@@ -113,14 +248,13 @@ function bulletList(items) {
|
|
|
113
248
|
* @param {number} titleLevel - Heading level for title (1 for main, 2 for items)
|
|
114
249
|
* @returns {Array} Array of TipTap nodes
|
|
115
250
|
*/
|
|
116
|
-
function buildGroupNodes(group, titleLevel = 1) {
|
|
251
|
+
function buildGroupNodes(group, titleLevel = 1, options = {}) {
|
|
117
252
|
const nodes = []
|
|
118
253
|
|
|
119
|
-
// 1. Headings: pretitle → title → subtitle
|
|
120
|
-
//
|
|
121
|
-
// e.g., H3 before H1 — mirrors isPreTitle() in groups.js
|
|
254
|
+
// 1. Headings: pretitle → title → subtitle.
|
|
255
|
+
// A pretitle is a LABEL LINE (`#> Text`), at the level of the title it labels.
|
|
122
256
|
if (group.pretitle) {
|
|
123
|
-
const pre =
|
|
257
|
+
const pre = labelLine(titleLevel, group.pretitle)
|
|
124
258
|
if (Array.isArray(pre)) nodes.push(...pre)
|
|
125
259
|
else if (pre) nodes.push(pre)
|
|
126
260
|
}
|
|
@@ -171,6 +305,12 @@ function buildGroupNodes(group, titleLevel = 1) {
|
|
|
171
305
|
}
|
|
172
306
|
}
|
|
173
307
|
|
|
308
|
+
if (group.documents) {
|
|
309
|
+
for (const doc of group.documents) {
|
|
310
|
+
nodes.push(documentNode(doc))
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
174
314
|
if (group.lists) {
|
|
175
315
|
for (const list of group.lists) {
|
|
176
316
|
const node = bulletList(list)
|
|
@@ -178,6 +318,29 @@ function buildGroupNodes(group, titleLevel = 1) {
|
|
|
178
318
|
}
|
|
179
319
|
}
|
|
180
320
|
|
|
321
|
+
if (group.snippets) {
|
|
322
|
+
for (const snippet of group.snippets) {
|
|
323
|
+
const node = codeBlock(snippet)
|
|
324
|
+
if (node) nodes.push(node)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// `data` is a MAP of tag → value, not a list: one tagged fence per key, in
|
|
329
|
+
// declaration order.
|
|
330
|
+
//
|
|
331
|
+
// ⚠️ A `concept_block` (```md:faq) also lands in `content.data`, as
|
|
332
|
+
// `{ items, sequence }` — a tagged PROSE fence, not data. This does not
|
|
333
|
+
// reconstruct one: it emits the data fence, whose payload round-trips to the
|
|
334
|
+
// same value. Prose that was parsed into a document does not come back as
|
|
335
|
+
// prose here, and a caller rebuilding a concept block wants the markdown.
|
|
336
|
+
if (group.data && typeof group.data === 'object' && !Array.isArray(group.data)) {
|
|
337
|
+
const language = options.dataLanguage || 'yaml'
|
|
338
|
+
for (const [tag, value] of Object.entries(group.data)) {
|
|
339
|
+
const node = dataBlock(tag, value, language)
|
|
340
|
+
if (node) nodes.push(node)
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
181
344
|
return nodes
|
|
182
345
|
}
|
|
183
346
|
|
|
@@ -191,22 +354,26 @@ function buildGroupNodes(group, titleLevel = 1) {
|
|
|
191
354
|
* roundtrips through parseContent() to yield the same structure.
|
|
192
355
|
*
|
|
193
356
|
* @param {Object} content - Content structure (same shape as parseContent output / starter)
|
|
357
|
+
* @param {Object} [options]
|
|
358
|
+
* @param {string} [options.dataLanguage='yaml'] - the fence a tagged data block
|
|
359
|
+
* is written as. It is recorded on the node because content-writer reads it
|
|
360
|
+
* back to choose the fence, and a parsed value cannot say which one it was.
|
|
194
361
|
* @returns {Object|null} TipTap document { type: 'doc', content: [...] }, or null if empty
|
|
195
362
|
*/
|
|
196
|
-
function buildDoc(content) {
|
|
363
|
+
function buildDoc(content, options = {}) {
|
|
197
364
|
if (!content) return null
|
|
198
365
|
|
|
199
366
|
const nodes = []
|
|
200
367
|
|
|
201
368
|
// Main group content (title level 1)
|
|
202
|
-
nodes.push(...buildGroupNodes(content, 1))
|
|
369
|
+
nodes.push(...buildGroupNodes(content, 1, options))
|
|
203
370
|
|
|
204
371
|
// Items: separated by DividerBlock (mirrors divider-based grouping in groups.js)
|
|
205
372
|
if (content.items && content.items.length > 0) {
|
|
206
373
|
for (const item of content.items) {
|
|
207
374
|
nodes.push(dividerBlock())
|
|
208
375
|
// Item headings use level 2 (one below main H1)
|
|
209
|
-
nodes.push(...buildGroupNodes(item, 2))
|
|
376
|
+
nodes.push(...buildGroupNodes(item, 2, options))
|
|
210
377
|
}
|
|
211
378
|
}
|
|
212
379
|
|
package/src/processors/groups.js
CHANGED
|
@@ -15,6 +15,7 @@ function flattenGroup(group) {
|
|
|
15
15
|
icons: group.body.icons || [],
|
|
16
16
|
lists: group.body.lists || [],
|
|
17
17
|
videos: group.body.videos || [],
|
|
18
|
+
documents: group.body.documents || [],
|
|
18
19
|
insets: group.body.insets || [],
|
|
19
20
|
snippets: group.body.snippets || [],
|
|
20
21
|
data: group.body.data || {},
|
|
@@ -303,6 +304,7 @@ function processGroupContent(elements, options = {}) {
|
|
|
303
304
|
images: [],
|
|
304
305
|
icons: [],
|
|
305
306
|
videos: [],
|
|
307
|
+
documents: [],
|
|
306
308
|
insets: [],
|
|
307
309
|
snippets: [],
|
|
308
310
|
paragraphs: [],
|
|
@@ -387,6 +389,10 @@ function processGroupContent(elements, options = {}) {
|
|
|
387
389
|
body.videos.push(preserveProps);
|
|
388
390
|
break;
|
|
389
391
|
|
|
392
|
+
case "document":
|
|
393
|
+
body.documents.push(preserveProps);
|
|
394
|
+
break;
|
|
395
|
+
|
|
390
396
|
case "link":
|
|
391
397
|
if (element.children && Array.isArray(element.children))
|
|
392
398
|
processInlineElements(element.children, body);
|
|
@@ -520,19 +526,6 @@ function processGroupContent(elements, options = {}) {
|
|
|
520
526
|
});
|
|
521
527
|
break;
|
|
522
528
|
|
|
523
|
-
case "document-group":
|
|
524
|
-
// Map documents to links with role=document
|
|
525
|
-
element.documents.forEach(doc => {
|
|
526
|
-
body.links.push({
|
|
527
|
-
href: doc.href || doc.downloadUrl || '',
|
|
528
|
-
label: doc.title || '',
|
|
529
|
-
role: 'document',
|
|
530
|
-
download: true,
|
|
531
|
-
preview: doc.coverImg,
|
|
532
|
-
fileType: doc.fileType,
|
|
533
|
-
});
|
|
534
|
-
});
|
|
535
|
-
break;
|
|
536
529
|
}
|
|
537
530
|
}
|
|
538
531
|
}
|
|
@@ -588,6 +581,9 @@ function processInlineElements(children, body) {
|
|
|
588
581
|
// `{role=video}` belongs in videos[] for the same reason a hoisted
|
|
589
582
|
// one does.
|
|
590
583
|
body.videos.push(item.attrs);
|
|
584
|
+
} else if (item.type === "document") {
|
|
585
|
+
// And an inline `{role=pdf}` in documents[].
|
|
586
|
+
body.documents.push(item.attrs);
|
|
591
587
|
} else if (item.type === "link") {
|
|
592
588
|
// Handle inline links extracted from paragraph text nodes
|
|
593
589
|
body.links.push(item.attrs);
|
|
@@ -210,7 +210,19 @@ function createSequenceElement(node, options = {}) {
|
|
|
210
210
|
case "image":
|
|
211
211
|
// `role` decides which content array this lands in — the contract
|
|
212
212
|
// published in docs/reference/content-structure.md ("Media Assets"):
|
|
213
|
-
// icon → icons, video → videos, everything else →
|
|
213
|
+
// icon → icons, video → videos, pdf → documents, everything else →
|
|
214
|
+
// images.
|
|
215
|
+
//
|
|
216
|
+
// A document is stored as this node, as a video is, and delivered as
|
|
217
|
+
// a kind of its own (`parseMarkdownDocument`). Until 2026-09-29 it
|
|
218
|
+
// landed in `images`, drawn as an image of the file, while the
|
|
219
|
+
// runtime had guaranteed an empty `content.documents` since January.
|
|
220
|
+
if (attrs?.role === "pdf") {
|
|
221
|
+
return {
|
|
222
|
+
type: "document",
|
|
223
|
+
attrs: parseMarkdownDocument(attrs, options),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
214
226
|
//
|
|
215
227
|
// The video branch was missing until 2026-07-30: every
|
|
216
228
|
// `{role=video}` node ran parseImgBlock, which does not carry
|
|
@@ -315,13 +327,16 @@ function createSequenceElement(node, options = {}) {
|
|
|
315
327
|
};
|
|
316
328
|
|
|
317
329
|
case "document-group":
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
330
|
+
// The editor's older container: one `document` element per document
|
|
331
|
+
// it holds, so a document is one kind of content whichever node
|
|
332
|
+
// wrote it (`documentFromEditorNode`). Until 2026-09-29 this was a
|
|
333
|
+
// `document-group` element whose documents became `content.links`.
|
|
334
|
+
return (node.content || [])
|
|
335
|
+
.filter((c) => c.type === "document")
|
|
336
|
+
.map((doc) => ({
|
|
337
|
+
type: "document",
|
|
338
|
+
attrs: documentFromEditorNode(doc.attrs),
|
|
339
|
+
}));
|
|
325
340
|
|
|
326
341
|
// The editor renamed this node to `StructuredContent` (2026-07-31,
|
|
327
342
|
// editor-internal). Both are accepted: the new name is what the editor
|
|
@@ -613,13 +628,15 @@ function processInlineElements(content, options = {}) {
|
|
|
613
628
|
// image silently deleted. groups.js has carried the receiving
|
|
614
629
|
// branch (and a comment describing exactly this failure) since it
|
|
615
630
|
// was written, but nothing ever emitted the item it reads.
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
attrs:
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
}
|
|
631
|
+
// The role partitions it as the block case does.
|
|
632
|
+
const role = item.attrs?.role;
|
|
633
|
+
if (role === "video") {
|
|
634
|
+
items.push({ type: "video", attrs: parseMarkdownVideo(item.attrs, options) });
|
|
635
|
+
} else if (role === "pdf") {
|
|
636
|
+
items.push({ type: "document", attrs: parseMarkdownDocument(item.attrs, options) });
|
|
637
|
+
} else {
|
|
638
|
+
items.push({ type: "image", attrs: parseImgBlock(item.attrs || {}, options) });
|
|
639
|
+
}
|
|
623
640
|
} else if (item.type === "math-inline" || item.type === "math_inline") {
|
|
624
641
|
items.push(item);
|
|
625
642
|
} else if (item.type === "inset_placeholder") {
|
|
@@ -802,6 +819,54 @@ function parseCardBlock(itemAttrs) {
|
|
|
802
819
|
};
|
|
803
820
|
}
|
|
804
821
|
|
|
822
|
+
// A document authored in markdown: `{role=pdf …}`.
|
|
823
|
+
//
|
|
824
|
+
// Read through `parseImgBlock`, which resolves the address and the preview's the
|
|
825
|
+
// way every image's are, and then narrowed to what a document IS: where the file
|
|
826
|
+
// is, what it is called, and the resource's own metadata. The image layout keys
|
|
827
|
+
// (direction, size, credit) describe a picture, not a file, and are left out.
|
|
828
|
+
function parseMarkdownDocument(itemAttrs, options) {
|
|
829
|
+
const { url, alt, caption, role, href, target, id, preview, author, description } =
|
|
830
|
+
parseImgBlock(itemAttrs || {}, options);
|
|
831
|
+
return {
|
|
832
|
+
url,
|
|
833
|
+
alt,
|
|
834
|
+
caption,
|
|
835
|
+
role,
|
|
836
|
+
...(href && { href }),
|
|
837
|
+
...(target && { target }),
|
|
838
|
+
...(id && { id }),
|
|
839
|
+
...(preview && { preview }),
|
|
840
|
+
...(author && { author }),
|
|
841
|
+
...(description && { description }),
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// The editor's older `document` node — its `title`, `coverImg` and `src` (or
|
|
846
|
+
// asset identifier) — read into the same shape a markdown document has.
|
|
847
|
+
function documentFromEditorNode(itemAttrs) {
|
|
848
|
+
const {
|
|
849
|
+
href,
|
|
850
|
+
downloadUrl,
|
|
851
|
+
title = "",
|
|
852
|
+
alt = "",
|
|
853
|
+
coverImg,
|
|
854
|
+
author,
|
|
855
|
+
description,
|
|
856
|
+
fileType,
|
|
857
|
+
} = parseDocumentBlock(itemAttrs || {});
|
|
858
|
+
return {
|
|
859
|
+
url: href || downloadUrl || "",
|
|
860
|
+
alt: alt || title,
|
|
861
|
+
caption: title,
|
|
862
|
+
role: "pdf",
|
|
863
|
+
...(coverImg && { preview: coverImg }),
|
|
864
|
+
...(author && { author }),
|
|
865
|
+
...(description && { description }),
|
|
866
|
+
...(fileType && { fileType }),
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
|
|
805
870
|
function parseDocumentBlock(itemAttrs) {
|
|
806
871
|
const { src, info = {}, coverImg = null, ...others } = itemAttrs;
|
|
807
872
|
|