@uniweb/semantic-parser 1.7.0 → 1.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/builders/doc.js +84 -58
- package/src/sanitize.js +100 -0
package/package.json
CHANGED
package/src/builders/doc.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Reverse conversion: content structure →
|
|
2
|
+
* Reverse conversion: content structure → a ProseMirror document in the
|
|
3
|
+
* rich-text vocabulary (`@uniweb/content-reader`'s schema).
|
|
3
4
|
*
|
|
4
5
|
* Mirrors the forward parser (processors/sequence.js + processors/groups.js)
|
|
5
6
|
* so that parseContent(buildDoc(content)) roundtrips cleanly.
|
|
@@ -7,6 +8,16 @@
|
|
|
7
8
|
* Starter content uses plain strings (no HTML marks), so the conversion
|
|
8
9
|
* is straightforward — no need to reverse inline HTML formatting.
|
|
9
10
|
*
|
|
11
|
+
* ## ⭐ IT WRITES THE VOCABULARY, IN THE SHAPES MARKDOWN READS TO
|
|
12
|
+
*
|
|
13
|
+
* Every node is one content-reader writes for the same markdown: a picture, a
|
|
14
|
+
* video and a document are an `image` with its `role`, an icon an `image` with
|
|
15
|
+
* `role: 'icon'` inside a paragraph, a divider a `divider`. What this returns is
|
|
16
|
+
* stored as it is and opened like any stored document, so it holds what every
|
|
17
|
+
* reader of a stored document reads — never one editor's own node names.
|
|
18
|
+
* ⛔ Until 2026-10-15 it wrote an editor's TipTap nodes (`ImageBlock`,
|
|
19
|
+
* `UniwebIcon`, `Video`, `DividerBlock`), which the vocabulary does not hold.
|
|
20
|
+
*
|
|
10
21
|
* ## ⭐ IT ACCEPTS THE PARSER'S OWN VOCABULARY TOO — and that is a contract
|
|
11
22
|
*
|
|
12
23
|
* This builder was written with its own spelling for several concepts, and
|
|
@@ -20,7 +31,7 @@
|
|
|
20
31
|
* most naturally reach for:
|
|
21
32
|
*
|
|
22
33
|
* { links: [{ label, href }] } → buildDoc returns **null** outright
|
|
23
|
-
* { images: [{ url, alt }] } → an
|
|
34
|
+
* { images: [{ url, alt }] } → an image node with NO address
|
|
24
35
|
* { videos: [{ coverImg: '/p.jpg' }] } → the cover parses back as ""
|
|
25
36
|
* { lists: parsed.lists } → a text node whose `text` is an
|
|
26
37
|
* OBJECT — an invalid document
|
|
@@ -36,7 +47,7 @@
|
|
|
36
47
|
* differs from the parser's needs the same treatment, or it joins this list.
|
|
37
48
|
*/
|
|
38
49
|
|
|
39
|
-
// ---
|
|
50
|
+
// --- Node builders ---
|
|
40
51
|
|
|
41
52
|
function textNode(text) {
|
|
42
53
|
return { type: 'text', text }
|
|
@@ -103,21 +114,23 @@ function linkParagraph({ text, label, href, target }) {
|
|
|
103
114
|
}
|
|
104
115
|
}
|
|
105
116
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
117
|
+
/** The attributes an image may hold beside its address, copied when they hold a value. */
|
|
118
|
+
const IMAGE_EXTRAS = ['href', 'target', 'author', 'id', 'fit', 'position', 'loading']
|
|
119
|
+
|
|
120
|
+
function imageNode(image) {
|
|
121
|
+
const { src, url, alt = '', caption = '', placement, role, width, height } = image
|
|
122
|
+
// The node's attr is `src`, this builder's original spelling; the parsed
|
|
123
|
+
// field is `url`. Either one in, `src` out.
|
|
124
|
+
const attrs = { src: src || url, alt, role: role || 'image' }
|
|
110
125
|
if (caption) attrs.caption = caption
|
|
111
|
-
// The
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
|
|
126
|
+
// The picture's own size. Its proportions are these two, so nothing else
|
|
127
|
+
// carries them — ⛔ until 2026-10-15 this wrote an `aspect_ratio` computed
|
|
128
|
+
// from them, which the vocabulary does not hold.
|
|
129
|
+
if (width) attrs.width = width
|
|
130
|
+
if (height) attrs.height = height
|
|
115
131
|
if (placement) attrs.placement = placement
|
|
116
|
-
if (
|
|
117
|
-
|
|
118
|
-
attrs.aspect_ratio = { width, height, ratio: (height / width) * 100 }
|
|
119
|
-
}
|
|
120
|
-
return { type: 'ImageBlock', attrs }
|
|
132
|
+
for (const key of IMAGE_EXTRAS) if (image[key]) attrs[key] = image[key]
|
|
133
|
+
return { type: 'image', attrs }
|
|
121
134
|
}
|
|
122
135
|
|
|
123
136
|
// A document is STORED as the image row with `role: 'pdf'` — the node the
|
|
@@ -132,46 +145,58 @@ function documentNode({ url, src, alt = '', caption = '', preview, author, descr
|
|
|
132
145
|
return { type: 'image', attrs }
|
|
133
146
|
}
|
|
134
147
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if (
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
148
|
+
/**
|
|
149
|
+
* An icon, as markdown's `` reads: an `image` with `role: 'icon'`
|
|
150
|
+
* inside a paragraph — from a library by `library` and `name`, or from a file
|
|
151
|
+
* by its address.
|
|
152
|
+
*
|
|
153
|
+
* `svg` is MARKUP, and a stored document holds none (the vocabulary has no
|
|
154
|
+
* attribute for it), so an icon given only as markup is left out: the builder
|
|
155
|
+
* returns null rather than a node that names no picture. A file-sourced icon
|
|
156
|
+
* arrives as `url` (what the parser emits) or `src`.
|
|
157
|
+
*/
|
|
158
|
+
function iconNode({ src, url, library, name, size, color }) {
|
|
159
|
+
const attrs = { role: 'icon' }
|
|
160
|
+
if (src || url) {
|
|
161
|
+
attrs.src = src || url
|
|
162
|
+
} else if (name) {
|
|
163
|
+
// A parsed editor node may carry the family inside the name, `family:id`.
|
|
164
|
+
// The vocabulary holds the two apart, as `` reads.
|
|
165
|
+
if (!library && typeof name === 'string' && name.includes(':')) {
|
|
166
|
+
const [family, ...id] = name.split(':')
|
|
167
|
+
library = family
|
|
168
|
+
name = id.join(':')
|
|
169
|
+
}
|
|
170
|
+
if (library) attrs.library = library
|
|
171
|
+
attrs.name = name
|
|
172
|
+
} else {
|
|
173
|
+
return null
|
|
174
|
+
}
|
|
155
175
|
if (size) attrs.size = size
|
|
156
176
|
if (color) attrs.color = color
|
|
157
|
-
return { type: '
|
|
177
|
+
return { type: 'paragraph', content: [{ type: 'image', attrs }] }
|
|
158
178
|
}
|
|
159
179
|
|
|
160
|
-
|
|
161
|
-
|
|
180
|
+
const PLAYBACK_FLAGS = ['autoplay', 'muted', 'loop', 'controls']
|
|
181
|
+
|
|
182
|
+
function videoNode(video) {
|
|
183
|
+
const { src, url, alt, caption, placement, poster, coverImg } = video
|
|
184
|
+
const attrs = { src: src || url, role: 'video' }
|
|
185
|
+
if (alt) attrs.alt = alt
|
|
162
186
|
if (caption) attrs.caption = caption
|
|
163
|
-
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
if (
|
|
170
|
-
|
|
187
|
+
// The cover is `poster`, what the parser emits for a video and what a
|
|
188
|
+
// component reads. `coverImg` is an editor's spelling — a string, or an
|
|
189
|
+
// object whose `src` or `url` is the address; an `identifier` alone names
|
|
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
|
|
193
|
+
if (placement) attrs.placement = placement
|
|
194
|
+
for (const flag of PLAYBACK_FLAGS) if (typeof video[flag] === 'boolean') attrs[flag] = video[flag]
|
|
195
|
+
return { type: 'image', attrs }
|
|
171
196
|
}
|
|
172
197
|
|
|
173
|
-
function
|
|
174
|
-
return { type: '
|
|
198
|
+
function dividerNode() {
|
|
199
|
+
return { type: 'divider' }
|
|
175
200
|
}
|
|
176
201
|
|
|
177
202
|
/**
|
|
@@ -246,11 +271,11 @@ function listItemText(item) {
|
|
|
246
271
|
// --- Group builder ---
|
|
247
272
|
|
|
248
273
|
/**
|
|
249
|
-
* Build
|
|
274
|
+
* Build the nodes of a content group (main or item).
|
|
250
275
|
*
|
|
251
276
|
* @param {Object} group - Content structure: { pretitle, title, subtitle, paragraphs, images, ... }
|
|
252
277
|
* @param {number} titleLevel - Heading level for title (1 for main, 2 for items)
|
|
253
|
-
* @returns {Array}
|
|
278
|
+
* @returns {Array} the group's nodes, in the vocabulary
|
|
254
279
|
*/
|
|
255
280
|
function buildGroupNodes(group, titleLevel = 1, options = {}) {
|
|
256
281
|
const nodes = []
|
|
@@ -286,7 +311,7 @@ function buildGroupNodes(group, titleLevel = 1, options = {}) {
|
|
|
286
311
|
|
|
287
312
|
if (group.images) {
|
|
288
313
|
for (const img of group.images) {
|
|
289
|
-
nodes.push(
|
|
314
|
+
nodes.push(imageNode(img))
|
|
290
315
|
}
|
|
291
316
|
}
|
|
292
317
|
|
|
@@ -299,7 +324,8 @@ function buildGroupNodes(group, titleLevel = 1, options = {}) {
|
|
|
299
324
|
|
|
300
325
|
if (group.icons) {
|
|
301
326
|
for (const icon of group.icons) {
|
|
302
|
-
|
|
327
|
+
const node = iconNode(icon)
|
|
328
|
+
if (node) nodes.push(node)
|
|
303
329
|
}
|
|
304
330
|
}
|
|
305
331
|
|
|
@@ -351,10 +377,10 @@ function buildGroupNodes(group, titleLevel = 1, options = {}) {
|
|
|
351
377
|
// --- Main export ---
|
|
352
378
|
|
|
353
379
|
/**
|
|
354
|
-
* Build a
|
|
380
|
+
* Build a document in the vocabulary from a content structure.
|
|
355
381
|
*
|
|
356
382
|
* This is the reverse of parseContent(): given a flat content object
|
|
357
|
-
* (title, paragraphs, items, etc.), produce a
|
|
383
|
+
* (title, paragraphs, items, etc.), produce a ProseMirror document that
|
|
358
384
|
* roundtrips through parseContent() to yield the same structure.
|
|
359
385
|
*
|
|
360
386
|
* @param {Object} content - Content structure (same shape as parseContent output / starter)
|
|
@@ -362,7 +388,7 @@ function buildGroupNodes(group, titleLevel = 1, options = {}) {
|
|
|
362
388
|
* @param {string} [options.dataLanguage='yaml'] - the fence a tagged data block
|
|
363
389
|
* is written as. It is recorded on the node because content-writer reads it
|
|
364
390
|
* back to choose the fence, and a parsed value cannot say which one it was.
|
|
365
|
-
* @returns {Object|null}
|
|
391
|
+
* @returns {Object|null} a document { type: 'doc', content: [...] }, or null if empty
|
|
366
392
|
*/
|
|
367
393
|
function buildDoc(content, options = {}) {
|
|
368
394
|
if (!content) return null
|
|
@@ -372,10 +398,10 @@ function buildDoc(content, options = {}) {
|
|
|
372
398
|
// Main group content (title level 1)
|
|
373
399
|
nodes.push(...buildGroupNodes(content, 1, options))
|
|
374
400
|
|
|
375
|
-
// Items: separated by
|
|
401
|
+
// Items: separated by a divider (mirrors divider-based grouping in groups.js)
|
|
376
402
|
if (content.items && content.items.length > 0) {
|
|
377
403
|
for (const item of content.items) {
|
|
378
|
-
nodes.push(
|
|
404
|
+
nodes.push(dividerNode())
|
|
379
405
|
// Item headings use level 2 (one below main H1)
|
|
380
406
|
nodes.push(...buildGroupNodes(item, 2, options))
|
|
381
407
|
}
|
package/src/sanitize.js
CHANGED
|
@@ -441,6 +441,19 @@ export function sanitizeSVG(svg) {
|
|
|
441
441
|
return rebuild(svg, SVG);
|
|
442
442
|
}
|
|
443
443
|
|
|
444
|
+
/**
|
|
445
|
+
* 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.
|
|
448
|
+
*
|
|
449
|
+
* @param {string} svg
|
|
450
|
+
* @returns {Array<{reason: 'element'|'attribute'|'style'|'markup', element?: string, attribute?: string, value?: string}>}
|
|
451
|
+
*/
|
|
452
|
+
export function inspectSVG(svg) {
|
|
453
|
+
if (typeof svg !== "string" || !svg) return [];
|
|
454
|
+
return inspect(svg, SVG);
|
|
455
|
+
}
|
|
456
|
+
|
|
444
457
|
/**
|
|
445
458
|
* Whether a link may be followed: a relative URL, or `http:`, `https:`,
|
|
446
459
|
* `mailto:`, `tel:`, or the framework's `page:` / `topic:` references. Control
|
|
@@ -454,6 +467,75 @@ export function isSafeLink(url) {
|
|
|
454
467
|
return typeof url === "string" && isSafeUrl(url);
|
|
455
468
|
}
|
|
456
469
|
|
|
470
|
+
/**
|
|
471
|
+
* Whether an address may be loaded as an image: a link a page may follow
|
|
472
|
+
* (`isSafeLink`), or the image itself, as data (`isImageData`).
|
|
473
|
+
*
|
|
474
|
+
* @param {string} url
|
|
475
|
+
* @returns {boolean}
|
|
476
|
+
*/
|
|
477
|
+
export function isImageSource(url) {
|
|
478
|
+
return isSafeLink(url) || isImageData(url);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* 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
|
|
485
|
+
* picture an `<img>` loads runs no script, and a built-in starter image is one.
|
|
486
|
+
* The URL is read as a browser reads it: tab and newline removed, leading and
|
|
487
|
+
* trailing spaces and controls trimmed.
|
|
488
|
+
*
|
|
489
|
+
* @param {string} url
|
|
490
|
+
* @returns {boolean}
|
|
491
|
+
*/
|
|
492
|
+
export function isImageData(url) {
|
|
493
|
+
if (typeof url !== "string") return false;
|
|
494
|
+
const address = url.replace(/[\t\n\r]/g, "").replace(/^[\u0000- ]+|[\u0000- ]+$/g, "");
|
|
495
|
+
if (RASTER_DATA.test(address)) return true;
|
|
496
|
+
const svg = svgOfData(address);
|
|
497
|
+
return svg !== null && inspect(svg, SVG).length === 0;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const SVG_DATA = /^data:image\/svg\+xml((?:;[^,;]*)*?)(;base64)?,([\s\S]*)$/i;
|
|
501
|
+
|
|
502
|
+
/** The markup an SVG `data:` URL holds, decoded as a browser decodes it; null for any other URL. */
|
|
503
|
+
function svgOfData(address) {
|
|
504
|
+
const data = SVG_DATA.exec(address);
|
|
505
|
+
if (!data) return null;
|
|
506
|
+
const body = percentDecode(data[3]);
|
|
507
|
+
if (!data[2]) return body;
|
|
508
|
+
try {
|
|
509
|
+
const binary = atob(body.replace(/[\t\n\f\r ]+/g, ""));
|
|
510
|
+
return new TextDecoder().decode(Uint8Array.from(binary, (c) => c.charCodeAt(0)));
|
|
511
|
+
} catch {
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** Percent-decode as a URL is: `%` and two hex digits is a byte, anything else stays, and the bytes are read as UTF-8. */
|
|
517
|
+
function percentDecode(text) {
|
|
518
|
+
if (!text.includes("%")) return text;
|
|
519
|
+
const bytes = [];
|
|
520
|
+
const utf8 = new TextEncoder();
|
|
521
|
+
let run = "";
|
|
522
|
+
const flush = () => {
|
|
523
|
+
for (const byte of utf8.encode(run)) bytes.push(byte);
|
|
524
|
+
run = "";
|
|
525
|
+
};
|
|
526
|
+
for (let i = 0; i < text.length; i++) {
|
|
527
|
+
if (text[i] === "%" && /^[0-9a-f]{2}$/i.test(text.slice(i + 1, i + 3))) {
|
|
528
|
+
flush();
|
|
529
|
+
bytes.push(parseInt(text.slice(i + 1, i + 3), 16));
|
|
530
|
+
i += 2;
|
|
531
|
+
} else {
|
|
532
|
+
run += text[i];
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
flush();
|
|
536
|
+
return new TextDecoder().decode(new Uint8Array(bytes));
|
|
537
|
+
}
|
|
538
|
+
|
|
457
539
|
// ─── Values content may hold ─────────────────────────────────────────────────
|
|
458
540
|
//
|
|
459
541
|
// The rules below decide what a text's marks may put into the markup the parser
|
|
@@ -552,5 +634,23 @@ export function allowlists() {
|
|
|
552
634
|
style: "declaration by declaration: a property of letters and -, a value with none of \\ < > { } /* and no unsafe value",
|
|
553
635
|
markup: "no comment, doctype or processing instruction; CDATA is text",
|
|
554
636
|
},
|
|
637
|
+
imageData: {
|
|
638
|
+
raster: RASTER_DATA.source,
|
|
639
|
+
rasterFlags: "i",
|
|
640
|
+
svg: {
|
|
641
|
+
mediaType: "image/svg+xml, with any parameters, and `;base64` when the body is base64",
|
|
642
|
+
decoding: "the body percent-decoded as a URL is (`%` and two hex digits is a byte, anything else stays), then base64-decoded when marked, read as UTF-8",
|
|
643
|
+
elements: [...SVG_ELEMENTS.values()],
|
|
644
|
+
attributes: [...SVG_ATTRIBUTES.values()],
|
|
645
|
+
commonAttributes: "class, id, role, dir, aria-<letters>, data-<letters, digits and ->",
|
|
646
|
+
droppedWithContent: [...DROP_WITH_CONTENT],
|
|
647
|
+
neverAttributes: "on…, in any case; src",
|
|
648
|
+
references: "href and xlink:href only: a fragment, `#` then a letter then letters, digits, _ . : or -; on `image`, also a raster image matching `raster`, or a URL with no scheme or one of the link schemes",
|
|
649
|
+
unsafeValue: UNSAFE_VALUE.source,
|
|
650
|
+
urlInAValue: "only url(#id), a reference within the document",
|
|
651
|
+
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",
|
|
653
|
+
},
|
|
654
|
+
},
|
|
555
655
|
};
|
|
556
656
|
}
|