aboutsummaryrefslogtreecommitdiff
path: root/website
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-22 14:58:26 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-22 14:59:09 +0300
commitc234f221f9be698bc0501c60459e1ae8e67df6c3 (patch)
tree944b4dec10fada8d93456dc5a85e6bfdb36a8b8b /website
parentef85fa0c7ebb48bb59fe7593af5a67f3e02ff3d4 (diff)
feat(docs): publish versioned release documentation
Diffstat (limited to 'website')
-rw-r--r--website/.gitignore3
-rw-r--r--website/README.md10
-rw-r--r--website/astro.config.mjs11
-rw-r--r--website/bun.lock1
-rw-r--r--website/index.html2
-rw-r--r--website/package.json5
-rw-r--r--website/scripts/check-built-docs.mjs100
-rw-r--r--website/scripts/docs-versions.integration.test.ts73
-rw-r--r--website/scripts/docs-versions.mjs308
-rw-r--r--website/scripts/docs-versions.test.ts33
-rw-r--r--website/scripts/prepare-docs.mjs173
-rw-r--r--website/src/components/Banner.astro29
-rw-r--r--website/src/components/ConfigReference.astro13
-rw-r--r--website/src/components/DocsVersionSelect.astro84
-rw-r--r--website/src/components/Head.astro7
-rw-r--r--website/src/components/Header.astro58
-rw-r--r--website/src/components/Search.astro28
-rw-r--r--website/src/components/Sidebar.astro67
-rw-r--r--website/src/components/SiteTitle.astro7
-rw-r--r--website/src/docs-path.test.ts49
-rw-r--r--website/src/docs-path.ts83
21 files changed, 983 insertions, 161 deletions
diff --git a/website/.gitignore b/website/.gitignore
index db0605ca..ddd50bda 100644
--- a/website/.gitignore
+++ b/website/.gitignore
@@ -3,4 +3,7 @@ public/
dist/
.astro/
src/content/docs/preview/
+src/content/docs/_versions/
src/data/config-reference-preview.json
+src/data/config-reference-versions.json
+src/data/docs-versions.json
diff --git a/website/README.md b/website/README.md
index 36dfb12d..fd8d8ea2 100644
--- a/website/README.md
+++ b/website/README.md
@@ -9,3 +9,13 @@ bun run build
```
The build output is `dist/`. Configure Cloudflare Pages to use `website` as the project root and publish `dist`.
+
+Stable docs live in `src/content/docs/`. Unreleased docs live in `../docs/next/website/src/content/docs/` and are generated at `/docs/preview/`. Immutable release snapshots live in `../docs/versions/` and are generated at `/docs/<version>/`.
+
+Do not promote docs manually before a release. After the GitHub Release succeeds, release CI runs:
+
+```bash
+node website/scripts/docs-versions.mjs publish <tag>
+```
+
+This snapshots the tagged next docs and promotes the same tagged content to stable before the website deploy. Use `node website/scripts/docs-versions.mjs check` to validate committed snapshots against their release tags.
diff --git a/website/astro.config.mjs b/website/astro.config.mjs
index f9d15b5d..0bf2a096 100644
--- a/website/astro.config.mjs
+++ b/website/astro.config.mjs
@@ -1,7 +1,9 @@
import { defineConfig } from 'astro/config';
+import sitemap from '@astrojs/sitemap';
import starlight from '@astrojs/starlight';
const repoBlob = 'https://github.com/ogulcancelik/herdr/blob/master/';
+const nonCanonicalDocsPath = /^\/(?:ja\/|zh-cn\/)?docs\/(?:preview|\d+\.\d+\.\d+)(?:\/|$)/;
function rewriteHerdrLinks() {
const docsLinks = new Map([
@@ -54,6 +56,13 @@ export default defineConfig({
'/zh-cn': '/zh-cn/docs/',
},
integrations: [
+ sitemap({
+ filter: (page) => !nonCanonicalDocsPath.test(new URL(page).pathname),
+ i18n: {
+ defaultLocale: 'root',
+ locales: { root: 'en', ja: 'ja', 'zh-cn': 'zh-CN' },
+ },
+ }),
starlight({
title: 'herdr',
description: 'Terminal-native agent runtime and multiplexer.',
@@ -190,7 +199,7 @@ export default defineConfig({
translations: { ja: 'ヘルプ', 'zh-CN': '帮助' },
items: [
{ label: 'Troubleshooting', translations: { ja: 'トラブルシューティング', 'zh-CN': '故障排除' }, slug: 'docs/troubleshooting' },
- { label: 'Preview docs', translations: { ja: 'プレビュー版ドキュメント', 'zh-CN': '预览版文档' }, slug: 'docs/preview' },
+ { label: 'Next docs', translations: { ja: '次期版ドキュメント', 'zh-CN': '下一版文档' }, slug: 'docs/preview' },
],
},
],
diff --git a/website/bun.lock b/website/bun.lock
index cdd23005..503a34cc 100644
--- a/website/bun.lock
+++ b/website/bun.lock
@@ -5,6 +5,7 @@
"": {
"name": "herdr-website",
"dependencies": {
+ "@astrojs/sitemap": "^3.7.2",
"@astrojs/starlight": "^0.36.0",
"astro": "^5.0.0",
},
diff --git a/website/index.html b/website/index.html
index 2a5dfd80..5d9c2b9c 100644
--- a/website/index.html
+++ b/website/index.html
@@ -174,7 +174,7 @@
<div class="windows-install" aria-label="Windows beta preview install">
<div class="windows-install-heading">
<span>Windows beta · preview only</span>
- <a href="/docs/preview/windows-beta/">Preview docs</a>
+ <a href="/docs/preview/windows-beta/">Next docs</a>
</div>
<div class="install-card install-card-secondary" aria-label="Windows beta install command">
<span class="prompt">PS&gt;</span>
diff --git a/website/package.json b/website/package.json
index 4e03eea0..5fdb4ba8 100644
--- a/website/package.json
+++ b/website/package.json
@@ -5,11 +5,12 @@
"scripts": {
"prepare-docs": "node scripts/prepare-docs.mjs",
"dev": "node scripts/prepare-docs.mjs && astro dev",
- "test": "bun test src/docs-path.test.ts",
- "build": "bun run test && node scripts/prepare-docs.mjs && astro build",
+ "test": "bun test",
+ "build": "bun run test && node scripts/prepare-docs.mjs && astro build && node scripts/check-built-docs.mjs",
"preview": "astro preview"
},
"dependencies": {
+ "@astrojs/sitemap": "^3.7.2",
"@astrojs/starlight": "^0.36.0",
"astro": "^5.0.0"
}
diff --git a/website/scripts/check-built-docs.mjs b/website/scripts/check-built-docs.mjs
new file mode 100644
index 00000000..6f5dfa0b
--- /dev/null
+++ b/website/scripts/check-built-docs.mjs
@@ -0,0 +1,100 @@
+import { access, readFile, readdir, stat } from 'node:fs/promises';
+import { dirname, join, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const websiteDir = resolve(dirname(fileURLToPath(import.meta.url)), '..');
+const distDir = resolve(websiteDir, 'dist');
+const nonCanonicalDocsUrl = /https:\/\/herdr\.dev\/(?:ja\/|zh-cn\/)?docs\/(?:preview|\d+\.\d+\.\d+)(?:\/|<)/;
+const versions = JSON.parse(
+ await readFile(resolve(websiteDir, 'src/data/docs-versions.json'), 'utf8'),
+);
+
+for (const entry of versions.versions) {
+ const scope = versions.scopes[entry.version];
+ if (!scope) throw new Error(`missing generated scope for ${entry.version}`);
+ for (const [locale, pages] of Object.entries(scope.locales)) {
+ const localePrefix = locale === 'root' ? '' : `${locale}/`;
+ for (const page of pages) {
+ const output = resolve(
+ distDir,
+ localePrefix,
+ 'docs',
+ entry.version,
+ page,
+ 'index.html',
+ );
+ await access(output);
+ }
+ }
+}
+
+const stable = await readFile(resolve(distDir, 'docs/index.html'), 'utf8');
+const preview = await readFile(resolve(distDir, 'docs/preview/index.html'), 'utf8');
+const archived = await readFile(
+ resolve(distDir, 'docs', versions.current, 'index.html'),
+ 'utf8',
+);
+
+assertIncludes(stable, 'data-pagefind-filter="version[content]" content="stable"');
+if (stable.includes('name="robots" content="noindex')) {
+ throw new Error('stable docs must remain indexable');
+}
+assertIncludes(preview, 'data-pagefind-filter="version[content]" content="preview"');
+assertIncludes(preview, 'name="robots" content="noindex, nofollow"');
+assertIncludes(archived, `data-pagefind-filter="version[content]" content="${versions.current}"`);
+assertIncludes(archived, 'name="robots" content="noindex, nofollow"');
+if (archived.includes(`This page documents Herdr ${versions.current}`)) {
+ throw new Error('the current immutable snapshot must not be labeled as outdated');
+}
+const versionSelect = stable.match(/<select[^>]*aria-label="Documentation version"[^>]*>([\s\S]*?)<\/select>/)?.[1];
+if (!versionSelect) throw new Error('stable docs are missing the version selector');
+if (versionSelect.includes(`value="/docs/${versions.current}/"`)) {
+ throw new Error('the current release is duplicated in the version selector');
+}
+
+const previous = versions.versions.find((entry) => entry.version !== versions.current);
+if (previous) {
+ const previousArchive = await readFile(
+ resolve(distDir, 'docs', previous.version, 'index.html'),
+ 'utf8',
+ );
+ assertIncludes(previousArchive, `This page documents Herdr ${previous.version}`);
+}
+
+const sitemap = await readFile(resolve(distDir, 'sitemap-0.xml'), 'utf8');
+assertIncludes(sitemap, 'https://herdr.dev/docs/');
+if (nonCanonicalDocsUrl.test(sitemap)) {
+ throw new Error('preview or immutable documentation URLs must not appear in the sitemap');
+}
+
+const build = await inspectFiles(distDir);
+if (build.count > 20_000) {
+ throw new Error(`website build has ${build.count} files, exceeding the Cloudflare Pages free-plan limit`);
+}
+if (build.largest.bytes > 25 * 1024 * 1024) {
+ throw new Error(`website asset ${build.largest.path} is ${build.largest.bytes} bytes, exceeding Cloudflare Pages' 25 MiB limit`);
+}
+process.stdout.write(`validated ${versions.versions.length} documentation versions in ${build.count} website files\n`);
+
+function assertIncludes(content, expected) {
+ if (!content.includes(expected)) throw new Error(`built documentation is missing ${expected}`);
+}
+
+async function inspectFiles(directory, root = directory) {
+ const result = { count: 0, largest: { path: '', bytes: 0 } };
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
+ const path = join(directory, entry.name);
+ if (entry.isDirectory()) {
+ const nested = await inspectFiles(path, root);
+ result.count += nested.count;
+ if (nested.largest.bytes > result.largest.bytes) result.largest = nested.largest;
+ } else if (entry.isFile()) {
+ result.count += 1;
+ const { size } = await stat(path);
+ if (size > result.largest.bytes) {
+ result.largest = { path: path.slice(root.length + 1), bytes: size };
+ }
+ }
+ }
+ return result;
+}
diff --git a/website/scripts/docs-versions.integration.test.ts b/website/scripts/docs-versions.integration.test.ts
new file mode 100644
index 00000000..aede1957
--- /dev/null
+++ b/website/scripts/docs-versions.integration.test.ts
@@ -0,0 +1,73 @@
+import { afterEach, describe, expect, test } from 'bun:test';
+import { execFileSync } from 'node:child_process';
+import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
+import { join, resolve } from 'node:path';
+import { tmpdir } from 'node:os';
+
+const script = resolve(import.meta.dir, 'docs-versions.mjs');
+const temporaryDirectories: string[] = [];
+
+afterEach(async () => {
+ await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })));
+});
+
+describe('documentation release publishing', () => {
+ test('snapshots tagged next docs and promotes the same content', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'herdr-docs-'));
+ temporaryDirectories.push(root);
+ await write(root, 'website/src/content/docs/index.mdx', 'stable docs\n');
+ await write(root, 'website/src/data/config-reference.json', '{"stable":true}\n');
+ await write(root, 'website/latest.json', '{"version":"0.9.0"}\n');
+ await write(root, 'README.md', 'stable readme\n');
+ await write(root, 'docs/next/website/src/content/docs/index.mdx', 'next docs\n');
+ await write(root, 'docs/next/website/src/data/config-reference.json', '{"next":true}\n');
+ await write(root, 'docs/next/README.md', 'next readme\n');
+
+ git(root, ['init', '-q']);
+ git(root, ['config', 'user.email', 'test@example.com']);
+ git(root, ['config', 'user.name', 'Test']);
+ git(root, ['add', '.']);
+ git(root, ['commit', '-qm', 'release fixture']);
+ git(root, ['tag', 'v1.0.0']);
+
+ runScript(root, ['publish', 'v1.0.0']);
+
+ expect(await read(root, 'website/src/content/docs/index.mdx')).toBe('next docs\n');
+ expect(await read(root, 'website/src/data/config-reference.json')).toBe('{"next":true}\n');
+ expect(await read(root, 'README.md')).toBe('next readme\n');
+ expect(await read(root, 'docs/versions/1.0.0/website/src/content/docs/index.mdx')).toBe('next docs\n');
+
+ const manifest = JSON.parse(await read(root, 'docs/versions/manifest.json'));
+ expect(manifest.current).toBe('1.0.0');
+ expect(manifest.versions[0]).toMatchObject({
+ version: '1.0.0',
+ tag: 'v1.0.0',
+ source: 'docs/next/website/src/content/docs',
+ });
+
+ await write(root, 'website/latest.json', '{"version":"1.0.0"}\n');
+ runScript(root, ['check']);
+ });
+});
+
+async function write(root: string, path: string, content: string) {
+ const destination = resolve(root, path);
+ await mkdir(resolve(destination, '..'), { recursive: true });
+ await writeFile(destination, content, 'utf8');
+}
+
+async function read(root: string, path: string) {
+ return readFile(resolve(root, path), 'utf8');
+}
+
+function git(root: string, args: string[]) {
+ execFileSync('git', args, { cwd: root, stdio: 'pipe' });
+}
+
+function runScript(root: string, args: string[]) {
+ execFileSync('node', [script, ...args], {
+ cwd: root,
+ env: { ...process.env, HERDR_DOCS_REPO_ROOT: root },
+ stdio: 'pipe',
+ });
+}
diff --git a/website/scripts/docs-versions.mjs b/website/scripts/docs-versions.mjs
new file mode 100644
index 00000000..dbdccd77
--- /dev/null
+++ b/website/scripts/docs-versions.mjs
@@ -0,0 +1,308 @@
+import { execFileSync } from 'node:child_process';
+import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
+import { dirname, join, relative, resolve } from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+
+const websiteDir = dirname(fileURLToPath(import.meta.url));
+const repoRoot = process.env.HERDR_DOCS_REPO_ROOT
+ ? resolve(process.env.HERDR_DOCS_REPO_ROOT)
+ : resolve(websiteDir, '../..');
+const versionsDir = resolve(repoRoot, 'docs/versions');
+const manifestPath = resolve(versionsDir, 'manifest.json');
+const stableDocsDir = resolve(repoRoot, 'website/src/content/docs');
+const stableReferencePath = resolve(repoRoot, 'website/src/data/config-reference.json');
+
+const VERSION_PATTERN = /^v?(\d+\.\d+\.\d+)$/;
+
+export function normalizeVersion(value) {
+ const match = VERSION_PATTERN.exec(value);
+ if (!match) throw new Error(`version must look like 0.7.5 or v0.7.5, got ${value}`);
+ return match[1];
+}
+
+export function sortVersionsNewestFirst(versions) {
+ return [...versions].sort((left, right) => {
+ const a = left.version.split('.').map(Number);
+ const b = right.version.split('.').map(Number);
+ for (let index = 0; index < 3; index += 1) {
+ if (a[index] !== b[index]) return b[index] - a[index];
+ }
+ return 0;
+ });
+}
+
+function git(args, options = {}) {
+ return execFileSync('git', args, {
+ cwd: repoRoot,
+ encoding: options.binary ? undefined : 'utf8',
+ maxBuffer: 64 * 1024 * 1024,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+}
+
+function gitTreesEqual(ref, left, right) {
+ try {
+ git(['diff', '--quiet', `${ref}:${left}`, `${ref}:${right}`]);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function gitPathExists(ref, path) {
+ try {
+ git(['cat-file', '-e', `${ref}:${path}`]);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function listGitFiles(ref, root) {
+ const output = git(['ls-tree', '-r', '--name-only', ref, '--', root]);
+ return output.split('\n').filter(Boolean);
+}
+
+async function extractGitTree(ref, sourceRoot, destinationRoot) {
+ const files = listGitFiles(ref, sourceRoot);
+ if (files.length === 0) throw new Error(`${ref}:${sourceRoot} contains no files`);
+
+ await rm(destinationRoot, { recursive: true, force: true });
+ for (const file of files) {
+ const destination = resolve(destinationRoot, relative(sourceRoot, file));
+ await mkdir(dirname(destination), { recursive: true });
+ await writeFile(destination, git(['show', `${ref}:${file}`], { binary: true }));
+ }
+}
+
+async function readManifest() {
+ try {
+ return JSON.parse(await readFile(manifestPath, 'utf8'));
+ } catch (error) {
+ if (error.code === 'ENOENT') return { schema_version: 1, current: null, versions: [] };
+ throw error;
+ }
+}
+
+async function writeManifest(manifest) {
+ manifest.versions = sortVersionsNewestFirst(manifest.versions).map(({ version, tag, source }) => ({
+ version,
+ tag,
+ source,
+ }));
+ await mkdir(dirname(manifestPath), { recursive: true });
+ await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
+}
+
+function releaseMetadata(tag) {
+ const version = normalizeVersion(tag);
+ return { version, tag: `v${version}` };
+}
+
+async function snapshotTag(tag, sourceRoot) {
+ const metadata = { ...releaseMetadata(tag), source: sourceRoot };
+ const destination = resolve(versionsDir, metadata.version);
+ await rm(destination, { recursive: true, force: true });
+ await extractGitTree(tag, sourceRoot, resolve(destination, 'website/src/content/docs'));
+
+ const referenceSource = sourceRoot.includes('docs/next/')
+ ? 'docs/next/website/src/data/config-reference.json'
+ : 'website/src/data/config-reference.json';
+ if (gitPathExists(tag, referenceSource)) {
+ const referenceDestination = resolve(destination, 'website/src/data/config-reference.json');
+ await mkdir(dirname(referenceDestination), { recursive: true });
+ await writeFile(referenceDestination, git(['show', `${tag}:${referenceSource}`], { binary: true }));
+ }
+
+ return metadata;
+}
+
+export async function backfillVersions() {
+ const tags = git(['tag', '--list', 'v*', '--sort=v:refname']).split('\n').filter(Boolean);
+ const manifest = await readManifest();
+ const entries = new Map(manifest.versions.map((entry) => [entry.version, entry]));
+
+ for (const tag of tags) {
+ const version = normalizeVersion(tag);
+ if (entries.has(version) || !gitPathExists(tag, 'website/src/content/docs')) continue;
+ const nextRoot = 'docs/next/website/src/content/docs';
+ const sourceRoot =
+ gitPathExists(tag, nextRoot) && !gitTreesEqual(tag, 'website/src/content/docs', nextRoot)
+ ? nextRoot
+ : 'website/src/content/docs';
+ const metadata = await snapshotTag(tag, sourceRoot);
+ entries.set(metadata.version, metadata);
+ process.stdout.write(`snapshotted ${tag}\n`);
+ }
+
+ const current = JSON.parse(await readFile(resolve(repoRoot, 'website/latest.json'), 'utf8')).version;
+ await writeManifest({
+ schema_version: 1,
+ current: normalizeVersion(current),
+ versions: [...entries.values()],
+ });
+}
+
+export async function checkVersions() {
+ const manifest = await readManifest();
+ if (manifest.schema_version !== 1 || typeof manifest.current !== 'string') {
+ throw new Error(`${manifestPath} has an unsupported schema`);
+ }
+
+ const latest = JSON.parse(await readFile(resolve(repoRoot, 'website/latest.json'), 'utf8')).version;
+ if (manifest.current !== normalizeVersion(latest)) {
+ throw new Error(`docs current version ${manifest.current} does not match website latest ${latest}`);
+ }
+
+ const expectedOrder = sortVersionsNewestFirst(manifest.versions).map(({ version }) => version);
+ if (JSON.stringify(manifest.versions.map(({ version }) => version)) !== JSON.stringify(expectedOrder)) {
+ throw new Error('documentation versions are not sorted newest first');
+ }
+
+ const seen = new Set();
+ for (const entry of manifest.versions) {
+ if (seen.has(entry.version)) throw new Error(`duplicate docs version ${entry.version}`);
+ seen.add(entry.version);
+ if (normalizeVersion(entry.tag) !== entry.version) {
+ throw new Error(`docs version ${entry.version} has mismatched tag ${entry.tag}`);
+ }
+ if (!['website/src/content/docs', 'docs/next/website/src/content/docs'].includes(entry.source)) {
+ throw new Error(`docs version ${entry.version} has unsupported source ${entry.source}`);
+ }
+ const sourceRoot = entry.source;
+ const snapshotRoot = resolve(versionsDir, entry.version, 'website/src/content/docs');
+ const expected = listGitFiles(entry.tag, sourceRoot).map((path) => relative(sourceRoot, path)).sort();
+ const actual = (await listFiles(snapshotRoot)).sort();
+ if (JSON.stringify(actual) !== JSON.stringify(expected)) {
+ throw new Error(`documentation file list for ${entry.version} differs from ${entry.tag}:${sourceRoot}`);
+ }
+ for (const relativePath of expected) {
+ const actualContent = await readFile(resolve(snapshotRoot, relativePath));
+ const taggedPath = `${sourceRoot}/${relativePath.split('\\').join('/')}`;
+ const taggedContent = git(['show', `${entry.tag}:${taggedPath}`], { binary: true });
+ if (!actualContent.equals(taggedContent)) {
+ throw new Error(`${entry.version}/${relativePath} differs from ${entry.tag}:${sourceRoot}`);
+ }
+ }
+
+ const referenceSource = sourceRoot.includes('docs/next/')
+ ? 'docs/next/website/src/data/config-reference.json'
+ : 'website/src/data/config-reference.json';
+ const referenceSnapshot = resolve(
+ versionsDir,
+ entry.version,
+ 'website/src/data/config-reference.json',
+ );
+ if (gitPathExists(entry.tag, referenceSource)) {
+ const actualReference = await readFile(referenceSnapshot);
+ const taggedReference = git(['show', `${entry.tag}:${referenceSource}`], { binary: true });
+ if (!actualReference.equals(taggedReference)) {
+ throw new Error(`${entry.version} config reference differs from ${entry.tag}:${referenceSource}`);
+ }
+ } else {
+ try {
+ await readFile(referenceSnapshot);
+ throw new Error(`${entry.version} has an unexpected config reference snapshot`);
+ } catch (error) {
+ if (error.code !== 'ENOENT') throw error;
+ }
+ }
+ }
+
+ if (!seen.has(manifest.current)) {
+ throw new Error(`current docs version ${manifest.current} has no snapshot`);
+ }
+ process.stdout.write(`validated ${manifest.versions.length} documentation snapshots\n`);
+}
+
+export async function publishVersion(tag) {
+ const release = releaseMetadata(tag);
+ if (!gitPathExists(tag, 'docs/next/website/src/content/docs')) {
+ throw new Error(`${tag} does not contain staged website documentation`);
+ }
+
+ const manifest = await readManifest();
+ const existing = manifest.versions.find((entry) => entry.version === release.version);
+ if (existing && existing.tag !== release.tag) {
+ throw new Error(`version ${release.version} is already associated with ${existing.tag}`);
+ }
+
+ const metadata = await snapshotTag(tag, 'docs/next/website/src/content/docs');
+ const snapshotRoot = resolve(versionsDir, metadata.version, 'website');
+ await replaceDirectory(resolve(snapshotRoot, 'src/content/docs'), stableDocsDir);
+
+ const snapshotReference = resolve(snapshotRoot, 'src/data/config-reference.json');
+ try {
+ await mkdir(dirname(stableReferencePath), { recursive: true });
+ await writeFile(stableReferencePath, await readFile(snapshotReference));
+ } catch (error) {
+ if (error.code !== 'ENOENT') throw error;
+ await rm(stableReferencePath, { force: true });
+ }
+
+ const nextReadme = `docs/next/README.md`;
+ if (gitPathExists(tag, nextReadme)) {
+ await writeFile(resolve(repoRoot, 'README.md'), git(['show', `${tag}:${nextReadme}`], { binary: true }));
+ }
+
+ const entries = new Map(manifest.versions.map((entry) => [entry.version, entry]));
+ entries.set(metadata.version, metadata);
+ await writeManifest({
+ schema_version: 1,
+ current: metadata.version,
+ versions: [...entries.values()],
+ });
+ process.stdout.write(`published documentation snapshot ${metadata.tag}\n`);
+}
+
+async function replaceDirectory(source, destination) {
+ await rm(destination, { recursive: true, force: true });
+ await copyDirectory(source, destination);
+}
+
+async function listFiles(root) {
+ const files = [];
+ async function walk(directory) {
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
+ const path = join(directory, entry.name);
+ if (entry.isDirectory()) await walk(path);
+ else if (entry.isFile()) files.push(relative(root, path));
+ }
+ }
+ await walk(root);
+ return files;
+}
+
+async function copyDirectory(source, destination) {
+ await mkdir(destination, { recursive: true });
+ for (const entry of await readdir(source, { withFileTypes: true })) {
+ const sourcePath = join(source, entry.name);
+ const destinationPath = join(destination, entry.name);
+ if (entry.isDirectory()) {
+ await copyDirectory(sourcePath, destinationPath);
+ } else if (entry.isFile()) {
+ await writeFile(destinationPath, await readFile(sourcePath));
+ }
+ }
+}
+
+async function main() {
+ const [command, value] = process.argv.slice(2);
+ if (command === 'backfill' && !value) {
+ await backfillVersions();
+ return;
+ }
+ if (command === 'publish' && value) {
+ await publishVersion(value);
+ return;
+ }
+ if (command === 'check' && !value) {
+ await checkVersions();
+ return;
+ }
+ throw new Error('usage: node website/scripts/docs-versions.mjs backfill | check | publish <tag>');
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ await main();
+}
diff --git a/website/scripts/docs-versions.test.ts b/website/scripts/docs-versions.test.ts
new file mode 100644
index 00000000..dc450b37
--- /dev/null
+++ b/website/scripts/docs-versions.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, test } from 'bun:test';
+import { normalizeVersion, sortVersionsNewestFirst } from './docs-versions.mjs';
+
+describe('normalizeVersion', () => {
+ test.each([
+ ['0.7.5', '0.7.5'],
+ ['v0.7.5', '0.7.5'],
+ ])('normalizes %s', (input, expected) => {
+ expect(normalizeVersion(input)).toBe(expected);
+ });
+
+ test('rejects non-release refs', () => {
+ expect(() => normalizeVersion('preview-123')).toThrow();
+ });
+});
+
+describe('sortVersionsNewestFirst', () => {
+ test('sorts semantic versions numerically', () => {
+ const versions = [
+ { version: '0.6.10' },
+ { version: '0.7.2' },
+ { version: '0.7.10' },
+ { version: '0.5.12' },
+ ];
+
+ expect(sortVersionsNewestFirst(versions).map(({ version }) => version)).toEqual([
+ '0.7.10',
+ '0.7.2',
+ '0.6.10',
+ '0.5.12',
+ ]);
+ });
+});
diff --git a/website/scripts/prepare-docs.mjs b/website/scripts/prepare-docs.mjs
index 2c6fd5f4..c7b3e9d8 100644
--- a/website/scripts/prepare-docs.mjs
+++ b/website/scripts/prepare-docs.mjs
@@ -1,5 +1,5 @@
-import { cp, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
-import { dirname, join, relative, resolve } from 'node:path';
+import { cp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
+import { dirname, extname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import process from 'node:process';
@@ -9,6 +9,10 @@ const publicDir = resolve(repoRoot, 'website/public');
const stableDocsDir = resolve(repoRoot, 'website/src/content/docs');
const previewDocsSourceDir = resolve(repoRoot, 'docs/next/website/src/content/docs');
const previewDocsDir = resolve(stableDocsDir, 'preview');
+const generatedVersionsDocsDir = resolve(stableDocsDir, '_versions');
+const versionsDir = resolve(repoRoot, 'docs/versions');
+const versionsManifestPath = resolve(versionsDir, 'manifest.json');
+const generatedVersionsDataPath = resolve(repoRoot, 'website/src/data/docs-versions.json');
const previewConfigReferenceSource = resolve(
repoRoot,
'docs/next/website/src/data/config-reference.json',
@@ -17,14 +21,29 @@ const previewConfigReferenceDestination = resolve(
repoRoot,
'website/src/data/config-reference-preview.json',
);
+const generatedVersionReferencesPath = resolve(
+ repoRoot,
+ 'website/src/data/config-reference-versions.json',
+);
if (process.argv[2] === '--rewrite-preview-doc-fixture') {
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
process.stdout.write(rewritePreviewDocContent(Buffer.concat(chunks).toString('utf8')));
+} else if (process.argv[2] === '--rewrite-version-doc-fixture') {
+ const chunks = [];
+ for await (const chunk of process.stdin) chunks.push(chunk);
+ process.stdout.write(
+ rewriteVersionDocContent(Buffer.concat(chunks).toString('utf8'), {
+ version: process.argv[3] ?? '0.7.5',
+ tag: `v${process.argv[3] ?? '0.7.5'}`,
+ sourceRoot: 'docs/next/website/src/content/docs',
+ relativePath: 'index.mdx',
+ }),
+ );
} else {
await preparePublicAssets();
- await preparePreviewDocs();
+ await prepareDocs();
}
async function preparePublicAssets() {
@@ -56,49 +75,165 @@ async function preparePublicAssets() {
}
}
-async function preparePreviewDocs() {
+async function prepareDocs() {
await rm(previewDocsDir, { recursive: true, force: true });
- await copyPreviewDocs(previewDocsSourceDir, previewDocsDir);
+ await rm(generatedVersionsDocsDir, { recursive: true, force: true });
+
+ await copyPreparedDocs(previewDocsSourceDir, previewDocsDir, (content, relativePath) =>
+ rewritePreviewDocContent(content, relativePath),
+ );
await cp(previewConfigReferenceSource, previewConfigReferenceDestination);
+
+ const manifest = JSON.parse(await readFile(versionsManifestPath, 'utf8'));
+ if (manifest.schema_version !== 1 || typeof manifest.current !== 'string') {
+ throw new Error(`${versionsManifestPath} has an unsupported schema`);
+ }
+
+ const scopes = {
+ stable: await collectDocsScope(stableDocsDir, new Set(['preview', '_versions'])),
+ preview: await collectDocsScope(previewDocsSourceDir),
+ };
+ const configReferences = {};
+
+ for (const entry of manifest.versions) {
+ const version = entry.version;
+ const snapshotDocsRoot = resolve(versionsDir, version, 'website/src/content/docs');
+ const destinationRoot = resolve(generatedVersionsDocsDir, version);
+ await copyPreparedDocs(snapshotDocsRoot, destinationRoot, (content, relativePath) =>
+ rewriteVersionDocContent(content, {
+ version,
+ tag: entry.tag,
+ sourceRoot: entry.source,
+ relativePath,
+ }),
+ );
+ scopes[version] = await collectDocsScope(snapshotDocsRoot);
+
+ const referencePath = resolve(
+ versionsDir,
+ version,
+ 'website/src/data/config-reference.json',
+ );
+ try {
+ configReferences[version] = JSON.parse(await readFile(referencePath, 'utf8'));
+ } catch (error) {
+ if (error.code !== 'ENOENT') throw error;
+ }
+ }
+
+ await writeFile(
+ generatedVersionsDataPath,
+ `${JSON.stringify({ ...manifest, scopes }, null, 2)}\n`,
+ 'utf8',
+ );
+ await writeFile(
+ generatedVersionReferencesPath,
+ `${JSON.stringify(configReferences, null, 2)}\n`,
+ 'utf8',
+ );
}
-async function copyPreviewDocs(sourceDir, destinationDir) {
+async function copyPreparedDocs(sourceDir, destinationDir, rewrite, pathPrefix = '') {
await mkdir(destinationDir, { recursive: true });
for (const entry of await readdir(sourceDir, { withFileTypes: true })) {
const source = join(sourceDir, entry.name);
const destination = join(destinationDir, entry.name);
+ const relativePath = pathPrefix ? `${pathPrefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
- await copyPreviewDocs(source, destination);
+ await copyPreparedDocs(source, destination, rewrite, relativePath);
continue;
}
if (!entry.isFile()) continue;
+ if (!['.md', '.mdx'].includes(extname(entry.name).toLowerCase())) {
+ await cp(source, destination);
+ continue;
+ }
const content = await readFile(source, 'utf8');
- const relativePath = relative(previewDocsSourceDir, source);
- await writeFile(destination, rewritePreviewDocContent(content, relativePath), 'utf8');
+ await writeFile(destination, rewrite(content, relativePath), 'utf8');
}
}
+async function collectDocsScope(sourceDir, excludedDirectories = new Set()) {
+ const locales = { root: [], ja: [], 'zh-cn': [] };
+
+ async function walk(directory, prefix = '') {
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
+ if (entry.isDirectory() && excludedDirectories.has(entry.name)) continue;
+ const path = join(directory, entry.name);
+ const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
+ if (entry.isDirectory()) {
+ await walk(path, relativePath);
+ continue;
+ }
+ if (!entry.isFile() || !['.md', '.mdx'].includes(extname(entry.name))) continue;
+
+ const segments = relativePath.replace(/\.(md|mdx)$/i, '').split('/');
+ const locale = segments[0] === 'ja' || segments[0] === 'zh-cn' ? segments.shift() : 'root';
+ const page = segments.join('/').replace(/(^|\/)index$/, '').replace(/\/$/, '');
+ locales[locale].push(page);
+ }
+ }
+
+ await walk(sourceDir);
+ for (const pages of Object.values(locales)) pages.sort();
+ for (const locale of ['ja', 'zh-cn']) {
+ if (locales[locale].length === 0) locales[locale] = [...locales.root];
+ }
+ return { locales };
+}
+
export function rewritePreviewDocContent(content, relativePath = '') {
- const rewritten = content
- .replaceAll('/docs/', '/docs/preview/')
- .replaceAll('../../../public/', '../../../../public/')
- // Preview docs live one directory deeper than stable docs, so component
- // imports need one more parent segment regardless of locale depth. Only
- // MDX import lines are rewritten; prose mentioning relative paths is not.
- .replace(/^(import .*from\s+['"])(?=(?:\.\.\/)+components\/)/gm, '$1../');
- return insertPreviewNotice(rewritten, relativePath);
+ const rewritten = rewriteRelativeDocPaths(
+ content.replaceAll('/docs/', '/docs/preview/'),
+ 1,
+ );
+ const withEditLink = setGeneratedEditUrl(
+ rewritten,
+ `https://github.com/ogulcancelik/herdr/edit/master/docs/next/website/src/content/docs/${relativePath}`,
+ );
+ return insertPreviewNotice(withEditLink, relativePath);
+}
+
+export function rewriteVersionDocContent(content, { version, tag, sourceRoot, relativePath }) {
+ const taggedContent = content
+ .replaceAll('/docs/', `/docs/${version}/`)
+ .replaceAll(
+ 'https://github.com/ogulcancelik/herdr/blob/master/',
+ `https://github.com/ogulcancelik/herdr/blob/${tag}/`,
+ )
+ .replaceAll(
+ 'https://raw.githubusercontent.com/ogulcancelik/herdr/master/',
+ `https://raw.githubusercontent.com/ogulcancelik/herdr/${tag}/`,
+ );
+ const rewritten = rewriteRelativeDocPaths(taggedContent, 2);
+ return setGeneratedEditUrl(
+ rewritten,
+ `https://github.com/ogulcancelik/herdr/blob/${tag}/${sourceRoot}/${relativePath}`,
+ );
+}
+
+function rewriteRelativeDocPaths(content, extraDepth) {
+ const parents = '../'.repeat(extraDepth);
+ return content
+ .replace(/((?:\.\.\/)+)(?=public\/)/g, `$1${parents}`)
+ .replace(/^(import .*from\s+['"])(?=(?:\.\.\/)+components\/)/gm, `$1${parents}`);
+}
+
+function setGeneratedEditUrl(content, editUrl) {
+ if (!content.startsWith('---\n') || /^editUrl:/m.test(content)) return content;
+ return content.replace(/^---\n/, `---\neditUrl: ${editUrl}\n`);
}
function insertPreviewNotice(content, relativePath) {
const notice = [
- '> Preview docs describe unreleased preview builds. Stable docs remain at [/docs/](/docs/).',
+ '> Next docs describe unreleased work from `master`. Stable docs remain at [/docs/](/docs/).',
'',
'',
].join('\n');
const indexPrefix =
relativePath === 'index.mdx'
- ? content.replace('title: Herdr documentation', 'title: Herdr preview documentation')
+ ? content.replace('title: Herdr documentation', 'title: Herdr next documentation')
: content;
const frontmatter = indexPrefix.match(/^---\n[\s\S]*?\n---\n/);
if (!frontmatter) {
diff --git a/website/src/components/Banner.astro b/website/src/components/Banner.astro
index 8a4bad22..01098659 100644
--- a/website/src/components/Banner.astro
+++ b/website/src/components/Banner.astro
@@ -1,4 +1,7 @@
---
+import versions from '../data/docs-versions.json';
+import { docsRoute, docsTargetHref } from '../docs-path';
+
const { entry, locale, isFallback } = Astro.locals.starlightRoute;
const { banner } = entry.data;
@@ -8,9 +11,24 @@ const translationNotices = {
'zh-cn': `本页面的翻译由 LLM 生成。如果你发现翻译有误,请<a href="${issuesUrl}">在 GitHub 上提交 issue</a> 告诉我们。`,
};
const translationNotice = locale && !isFallback ? translationNotices[locale] : undefined;
+const docs = docsRoute(Astro.url.pathname);
+const archivedVersion =
+ docs.target !== 'stable' && docs.target !== 'preview' && docs.target !== versions.current
+ ? docs.target
+ : undefined;
+const latestHref = docsTargetHref(docs, 'stable', versions.scopes);
+const archivedNotices = {
+ root: `This page documents Herdr ${archivedVersion}. <a href="${latestHref}">Switch to the latest release.</a>`,
+ ja: `このページは Herdr ${archivedVersion} のドキュメントです。<a href="${latestHref}">最新版に切り替えてください。</a>`,
+ 'zh-cn': `本页面是 Herdr ${archivedVersion} 的文档。<a href="${latestHref}">切换到最新版本。</a>`,
+};
+const archivedNotice = archivedVersion ? archivedNotices[locale ?? 'root'] : undefined;
---
{banner && <div class="sl-banner" data-pagefind-ignore set:html={banner.content} />}
+{archivedNotice && (
+ <div class="version-notice" data-pagefind-ignore set:html={archivedNotice} />
+)}
{translationNotice && (
<div class="translation-notice" data-pagefind-ignore set:html={translationNotice} />
)}
@@ -31,7 +49,8 @@ const translationNotice = locale && !isFallback ? translationNotices[locale] : u
color: var(--__sl-banner-text);
}
- .translation-notice {
+ .translation-notice,
+ .version-notice {
padding: 0.5rem var(--sl-nav-pad-x);
background-color: var(--sl-color-bg-nav);
border-bottom: 1px solid var(--sl-color-hairline);
@@ -41,8 +60,16 @@ const translationNotice = locale && !isFallback ? translationNotices[locale] : u
text-align: center;
text-wrap: balance;
}
+ .version-notice {
+ background-color: var(--sl-color-orange-low);
+ border-bottom-color: var(--sl-color-orange);
+ color: var(--sl-color-orange-high);
+ }
.translation-notice :global(a) {
color: var(--sl-color-text-accent);
}
+ .version-notice :global(a) {
+ color: var(--sl-color-orange-high);
+ }
}
</style>
diff --git a/website/src/components/ConfigReference.astro b/website/src/components/ConfigReference.astro
index d951f1cc..33894993 100644
--- a/website/src/components/ConfigReference.astro
+++ b/website/src/components/ConfigReference.astro
@@ -1,6 +1,8 @@
---
import stableReference from '../data/config-reference.json';
import previewReference from '../data/config-reference-preview.json';
+import versionReferences from '../data/config-reference-versions.json';
+import { docsVersion } from '../docs-path';
interface Props {
placeholder?: string;
@@ -14,9 +16,16 @@ const {
keyCountLabel = 'keys',
} = Astro.props;
-const reference = Astro.url.pathname.includes('/docs/preview/')
+const version = docsVersion(Astro.url.pathname);
+const archivedReferences = versionReferences as Record<string, typeof stableReference>;
+const reference = version === 'preview'
? previewReference
- : stableReference;
+ : version === 'stable'
+ ? stableReference
+ : archivedReferences[version];
+if (!reference) {
+ throw new Error(`documentation version ${version} has no archived configuration reference`);
+}
const totalKeys = reference.sections.reduce((sum, section) => sum + section.keys.length, 0);
---
diff --git a/website/src/components/DocsVersionSelect.astro b/website/src/components/DocsVersionSelect.astro
new file mode 100644
index 00000000..cc6ea67f
--- /dev/null
+++ b/website/src/components/DocsVersionSelect.astro
@@ -0,0 +1,84 @@
+---
+import versions from '../data/docs-versions.json';
+import { docsRoute, docsTargetHref } from '../docs-path';
+
+const route = docsRoute(Astro.url.pathname);
+const locale = route.locale ?? 'root';
+const labels = {
+ root: { accessible: 'Documentation version', latest: 'Latest', next: 'Unreleased' },
+ ja: { accessible: 'ドキュメントのバージョン', latest: '最新', next: '未リリース' },
+ 'zh-cn': { accessible: '文档版本', latest: '最新', next: '未发布' },
+}[locale];
+const scopes = versions.scopes;
+---
+
+{route.isDocs && (
+ <label class="docs-version-select">
+ <span class="sr-only">{labels.accessible}</span>
+ <select autocomplete="off" aria-label={labels.accessible}>
+ <option
+ value={docsTargetHref(route, 'stable', scopes)}
+ selected={route.target === 'stable'}
+ >
+ {labels.latest}{' '}{versions.current}
+ </option>
+ <option
+ value={docsTargetHref(route, 'preview', scopes)}
+ selected={route.target === 'preview'}
+ >
+ {labels.next}
+ </option>
+ {versions.versions
+ .filter((entry) => entry.version !== versions.current || route.target === versions.current)
+ .map((entry) => (
+ <option
+ value={docsTargetHref(route, entry.version, scopes)}
+ selected={route.target === entry.version}
+ >
+ {entry.version}
+ </option>
+ ))}
+ </select>
+ </label>
+)}
+
+<script>
+ for (const select of document.querySelectorAll<HTMLSelectElement>('.docs-version-select select')) {
+ select.addEventListener('change', () => {
+ globalThis.location.href = select.value;
+ });
+ }
+</script>
+
+<style>
+ .docs-version-select {
+ display: flex;
+ min-width: 0;
+ }
+
+ select {
+ width: 9.5rem;
+ min-width: 0;
+ border: 1px solid var(--sl-color-hairline);
+ border-radius: 4px;
+ padding: 0.4rem 1.6rem 0.4rem 0.55rem;
+ background: var(--sl-color-bg-nav);
+ color: var(--sl-color-gray-2);
+ font: inherit;
+ font-size: var(--sl-text-xs);
+ font-weight: 700;
+ text-overflow: ellipsis;
+ }
+
+ select:hover,
+ select:focus {
+ border-color: var(--sl-color-accent);
+ color: var(--sl-color-accent-high);
+ }
+
+ @media (max-width: 38rem) {
+ select {
+ width: 7.5rem;
+ }
+ }
+</style>
diff --git a/website/src/components/Head.astro b/website/src/components/Head.astro
index b1214cb1..9987173b 100644
--- a/website/src/components/Head.astro
+++ b/website/src/components/Head.astro
@@ -1,9 +1,10 @@
---
-import { docsChannel } from '../docs-path';
+import { docsVersion } from '../docs-path';
const { head } = Astro.locals.starlightRoute;
-const channel = docsChannel(Astro.url.pathname);
+const version = docsVersion(Astro.url.pathname);
---
{head.map(({ tag: Tag, attrs, content }) => <Tag {...attrs} set:html={content} />)}
-<meta data-pagefind-filter="channel[content]" content={channel} />
+<meta data-pagefind-filter="version[content]" content={version} />
+{version !== 'stable' && <meta name="robots" content="noindex, nofollow" />}
diff --git a/website/src/components/Header.astro b/website/src/components/Header.astro
index 982efd2e..cfa3d759 100644
--- a/website/src/components/Header.astro
+++ b/website/src/components/Header.astro
@@ -6,19 +6,12 @@ import Search from 'virtual:starlight/components/Search';
import SiteTitle from 'virtual:starlight/components/SiteTitle';
import SocialIcons from 'virtual:starlight/components/SocialIcons';
import ThemeSelect from 'virtual:starlight/components/ThemeSelect';
+import DocsVersionSelect from './DocsVersionSelect.astro';
+import { docsRoute } from '../docs-path';
const shouldRenderSearch =
config.pagefind || config.components.Search !== '@astrojs/starlight/components/Search.astro';
-
-const pathname = Astro.url.pathname;
-const isDocs = pathname === '/docs/' || pathname.startsWith('/docs/');
-const isPreview = pathname === '/docs/preview/' || pathname.startsWith('/docs/preview/');
-const stableHref = isPreview ? pathname.replace(/^\/docs\/preview\/?/, '/docs/') : pathname;
-const previewHref = isPreview
- ? pathname
- : pathname === '/docs/'
- ? '/docs/preview/'
- : pathname.replace(/^\/docs\/?/, '/docs/preview/');
+const isDocs = docsRoute(Astro.url.pathname).isDocs;
---
<div class="header">
@@ -26,12 +19,7 @@ const previewHref = isPreview
<SiteTitle />
</div>
<div class="sl-flex print:hidden search-wrapper">
- {isDocs && (
- <nav class="docs-channel-switcher" aria-label="Documentation channel">
- <a href={stableHref} aria-current={!isPreview ? 'page' : undefined}>Stable</a>
- <a href={previewHref} aria-current={isPreview ? 'page' : undefined}>Preview</a>
- </nav>
- )}
+ {isDocs && <DocsVersionSelect />}
{shouldRenderSearch && <Search />}
</div>
<div class="sl-hidden md:sl-flex print:hidden right-group">
@@ -66,38 +54,6 @@ const previewHref = isPreview
min-width: 0;
}
- .docs-channel-switcher {
- display: flex;
- align-items: center;
- border: 1px solid var(--sl-color-hairline);
- border-radius: 4px;
- overflow: hidden;
- background: var(--sl-color-bg-nav);
- font-size: var(--sl-text-xs);
- font-weight: 700;
- line-height: 1;
- white-space: nowrap;
- }
-
- .docs-channel-switcher a {
- color: var(--sl-color-gray-2);
- text-decoration: none;
- padding: 0.45rem 0.6rem;
- }
-
- .docs-channel-switcher a + a {
- border-inline-start: 1px solid var(--sl-color-hairline);
- }
-
- .docs-channel-switcher a[aria-current="page"] {
- color: var(--sl-color-accent-high);
- background: color-mix(in srgb, var(--sl-color-accent) 14%, transparent);
- }
-
- .docs-channel-switcher a:hover {
- color: var(--sl-color-accent-high);
- }
-
.right-group,
.social-icons {
gap: 1rem;
@@ -110,12 +66,6 @@ const previewHref = isPreview
border-inline-end: 1px solid var(--sl-color-gray-5);
}
- @media (max-width: 38rem) {
- .docs-channel-switcher {
- display: none;
- }
- }
-
@media (min-width: 50rem) {
:global(:root[data-has-sidebar]) {
--__sidebar-pad: calc(2 * var(--sl-nav-pad-x));
diff --git a/website/src/components/Search.astro b/website/src/components/Search.astro
index 2a31ea24..cceeed34 100644
--- a/website/src/components/Search.astro
+++ b/website/src/components/Search.astro
@@ -1,25 +1,25 @@
---
import StarlightSearch from '@astrojs/starlight/components/Search.astro';
-import { docsChannel } from '../docs-path';
+import { docsVersion } from '../docs-path';
-const channel = docsChannel(Astro.url.pathname);
+const version = docsVersion(Astro.url.pathname);
---
-<channel-search data-channel={channel}>
+<version-search data-version={version}>
<StarlightSearch />
-</channel-search>
+</version-search>
<script>
- class ChannelSearch extends HTMLElement {
+ class VersionSearch extends HTMLElement {
observer?: MutationObserver;
connectedCallback() {
- const channel = this.dataset.channel;
- if (!channel) return;
+ const version = this.dataset.version;
+ if (!version) return;
- const selectChannel = () => {
+ const selectVersion = () => {
const checkbox = this.querySelector<HTMLInputElement>(
- `.pagefind-ui__filter-checkbox[name="channel"][value="${channel}"]`,
+ `.pagefind-ui__filter-checkbox[name="version"][value="${version}"]`,
);
if (!checkbox) return false;
if (!checkbox.checked) checkbox.click();
@@ -27,9 +27,9 @@ const channel = docsChannel(Astro.url.pathname);
return true;
};
- if (selectChannel()) return;
+ if (selectVersion()) return;
// Starlight does not expose its PagefindUI instance, so wait for Pagefind's filter control.
- this.observer = new MutationObserver(selectChannel);
+ this.observer = new MutationObserver(selectVersion);
this.observer.observe(this, { childList: true, subtree: true });
}
@@ -38,15 +38,15 @@ const channel = docsChannel(Astro.url.pathname);
}
}
- customElements.define('channel-search', ChannelSearch);
+ customElements.define('version-search', VersionSearch);
</script>
<style>
- channel-search {
+ version-search {
display: contents;
}
- channel-search :global(.pagefind-ui__filter-panel) {
+ version-search :global(.pagefind-ui__filter-panel) {
display: none;
}
</style>
diff --git a/website/src/components/Sidebar.astro b/website/src/components/Sidebar.astro
index 8ded83bb..08d31f79 100644
--- a/website/src/components/Sidebar.astro
+++ b/website/src/components/Sidebar.astro
@@ -2,62 +2,39 @@
import MobileMenuFooter from 'virtual:starlight/components/MobileMenuFooter';
import SidebarPersister from '@astrojs/starlight/components/SidebarPersister.astro';
import SidebarSublist from '@astrojs/starlight/components/SidebarSublist.astro';
+import versions from '../data/docs-versions.json';
+import { docsRoute, docsTargetHref } from '../docs-path';
const { sidebar } = Astro.locals.starlightRoute;
const pathname = Astro.url.pathname;
-const isPreview = pathname === '/docs/preview/' || pathname.startsWith('/docs/preview/');
-const channelSidebar = isPreview ? addPreviewOnlyItems(rewriteSidebar(sidebar)) : sidebar;
+const route = docsRoute(pathname);
+const scopedSidebar = route.target === 'stable' ? sidebar : rewriteSidebar(sidebar);
-function addPreviewOnlyItems(entries) {
- return entries.map((entry) => {
- if (entry.type !== 'group' || entry.label !== 'Start here') return entry;
-
- const windowsBeta = {
- type: 'link',
- label: 'Windows beta',
- href: '/docs/preview/windows-beta/',
- badge: undefined,
- isCurrent: samePath('/docs/preview/windows-beta/', pathname),
- attrs: {},
- };
- const nextEntries = [];
- for (const child of entry.entries) {
- nextEntries.push(child);
- if (child.type === 'link' && child.label === 'Install') {
- nextEntries.push(windowsBeta);
+function rewriteSidebar(entries) {
+ return entries.flatMap((entry) => {
+ if (entry.type === 'link') {
+ const linkRoute = docsRoute(entry.href);
+ if (!linkRoute.isDocs) return [entry];
+ if (linkRoute.target !== 'stable') {
+ return linkRoute.target === route.target
+ ? [{ ...entry, isCurrent: samePath(entry.href, pathname) }]
+ : [];
}
- }
- return {
- ...entry,
- entries: nextEntries,
- };
- });
-}
+ const localeKey = route.locale ?? 'root';
+ const availablePages = versions.scopes[route.target]?.locales[localeKey] ?? [];
+ if (!availablePages.includes(linkRoute.page)) return [];
-function rewriteSidebar(entries) {
- return entries.map((entry) => {
- if (entry.type === 'link') {
- const href = previewHref(entry.href);
- return {
- ...entry,
- href,
- isCurrent: samePath(href, pathname),
- };
+ const href = docsTargetHref({ ...linkRoute, locale: route.locale }, route.target, versions.scopes);
+ return [{ ...entry, href, isCurrent: samePath(href, pathname) }];
}
+
const rewrittenEntries = rewriteSidebar(entry.entries);
- return {
- ...entry,
- entries: rewrittenEntries,
- };
+ if (rewrittenEntries.length === 0) return [];
+ return [{ ...entry, entries: rewrittenEntries }];
});
}
-function previewHref(href) {
- if (!href.startsWith('/docs/') || href.startsWith('/docs/preview/')) return href;
- return href === '/docs/' ? '/docs/preview/' : href.replace(/^\/docs\//, '/docs/preview/');
-}
-
function samePath(left, right) {
return stripTrailingSlash(left) === stripTrailingSlash(right);
}
@@ -68,7 +45,7 @@ function stripTrailingSlash(path) {
---
<SidebarPersister>
- <SidebarSublist sublist={channelSidebar} />
+ <SidebarSublist sublist={scopedSidebar} />
</SidebarPersister>
<div class="md:sl-hidden">
diff --git a/website/src/components/SiteTitle.astro b/website/src/components/SiteTitle.astro
index 576e7635..3e5af0a3 100644
--- a/website/src/components/SiteTitle.astro
+++ b/website/src/components/SiteTitle.astro
@@ -1,8 +1,11 @@
---
+import { docsRoute } from '../docs-path';
+
const { siteTitle, siteTitleHref, locale } = Astro.locals.starlightRoute;
-const isPreview = Astro.url.pathname === '/docs/preview/' || Astro.url.pathname.startsWith('/docs/preview/');
+const route = docsRoute(Astro.url.pathname);
const localePrefix = locale ? `/${locale}` : '';
-const docsHref = isPreview ? '/docs/preview/' : `${localePrefix}/docs/`;
+const targetSegment = route.target === 'stable' ? '' : `/${route.target}`;
+const docsHref = `${localePrefix}/docs${targetSegment}/`;
---
<div class="title-row sl-flex">
diff --git a/website/src/docs-path.test.ts b/website/src/docs-path.test.ts
index 26f9a701..0fadb26e 100644
--- a/website/src/docs-path.test.ts
+++ b/website/src/docs-path.test.ts
@@ -1,16 +1,44 @@
import { describe, expect, test } from 'bun:test';
-import { docsChannel, docsPath } from './docs-path';
+import { docsPath, docsRoute, docsTargetHref, docsVersion } from './docs-path';
-describe('docsChannel', () => {
+const scopes = {
+ stable: { locales: { root: ['', 'install'], ja: ['', 'install'] } },
+ preview: { locales: { root: ['', 'install', 'new-page'], ja: ['', 'install'] } },
+ '0.7.4': { locales: { root: ['', 'install'], ja: [''] } },
+};
+
+describe('docsRoute', () => {
+ test.each([
+ ['/docs/', { isDocs: true, locale: undefined, target: 'stable', page: '' }],
+ ['/ja/docs/install/', { isDocs: true, locale: 'ja', target: 'stable', page: 'install' }],
+ ['/docs/preview/new-page/', { isDocs: true, locale: undefined, target: 'preview', page: 'new-page' }],
+ ['/zh-cn/docs/preview/', { isDocs: true, locale: 'zh-cn', target: 'preview', page: '' }],
+ ['/docs/0.7.4/install/', { isDocs: true, locale: undefined, target: '0.7.4', page: 'install' }],
+ ['/ja/docs/0.7.4/', { isDocs: true, locale: 'ja', target: '0.7.4', page: '' }],
+ ['/blog/', { isDocs: false, locale: undefined, target: 'stable', page: '' }],
+ ])('parses %s', (pathname, expected) => {
+ expect(docsRoute(pathname)).toEqual(expected);
+ });
+});
+
+describe('docsVersion', () => {
test.each([
['/docs/', 'stable'],
- ['/ja/docs/install/', 'stable'],
- ['/zh-cn/docs/', 'stable'],
- ['/docs/preview/', 'preview'],
['/ja/docs/preview/install/', 'preview'],
- ['/zh-cn/docs/preview/', 'preview'],
- ])('maps %s to %s', (pathname, expected) => {
- expect(docsChannel(pathname)).toBe(expected);
+ ['/zh-cn/docs/0.7.4/', '0.7.4'],
+ ])('maps %s', (pathname, version) => {
+ expect(docsVersion(pathname)).toBe(version);
+ });
+});
+
+describe('docsTargetHref', () => {
+ test.each([
+ ['/docs/install/', 'preview', '/docs/preview/install/'],
+ ['/ja/docs/install/', '0.7.4', '/ja/docs/0.7.4/'],
+ ['/docs/0.7.4/install/', 'stable', '/docs/install/'],
+ ['/docs/preview/new-page/', '0.7.4', '/docs/0.7.4/'],
+ ])('maps %s to %s', (pathname, target, expected) => {
+ expect(docsTargetHref(docsRoute(pathname), target, scopes)).toBe(expected);
});
});
@@ -25,7 +53,10 @@ describe('docsPath', () => {
['preview/install.mdx', 'docs/preview/install'],
['preview/ja/index.mdx', 'ja/docs/preview'],
['preview/ja/install.mdx', 'ja/docs/preview/install'],
- ['preview/zh-cn/install.mdx', 'zh-cn/docs/preview/install'],
+ ['_versions/0.7.4/index.mdx', 'docs/0.7.4'],
+ ['_versions/0.7.4/install.mdx', 'docs/0.7.4/install'],
+ ['_versions/0.7.4/ja/index.mdx', 'ja/docs/0.7.4'],
+ ['_versions/0.7.4/zh-cn/install.mdx', 'zh-cn/docs/0.7.4/install'],
])('maps %s to %s', (entry, expected) => {
expect(docsPath({ entry })).toBe(expected);
});
diff --git a/website/src/docs-path.ts b/website/src/docs-path.ts
index be022a4b..53ace41d 100644
--- a/website/src/docs-path.ts
+++ b/website/src/docs-path.ts
@@ -1,20 +1,87 @@
const docsLocales = new Set(['ja', 'zh-cn']);
+const docsVersionPattern = /^\d+\.\d+\.\d+$/;
-export function docsChannel(pathname: string) {
- return /^\/(?:ja\/|zh-cn\/)?docs\/preview(?:\/|$)/.test(pathname) ? 'preview' : 'stable';
+export type DocsTarget = 'stable' | 'preview' | string;
+
+export interface DocsRoute {
+ isDocs: boolean;
+ locale: string | undefined;
+ target: DocsTarget;
+ page: string;
+}
+
+export interface DocsScope {
+ locales: Record<string, string[]>;
+}
+
+export function docsRoute(pathname: string): DocsRoute {
+ const segments = pathname.split('/').filter(Boolean);
+ let locale: string | undefined;
+
+ if (segments[0] && docsLocales.has(segments[0])) {
+ locale = segments.shift();
+ }
+
+ if (segments.shift() !== 'docs') {
+ return { isDocs: false, locale, target: 'stable', page: '' };
+ }
+
+ let target: DocsTarget = 'stable';
+ if (segments[0] === 'preview') {
+ target = 'preview';
+ segments.shift();
+ } else if (segments[0] && docsVersionPattern.test(segments[0])) {
+ target = segments.shift()!;
+ }
+
+ return {
+ isDocs: true,
+ locale,
+ target,
+ page: segments.join('/'),
+ };
+}
+
+export function docsVersion(pathname: string) {
+ return docsRoute(pathname).target;
+}
+
+export function docsTargetHref(
+ route: DocsRoute,
+ target: DocsTarget,
+ scopes?: Record<string, DocsScope>,
+) {
+ if (!route.isDocs) throw new Error('cannot build a documentation target from a non-docs route');
+
+ const localeKey = route.locale ?? 'root';
+ const availablePages = scopes?.[target]?.locales[localeKey];
+ const page = availablePages?.includes(route.page) === false ? '' : route.page;
+ const prefix = route.locale ? `/${route.locale}` : '';
+ const targetSegment = target === 'stable' ? '' : `/${target}`;
+ return `${prefix}/docs${targetSegment}${page ? `/${page}` : ''}/`;
}
export function docsPath({ entry }: { entry: string }) {
const slug = entry.replace(/\.(md|mdx|markdown|mdown|mkdn|mkd|mdwn)$/i, '');
const normalized = slug.replace(/\/index$/, '');
const segments = normalized.split('/');
- const localeIndex = segments[0] === 'preview' ? 1 : 0;
- const locale = segments[localeIndex];
- if (locale && docsLocales.has(locale)) {
- segments.splice(localeIndex, 1);
- return `${locale}/docs${segments.length > 0 ? `/${segments.join('/')}` : ''}`;
+ let targetSegment = '';
+ if (segments[0] === 'preview') {
+ targetSegment = 'preview';
+ segments.shift();
+ } else if (segments[0] === '_versions' && segments[1] && docsVersionPattern.test(segments[1])) {
+ segments.shift();
+ targetSegment = segments.shift()!;
+ }
+
+ let locale: string | undefined;
+ if (segments[0] && docsLocales.has(segments[0])) {
+ locale = segments.shift();
}
- return normalized === 'index' ? 'docs' : `docs/${normalized}`;
+ const page = segments.join('/');
+ const prefix = locale ? `${locale}/` : '';
+ const target = targetSegment ? `/${targetSegment}` : '';
+ return `${prefix}docs${target}${page && page !== 'index' ? `/${page}` : ''}`;
}