@uniweb/content-writer 0.2.7 → 0.2.9

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.
Files changed (33) hide show
  1. package/package.json +4 -3
  2. package/probe.tmp.mjs +18 -0
  3. package/src/attributes.js +8 -8
  4. package/src/entities.js +107 -0
  5. package/src/index.js +2 -1
  6. package/src/marks.js +181 -93
  7. package/src/nodes.js +204 -33
  8. package/src/normalize.js +158 -13
  9. package/src/plain-text.js +71 -4
  10. package/src/serializer.js +2 -0
  11. package/tests/attributes.test.js +6 -6
  12. package/tests/entities.test.js +175 -0
  13. package/tests/fixtures/roundtrip/bare-autolink.md +7 -0
  14. package/tests/fixtures/roundtrip/canonical-math.md +3 -0
  15. package/tests/fixtures/roundtrip/code.md +11 -0
  16. package/tests/fixtures/roundtrip/containers.md +22 -0
  17. package/tests/fixtures/roundtrip/defect-bold-code.md +3 -0
  18. package/tests/fixtures/roundtrip/defect-bold-link.md +1 -0
  19. package/tests/fixtures/roundtrip/defect-data-block.md +6 -0
  20. package/tests/fixtures/roundtrip/defect-loose-list.md +5 -0
  21. package/tests/fixtures/roundtrip/fences.md +9 -0
  22. package/tests/fixtures/roundtrip/list-blocks.md +18 -0
  23. package/tests/fixtures/roundtrip/lists.md +13 -0
  24. package/tests/fixtures/roundtrip/media.md +7 -0
  25. package/tests/fixtures/roundtrip/nested-list-indent.md +18 -0
  26. package/tests/fixtures/roundtrip/prose.md +12 -0
  27. package/tests/fixtures/roundtrip/structure.md +16 -0
  28. package/tests/fixtures/roundtrip/tables-escaped.md +6 -0
  29. package/tests/fixtures/roundtrip/tables.md +6 -0
  30. package/tests/marks.test.js +13 -8
  31. package/tests/normalize.test.js +107 -4
  32. package/tests/roundtrip-source.test.js +284 -0
  33. package/tests/serializer.test.js +24 -14
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@uniweb/content-writer",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "description": "ProseMirror document structure to Markdown converter",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
8
- ".": "./src/index.js"
8
+ ".": "./src/index.js",
9
+ "./entities": "./src/entities.js"
9
10
  },
10
11
  "keywords": [
11
12
  "markdown",
@@ -19,7 +20,7 @@
19
20
  },
20
21
  "devDependencies": {
21
22
  "vitest": "^4.1.7",
22
- "@uniweb/content-reader": "1.1.14"
23
+ "@uniweb/content-reader": "1.1.17"
23
24
  },
