@uniweb/content-writer 0.2.9 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/content-writer",
3
- "version": "0.2.9",
3
+ "version": "0.3.1",
4
4
  "description": "ProseMirror document structure to Markdown converter",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -20,7 +20,7 @@
20
20
  },
21
21
  "devDependencies": {
22
22
  "vitest": "^4.1.7",
23
- "@uniweb/content-reader": "1.1.17"
23
+ "@uniweb/content-reader": "1.2.0"
24
24
  },
25
25
  "directories": {
26
26
  "test": "tests"
package/src/nodes.js CHANGED
@@ -126,6 +126,42 @@ export function serializeInsetBlock(node) {
126
126
  return `${fence}@${component}${attrStr}\n${body}\n${fence}`
127
127
  }
128
128
 
129
+ /**
130
+ * Serialize a concept block back to its ```md:<tag> fence.
131
+ *
132
+ * The inverse of content-reader's concept-block branch. The body is real block
133
+ * content, so it serializes through the ordinary node path — a concept block is
134
+ * the section pipeline one level down, and its body is markdown at both ends.
135
+ *
136
+ * The fence widens past any backtick run in the body, for the same reason a
137
+ * container's does: an answer that shows a code sample is ordinary
138
+ * documentation, and a three-backtick fence would close on the sample's own
139
+ * fence and spill the remainder into the page as prose.
140
+ *
141
+ * A missing serializer here is not a cosmetic gap. `serializeNode` omits an
142
+ * unmapped node, so the whole block — the author's prose with it — would
143
+ * vanish from the file on the next write-back and from the per-page markdown
144
+ * projection. That is why the round-trip test asserts the BODY rather than
145
+ * just the fence line.
146
+ *
147
+ * @param {Object} node - concept_block node with attrs.tag + block content
148
+ * @returns {string} Fenced concept block
149
+ */
150
+ export function serializeConceptBlock(node) {
151
+ const { tag } = node.attrs || {}
152
+ if (!tag) return ''
153
+
154
+ const { serializeNode } = await_serializer()
155
+ const body = (node.content || [])
156
+ .map(child => serializeNode(child))
157
+ .filter(Boolean)
158
+ .join('\n\n')
159
+
160
+ const fence = '`'.repeat(fenceWidthFor(body))
161
+
162
+ return `${fence}md:${tag}\n${body}\n${fence}`
163
+ }
164
+
129
165
  /**
130
166
  * Serialize a data block node.
131
167
  *
@@ -317,9 +353,21 @@ function indentBlock(text, firstPrefix, pad) {
317
353
 
318
354
  /**
319
355
  * Serialize a divider node.
320
- * @returns {string} Markdown horizontal rule
356
+ *
357
+ * Emits the attribute spelling only when `type` carries a non-default value, so
358
+ * an ordinary rule stays `---` and no existing document is rewritten. `style` and
359
+ * `size` are deliberately NOT emitted: they are dead attrs (nothing reads them,
360
+ * no spelling sets them) and writing them would put vocabulary into every
361
+ * author's file that the framework is trying to retire.
362
+ *
363
+ * @param {Object} [node] - Divider node
364
+ * @returns {string} Markdown thematic break, with `{type=…}` when meaningful
321
365
  */
322
- export function serializeDivider() {
366
+ export function serializeDivider(node) {
367
+ const type = node?.attrs?.type
368
+ if (typeof type === 'string' && type && type !== 'hr') {
369
+ return `---{type=${type}}`
370
+ }
323
371
  return '---'
324
372
  }
325
373
 
package/src/normalize.js CHANGED
@@ -193,14 +193,8 @@ function mapEditorNode(node) {
193
193
  case 'Icon':
194
194
  return mapUniwebIcon(node)
195
195
  case 'WarningBlock':
196
- case 'details': {
197
- const attrs = EDITOR_CONTAINERS[node.type](node)
198
- return {
199
- type: 'inset_block',
200
- attrs,
201
- content: node.type === 'details' ? detailsBody(node) : asBlocks(node.content),
202
- }
203
- }
196
+ case 'details':
197
+ return EDITOR_CONTAINERS[node.type](node)
204
198
  case 'emoji':
205
199
  // The node carries the character alongside a name; the character is the
206
200
  // whole markdown form. Guarded rather than assumed to be populated.
@@ -256,8 +250,25 @@ const NAMED_INLINE_STYLES = new Set(['accent', 'highlight', 'callout', 'muted'])
256
250
  * missing entry here is a visible gap rather than a silent assumption.
257
251
  */
258
252
  const EDITOR_CONTAINERS = {
259
- WarningBlock: node => ({ component: 'Alert', type: node.attrs?.type || 'info' }),
260
- details: () => ({ component: 'Details' }),
253
+ // A callout's SEVERITY is the concept, not a parameter on one — a warning and
254
+ // a note are different kinds of thing, so they are different tags. This is
255
+ // where the editor's `type` attribute goes.
256
+ WarningBlock: node => ({
257
+ type: 'concept_block',
258
+ attrs: { tag: node.attrs?.type || 'info' },
259
+ content: asBlocks(node.content),
260
+ }),
261
+ // The summary becomes the leading HEADING rather than a paragraph, which is
262
+ // what makes the mapping lossless in both directions: a concept block's items
263
+ // come from its headings, so the summary recovers as that item's title and
264
+ // the body as its paragraphs. Flattening it to a paragraph — what this did
265
+ // while the target was an inset — made the two indistinguishable on the way
266
+ // back.
267
+ details: node => ({
268
+ type: 'concept_block',
269
+ attrs: { tag: 'details' },
270
+ content: detailsBody(node),
271
+ }),
261
272
  }
262
273
 
263
274
  /** Inline node types, for deciding whether a container's body needs wrapping. */
@@ -289,11 +300,18 @@ function asBlocks(content) {
289
300
  }
290
301
 
291
302
  /**
292
- * Flatten the editor's `details` › `detailsSummary` + `detailsContent` into the
293
- * container's body, summary first.
303
+ * Flatten the editor's `details` › `detailsSummary` + `detailsContent` into a
304
+ * concept block's body, summary first — as a HEADING.
294
305
  *
295
- * The summary leads rather than riding on an attribute: `detailsSummary` can
296
- * carry marks, and `summary="…"` would flatten emphasis and links.
306
+ * The heading is what makes the mapping lossless. A concept block's items come
307
+ * from its headings, so a summary written as `# …` recovers as that item's
308
+ * title and the body as its paragraphs; written as a paragraph (which is what
309
+ * this did while the target was an inset) the two become indistinguishable on
310
+ * the way back, and a disclosure loses which half was its label.
311
+ *
312
+ * The summary stays inline content rather than moving to an attribute for the
313
+ * original reason: `detailsSummary` can carry marks, and `summary="…"` would
314
+ * flatten emphasis and links.
297
315
  *
298
316
  * @param {Object} node
299
317
  * @returns {Array}
@@ -301,8 +319,11 @@ function asBlocks(content) {
301
319
  function detailsBody(node) {
302
320
  const body = []
303
321
  for (const child of node.content || []) {
304
- if (child.type === 'detailsSummary') body.push(...asBlocks(child.content))
305
- else if (child.type === 'detailsContent') body.push(...(child.content || []))
322
+ if (child.type === 'detailsSummary') {
323
+ if (child.content?.length) {
324
+ body.push({ type: 'heading', attrs: { level: 1 }, content: child.content })
325
+ }
326
+ } else if (child.type === 'detailsContent') body.push(...(child.content || []))
306
327
  else body.push(child)
307
328
  }
308
329
  return body
package/src/serializer.js CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  serializeImage,
11
11
  serializeInsetRef,
12
12
  serializeInsetBlock,
13
+ serializeConceptBlock,
13
14
  serializeCodeBlock,
14
15
  serializeDataBlock,
15
16
  serializeBlockquote,
@@ -29,6 +30,7 @@ const NODE_SERIALIZERS = {
29
30
  image: serializeImage,
30
31
  inset_ref: serializeInsetRef,
31
32
  inset_block: serializeInsetBlock,
33
+ concept_block: serializeConceptBlock,
32
34
  codeBlock: serializeCodeBlock,
33
35
  dataBlock: serializeDataBlock,
34
36
  blockquote: serializeBlockquote,
@@ -263,22 +263,34 @@ describe('containers — the block form of an inset', () => {
263
263
  expect(rt(source)).toBe(source)
264
264
  })
265
265
 
266
- test('the editor container shapes normalize onto it', () => {
266
+ test('the editor containers normalize onto CONCEPT blocks, not insets', () => {
267
+ // Changed 2026-07-31. These used to become `@Alert` and `@Details` — an
268
+ // inset naming the component that renders it, which is a rendering
269
+ // decision living in content. A concept block names what the content IS
270
+ // and leaves rendering to the foundation.
267
271
  const text = t => ({ type: 'text', text: t })
268
272
  const doc = node => proseMirrorToMarkdown({ type: 'doc', content: [node] }).trim()
269
273
 
270
- // WarningBlock ↔ @Alert, defaulting to info, NOT warning — a name-derived
271
- // mapping drops exactly this.
274
+ // The editor's `type` becomes the TAG: a warning and a note are different
275
+ // kinds of thing, not one kind with a parameter. `info` is still the
276
+ // default, and still not `warning` — a name-derived mapping drops that.
272
277
  expect(doc({ type: 'WarningBlock', attrs: {}, content: [text('Careful.')] }))
273
- .toBe('```@Alert{type=info}\nCareful.\n```')
274
-
275
- // The summary is the first block and keeps its marks.
278
+ .toBe('```md:info\nCareful.\n```')
279
+ expect(doc({ type: 'WarningBlock', attrs: { type: 'warning' }, content: [text('Careful.')] }))
280
+ .toBe('```md:warning\nCareful.\n```')
281
+
282
+ // The summary becomes the leading HEADING, which is what makes this
283
+ // lossless: a concept block's items come from its headings, so the summary
284
+ // recovers as the item's title and the body as its paragraphs. As a
285
+ // paragraph — what this produced while the target was an inset — the two
286
+ // were indistinguishable coming back.
276
287
  expect(doc({
277
288
  type: 'details',
278
289
  content: [
279
290
  { type: 'detailsSummary', content: [text('Breaking '), { type: 'text', text: 'changes', marks: [{ type: 'bold' }] }] },
280
291
  { type: 'detailsContent', content: [{ type: 'paragraph', content: [text('Body.')] }] },
281
292
  ],
282
- })).toBe('```@Details\nBreaking **changes**\n\nBody.\n```')
293
+ })).toBe('```md:details\n# Breaking **changes**\n\nBody.\n```')
283
294
  })
295
+
284
296
  })
@@ -389,3 +389,71 @@ describe('Round-trip: Hard breaks', () => {
389
389
  expect(proseMirrorToMarkdown(parsed).trim()).toBe('wrapped\nparagraph')
390
390
  })
391
391
  })
392
+
393
+ describe('Round-trip: Concept blocks', () => {
394
+ testRoundTrip(
395
+ '```md:faq\n# A question\nAn answer.\n\n# Another\nAlso answered.\n```',
396
+ 'concept block with two pairs'
397
+ )
398
+
399
+ testRoundTrip(
400
+ '```md:warning\nBack up your database **first**.\n\n- Not reversible\n```',
401
+ 'headingless concept block (a callout)'
402
+ )
403
+
404
+ testRoundTrip(
405
+ '```md:faq\n# Q\nAn answer with **bold** and a [link](/x).\n```',
406
+ 'concept block whose body carries marks and links'
407
+ )
408
+
409
+ test('the BODY survives, not just the fence line', () => {
410
+ // A missing NODE_SERIALIZERS entry omits the whole node, so a test that
411
+ // only checks the fence and tag came back would pass on an empty block —
412
+ // and on a serializer that mangles the prose while the first line looks
413
+ // right. Assert the content.
414
+ const md = '```md:faq\n# What plans do you have?\nWe have three.\n```'
415
+ const out = proseMirrorToMarkdown(markdownToProseMirror(md))
416
+
417
+ expect(out).toContain('md:faq')
418
+ expect(out).toContain('# What plans do you have?')
419
+ expect(out).toContain('We have three.')
420
+ })
421
+
422
+ test('serialization is a FIXED POINT, which is the bar — not byte equality', () => {
423
+ // pass1 may differ from the author's original: the serializer normalizes
424
+ // spacing, so `# Q` immediately followed by its answer comes back with a
425
+ // blank line between them. Nothing is lost. Asserting byte-equality
426
+ // against the original would fail on content that is behaving correctly,
427
+ // and someone would "fix" the serializer to make it pass.
428
+ const original = '```md:faq\n# Q1\nA1\n# Q2\nA2\n```'
429
+ const pass1 = proseMirrorToMarkdown(markdownToProseMirror(original))
430
+ const pass2 = proseMirrorToMarkdown(markdownToProseMirror(pass1))
431
+
432
+ // Guard the guard: a fixed point on NOTHING is trivially true. Verified by
433
+ // canary — unregister the serializer and every other test in this block
434
+ // fails while `'' === ''` sails through. So pin the content first.
435
+ expect(pass1).toContain('# Q1')
436
+ expect(pass1).toContain('A2')
437
+ expect(pass2).toBe(pass1)
438
+ })
439
+
440
+ test('a code sample inside a concept block widens the outer fence', () => {
441
+ // Without fenceWidthFor the outer fence closes on the sample's own fence
442
+ // and the remainder spills into the page as prose. An FAQ answer that
443
+ // shows code is ordinary documentation, so this is the common case, not
444
+ // an edge one.
445
+ const md = '````md:faq\n# How do I start?\nRun this:\n\n```sh\nnpm i\n```\n````'
446
+ const out = proseMirrorToMarkdown(markdownToProseMirror(md))
447
+
448
+ expect(out.startsWith('````md:faq')).toBe(true)
449
+ expect(out).toContain('```sh')
450
+ expect(markdownToProseMirror(out)).toEqual(markdownToProseMirror(md))
451
+ })
452
+
453
+ test('an unmapped node would be dropped — so this one must stay mapped', () => {
454
+ // The failure mode this suite exists to catch: serializeNode omits a node
455
+ // it has no serializer for, taking the author's prose with it.
456
+ const doc = markdownToProseMirror('```md:faq\n# Q\nA\n```')
457
+ expect(proseMirrorToMarkdown(doc).trim()).not.toBe('')
458
+ })
459
+ })