@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 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) or smaller headings above the title
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"`), and smaller headings stacked directly above a more important one (H3→H1, H6→H5, …). String or array.
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), or smaller headings above the title
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/semantic-parser",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "Semantic parser for ProseMirror/TipTap content structures",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -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
- if (!text || !href) return null
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
- const attrs = { url: src, alt }
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
- if (coverImg) attrs.coverImg = coverImg
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: items.map(item => ({
208
+ content: entries.map(text => ({
101
209
  type: 'listItem',
102
- content: [paragraph(item)].filter(Boolean),
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
- // Pretitle uses a higher level number (less important) than title
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 = heading(titleLevel + 2, group.pretitle)
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