@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 +1 -1
- package/README.md +2 -0
- package/package.json +1 -1
- package/src/builders/doc.js +204 -0
- package/src/index.js +2 -1
- package/src/processors/groups.js +5 -3
- package/src/processors/sequence.js +18 -17
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
|
|
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
|
@@ -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 };
|
package/src/processors/groups.js
CHANGED
|
@@ -183,9 +183,11 @@ function readHeadingGroup(sequence, startIdx) {
|
|
|
183
183
|
break;
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
-
// Case 1:
|
|
187
|
-
//
|
|
188
|
-
|
|
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
|
-
|
|
416
|
-
let url = "";
|
|
417
|
-
|
|
418
|
-
let src = info?.src || info?.url || "";
|
|
415
|
+
const ASSET_BASE_URL = "https://assets.uniweb.app/";
|
|
419
416
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
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 =
|
|
471
|
-
`docufolio/profile`,
|
|
472
|
-
"_template"
|
|
473
|
-
).getAssetInfo(identifier)?.href;
|
|
474
|
+
ele.downloadUrl = resolveAssetIdentifier(identifier);
|
|
474
475
|
}
|
|
475
476
|
}
|
|
476
477
|
|