kaloko 0.5.2 → 0.6.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.
- package/README.md +75 -0
- package/package.json +1 -1
- package/schemas/changelog.v1.json +83 -0
- package/schemas/run.v1.json +19 -0
- package/schemas/scenario.v1.json +6 -1
- package/skills/kaloko/SKILL.md +28 -7
- package/skills/kaloko/reference/design.md +55 -0
- package/skills/kaloko/reference/docs.md +122 -3
- package/skills/kaloko/reference/integrations.md +67 -0
- package/skills/kaloko/reference/mcp.md +7 -1
- package/src/cli.mjs +213 -10
- package/src/lib/changelog.mjs +74 -8
- package/src/lib/ci.mjs +71 -0
- package/src/lib/design-cli.mjs +22 -2
- package/src/lib/design-notes.mjs +191 -0
- package/src/lib/docs-core.mjs +450 -0
- package/src/lib/docs-export.mjs +271 -0
- package/src/lib/docs.mjs +221 -0
- package/src/lib/figma.mjs +584 -0
- package/src/lib/git.mjs +23 -3
- package/src/lib/integrations-cli.mjs +211 -0
- package/src/lib/screens-cli.mjs +36 -0
- package/src/lib/service.mjs +20 -1
- package/viewer/index.html +266 -30
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// "What changed" notes of a design version (batch G, #3): a short plain-language changelog per changed step against
|
|
2
|
+
// the previous version, built from what the service already knows (revision ids, screenshot hashes per viewport,
|
|
3
|
+
// the text the step spec captured from the DOM, the token set of each revision). Pure and dependency-free apart from
|
|
4
|
+
// the token module, so the CLI (preview, --notes) and the Worker (publishing) share it.
|
|
5
|
+
import { flattenTokens } from './tokens.mjs'
|
|
6
|
+
|
|
7
|
+
export const NOTES_SCHEMA = 'qawalk.version-notes.v1'
|
|
8
|
+
export const NOTE_LIMITS = { summary: 2000, step: 1000, texts: 6, tokens: 40 }
|
|
9
|
+
|
|
10
|
+
/** Text in the reader's language: an edited note is one string for everyone, a generated one has en and cs. */
|
|
11
|
+
export const noteText = (t, lang = 'en') => (t == null ? '' : typeof t === 'string' ? t : t[lang] || t.en || '')
|
|
12
|
+
|
|
13
|
+
const clean = (s) => String(s ?? '').replace(/\s+/g, ' ').trim()
|
|
14
|
+
const quote = (s, lang) => (lang === 'cs' ? `„${s}“` : `“${s}”`)
|
|
15
|
+
const short = (s, n = 60) => (s.length > n ? s.slice(0, n - 1) + '…' : s)
|
|
16
|
+
const list = (items, lang) => {
|
|
17
|
+
if (items.length < 2) return items.join('')
|
|
18
|
+
return `${items.slice(0, -1).join(', ')} ${lang === 'cs' ? 'a' : 'and'} ${items[items.length - 1]}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The texts a capture shows: accessible names of the spec's elements (from the DOM), the H1 and the page title.
|
|
23
|
+
* `names` may be the spec's elements or a plain list of names (the Worker extracts them in SQL).
|
|
24
|
+
*/
|
|
25
|
+
export function textsOf ({ names = [], h1 = [], title = null } = {}) {
|
|
26
|
+
const out = new Set()
|
|
27
|
+
for (const n of [...[].concat(h1 || []), ...names.map((x) => (x && typeof x === 'object' ? x.name : x))]) {
|
|
28
|
+
const t = clean(n)
|
|
29
|
+
if (t && t.length > 1 && !/^[\d\s.,:/-]+$/.test(t)) out.add(t)
|
|
30
|
+
}
|
|
31
|
+
if (title && clean(title)) out.add(clean(title))
|
|
32
|
+
return out
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Texts that appeared and disappeared; one of each becomes "from → to" when that reads naturally. */
|
|
36
|
+
export function textChanges (before, after) {
|
|
37
|
+
const removed = [...before].filter((t) => !after.has(t))
|
|
38
|
+
const added = [...after].filter((t) => !before.has(t))
|
|
39
|
+
if (!removed.length && !added.length) return null
|
|
40
|
+
const out = { kind: 'text', added: added.slice(0, NOTE_LIMITS.texts), removed: removed.slice(0, NOTE_LIMITS.texts) }
|
|
41
|
+
if (added.length > NOTE_LIMITS.texts) out.more_added = added.length - NOTE_LIMITS.texts
|
|
42
|
+
if (removed.length > NOTE_LIMITS.texts) out.more_removed = removed.length - NOTE_LIMITS.texts
|
|
43
|
+
return out
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Token changes between two DTCG sets (resolved values): changed, added and removed names. */
|
|
47
|
+
export function tokenChanges (before, after) {
|
|
48
|
+
const flat = (set) => { try { return new Map(set ? flattenTokens(set).map((t) => [t.name, JSON.stringify(t.value)]) : []) } catch { return new Map() } }
|
|
49
|
+
const a = flat(before), b = flat(after)
|
|
50
|
+
const show = (v) => { try { const x = JSON.parse(v); return typeof x === 'string' || typeof x === 'number' ? String(x) : v } catch { return v } }
|
|
51
|
+
const changed = [], added = [], removed = []
|
|
52
|
+
for (const [name, v] of b) { if (!a.has(name)) added.push(name); else if (a.get(name) !== v) changed.push({ name, from: show(a.get(name)), to: show(v) }) }
|
|
53
|
+
for (const name of a.keys()) if (!b.has(name)) removed.push(name)
|
|
54
|
+
if (!changed.length && !added.length && !removed.length) return null
|
|
55
|
+
return { changed: changed.slice(0, NOTE_LIMITS.tokens), added: added.slice(0, NOTE_LIMITS.tokens), removed: removed.slice(0, NOTE_LIMITS.tokens), count: changed.length + added.length + removed.length }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One change as a sentence. */
|
|
59
|
+
function changeText (c, lang) {
|
|
60
|
+
const cs = lang === 'cs'
|
|
61
|
+
switch (c.kind) {
|
|
62
|
+
case 'new': return cs ? 'Nový krok.' : 'New step.'
|
|
63
|
+
case 'removed': return cs ? 'Krok z flow zmizel.' : 'Removed from the flow.'
|
|
64
|
+
case 'locales': return cs ? `Nově i v jazycích ${c.added.join(', ')}.` : `Now also in ${c.added.join(', ')}.`
|
|
65
|
+
case 'text': {
|
|
66
|
+
const parts = []
|
|
67
|
+
const [a] = c.added, [r] = c.removed
|
|
68
|
+
if (c.added.length === 1 && c.removed.length === 1 && !c.more_added && !c.more_removed) parts.push(`${quote(short(r), lang)} → ${quote(short(a), lang)}`)
|
|
69
|
+
else {
|
|
70
|
+
if (c.added.length) parts.push(`${cs ? 'nově' : 'new'} ${c.added.map((t) => quote(short(t, 40), lang)).join(', ')}${c.more_added ? (cs ? ` a ${c.more_added} dalších` : ` and ${c.more_added} more`) : ''}`)
|
|
71
|
+
if (c.removed.length) parts.push(`${cs ? 'zmizelo' : 'gone'} ${c.removed.map((t) => quote(short(t, 40), lang)).join(', ')}${c.more_removed ? (cs ? ` a ${c.more_removed} dalších` : ` and ${c.more_removed} more`) : ''}`)
|
|
72
|
+
}
|
|
73
|
+
return `${cs ? 'Text' : 'Text'}: ${parts.join('; ')}.`
|
|
74
|
+
}
|
|
75
|
+
case 'look': return c.viewports?.length ? (cs ? `Vypadá jinak na ${list(c.viewports, lang)}.` : `Looks different on ${list(c.viewports, lang)}.`) : (cs ? 'Vypadá jinak.' : 'Looks different.')
|
|
76
|
+
case 'same_look': return cs ? 'Vypadá stejně, změnil se jen kód stránky.' : 'Looks the same; only the page code changed.'
|
|
77
|
+
case 'tokens': return cs ? `Používá novou sadu tokenů (${c.count} ${c.count === 1 ? 'změna' : c.count < 5 ? 'změny' : 'změn'}).` : `Uses the new token set (${c.count} change${c.count === 1 ? '' : 's'}).`
|
|
78
|
+
case 'not_rendered': return cs ? 'Zatím bez nového snímku.' : 'Not rendered yet.'
|
|
79
|
+
default: return ''
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const stepText = (changes, lang) => changes.map((c) => changeText(c, lang)).filter(Boolean).join(' ')
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Builds the notes from the facts of each changed step. `steps`: [{ step, title, locales, reason: new|changed|removed,
|
|
87
|
+
* look: [viewports whose screenshot differs] | null (no captures to compare), texts: { before: Set, after: Set } | null,
|
|
88
|
+
* tokens: boolean (the revision's token set changed), files: boolean }], `tokens`: tokenChanges() of the version or
|
|
89
|
+
* null, `unchanged`: count of unchanged step×locale, `from`: the previous version { version, id } or null.
|
|
90
|
+
*/
|
|
91
|
+
export function buildNotes ({ steps = [], tokens = null, unchanged = 0, from = null }) {
|
|
92
|
+
const out = []
|
|
93
|
+
for (const s of steps) {
|
|
94
|
+
const changes = []
|
|
95
|
+
if (s.reason === 'new') changes.push({ kind: 'new' })
|
|
96
|
+
else if (s.reason === 'removed') changes.push({ kind: 'removed' })
|
|
97
|
+
else {
|
|
98
|
+
if (s.new_locales?.length) changes.push({ kind: 'locales', added: s.new_locales })
|
|
99
|
+
if (s.texts) { const t = textChanges(s.texts.before, s.texts.after); if (t) changes.push(t) }
|
|
100
|
+
if (s.look == null) changes.push({ kind: 'not_rendered' })
|
|
101
|
+
else if (s.look.length) changes.push({ kind: 'look', viewports: s.look })
|
|
102
|
+
if (s.tokens && tokens) changes.push({ kind: 'tokens', count: tokens.count })
|
|
103
|
+
if (s.look && !s.look.length && !changes.some((c) => c.kind === 'text' || c.kind === 'tokens' || c.kind === 'locales')) changes.push({ kind: 'same_look' })
|
|
104
|
+
}
|
|
105
|
+
out.push({ step: s.step, title: s.title || s.step, locales: s.locales || [], reason: s.reason, changes, text: { en: stepText(changes, 'en'), cs: stepText(changes, 'cs') } })
|
|
106
|
+
}
|
|
107
|
+
return { schema: NOTES_SCHEMA, from, summary: summaryOf(out, tokens, unchanged), steps: out, tokens, unchanged }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function summaryOf (steps, tokens, unchanged) {
|
|
111
|
+
const mk = (lang) => {
|
|
112
|
+
const cs = lang === 'cs'
|
|
113
|
+
const changed = steps.filter((s) => s.reason !== 'removed')
|
|
114
|
+
const removed = steps.filter((s) => s.reason === 'removed')
|
|
115
|
+
const parts = []
|
|
116
|
+
if (!steps.length) parts.push(cs ? 'Beze změny proti předchozí verzi.' : 'Nothing changed since the previous version.')
|
|
117
|
+
else {
|
|
118
|
+
const names = (xs) => xs.slice(0, 6).map((s) => s.title).join(', ') + (xs.length > 6 ? (cs ? ` a ${xs.length - 6} dalších` : ` and ${xs.length - 6} more`) : '')
|
|
119
|
+
if (changed.length) parts.push(cs ? `Změněno: ${names(changed)}.` : `Changed: ${names(changed)}.`)
|
|
120
|
+
if (removed.length) parts.push(cs ? `Odebráno: ${names(removed)}.` : `Removed: ${names(removed)}.`)
|
|
121
|
+
if (unchanged) parts.push(cs ? `Beze změny: ${unchanged} (počítáno po jazycích).` : `Unchanged: ${unchanged} (counted per locale).`)
|
|
122
|
+
}
|
|
123
|
+
if (tokens?.changed?.length) {
|
|
124
|
+
const shown = tokens.changed.slice(0, 3)
|
|
125
|
+
const t = shown.map((c) => `${c.name} ${c.from} → ${c.to}`).join(', ')
|
|
126
|
+
const more = tokens.count - shown.length
|
|
127
|
+
parts.push(cs ? `Tokeny: ${t}${more ? ` a ${more} ${more === 1 ? 'další' : 'dalších'}` : ''}.` : `Tokens: ${t}${more ? ` and ${more} more` : ''}.`)
|
|
128
|
+
} else if (tokens?.count) parts.push(cs ? `Sada tokenů: ${tokens.count} ${tokens.count === 1 ? 'změna' : tokens.count < 5 ? 'změny' : 'změn'}.` : `Token set: ${tokens.count} change${tokens.count === 1 ? '' : 's'}.`)
|
|
129
|
+
return parts.join(' ')
|
|
130
|
+
}
|
|
131
|
+
return { en: mk('en'), cs: mk('cs') }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Notes someone wrote (an agent with --notes, a person in the canvas): JSON { summary, steps: { id: text } } or text
|
|
136
|
+
* where lines "step-id: text" (optionally as a list item) belong to that step and the rest is the summary.
|
|
137
|
+
*/
|
|
138
|
+
export function parseNotes (input, stepIds = []) {
|
|
139
|
+
if (input == null || input === '') return null
|
|
140
|
+
let obj = input
|
|
141
|
+
if (typeof input === 'string') {
|
|
142
|
+
const s = input.trim()
|
|
143
|
+
if (s.startsWith('{')) { try { obj = JSON.parse(s) } catch { obj = s } } else obj = s
|
|
144
|
+
}
|
|
145
|
+
if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
|
|
146
|
+
const steps = {}
|
|
147
|
+
const src = obj.steps && typeof obj.steps === 'object' ? obj.steps : {}
|
|
148
|
+
for (const [k, v] of Array.isArray(src) ? src.map((x) => [x?.step, x?.text]) : Object.entries(src)) { const t = clean(typeof v === 'string' ? v : v?.text ?? v?.en ?? ''); if (k && t) steps[String(k)] = t.slice(0, NOTE_LIMITS.step) }
|
|
149
|
+
const summary = typeof obj.summary === 'string' ? obj.summary.trim().slice(0, NOTE_LIMITS.summary) : null
|
|
150
|
+
return summary || Object.keys(steps).length ? { summary, steps } : null
|
|
151
|
+
}
|
|
152
|
+
const ids = new Set(stepIds)
|
|
153
|
+
const steps = {}
|
|
154
|
+
const rest = []
|
|
155
|
+
let current = null
|
|
156
|
+
for (const line of String(obj).split(/\r?\n/)) {
|
|
157
|
+
const m = line.match(/^\s*(?:[-*]\s+)?(?:\*\*|`)?([A-Za-z0-9][A-Za-z0-9._-]*)(?:\*\*|`)?\s*:\s+(.*)$/)
|
|
158
|
+
if (m && ids.has(m[1])) { current = m[1]; steps[current] = clean(m[2]); continue }
|
|
159
|
+
if (current && /^\s{2,}\S/.test(line)) { steps[current] = clean(`${steps[current]} ${line}`); continue }
|
|
160
|
+
current = null
|
|
161
|
+
rest.push(line)
|
|
162
|
+
}
|
|
163
|
+
for (const k of Object.keys(steps)) steps[k] = steps[k].slice(0, NOTE_LIMITS.step)
|
|
164
|
+
const summary = rest.join('\n').replace(/^\s*#+\s.*$/gm, '').trim().slice(0, NOTE_LIMITS.summary) || null
|
|
165
|
+
return summary || Object.keys(steps).length ? { summary, steps } : null
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Generated notes with the edits applied: an edited summary or step text replaces the generated one. */
|
|
169
|
+
export function mergeNotes (auto, edited, { by = null, at = null, titles = {} } = {}) {
|
|
170
|
+
const base = auto ? JSON.parse(JSON.stringify(auto)) : { schema: NOTES_SCHEMA, from: null, summary: null, steps: [], tokens: null, unchanged: 0 }
|
|
171
|
+
if (!edited) return base
|
|
172
|
+
if (edited.summary) { base.summary = edited.summary; base.summary_edited = true }
|
|
173
|
+
for (const [id, text] of Object.entries(edited.steps || {})) {
|
|
174
|
+
const s = base.steps.find((x) => x.step === id)
|
|
175
|
+
if (s) { s.text = text; s.edited = true } else base.steps.push({ step: id, title: titles[id] || id, locales: [], reason: 'note', changes: [], text, edited: true })
|
|
176
|
+
}
|
|
177
|
+
if (by) base.edited_by = by
|
|
178
|
+
if (at) base.edited_at = at
|
|
179
|
+
return base
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The notes as plain text for the terminal and MCP. */
|
|
183
|
+
export function notesText (notes, { lang = 'en', title = '' } = {}) {
|
|
184
|
+
if (!notes) return 'No notes.'
|
|
185
|
+
const lines = []
|
|
186
|
+
if (title) lines.push(title)
|
|
187
|
+
const sum = noteText(notes.summary, lang)
|
|
188
|
+
if (sum) lines.push(sum)
|
|
189
|
+
for (const s of notes.steps || []) lines.push(`- ${s.step}${s.title && s.title !== s.step ? ` (${s.title})` : ''}${s.locales?.length ? ` [${s.locales.join(', ')}]` : ''}: ${noteText(s.text, lang)}${s.edited ? '' : ''}`)
|
|
190
|
+
return lines.join('\n')
|
|
191
|
+
}
|
|
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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+"[^&]*")?\)/g, (_, t, href) => {
|
|
296
|
+
const raw = href.replace(/&/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
|
+
}
|