@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 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 `horizontalRule` explicitly closes the current group; it composes with the heading rules rather than replacing them.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/semantic-parser",
3
- "version": "1.9.0",
3
+ "version": "1.11.0",
4
4
  "description": "Semantic parser for ProseMirror/TipTap content structures",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -500,19 +500,10 @@ function processGroupContent(elements, options = {}) {
500
500
  body.media.push({ refId: element.refId, kind: "inset" });
501
501
  break;
502
502
 
503
- case "form":
504
- // Route a form's data by the schema id it targets, so it
505
- // shares a namespace with tagged markdown data blocks
506
- // (```yaml:<id>``` also lands at data[<id>]). When no
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: textStyle, highlight, bold, italic, link
458
- // This ensures proper nesting
459
-
460
- // textStyle (color)
461
- if (marks.some((mark) => mark.type === "textStyle")) {
462
- const color = marks.find(
463
- (mark) => mark.type === "textStyle"
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 ![](./cells.jpg)
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. Empty when the markup is within the
351
- * policy, which is when a rebuild changes nothing but spelling (quotes,
352
- * escapes, a style's spacing).
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. Empty when the
416
- * markup is within it.
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 the markup is
447
- * within it.
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 within
484
- * the allowlist `sanitizeSVG` rebuilds from. ⭐ Admitted [Diego, 2026-10-16]: a
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 `&amp;`, `&lt;` and `&gt;`, 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 `&amp;`, `&lt;`, `&gt;` and `&quot;`; " +
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 or processing instruction; CDATA is text",
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 or processing instruction; CDATA is text",
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
  };