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