@uniweb/semantic-parser 1.1.21 → 1.2.1
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 +11 -2
- package/src/index.js +16 -0
- package/src/processors/groups.js +85 -6
- package/src/processors/sequence.js +193 -2
package/package.json
CHANGED
package/src/builders/doc.js
CHANGED
|
@@ -56,7 +56,7 @@ function imageBlock({ src, alt = '', caption = '', direction, role, width, heigh
|
|
|
56
56
|
return { type: 'ImageBlock', attrs }
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
function iconNode({ src, svg, library, name, size, color }) {
|
|
59
|
+
function iconNode({ src, url, svg, library, name, size, color }) {
|
|
60
60
|
// UniwebIcon supports multiple source types.
|
|
61
61
|
//
|
|
62
62
|
// The family rides INSIDE `name` as `family:id`. The editor's node declares
|
|
@@ -64,8 +64,17 @@ function iconNode({ src, svg, library, name, size, color }) {
|
|
|
64
64
|
// and ProseMirror silently drops undeclared attrs on `fromJSON` — so emitting
|
|
65
65
|
// a separate `library` lost the family without a trace, on the path that
|
|
66
66
|
// builds starter content for every new section.
|
|
67
|
+
//
|
|
68
|
+
// `svg` is MARKUP and `url` is a URL — on both sides. A file-sourced icon
|
|
69
|
+
// arrives here as `src` and belongs in `url`, not in the markup slot; putting
|
|
70
|
+
// a path where a consumer expects `<svg …>` renders nothing. (Fixed
|
|
71
|
+
// 2026-07-30; `url` is declared on the editor's node, so it crosses intact.)
|
|
67
72
|
const attrs = {}
|
|
68
|
-
if (svg
|
|
73
|
+
if (svg) attrs.svg = svg
|
|
74
|
+
// Either spelling in, one spelling out — `parseUniwebIcon` now emits `url`
|
|
75
|
+
// for a file-sourced icon, and this builder's documented invariant is that
|
|
76
|
+
// parseContent(buildDoc(x)) round-trips.
|
|
77
|
+
if (url || src) attrs.url = url || src
|
|
69
78
|
if (name) attrs.name = library ? `${library}:${name}` : name
|
|
70
79
|
if (size) attrs.size = size
|
|
71
80
|
if (color) attrs.color = color
|
package/src/index.js
CHANGED
|
@@ -4,9 +4,25 @@ import { buildDoc } from "./builders/doc.js";
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Parse ProseMirror/TipTap content into semantic structure
|
|
7
|
+
*
|
|
8
|
+
* This is the PUBLIC entry point for deriving the flat shape, and every
|
|
9
|
+
* consumer calls it: the runtime derives a block's content this way, and an
|
|
10
|
+
* editor drawing a surface over the same document derives it the same way. That
|
|
11
|
+
* both sides run one implementation is what makes their readings agree — so a
|
|
12
|
+
* derivation rule must be reachable from here rather than applied privately by
|
|
13
|
+
* whoever produced the ProseMirror.
|
|
14
|
+
*
|
|
15
|
+
* Nothing derived is stored. This runs JIT on each side, every time, which is
|
|
16
|
+
* also why changing a grouping rule re-reads every document that already
|
|
17
|
+
* exists — see `alwaysItems` below.
|
|
18
|
+
*
|
|
7
19
|
* @param {Object} doc - ProseMirror document
|
|
8
20
|
* @param {Object} options - Parsing options
|
|
9
21
|
* @param {boolean} options.parseCodeAsJson - Parse code blocks as JSON. Default: false
|
|
22
|
+
* @param {boolean} options.alwaysItems - Every heading group becomes an item:
|
|
23
|
+
* no title promotion, no same-level heading merge. The shape a tagged concept
|
|
24
|
+
* block (```md:faq) always takes, fixed by its fence. Default: false, and the
|
|
25
|
+
* default path is pinned by tests/grouping-default-path.test.js.
|
|
10
26
|
* @returns {Object} Flat content structure with sequence for ordered access
|
|
11
27
|
*/
|
|
12
28
|
function parseContent(doc, options = {}) {
|
package/src/processors/groups.js
CHANGED
|
@@ -25,13 +25,33 @@ function flattenGroup(group) {
|
|
|
25
25
|
// on every page (math is <1% of content in practice; authors who need
|
|
26
26
|
// ordering should use content.sequence anyway).
|
|
27
27
|
if (group.body.math && group.body.math.length) flat.math = group.body.math;
|
|
28
|
+
if (group.body.tables && group.body.tables.length) flat.tables = group.body.tables;
|
|
28
29
|
return flat;
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
/**
|
|
32
33
|
* Transform a sequence into content groups with semantic structure
|
|
34
|
+
*
|
|
35
|
+
* `options.alwaysItems` makes every group an ITEM: it turns off the two rules
|
|
36
|
+
* that otherwise collapse a short document into a titled main block —
|
|
37
|
+
* title promotion (`identifyMainContent`) and same-level heading merge
|
|
38
|
+
* (`readHeadingGroup`'s Case 3). Used for a tagged concept block, whose shape is
|
|
39
|
+
* fixed by its fence rather than by what it happens to contain.
|
|
40
|
+
*
|
|
41
|
+
* It names a GROUPING BEHAVIOUR, not a concept. Nothing here knows what `faq` or
|
|
42
|
+
* `warning` mean, and nothing should: a framework-side registry of concept names
|
|
43
|
+
* is the failure the whole design avoids.
|
|
44
|
+
*
|
|
45
|
+
* DEFAULT OFF, and that matters more than it looks. This parser runs JIT — at
|
|
46
|
+
* render time and at editor time — and nothing derived is ever stored, so a
|
|
47
|
+
* change to a grouping rule retroactively re-reads every document that already
|
|
48
|
+
* exists. Leaking these suppressions into the default path would silently
|
|
49
|
+
* reshape every section body ever authored. `tests/grouping-default-path.test.js`
|
|
50
|
+
* pins the default against that.
|
|
51
|
+
*
|
|
33
52
|
* @param {Array} sequence Flat sequence of elements
|
|
34
53
|
* @param {Object} options Parsing options
|
|
54
|
+
* @param {boolean} [options.alwaysItems] Every group is an item; no main block
|
|
35
55
|
* @returns {Object} Flat content object with items array
|
|
36
56
|
*/
|
|
37
57
|
function processGroups(sequence, options = {}) {
|
|
@@ -56,7 +76,7 @@ function processGroups(sequence, options = {}) {
|
|
|
56
76
|
};
|
|
57
77
|
}
|
|
58
78
|
|
|
59
|
-
const groups = splitBySlices(sequence);
|
|
79
|
+
const groups = splitBySlices(sequence, options);
|
|
60
80
|
|
|
61
81
|
// Process each group's structure (still nested internally)
|
|
62
82
|
const processedGroups = groups.map((group) => processGroupContent(group));
|
|
@@ -65,7 +85,12 @@ function processGroups(sequence, options = {}) {
|
|
|
65
85
|
let mainGroup = null;
|
|
66
86
|
let itemGroups = [];
|
|
67
87
|
|
|
68
|
-
|
|
88
|
+
// Suppression 1 of 2: title promotion. Without it a lone heading+body pair
|
|
89
|
+
// is the whole "main" block and yields NO items — the single-question FAQ,
|
|
90
|
+
// and every callout, would arrive empty.
|
|
91
|
+
const shouldBeMain = options.alwaysItems
|
|
92
|
+
? false
|
|
93
|
+
: identifyMainContent(processedGroups);
|
|
69
94
|
if (shouldBeMain) {
|
|
70
95
|
mainGroup = processedGroups[0];
|
|
71
96
|
itemGroups = processedGroups.slice(1);
|
|
@@ -99,7 +124,7 @@ function processGroups(sequence, options = {}) {
|
|
|
99
124
|
};
|
|
100
125
|
}
|
|
101
126
|
|
|
102
|
-
function splitBySlices(sequence) {
|
|
127
|
+
function splitBySlices(sequence, options = {}) {
|
|
103
128
|
const groups = [];
|
|
104
129
|
let currentGroup = [];
|
|
105
130
|
|
|
@@ -132,7 +157,7 @@ function splitBySlices(sequence) {
|
|
|
132
157
|
|
|
133
158
|
// Consume the entire semantic heading block (Title + Subtitles)
|
|
134
159
|
// We reuse your smart readHeadingGroup logic here!
|
|
135
|
-
const headingBlock = readHeadingGroup(sequence, i);
|
|
160
|
+
const headingBlock = readHeadingGroup(sequence, i, options);
|
|
136
161
|
currentGroup.push(...headingBlock);
|
|
137
162
|
|
|
138
163
|
// Advance the index by the number of headings consumed
|
|
@@ -175,7 +200,7 @@ function isBannerImage(sequence, i) {
|
|
|
175
200
|
);
|
|
176
201
|
}
|
|
177
202
|
|
|
178
|
-
function readHeadingGroup(sequence, startIdx) {
|
|
203
|
+
function readHeadingGroup(sequence, startIdx, options = {}) {
|
|
179
204
|
const elements = [sequence[startIdx]];
|
|
180
205
|
let hasGoneDeeper = false;
|
|
181
206
|
|
|
@@ -211,7 +236,18 @@ function readHeadingGroup(sequence, startIdx) {
|
|
|
211
236
|
// same-level headings are new sections, not continuations.
|
|
212
237
|
// e.g. H1 -> H1 → merged into title array
|
|
213
238
|
// but H1 -> H2 -> H2 → second H2 starts a new group (items)
|
|
214
|
-
|
|
239
|
+
//
|
|
240
|
+
// Suppression 2 of 2 (`alwaysItems`): in a tagged concept block two
|
|
241
|
+
// same-level headings are two ITEMS, not one heading split across
|
|
242
|
+
// visual lines. Merging them is why bodiless headings collapse into a
|
|
243
|
+
// single group carrying a title ARRAY — and no amount of suppressing
|
|
244
|
+
// title promotion downstream can split what was never split here, which
|
|
245
|
+
// is what makes this the second half of a fix that looks like one line.
|
|
246
|
+
if (
|
|
247
|
+
element.level === previousElement.level &&
|
|
248
|
+
!hasGoneDeeper &&
|
|
249
|
+
!options.alwaysItems
|
|
250
|
+
) {
|
|
215
251
|
elements.push(element);
|
|
216
252
|
continue;
|
|
217
253
|
}
|
|
@@ -366,6 +402,32 @@ function processGroupContent(elements) {
|
|
|
366
402
|
body.quotes.push(quoteContent.body);
|
|
367
403
|
break;
|
|
368
404
|
|
|
405
|
+
case "concept_block": {
|
|
406
|
+
// A tagged prose fence lands under its tag, sharing the
|
|
407
|
+
// namespace tagged DATA blocks use (```yaml:nav also lands
|
|
408
|
+
// at data[nav]) — one place a component looks, whichever
|
|
409
|
+
// form the author reached for.
|
|
410
|
+
//
|
|
411
|
+
// Both views, because two consumers need different ones and
|
|
412
|
+
// `parseContent` computes them in one pass anyway. `items`
|
|
413
|
+
// is what an accordion or a step list renders. `sequence` is
|
|
414
|
+
// what anything rendering a concept it does not RECOGNIZE
|
|
415
|
+
// has to use: `items` is a bucketed flattening that discards
|
|
416
|
+
// ordering, and it collapses to [] exactly where `sequence`
|
|
417
|
+
// survives — two bodiless headings give items 0, sequence 2.
|
|
418
|
+
//
|
|
419
|
+
// `alwaysItems` is hard-coded rather than inherited, and
|
|
420
|
+
// that is the scoping: a concept block's shape is fixed by
|
|
421
|
+
// its fence no matter how the surrounding document was
|
|
422
|
+
// parsed, and the suppressions never reach the default path.
|
|
423
|
+
const children = element.children || [];
|
|
424
|
+
body.data[element.tag] = {
|
|
425
|
+
items: processGroups(children, { alwaysItems: true }).items,
|
|
426
|
+
sequence: children,
|
|
427
|
+
};
|
|
428
|
+
break;
|
|
429
|
+
}
|
|
430
|
+
|
|
369
431
|
case "dataBlock":
|
|
370
432
|
// Pre-parsed structured data from content-reader
|
|
371
433
|
body.data[element.tag] = element.data;
|
|
@@ -400,6 +462,18 @@ function processGroupContent(elements) {
|
|
|
400
462
|
});
|
|
401
463
|
break;
|
|
402
464
|
|
|
465
|
+
case "table":
|
|
466
|
+
// Lazily attached, like `math`: most content has no table,
|
|
467
|
+
// and a component that cares about ordering should read
|
|
468
|
+
// `content.sequence` anyway. Before 2026-07-30 the sequence
|
|
469
|
+
// did not carry tables at all, so there was nothing here to
|
|
470
|
+
// collect.
|
|
471
|
+
(body.tables ||= []).push({
|
|
472
|
+
rows: element.rows || [],
|
|
473
|
+
attrs: element.attrs || {},
|
|
474
|
+
});
|
|
475
|
+
break;
|
|
476
|
+
|
|
403
477
|
case "inset":
|
|
404
478
|
body.insets.push({ refId: element.refId });
|
|
405
479
|
break;
|
|
@@ -492,6 +566,11 @@ function processInlineElements(children, body) {
|
|
|
492
566
|
} else {
|
|
493
567
|
body.images.push(item.attrs);
|
|
494
568
|
}
|
|
569
|
+
} else if (item.type === "video") {
|
|
570
|
+
// Mirrors the top-level `case "video"` partition — an inline
|
|
571
|
+
// `{role=video}` belongs in videos[] for the same reason a hoisted
|
|
572
|
+
// one does.
|
|
573
|
+
body.videos.push(item.attrs);
|
|
495
574
|
} else if (item.type === "link") {
|
|
496
575
|
// Handle inline links extracted from paragraph text nodes
|
|
497
576
|
body.links.push(item.attrs);
|
|
@@ -168,6 +168,26 @@ function createSequenceElement(node, options = {}) {
|
|
|
168
168
|
};
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
case "concept_block": {
|
|
172
|
+
// A tagged prose fence (```md:faq). Its body is real block content,
|
|
173
|
+
// recursed like a container's — the default branch would flatten it
|
|
174
|
+
// to a text string and lose the author's prose.
|
|
175
|
+
//
|
|
176
|
+
// No markdown is parsed here and none may ever be: this package
|
|
177
|
+
// depends on `yaml` alone, and reaching for a markdown parser would
|
|
178
|
+
// put content-reader inside the runtime. The reader already built
|
|
179
|
+
// this node; the job here is only to WALK it.
|
|
180
|
+
//
|
|
181
|
+
// `options` rides along, unlike the `inset_block` and `blockquote`
|
|
182
|
+
// cases above, so nested content is sequenced under the same
|
|
183
|
+
// options as its parent.
|
|
184
|
+
return {
|
|
185
|
+
type: "concept_block",
|
|
186
|
+
tag: attrs?.tag,
|
|
187
|
+
children: processSequence({ content }, options),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
171
191
|
case "dataBlock":
|
|
172
192
|
// Pre-parsed structured data from content-reader
|
|
173
193
|
return {
|
|
@@ -225,6 +245,24 @@ function createSequenceElement(node, options = {}) {
|
|
|
225
245
|
attrs: parseImgBlock(attrs),
|
|
226
246
|
};
|
|
227
247
|
case "image":
|
|
248
|
+
// `role` decides which content array this lands in — the contract
|
|
249
|
+
// published in docs/reference/content-structure.md ("Media Assets"):
|
|
250
|
+
// icon → icons, video → videos, everything else → images.
|
|
251
|
+
//
|
|
252
|
+
// The video branch was missing until 2026-07-30: every
|
|
253
|
+
// `{role=video}` node ran parseImgBlock, which does not carry
|
|
254
|
+
// poster/autoplay/muted/loop/controls, so the author's declaration
|
|
255
|
+
// was demoted to an image AND its playback attrs were dropped —
|
|
256
|
+
// while the build had already generated a poster for it
|
|
257
|
+
// (@uniweb/build site/assets.js). Note this does NOT route to
|
|
258
|
+
// parseVideoBlock: that reader speaks the editor's dialect
|
|
259
|
+
// (coverImg, info), not the markdown one.
|
|
260
|
+
if (attrs?.role === "video") {
|
|
261
|
+
return {
|
|
262
|
+
type: "video",
|
|
263
|
+
attrs: parseMarkdownVideo(attrs),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
228
266
|
// Standard ProseMirror image node - resolve attrs the same way as
|
|
229
267
|
// ImageBlock so info.identifier lands as a CDN URL. CLI-deployed
|
|
230
268
|
// content uses this node type; without parseImgBlock the url
|
|
@@ -258,10 +296,49 @@ function createSequenceElement(node, options = {}) {
|
|
|
258
296
|
};
|
|
259
297
|
}
|
|
260
298
|
|
|
299
|
+
case "table": {
|
|
300
|
+
// Rows of cells, each cell a nested SEQUENCE — the same recursion a
|
|
301
|
+
// blockquote or a list item gets, so a cell may hold anything a
|
|
302
|
+
// block can.
|
|
303
|
+
//
|
|
304
|
+
// Added 2026-07-30. Before this a table fell to the `default:`
|
|
305
|
+
// branch and became `{ type: 'table', content: '' }` — not merely
|
|
306
|
+
// unrendered but DESTROYED: `getTextContent` walks for text nodes
|
|
307
|
+
// and a table's children are rows, so even the cell text came back
|
|
308
|
+
// empty. Every consumer of `content.sequence` therefore lost tables
|
|
309
|
+
// entirely, which read as "the renderer has no table case" and was
|
|
310
|
+
// really the vocabulary not carrying one.
|
|
311
|
+
const rows = (content || [])
|
|
312
|
+
.filter((row) => row.type === "tableRow")
|
|
313
|
+
.map((row) => ({
|
|
314
|
+
cells: (row.content || [])
|
|
315
|
+
.filter((cell) => cell.type === "tableCell")
|
|
316
|
+
.map((cell) => ({
|
|
317
|
+
children: processSequence({ content: cell.content }, options),
|
|
318
|
+
header: cell.attrs?.header === true,
|
|
319
|
+
align: cell.attrs?.align || null,
|
|
320
|
+
colspan: cell.attrs?.colspan || 1,
|
|
321
|
+
rowspan: cell.attrs?.rowspan || 1,
|
|
322
|
+
})),
|
|
323
|
+
}));
|
|
324
|
+
|
|
325
|
+
return { type: "table", rows, attrs };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// `divider` is what content-reader emits; `horizontalRule` and
|
|
329
|
+
// `DividerBlock` are the editor's spellings. All three are one element.
|
|
330
|
+
//
|
|
331
|
+
// The reader's own name was NOT listed here until 2026-07-30 — markdown
|
|
332
|
+
// dividers reached consumers only by falling through `default:`, which
|
|
333
|
+
// happens to produce `{ type: 'divider', content: '' }` and happens to
|
|
334
|
+
// match what everything downstream keys on. It worked by coincidence,
|
|
335
|
+
// and the coincidence would have broken the moment `default:` changed.
|
|
336
|
+
case "divider":
|
|
261
337
|
case "DividerBlock":
|
|
262
338
|
case "horizontalRule":
|
|
263
339
|
return {
|
|
264
340
|
type: "divider",
|
|
341
|
+
attrs,
|
|
265
342
|
};
|
|
266
343
|
|
|
267
344
|
// Custom TipTap elements
|
|
@@ -283,6 +360,11 @@ function createSequenceElement(node, options = {}) {
|
|
|
283
360
|
.map((doc) => parseDocumentBlock(doc.attrs)) || [],
|
|
284
361
|
};
|
|
285
362
|
|
|
363
|
+
// The editor renamed this node to `StructuredContent` (2026-07-31,
|
|
364
|
+
// editor-internal). Both are accepted: the new name is what the editor
|
|
365
|
+
// emits now, and the old one still arrives from documents authored
|
|
366
|
+
// before the rename, which the framework never rewrites.
|
|
367
|
+
case "StructuredContent":
|
|
286
368
|
case "FormBlock":
|
|
287
369
|
// Parse form data (can be JSON string or object)
|
|
288
370
|
let formData = attrs?.data;
|
|
@@ -529,6 +611,24 @@ function processInlineElements(content) {
|
|
|
529
611
|
type: "icon",
|
|
530
612
|
attrs: parseUniwebIcon(item.attrs),
|
|
531
613
|
});
|
|
614
|
+
} else if (item.type === "image" || item.type === "ImageBlock") {
|
|
615
|
+
// A media node that stayed inside its paragraph — content-reader
|
|
616
|
+
// hoists a standalone one to the document root, but an image sitting
|
|
617
|
+
// beside text does not qualify (parser/block.js `isBlockEligible`).
|
|
618
|
+
//
|
|
619
|
+
// Without this branch such a node reached NOTHING: getTextContent
|
|
620
|
+
// skips it, so it was absent from the paragraph's `text`, absent
|
|
621
|
+
// from `children`, and absent from `body.images` — the author's
|
|
622
|
+
// image silently deleted. groups.js has carried the receiving
|
|
623
|
+
// branch (and a comment describing exactly this failure) since it
|
|
624
|
+
// was written, but nothing ever emitted the item it reads.
|
|
625
|
+
const isVideo = item.attrs?.role === "video";
|
|
626
|
+
items.push({
|
|
627
|
+
type: isVideo ? "video" : "image",
|
|
628
|
+
attrs: isVideo
|
|
629
|
+
? parseMarkdownVideo(item.attrs)
|
|
630
|
+
: parseImgBlock(item.attrs || {}),
|
|
631
|
+
});
|
|
532
632
|
} else if (item.type === "math-inline" || item.type === "math_inline") {
|
|
533
633
|
items.push(item);
|
|
534
634
|
} else if (item.type === "inset_placeholder") {
|
|
@@ -626,14 +726,23 @@ function parseDocumentBlock(itemAttrs) {
|
|
|
626
726
|
}
|
|
627
727
|
|
|
628
728
|
function parseUniwebIcon(itemAttrs) {
|
|
629
|
-
const { svg, url, size, color, preserveColors, href, target, library, name } = itemAttrs || {};
|
|
729
|
+
const { svg, url, src, size, color, preserveColors, href, target, library, name } = itemAttrs || {};
|
|
630
730
|
|
|
631
731
|
// Build object with only defined fields — icon source varies:
|
|
632
732
|
// TipTap editor: svg/url (resolved inline)
|
|
633
733
|
// Markdown pipeline: library + name (resolved at runtime via CDN)
|
|
734
|
+
// OR a file, which arrives as `src`
|
|
634
735
|
const icon = {};
|
|
635
736
|
if (svg) icon.svg = svg;
|
|
636
|
-
|
|
737
|
+
// Standard ProseMirror `image` nodes use `src`; the editor's `UniwebIcon`
|
|
738
|
+
// uses `url`. Honor either, exactly as parseImgBlock does below — a
|
|
739
|
+
// file-sourced icon is authored `` or
|
|
740
|
+
// `{role=icon}` (both documented in AGENTS.md and
|
|
741
|
+
// docs/reference/content-structure.md), and reaches here as `src` with no
|
|
742
|
+
// library/name. Until 2026-07-30 this reader dropped it, so every
|
|
743
|
+
// documented file-sourced icon delivered `{}` and rendered nothing.
|
|
744
|
+
// `url` is the key kit's <Icon> fetches from.
|
|
745
|
+
if (url || src) icon.url = url || src;
|
|
637
746
|
if (size) icon.size = size;
|
|
638
747
|
if (color) icon.color = color;
|
|
639
748
|
if (preserveColors) icon.preserveColors = preserveColors;
|
|
@@ -695,6 +804,24 @@ function parseImgBlock(itemAttrs) {
|
|
|
695
804
|
credit = "",
|
|
696
805
|
id, // {#fig-id} cross-reference label — preserved so Press
|
|
697
806
|
// adapters can emit \label{id} (LaTeX) / <id> (Typst).
|
|
807
|
+
// ── Markdown-authored attributes ────────────────────────────────
|
|
808
|
+
// This function was written for the EDITOR's `ImageBlock` node and
|
|
809
|
+
// later reused for the markdown `image` node (see `case "image"`), so
|
|
810
|
+
// its vocabulary is the editor's: contentType, direction, filter,
|
|
811
|
+
// theme, credit. Everything markdown declares and the editor does not
|
|
812
|
+
// was therefore tokenized, declared, and then dropped one layer before
|
|
813
|
+
// delivery — the published "Image Attributes" table
|
|
814
|
+
// (docs/reference/content-structure.md) reached no component, and
|
|
815
|
+
// neither did the `role=pdf` set added 2026-07-29 for the editor's
|
|
816
|
+
// `document` fold-in. Carried 2026-07-30.
|
|
817
|
+
width,
|
|
818
|
+
height,
|
|
819
|
+
loading,
|
|
820
|
+
fit,
|
|
821
|
+
position,
|
|
822
|
+
preview, // role=pdf — the preview image
|
|
823
|
+
author, // role=pdf — resource metadata, rendered beside the preview
|
|
824
|
+
description, // role=pdf — describes the RESOURCE, not the image (≠ alt)
|
|
698
825
|
} = itemAttrs;
|
|
699
826
|
|
|
700
827
|
let { contentType, viewType, contentId, identifier } = imgInfo || {};
|
|
@@ -729,6 +856,10 @@ function parseImgBlock(itemAttrs) {
|
|
|
729
856
|
direction,
|
|
730
857
|
filter,
|
|
731
858
|
imgPos: direction === "left" || direction === "right" ? direction : "",
|
|
859
|
+
// NOTE: `size` here is the direction-derived LAYOUT size
|
|
860
|
+
// (basic/lg/full), not the author's `{size=…}` — that attribute is
|
|
861
|
+
// icon-only and is read by parseUniwebIcon. Do not wire `{size=…}`
|
|
862
|
+
// through here; the two meanings collide on one key.
|
|
732
863
|
size: sizes[direction] || "basic",
|
|
733
864
|
href,
|
|
734
865
|
target,
|
|
@@ -736,9 +867,69 @@ function parseImgBlock(itemAttrs) {
|
|
|
736
867
|
role,
|
|
737
868
|
credit,
|
|
738
869
|
id: id || undefined,
|
|
870
|
+
// Omitted when absent, so an existing consumer sees no new empty keys.
|
|
871
|
+
...(width !== undefined && width !== null && { width }),
|
|
872
|
+
...(height !== undefined && height !== null && { height }),
|
|
873
|
+
...(loading && { loading }),
|
|
874
|
+
...(fit && { fit }),
|
|
875
|
+
...(position && { position }),
|
|
876
|
+
...(preview && { preview }),
|
|
877
|
+
...(author && { author }),
|
|
878
|
+
...(description && { description }),
|
|
739
879
|
};
|
|
740
880
|
}
|
|
741
881
|
|
|
882
|
+
// A video authored in markdown: `{role=video …}`.
|
|
883
|
+
//
|
|
884
|
+
// Distinct from parseVideoBlock below, which reads the EDITOR's `Video` node
|
|
885
|
+
// (`coverImg`, `info`). The two surfaces name the same concepts differently and
|
|
886
|
+
// collapsing them would drop one side's spelling; this reader carries the
|
|
887
|
+
// attribute set that docs/reference/content-structure.md publishes for the
|
|
888
|
+
// video role, and the shape it documents: `{ src, href, target }` plus the
|
|
889
|
+
// playback flags.
|
|
890
|
+
function parseMarkdownVideo(itemAttrs) {
|
|
891
|
+
const {
|
|
892
|
+
src,
|
|
893
|
+
url,
|
|
894
|
+
info,
|
|
895
|
+
poster,
|
|
896
|
+
alt = "",
|
|
897
|
+
caption = "",
|
|
898
|
+
autoplay,
|
|
899
|
+
muted,
|
|
900
|
+
loop,
|
|
901
|
+
controls,
|
|
902
|
+
href = "",
|
|
903
|
+
target = "",
|
|
904
|
+
role,
|
|
905
|
+
id, // {#fig-id} cross-reference label, same as parseImgBlock
|
|
906
|
+
} = itemAttrs || {};
|
|
907
|
+
|
|
908
|
+
const video = {
|
|
909
|
+
// `identifier` (a CDN-resolvable asset id) wins when present, matching
|
|
910
|
+
// parseImgBlock — an editor-deployed asset with a stale src must not
|
|
911
|
+
// shadow the CDN copy.
|
|
912
|
+
src: makeAssetUrl({ src: url || src, ...info }),
|
|
913
|
+
alt,
|
|
914
|
+
caption: stripTags(caption),
|
|
915
|
+
role,
|
|
916
|
+
href,
|
|
917
|
+
target,
|
|
918
|
+
};
|
|
919
|
+
|
|
920
|
+
// Playback attributes are omitted rather than defaulted: a component can
|
|
921
|
+
// then tell "the author said nothing" from "the author said false", and
|
|
922
|
+
// kit's <Media> supplies its own defaults (controls on, the rest off).
|
|
923
|
+
if (poster) video.poster = poster;
|
|
924
|
+
if (autoplay !== undefined) video.autoplay = autoplay;
|
|
925
|
+
if (muted !== undefined) video.muted = muted;
|
|
926
|
+
if (loop !== undefined) video.loop = loop;
|
|
927
|
+
if (controls !== undefined) video.controls = controls;
|
|
928
|
+
if (id) video.id = id;
|
|
929
|
+
|
|
930
|
+
return video;
|
|
931
|
+
}
|
|
932
|
+
|
|
742
933
|
function parseVideoBlock(itemAttrs) {
|
|
743
934
|
let {
|
|
744
935
|
src,
|