@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 CHANGED
@@ -73,16 +73,22 @@ result = {
73
73
 
74
74
  // Body fields
75
75
  paragraphs: ["Get started today."],
76
- links: [], // All links (including buttons, documents)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/semantic-parser",
3
- "version": "1.4.1",
3
+ "version": "1.5.1",
4
4
  "description": "Semantic parser for ProseMirror/TipTap content structures",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -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
- export { parseContent, buildDoc, resolveAssetUrl, ASSET_SLOTS, lintContent };
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 };
@@ -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 → images.
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
- return {
319
- type: "document-group",
320
- documents:
321
- node.content
322
- ?.filter((c) => c.type === "document")
323
- .map((doc) => parseDocumentBlock(doc.attrs)) || [],
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
- const isVideo = item.attrs?.role === "video";
617
- items.push({
618
- type: isVideo ? "video" : "image",
619
- attrs: isVideo
620
- ? parseMarkdownVideo(item.attrs, options)
621
- : parseImgBlock(item.attrs || {}, options),
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: `![Annual report](./report.pdf){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
+ }