@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
@@ -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 `&lt;name&gt;`, 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 `&lt;name&gt;`
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
+ })
@@ -180,7 +180,7 @@ describe('Image Serialization', () => {
180
180
  alt: 'Logo',
181
181
  caption: null,
182
182
  role: 'image',
183
- class: 'featured',
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
- class: 'featured rounded shadow',
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
- test('serializes data block', () => {
353
- const doc = {
354
- type: 'doc',
355
- content: [
356
- {
357
- type: 'dataBlock',
358
- attrs: { tag: 'nav-links', data: [{ label: 'Home' }] },
359
- },
360
- ],
361
- }
362
- const expected = '```json:nav-links\n[\n {\n "label": "Home"\n }\n]\n```'
363
- expect(serializeDoc(doc)).toBe(expected)
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