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.
- package/README.md +97 -6
- 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 +35 -13
- package/skills/kaloko/reference/design.md +55 -0
- package/skills/kaloko/reference/docs.md +130 -4
- package/skills/kaloko/reference/integrations.md +67 -0
- package/skills/kaloko/reference/mcp.md +8 -1
- package/src/cli.mjs +241 -11
- package/src/lib/changelog.mjs +144 -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/keyboard.mjs +42 -12
- package/src/lib/onboarding.mjs +3 -1
- package/src/lib/screens-cli.mjs +36 -0
- package/src/lib/service.mjs +20 -1
- package/viewer/index.html +580 -90
|
@@ -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
|
+
}
|