From bc73a02623c5cfa25f0add5a15bd931049660895 Mon Sep 17 00:00:00 2001 From: sudacode Date: Thu, 24 Sep 2026 15:28:58 -0700 Subject: [PATCH] ci(docs): serve frozen version archives from R2 (#269) --- .github/workflows/docs-pages.yml | 28 ++- docs-site/.vitepress/config.ts | 206 +-------------- docs-site/README.md | 17 +- docs-site/archive-function.test.ts | 116 +++++++++ docs-site/development.md | 4 +- docs-site/docs-sync.test.ts | 10 - docs-site/functions/v/[[path]].ts | 212 ++++++++++++++++ docs-site/package.json | 4 +- docs-site/plausible.test.ts | 18 +- docs-site/seo.test.ts | 336 ++----------------------- scripts/build-versioned-docs.ts | 245 ++++++++---------- scripts/docs-archive-store.ts | 107 ++++++++ scripts/docs-versioned-assets.test.ts | 31 +-- scripts/docs-versioned-assets.ts | 27 -- scripts/docs-versioning.test.ts | 26 +- scripts/docs-versioning.ts | 42 ++-- scripts/print-docs-version-manifest.ts | 41 --- src/ci-workflow.test.ts | 14 +- src/release-workflow.test.ts | 2 +- 19 files changed, 651 insertions(+), 835 deletions(-) create mode 100644 docs-site/archive-function.test.ts create mode 100644 docs-site/functions/v/[[path]].ts create mode 100644 scripts/docs-archive-store.ts delete mode 100644 scripts/print-docs-version-manifest.ts diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml index e95f137d..477fa1d8 100644 --- a/.github/workflows/docs-pages.yml +++ b/.github/workflows/docs-pages.yml @@ -2,6 +2,11 @@ name: Docs Pages on: 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: branches: - main @@ -10,6 +15,8 @@ on: paths: - 'docs-site/**' - 'scripts/docs-versioning.ts' + - 'scripts/docs-versioned-assets.ts' + - 'scripts/docs-archive-store.ts' - 'scripts/build-versioned-docs.ts' - '.github/workflows/docs-pages.yml' - 'package.json' @@ -54,20 +61,21 @@ jobs: 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 if: steps.tag_guard.outputs.stable_tag != 'false' 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 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 if: steps.tag_guard.outputs.stable_tag != 'false' @@ -75,4 +83,6 @@ jobs: with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} 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 diff --git a/docs-site/.vitepress/config.ts b/docs-site/.vitepress/config.ts index acace595..42795fd1 100644 --- a/docs-site/.vitepress/config.ts +++ b/docs-site/.vitepress/config.ts @@ -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//` 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 }], [ diff --git a/docs-site/README.md b/docs-site/README.md index 687452cb..85d0061c 100644 --- a/docs-site/README.md +++ b/docs-site/README.md @@ -40,8 +40,23 @@ The public docs root is stable-only: - `/` serves the latest stable release docs. - `/main/` serves development docs from `main`. - `/v//` 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//` 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 `` 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//` 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 `` 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//` archives are not part of the Pages deployment. Each one is built once, uploaded to an R2 bucket under `v//`, 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. diff --git a/docs-site/archive-function.test.ts b/docs-site/archive-function.test.ts new file mode 100644 index 00000000..ddbcaecc --- /dev/null +++ b/docs-site/archive-function.test.ts @@ -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): 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': '

home

', + 'v/0.19.6/usage.html': '

usage

', + 'v/0.19.6/404.html': '

missing

', + '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('

usage

'); + + 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('

missing

'); + + 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); +}); diff --git a/docs-site/development.md b/docs-site/development.md index 71a7a793..d4b795bc 100644 --- a/docs-site/development.md +++ b/docs-site/development.md @@ -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//`. 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//` 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 diff --git a/docs-site/docs-sync.test.ts b/docs-site/docs-sync.test.ts index 35e8e2a1..89065610 100644 --- a/docs-site/docs-sync.test.ts +++ b/docs-site/docs-sync.test.ts @@ -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); diff --git a/docs-site/functions/v/[[path]].ts b/docs-site/functions/v/[[path]].ts new file mode 100644 index 00000000..11ff5e7a --- /dev/null +++ b/docs-site/functions/v/[[path]].ts @@ -0,0 +1,212 @@ +// Cloudflare Pages Function serving frozen `/v//` 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; +}; + +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 = { + 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 { + 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 { + 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' }, + }); +} diff --git a/docs-site/package.json b/docs-site/package.json index 747f333d..83a425e5 100644 --- a/docs-site/package.json +++ b/docs-site/package.json @@ -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", diff --git a/docs-site/plausible.test.ts b/docs-site/plausible.test.ts index 25cbb2b2..905d0c5b 100644 --- a/docs-site/plausible.test.ts +++ b/docs-site/plausible.test.ts @@ -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']"); }); diff --git a/docs-site/seo.test.ts b/docs-site/seo.test.ts index 99980516..9b682fe0 100644 --- a/docs-site/seo.test.ts +++ b/docs-site/seo.test.ts @@ -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'), '

