@uniweb/semantic-parser 1.9.0 → 1.11.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/processors/groups.js +4 -13
- package/src/processors/sequence.js +47 -47
- package/src/sanitize.js +40 -13
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/processors/groups.js
CHANGED
|
@@ -500,19 +500,10 @@ function processGroupContent(elements, options = {}) {
|
|
|
500
500
|
body.media.push({ refId: element.refId, kind: "inset" });
|
|
501
501
|
break;
|
|
502
502
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
// schemaId is present (legacy content), fall back to the
|
|
508
|
-
// literal key "form" — eventual deprecation.
|
|
509
|
-
{
|
|
510
|
-
const payload = element.data || element.attrs;
|
|
511
|
-
const key = element.schemaId || "form";
|
|
512
|
-
body.data[key] = payload;
|
|
513
|
-
}
|
|
514
|
-
break;
|
|
515
|
-
|
|
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.
|
|
516
507
|
}
|
|
517
508
|
}
|
|
518
509
|
}
|
|
@@ -22,6 +22,17 @@ function isIconNode(node) {
|
|
|
22
22
|
return node.type === "image" && node.attrs?.role === "icon";
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
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.
|
|
29
|
+
*/
|
|
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";
|
|
34
|
+
}
|
|
35
|
+
|
|
25
36
|
/**
|
|
26
37
|
* Process a ProseMirror/TipTap document into a flat sequence
|
|
27
38
|
* @param {Object} doc ProseMirror document
|
|
@@ -288,44 +299,26 @@ function createSequenceElement(node, options = {}) {
|
|
|
288
299
|
return { type: "table", rows, attrs };
|
|
289
300
|
}
|
|
290
301
|
|
|
291
|
-
// `divider` is what content-reader emits; `horizontalRule` and
|
|
292
|
-
// `DividerBlock` are the editor's spellings. All three are one element.
|
|
293
|
-
//
|
|
294
302
|
// The reader's own name was NOT listed here until 2026-07-30 — markdown
|
|
295
303
|
// dividers reached consumers only by falling through `default:`, which
|
|
296
304
|
// happens to produce `{ type: 'divider', content: '' }` and happens to
|
|
297
305
|
// match what everything downstream keys on. It worked by coincidence,
|
|
298
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.
|
|
299
316
|
case "divider":
|
|
300
|
-
case "DividerBlock":
|
|
301
|
-
case "horizontalRule":
|
|
302
317
|
return {
|
|
303
318
|
type: "divider",
|
|
304
319
|
attrs,
|
|
305
320
|
};
|
|
306
321
|
|
|
307
|
-
// The editor's form node. ⛔ Until 2026-10-18 its name before
|
|
308
|
-
// 2026-07-31, `FormBlock`, was read too — and the editor's `card-group`,
|
|
309
|
-
// `document-group`, `ImageBlock`, `Video`, `UniwebIcon`, `Icon` and
|
|
310
|
-
// `button` nodes, which no writer produces now.
|
|
311
|
-
case "StructuredContent":
|
|
312
|
-
// Parse form data (can be JSON string or object)
|
|
313
|
-
let formData = attrs?.data;
|
|
314
|
-
if (typeof formData === "string") {
|
|
315
|
-
try {
|
|
316
|
-
formData = JSON.parse(formData);
|
|
317
|
-
} catch (err) {
|
|
318
|
-
// Keep as string
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
return {
|
|
323
|
-
type: "form",
|
|
324
|
-
schemaId: attrs?.activeSchemaId || null,
|
|
325
|
-
data: formData,
|
|
326
|
-
attrs,
|
|
327
|
-
};
|
|
328
|
-
|
|
329
322
|
default:
|
|
330
323
|
return {
|
|
331
324
|
type: node.type,
|
|
@@ -424,16 +417,22 @@ function getTextContent(content, options = {}) {
|
|
|
424
417
|
// one with `children.filter(c => c.type === 'icon')[index]`. Referencing by
|
|
425
418
|
// ordinal rather than serializing the attrs keeps an inline `svg` blob out
|
|
426
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]`.
|
|
427
423
|
const iconOrdinals = new Map();
|
|
424
|
+
const pictureOrdinals = new Map();
|
|
428
425
|
let nextIcon = 0;
|
|
426
|
+
let nextPicture = 0;
|
|
429
427
|
for (const node of content) {
|
|
430
428
|
if (isIconNode(node)) iconOrdinals.set(node, nextIcon++);
|
|
429
|
+
else if (isInlinePicture(node)) pictureOrdinals.set(node, nextPicture++);
|
|
431
430
|
}
|
|
432
431
|
|
|
433
432
|
return groups
|
|
434
433
|
.reduce((prev, group) => {
|
|
435
434
|
const inner = group.nodes.reduce(
|
|
436
|
-
(acc, node) => acc + renderInlineNode(node, iconOrdinals),
|
|
435
|
+
(acc, node) => acc + renderInlineNode(node, iconOrdinals, pictureOrdinals),
|
|
437
436
|
"",
|
|
438
437
|
);
|
|
439
438
|
if (!inner && !group.link) return prev;
|
|
@@ -446,7 +445,7 @@ function getTextContent(content, options = {}) {
|
|
|
446
445
|
* Render one inline node to HTML, applying every mark EXCEPT link — the
|
|
447
446
|
* anchor is applied per group by getTextContent, since it may span nodes.
|
|
448
447
|
*/
|
|
449
|
-
function renderInlineNode(curr, iconOrdinals) {
|
|
448
|
+
function renderInlineNode(curr, iconOrdinals, pictureOrdinals) {
|
|
450
449
|
return [curr]
|
|
451
450
|
.reduce((prev, curr) => {
|
|
452
451
|
const { type, marks = [], text } = curr;
|
|
@@ -454,24 +453,13 @@ function renderInlineNode(curr, iconOrdinals) {
|
|
|
454
453
|
if (type === "text") {
|
|
455
454
|
let styledText = escapeText(text || "");
|
|
456
455
|
|
|
457
|
-
// Apply marks in order:
|
|
458
|
-
// This ensures proper nesting
|
|
459
|
-
|
|
460
|
-
// textStyle (color)
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
)?.attrs?.color;
|
|
465
|
-
// A theme token's name, or nothing — it lands inside `var(--…)`.
|
|
466
|
-
if (typeof color === "string" && /^[a-zA-Z0-9-]+$/.test(color)) {
|
|
467
|
-
styledText = `<span style="color: var(--${color})">${styledText}</span>`;
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
// highlight
|
|
472
|
-
if (marks.some((mark) => mark.type === "highlight")) {
|
|
473
|
-
styledText = `<span style="background-color: var(--highlight)">${styledText}</span>`;
|
|
474
|
-
}
|
|
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.
|
|
475
463
|
|
|
476
464
|
// span (bracketed spans with class/id/attributes)
|
|
477
465
|
if (marks.some((mark) => mark.type === "span")) {
|
|
@@ -574,6 +562,18 @@ function renderInlineNode(curr, iconOrdinals) {
|
|
|
574
562
|
const index = iconOrdinals?.get(curr);
|
|
575
563
|
if (index === undefined) return prev;
|
|
576
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>`;
|
|
577
577
|
} else {
|
|
578
578
|
// console.warn(`unhandled text content type: ${type}`, curr);
|
|
579
579
|
return prev;
|
package/src/sanitize.js
CHANGED
|
@@ -347,9 +347,16 @@ function rebuild(input, policy) {
|
|
|
347
347
|
|
|
348
348
|
/**
|
|
349
349
|
* What a rebuild would leave out of `input`, item by item — the same walk as
|
|
350
|
-
* `rebuild`, reporting instead of writing
|
|
351
|
-
*
|
|
352
|
-
*
|
|
350
|
+
* `rebuild`, reporting instead of writing — and, when it leaves nothing out but
|
|
351
|
+
* writes the markup differently, one `spelling` item at the first difference.
|
|
352
|
+
* Empty when a rebuild writes `input` back exactly as given.
|
|
353
|
+
*
|
|
354
|
+
* ⭐ Exactly, not "up to spelling" (since 2026-10-18): every text run and
|
|
355
|
+
* attribute value a rebuild writes is escaped, so markup in that form reads the
|
|
356
|
+
* same to any reader. Markup spelled otherwise — a `<` left in text, a CDATA
|
|
357
|
+
* section, a reference the rebuild would write as its character — can read one
|
|
358
|
+
* way here and another way to a reader that parses it as written, which a store
|
|
359
|
+
* holding it for every reader cannot allow.
|
|
353
360
|
*/
|
|
354
361
|
function inspect(input, policy) {
|
|
355
362
|
const outside = [];
|
|
@@ -394,6 +401,14 @@ function inspect(input, policy) {
|
|
|
394
401
|
}
|
|
395
402
|
}
|
|
396
403
|
}
|
|
404
|
+
if (!outside.length) {
|
|
405
|
+
const rebuilt = rebuild(input, policy);
|
|
406
|
+
if (rebuilt !== input) {
|
|
407
|
+
let at = 0;
|
|
408
|
+
while (input[at] === rebuilt[at]) at++;
|
|
409
|
+
outside.push({ reason: "spelling", value: input.slice(at, at + 40) });
|
|
410
|
+
}
|
|
411
|
+
}
|
|
397
412
|
return outside;
|
|
398
413
|
}
|
|
399
414
|
|
|
@@ -412,14 +427,16 @@ export function sanitizeMathML(mathml) {
|
|
|
412
427
|
/**
|
|
413
428
|
* What in a MathML string falls outside the allowlist `sanitizeMathML` rebuilds
|
|
414
429
|
* from: each element and attribute a rebuild drops, each style declaration it
|
|
415
|
-
* drops, and any comment, doctype or processing instruction
|
|
416
|
-
*
|
|
430
|
+
* drops, and any comment, doctype or processing instruction — or, when it drops
|
|
431
|
+
* nothing, a `spelling` item if the rebuild writes the markup differently. Empty
|
|
432
|
+
* when `sanitizeMathML` writes the markup back exactly as given, so a writer
|
|
433
|
+
* that stores what `sanitizeMathML` returns stores markup within it.
|
|
417
434
|
*
|
|
418
435
|
* This is how content is checked rather than cleaned — a writer refused with
|
|
419
436
|
* what it sent can fix it, where a cleaned value changes without a word.
|
|
420
437
|
*
|
|
421
438
|
* @param {string} mathml
|
|
422
|
-
* @returns {Array<{reason: 'element'|'attribute'|'style'|'markup', element?: string, attribute?: string, value?: string}>}
|
|
439
|
+
* @returns {Array<{reason: 'element'|'attribute'|'style'|'markup'|'spelling', element?: string, attribute?: string, value?: string}>}
|
|
423
440
|
*/
|
|
424
441
|
export function inspectMathML(mathml) {
|
|
425
442
|
if (typeof mathml !== "string" || !mathml) return [];
|
|
@@ -443,11 +460,11 @@ export function sanitizeSVG(svg) {
|
|
|
443
460
|
|
|
444
461
|
/**
|
|
445
462
|
* What in an SVG string falls outside the allowlist `sanitizeSVG` rebuilds
|
|
446
|
-
* from, reported as `inspectMathML` reports MathML. Empty when
|
|
447
|
-
*
|
|
463
|
+
* from, reported as `inspectMathML` reports MathML. Empty when `sanitizeSVG`
|
|
464
|
+
* writes the markup back exactly as given.
|
|
448
465
|
*
|
|
449
466
|
* @param {string} svg
|
|
450
|
-
* @returns {Array<{reason: 'element'|'attribute'|'style'|'markup', element?: string, attribute?: string, value?: string}>}
|
|
467
|
+
* @returns {Array<{reason: 'element'|'attribute'|'style'|'markup'|'spelling', element?: string, attribute?: string, value?: string}>}
|
|
451
468
|
*/
|
|
452
469
|
export function inspectSVG(svg) {
|
|
453
470
|
if (typeof svg !== "string" || !svg) return [];
|
|
@@ -480,8 +497,8 @@ export function isImageSource(url) {
|
|
|
480
497
|
|
|
481
498
|
/**
|
|
482
499
|
* Whether a `data:` URL holds an image a page may show: a PNG, JPEG, GIF, WebP
|
|
483
|
-
* or AVIF in base64, or SVG — percent-encoded or base64 — whose markup is
|
|
484
|
-
*
|
|
500
|
+
* or AVIF in base64, or SVG — percent-encoded or base64 — whose markup is
|
|
501
|
+
* exactly what `sanitizeSVG` writes of it (`inspectSVG`). ⭐ Admitted [Diego, 2026-10-16]: a
|
|
485
502
|
* picture an `<img>` loads runs no script, and a built-in starter image is one.
|
|
486
503
|
* The URL is read as a browser reads it: tab and newline removed, leading and
|
|
487
504
|
* trailing spaces and controls trimmed.
|
|
@@ -598,6 +615,14 @@ export function isCssColor(value) {
|
|
|
598
615
|
return typeof value === "string" && CSS_COLOR.test(value) && !NOT_A_COLOR.test(value);
|
|
599
616
|
}
|
|
600
617
|
|
|
618
|
+
/** How a rebuild writes markup — what `inspect` compares the input with. */
|
|
619
|
+
const WRITTEN =
|
|
620
|
+
"exactly as a rebuild writes it: a text run with `&`, `<` and `>` written `&`, `<` and `>`, and every other character as itself; " +
|
|
621
|
+
"a start tag `<name>` or `<name/>` and an end tag `</name>`, the name as `elements` spells it, with no space before `>` or `/>`; " +
|
|
622
|
+
"each attribute once, as one space then `name=\"value\"` — the name as `attributes` spells it, or a common one in lower case — " +
|
|
623
|
+
"with `&`, `<`, `>` and `\"` in the value written `&`, `<`, `>` and `"`; " +
|
|
624
|
+
"a `style` value as its declarations, each `property:value;` with the property in lower case and the value trimmed";
|
|
625
|
+
|
|
601
626
|
/**
|
|
602
627
|
* The same rules, as data — for a reader that implements them in another
|
|
603
628
|
* language, such as a backend checking content when it is written. Every list
|
|
@@ -632,7 +657,8 @@ export function allowlists() {
|
|
|
632
657
|
unsafeValue: UNSAFE_VALUE.source,
|
|
633
658
|
urlInAValue: "only url(#id), a reference within the document",
|
|
634
659
|
style: "declaration by declaration: a property of letters and -, a value with none of \\ < > { } /* and no unsafe value",
|
|
635
|
-
markup: "no comment, doctype
|
|
660
|
+
markup: "no comment, doctype, processing instruction or CDATA section",
|
|
661
|
+
written: WRITTEN,
|
|
636
662
|
},
|
|
637
663
|
imageData: {
|
|
638
664
|
raster: RASTER_DATA.source,
|
|
@@ -649,7 +675,8 @@ export function allowlists() {
|
|
|
649
675
|
unsafeValue: UNSAFE_VALUE.source,
|
|
650
676
|
urlInAValue: "only url(#id), a reference within the document",
|
|
651
677
|
style: "declaration by declaration: a property of letters and -, a value with none of \\ < > { } /* and no unsafe value",
|
|
652
|
-
markup: "no comment, doctype
|
|
678
|
+
markup: "no comment, doctype, processing instruction or CDATA section",
|
|
679
|
+
written: `${WRITTEN}; an \`href\` or \`xlink:href\` value trimmed`,
|
|
653
680
|
},
|
|
654
681
|
},
|
|
655
682
|
};
|