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.
@@ -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) => ({ '&': '&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
+ }