@uniweb/semantic-parser 1.4.0 → 1.4.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/AGENTS.md +2 -2
- package/README.md +3 -2
- package/package.json +1 -1
- package/src/builders/doc.js +167 -18
package/AGENTS.md
CHANGED
|
@@ -53,7 +53,7 @@ The parser returns a flat content structure:
|
|
|
53
53
|
```js
|
|
54
54
|
{
|
|
55
55
|
title: '', // Main heading
|
|
56
|
-
pretitle: '', // `#>` label line(s)
|
|
56
|
+
pretitle: '', // `#>` label line(s); also smaller headings above the title
|
|
57
57
|
subtitle: '', // Line(s) one step below the title (string or array)
|
|
58
58
|
paragraphs: [],
|
|
59
59
|
links: [], // All link-like entities (including buttons, documents)
|
|
@@ -161,7 +161,7 @@ Lists maintain hierarchy through nested structure. The `processListItems()` func
|
|
|
161
161
|
|
|
162
162
|
Key patterns — the staircase rule (each heading relates to the one before it):
|
|
163
163
|
|
|
164
|
-
- **Pretitle**: `#>` label lines (headings with `role: "pretitle"`)
|
|
164
|
+
- **Pretitle**: `#>` label lines (headings with `role: "pretitle"`) — what to write. Also smaller headings stacked directly above a more important one (H3→H1, H6→H5, …), the older spelling, read for compatibility. String or array.
|
|
165
165
|
- **Subtitle**: one step below the title, directly adjacent — and each further one-step descent (or same-size repeat) is another subtitle line. String or array.
|
|
166
166
|
- **Items**: a heading two or more steps below the previous one, a step back up, or any heading after body content starts a new group.
|
|
167
167
|
- **Banner Pattern**: an image (with banner role or followed by a heading) at the start of the first group stays with the headline.
|
package/README.md
CHANGED
|
@@ -66,7 +66,8 @@ Main content fields are at the top level. The `items` array contains additional
|
|
|
66
66
|
result = {
|
|
67
67
|
// Header fields (from headings) — each is a string, or an array when the
|
|
68
68
|
// part spans several lines
|
|
69
|
-
pretitle: "", // `#>` label line(s)
|
|
69
|
+
pretitle: "", // `#>` label line(s) — also smaller headings above
|
|
70
|
+
// the title, the older spelling
|
|
70
71
|
title: "Welcome", // Main heading
|
|
71
72
|
subtitle: "", // Line(s) one step below the title
|
|
72
73
|
|
|
@@ -180,7 +181,7 @@ This is the mechanism that creates repeating content groups (cards, features, FA
|
|
|
180
181
|
Two spellings fill the `pretitle` slot:
|
|
181
182
|
|
|
182
183
|
- **A `#>` label line** — a heading with `role: "pretitle"`. It names the block that starts next, at any depth, with no level arithmetic; the hash count is carried for round-tripping and means nothing.
|
|
183
|
-
- **Position** — smaller headings stacked above the most important heading of a run (H3 before H1, H6 before H5, …), in any order. `#### / ### / # Title` and `## / ### / # Title` both yield a two-line pretitle.
|
|
184
|
+
- **Position** — smaller headings stacked above the most important heading of a run (H3 before H1, H6 before H5, …), in any order. `#### / ### / # Title` and `## / ### / # Title` both yield a two-line pretitle. ⚖️ This is the older spelling and is read for compatibility; a label line is what to WRITE, because it carries its meaning alone rather than by what follows it.
|
|
184
185
|
|
|
185
186
|
### Banner image
|
|
186
187
|
|
package/package.json
CHANGED
package/src/builders/doc.js
CHANGED
|
@@ -6,6 +6,34 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Starter content uses plain strings (no HTML marks), so the conversion
|
|
8
8
|
* is straightforward — no need to reverse inline HTML formatting.
|
|
9
|
+
*
|
|
10
|
+
* ## ⭐ IT ACCEPTS THE PARSER'S OWN VOCABULARY TOO — and that is a contract
|
|
11
|
+
*
|
|
12
|
+
* This builder was written with its own spelling for several concepts, and
|
|
13
|
+
* `parseContent` emits a DIFFERENT one for the same thing: a link's text is
|
|
14
|
+
* `text` here and `label` there; an image's address is `src` here and `url`
|
|
15
|
+
* there; a video's cover is a string here and `{ src | url | identifier }`
|
|
16
|
+
* there; a list item is a plain string here and a whole content GROUP there.
|
|
17
|
+
*
|
|
18
|
+
* ⛔ EVERY ONE OF THOSE MISMATCHES FAILED SILENTLY, which is why the rule is
|
|
19
|
+
* now written down. Measured 2026-09-16, all four on the shapes a caller would
|
|
20
|
+
* most naturally reach for:
|
|
21
|
+
*
|
|
22
|
+
* { links: [{ label, href }] } → buildDoc returns **null** outright
|
|
23
|
+
* { images: [{ url, alt }] } → an ImageBlock with NO url attr
|
|
24
|
+
* { videos: [{ coverImg: '/p.jpg' }] } → the cover parses back as ""
|
|
25
|
+
* { lists: parsed.lists } → a text node whose `text` is an
|
|
26
|
+
* OBJECT — an invalid document
|
|
27
|
+
*
|
|
28
|
+
* None of them threw. A generator author reaches for `label` and `url` because
|
|
29
|
+
* those are the names in `docs/reference/component-metadata.md` and the names a
|
|
30
|
+
* component actually reads off `content` — so the natural spelling was the
|
|
31
|
+
* broken one, and it produced an empty section rather than an error.
|
|
32
|
+
*
|
|
33
|
+
* ⇒ For every concept where the two sides disagree, ACCEPT BOTH SPELLINGS. The
|
|
34
|
+
* emitted document is unchanged — this only widens what may come in — so the
|
|
35
|
+
* round-trip assertions above still pin the output. A new field whose name
|
|
36
|
+
* differs from the parser's needs the same treatment, or it joins this list.
|
|
9
37
|
*/
|
|
10
38
|
|
|
11
39
|
// --- TipTap node builders ---
|
|
@@ -27,6 +55,33 @@ function heading(level, text) {
|
|
|
27
55
|
}
|
|
28
56
|
}
|
|
29
57
|
|
|
58
|
+
/**
|
|
59
|
+
* A label line — `#> Text` — which is how a pretitle is written.
|
|
60
|
+
*
|
|
61
|
+
* ⛔ NOT A SMALLER HEADING BEFORE THE TITLE. That positional form still parses,
|
|
62
|
+
* and it is what this builder emitted until 2026-09-16, but it is the old
|
|
63
|
+
* spelling and it is fragile in a way the label line is not: it means "pretitle"
|
|
64
|
+
* only by sitting before a bigger heading, so the same line moved, or left
|
|
65
|
+
* alone, becomes something else. `#>` says what it is wherever it lands.
|
|
66
|
+
*
|
|
67
|
+
* ⭐ THE `#` COUNT MEANS NOTHING. `#>`, `##>` and `###>` are the same thing —
|
|
68
|
+
* content-reader keeps the authored count in `level` purely so a file
|
|
69
|
+
* round-trips byte-for-byte. We match the level of the title it labels, which is
|
|
70
|
+
* what an author writing by hand does (`##>` above an `##` item title).
|
|
71
|
+
*
|
|
72
|
+
* On the wire it is an ordinary heading carrying `role: 'pretitle'`, so every
|
|
73
|
+
* consumer that already understands headings keeps working.
|
|
74
|
+
*/
|
|
75
|
+
function labelLine(level, text) {
|
|
76
|
+
if (!text) return null
|
|
77
|
+
if (Array.isArray(text)) return text.map(t => labelLine(level, t)).filter(Boolean)
|
|
78
|
+
return {
|
|
79
|
+
type: 'heading',
|
|
80
|
+
attrs: { level, role: 'pretitle' },
|
|
81
|
+
content: [textNode(text)],
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
30
85
|
function paragraph(text) {
|
|
31
86
|
if (!text) return null
|
|
32
87
|
return {
|
|
@@ -35,18 +90,23 @@ function paragraph(text) {
|
|
|
35
90
|
}
|
|
36
91
|
}
|
|
37
92
|
|
|
38
|
-
function linkParagraph({ text, href, target }) {
|
|
39
|
-
|
|
93
|
+
function linkParagraph({ text, label, href, target }) {
|
|
94
|
+
// `label` is what parseContent emits and what a component reads off
|
|
95
|
+
// `content.links[]`; `text` is this builder's original spelling.
|
|
96
|
+
const caption = text || label
|
|
97
|
+
if (!caption || !href) return null
|
|
40
98
|
const mark = { type: 'link', attrs: { href } }
|
|
41
99
|
if (target) mark.attrs.target = target
|
|
42
100
|
return {
|
|
43
101
|
type: 'paragraph',
|
|
44
|
-
content: [{ type: 'text', text, marks: [mark] }],
|
|
102
|
+
content: [{ type: 'text', text: caption, marks: [mark] }],
|
|
45
103
|
}
|
|
46
104
|
}
|
|
47
105
|
|
|
48
|
-
function imageBlock({ src, alt = '', caption = '', direction, role, width, height }) {
|
|
49
|
-
|
|
106
|
+
function imageBlock({ src, url, alt = '', caption = '', direction, role, width, height }) {
|
|
107
|
+
// The node attr is `url` and so is the parsed field; `src` is this builder's
|
|
108
|
+
// original spelling. Either one in, `url` out.
|
|
109
|
+
const attrs = { url: src || url, alt }
|
|
50
110
|
if (caption) attrs.caption = caption
|
|
51
111
|
if (direction) attrs.direction = direction
|
|
52
112
|
if (role) attrs.role = role
|
|
@@ -81,11 +141,16 @@ function iconNode({ src, url, svg, library, name, size, color }) {
|
|
|
81
141
|
return { type: 'UniwebIcon', attrs }
|
|
82
142
|
}
|
|
83
143
|
|
|
84
|
-
function videoNode({ src, caption, direction, coverImg }) {
|
|
85
|
-
const attrs = { src }
|
|
144
|
+
function videoNode({ src, url, caption, direction, coverImg }) {
|
|
145
|
+
const attrs = { src: src || url }
|
|
86
146
|
if (caption) attrs.caption = caption
|
|
87
147
|
if (direction) attrs.direction = direction
|
|
88
|
-
|
|
148
|
+
// ⛔ The editor's `Video` node holds `coverImg` as an OBJECT — the reader is
|
|
149
|
+
// `makeAssetUrl`, which looks for `.src` / `.url` / `.identifier`. A bare
|
|
150
|
+
// string passed straight through parses back as "": `info?.src` on a string
|
|
151
|
+
// is undefined. Normalize here so a caller may write either.
|
|
152
|
+
const cover = typeof coverImg === 'string' ? { src: coverImg } : coverImg
|
|
153
|
+
if (cover && (cover.src || cover.url || cover.identifier)) attrs.coverImg = cover
|
|
89
154
|
return { type: 'Video', attrs }
|
|
90
155
|
}
|
|
91
156
|
|
|
@@ -93,17 +158,75 @@ function dividerBlock() {
|
|
|
93
158
|
return { type: 'DividerBlock' }
|
|
94
159
|
}
|
|
95
160
|
|
|
161
|
+
/**
|
|
162
|
+
* A code block for DISPLAY — what lands in `content.snippets`.
|
|
163
|
+
*
|
|
164
|
+
* `parseContent` groups one as `{ language, code }`; the element before
|
|
165
|
+
* grouping carries `{ text, attrs }`. Accept either spelling, and a bare string
|
|
166
|
+
* for a snippet with no language.
|
|
167
|
+
*/
|
|
168
|
+
function codeBlock(snippet) {
|
|
169
|
+
const spec = typeof snippet === 'string' ? { code: snippet } : snippet
|
|
170
|
+
if (!spec || typeof spec !== 'object') return null
|
|
171
|
+
const text = spec.code ?? spec.text
|
|
172
|
+
if (typeof text !== 'string' || text === '') return null
|
|
173
|
+
const attrs = {}
|
|
174
|
+
if (spec.language) attrs.language = spec.language
|
|
175
|
+
if (spec.filename) attrs.filename = spec.filename
|
|
176
|
+
return { type: 'codeBlock', attrs, content: [textNode(text)] }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* A tagged data block — what lands in `content.data[tag]`.
|
|
181
|
+
*
|
|
182
|
+
* ⛔ IT IS A `dataBlock`, NOT A TAGGED `codeBlock`, and the difference is not
|
|
183
|
+
* cosmetic. content-reader emits `dataBlock` when a tagged fence PARSED, with
|
|
184
|
+
* the parsed value on `attrs.data`; it falls back to a tagged `codeBlock`
|
|
185
|
+
* carrying raw TEXT only when parsing failed. A builder handed a real object
|
|
186
|
+
* has parsed data by definition, so emitting the failure shape would hand a
|
|
187
|
+
* component a string where it expects a record.
|
|
188
|
+
*
|
|
189
|
+
* ⭐ `language` IS LOAD-BEARING, not decoration. content-writer reads it back to
|
|
190
|
+
* choose the fence, and the parsed value alone cannot say whether the author
|
|
191
|
+
* wrote YAML or JSON — without it an author's YAML silently becomes JSON on the
|
|
192
|
+
* next editor sync. A builder has no author to echo, so it states one.
|
|
193
|
+
*/
|
|
194
|
+
function dataBlock(tag, value, language) {
|
|
195
|
+
// A null value is the same absence as no key at all — a component reading
|
|
196
|
+
// `content.data[tag]` gets null either way — so emit no fence rather than one
|
|
197
|
+
// whose whole payload is `null`.
|
|
198
|
+
if (!tag || value === undefined || value === null) return null
|
|
199
|
+
return { type: 'dataBlock', attrs: { tag, language, data: value } }
|
|
200
|
+
}
|
|
201
|
+
|
|
96
202
|
function bulletList(items) {
|
|
97
203
|
if (!items || !items.length) return null
|
|
204
|
+
const entries = items.map(listItemText).filter(Boolean)
|
|
205
|
+
if (!entries.length) return null
|
|
98
206
|
return {
|
|
99
207
|
type: 'bulletList',
|
|
100
|
-
content:
|
|
208
|
+
content: entries.map(text => ({
|
|
101
209
|
type: 'listItem',
|
|
102
|
-
content: [paragraph(
|
|
210
|
+
content: [paragraph(text)].filter(Boolean),
|
|
103
211
|
})),
|
|
104
212
|
}
|
|
105
213
|
}
|
|
106
214
|
|
|
215
|
+
/**
|
|
216
|
+
* One list entry's text. A generator writes plain strings; `parseContent`
|
|
217
|
+
* emits a full content GROUP per entry ({ paragraphs, links, … }), and feeding
|
|
218
|
+
* that back produced a text node whose `text` was an object — an invalid
|
|
219
|
+
* document that threw nowhere.
|
|
220
|
+
*/
|
|
221
|
+
function listItemText(item) {
|
|
222
|
+
if (typeof item === 'string') return item
|
|
223
|
+
if (!item || typeof item !== 'object') return ''
|
|
224
|
+
if (Array.isArray(item.paragraphs) && item.paragraphs.length) {
|
|
225
|
+
return item.paragraphs.join(' ')
|
|
226
|
+
}
|
|
227
|
+
return item.title || ''
|
|
228
|
+
}
|
|
229
|
+
|
|
107
230
|
// --- Group builder ---
|
|
108
231
|
|
|
109
232
|
/**
|
|
@@ -113,14 +236,13 @@ function bulletList(items) {
|
|
|
113
236
|
* @param {number} titleLevel - Heading level for title (1 for main, 2 for items)
|
|
114
237
|
* @returns {Array} Array of TipTap nodes
|
|
115
238
|
*/
|
|
116
|
-
function buildGroupNodes(group, titleLevel = 1) {
|
|
239
|
+
function buildGroupNodes(group, titleLevel = 1, options = {}) {
|
|
117
240
|
const nodes = []
|
|
118
241
|
|
|
119
|
-
// 1. Headings: pretitle → title → subtitle
|
|
120
|
-
//
|
|
121
|
-
// e.g., H3 before H1 — mirrors isPreTitle() in groups.js
|
|
242
|
+
// 1. Headings: pretitle → title → subtitle.
|
|
243
|
+
// A pretitle is a LABEL LINE (`#> Text`), at the level of the title it labels.
|
|
122
244
|
if (group.pretitle) {
|
|
123
|
-
const pre =
|
|
245
|
+
const pre = labelLine(titleLevel, group.pretitle)
|
|
124
246
|
if (Array.isArray(pre)) nodes.push(...pre)
|
|
125
247
|
else if (pre) nodes.push(pre)
|
|
126
248
|
}
|
|
@@ -178,6 +300,29 @@ function buildGroupNodes(group, titleLevel = 1) {
|
|
|
178
300
|
}
|
|
179
301
|
}
|
|
180
302
|
|
|
303
|
+
if (group.snippets) {
|
|
304
|
+
for (const snippet of group.snippets) {
|
|
305
|
+
const node = codeBlock(snippet)
|
|
306
|
+
if (node) nodes.push(node)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// `data` is a MAP of tag → value, not a list: one tagged fence per key, in
|
|
311
|
+
// declaration order.
|
|
312
|
+
//
|
|
313
|
+
// ⚠️ A `concept_block` (```md:faq) also lands in `content.data`, as
|
|
314
|
+
// `{ items, sequence }` — a tagged PROSE fence, not data. This does not
|
|
315
|
+
// reconstruct one: it emits the data fence, whose payload round-trips to the
|
|
316
|
+
// same value. Prose that was parsed into a document does not come back as
|
|
317
|
+
// prose here, and a caller rebuilding a concept block wants the markdown.
|
|
318
|
+
if (group.data && typeof group.data === 'object' && !Array.isArray(group.data)) {
|
|
319
|
+
const language = options.dataLanguage || 'yaml'
|
|
320
|
+
for (const [tag, value] of Object.entries(group.data)) {
|
|
321
|
+
const node = dataBlock(tag, value, language)
|
|
322
|
+
if (node) nodes.push(node)
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
181
326
|
return nodes
|
|
182
327
|
}
|
|
183
328
|
|
|
@@ -191,22 +336,26 @@ function buildGroupNodes(group, titleLevel = 1) {
|
|
|
191
336
|
* roundtrips through parseContent() to yield the same structure.
|
|
192
337
|
*
|
|
193
338
|
* @param {Object} content - Content structure (same shape as parseContent output / starter)
|
|
339
|
+
* @param {Object} [options]
|
|
340
|
+
* @param {string} [options.dataLanguage='yaml'] - the fence a tagged data block
|
|
341
|
+
* is written as. It is recorded on the node because content-writer reads it
|
|
342
|
+
* back to choose the fence, and a parsed value cannot say which one it was.
|
|
194
343
|
* @returns {Object|null} TipTap document { type: 'doc', content: [...] }, or null if empty
|
|
195
344
|
*/
|
|
196
|
-
function buildDoc(content) {
|
|
345
|
+
function buildDoc(content, options = {}) {
|
|
197
346
|
if (!content) return null
|
|
198
347
|
|
|
199
348
|
const nodes = []
|
|
200
349
|
|
|
201
350
|
// Main group content (title level 1)
|
|
202
|
-
nodes.push(...buildGroupNodes(content, 1))
|
|
351
|
+
nodes.push(...buildGroupNodes(content, 1, options))
|
|
203
352
|
|
|
204
353
|
// Items: separated by DividerBlock (mirrors divider-based grouping in groups.js)
|
|
205
354
|
if (content.items && content.items.length > 0) {
|
|
206
355
|
for (const item of content.items) {
|
|
207
356
|
nodes.push(dividerBlock())
|
|
208
357
|
// Item headings use level 2 (one below main H1)
|
|
209
|
-
nodes.push(...buildGroupNodes(item, 2))
|
|
358
|
+
nodes.push(...buildGroupNodes(item, 2, options))
|
|
210
359
|
}
|
|
211
360
|
}
|
|
212
361
|
|