@uniweb/semantic-parser 1.8.1 → 1.10.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 +1 -1
- package/README.md +18 -0
- package/package.json +1 -1
- package/src/builders/doc.js +5 -7
- package/src/processors/groups.js +4 -39
- package/src/processors/sequence.js +93 -373
package/AGENTS.md
CHANGED
|
@@ -165,7 +165,7 @@ Key patterns — the staircase rule (each heading relates to the one before it):
|
|
|
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.
|
|
168
|
-
- **Dividers**: a `
|
|
168
|
+
- **Dividers**: a `divider` (`---` in markdown) explicitly closes the current group; it composes with the heading rules rather than replacing them.
|
|
169
169
|
- **Main Content**: the first group is main if it's the only group, has a more important heading level than the second group, or has no heading at all.
|
|
170
170
|
- **Body Headings**: `body.headings` fills only from nested content (blockquote children, list items) — a section's headline never spills there.
|
|
171
171
|
|
package/README.md
CHANGED
|
@@ -280,6 +280,24 @@ or `[#id]{k=v}` references. Two artifacts ship to consumers:
|
|
|
280
280
|
The two views stay in lockstep — both record every inline inset the
|
|
281
281
|
parser sees, in source order.
|
|
282
282
|
|
|
283
|
+
### Inline Icons and Pictures
|
|
284
|
+
|
|
285
|
+
An icon or a picture beside text stays in its paragraph, and it is carried
|
|
286
|
+
the same two ways:
|
|
287
|
+
|
|
288
|
+
1. **`children` carries an `icon` or an `image` entry** — the icon's library
|
|
289
|
+
and name, the picture's resolved `url` and its `alt`.
|
|
290
|
+
|
|
291
|
+
2. **`text` carries a marker at its position** —
|
|
292
|
+
`<uniweb-icon data-index="N"></uniweb-icon>` or
|
|
293
|
+
`<uniweb-image data-index="N"></uniweb-image>`, where `N` is its ordinal
|
|
294
|
+
among the paragraph's icons, or among its pictures
|
|
295
|
+
(`children.filter(c => c.type === 'image')[N]`).
|
|
296
|
+
|
|
297
|
+
A renderer that reads `text` as HTML and does not know a marker draws
|
|
298
|
+
nothing for it, as for any empty element it does not know. kit's `<Render>`
|
|
299
|
+
draws an icon with `<Icon>` and a picture with `<Image>`.
|
|
300
|
+
|
|
283
301
|
## Use Cases
|
|
284
302
|
|
|
285
303
|
- **Component-based websites**: Extract structured data for React/Vue components
|
package/package.json
CHANGED
package/src/builders/doc.js
CHANGED
|
@@ -23,8 +23,9 @@
|
|
|
23
23
|
* This builder was written with its own spelling for several concepts, and
|
|
24
24
|
* `parseContent` emits a DIFFERENT one for the same thing: a link's text is
|
|
25
25
|
* `text` here and `label` there; an image's address is `src` here and `url`
|
|
26
|
-
* there; a video's cover is a string here and `{ src | url | identifier }`
|
|
27
26
|
* there; a list item is a plain string here and a whole content GROUP there.
|
|
27
|
+
* (A video's cover was a fourth — an editor's `coverImg` — until the parser
|
|
28
|
+
* stopped reading the editor's `Video`, 2026-10-18; both sides say `poster`.)
|
|
28
29
|
*
|
|
29
30
|
* ⛔ EVERY ONE OF THOSE MISMATCHES FAILED SILENTLY, which is why the rule is
|
|
30
31
|
* now written down. Measured 2026-09-16, all four on the shapes a caller would
|
|
@@ -180,16 +181,13 @@ function iconNode({ src, url, library, name, size, color }) {
|
|
|
180
181
|
const PLAYBACK_FLAGS = ['autoplay', 'muted', 'loop', 'controls']
|
|
181
182
|
|
|
182
183
|
function videoNode(video) {
|
|
183
|
-
const { src, url, alt, caption, placement, poster
|
|
184
|
+
const { src, url, alt, caption, placement, poster } = video
|
|
184
185
|
const attrs = { src: src || url, role: 'video' }
|
|
185
186
|
if (alt) attrs.alt = alt
|
|
186
187
|
if (caption) attrs.caption = caption
|
|
187
188
|
// The cover is `poster`, what the parser emits for a video and what a
|
|
188
|
-
// component reads.
|
|
189
|
-
|
|
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
|
|
189
|
+
// component reads. ⛔ Until 2026-10-18 an editor's `coverImg` was taken too.
|
|
190
|
+
if (poster) attrs.poster = poster
|
|
193
191
|
if (placement) attrs.placement = placement
|
|
194
192
|
for (const flag of PLAYBACK_FLAGS) if (typeof video[flag] === 'boolean') attrs[flag] = video[flag]
|
|
195
193
|
return { type: 'image', attrs }
|
package/src/processors/groups.js
CHANGED
|
@@ -417,20 +417,6 @@ function processGroupContent(elements, options = {}) {
|
|
|
417
417
|
body.icons.push(element.attrs);
|
|
418
418
|
break;
|
|
419
419
|
|
|
420
|
-
case "button":
|
|
421
|
-
// Map button to link with role
|
|
422
|
-
body.links.push({
|
|
423
|
-
href: element.attrs?.href || '',
|
|
424
|
-
label: element.text || '',
|
|
425
|
-
role: element.attrs?.variant ? `button-${element.attrs.variant}` : 'button',
|
|
426
|
-
variant: element.attrs?.variant || 'primary',
|
|
427
|
-
size: element.attrs?.size,
|
|
428
|
-
icon: element.attrs?.icon,
|
|
429
|
-
target: element.attrs?.target,
|
|
430
|
-
class: element.attrs?.class,
|
|
431
|
-
});
|
|
432
|
-
break;
|
|
433
|
-
|
|
434
420
|
case "blockquote":
|
|
435
421
|
// Process blockquote content recursively
|
|
436
422
|
const quoteContent = processGroupContent(element.children);
|
|
@@ -514,31 +500,10 @@ function processGroupContent(elements, options = {}) {
|
|
|
514
500
|
body.media.push({ refId: element.refId, kind: "inset" });
|
|
515
501
|
break;
|
|
516
502
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
// schemaId is present (legacy content), fall back to the
|
|
522
|
-
// literal key "form" — eventual deprecation.
|
|
523
|
-
{
|
|
524
|
-
const payload = element.data || element.attrs;
|
|
525
|
-
const key = element.schemaId || "form";
|
|
526
|
-
body.data[key] = payload;
|
|
527
|
-
}
|
|
528
|
-
break;
|
|
529
|
-
|
|
530
|
-
case "card-group":
|
|
531
|
-
// Map cards to data by type: data.person = [...], data.event = [...]
|
|
532
|
-
// Each card type becomes a key, with an array of cards of that type
|
|
533
|
-
(element.cards || []).forEach(card => {
|
|
534
|
-
const cardType = card.cardType || 'card';
|
|
535
|
-
if (!body.data[cardType]) body.data[cardType] = [];
|
|
536
|
-
// Remove cardType from the card object since it's now the key
|
|
537
|
-
const { cardType: _, ...cardData } = card;
|
|
538
|
-
body.data[cardType].push(cardData);
|
|
539
|
-
});
|
|
540
|
-
break;
|
|
541
|
-
|
|
503
|
+
// ⛔ A `form` element was routed here until 2026-10-18 — the
|
|
504
|
+
// editor's form node, its data at `data[<schema id>]`, else at
|
|
505
|
+
// `data.form`. The node is read nowhere now: a form's data is a
|
|
506
|
+
// tagged data block, which lands at `data[<tag>]` above.
|
|
542
507
|
}
|
|
543
508
|
}
|
|
544
509
|
}
|
|
@@ -3,7 +3,6 @@ import {
|
|
|
3
3
|
escapeText,
|
|
4
4
|
escapeAttribute as escapeAttr,
|
|
5
5
|
sanitizeMathML,
|
|
6
|
-
sanitizeSVG,
|
|
7
6
|
isSafeLink,
|
|
8
7
|
isLinkTarget,
|
|
9
8
|
isStyleName,
|
|
@@ -18,12 +17,20 @@ import {
|
|
|
18
17
|
// rule that a text node held HTML — so `<img src=x onerror=…>` written in
|
|
19
18
|
// content ran on the published page, and `if a<b then` showed as `if a`.
|
|
20
19
|
|
|
20
|
+
/** Check if an inline node is an icon: an `image` with role "icon". */
|
|
21
|
+
function isIconNode(node) {
|
|
22
|
+
return node.type === "image" && node.attrs?.role === "icon";
|
|
23
|
+
}
|
|
24
|
+
|
|
21
25
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
26
|
+
* Is an inline node a picture — an `image` that is not an icon, a video or a
|
|
27
|
+
* file? These are the nodes processInlineElements() delivers as `image`
|
|
28
|
+
* children, so the marker's ordinal (getTextContent) counts exactly them.
|
|
24
29
|
*/
|
|
25
|
-
function
|
|
26
|
-
|
|
30
|
+
function isInlinePicture(node) {
|
|
31
|
+
if (node?.type !== "image" || isIconNode(node)) return false;
|
|
32
|
+
const role = node.attrs?.role;
|
|
33
|
+
return role !== "video" && role !== "pdf";
|
|
27
34
|
}
|
|
28
35
|
|
|
29
36
|
/**
|
|
@@ -207,11 +214,6 @@ function createSequenceElement(node, options = {}) {
|
|
|
207
214
|
mathml: sanitizeMathML(node.attrs?.mathml || ""),
|
|
208
215
|
attrs,
|
|
209
216
|
};
|
|
210
|
-
case "ImageBlock":
|
|
211
|
-
return {
|
|
212
|
-
type: "image",
|
|
213
|
-
attrs: parseImgBlock(attrs, options),
|
|
214
|
-
};
|
|
215
217
|
case "image":
|
|
216
218
|
// `role` decides which content array this lands in — the contract
|
|
217
219
|
// published in docs/reference/content-structure.md ("Media Assets"):
|
|
@@ -234,28 +236,20 @@ function createSequenceElement(node, options = {}) {
|
|
|
234
236
|
// poster/autoplay/muted/loop/controls, so the author's declaration
|
|
235
237
|
// was demoted to an image AND its playback attrs were dropped —
|
|
236
238
|
// while the build had already generated a poster for it
|
|
237
|
-
// (@uniweb/build site/assets.js).
|
|
238
|
-
// parseVideoBlock: that reader speaks the editor's dialect
|
|
239
|
-
// (coverImg, info), not the markdown one.
|
|
239
|
+
// (@uniweb/build site/assets.js).
|
|
240
240
|
if (attrs?.role === "video") {
|
|
241
241
|
return {
|
|
242
242
|
type: "video",
|
|
243
243
|
attrs: parseMarkdownVideo(attrs, options),
|
|
244
244
|
};
|
|
245
245
|
}
|
|
246
|
-
// Standard ProseMirror image node -
|
|
247
|
-
//
|
|
248
|
-
// content uses this node type; without parseImgBlock the url
|
|
246
|
+
// Standard ProseMirror image node - read through parseImgBlock, so
|
|
247
|
+
// its address lands in `url`. Without parseImgBlock the url
|
|
249
248
|
// wouldn't be populated and components render an empty slot.
|
|
250
249
|
return {
|
|
251
250
|
type: "image",
|
|
252
251
|
attrs: parseImgBlock(attrs || {}, options),
|
|
253
252
|
};
|
|
254
|
-
case "Video":
|
|
255
|
-
return {
|
|
256
|
-
type: "video",
|
|
257
|
-
attrs: parseVideoBlock(attrs, options),
|
|
258
|
-
};
|
|
259
253
|
case "bulletList":
|
|
260
254
|
case "orderedList": {
|
|
261
255
|
const listItems = content
|
|
@@ -305,90 +299,26 @@ function createSequenceElement(node, options = {}) {
|
|
|
305
299
|
return { type: "table", rows, attrs };
|
|
306
300
|
}
|
|
307
301
|
|
|
308
|
-
// `divider` is what content-reader emits; `horizontalRule` and
|
|
309
|
-
// `DividerBlock` are the editor's spellings. All three are one element.
|
|
310
|
-
//
|
|
311
302
|
// The reader's own name was NOT listed here until 2026-07-30 — markdown
|
|
312
303
|
// dividers reached consumers only by falling through `default:`, which
|
|
313
304
|
// happens to produce `{ type: 'divider', content: '' }` and happens to
|
|
314
305
|
// match what everything downstream keys on. It worked by coincidence,
|
|
315
306
|
// and the coincidence would have broken the moment `default:` changed.
|
|
307
|
+
//
|
|
308
|
+
// ⭐ The vocabulary's names are the only ones read. ⛔ Until 2026-10-18 the
|
|
309
|
+
// editor's spellings were read too: `DividerBlock` and `horizontalRule` as
|
|
310
|
+
// a divider, and its form node — `StructuredContent`, and `FormBlock`, its
|
|
311
|
+
// name before 2026-07-31 — as a `form` element whose data landed at
|
|
312
|
+
// `content.data[<schema id>]`. The editor writes `divider` and `dataBlock`
|
|
313
|
+
// itself now, and no writer produces the others, as none does its
|
|
314
|
+
// `card-group`, `document-group`, `ImageBlock`, `Video`, `UniwebIcon`,
|
|
315
|
+
// `Icon` and `button` nodes.
|
|
316
316
|
case "divider":
|
|
317
|
-
case "DividerBlock":
|
|
318
|
-
case "horizontalRule":
|
|
319
317
|
return {
|
|
320
318
|
type: "divider",
|
|
321
319
|
attrs,
|
|
322
320
|
};
|
|
323
321
|
|
|
324
|
-
// Custom TipTap elements
|
|
325
|
-
case "card-group":
|
|
326
|
-
return {
|
|
327
|
-
type: "card-group",
|
|
328
|
-
cards:
|
|
329
|
-
node.content
|
|
330
|
-
?.filter((c) => c.type === "card" && !c.attrs?.hidden)
|
|
331
|
-
.map((card) => parseCardBlock(card.attrs)) || [],
|
|
332
|
-
};
|
|
333
|
-
|
|
334
|
-
case "document-group":
|
|
335
|
-
// The editor's older container: one `document` element per document
|
|
336
|
-
// it holds, so a document is one kind of content whichever node
|
|
337
|
-
// wrote it (`documentFromEditorNode`). Until 2026-09-29 this was a
|
|
338
|
-
// `document-group` element whose documents became `content.links`.
|
|
339
|
-
return (node.content || [])
|
|
340
|
-
.filter((c) => c.type === "document")
|
|
341
|
-
.map((doc) => ({
|
|
342
|
-
type: "document",
|
|
343
|
-
attrs: documentFromEditorNode(doc.attrs),
|
|
344
|
-
}));
|
|
345
|
-
|
|
346
|
-
// The editor renamed this node to `StructuredContent` (2026-07-31,
|
|
347
|
-
// editor-internal). Both are accepted: the new name is what the editor
|
|
348
|
-
// emits now, and the old one still arrives from documents authored
|
|
349
|
-
// before the rename, which the framework never rewrites.
|
|
350
|
-
case "StructuredContent":
|
|
351
|
-
case "FormBlock":
|
|
352
|
-
// Parse form data (can be JSON string or object)
|
|
353
|
-
let formData = attrs?.data;
|
|
354
|
-
if (typeof formData === "string") {
|
|
355
|
-
try {
|
|
356
|
-
formData = JSON.parse(formData);
|
|
357
|
-
} catch (err) {
|
|
358
|
-
// Keep as string
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
return {
|
|
363
|
-
type: "form",
|
|
364
|
-
schemaId: attrs?.activeSchemaId || null,
|
|
365
|
-
data: formData,
|
|
366
|
-
attrs,
|
|
367
|
-
};
|
|
368
|
-
|
|
369
|
-
case "button": {
|
|
370
|
-
let textContent = getTextContent(content, options);
|
|
371
|
-
|
|
372
|
-
if (!textContent) return null;
|
|
373
|
-
|
|
374
|
-
return {
|
|
375
|
-
type: "button",
|
|
376
|
-
text: textContent,
|
|
377
|
-
children: processInlineElements(content, options),
|
|
378
|
-
attrs,
|
|
379
|
-
};
|
|
380
|
-
}
|
|
381
|
-
case "UniwebIcon":
|
|
382
|
-
return {
|
|
383
|
-
type: "icon",
|
|
384
|
-
attrs: parseUniwebIcon(attrs),
|
|
385
|
-
};
|
|
386
|
-
case "Icon":
|
|
387
|
-
return {
|
|
388
|
-
type: "icon",
|
|
389
|
-
attrs: parseIconBlock(attrs),
|
|
390
|
-
};
|
|
391
|
-
|
|
392
322
|
default:
|
|
393
323
|
return {
|
|
394
324
|
type: node.type,
|
|
@@ -487,16 +417,22 @@ function getTextContent(content, options = {}) {
|
|
|
487
417
|
// one with `children.filter(c => c.type === 'icon')[index]`. Referencing by
|
|
488
418
|
// ordinal rather than serializing the attrs keeps an inline `svg` blob out
|
|
489
419
|
// of the text stream and leaves `children` the single source of truth.
|
|
420
|
+
//
|
|
421
|
+
// A picture beside text is marked the same way, by its ordinal among the
|
|
422
|
+
// pictures of this array — `children.filter(c => c.type === 'image')[index]`.
|
|
490
423
|
const iconOrdinals = new Map();
|
|
424
|
+
const pictureOrdinals = new Map();
|
|
491
425
|
let nextIcon = 0;
|
|
426
|
+
let nextPicture = 0;
|
|
492
427
|
for (const node of content) {
|
|
493
428
|
if (isIconNode(node)) iconOrdinals.set(node, nextIcon++);
|
|
429
|
+
else if (isInlinePicture(node)) pictureOrdinals.set(node, nextPicture++);
|
|
494
430
|
}
|
|
495
431
|
|
|
496
432
|
return groups
|
|
497
433
|
.reduce((prev, group) => {
|
|
498
434
|
const inner = group.nodes.reduce(
|
|
499
|
-
(acc, node) => acc + renderInlineNode(node, iconOrdinals),
|
|
435
|
+
(acc, node) => acc + renderInlineNode(node, iconOrdinals, pictureOrdinals),
|
|
500
436
|
"",
|
|
501
437
|
);
|
|
502
438
|
if (!inner && !group.link) return prev;
|
|
@@ -509,7 +445,7 @@ function getTextContent(content, options = {}) {
|
|
|
509
445
|
* Render one inline node to HTML, applying every mark EXCEPT link — the
|
|
510
446
|
* anchor is applied per group by getTextContent, since it may span nodes.
|
|
511
447
|
*/
|
|
512
|
-
function renderInlineNode(curr, iconOrdinals) {
|
|
448
|
+
function renderInlineNode(curr, iconOrdinals, pictureOrdinals) {
|
|
513
449
|
return [curr]
|
|
514
450
|
.reduce((prev, curr) => {
|
|
515
451
|
const { type, marks = [], text } = curr;
|
|
@@ -517,24 +453,13 @@ function renderInlineNode(curr, iconOrdinals) {
|
|
|
517
453
|
if (type === "text") {
|
|
518
454
|
let styledText = escapeText(text || "");
|
|
519
455
|
|
|
520
|
-
// Apply marks in order:
|
|
521
|
-
// This ensures proper nesting
|
|
522
|
-
|
|
523
|
-
// textStyle (color)
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
)?.attrs?.color;
|
|
528
|
-
// A theme token's name, or nothing — it lands inside `var(--…)`.
|
|
529
|
-
if (typeof color === "string" && /^[a-zA-Z0-9-]+$/.test(color)) {
|
|
530
|
-
styledText = `<span style="color: var(--${color})">${styledText}</span>`;
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
// highlight
|
|
535
|
-
if (marks.some((mark) => mark.type === "highlight")) {
|
|
536
|
-
styledText = `<span style="background-color: var(--highlight)">${styledText}</span>`;
|
|
537
|
-
}
|
|
456
|
+
// Apply marks in order: span, code, bold, italic — the link is
|
|
457
|
+
// applied per group by getTextContent. This ensures proper nesting.
|
|
458
|
+
//
|
|
459
|
+
// ⛔ Until 2026-10-18 the editor's `textStyle` (a theme color) and
|
|
460
|
+
// `highlight` marks were applied first. The editor saves both as
|
|
461
|
+
// the vocabulary's `span` — `[x]{accent}`, `[x]{highlight}` — so
|
|
462
|
+
// neither is read now.
|
|
538
463
|
|
|
539
464
|
// span (bracketed spans with class/id/attributes)
|
|
540
465
|
if (marks.some((mark) => mark.type === "span")) {
|
|
@@ -637,6 +562,18 @@ function renderInlineNode(curr, iconOrdinals) {
|
|
|
637
562
|
const index = iconOrdinals?.get(curr);
|
|
638
563
|
if (index === undefined) return prev;
|
|
639
564
|
return prev + `<uniweb-icon data-index="${index}"></uniweb-icon>`;
|
|
565
|
+
} else if (isInlinePicture(curr)) {
|
|
566
|
+
// A picture sitting IN the prose — `as the figure 
|
|
567
|
+
// shows` — marked for the icon's reason: a renderer draws it with a
|
|
568
|
+
// component (kit's <Image>), from the entry processInlineElements()
|
|
569
|
+
// puts in `children`, its address resolved there.
|
|
570
|
+
//
|
|
571
|
+
// ⛔ Until 2026-10-18 it fell through to the catch-all below, as an
|
|
572
|
+
// icon did before 2026-08-12: the text kept a gap, the picture stayed
|
|
573
|
+
// in `children` and `content.images`, and a walker drew nothing.
|
|
574
|
+
const index = pictureOrdinals?.get(curr);
|
|
575
|
+
if (index === undefined) return prev;
|
|
576
|
+
return prev + `<uniweb-image data-index="${index}"></uniweb-image>`;
|
|
640
577
|
} else {
|
|
641
578
|
// console.warn(`unhandled text content type: ${type}`, curr);
|
|
642
579
|
return prev;
|
|
@@ -653,9 +590,9 @@ function processInlineElements(content, options = {}) {
|
|
|
653
590
|
if (isIconNode(item)) {
|
|
654
591
|
items.push({
|
|
655
592
|
type: "icon",
|
|
656
|
-
attrs:
|
|
593
|
+
attrs: parseIcon(item.attrs),
|
|
657
594
|
});
|
|
658
|
-
} else if (item.type === "image"
|
|
595
|
+
} else if (item.type === "image") {
|
|
659
596
|
// A media node that stayed inside its paragraph — content-reader
|
|
660
597
|
// hoists a standalone one to the document root, but an image sitting
|
|
661
598
|
// beside text does not qualify (parser/block.js `isBlockEligible`).
|
|
@@ -715,6 +652,11 @@ function processInlineElements(content, options = {}) {
|
|
|
715
652
|
* layout, so a framework package never composes a serve location or guesses an
|
|
716
653
|
* origin — a host may move its assets without a framework release.
|
|
717
654
|
*
|
|
655
|
+
* ⛔ No asset host is built into this package: a reference resolves through this
|
|
656
|
+
* pattern, or is the address the content holds. Until 2026-10-17 an
|
|
657
|
+
* `info.identifier` (`{version}/{filename}`) was composed against a fixed host;
|
|
658
|
+
* it is not read.
|
|
659
|
+
*
|
|
718
660
|
* ⛔ Three ways this returns '' — "unresolved", never a guess:
|
|
719
661
|
* - **no pattern** (the host declared none) — absent ⇒ absent
|
|
720
662
|
* - **an unknown placeholder** — never emit a half-substituted URL
|
|
@@ -750,8 +692,9 @@ function processInlineElements(content, options = {}) {
|
|
|
750
692
|
* the one every fallback path already resolves to. The convention is: unprefixed
|
|
751
693
|
* identifies the primary; a prefix names the attr it belongs to.
|
|
752
694
|
*
|
|
753
|
-
* `url` rides in the primary slot because the editor's `ImageBlock`
|
|
754
|
-
*
|
|
695
|
+
* `url` rides in the primary slot because the editor's `ImageBlock` used it where
|
|
696
|
+
* `image` uses `src`. This parser reads `src` alone since 2026-10-18; the entry
|
|
697
|
+
* stays because this list is exported and read outside it.
|
|
755
698
|
*/
|
|
756
699
|
export const ASSET_SLOTS = [
|
|
757
700
|
{ urls: ["src", "url"], id: "assetId", ext: "assetExt" },
|
|
@@ -791,72 +734,6 @@ function secondaryAssetUrl(attrs, options, urlAttr) {
|
|
|
791
734
|
return resolved || attrs?.[urlAttr] || "";
|
|
792
735
|
}
|
|
793
736
|
|
|
794
|
-
// ⛔ LEGACY BELOW — the PHP-estate identifier path. Do not extend it.
|
|
795
|
-
//
|
|
796
|
-
// `{version}/{filename}` identifiers have no current producer — the CLI emits
|
|
797
|
-
// `src`, and editors store the URL their host hands them. Only content authored
|
|
798
|
-
// against the original hosted service still carries one, and the hardcoded host
|
|
799
|
-
// below cannot be correct for any other deployment: the CLI can be pointed at
|
|
800
|
-
// any host. New references use `assetId`/`assetExt` above.
|
|
801
|
-
const ASSET_BASE_URL = "https://assets.uniweb.app/";
|
|
802
|
-
|
|
803
|
-
/**
|
|
804
|
-
* Resolve an asset identifier ({version}/{filename}) to a direct URL.
|
|
805
|
-
* Assets are hosted at assets.uniweb.app under dist/{version}/base.{ext}.
|
|
806
|
-
*/
|
|
807
|
-
function resolveAssetIdentifier(identifier) {
|
|
808
|
-
if (!identifier || typeof identifier !== "string") return "";
|
|
809
|
-
// ⛔ An identifier that is ALREADY a URL or a rooted path is not an
|
|
810
|
-
// identifier — it is a serve URL sitting in the identifier slot, and it is
|
|
811
|
-
// usable exactly as it stands. Composing it against the legacy base
|
|
812
|
-
// destroys it: `split("/")` on `/gateway/asset/dist/{id}/base.png` takes
|
|
813
|
-
// `""` as the version and `"gateway"` as the filename, yielding
|
|
814
|
-
// `https://assets.uniweb.app/dist//base.gateway` — not a 404 and not a
|
|
815
|
-
// validation failure, a URL-shaped string that renders a broken image and
|
|
816
|
-
// reads like a missing file.
|
|
817
|
-
//
|
|
818
|
-
// The legacy grammar is `{version}/{filename}` with no leading slash, so
|
|
819
|
-
// nothing legitimate is caught here. Passing it through is not constructing
|
|
820
|
-
// a serve location: it is declining to construct one over a value that is
|
|
821
|
-
// already an address. (Measured 2026-08-17 against a real producer.)
|
|
822
|
-
if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|\/)/i.test(identifier)) return identifier;
|
|
823
|
-
const [version, filename] = identifier.split("/");
|
|
824
|
-
if (!filename) return "";
|
|
825
|
-
const ext = filename.substring(filename.lastIndexOf(".") + 1);
|
|
826
|
-
return `${ASSET_BASE_URL}dist/${version}/base.${ext}`;
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
function makeAssetUrl(info) {
|
|
830
|
-
const src = info?.src || info?.url || "";
|
|
831
|
-
if (src) return src;
|
|
832
|
-
if (info?.identifier) return resolveAssetIdentifier(info.identifier);
|
|
833
|
-
return "";
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
function parseCardBlock(itemAttrs) {
|
|
837
|
-
const { address, ...others } = itemAttrs;
|
|
838
|
-
|
|
839
|
-
let parsedAddress = null;
|
|
840
|
-
|
|
841
|
-
try {
|
|
842
|
-
if (address) {
|
|
843
|
-
parsedAddress = JSON.parse(address);
|
|
844
|
-
}
|
|
845
|
-
} catch {}
|
|
846
|
-
|
|
847
|
-
const { coverImg = null, icon } = others;
|
|
848
|
-
|
|
849
|
-
if (icon) {
|
|
850
|
-
others.icon = parseUniwebIcon(icon);
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
return {
|
|
854
|
-
...others,
|
|
855
|
-
address: parsedAddress,
|
|
856
|
-
coverImg: makeAssetUrl(coverImg),
|
|
857
|
-
};
|
|
858
|
-
}
|
|
859
|
-
|
|
860
737
|
// A document authored in markdown: `{role=pdf …}`.
|
|
861
738
|
//
|
|
862
739
|
// Read through `parseImgBlock`, which resolves the address and the preview's the
|
|
@@ -900,146 +777,47 @@ function fileFacts(url, attrs = {}) {
|
|
|
900
777
|
};
|
|
901
778
|
}
|
|
902
779
|
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
} =
|
|
916
|
-
const url = href || downloadUrl || "";
|
|
917
|
-
return {
|
|
918
|
-
url,
|
|
919
|
-
...fileFacts(url, itemAttrs),
|
|
920
|
-
alt: alt || title,
|
|
921
|
-
caption: title,
|
|
922
|
-
role: "pdf",
|
|
923
|
-
...(coverImg && { preview: coverImg }),
|
|
924
|
-
...(author && { author }),
|
|
925
|
-
...(description && { description }),
|
|
926
|
-
...(fileType && { fileType }),
|
|
927
|
-
};
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
function parseDocumentBlock(itemAttrs) {
|
|
931
|
-
const { src, info = {}, coverImg = null, ...others } = itemAttrs;
|
|
932
|
-
|
|
933
|
-
let ele = {
|
|
934
|
-
...others,
|
|
935
|
-
coverImg: makeAssetUrl(coverImg),
|
|
936
|
-
};
|
|
937
|
-
|
|
938
|
-
if (src) {
|
|
939
|
-
ele.href = src;
|
|
940
|
-
} else {
|
|
941
|
-
const { identifier = "" } = info;
|
|
942
|
-
|
|
943
|
-
if (identifier) {
|
|
944
|
-
ele.downloadUrl = resolveAssetIdentifier(identifier);
|
|
945
|
-
}
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
return ele;
|
|
949
|
-
}
|
|
950
|
-
|
|
951
|
-
function parseUniwebIcon(itemAttrs) {
|
|
952
|
-
const { svg, url, src, size, color, preserveColors, href, target, library, name } = itemAttrs || {};
|
|
780
|
+
/**
|
|
781
|
+
* An icon: named (`library` + `name`, resolved at render through the icon
|
|
782
|
+
* corpus) or a file (`src`) — `` or
|
|
783
|
+
* `{role=icon}`, both documented in AGENTS.md and
|
|
784
|
+
* docs/reference/content-structure.md. Until 2026-07-30 a file-sourced icon
|
|
785
|
+
* delivered `{}` and rendered nothing. `url` is the key kit's <Icon> fetches
|
|
786
|
+
* from. Keys are omitted when absent.
|
|
787
|
+
*
|
|
788
|
+
* ⛔ Until 2026-10-18 this also read the editor's `UniwebIcon` (an inline `svg`,
|
|
789
|
+
* a `url`, `preserveColors`, a `family:id` name) and its `Icon` node.
|
|
790
|
+
*/
|
|
791
|
+
function parseIcon(itemAttrs) {
|
|
792
|
+
const { src, size, color, href, target, library, name } = itemAttrs || {};
|
|
953
793
|
|
|
954
|
-
// Build object with only defined fields — icon source varies:
|
|
955
|
-
// TipTap editor: svg/url (resolved inline)
|
|
956
|
-
// Markdown pipeline: library + name (resolved at runtime via CDN)
|
|
957
|
-
// OR a file, which arrives as `src`
|
|
958
794
|
const icon = {};
|
|
959
|
-
|
|
960
|
-
if (svg) icon.svg = sanitizeSVG(svg);
|
|
961
|
-
// Standard ProseMirror `image` nodes use `src`; the editor's `UniwebIcon`
|
|
962
|
-
// uses `url`. Honor either, exactly as parseImgBlock does below — a
|
|
963
|
-
// file-sourced icon is authored `` or
|
|
964
|
-
// `{role=icon}` (both documented in AGENTS.md and
|
|
965
|
-
// docs/reference/content-structure.md), and reaches here as `src` with no
|
|
966
|
-
// library/name. Until 2026-07-30 this reader dropped it, so every
|
|
967
|
-
// documented file-sourced icon delivered `{}` and rendered nothing.
|
|
968
|
-
// `url` is the key kit's <Icon> fetches from.
|
|
969
|
-
if (url || src) icon.url = url || src;
|
|
795
|
+
if (src) icon.url = src;
|
|
970
796
|
if (size) icon.size = size;
|
|
971
797
|
if (color) icon.color = color;
|
|
972
|
-
if (preserveColors) icon.preserveColors = preserveColors;
|
|
973
798
|
if (href) icon.href = href;
|
|
974
799
|
if (target) icon.target = target;
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
// is what `<Icon library name />` consumes.
|
|
978
|
-
let resolvedLibrary = library;
|
|
979
|
-
let resolvedName = name;
|
|
980
|
-
if (!resolvedLibrary && typeof name === "string" && name.includes(":")) {
|
|
981
|
-
const [family, ...rest] = name.split(":");
|
|
982
|
-
resolvedLibrary = family;
|
|
983
|
-
resolvedName = rest.join(":");
|
|
984
|
-
}
|
|
985
|
-
if (resolvedLibrary) icon.library = resolvedLibrary;
|
|
986
|
-
if (resolvedName) icon.name = resolvedName;
|
|
987
|
-
|
|
988
|
-
return icon;
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
// The editor's `Icon` node carries an inline SVG blob plus a theme. Return an
|
|
992
|
-
// ATTRS OBJECT, like every other parser in this file — not the bare `svg`
|
|
993
|
-
// string it used to return.
|
|
994
|
-
//
|
|
995
|
-
// The string form was a live bug, not just an inconsistency: kit renders a
|
|
996
|
-
// sequence icon with `<Icon {...element.attrs} />` (styled/Prose/index.jsx),
|
|
997
|
-
// and spreading a string yields indexed character props — `{0:'<', 1:'s', …}`
|
|
998
|
-
// — so the icon rendered as nothing and React saw a wall of unknown props. It
|
|
999
|
-
// failed silently because `attrs.svg` on a string is `undefined`, never an
|
|
1000
|
-
// error.
|
|
1001
|
-
//
|
|
1002
|
-
// Keys are omitted when absent so a consumer can distinguish "not provided"
|
|
1003
|
-
// from "empty", matching parseUniwebIcon's shape above.
|
|
1004
|
-
function parseIconBlock(itemAttrs) {
|
|
1005
|
-
const { svg, theme } = itemAttrs || {};
|
|
1006
|
-
|
|
1007
|
-
const icon = {};
|
|
1008
|
-
if (svg) icon.svg = sanitizeSVG(svg);
|
|
1009
|
-
if (theme) icon.theme = theme;
|
|
800
|
+
if (library) icon.library = library;
|
|
801
|
+
if (name) icon.name = name;
|
|
1010
802
|
|
|
1011
803
|
return icon;
|
|
1012
804
|
}
|
|
1013
805
|
|
|
1014
806
|
function parseImgBlock(itemAttrs, options) {
|
|
1015
807
|
let {
|
|
1016
|
-
info: imgInfo,
|
|
1017
|
-
targetId,
|
|
1018
808
|
caption = "",
|
|
1019
809
|
alt = "",
|
|
1020
810
|
src,
|
|
1021
|
-
url,
|
|
1022
811
|
href = "",
|
|
1023
812
|
target = "",
|
|
1024
813
|
role,
|
|
1025
|
-
credit, // the editor's word for `author` — delivered as `author`
|
|
1026
814
|
id, // {#fig-id} cross-reference label — preserved so Press
|
|
1027
815
|
// adapters can emit \label{id} (LaTeX) / <id> (Typst).
|
|
1028
|
-
//
|
|
1029
|
-
//
|
|
1030
|
-
//
|
|
1031
|
-
//
|
|
1032
|
-
//
|
|
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
|
|
816
|
+
// ⭐ An element holds the vocabulary's words. ⛔ This function was
|
|
817
|
+
// written for the editor's `ImageBlock` and read its words too — `url`,
|
|
818
|
+
// `credit`, an `info` envelope (`contentType`, `viewType`, `contentId`)
|
|
819
|
+
// and `targetId` — until 2026-10-18; and until 2026-10-15 its
|
|
820
|
+
// `direction`, `imgPos`, a layout `size`, `filter` and `theme`. A
|
|
1043
821
|
// component handing the element to kit's <Image> passed that `size` as
|
|
1044
822
|
// Image's avatar preset, so a "wide" image drew as a 96px square.
|
|
1045
823
|
width,
|
|
@@ -1053,39 +831,18 @@ function parseImgBlock(itemAttrs, options) {
|
|
|
1053
831
|
description, // role=pdf — describes the RESOURCE, not the image (≠ alt)
|
|
1054
832
|
} = itemAttrs;
|
|
1055
833
|
|
|
1056
|
-
let { contentType, viewType, contentId, identifier } = imgInfo || {};
|
|
1057
|
-
|
|
1058
834
|
caption = stripTags(caption);
|
|
1059
|
-
const attribution = author || credit;
|
|
1060
835
|
|
|
1061
|
-
// Reference precedence. Standard ProseMirror `image` nodes use `src`; the
|
|
1062
|
-
// custom `ImageBlock` node uses `url`. Honor either so callers don't have to
|
|
1063
|
-
// pre-normalize.
|
|
1064
|
-
//
|
|
1065
836
|
// ⭐ A store-held asset (`assetId`) wins WHEN IT RESOLVES — not merely when
|
|
1066
837
|
// it is present. That distinction is the whole reason a producer can write
|
|
1067
838
|
// `assetId` alongside `src` before any deployment declares
|
|
1068
839
|
// `config.assets.url`: until one does, resolution yields '' and the node
|
|
1069
840
|
// renders through `src` exactly as it does today. Content written now stays
|
|
1070
841
|
// correct whichever order the halves arrive in.
|
|
1071
|
-
|
|
1072
|
-
// `identifier` is the LEGACY estate path and keeps its old precedence over a
|
|
1073
|
-
// stale `src`.
|
|
1074
|
-
const assetUrl = assetUrlFromAttrs(itemAttrs, options);
|
|
1075
|
-
if (assetUrl) {
|
|
1076
|
-
url = assetUrl;
|
|
1077
|
-
} else if (identifier) {
|
|
1078
|
-
url = makeAssetUrl(imgInfo);
|
|
1079
|
-
} else if (!url && src) {
|
|
1080
|
-
url = src;
|
|
1081
|
-
}
|
|
842
|
+
const url = assetUrlFromAttrs(itemAttrs, options) || src || undefined;
|
|
1082
843
|
|
|
1083
844
|
return {
|
|
1084
|
-
contentType,
|
|
1085
|
-
viewType,
|
|
1086
|
-
contentId: targetId || contentId,
|
|
1087
845
|
url,
|
|
1088
|
-
value: identifier || "",
|
|
1089
846
|
alt: alt || caption,
|
|
1090
847
|
caption,
|
|
1091
848
|
href,
|
|
@@ -1104,24 +861,21 @@ function parseImgBlock(itemAttrs, options) {
|
|
|
1104
861
|
const p = secondaryAssetUrl(itemAttrs, options, "preview");
|
|
1105
862
|
return p ? { preview: p } : {};
|
|
1106
863
|
})()),
|
|
1107
|
-
...(
|
|
864
|
+
...(author && { author }),
|
|
1108
865
|
...(description && { description }),
|
|
1109
866
|
};
|
|
1110
867
|
}
|
|
1111
868
|
|
|
1112
869
|
// A video authored in markdown: `{role=video …}`.
|
|
1113
870
|
//
|
|
1114
|
-
//
|
|
1115
|
-
//
|
|
1116
|
-
//
|
|
1117
|
-
//
|
|
1118
|
-
//
|
|
1119
|
-
// playback flags.
|
|
871
|
+
// This reader carries the attribute set that docs/reference/content-structure.md
|
|
872
|
+
// publishes for the video role, and the shape it documents: `{ src, href,
|
|
873
|
+
// target }` plus the playback flags. ⛔ Until 2026-10-18 the editor's `Video`
|
|
874
|
+
// node had a reader of its own, `parseVideoBlock`, and this one took the
|
|
875
|
+
// editor's `url` and `info` too.
|
|
1120
876
|
function parseMarkdownVideo(itemAttrs, options) {
|
|
1121
877
|
const {
|
|
1122
878
|
src,
|
|
1123
|
-
url,
|
|
1124
|
-
info,
|
|
1125
879
|
poster,
|
|
1126
880
|
alt = "",
|
|
1127
881
|
caption = "",
|
|
@@ -1136,18 +890,11 @@ function parseMarkdownVideo(itemAttrs, options) {
|
|
|
1136
890
|
placement, // where it sits in a flow, same as parseImgBlock
|
|
1137
891
|
} = itemAttrs || {};
|
|
1138
892
|
|
|
1139
|
-
// Reference precedence,
|
|
1140
|
-
//
|
|
1141
|
-
// src/url.
|
|
1142
|
-
//
|
|
1143
|
-
// ⚠️ The comment here used to claim identifier-precedence "matching
|
|
1144
|
-
// parseImgBlock" while the code did the opposite — it passed `src` INTO
|
|
1145
|
-
// `makeAssetUrl`, which returns a src/url before it ever looks at
|
|
1146
|
-
// `identifier`. So image and video resolved differently for years and the
|
|
1147
|
-
// comment asserted they did not. Aligned to the documented intent (2026-08-17).
|
|
893
|
+
// Reference precedence, as parseImgBlock's: a resolvable store-held asset,
|
|
894
|
+
// then the address the node holds.
|
|
1148
895
|
const assetUrl = assetUrlFromAttrs(itemAttrs, options);
|
|
1149
896
|
const video = {
|
|
1150
|
-
src: assetUrl ||
|
|
897
|
+
src: assetUrl || src || "",
|
|
1151
898
|
alt,
|
|
1152
899
|
caption: stripTags(caption),
|
|
1153
900
|
role,
|
|
@@ -1172,33 +919,6 @@ function parseMarkdownVideo(itemAttrs, options) {
|
|
|
1172
919
|
return video;
|
|
1173
920
|
}
|
|
1174
921
|
|
|
1175
|
-
function parseVideoBlock(itemAttrs, options) {
|
|
1176
|
-
let {
|
|
1177
|
-
src,
|
|
1178
|
-
caption = "",
|
|
1179
|
-
info = {},
|
|
1180
|
-
coverImg = {},
|
|
1181
|
-
alt,
|
|
1182
|
-
href = "",
|
|
1183
|
-
target = "",
|
|
1184
|
-
placement, // where it sits in a flow; the editor's `direction` is not read
|
|
1185
|
-
} = itemAttrs;
|
|
1186
|
-
|
|
1187
|
-
// Same precedence as parseImgBlock / parseMarkdownVideo: a resolvable
|
|
1188
|
-
// store-held asset, then the legacy identifier, then the authored src.
|
|
1189
|
-
const video = assetUrlFromAttrs(itemAttrs, options) || makeAssetUrl(info) || src || "";
|
|
1190
|
-
|
|
1191
|
-
return {
|
|
1192
|
-
src: video,
|
|
1193
|
-
caption,
|
|
1194
|
-
coverImg: makeAssetUrl(coverImg),
|
|
1195
|
-
alt,
|
|
1196
|
-
href,
|
|
1197
|
-
target,
|
|
1198
|
-
...(placement && { placement }),
|
|
1199
|
-
};
|
|
1200
|
-
}
|
|
1201
|
-
|
|
1202
922
|
function stripTags(htmlString) {
|
|
1203
923
|
if (!htmlString || typeof htmlString !== "string") return "";
|
|
1204
924
|
|
|
@@ -1260,7 +980,7 @@ function isLink(item, options = {}) {
|
|
|
1260
980
|
|
|
1261
981
|
for (let i = 0; i < originalContent.length; i++) {
|
|
1262
982
|
if (isIconNode(originalContent[i])) {
|
|
1263
|
-
const iconAttrs =
|
|
983
|
+
const iconAttrs = parseIcon(originalContent[i].attrs);
|
|
1264
984
|
if (i < linkIndex) {
|
|
1265
985
|
// Take the last icon before the link
|
|
1266
986
|
iconBefore = iconAttrs;
|