diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-22 14:58:26 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-22 14:59:09 +0300 |
| commit | c234f221f9be698bc0501c60459e1ae8e67df6c3 (patch) | |
| tree | 944b4dec10fada8d93456dc5a85e6bfdb36a8b8b /website | |
| parent | ef85fa0c7ebb48bb59fe7593af5a67f3e02ff3d4 (diff) | |
feat(docs): publish versioned release documentation
Diffstat (limited to 'website')
| -rw-r--r-- | website/.gitignore | 3 | ||||
| -rw-r--r-- | website/README.md | 10 | ||||
| -rw-r--r-- | website/astro.config.mjs | 11 | ||||
| -rw-r--r-- | website/bun.lock | 1 | ||||
| -rw-r--r-- | website/index.html | 2 | ||||
| -rw-r--r-- | website/package.json | 5 | ||||
| -rw-r--r-- | website/scripts/check-built-docs.mjs | 100 | ||||
| -rw-r--r-- | website/scripts/docs-versions.integration.test.ts | 73 | ||||
| -rw-r--r-- | website/scripts/docs-versions.mjs | 308 | ||||
| -rw-r--r-- | website/scripts/docs-versions.test.ts | 33 | ||||
| -rw-r--r-- | website/scripts/prepare-docs.mjs | 173 | ||||
| -rw-r--r-- | website/src/components/Banner.astro | 29 | ||||
| -rw-r--r-- | website/src/components/ConfigReference.astro | 13 | ||||
| -rw-r--r-- | website/src/components/DocsVersionSelect.astro | 84 | ||||
| -rw-r--r-- | website/src/components/Head.astro | 7 | ||||
| -rw-r--r-- | website/src/components/Header.astro | 58 | ||||
| -rw-r--r-- | website/src/components/Search.astro | 28 | ||||
| -rw-r--r-- | website/src/components/Sidebar.astro | 67 | ||||
| -rw-r--r-- | website/src/components/SiteTitle.astro | 7 | ||||
| -rw-r--r-- | website/src/docs-path.test.ts | 49 | ||||
| -rw-r--r-- | website/src/docs-path.ts | 83 |
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></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}` : ''}`; } |
