@uniweb/content-writer 0.2.7 → 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 CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@uniweb/content-writer",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
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.14"
23
+ "@uniweb/content-reader": "1.1.16"
23
24
  },
24
25
  "directories": {
25
26
  "test": "tests"
@@ -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 `&amp;`,
10
+ * `&quot;` and `&#39;` back to plain characters and leaves `&lt;` / `&gt;`
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 (`&lt;` / `&gt;`).
26
+ *
27
+ * Deliberately narrow. `&amp;`, `&quot;` and `&#39;` 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(/&lt;/g, '<').replace(/&gt;/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, '&lt;').replace(/>/g, '&gt;')
61
+ }
62
+
63
+ /**
64
+ * Resolve every entity to the character a reader would actually see.
65
+ *
66
+ * `&amp;` is decoded **last** so `&amp;lt;` yields `&lt;` 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(/&lt;/g, '<')
76
+ .replace(/&gt;/g, '>')
77
+ .replace(/&quot;/g, '"')
78
+ .replace(/&#0*39;/g, "'")
79
+ .replace(/&apos;/g, "'")
80
+ .replace(/&nbsp;/g, ' ')
81
+ .replace(/&amp;/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 (`&lt;b&gt;`, 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), `strike`, `underline`, `uniqueID`, which need a
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
  */
@@ -115,15 +106,40 @@ export function serializeInsetRef(node) {
115
106
  return `${bang}[${label}](@${component})${attrStr}`
116
107
  }
117
108
 
109
+ /**
110
+ * Icon library short codes, mirroring content-reader's `ICON_FAMILIES_SHORT`.
111
+ *
112
+ * The two spellings are not interchangeable on the way back in: the reader
113
+ * accepts `family:name` for ANY family but `family-name` only for a short
114
+ * code, deliberately, so a dash form stays unambiguous against an ordinary
115
+ * relative path. Emitting the dash form for a full family name produced
116
+ * `![](lucide-star)`, which reads back as a plain image with
117
+ * `src: "lucide-star"` — the icon destroyed, and stable in that state.
118
+ */
119
+ const ICON_SHORT_CODES = new Set([
120
+ 'lu', 'hi', 'hi2', 'pi', 'tb', 'fi', 'bs', 'md', 'ai',
121
+ 'ri', 'si', 'io5', 'bi', 'vsc', 'wi', 'gi', 'fa', 'fa6',
122
+ ])
123
+
124
+ /**
125
+ * Spell an icon reference so the reader parses it back as an icon.
126
+ * @param {string} library
127
+ * @param {string} name
128
+ * @returns {string}
129
+ */
130
+ function iconRef(library, name) {
131
+ return ICON_SHORT_CODES.has(library) ? `${library}-${name}` : `${library}:${name}`
132
+ }
133
+
118
134
  /**
119
135
  * Serialize an inline image node (icon within a paragraph).
120
136
  */
