@uniweb/content-writer 0.2.5 → 0.2.7

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/content-writer",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "ProseMirror document structure to Markdown converter",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -19,7 +19,7 @@
19
19
  },
20
20
  "devDependencies": {
21
21
  "vitest": "^4.1.7",
22
- "@uniweb/content-reader": "1.1.12"
22
+ "@uniweb/content-reader": "1.1.14"
23
23
  },
24
24
  "directories": {
25
25
  "test": "tests"
package/src/index.js CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  export { serializeDoc as proseMirrorToMarkdown } from './serializer.js'
9
+ export { serializeInlineContent } from './marks.js'
9
10
  export { serializeFrontmatter } from './frontmatter.js'
10
11
  export { docToPlainText } from './plain-text.js'
11
12
  export { normalizeDialect } from './normalize.js'
package/src/marks.js CHANGED
@@ -287,6 +287,12 @@ function serializePlainNode(node) {
287
287
  const latex = node.attrs?.latex || ''
288
288
  return node.attrs?.display ? '$$' + latex + '$$' : '$' + latex + '$'
289
289
  }
290
+ if (node.type === 'hardBreak') {
291
+ // Backslash rather than the two-trailing-spaces spelling: it survives
292
+ // editors, formatters and pre-commit hooks that strip trailing whitespace,
293
+ // and it is visible in a diff. Both spellings read back as a hard break.
294
+ return '\\\n'
295
+ }
290
296
  if (node.type !== 'text') {
291
297
  // No silent drops: an inline node we have no markdown form for is
292
298
  // still omitted (we can't invent one) but reported loudly, the same
package/src/normalize.js CHANGED
@@ -180,6 +180,16 @@ function mapEditorNode(node) {
180
180
  return mapVideo(node)
181
181
  case 'UniwebIcon':
182
182
  return mapUniwebIcon(node)
183
+ case 'text':
184
+ // Legacy hard break: content-reader used to emit a hard break as a
185
+ // standalone "\n" text node. Self-heal it to the node form so old
186
+ // documents converge on the next write.
187
+ //
188
+ // Deliberately narrow — ONLY a text node whose text is exactly "\n".
189
+ // A newline INSIDE a larger text node is a SOFT break (an ordinary
190
+ // wrapped paragraph), which is a space and must stay one; mapping those
191
+ // would inject a visible break into every wrapped paragraph.
192
+ return node.text === '\n' ? { type: 'hardBreak' } : null
183
193
  default:
184
194
  return null
185
195
  }
@@ -195,4 +195,27 @@ describe('no-silent-drop guard: unmappable nodes are reported, not lost', () =>
195
195
  expect(md).toBe('before after')
196
196
  expect(warnings.some((m) => m.includes('FancyInlineWidget'))).toBe(true)
197
197
  })
198
+
199
+ test('a legacy standalone "\\n" text node self-heals to a hard break', () => {
200
+ const md = proseMirrorToMarkdown(
201
+ doc(
202
+ para(
203
+ { type: 'text', text: 'line one' },
204
+ { type: 'text', text: '\n' },
205
+ { type: 'text', text: 'line two' }
206
+ )
207
+ )
208
+ )
209
+ expect(md.trim()).toBe('line one\\\nline two')
210
+ expect(warnings).toEqual([]) // healed, not reported as unmapped
211
+ })
212
+
213
+ test('a newline INSIDE a text node is a soft break and must not be converted', () => {
214
+ // The narrow rule matters: this is an ordinary wrapped paragraph, which is
215
+ // a space. Converting it would inject a break into every wrapped paragraph.
216
+ const md = proseMirrorToMarkdown(
217
+ doc(para({ type: 'text', text: 'wrapped\nparagraph' }))
218
+ )
219
+ expect(md.trim()).toBe('wrapped\nparagraph')
220
+ })
198
221
  })
@@ -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
+ })