@uniweb/content-writer 0.2.9 → 0.3.0

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.0",
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/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,
@@ -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
+ })