@uniweb/content-writer 0.2.8 → 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.8",
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.16"
23
+ "@uniweb/content-reader": "1.2.0"
24
24
  },
25
25
  "directories": {
26
26
  "test": "tests"
package/probe.tmp.mjs ADDED
@@ -0,0 +1,18 @@
1
+ import { proseMirrorToMarkdown } from './src/index.js'
2
+ const doc = c => ({ type: 'doc', content: [c] })
3
+ const text = t => ({ type: 'text', text: t })
4
+
5
+ console.log('WarningBlock (text*, today):')
6
+ console.log(' ', JSON.stringify(proseMirrorToMarkdown(doc({ type: 'WarningBlock', attrs: {}, content: [text('Be careful.')] })).trim()))
7
+
8
+ console.log('WarningBlock with type:')
9
+ console.log(' ', JSON.stringify(proseMirrorToMarkdown(doc({ type: 'WarningBlock', attrs: { type: 'warning' }, content: [text('Careful.')] })).trim()))
10
+
11
+ console.log('WarningBlock (block+, after widening):')
12
+ console.log(' ', JSON.stringify(proseMirrorToMarkdown(doc({ type: 'WarningBlock', attrs: {}, content: [
13
+ { type: 'paragraph', content: [text('One.')] }, { type: 'paragraph', content: [text('Two.')] }] })).trim()))
14
+
15
+ console.log('details:')
16
+ console.log(' ', JSON.stringify(proseMirrorToMarkdown(doc({ type: 'details', content: [
17
+ { type: 'detailsSummary', content: [text('Breaking '), { type: 'text', text: 'changes', marks: [{ type: 'bold' }] }] },
18
+ { type: 'detailsContent', content: [{ type: 'paragraph', content: [text('Body text.')] }] }] })).trim()))
package/src/attributes.js CHANGED
@@ -2,7 +2,13 @@
2
2
  * @fileoverview Serialize attributes to curly brace syntax
3
3
  *
4
4
  * Inverse of content-reader's attributes.js.
5
- * Produces syntax like: {role=hero width=1200 .class #id autoplay}
5
+ * Produces syntax like: {role=hero width=1200 #id autoplay}
6
+ *
7
+ * There is no CSS-class form. A `class` attribute serializes as an ordinary
8
+ * `class=value` pair, and a name that starts with a dot (`{".featured": true}`)
9
+ * serializes through the plain boolean branch as `{.featured}` — the dot is
10
+ * part of the name. Both directions need no special case, which is why this
11
+ * file no longer has one.
6
12
  */
7
13
 
8
14
  /**
@@ -22,13 +28,7 @@ export function serializeAttributes(attrs, skipKeys = []) {
22
28
  if (skipSet.has(key)) continue
23
29
  if (value === null || value === undefined) continue
24
30
 
25
- if (key === 'class') {
26
- // Split class string into individual .class entries
27
- const classes = String(value).split(/\s+/).filter(Boolean)
28
- for (const cls of classes) {
29
- parts.push(`.${cls}`)
30
- }
31
- } else if (key === 'id') {
31
+ if (key === 'id') {
32
32
  parts.push(`#${value}`)
33
33
  } else if (value === true) {
34
34
  // Boolean attribute
package/src/marks.js CHANGED
@@ -38,6 +38,32 @@ function marksEqual(a, b) {
38
38
  return true
39
39
  }
40
40
 
41
+ /**
42
+ * Would `inner` on its own re-parse to exactly this link?
43
+ *
44
+ * An author who writes a bare `https://example.com` gets a link mark whose
45
+ * href equals its text. Writing that back as `[https://example.com](https://example.com)`
46
+ * is content-identical but rewrites the author's file — and content-writer is
47
+ * what the editor uses to sync back to a developer's repository, so every save
48
+ * would churn every bare URL in it.
49
+ *
50
+ * Kept deliberately narrow. The bare form is only emitted when the mark
51
+ * carries nothing but an href, the text IS that href (or its address, for
52
+ * mailto), and the URL is one GFM autolinking will recapture with the same
53
+ * bounds: no whitespace or brackets, and no trailing punctuation, which
54
+ * autolink detection excludes from the match. Anything else keeps the
55
+ * explicit form, which is always correct if more verbose.
56
+ */
57
+ const AUTOLINK_SAFE = /^[^\s<>()[\]]*[^\s<>()[\].,;:!?'"]$/
58
+
59
+ function isBareAutolink(mark, inner) {
60
+ const { href, title, ...rest } = mark.attrs || {}
61
+ if (!href || title || Object.keys(rest).length > 0) return false
62
+ if (href === inner) return /^https?:\/\//.test(href) && AUTOLINK_SAFE.test(href)
63
+ if (href === `mailto:${inner}`) return AUTOLINK_SAFE.test(inner) && inner.includes('@')
64
+ return false
65
+ }
66
+
41
67
  /**
42
68
  * Serialize a link mark's suffix: (href "title"){attrs}
43
69
  */
@@ -313,7 +339,7 @@ function applyMark(inner, mark) {
313
339
  // rule, Mod-Shift-s) and had no markdown form, so it was dropped.
314
340
  return `~~${inner}~~`
315
341
  case 'link':
316
- return `[${inner}]${serializeLinkSuffix(mark)}`
342
+ return isBareAutolink(mark, inner) ? inner : `[${inner}]${serializeLinkSuffix(mark)}`
317
343
  case 'button':
318
344
  return `[${inner}]${serializeButtonSuffix(mark)}`
319
345
  case 'span':
package/src/nodes.js CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  import yaml from 'js-yaml'
8
8
  import { serializeInlineContent, serializeBlockImage, serializeInsetRef } from './marks.js'
9
+ import { serializeAttributes } from './attributes.js'
9
10
 
10
11
  // inset_ref serialization is shared with the inline path; it lives in
11
12
  // marks.js (alongside the other inline-attribute serializers) and is
@@ -95,6 +96,72 @@ export function serializeMathDisplay(node) {
95
96
  : '$$' + latex + '$$'
96
97
  }
97
98
 
99
+ /**
100
+ * Serialize a container node back to its fence.
101
+ *
102
+ * The inverse of content-reader's `@Component{params}` fence. The info string
103
+ * is the inset's own token, so a container is byte-for-byte the block form of
104
+ * an inset — same concept, two levels.
105
+ *
106
+ * The fence widens past any backtick run in the body, which is what lets a
107
+ * container hold a code sample: `fenceWidthFor` is the same computation the
108
+ * plain code-block path uses.
109
+ *
110
+ * @param {Object} node - inset_block node with attrs.component + params
111
+ * @returns {string} Fenced container
112
+ */
113
+ export function serializeInsetBlock(node) {
114
+ const { component, ...params } = node.attrs || {}
115
+ if (!component) return ''
116
+
117
+ const { serializeNode } = await_serializer()
118
+ const body = (node.content || [])
119
+ .map(child => serializeNode(child))
120
+ .filter(Boolean)
121
+ .join('\n\n')
122
+
123
+ const fence = '`'.repeat(fenceWidthFor(body))
124
+ const attrStr = serializeAttributes(params)
125
+
126
+ return `${fence}@${component}${attrStr}\n${body}\n${fence}`
127
+ }
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
+
98
165
  /**
99
166
  * Serialize a data block node.
100
167
  *
@@ -154,30 +221,35 @@ export function serializeBlockquote(node) {
154
221
 
155
222
  /**
156
223
  * Serialize a bullet list node.
224
+ *
225
+ * `pad` is the literal indentation this list sits at — the parent item's
226
+ * CONTENT COLUMN, not a level count. See serializeListItem for why.
227
+ *
157
228
  * @param {Object} node - Bullet list node with content (listItem nodes)
158
- * @param {number} [indent=0] - Indentation level
229
+ * @param {string} [pad=''] - Indentation prefix for every marker in this list
159
230
  * @returns {string} Markdown bullet list
160
231
  */
161
- export function serializeBulletList(node, indent = 0) {
232
+ export function serializeBulletList(node, pad = '') {
162
233
  if (!node.content) return ''
163
- const prefix = ' '.repeat(indent)
164
234
  return node.content
165
- .map(item => serializeListItem(item, `${prefix}- `, indent, isLoose(node)))
235
+ .map(item => serializeListItem(item, `${pad}- `, isLoose(node)))
166
236
  .join(itemSeparator(node))
167
237
  }
168
238
 
169
239
  /**
170
240
  * Serialize an ordered list node.
241
+ *
242
+ * `pad` is the literal indentation this list sits at — see serializeBulletList.
243
+ *
171
244
  * @param {Object} node - Ordered list node with attrs.start and content
172
- * @param {number} [indent=0] - Indentation level
245
+ * @param {string} [pad=''] - Indentation prefix for every marker in this list
173
246
  * @returns {string} Markdown ordered list
174
247
  */
175
- export function serializeOrderedList(node, indent = 0) {
248
+ export function serializeOrderedList(node, pad = '') {
176
249
  if (!node.content) return ''
177
250
  const start = node.attrs?.start || 1
178
- const prefix = ' '.repeat(indent)
179
251
  return node.content
180
- .map((item, i) => serializeListItem(item, `${prefix}${start + i}. `, indent, isLoose(node)))
252
+ .map((item, i) => serializeListItem(item, `${pad}${start + i}. `, isLoose(node)))
181
253
  .join(itemSeparator(node))
182
254
  }
183
255
 
@@ -207,13 +279,24 @@ function isLoose(node) {
207
279
 
208
280
  /**
209
281
  * Serialize a list item.
282
+ *
283
+ * A nested list is indented to this item's CONTENT COLUMN — `bullet.length` —
284
+ * not by a fixed two spaces per level. The marker's width varies (`- ` is 2,
285
+ * `9. ` is 3, `10. ` is 4), and a child indented less than the parent's
286
+ * content column is not a child at all: markdown reads it as ending the
287
+ * outer list. That is a source-corrupting bug, not a cosmetic one — a
288
+ * three-item ordered list with a nested bullet list came back as three
289
+ * separate top-level nodes.
290
+ *
291
+ * `pad` already computes that column for every other block in the item; nested
292
+ * lists now use the same value instead of their own count.
293
+ *
210
294
  * @param {Object} node - List item node with content
211
295
  * @param {string} bullet - The bullet prefix (e.g., "- " or "1. ")
212
- * @param {number} indent - Current indentation level
213
296
  * @param {boolean} [loose=false] - Whether the parent list is loose
214
297
  * @returns {string} Markdown list item
215
298
  */
216
- function serializeListItem(node, bullet, indent, loose = false) {
299
+ function serializeListItem(node, bullet, loose = false) {
217
300
  if (!node.content) return bullet
218
301
 
219
302
  const parts = []
@@ -224,11 +307,11 @@ function serializeListItem(node, bullet, indent, loose = false) {
224
307
 
225
308
  // Nested lists carry their own indentation.
226
309
  if (child.type === 'bulletList') {
227
- parts.push(serializeBulletList(child, indent + 1))
310
+ parts.push(serializeBulletList(child, pad))
228
311
  continue
229
312
  }
230
313
  if (child.type === 'orderedList') {
231
- parts.push(serializeOrderedList(child, indent + 1))
314
+ parts.push(serializeOrderedList(child, pad))
232
315
  continue
233
316
  }
234
317
 
@@ -270,9 +353,21 @@ function indentBlock(text, firstPrefix, pad) {
270
353
 
271
354
  /**
272
355
  * Serialize a divider node.
273
- * @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
274
365
  */
275
- 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
+ }
276
371
  return '---'
277
372
  }
278
373
 
package/src/normalize.js CHANGED
@@ -192,6 +192,15 @@ function mapEditorNode(node) {
192
192
  case 'UniwebIcon':
193
193
  case 'Icon':
194
194
  return mapUniwebIcon(node)
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
+ }
195
204
  case 'emoji':
196
205
  // The node carries the character alongside a name; the character is the
197
206
  // whole markdown form. Guarded rather than assumed to be populated.
@@ -237,6 +246,68 @@ function mapEditorNode(node) {
237
246
  */
238
247
  const NAMED_INLINE_STYLES = new Set(['accent', 'highlight', 'callout', 'muted'])
239
248
 
249
+ /**
250
+ * Editor container nodes → the framework's `inset_block`, as an explicit table.
251
+ *
252
+ * Deliberately not derived from the node name. The editor's node is
253
+ * `WarningBlock` while the component is `Alert`, and its `type` defaults to
254
+ * `info` rather than `warning` — a name-derived mapping drops exactly that row,
255
+ * which is how `mapUniwebIcon` came to lose every icon the editor produces. A
256
+ * missing entry here is a visible gap rather than a silent assumption.
257
+ */
258
+ const EDITOR_CONTAINERS = {
259
+ WarningBlock: node => ({ component: 'Alert', type: node.attrs?.type || 'info' }),
260
+ details: () => ({ component: 'Details' }),
261
+ }
262
+
263
+ /** Inline node types, for deciding whether a container's body needs wrapping. */
264
+ const INLINE_TYPES = new Set([
265
+ 'text',
266
+ 'hardBreak',
267
+ 'image',
268
+ 'math_inline',
269
+ 'inset_ref',
270
+ 'emoji',
271
+ 'UniwebIcon',
272
+ ])
273
+
274
+ /**
275
+ * A container body as block content.
276
+ *
277
+ * `WarningBlock` holds `text*` today and `block+` after the editor's widening,
278
+ * so both shapes arrive here. Inline content is wrapped in one paragraph;
279
+ * block content passes through.
280
+ *
281
+ * @param {Array} content
282
+ * @returns {Array}
283
+ */
284
+ function asBlocks(content) {
285
+ if (!content?.length) return []
286
+ return content.every(node => INLINE_TYPES.has(node.type))
287
+ ? [{ type: 'paragraph', content }]
288
+ : content
289
+ }
290
+
291
+ /**
292
+ * Flatten the editor's `details` › `detailsSummary` + `detailsContent` into the
293
+ * container's body, summary first.
294
+ *
295
+ * The summary leads rather than riding on an attribute: `detailsSummary` can
296
+ * carry marks, and `summary="…"` would flatten emphasis and links.
297
+ *
298
+ * @param {Object} node
299
+ * @returns {Array}
300
+ */
301
+ function detailsBody(node) {
302
+ const body = []
303
+ 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 || []))
306
+ else body.push(child)
307
+ }
308
+ return body
309
+ }
310
+
240
311
  /** A mark that carries nothing and should be removed rather than reported. */
241
312
  const DROP_MARK = Symbol('drop-mark')
242
313
 
package/src/serializer.js CHANGED
@@ -9,6 +9,8 @@ import {
9
9
  serializeParagraph,
10
10
  serializeImage,
11
11
  serializeInsetRef,
12
+ serializeInsetBlock,
13
+ serializeConceptBlock,
12
14
  serializeCodeBlock,
13
15
  serializeDataBlock,
14
16
  serializeBlockquote,
@@ -27,6 +29,8 @@ const NODE_SERIALIZERS = {
27
29
  paragraph: serializeParagraph,
28
30
  image: serializeImage,
29
31
  inset_ref: serializeInsetRef,
32
+ inset_block: serializeInsetBlock,
33
+ concept_block: serializeConceptBlock,
30
34
  codeBlock: serializeCodeBlock,
31
35
  dataBlock: serializeDataBlock,
32
36
  blockquote: serializeBlockquote,
@@ -27,12 +27,12 @@ describe('Attribute Serialization', () => {
27
27
  expect(serializeAttributes({ autoplay: true, muted: true })).toBe('{autoplay muted}')
28
28
  })
29
29
 
30
- test('serializes class as dot notation', () => {
31
- expect(serializeAttributes({ class: 'featured' })).toBe('{.featured}')
30
+ test('serializes a dotted NAME through the plain boolean branch', () => {
31
+ expect(serializeAttributes({ '.featured': true })).toBe('{.featured}')
32
32
  })
33
33
 
34
- test('serializes multiple classes', () => {
35
- expect(serializeAttributes({ class: 'featured rounded shadow' })).toBe('{.featured .rounded .shadow}')
34
+ test('serializes several dotted names', () => {
35
+ expect(serializeAttributes({ '.featured': true, '.rounded': true, '.shadow': true })).toBe('{.featured .rounded .shadow}')
36
36
  })
37
37
 
38
38
  test('serializes id as hash notation', () => {
@@ -41,7 +41,7 @@ describe('Attribute Serialization', () => {
41
41
 
42
42
  test('serializes mixed attributes', () => {
43
43
  const result = serializeAttributes({
44
- class: 'featured',
44
+ '.featured': true,
45
45
  id: 'main-logo',
46
46
  width: 800,
47
47
  loading: 'lazy',
@@ -95,7 +95,7 @@ describe('Attribute Serialization', () => {
95
95
 
96
96
  test('serializes data attributes', () => {
97
97
  const result = serializeAttributes({
98
- class: 'info',
98
+ '.info': true,
99
99
  'data-tooltip': 'More info',
100
100
  })
101
101
  expect(result).toBe('{.info data-tooltip="More info"}')
@@ -0,0 +1,7 @@
1
+ Open http://localhost:5173 in your browser.
2
+
3
+ Email me at a@b.com when it works.
4
+
5
+ A labelled link to [the docs](https://example.com) stays labelled.
6
+
7
+ A link whose label happens to be a URL, [https://example.com](https://example.com "titled"), keeps its explicit form.
@@ -0,0 +1,22 @@
1
+ # Containers
2
+
3
+ ```@Details
4
+ A summary with **marks**
5
+
6
+ Body content, [links](/x), the lot.
7
+ ```
8
+
9
+ ```@Alert{type=warning}
10
+ Be careful with this.
11
+
12
+ - one
13
+ - two
14
+ ```
15
+
16
+ ````@Alert{type=info}
17
+ Do not do this:
18
+
19
+ ```js
20
+ const x = 1
21
+ ```
22
+ ````
@@ -0,0 +1,18 @@
1
+ 1. Push your repo to the host
2
+ 2. Configure:
3
+ - Base directory: `site`
4
+ - Build command: `pnpm build`
5
+ 3. Deploy
6
+
7
+ ## A two-digit marker is wider
8
+
9
+ 8. eight
10
+ 9. nine
11
+ 10. ten
12
+ - nested under a two-digit marker
13
+
14
+ ## A bullet parent stays at two
15
+
16
+ - a
17
+ - b
18
+ - c
@@ -202,7 +202,7 @@ describe('Inline Content Serialization', () => {
202
202
  {
203
203
  type: 'text',
204
204
  text: 'highlighted text',
205
- marks: [{ type: 'span', attrs: { class: 'highlight' } }],
205
+ marks: [{ type: 'span', attrs: { '.highlight': true } }],
206
206
  },
207
207
  { type: 'text', text: ' in a sentence.' },
208
208
  ]
@@ -216,7 +216,7 @@ describe('Inline Content Serialization', () => {
216
216
  {
217
217
  type: 'text',
218
218
  text: 'important note',
219
- marks: [{ type: 'span', attrs: { class: 'callout bold' } }],
219
+ marks: [{ type: 'span', attrs: { '.callout': true, '.bold': true } }],
220
220
  },
221
221
  ]
222
222
  expect(serializeInlineContent(content)).toBe('[important note]{.callout .bold}')
@@ -227,7 +227,7 @@ describe('Inline Content Serialization', () => {
227
227
  {
228
228
  type: 'text',
229
229
  text: 'key term',
230
- marks: [{ type: 'span', attrs: { class: 'highlight', id: 'glossary-term' } }],
230
+ marks: [{ type: 'span', attrs: { '.highlight': true, id: 'glossary-term' } }],
231
231
  },
232
232
  ]
233
233
  expect(serializeInlineContent(content)).toBe('[key term]{.highlight #glossary-term}')
@@ -238,7 +238,7 @@ describe('Inline Content Serialization', () => {
238
238
  {
239
239
  type: 'text',
240
240
  text: 'tooltip text',
241
- marks: [{ type: 'span', attrs: { class: 'info', 'data-tooltip': 'More info' } }],
241
+ marks: [{ type: 'span', attrs: { '.info': true, 'data-tooltip': 'More info' } }],
242
242
  },
243
243
  ]
244
244
  expect(serializeInlineContent(content)).toBe('[tooltip text]{.info data-tooltip="More info"}')
@@ -274,13 +274,13 @@ describe('Inline Content Serialization', () => {
274
274
  {
275
275
  type: 'text',
276
276
  text: 'first',
277
- marks: [{ type: 'span', attrs: { class: 'highlight' } }],
277
+ marks: [{ type: 'span', attrs: { '.highlight': true } }],
278
278
  },
279
279
  { type: 'text', text: ' normal ' },
280
280
  {
281
281
  type: 'text',
282
282
  text: 'second',
283
- marks: [{ type: 'span', attrs: { class: 'muted' } }],
283
+ marks: [{ type: 'span', attrs: { '.muted': true } }],
284
284
  },
285
285
  ]
286
286
  expect(serializeInlineContent(content)).toBe('[first]{.highlight} normal [second]{.muted}')
@@ -297,7 +297,7 @@ describe('Inline Content Serialization', () => {
297
297
  {
298
298
  type: 'text',
299
299
  text: 'span',
300
- marks: [{ type: 'span', attrs: { class: 'highlight' } }],
300
+ marks: [{ type: 'span', attrs: { '.highlight': true } }],
301
301
  },
302
302
  ]
303
303
  expect(serializeInlineContent(content)).toBe(
@@ -242,3 +242,43 @@ describe('blocks inside list items', () => {
242
242
  expect(rt(source)).toBe(source)
243
243
  })
244
244
  })
245
+
246
+ describe('containers — the block form of an inset', () => {
247
+ const rt = md => proseMirrorToMarkdown(markdownToProseMirror(md)).trim()
248
+
249
+ test('a container round-trips with its params', () => {
250
+ const source = '```@Alert{type=warning}\nBe careful.\n```'
251
+ expect(rt(source)).toBe(source)
252
+ })
253
+
254
+ test('a rich body survives — marks, links and blocks', () => {
255
+ const source = '```@Details\nA **summary**\n\nBody with [a link](/x).\n```'
256
+ expect(rt(source)).toBe(source)
257
+ })
258
+
259
+ test('the outer fence widens for a code block inside', () => {
260
+ // What makes a code sample in a callout possible, and why the body
261
+ // cannot be inline-only.
262
+ const source = '````@Alert{type=info}\nDo not:\n\n```js\nconst x = 1\n```\n````'
263
+ expect(rt(source)).toBe(source)
264
+ })
265
+
266
+ test('the editor container shapes normalize onto it', () => {
267
+ const text = t => ({ type: 'text', text: t })
268
+ const doc = node => proseMirrorToMarkdown({ type: 'doc', content: [node] }).trim()
269
+
270
+ // WarningBlock ↔ @Alert, defaulting to info, NOT warning — a name-derived
271
+ // mapping drops exactly this.
272
+ 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.
276
+ expect(doc({
277
+ type: 'details',
278
+ content: [
279
+ { type: 'detailsSummary', content: [text('Breaking '), { type: 'text', text: 'changes', marks: [{ type: 'bold' }] }] },
280
+ { type: 'detailsContent', content: [{ type: 'paragraph', content: [text('Body.')] }] },
281
+ ],
282
+ })).toBe('```@Details\nBreaking **changes**\n\nBody.\n```')
283
+ })
284
+ })
@@ -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
+ })
@@ -180,7 +180,7 @@ describe('Image Serialization', () => {
180
180
  alt: 'Logo',
181
181
  caption: null,
182
182
  role: 'image',
183
- class: 'featured',
183
+ '.featured': true,
184
184
  id: 'main-logo',
185
185
  },
186
186
  },
@@ -200,7 +200,7 @@ describe('Image Serialization', () => {
200
200
  alt: 'Gallery',
201
201
  caption: null,
202
202
  role: 'image',
203
- class: 'featured rounded shadow',
203
+ '.featured': true, '.rounded': true, '.shadow': true,
204
204
  },
205
205
  },
206
206
  ],