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,271 @@
|
|
|
1
|
+
// Exports of living docs and the visual changelog (docs/DOCS-CHANGELOG.md → Exports): Markdown with the pictures as
|
|
2
|
+
// files, folders for Docusaurus, VitePress and MkDocs (sidebars and front matter), a self-contained HTML folder that
|
|
3
|
+
// opens offline, a structured JSON changelog, and neutral blocks for the PDF writer. Pure: the service builds the zip
|
|
4
|
+
// (worker/src/docs-export.js adds the picture files), the CLI unpacks it. No Node built-ins here.
|
|
5
|
+
import { frontMatter, renderMarkdown, buildNav, navOrder, folderTitle, docLink, escapeHtml as esc, inlineHtml } from './docs-core.mjs'
|
|
6
|
+
import { findStepRefs, parseEntry, GROUPS } from './changelog-core.mjs'
|
|
7
|
+
|
|
8
|
+
export const FORMATS = ['md', 'docusaurus', 'vitepress', 'mkdocs', 'html', 'pdf', 'json']
|
|
9
|
+
export const ZIP_FORMATS = ['md', 'docusaurus', 'vitepress', 'mkdocs', 'html']
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The file name of a resolved picture inside an export: assets/<scenario>--<step>--<locale>--<viewport>--<run>.<ext>.
|
|
13
|
+
* `pic` is a resolver answer ({ scenario, step, locale, viewport, run, file }).
|
|
14
|
+
*/
|
|
15
|
+
export function assetFile (pic) {
|
|
16
|
+
const ext = (String(pic.file || pic.path || '').match(/\.(png|jpe?g|webp|gif)$/i) || [, 'png'])[1].toLowerCase()
|
|
17
|
+
const safe = (s) => String(s || '').replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 60)
|
|
18
|
+
return `assets/${safe(pic.scenario)}--${safe(pic.step?.id || pic.step)}--${safe(pic.locale)}--${safe(pic.viewport)}--${safe(String(pic.run?.id || pic.run || '').split('-').pop())}.${ext}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The key of a picture in a bundle: the reference, in the context of a release for changelog entries (prev). */
|
|
22
|
+
export const pictureKey = (ref, context = null) => `${context || ''}|${ref}`
|
|
23
|
+
|
|
24
|
+
/** The file of a page inside a docs folder: '' → index.md, 'how-to' (with pages under it) → how-to/index.md. */
|
|
25
|
+
export function pageFile (path, { folders = new Set(), ext = '.md' } = {}) {
|
|
26
|
+
if (!path) return `index${ext}`
|
|
27
|
+
return folders.has(path) ? `${path}/index${ext}` : `${path}${ext}`
|
|
28
|
+
}
|
|
29
|
+
const foldersOf = (pages) => new Set(pages.flatMap((p) => p.path.split('/').slice(0, -1).map((_, i, a) => a.slice(0, i + 1).join('/'))))
|
|
30
|
+
|
|
31
|
+
/** The relative way from one file to another inside the export ('docs/how-to/a.md' → 'docs/assets/x.png'). */
|
|
32
|
+
export function relativePath (from, to) {
|
|
33
|
+
const a = from.split('/').slice(0, -1); const b = to.split('/')
|
|
34
|
+
let i = 0
|
|
35
|
+
while (i < a.length && i < b.length - 1 && a[i] === b[i]) i++
|
|
36
|
+
return [...a.slice(i).map(() => '..'), ...b.slice(i)].join('/') || to
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Markdown with its step references replaced by files of the export: `` → ``.
|
|
41
|
+
* A reference without a picture becomes its alt text in italics, so the text still reads. Links to other pages are
|
|
42
|
+
* rewritten by `link(href)` when given.
|
|
43
|
+
*/
|
|
44
|
+
export function localizeMarkdown (md, { file, pictures, context = null, root = 'docs', link = null }) {
|
|
45
|
+
let out = String(md || '').replace(/!\[([^\]]*)\]\((kaloko:[^)\s]+)\)/g, (_, alt, ref) => {
|
|
46
|
+
const p = pictures.get(pictureKey(ref, context))
|
|
47
|
+
if (!p?.ok) return `*${alt || 'picture'}*`
|
|
48
|
+
return `})`
|
|
49
|
+
})
|
|
50
|
+
if (link) out = out.replace(/(?<!!)\[([^\]]+)\]\(([^)\s]+)\)/g, (m, t, href) => { const x = link(href); return x == null ? m : `[${t}](${x})` })
|
|
51
|
+
return out
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Every reference a bundle uses, with its context: [{ ref, context }] (docs: null; changelog: the release). */
|
|
55
|
+
export function bundleRefs (bundle) {
|
|
56
|
+
const out = []; const seen = new Set()
|
|
57
|
+
const add = (ref, context) => { const k = pictureKey(ref, context); if (!seen.has(k)) { seen.add(k); out.push({ ref, context }) } }
|
|
58
|
+
for (const p of bundle.pages || []) for (const r of findStepRefs(p.md)) if (r.parsed) add(r.ref, null)
|
|
59
|
+
for (const r of bundle.releases || []) for (const x of findStepRefs(r.markdown)) if (x.parsed) add(x.ref, r.version)
|
|
60
|
+
return out
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const productTitle = (b) => typeof b.product?.name === 'string' ? b.product.name : b.product?.slug || 'Docs'
|
|
64
|
+
const changelogMarkdown = (b, file, root) => {
|
|
65
|
+
const head = `# ${b.lang === 'cs' ? 'Co je nového' : 'Changelog'}: ${productTitle(b)}\n`
|
|
66
|
+
return `${head}\n${(b.releases || []).map((r) => `## [${r.version}] - ${r.released_on}\n\n${localizeMarkdown(String(r.markdown || '').replace(/<!--[\s\S]*?-->/g, '').trim(), { file, pictures: b.pictureMap, context: r.version, root })}\n`).join('\n')}`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Text files of a Markdown export (the pictures are added by the caller from `bundle.pictureMap`):
|
|
71
|
+
* md docs/<page>.md, docs/assets/…, CHANGELOG.md
|
|
72
|
+
* docusaurus docs/<page>.md with title and sidebar_position, _category_.json per folder, docs/changelog.md,
|
|
73
|
+
* sidebars.js
|
|
74
|
+
* vitepress docs/<page>.md, docs/changelog.md, docs/.vitepress/config.mjs with the sidebar
|
|
75
|
+
* mkdocs docs/<page>.md, docs/changelog.md, mkdocs.yml with the nav
|
|
76
|
+
* Returns [{ name, text }].
|
|
77
|
+
*/
|
|
78
|
+
export function markdownExport (bundle, flavor = 'md') {
|
|
79
|
+
const b = { ...bundle, pictureMap: bundle.pictureMap || new Map() }
|
|
80
|
+
const pages = b.pages || []
|
|
81
|
+
const folders = foldersOf(pages)
|
|
82
|
+
const nav = buildNav(pages.map((p) => ({ path: p.path, title: p.title, order: p.order ?? null })))
|
|
83
|
+
const fileOf = (path) => `docs/${pageFile(path, { folders })}`
|
|
84
|
+
const files = []
|
|
85
|
+
pages.forEach((p) => {
|
|
86
|
+
const file = fileOf(p.path)
|
|
87
|
+
const link = (href) => { const t = docLink(p.path, href, { isIndex: folders.has(p.path) || p.path === '' }); return t && pages.some((x) => x.path === t.path) ? `${relativePath(file, fileOf(t.path))}${t.hash ? `#${t.hash}` : ''}` : null }
|
|
88
|
+
const { data, body } = frontMatter(p.md)
|
|
89
|
+
let text = localizeMarkdown(body, { file, pictures: b.pictureMap, link })
|
|
90
|
+
const pos = navOrder(nav).indexOf(p.path) + 1
|
|
91
|
+
if (flavor === 'docusaurus') text = `---\ntitle: ${yamlString(p.title)}\nsidebar_position: ${data.sidebar_position ?? data.order ?? pos}\n${p.path === '' ? 'slug: /\n' : ''}---\n\n${text.trim()}\n`
|
|
92
|
+
else if (flavor === 'vitepress' || flavor === 'mkdocs') text = `---\ntitle: ${yamlString(p.title)}\n---\n\n${text.trim()}\n`
|
|
93
|
+
else text = `${Object.keys(data).length ? `---\n${Object.entries(data).map(([k, v]) => `${k}: ${Array.isArray(v) ? `[${v.join(', ')}]` : yamlString(v)}`).join('\n')}\n---\n\n` : ''}${text.trim()}\n`
|
|
94
|
+
files.push({ name: file, text })
|
|
95
|
+
})
|
|
96
|
+
const hasChangelog = (b.releases || []).length > 0
|
|
97
|
+
if (hasChangelog) {
|
|
98
|
+
const file = flavor === 'md' ? 'CHANGELOG.md' : 'docs/changelog.md'
|
|
99
|
+
let text = changelogMarkdown(b, file, 'docs')
|
|
100
|
+
if (flavor === 'docusaurus') text = `---\ntitle: ${yamlString(b.lang === 'cs' ? 'Co je nového' : 'Changelog')}\nsidebar_position: 1000\n---\n\n${text}`
|
|
101
|
+
files.push({ name: file, text })
|
|
102
|
+
}
|
|
103
|
+
const label = (n) => n.title || folderTitle(n.path)
|
|
104
|
+
if (flavor === 'docusaurus') {
|
|
105
|
+
nav.forEach((n, i) => { if (n.children.length) files.push({ name: `docs/${n.path}/_category_.json`, text: `${JSON.stringify({ label: label(n), position: i + 1 }, null, 2)}\n` }) })
|
|
106
|
+
const id = (path) => pageFile(path, { folders, ext: '' }).replace(/\/index$/, '/index')
|
|
107
|
+
const items = nav.map((n) => n.children.length ? { type: 'category', label: label(n), items: [...(n.page ? [id(n.path)] : []), ...n.children.map((c) => id(c.path))] } : id(n.path))
|
|
108
|
+
if (hasChangelog) items.push('changelog')
|
|
109
|
+
files.push({ name: 'sidebars.js', text: `// Written by kaloko export --format docusaurus\nmodule.exports = {\n docs: ${JSON.stringify(items, null, 2).replace(/\n/g, '\n ')}\n}\n` })
|
|
110
|
+
}
|
|
111
|
+
if (flavor === 'vitepress') {
|
|
112
|
+
const link = (path) => `/${pageFile(path, { folders, ext: '' }).replace(/(^|\/)index$/, '$1')}`
|
|
113
|
+
const sidebar = nav.map((n) => n.children.length ? { text: label(n), items: [...(n.page ? [{ text: label(n), link: link(n.path) }] : []), ...n.children.map((c) => ({ text: c.title, link: link(c.path) }))] } : { text: label(n), link: link(n.path) })
|
|
114
|
+
if (hasChangelog) sidebar.push({ text: b.lang === 'cs' ? 'Co je nového' : 'Changelog', link: '/changelog' })
|
|
115
|
+
files.push({ name: 'docs/.vitepress/config.mjs', text: `// Written by kaloko export --format vitepress\nexport default {\n title: ${JSON.stringify(productTitle(b))},\n lang: ${JSON.stringify(b.lang || 'en')},\n themeConfig: {\n sidebar: ${JSON.stringify(sidebar, null, 2).replace(/\n/g, '\n ')}\n }\n}\n` })
|
|
116
|
+
}
|
|
117
|
+
if (flavor === 'mkdocs') {
|
|
118
|
+
const entry = (path, title) => `${yamlString(title)}: ${pageFile(path, { folders })}`
|
|
119
|
+
const lines = ['# Written by kaloko export --format mkdocs', `site_name: ${yamlString(productTitle(b))}`, 'docs_dir: docs', 'nav:']
|
|
120
|
+
for (const n of nav) {
|
|
121
|
+
if (!n.children.length) { lines.push(` - ${entry(n.path, label(n))}`); continue }
|
|
122
|
+
lines.push(` - ${yamlString(label(n))}:`)
|
|
123
|
+
if (n.page) lines.push(` - ${entry(n.path, label(n))}`)
|
|
124
|
+
for (const c of n.children) lines.push(` - ${entry(c.path, c.title)}`)
|
|
125
|
+
}
|
|
126
|
+
if (hasChangelog) lines.push(` - ${yamlString(b.lang === 'cs' ? 'Co je nového' : 'Changelog')}: changelog.md`)
|
|
127
|
+
files.push({ name: 'mkdocs.yml', text: `${lines.join('\n')}\n` })
|
|
128
|
+
}
|
|
129
|
+
return files
|
|
130
|
+
}
|
|
131
|
+
const yamlString = (v) => typeof v === 'number' || typeof v === 'boolean' ? String(v) : /^[A-Za-z0-9 ._/-]+$/.test(String(v)) && !/^[-\d]/.test(String(v)) && !/^(true|false|null|yes|no)$/i.test(String(v)) ? String(v) : JSON.stringify(String(v))
|
|
132
|
+
|
|
133
|
+
// ---------- HTML that opens offline ----------
|
|
134
|
+
|
|
135
|
+
const HTML_CSS = `:root{--ink:#151514;--muted:#6a6a64;--line:#e4e2dc;--panel:#fff;--bg:#faf9f6;--accent:#ffe452}
|
|
136
|
+
@media (prefers-color-scheme:dark){:root{--ink:#f1f0ec;--muted:#a3a29b;--line:#34332f;--panel:#1d1d1b;--bg:#141413}}
|
|
137
|
+
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.6 -apple-system,"Segoe UI",Roboto,Arial,sans-serif}
|
|
138
|
+
a{color:inherit}.wrap{display:grid;grid-template-columns:240px minmax(0,1fr);gap:32px;max-width:1120px;margin:0 auto;padding:24px 20px 64px}
|
|
139
|
+
nav.side{font-size:14px;position:sticky;top:16px;align-self:start;max-height:calc(100vh - 32px);overflow:auto}nav.side ul{list-style:none;margin:0 0 8px;padding:0}nav.side ul ul{padding-left:12px}nav.side li{margin:2px 0}
|
|
140
|
+
nav.side a{text-decoration:none;display:block;padding:3px 8px;border-radius:6px}nav.side a[aria-current]{background:var(--accent);color:#151514;font-weight:600}nav.side .grp{font-weight:700;margin-top:10px;padding:3px 8px}
|
|
141
|
+
main{min-width:0;max-width:760px}h1{font-size:30px;line-height:1.2;letter-spacing:-.02em}h2{margin-top:2em}img{max-width:100%;height:auto}
|
|
142
|
+
figure{margin:12px 0}figure img{display:block;border:1px solid var(--line);border-radius:10px}figcaption{font-size:13px;color:var(--muted);margin-top:4px}
|
|
143
|
+
pre{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px;overflow:auto}code{font-family:ui-monospace,Menlo,monospace;font-size:.92em}
|
|
144
|
+
table{border-collapse:collapse}td,th{border:1px solid var(--line);padding:4px 8px}.pager{display:flex;justify-content:space-between;gap:12px;margin-top:40px;font-size:14px}.foot{color:var(--muted);font-size:12.5px;margin-top:40px}
|
|
145
|
+
.release{border-top:1px solid var(--line);margin-top:28px;padding-top:8px}.missing{color:var(--muted);font-style:italic}
|
|
146
|
+
@media (max-width:760px){.wrap{grid-template-columns:1fr;gap:12px}nav.side{position:static;max-height:none;border-bottom:1px solid var(--line);padding-bottom:8px}}
|
|
147
|
+
@media print{nav.side,.pager{display:none}.wrap{display:block;max-width:none;padding:0}main{max-width:none}section.page{break-after:page}figure{break-inside:avoid}}`
|
|
148
|
+
|
|
149
|
+
const doc = ({ lang, title, body }) => `<!doctype html><html lang="${esc(lang || 'en')}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${esc(title)}</title><style>${HTML_CSS}</style></head><body>${body}</body></html>\n`
|
|
150
|
+
|
|
151
|
+
function htmlPictures (imgs, { file, pictures, context }) {
|
|
152
|
+
return imgs.map((i) => {
|
|
153
|
+
const p = i.ref ? pictures.get(pictureKey(i.src, context)) : null
|
|
154
|
+
if (!i.ref) return /^https?:\/\//.test(i.src) ? `<p><a href="${esc(i.src)}" rel="noopener">${esc(i.alt || i.src)}</a></p>` : ''
|
|
155
|
+
if (!p?.ok) return `<p class="missing">${esc(i.alt || i.src)}</p>`
|
|
156
|
+
return `<figure><img src="${esc(relativePath(file, p.export_file))}" alt="${esc(i.alt || p.alt || '')}" loading="lazy"><figcaption>${esc(i.alt || p.step?.title || '')} · ${esc(p.locale)} · ${esc(p.viewport)}</figcaption></figure>`
|
|
157
|
+
}).join('')
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A folder that opens from disk: index.html, one HTML file per page with the sidebar, changelog.html and print.html
|
|
162
|
+
* (every page in one document, for printing to PDF). Pictures are referenced relatively (assets/…).
|
|
163
|
+
*/
|
|
164
|
+
export function htmlExport (bundle) {
|
|
165
|
+
const b = { ...bundle, pictureMap: bundle.pictureMap || new Map() }
|
|
166
|
+
const pages = b.pages || []
|
|
167
|
+
const folders = foldersOf(pages)
|
|
168
|
+
const nav = buildNav(pages.map((p) => ({ path: p.path, title: p.title, order: p.order ?? null })))
|
|
169
|
+
const order = navOrder(nav)
|
|
170
|
+
const fileOf = (path) => pageFile(path, { folders, ext: '.html' })
|
|
171
|
+
const hasChangelog = (b.releases || []).length > 0
|
|
172
|
+
const cl = b.lang === 'cs' ? 'Co je nového' : 'Changelog'
|
|
173
|
+
const sidebar = (file, current) => `<nav class="side" aria-label="Docs"><ul>${nav.map((n) => {
|
|
174
|
+
const a = (path, title) => `<a href="${esc(relativePath(file, fileOf(path)))}"${path === current ? ' aria-current="page"' : ''}>${esc(title)}</a>`
|
|
175
|
+
if (!n.children.length) return `<li>${a(n.path, n.title || folderTitle(n.path))}</li>`
|
|
176
|
+
return `<li><div class="grp">${n.page ? a(n.path, n.title || folderTitle(n.path)) : esc(n.title || folderTitle(n.path))}</div><ul>${n.children.map((c) => `<li>${a(c.path, c.title)}</li>`).join('')}</ul></li>`
|
|
177
|
+
}).join('')}${hasChangelog ? `<li><a href="${esc(relativePath(file, 'changelog.html'))}"${current === '#changelog' ? ' aria-current="page"' : ''}>${esc(cl)}</a></li>` : ''}</ul></nav>`
|
|
178
|
+
const foot = `<p class="foot">${esc(productTitle(b))}${b.version ? ` · ${esc(b.version)}` : ''}${b.generated_at ? ` · ${esc(String(b.generated_at).slice(0, 10))}` : ''}</p>`
|
|
179
|
+
const pageBody = (p, file) => {
|
|
180
|
+
const { body } = frontMatter(p.md)
|
|
181
|
+
const link = (href) => { const t = docLink(p.path, href, { isIndex: folders.has(p.path) || p.path === '' }); return t && pages.some((x) => x.path === t.path) ? `${relativePath(file, fileOf(t.path))}${t.hash ? `#${t.hash}` : ''}` : href }
|
|
182
|
+
return renderMarkdown(body, { link, picture: (imgs) => htmlPictures(imgs, { file, pictures: b.pictureMap, context: null }) })
|
|
183
|
+
}
|
|
184
|
+
const files = []
|
|
185
|
+
for (const p of pages) {
|
|
186
|
+
const file = fileOf(p.path)
|
|
187
|
+
const i = order.indexOf(p.path)
|
|
188
|
+
const prev = i > 0 ? pages.find((x) => x.path === order[i - 1]) : null
|
|
189
|
+
const next = i >= 0 && i < order.length - 1 ? pages.find((x) => x.path === order[i + 1]) : null
|
|
190
|
+
const pager = `<div class="pager"><span>${prev ? `← <a href="${esc(relativePath(file, fileOf(prev.path)))}">${esc(prev.title)}</a>` : ''}</span><span>${next ? `<a href="${esc(relativePath(file, fileOf(next.path)))}">${esc(next.title)}</a> →` : ''}</span></div>`
|
|
191
|
+
files.push({ name: file, text: doc({ lang: b.lang, title: `${p.title} · ${productTitle(b)}`, body: `<div class="wrap">${sidebar(file, p.path)}<main>${pageBody(p, file)}${pager}${foot}</main></div>` }) })
|
|
192
|
+
}
|
|
193
|
+
if (!pages.some((p) => p.path === '')) {
|
|
194
|
+
const list = nav.map((n) => `<li><a href="${esc(fileOf(n.page ? n.path : n.children[0]?.path))}">${esc(n.title || folderTitle(n.path))}</a></li>`).join('')
|
|
195
|
+
files.push({ name: 'index.html', text: doc({ lang: b.lang, title: productTitle(b), body: `<div class="wrap">${sidebar('index.html', '')}<main><h1>${esc(productTitle(b))}</h1><ul>${list}</ul>${hasChangelog ? `<p><a href="changelog.html">${esc(cl)}</a></p>` : ''}${foot}</main></div>` }) })
|
|
196
|
+
}
|
|
197
|
+
const releasesHtml = (file) => (b.releases || []).map((r) => `<section class="release" id="v${esc(r.version)}"><h2>${esc(r.version)} <small>${esc(r.released_on || '')}</small></h2>${renderMarkdown(String(r.markdown || ''), { picture: (imgs) => htmlPictures(imgs, { file, pictures: b.pictureMap, context: r.version }) })}</section>`).join('')
|
|
198
|
+
if (hasChangelog) files.push({ name: 'changelog.html', text: doc({ lang: b.lang, title: `${cl} · ${productTitle(b)}`, body: `<div class="wrap">${sidebar('changelog.html', '#changelog')}<main><h1>${esc(cl)}</h1>${releasesHtml('changelog.html')}${foot}</main></div>` }) })
|
|
199
|
+
const all = order.map((path) => pages.find((p) => p.path === path)).filter(Boolean)
|
|
200
|
+
files.push({ name: 'print.html', text: doc({ lang: b.lang, title: productTitle(b), body: `<div class="wrap"><main>${all.map((p) => `<section class="page">${pageBody(p, 'print.html')}</section>`).join('')}${hasChangelog ? `<section class="page"><h1>${esc(cl)}</h1>${releasesHtml('print.html')}</section>` : ''}${foot}</main></div>` }) })
|
|
201
|
+
return files
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ---------- the structured changelog ----------
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The changelog as data (schema qawalk.changelog.v1): releases newest first with groups and items, each item with
|
|
208
|
+
* its pictures (step, locale, viewport, run, image link for signed-in readers, the file in an export when given).
|
|
209
|
+
*/
|
|
210
|
+
export function changelogJson (bundle) {
|
|
211
|
+
const b = { ...bundle, pictureMap: bundle.pictureMap || new Map() }
|
|
212
|
+
const pic = (ref, context) => {
|
|
213
|
+
const p = b.pictureMap.get(pictureKey(ref, context))
|
|
214
|
+
if (!p?.ok) return { ref, ok: false }
|
|
215
|
+
return { ref, ok: true, scenario: p.scenario, step: p.step?.id || p.step, title: p.step?.title || null, locale: p.locale, viewport: p.viewport, run: p.run?.id || null, version: p.version?.resolved || null, image: p.image?.url || null, canvas: p.canvas?.step || null }
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
schema: 'qawalk.changelog.v1',
|
|
219
|
+
product: { slug: b.product?.slug, name: b.product?.name ?? null },
|
|
220
|
+
generated_at: b.generated_at || null,
|
|
221
|
+
releases: (b.releases || []).map((r) => {
|
|
222
|
+
const e = parseEntry(r.markdown || '')
|
|
223
|
+
return {
|
|
224
|
+
version: r.version, released_on: r.released_on || null, tag: r.tag || null, notes: e.notes || '',
|
|
225
|
+
groups: e.groups.map((g) => ({ name: g.name, heading: g.heading, items: g.items.map((it) => ({ text: it.replace(/!\[[^\]]*\]\([^)]*\)/g, '').replace(/<!--[\s\S]*?-->/g, '').replace(/\s+/g, ' ').trim(), pictures: findStepRefs(it).filter((x) => x.parsed).map((x) => pic(x.ref, r.version)) })) }))
|
|
226
|
+
}
|
|
227
|
+
}),
|
|
228
|
+
docs: (b.pages || []).map((p) => ({ path: p.path, lang: p.lang || null, title: p.title, kind: p.kind || null }))
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ---------- blocks for the PDF writer ----------
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* A page body as neutral blocks the service turns into PDF blocks (worker/src/pdf.js): { type: 'h1'|'h2'|'h3'|'p'|
|
|
236
|
+
* 'li'|'code'|'pics', text, refs }. Inline Markdown is reduced to plain text.
|
|
237
|
+
*/
|
|
238
|
+
export function markdownBlocks (md) {
|
|
239
|
+
const plain = (s) => inlineHtml(s).replace(/<[^>]+>/g, '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'")
|
|
240
|
+
const out = []
|
|
241
|
+
const lines = frontMatter(md).body.replace(/<!--[\s\S]*?-->/g, '').split('\n')
|
|
242
|
+
let para = []
|
|
243
|
+
const flush = () => {
|
|
244
|
+
if (!para.length) return
|
|
245
|
+
const t = para.join(' ')
|
|
246
|
+
const refs = findStepRefs(t).filter((r) => r.parsed)
|
|
247
|
+
const words = plain(t.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ').replace(/\s+/g, ' ').trim())
|
|
248
|
+
if (words) out.push({ type: para.li ? 'li' : 'p', text: para.li ? `${para.li} ${words}` : words })
|
|
249
|
+
if (refs.length) out.push({ type: 'pics', refs: refs.map((r) => ({ ref: r.ref, alt: r.alt })) })
|
|
250
|
+
para = []
|
|
251
|
+
}
|
|
252
|
+
for (let i = 0; i < lines.length; i++) {
|
|
253
|
+
const l = lines[i]
|
|
254
|
+
if (/^\s{0,3}(```|~~~)/.test(l)) {
|
|
255
|
+
flush(); const buf = []; i++
|
|
256
|
+
while (i < lines.length && !/^\s{0,3}(```|~~~)/.test(lines[i])) buf.push(lines[i++])
|
|
257
|
+
out.push({ type: 'code', text: buf.join('\n') })
|
|
258
|
+
continue
|
|
259
|
+
}
|
|
260
|
+
const h = l.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/)
|
|
261
|
+
if (h) { flush(); out.push({ type: h[1].length === 1 ? 'h1' : h[1].length === 2 ? 'h2' : 'h3', text: plain(h[2]) }); continue }
|
|
262
|
+
const li = l.match(/^\s*([-*+]|\d+[.)])\s+(.*)$/)
|
|
263
|
+
if (li) { flush(); para = [li[2]]; para.li = /\d/.test(li[1]) ? li[1] : '•'; continue }
|
|
264
|
+
if (!l.trim()) { flush(); continue }
|
|
265
|
+
para.push(l.trim())
|
|
266
|
+
}
|
|
267
|
+
flush()
|
|
268
|
+
return out
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export const GROUP_ORDER = GROUPS
|
package/src/lib/docs.mjs
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// `kaloko docs publish|check` and `kaloko export --format …` (docs/DOCS-CHANGELOG.md, phases 2 and 3): the Markdown
|
|
2
|
+
// of a product's docs folder (Diátaxis folders, sibling translations guide.cs.md) sent to the service per version, the
|
|
3
|
+
// checks an agent runs before a pull request (references, stale sections, translations, changelog pictures), and
|
|
4
|
+
// exports the service builds (worker/src/docs-export.js) unpacked into a folder. Shared parsing: docs-core.mjs.
|
|
5
|
+
import { readFile, readdir, writeFile, mkdir, mkdtemp, rm } from 'node:fs/promises'
|
|
6
|
+
import { execFile } from 'node:child_process'
|
|
7
|
+
import { promisify } from 'node:util'
|
|
8
|
+
import { join, resolve, relative, dirname } from 'node:path'
|
|
9
|
+
import { tmpdir } from 'node:os'
|
|
10
|
+
import { UserError, sha256 } from './util.mjs'
|
|
11
|
+
import { findStepRefs, cleanVersion } from './changelog-core.mjs'
|
|
12
|
+
import { docFile, frontMatter, docTitle, kindOf, translationReport, itemsWithoutPictures, sections, MAX_PAGE, LANG } from './docs-core.mjs'
|
|
13
|
+
import { FORMATS, ZIP_FORMATS } from './docs-export.mjs'
|
|
14
|
+
import { changelogFile, productPayload, detectVersionSource } from './changelog.mjs'
|
|
15
|
+
import { readZip } from './unzip.mjs'
|
|
16
|
+
|
|
17
|
+
const run = promisify(execFile)
|
|
18
|
+
const SKIP = new Set(['node_modules', '.git', '.vitepress', '.docusaurus', 'build', 'dist', 'site'])
|
|
19
|
+
|
|
20
|
+
/** The docs folder of a product: products.<p>.docs.dir, else docs.dir, else docs/. */
|
|
21
|
+
export const docsDir = (root, product) => resolve(root, product.docs?.dir || 'docs')
|
|
22
|
+
|
|
23
|
+
/** Languages of a product's docs: docs.languages (the first is the originals'), else en plus the translations found. */
|
|
24
|
+
export function docsLanguages (product, pages = []) {
|
|
25
|
+
const conf = (product.docs?.languages || []).filter((l) => LANG.test(l))
|
|
26
|
+
if (conf.length) return conf
|
|
27
|
+
const found = [...new Set(pages.map((p) => p.lang).filter(Boolean))].sort()
|
|
28
|
+
return ['en', ...found.filter((l) => l !== 'en')]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** When each file of a folder last changed in git ({ relative path: ISO date }), one git call for the folder. */
|
|
32
|
+
async function gitDates (root, dir) {
|
|
33
|
+
try {
|
|
34
|
+
const { stdout } = await run('git', ['log', '--format=@%cI', '--name-only', '--', relative(root, dir) || '.'], { cwd: root, maxBuffer: 50 * 1024 * 1024 })
|
|
35
|
+
const out = {}; let date = null
|
|
36
|
+
for (const line of stdout.split('\n')) {
|
|
37
|
+
if (line.startsWith('@')) { date = line.slice(1); continue }
|
|
38
|
+
if (line.trim() && date && !(line in out)) out[line.trim()] = date
|
|
39
|
+
}
|
|
40
|
+
return out
|
|
41
|
+
} catch { return {} }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The pages of a product's docs folder: [{ file, path, lang, md, hash, front, title, kind, refs, updated }], sorted by
|
|
46
|
+
* path then language. Files the service would not take (other extensions, hidden or _partials) are left out.
|
|
47
|
+
*/
|
|
48
|
+
export async function collectDocs (root, product) {
|
|
49
|
+
const dir = docsDir(root, product)
|
|
50
|
+
const files = []
|
|
51
|
+
const walk = async (d) => {
|
|
52
|
+
let entries = []
|
|
53
|
+
try { entries = await readdir(d, { withFileTypes: true }) } catch { return }
|
|
54
|
+
for (const e of entries) {
|
|
55
|
+
if (e.name.startsWith('.') || SKIP.has(e.name)) continue
|
|
56
|
+
const full = join(d, e.name)
|
|
57
|
+
if (e.isDirectory()) await walk(full); else files.push(full)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
await walk(dir)
|
|
61
|
+
const dates = await gitDates(root, dir)
|
|
62
|
+
const pages = []; const seen = new Map()
|
|
63
|
+
for (const full of files.sort()) {
|
|
64
|
+
const rel = relative(dir, full).split('\\').join('/')
|
|
65
|
+
const f = docFile(rel)
|
|
66
|
+
if (!f) continue
|
|
67
|
+
const key = `${f.path}|${f.lang || ''}`
|
|
68
|
+
if (seen.has(key)) throw new UserError(`${relative(root, full)} and ${seen.get(key)} are the same page (${f.path || 'index'}); keep one (index.md or README.md).`)
|
|
69
|
+
seen.set(key, relative(root, full))
|
|
70
|
+
const md = await readFile(full, 'utf8')
|
|
71
|
+
if (md.length > MAX_PAGE) throw new UserError(`${relative(root, full)} is longer than 200 KB; split it.`)
|
|
72
|
+
const { data } = frontMatter(md)
|
|
73
|
+
pages.push({ file: relative(root, full), path: f.path, lang: f.lang, md, hash: sha256(md), front: data, title: docTitle(md, f.path), kind: kindOf(f.path), refs: findStepRefs(md), updated: dates[relative(root, full)] || null })
|
|
74
|
+
}
|
|
75
|
+
return { dir, pages: pages.sort((a, b) => a.path.localeCompare(b.path) || String(a.lang || '').localeCompare(String(b.lang || ''))) }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------- the check ----------
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* What needs attention in the docs, from the repository alone: broken references (not kaloko:<scenario>/<step>),
|
|
82
|
+
* references to scenarios or steps the scenario files do not have (`scenarios`: Map id → Set of step ids), short
|
|
83
|
+
* references without a default scenario, missing and outdated translations, and user-visible changelog items without a
|
|
84
|
+
* picture. Returns { pages, broken, unknown, translations, changelog_without_pictures }.
|
|
85
|
+
*/
|
|
86
|
+
export async function localCheck ({ root, product, scenarios = null }) {
|
|
87
|
+
const { dir, pages } = await collectDocs(root, product)
|
|
88
|
+
const broken = []; const unknown = []
|
|
89
|
+
for (const p of pages) {
|
|
90
|
+
for (const r of p.refs) {
|
|
91
|
+
if (!r.parsed) { broken.push({ file: p.file, ref: r.ref }); continue }
|
|
92
|
+
const sc = r.parsed.scenario || product.default_scenario
|
|
93
|
+
if (!sc) { unknown.push({ file: p.file, ref: r.ref, why: 'A short reference needs default_scenario on the product.' }); continue }
|
|
94
|
+
if (!scenarios) continue
|
|
95
|
+
if (!scenarios.has(sc)) unknown.push({ file: p.file, ref: r.ref, why: `No scenario ${sc} in the scenario files.` })
|
|
96
|
+
else if (!scenarios.get(sc).has(r.parsed.step)) unknown.push({ file: p.file, ref: r.ref, why: `Scenario ${sc} has no step ${r.parsed.step}.` })
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const languages = docsLanguages(product, pages)
|
|
100
|
+
const translations = translationReport(pages.map((p) => ({ path: p.path, lang: p.lang, hash: p.hash, front: p.front, updated: p.updated })), languages)
|
|
101
|
+
let changelog = []
|
|
102
|
+
try { changelog = itemsWithoutPictures(await readFile(changelogFile(root, product), 'utf8')) } catch {}
|
|
103
|
+
return { dir, pages, languages, broken, unknown, translations, changelog_without_pictures: changelog }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The check with the service's half (stale sections, references without an accepted picture), when a client is given. */
|
|
107
|
+
export async function docsCheck ({ root, config, product, client = null, scenarios = null, version = 'latest' }) {
|
|
108
|
+
const local = await localCheck({ root, product, scenarios })
|
|
109
|
+
let service = null; let serviceError = null
|
|
110
|
+
if (client && config.org) {
|
|
111
|
+
try { service = await client.request('GET', `/orgs/${config.org}/docs/check?${new URLSearchParams({ product: product.slug, version })}`) } catch (e) { serviceError = String(e.message || e).split('\n')[0].slice(0, 200) }
|
|
112
|
+
}
|
|
113
|
+
const errors = local.broken.length + local.unknown.length + (service ? service.stale.length + service.unresolved.length : 0)
|
|
114
|
+
const warnings = local.translations.missing.length + local.translations.outdated.length + local.changelog_without_pictures.length
|
|
115
|
+
return {
|
|
116
|
+
product: product.slug, version, dir: relative(root, local.dir) || '.', pages: local.pages.length, languages: local.languages,
|
|
117
|
+
broken: local.broken, unknown: local.unknown, translations: local.translations, changelog_without_pictures: local.changelog_without_pictures,
|
|
118
|
+
service: service ? { published: service.published, published_at: service.published_at || null, stale: service.stale, unresolved: service.unresolved, stale_checked: service.stale_checked !== false, url: service.url } : null,
|
|
119
|
+
service_error: serviceError, errors, warnings, ok: errors === 0
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The report for people (kaloko docs check without --json). */
|
|
124
|
+
export function formatCheck (r) {
|
|
125
|
+
const lines = [`${r.product}: ${r.pages} page(s) in ${r.dir}${r.languages.length > 1 ? ` (${r.languages.join(', ')})` : ''}`]
|
|
126
|
+
const list = (title, items, fmt) => { if (!items.length) return; lines.push(` ${title}:`); for (const x of items.slice(0, 30)) lines.push(` ${fmt(x)}`); if (items.length > 30) lines.push(` … ${items.length - 30} more`) }
|
|
127
|
+
list('✗ broken step references', r.broken, (x) => `${x.file}: ${x.ref} (use kaloko:<scenario>/<step>)`)
|
|
128
|
+
list('✗ unknown steps', r.unknown, (x) => `${x.file}: ${x.ref} — ${x.why}`)
|
|
129
|
+
if (r.service) {
|
|
130
|
+
list('✗ stale sections (the screen changed since the text was published)', r.service.stale, (x) => `${x.path || 'index'}${x.lang ? ` (${x.lang})` : ''} § ${x.title || 'intro'}: the screen changed${x.refs[0]?.changed_in ? ` in ${x.refs[0].changed_in}` : ''} — check the text (${x.refs.map((y) => y.ref).join(', ')})`)
|
|
131
|
+
list('✗ references without an accepted picture', r.service.unresolved, (x) => `${x.path || 'index'}${x.lang ? ` (${x.lang})` : ''}: ${x.ref}`)
|
|
132
|
+
if (!r.service.published) lines.push(' · nothing published yet: kaloko docs publish')
|
|
133
|
+
else if (!r.service.stale_checked) lines.push(' · stale sections are checked on the Business plan')
|
|
134
|
+
} else lines.push(` · ${r.service_error ? `service: ${r.service_error}` : 'offline: stale sections and pictures need a token'}`)
|
|
135
|
+
list('! missing translations', r.translations.missing, (x) => `${x.path || 'index'} → ${x.lang}`)
|
|
136
|
+
list('! outdated translations (the original changed later)', r.translations.outdated, (x) => `${x.path || 'index'} (${x.lang})${x.why === 'source_hash' ? ': source_hash differs' : ''}`)
|
|
137
|
+
list('! translations without an original', r.translations.orphans, (x) => `${x.path || 'index'} (${x.lang})`)
|
|
138
|
+
list('! user-visible changelog items without a picture', r.changelog_without_pictures, (x) => `${x.section} ${x.group}: ${x.item}`)
|
|
139
|
+
lines.push(r.ok ? (r.warnings ? ` ✓ references fine; ${r.warnings} warning(s)` : ' ✓ all fine') : ` ${r.errors} problem(s) to fix`)
|
|
140
|
+
return lines.join('\n')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------- publishing ----------
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Sends the docs folder to the service for a version ('latest' by default, or a released version): the product is
|
|
147
|
+
* reported first (as kaloko release does), then every page. `reviewed` says the text was checked: stale notes clear.
|
|
148
|
+
*/
|
|
149
|
+
export async function publishDocs ({ root, config, product, client, version = 'latest', reviewed = false, dryRun = false, commit = null }) {
|
|
150
|
+
const { dir, pages } = await collectDocs(root, product)
|
|
151
|
+
if (!pages.length) throw new UserError(`No Markdown pages in ${relative(root, dir) || dir}. kaloko init --docs=full creates the folders, or set docs.dir in the config.`)
|
|
152
|
+
const v = !version || version === 'latest' ? 'latest' : cleanVersion(version)
|
|
153
|
+
const body = { product: product.slug, version: v, languages: docsLanguages(product, pages), reviewed: !!reviewed, ...(commit ? { commit } : {}), pages: pages.map((p) => ({ path: p.path, lang: p.lang, markdown: p.md })) }
|
|
154
|
+
const broken = pages.flatMap((p) => p.refs.filter((r) => !r.parsed).map((r) => `${p.file}: ${r.ref}`))
|
|
155
|
+
if (dryRun) return { dry_run: true, dir: relative(root, dir), version: v, pages: pages.length, languages: body.languages, broken, bytes: JSON.stringify(body).length }
|
|
156
|
+
const src = await detectVersionSource(root, product)
|
|
157
|
+
const saved = await client.request('POST', `/orgs/${config.org}/products`, await productPayload(root, product, src))
|
|
158
|
+
const res = await client.request('POST', `/orgs/${config.org}/docs`, body)
|
|
159
|
+
return { ...res, dir: relative(root, dir), notes: saved.notes || [], broken_local: broken }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------- exports ----------
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Downloads an export from the service and writes it: a folder for md, docusaurus, vitepress, mkdocs and html (the
|
|
166
|
+
* zip unpacked into `out`), a file for json and pdf. `local` sends the repository's pages instead of the published
|
|
167
|
+
* ones. A PDF is printed from the offline HTML with the browser when one is installed (real pictures), else the
|
|
168
|
+
* service's PDF is taken.
|
|
169
|
+
*/
|
|
170
|
+
export async function exportDocs ({ root, config, product, client, format, version = 'latest', lang = null, what = 'all', out = null, local = false, printPdf = null, say = () => {} }) {
|
|
171
|
+
if (!FORMATS.includes(format)) throw new UserError(`--format must be one of ${FORMATS.join(', ')}.`)
|
|
172
|
+
const q = (f) => `/orgs/${config.org}/docs/export?${new URLSearchParams({ product: product.slug, format: f, ...(version && version !== 'latest' ? { version } : {}), ...(lang ? { lang } : {}), what })}`
|
|
173
|
+
const body = local ? { pages: (await collectDocs(root, product)).pages.map((p) => ({ path: p.path, lang: p.lang, markdown: p.md })) } : null
|
|
174
|
+
const fetchBytes = async (f) => {
|
|
175
|
+
const res = body ? await client.raw('POST', q(f), body) : await client.fetch(q(f))
|
|
176
|
+
return Buffer.from(await res.arrayBuffer())
|
|
177
|
+
}
|
|
178
|
+
const name = `${product.slug}-${version && version !== 'latest' ? version : 'latest'}${lang ? `-${lang}` : ''}`
|
|
179
|
+
if (format === 'json' || (format === 'pdf' && !printPdf)) {
|
|
180
|
+
const file = resolve(out || join(root, config.output || 'tmp/kaloko', 'exports', `${name}.${format}`))
|
|
181
|
+
await mkdir(dirname(file), { recursive: true })
|
|
182
|
+
await writeFile(file, await fetchBytes(format))
|
|
183
|
+
return { format, file, kind: 'file' }
|
|
184
|
+
}
|
|
185
|
+
if (format === 'pdf') {
|
|
186
|
+
// the offline HTML printed by the browser: the pictures as they are (the service's PDF only takes PNG and JPEG)
|
|
187
|
+
const tmp = await mkdtemp(join(tmpdir(), 'kaloko-pdf-'))
|
|
188
|
+
try {
|
|
189
|
+
await unpack(await fetchBytes('html'), tmp)
|
|
190
|
+
const file = resolve(out || join(root, config.output || 'tmp/kaloko', 'exports', `${name}.pdf`))
|
|
191
|
+
await mkdir(dirname(file), { recursive: true })
|
|
192
|
+
try { await printPdf(join(tmp, 'print.html'), file) } catch (e) {
|
|
193
|
+
say(` (printing with the browser failed: ${String(e.message || e).split('\n')[0]}; taking the service's PDF)`)
|
|
194
|
+
await writeFile(file, await fetchBytes('pdf'))
|
|
195
|
+
}
|
|
196
|
+
return { format, file, kind: 'file' }
|
|
197
|
+
} finally { await rm(tmp, { recursive: true, force: true }) }
|
|
198
|
+
}
|
|
199
|
+
const dir = resolve(out || join(root, config.output || 'tmp/kaloko', 'exports', `${name}-${format}`))
|
|
200
|
+
const files = await unpack(await fetchBytes(format), dir)
|
|
201
|
+
let manifest = null
|
|
202
|
+
try { manifest = JSON.parse(await readFile(join(dir, 'kaloko-export.json'), 'utf8')) } catch {}
|
|
203
|
+
return { format, dir, files, kind: 'folder', manifest }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Writes the entries of a zip into a folder (paths that would leave it are refused). Returns the number of files. */
|
|
207
|
+
export async function unpack (buf, dir) {
|
|
208
|
+
const entries = readZip(buf)
|
|
209
|
+
let n = 0
|
|
210
|
+
for (const [name, read] of entries) {
|
|
211
|
+
if (name.endsWith('/')) continue
|
|
212
|
+
const target = resolve(dir, name)
|
|
213
|
+
if (!target.startsWith(resolve(dir) + '/')) throw new UserError(`The export holds a path outside its folder: ${name}`)
|
|
214
|
+
await mkdir(dirname(target), { recursive: true })
|
|
215
|
+
await writeFile(target, read())
|
|
216
|
+
n++
|
|
217
|
+
}
|
|
218
|
+
return n
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export { ZIP_FORMATS, sections }
|