@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
package/src/nodes.js CHANGED
@@ -4,7 +4,9 @@
4
4
  * Each function takes a ProseMirror node and returns a markdown string.
5
5
  */
6
6
 
7
+ import yaml from 'js-yaml'
7
8
  import { serializeInlineContent, serializeBlockImage, serializeInsetRef } from './marks.js'
9
+ import { serializeAttributes } from './attributes.js'
8
10
 
9
11
  // inset_ref serialization is shared with the inline path; it lives in
10
12
  // marks.js (alongside the other inline-attribute serializers) and is
@@ -49,7 +51,25 @@ export function serializeCodeBlock(node) {
49
51
  const lang = node.attrs?.language || ''
50
52
  const tag = node.attrs?.tag ? `:${node.attrs.tag}` : ''
51
53
  const text = node.content?.[0]?.text || ''
52
- return `\`\`\`${lang}${tag}\n${text}\n\`\`\``
54
+ const fence = '`'.repeat(fenceWidthFor(text))
55
+ return `${fence}${lang}${tag}\n${text}\n${fence}`
56
+ }
57
+
58
+ /**
59
+ * How many backticks this content needs as a fence.
60
+ *
61
+ * A fence must be longer than the longest backtick run inside it, or the
62
+ * block ends early — which is why documentation that shows fenced markdown
63
+ * is itself fenced in four backticks. Emitting three unconditionally
64
+ * truncated those blocks and spilled their remainder into the page as prose.
65
+ *
66
+ * @param {string} text - The code block's content
67
+ * @returns {number} Fence length, never fewer than three
68
+ */
69
+ function fenceWidthFor(text) {
70
+ const runs = String(text).match(/`+/g) || []
71
+ const longest = runs.reduce((max, run) => Math.max(max, run.length), 0)
72
+ return Math.max(3, longest + 1)
53
73
  }
54
74
 
55
75
  /**
@@ -76,15 +96,70 @@ export function serializeMathDisplay(node) {
76
96
  : '$$' + latex + '$$'
77
97
  }
78
98
 
99
+ /**
100
+ * Serialize a container node back to its fence.
101
+ *
102
+ * The inverse of content-reader's `@Component{params}` fence. The info string
103
+ * is the inset's own token, so a container is byte-for-byte the block form of
104
+ * an inset — same concept, two levels.
105
+ *
106
+ * The fence widens past any backtick run in the body, which is what lets a
107
+ * container hold a code sample: `fenceWidthFor` is the same computation the
108
+ * plain code-block path uses.
109
+ *
110
+ * @param {Object} node - inset_block node with attrs.component + params
111
+ * @returns {string} Fenced container
112
+ */
113
+ export function serializeInsetBlock(node) {
114
+ const { component, ...params } = node.attrs || {}
115
+ if (!component) return ''
116
+
117
+ const { serializeNode } = await_serializer()
118
+ const body = (node.content || [])
119
+ .map(child => serializeNode(child))
120
+ .filter(Boolean)
121
+ .join('\n\n')
122
+
123
+ const fence = '`'.repeat(fenceWidthFor(body))
124
+ const attrStr = serializeAttributes(params)
125
+
126
+ return `${fence}@${component}${attrStr}\n${body}\n${fence}`
127
+ }
128
+
79
129
  /**
80
130
  * Serialize a data block node.
81
- * @param {Object} node - Data block node with attrs.tag and attrs.data
131
+ *
132
+ * Emits the serialization the author wrote, which `attrs.language` records.
133
+ * This used to hardcode JSON, so a ```yaml:nav block came back as ```json:nav
134
+ * — same data, rewritten source, on every editor sync.
135
+ *
136
+ * **YAML is the fallback when `language` is absent**, not JSON. A node without
137
+ * it predates the reader recording it, and YAML is both the form the docs
138
+ * teach and the friendlier one to leave in someone's file; JSON is still
139
+ * emitted whenever it is what the author actually chose.
140
+ *
141
+ * @param {Object} node - Data block node with attrs.tag, attrs.data, attrs.language
82
142
  * @returns {string} Tagged fenced code block with serialized data
83
143
  */
84
144
  export function serializeDataBlock(node) {
85
- const { tag, data } = node.attrs || {}
86
- const serialized = JSON.stringify(data, null, 2)
87
- return `\`\`\`json:${tag}\n${serialized}\n\`\`\``
145
+ const { tag, data, language } = node.attrs || {}
146
+
147
+ if ((language || '').toLowerCase() === 'json') {
148
+ return `\`\`\`json:${tag}\n${JSON.stringify(data, null, 2)}\n\`\`\``
149
+ }
150
+
151
+ // `yaml` and `yml` are both valid fences; echo back whichever was written.
152
+ const fence = language || 'yaml'
153
+ const serialized = yaml
154
+ .dump(data, {
155
+ lineWidth: -1, // Don't wrap long lines
156
+ quotingType: "'", // Use single quotes when quoting is needed
157
+ forceQuotes: false, // Only quote when necessary
158
+ noRefs: true, // Don't use YAML references
159
+ })
160
+ .trimEnd()
161
+
162
+ return `\`\`\`${fence}:${tag}\n${serialized}\n\`\`\``
88
163
  }
89
164
 
90
165
  /**
@@ -110,63 +185,134 @@ export function serializeBlockquote(node) {
110
185
 
111
186
  /**
112
187
  * Serialize a bullet list node.
188
+ *
189
+ * `pad` is the literal indentation this list sits at — the parent item's
190
+ * CONTENT COLUMN, not a level count. See serializeListItem for why.
191
+ *
113
192
  * @param {Object} node - Bullet list node with content (listItem nodes)
114
- * @param {number} [indent=0] - Indentation level
193
+ * @param {string} [pad=''] - Indentation prefix for every marker in this list
115
194
  * @returns {string} Markdown bullet list
116
195
  */
117
- export function serializeBulletList(node, indent = 0) {
196
+ export function serializeBulletList(node, pad = '') {
118
197
  if (!node.content) return ''
119
- const prefix = ' '.repeat(indent)
120
198
  return node.content
121
- .map(item => serializeListItem(item, `${prefix}- `, indent))
122
- .join('\n')
199
+ .map(item => serializeListItem(item, `${pad}- `, isLoose(node)))
200
+ .join(itemSeparator(node))
123
201
  }
124
202
 
125
203
  /**
126
204
  * Serialize an ordered list node.
205
+ *
206
+ * `pad` is the literal indentation this list sits at — see serializeBulletList.
207
+ *
127
208
  * @param {Object} node - Ordered list node with attrs.start and content
128
- * @param {number} [indent=0] - Indentation level
209
+ * @param {string} [pad=''] - Indentation prefix for every marker in this list
129
210
  * @returns {string} Markdown ordered list
130
211
  */
131
- export function serializeOrderedList(node, indent = 0) {
212
+ export function serializeOrderedList(node, pad = '') {
132
213
  if (!node.content) return ''
133
214
  const start = node.attrs?.start || 1
134
- const prefix = ' '.repeat(indent)
135
215
  return node.content
136
- .map((item, i) => serializeListItem(item, `${prefix}${start + i}. `, indent))
137
- .join('\n')
216
+ .map((item, i) => serializeListItem(item, `${pad}${start + i}. `, isLoose(node)))
217
+ .join(itemSeparator(node))
218
+ }
219
+
220
+ /**
221
+ * What goes between two list items.
222
+ *
223
+ * A loose list — one the author spaced out with blank lines — keeps that
224
+ * spacing. `attrs.loose` is recorded by content-reader; a list without it is
225
+ * tight, which is both the common case and the safe default for documents
226
+ * written before the flag existed.
227
+ *
228
+ * @param {Object} node - A bulletList or orderedList node
229
+ * @returns {string}
230
+ */
231
+ function itemSeparator(node) {
232
+ return isLoose(node) ? '\n\n' : '\n'
233
+ }
234
+
235
+ /**
236
+ * Did the author space this list out?
237
+ * @param {Object} node - A bulletList or orderedList node
238
+ * @returns {boolean}
239
+ */
240
+ function isLoose(node) {
241
+ return Boolean(node.attrs?.loose)
138
242
  }
139
243
 
140
244
  /**
141
245
  * Serialize a list item.
246
+ *
247
+ * A nested list is indented to this item's CONTENT COLUMN — `bullet.length` —
248
+ * not by a fixed two spaces per level. The marker's width varies (`- ` is 2,
249
+ * `9. ` is 3, `10. ` is 4), and a child indented less than the parent's
250
+ * content column is not a child at all: markdown reads it as ending the
251
+ * outer list. That is a source-corrupting bug, not a cosmetic one — a
252
+ * three-item ordered list with a nested bullet list came back as three
253
+ * separate top-level nodes.
254
+ *
255
+ * `pad` already computes that column for every other block in the item; nested
256
+ * lists now use the same value instead of their own count.
257
+ *
142
258
  * @param {Object} node - List item node with content
143
259
  * @param {string} bullet - The bullet prefix (e.g., "- " or "1. ")
144
- * @param {number} indent - Current indentation level
260
+ * @param {boolean} [loose=false] - Whether the parent list is loose
145
261
  * @returns {string} Markdown list item
146
262
  */
147
- function serializeListItem(node, bullet, indent) {
263
+ function serializeListItem(node, bullet, loose = false) {
148
264
  if (!node.content) return bullet
149
265
 
150
266
  const parts = []
267
+ const pad = ' '.repeat(bullet.length)
151
268
 
152
269
  for (let i = 0; i < node.content.length; i++) {
153
270
  const child = node.content[i]
154
271
 
155
- if (i === 0 && child.type === 'paragraph') {
156
- // First child paragraph is the item text
157
- parts.push(bullet + serializeInlineContent(child.content))
158
- } else if (child.type === 'bulletList') {
159
- parts.push(serializeBulletList(child, indent + 1))
160
- } else if (child.type === 'orderedList') {
161
- parts.push(serializeOrderedList(child, indent + 1))
162
- } else if (child.type === 'paragraph') {
163
- // Additional paragraphs in the same list item
164
- const pad = ' '.repeat(bullet.length)
165
- parts.push(pad + serializeInlineContent(child.content))
272
+ // Nested lists carry their own indentation.
273
+ if (child.type === 'bulletList') {
274
+ parts.push(serializeBulletList(child, pad))
275
+ continue
276
+ }
277
+ if (child.type === 'orderedList') {
278
+ parts.push(serializeOrderedList(child, pad))
279
+ continue
166
280
  }
281
+
282
+ // Anything else is an ordinary block. A paragraph is inline content; a
283
+ // code block, blockquote or table goes through the node serializer, the
284
+ // same way a blockquote's children do. Every one of those used to fall
285
+ // off the end of this loop and be dropped without a trace.
286
+ const text =
287
+ child.type === 'paragraph'
288
+ ? serializeInlineContent(child.content)
289
+ : await_serializer().serializeNode(child) || ''
290
+
291
+ parts.push(indentBlock(text, i === 0 ? bullet : pad, pad))
167
292
  }
168
293
 
169
- return parts.join('\n')
294
+ // In a loose list the item's own blocks are blank-line separated too — the
295
+ // spacing is a property of the whole list, not just the gaps between items.
296
+ return parts.join(loose ? '\n\n' : '\n')
297
+ }
298
+
299
+ /**
300
+ * Place a block's lines inside a list item.
301
+ *
302
+ * The first line carries the bullet (or aligns under it, for a block that is
303
+ * not the item's opening one); every later line aligns under the bullet so it
304
+ * stays part of the item instead of ending the list.
305
+ *
306
+ * @param {string} text - Serialized block, possibly multi-line
307
+ * @param {string} firstPrefix - Prefix for the first line
308
+ * @param {string} pad - Prefix for continuation lines
309
+ * @returns {string}
310
+ */
311
+ function indentBlock(text, firstPrefix, pad) {
312
+ const [first = '', ...rest] = String(text).split('\n')
313
+ const head = firstPrefix + first
314
+ if (rest.length === 0) return head
315
+ return [head, ...rest.map(line => (line ? pad + line : ''))].join('\n')
170
316
  }
171
317
 
172
318
  /**
@@ -187,13 +333,17 @@ export function serializeTable(node) {
187
333
 
188
334
  const rows = node.content
189
335
  const headerRow = rows[0]
336
+ // A row with no cells is legal ProseMirror — `tableRow` is
337
+ // `(tableCell | tableHeader)*`, a star — and PM's toJSON omits `content`
338
+ // entirely for an empty node. Reading it unguarded threw.
339
+ const cellsOf = row => row?.content ?? []
190
340
  const bodyRows = rows.slice(1)
191
341
 
192
342
  // Serialize header cells
193
- const headerCells = headerRow.content.map(cell => serializeTableCell(cell))
343
+ const headerCells = cellsOf(headerRow).map(cell => serializeTableCell(cell))
194
344
 
195
345
  // Build alignment row from header cell attrs
196
- const alignments = headerRow.content.map(cell => {
346
+ const alignments = cellsOf(headerRow).map(cell => {
197
347
  const align = cell.attrs?.align
198
348
  if (align === 'left') return ':---'
199
349
  if (align === 'center') return ':---:'
@@ -203,7 +353,7 @@ export function serializeTable(node) {
203
353
 
204
354
  // Serialize body rows
205
355
  const bodyLines = bodyRows.map(row =>
206
- '| ' + row.content.map(cell => serializeTableCell(cell)).join(' | ') + ' |'
356
+ '| ' + cellsOf(row).map(cell => serializeTableCell(cell)).join(' | ') + ' |'
207
357
  )
208
358
 
209
359
  const headerLine = '| ' + headerCells.join(' | ') + ' |'
@@ -220,7 +370,7 @@ export function serializeTable(node) {
220
370
  function serializeTableCell(cell) {
221
371
  if (!cell.content) return ''
222
372
  // Table cells contain paragraphs; serialize their inline content
223
- return cell.content
373
+ const text = cell.content
224
374
  .map(child => {
225
375
  if (child.type === 'paragraph') {
226
376
  return serializeInlineContent(child.content)
@@ -228,6 +378,27 @@ function serializeTableCell(cell) {
228
378
  return ''
229
379
  })
230
380
  .join(' ')
381
+
382
+ return escapeTableCell(text)
383
+ }
384
+
385
+ /**
386
+ * Escape the one character a table cell cannot contain literally.
387
+ *
388
+ * A pipe inside a cell ends it, so markdown spells a literal one `\|` and the
389
+ * parser hands back the unescaped character. Writing that back out unescaped
390
+ * splits the cell and shifts every column after it — the table silently gains
391
+ * a column and stops lining up with its header.
392
+ *
393
+ * Only the pipe needs this: the cell's other content has already been through
394
+ * the inline serializers, and a row is a single line by construction.
395
+ *
396
+ * @param {string} text - Serialized inline content of one cell
397
+ * @returns {string}
398
+ */
399
+ function escapeTableCell(text) {
400
+ // Leave an already-escaped pipe alone rather than doubling its backslash.
401
+ return text.replace(/\\?\|/g, '\\|')
231
402
  }
232
403
 
233
404
  // Lazy reference to serializeNode to handle circular dependency with blockquote
package/src/normalize.js CHANGED
@@ -155,9 +155,20 @@ function mapVideo(node) {
155
155
  */
156
156
  function mapUniwebIcon(node) {
157
157
  const attrs = node.attrs || {}
158
- if (!attrs.library || !attrs.name) return null
159
158
 
160
- const out = { role: 'icon', library: attrs.library, name: attrs.name }
159
+ // The insert path stores a single colon-joined `family:id` name (built from
160
+ // the icon CDN URL) and leaves `library` unset — so requiring both dropped
161
+ // every icon the editor actually produces. The separate-attr form is kept
162
+ // for content that already carries it.
163
+ let { library, name } = attrs
164
+ if (!library && typeof name === 'string' && name.includes(':')) {
165
+ const [family, ...rest] = name.split(':')
166
+ library = family
167
+ name = rest.join(':')
168
+ }
169
+ if (!library || !name) return null
170
+
171
+ const out = { role: 'icon', library, name }
161
172
  if (attrs.size) out.size = attrs.size
162
173
  if (attrs.color) out.color = attrs.color
163
174
  return { type: 'image', attrs: out }
@@ -179,17 +190,36 @@ function mapEditorNode(node) {
179
190
  case 'Video':
180
191
  return mapVideo(node)
181
192
  case 'UniwebIcon':
193
+ case 'Icon':
182
194
  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
195
+ case 'WarningBlock':
196
+ case 'details': {
197
+ const attrs = EDITOR_CONTAINERS[node.type](node)
198
+ return {
199
+ type: 'inset_block',
200
+ attrs,
201
+ content: node.type === 'details' ? detailsBody(node) : asBlocks(node.content),
202
+ }
203
+ }
204
+ case 'emoji':
205
+ // The node carries the character alongside a name; the character is the
206
+ // whole markdown form. Guarded rather than assumed to be populated.
207
+ return node.attrs?.emoji ? { type: 'text', text: node.attrs.emoji } : null
208
+ // A standalone "\n" text node is NOT mapped here, deliberately.
209
+ //
210
+ // It used to be: content-reader once spelled a hard break that way, and
211
+ // this healed old documents onto the node form. The migration is done —
212
+ // content-reader has emitted a `hardBreak` node for both markdown
213
+ // spellings for some time, and no persisted document carries the legacy
214
+ // form (confirmed 2026-07-26).
215
+ //
216
+ // Keeping the rule past that point made it wrong. content-reader still
217
+ // produces a lone "\n" node, but for a SOFT break — a newline lands in
218
+ // its own node whenever the spans on both sides are marked, as in
219
+ // "`--heading` → `text-heading`" followed by another such line. Healing
220
+ // that turned an ordinary wrapped line into a visible break: the sync
221
+ // wrote a trailing "\", and the next read made it a real <br>, so the
222
+ // page reflowed one round trip after the edit.
193
223
  default:
194
224
  return null
195
225
  }
@@ -201,10 +231,125 @@ function mapEditorNode(node) {
201
231
  * its children. Nodes without a mapping pass through unchanged so any
202
232
  * editor-dialect descendants are still caught.
203
233
  */
234
+ /**
235
+ * The editor's named inline styles, and the framework spelling of each.
236
+ *
237
+ * These are theme tokens, not colours. The editor's picker offers exactly this
238
+ * closed set — the attribute is *named* `color` but holds a token, which its
239
+ * extension renders as `var(--<token>)`. So they map onto the `span` mark the
240
+ * framework already round-trips as `[text]{token}`, and nothing is lost.
241
+ *
242
+ * A value outside this set (a hex from a paste, until the editor gates its
243
+ * `parseHTML`) is deliberately NOT mapped: it falls through to the
244
+ * no-silent-drop guard rather than writing a raw colour into markdown, where
245
+ * it would contradict the theming model and not survive a change of brand.
246
+ */
247
+ const NAMED_INLINE_STYLES = new Set(['accent', 'highlight', 'callout', 'muted'])
248
+
249
+ /**
250
+ * Editor container nodes → the framework's `inset_block`, as an explicit table.
251
+ *
252
+ * Deliberately not derived from the node name. The editor's node is
253
+ * `WarningBlock` while the component is `Alert`, and its `type` defaults to
254
+ * `info` rather than `warning` — a name-derived mapping drops exactly that row,
255
+ * which is how `mapUniwebIcon` came to lose every icon the editor produces. A
256
+ * missing entry here is a visible gap rather than a silent assumption.
257
+ */
258
+ const EDITOR_CONTAINERS = {
259
+ WarningBlock: node => ({ component: 'Alert', type: node.attrs?.type || 'info' }),
260
+ details: () => ({ component: 'Details' }),
261
+ }
262
+
263
+ /** Inline node types, for deciding whether a container's body needs wrapping. */
264
+ const INLINE_TYPES = new Set([
265
+ 'text',
266
+ 'hardBreak',
267
+ 'image',
268
+ 'math_inline',
269
+ 'inset_ref',
270
+ 'emoji',
271
+ 'UniwebIcon',
272
+ ])
273
+
274
+ /**
275
+ * A container body as block content.
276
+ *
277
+ * `WarningBlock` holds `text*` today and `block+` after the editor's widening,
278
+ * so both shapes arrive here. Inline content is wrapped in one paragraph;
279
+ * block content passes through.
280
+ *
281
+ * @param {Array} content
282
+ * @returns {Array}
283
+ */
284
+ function asBlocks(content) {
285
+ if (!content?.length) return []
286
+ return content.every(node => INLINE_TYPES.has(node.type))
287
+ ? [{ type: 'paragraph', content }]
288
+ : content
289
+ }
290
+
291
+ /**
292
+ * Flatten the editor's `details` › `detailsSummary` + `detailsContent` into the
293
+ * container's body, summary first.
294
+ *
295
+ * The summary leads rather than riding on an attribute: `detailsSummary` can
296
+ * carry marks, and `summary="…"` would flatten emphasis and links.
297
+ *
298
+ * @param {Object} node
299
+ * @returns {Array}
300
+ */
301
+ function detailsBody(node) {
302
+ const body = []
303
+ for (const child of node.content || []) {
304
+ if (child.type === 'detailsSummary') body.push(...asBlocks(child.content))
305
+ else if (child.type === 'detailsContent') body.push(...(child.content || []))
306
+ else body.push(child)
307
+ }
308
+ return body
309
+ }
310
+
311
+ /** A mark that carries nothing and should be removed rather than reported. */
312
+ const DROP_MARK = Symbol('drop-mark')
313
+
314
+ /**
315
+ * Map an editor-dialect mark to the framework dialect.
316
+ *
317
+ * @param {Object} mark
318
+ * @returns {Object|Symbol|null} a framework mark, `DROP_MARK` when the mark
319
+ * carries no styling at all, or null when there is no mapping (the original
320
+ * is kept and reported by the no-silent-drop guard)
321
+ */
322
+ function mapEditorMark(mark) {
323
+ if (mark?.type === 'highlight') {
324
+ // Effectively boolean in the editor: its extension hardcodes the
325
+ // background and ignores the attribute.
326
+ return { type: 'span', attrs: { highlight: true } }
327
+ }
328
+
329
+ if (mark?.type === 'textStyle') {
330
+ const token = mark.attrs?.color
331
+ // The picker's "Normal" option. Nothing is being dropped, so reporting it
332
+ // would be noise in a guard whose value depends on staying quiet.
333
+ if (!token) return DROP_MARK
334
+ if (!NAMED_INLINE_STYLES.has(token)) return null // a colour, not a token
335
+ return { type: 'span', attrs: { [token]: true } }
336
+ }
337
+
338
+ return null
339
+ }
340
+
204
341
  function normalizeNode(node) {
205
342
  if (!node || typeof node !== 'object') return node
206
343
 
207
- const result = mapEditorNode(node) || node
344
+ let result = mapEditorNode(node) || node
345
+
346
+ if (Array.isArray(result.marks) && result.marks.length) {
347
+ // An unmapped mark is kept, so the no-silent-drop guard still reports it.
348
+ result = {
349
+ ...result,
350
+ marks: result.marks.map(m => mapEditorMark(m) || m).filter(m => m !== DROP_MARK),
351
+ }
352
+ }
208
353
 
209
354
  if (Array.isArray(result.content)) {
210
355
  return { ...result, content: result.content.map(normalizeNode) }
package/src/plain-text.js CHANGED
@@ -4,8 +4,15 @@
4
4
  * Strips all markdown formatting, returning just the text content
5
5
  * with newlines for structure. Useful for word counting, search
6
6
  * indexing, and generating summaries.
7
+ *
8
+ * "Plain" is meant literally: PM text nodes carry HTML (see `entities.js`),
9
+ * so every text run is stripped of raw inline tags and its entities resolved
10
+ * to the characters a reader would see. Without that, a summary can ship
11
+ * `&lt;` or a stray `<span>` into whatever consumes it.
7
12
  */
8
13
 
14
+ import { toPlainCharacters } from './entities.js'
15
+
9
16
  /**
10
17
  * Extract plain text from a ProseMirror document.
11
18
  *
@@ -21,6 +28,64 @@ export function docToPlainText(doc) {
21
28
  .join('\n\n')
22
29
  }
23
30
 
31
+ /**
32
+ * Summarize a ProseMirror document in one sentence-ish line.
33
+ *
34
+ * Reads the document's **first paragraph**, not the whole document: pages
35
+ * routinely open with an `# H1`, and `docToPlainText` would hand back that
36
+ * title as the summary. A page with no paragraph at the top level returns
37
+ * `''` — callers are expected to have an authored fallback.
38
+ *
39
+ * The result is plain characters (tags stripped, entities resolved), so
40
+ * truncating on a sentence boundary lands on a real sentence boundary
41
+ * rather than somewhere inside a tag or an entity.
42
+ *
43
+ * @param {Object} doc - ProseMirror document
44
+ * @param {Object} [options]
45
+ * @param {number} [options.maxChars=200] - Soft upper bound on the result
46
+ * @returns {string} A single-line summary, or '' when there is nothing to say
47
+ */
48
+ export function docSummaryText(doc, { maxChars = 200 } = {}) {
49
+ if (!doc?.content) return ''
50
+
51
+ let text = ''
52
+ for (const node of doc.content) {
53
+ if (node.type !== 'paragraph') continue
54
+ text = inlineToPlainText(node.content).trim()
55
+ if (text) break
56
+ }
57
+ if (!text) return ''
58
+
59
+ // Collapse internal whitespace — a summary is one line.
60
+ text = text.replace(/\s+/g, ' ')
61
+
62
+ return truncateAtSentence(text, maxChars)
63
+ }
64
+
65
+ /**
66
+ * Trim to `maxChars`, preferring a sentence boundary, then a word boundary.
67
+ *
68
+ * @param {string} text - Already-collapsed plain text
69
+ * @param {number} maxChars
70
+ * @returns {string}
71
+ */
72
+ function truncateAtSentence(text, maxChars) {
73
+ if (text.length <= maxChars) return text
74
+
75
+ const window = text.slice(0, maxChars)
76
+
77
+ // A sentence end is terminal punctuation followed by whitespace — so
78
+ // "5 minutes." ends a sentence but the dot in "site.yml" does not. Take the
79
+ // last one in the window, to keep as much of the paragraph as fits.
80
+ const ends = [...window.matchAll(/[.!?](?=\s|$)/g)]
81
+ const lastEnd = ends.length ? ends[ends.length - 1].index : -1
82
+ if (lastEnd > 0) return window.slice(0, lastEnd + 1)
83
+
84
+ const lastSpace = window.lastIndexOf(' ')
85
+ const cut = lastSpace > 0 ? window.slice(0, lastSpace) : window
86
+ return `${cut.replace(/[,;:—–-]$/, '').trimEnd()}…`
87
+ }
88
+
24
89
  /**
25
90
  * Extract plain text from a single ProseMirror node.
26
91
  * @param {Object} node
@@ -67,10 +132,12 @@ function nodeToPlainText(node) {
67
132
  */
68
133
  function inlineToPlainText(content) {
69
134
  if (!content) return ''
70
- return content
71
- .filter(node => node.type === 'text')
72
- .map(node => node.text || '')
73
- .join('')
135
+ return toPlainCharacters(
136
+ content
137
+ .filter(node => node.type === 'text')
138
+ .map(node => node.text || '')
139
+ .join('')
140
+ )
74
141
  }
75
142
 
76
143
  /**
package/src/serializer.js CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  serializeParagraph,
10
10
  serializeImage,
11
11
  serializeInsetRef,
12
+ serializeInsetBlock,
12
13
  serializeCodeBlock,
13
14
  serializeDataBlock,
14
15
  serializeBlockquote,
@@ -27,6 +28,7 @@ const NODE_SERIALIZERS = {
27
28
  paragraph: serializeParagraph,
28
29
  image: serializeImage,
29
30
  inset_ref: serializeInsetRef,
31
+ inset_block: serializeInsetBlock,
30
32
  codeBlock: serializeCodeBlock,
31
33
  dataBlock: serializeDataBlock,
32
34
  blockquote: serializeBlockquote,
@@ -27,12 +27,12 @@ describe('Attribute Serialization', () => {
27
27
  expect(serializeAttributes({ autoplay: true, muted: true })).toBe('{autoplay muted}')
28
28
  })
29
29
 
30
- test('serializes class as dot notation', () => {
31
- expect(serializeAttributes({ class: 'featured' })).toBe('{.featured}')
30
+ test('serializes a dotted NAME through the plain boolean branch', () => {
31
+ expect(serializeAttributes({ '.featured': true })).toBe('{.featured}')
32
32
  })
33
33
 
34
- test('serializes multiple classes', () => {
35
- expect(serializeAttributes({ class: 'featured rounded shadow' })).toBe('{.featured .rounded .shadow}')
34
+ test('serializes several dotted names', () => {
35
+ expect(serializeAttributes({ '.featured': true, '.rounded': true, '.shadow': true })).toBe('{.featured .rounded .shadow}')
36
36
  })
37
37
 
38
38
  test('serializes id as hash notation', () => {
@@ -41,7 +41,7 @@ describe('Attribute Serialization', () => {
41
41
 
42
42
  test('serializes mixed attributes', () => {
43
43
  const result = serializeAttributes({
44
- class: 'featured',
44
+ '.featured': true,
45
45
  id: 'main-logo',
46
46
  width: 800,
47
47
  loading: 'lazy',
@@ -95,7 +95,7 @@ describe('Attribute Serialization', () => {
95
95
 
96
96
  test('serializes data attributes', () => {
97
97
  const result = serializeAttributes({
98
- class: 'info',
98
+ '.info': true,
99
99
  'data-tooltip': 'More info',
100
100
  })
101
101
  expect(result).toBe('{.info data-tooltip="More info"}')