local archive

'); - 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('

local archive

'); - } 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 = {}; - 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' }]; diff --git a/scripts/build-versioned-docs.ts b/scripts/build-versioned-docs.ts index 012de33f..a6bbf24f 100644 --- a/scripts/build-versioned-docs.ts +++ b/scripts/build-versioned-docs.ts @@ -1,49 +1,52 @@ import { spawnSync } from 'node:child_process'; -import { createHash } from 'node:crypto'; import { cpSync, existsSync, lstatSync, mkdirSync, - readFileSync, readdirSync, - readlinkSync, rmSync, symlinkSync, writeFileSync, } from 'node:fs'; import { join, resolve } from 'node:path'; import { - collectSharedAssetPaths, - dedupeVersionedPublicAssets, - pruneArchiveCacheGenerations, -} from './docs-versioned-assets'; + archiveStoreEnvFromProcess, + createArchiveStore, + type DocsArchiveStore, +} from './docs-archive-store'; +import { collectSharedAssetPaths, dedupeVersionedPublicAssets } from './docs-versioned-assets'; import { buildVersionManifest, + renderVersionsPage, stableTagsWithDocs, - versionArchiveCacheKey, - versionArchiveCacheName, versionOutputPath, versionPath, } from './docs-versioning'; +// Assembles the Cloudflare Pages deployment: latest stable at `/` and development docs +// at `/main/`. Stable archives under `/v//` 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= comma-separated tags (or `all`) to rebuild even if present + const repoRoot = resolve(__dirname, '..'); const currentDocsSite = join(repoRoot, 'docs-site'); const buildRoot = join(repoRoot, '.tmp/docs-versioned-build'); 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 maxCloudflareFileBytes = 25 * 1024 * 1024; -// Cloudflare Pages header rules for the whole deployment. Mirrors the `noindex,follow` -// meta tag the non-root channels emit, so the duplicate trees stay out of the index -// even for responses a crawler takes without parsing the HTML. +// Cloudflare Pages header rules for the static deployment. Mirrors the `noindex,follow` +// meta tag the `main` channel emits, so the duplicate tree stays out of the index even +// 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. /main/* X-Robots-Tag: noindex, follow - -/v/* - X-Robots-Tag: noindex, follow `; function run( @@ -103,8 +106,7 @@ function copyCurrentDocsSite(targetDir: string) { recursive: true, dereference: false, filter: (source) => - !/[\\/]node_modules([\\/]|$)/.test(source) && - !/[\\/]\\.vitepress[\\/]dist([\\/]|$)/.test(source), + !/[\\/]node_modules([\\/]|$)/.test(source) && !isGeneratedVitePressPath(source), }); } @@ -173,8 +175,6 @@ function buildDocs(options: { outDir: string; channel: string; version?: string; - latestStable: string; - manifestJson: string; }) { console.info(`[docs] building ${options.version ?? options.channel} -> ${options.base}`); run('bun', ['run', '--cwd', currentDocsSite, 'vitepress', 'build', options.snapshotDocsSite], { @@ -187,98 +187,13 @@ function buildDocs(options: { SUBMINER_DOCS_REPO_DIR: currentDocsSite, SUBMINER_DOCS_CHANNEL: options.channel, SUBMINER_DOCS_VERSION: options.version ?? '', - SUBMINER_DOCS_LATEST_STABLE: options.latestStable, - SUBMINER_DOCS_VERSION_MANIFEST: options.manifestJson, VITE_EXTRA_EXTENSIONS: 'jsonc', }, }); } -function updateHashWithPath(hash: ReturnType, 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 { - 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 }); + return /[\\/]\.vitepress[\\/](cache|dist)([\\/]|$)/.test(path); } 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 | 'all' } { + let requireArchives = false; + let rebuild: Set | '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 | '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() { + const { requireArchives, rebuild } = parseArgs(process.argv.slice(2)); const stableVersions = getStableVersions(); const latestStable = stableVersions[0]; @@ -325,54 +299,42 @@ function main() { 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 manifestJson = JSON.stringify(manifest); - const sharedInternalsHash = computeSharedInternalsHash(); - const archiveCacheKey = versionArchiveCacheKey({ sharedInternalsHash, manifestJson }); const sharedAssetPaths = collectSharedAssetPaths(join(currentDocsSite, 'public/assets')); - console.info(`[docs] archive cache key ${archiveCacheKey.slice(0, 12)}`); rmSync(buildRoot, { recursive: true, force: true }); rmSync(aggregateOutDir, { recursive: true, force: true }); mkdirSync(buildRoot, { 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// archive sync'); + } + const latestStableSnapshot = prepareSnapshot(latestStable, latestStable); + writeFileSync(join(latestStableSnapshot, 'versions.md'), renderVersionsPage(manifest)); buildDocs({ snapshotDocsSite: latestStableSnapshot, base: '/', outDir: aggregateOutDir, channel: 'stable-root', 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'); buildDocs({ snapshotDocsSite: mainSnapshot, @@ -380,8 +342,6 @@ function main() { outDir: join(aggregateOutDir, 'main'), channel: 'main', version: 'main', - latestStable, - manifestJson, }); dedupeVersionedPublicAssets({ outDir: join(aggregateOutDir, 'main'), @@ -392,13 +352,6 @@ function main() { writeFileSync(join(aggregateOutDir, 'versions.json'), `${JSON.stringify(manifest, null, 2)}\n`); writeFileSync(join(aggregateOutDir, '_headers'), deployHeaders); 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 }); } diff --git a/scripts/docs-archive-store.ts b/scripts/docs-archive-store.ts new file mode 100644 index 00000000..bc8966f6 --- /dev/null +++ b/scripts/docs-archive-store.ts @@ -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//` 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()}`); + } + }, + }; +} diff --git a/scripts/docs-versioned-assets.test.ts b/scripts/docs-versioned-assets.test.ts index 27e27c00..aa44b35a 100644 --- a/scripts/docs-versioned-assets.test.ts +++ b/scripts/docs-versioned-assets.test.ts @@ -3,11 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from import { rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { - dedupeVersionedPublicAssets, - pruneArchiveCacheGenerations, - rewriteSharedAssetReferences, -} from './docs-versioned-assets'; +import { dedupeVersionedPublicAssets, rewriteSharedAssetReferences } from './docs-versioned-assets'; function tempDir() { 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 }); - } - }); -}); diff --git a/scripts/docs-versioned-assets.ts b/scripts/docs-versioned-assets.ts index 38e1f988..da9f5deb 100644 --- a/scripts/docs-versioned-assets.ts +++ b/scripts/docs-versioned-assets.ts @@ -119,30 +119,3 @@ function removeEmptyDirectories(root: string) { 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; -} diff --git a/scripts/docs-versioning.test.ts b/scripts/docs-versioning.test.ts index 379f0b54..e9afdf5a 100644 --- a/scripts/docs-versioning.test.ts +++ b/scripts/docs-versioning.test.ts @@ -2,10 +2,9 @@ import { describe, expect, test } from 'bun:test'; import { buildVersionManifest, compareStableVersionsDesc, - versionArchiveCacheKey, isStableReleaseTag, + renderVersionsPage, stableTagsWithDocs, - versionArchiveCacheName, versionOutputPath, versionPath, } from './docs-versioning'; @@ -47,21 +46,16 @@ describe('docs versioning helpers', () => { }); }); - test('archive cache names are normalized by version and shared internals hash', () => { - expect(versionArchiveCacheName('v0.14.0', 'abcdef1234567890')).toBe('abcdef123456-v0.14.0'); - }); + test('versions page links every build with full page loads', () => { + const page = renderVersionsPage( + buildVersionManifest({ latestStable: 'v0.14.0', stableVersions: ['v0.14.0', 'v0.13.0'] }), + ); - test('archive cache keys change when manifest contents change', () => { - const firstKey = versionArchiveCacheKey({ - sharedInternalsHash: 'abcdef1234567890', - manifestJson: '{"latestStable":"v0.14.0"}', - }); - const secondKey = versionArchiveCacheKey({ - sharedInternalsHash: 'abcdef1234567890', - manifestJson: '{"latestStable":"v0.15.0"}', - }); - - expect(firstKey).not.toBe(secondKey); + expect(page).toContain('Latest stable (v0.14.0)'); + expect(page).toContain('main'); + expect(page).toContain('v0.14.0'); + expect(page.indexOf('/v/0.14.0/')).toBeLessThan(page.indexOf('/v/0.13.0/')); + expect(page).toContain('v0.13.0'); }); test('archive output paths stay relative for filesystem joins', () => { diff --git a/scripts/docs-versioning.ts b/scripts/docs-versioning.ts index 517871b2..c3a50627 100644 --- a/scripts/docs-versioning.ts +++ b/scripts/docs-versioning.ts @@ -1,5 +1,3 @@ -import { createHash } from 'node:crypto'; - export type DocsVersionEntry = { version: string; path: string; @@ -55,22 +53,6 @@ export function versionOutputPath(version: string): string { 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( tags: string[], 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) => `${text}`; + 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'); +} diff --git a/scripts/print-docs-version-manifest.ts b/scripts/print-docs-version-manifest.ts deleted file mode 100644 index e6fc107e..00000000 --- a/scripts/print-docs-version-manifest.ts +++ /dev/null @@ -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 }))); diff --git a/src/ci-workflow.test.ts b/src/ci-workflow.test.ts index ce153430..db4895c1 100644 --- a/src/ci-workflow.test.ts +++ b/src/ci-workflow.test.ts @@ -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_ACCOUNT_ID/); 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/); }); -test('docs deploy caches stable archive builds between runs', () => { - assert.match(docsPagesWorkflow, /actions\/cache@v4/); - assert.match(docsPagesWorkflow, /\.tmp\/docs-versioned-archive-cache/); - assert.match(docsPagesWorkflow, /docs-versioned-archives-/); - assert.match(docsPagesWorkflow, /docs-site\/\.vitepress\/\*\*/); +test('docs deploy syncs frozen archives to R2 and ships the archive Pages Function', () => { + assert.doesNotMatch(docsPagesWorkflow, /actions\/cache@/); + assert.match(docsPagesWorkflow, /DOCS_ARCHIVE_R2_ACCESS_KEY_ID/); + assert.match(docsPagesWorkflow, /DOCS_ARCHIVE_R2_SECRET_ACCESS_KEY/); + 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', () => { diff --git a/src/release-workflow.test.ts b/src/release-workflow.test.ts index 55d4ee9b..9946d1de 100644 --- a/src/release-workflow.test.ts +++ b/src/release-workflow.test.ts @@ -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, /github\.ref_name/); 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/); });