@uniweb/semantic-parser 1.1.6 → 1.1.8

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
@@ -167,7 +167,7 @@ Key patterns:
167
167
  - **Pretitle Pattern**: Any heading followed by a more important heading (e.g., H3→H1, H2→H1, H6→H5, etc.)
168
168
  - **Banner Pattern**: Image (with banner role or followed by heading) at start of first group
169
169
  - **Divider Mode**: Presence of any `horizontalRule` switches entire document to divider-based grouping
170
- - **Heading Groups**: Consecutive headings with increasing levels are consumed together
170
+ - **Heading Groups**: Consecutive headings are consumed together only when each is exactly one level deeper (H1→H2 yes, H1→H3 no — skipped levels start a new group)
171
171
  - **Main Content**: First group is main if it's the only group OR has lower heading level than second group
172
172
  - **Body Headings**: Headings after the title and subtitle slots are collected in `body.headings` in document order
173
173
 
package/README.md CHANGED
@@ -153,6 +153,8 @@ Groups are created based on heading patterns. A new group starts when:
153
153
  - Multiple H1s appear (no main content created)
154
154
  - The heading level indicates a new section
155
155
 
156
+ **Heading group formation:** Consecutive headings are consumed as a single semantic unit only when each subsequent heading is _exactly one level deeper_ than the previous (e.g., H1→H2→H3). Skipping levels (H1→H3) breaks the group — the deeper heading starts a new group instead of becoming a subtitle. This reflects natural document structure: a large level gap signals items, not a title-subtitle pair.
157
+
156
158
  **Pretitle Detection:** Any heading followed by a more important heading is automatically detected as a pretitle:
157
159
  - H3 before H1 → pretitle ✅
158
160
  - H2 before H1 → pretitle ✅
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/semantic-parser",
3
- "version": "1.1.6",
3
+ "version": "1.1.8",
4
4
  "description": "Semantic parser for ProseMirror/TipTap content structures",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Reverse conversion: content structure → TipTap document.
