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
+13 -193
View File
@@ -1,6 +1,6 @@
import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync, statSync } from 'node:fs';
import { extname, join, posix, resolve, sep } from 'node:path';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import type { DefaultTheme, HeadConfig, TransformContext, UserConfig } from 'vitepress';
const DOCS_HOSTNAME = 'https://docs.subminer.moe';
@@ -14,12 +14,6 @@ const PLAUSIBLE_INIT_SCRIPT = [
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 {
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 channel = normalizeChannel(optionalEnv(process.env.SUBMINER_DOCS_CHANNEL));
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 {
if (!value || value === '/') return '/';
@@ -54,21 +37,6 @@ function normalizeChannel(value: string | undefined): DocsChannel {
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 {
if (/^[a-z]+:\/\//i.test(path)) return 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));
}
function versionSwitchLink(path: string): string {
if (/^[a-z]+:\/\//i.test(path)) return path;
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
if (versionLinkOrigin === 'local') return localVersionSwitchLink(normalizedPath);
return `${DOCS_HOSTNAME}${normalizedPath}`;
}
function localVersionSwitchLink(path: string): string {
if (base === '/') return path;
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,
})),
];
// Version navigation targets other builds (root, `/main/`, `/versions`), so it links to
// production by absolute URL: base-relative links would stay inside this build, and
// `target: '_self'` makes the VitePress router do a full page load. The list is
// deliberately static; the full release list lives on the root-only `/versions` page
// so frozen `/v/<version>/` archives never need a rebuild when a new tag ships.
const versionItems: DefaultTheme.NavItemWithLink[] = [
{ text: 'Latest stable', link: `${DOCS_HOSTNAME}/` },
{ text: 'main', link: `${DOCS_HOSTNAME}/main/` },
{ text: 'All versions', link: `${DOCS_HOSTNAME}/versions` },
].map((item) => ({ ...item, target: '_self', noIcon: true }));
function sitemapUrlToPage(url: string): string {
const route = url.replace(/\.html$/, '').replace(/^\/+|\/+$/g, '');
@@ -336,7 +183,7 @@ const nav: DefaultTheme.NavItem[] = [
{ text: 'Configuration', link: '/configuration' },
{ text: 'Changelog', link: '/changelog' },
{ text: 'Troubleshooting', link: '/troubleshooting' },
{ text: docsVersion ?? (channel === 'main' ? 'main' : latestStable), items: versionItems },
{ text: docsVersion ?? 'main', items: versionItems },
];
const sidebar: DefaultTheme.SidebarItem[] = [
@@ -394,33 +241,6 @@ const config: UserConfig = {
'SubMiner: an MPV immersion-mining overlay with Yomitan and AnkiConnect integration.',
base,
...(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: [
['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.
- `/main/` serves development docs from `main`.
- `/v/<version>/` serves stable release archives.
- `/versions` (root build only) lists every published version.
- 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.
### 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
```
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:
@@ -192,7 +192,7 @@ From the SubMiner app repo:
```bash
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:preview # Preview built site at http://localhost:4173
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 developmentContents = readFileSync(new URL('./development.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(
new URL('./anki-integration.md', import.meta.url),
'utf8',
@@ -101,15 +100,6 @@ test('docs state the real secondary-subtitle and Anki field-matching behavior',
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', () => {
const docsHeadings = extractCurrentMinorHeadings(changelogContents);
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",
"packageManager": "bun@1.3.5",
"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: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": {
"@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)');
});
test('versioned docs build reports archive cache hits and rebuilds', () => {
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', () => {
test('versioned docs build deduplicates main public assets and removes build workspaces', () => {
expect(versionedBuildContents).toContain('dedupeVersionedPublicAssets({');
expect(versionedBuildContents).toContain('pruneArchiveCacheGenerations({');
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', () => {
expect(docsConfigContents).toContain("srcExclude: ['subagents/**', 'README.md']");
});
+20 -316
View File
@@ -1,7 +1,4 @@
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 type { TransformContext } from 'vitepress';
import docsConfig from './.vitepress/config';
@@ -63,19 +60,17 @@ test('main docs canonical uses /main/ and emits noindex', async () => {
});
test.each([
['latest stable', '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.14.0', '/v/0.14.0/', 'https://docs.subminer.moe/v/0.14.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',
async (_label, version, base, expectedCanonical) => {
async (version, base, expectedCanonical) => {
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;
process.env.SUBMINER_DOCS_CHANNEL = 'stable-archive';
process.env.SUBMINER_DOCS_BASE = base;
process.env.SUBMINER_DOCS_VERSION = version;
process.env.SUBMINER_DOCS_LATEST_STABLE = 'v0.14.0';
try {
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_BASE = previousBase;
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 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.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 {
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;
items?: Array<{ text: string; link: string }>;
}>;
const configurationNav = nav.find((item) => item.text === 'Configuration');
const versionNav = nav.find((item) => item.text === 'v0.12.0');
const referenceSidebar = sidebar.find((item) => item.text === 'Reference');
const configurationSidebar = referenceSidebar?.items?.find(
(item) => item.text === 'Configuration',
);
const configurationSidebar = sidebar
.find((item) => item.text === 'Reference')
?.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(versionNav?.items).toContainEqual({
text: 'Latest stable (v0.14.0)',
link: 'https://docs.subminer.moe/',
target: '_self',
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,
});
// Frozen archives must not embed the release list, or every new tag would
// invalidate them. They point at the root-only /versions page instead.
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',
noIcon: true,
},
]);
expect(archiveConfig.themeConfig?.logo).toEqual({
light: '/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_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('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 () => {
const items = [{ url: '' }, { url: 'README' }, { url: 'usage' }];