@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/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
- return `\`\`\`${lang}${tag}\n${text}\n\`\`\``
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
- * @param {Object} node - Data block node with attrs.tag and attrs.data
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
- const serialized = JSON.stringify(data, null, 2)
87
- return `\`\`\`json:${tag}\n${serialized}\n\`\`\``
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('\n')
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('\n')
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
- 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') {
225
+ // Nested lists carry their own indentation.
226
+ if (child.type === 'bulletList') {
159
227
  parts.push(serializeBulletList(child, indent + 1))
160
- } else if (child.type === 'orderedList') {
228
+ continue
229
+ }
230
+ if (child.type === 'orderedList') {
161
231
  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))
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
- return parts.join('\n')
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.content.map(cell => serializeTableCell(cell))
296
+ const headerCells = cellsOf(headerRow).map(cell => serializeTableCell(cell))
194
297
 
195
298
  // Build alignment row from header cell attrs
196
- const alignments = headerRow.content.map(cell => {
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.content.map(cell => serializeTableCell(cell)).join(' | ') + ' |'
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
- return cell.content
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
- 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,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
- const result = mapEditorNode(node) || node
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
+ * `&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
  /**
@@ -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 `&amp;`,
6
+ * `&quot;` and `&#39;` and leaves `&lt;`/`&gt;` 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/&lt;name&gt;.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 &amp; b')).toBe('a &amp; b')
25
+ expect(decodeMarkupEntities('say &quot;hi&quot;')).toBe('say &quot;hi&quot;')
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('&lt;b&gt; &amp; &quot;q&quot; &#39;a&#39;')).toBe(`<b> & "q" 'a'`)
36
+ })
37
+
38
+ test('decodes &amp; last, so &amp;lt; yields &lt; and not <', () => {
39
+ expect(decodeTextEntities('&amp;lt;')).toBe('&lt;')
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 &lt;name&gt; in prose.')).toBe('Literal &lt;name&gt; 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 &amp; inside a code span survives', () => {
73
+ expect(md('Code `a &amp; b` here.')).toBe('Code `a &amp; 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 &lt;slug&gt; 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 &lt;x&gt; 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/&lt;name&gt;.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('&amp;')).toBe('&amp;')
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 `&lt;div&gt;` both store as `&lt;div&gt;`, 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 `&lt;div&gt;` 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('&lt;div&gt;'))).toBe('<div>')
174
+ })
175
+ })
@@ -0,0 +1,3 @@
1
+ $$
2
+ E = mc^2
3
+ $$
@@ -0,0 +1,11 @@
1
+ # Code
2
+
3
+ Install it first:
4
+
5
+ ```bash
6
+ pnpm add @uniweb/kit
7
+ ```
8
+
9
+ ```js
10
+ const site = { name: 'docs' }
11
+ ```
@@ -0,0 +1,3 @@
1
+ - **Preview with `pnpm dev`.**
2
+
3
+ Use **`npm install`** first.
@@ -0,0 +1 @@
1
+ - **[Predicates](./predicates.md)** — filtering with `where:` clauses
@@ -0,0 +1,6 @@
1
+ # Navigation
2
+
3
+ ```yaml:nav
4
+ - label: Docs
5
+ href: /docs
6
+ ```