kaloko 0.5.2 → 0.7.0

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.
@@ -0,0 +1,450 @@
1
+ // Living documentation (docs/DOCS-CHANGELOG.md, phases 2 and 3), the parts the CLI and the service share: where a
2
+ // Markdown file sits in the docs (Diátaxis folders, sibling translations `guide.cs.md`), front matter, sections with
3
+ // their step references, the guide as training steps, the translations report, stale sections, changelog items
4
+ // without pictures, and a small Markdown renderer. No I/O and no Node built-ins: the worker imports this file too.
5
+ import { findStepRefs, parseStepRef, parseChangelog, parseEntry } from './changelog-core.mjs'
6
+
7
+ export const KINDS = ['tutorials', 'how-to', 'reference', 'explanation']
8
+ const SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/
9
+ export const LANG = /^[a-z]{2}(-[A-Z]{2})?$/
10
+ export const MAX_PAGE = 200 * 1024
11
+
12
+ // ---------- files and paths ----------
13
+
14
+ /**
15
+ * Where a file of the docs folder goes: `how-to/pay.md` → { path: 'how-to/pay', lang: null }, `how-to/pay.cs.md` →
16
+ * lang cs, `index.md` and `README.md` → the folder itself (`''`, `how-to`). Null for files that are not pages
17
+ * (not Markdown, hidden, or with a path the service does not take).
18
+ */
19
+ export function docFile (rel) {
20
+ const p = String(rel || '').replace(/\\/g, '/').replace(/^\.?\//, '')
21
+ if (!/\.(md|markdown)$/i.test(p) || p.split('/').some((s) => !s || s.startsWith('.') || s.startsWith('_'))) return null
22
+ const parts = p.split('/')
23
+ let name = parts.pop().replace(/\.(md|markdown)$/i, '')
24
+ let lang = null
25
+ const m = name.match(/^(.+)\.([a-z]{2}(?:-[A-Z]{2})?)$/)
26
+ if (m) { name = m[1]; lang = m[2] }
27
+ if (/^(index|readme)$/i.test(name)) name = ''
28
+ const path = [...parts, name].filter(Boolean).join('/')
29
+ if (path && !path.split('/').every((s) => SEGMENT.test(s))) return null
30
+ if (path.length > 200) return null
31
+ return { path, lang }
32
+ }
33
+
34
+ /** Is this a page path the service stores ('' is the docs index)? */
35
+ export const validDocPath = (p) => p === '' || (typeof p === 'string' && p.length <= 200 && p.split('/').every((s) => SEGMENT.test(s)))
36
+
37
+ /** The Diátaxis kind of a page from its first folder (tutorials, how-to, reference, explanation), else null. */
38
+ export function kindOf (path) {
39
+ const top = String(path || '').split('/')[0].toLowerCase()
40
+ const alias = { tutorial: 'tutorials', howto: 'how-to', 'how-tos': 'how-to', guides: 'how-to', explanations: 'explanation', concepts: 'explanation' }
41
+ const k = alias[top] || top
42
+ return KINDS.includes(k) ? k : null
43
+ }
44
+
45
+ // ---------- front matter ----------
46
+
47
+ /** `---` front matter with a flat YAML subset (scalars, quoted strings, [a, b] lists, true/false, numbers). */
48
+ export function frontMatter (md) {
49
+ const text = String(md || '').replace(/^/, '').replace(/\r/g, '')
50
+ const m = text.match(/^---\n([\s\S]*?)\n---[ \t]*(?:\n|$)/)
51
+ if (!m) return { data: {}, body: text }
52
+ const data = {}
53
+ for (const line of m[1].split('\n')) {
54
+ const kv = line.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/)
55
+ if (!kv) continue
56
+ data[kv[1]] = scalar(kv[2].trim())
57
+ }
58
+ return { data, body: text.slice(m[0].length) }
59
+ }
60
+ function scalar (v) {
61
+ if (v === '') return ''
62
+ if (/^\[.*\]$/.test(v)) return v.slice(1, -1).split(',').map((x) => scalar(x.trim())).filter((x) => x !== '')
63
+ if (/^".*"$/.test(v) || /^'.*'$/.test(v)) return v.slice(1, -1)
64
+ if (v === 'true' || v === 'false') return v === 'true'
65
+ if (/^-?\d+(\.\d+)?$/.test(v)) return Number(v)
66
+ return v.replace(/\s+#.*$/, '')
67
+ }
68
+
69
+ /** The title of a page: front matter title, else its first `# ` heading, else the last part of its path. */
70
+ export function docTitle (md, path = '') {
71
+ const { data, body } = frontMatter(md)
72
+ if (typeof data.title === 'string' && data.title.trim()) return data.title.trim().slice(0, 200)
73
+ const h = body.match(/^#\s+(.+?)\s*#*\s*$/m)
74
+ if (h) return stripInline(h[1]).slice(0, 200)
75
+ const last = String(path || '').split('/').pop()
76
+ return last ? last.replace(/[-_]+/g, ' ').replace(/^./, (c) => c.toUpperCase()) : 'Documentation'
77
+ }
78
+
79
+ /** The order of a page in the sidebar: front matter order / sidebar_position / nav_order, else null. */
80
+ export const docOrder = (data) => [data?.order, data?.sidebar_position, data?.nav_order].find((x) => typeof x === 'number') ?? null
81
+
82
+ const stripInline = (s) => String(s).replace(/!\[[^\]]*\]\([^)]*\)/g, '').replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/[`*_]/g, '').trim()
83
+
84
+ // ---------- sections ----------
85
+
86
+ /** A heading id: lower case, accents dropped, words joined by dashes. */
87
+ export function slugify (s) {
88
+ return String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/<[^>]+>/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'section'
89
+ }
90
+
91
+ /** Lines of a body with whether each is inside a fenced code block. */
92
+ function fenced (lines) {
93
+ let inside = false
94
+ return lines.map((l) => { const fence = /^\s{0,3}(```|~~~)/.test(l); const was = inside; if (fence) inside = !inside; return was || fence })
95
+ }
96
+
97
+ /**
98
+ * The sections of a page body: text before the first heading (id '', level 0), then one per heading of level 1–3,
99
+ * each with its own text (up to the next heading of any of these levels) and its step references.
100
+ */
101
+ export function sections (body) {
102
+ const lines = String(body || '').replace(/\r/g, '').split('\n')
103
+ const code = fenced(lines)
104
+ const out = []; const used = new Map()
105
+ let cur = { id: '', level: 0, title: '', lines: [] }
106
+ for (let i = 0; i < lines.length; i++) {
107
+ const h = !code[i] && lines[i].match(/^(#{1,3})\s+(.+?)\s*#*\s*$/)
108
+ if (h) {
109
+ out.push(cur)
110
+ let id = slugify(stripInline(h[2]))
111
+ const n = (used.get(id) || 0) + 1; used.set(id, n); if (n > 1) id = `${id}-${n}`
112
+ cur = { id, level: h[1].length, title: stripInline(h[2]), lines: [], heading: lines[i] }
113
+ continue
114
+ }
115
+ cur.lines.push(lines[i])
116
+ }
117
+ out.push(cur)
118
+ return out.filter((s) => s.level || s.lines.some((l) => l.trim())).map((s) => { const text = s.lines.join('\n'); return { id: s.id, level: s.level, title: s.title, text, refs: findStepRefs(text).filter((r) => r.parsed).map((r) => r.ref) } })
119
+ }
120
+
121
+ // ---------- training mode ----------
122
+
123
+ /**
124
+ * A guide as training steps: every block (list item or paragraph) that shows a step becomes one step whose caption is
125
+ * the block's words and whose picture is its last step reference (the "after" of a before/after pair). A picture on
126
+ * its own line belongs to the text right above it. Returns [{ caption, ref, alt, refs }].
127
+ */
128
+ export function trainingSteps (body) {
129
+ const blocks = []
130
+ const lines = String(body || '').replace(/\r/g, '').split('\n')
131
+ const code = fenced(lines)
132
+ let cur = null
133
+ const flush = () => { if (cur && cur.join('\n').trim()) blocks.push(cur.join('\n')); cur = null }
134
+ for (let i = 0; i < lines.length; i++) {
135
+ const l = lines[i]
136
+ if (code[i]) { flush(); continue }
137
+ if (/^#{1,6}\s/.test(l)) { flush(); blocks.push({ heading: l.replace(/^#+\s+/, '').trim() }); continue }
138
+ if (/^\s{0,3}([-*+]|\d+[.)])\s+/.test(l)) { flush(); cur = [l.replace(/^\s{0,3}([-*+]|\d+[.)])\s+/, '')]; continue }
139
+ if (!l.trim()) { if (cur && i + 1 < lines.length && /^\s{2,}\S/.test(lines[i + 1])) { cur.push(''); continue } flush(); continue }
140
+ if (cur && /^\s+\S/.test(l)) { cur.push(l.trim()); continue }
141
+ if (!cur) cur = []
142
+ cur.push(l.trim())
143
+ }
144
+ flush()
145
+ const steps = []
146
+ let heading = null
147
+ let pendingText = null
148
+ for (const b of blocks) {
149
+ if (typeof b === 'object') { heading = b.heading; pendingText = null; continue }
150
+ const refs = findStepRefs(b).filter((r) => r.parsed)
151
+ const words = b.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ').replace(/<!--[\s\S]*?-->/g, '').replace(/[ \t]+/g, ' ').replace(/\s*\n\s*/g, '\n').trim()
152
+ if (!refs.length) { pendingText = words || null; continue }
153
+ const caption = words || pendingText || heading || refs[refs.length - 1].alt || ''
154
+ const last = refs[refs.length - 1]
155
+ steps.push({ caption, ref: last.ref, alt: last.alt, refs: refs.map((r) => r.ref), heading })
156
+ pendingText = null
157
+ }
158
+ return steps
159
+ }
160
+
161
+ // ---------- the sidebar ----------
162
+
163
+ /**
164
+ * The sidebar of a set of pages (one language): the index first, then the Diátaxis folders in their order, then
165
+ * other folders; inside a folder by front matter order, then title. [{ path, title, kind, children: [...] }]
166
+ */
167
+ export function buildNav (pages) {
168
+ const byPath = new Map(pages.map((p) => [p.path, p]))
169
+ const top = new Map()
170
+ for (const p of pages) {
171
+ if (p.path === '') continue
172
+ const [first] = p.path.split('/')
173
+ if (!top.has(first)) top.set(first, { path: first, title: byPath.get(first)?.title || folderTitle(first), kind: kindOf(first), order: byPath.get(first)?.order ?? null, page: byPath.has(first), children: [] })
174
+ if (p.path !== first) top.get(first).children.push({ path: p.path, title: p.title, kind: kindOf(p.path), order: p.order ?? null, page: true })
175
+ }
176
+ const rank = (n) => { const i = KINDS.indexOf(kindOf(n.path)); return i < 0 ? 10 : i }
177
+ const sort = (a, b) => (a.order ?? 1e9) - (b.order ?? 1e9) || String(a.title).localeCompare(String(b.title))
178
+ const list = [...top.values()].sort((a, b) => rank(a) - rank(b) || sort(a, b))
179
+ for (const n of list) n.children.sort(sort)
180
+ return [...(byPath.has('') ? [{ path: '', title: byPath.get('').title, kind: null, page: true, children: [] }] : []), ...list]
181
+ }
182
+ const FOLDERS = { tutorials: 'Tutorials', 'how-to': 'How-to guides', reference: 'Reference', explanation: 'Explanation' }
183
+ export const folderTitle = (f) => FOLDERS[kindOf(f)] && kindOf(f) === f ? FOLDERS[f] : String(f).replace(/[-_]+/g, ' ').replace(/^./, (c) => c.toUpperCase())
184
+
185
+ /** The nav flattened in reading order (for previous / next links). */
186
+ export const navOrder = (nav) => nav.flatMap((n) => [...(n.page ? [n.path] : []), ...n.children.map((c) => c.path)])
187
+
188
+ // ---------- translations ----------
189
+
190
+ /**
191
+ * Which translations are missing or older than their original. `pages`: [{ path, lang (null = the original
192
+ * language), hash, front, updated (ISO, optional) }]; `languages`: the configured languages, the first is the
193
+ * original's. A translation is outdated when its front matter `source_hash` (a prefix of the original's SHA-256)
194
+ * differs from the original, or, without one, when the original changed later (git dates). Returns
195
+ * { languages, missing: [{ path, lang }], outdated: [{ path, lang, why }], orphans: [{ path, lang }] }.
196
+ */
197
+ export function translationReport (pages, languages = []) {
198
+ const langs = [...new Set((languages || []).filter((l) => LANG.test(l)))]
199
+ const base = langs[0] || null
200
+ const others = langs.slice(1)
201
+ const originals = new Map(); const translations = new Map()
202
+ for (const p of pages) {
203
+ if (!p.lang || p.lang === base) originals.set(p.path, p)
204
+ else translations.set(`${p.path}|${p.lang}`, p)
205
+ }
206
+ const missing = []; const outdated = []; const orphans = []
207
+ for (const [path, o] of originals) {
208
+ for (const l of others) {
209
+ const t = translations.get(`${path}|${l}`)
210
+ if (!t) { missing.push({ path, lang: l }); continue }
211
+ const want = t.front?.source_hash ? String(t.front.source_hash) : null
212
+ if (want && o.hash && !o.hash.startsWith(want.toLowerCase())) outdated.push({ path, lang: l, why: 'source_hash' })
213
+ else if (!want && o.updated && t.updated && o.updated > t.updated) outdated.push({ path, lang: l, why: 'older' })
214
+ }
215
+ }
216
+ for (const t of translations.values()) if (!originals.has(t.path)) orphans.push({ path: t.path, lang: t.lang })
217
+ return { languages: langs, missing, outdated, orphans }
218
+ }
219
+
220
+ // ---------- stale sections ----------
221
+
222
+ /**
223
+ * Sections whose pictures changed since the text was published. `recorded`: { ref: { hash, run } | null } taken when
224
+ * the page's text last changed; `current`: { ref: { hash, run, app_version, started_at } | null } now. A reference
225
+ * that had no picture then is not stale (it is new). Returns [{ section, title, refs: [{ ref, changed_in, run }] }].
226
+ */
227
+ export function staleSections (body, recorded = {}, current = {}) {
228
+ const out = []
229
+ for (const s of sections(body)) {
230
+ const refs = []
231
+ for (const ref of new Set(s.refs)) {
232
+ const was = recorded?.[ref]; const now = current?.[ref]
233
+ if (!was?.hash || !now?.hash || was.hash === now.hash) continue
234
+ refs.push({ ref, changed_in: now.app_version || (now.started_at ? String(now.started_at).slice(0, 10) : null), run: now.run || null, was_run: was.run || null })
235
+ }
236
+ if (refs.length) out.push({ section: s.id, title: s.title, refs })
237
+ }
238
+ return out
239
+ }
240
+
241
+ // ---------- changelog items without pictures ----------
242
+
243
+ const VISIBLE = ['Added', 'Changed', 'Removed']
244
+ /**
245
+ * User-visible changelog items (Added, Changed, Removed) without a step reference, in Unreleased and the newest
246
+ * release. `<!-- no picture -->` in an item says it has nothing to show. [{ section, group, item }]
247
+ */
248
+ export function itemsWithoutPictures (changelogText) {
249
+ const { sections: secs } = parseChangelog(changelogText)
250
+ const pick = [secs.find((s) => s.unreleased), secs.find((s) => !s.unreleased)].filter(Boolean)
251
+ const out = []
252
+ for (const s of pick) {
253
+ for (const g of parseEntry(s.body).groups) {
254
+ if (!VISIBLE.includes(g.name)) continue
255
+ for (const it of g.items) {
256
+ if (findStepRefs(it).some((r) => r.parsed) || /<!--\s*no[ -]picture\s*-->/i.test(it)) continue
257
+ out.push({ section: s.unreleased ? 'Unreleased' : s.version, group: g.name, item: it.split('\n')[0].replace(/<!--[\s\S]*?-->/g, '').trim().slice(0, 200) })
258
+ }
259
+ }
260
+ }
261
+ return out
262
+ }
263
+
264
+ // ---------- links between pages ----------
265
+
266
+ /**
267
+ * Where a link in a page points: another page of the docs ({ path, hash }) for relative `.md` links (sibling
268
+ * translations included), else null (external links, anchors and anything else stay as written).
269
+ */
270
+ export function docLink (fromPath, href, { isIndex = false } = {}) {
271
+ const h = String(href || '')
272
+ if (/^[a-z][a-z0-9+.-]*:|^\/\/|^#|^\//i.test(h)) return null
273
+ const [file, hash = ''] = h.split('#')
274
+ if (!/\.(md|markdown)$/i.test(file)) return null
275
+ const dir = isIndex ? String(fromPath || '').split('/').filter(Boolean) : String(fromPath || '').split('/').filter(Boolean).slice(0, -1)
276
+ for (const seg of file.split('/')) {
277
+ if (seg === '..') dir.pop()
278
+ else if (seg && seg !== '.') dir.push(seg)
279
+ }
280
+ const f = docFile(dir.join('/'))
281
+ return f ? { path: f.path, lang: f.lang, hash } : null
282
+ }
283
+
284
+ // ---------- Markdown to HTML ----------
285
+
286
+ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
287
+ export const escapeHtml = esc
288
+
289
+ /** Inline Markdown: code, bold, italics, links (safe schemes; `link(href)` may rewrite or drop them). Images are removed. */
290
+ export function inlineHtml (s, { link = null } = {}) {
291
+ let out = esc(String(s).replace(/!\[[^\]]*\]\([^)]*\)/g, ''))
292
+ const codes = []
293
+ out = out.replace(/`([^`]+)`/g, (_, c) => { codes.push(`<code>${c}</code>`); return `\u0000${codes.length - 1}\u0000` })
294
+ out = out.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>').replace(/__([^_]+)__/g, '<b>$1</b>').replace(/(^|[\s(])_([^_]+)_(?=$|[\s).,;:!?])/g, '$1<i>$2</i>').replace(/(^|[\s(])\*([^*\s][^*]*)\*(?=$|[\s).,;:!?])/g, '$1<i>$2</i>')
295
+ out = out.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+&quot;[^&]*&quot;)?\)/g, (_, t, href) => {
296
+ const raw = href.replace(/&amp;/g, '&')
297
+ const target = link ? link(raw) : raw
298
+ if (target == null) return t
299
+ return /^(https?:\/\/|\/|#|mailto:|\.{0,2}\/?[A-Za-z0-9._/-]+(\.html)?(#[A-Za-z0-9_-]*)?$)/.test(target) && !/^javascript:/i.test(target) ? `<a href="${esc(target)}"${/^https?:/.test(target) ? ' rel="noopener"' : ''}>${t}</a>` : t
300
+ })
301
+ return out.replace(/\u0000(\d+)\u0000/g, (_, i) => codes[Number(i)])
302
+ }
303
+
304
+ const LIST = /^(\s{0,3})([-*+]|\d{1,9}[.)])\s+(.*)$/
305
+ const IMG = /!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g
306
+
307
+ /**
308
+ * Markdown to HTML for docs: headings (with ids), paragraphs, lists (nested by indentation), fenced code,
309
+ * blockquotes, tables, rules, inline formatting and pictures. `picture(images)` gets the images of one block
310
+ * ([{ alt, src, ref }]) and returns their HTML; `link(href)` rewrites links; `notice(sectionId)` adds HTML right
311
+ * under a section heading (stale notes); `headingIds` is filled with { level, id, title }. Raw HTML is escaped.
312
+ */
313
+ export function renderMarkdown (body, opts = {}) {
314
+ const ids = opts.headingIds || []
315
+ const used = new Map()
316
+ const text = String(body || '').replace(/\r/g, '').replace(/<!--[\s\S]*?-->/g, '')
317
+ const html = blocks(text.split('\n'), { ...opts, ids, used, top: true })
318
+ return html
319
+ }
320
+
321
+ function picturesOf (text) {
322
+ const imgs = []
323
+ const words = String(text).replace(IMG, (_, alt, src) => { imgs.push({ alt, src, ref: parseStepRef(src) }); return ' ' })
324
+ return { imgs, words: words.replace(/[ \t]+\n/g, '\n').trim() }
325
+ }
326
+
327
+ function blocks (lines, o) {
328
+ const out = []
329
+ let i = 0
330
+ const para = []
331
+ const flush = () => {
332
+ if (!para.length) return
333
+ const { imgs, words } = picturesOf(para.join('\n'))
334
+ if (words) out.push(`<p>${inlineHtml(words.replace(/\n/g, ' '), o)}</p>`)
335
+ if (imgs.length) out.push(pics(imgs, o))
336
+ para.length = 0
337
+ }
338
+ while (i < lines.length) {
339
+ const l = lines[i]
340
+ const fence = l.match(/^\s{0,3}(```+|~~~+)\s*([\w-]*)/)
341
+ if (fence) {
342
+ flush()
343
+ const buf = []; i++
344
+ while (i < lines.length && !lines[i].trim().startsWith(fence[1])) buf.push(lines[i++])
345
+ i++
346
+ out.push(`<pre class="dc-code"><code${fence[2] ? ` class="language-${esc(fence[2])}"` : ''}>${esc(buf.join('\n'))}</code></pre>`)
347
+ continue
348
+ }
349
+ const h = l.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/)
350
+ if (h) {
351
+ flush()
352
+ const level = h[1].length
353
+ const title = h[2]
354
+ let id = slugify(stripInline(title))
355
+ const n = (o.used.get(id) || 0) + 1; o.used.set(id, n); if (n > 1) id = `${id}-${n}`
356
+ if (level <= 3) o.ids.push({ level, id, title: stripInline(title) })
357
+ const tag = `h${Math.min(6, level)}`
358
+ out.push(`<${tag} id="${esc(id)}">${inlineHtml(title, o)}${level > 1 && level <= 3 ? ` <a class="dc-anchor" href="#${esc(id)}" aria-hidden="true">#</a>` : ''}</${tag}>`)
359
+ if (level <= 3 && o.notice) { const x = o.notice(id); if (x) out.push(x) }
360
+ i++
361
+ continue
362
+ }
363
+ if (/^\s{0,3}([-*_])(\s*\1){2,}\s*$/.test(l)) { flush(); out.push('<hr>'); i++; continue }
364
+ if (/^\s{0,3}>/.test(l)) {
365
+ flush()
366
+ const buf = []
367
+ while (i < lines.length && /^\s{0,3}>/.test(lines[i])) buf.push(lines[i++].replace(/^\s{0,3}>\s?/, ''))
368
+ out.push(`<blockquote>${blocks(buf, { ...o, notice: null })}</blockquote>`)
369
+ continue
370
+ }
371
+ if (/^\s*\|.*\|\s*$/.test(l) && i + 1 < lines.length && /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/.test(lines[i + 1])) {
372
+ flush()
373
+ const row = (x) => x.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim())
374
+ const head = row(l); i += 2
375
+ const rows = []
376
+ while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) rows.push(row(lines[i++]))
377
+ out.push(`<div class="dc-table"><table><thead><tr>${head.map((c) => `<th>${inlineHtml(c, o)}</th>`).join('')}</tr></thead><tbody>${rows.map((r) => `<tr>${r.map((c) => `<td>${inlineHtml(c, o)}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`)
378
+ continue
379
+ }
380
+ const li = l.match(LIST)
381
+ if (li) {
382
+ flush()
383
+ const ordered = /\d/.test(li[2])
384
+ const start = ordered ? Number(li[2].replace(/\D/g, '')) : 1
385
+ const items = []
386
+ while (i < lines.length) {
387
+ const m = lines[i].match(LIST)
388
+ if (!m || /\d/.test(m[2]) !== ordered || m[1].length > 3) break
389
+ const indent = m[1].length + m[2].length + 1
390
+ const item = [m[3]]; i++
391
+ while (i < lines.length) {
392
+ const n = lines[i]
393
+ if (!n.trim()) {
394
+ // a blank line keeps the item open when indented text follows
395
+ if (i + 1 < lines.length && /^\s{2,}\S/.test(lines[i + 1])) { item.push(''); i++; continue }
396
+ break
397
+ }
398
+ if (LIST.test(n) && n.match(/^\s*/)[0].length < 2) break
399
+ if (/^\s{2,}/.test(n)) { item.push(n.replace(new RegExp(`^\\s{0,${Math.max(2, indent)}}`), '')); i++; continue }
400
+ if (/^(#{1,6}\s|\s{0,3}(```|~~~|>))/.test(n)) break
401
+ item.push(n.trim()); i++
402
+ }
403
+ items.push(item)
404
+ while (i < lines.length && !lines[i].trim() && i + 1 < lines.length && LIST.test(lines[i + 1]) && lines[i + 1].match(LIST)[1].length <= 3 && /\d/.test(lines[i + 1].match(LIST)[2]) === ordered) i++
405
+ }
406
+ const tag = ordered ? 'ol' : 'ul'
407
+ out.push(`<${tag}${ordered && start !== 1 ? ` start="${start}"` : ''}>${items.map((it) => `<li>${listItem(it, o)}</li>`).join('')}</${tag}>`)
408
+ continue
409
+ }
410
+ if (!l.trim()) { flush(); i++; continue }
411
+ para.push(l); i++
412
+ }
413
+ flush()
414
+ return out.join('\n')
415
+ }
416
+
417
+ /** A list item: its first paragraph inline (tight lists), the rest as blocks (nested lists, pictures, code). */
418
+ function listItem (lines, o) {
419
+ const first = []
420
+ let i = 0
421
+ while (i < lines.length && lines[i].trim() && !LIST.test(lines[i]) && !/^\s{0,3}(```|~~~)/.test(lines[i])) first.push(lines[i++])
422
+ const { imgs, words } = picturesOf(first.join('\n'))
423
+ const rest = lines.slice(i)
424
+ return `${words ? inlineHtml(words.replace(/\n/g, ' '), o) : ''}${imgs.length ? pics(imgs, o) : ''}${rest.some((l) => l.trim()) ? blocks(rest, { ...o, notice: null }) : ''}`
425
+ }
426
+
427
+ function pics (imgs, o) {
428
+ if (o.picture) return o.picture(imgs)
429
+ return `<div class="dc-pics">${imgs.map((i) => /^https?:\/\//.test(i.src) || (!i.ref && /^[A-Za-z0-9._/-]+$/.test(i.src)) ? `<img src="${esc(i.src)}" alt="${esc(i.alt)}" loading="lazy">` : `<span class="dc-missing">${esc(i.alt || i.src)}</span>`).join('')}</div>`
430
+ }
431
+
432
+ // ---------- search ----------
433
+
434
+ /** Plain words of a page for search and snippets (no Markdown syntax, no pictures, no comments). */
435
+ export function plainText (md) {
436
+ return frontMatter(md).body.replace(/<!--[\s\S]*?-->/g, ' ').replace(/```[\s\S]*?```/g, ' ').replace(/!\[[^\]]*\]\([^)]*\)/g, ' ').replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/^#+\s+/gm, '').replace(/^\s*([-*+]|\d+[.)])\s+/gm, '').replace(/[`*_>|]/g, '').replace(/\s+/g, ' ').trim()
437
+ }
438
+
439
+ /** Where `q` occurs in a text: a short snippet around the first hit, or null. Case and accents do not matter. */
440
+ export function snippet (text, q, width = 160) {
441
+ const fold = (s) => s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
442
+ const words = fold(String(q || '')).split(/\s+/).filter((w) => w.length > 1)
443
+ if (!words.length) return null
444
+ const t = String(text || '')
445
+ const f = fold(t)
446
+ if (!words.every((w) => f.includes(w))) return null
447
+ const at = f.indexOf(words[0])
448
+ const start = Math.max(0, at - Math.floor(width / 3))
449
+ return `${start ? '…' : ''}${t.slice(start, start + width).trim()}${start + width < t.length ? '…' : ''}`
450
+ }