@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
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
87
|
-
|
|
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 {
|
|
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,
|
|
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, `${
|
|
122
|
-
.join(
|
|
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 {
|
|
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,
|
|
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, `${
|
|
137
|
-
.join(
|
|
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 {
|
|
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,
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
parts.push(
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
parts.push(serializeOrderedList(child,
|
|
162
|
-
|
|
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
|
-
|
|
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.
|
|
343
|
+
const headerCells = cellsOf(headerRow).map(cell => serializeTableCell(cell))
|
|
194
344
|
|
|
195
345
|
// Build alignment row from header cell attrs
|
|
196
|
-
const alignments = headerRow.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 '
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
+
* `<` 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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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,
|
package/tests/attributes.test.js
CHANGED
|
@@ -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
|
|
31
|
-
expect(serializeAttributes({
|
|
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
|
|
35
|
-
expect(serializeAttributes({
|
|
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
|
-
|
|
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
|
-
|
|
98
|
+
'.info': true,
|
|
99
99
|
'data-tooltip': 'More info',
|
|
100
100
|
})
|
|
101
101
|
expect(result).toBe('{.info data-tooltip="More info"}')
|