@uniweb/content-writer 0.2.6 → 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 +4 -3
- package/src/entities.js +107 -0
- package/src/index.js +2 -1
- package/src/marks.js +161 -93
- package/src/nodes.js +148 -24
- package/src/normalize.js +87 -3
- package/src/plain-text.js +71 -4
- package/tests/entities.test.js +175 -0
- package/tests/fixtures/roundtrip/canonical-math.md +3 -0
- package/tests/fixtures/roundtrip/code.md +11 -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/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 +6 -1
- package/tests/normalize.test.js +128 -2
- package/tests/roundtrip-source.test.js +244 -0
- package/tests/roundtrip.test.js +31 -0
- package/tests/serializer.test.js +22 -12
package/src/nodes.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
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'
|
|
8
9
|
|
|
9
10
|
// inset_ref serialization is shared with the inline path; it lives in
|
|
@@ -49,7 +50,25 @@ export function serializeCodeBlock(node) {
|
|
|
49
50
|
const lang = node.attrs?.language || ''
|
|
50
51
|
const tag = node.attrs?.tag ? `:${node.attrs.tag}` : ''
|
|
51
52
|
const text = node.content?.[0]?.text || ''
|
|
52
|
-
|
|
53
|
+
const fence = '`'.repeat(fenceWidthFor(text))
|
|
54
|
+
return `${fence}${lang}${tag}\n${text}\n${fence}`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* How many backticks this content needs as a fence.
|
|
59
|
+
*
|
|
60
|
+
* A fence must be longer than the longest backtick run inside it, or the
|
|
61
|
+
* block ends early — which is why documentation that shows fenced markdown
|
|
62
|
+
* is itself fenced in four backticks. Emitting three unconditionally
|
|
63
|
+
* truncated those blocks and spilled their remainder into the page as prose.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} text - The code block's content
|
|
66
|
+
* @returns {number} Fence length, never fewer than three
|
|
67
|
+
*/
|
|
68
|
+
function fenceWidthFor(text) {
|
|
69
|
+
const runs = String(text).match(/`+/g) || []
|
|
70
|
+
const longest = runs.reduce((max, run) => Math.max(max, run.length), 0)
|
|
71
|
+
return Math.max(3, longest + 1)
|
|
53
72
|
}
|
|
54
73
|
|
|
55
74
|
/**
|
|
@@ -78,13 +97,38 @@ export function serializeMathDisplay(node) {
|
|
|
78
97
|
|
|
79
98
|
/**
|
|
80
99
|
* Serialize a data block node.
|
|
81
|
-
*
|
|
100
|
+
*
|
|
101
|
+
* Emits the serialization the author wrote, which `attrs.language` records.
|
|
102
|
+
* This used to hardcode JSON, so a ```yaml:nav block came back as ```json:nav
|
|
103
|
+
* — same data, rewritten source, on every editor sync.
|
|
104
|
+
*
|
|
105
|
+
* **YAML is the fallback when `language` is absent**, not JSON. A node without
|
|
106
|
+
* it predates the reader recording it, and YAML is both the form the docs
|
|
107
|
+
* teach and the friendlier one to leave in someone's file; JSON is still
|
|
108
|
+
* emitted whenever it is what the author actually chose.
|
|
109
|
+
*
|
|
110
|
+
* @param {Object} node - Data block node with attrs.tag, attrs.data, attrs.language
|
|
82
111
|
* @returns {string} Tagged fenced code block with serialized data
|
|
83
112
|
*/
|
|
84
113
|
export function serializeDataBlock(node) {
|
|
85
|
-
const { tag, data } = node.attrs || {}
|
|
86
|
-
|
|
87
|
-
|
|
114
|
+
const { tag, data, language } = node.attrs || {}
|
|
115
|
+
|
|
116
|
+
if ((language || '').toLowerCase() === 'json') {
|
|
117
|
+
return `\`\`\`json:${tag}\n${JSON.stringify(data, null, 2)}\n\`\`\``
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// `yaml` and `yml` are both valid fences; echo back whichever was written.
|
|
121
|
+
const fence = language || 'yaml'
|
|
122
|
+
const serialized = yaml
|
|
123
|
+
.dump(data, {
|
|
124
|
+
lineWidth: -1, // Don't wrap long lines
|
|
125
|
+
quotingType: "'", // Use single quotes when quoting is needed
|
|
126
|
+
forceQuotes: false, // Only quote when necessary
|
|
127
|
+
noRefs: true, // Don't use YAML references
|
|
128
|
+
})
|
|
129
|
+
.trimEnd()
|
|
130
|
+
|
|
131
|
+
return `\`\`\`${fence}:${tag}\n${serialized}\n\`\`\``
|
|
88
132
|
}
|
|
89
133
|
|
|
90
134
|
/**
|
|
@@ -118,8 +162,8 @@ export function serializeBulletList(node, indent = 0) {
|
|
|
118
162
|
if (!node.content) return ''
|
|
119
163
|
const prefix = ' '.repeat(indent)
|
|
120
164
|
return node.content
|
|
121
|
-
.map(item => serializeListItem(item, `${prefix}- `, indent))
|
|
122
|
-
.join(
|
|
165
|
+
.map(item => serializeListItem(item, `${prefix}- `, indent, isLoose(node)))
|
|
166
|
+
.join(itemSeparator(node))
|
|
123
167
|
}
|
|
124
168
|
|
|
125
169
|
/**
|
|
@@ -133,8 +177,32 @@ export function serializeOrderedList(node, indent = 0) {
|
|
|
133
177
|
const start = node.attrs?.start || 1
|
|
134
178
|
const prefix = ' '.repeat(indent)
|
|
135
179
|
return node.content
|
|
136
|
-
.map((item, i) => serializeListItem(item, `${prefix}${start + i}. `, indent))
|
|
137
|
-
.join(
|
|
180
|
+
.map((item, i) => serializeListItem(item, `${prefix}${start + i}. `, indent, isLoose(node)))
|
|
181
|
+
.join(itemSeparator(node))
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* What goes between two list items.
|
|
186
|
+
*
|
|
187
|
+
* A loose list — one the author spaced out with blank lines — keeps that
|
|
188
|
+
* spacing. `attrs.loose` is recorded by content-reader; a list without it is
|
|
189
|
+
* tight, which is both the common case and the safe default for documents
|
|
190
|
+
* written before the flag existed.
|
|
191
|
+
*
|
|
192
|
+
* @param {Object} node - A bulletList or orderedList node
|
|
193
|
+
* @returns {string}
|
|
194
|
+
*/
|
|
195
|
+
function itemSeparator(node) {
|
|
196
|
+
return isLoose(node) ? '\n\n' : '\n'
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Did the author space this list out?
|
|
201
|
+
* @param {Object} node - A bulletList or orderedList node
|
|
202
|
+
* @returns {boolean}
|
|
203
|
+
*/
|
|
204
|
+
function isLoose(node) {
|
|
205
|
+
return Boolean(node.attrs?.loose)
|
|
138
206
|
}
|
|
139
207
|
|
|
140
208
|
/**
|
|
@@ -142,31 +210,62 @@ export function serializeOrderedList(node, indent = 0) {
|
|
|
142
210
|
* @param {Object} node - List item node with content
|
|
143
211
|
* @param {string} bullet - The bullet prefix (e.g., "- " or "1. ")
|
|
144
212
|
* @param {number} indent - Current indentation level
|
|
213
|
+
* @param {boolean} [loose=false] - Whether the parent list is loose
|
|
145
214
|
* @returns {string} Markdown list item
|
|
146
215
|
*/
|
|
147
|
-
function serializeListItem(node, bullet, indent) {
|
|
216
|
+
function serializeListItem(node, bullet, indent, loose = false) {
|
|
148
217
|
if (!node.content) return bullet
|
|
149
218
|
|
|
150
219
|
const parts = []
|
|
220
|
+
const pad = ' '.repeat(bullet.length)
|
|
151
221
|
|
|
152
222
|
for (let i = 0; i < node.content.length; i++) {
|
|
153
223
|
const child = node.content[i]
|
|
154
224
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
parts.push(bullet + serializeInlineContent(child.content))
|
|
158
|
-
} else if (child.type === 'bulletList') {
|
|
225
|
+
// Nested lists carry their own indentation.
|
|
226
|
+
if (child.type === 'bulletList') {
|
|
159
227
|
parts.push(serializeBulletList(child, indent + 1))
|
|
160
|
-
|
|
228
|
+
continue
|
|
229
|
+
}
|
|
230
|
+
if (child.type === 'orderedList') {
|
|
161
231
|
parts.push(serializeOrderedList(child, indent + 1))
|
|
162
|
-
|
|
163
|
-
// Additional paragraphs in the same list item
|
|
164
|
-
const pad = ' '.repeat(bullet.length)
|
|
165
|
-
parts.push(pad + serializeInlineContent(child.content))
|
|
232
|
+
continue
|
|
166
233
|
}
|
|
234
|
+
|
|
235
|
+
// Anything else is an ordinary block. A paragraph is inline content; a
|
|
236
|
+
// code block, blockquote or table goes through the node serializer, the
|
|
237
|
+
// same way a blockquote's children do. Every one of those used to fall
|
|
238
|
+
// off the end of this loop and be dropped without a trace.
|
|
239
|
+
const text =
|
|
240
|
+
child.type === 'paragraph'
|
|
241
|
+
? serializeInlineContent(child.content)
|
|
242
|
+
: await_serializer().serializeNode(child) || ''
|
|
243
|
+
|
|
244
|
+
parts.push(indentBlock(text, i === 0 ? bullet : pad, pad))
|
|
167
245
|
}
|
|
168
246
|
|
|
169
|
-
|
|
247
|
+
// In a loose list the item's own blocks are blank-line separated too — the
|
|
248
|
+
// spacing is a property of the whole list, not just the gaps between items.
|
|
249
|
+
return parts.join(loose ? '\n\n' : '\n')
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Place a block's lines inside a list item.
|
|
254
|
+
*
|
|
255
|
+
* The first line carries the bullet (or aligns under it, for a block that is
|
|
256
|
+
* not the item's opening one); every later line aligns under the bullet so it
|
|
257
|
+
* stays part of the item instead of ending the list.
|
|
258
|
+
*
|
|
259
|
+
* @param {string} text - Serialized block, possibly multi-line
|
|
260
|
+
* @param {string} firstPrefix - Prefix for the first line
|
|
261
|
+
* @param {string} pad - Prefix for continuation lines
|
|
262
|
+
* @returns {string}
|
|
263
|
+
*/
|
|
264
|
+
function indentBlock(text, firstPrefix, pad) {
|
|
265
|
+
const [first = '', ...rest] = String(text).split('\n')
|
|
266
|
+
const head = firstPrefix + first
|
|
267
|
+
if (rest.length === 0) return head
|
|
268
|
+
return [head, ...rest.map(line => (line ? pad + line : ''))].join('\n')
|
|
170
269
|
}
|
|
171
270
|
|
|
172
271
|
/**
|
|
@@ -187,13 +286,17 @@ export function serializeTable(node) {
|
|
|
187
286
|
|
|
188
287
|
const rows = node.content
|
|
189
288
|
const headerRow = rows[0]
|
|
289
|
+
// A row with no cells is legal ProseMirror — `tableRow` is
|
|
290
|
+
// `(tableCell | tableHeader)*`, a star — and PM's toJSON omits `content`
|
|
291
|
+
// entirely for an empty node. Reading it unguarded threw.
|
|
292
|
+
const cellsOf = row => row?.content ?? []
|
|
190
293
|
const bodyRows = rows.slice(1)
|
|
191
294
|
|
|
192
295
|
// Serialize header cells
|
|
193
|
-
const headerCells = headerRow.
|
|
296
|
+
const headerCells = cellsOf(headerRow).map(cell => serializeTableCell(cell))
|
|
194
297
|
|
|
195
298
|
// Build alignment row from header cell attrs
|
|
196
|
-
const alignments = headerRow.
|
|
299
|
+
const alignments = cellsOf(headerRow).map(cell => {
|
|
197
300
|
const align = cell.attrs?.align
|
|
198
301
|
if (align === 'left') return ':---'
|
|
199
302
|
if (align === 'center') return ':---:'
|
|
@@ -203,7 +306,7 @@ export function serializeTable(node) {
|
|
|
203
306
|
|
|
204
307
|
// Serialize body rows
|
|
205
308
|
const bodyLines = bodyRows.map(row =>
|
|
206
|
-
'| ' + row.
|
|
309
|
+
'| ' + cellsOf(row).map(cell => serializeTableCell(cell)).join(' | ') + ' |'
|
|
207
310
|
)
|
|
208
311
|
|
|
209
312
|
const headerLine = '| ' + headerCells.join(' | ') + ' |'
|
|
@@ -220,7 +323,7 @@ export function serializeTable(node) {
|
|
|
220
323
|
function serializeTableCell(cell) {
|
|
221
324
|
if (!cell.content) return ''
|
|
222
325
|
// Table cells contain paragraphs; serialize their inline content
|
|
223
|
-
|
|
326
|
+
const text = cell.content
|
|
224
327
|
.map(child => {
|
|
225
328
|
if (child.type === 'paragraph') {
|
|
226
329
|
return serializeInlineContent(child.content)
|
|
@@ -228,6 +331,27 @@ function serializeTableCell(cell) {
|
|
|
228
331
|
return ''
|
|
229
332
|
})
|
|
230
333
|
.join(' ')
|
|
334
|
+
|
|
335
|
+
return escapeTableCell(text)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Escape the one character a table cell cannot contain literally.
|
|
340
|
+
*
|
|
341
|
+
* A pipe inside a cell ends it, so markdown spells a literal one `\|` and the
|
|
342
|
+
* parser hands back the unescaped character. Writing that back out unescaped
|
|
343
|
+
* splits the cell and shifts every column after it — the table silently gains
|
|
344
|
+
* a column and stops lining up with its header.
|
|
345
|
+
*
|
|
346
|
+
* Only the pipe needs this: the cell's other content has already been through
|
|
347
|
+
* the inline serializers, and a row is a single line by construction.
|
|
348
|
+
*
|
|
349
|
+
* @param {string} text - Serialized inline content of one cell
|
|
350
|
+
* @returns {string}
|
|
351
|
+
*/
|
|
352
|
+
function escapeTableCell(text) {
|
|
353
|
+
// Leave an already-escaped pipe alone rather than doubling its backslash.
|
|
354
|
+
return text.replace(/\\?\|/g, '\\|')
|
|
231
355
|
}
|
|
232
356
|
|
|
233
357
|
// 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,7 +190,27 @@ function mapEditorNode(node) {
|
|
|
179
190
|
case 'Video':
|
|
180
191
|
return mapVideo(node)
|
|
181
192
|
case 'UniwebIcon':
|
|
193
|
+
case 'Icon':
|
|
182
194
|
return mapUniwebIcon(node)
|
|
195
|
+
case 'emoji':
|
|
196
|
+
// The node carries the character alongside a name; the character is the
|
|
197
|
+
// whole markdown form. Guarded rather than assumed to be populated.
|
|
198
|
+
return node.attrs?.emoji ? { type: 'text', text: node.attrs.emoji } : null
|
|
199
|
+
// A standalone "\n" text node is NOT mapped here, deliberately.
|
|
200
|
+
//
|
|
201
|
+
// It used to be: content-reader once spelled a hard break that way, and
|
|
202
|
+
// this healed old documents onto the node form. The migration is done —
|
|
203
|
+
// content-reader has emitted a `hardBreak` node for both markdown
|
|
204
|
+
// spellings for some time, and no persisted document carries the legacy
|
|
205
|
+
// form (confirmed 2026-07-26).
|
|
206
|
+
//
|
|
207
|
+
// Keeping the rule past that point made it wrong. content-reader still
|
|
208
|
+
// produces a lone "\n" node, but for a SOFT break — a newline lands in
|
|
209
|
+
// its own node whenever the spans on both sides are marked, as in
|
|
210
|
+
// "`--heading` → `text-heading`" followed by another such line. Healing
|
|
211
|
+
// that turned an ordinary wrapped line into a visible break: the sync
|
|
212
|
+
// wrote a trailing "\", and the next read made it a real <br>, so the
|
|
213
|
+
// page reflowed one round trip after the edit.
|
|
183
214
|
default:
|
|
184
215
|
return null
|
|
185
216
|
}
|
|
@@ -191,10 +222,63 @@ function mapEditorNode(node) {
|
|
|
191
222
|
* its children. Nodes without a mapping pass through unchanged so any
|
|
192
223
|
* editor-dialect descendants are still caught.
|
|
193
224
|
*/
|
|
225
|
+
/**
|
|
226
|
+
* The editor's named inline styles, and the framework spelling of each.
|
|
227
|
+
*
|
|
228
|
+
* These are theme tokens, not colours. The editor's picker offers exactly this
|
|
229
|
+
* closed set — the attribute is *named* `color` but holds a token, which its
|
|
230
|
+
* extension renders as `var(--<token>)`. So they map onto the `span` mark the
|
|
231
|
+
* framework already round-trips as `[text]{token}`, and nothing is lost.
|
|
232
|
+
*
|
|
233
|
+
* A value outside this set (a hex from a paste, until the editor gates its
|
|
234
|
+
* `parseHTML`) is deliberately NOT mapped: it falls through to the
|
|
235
|
+
* no-silent-drop guard rather than writing a raw colour into markdown, where
|
|
236
|
+
* it would contradict the theming model and not survive a change of brand.
|
|
237
|
+
*/
|
|
238
|
+
const NAMED_INLINE_STYLES = new Set(['accent', 'highlight', 'callout', 'muted'])
|
|
239
|
+
|
|
240
|
+
/** A mark that carries nothing and should be removed rather than reported. */
|
|
241
|
+
const DROP_MARK = Symbol('drop-mark')
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Map an editor-dialect mark to the framework dialect.
|
|
245
|
+
*
|
|
246
|
+
* @param {Object} mark
|
|
247
|
+
* @returns {Object|Symbol|null} a framework mark, `DROP_MARK` when the mark
|
|
248
|
+
* carries no styling at all, or null when there is no mapping (the original
|
|
249
|
+
* is kept and reported by the no-silent-drop guard)
|
|
250
|
+
*/
|
|
251
|
+
function mapEditorMark(mark) {
|
|
252
|
+
if (mark?.type === 'highlight') {
|
|
253
|
+
// Effectively boolean in the editor: its extension hardcodes the
|
|
254
|
+
// background and ignores the attribute.
|
|
255
|
+
return { type: 'span', attrs: { highlight: true } }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (mark?.type === 'textStyle') {
|
|
259
|
+
const token = mark.attrs?.color
|
|
260
|
+
// The picker's "Normal" option. Nothing is being dropped, so reporting it
|
|
261
|
+
// would be noise in a guard whose value depends on staying quiet.
|
|
262
|
+
if (!token) return DROP_MARK
|
|
263
|
+
if (!NAMED_INLINE_STYLES.has(token)) return null // a colour, not a token
|
|
264
|
+
return { type: 'span', attrs: { [token]: true } }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return null
|
|
268
|
+
}
|
|
269
|
+
|
|
194
270
|
function normalizeNode(node) {
|
|
195
271
|
if (!node || typeof node !== 'object') return node
|
|
196
272
|
|
|
197
|
-
|
|
273
|
+
let result = mapEditorNode(node) || node
|
|
274
|
+
|
|
275
|
+
if (Array.isArray(result.marks) && result.marks.length) {
|
|
276
|
+
// An unmapped mark is kept, so the no-silent-drop guard still reports it.
|
|
277
|
+
result = {
|
|
278
|
+
...result,
|
|
279
|
+
marks: result.marks.map(m => mapEditorMark(m) || m).filter(m => m !== DROP_MARK),
|
|
280
|
+
}
|
|
281
|
+
}
|
|
198
282
|
|
|
199
283
|
if (Array.isArray(result.content)) {
|
|
200
284
|
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
|
/**
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entity handling on the way out of ProseMirror.
|
|
3
|
+
*
|
|
4
|
+
* The invariant these tests pin down (verified against `content-reader`'s
|
|
5
|
+
* `parser/inline.js`): a PM text node holds HTML. The reader decodes `&`,
|
|
6
|
+
* `"` and `'` and leaves `<`/`>` escaped, because the render
|
|
7
|
+
* path injects the string as HTML. Serializing has to undo exactly that, and
|
|
8
|
+
* exactly there — decoding more, or decoding in prose, corrupts source.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { markdownToProseMirror } from '@uniweb/content-reader'
|
|
12
|
+
import { proseMirrorToMarkdown, docSummaryText, docToPlainText } from '../src/index.js'
|
|
13
|
+
import { encodeMarkupEntities, decodeMarkupEntities, decodeTextEntities } from '../src/entities.js'
|
|
14
|
+
|
|
15
|
+
const md = s => proseMirrorToMarkdown(markdownToProseMirror(s)).trim()
|
|
16
|
+
|
|
17
|
+
describe('decodeMarkupEntities', () => {
|
|
18
|
+
test('undoes the pair the reader leaves escaped', () => {
|
|
19
|
+
expect(decodeMarkupEntities('/data/<name>.json')).toBe('/data/<name>.json')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('leaves entities the reader already decoded alone', () => {
|
|
23
|
+
// A PM node holding these means the author typed them.
|
|
24
|
+
expect(decodeMarkupEntities('a & b')).toBe('a & b')
|
|
25
|
+
expect(decodeMarkupEntities('say "hi"')).toBe('say "hi"')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('is a no-op on text with no entities', () => {
|
|
29
|
+
expect(decodeMarkupEntities('plain text')).toBe('plain text')
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe('decodeTextEntities', () => {
|
|
34
|
+
test('resolves every entity to the character a reader sees', () => {
|
|
35
|
+
expect(decodeTextEntities('<b> & "q" 'a'')).toBe(`<b> & "q" 'a'`)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('decodes & last, so &lt; yields < and not <', () => {
|
|
39
|
+
expect(decodeTextEntities('&lt;')).toBe('<')
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('D1 — code spans round-trip a literal < >', () => {
|
|
44
|
+
test('the reported defect', () => {
|
|
45
|
+
expect(md('Read `/data/<name>.json` for the cascade.')).toBe(
|
|
46
|
+
'Read `/data/<name>.json` for the cascade.'
|
|
47
|
+
)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('inside a heading', () => {
|
|
51
|
+
expect(md('# The `<slug>` folder')).toBe('# The `<slug>` folder')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('inside a table cell', () => {
|
|
55
|
+
expect(md('| Col |\n| --- |\n| `<x>` |')).toContain('`<x>`')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('inside a link', () => {
|
|
59
|
+
expect(md('[the `<x>` doc](/a)')).toBe('[the `<x>` doc](/a)')
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('D1 — what the fix must NOT touch', () => {
|
|
64
|
+
test('prose entities stay escaped (decoding them would render as a tag)', () => {
|
|
65
|
+
expect(md('Literal <name> in prose.')).toBe('Literal <name> in prose.')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('raw inline HTML in prose is preserved verbatim', () => {
|
|
69
|
+
expect(md('A <name> tag in prose.')).toBe('A <name> tag in prose.')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('an authored & inside a code span survives', () => {
|
|
73
|
+
expect(md('Code `a & b` here.')).toBe('Code `a & b` here.')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('code blocks are raw text and are left alone', () => {
|
|
77
|
+
const source = '```js\nconst x = a < b && c > d;\n```'
|
|
78
|
+
expect(md(source)).toBe(source)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('quotes and apostrophes in code spans are untouched', () => {
|
|
82
|
+
expect(md(`Code \`say "hi" it's\` here.`)).toBe(`Code \`say "hi" it's\` here.`)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('docSummaryText', () => {
|
|
87
|
+
const summary = (source, opts) => docSummaryText(markdownToProseMirror(source), opts)
|
|
88
|
+
|
|
89
|
+
test('skips the H1 and reads the first paragraph', () => {
|
|
90
|
+
expect(summary('# Quickstart\n\nCreate your first Uniweb site in 5 minutes.')).toBe(
|
|
91
|
+
'Create your first Uniweb site in 5 minutes.'
|
|
92
|
+
)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('returns plain text — no anchors, no mark tags', () => {
|
|
96
|
+
expect(summary('# T\n\nSee **the [docs](/docs)** for *more*.')).toBe(
|
|
97
|
+
'See the docs for more.'
|
|
98
|
+
)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('resolves entities and drops raw inline HTML', () => {
|
|
102
|
+
expect(summary('# T\n\nUse `<name>` and <slug> and <br> here.')).toBe(
|
|
103
|
+
'Use <name> and <slug> and here.'
|
|
104
|
+
)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('truncates at a sentence boundary', () => {
|
|
108
|
+
const text = 'One sentence here. Two sentence here. Three sentence here.'
|
|
109
|
+
expect(summary(`# T\n\n${text}`, { maxChars: 40 })).toBe('One sentence here. Two sentence here.')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('falls back to a word boundary with an ellipsis', () => {
|
|
113
|
+
const text = 'Collections let you manage repeating content across many files'
|
|
114
|
+
expect(summary(`# T\n\n${text}`, { maxChars: 40 })).toBe(
|
|
115
|
+
'Collections let you manage repeating…'
|
|
116
|
+
)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('a dot inside a filename does not end a sentence', () => {
|
|
120
|
+
expect(summary('# T\n\nEdit site.yml then run the build now please.', { maxChars: 20 }))
|
|
121
|
+
.toBe('Edit site.yml then…')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('returns empty string when there is no paragraph', () => {
|
|
125
|
+
expect(summary('# Only a heading')).toBe('')
|
|
126
|
+
expect(docSummaryText(null)).toBe('')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('collapses newlines into one line', () => {
|
|
130
|
+
expect(summary('# T\n\nA line\nand another.')).toBe('A line and another.')
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
describe('docToPlainText also yields plain characters', () => {
|
|
135
|
+
test('strips tags and resolves entities', () => {
|
|
136
|
+
// Note the doubled space where <br> was: this primitive does not
|
|
137
|
+
// normalize whitespace, and its consumers (word counts, search tokens)
|
|
138
|
+
// do not care. `docSummaryText` is the one that promises a single line.
|
|
139
|
+
expect(docToPlainText(markdownToProseMirror('Use <x> and <br> here.'))).toBe(
|
|
140
|
+
'Use <x> and here.'
|
|
141
|
+
)
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
describe('encodeMarkupEntities — the producer-side half', () => {
|
|
146
|
+
test('is the exact inverse of decodeMarkupEntities', () => {
|
|
147
|
+
const raw = '/data/<name>.json'
|
|
148
|
+
expect(encodeMarkupEntities(raw)).toBe('/data/<name>.json')
|
|
149
|
+
expect(decodeMarkupEntities(encodeMarkupEntities(raw))).toBe(raw)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('does not touch &, so an authored entity is not double-encoded', () => {
|
|
153
|
+
expect(encodeMarkupEntities('a & b')).toBe('a & b')
|
|
154
|
+
expect(encodeMarkupEntities('&')).toBe('&')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('round-trips what an author types in a code span', () => {
|
|
158
|
+
for (const raw of ['<div>', 'a < b && c > d', 'a & b', 'plain']) {
|
|
159
|
+
expect(decodeMarkupEntities(encodeMarkupEntities(raw))).toBe(raw)
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('the one input it does NOT round-trip: literal entity text', () => {
|
|
164
|
+
// `<div>` and `<div>` both store as `<div>`, so the pair
|
|
165
|
+
// cannot tell them apart and resolves to the decoded form.
|
|
166
|
+
//
|
|
167
|
+
// Accepted deliberately, because the loss happened earlier and elsewhere:
|
|
168
|
+
// a code span is rendered by injecting its text as HTML, so an author who
|
|
169
|
+
// typed `<div>` already sees `<div>` on the page. Decoding makes the
|
|
170
|
+
// file agree with the page rather than destroying something a reader could
|
|
171
|
+
// otherwise see. Escaping `&` as well would make the pair lossless, at the
|
|
172
|
+
// cost of changing what every existing code span stores.
|
|
173
|
+
expect(decodeMarkupEntities(encodeMarkupEntities('<div>'))).toBe('<div>')
|
|
174
|
+
})
|
|
175
|
+
})
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
- **[Predicates](./predicates.md)** — filtering with `where:` clauses
|