3
+ *
4
+ * Mirrors the forward parser (processors/sequence.js + processors/groups.js)
5
+ * so that parseContent(buildDoc(content)) roundtrips cleanly.
6
+ *
7
+ * Starter content uses plain strings (no HTML marks), so the conversion
8
+ * is straightforward — no need to reverse inline HTML formatting.
9
+ */
10
+
11
+ // --- TipTap node builders ---
12
+
13
+ function textNode(text) {
14
+ return { type: 'text', text }
15
+ }
16
+
17
+ function heading(level, text) {
18
+ if (!text) return null
19
+ // Multi-line title: string[] → multiple headings at same level
20
+ if (Array.isArray(text)) {
21
+ return text.map(t => heading(level, t)).filter(Boolean)
22
+ }
23
+ return {
24
+ type: 'heading',
25
+ attrs: { level },
26
+ content: [textNode(text)],
27
+ }
28
+ }
29
+
30
+ function paragraph(text) {
31
+ if (!text) return null
32
+ return {
33
+ type: 'paragraph',
34
+ content: [textNode(text)],
35
+ }
36
+ }
37
+
38
+ function linkParagraph({ text, href, target }) {
39
+ if (!text || !href) return null
40
+ const mark = { type: 'link', attrs: { href } }
41
+ if (target) mark.attrs.target = target
42
+ return {
43
+ type: 'paragraph',
44
+ content: [{ type: 'text', text, marks: [mark] }],
45
+ }
46
+ }
47
+
48
+ function imageBlock({ src, alt = '', caption = '', direction, role, width, height }) {
49
+ const attrs = { url: src, alt }
50
+ if (caption) attrs.caption = caption
51
+ if (direction) attrs.direction = direction
52
+ if (role) attrs.role = role
53
+ if (width && height) {
54
+ attrs.aspect_ratio = { width, height, ratio: (height / width) * 100 }
55
+ }
56
+ return { type: 'ImageBlock', attrs }
57
+ }
58
+
59
+ function iconNode({ src, svg, library, name, size, color }) {
60
+ // UniwebIcon supports multiple source types
61
+ const attrs = {}
62
+ if (svg || src) attrs.svg = svg || src
63
+ if (library) attrs.library = library
64
+ if (name) attrs.name = name
65
+ if (size) attrs.size = size
66
+ if (color) attrs.color = color
67
+ return { type: 'UniwebIcon', attrs }
68
+ }
69
+
70
+ function videoNode({ src, caption, direction, coverImg }) {
71
+ const attrs = { src }
72
+ if (caption) attrs.caption = caption
73
+ if (direction) attrs.direction = direction
74
+ if (coverImg) attrs.coverImg = coverImg
75
+ return { type: 'Video', attrs }
76
+ }
77
+
78
+ function dividerBlock() {
79
+ return { type: 'DividerBlock' }
80
+ }
81
+
82
+ function bulletList(items) {
83
+ if (!items || !items.length) return null
84
+ return {
85
+ type: 'bulletList',
86
+ content: items.map(item => ({
87
+ type: 'listItem',
88
+ content: [paragraph(item)].filter(Boolean),
89
+ })),
90
+ }
91
+ }
92
+
93
+ // --- Group builder ---
94
+
95
+ /**
96
+ * Build TipTap nodes from a content group (main or item).
97
+ *
98
+ * @param {Object} group - Content structure: { pretitle, title, subtitle, paragraphs, images, ... }
99
+ * @param {number} titleLevel - Heading level for title (1 for main, 2 for items)
100
+ * @returns {Array} Array of TipTap nodes
101
+ */
102
+ function buildGroupNodes(group, titleLevel = 1) {
103
+ const nodes = []
104
+
105
+ // 1. Headings: pretitle → title → subtitle
106
+ // Pretitle uses a higher level number (less important) than title
107
+ // e.g., H3 before H1 — mirrors isPreTitle() in groups.js
108
+ if (group.pretitle) {
109
+ const pre = heading(titleLevel + 2, group.pretitle)
110
+ if (Array.isArray(pre)) nodes.push(...pre)
111
+ else if (pre) nodes.push(pre)
112
+ }
113
+
114
+ if (group.title) {
115
+ const t = heading(titleLevel, group.title)
116
+ if (Array.isArray(t)) nodes.push(...t)
117
+ else if (t) nodes.push(t)
118
+ }
119
+
120
+ // Subtitle is one level below title
121
+ if (group.subtitle) {
122
+ const sub = heading(titleLevel + 1, group.subtitle)
123
+ if (Array.isArray(sub)) nodes.push(...sub)
124
+ else if (sub) nodes.push(sub)
125
+ }
126
+
127
+ // 2. Body fields in document order
128
+ if (group.paragraphs) {
129
+ for (const p of group.paragraphs) {
130
+ const node = paragraph(p)
131
+ if (node) nodes.push(node)
132
+ }
133
+ }
134
+
135
+ if (group.images) {
136
+ for (const img of group.images) {
137
+ nodes.push(imageBlock(img))
138
+ }
139
+ }
140
+
141
+ if (group.links) {
142
+ for (const link of group.links) {
143
+ const node = linkParagraph(link)
144
+ if (node) nodes.push(node)
145
+ }
146
+ }
147
+
148
+ if (group.icons) {
149
+ for (const icon of group.icons) {
150
+ nodes.push(iconNode(icon))
151
+ }
152
+ }
153
+
154
+ if (group.videos) {
155
+ for (const video of group.videos) {
156
+ nodes.push(videoNode(video))
157
+ }
158
+ }
159
+
160
+ if (group.lists) {
161
+ for (const list of group.lists) {
162
+ const node = bulletList(list)
163
+ if (node) nodes.push(node)
164
+ }
165
+ }
166
+
167
+ return nodes
168
+ }
169
+
170
+ // --- Main export ---
171
+
172
+ /**
173
+ * Build a TipTap document from a content structure.
174
+ *
175
+ * This is the reverse of parseContent(): given a flat content object
176
+ * (title, paragraphs, items, etc.), produce a TipTap document that
177
+ * roundtrips through parseContent() to yield the same structure.
178
+ *
179
+ * @param {Object} content - Content structure (same shape as parseContent output / starter)
180
+ * @returns {Object|null} TipTap document { type: 'doc', content: [...] }, or null if empty
181
+ */
182
+ function buildDoc(content) {
183
+ if (!content) return null
184
+
185
+ const nodes = []
186
+
187
+ // Main group content (title level 1)
188
+ nodes.push(...buildGroupNodes(content, 1))
189
+
190
+ // Items: separated by DividerBlock (mirrors divider-based grouping in groups.js)
191
+ if (content.items && content.items.length > 0) {
192
+ for (const item of content.items) {
193
+ nodes.push(dividerBlock())
194
+ // Item headings use level 2 (one below main H1)
195
+ nodes.push(...buildGroupNodes(item, 2))
196
+ }
197
+ }
198
+
199
+ if (nodes.length === 0) return null
200
+
201
+ return { type: 'doc', content: nodes }
202
+ }
203
+
204
+ export { buildDoc }
package/src/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { processSequence } from "./processors/sequence.js";
2
2
  import { processGroups } from "./processors/groups.js";
