@zebradil/docs-kit 0.0.0-stage → 0.11.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/LICENSE +21 -0
- package/README.md +147 -2
- package/package.json +42 -4
- package/packages/docs-kit/Banner.astro +25 -0
- package/packages/docs-kit/Cascade.astro +205 -0
- package/packages/docs-kit/Diff.astro +134 -0
- package/packages/docs-kit/Landing.astro +372 -0
- package/packages/docs-kit/SiteTitle.astro +45 -0
- package/packages/docs-kit/Tree.astro +139 -0
- package/packages/docs-kit/content.mjs +5 -0
- package/packages/docs-kit/index.mjs +94 -0
- package/packages/docs-kit/kit.test.mjs +74 -0
- package/packages/docs-kit/layout.css +5 -0
- package/packages/docs-kit/llms.txt.mjs +31 -0
- package/packages/docs-kit/page.md.mjs +42 -0
- package/packages/docs-kit/text.mjs +6 -0
- package/packages/docs-kit/themes/default.css +12 -0
- package/packages/docs-kit/themes/mono.css +30 -0
- package/packages/docs-kit/themes/porridge.css +42 -0
- package/packages/docs-kit/themes/sieve.css +49 -0
- package/packages/docs-kit/themes/vortex.css +45 -0
- package/packages/docs-kit/versions.mjs +103 -0
- package/refgen/help2md.mjs +85 -0
- package/schema/site.schema.json +563 -0
- package/schema/validate.mjs +63 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtempSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { test } from 'node:test';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import docsKit from './index.mjs';
|
|
8
|
+
import { minorVersions, selectVersions } from './versions.mjs';
|
|
9
|
+
|
|
10
|
+
const path = (p) => fileURLToPath(new URL(p, import.meta.url));
|
|
11
|
+
|
|
12
|
+
test('schema theme enum matches the shipped theme files', () => {
|
|
13
|
+
const schema = JSON.parse(readFileSync(path('../../schema/site.schema.json'), 'utf8'));
|
|
14
|
+
const themes = readdirSync(path('themes')).map((f) => f.replace(/\.css$/, ''));
|
|
15
|
+
assert.deepEqual(themes.sort(), schema.properties.theme.enum.sort());
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('serves the site at <owner>.github.io/<repo>', () => {
|
|
19
|
+
const config = docsKit({ site: path('../../examples/site.yaml') });
|
|
20
|
+
assert.equal(config.site, 'https://zebradil.github.io');
|
|
21
|
+
assert.equal(config.base, '/kasha');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('serves the site at the root of a custom domain', () => {
|
|
25
|
+
const dir = mkdtempSync(join(tmpdir(), 'docs-kit-'));
|
|
26
|
+
const site = join(dir, 'site.yaml');
|
|
27
|
+
writeFileSync(site, `${readFileSync(path('../../examples/site.yaml'), 'utf8')}\ndomain: kasha.dev\n`);
|
|
28
|
+
const config = docsKit({ site });
|
|
29
|
+
assert.equal(config.site, 'https://kasha.dev');
|
|
30
|
+
assert.equal(config.base, '/');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('accepts analytics with data-* attributes only', () => {
|
|
34
|
+
const dir = mkdtempSync(join(tmpdir(), 'docs-kit-'));
|
|
35
|
+
const site = join(dir, 'site.yaml');
|
|
36
|
+
const withAttribute = (attr) =>
|
|
37
|
+
`${readFileSync(path('../../examples/site.yaml'), 'utf8')}\nanalytics:\n script: https://stats.example.dev/app.js\n attributes:\n ${attr}\n`;
|
|
38
|
+
writeFileSync(site, withAttribute('data-website-id: abc'));
|
|
39
|
+
assert.doesNotThrow(() => docsKit({ site }));
|
|
40
|
+
writeFileSync(site, withAttribute('onload: alert(1)'));
|
|
41
|
+
assert.throws(() => docsKit({ site }), /\/analytics\/attributes/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('rejects an invalid site.yaml with readable errors', () => {
|
|
45
|
+
assert.throws(() => docsKit({ site: path('../../schema/testdata/broken.yaml') }), /must be one of: default, mono, porridge/);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('serves a release version below the site root', (t) => {
|
|
49
|
+
t.after(() => delete process.env.DOCS_KIT_VERSION);
|
|
50
|
+
process.env.DOCS_KIT_VERSION = 'v1.2';
|
|
51
|
+
const config = docsKit({ site: path('../../examples/site.yaml') });
|
|
52
|
+
assert.equal(config.base, '/kasha/v1.2');
|
|
53
|
+
process.env.DOCS_KIT_VERSION = '../x';
|
|
54
|
+
assert.throws(() => docsKit({ site: path('../../examples/site.yaml') }), /not a path segment/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('picks the newest patch of each minor version, newest minor first', () => {
|
|
58
|
+
const tags = ['v1.2.0', 'v1.10.0', 'v1.2.3', 'v1.2.10', 'v2.0.0-rc.1', 'v0.9.1', 'docs-v3.0.0', 'v1.10.1', ''];
|
|
59
|
+
assert.deepEqual(minorVersions(tags), [
|
|
60
|
+
{ name: 'v1.10', tag: 'v1.10.1', line: '1' },
|
|
61
|
+
{ name: 'v1.2', tag: 'v1.2.10', line: '1' },
|
|
62
|
+
{ name: 'v0.9', tag: 'v0.9.1', line: '0.9' },
|
|
63
|
+
]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('selects the newest minors, the newest minor of each newest major, and pinned ones', () => {
|
|
67
|
+
const tags = ['v3.0.0', 'v2.5.0', 'v2.4.0', 'v2.3.0', 'v1.9.0', 'v1.8.0', 'v0.4.0', 'v0.3.0'];
|
|
68
|
+
const names = (options) => selectVersions(minorVersions(tags), options).map((v) => v.name);
|
|
69
|
+
assert.deepEqual(names({ majors: 3, minors: 3 }), ['v3.0', 'v2.5', 'v2.4', 'v1.9']);
|
|
70
|
+
assert.deepEqual(names({ majors: 0, minors: 1, pin: ['v1.8', 'v9.9'] }), ['v3.0', 'v1.8']);
|
|
71
|
+
// Below 1.0 each minor is its own release line.
|
|
72
|
+
assert.deepEqual(names({ majors: 5, minors: 0 }), ['v3.0', 'v2.5', 'v1.9', 'v0.4', 'v0.3']);
|
|
73
|
+
assert.deepEqual(names({}), ['v3.0', 'v2.5', 'v2.4', 'v1.9']);
|
|
74
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import site from 'virtual:docs-kit/site';
|
|
2
|
+
import { docsSections, pageMarkdown, siteRoot } from './page.md.mjs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Serves llms.txt (https://llmstxt.org): an index linking every page's Markdown, and llms-full.txt: every page in one
|
|
6
|
+
* file, for pasting a project's whole docs into a model's context.
|
|
7
|
+
*/
|
|
8
|
+
export function getStaticPaths() {
|
|
9
|
+
return [{ params: { name: 'llms' } }, { params: { name: 'llms-full' } }];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function GET(context) {
|
|
13
|
+
const sections = await docsSections();
|
|
14
|
+
const root = siteRoot(context);
|
|
15
|
+
const head = `# ${site.name}\n\n> ${site.tagline}`;
|
|
16
|
+
|
|
17
|
+
if (context.params.name === 'llms-full') {
|
|
18
|
+
const pages = sections.flatMap(({ pages }) => pages.map((page) => pageMarkdown(page, root)));
|
|
19
|
+
return new Response([`${head}\n`, ...pages].join('\n'));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const links = sections.map(
|
|
23
|
+
({ label, pages }) =>
|
|
24
|
+
`## ${label}\n\n` +
|
|
25
|
+
pages
|
|
26
|
+
.map(({ id, data }) => `- [${data.title}](${new URL(`${id}.md`, root)})${data.description ? `: ${data.description}` : ''}`)
|
|
27
|
+
.join('\n'),
|
|
28
|
+
);
|
|
29
|
+
const intro = `Each page below links to its Markdown source. All pages in one file: ${new URL('llms-full.txt', root)}`;
|
|
30
|
+
return new Response([head, intro, ...links].join('\n\n') + '\n');
|
|
31
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { getCollection } from 'astro:content';
|
|
2
|
+
|
|
3
|
+
const sections = ['Getting started', 'Guides', 'Concepts', 'Reference'];
|
|
4
|
+
|
|
5
|
+
/** Published docs pages in sidebar order, grouped under the sidebar's section labels. */
|
|
6
|
+
export async function docsSections() {
|
|
7
|
+
const pages = (await getCollection('docs', ({ data }) => !data.draft)).sort(
|
|
8
|
+
(a, b) => (a.data.sidebar?.order ?? Infinity) - (b.data.sidebar?.order ?? Infinity) || a.id.localeCompare(b.id),
|
|
9
|
+
);
|
|
10
|
+
const section = ({ id }) => (id.includes('/') ? id.split('/')[0] : 'getting started');
|
|
11
|
+
return sections
|
|
12
|
+
.map((label) => ({ label, pages: pages.filter((page) => section(page) === label.toLowerCase()) }))
|
|
13
|
+
.filter(({ pages }) => pages.length);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The site's root URL, base path included, with a trailing slash. */
|
|
17
|
+
export const siteRoot = ({ site }) => new URL(import.meta.env.BASE_URL.replace(/\/?$/, '/'), site);
|
|
18
|
+
|
|
19
|
+
// Inline `](target)` and reference-style `[label]: target` link destinations.
|
|
20
|
+
// shortcut: also matches look-alikes inside code blocks; parse with remark if a page trips on it.
|
|
21
|
+
const linkTarget = /(\]\(|^ {0,3}\[[^\]]+\]:[ \t]*)([^)\s]+)/gm;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A page as Markdown: its title and description from frontmatter, then the source body. Relative links are written
|
|
25
|
+
* against the page's HTML URL (/guides/x/), so they are made absolute to stay valid at /guides/x.md and in llms-full.txt.
|
|
26
|
+
*/
|
|
27
|
+
export function pageMarkdown({ id, data: { title, description }, body = '' }, root) {
|
|
28
|
+
const page = new URL(`${id}/`, root);
|
|
29
|
+
const absolute = body.trim().replace(linkTarget, (_, prefix, target) => prefix + new URL(target, page));
|
|
30
|
+
return [`# ${title}`, description && `> ${description}`, absolute].filter(Boolean).join('\n\n') + '\n';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Serves each docs page's Markdown at its URL with `.md` appended, for agents that read docs. */
|
|
34
|
+
export async function getStaticPaths() {
|
|
35
|
+
return (await docsSections()).flatMap(({ pages }) => pages.map((entry) => ({ params: { slug: entry.id }, props: { entry } })));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function GET(context) {
|
|
39
|
+
return new Response(pageMarkdown(context.props.entry, siteRoot(context)), {
|
|
40
|
+
headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Escapes text for HTML strings passed to `set:html` or Starlight's hero slots. */
|
|
2
|
+
export const escape = (text) => text.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
3
|
+
|
|
4
|
+
// ponytail: only `inline code` is rendered; switch to a Markdown renderer if bodies need links or emphasis.
|
|
5
|
+
/** Escapes text and renders its `inline code` spans. */
|
|
6
|
+
export const inline = (text) => escape(text).replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/* Starlight's stock palette and fonts with a teal accent. */
|
|
2
|
+
:root {
|
|
3
|
+
--sl-color-accent-low: hsl(174, 60%, 16%);
|
|
4
|
+
--sl-color-accent: hsl(174, 70%, 40%);
|
|
5
|
+
--sl-color-accent-high: hsl(174, 70%, 82%);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
:root[data-theme='light'] {
|
|
9
|
+
--sl-color-accent-low: hsl(174, 60%, 88%);
|
|
10
|
+
--sl-color-accent: hsl(174, 80%, 30%);
|
|
11
|
+
--sl-color-accent-high: hsl(174, 80%, 18%);
|
|
12
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/* Grayscale, monospace everywhere. */
|
|
2
|
+
:root {
|
|
3
|
+
--sl-font: var(--sl-font-system-mono);
|
|
4
|
+
--sl-color-white: hsl(0, 0%, 100%);
|
|
5
|
+
--sl-color-gray-1: hsl(0, 0%, 92%);
|
|
6
|
+
--sl-color-gray-2: hsl(0, 0%, 76%);
|
|
7
|
+
--sl-color-gray-3: hsl(0, 0%, 56%);
|
|
8
|
+
--sl-color-gray-4: hsl(0, 0%, 36%);
|
|
9
|
+
--sl-color-gray-5: hsl(0, 0%, 22%);
|
|
10
|
+
--sl-color-gray-6: hsl(0, 0%, 12%);
|
|
11
|
+
--sl-color-black: hsl(0, 0%, 4%);
|
|
12
|
+
--sl-color-accent-low: hsl(0, 0%, 20%);
|
|
13
|
+
--sl-color-accent: hsl(0, 0%, 90%);
|
|
14
|
+
--sl-color-accent-high: hsl(0, 0%, 100%);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
:root[data-theme='light'] {
|
|
18
|
+
--sl-color-white: hsl(0, 0%, 0%);
|
|
19
|
+
--sl-color-gray-1: hsl(0, 0%, 10%);
|
|
20
|
+
--sl-color-gray-2: hsl(0, 0%, 22%);
|
|
21
|
+
--sl-color-gray-3: hsl(0, 0%, 36%);
|
|
22
|
+
--sl-color-gray-4: hsl(0, 0%, 56%);
|
|
23
|
+
--sl-color-gray-5: hsl(0, 0%, 78%);
|
|
24
|
+
--sl-color-gray-6: hsl(0, 0%, 93%);
|
|
25
|
+
--sl-color-gray-7: hsl(0, 0%, 97%);
|
|
26
|
+
--sl-color-black: hsl(0, 0%, 100%);
|
|
27
|
+
--sl-color-accent-low: hsl(0, 0%, 88%);
|
|
28
|
+
--sl-color-accent: hsl(0, 0%, 15%);
|
|
29
|
+
--sl-color-accent-high: hsl(0, 0%, 0%);
|
|
30
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/* Warm oat and roast neutrals with a honey accent; Fraunces headings over Instrument Sans. */
|
|
2
|
+
@import '@fontsource-variable/fraunces';
|
|
3
|
+
@import '@fontsource-variable/instrument-sans';
|
|
4
|
+
@import '@fontsource-variable/jetbrains-mono';
|
|
5
|
+
|
|
6
|
+
:root {
|
|
7
|
+
--sl-font: 'Instrument Sans Variable', system-ui, sans-serif;
|
|
8
|
+
--sl-font-mono: 'JetBrains Mono Variable', ui-monospace, monospace;
|
|
9
|
+
--kit-font-display: 'Fraunces Variable', Georgia, serif;
|
|
10
|
+
|
|
11
|
+
--sl-color-white: #f2eadb;
|
|
12
|
+
--sl-color-gray-1: #e5dac7;
|
|
13
|
+
--sl-color-gray-2: #c9b8a2;
|
|
14
|
+
--sl-color-gray-3: #a8957f;
|
|
15
|
+
--sl-color-gray-4: #6b5a4c;
|
|
16
|
+
--sl-color-gray-5: #3f3128;
|
|
17
|
+
--sl-color-gray-6: #211a15;
|
|
18
|
+
--sl-color-black: #17120e;
|
|
19
|
+
--sl-color-accent-low: #3a2a18;
|
|
20
|
+
--sl-color-accent: #e39a3b;
|
|
21
|
+
--sl-color-accent-high: #f6ce96;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
:root[data-theme='light'] {
|
|
25
|
+
--sl-color-white: #1e1712;
|
|
26
|
+
--sl-color-gray-1: #2b2119;
|
|
27
|
+
--sl-color-gray-2: #4a3a2e;
|
|
28
|
+
--sl-color-gray-3: #6b5a4c;
|
|
29
|
+
--sl-color-gray-4: #9a8876;
|
|
30
|
+
--sl-color-gray-5: #cdb99a;
|
|
31
|
+
--sl-color-gray-6: #e5dac6;
|
|
32
|
+
--sl-color-gray-7: #efe7d8;
|
|
33
|
+
--sl-color-black: #f7f2e8;
|
|
34
|
+
--sl-color-accent-low: #f4ddbb;
|
|
35
|
+
--sl-color-accent: #8a4b1f;
|
|
36
|
+
--sl-color-accent-high: #4a2a16;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
:is(h1, h2, h3, .site-title, .card .title) {
|
|
40
|
+
font-family: var(--kit-font-display);
|
|
41
|
+
letter-spacing: -0.015em;
|
|
42
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/* Cool graphite with a signal-green accent and amber for "down"; Schibsted Grotesk over IBM Plex Mono. */
|
|
2
|
+
@import '@fontsource-variable/schibsted-grotesk';
|
|
3
|
+
@import '@fontsource/ibm-plex-mono/400.css';
|
|
4
|
+
@import '@fontsource/ibm-plex-mono/500.css';
|
|
5
|
+
|
|
6
|
+
:root {
|
|
7
|
+
--sl-font: 'Schibsted Grotesk Variable', system-ui, sans-serif;
|
|
8
|
+
--sl-font-mono: 'IBM Plex Mono', ui-monospace, monospace;
|
|
9
|
+
--kit-font-display: 'Schibsted Grotesk Variable', system-ui, sans-serif;
|
|
10
|
+
|
|
11
|
+
--sl-color-white: #eef3f1;
|
|
12
|
+
--sl-color-gray-1: #d5dedf;
|
|
13
|
+
--sl-color-gray-2: #aebbbf;
|
|
14
|
+
--sl-color-gray-3: #7d8d94;
|
|
15
|
+
--sl-color-gray-4: #4a5a62;
|
|
16
|
+
--sl-color-gray-5: #263137;
|
|
17
|
+
--sl-color-gray-6: #151c20;
|
|
18
|
+
--sl-color-black: #0d1215;
|
|
19
|
+
--sl-color-accent-low: #12301f;
|
|
20
|
+
--sl-color-accent: #5fd39a;
|
|
21
|
+
--sl-color-accent-high: #c4f2d6;
|
|
22
|
+
--sl-color-orange-low: #3a2a12;
|
|
23
|
+
--sl-color-orange: #c98a3a;
|
|
24
|
+
--sl-color-orange-high: #e0a85a;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
:root[data-theme='light'] {
|
|
28
|
+
--sl-color-white: #10181a;
|
|
29
|
+
--sl-color-gray-1: #222b2c;
|
|
30
|
+
--sl-color-gray-2: #3a4645;
|
|
31
|
+
--sl-color-gray-3: #5d6b69;
|
|
32
|
+
--sl-color-gray-4: #8a9894;
|
|
33
|
+
--sl-color-gray-5: #b9c4c0;
|
|
34
|
+
--sl-color-gray-6: #e4e9e5;
|
|
35
|
+
--sl-color-gray-7: #eef1ee;
|
|
36
|
+
--sl-color-black: #f4f6f3;
|
|
37
|
+
--sl-color-accent-low: #cfeedd;
|
|
38
|
+
--sl-color-accent: #17714a;
|
|
39
|
+
--sl-color-accent-high: #0b4029;
|
|
40
|
+
--sl-color-orange-low: #f5e2c4;
|
|
41
|
+
--sl-color-orange: #b07a2a;
|
|
42
|
+
--sl-color-orange-high: #6e4a0e;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
:is(h1, h2, h3, .site-title, .card .title) {
|
|
46
|
+
font-family: var(--kit-font-display);
|
|
47
|
+
font-weight: 800;
|
|
48
|
+
letter-spacing: -0.03em;
|
|
49
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/* Plum-tinted neutrals with a coral accent; Outfit SemiBold headings over the system sans. */
|
|
2
|
+
@import '@fontsource/outfit/600.css';
|
|
3
|
+
|
|
4
|
+
:root {
|
|
5
|
+
--kit-font-display: 'Outfit', system-ui, sans-serif;
|
|
6
|
+
|
|
7
|
+
--sl-color-white: #f7f4fc;
|
|
8
|
+
--sl-color-gray-1: #e9e4f2;
|
|
9
|
+
--sl-color-gray-2: #c9c1d8;
|
|
10
|
+
--sl-color-gray-3: #a197b5;
|
|
11
|
+
--sl-color-gray-4: #6a5f80;
|
|
12
|
+
--sl-color-gray-5: #382c4d;
|
|
13
|
+
--sl-color-gray-6: #1c1030;
|
|
14
|
+
--sl-color-black: #120a1f;
|
|
15
|
+
--sl-color-accent-low: #4a1a22;
|
|
16
|
+
--sl-color-accent: #ff5a4e;
|
|
17
|
+
--sl-color-accent-high: #ffc9c4;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
:root[data-theme='light'] {
|
|
21
|
+
--sl-color-white: #1c1030;
|
|
22
|
+
--sl-color-gray-1: #2a1f3d;
|
|
23
|
+
--sl-color-gray-2: #3d3250;
|
|
24
|
+
--sl-color-gray-3: #5c5270;
|
|
25
|
+
--sl-color-gray-4: #8c839c;
|
|
26
|
+
--sl-color-gray-5: #cbc4d6;
|
|
27
|
+
--sl-color-gray-6: #ece8f2;
|
|
28
|
+
--sl-color-gray-7: #f6f4f9;
|
|
29
|
+
--sl-color-black: #ffffff;
|
|
30
|
+
--sl-color-accent-low: #ffe1dd;
|
|
31
|
+
--sl-color-accent: #c22f25; /* 4.5:1 or more as text on every light background, white and tints alike */
|
|
32
|
+
--sl-color-accent-high: #7a1a14;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
:is(h1, h2, h3, .site-title, .card .title) {
|
|
36
|
+
font-family: var(--kit-font-display);
|
|
37
|
+
font-weight: 600;
|
|
38
|
+
letter-spacing: -0.02em;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* The wordmark: white on dark, plum on light, as in the logo lockups. */
|
|
42
|
+
.site-title {
|
|
43
|
+
color: var(--sl-color-white);
|
|
44
|
+
letter-spacing: -0.035em;
|
|
45
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Builds the docs of past releases next to the default branch's site in dist/: the newest patch tag (vX.Y.Z) of each
|
|
3
|
+
// minor version that `versions:` in site.yaml selects, at dist/vX.Y/, and the list of all of them in dist/versions.json.
|
|
4
|
+
// Each release's pages, assets and public files come from its tag; site.yaml, dependencies and the kit come from the
|
|
5
|
+
// working tree, so every version is built with the current kit and theme.
|
|
6
|
+
// Usage, from the docs directory after `astro build`: docs-kit-versions [--default-branch main]
|
|
7
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
8
|
+
import { cpSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { join, relative, resolve } from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { parseArgs } from 'node:util';
|
|
12
|
+
import { parse } from 'yaml';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Release tags grouped by minor version, newest first, each with its newest patch and its release line: the major
|
|
16
|
+
* version, or `0.<minor>` below 1.0, where semver lets every minor break. Pre-releases are skipped.
|
|
17
|
+
*/
|
|
18
|
+
export function minorVersions(tags) {
|
|
19
|
+
const newest = new Map();
|
|
20
|
+
for (const tag of tags) {
|
|
21
|
+
const match = /^v(\d+)\.(\d+)\.(\d+)$/.exec(tag);
|
|
22
|
+
if (!match) continue;
|
|
23
|
+
const [major, minor, patch] = match.slice(1).map(Number);
|
|
24
|
+
const name = `v${major}.${minor}`;
|
|
25
|
+
if (!newest.has(name) || newest.get(name).patch < patch) newest.set(name, { name, tag, major, minor, patch });
|
|
26
|
+
}
|
|
27
|
+
return [...newest.values()]
|
|
28
|
+
.sort((a, b) => b.major - a.major || b.minor - a.minor)
|
|
29
|
+
.map(({ name, tag, major, minor }) => ({ name, tag, line: major ? `${major}` : `0.${minor}` }));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The minor versions to publish, newest first: the `minors` newest, the newest of each of the `majors` newest release
|
|
34
|
+
* lines, and the pinned ones.
|
|
35
|
+
*/
|
|
36
|
+
export function selectVersions(candidates, { majors = 3, minors = 3, pin = [] } = {}) {
|
|
37
|
+
const lines = [...new Set(candidates.map((c) => c.line))].slice(0, majors);
|
|
38
|
+
const newestOfLine = lines.map((line) => candidates.find((c) => c.line === line));
|
|
39
|
+
return candidates.filter((c, i) => i < minors || newestOfLine.includes(c) || pin.includes(c.name));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Copied from a tag into the build: content replaces the working tree's, the others are laid over it so that files
|
|
43
|
+
// site.yaml names (logo, art) stay present.
|
|
44
|
+
const fromTag = ['src/content/docs', 'src/assets', 'public'];
|
|
45
|
+
const skipped = /^(node_modules|dist|\.astro)(\/|$)/;
|
|
46
|
+
|
|
47
|
+
function main() {
|
|
48
|
+
const { values } = parseArgs({ options: { 'default-branch': { type: 'string', default: 'main' } } });
|
|
49
|
+
const site = parse(readFileSync('site.yaml', 'utf8'));
|
|
50
|
+
if (!site.versions) throw new Error('site.yaml has no `versions:`; nothing to build');
|
|
51
|
+
|
|
52
|
+
const git = (...args) => execFileSync('git', args, { encoding: 'utf8' });
|
|
53
|
+
// `<tag>:./<path>` resolves the path against the working directory, wherever the docs directory is in the repo.
|
|
54
|
+
const has = (tag, path) => spawnSync('git', ['cat-file', '-e', `${tag}:./${path}`]).status === 0;
|
|
55
|
+
const candidates = minorVersions(git('tag', '--list', 'v*').split('\n')).filter(({ tag }) => has(tag, fromTag[0]));
|
|
56
|
+
for (const name of (site.versions.pin ?? []).filter((n) => !candidates.some((c) => c.name === n))) {
|
|
57
|
+
console.log(`::warning::Pinned docs version ${name} has no vX.Y.Z tag with ${fromTag[0]}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const built = [];
|
|
61
|
+
for (const { name, tag } of selectVersions(candidates, site.versions)) {
|
|
62
|
+
const out = resolve('dist', name);
|
|
63
|
+
// Inside the docs directory, so the build finds node_modules by walking up; a symlink confuses Astro's paths.
|
|
64
|
+
mkdirSync('.astro', { recursive: true });
|
|
65
|
+
const work = mkdtempSync(resolve('.astro/version-'));
|
|
66
|
+
try {
|
|
67
|
+
cpSync('.', work, { recursive: true, filter: (src) => !skipped.test(relative('.', src)) });
|
|
68
|
+
rmSync(join(work, fromTag[0]), { recursive: true });
|
|
69
|
+
// Pathspecs are relative to the working directory, and so are the archived paths.
|
|
70
|
+
const archive = execFileSync('git', ['archive', tag, '--', ...fromTag.filter((p) => has(tag, p))], {
|
|
71
|
+
maxBuffer: 1 << 30,
|
|
72
|
+
});
|
|
73
|
+
execFileSync('tar', ['-x', '-C', work], { input: archive });
|
|
74
|
+
console.log(`Building ${name} from ${tag}`);
|
|
75
|
+
const astro = resolve('node_modules/.bin/astro');
|
|
76
|
+
const result = spawnSync(astro, ['build', '--outDir', out], {
|
|
77
|
+
cwd: work,
|
|
78
|
+
env: { ...process.env, DOCS_KIT_VERSION: name },
|
|
79
|
+
stdio: 'inherit',
|
|
80
|
+
});
|
|
81
|
+
if (result.status !== 0) throw new Error(`astro build exited with ${result.status ?? result.signal}`);
|
|
82
|
+
built.push(name);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
// One release that no longer builds with the current kit must not block publishing the others.
|
|
85
|
+
console.log(`::warning::Skipped the ${name} docs (${tag}): ${error.message}`);
|
|
86
|
+
rmSync(out, { recursive: true, force: true });
|
|
87
|
+
} finally {
|
|
88
|
+
rmSync(work, { recursive: true, force: true });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const latest = candidates[0]?.name;
|
|
93
|
+
const versions = [
|
|
94
|
+
{ name: values['default-branch'], path: '' },
|
|
95
|
+
...built.map((name) => ({ name, path: `${name}/`, ...(name === latest && { latest: true }) })),
|
|
96
|
+
];
|
|
97
|
+
mkdirSync('dist', { recursive: true });
|
|
98
|
+
writeFileSync('dist/versions.json', `${JSON.stringify(versions, null, 2)}\n`);
|
|
99
|
+
console.log(`Wrote dist/versions.json: ${versions.map((v) => v.name).join(', ')}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// npx runs this through a symlink in node_modules/.bin; tests import it for minorVersions.
|
|
103
|
+
if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) main();
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Walks `<bin> [sub...] --help` recursively and writes one Starlight Markdown page per command.
|
|
3
|
+
// Usage: node refgen/help2md.mjs --bin <path-or-name> [--out <dir>]
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { basename, join } from 'node:path';
|
|
7
|
+
import { parseArgs } from 'node:util';
|
|
8
|
+
|
|
9
|
+
// Fixed width and no colors so output is identical on every machine and CI run. Only PATH is inherited:
|
|
10
|
+
// clap's `env` feature prints each variable's current value in --help (`[env: KASHA_TOKEN=secret]`),
|
|
11
|
+
// which would put local secrets into committed pages and make them differ from CI's.
|
|
12
|
+
const ENV = { PATH: process.env.PATH, COLUMNS: '100', NO_COLOR: '1', CLICOLOR: '0', TERM: 'dumb' };
|
|
13
|
+
const MAX_DEPTH = 8;
|
|
14
|
+
|
|
15
|
+
const help = (bin, path) =>
|
|
16
|
+
execFileSync(bin, [...path, '--help'], { env: ENV, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
|
|
17
|
+
.split('\n').map((l) => l.trimEnd()).join('\n').trim();
|
|
18
|
+
|
|
19
|
+
// Subcommand names from clap ("Commands:", clap 3 "SUBCOMMANDS:") and cobra ("Available Commands:",
|
|
20
|
+
// "Additional Commands:", command-group titles) sections. Entries sit at a 2-space indent; deeper
|
|
21
|
+
// lines are wrapped descriptions.
|
|
22
|
+
function subcommands(text) {
|
|
23
|
+
const names = [];
|
|
24
|
+
let inSection = false;
|
|
25
|
+
for (const line of text.split('\n')) {
|
|
26
|
+
if (/^(\w[\w ]*)?commands:$/i.test(line)) inSection = true;
|
|
27
|
+
else if (!line.trim() || /^\S/.test(line)) inSection = false;
|
|
28
|
+
else if (inSection) {
|
|
29
|
+
const m = /^ {2}([\w][\w.-]*)(?:\s+(.*))?$/.exec(line);
|
|
30
|
+
if (m && !skip(m[1], m[2] ?? '')) names.push(m[1]);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return names;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// `help` only repeats other pages. Cobra's default `completion` is identical boilerplate in every
|
|
37
|
+
// cobra CLI, so it is recognised by its stock description; a project's own `completion` stays.
|
|
38
|
+
const skip = (name, desc) =>
|
|
39
|
+
name === 'help' || (name === 'completion' && desc.startsWith('Generate the autocompletion script'));
|
|
40
|
+
|
|
41
|
+
function page(title, text) {
|
|
42
|
+
const description = text.split('\n')[0];
|
|
43
|
+
const fence = '`'.repeat(Math.max(3, ...(text.match(/`+/g) ?? []).map((s) => s.length + 1)));
|
|
44
|
+
return [
|
|
45
|
+
'---',
|
|
46
|
+
`title: ${JSON.stringify(title)}`,
|
|
47
|
+
...(description && !/^usage:/i.test(description) ? [`description: ${JSON.stringify(description)}`] : []),
|
|
48
|
+
'---',
|
|
49
|
+
'',
|
|
50
|
+
'<!-- Generated by docs-kit refgen/help2md from --help output. Do not edit. -->',
|
|
51
|
+
'',
|
|
52
|
+
`${fence}text`,
|
|
53
|
+
text,
|
|
54
|
+
fence,
|
|
55
|
+
'',
|
|
56
|
+
].join('\n');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function generate(bin, out) {
|
|
60
|
+
const root = basename(bin).toLowerCase();
|
|
61
|
+
mkdirSync(out, { recursive: true });
|
|
62
|
+
// Drop pages of commands that no longer exist so the "reference up to date" check sees removals.
|
|
63
|
+
// ponytail: prefix match also hits a sibling binary's pages (`kasha` vs `kasha-server`) in the same
|
|
64
|
+
// --out; give each binary its own --out if that ever happens.
|
|
65
|
+
for (const f of readdirSync(out)) {
|
|
66
|
+
if (f === `${root}.md` || (f.startsWith(`${root}-`) && f.endsWith('.md'))) rmSync(join(out, f));
|
|
67
|
+
}
|
|
68
|
+
const walk = (path) => {
|
|
69
|
+
if (path.length > MAX_DEPTH) throw new Error(`command tree deeper than ${MAX_DEPTH}: ${path.join(' ')}`);
|
|
70
|
+
const text = help(bin, path);
|
|
71
|
+
const file = [root, ...path].join('-').toLowerCase() + '.md';
|
|
72
|
+
writeFileSync(join(out, file), page([root, ...path].join(' '), text));
|
|
73
|
+
for (const sub of subcommands(text)) walk([...path, sub]);
|
|
74
|
+
};
|
|
75
|
+
walk([]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const { values } = parseArgs({
|
|
79
|
+
options: { bin: { type: 'string' }, out: { type: 'string', default: 'docs/src/content/docs/reference/cli' } },
|
|
80
|
+
});
|
|
81
|
+
if (!values.bin) {
|
|
82
|
+
console.error('usage: help2md.mjs --bin <path-or-name> [--out <dir>]');
|
|
83
|
+
process.exit(2);
|
|
84
|
+
}
|
|
85
|
+
generate(values.bin, values.out);
|