24
25
  "directories": {
25
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
@@ -0,0 +1,107 @@
1
+ /**
2
+ * @fileoverview Undo the HTML escaping that ProseMirror text nodes carry.
3
+ *
4
+ * Text in a PM node is HTML, because the render path injects it as HTML
5
+ * (`kit`'s `SafeHtml` / `Text` use `dangerouslySetInnerHTML`). A literal `<`
6
+ * the author typed inside a code span therefore has to be *stored* escaped —
7
+ * `<code><x></code>` would have the browser swallow `<x>` as an unknown tag.
8
+ *
9
+ * `content-reader` encodes that asymmetry deliberately: it decodes `&amp;`,
10
+ * `&quot;` and `&#39;` back to plain characters and leaves `&lt;` / `&gt;`
11
+ * escaped. Verified against the parser (`parser/inline.js`) — raw HTML an
12
+ * author writes in prose is preserved verbatim through the `html` token, so
13
+ * only *machine-escaped* text carries entities on the way out.
14
+ *
15
+ * Two exports, because "leaving HTML-land" means different things depending
16
+ * on where the text is going:
17
+ *
18
+ * - `decodeMarkupEntities` — the markdown lane. Undoes only the pair the
19
+ * reader left behind, so serializing is the reader's exact inverse.
20
+ * - `decodeTextEntities` — the plain-text lane. The target is not HTML at
21
+ * all, so every entity resolves to the character a reader would see.
22
+ */
23
+
24
+ /**
25
+ * Undo the escaping `content-reader` leaves in place (`&lt;` / `&gt;`).
26
+ *
27
+ * Deliberately narrow. `&amp;`, `&quot;` and `&#39;` are already decoded by
28
+ * the reader, so a PM node holding one of those means the author wrote the
29
+ * entity themselves — decoding it again would corrupt their source.
30
+ *
31
+ * @param {string} text
32
+ * @returns {string}
33
+ */
34
+ export function decodeMarkupEntities(text) {
35
+ if (!text || !text.includes('&')) return text
36
+ return text.replace(/&lt;/g, '<').replace(/&gt;/g, '>')
37
+ }
38
+
39
+ /**
40
+ * Escape a code span's text for storage in a ProseMirror text node.
41
+ *
42
+ * The exact inverse of {@link decodeMarkupEntities}, and the other half of the
43
+ * invariant: a code span's content is rendered by injecting it as HTML, so a
44
+ * literal `<` has to be stored escaped or `<code><x></code>` has the browser
45
+ * swallow `<x>` and the span renders empty.
46
+ *
47
+ * Exported for **producers other than content-reader**. Any tool that writes a
48
+ * ProseMirror code span — an editor, a converter — needs this rule, and the
49
+ * rule living in prose rather than in code is what let two producers diverge.
50
+ * Import it instead of reimplementing it.
51
+ *
52
+ * Only `<` and `>`: every other character is safe inside an HTML text node,
53
+ * and escaping `&` here would double-encode an entity the author typed.
54
+ *
55
+ * @param {string} text - Raw code span content, as the author typed it
56
+ * @returns {string} Text safe to store in a PM code-marked node
57
+ */
58
+ export function encodeMarkupEntities(text) {
59
+ if (!text) return text
60
+ return text.replace(/</g, '&lt;').replace(/>/g, '&gt;')
61
+ }
62
+
63
+ /**
64
+ * Resolve every entity to the character a reader would actually see.
65
+ *
66
+ * `&amp;` is decoded **last** so `&amp;lt;` yields `&lt;` rather than `<` —
67
+ * one decoding pass, not a cascade.
68
+ *
69
+ * @param {string} text
70
+ * @returns {string}
71
+ */
72
+ export function decodeTextEntities(text) {
73
+ if (!text || !text.includes('&')) return text
74
+ return text
75
+ .replace(/&lt;/g, '<')
76
+ .replace(/&gt;/g, '>')
77
+ .replace(/&quot;/g, '"')
78
+ .replace(/&#0*39;/g, "'")
79
+ .replace(/&apos;/g, "'")
80
+ .replace(/&nbsp;/g, ' ')
81
+ .replace(/&amp;/g, '&')
82
+ }
83
+
84
+ /**
85
+ * Drop raw inline HTML an author wrote in prose (`<br>`, `<span …>`).
86
+ *
87
+ * Only for the plain-text lane. Runs **before** entity decoding, so text the
88
+ * author escaped on purpose (`&lt;b&gt;`, meaning a literal `<b>`) survives
89
+ * as characters instead of being mistaken for a tag and stripped.
90
+ *
91
+ * @param {string} text
92
+ * @returns {string}
93
+ */
94
+ export function stripInlineHtml(text) {
95
+ if (!text || !text.includes('<')) return text
96
+ return text.replace(/<\/?[a-zA-Z][^>]*>/g, '')
97
+ }
98
+
99
+ /**
100
+ * The plain-text cleanup, in the order the two steps have to run.
101
+ *
102
+ * @param {string} text
103
+ * @returns {string}
104
+ */
105
+ export function toPlainCharacters(text) {
106
+ return decodeTextEntities(stripInlineHtml(text))
107
+ }
package/src/index.js CHANGED
@@ -8,7 +8,8 @@
8
8
  export { serializeDoc as proseMirrorToMarkdown } from './serializer.js'
9
9
  export { serializeInlineContent } from './marks.js'
10
10
  export { serializeFrontmatter } from './frontmatter.js'
11
- export { docToPlainText } from './plain-text.js'
11
+ export { docToPlainText, docSummaryText } from './plain-text.js'
12
+ export { encodeMarkupEntities, decodeMarkupEntities, decodeTextEntities } from './entities.js'
12
13
  export { normalizeDialect } from './normalize.js'
13
14
  export { setDiagnosticsReporter, resetDiagnostics } from './diagnostics.js'
14
15
 
package/src/marks.js CHANGED
@@ -5,16 +5,17 @@
5
5
  */
6
6
 
7
7
  import { serializeAttributes } from './attributes.js'
8
+ import { decodeMarkupEntities } from './entities.js'
8
9
  import { warnUnmappedMark, warnUnmappedNode } from './diagnostics.js'
9
10
 
10
11
  /**
11
12
  * Marks the markdown serializer can represent. Any other mark on an
12
13
  * inline node has its styling dropped (the text is still emitted) and is
13
14
  * reported by the no-silent-drop guard — e.g. editor-dialect `highlight`,
14
- * `textStyle` (colour), `strike`, `underline`, `uniqueID`, which need a
15
+ * `textStyle` (colour), `highlight`, `underline`, `uniqueID`, which need a
15
16
  * named-inline-style mapping decision.
16
17
  */
17
- const KNOWN_MARKS = new Set(['bold', 'italic', 'code', 'link', 'button', 'span'])
18
+ const KNOWN_MARKS = new Set(['bold', 'italic', 'code', 'strike', 'link', 'button', 'span'])
18
19
 
19
20
  /** Report any inline mark we have no markdown form for (deduped per type). */
20
21
  function reportUnknownMarks(content) {
@@ -26,16 +27,6 @@ function reportUnknownMarks(content) {
26
27
  }
27
28
  }
28
29
 
29
- /**
30
- * Check if a node has a specific mark type.
31
- * @param {Object} node - ProseMirror inline node
32
- * @param {string} type - Mark type
33
- * @returns {Object|undefined} The mark object if found
34
- */
35
- function findMark(node, type) {
36
- return node.marks?.find(m => m.type === type)
37
- }
38
-
39
30
  /**
40
31
  * Check if two marks are the same (same type and same attrs for link/button/span).
41
32
  */
@@ -47,6 +38,32 @@ function marksEqual(a, b) {
47
38
  return true
48
39
  }
49
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
+
50
67
  /**
51
68
  * Serialize a link mark's suffix: (href "title"){attrs}
52
69
  */
@@ -115,15 +132,40 @@ export function serializeInsetRef(node) {
115
132
  return `${bang}[${label}](@${component})${attrStr}`
116
133
  }
117
134
 
135
+ /**
136
+ * Icon library short codes, mirroring content-reader's `ICON_FAMILIES_SHORT`.
137
+ *
138
+ * The two spellings are not interchangeable on the way back in: the reader
139
+ * accepts `family:name` for ANY family but `family-name` only for a short
140
+ * code, deliberately, so a dash form stays unambiguous against an ordinary
141
+ * relative path. Emitting the dash form for a full family name produced
142
+ * `![](lucide-star)`, which reads back as a plain image with
143
+ * `src: "lucide-star"` — the icon destroyed, and stable in that state.
144
+ */
145
+ const ICON_SHORT_CODES = new Set([
146
+ 'lu', 'hi', 'hi2', 'pi', 'tb', 'fi', 'bs', 'md', 'ai',
147
+ 'ri', 'si', 'io5', 'bi', 'vsc', 'wi', 'gi', 'fa', 'fa6',
148
+ ])
149
+
150
+ /**
151
+ * Spell an icon reference so the reader parses it back as an icon.
152
+ * @param {string} library
153
+ * @param {string} name
154
+ * @returns {string}
155
+ */
156
+ function iconRef(library, name) {
157
+ return ICON_SHORT_CODES.has(library) ? `${library}-${name}` : `${library}:${name}`
158
+ }
159
+
118
160
  /**
119
161
  * Serialize an inline image node (icon within a paragraph).
120
162
  */
121
163
  function serializeInlineImage(node) {
122
164
  const { src, alt, caption, role, library, name, ...rest } = node.attrs || {}
123
165
 
124
- // Icon with library+name → compact dash format
166
+ // Icon with library+name → dash for a short code, colon otherwise
125
167
  if (library && name) {
126
- const iconSrc = `${library}-${name}`
168
+ const iconSrc = iconRef(library, name)
127
169
  const extraAttrs = { ...rest }
128
170
  // Remove icon-derived attrs from extra attrs
129
171
  delete extraAttrs.size
@@ -154,9 +196,9 @@ function serializeInlineImage(node) {
154
196
  export function serializeBlockImage(node) {
155
197
  const { src, alt, caption, role, library, name, ...rest } = node.attrs || {}
156
198
 
157
- // Icon with library+name → compact dash format
199
+ // Icon with library+name → dash for a short code, colon otherwise
158
200
  if (library && name) {
159
- const iconSrc = `${library}-${name}`
201
+ const iconSrc = iconRef(library, name)
160
202
  const extraAttrs = { ...rest }
161
203
  const attrStr = serializeAttributes(extraAttrs)
162
204
  const altPart = alt || ''
@@ -196,81 +238,115 @@ export function serializeInlineContent(content) {
196
238
  // No silent drops: flag any mark we can't represent before serializing.
197
239
  reportUnknownMarks(content)
198
240
 
199
- // Pre-process: group nodes by wrapping marks (link, button, span)
200
- const segments = groupByWrappingMarks(content)
201
- return segments.map(seg => serializeSegment(seg)).join('')
241
+ return serializeRuns(content)
202
242
  }
203
243
 
204
244
  /**
205
- * Group consecutive nodes by shared wrapping marks (link, button, span).
206
- * Returns an array of segments, where each segment is either:
207
- * - { type: 'link'|'button'|'span', mark, nodes } — wrapped group
208
- * - { type: 'plain', nodes } — unwrapped nodes
245
+ * Serialize a run of inline nodes, honoring mark nesting.
246
+ *
247
+ * `node.marks` is **ordered, innermost first** — the reader records which mark
248
+ * the author wrapped around which. `**\`x\`**` gives `[code, bold]`;
249
+ * `[**x**](url)` gives `[bold, link]`; `**[x](url)**` gives `[link, bold]`.
250
+ * The semantic parser already reads that order (it renders
251
+ * `<strong><code>x</code></strong>`), and this serializer now does too.
252
+ *
253
+ * The algorithm is one rule applied recursively: take the OUTERMOST mark of
254
+ * the first node, absorb every following node that shares it, strip that one
255
+ * mark from the group, recurse, then wrap the result once.
256
+ *
257
+ * Absorbing the neighbours is what keeps a mark that spans several nodes from
258
+ * being re-emitted around each of them. `**Preview with \`pnpm dev\`.**` is
259
+ * three text nodes — `[bold]`, `[code, bold]`, `[bold]` — and wrapping each
260
+ * separately produced the invalid `**Preview with **\`pnpm dev\`**.**`.
261
+ *
262
+ * @param {Array} nodes - Inline nodes
263
+ * @returns {string} Markdown string
209
264
  */
210
- function groupByWrappingMarks(content) {
211
- const segments = []
265
+ function serializeRuns(nodes) {
266
+ const out = []
212
267
  let i = 0
213
268
 
214
- while (i < content.length) {
215
- const node = content[i]
216
-
217
- // Check for wrapping marks: link, button, span
218
- const linkMark = findMark(node, 'link')
219
- const buttonMark = findMark(node, 'button')
220
- const spanMark = findMark(node, 'span')
221
- const wrappingMark = buttonMark || linkMark || spanMark
222
-
223
- if (wrappingMark && node.type === 'text') {
224
- // Collect consecutive nodes with the same wrapping mark
225
- const group = [node]
226
- let j = i + 1
227
- while (j < content.length && content[j].type === 'text') {
228
- const nextMark = findMark(content[j], wrappingMark.type)
229
- if (nextMark && marksEqual(nextMark, wrappingMark)) {
230
- group.push(content[j])
231
- j++
232
- } else {
233
- break
234
- }
235
- }
236
- segments.push({ type: wrappingMark.type, mark: wrappingMark, nodes: group })
237
- i = j
238
- } else {
239
- // Plain node (no wrapping mark, or an image node)
240
- segments.push({ type: 'plain', nodes: [node] })
269
+ while (i < nodes.length) {
270
+ const node = nodes[i]
271
+ const outer = node.type === 'text' ? outermostMark(node) : null
272
+
273
+ if (!outer) {
274
+ out.push(serializePlainNode(node))
241
275
  i++
276
+ continue
277
+ }
278
+
279
+ // Absorb the consecutive text nodes wrapped by this same mark.
280
+ const group = []
281
+ let j = i
282
+ while (j < nodes.length && nodes[j].type === 'text') {
283
+ const candidate = outermostMark(nodes[j])
284
+ if (!candidate || !marksEqual(candidate, outer)) break
285
+ group.push(withoutOutermostMark(nodes[j]))
286
+ j++
242
287
  }
288
+
289
+ out.push(applyMark(serializeRuns(group), outer))
290
+ i = j
243
291
  }
244
292
 
245
- return segments
293
+ return out.join('')
246
294
  }
247
295
 
248
296
  /**
249
- * Serialize a segment (group of nodes with a common wrapping mark, or plain nodes).
297
+ * The mark an author wrapped outermost — the last entry, since marks are
298
+ * ordered innermost first.
299
+ *
300
+ * @param {Object} node
301
+ * @returns {Object|null}
250
302
  */
251
- function serializeSegment(segment) {
252
- if (segment.type === 'plain') {
253
- return segment.nodes.map(n => serializePlainNode(n)).join('')
254
- }
303
+ function outermostMark(node) {
304
+ const marks = node.marks || []
305
+ return marks.length ? marks[marks.length - 1] : null
306
+ }
255
307
 
256
- // Wrapped segment: serialize inner content (with the wrapping mark stripped)
257
- const innerText = segment.nodes.map(node => {
258
- // Strip the wrapping mark from this node's marks for inner serialization
259
- const innerMarks = (node.marks || []).filter(m => !marksEqual(m, segment.mark))
260
- return serializeTextWithMarks(node.text, innerMarks)
261
- }).join('')
308
+ /**
309
+ * The same node with its outermost mark peeled off.
310
+ * @param {Object} node
311
+ * @returns {Object}
312
+ */
313
+ function withoutOutermostMark(node) {
314
+ return { ...node, marks: (node.marks || []).slice(0, -1) }
315
+ }
262
316
 
263
- if (segment.type === 'link') {
264
- return `[${innerText}]${serializeLinkSuffix(segment.mark)}`
265
- }
266
- if (segment.type === 'button') {
267
- return `[${innerText}]${serializeButtonSuffix(segment.mark)}`
268
- }
269
- if (segment.type === 'span') {
270
- return `[${innerText}]${serializeSpanSuffix(segment.mark)}`
317
+ /**
318
+ * Wrap already-serialized inner markdown in one mark.
319
+ *
320
+ * A mark with no markdown form leaves the text alone — the no-silent-drop
321
+ * guard has already reported it.
322
+ *
323
+ * @param {string} inner - Serialized inner content
324
+ * @param {Object} mark
325
+ * @returns {string}
326
+ */
327
+ function applyMark(inner, mark) {
328
+ switch (mark.type) {
329
+ case 'code':
330
+ // Leaving HTML-land: a code span is the one place the reader leaves
331
+ // `&lt;`/`&gt;` escaped (see entities.js).
332
+ return serializeCodeSpan(decodeMarkupEntities(inner))
333
+ case 'bold':
334
+ return `**${inner}**`
335
+ case 'italic':
336
+ return `*${inner}*`
337
+ case 'strike':
338
+ // GFM. The editor's strike mark is author-reachable (input rule, paste
339
+ // rule, Mod-Shift-s) and had no markdown form, so it was dropped.
340
+ return `~~${inner}~~`
341
+ case 'link':
342
+ return isBareAutolink(mark, inner) ? inner : `[${inner}]${serializeLinkSuffix(mark)}`
343
+ case 'button':
344
+ return `[${inner}]${serializeButtonSuffix(mark)}`
345
+ case 'span':
346
+ return `[${inner}]${serializeSpanSuffix(mark)}`
347
+ default:
348
+ return inner
271
349
  }
272
-
273
- return innerText
274
350
  }
275
351
 
276
352
  /**
@@ -304,28 +380,40 @@ function serializePlainNode(node) {
304
380
  }
305
381
 
306
382
  /**
307
- * Serialize text with formatting marks (bold, italic, code).
383
+ * Wrap text in code-span backticks, picking a delimiter it can survive.
384
+ *
385
+ * A code span holding backticks needs a longer run to delimit it, and one
386
+ * that begins or ends with a backtick needs a padding space (the parser
387
+ * strips exactly one from each end). A single backtick emitted regardless
388
+ * mangles anything documenting markdown itself — `` ` ```math ` `` came back
389
+ * as ` ````math` `, which is a different, broken span.
390
+ *
391
+ * @param {string} text - Code span content
392
+ * @returns {string} The delimited span
308
393
  */
309
- function serializeTextWithMarks(text, marks) {
310
- if (!marks || marks.length === 0) return text
394
+ function serializeCodeSpan(text) {
395
+ const content = String(text)
396
+ const runs = (content.match(/`+/g) || []).map(run => run.length)
311
397
 
312
- const hasCode = marks.some(m => m.type === 'code')
313
- if (hasCode) {
314
- return `\`${text}\``
315
- }
398
+ // The shortest run length not already present, so the delimiter is
399
+ // unambiguous — CommonMark matches on equal-length runs.
400
+ let width = 1
401
+ while (runs.includes(width)) width++
402
+ const fence = '`'.repeat(width)
316
403
 
317
- const hasBold = marks.some(m => m.type === 'bold')
318
- const hasItalic = marks.some(m => m.type === 'italic')
404
+ // A leading or trailing backtick would fuse with the delimiter.
405
+ const pad = content.startsWith('`') || content.endsWith('`') ? ' ' : ''
319
406
 
320
- if (hasBold && hasItalic) {
321
- return `***${text}***`
322
- }
323
- if (hasBold) {
324
- return `**${text}**`
325
- }
326
- if (hasItalic) {
327
- return `*${text}*`
328
- }
407
+ return `${fence}${pad}${content}${pad}${fence}`
408
+ }
329
409
 
330
- return text
410
+ /**
411
+ * Serialize one text node's own marks, innermost first.
412
+ *
413
+ * Only reached for a lone node — `serializeRuns` handles anything that shares
414
+ * a mark with its neighbours.
415
+ */
416
+ function serializeTextWithMarks(text, marks) {
417
+ if (!marks || marks.length === 0) return text
418
+ return marks.reduce((inner, mark) => applyMark(inner, mark), text)
331
419
  }