@uniweb/content-writer 0.2.7 → 0.2.8
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 +4 -3
- package/src/entities.js +107 -0
- package/src/index.js +2 -1
- package/src/marks.js +155 -93
- package/src/nodes.js +148 -24
- package/src/normalize.js +87 -13
- package/src/plain-text.js +71 -4
- package/tests/entities.test.js +175 -0
- package/tests/fixtures/roundtrip/canonical-math.md +3 -0
- package/tests/fixtures/roundtrip/code.md +11 -0
- package/tests/fixtures/roundtrip/defect-bold-code.md +3 -0
- package/tests/fixtures/roundtrip/defect-bold-link.md +1 -0
- package/tests/fixtures/roundtrip/defect-data-block.md +6 -0
- package/tests/fixtures/roundtrip/defect-loose-list.md +5 -0
- package/tests/fixtures/roundtrip/fences.md +9 -0
- package/tests/fixtures/roundtrip/list-blocks.md +18 -0
- package/tests/fixtures/roundtrip/lists.md +13 -0
- package/tests/fixtures/roundtrip/media.md +7 -0
- package/tests/fixtures/roundtrip/prose.md +12 -0
- package/tests/fixtures/roundtrip/structure.md +16 -0
- package/tests/fixtures/roundtrip/tables-escaped.md +6 -0
- package/tests/fixtures/roundtrip/tables.md +6 -0
- package/tests/marks.test.js +6 -1
- package/tests/normalize.test.js +107 -4
- package/tests/roundtrip-source.test.js +244 -0
- package/tests/serializer.test.js +22 -12
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/content-writer",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.8",
|
|
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.
|
|
23
|
+
"@uniweb/content-reader": "1.1.16"
|
|
23
24
|
},
|
|
24
25
|
"directories": {
|
|
25
26
|
"test": "tests"
|
package/src/entities.js
ADDED
|
@@ -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 `&`,
|
|
10
|
+
* `"` and `'` back to plain characters and leaves `<` / `>`
|
|
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 (`<` / `>`).
|
|
26
|
+
*
|
|
27
|
+
* Deliberately narrow. `&`, `"` and `'` 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(/</g, '<').replace(/>/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, '<').replace(/>/g, '>')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolve every entity to the character a reader would actually see.
|
|
65
|
+
*
|
|
66
|
+
* `&` is decoded **last** so `&lt;` yields `<` 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(/</g, '<')
|
|
76
|
+
.replace(/>/g, '>')
|
|
77
|
+
.replace(/"/g, '"')
|
|
78
|
+
.replace(/�*39;/g, "'")
|
|
79
|
+
.replace(/'/g, "'")
|
|
80
|
+
.replace(/ /g, ' ')
|
|
81
|
+
.replace(/&/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 (`<b>`, 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), `
|
|
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
|
*/
|
|
@@ -115,15 +106,40 @@ export function serializeInsetRef(node) {
|
|
|
115
106
|
return `${bang}[${label}](@${component})${attrStr}`
|
|
116
107
|
}
|
|
117
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Icon library short codes, mirroring content-reader's `ICON_FAMILIES_SHORT`.
|
|
111
|
+
*
|
|
112
|
+
* The two spellings are not interchangeable on the way back in: the reader
|
|
113
|
+
* accepts `family:name` for ANY family but `family-name` only for a short
|
|
114
|
+
* code, deliberately, so a dash form stays unambiguous against an ordinary
|
|
115
|
+
* relative path. Emitting the dash form for a full family name produced
|
|
116
|
+
* ``, which reads back as a plain image with
|
|
117
|
+
* `src: "lucide-star"` — the icon destroyed, and stable in that state.
|
|
118
|
+
*/
|
|
119
|
+
const ICON_SHORT_CODES = new Set([
|
|
120
|
+
'lu', 'hi', 'hi2', 'pi', 'tb', 'fi', 'bs', 'md', 'ai',
|
|
121
|
+
'ri', 'si', 'io5', 'bi', 'vsc', 'wi', 'gi', 'fa', 'fa6',
|
|
122
|
+
])
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Spell an icon reference so the reader parses it back as an icon.
|
|
126
|
+
* @param {string} library
|
|
127
|
+
* @param {string} name
|
|
128
|
+
* @returns {string}
|
|
129
|
+
*/
|
|
130
|
+
function iconRef(library, name) {
|
|
131
|
+
return ICON_SHORT_CODES.has(library) ? `${library}-${name}` : `${library}:${name}`
|
|
132
|
+
}
|
|
133
|
+
|
|
118
134
|
/**
|
|
119
135
|
* Serialize an inline image node (icon within a paragraph).
|
|
120
136
|
*/
|
|
121
137
|
function serializeInlineImage(node) {
|
|
122
138
|
const { src, alt, caption, role, library, name, ...rest } = node.attrs || {}
|
|
123
139
|
|
|
124
|
-
// Icon with library+name →
|
|
140
|
+
// Icon with library+name → dash for a short code, colon otherwise
|
|
125
141
|
if (library && name) {
|
|
126
|
-
const iconSrc =
|
|
142
|
+
const iconSrc = iconRef(library, name)
|
|
127
143
|
const extraAttrs = { ...rest }
|
|
128
144
|
// Remove icon-derived attrs from extra attrs
|
|
129
145
|
delete extraAttrs.size
|
|
@@ -154,9 +170,9 @@ function serializeInlineImage(node) {
|
|
|
154
170
|
export function serializeBlockImage(node) {
|
|
155
171
|
const { src, alt, caption, role, library, name, ...rest } = node.attrs || {}
|
|
156
172
|
|
|
157
|
-
// Icon with library+name →
|
|
173
|
+
// Icon with library+name → dash for a short code, colon otherwise
|
|
158
174
|
if (library && name) {
|
|
159
|
-
const iconSrc =
|
|
175
|
+
const iconSrc = iconRef(library, name)
|
|
160
176
|
const extraAttrs = { ...rest }
|
|
161
177
|
const attrStr = serializeAttributes(extraAttrs)
|
|
162
178
|
const altPart = alt || ''
|
|
@@ -196,81 +212,115 @@ export function serializeInlineContent(content) {
|
|
|
196
212
|
// No silent drops: flag any mark we can't represent before serializing.
|
|
197
213
|
reportUnknownMarks(content)
|
|
198
214
|
|
|
199
|
-
|
|
200
|
-
const segments = groupByWrappingMarks(content)
|
|
201
|
-
return segments.map(seg => serializeSegment(seg)).join('')
|
|
215
|
+
return serializeRuns(content)
|
|
202
216
|
}
|
|
203
217
|
|
|
204
218
|
/**
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
219
|
+
* Serialize a run of inline nodes, honoring mark nesting.
|
|
220
|
+
*
|
|
221
|
+
* `node.marks` is **ordered, innermost first** — the reader records which mark
|
|
222
|
+
* the author wrapped around which. `**\`x\`**` gives `[code, bold]`;
|
|
223
|
+
* `[**x**](url)` gives `[bold, link]`; `**[x](url)**` gives `[link, bold]`.
|
|
224
|
+
* The semantic parser already reads that order (it renders
|
|
225
|
+
* `<strong><code>x</code></strong>`), and this serializer now does too.
|
|
226
|
+
*
|
|
227
|
+
* The algorithm is one rule applied recursively: take the OUTERMOST mark of
|
|
228
|
+
* the first node, absorb every following node that shares it, strip that one
|
|
229
|
+
* mark from the group, recurse, then wrap the result once.
|
|
230
|
+
*
|
|
231
|
+
* Absorbing the neighbours is what keeps a mark that spans several nodes from
|
|
232
|
+
* being re-emitted around each of them. `**Preview with \`pnpm dev\`.**` is
|
|
233
|
+
* three text nodes — `[bold]`, `[code, bold]`, `[bold]` — and wrapping each
|
|
234
|
+
* separately produced the invalid `**Preview with **\`pnpm dev\`**.**`.
|
|
235
|
+
*
|
|
236
|
+
* @param {Array} nodes - Inline nodes
|
|
237
|
+
* @returns {string} Markdown string
|
|
209
238
|
*/
|
|
210
|
-
function
|
|
211
|
-
const
|
|
239
|
+
function serializeRuns(nodes) {
|
|
240
|
+
const out = []
|
|
212
241
|
let i = 0
|
|
213
242
|
|
|
214
|
-
while (i <
|
|
215
|
-
const node =
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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] })
|
|
243
|
+
while (i < nodes.length) {
|
|
244
|
+
const node = nodes[i]
|
|
245
|
+
const outer = node.type === 'text' ? outermostMark(node) : null
|
|
246
|
+
|
|
247
|
+
if (!outer) {
|
|
248
|
+
out.push(serializePlainNode(node))
|
|
241
249
|
i++
|
|
250
|
+
continue
|
|
242
251
|
}
|
|
252
|
+
|
|
253
|
+
// Absorb the consecutive text nodes wrapped by this same mark.
|
|
254
|
+
const group = []
|
|
255
|
+
let j = i
|
|
256
|
+
while (j < nodes.length && nodes[j].type === 'text') {
|
|
257
|
+
const candidate = outermostMark(nodes[j])
|
|
258
|
+
if (!candidate || !marksEqual(candidate, outer)) break
|
|
259
|
+
group.push(withoutOutermostMark(nodes[j]))
|
|
260
|
+
j++
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
out.push(applyMark(serializeRuns(group), outer))
|
|
264
|
+
i = j
|
|
243
265
|
}
|
|
244
266
|
|
|
245
|
-
return
|
|
267
|
+
return out.join('')
|
|
246
268
|
}
|
|
247
269
|
|
|
248
270
|
/**
|
|
249
|
-
*
|
|
271
|
+
* The mark an author wrapped outermost — the last entry, since marks are
|
|
272
|
+
* ordered innermost first.
|
|
273
|
+
*
|
|
274
|
+
* @param {Object} node
|
|
275
|
+
* @returns {Object|null}
|
|
250
276
|
*/
|
|
251
|
-
function
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
277
|
+
function outermostMark(node) {
|
|
278
|
+
const marks = node.marks || []
|
|
279
|
+
return marks.length ? marks[marks.length - 1] : null
|
|
280
|
+
}
|
|
255
281
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
282
|
+
/**
|
|
283
|
+
* The same node with its outermost mark peeled off.
|
|
284
|
+
* @param {Object} node
|
|
285
|
+
* @returns {Object}
|
|
286
|
+
*/
|
|
287
|
+
function withoutOutermostMark(node) {
|
|
288
|
+
return { ...node, marks: (node.marks || []).slice(0, -1) }
|
|
289
|
+
}
|
|
262
290
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
291
|
+
/**
|
|
292
|
+
* Wrap already-serialized inner markdown in one mark.
|
|
293
|
+
*
|
|
294
|
+
* A mark with no markdown form leaves the text alone — the no-silent-drop
|
|
295
|
+
* guard has already reported it.
|
|
296
|
+
*
|
|
297
|
+
* @param {string} inner - Serialized inner content
|
|
298
|
+
* @param {Object} mark
|
|
299
|
+
* @returns {string}
|
|
300
|
+
*/
|
|
301
|
+
function applyMark(inner, mark) {
|
|
302
|
+
switch (mark.type) {
|
|
303
|
+
case 'code':
|
|
304
|
+
// Leaving HTML-land: a code span is the one place the reader leaves
|
|
305
|
+
// `<`/`>` escaped (see entities.js).
|
|
306
|
+
return serializeCodeSpan(decodeMarkupEntities(inner))
|
|
307
|
+
case 'bold':
|
|
308
|
+
return `**${inner}**`
|
|
309
|
+
case 'italic':
|
|
310
|
+
return `*${inner}*`
|
|
311
|
+
case 'strike':
|
|
312
|
+
// GFM. The editor's strike mark is author-reachable (input rule, paste
|
|
313
|
+
// rule, Mod-Shift-s) and had no markdown form, so it was dropped.
|
|
314
|
+
return `~~${inner}~~`
|
|
315
|
+
case 'link':
|
|
316
|
+
return `[${inner}]${serializeLinkSuffix(mark)}`
|
|
317
|
+
case 'button':
|
|
318
|
+
return `[${inner}]${serializeButtonSuffix(mark)}`
|
|
319
|
+
case 'span':
|
|
320
|
+
return `[${inner}]${serializeSpanSuffix(mark)}`
|
|
321
|
+
default:
|
|
322
|
+
return inner
|
|
271
323
|
}
|
|
272
|
-
|
|
273
|
-
return innerText
|
|
274
324
|
}
|
|
275
325
|
|
|
276
326
|
/**
|
|
@@ -304,28 +354,40 @@ function serializePlainNode(node) {
|
|
|
304
354
|
}
|
|
305
355
|
|
|
306
356
|
/**
|
|
307
|
-
*
|
|
357
|
+
* Wrap text in code-span backticks, picking a delimiter it can survive.
|
|
358
|
+
*
|
|
359
|
+
* A code span holding backticks needs a longer run to delimit it, and one
|
|
360
|
+
* that begins or ends with a backtick needs a padding space (the parser
|
|
361
|
+
* strips exactly one from each end). A single backtick emitted regardless
|
|
362
|
+
* mangles anything documenting markdown itself — `` ` ```math ` `` came back
|
|
363
|
+
* as ` ````math` `, which is a different, broken span.
|
|
364
|
+
*
|
|
365
|
+
* @param {string} text - Code span content
|
|
366
|
+
* @returns {string} The delimited span
|
|
308
367
|
*/
|
|
309
|
-
function
|
|
310
|
-
|
|
368
|
+
function serializeCodeSpan(text) {
|
|
369
|
+
const content = String(text)
|
|
370
|
+
const runs = (content.match(/`+/g) || []).map(run => run.length)
|
|
311
371
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
372
|
+
// The shortest run length not already present, so the delimiter is
|
|
373
|
+
// unambiguous — CommonMark matches on equal-length runs.
|
|
374
|
+
let width = 1
|
|
375
|
+
while (runs.includes(width)) width++
|
|
376
|
+
const fence = '`'.repeat(width)
|
|
316
377
|
|
|
317
|
-
|
|
318
|
-
const
|
|
378
|
+
// A leading or trailing backtick would fuse with the delimiter.
|
|
379
|
+
const pad = content.startsWith('`') || content.endsWith('`') ? ' ' : ''
|
|
319
380
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
}
|
|
323
|
-
if (hasBold) {
|
|
324
|
-
return `**${text}**`
|
|
325
|
-
}
|
|
326
|
-
if (hasItalic) {
|
|
327
|
-
return `*${text}*`
|
|
328
|
-
}
|
|
381
|
+
return `${fence}${pad}${content}${pad}${fence}`
|
|
382
|
+
}
|
|
329
383
|
|
|
330
|
-
|
|
384
|
+
/**
|
|
385
|
+
* Serialize one text node's own marks, innermost first.
|
|
386
|
+
*
|
|
387
|
+
* Only reached for a lone node — `serializeRuns` handles anything that shares
|
|
388
|
+
* a mark with its neighbours.
|
|
389
|
+
*/
|
|
390
|
+
function serializeTextWithMarks(text, marks) {
|
|
391
|
+
if (!marks || marks.length === 0) return text
|
|
392
|
+
return marks.reduce((inner, mark) => applyMark(inner, mark), text)
|
|
331
393
|
}
|