@uniweb/content-writer 0.2.6 → 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 +161 -93
- package/src/nodes.js +148 -24
- package/src/normalize.js +87 -3
- 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 +128 -2
- package/tests/roundtrip-source.test.js +244 -0
- package/tests/roundtrip.test.js +31 -0
- package/tests/serializer.test.js +22 -12
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Blocks inside list items
|
|
2
|
+
|
|
3
|
+
- Use the hook:
|
|
4
|
+
```jsx
|
|
5
|
+
const { data } = useEntityDetail(article)
|
|
6
|
+
```
|
|
7
|
+
- A quoted aside:
|
|
8
|
+
> Worth knowing.
|
|
9
|
+
- A nested list:
|
|
10
|
+
- child
|
|
11
|
+
- sibling
|
|
12
|
+
- Plain item
|
|
13
|
+
|
|
14
|
+
1. Step one
|
|
15
|
+
```sh
|
|
16
|
+
pnpm install
|
|
17
|
+
```
|
|
18
|
+
2. Step two
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Working with Collections
|
|
2
|
+
|
|
3
|
+
Collections let you manage repeating content as a set of files.
|
|
4
|
+
|
|
5
|
+
## Declaring a collection
|
|
6
|
+
|
|
7
|
+
Each collection is declared in `site.yml`, and every item compiles into
|
|
8
|
+
`/data/<name>.json`. See the [collections guide](/docs/authoring/collections)
|
|
9
|
+
for the full story.
|
|
10
|
+
|
|
11
|
+
Text can be **bold**, *italic*, or ***both***. A literal <name> stays
|
|
12
|
+
escaped in prose, and `a & b` keeps its entity inside code.
|
package/tests/marks.test.js
CHANGED
|
@@ -44,7 +44,12 @@ describe('Inline Content Serialization', () => {
|
|
|
44
44
|
marks: [{ type: 'italic' }, { type: 'bold' }],
|
|
45
45
|
},
|
|
46
46
|
]
|
|
47
|
-
|
|
47
|
+
// Bold spans both nodes and italic sits inside it, so bold is emitted once
|
|
48
|
+
// around the pair. This used to assert `**bold *****then italic***` — two
|
|
49
|
+
// bold spans butted together — which does not survive a reparse: the run
|
|
50
|
+
// of five asterisks reads back as the literal text `**bold **` with no
|
|
51
|
+
// marks at all, destroying the bold it was meant to carry.
|
|
52
|
+
expect(serializeInlineContent(content)).toBe('**bold *then italic***')
|
|
48
53
|
})
|
|
49
54
|
|
|
50
55
|
test('serializes inline code', () => {
|
package/tests/normalize.test.js
CHANGED
|
@@ -156,8 +156,12 @@ describe('no-silent-drop guard: unmappable nodes are reported, not lost', () =>
|
|
|
156
156
|
})
|
|
157
157
|
)
|
|
158
158
|
)
|
|
159
|
-
|
|
160
|
-
|
|
159
|
+
// `highlight` used to have no markdown form and was reported as an
|
|
160
|
+
// unmapped mark. It is now a named inline style — the editor stores a
|
|
161
|
+
// theme token rather than a colour — so it maps onto the span mark and
|
|
162
|
+
// nothing is dropped or reported.
|
|
163
|
+
expect(md).toBe('[important]{highlight}')
|
|
164
|
+
expect(warnings).toEqual([])
|
|
161
165
|
})
|
|
162
166
|
|
|
163
167
|
test('a known framework document produces no warnings', () => {
|
|
@@ -195,4 +199,126 @@ describe('no-silent-drop guard: unmappable nodes are reported, not lost', () =>
|
|
|
195
199
|
expect(md).toBe('before after')
|
|
196
200
|
expect(warnings.some((m) => m.includes('FancyInlineWidget'))).toBe(true)
|
|
197
201
|
})
|
|
202
|
+
|
|
203
|
+
test('a standalone "\\n" text node is a soft break, not a hard one', () => {
|
|
204
|
+
// content-reader emits a lone "\n" node for a SOFT break whenever the
|
|
205
|
+
// spans on both sides are marked. This used to be healed into a hard
|
|
206
|
+
// break, on the assumption it could only be the legacy spelling of one —
|
|
207
|
+
// which turned a wrapped line into a visible <br> one round trip later.
|
|
208
|
+
const md = proseMirrorToMarkdown(
|
|
209
|
+
doc(
|
|
210
|
+
para(
|
|
211
|
+
{ type: 'text', text: 'line one' },
|
|
212
|
+
{ type: 'text', text: '\n' },
|
|
213
|
+
{ type: 'text', text: 'line two' }
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
expect(md.trim()).toBe('line one\nline two')
|
|
218
|
+
expect(warnings).toEqual([])
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
test('a hard break is the hardBreak node, and still serializes as one', () => {
|
|
222
|
+
const md = proseMirrorToMarkdown(
|
|
223
|
+
doc(para({ type: 'text', text: 'line one' }, { type: 'hardBreak' }, { type: 'text', text: 'line two' }))
|
|
224
|
+
)
|
|
225
|
+
expect(md.trim()).toBe('line one\\\nline two')
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
test('a newline INSIDE a text node is a soft break and must not be converted', () => {
|
|
229
|
+
// The narrow rule matters: this is an ordinary wrapped paragraph, which is
|
|
230
|
+
// a space. Converting it would inject a break into every wrapped paragraph.
|
|
231
|
+
const md = proseMirrorToMarkdown(
|
|
232
|
+
doc(para({ type: 'text', text: 'wrapped\nparagraph' }))
|
|
233
|
+
)
|
|
234
|
+
expect(md.trim()).toBe('wrapped\nparagraph')
|
|
235
|
+
})
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
describe('editor named inline styles map onto the span mark', () => {
|
|
239
|
+
let warnings
|
|
240
|
+
|
|
241
|
+
beforeEach(() => {
|
|
242
|
+
warnings = []
|
|
243
|
+
resetDiagnostics()
|
|
244
|
+
setDiagnosticsReporter(m => warnings.push(m))
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
const styled = marks => proseMirrorToMarkdown(doc(para({ type: 'text', text: 'x', marks }))).trim()
|
|
248
|
+
|
|
249
|
+
test('a textStyle token becomes a named inline style', () => {
|
|
250
|
+
// The editor's picker offers a closed set of theme tokens, not colours —
|
|
251
|
+
// the attribute is only *named* `color`. So they round-trip through the
|
|
252
|
+
// span mark the framework already has, and nothing is lost.
|
|
253
|
+
expect(styled([{ type: 'textStyle', attrs: { color: 'accent' } }])).toBe('[x]{accent}')
|
|
254
|
+
expect(styled([{ type: 'textStyle', attrs: { color: 'muted' } }])).toBe('[x]{muted}')
|
|
255
|
+
expect(styled([{ type: 'textStyle', attrs: { color: 'callout' } }])).toBe('[x]{callout}')
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
test('highlight is effectively boolean and maps the same way', () => {
|
|
259
|
+
expect(styled([{ type: 'highlight' }])).toBe('[x]{highlight}')
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
test('the "Normal" option carries no styling', () => {
|
|
263
|
+
expect(styled([{ type: 'textStyle', attrs: { color: '' } }])).toBe('x')
|
|
264
|
+
expect(warnings).toEqual([])
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
test('a raw colour is NOT mapped — it is reported, not written into markdown', () => {
|
|
268
|
+
// Reachable today via a paste carrying inline style; the editor is gating
|
|
269
|
+
// its parseHTML. A hex in markdown would contradict the theming model and
|
|
270
|
+
// would not survive a change of brand, so it must not be invented here.
|
|
271
|
+
expect(styled([{ type: 'textStyle', attrs: { color: '#ff0000' } }])).toBe('x')
|
|
272
|
+
expect(warnings.some(m => m.includes('textStyle'))).toBe(true)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
test('a mapped style round-trips back through the reader', () => {
|
|
276
|
+
expect(styled([{ type: 'textStyle', attrs: { color: 'accent' } }])).toBe('[x]{accent}')
|
|
277
|
+
})
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
describe('editor icons and emoji', () => {
|
|
281
|
+
const inline = n => proseMirrorToMarkdown(doc(para(n))).trim()
|
|
282
|
+
|
|
283
|
+
test('the insert path stores a colon-joined family:id name', () => {
|
|
284
|
+
// What the editor actually emits: one `name`, no `library`. Requiring
|
|
285
|
+
// both attrs dropped every icon the editor produces.
|
|
286
|
+
expect(inline({ type: 'UniwebIcon', attrs: { name: 'lucide:star', svg: '<svg/>' } }))
|
|
287
|
+
.toBe('')
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
test('a short-code family uses the compact dash spelling', () => {
|
|
291
|
+
expect(inline({ type: 'UniwebIcon', attrs: { name: 'lu:star' } })).toBe('')
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
test('the separate library+name form still maps', () => {
|
|
295
|
+
expect(inline({ type: 'UniwebIcon', attrs: { library: 'lu', name: 'star' } })).toBe('')
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
test('the inlined svg blob is never written into markdown', () => {
|
|
299
|
+
const md = inline({ type: 'UniwebIcon', attrs: { name: 'lucide:star', svg: '<svg viewBox="0 0 24 24"/>' } })
|
|
300
|
+
expect(md).not.toContain('<svg')
|
|
301
|
+
expect(md).not.toContain('viewBox')
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
test('emoji carries its character', () => {
|
|
305
|
+
expect(inline({ type: 'emoji', attrs: { name: 'star', emoji: '⭐' } })).toBe('⭐')
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
test('an emoji with no character is not invented', () => {
|
|
309
|
+
expect(inline({ type: 'emoji', attrs: { name: 'star' } })).toBe('')
|
|
310
|
+
})
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
describe('icon spelling round-trips', () => {
|
|
314
|
+
test('a full family name keeps the colon form', () => {
|
|
315
|
+
// `` reads back as a plain image with src "lucide-star" —
|
|
316
|
+
// the dash spelling is short-codes-only, by design in the reader, so that
|
|
317
|
+
// the form stays unambiguous against a relative path.
|
|
318
|
+
for (const md of ['', '', '']) {
|
|
319
|
+
const out = proseMirrorToMarkdown(markdownToProseMirror(md)).trim()
|
|
320
|
+
const attrs = markdownToProseMirror(out).content[0].content[0].attrs
|
|
321
|
+
expect(attrs.role).toBe('icon')
|
|
322
|
+
}
|
|
323
|
+
})
|
|
198
324
|
})
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source-fidelity round trip: does `source.md` survive reader → writer as the
|
|
3
|
+
* *same text*, not merely as the same ProseMirror structure?
|
|
4
|
+
*
|
|
5
|
+
* `roundtrip.test.js` already asserts the structural fixed point (reparse the
|
|
6
|
+
* output, compare documents). That test cannot catch a whole class of defect:
|
|
7
|
+
* the file-lane bug where the second pass is stable but the *first* pass has
|
|
8
|
+
* already rewritten the author's source. D1 was exactly that shape — a code
|
|
9
|
+
* span holding `<name>` re-serialized as `<name>`, which reparses to
|
|
10
|
+
* the same document forever after.
|
|
11
|
+
*
|
|
12
|
+
* That matters beyond tidiness. `content-writer` is what the editor uses to
|
|
13
|
+
* sync ProseMirror back to markdown files, so anything this test tolerates is
|
|
14
|
+
* something the editor will eventually write into a developer's repository.
|
|
15
|
+
* The app lane has no source file to diff against, so this is the only place
|
|
16
|
+
* the codec can be checked at all.
|
|
17
|
+
*
|
|
18
|
+
* Known defects are an explicit allowlist below. They assert the *current*
|
|
19
|
+
* wrong output, so fixing one fails this test and forces the entry to be
|
|
20
|
+
* removed — the allowlist can only shrink.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { readFileSync, readdirSync } from 'node:fs'
|
|
24
|
+
import { join, dirname } from 'node:path'
|
|
25
|
+
import { fileURLToPath } from 'node:url'
|
|
26
|
+
import { markdownToProseMirror } from '@uniweb/content-reader'
|
|
27
|
+
import { proseMirrorToMarkdown } from '../src/index.js'
|
|
28
|
+
|
|
29
|
+
const FIXTURE_DIR = join(dirname(fileURLToPath(import.meta.url)), 'fixtures', 'roundtrip')
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Cosmetic normalization applied to BOTH sides before comparing.
|
|
33
|
+
*
|
|
34
|
+
* Declared, not incidental — each entry is a difference we have decided is
|
|
35
|
+
* not a defect. Keep this list short; anything added here is fidelity given
|
|
36
|
+
* up permanently.
|
|
37
|
+
*/
|
|
38
|
+
function normalize(markdown) {
|
|
39
|
+
return (
|
|
40
|
+
markdown
|
|
41
|
+
// Table cell padding and delimiter width. Markdown tables are
|
|
42
|
+
// whitespace-insensitive between pipes, so `|---|---|` and
|
|
43
|
+
// `| --- | --- |` are the same table — as are `| |` and `| |`.
|
|
44
|
+
.replace(/^\|.*\|$/gm, normalizeTableRow)
|
|
45
|
+
// Hard breaks have two spellings. Two trailing spaces is the classic
|
|
46
|
+
// one; the serializer deliberately emits a backslash instead, because it
|
|
47
|
+
// survives editors and pre-commit hooks that strip trailing whitespace.
|
|
48
|
+
// Fold both to the backslash so a real hard break is not read as a diff
|
|
49
|
+
// — and so stripping trailing space below cannot silently delete one.
|
|
50
|
+
.replace(/[ \t]{2,}$/gm, '\\')
|
|
51
|
+
// Remaining trailing whitespace, and the final newline.
|
|
52
|
+
.replace(/[ \t]+$/gm, '')
|
|
53
|
+
.trim()
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Canonicalize one table row: trim every cell, collapse delimiter runs.
|
|
59
|
+
* @param {string} line
|
|
60
|
+
* @returns {string}
|
|
61
|
+
*/
|
|
62
|
+
function normalizeTableRow(line) {
|
|
63
|
+
const cells = line.slice(1, -1).split('|').map(cell => {
|
|
64
|
+
const trimmed = cell.trim()
|
|
65
|
+
return /^:?-{2,}:?$/.test(trimmed) ? trimmed.replace(/-+/, '---') : trimmed
|
|
66
|
+
})
|
|
67
|
+
return `| ${cells.join(' | ')} |`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Intentional canonicalizations: the projection differs from the source and
|
|
72
|
+
* that is the designed behavior, not a bug. Separate from the defect list
|
|
73
|
+
* below because these never "get fixed" — they are the serializer's chosen
|
|
74
|
+
* spelling, and the entry exists so the choice stays deliberate.
|
|
75
|
+
*/
|
|
76
|
+
const CANONICAL_FORMS = {
|
|
77
|
+
'canonical-math.md': {
|
|
78
|
+
reason: 'single-line display math canonicalizes to the compact $$…$$ spelling',
|
|
79
|
+
// `serializeMathDisplay` documents this: compact for one line, fenced
|
|
80
|
+
// ```math for multi-line. Both read back as the same node.
|
|
81
|
+
current: '$$E = mc^2$$'
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Defects known to corrupt source today, each with the output it currently
|
|
87
|
+
* produces. Fixing one makes its assertion fail, which is the point: the entry
|
|
88
|
+
* then has to come out, so this list can only shrink.
|
|
89
|
+
*/
|
|
90
|
+
const KNOWN_DEFECTS = {
|
|
91
|
+
// Empty, and that is the point of the mechanism rather than a claim of
|
|
92
|
+
// perfection. Four entries lived here and each left by being fixed:
|
|
93
|
+
//
|
|
94
|
+
// D1 — a code span's `<name>` written back as `<name>`
|
|
95
|
+
// D2 — bold mishandled around a code span (and dropped outright when it
|
|
96
|
+
// wrapped only one)
|
|
97
|
+
// D5 — a bold link re-nested to [**x**](url)
|
|
98
|
+
// D4 — a tagged data block's YAML rewritten as JSON
|
|
99
|
+
// D3 — a loose list re-serialized tight
|
|
100
|
+
//
|
|
101
|
+
// D2 and D5 turned out to be one bug (the serializer ignoring `node.marks`
|
|
102
|
+
// order); D1, D3 and D4 were all the same shape as each other — the reader
|
|
103
|
+
// dropping something the writer then had to guess at.
|
|
104
|
+
//
|
|
105
|
+
// Add an entry when you find a defect you are not fixing today, with the
|
|
106
|
+
// wrong output it currently produces. Fixing it will break the entry, which
|
|
107
|
+
// is what forces the removal: this list can only shrink.
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const fixtures = readdirSync(FIXTURE_DIR).filter(f => f.endsWith('.md')).sort()
|
|
111
|
+
|
|
112
|
+
describe('source-fidelity round trip', () => {
|
|
113
|
+
test('the fixture corpus is not empty', () => {
|
|
114
|
+
expect(fixtures.length).toBeGreaterThan(0)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
for (const name of fixtures) {
|
|
118
|
+
const source = readFileSync(join(FIXTURE_DIR, name), 'utf8')
|
|
119
|
+
const canonical = CANONICAL_FORMS[name]
|
|
120
|
+
const known = KNOWN_DEFECTS[name]
|
|
121
|
+
|
|
122
|
+
if (canonical) {
|
|
123
|
+
test(`${name} — canonicalized: ${canonical.reason}`, () => {
|
|
124
|
+
const projected = proseMirrorToMarkdown(markdownToProseMirror(source))
|
|
125
|
+
expect(normalize(projected)).toBe(normalize(canonical.current))
|
|
126
|
+
})
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (known) {
|
|
131
|
+
test(`${name} — known defect: ${known.defect}`, () => {
|
|
132
|
+
const projected = proseMirrorToMarkdown(markdownToProseMirror(source))
|
|
133
|
+
expect(normalize(projected)).toBe(normalize(known.current))
|
|
134
|
+
// Guard the guard: if this ever equals the source, the defect is
|
|
135
|
+
// fixed and the allowlist entry must be deleted.
|
|
136
|
+
expect(normalize(projected)).not.toBe(normalize(source))
|
|
137
|
+
})
|
|
138
|
+
continue
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
test(`${name} round-trips to its source`, () => {
|
|
142
|
+
const projected = proseMirrorToMarkdown(markdownToProseMirror(source))
|
|
143
|
+
expect(normalize(projected)).toBe(normalize(source))
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
test('every allowlist entry names a real fixture', () => {
|
|
148
|
+
for (const name of [...Object.keys(KNOWN_DEFECTS), ...Object.keys(CANONICAL_FORMS)]) {
|
|
149
|
+
expect(fixtures).toContain(name)
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
describe('structural fixed point holds for every fixture', () => {
|
|
155
|
+
// Even a fixture with a known source-level defect must be stable on the
|
|
156
|
+
// second pass — otherwise the defect compounds on each sync.
|
|
157
|
+
for (const name of fixtures) {
|
|
158
|
+
test(`${name} is stable on re-serialization`, () => {
|
|
159
|
+
const source = readFileSync(join(FIXTURE_DIR, name), 'utf8')
|
|
160
|
+
const once = proseMirrorToMarkdown(markdownToProseMirror(source))
|
|
161
|
+
const twice = proseMirrorToMarkdown(markdownToProseMirror(once))
|
|
162
|
+
expect(twice).toBe(once)
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
describe('list looseness', () => {
|
|
168
|
+
const rt = md => proseMirrorToMarkdown(markdownToProseMirror(md)).trim()
|
|
169
|
+
|
|
170
|
+
test('a tight list stays tight', () => {
|
|
171
|
+
expect(rt('- one\n- two')).toBe('- one\n- two')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
test('a loose list keeps its blank lines', () => {
|
|
175
|
+
expect(rt('- one\n\n- two')).toBe('- one\n\n- two')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('looseness applies inside an item too, not just between items', () => {
|
|
179
|
+
const source = '- parent\n\n - child\n - sibling\n\n- next'
|
|
180
|
+
expect(rt(source)).toBe(source)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('ordered lists carry looseness alongside start', () => {
|
|
184
|
+
expect(rt('1. a\n\n2. b')).toBe('1. a\n\n2. b')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
test('a nested list is loose independently of its parent', () => {
|
|
188
|
+
expect(rt('- parent\n - child\n - sibling\n- next')).toBe('- parent\n - child\n - sibling\n- next')
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
test('a list node with no loose flag serializes tight', () => {
|
|
192
|
+
// Documents written before the reader recorded looseness.
|
|
193
|
+
const doc = {
|
|
194
|
+
type: 'doc',
|
|
195
|
+
content: [{
|
|
196
|
+
type: 'bulletList',
|
|
197
|
+
content: ['one', 'two'].map(t => ({
|
|
198
|
+
type: 'listItem',
|
|
199
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: t }] }]
|
|
200
|
+
}))
|
|
201
|
+
}]
|
|
202
|
+
}
|
|
203
|
+
expect(proseMirrorToMarkdown(doc).trim()).toBe('- one\n- two')
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
describe('blocks inside list items', () => {
|
|
208
|
+
const rt = md => proseMirrorToMarkdown(markdownToProseMirror(md)).trim()
|
|
209
|
+
const itemChildren = md => markdownToProseMirror(md).content[0].content[0].content.map(n => n.type)
|
|
210
|
+
|
|
211
|
+
test('a fenced block under a bullet stays a code block', () => {
|
|
212
|
+
// It used to be lexed as inline and folded into the item's paragraph,
|
|
213
|
+
// losing the fence, the language and the line breaks — in the rendered
|
|
214
|
+
// page, not only on a round trip.
|
|
215
|
+
expect(itemChildren('- Use it:\n ```jsx\n const x = 1\n ```')).toEqual(['paragraph', 'codeBlock'])
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
test('a blockquote under a bullet stays a blockquote', () => {
|
|
219
|
+
expect(itemChildren('- Item\n > quoted')).toEqual(['paragraph', 'blockquote'])
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
test('a nested list is still a nested list', () => {
|
|
223
|
+
expect(itemChildren('- parent\n - child')).toEqual(['paragraph', 'bulletList'])
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
test('multiple paragraphs in one item survive', () => {
|
|
227
|
+
expect(itemChildren('- First\n\n Second\n\n- Next')).toEqual(['paragraph', 'paragraph'])
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
test('a code block round-trips inside a tight list', () => {
|
|
231
|
+
const source = '- Use the hook:\n ```jsx\n const x = 1\n ```\n- Next item'
|
|
232
|
+
expect(rt(source)).toBe(source)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
test('a code block round-trips inside a loose list', () => {
|
|
236
|
+
const source = '- Use the hook:\n\n ```jsx\n const x = 1\n ```\n\n- Next item'
|
|
237
|
+
expect(rt(source)).toBe(source)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
test('an ordered item indents its block to the wider bullet', () => {
|
|
241
|
+
const source = '1. Step one\n ```sh\n run it\n ```\n2. Step two'
|
|
242
|
+
expect(rt(source)).toBe(source)
|
|
243
|
+
})
|
|
244
|
+
})
|
package/tests/roundtrip.test.js
CHANGED
|
@@ -358,3 +358,34 @@ describe('Round-trip: Mixed Content', () => {
|
|
|
358
358
|
expect(reparsed).toEqual(parsed)
|
|
359
359
|
})
|
|
360
360
|
})
|
|
361
|
+
|
|
362
|
+
describe('Round-trip: Hard breaks', () => {
|
|
363
|
+
// A hard break used to become a "\n" text node, which re-parsed as a SOFT
|
|
364
|
+
// break — so the break was silently downgraded on every round trip. It is a
|
|
365
|
+
// `hardBreak` node now, and both markdown spellings converge on the
|
|
366
|
+
// backslash form.
|
|
367
|
+
testRoundTrip('line one\\\nline two', 'backslash hard break')
|
|
368
|
+
testRoundTrip('a\\\nb\\\nc', 'consecutive hard breaks')
|
|
369
|
+
testRoundTrip('**bold**\\\nplain', 'hard break after a mark')
|
|
370
|
+
|
|
371
|
+
test('two-space spelling normalizes to backslash and keeps the break', () => {
|
|
372
|
+
const parsed = markdownToProseMirror('line one \nline two')
|
|
373
|
+
expect(parsed.content[0].content).toEqual([
|
|
374
|
+
{ type: 'text', text: 'line one' },
|
|
375
|
+
{ type: 'hardBreak' },
|
|
376
|
+
{ type: 'text', text: 'line two' }
|
|
377
|
+
])
|
|
378
|
+
// One-time source normalization; the break itself survives.
|
|
379
|
+
const serialized = proseMirrorToMarkdown(parsed).trim()
|
|
380
|
+
expect(serialized).toBe('line one\\\nline two')
|
|
381
|
+
expect(markdownToProseMirror(serialized)).toEqual(parsed)
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
test('a soft break stays soft — it is a space, not a break', () => {
|
|
385
|
+
const parsed = markdownToProseMirror('wrapped\nparagraph')
|
|
386
|
+
expect(parsed.content[0].content).toEqual([
|
|
387
|
+
{ type: 'text', text: 'wrapped\nparagraph' }
|
|
388
|
+
])
|
|
389
|
+
expect(proseMirrorToMarkdown(parsed).trim()).toBe('wrapped\nparagraph')
|
|
390
|
+
})
|
|
391
|
+
})
|
package/tests/serializer.test.js
CHANGED
|
@@ -349,18 +349,28 @@ describe('Code Blocks', () => {
|
|
|
349
349
|
expect(serializeDoc(doc)).toBe('```\nline 1\n\nline 2\n```')
|
|
350
350
|
})
|
|
351
351
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
352
|
+
const dataBlockDoc = attrs => ({ type: 'doc', content: [{ type: 'dataBlock', attrs }] })
|
|
353
|
+
|
|
354
|
+
test('serializes a data block as JSON when that is what the author wrote', () => {
|
|
355
|
+
const doc = dataBlockDoc({ tag: 'nav-links', language: 'json', data: [{ label: 'Home' }] })
|
|
356
|
+
expect(serializeDoc(doc)).toBe('```json:nav-links\n[\n {\n "label": "Home"\n }\n]\n```')
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
test('serializes a data block as YAML when that is what the author wrote', () => {
|
|
360
|
+
const doc = dataBlockDoc({ tag: 'nav-links', language: 'yaml', data: [{ label: 'Home' }] })
|
|
361
|
+
expect(serializeDoc(doc)).toBe('```yaml:nav-links\n- label: Home\n```')
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
test('echoes back the `yml` spelling rather than normalizing it', () => {
|
|
365
|
+
const doc = dataBlockDoc({ tag: 'cfg', language: 'yml', data: { key: 'value' } })
|
|
366
|
+
expect(serializeDoc(doc)).toBe('```yml:cfg\nkey: value\n```')
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
test('falls back to YAML when no language was recorded', () => {
|
|
370
|
+
// A node from before the reader recorded `language`. This used to emit
|
|
371
|
+
// JSON unconditionally, which rewrote every YAML block an author wrote.
|
|
372
|
+
const doc = dataBlockDoc({ tag: 'nav-links', data: [{ label: 'Home' }] })
|
|
373
|
+
expect(serializeDoc(doc)).toBe('```yaml:nav-links\n- label: Home\n```')
|
|
364
374
|
})
|
|
365
375
|
})
|
|
366
376
|
|