@uniweb/semantic-parser 1.1.20 → 1.2.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 +11 -2
- package/src/index.js +16 -0
- package/src/processors/groups.js +85 -6
- package/src/processors/sequence.js +207 -4
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
|
|
@@ -529,6 +606,24 @@ function processInlineElements(content) {
|
|
|
529
606
|
type: "icon",
|
|
530
607
|
attrs: parseUniwebIcon(item.attrs),
|
|
531
608
|
});
|
|
609
|
+
} else if (item.type === "image" || item.type === "ImageBlock") {
|
|
610
|
+
// A media node that stayed inside its paragraph — content-reader
|
|
611
|
+
// hoists a standalone one to the document root, but an image sitting
|
|
612
|
+
// beside text does not qualify (parser/block.js `isBlockEligible`).
|
|
613
|
+
//
|
|
614
|
+
// Without this branch such a node reached NOTHING: getTextContent
|
|
615
|
+
// skips it, so it was absent from the paragraph's `text`, absent
|
|
616
|
+
// from `children`, and absent from `body.images` — the author's
|
|
617
|
+
// image silently deleted. groups.js has carried the receiving
|
|
618
|
+
// branch (and a comment describing exactly this failure) since it
|
|
619
|
+
// was written, but nothing ever emitted the item it reads.
|
|
620
|
+
const isVideo = item.attrs?.role === "video";
|
|
621
|
+
items.push({
|
|
622
|
+
type: isVideo ? "video" : "image",
|
|
623
|
+
attrs: isVideo
|
|
624
|
+
? parseMarkdownVideo(item.attrs)
|
|
625
|
+
: parseImgBlock(item.attrs || {}),
|
|
626
|
+
});
|
|
532
627
|
} else if (item.type === "math-inline" || item.type === "math_inline") {
|
|
533
628
|
items.push(item);
|
|
534
629
|
} else if (item.type === "inset_placeholder") {
|
|
@@ -626,14 +721,23 @@ function parseDocumentBlock(itemAttrs) {
|
|
|
626
721
|
}
|
|
627
722
|
|
|
628
723
|
function parseUniwebIcon(itemAttrs) {
|
|
629
|
-
const { svg, url, size, color, preserveColors, href, target, library, name } = itemAttrs || {};
|
|
724
|
+
const { svg, url, src, size, color, preserveColors, href, target, library, name } = itemAttrs || {};
|
|
630
725
|
|
|
631
726
|
// Build object with only defined fields — icon source varies:
|
|
632
727
|
// TipTap editor: svg/url (resolved inline)
|
|
633
728
|
// Markdown pipeline: library + name (resolved at runtime via CDN)
|
|
729
|
+
// OR a file, which arrives as `src`
|
|
634
730
|
const icon = {};
|
|
635
731
|
if (svg) icon.svg = svg;
|
|
636
|
-
|
|
732
|
+
// Standard ProseMirror `image` nodes use `src`; the editor's `UniwebIcon`
|
|
733
|
+
// uses `url`. Honor either, exactly as parseImgBlock does below — a
|
|
734
|
+
// file-sourced icon is authored `` or
|
|
735
|
+
// `{role=icon}` (both documented in AGENTS.md and
|
|
736
|
+
// docs/reference/content-structure.md), and reaches here as `src` with no
|
|
737
|
+
// library/name. Until 2026-07-30 this reader dropped it, so every
|
|
738
|
+
// documented file-sourced icon delivered `{}` and rendered nothing.
|
|
739
|
+
// `url` is the key kit's <Icon> fetches from.
|
|
740
|
+
if (url || src) icon.url = url || src;
|
|
637
741
|
if (size) icon.size = size;
|
|
638
742
|
if (color) icon.color = color;
|
|
639
743
|
if (preserveColors) icon.preserveColors = preserveColors;
|
|
@@ -655,10 +759,27 @@ function parseUniwebIcon(itemAttrs) {
|
|
|
655
759
|
return icon;
|
|
656
760
|
}
|
|
657
761
|
|
|
762
|
+
// The editor's `Icon` node carries an inline SVG blob plus a theme. Return an
|
|
763
|
+
// ATTRS OBJECT, like every other parser in this file — not the bare `svg`
|
|
764
|
+
// string it used to return.
|
|
765
|
+
//
|
|
766
|
+
// The string form was a live bug, not just an inconsistency: kit renders a
|
|
767
|
+
// sequence icon with `<Icon {...element.attrs} />` (styled/Prose/index.jsx),
|
|
768
|
+
// and spreading a string yields indexed character props — `{0:'<', 1:'s', …}`
|
|
769
|
+
// — so the icon rendered as nothing and React saw a wall of unknown props. It
|
|
770
|
+
// failed silently because `attrs.svg` on a string is `undefined`, never an
|
|
771
|
+
// error.
|
|
772
|
+
//
|
|
773
|
+
// Keys are omitted when absent so a consumer can distinguish "not provided"
|
|
774
|
+
// from "empty", matching parseUniwebIcon's shape above.
|
|
658
775
|
function parseIconBlock(itemAttrs) {
|
|
659
|
-
|
|
776
|
+
const { svg, theme } = itemAttrs || {};
|
|
777
|
+
|
|
778
|
+
const icon = {};
|
|
779
|
+
if (svg) icon.svg = svg;
|
|
780
|
+
if (theme) icon.theme = theme;
|
|
660
781
|
|
|
661
|
-
return
|
|
782
|
+
return icon;
|
|
662
783
|
}
|
|
663
784
|
|
|
664
785
|
function parseImgBlock(itemAttrs) {
|
|
@@ -678,6 +799,24 @@ function parseImgBlock(itemAttrs) {
|
|
|
678
799
|
credit = "",
|
|
679
800
|
id, // {#fig-id} cross-reference label — preserved so Press
|
|
680
801
|
// adapters can emit \label{id} (LaTeX) / <id> (Typst).
|
|
802
|
+
// ── Markdown-authored attributes ────────────────────────────────
|
|
803
|
+
// This function was written for the EDITOR's `ImageBlock` node and
|
|
804
|
+
// later reused for the markdown `image` node (see `case "image"`), so
|
|
805
|
+
// its vocabulary is the editor's: contentType, direction, filter,
|
|
806
|
+
// theme, credit. Everything markdown declares and the editor does not
|
|
807
|
+
// was therefore tokenized, declared, and then dropped one layer before
|
|
808
|
+
// delivery — the published "Image Attributes" table
|
|
809
|
+
// (docs/reference/content-structure.md) reached no component, and
|
|
810
|
+
// neither did the `role=pdf` set added 2026-07-29 for the editor's
|
|
811
|
+
// `document` fold-in. Carried 2026-07-30.
|
|
812
|
+
width,
|
|
813
|
+
height,
|
|
814
|
+
loading,
|
|
815
|
+
fit,
|
|
816
|
+
position,
|
|
817
|
+
preview, // role=pdf — the preview image
|
|
818
|
+
author, // role=pdf — resource metadata, rendered beside the preview
|
|
819
|
+
description, // role=pdf — describes the RESOURCE, not the image (≠ alt)
|
|
681
820
|
} = itemAttrs;
|
|
682
821
|
|
|
683
822
|
let { contentType, viewType, contentId, identifier } = imgInfo || {};
|
|
@@ -712,6 +851,10 @@ function parseImgBlock(itemAttrs) {
|
|
|
712
851
|
direction,
|
|
713
852
|
filter,
|
|
714
853
|
imgPos: direction === "left" || direction === "right" ? direction : "",
|
|
854
|
+
// NOTE: `size` here is the direction-derived LAYOUT size
|
|
855
|
+
// (basic/lg/full), not the author's `{size=…}` — that attribute is
|
|
856
|
+
// icon-only and is read by parseUniwebIcon. Do not wire `{size=…}`
|
|
857
|
+
// through here; the two meanings collide on one key.
|
|
715
858
|
size: sizes[direction] || "basic",
|
|
716
859
|
href,
|
|
717
860
|
target,
|
|
@@ -719,7 +862,67 @@ function parseImgBlock(itemAttrs) {
|
|
|
719
862
|
role,
|
|
720
863
|
credit,
|
|
721
864
|
id: id || undefined,
|
|
865
|
+
// Omitted when absent, so an existing consumer sees no new empty keys.
|
|
866
|
+
...(width !== undefined && width !== null && { width }),
|
|
867
|
+
...(height !== undefined && height !== null && { height }),
|
|
868
|
+
...(loading && { loading }),
|
|
869
|
+
...(fit && { fit }),
|
|
870
|
+
...(position && { position }),
|
|
871
|
+
...(preview && { preview }),
|
|
872
|
+
...(author && { author }),
|
|
873
|
+
...(description && { description }),
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// A video authored in markdown: `{role=video …}`.
|
|
878
|
+
//
|
|
879
|
+
// Distinct from parseVideoBlock below, which reads the EDITOR's `Video` node
|
|
880
|
+
// (`coverImg`, `info`). The two surfaces name the same concepts differently and
|
|
881
|
+
// collapsing them would drop one side's spelling; this reader carries the
|
|
882
|
+
// attribute set that docs/reference/content-structure.md publishes for the
|
|
883
|
+
// video role, and the shape it documents: `{ src, href, target }` plus the
|
|
884
|
+
// playback flags.
|
|
885
|
+
function parseMarkdownVideo(itemAttrs) {
|
|
886
|
+
const {
|
|
887
|
+
src,
|
|
888
|
+
url,
|
|
889
|
+
info,
|
|
890
|
+
poster,
|
|
891
|
+
alt = "",
|
|
892
|
+
caption = "",
|
|
893
|
+
autoplay,
|
|
894
|
+
muted,
|
|
895
|
+
loop,
|
|
896
|
+
controls,
|
|
897
|
+
href = "",
|
|
898
|
+
target = "",
|
|
899
|
+
role,
|
|
900
|
+
id, // {#fig-id} cross-reference label, same as parseImgBlock
|
|
901
|
+
} = itemAttrs || {};
|
|
902
|
+
|
|
903
|
+
const video = {
|
|
904
|
+
// `identifier` (a CDN-resolvable asset id) wins when present, matching
|
|
905
|
+
// parseImgBlock — an editor-deployed asset with a stale src must not
|
|
906
|
+
// shadow the CDN copy.
|
|
907
|
+
src: makeAssetUrl({ src: url || src, ...info }),
|
|
908
|
+
alt,
|
|
909
|
+
caption: stripTags(caption),
|
|
910
|
+
role,
|
|
911
|
+
href,
|
|
912
|
+
target,
|
|
722
913
|
};
|
|
914
|
+
|
|
915
|
+
// Playback attributes are omitted rather than defaulted: a component can
|
|
916
|
+
// then tell "the author said nothing" from "the author said false", and
|
|
917
|
+
// kit's <Media> supplies its own defaults (controls on, the rest off).
|
|
918
|
+
if (poster) video.poster = poster;
|
|
919
|
+
if (autoplay !== undefined) video.autoplay = autoplay;
|
|
920
|
+
if (muted !== undefined) video.muted = muted;
|
|
921
|
+
if (loop !== undefined) video.loop = loop;
|
|
922
|
+
if (controls !== undefined) video.controls = controls;
|
|
923
|
+
if (id) video.id = id;
|
|
924
|
+
|
|
925
|
+
return video;
|
|
723
926
|
}
|
|
724
927
|
|
|
725
928
|
function parseVideoBlock(itemAttrs) {
|