@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 +2 -2
- package/src/nodes.js +50 -2
- package/src/serializer.js +2 -0
- package/tests/roundtrip.test.js +68 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/content-writer",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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
|
-
*
|
|
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,
|
package/tests/roundtrip.test.js
CHANGED
|
@@ -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
|
+
})
|