ci(docs): serve frozen version archives from R2 (#269)

This commit is contained in:
2026-09-24 15:28:58 -07:00
committed by GitHub
parent 5096ca217b
commit bc73a02623
19 changed files with 651 additions and 835 deletions
+19 -9
View File
@@ -2,6 +2,11 @@ name: Docs Pages
on: on:
workflow_dispatch: workflow_dispatch:
inputs:
rebuild_archives:
description: 'Stable tags to rebuild in R2 (comma-separated, or "all"); archives are otherwise built once'
required: false
default: ''
push: push:
branches: branches:
- main - main
@@ -10,6 +15,8 @@ on:
paths: paths:
- 'docs-site/**' - 'docs-site/**'
- 'scripts/docs-versioning.ts' - 'scripts/docs-versioning.ts'
- 'scripts/docs-versioned-assets.ts'
- 'scripts/docs-archive-store.ts'
- 'scripts/build-versioned-docs.ts' - 'scripts/build-versioned-docs.ts'
- '.github/workflows/docs-pages.yml' - '.github/workflows/docs-pages.yml'
- 'package.json' - 'package.json'
@@ -54,20 +61,21 @@ jobs:
bun install --frozen-lockfile bun install --frozen-lockfile
cd docs-site && bun install --frozen-lockfile cd docs-site && bun install --frozen-lockfile
- name: Cache versioned docs archives
if: steps.tag_guard.outputs.stable_tag != 'false'
uses: actions/cache@v4
with:
path: .tmp/docs-versioned-archive-cache
key: docs-versioned-archives-${{ runner.os }}-${{ hashFiles('docs-site/.vitepress/**', 'docs-site/public/assets/fonts/**', 'docs-site/package.json', 'docs-site/bun.lock', 'scripts/build-versioned-docs.ts', 'scripts/docs-versioning.ts') }}
- name: Test docs - name: Test docs
if: steps.tag_guard.outputs.stable_tag != 'false' if: steps.tag_guard.outputs.stable_tag != 'false'
run: bun run docs:test run: bun run docs:test
# Builds only archives missing from R2 (plus any requested rebuilds), then the
# root and /main/ builds that make up the Pages deployment.
- name: Build versioned docs - name: Build versioned docs
if: steps.tag_guard.outputs.stable_tag != 'false' if: steps.tag_guard.outputs.stable_tag != 'false'
run: bun run docs:build:versioned env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
DOCS_ARCHIVE_R2_ACCESS_KEY_ID: ${{ secrets.DOCS_ARCHIVE_R2_ACCESS_KEY_ID }}
DOCS_ARCHIVE_R2_SECRET_ACCESS_KEY: ${{ secrets.DOCS_ARCHIVE_R2_SECRET_ACCESS_KEY }}
DOCS_ARCHIVE_R2_BUCKET: ${{ vars.DOCS_ARCHIVE_R2_BUCKET }}
REBUILD_ARCHIVES: ${{ inputs.rebuild_archives }}
run: bun run scripts/build-versioned-docs.ts --require-archives "--rebuild-archives=${REBUILD_ARCHIVES}"
- name: Deploy docs to Cloudflare Pages - name: Deploy docs to Cloudflare Pages
if: steps.tag_guard.outputs.stable_tag != 'false' if: steps.tag_guard.outputs.stable_tag != 'false'
@@ -75,4 +83,6 @@ jobs:
with: with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy .tmp/docs-versioned-site --project-name "${{ vars.CLOUDFLARE_PAGES_PROJECT_NAME }}" --branch main # Run from docs-site so Wrangler bundles docs-site/functions (the /v/* archive server).
workingDirectory: docs-site
command: pages deploy ../.tmp/docs-versioned-site --project-name "${{ vars.CLOUDFLARE_PAGES_PROJECT_NAME }}" --branch main
+13 -193
View File
@@ -1,6 +1,6 @@
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync, statSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { extname, join, posix, resolve, sep } from 'node:path'; import { join } from 'node:path';
import type { DefaultTheme, HeadConfig, TransformContext, UserConfig } from 'vitepress'; import type { DefaultTheme, HeadConfig, TransformContext, UserConfig } from 'vitepress';
const DOCS_HOSTNAME = 'https://docs.subminer.moe'; const DOCS_HOSTNAME = 'https://docs.subminer.moe';
@@ -14,12 +14,6 @@ const PLAUSIBLE_INIT_SCRIPT = [
type DocsChannel = 'stable-root' | 'stable-archive' | 'main'; type DocsChannel = 'stable-root' | 'stable-archive' | 'main';
type VersionManifest = {
latestStable: string;
channels: Array<{ label: string; path: string }>;
versions: Array<{ version: string; path: string }>;
};
function optionalEnv(value: string | undefined): string | undefined { function optionalEnv(value: string | undefined): string | undefined {
return value && value !== 'undefined' ? value : undefined; return value && value !== 'undefined' ? value : undefined;
} }
@@ -32,17 +26,6 @@ const docsSourceDir = optionalEnv(process.env.SUBMINER_DOCS_SOURCE_DIR) ?? proce
const repoDocsDir = optionalEnv(process.env.SUBMINER_DOCS_REPO_DIR) ?? process.cwd(); const repoDocsDir = optionalEnv(process.env.SUBMINER_DOCS_REPO_DIR) ?? process.cwd();
const channel = normalizeChannel(optionalEnv(process.env.SUBMINER_DOCS_CHANNEL)); const channel = normalizeChannel(optionalEnv(process.env.SUBMINER_DOCS_CHANNEL));
const docsVersion = optionalEnv(process.env.SUBMINER_DOCS_VERSION); const docsVersion = optionalEnv(process.env.SUBMINER_DOCS_VERSION);
const latestStable = optionalEnv(process.env.SUBMINER_DOCS_LATEST_STABLE) ?? 'v0.18.0';
const versionManifest = parseVersionManifest(process.env.SUBMINER_DOCS_VERSION_MANIFEST);
const versionLinkOrigin =
optionalEnv(process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN) ?? 'production';
function getLocalArchiveDir(): string {
return resolve(
optionalEnv(process.env.SUBMINER_DOCS_LOCAL_ARCHIVE_DIR) ??
join(docsSourceDir, '..', '.tmp/docs-versioned-site'),
);
}
function normalizeBase(value: string): string { function normalizeBase(value: string): string {
if (!value || value === '/') return '/'; if (!value || value === '/') return '/';
@@ -54,21 +37,6 @@ function normalizeChannel(value: string | undefined): DocsChannel {
return 'stable-root'; return 'stable-root';
} }
function parseVersionManifest(value: string | undefined): VersionManifest {
if (!value || value === 'undefined') {
return {
latestStable,
channels: [
{ label: 'Latest stable', path: '/' },
{ label: 'main', path: '/main/' },
],
versions: [{ version: latestStable, path: `/v/${latestStable.replace(/^v/, '')}/` }],
};
}
return JSON.parse(value) as VersionManifest;
}
function withDocsBase(path: string): string { function withDocsBase(path: string): string {
if (/^[a-z]+:\/\//i.test(path)) return path; if (/^[a-z]+:\/\//i.test(path)) return path;
const normalizedPath = path.startsWith('/') ? path : `/${path}`; const normalizedPath = path.startsWith('/') ? path : `/${path}`;
@@ -164,137 +132,16 @@ function filterSidebar(items: DefaultTheme.SidebarItem[]): DefaultTheme.SidebarI
.filter((item): item is DefaultTheme.SidebarItem => Boolean(item)); .filter((item): item is DefaultTheme.SidebarItem => Boolean(item));
} }
function versionSwitchLink(path: string): string { // Version navigation targets other builds (root, `/main/`, `/versions`), so it links to
if (/^[a-z]+:\/\//i.test(path)) return path; // production by absolute URL: base-relative links would stay inside this build, and
const normalizedPath = path.startsWith('/') ? path : `/${path}`; // `target: '_self'` makes the VitePress router do a full page load. The list is
if (versionLinkOrigin === 'local') return localVersionSwitchLink(normalizedPath); // deliberately static; the full release list lives on the root-only `/versions` page
return `${DOCS_HOSTNAME}${normalizedPath}`; // so frozen `/v/<version>/` archives never need a rebuild when a new tag ships.
} const versionItems: DefaultTheme.NavItemWithLink[] = [
{ text: 'Latest stable', link: `${DOCS_HOSTNAME}/` },
function localVersionSwitchLink(path: string): string { { text: 'main', link: `${DOCS_HOSTNAME}/main/` },
if (base === '/') return path; { text: 'All versions', link: `${DOCS_HOSTNAME}/versions` },
].map((item) => ({ ...item, target: '_self', noIcon: true }));
const basePath = base.replace(/\/$/, '');
const targetPath = path === '/' ? '/' : path.replace(/\/$/, '');
const relativePath = posix.relative(basePath, targetPath) || '.';
return path.endsWith('/') ? `${relativePath}/` : relativePath;
}
function shouldHandleLocalVersionRoute(pathname: string): boolean {
if (base !== '/' || channel !== 'stable-root') return false;
return /^\/main(?:\/|$)/.test(pathname) || /^\/v\/[^/]+(?:\/|$)/.test(pathname);
}
function contentTypeForPath(path: string): string {
switch (extname(path)) {
case '.css':
return 'text/css; charset=utf-8';
case '.gif':
return 'image/gif';
case '.ico':
return 'image/x-icon';
case '.jpg':
case '.jpeg':
return 'image/jpeg';
case '.js':
case '.mjs':
return 'text/javascript; charset=utf-8';
case '.json':
case '.jsonc':
return 'application/json; charset=utf-8';
case '.mp4':
return 'video/mp4';
case '.png':
return 'image/png';
case '.svg':
return 'image/svg+xml';
case '.ttf':
return 'font/ttf';
case '.webm':
return 'video/webm';
case '.woff':
return 'font/woff';
case '.woff2':
return 'font/woff2';
case '.xml':
return 'application/xml; charset=utf-8';
default:
return 'text/html; charset=utf-8';
}
}
function isFile(path: string): boolean {
try {
return statSync(path).isFile();
} catch {
return false;
}
}
function archiveFileForPathname(pathname: string): string | null {
if (!shouldHandleLocalVersionRoute(pathname)) return null;
const localArchiveDir = getLocalArchiveDir();
const routePath = decodeURIComponent(pathname).replace(/^\/+/, '');
const filePath = resolve(localArchiveDir, routePath);
if (filePath !== localArchiveDir && !filePath.startsWith(`${localArchiveDir}${sep}`)) {
return null;
}
const candidates = pathname.endsWith('/')
? [join(filePath, 'index.html')]
: extname(filePath)
? [filePath]
: [`${filePath}.html`, join(filePath, 'index.html')];
return candidates.find(isFile) ?? null;
}
function serveLocalArchiveRoute(pathname: string, response: DevServerResponse): boolean {
if (
(optionalEnv(process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN) ?? versionLinkOrigin) !== 'local'
) {
return false;
}
const filePath = archiveFileForPathname(pathname);
if (!filePath) return false;
response.statusCode = 200;
response.setHeader('Content-Type', contentTypeForPath(filePath));
response.end(readFileSync(filePath));
return true;
}
type DevServerResponse = {
statusCode: number;
setHeader(name: string, value: string): void;
end(chunk?: string | Uint8Array): void;
};
const versionItems = [
{
text: `Latest stable (${versionManifest.latestStable})`,
link: versionSwitchLink('/'),
target: '_self',
noIcon: true,
},
...versionManifest.channels
.filter((entry) => entry.label !== 'Latest stable')
.map((entry) => ({
text: entry.label,
link: versionSwitchLink(entry.path),
target: '_self',
noIcon: true,
})),
...versionManifest.versions.map((entry) => ({
text: entry.version,
link: versionSwitchLink(entry.path),
target: '_self',
noIcon: true,
})),
];
function sitemapUrlToPage(url: string): string { function sitemapUrlToPage(url: string): string {
const route = url.replace(/\.html$/, '').replace(/^\/+|\/+$/g, ''); const route = url.replace(/\.html$/, '').replace(/^\/+|\/+$/g, '');
@@ -336,7 +183,7 @@ const nav: DefaultTheme.NavItem[] = [
{ text: 'Configuration', link: '/configuration' }, { text: 'Configuration', link: '/configuration' },
{ text: 'Changelog', link: '/changelog' }, { text: 'Changelog', link: '/changelog' },
{ text: 'Troubleshooting', link: '/troubleshooting' }, { text: 'Troubleshooting', link: '/troubleshooting' },
{ text: docsVersion ?? (channel === 'main' ? 'main' : latestStable), items: versionItems }, { text: docsVersion ?? 'main', items: versionItems },
]; ];
const sidebar: DefaultTheme.SidebarItem[] = [ const sidebar: DefaultTheme.SidebarItem[] = [
@@ -394,33 +241,6 @@ const config: UserConfig = {
'SubMiner: an MPV immersion-mining overlay with Yomitan and AnkiConnect integration.', 'SubMiner: an MPV immersion-mining overlay with Yomitan and AnkiConnect integration.',
base, base,
...(outDir ? { outDir } : {}), ...(outDir ? { outDir } : {}),
vite: {
plugins: [
{
name: 'subminer-docs-local-version-redirects',
configureServer(server) {
server.middlewares.use((request, response, next) => {
const requestUrl = new URL(request.url ?? '/', 'http://localhost');
if (serveLocalArchiveRoute(requestUrl.pathname, response)) {
return;
}
if (!shouldHandleLocalVersionRoute(requestUrl.pathname)) {
next();
return;
}
response.statusCode = 302;
response.setHeader(
'Location',
`${DOCS_HOSTNAME}${requestUrl.pathname}${requestUrl.search}`,
);
response.end();
});
},
},
],
},
head: [ head: [
['link', { rel: 'preconnect', href: PLAUSIBLE_PROXY_HOSTNAME }], ['link', { rel: 'preconnect', href: PLAUSIBLE_PROXY_HOSTNAME }],
[ [
+16 -1
View File
@@ -40,8 +40,23 @@ The public docs root is stable-only:
- `/` serves the latest stable release docs. - `/` serves the latest stable release docs.
- `/main/` serves development docs from `main`. - `/main/` serves development docs from `main`.
- `/v/<version>/` serves stable release archives. - `/v/<version>/` serves stable release archives.
- `/versions` (root build only) lists every published version.
- Prerelease tags do not update the docs site. - Prerelease tags do not update the docs site.
Only `/` is indexable. `/main/` and every `/v/<version>/` page carries a self-referential canonical plus `noindex,follow`, and the generated `_headers` file repeats that as an `X-Robots-Tag`. They stay crawlable so their links still resolve, but ~30 archived copies of every page would otherwise consume the crawl budget the current docs need. Only the root build emits `sitemap.xml`, and its `<lastmod>` dates come from `git log` against the tracked checkout at the released tag, because the build renders from an untracked snapshot that VitePress cannot date itself. Only `/` is indexable. `/main/` and every `/v/<version>/` page carries a self-referential canonical plus `noindex,follow`, repeated as an `X-Robots-Tag` header (by the generated `_headers` file for `/main/`, by the archive function for `/v/`). They stay crawlable so their links still resolve, but ~30 archived copies of every page would otherwise consume the crawl budget the current docs need. Only the root build emits `sitemap.xml`, and its `<lastmod>` dates come from `git log` against the tracked checkout at the released tag, because the build renders from an untracked snapshot that VitePress cannot date itself.
Keep Cloudflare Git auto-deploy disabled. The production deploy is `.github/workflows/docs-pages.yml`, which uploads `.tmp/docs-versioned-site` with `--branch main` so tag-triggered runs update Production instead of creating preview deployments. Keep Cloudflare Git auto-deploy disabled. The production deploy is `.github/workflows/docs-pages.yml`, which uploads `.tmp/docs-versioned-site` with `--branch main` so tag-triggered runs update Production instead of creating preview deployments.
### Stable archives in R2
`/v/<version>/` archives are not part of the Pages deployment. Each one is built once, uploaded to an R2 bucket under `v/<version>/`, and served by the Pages Function in `functions/v/[[path]].ts`. Each deploy builds only archives the bucket is missing (an archive counts as present once its `_archive.json` marker exists), plus the root and `/main/` builds. Archive nav links to `/versions` instead of listing releases, so a new tag never invalidates old archives.
To re-render archives on purpose (theme change, docs fix), run the `Docs Pages` workflow manually with `rebuild_archives` set to comma-separated tags or `all`.
One-time setup:
- R2 bucket for archives; its name goes in the `DOCS_ARCHIVE_R2_BUCKET` repository variable.
- R2 API token with Object Read & Write on that bucket; its S3 credentials go in the `DOCS_ARCHIVE_R2_ACCESS_KEY_ID` and `DOCS_ARCHIVE_R2_SECRET_ACCESS_KEY` repository secrets.
- Pages project: Settings > Bindings > R2 bucket, variable name `DOCS_ARCHIVES`, pointing at the same bucket.
The first deploy after setup builds and uploads every stable archive; later deploys only add new tags.
+116
View File
@@ -0,0 +1,116 @@
import { expect, test } from 'bun:test';
import { onRequest, resolveArchiveRoute, type ArchiveBucket } from './functions/v/[[path]]';
// In-memory stand-in for the R2 binding, including the range and precondition behavior
// the function relies on.
function fakeBucket(objects: Record<string, string>): ArchiveBucket {
return {
async get(key, options) {
const content = objects[key];
if (content === undefined) return null;
const bytes = new TextEncoder().encode(content);
const httpEtag = `"${key}"`;
if (options?.onlyIf?.get('if-none-match') === httpEtag) {
return { size: bytes.length, httpEtag };
}
const rangeHeader = options?.range?.get('range');
const match = rangeHeader ? /^bytes=(\d+)-(\d*)$/.exec(rangeHeader) : null;
if (match) {
const offset = Number(match[1]);
const end = match[2] ? Number(match[2]) : bytes.length - 1;
const slice = bytes.slice(offset, end + 1);
return {
size: bytes.length,
httpEtag,
range: { offset, length: slice.length },
body: new Blob([slice]).stream(),
};
}
return { size: bytes.length, httpEtag, body: new Blob([bytes]).stream() };
},
};
}
const bucket = fakeBucket({
'v/0.19.6/index.html': '<h1>home</h1>',
'v/0.19.6/usage.html': '<h1>usage</h1>',
'v/0.19.6/404.html': '<h1>missing</h1>',
'v/0.19.6/assets/app.abc123.js': 'console.log(1)',
});
function request(path: string, init?: RequestInit) {
return onRequest({
request: new Request(`https://docs.subminer.moe${path}`, init),
env: { DOCS_ARCHIVES: bucket },
});
}
test('archive routes follow the clean-URL layout of the built archives', () => {
expect(resolveArchiveRoute('/v/0.19.6/usage')).toEqual({
kind: 'lookup',
version: '0.19.6',
keys: ['v/0.19.6/usage.html', 'v/0.19.6/usage/index.html'],
});
expect(resolveArchiveRoute('/v/0.19.6/')).toEqual({
kind: 'lookup',
version: '0.19.6',
keys: ['v/0.19.6/index.html'],
});
expect(resolveArchiveRoute('/v/0.19.6', '?q=1')).toEqual({
kind: 'redirect',
location: '/v/0.19.6/?q=1',
status: 301,
});
expect(resolveArchiveRoute('/v/')).toEqual({
kind: 'redirect',
location: '/versions',
status: 302,
});
expect(resolveArchiveRoute('/v/0.19.6/%2e%2e/secret')).toEqual({ kind: 'not-found' });
expect(resolveArchiveRoute('/v/latest/')).toEqual({ kind: 'not-found' });
});
test('serves archive pages and hashed assets with their cache policy', async () => {
const page = await request('/v/0.19.6/usage');
expect(page.status).toBe(200);
expect(page.headers.get('content-type')).toBe('text/html; charset=utf-8');
expect(page.headers.get('cache-control')).toBe('public, max-age=3600');
expect(page.headers.get('x-robots-tag')).toBe('noindex, follow');
expect(await page.text()).toBe('<h1>usage</h1>');
const asset = await request('/v/0.19.6/assets/app.abc123.js');
expect(asset.headers.get('content-type')).toBe('text/javascript; charset=utf-8');
expect(asset.headers.get('cache-control')).toContain('immutable');
});
test('missing archive pages fall back to the archive 404 page', async () => {
const response = await request('/v/0.19.6/nope');
expect(response.status).toBe(404);
expect(await response.text()).toBe('<h1>missing</h1>');
const unknownVersion = await request('/v/0.1.0/usage');
expect(unknownVersion.status).toBe(404);
});
test('supports byte ranges, conditional requests, and HEAD', async () => {
const partial = await request('/v/0.19.6/usage', { headers: { Range: 'bytes=4-8' } });
expect(partial.status).toBe(206);
expect(partial.headers.get('content-range')).toBe('bytes 4-8/14');
expect(await partial.text()).toBe('usage');
const notModified = await request('/v/0.19.6/usage', {
headers: { 'If-None-Match': '"v/0.19.6/usage.html"' },
});
expect(notModified.status).toBe(304);
const head = await request('/v/0.19.6/usage', { method: 'HEAD' });
expect(head.status).toBe(200);
expect(head.headers.get('content-length')).toBe('14');
expect(await head.text()).toBe('');
const post = await request('/v/0.19.6/usage', { method: 'POST' });
expect(post.status).toBe(405);
});
+2 -2
View File
@@ -127,7 +127,7 @@ For production docs routing, run the versioned build:
bun run docs:build:versioned bun run docs:build:versioned
``` ```
The versioned build writes `.tmp/docs-versioned-site` with latest stable docs at `/`, development docs at `/main/`, and stable archives under `/v/<version>/`. Prerelease tags are skipped. Public assets from `docs-site/public/assets` are shared from root `/assets/` so large demo media is not duplicated into every version archive; generated VitePress CSS and JS assets stay under each version route. Stale `.tmp/docs-versioned-archive-cache` generations are pruned after a successful build, and intermediate `.tmp/docs-versioned-build` workspaces are removed. The versioned build writes `.tmp/docs-versioned-site` with latest stable docs at `/` (plus a generated `/versions` page) and development docs at `/main/`. Prerelease tags are skipped. `/main/` shares public assets from root `/assets/` instead of duplicating them. Stable archives under `/v/<version>/` are built once and stored in R2 (see `docs-site/README.md`); without R2 credentials the build skips archive sync, so local runs only produce the root and `/main/` trees.
Focused commands: Focused commands:
@@ -192,7 +192,7 @@ From the SubMiner app repo:
```bash ```bash
bun --cwd docs-site install bun --cwd docs-site install
bun run docs:dev # Dev server at http://localhost:5173 bun run docs:dev # Dev server at http://localhost:5173 (version links go to production)
bun run docs:build # Production build into docs-site/.vitepress/dist bun run docs:build # Production build into docs-site/.vitepress/dist
bun run docs:preview # Preview built site at http://localhost:4173 bun run docs:preview # Preview built site at http://localhost:4173
bun run docs:test # Docs regression tests bun run docs:test # Docs regression tests
-10
View File
@@ -8,7 +8,6 @@ const installationContents = readFileSync(new URL('./installation.md', import.me
const mpvPluginContents = readFileSync(new URL('./mpv-plugin.md', import.meta.url), 'utf8'); const mpvPluginContents = readFileSync(new URL('./mpv-plugin.md', import.meta.url), 'utf8');
const developmentContents = readFileSync(new URL('./development.md', import.meta.url), 'utf8'); const developmentContents = readFileSync(new URL('./development.md', import.meta.url), 'utf8');
const changelogContents = readFileSync(new URL('./changelog.md', import.meta.url), 'utf8'); const changelogContents = readFileSync(new URL('./changelog.md', import.meta.url), 'utf8');
const docsPackageContents = readFileSync(new URL('./package.json', import.meta.url), 'utf8');
const ankiIntegrationContents = readFileSync( const ankiIntegrationContents = readFileSync(
new URL('./anki-integration.md', import.meta.url), new URL('./anki-integration.md', import.meta.url),
'utf8', 'utf8',
@@ -101,15 +100,6 @@ test('docs state the real secondary-subtitle and Anki field-matching behavior',
expect(ankiIntegrationContents).toContain('case-insensitively'); expect(ankiIntegrationContents).toContain('case-insensitively');
}); });
test('docs dev server links version navigation to local dev routes', () => {
expect(docsPackageContents).toContain('scripts/build-versioned-docs.ts');
expect(docsPackageContents).toContain(
'SUBMINER_DOCS_VERSION_LINK_ORIGIN=local bun run ../scripts/build-versioned-docs.ts',
);
expect(docsPackageContents).toContain('SUBMINER_DOCS_VERSION_LINK_ORIGIN=local');
expect(docsPackageContents).toContain('SUBMINER_DOCS_VERSION_MANIFEST');
});
test('docs changelog keeps the current minor release headings aligned with the root changelog', () => { test('docs changelog keeps the current minor release headings aligned with the root changelog', () => {
const docsHeadings = extractCurrentMinorHeadings(changelogContents); const docsHeadings = extractCurrentMinorHeadings(changelogContents);
expect(docsHeadings.length).toBeGreaterThan(0); expect(docsHeadings.length).toBeGreaterThan(0);
+212
View File
@@ -0,0 +1,212 @@
// Cloudflare Pages Function serving frozen `/v/<version>/` doc archives from R2.
// Archives are uploaded by scripts/build-versioned-docs.ts and never ship in the Pages
// deployment itself, so they do not count toward the Pages file limit. Requires an R2
// binding named DOCS_ARCHIVES on the Pages project (see docs-site/README.md).
// Minimal slice of the Workers R2 API used here; avoids a workers-types dependency.
type R2Range = { offset: number; length?: number } | { suffix: number };
type R2ObjectMeta = {
size: number;
httpEtag: string;
range?: R2Range;
};
type R2ObjectBody = R2ObjectMeta & { body: ReadableStream };
type R2GetOptions = { range?: Headers; onlyIf?: Headers };
export type ArchiveBucket = {
get(key: string, options?: R2GetOptions): Promise<R2ObjectMeta | R2ObjectBody | null>;
};
type ArchiveContext = {
request: Request;
env: { DOCS_ARCHIVES: ArchiveBucket };
};
export type ArchiveRoute =
| { kind: 'redirect'; location: string; status: 301 | 302 }
| { kind: 'lookup'; version: string; keys: string[] }
| { kind: 'not-found' };
const CONTENT_TYPES: Record<string, string> = {
css: 'text/css; charset=utf-8',
gif: 'image/gif',
html: 'text/html; charset=utf-8',
ico: 'image/x-icon',
jpeg: 'image/jpeg',
jpg: 'image/jpeg',
js: 'text/javascript; charset=utf-8',
json: 'application/json; charset=utf-8',
jsonc: 'application/json; charset=utf-8',
mjs: 'text/javascript; charset=utf-8',
mkv: 'video/x-matroska',
mp4: 'video/mp4',
png: 'image/png',
svg: 'image/svg+xml',
ttf: 'font/ttf',
txt: 'text/plain; charset=utf-8',
webm: 'video/webm',
webp: 'image/webp',
woff: 'font/woff',
woff2: 'font/woff2',
xml: 'application/xml; charset=utf-8',
};
function extensionOf(path: string): string | null {
const name = path.slice(path.lastIndexOf('/') + 1);
const dot = name.lastIndexOf('.');
return dot > 0 ? name.slice(dot + 1).toLowerCase() : null;
}
function contentTypeFor(key: string): string {
return CONTENT_TYPES[extensionOf(key) ?? ''] ?? 'application/octet-stream';
}
// Maps a request path onto candidate R2 keys, mirroring the Pages clean-URL rules the
// archives were built for (`cleanUrls: true`).
export function resolveArchiveRoute(pathname: string, search = ''): ArchiveRoute {
if (pathname === '/v' || pathname === '/v/') {
return { kind: 'redirect', location: '/versions', status: 302 };
}
const match = /^\/v\/(\d+\.\d+\.\d+)(\/.*)?$/.exec(pathname);
if (!match) return { kind: 'not-found' };
const version = match[1]!;
const rest = match[2];
if (!rest) {
return { kind: 'redirect', location: `/v/${version}/${search}`, status: 301 };
}
let decoded: string;
try {
decoded = decodeURIComponent(rest);
} catch {
return { kind: 'not-found' };
}
if (decoded.split('/').some((segment) => segment === '..' || segment === '.')) {
return { kind: 'not-found' };
}
const prefix = `v/${version}`;
const path = `${prefix}${decoded}`;
const keys = decoded.endsWith('/')
? [`${path}index.html`]
: extensionOf(decoded)
? [path]
: [`${path}.html`, `${path}/index.html`];
return { kind: 'lookup', version, keys };
}
function cacheControlFor(key: string): string {
// VitePress content-hashes everything it emits under assets/.
if (/\/assets\//.test(key) && extensionOf(key) !== 'html') {
return 'public, max-age=31536000, immutable';
}
return 'public, max-age=3600';
}
function hasBody(object: R2ObjectMeta | R2ObjectBody): object is R2ObjectBody {
return 'body' in object && object.body !== undefined;
}
function contentRange(range: R2Range, size: number): { start: number; end: number } {
if ('suffix' in range) {
const length = Math.min(range.suffix, size);
return { start: size - length, end: size - 1 };
}
const length = range.length ?? size - range.offset;
return { start: range.offset, end: range.offset + length - 1 };
}
async function respondWithObject(options: {
request: Request;
bucket: ArchiveBucket;
key: string;
status: number;
}): Promise<Response | null> {
const { request, bucket, key } = options;
// Ranges and conditional requests only make sense for the page that was asked for,
// not the 404 fallback.
const isRequestedObject = options.status === 200;
const wantsRange = isRequestedObject && request.headers.has('range');
let object: R2ObjectMeta | R2ObjectBody | null;
try {
object = await bucket.get(key, {
range: wantsRange ? request.headers : undefined,
onlyIf: isRequestedObject ? request.headers : undefined,
});
} catch {
// R2 rejects unsatisfiable ranges.
return new Response(null, { status: 416 });
}
if (!object) return null;
const headers = new Headers({
'Content-Type': contentTypeFor(key),
'Cache-Control': cacheControlFor(key),
ETag: object.httpEtag,
'Accept-Ranges': 'bytes',
'X-Robots-Tag': 'noindex, follow',
});
// R2 returns the object without a body when an If-None-Match/If-Modified-Since
// precondition matched.
if (!hasBody(object)) {
return new Response(null, { status: 304, headers });
}
let status = options.status;
if (wantsRange && object.range) {
const { start, end } = contentRange(object.range, object.size);
status = 206;
headers.set('Content-Range', `bytes ${start}-${end}/${object.size}`);
headers.set('Content-Length', String(end - start + 1));
} else {
headers.set('Content-Length', String(object.size));
}
return new Response(request.method === 'HEAD' ? null : object.body, { status, headers });
}
export async function onRequest({ request, env }: ArchiveContext): Promise<Response> {
if (request.method !== 'GET' && request.method !== 'HEAD') {
return new Response('Method Not Allowed', { status: 405, headers: { Allow: 'GET, HEAD' } });
}
const url = new URL(request.url);
const route = resolveArchiveRoute(url.pathname, url.search);
if (route.kind === 'redirect') {
return Response.redirect(new URL(route.location, url).toString(), route.status);
}
if (route.kind === 'lookup') {
for (const key of route.keys) {
const response = await respondWithObject({
request,
bucket: env.DOCS_ARCHIVES,
key,
status: 200,
});
if (response) return response;
}
const notFoundPage = await respondWithObject({
request,
bucket: env.DOCS_ARCHIVES,
key: `v/${route.version}/404.html`,
status: 404,
});
if (notFoundPage) return notFoundPage;
}
return new Response('Not Found', {
status: 404,
headers: { 'Content-Type': 'text/plain; charset=utf-8', 'X-Robots-Tag': 'noindex, follow' },
});
}
+2 -2
View File
@@ -5,10 +5,10 @@
"description": "In-repo VitePress documentation site for SubMiner", "description": "In-repo VitePress documentation site for SubMiner",
"packageManager": "bun@1.3.5", "packageManager": "bun@1.3.5",
"scripts": { "scripts": {
"docs:dev": "SUBMINER_DOCS_VERSION_LINK_ORIGIN=local bun run ../scripts/build-versioned-docs.ts && SUBMINER_DOCS_VERSION_LINK_ORIGIN=local SUBMINER_DOCS_VERSION_MANIFEST=\"$(bun run ../scripts/print-docs-version-manifest.ts)\" VITE_EXTRA_EXTENSIONS=jsonc vitepress dev --host 0.0.0.0 --port 5173 --strictPort", "docs:dev": "VITE_EXTRA_EXTENSIONS=jsonc vitepress dev --host 0.0.0.0 --port 5173 --strictPort",
"docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build", "docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build",
"docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview --host 0.0.0.0 --port 4173 --strictPort", "docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview --host 0.0.0.0 --port 4173 --strictPort",
"test": "bun test plausible.test.ts index.assets.test.ts docs-sync.test.ts links.test.ts seo.test.ts .vitepress/theme/status-line.test.ts ../scripts/docs-versioning.test.ts" "test": "bun test plausible.test.ts index.assets.test.ts archive-function.test.ts docs-sync.test.ts links.test.ts seo.test.ts .vitepress/theme/status-line.test.ts ../scripts/docs-versioning.test.ts"
}, },
"dependencies": { "dependencies": {
"@catppuccin/vitepress": "^0.1.2", "@catppuccin/vitepress": "^0.1.2",
+1 -17
View File
@@ -42,27 +42,11 @@ test('versioned docs reuse current VitePress internals for old page snapshots',
expect(versionedBuildContents).toContain('overlayCurrentVitePress(snapshotDocsSite)'); expect(versionedBuildContents).toContain('overlayCurrentVitePress(snapshotDocsSite)');
}); });
test('versioned docs build reports archive cache hits and rebuilds', () => { test('versioned docs build deduplicates main public assets and removes build workspaces', () => {
expect(versionedBuildContents).toContain(
'console.info(`[docs] archive cache key ${archiveCacheKey.slice(0, 12)}`)',
);
expect(versionedBuildContents).toContain('console.info(`[docs] cache hit ${version}`)');
expect(versionedBuildContents).toContain('console.info(`[docs] rebuilding archive ${version}`)');
});
test('versioned docs build deduplicates public assets and prunes stale workspaces', () => {
expect(versionedBuildContents).toContain('dedupeVersionedPublicAssets({'); expect(versionedBuildContents).toContain('dedupeVersionedPublicAssets({');
expect(versionedBuildContents).toContain('pruneArchiveCacheGenerations({');
expect(versionedBuildContents).toContain('rmSync(buildRoot, { recursive: true, force: true });'); expect(versionedBuildContents).toContain('rmSync(buildRoot, { recursive: true, force: true });');
}); });
test('versioned docs archive cache key ignores generated and test-only files', () => {
expect(versionedBuildContents).toContain('isSharedInternalsHashIgnoredPath(path)');
expect(versionedBuildContents).toContain('|| /\\.test\\.[cm]?[jt]s$/.test(path)');
expect(versionedBuildContents).toContain('process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN');
expect(versionedBuildContents).not.toContain('hash.update(String(stat.mode))');
});
test('docs builds exclude the internal README from VitePress page entries', () => { test('docs builds exclude the internal README from VitePress page entries', () => {
expect(docsConfigContents).toContain("srcExclude: ['subagents/**', 'README.md']"); expect(docsConfigContents).toContain("srcExclude: ['subagents/**', 'README.md']");
}); });
+18 -314
View File
@@ -1,7 +1,4 @@
import { expect, test } from 'bun:test'; import { expect, test } from 'bun:test';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import type { TransformContext } from 'vitepress'; import type { TransformContext } from 'vitepress';
import docsConfig from './.vitepress/config'; import docsConfig from './.vitepress/config';
@@ -63,19 +60,17 @@ test('main docs canonical uses /main/ and emits noindex', async () => {
}); });
test.each([ test.each([
['latest stable', 'v0.14.0', '/v/0.14.0/', 'https://docs.subminer.moe/v/0.14.0/usage'], ['v0.14.0', '/v/0.14.0/', 'https://docs.subminer.moe/v/0.14.0/usage'],
['superseded', 'v0.12.0', '/v/0.12.0/', 'https://docs.subminer.moe/v/0.12.0/usage'], ['v0.12.0', '/v/0.12.0/', 'https://docs.subminer.moe/v/0.12.0/usage'],
])( ])(
'%s archive keeps a self-referential canonical and stays out of the index', '%s archive keeps a self-referential canonical and stays out of the index',
async (_label, version, base, expectedCanonical) => { async (version, base, expectedCanonical) => {
const previousChannel = process.env.SUBMINER_DOCS_CHANNEL; const previousChannel = process.env.SUBMINER_DOCS_CHANNEL;
const previousBase = process.env.SUBMINER_DOCS_BASE; const previousBase = process.env.SUBMINER_DOCS_BASE;
const previousVersion = process.env.SUBMINER_DOCS_VERSION; const previousVersion = process.env.SUBMINER_DOCS_VERSION;
const previousLatest = process.env.SUBMINER_DOCS_LATEST_STABLE;
process.env.SUBMINER_DOCS_CHANNEL = 'stable-archive'; process.env.SUBMINER_DOCS_CHANNEL = 'stable-archive';
process.env.SUBMINER_DOCS_BASE = base; process.env.SUBMINER_DOCS_BASE = base;
process.env.SUBMINER_DOCS_VERSION = version; process.env.SUBMINER_DOCS_VERSION = version;
process.env.SUBMINER_DOCS_LATEST_STABLE = 'v0.14.0';
try { try {
const { default: archiveConfig } = await import(`./.vitepress/config?archive-${version}`); const { default: archiveConfig } = await import(`./.vitepress/config?archive-${version}`);
@@ -89,36 +84,19 @@ test.each([
process.env.SUBMINER_DOCS_CHANNEL = previousChannel; process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
process.env.SUBMINER_DOCS_BASE = previousBase; process.env.SUBMINER_DOCS_BASE = previousBase;
process.env.SUBMINER_DOCS_VERSION = previousVersion; process.env.SUBMINER_DOCS_VERSION = previousVersion;
process.env.SUBMINER_DOCS_LATEST_STABLE = previousLatest;
} }
}, },
); );
test('stable archive theme links stay on the selected version', async () => { test('archive nav keeps page links in-version and version links release-independent', async () => {
const previousCwd = process.cwd(); const previousCwd = process.cwd();
const previousChannel = process.env.SUBMINER_DOCS_CHANNEL; const previousChannel = process.env.SUBMINER_DOCS_CHANNEL;
const previousBase = process.env.SUBMINER_DOCS_BASE; const previousBase = process.env.SUBMINER_DOCS_BASE;
const previousVersion = process.env.SUBMINER_DOCS_VERSION; const previousVersion = process.env.SUBMINER_DOCS_VERSION;
const previousLatest = process.env.SUBMINER_DOCS_LATEST_STABLE;
const previousManifest = process.env.SUBMINER_DOCS_VERSION_MANIFEST;
const previousVersionLinkOrigin = process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN;
process.chdir(docsSiteDir); process.chdir(docsSiteDir);
process.env.SUBMINER_DOCS_CHANNEL = 'stable-archive'; process.env.SUBMINER_DOCS_CHANNEL = 'stable-archive';
process.env.SUBMINER_DOCS_BASE = '/v/0.12.0/'; process.env.SUBMINER_DOCS_BASE = '/v/0.12.0/';
process.env.SUBMINER_DOCS_VERSION = 'v0.12.0'; process.env.SUBMINER_DOCS_VERSION = 'v0.12.0';
process.env.SUBMINER_DOCS_LATEST_STABLE = 'v0.14.0';
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = 'production';
process.env.SUBMINER_DOCS_VERSION_MANIFEST = JSON.stringify({
latestStable: 'v0.14.0',
channels: [
{ label: 'Latest stable', path: '/' },
{ label: 'main', path: '/main/' },
],
versions: [
{ version: 'v0.14.0', path: '/v/0.14.0/' },
{ version: 'v0.12.0', path: '/v/0.12.0/' },
],
});
try { try {
const { default: archiveConfig } = await import('./.vitepress/config?stable-archive-links'); const { default: archiveConfig } = await import('./.vitepress/config?stable-archive-links');
@@ -131,39 +109,24 @@ test('stable archive theme links stay on the selected version', async () => {
text: string; text: string;
items?: Array<{ text: string; link: string }>; items?: Array<{ text: string; link: string }>;
}>; }>;
const configurationNav = nav.find((item) => item.text === 'Configuration'); const configurationSidebar = sidebar
const versionNav = nav.find((item) => item.text === 'v0.12.0'); .find((item) => item.text === 'Reference')
const referenceSidebar = sidebar.find((item) => item.text === 'Reference'); ?.items?.find((item) => item.text === 'Configuration');
const configurationSidebar = referenceSidebar?.items?.find(
(item) => item.text === 'Configuration',
);
expect(configurationNav?.link).toBe('/configuration'); expect(nav.find((item) => item.text === 'Configuration')?.link).toBe('/configuration');
expect(configurationSidebar?.link).toBe('/configuration'); expect(configurationSidebar?.link).toBe('/configuration');
expect(versionNav?.items).toContainEqual({ // Frozen archives must not embed the release list, or every new tag would
text: 'Latest stable (v0.14.0)', // invalidate them. They point at the root-only /versions page instead.
link: 'https://docs.subminer.moe/', expect(nav.find((item) => item.text === 'v0.12.0')?.items).toEqual([
{ text: 'Latest stable', link: 'https://docs.subminer.moe/', target: '_self', noIcon: true },
{ text: 'main', link: 'https://docs.subminer.moe/main/', target: '_self', noIcon: true },
{
text: 'All versions',
link: 'https://docs.subminer.moe/versions',
target: '_self', target: '_self',
noIcon: true, noIcon: true,
}); },
expect(versionNav?.items).toContainEqual({ ]);
text: 'main',
link: 'https://docs.subminer.moe/main/',
target: '_self',
noIcon: true,
});
expect(versionNav?.items).toContainEqual({
text: 'v0.14.0',
link: 'https://docs.subminer.moe/v/0.14.0/',
target: '_self',
noIcon: true,
});
expect(versionNav?.items).toContainEqual({
text: 'v0.12.0',
link: 'https://docs.subminer.moe/v/0.12.0/',
target: '_self',
noIcon: true,
});
expect(archiveConfig.themeConfig?.logo).toEqual({ expect(archiveConfig.themeConfig?.logo).toEqual({
light: '/assets/SubMiner.png', light: '/assets/SubMiner.png',
dark: '/assets/SubMiner.png', dark: '/assets/SubMiner.png',
@@ -173,268 +136,9 @@ test('stable archive theme links stay on the selected version', async () => {
process.env.SUBMINER_DOCS_CHANNEL = previousChannel; process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
process.env.SUBMINER_DOCS_BASE = previousBase; process.env.SUBMINER_DOCS_BASE = previousBase;
process.env.SUBMINER_DOCS_VERSION = previousVersion; process.env.SUBMINER_DOCS_VERSION = previousVersion;
process.env.SUBMINER_DOCS_LATEST_STABLE = previousLatest;
process.env.SUBMINER_DOCS_VERSION_MANIFEST = previousManifest;
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = previousVersionLinkOrigin;
} }
}); });
test('local stable archive version links stay on the dev server', async () => {
const previousCwd = process.cwd();
const previousChannel = process.env.SUBMINER_DOCS_CHANNEL;
const previousBase = process.env.SUBMINER_DOCS_BASE;
const previousVersion = process.env.SUBMINER_DOCS_VERSION;
const previousLatest = process.env.SUBMINER_DOCS_LATEST_STABLE;
const previousManifest = process.env.SUBMINER_DOCS_VERSION_MANIFEST;
const previousVersionLinkOrigin = process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN;
process.chdir(docsSiteDir);
process.env.SUBMINER_DOCS_CHANNEL = 'stable-archive';
process.env.SUBMINER_DOCS_BASE = '/v/0.10.0/';
process.env.SUBMINER_DOCS_VERSION = 'v0.10.0';
process.env.SUBMINER_DOCS_LATEST_STABLE = 'v0.14.0';
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = 'local';
process.env.SUBMINER_DOCS_VERSION_MANIFEST = JSON.stringify({
latestStable: 'v0.14.0',
channels: [
{ label: 'Latest stable', path: '/' },
{ label: 'main', path: '/main/' },
],
versions: [
{ version: 'v0.14.0', path: '/v/0.14.0/' },
{ version: 'v0.10.0', path: '/v/0.10.0/' },
],
});
try {
const { default: archiveConfig } = await import('./.vitepress/config?local-archive-links');
const nav = archiveConfig.themeConfig?.nav as Array<{
text: string;
items?: Array<{ text: string; link: string }>;
}>;
const versionNav = nav.find((item) => item.text === 'v0.10.0');
expect(versionNav?.items).toContainEqual({
text: 'Latest stable (v0.14.0)',
link: '../../',
target: '_self',
noIcon: true,
});
expect(versionNav?.items).toContainEqual({
text: 'main',
link: '../../main/',
target: '_self',
noIcon: true,
});
expect(versionNav?.items).toContainEqual({
text: 'v0.14.0',
link: '../0.14.0/',
target: '_self',
noIcon: true,
});
expect(versionNav?.items).toContainEqual({
text: 'v0.10.0',
link: './',
target: '_self',
noIcon: true,
});
} finally {
process.chdir(previousCwd);
process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
process.env.SUBMINER_DOCS_BASE = previousBase;
process.env.SUBMINER_DOCS_VERSION = previousVersion;
process.env.SUBMINER_DOCS_LATEST_STABLE = previousLatest;
process.env.SUBMINER_DOCS_VERSION_MANIFEST = previousManifest;
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = previousVersionLinkOrigin;
}
});
test('dev docs version links use local targets for version route testing', async () => {
const previousCwd = process.cwd();
const previousChannel = process.env.SUBMINER_DOCS_CHANNEL;
const previousBase = process.env.SUBMINER_DOCS_BASE;
const previousVersion = process.env.SUBMINER_DOCS_VERSION;
const previousLatest = process.env.SUBMINER_DOCS_LATEST_STABLE;
const previousManifest = process.env.SUBMINER_DOCS_VERSION_MANIFEST;
const previousVersionLinkOrigin = process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN;
process.chdir(docsSiteDir);
delete process.env.SUBMINER_DOCS_CHANNEL;
delete process.env.SUBMINER_DOCS_BASE;
delete process.env.SUBMINER_DOCS_VERSION;
// Set explicitly (like the sibling version-nav tests) so this assertion stays
// pinned to the manifest under test instead of the config's fallback constant.
process.env.SUBMINER_DOCS_LATEST_STABLE = 'v0.14.0';
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = 'local';
process.env.SUBMINER_DOCS_VERSION_MANIFEST = JSON.stringify({
latestStable: 'v0.14.0',
channels: [
{ label: 'Latest stable', path: '/' },
{ label: 'main', path: '/main/' },
],
versions: [
{ version: 'v0.14.0', path: '/v/0.14.0/' },
{ version: 'v0.12.0', path: '/v/0.12.0/' },
{ version: 'v0.11.2', path: '/v/0.11.2/' },
],
});
try {
const { default: devConfig } = await import('./.vitepress/config?dev-version-links');
const nav = devConfig.themeConfig?.nav as Array<{
text: string;
items?: Array<{ text: string; link: string }>;
}>;
const versionNav = nav.find((item) => item.text === 'v0.14.0');
expect(versionNav?.items).toContainEqual({
text: 'Latest stable (v0.14.0)',
link: '/',
target: '_self',
noIcon: true,
});
expect(versionNav?.items).toContainEqual({
text: 'main',
link: '/main/',
target: '_self',
noIcon: true,
});
expect(versionNav?.items).toContainEqual({
text: 'v0.12.0',
link: '/v/0.12.0/',
target: '_self',
noIcon: true,
});
expect(versionNav?.items?.map((item) => item.text)).toEqual([
'Latest stable (v0.14.0)',
'main',
'v0.14.0',
'v0.12.0',
'v0.11.2',
]);
} finally {
process.chdir(previousCwd);
process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
process.env.SUBMINER_DOCS_BASE = previousBase;
process.env.SUBMINER_DOCS_VERSION = previousVersion;
process.env.SUBMINER_DOCS_LATEST_STABLE = previousLatest;
process.env.SUBMINER_DOCS_VERSION_MANIFEST = previousManifest;
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = previousVersionLinkOrigin;
}
});
test('dev server redirects unserved version routes to production docs', () => {
let routeHandler:
| ((req: { url?: string }, res: DevRedirectResponse, next: () => void) => void)
| undefined;
const fakeServer = {
middlewares: {
use(handler: typeof routeHandler) {
routeHandler = handler;
},
},
};
const plugins = Array.isArray(docsConfig.vite?.plugins)
? docsConfig.vite.plugins
: [docsConfig.vite?.plugins].filter(Boolean);
const redirectPlugin = plugins.find(
(plugin): plugin is { name: string; configureServer: (server: never) => void } =>
Boolean(plugin) &&
typeof plugin === 'object' &&
'name' in plugin &&
plugin.name === 'subminer-docs-local-version-redirects' &&
'configureServer' in plugin,
);
expect(redirectPlugin).toBeDefined();
redirectPlugin?.configureServer(fakeServer as never);
const response = new DevRedirectResponse();
let nextCalled = false;
routeHandler?.({ url: '/v/0.14.0/?from=dev' }, response, () => {
nextCalled = true;
});
expect(nextCalled).toBe(false);
expect(response.statusCode).toBe(302);
expect(response.headers.location).toBe('https://docs.subminer.moe/v/0.14.0/?from=dev');
const rootResponse = new DevRedirectResponse();
routeHandler?.({ url: '/configuration' }, rootResponse, () => {
nextCalled = true;
});
expect(rootResponse.ended).toBe(false);
expect(nextCalled).toBe(true);
});
test('dev server serves local archive files for local version links', async () => {
const previousVersionLinkOrigin = process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN;
const previousArchiveDir = process.env.SUBMINER_DOCS_LOCAL_ARCHIVE_DIR;
const archiveDir = mkdtempSync(join(tmpdir(), 'subminer-docs-archive-'));
mkdirSync(join(archiveDir, 'v/0.14.0'), { recursive: true });
writeFileSync(join(archiveDir, 'v/0.14.0/index.html'), '<h1>local archive</h1>');
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = 'local';
process.env.SUBMINER_DOCS_LOCAL_ARCHIVE_DIR = archiveDir;
try {
const { default: localDevConfig } = await import(
`./.vitepress/config?local-dev-redirects-${Date.now()}`
);
let routeHandler:
| ((req: { url?: string }, res: DevRedirectResponse, next: () => void) => void)
| undefined;
const fakeServer = {
middlewares: {
use(handler: typeof routeHandler) {
routeHandler = handler;
},
},
};
const plugins = Array.isArray(localDevConfig.vite?.plugins)
? localDevConfig.vite.plugins
: [localDevConfig.vite?.plugins].filter(Boolean);
const redirectPlugin = plugins.find(
(plugin): plugin is { name: string; configureServer: (server: never) => void } =>
Boolean(plugin) &&
typeof plugin === 'object' &&
'name' in plugin &&
plugin.name === 'subminer-docs-local-version-redirects' &&
'configureServer' in plugin,
);
redirectPlugin?.configureServer(fakeServer as never);
const response = new DevRedirectResponse();
let nextCalled = false;
routeHandler?.({ url: '/v/0.14.0/?from=dev' }, response, () => {
nextCalled = true;
});
expect(nextCalled).toBe(false);
expect(response.statusCode).toBe(200);
expect(response.headers['content-type']).toBe('text/html; charset=utf-8');
expect(response.headers.location).toBeUndefined();
expect(response.body).toBe('<h1>local archive</h1>');
} finally {
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = previousVersionLinkOrigin;
process.env.SUBMINER_DOCS_LOCAL_ARCHIVE_DIR = previousArchiveDir;
rmSync(archiveDir, { recursive: true, force: true });
}
});
class DevRedirectResponse {
statusCode = 200;
headers: Record<string, string> = {};
ended = false;
body = '';
setHeader(name: string, value: string) {
this.headers[name.toLowerCase()] = value;
}
end(chunk?: string | Uint8Array) {
if (chunk) {
this.body = typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk);
}
this.ended = true;
}
}
test('docs sitemap excludes duplicate README page from indexable URLs', async () => { test('docs sitemap excludes duplicate README page from indexable URLs', async () => {
const items = [{ url: '' }, { url: 'README' }, { url: 'usage' }]; const items = [{ url: '' }, { url: 'README' }, { url: 'usage' }];
+99 -146
View File
@@ -1,49 +1,52 @@
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { import {
cpSync, cpSync,
existsSync, existsSync,
lstatSync, lstatSync,
mkdirSync, mkdirSync,
readFileSync,
readdirSync, readdirSync,
readlinkSync,
rmSync, rmSync,
symlinkSync, symlinkSync,
writeFileSync, writeFileSync,
} from 'node:fs'; } from 'node:fs';
import { join, resolve } from 'node:path'; import { join, resolve } from 'node:path';
import { import {
collectSharedAssetPaths, archiveStoreEnvFromProcess,
dedupeVersionedPublicAssets, createArchiveStore,
pruneArchiveCacheGenerations, type DocsArchiveStore,
} from './docs-versioned-assets'; } from './docs-archive-store';
import { collectSharedAssetPaths, dedupeVersionedPublicAssets } from './docs-versioned-assets';
import { import {
buildVersionManifest, buildVersionManifest,
renderVersionsPage,
stableTagsWithDocs, stableTagsWithDocs,
versionArchiveCacheKey,
versionArchiveCacheName,
versionOutputPath, versionOutputPath,
versionPath, versionPath,
} from './docs-versioning'; } from './docs-versioning';
// Assembles the Cloudflare Pages deployment: latest stable at `/` and development docs
// at `/main/`. Stable archives under `/v/<version>/` are built once, uploaded to R2, and
// never rebuilt unless requested; this script only fills in archives R2 is missing.
//
// Flags:
// --require-archives fail instead of skipping archive sync without R2 credentials
// --rebuild-archives=<list> comma-separated tags (or `all`) to rebuild even if present
const repoRoot = resolve(__dirname, '..'); const repoRoot = resolve(__dirname, '..');
const currentDocsSite = join(repoRoot, 'docs-site'); const currentDocsSite = join(repoRoot, 'docs-site');
const buildRoot = join(repoRoot, '.tmp/docs-versioned-build'); const buildRoot = join(repoRoot, '.tmp/docs-versioned-build');
const aggregateOutDir = join(repoRoot, '.tmp/docs-versioned-site'); const aggregateOutDir = join(repoRoot, '.tmp/docs-versioned-site');
const archiveCacheRoot = join(repoRoot, '.tmp/docs-versioned-archive-cache'); const archiveOutRoot = join(repoRoot, '.tmp/docs-versioned-archives');
const maxCloudflareFiles = 20_000; const maxCloudflareFiles = 20_000;
const maxCloudflareFileBytes = 25 * 1024 * 1024; const maxCloudflareFileBytes = 25 * 1024 * 1024;
// Cloudflare Pages header rules for the whole deployment. Mirrors the `noindex,follow` // Cloudflare Pages header rules for the static deployment. Mirrors the `noindex,follow`
// meta tag the non-root channels emit, so the duplicate trees stay out of the index // meta tag the `main` channel emits, so the duplicate tree stays out of the index even
// even for responses a crawler takes without parsing the HTML. // for responses a crawler takes without parsing the HTML. `/v/*` is served by the
// archive Pages Function, which sets the same header itself.
const deployHeaders = `# Generated by scripts/build-versioned-docs.ts. Do not edit by hand. const deployHeaders = `# Generated by scripts/build-versioned-docs.ts. Do not edit by hand.
/main/* /main/*
X-Robots-Tag: noindex, follow X-Robots-Tag: noindex, follow
/v/*
X-Robots-Tag: noindex, follow
`; `;
function run( function run(
@@ -103,8 +106,7 @@ function copyCurrentDocsSite(targetDir: string) {
recursive: true, recursive: true,
dereference: false, dereference: false,
filter: (source) => filter: (source) =>
!/[\\/]node_modules([\\/]|$)/.test(source) && !/[\\/]node_modules([\\/]|$)/.test(source) && !isGeneratedVitePressPath(source),
!/[\\/]\\.vitepress[\\/]dist([\\/]|$)/.test(source),
}); });
} }
@@ -173,8 +175,6 @@ function buildDocs(options: {
outDir: string; outDir: string;
channel: string; channel: string;
version?: string; version?: string;
latestStable: string;
manifestJson: string;
}) { }) {
console.info(`[docs] building ${options.version ?? options.channel} -> ${options.base}`); console.info(`[docs] building ${options.version ?? options.channel} -> ${options.base}`);
run('bun', ['run', '--cwd', currentDocsSite, 'vitepress', 'build', options.snapshotDocsSite], { run('bun', ['run', '--cwd', currentDocsSite, 'vitepress', 'build', options.snapshotDocsSite], {
@@ -187,98 +187,13 @@ function buildDocs(options: {
SUBMINER_DOCS_REPO_DIR: currentDocsSite, SUBMINER_DOCS_REPO_DIR: currentDocsSite,
SUBMINER_DOCS_CHANNEL: options.channel, SUBMINER_DOCS_CHANNEL: options.channel,
SUBMINER_DOCS_VERSION: options.version ?? '', SUBMINER_DOCS_VERSION: options.version ?? '',
SUBMINER_DOCS_LATEST_STABLE: options.latestStable,
SUBMINER_DOCS_VERSION_MANIFEST: options.manifestJson,
VITE_EXTRA_EXTENSIONS: 'jsonc', VITE_EXTRA_EXTENSIONS: 'jsonc',
}, },
}); });
} }
function updateHashWithPath(hash: ReturnType<typeof createHash>, path: string) {
if (isSharedInternalsHashIgnoredPath(path)) {
return;
}
const stat = lstatSync(path);
const relativePath = path.replace(repoRoot, '');
if (stat.isSymbolicLink()) {
hash.update(`symlink:${relativePath}`);
hash.update(readlinkSync(path));
return;
}
if (stat.isDirectory()) {
hash.update(`dir:${relativePath}`);
for (const entry of readdirSync(path).sort()) {
updateHashWithPath(hash, join(path, entry));
}
return;
}
hash.update(`file:${relativePath}`);
hash.update(readFileSync(path));
}
function isGeneratedVitePressPath(path: string): boolean { function isGeneratedVitePressPath(path: string): boolean {
return /[\\/]\\.vitepress[\\/](cache|dist)([\\/]|$)/.test(path); return /[\\/]\.vitepress[\\/](cache|dist)([\\/]|$)/.test(path);
}
function isSharedInternalsHashIgnoredPath(path: string): boolean {
return isGeneratedVitePressPath(path) || /\.test\.[cm]?[jt]s$/.test(path);
}
function computeSharedInternalsHash(): string {
const hash = createHash('sha256');
hash.update(
`version-link-origin:${process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN === 'local' ? 'local' : 'production'}`,
);
const paths = [
join(currentDocsSite, '.vitepress'),
join(currentDocsSite, 'public/assets/fonts'),
join(currentDocsSite, 'package.json'),
join(currentDocsSite, 'bun.lock'),
join(repoRoot, 'scripts/build-versioned-docs.ts'),
join(repoRoot, 'scripts/docs-versioning.ts'),
];
for (const path of paths) {
if (existsSync(path)) {
updateHashWithPath(hash, path);
}
}
return hash.digest('hex');
}
function archiveCachePath(version: string, sharedInternalsHash: string): string {
return join(archiveCacheRoot, versionArchiveCacheName(version, sharedInternalsHash));
}
function restoreCachedArchive(version: string, sharedInternalsHash: string): boolean {
const cachedArchive = archiveCachePath(version, sharedInternalsHash);
if (!existsSync(cachedArchive)) {
return false;
}
console.info(`[docs] cache hit ${version}`);
cpSync(cachedArchive, join(aggregateOutDir, versionOutputPath(version)), {
recursive: true,
force: true,
});
return true;
}
function saveArchiveCache(version: string, sharedInternalsHash: string) {
const outputPath = join(aggregateOutDir, versionOutputPath(version));
if (!existsSync(outputPath)) {
return;
}
const cachedArchive = archiveCachePath(version, sharedInternalsHash);
rmSync(cachedArchive, { recursive: true, force: true });
mkdirSync(archiveCacheRoot, { recursive: true });
cpSync(outputPath, cachedArchive, { recursive: true, force: true });
} }
function assertCloudflarePagesLimits(root: string) { function assertCloudflarePagesLimits(root: string) {
@@ -317,7 +232,66 @@ function assertCloudflarePagesLimits(root: string) {
} }
} }
function currentCommit(): string {
return capture('git', ['rev-parse', 'HEAD']).trim();
}
function parseArgs(argv: string[]): { requireArchives: boolean; rebuild: Set<string> | 'all' } {
let requireArchives = false;
let rebuild: Set<string> | 'all' = new Set();
for (const arg of argv) {
if (arg === '--require-archives') {
requireArchives = true;
} else if (arg.startsWith('--rebuild-archives=')) {
const value = arg.slice('--rebuild-archives='.length).trim();
rebuild =
value === 'all'
? 'all'
: new Set(
value
.split(',')
.map((tag) => tag.trim())
.filter(Boolean),
);
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
return { requireArchives, rebuild };
}
// Builds and uploads every stable archive that R2 does not already hold (or that was
// explicitly requested). Old tags are rendered with the current `.vitepress` overlay.
function syncArchives(options: {
store: DocsArchiveStore;
stableVersions: string[];
rebuild: Set<string> | 'all';
}) {
const builtFrom = currentCommit();
for (const version of options.stableVersions) {
const forced = options.rebuild === 'all' || options.rebuild.has(version);
if (!forced && options.store.has(version)) {
continue;
}
console.info(`[docs] building archive ${version}`);
const outDir = join(archiveOutRoot, versionOutputPath(version));
rmSync(outDir, { recursive: true, force: true });
buildDocs({
snapshotDocsSite: prepareSnapshot(version, version),
base: versionPath(version),
outDir,
channel: 'stable-archive',
version,
});
console.info(`[docs] uploading archive ${version}`);
options.store.upload(version, outDir, builtFrom);
rmSync(outDir, { recursive: true, force: true });
}
}
function main() { function main() {
const { requireArchives, rebuild } = parseArgs(process.argv.slice(2));
const stableVersions = getStableVersions(); const stableVersions = getStableVersions();
const latestStable = stableVersions[0]; const latestStable = stableVersions[0];
@@ -325,54 +299,42 @@ function main() {
throw new Error('No stable release tags with docs-site/package.json found.'); throw new Error('No stable release tags with docs-site/package.json found.');
} }
if (rebuild !== 'all') {
const unknown = [...rebuild].filter((tag) => !stableVersions.includes(tag));
if (unknown.length > 0) {
throw new Error(`Cannot rebuild unknown stable docs versions: ${unknown.join(', ')}`);
}
}
const manifest = buildVersionManifest({ latestStable, stableVersions }); const manifest = buildVersionManifest({ latestStable, stableVersions });
const manifestJson = JSON.stringify(manifest);
const sharedInternalsHash = computeSharedInternalsHash();
const archiveCacheKey = versionArchiveCacheKey({ sharedInternalsHash, manifestJson });
const sharedAssetPaths = collectSharedAssetPaths(join(currentDocsSite, 'public/assets')); const sharedAssetPaths = collectSharedAssetPaths(join(currentDocsSite, 'public/assets'));
console.info(`[docs] archive cache key ${archiveCacheKey.slice(0, 12)}`);
rmSync(buildRoot, { recursive: true, force: true }); rmSync(buildRoot, { recursive: true, force: true });
rmSync(aggregateOutDir, { recursive: true, force: true }); rmSync(aggregateOutDir, { recursive: true, force: true });
mkdirSync(buildRoot, { recursive: true }); mkdirSync(buildRoot, { recursive: true });
mkdirSync(aggregateOutDir, { recursive: true }); mkdirSync(aggregateOutDir, { recursive: true });
const store = createArchiveStore(archiveStoreEnvFromProcess(process.env));
if (store) {
syncArchives({ store, stableVersions, rebuild });
} else if (requireArchives) {
throw new Error(
'Docs archive R2 credentials are missing (CLOUDFLARE_ACCOUNT_ID, DOCS_ARCHIVE_R2_ACCESS_KEY_ID, DOCS_ARCHIVE_R2_SECRET_ACCESS_KEY, DOCS_ARCHIVE_R2_BUCKET).',
);
} else {
console.warn('[docs] R2 credentials not set; skipping /v/<version>/ archive sync');
}
const latestStableSnapshot = prepareSnapshot(latestStable, latestStable); const latestStableSnapshot = prepareSnapshot(latestStable, latestStable);
writeFileSync(join(latestStableSnapshot, 'versions.md'), renderVersionsPage(manifest));
buildDocs({ buildDocs({
snapshotDocsSite: latestStableSnapshot, snapshotDocsSite: latestStableSnapshot,
base: '/', base: '/',
outDir: aggregateOutDir, outDir: aggregateOutDir,
channel: 'stable-root', channel: 'stable-root',
version: latestStable, version: latestStable,
latestStable,
manifestJson,
}); });
for (const version of stableVersions) {
if (restoreCachedArchive(version, archiveCacheKey)) {
continue;
}
console.info(`[docs] rebuilding archive ${version}`);
const snapshot =
version === latestStable ? latestStableSnapshot : prepareSnapshot(version, version);
buildDocs({
snapshotDocsSite: snapshot,
base: versionPath(version),
outDir: join(aggregateOutDir, versionOutputPath(version)),
channel: 'stable-archive',
version,
latestStable,
manifestJson,
});
dedupeVersionedPublicAssets({
outDir: join(aggregateOutDir, versionOutputPath(version)),
base: versionPath(version),
sharedAssetPaths,
});
saveArchiveCache(version, archiveCacheKey);
}
const mainSnapshot = prepareSnapshot('main'); const mainSnapshot = prepareSnapshot('main');
buildDocs({ buildDocs({
snapshotDocsSite: mainSnapshot, snapshotDocsSite: mainSnapshot,
@@ -380,8 +342,6 @@ function main() {
outDir: join(aggregateOutDir, 'main'), outDir: join(aggregateOutDir, 'main'),
channel: 'main', channel: 'main',
version: 'main', version: 'main',
latestStable,
manifestJson,
}); });
dedupeVersionedPublicAssets({ dedupeVersionedPublicAssets({
outDir: join(aggregateOutDir, 'main'), outDir: join(aggregateOutDir, 'main'),
@@ -392,13 +352,6 @@ function main() {
writeFileSync(join(aggregateOutDir, 'versions.json'), `${JSON.stringify(manifest, null, 2)}\n`); writeFileSync(join(aggregateOutDir, 'versions.json'), `${JSON.stringify(manifest, null, 2)}\n`);
writeFileSync(join(aggregateOutDir, '_headers'), deployHeaders); writeFileSync(join(aggregateOutDir, '_headers'), deployHeaders);
assertCloudflarePagesLimits(aggregateOutDir); assertCloudflarePagesLimits(aggregateOutDir);
const prunedArchives = pruneArchiveCacheGenerations({
cacheRoot: archiveCacheRoot,
activeCacheKey: archiveCacheKey,
});
if (prunedArchives.length > 0) {
console.info(`[docs] pruned ${prunedArchives.length} stale archive cache directories`);
}
rmSync(buildRoot, { recursive: true, force: true }); rmSync(buildRoot, { recursive: true, force: true });
} }
+107
View File
@@ -0,0 +1,107 @@
import { spawnSync } from 'node:child_process';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { versionOutputPath } from './docs-versioning';
// Frozen `/v/<version>/` doc builds live in an R2 bucket and are served by the Pages
// Function in `docs-site/functions/v/[[path]].ts`, so they never count toward the Pages
// deployment. Transfers go through the AWS CLI's S3 API (preinstalled on GitHub runners).
// Written last on upload; an archive without it is treated as missing and rebuilt.
export const ARCHIVE_MARKER = '_archive.json';
export type DocsArchiveStore = {
has(version: string): boolean;
upload(version: string, dir: string, builtFrom: string): void;
};
export type DocsArchiveStoreEnv = {
accountId?: string;
accessKeyId?: string;
secretAccessKey?: string;
bucket?: string;
};
export function archiveStoreEnvFromProcess(env: NodeJS.ProcessEnv): DocsArchiveStoreEnv {
return {
accountId: env.CLOUDFLARE_ACCOUNT_ID,
accessKeyId: env.DOCS_ARCHIVE_R2_ACCESS_KEY_ID,
secretAccessKey: env.DOCS_ARCHIVE_R2_SECRET_ACCESS_KEY,
bucket: env.DOCS_ARCHIVE_R2_BUCKET,
};
}
export function archiveKeyPrefix(version: string): string {
return `${versionOutputPath(version)}/`;
}
// Returns null when credentials are absent so local builds can skip archive sync.
export function createArchiveStore(config: DocsArchiveStoreEnv): DocsArchiveStore | null {
const { accountId, accessKeyId, secretAccessKey, bucket } = config;
if (!accountId || !accessKeyId || !secretAccessKey || !bucket) {
return null;
}
const endpoint = `https://${accountId}.r2.cloudflarestorage.com`;
const env: NodeJS.ProcessEnv = {
...process.env,
AWS_ACCESS_KEY_ID: accessKeyId,
AWS_SECRET_ACCESS_KEY: secretAccessKey,
AWS_DEFAULT_REGION: 'auto',
// AWS CLI >= 2.23 sends CRC checksums by default, which R2 does not fully support.
AWS_REQUEST_CHECKSUM_CALCULATION: 'when_required',
AWS_RESPONSE_CHECKSUM_VALIDATION: 'when_required',
};
function aws(args: string[]) {
return spawnSync('aws', [...args, '--endpoint-url', endpoint], {
env,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
}
return {
has(version) {
const result = aws([
's3api',
'head-object',
'--bucket',
bucket,
'--key',
`${archiveKeyPrefix(version)}${ARCHIVE_MARKER}`,
]);
if (result.error) throw result.error;
if (result.status === 0) return true;
if (/\b404\b|Not Found/i.test(result.stderr)) return false;
throw new Error(`Unable to check docs archive ${version}: ${result.stderr.trim()}`);
},
upload(version, dir, builtFrom) {
const target = `s3://${bucket}/${archiveKeyPrefix(version)}`;
// Unmark first so a rebuild that fails partway is retried by the next deploy
// instead of being skipped as complete. Deleting a missing key succeeds.
const unmark = aws(['s3', 'rm', `${target}${ARCHIVE_MARKER}`, '--only-show-errors']);
if (unmark.error) throw unmark.error;
if (unmark.status !== 0) {
throw new Error(`Unable to unmark docs archive ${version}: ${unmark.stderr.trim()}`);
}
const sync = aws(['s3', 'sync', dir, target, '--only-show-errors']);
if (sync.error) throw sync.error;
if (sync.status !== 0) {
throw new Error(`Unable to upload docs archive ${version}: ${sync.stderr.trim()}`);
}
const markerPath = join(dir, ARCHIVE_MARKER);
writeFileSync(
markerPath,
`${JSON.stringify({ version, builtFrom, builtAt: new Date().toISOString() }, null, 2)}\n`,
);
const marker = aws(['s3', 'cp', markerPath, `${target}${ARCHIVE_MARKER}`]);
if (marker.status !== 0) {
throw new Error(`Unable to mark docs archive ${version}: ${marker.stderr.trim()}`);
}
},
};
}
+1 -30
View File
@@ -3,11 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from
import { rm } from 'node:fs/promises'; import { rm } from 'node:fs/promises';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { import { dedupeVersionedPublicAssets, rewriteSharedAssetReferences } from './docs-versioned-assets';
dedupeVersionedPublicAssets,
pruneArchiveCacheGenerations,
rewriteSharedAssetReferences,
} from './docs-versioned-assets';
function tempDir() { function tempDir() {
return mkdtempSync(join(tmpdir(), 'subminer-docs-versioned-assets-')); return mkdtempSync(join(tmpdir(), 'subminer-docs-versioned-assets-'));
@@ -97,28 +93,3 @@ describe('docs versioned asset dedupe', () => {
} }
}); });
}); });
describe('docs archive cache pruning', () => {
test('removes stale cache generations while keeping the active generation', async () => {
const dir = tempDir();
try {
mkdirSync(join(dir, 'active123456-v0.14.0'), { recursive: true });
mkdirSync(join(dir, 'stale654321-v0.14.0'), { recursive: true });
mkdirSync(join(dir, 'stale654321-v0.13.0'), { recursive: true });
const removed = pruneArchiveCacheGenerations({
cacheRoot: dir,
activeCacheKey: 'active123456abcdef',
});
expect(removed.sort()).toEqual([
join(dir, 'stale654321-v0.13.0'),
join(dir, 'stale654321-v0.14.0'),
]);
expect(existsSync(join(dir, 'active123456-v0.14.0'))).toBe(true);
expect(existsSync(join(dir, 'stale654321-v0.14.0'))).toBe(false);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
});
-27
View File
@@ -119,30 +119,3 @@ function removeEmptyDirectories(root: string) {
rmSync(root, { recursive: true, force: true }); rmSync(root, { recursive: true, force: true });
} }
} }
export function pruneArchiveCacheGenerations(options: {
cacheRoot: string;
activeCacheKey: string;
}): string[] {
if (!existsSync(options.cacheRoot)) {
return [];
}
const activePrefix = options.activeCacheKey.slice(0, 12);
const removed: string[] = [];
for (const entry of readdirSync(options.cacheRoot)) {
const path = join(options.cacheRoot, entry);
if (!lstatSync(path).isDirectory()) {
continue;
}
if (entry.startsWith(`${activePrefix}-`)) {
continue;
}
rmSync(path, { recursive: true, force: true });
removed.push(path);
}
return removed;
}
+10 -16
View File
@@ -2,10 +2,9 @@ import { describe, expect, test } from 'bun:test';
import { import {
buildVersionManifest, buildVersionManifest,
compareStableVersionsDesc, compareStableVersionsDesc,
versionArchiveCacheKey,
isStableReleaseTag, isStableReleaseTag,
renderVersionsPage,
stableTagsWithDocs, stableTagsWithDocs,
versionArchiveCacheName,
versionOutputPath, versionOutputPath,
versionPath, versionPath,
} from './docs-versioning'; } from './docs-versioning';
@@ -47,21 +46,16 @@ describe('docs versioning helpers', () => {
}); });
}); });
test('archive cache names are normalized by version and shared internals hash', () => { test('versions page links every build with full page loads', () => {
expect(versionArchiveCacheName('v0.14.0', 'abcdef1234567890')).toBe('abcdef123456-v0.14.0'); const page = renderVersionsPage(
}); buildVersionManifest({ latestStable: 'v0.14.0', stableVersions: ['v0.14.0', 'v0.13.0'] }),
);
test('archive cache keys change when manifest contents change', () => { expect(page).toContain('<a href="/" target="_self">Latest stable (v0.14.0)</a>');
const firstKey = versionArchiveCacheKey({ expect(page).toContain('<a href="/main/" target="_self">main</a>');
sharedInternalsHash: 'abcdef1234567890', expect(page).toContain('<a href="/v/0.14.0/" target="_self">v0.14.0</a>');
manifestJson: '{"latestStable":"v0.14.0"}', expect(page.indexOf('/v/0.14.0/')).toBeLessThan(page.indexOf('/v/0.13.0/'));
}); expect(page).toContain('<a href="/v/0.13.0/" target="_self">v0.13.0</a>');
const secondKey = versionArchiveCacheKey({
sharedInternalsHash: 'abcdef1234567890',
manifestJson: '{"latestStable":"v0.15.0"}',
});
expect(firstKey).not.toBe(secondKey);
}); });
test('archive output paths stay relative for filesystem joins', () => { test('archive output paths stay relative for filesystem joins', () => {
+24 -18
View File
@@ -1,5 +1,3 @@
import { createHash } from 'node:crypto';
export type DocsVersionEntry = { export type DocsVersionEntry = {
version: string; version: string;
path: string; path: string;
@@ -55,22 +53,6 @@ export function versionOutputPath(version: string): string {
return `v/${version.replace(/^v/, '')}`; return `v/${version.replace(/^v/, '')}`;
} }
export function versionArchiveCacheName(version: string, sharedInternalsHash: string): string {
return `${sharedInternalsHash.slice(0, 12)}-${version}`;
}
export function versionArchiveCacheKey(options: {
sharedInternalsHash: string;
manifestJson: string;
}): string {
const hash = createHash('sha256');
hash.update('shared-internals:');
hash.update(options.sharedInternalsHash);
hash.update('\nmanifest:');
hash.update(options.manifestJson);
return hash.digest('hex');
}
export function stableTagsWithDocs( export function stableTagsWithDocs(
tags: string[], tags: string[],
hasDocsSite: (tag: string) => boolean, hasDocsSite: (tag: string) => boolean,
@@ -94,3 +76,27 @@ export function buildVersionManifest(options: {
})), })),
}; };
} }
// Markdown for the root-only `/versions` page. Archives link here instead of baking the
// release list into their nav, so an archive never needs a rebuild when a new tag ships.
// Raw anchors with `target="_self"` keep VitePress from treating the other builds as
// dead links or routing to them client-side.
export function renderVersionsPage(manifest: DocsVersionManifest): string {
const link = (path: string, text: string) => `<a href="${path}" target="_self">${text}</a>`;
return [
'---',
'title: Documentation versions',
'description: Every published version of the SubMiner documentation.',
'---',
'',
'# Documentation versions',
'',
`- ${link('/', `Latest stable (${manifest.latestStable})`)}`,
`- ${link('/main/', 'main')}: development docs, may describe unreleased behavior`,
'',
'## Stable releases',
'',
...manifest.versions.map((entry) => `- ${link(entry.path, entry.version)}`),
'',
].join('\n');
}
-41
View File
@@ -1,41 +0,0 @@
import { spawnSync } from 'node:child_process';
import { resolve } from 'node:path';
import { buildVersionManifest, stableTagsWithDocs } from './docs-versioning';
const repoRoot = resolve(__dirname, '..');
function capture(command: string, args: string[]): string {
const result = spawnSync(command, args, {
cwd: repoRoot,
encoding: 'utf8',
});
if (result.status !== 0) {
throw new Error(result.stderr || `Command failed: ${command} ${args.join(' ')}`);
}
return result.stdout;
}
function tagHasDocsSite(tag: string): boolean {
const result = spawnSync('git', ['cat-file', '-e', `${tag}:docs-site/package.json`], {
cwd: repoRoot,
});
return result.status === 0;
}
const stableVersions = stableTagsWithDocs(
capture('git', ['tag', '--list', 'v*'])
.split('\n')
.map((tag) => tag.trim())
.filter(Boolean),
tagHasDocsSite,
);
const latestStable = stableVersions[0];
if (!latestStable) {
throw new Error('No stable release tags with docs-site/package.json found.');
}
process.stdout.write(JSON.stringify(buildVersionManifest({ latestStable, stableVersions })));
+8 -6
View File
@@ -53,15 +53,17 @@ test('main docs deploy exists, serializes deploys, and uses Cloudflare credentia
assert.match(docsPagesWorkflow, /CLOUDFLARE_API_TOKEN/); assert.match(docsPagesWorkflow, /CLOUDFLARE_API_TOKEN/);
assert.match(docsPagesWorkflow, /CLOUDFLARE_ACCOUNT_ID/); assert.match(docsPagesWorkflow, /CLOUDFLARE_ACCOUNT_ID/);
assert.match(docsPagesWorkflow, /CLOUDFLARE_PAGES_PROJECT_NAME/); assert.match(docsPagesWorkflow, /CLOUDFLARE_PAGES_PROJECT_NAME/);
assert.match(docsPagesWorkflow, /pages deploy \.tmp\/docs-versioned-site/); assert.match(docsPagesWorkflow, /pages deploy \.\.\/\.tmp\/docs-versioned-site/);
assert.match(docsPagesWorkflow, /--branch main/); assert.match(docsPagesWorkflow, /--branch main/);
}); });
test('docs deploy caches stable archive builds between runs', () => { test('docs deploy syncs frozen archives to R2 and ships the archive Pages Function', () => {
assert.match(docsPagesWorkflow, /actions\/cache@v4/); assert.doesNotMatch(docsPagesWorkflow, /actions\/cache@/);
assert.match(docsPagesWorkflow, /\.tmp\/docs-versioned-archive-cache/); assert.match(docsPagesWorkflow, /DOCS_ARCHIVE_R2_ACCESS_KEY_ID/);
assert.match(docsPagesWorkflow, /docs-versioned-archives-/); assert.match(docsPagesWorkflow, /DOCS_ARCHIVE_R2_SECRET_ACCESS_KEY/);
assert.match(docsPagesWorkflow, /docs-site\/\.vitepress\/\*\*/); assert.match(docsPagesWorkflow, /--require-archives/);
assert.match(docsPagesWorkflow, /--rebuild-archives=\$\{REBUILD_ARCHIVES\}/);
assert.match(docsPagesWorkflow, /workingDirectory: docs-site/);
}); });
test('docs deploy skips invalid release tags without failing the workflow', () => { test('docs deploy skips invalid release tags without failing the workflow', () => {
+1 -1
View File
@@ -72,7 +72,7 @@ test('stable release tags publish docs and prereleases do not update stable docs
assert.match(docsPagesWorkflow, /tags:\s*\n\s*-\s*'v\*'/); assert.match(docsPagesWorkflow, /tags:\s*\n\s*-\s*'v\*'/);
assert.match(docsPagesWorkflow, /github\.ref_name/); assert.match(docsPagesWorkflow, /github\.ref_name/);
assert.match(docsPagesWorkflow, /\^v\[0-9\]\+\\\.\[0-9\]\+\\\.\[0-9\]\+\$/); assert.match(docsPagesWorkflow, /\^v\[0-9\]\+\\\.\[0-9\]\+\\\.\[0-9\]\+\$/);
assert.match(docsPagesWorkflow, /bun run docs:build:versioned/); assert.match(docsPagesWorkflow, /bun run scripts\/build-versioned-docs\.ts/);
assert.doesNotMatch(docsPagesWorkflow, /beta/); assert.doesNotMatch(docsPagesWorkflow, /beta/);
}); });