@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 +2 -2
- package/src/nodes.js +50 -2
- package/src/normalize.js +37 -16
- package/src/serializer.js +2 -0
- package/tests/roundtrip-source.test.js +19 -7
- 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.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.
|
|
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/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
|
-
|
|
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
|
-
|
|
260
|
-
|
|
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
|
|
293
|
-
*
|
|
303
|
+
* Flatten the editor's `details` › `detailsSummary` + `detailsContent` into a
|
|
304
|
+
* concept block's body, summary first — as a HEADING.
|
|
294
305
|
*
|
|
295
|
-
* The
|
|
296
|
-
*
|
|
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')
|
|
305
|
-
|
|
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
|
|
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
|
-
//
|
|
271
|
-
//
|
|
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('
|
|
274
|
-
|
|
275
|
-
|
|
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('
|
|
293
|
+
})).toBe('```md:details\n# Breaking **changes**\n\nBody.\n```')
|
|
283
294
|
})
|
|
295
|
+
|
|
284
296
|
})
|
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
|
+
})
|