@uniweb/semantic-parser 1.4.1 → 1.5.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/README.md +7 -1
- package/package.json +1 -1
- package/src/builders/doc.js +18 -0
- package/src/index.js +4 -1
- package/src/processors/groups.js +22 -13
- package/src/processors/sequence.js +104 -15
- package/src/utils/mime.js +90 -0
package/README.md
CHANGED
|
@@ -73,16 +73,22 @@ result = {
|
|
|
73
73
|
|
|
74
74
|
// Body fields
|
|
75
75
|
paragraphs: ["Get started today."],
|
|
76
|
-
links: [], // All links (including buttons
|
|
76
|
+
links: [], // All links (including buttons)
|
|
77
77
|
images: [],
|
|
78
78
|
videos: [],
|
|
79
|
+
documents: [], // Files marked {role=pdf} — { url, name, mime, size?, alt,
|
|
80
|
+
// caption, preview?, … }
|
|
79
81
|
icons: [],
|
|
80
82
|
lists: [],
|
|
81
83
|
quotes: [],
|
|
82
84
|
insets: [], // Inline @Component references — { refId }
|
|
85
|
+
media: [], // Images, videos and insets on their own line, in the
|
|
86
|
+
// order written — each with its `kind`
|
|
83
87
|
snippets: [], // Fenced code blocks — { language, code }
|
|
84
88
|
data: {}, // Structured data (tagged data blocks, forms, cards)
|
|
85
89
|
headings: [], // Only from nested content (quote/list bodies)
|
|
90
|
+
// tables: [], // Only when the content has one — { rows, attrs }
|
|
91
|
+
// math: [], // Only when the content has one — { id, latex, mathml }
|
|
86
92
|
|
|
87
93
|
// Additional content groups (from headings after content)
|
|
88
94
|
// Each item has the SAME flat structure as the top level — title,
|
package/package.json
CHANGED
package/src/builders/doc.js
CHANGED
|
@@ -116,6 +116,18 @@ function imageBlock({ src, url, alt = '', caption = '', direction, role, width,
|
|
|
116
116
|
return { type: 'ImageBlock', attrs }
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
// A document is STORED as the image row with `role: 'pdf'` — the node the
|
|
120
|
+
// markdown reader writes and the parser reads as a `document` — so that is what
|
|
121
|
+
// this emits. `url` (the parser's spelling) or `src` (this builder's) in.
|
|
122
|
+
function documentNode({ url, src, alt = '', caption = '', preview, author, description }) {
|
|
123
|
+
const attrs = { src: url || src, alt, role: 'pdf' }
|
|
124
|
+
if (caption) attrs.caption = caption
|
|
125
|
+
if (preview) attrs.preview = preview
|
|
126
|
+
if (author) attrs.author = author
|
|
127
|
+
if (description) attrs.description = description
|
|
128
|
+
return { type: 'image', attrs }
|
|
129
|
+
}
|
|
130
|
+
|
|
119
131
|
function iconNode({ src, url, svg, library, name, size, color }) {
|
|
120
132
|
// UniwebIcon supports multiple source types.
|
|
121
133
|
//
|
|
@@ -293,6 +305,12 @@ function buildGroupNodes(group, titleLevel = 1, options = {}) {
|
|
|
293
305
|
}
|
|
294
306
|
}
|
|
295
307
|
|
|
308
|
+
if (group.documents) {
|
|
309
|
+
for (const doc of group.documents) {
|
|
310
|
+
nodes.push(documentNode(doc))
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
296
314
|
if (group.lists) {
|
|
297
315
|
for (const list of group.lists) {
|
|
298
316
|
const node = bulletList(list)
|
package/src/index.js
CHANGED
|
@@ -2,6 +2,7 @@ import { processSequence, resolveAssetUrl, ASSET_SLOTS } from "./processors/sequ
|
|
|
2
2
|
import { processGroups } from "./processors/groups.js";
|
|
3
3
|
import { buildDoc } from "./builders/doc.js";
|
|
4
4
|
import { lintContent } from "./lint.js";
|
|
5
|
+
import { MIME_TYPES, mimeFor } from "./utils/mime.js";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Parse ProseMirror/TipTap content into semantic structure
|
|
@@ -54,4 +55,6 @@ function parseContent(doc, options = {}) {
|
|
|
54
55
|
// `lintContent` diagnoses grouping near-misses (see src/lint.js). It never
|
|
55
56
|
// affects a parse — parsing stays deterministic; the judgment lives here so
|
|
56
57
|
// the CLI, the dev server, and an editor surface the same findings.
|
|
57
|
-
|
|
58
|
+
// `MIME_TYPES` / `mimeFor` are the one extension → media-type table: a document's
|
|
59
|
+
// `mime` here, a file's in the build (src/utils/mime.js).
|
|
60
|
+
export { parseContent, buildDoc, resolveAssetUrl, ASSET_SLOTS, lintContent, MIME_TYPES, mimeFor };
|
package/src/processors/groups.js
CHANGED
|
@@ -15,7 +15,9 @@ function flattenGroup(group) {
|
|
|
15
15
|
icons: group.body.icons || [],
|
|
16
16
|
lists: group.body.lists || [],
|
|
17
17
|
videos: group.body.videos || [],
|
|
18
|
+
documents: group.body.documents || [],
|
|
18
19
|
insets: group.body.insets || [],
|
|
20
|
+
media: group.body.media || [],
|
|
19
21
|
snippets: group.body.snippets || [],
|
|
20
22
|
data: group.body.data || {},
|
|
21
23
|
quotes: group.body.quotes || [],
|
|
@@ -68,6 +70,7 @@ function processGroups(sequence, options = {}) {
|
|
|
68
70
|
lists: [],
|
|
69
71
|
videos: [],
|
|
70
72
|
insets: [],
|
|
73
|
+
media: [],
|
|
71
74
|
snippets: [],
|
|
72
75
|
data: {},
|
|
73
76
|
quotes: [],
|
|
@@ -112,6 +115,7 @@ function processGroups(sequence, options = {}) {
|
|
|
112
115
|
lists: [],
|
|
113
116
|
videos: [],
|
|
114
117
|
insets: [],
|
|
118
|
+
media: [],
|
|
115
119
|
data: {},
|
|
116
120
|
quotes: [],
|
|
117
121
|
headings: [],
|
|
@@ -303,7 +307,15 @@ function processGroupContent(elements, options = {}) {
|
|
|
303
307
|
images: [],
|
|
304
308
|
icons: [],
|
|
305
309
|
videos: [],
|
|
310
|
+
documents: [],
|
|
306
311
|
insets: [],
|
|
312
|
+
// ⭐ The group's visual media — each block-level image, video and inset, in the
|
|
313
|
+
// order written, tagged with its `kind`: what a media slot holds. `images`,
|
|
314
|
+
// `videos` and `insets` keep what they hold, and keep the author's order only
|
|
315
|
+
// within one kind; across kinds only this does (and `sequence`). An image
|
|
316
|
+
// inside a sentence belongs to the text, so it is not here — nor is a
|
|
317
|
+
// document, which is not visual media.
|
|
318
|
+
media: [],
|
|
307
319
|
snippets: [],
|
|
308
320
|
paragraphs: [],
|
|
309
321
|
links: [],
|
|
@@ -380,11 +392,17 @@ function processGroupContent(elements, options = {}) {
|
|
|
380
392
|
body.icons.push(element.attrs);
|
|
381
393
|
} else {
|
|
382
394
|
body.images.push(preserveProps);
|
|
395
|
+
body.media.push({ ...preserveProps, kind: "image" });
|
|
383
396
|
}
|
|
384
397
|
break;
|
|
385
398
|
|
|
386
399
|
case "video":
|
|
387
400
|
body.videos.push(preserveProps);
|
|
401
|
+
body.media.push({ ...preserveProps, kind: "video" });
|
|
402
|
+
break;
|
|
403
|
+
|
|
404
|
+
case "document":
|
|
405
|
+
body.documents.push(preserveProps);
|
|
388
406
|
break;
|
|
389
407
|
|
|
390
408
|
case "link":
|
|
@@ -493,6 +511,7 @@ function processGroupContent(elements, options = {}) {
|
|
|
493
511
|
|
|
494
512
|
case "inset":
|
|
495
513
|
body.insets.push({ refId: element.refId });
|
|
514
|
+
body.media.push({ refId: element.refId, kind: "inset" });
|
|
496
515
|
break;
|
|
497
516
|
|
|
498
517
|
case "form":
|
|
@@ -520,19 +539,6 @@ function processGroupContent(elements, options = {}) {
|
|
|
520
539
|
});
|
|
521
540
|
break;
|
|
522
541
|
|
|
523
|
-
case "document-group":
|
|
524
|
-
// Map documents to links with role=document
|
|
525
|
-
element.documents.forEach(doc => {
|
|
526
|
-
body.links.push({
|
|
527
|
-
href: doc.href || doc.downloadUrl || '',
|
|
528
|
-
label: doc.title || '',
|
|
529
|
-
role: 'document',
|
|
530
|
-
download: true,
|
|
531
|
-
preview: doc.coverImg,
|
|
532
|
-
fileType: doc.fileType,
|
|
533
|
-
});
|
|
534
|
-
});
|
|
535
|
-
break;
|
|
536
542
|
}
|
|
537
543
|
}
|
|
538
544
|
}
|
|
@@ -588,6 +594,9 @@ function processInlineElements(children, body) {
|
|
|
588
594
|
// `{role=video}` belongs in videos[] for the same reason a hoisted
|
|
589
595
|
// one does.
|
|
590
596
|
body.videos.push(item.attrs);
|
|
597
|
+
} else if (item.type === "document") {
|
|
598
|
+
// And an inline `{role=pdf}` in documents[].
|
|
599
|
+
body.documents.push(item.attrs);
|
|
591
600
|
} else if (item.type === "link") {
|
|
592
601
|
// Handle inline links extracted from paragraph text nodes
|
|
593
602
|
body.links.push(item.attrs);
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { mimeFor, fileNameOf } from "../utils/mime.js";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* HTML-attribute encoding for values placed inside double-quoted
|
|
3
5
|
* attributes. Backslashes and braces (common in LaTeX) pass through
|
|
@@ -210,7 +212,19 @@ function createSequenceElement(node, options = {}) {
|
|
|
210
212
|
case "image":
|
|
211
213
|
// `role` decides which content array this lands in — the contract
|
|
212
214
|
// published in docs/reference/content-structure.md ("Media Assets"):
|
|
213
|
-
// icon → icons, video → videos, everything else →
|
|
215
|
+
// icon → icons, video → videos, pdf → documents, everything else →
|
|
216
|
+
// images.
|
|
217
|
+
//
|
|
218
|
+
// A document is stored as this node, as a video is, and delivered as
|
|
219
|
+
// a kind of its own (`parseMarkdownDocument`). Until 2026-09-29 it
|
|
220
|
+
// landed in `images`, drawn as an image of the file, while the
|
|
221
|
+
// runtime had guaranteed an empty `content.documents` since January.
|
|
222
|
+
if (attrs?.role === "pdf") {
|
|
223
|
+
return {
|
|
224
|
+
type: "document",
|
|
225
|
+
attrs: parseMarkdownDocument(attrs, options),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
214
228
|
//
|
|
215
229
|
// The video branch was missing until 2026-07-30: every
|
|
216
230
|
// `{role=video}` node ran parseImgBlock, which does not carry
|
|
@@ -315,13 +329,16 @@ function createSequenceElement(node, options = {}) {
|
|
|
315
329
|
};
|
|
316
330
|
|
|
317
331
|
case "document-group":
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
332
|
+
// The editor's older container: one `document` element per document
|
|
333
|
+
// it holds, so a document is one kind of content whichever node
|
|
334
|
+
// wrote it (`documentFromEditorNode`). Until 2026-09-29 this was a
|
|
335
|
+
// `document-group` element whose documents became `content.links`.
|
|
336
|
+
return (node.content || [])
|
|
337
|
+
.filter((c) => c.type === "document")
|
|
338
|
+
.map((doc) => ({
|
|
339
|
+
type: "document",
|
|
340
|
+
attrs: documentFromEditorNode(doc.attrs),
|
|
341
|
+
}));
|
|
325
342
|
|
|
326
343
|
// The editor renamed this node to `StructuredContent` (2026-07-31,
|
|
327
344
|
// editor-internal). Both are accepted: the new name is what the editor
|
|
@@ -613,13 +630,15 @@ function processInlineElements(content, options = {}) {
|
|
|
613
630
|
// image silently deleted. groups.js has carried the receiving
|
|
614
631
|
// branch (and a comment describing exactly this failure) since it
|
|
615
632
|
// was written, but nothing ever emitted the item it reads.
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
attrs:
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
}
|
|
633
|
+
// The role partitions it as the block case does.
|
|
634
|
+
const role = item.attrs?.role;
|
|
635
|
+
if (role === "video") {
|
|
636
|
+
items.push({ type: "video", attrs: parseMarkdownVideo(item.attrs, options) });
|
|
637
|
+
} else if (role === "pdf") {
|
|
638
|
+
items.push({ type: "document", attrs: parseMarkdownDocument(item.attrs, options) });
|
|
639
|
+
} else {
|
|
640
|
+
items.push({ type: "image", attrs: parseImgBlock(item.attrs || {}, options) });
|
|
641
|
+
}
|
|
623
642
|
} else if (item.type === "math-inline" || item.type === "math_inline") {
|
|
624
643
|
items.push(item);
|
|
625
644
|
} else if (item.type === "inset_placeholder") {
|
|
@@ -802,6 +821,76 @@ function parseCardBlock(itemAttrs) {
|
|
|
802
821
|
};
|
|
803
822
|
}
|
|
804
823
|
|
|
824
|
+
// A document authored in markdown: `{role=pdf …}`.
|
|
825
|
+
//
|
|
826
|
+
// Read through `parseImgBlock`, which resolves the address and the preview's the
|
|
827
|
+
// way every image's are, and then narrowed to what a document IS: where the file
|
|
828
|
+
// is, what it is called, and the resource's own metadata. The image layout keys
|
|
829
|
+
// (direction, size, credit) describe a picture, not a file, and are left out.
|
|
830
|
+
function parseMarkdownDocument(itemAttrs, options) {
|
|
831
|
+
const { url, alt, caption, role, href, target, id, preview, author, description } =
|
|
832
|
+
parseImgBlock(itemAttrs || {}, options);
|
|
833
|
+
return {
|
|
834
|
+
url,
|
|
835
|
+
...fileFacts(url, itemAttrs),
|
|
836
|
+
alt,
|
|
837
|
+
caption,
|
|
838
|
+
role,
|
|
839
|
+
...(href && { href }),
|
|
840
|
+
...(target && { target }),
|
|
841
|
+
...(id && { id }),
|
|
842
|
+
...(preview && { preview }),
|
|
843
|
+
...(author && { author }),
|
|
844
|
+
...(description && { description }),
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* A document's file, as a file record's value names it: `name` (the file's name) and
|
|
850
|
+
* `mime` from its address, and `size` — known only where the file is — when a
|
|
851
|
+
* producer stamped one. A stamped `name` or `mime` wins over the address: the build
|
|
852
|
+
* copies a local file under a hashed name, so it stamps the one the author gave it.
|
|
853
|
+
* ✅ The field names are a file record's [Diego, 2026-09-29], so one component can
|
|
854
|
+
* render a downloads list from either.
|
|
855
|
+
*/
|
|
856
|
+
function fileFacts(url, attrs = {}) {
|
|
857
|
+
const name = typeof attrs?.name === "string" && attrs.name ? attrs.name : fileNameOf(url);
|
|
858
|
+
const mime = typeof attrs?.mime === "string" && attrs.mime ? attrs.mime : name ? mimeFor(name) : "";
|
|
859
|
+
const size = Number.isFinite(attrs?.size) ? attrs.size : null;
|
|
860
|
+
return {
|
|
861
|
+
...(name && { name }),
|
|
862
|
+
...(mime && { mime }),
|
|
863
|
+
...(size !== null && { size }),
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// The editor's older `document` node — its `title`, `coverImg` and `src` (or
|
|
868
|
+
// asset identifier) — read into the same shape a markdown document has.
|
|
869
|
+
function documentFromEditorNode(itemAttrs) {
|
|
870
|
+
const {
|
|
871
|
+
href,
|
|
872
|
+
downloadUrl,
|
|
873
|
+
title = "",
|
|
874
|
+
alt = "",
|
|
875
|
+
coverImg,
|
|
876
|
+
author,
|
|
877
|
+
description,
|
|
878
|
+
fileType,
|
|
879
|
+
} = parseDocumentBlock(itemAttrs || {});
|
|
880
|
+
const url = href || downloadUrl || "";
|
|
881
|
+
return {
|
|
882
|
+
url,
|
|
883
|
+
...fileFacts(url, itemAttrs),
|
|
884
|
+
alt: alt || title,
|
|
885
|
+
caption: title,
|
|
886
|
+
role: "pdf",
|
|
887
|
+
...(coverImg && { preview: coverImg }),
|
|
888
|
+
...(author && { author }),
|
|
889
|
+
...(description && { description }),
|
|
890
|
+
...(fileType && { fileType }),
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
|
|
805
894
|
function parseDocumentBlock(itemAttrs) {
|
|
806
895
|
const { src, info = {}, coverImg = null, ...others } = itemAttrs;
|
|
807
896
|
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a file is, by its extension — the one extension → media-type table.
|
|
3
|
+
*
|
|
4
|
+
* ⭐ ONE TABLE, HERE, because two producers need it and this is the package both can
|
|
5
|
+
* reach: the parser gives a document its `mime` from its address, and the build gives
|
|
6
|
+
* a file record — and a document file it copies — theirs (`@uniweb/build`'s
|
|
7
|
+
* `site/file-records.js` imports `mimeFor` from here, as it imports `ASSET_SLOTS`).
|
|
8
|
+
* The parser has no dependencies, so the table cannot live in the build and be
|
|
9
|
+
* reached from here. ⛔ Do not write a second.
|
|
10
|
+
*
|
|
11
|
+
* Anything not here is `application/octet-stream`.
|
|
12
|
+
*/
|
|
13
|
+
export const MIME_TYPES = {
|
|
14
|
+
pdf: "application/pdf",
|
|
15
|
+
doc: "application/msword",
|
|
16
|
+
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
17
|
+
xls: "application/vnd.ms-excel",
|
|
18
|
+
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
19
|
+
ppt: "application/vnd.ms-powerpoint",
|
|
20
|
+
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
21
|
+
odt: "application/vnd.oasis.opendocument.text",
|
|
22
|
+
ods: "application/vnd.oasis.opendocument.spreadsheet",
|
|
23
|
+
odp: "application/vnd.oasis.opendocument.presentation",
|
|
24
|
+
rtf: "application/rtf",
|
|
25
|
+
epub: "application/epub+zip",
|
|
26
|
+
zip: "application/zip",
|
|
27
|
+
gz: "application/gzip",
|
|
28
|
+
tar: "application/x-tar",
|
|
29
|
+
csv: "text/csv",
|
|
30
|
+
tsv: "text/tab-separated-values",
|
|
31
|
+
txt: "text/plain",
|
|
32
|
+
md: "text/markdown",
|
|
33
|
+
json: "application/json",
|
|
34
|
+
xml: "application/xml",
|
|
35
|
+
yml: "application/yaml",
|
|
36
|
+
yaml: "application/yaml",
|
|
37
|
+
html: "text/html",
|
|
38
|
+
png: "image/png",
|
|
39
|
+
jpg: "image/jpeg",
|
|
40
|
+
jpeg: "image/jpeg",
|
|
41
|
+
gif: "image/gif",
|
|
42
|
+
svg: "image/svg+xml",
|
|
43
|
+
webp: "image/webp",
|
|
44
|
+
avif: "image/avif",
|
|
45
|
+
mp3: "audio/mpeg",
|
|
46
|
+
wav: "audio/wav",
|
|
47
|
+
ogg: "audio/ogg",
|
|
48
|
+
m4a: "audio/mp4",
|
|
49
|
+
mp4: "video/mp4",
|
|
50
|
+
webm: "video/webm",
|
|
51
|
+
mov: "video/quicktime",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The extension of a file name or path, lower-cased, without its dot — `''` for none,
|
|
56
|
+
* and for a dotfile (`.env`), as Node's `extname` reads them.
|
|
57
|
+
*/
|
|
58
|
+
function extensionOf(name) {
|
|
59
|
+
const base = String(name || "").split(/[\\/]/).pop();
|
|
60
|
+
const dot = base.lastIndexOf(".");
|
|
61
|
+
return dot > 0 ? base.slice(dot + 1).toLowerCase() : "";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The media type of a file, by its extension.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} name - a file name or path
|
|
68
|
+
* @returns {string}
|
|
69
|
+
*/
|
|
70
|
+
export function mimeFor(name) {
|
|
71
|
+
return MIME_TYPES[extensionOf(name)] || "application/octet-stream";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The file name an address ends in — `/files/Annual%20report.pdf?v=2` →
|
|
76
|
+
* `Annual report.pdf` — or `''` when it names none.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} url
|
|
79
|
+
* @returns {string}
|
|
80
|
+
*/
|
|
81
|
+
export function fileNameOf(url) {
|
|
82
|
+
const text = String(url || "");
|
|
83
|
+
if (/^data:/i.test(text)) return "";
|
|
84
|
+
const segment = text.split(/[?#]/)[0].split("/").pop() || "";
|
|
85
|
+
try {
|
|
86
|
+
return decodeURIComponent(segment);
|
|
87
|
+
} catch {
|
|
88
|
+
return segment;
|
|
89
|
+
}
|
|
90
|
+
}
|