121
137
  function serializeInlineImage(node) {
122
138
  const { src, alt, caption, role, library, name, ...rest } = node.attrs || {}
123
139
 
124
- // Icon with library+name → compact dash format
140
+ // Icon with library+name → dash for a short code, colon otherwise
125
141
  if (library && name) {
126
- const iconSrc = `${library}-${name}`
142
+ const iconSrc = iconRef(library, name)
127
143
  const extraAttrs = { ...rest }
128
144
  // Remove icon-derived attrs from extra attrs
129
145
  delete extraAttrs.size
@@ -154,9 +170,9 @@ function serializeInlineImage(node) {
154
170
  export function serializeBlockImage(node) {
155
171
  const { src, alt, caption, role, library, name, ...rest } = node.attrs || {}
156
172
 
157
- // Icon with library+name → compact dash format
173
+ // Icon with library+name → dash for a short code, colon otherwise
158
174
  if (library && name) {
159
- const iconSrc = `${library}-${name}`
175
+ const iconSrc = iconRef(library, name)
160
176
  const extraAttrs = { ...rest }
161
177
  const attrStr = serializeAttributes(extraAttrs)
162
178
  const altPart = alt || ''
@@ -196,81 +212,115 @@ export function serializeInlineContent(content) {
196
212
  // No silent drops: flag any mark we can't represent before serializing.
197
213
  reportUnknownMarks(content)
198
214
 
199
- // Pre-process: group nodes by wrapping marks (link, button, span)
200
- const segments = groupByWrappingMarks(content)
201
- return segments.map(seg => serializeSegment(seg)).join('')
215
+ return serializeRuns(content)
202
216
  }
203
217
 
204
218
  /**
205
- * Group consecutive nodes by shared wrapping marks (link, button, span).
206
- * Returns an array of segments, where each segment is either:
207
- * - { type: 'link'|'button'|'span', mark, nodes } — wrapped group
208
- * - { type: 'plain', nodes } — unwrapped nodes
219
+ * Serialize a run of inline nodes, honoring mark nesting.
220
+ *
221
+ * `node.marks` is **ordered, innermost first** — the reader records which mark
222
+ * the author wrapped around which. `**\`x\`**` gives `[code, bold]`;
223
+ * `[**x**](url)` gives `[bold, link]`; `**[x](url)**` gives `[link, bold]`.
224
+ * The semantic parser already reads that order (it renders
225
+ * `<strong><code>x</code></strong>`), and this serializer now does too.
226
+ *
227
+ * The algorithm is one rule applied recursively: take the OUTERMOST mark of
228
+ * the first node, absorb every following node that shares it, strip that one
229
+ * mark from the group, recurse, then wrap the result once.
230
+ *
231
+ * Absorbing the neighbours is what keeps a mark that spans several nodes from
232
+ * being re-emitted around each of them. `**Preview with \`pnpm dev\`.**` is
233
+ * three text nodes — `[bold]`, `[code, bold]`, `[bold]` — and wrapping each
234
+ * separately produced the invalid `**Preview with **\`pnpm dev\`**.**`.
235
+ *
236
+ * @param {Array} nodes - Inline nodes
237
+ * @returns {string} Markdown string
209
238
  */
210
- function groupByWrappingMarks(content) {
211
- const segments = []
239
+ function serializeRuns(nodes) {
240
+ const out = []
212
241
  let i = 0
213
242
 
214
- while (i < content.length) {
215
- const node = content[i]
216
-
217
- // Check for wrapping marks: link, button, span
218
- const linkMark = findMark(node, 'link')
219
- const buttonMark = findMark(node, 'button')
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] })
243
+ while (i < nodes.length) {
244
+ const node = nodes[i]
245
+ const outer = node.type === 'text' ? outermostMark(node) : null
246
+
247
+ if (!outer) {
248
+ out.push(serializePlainNode(node))
241
249
  i++
250
+ continue
242
251
  }
252
+
253
+ // Absorb the consecutive text nodes wrapped by this same mark.
254
+ const group = []
255
+ let j = i
256
+ while (j < nodes.length && nodes[j].type === 'text') {
257
+ const candidate = outermostMark(nodes[j])
258
+ if (!candidate || !marksEqual(candidate, outer)) break
259
+ group.push(withoutOutermostMark(nodes[j]))
260
+ j++
261
+ }
262
+
263
+ out.push(applyMark(serializeRuns(group), outer))
264
+ i = j
243
265
  }
244
266
 
245
- return segments
267
+ return out.join('')
246
268
  }
247
269
 
248
270
  /**
249
- * Serialize a segment (group of nodes with a common wrapping mark, or plain nodes).
271
+ * The mark an author wrapped outermost — the last entry, since marks are
272
+ * ordered innermost first.
273
+ *
274
+ * @param {Object} node
275
+ * @returns {Object|null}
250
276
  */
251
- function serializeSegment(segment) {
252
- if (segment.type === 'plain') {
253
- return segment.nodes.map(n => serializePlainNode(n)).join('')
254
- }
277
+ function outermostMark(node) {
278
+ const marks = node.marks || []
279
+ return marks.length ? marks[marks.length - 1] : null
280
+ }
255
281
 
256
- // Wrapped segment: serialize inner content (with the wrapping mark stripped)
257
- const innerText = segment.nodes.map(node => {
258
- // Strip the wrapping mark from this node's marks for inner serialization
259
- const innerMarks = (node.marks || []).filter(m => !marksEqual(m, segment.mark))
260
- return serializeTextWithMarks(node.text, innerMarks)
261
- }).join('')
282
+ /**
283
+ * The same node with its outermost mark peeled off.
284
+ * @param {Object} node
285
+ * @returns {Object}
286
+ */
287
+ function withoutOutermostMark(node) {
288
+ return { ...node, marks: (node.marks || []).slice(0, -1) }
289
+ }
262
290
 
263
- if (segment.type === 'link') {
264
- return `[${innerText}]${serializeLinkSuffix(segment.mark)}`
265
- }
266
- if (segment.type === 'button') {
267
- return `[${innerText}]${serializeButtonSuffix(segment.mark)}`
268
- }
269
- if (segment.type === 'span') {
270
- return `[${innerText}]${serializeSpanSuffix(segment.mark)}`
291
+ /**
292
+ * Wrap already-serialized inner markdown in one mark.
293
+ *
294
+ * A mark with no markdown form leaves the text alone — the no-silent-drop
295
+ * guard has already reported it.
296
+ *
297
+ * @param {string} inner - Serialized inner content
298
+ * @param {Object} mark
299
+ * @returns {string}
300
+ */
301
+ function applyMark(inner, mark) {
302
+ switch (mark.type) {
303
+ case 'code':
304
+ // Leaving HTML-land: a code span is the one place the reader leaves
305
+ // `&lt;`/`&gt;` escaped (see entities.js).
306
+ return serializeCodeSpan(decodeMarkupEntities(inner))
307
+ case 'bold':
308
+ return `**${inner}**`
309
+ case 'italic':
310
+ return `*${inner}*`
311
+ case 'strike':
312
+ // GFM. The editor's strike mark is author-reachable (input rule, paste
313
+ // rule, Mod-Shift-s) and had no markdown form, so it was dropped.
314
+ return `~~${inner}~~`
315
+ case 'link':
316
+ return `[${inner}]${serializeLinkSuffix(mark)}`
317
+ case 'button':
318
+ return `[${inner}]${serializeButtonSuffix(mark)}`
319
+ case 'span':
320
+ return `[${inner}]${serializeSpanSuffix(mark)}`
321
+ default:
322
+ return inner
271
323
  }
272
-
273
- return innerText
274
324
  }
275
325
 
276
326
  /**
@@ -304,28 +354,40 @@ function serializePlainNode(node) {
304
354
  }
305
355
 
306
356
  /**
307
- * Serialize text with formatting marks (bold, italic, code).
357
+ * Wrap text in code-span backticks, picking a delimiter it can survive.
358
+ *
359
+ * A code span holding backticks needs a longer run to delimit it, and one
360
+ * that begins or ends with a backtick needs a padding space (the parser
361
+ * strips exactly one from each end). A single backtick emitted regardless
362
+ * mangles anything documenting markdown itself — `` ` ```math ` `` came back
363
+ * as ` ````math` `, which is a different, broken span.
364
+ *
365
+ * @param {string} text - Code span content
366
+ * @returns {string} The delimited span
308
367
  */
309
- function serializeTextWithMarks(text, marks) {
310
- if (!marks || marks.length === 0) return text
368
+ function serializeCodeSpan(text) {
369
+ const content = String(text)
370
+ const runs = (content.match(/`+/g) || []).map(run => run.length)
311
371
 
312
- const hasCode = marks.some(m => m.type === 'code')
313
- if (hasCode) {
314
- return `\`${text}\``
315
- }
372
+ // The shortest run length not already present, so the delimiter is
373
+ // unambiguous — CommonMark matches on equal-length runs.
374
+ let width = 1
375
+ while (runs.includes(width)) width++
376
+ const fence = '`'.repeat(width)
316
377
 
317
- const hasBold = marks.some(m => m.type === 'bold')
318
- const hasItalic = marks.some(m => m.type === 'italic')
378
+ // A leading or trailing backtick would fuse with the delimiter.
379
+ const pad = content.startsWith('`') || content.endsWith('`') ? ' ' : ''
319
380
 
320
- if (hasBold && hasItalic) {
321
- return `***${text}***`
322
- }
323
- if (hasBold) {
324
- return `**${text}**`
325
- }
326
- if (hasItalic) {
327
- return `*${text}*`
328
- }
381
+ return `${fence}${pad}${content}${pad}${fence}`
382
+ }
329
383
 
330
- return text
384
+ /**
385
+ * Serialize one text node's own marks, innermost first.
386
+ *
387
+ * Only reached for a lone node — `serializeRuns` handles anything that shares
388
+ * a mark with its neighbours.
389
+ */
390
+ function serializeTextWithMarks(text, marks) {
391
+ if (!marks || marks.length === 0) return text
392
+ return marks.reduce((inner, mark) => applyMark(inner, mark), text)
331
393
  }