3
+ import { buildDoc } from "./builders/doc.js";
3
4
 
4
5
  /**
5
6
  * Parse ProseMirror/TipTap content into semantic structure
@@ -29,4 +30,4 @@ function parseContent(doc, options = {}) {
29
30
  };
30
31
  }
31
32
 
32
- export { parseContent };
33
+ export { parseContent, buildDoc };
@@ -183,9 +183,11 @@ function readHeadingGroup(sequence, startIdx) {
183
183
  break;
184
184
  }
185
185
 
186
- // Case 1: Strictly Deeper (Standard Subtitle/Deep Header)
187
- // e.g. H1 -> H2
188
- if (element.level > previousElement.level) {
186
+ // Case 1: Adjacent Deeper (Standard Subtitle/Deep Header)
187
+ // Only group headings that are exactly one level apart.
188
+ // e.g. H1 -> H2 groups, but H1 -> H3 does not (skipped level
189
+ // signals a structural tier change, not a title-subtitle pair).
190
+ if (element.level - previousElement.level === 1) {
189
191
  hasGoneDeeper = true;
190
192
  elements.push(element);
191
193
  continue;
@@ -412,21 +412,25 @@ function processInlineElements(content) {
412
412
  return items;
413
413
  }
414
414
 
415
- function makeAssetUrl(info) {
416
- let url = "";
417
-
418
- let src = info?.src || info?.url || "";
415
+ const ASSET_BASE_URL = "https://assets.uniweb.app/";
419
416
 
420
- if (src) {
421
- url = src;
422
- } else if (info?.identifier) {
423
- url =
424
- new uniweb.Profile(`docufolio/profile`, "_template").getAssetInfo(
425
- info.identifier
426
- )?.src || "";
427
- }
417
+ /**
418
+ * Resolve an asset identifier ({version}/{filename}) to a direct URL.
419
+ * Assets are hosted at assets.uniweb.app under dist/{version}/base.{ext}.
420
+ */
421
+ function resolveAssetIdentifier(identifier) {
422
+ if (!identifier || typeof identifier !== "string") return "";
423
+ const [version, filename] = identifier.split("/");
424
+ if (!filename) return "";
425
+ const ext = filename.substring(filename.lastIndexOf(".") + 1);
426
+ return `${ASSET_BASE_URL}dist/${version}/base.${ext}`;
427
+ }
428
428
 
429
- return url;
429
+ function makeAssetUrl(info) {
430
+ const src = info?.src || info?.url || "";
431
+ if (src) return src;
432
+ if (info?.identifier) return resolveAssetIdentifier(info.identifier);
433
+ return "";
430
434
  }
431
435
 
432
436
  function parseCardBlock(itemAttrs) {
@@ -467,10 +471,7 @@ function parseDocumentBlock(itemAttrs) {
467
471
  const { identifier = "" } = info;
468
472
 
469
473
  if (identifier) {
470
- ele.downloadUrl = new uniweb.Profile(
471
- `docufolio/profile`,
472
- "_template"
473
- ).getAssetInfo(identifier)?.href;
474
+ ele.downloadUrl = resolveAssetIdentifier(identifier);
474
475
  }
475
476
  }
476
477