@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.
- package/package.json +4 -3
- package/probe.tmp.mjs +18 -0
- package/src/attributes.js +8 -8
- package/src/entities.js +107 -0
- package/src/index.js +2 -1
- package/src/marks.js +181 -93
- package/src/nodes.js +204 -33
- package/src/normalize.js +158 -13
- package/src/plain-text.js +71 -4
- package/src/serializer.js +2 -0
- package/tests/attributes.test.js +6 -6
- package/tests/entities.test.js +175 -0
- package/tests/fixtures/roundtrip/bare-autolink.md +7 -0
- package/tests/fixtures/roundtrip/canonical-math.md +3 -0
- package/tests/fixtures/roundtrip/code.md +11 -0
- package/tests/fixtures/roundtrip/containers.md +22 -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/nested-list-indent.md +18 -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 +13 -8
- package/tests/normalize.test.js +107 -4
- package/tests/roundtrip-source.test.js +284 -0
- 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.
|
|
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.
|
|
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
|
|
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 === '
|
|
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/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
|
*/
|
|
@@ -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
|
+
* ``, 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 →
|
|
166
|
+
// Icon with library+name → dash for a short code, colon otherwise
|
|
125
167
|
if (library && name) {
|
|
126
|
-
const iconSrc =
|
|
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 →
|
|
199
|
+
// Icon with library+name → dash for a short code, colon otherwise
|
|
158
200
|
if (library && name) {
|
|
159
|
-
const iconSrc =
|
|
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
|
-
|
|
200
|
-
const segments = groupByWrappingMarks(content)
|
|
201
|
-
return segments.map(seg => serializeSegment(seg)).join('')
|
|
241
|
+
return serializeRuns(content)
|
|
202
242
|
}
|
|
203
243
|
|
|
204
244
|
/**
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
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
|
|
211
|
-
const
|
|
265
|
+
function serializeRuns(nodes) {
|
|
266
|
+
const out = []
|
|
212
267
|
let i = 0
|
|
213
268
|
|
|
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] })
|
|
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
|
|
293
|
+
return out.join('')
|
|
246
294
|
}
|
|
247
295
|
|
|
248
296
|
/**
|
|
249
|
-
*
|
|
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
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
303
|
+
function outermostMark(node) {
|
|
304
|
+
const marks = node.marks || []
|
|
305
|
+
return marks.length ? marks[marks.length - 1] : null
|
|
306
|
+
}
|
|
255
307
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
+
// `<`/`>` 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
|
-
*
|
|
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
|
|
310
|
-
|
|
394
|
+
function serializeCodeSpan(text) {
|
|
395
|
+
const content = String(text)
|
|
396
|
+
const runs = (content.match(/`+/g) || []).map(run => run.length)
|
|
311
397
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
318
|
-
const
|
|
404
|
+
// A leading or trailing backtick would fuse with the delimiter.
|
|
405
|
+
const pad = content.startsWith('`') || content.endsWith('`') ? ' ' : ''
|
|
319
406
|
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
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
|
}
|