@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
|
@@ -0,0 +1,284 @@
|
|
|
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
|
+
})
|
|
245
|
+
|
|
246
|
+
describe('containers — the block form of an inset', () => {
|
|
247
|
+
const rt = md => proseMirrorToMarkdown(markdownToProseMirror(md)).trim()
|
|
248
|
+
|
|
249
|
+
test('a container round-trips with its params', () => {
|
|
250
|
+
const source = '```@Alert{type=warning}\nBe careful.\n```'
|
|
251
|
+
expect(rt(source)).toBe(source)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
test('a rich body survives — marks, links and blocks', () => {
|
|
255
|
+
const source = '```@Details\nA **summary**\n\nBody with [a link](/x).\n```'
|
|
256
|
+
expect(rt(source)).toBe(source)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
test('the outer fence widens for a code block inside', () => {
|
|
260
|
+
// What makes a code sample in a callout possible, and why the body
|
|
261
|
+
// cannot be inline-only.
|
|
262
|
+
const source = '````@Alert{type=info}\nDo not:\n\n```js\nconst x = 1\n```\n````'
|
|
263
|
+
expect(rt(source)).toBe(source)
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
test('the editor container shapes normalize onto it', () => {
|
|
267
|
+
const text = t => ({ type: 'text', text: t })
|
|
268
|
+
const doc = node => proseMirrorToMarkdown({ type: 'doc', content: [node] }).trim()
|
|
269
|
+
|
|
270
|
+
// WarningBlock ↔ @Alert, defaulting to info, NOT warning — a name-derived
|
|
271
|
+
// mapping drops exactly this.
|
|
272
|
+
expect(doc({ type: 'WarningBlock', attrs: {}, content: [text('Careful.')] }))
|
|
273
|
+
.toBe('```@Alert{type=info}\nCareful.\n```')
|
|
274
|
+
|
|
275
|
+
// The summary is the first block and keeps its marks.
|
|
276
|
+
expect(doc({
|
|
277
|
+
type: 'details',
|
|
278
|
+
content: [
|
|
279
|
+
{ type: 'detailsSummary', content: [text('Breaking '), { type: 'text', text: 'changes', marks: [{ type: 'bold' }] }] },
|
|
280
|
+
{ type: 'detailsContent', content: [{ type: 'paragraph', content: [text('Body.')] }] },
|
|
281
|
+
],
|
|
282
|
+
})).toBe('```@Details\nBreaking **changes**\n\nBody.\n```')
|
|
283
|
+
})
|
|
284
|
+
})
|
package/tests/serializer.test.js
CHANGED
|
@@ -180,7 +180,7 @@ describe('Image Serialization', () => {
|
|
|
180
180
|
alt: 'Logo',
|
|
181
181
|
caption: null,
|
|
182
182
|
role: 'image',
|
|
183
|
-
|
|
183
|
+
'.featured': true,
|
|
184
184
|
id: 'main-logo',
|
|
185
185
|
},
|
|
186
186
|
},
|
|
@@ -200,7 +200,7 @@ describe('Image Serialization', () => {
|
|
|
200
200
|
alt: 'Gallery',
|
|
201
201
|
caption: null,
|
|
202
202
|
role: 'image',
|
|
203
|
-
|
|
203
|
+
'.featured': true, '.rounded': true, '.shadow': true,
|
|
204
204
|
},
|
|
205
205
|
},
|
|
206
206
|
],
|
|
@@ -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
|
|