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:
+13
-193
@@ -1,6 +1,6 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync, statSync } from 'node:fs';
|
||||
import { extname, join, posix, resolve, sep } from 'node:path';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { DefaultTheme, HeadConfig, TransformContext, UserConfig } from 'vitepress';
|
||||
|
||||
const DOCS_HOSTNAME = 'https://docs.subminer.moe';
|
||||
@@ -14,12 +14,6 @@ const PLAUSIBLE_INIT_SCRIPT = [
|
||||
|
||||
type DocsChannel = 'stable-root' | 'stable-archive' | 'main';
|
||||
|
||||
type VersionManifest = {
|
||||
latestStable: string;
|
||||
channels: Array<{ label: string; path: string }>;
|
||||
versions: Array<{ version: string; path: string }>;
|
||||
};
|
||||
|
||||
function optionalEnv(value: string | undefined): string | undefined {
|
||||
return value && value !== 'undefined' ? value : undefined;
|
||||
}
|
||||
@@ -32,17 +26,6 @@ const docsSourceDir = optionalEnv(process.env.SUBMINER_DOCS_SOURCE_DIR) ?? proce
|
||||
const repoDocsDir = optionalEnv(process.env.SUBMINER_DOCS_REPO_DIR) ?? process.cwd();
|
||||
const channel = normalizeChannel(optionalEnv(process.env.SUBMINER_DOCS_CHANNEL));
|
||||
const docsVersion = optionalEnv(process.env.SUBMINER_DOCS_VERSION);
|
||||
const latestStable = optionalEnv(process.env.SUBMINER_DOCS_LATEST_STABLE) ?? 'v0.18.0';
|
||||
const versionManifest = parseVersionManifest(process.env.SUBMINER_DOCS_VERSION_MANIFEST);
|
||||
const versionLinkOrigin =
|
||||
optionalEnv(process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN) ?? 'production';
|
||||
|
||||
function getLocalArchiveDir(): string {
|
||||
return resolve(
|
||||
optionalEnv(process.env.SUBMINER_DOCS_LOCAL_ARCHIVE_DIR) ??
|
||||
join(docsSourceDir, '..', '.tmp/docs-versioned-site'),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeBase(value: string): string {
|
||||
if (!value || value === '/') return '/';
|
||||
@@ -54,21 +37,6 @@ function normalizeChannel(value: string | undefined): DocsChannel {
|
||||
return 'stable-root';
|
||||
}
|
||||
|
||||
function parseVersionManifest(value: string | undefined): VersionManifest {
|
||||
if (!value || value === 'undefined') {
|
||||
return {
|
||||
latestStable,
|
||||
channels: [
|
||||
{ label: 'Latest stable', path: '/' },
|
||||
{ label: 'main', path: '/main/' },
|
||||
],
|
||||
versions: [{ version: latestStable, path: `/v/${latestStable.replace(/^v/, '')}/` }],
|
||||
};
|
||||
}
|
||||
|
||||
return JSON.parse(value) as VersionManifest;
|
||||
}
|
||||
|
||||
function withDocsBase(path: string): string {
|
||||
if (/^[a-z]+:\/\//i.test(path)) return path;
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||
@@ -164,137 +132,16 @@ function filterSidebar(items: DefaultTheme.SidebarItem[]): DefaultTheme.SidebarI
|
||||
.filter((item): item is DefaultTheme.SidebarItem => Boolean(item));
|
||||
}
|
||||
|
||||
function versionSwitchLink(path: string): string {
|
||||
if (/^[a-z]+:\/\//i.test(path)) return path;
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||
if (versionLinkOrigin === 'local') return localVersionSwitchLink(normalizedPath);
|
||||
return `${DOCS_HOSTNAME}${normalizedPath}`;
|
||||
}
|
||||
|
||||
function localVersionSwitchLink(path: string): string {
|
||||
if (base === '/') return path;
|
||||
|
||||
const basePath = base.replace(/\/$/, '');
|
||||
const targetPath = path === '/' ? '/' : path.replace(/\/$/, '');
|
||||
const relativePath = posix.relative(basePath, targetPath) || '.';
|
||||
|
||||
return path.endsWith('/') ? `${relativePath}/` : relativePath;
|
||||
}
|
||||
|
||||
function shouldHandleLocalVersionRoute(pathname: string): boolean {
|
||||
if (base !== '/' || channel !== 'stable-root') return false;
|
||||
return /^\/main(?:\/|$)/.test(pathname) || /^\/v\/[^/]+(?:\/|$)/.test(pathname);
|
||||
}
|
||||
|
||||
function contentTypeForPath(path: string): string {
|
||||
switch (extname(path)) {
|
||||
case '.css':
|
||||
return 'text/css; charset=utf-8';
|
||||
case '.gif':
|
||||
return 'image/gif';
|
||||
case '.ico':
|
||||
return 'image/x-icon';
|
||||
case '.jpg':
|
||||
case '.jpeg':
|
||||
return 'image/jpeg';
|
||||
case '.js':
|
||||
case '.mjs':
|
||||
return 'text/javascript; charset=utf-8';
|
||||
case '.json':
|
||||
case '.jsonc':
|
||||
return 'application/json; charset=utf-8';
|
||||
case '.mp4':
|
||||
return 'video/mp4';
|
||||
case '.png':
|
||||
return 'image/png';
|
||||
case '.svg':
|
||||
return 'image/svg+xml';
|
||||
case '.ttf':
|
||||
return 'font/ttf';
|
||||
case '.webm':
|
||||
return 'video/webm';
|
||||
case '.woff':
|
||||
return 'font/woff';
|
||||
case '.woff2':
|
||||
return 'font/woff2';
|
||||
case '.xml':
|
||||
return 'application/xml; charset=utf-8';
|
||||
default:
|
||||
return 'text/html; charset=utf-8';
|
||||
}
|
||||
}
|
||||
|
||||
function isFile(path: string): boolean {
|
||||
try {
|
||||
return statSync(path).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function archiveFileForPathname(pathname: string): string | null {
|
||||
if (!shouldHandleLocalVersionRoute(pathname)) return null;
|
||||
|
||||
const localArchiveDir = getLocalArchiveDir();
|
||||
const routePath = decodeURIComponent(pathname).replace(/^\/+/, '');
|
||||
const filePath = resolve(localArchiveDir, routePath);
|
||||
if (filePath !== localArchiveDir && !filePath.startsWith(`${localArchiveDir}${sep}`)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = pathname.endsWith('/')
|
||||
? [join(filePath, 'index.html')]
|
||||
: extname(filePath)
|
||||
? [filePath]
|
||||
: [`${filePath}.html`, join(filePath, 'index.html')];
|
||||
|
||||
return candidates.find(isFile) ?? null;
|
||||
}
|
||||
|
||||
function serveLocalArchiveRoute(pathname: string, response: DevServerResponse): boolean {
|
||||
if (
|
||||
(optionalEnv(process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN) ?? versionLinkOrigin) !== 'local'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const filePath = archiveFileForPathname(pathname);
|
||||
if (!filePath) return false;
|
||||
|
||||
response.statusCode = 200;
|
||||
response.setHeader('Content-Type', contentTypeForPath(filePath));
|
||||
response.end(readFileSync(filePath));
|
||||
return true;
|
||||
}
|
||||
|
||||
type DevServerResponse = {
|
||||
statusCode: number;
|
||||
setHeader(name: string, value: string): void;
|
||||
end(chunk?: string | Uint8Array): void;
|
||||
};
|
||||
|
||||
const versionItems = [
|
||||
{
|
||||
text: `Latest stable (${versionManifest.latestStable})`,
|
||||
link: versionSwitchLink('/'),
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
},
|
||||
...versionManifest.channels
|
||||
.filter((entry) => entry.label !== 'Latest stable')
|
||||
.map((entry) => ({
|
||||
text: entry.label,
|
||||
link: versionSwitchLink(entry.path),
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
})),
|
||||
...versionManifest.versions.map((entry) => ({
|
||||
text: entry.version,
|
||||
link: versionSwitchLink(entry.path),
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
})),
|
||||
];
|
||||
// Version navigation targets other builds (root, `/main/`, `/versions`), so it links to
|
||||
// production by absolute URL: base-relative links would stay inside this build, and
|
||||
// `target: '_self'` makes the VitePress router do a full page load. The list is
|
||||
// deliberately static; the full release list lives on the root-only `/versions` page
|
||||
// so frozen `/v/<version>/` archives never need a rebuild when a new tag ships.
|
||||
const versionItems: DefaultTheme.NavItemWithLink[] = [
|
||||
{ text: 'Latest stable', link: `${DOCS_HOSTNAME}/` },
|
||||
{ text: 'main', link: `${DOCS_HOSTNAME}/main/` },
|
||||
{ text: 'All versions', link: `${DOCS_HOSTNAME}/versions` },
|
||||
].map((item) => ({ ...item, target: '_self', noIcon: true }));
|
||||
|
||||
function sitemapUrlToPage(url: string): string {
|
||||
const route = url.replace(/\.html$/, '').replace(/^\/+|\/+$/g, '');
|
||||
@@ -336,7 +183,7 @@ const nav: DefaultTheme.NavItem[] = [
|
||||
{ text: 'Configuration', link: '/configuration' },
|
||||
{ text: 'Changelog', link: '/changelog' },
|
||||
{ text: 'Troubleshooting', link: '/troubleshooting' },
|
||||
{ text: docsVersion ?? (channel === 'main' ? 'main' : latestStable), items: versionItems },
|
||||
{ text: docsVersion ?? 'main', items: versionItems },
|
||||
];
|
||||
|
||||
const sidebar: DefaultTheme.SidebarItem[] = [
|
||||
@@ -394,33 +241,6 @@ const config: UserConfig = {
|
||||
'SubMiner: an MPV immersion-mining overlay with Yomitan and AnkiConnect integration.',
|
||||
base,
|
||||
...(outDir ? { outDir } : {}),
|
||||
vite: {
|
||||
plugins: [
|
||||
{
|
||||
name: 'subminer-docs-local-version-redirects',
|
||||
configureServer(server) {
|
||||
server.middlewares.use((request, response, next) => {
|
||||
const requestUrl = new URL(request.url ?? '/', 'http://localhost');
|
||||
if (serveLocalArchiveRoute(requestUrl.pathname, response)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldHandleLocalVersionRoute(requestUrl.pathname)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
response.statusCode = 302;
|
||||
response.setHeader(
|
||||
'Location',
|
||||
`${DOCS_HOSTNAME}${requestUrl.pathname}${requestUrl.search}`,
|
||||
);
|
||||
response.end();
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
head: [
|
||||
['link', { rel: 'preconnect', href: PLAUSIBLE_PROXY_HOSTNAME }],
|
||||
[
|
||||
|
||||
+16
-1
@@ -40,8 +40,23 @@ The public docs root is stable-only:
|
||||
- `/` serves the latest stable release docs.
|
||||
- `/main/` serves development docs from `main`.
|
||||
- `/v/<version>/` serves stable release archives.
|
||||
- `/versions` (root build only) lists every published version.
|
||||
- Prerelease tags do not update the docs site.
|
||||
|
||||
Only `/` is indexable. `/main/` and every `/v/<version>/` page carries a self-referential canonical plus `noindex,follow`, and the generated `_headers` file repeats that as an `X-Robots-Tag`. They stay crawlable so their links still resolve, but ~30 archived copies of every page would otherwise consume the crawl budget the current docs need. Only the root build emits `sitemap.xml`, and its `<lastmod>` dates come from `git log` against the tracked checkout at the released tag, because the build renders from an untracked snapshot that VitePress cannot date itself.
|
||||
Only `/` is indexable. `/main/` and every `/v/<version>/` page carries a self-referential canonical plus `noindex,follow`, repeated as an `X-Robots-Tag` header (by the generated `_headers` file for `/main/`, by the archive function for `/v/`). They stay crawlable so their links still resolve, but ~30 archived copies of every page would otherwise consume the crawl budget the current docs need. Only the root build emits `sitemap.xml`, and its `<lastmod>` dates come from `git log` against the tracked checkout at the released tag, because the build renders from an untracked snapshot that VitePress cannot date itself.
|
||||
|
||||
Keep Cloudflare Git auto-deploy disabled. The production deploy is `.github/workflows/docs-pages.yml`, which uploads `.tmp/docs-versioned-site` with `--branch main` so tag-triggered runs update Production instead of creating preview deployments.
|
||||
|
||||
### Stable archives in R2
|
||||
|
||||
`/v/<version>/` archives are not part of the Pages deployment. Each one is built once, uploaded to an R2 bucket under `v/<version>/`, and served by the Pages Function in `functions/v/[[path]].ts`. Each deploy builds only archives the bucket is missing (an archive counts as present once its `_archive.json` marker exists), plus the root and `/main/` builds. Archive nav links to `/versions` instead of listing releases, so a new tag never invalidates old archives.
|
||||
|
||||
To re-render archives on purpose (theme change, docs fix), run the `Docs Pages` workflow manually with `rebuild_archives` set to comma-separated tags or `all`.
|
||||
|
||||
One-time setup:
|
||||
|
||||
- R2 bucket for archives; its name goes in the `DOCS_ARCHIVE_R2_BUCKET` repository variable.
|
||||
- R2 API token with Object Read & Write on that bucket; its S3 credentials go in the `DOCS_ARCHIVE_R2_ACCESS_KEY_ID` and `DOCS_ARCHIVE_R2_SECRET_ACCESS_KEY` repository secrets.
|
||||
- Pages project: Settings > Bindings > R2 bucket, variable name `DOCS_ARCHIVES`, pointing at the same bucket.
|
||||
|
||||
The first deploy after setup builds and uploads every stable archive; later deploys only add new tags.
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
The versioned build writes `.tmp/docs-versioned-site` with latest stable docs at `/`, development docs at `/main/`, and stable archives under `/v/<version>/`. Prerelease tags are skipped. Public assets from `docs-site/public/assets` are shared from root `/assets/` so large demo media is not duplicated into every version archive; generated VitePress CSS and JS assets stay under each version route. Stale `.tmp/docs-versioned-archive-cache` generations are pruned after a successful build, and intermediate `.tmp/docs-versioned-build` workspaces are removed.
|
||||
The versioned build writes `.tmp/docs-versioned-site` with latest stable docs at `/` (plus a generated `/versions` page) and development docs at `/main/`. Prerelease tags are skipped. `/main/` shares public assets from root `/assets/` instead of duplicating them. Stable archives under `/v/<version>/` are built once and stored in R2 (see `docs-site/README.md`); without R2 credentials the build skips archive sync, so local runs only produce the root and `/main/` trees.
|
||||
|
||||
Focused commands:
|
||||
|
||||
@@ -192,7 +192,7 @@ From the SubMiner app repo:
|
||||
|
||||
```bash
|
||||
bun --cwd docs-site install
|
||||
bun run docs:dev # Dev server at http://localhost:5173
|
||||
bun run docs:dev # Dev server at http://localhost:5173 (version links go to production)
|
||||
bun run docs:build # Production build into docs-site/.vitepress/dist
|
||||
bun run docs:preview # Preview built site at http://localhost:4173
|
||||
bun run docs:test # Docs regression tests
|
||||
|
||||
@@ -8,7 +8,6 @@ const installationContents = readFileSync(new URL('./installation.md', import.me
|
||||
const mpvPluginContents = readFileSync(new URL('./mpv-plugin.md', import.meta.url), 'utf8');
|
||||
const developmentContents = readFileSync(new URL('./development.md', import.meta.url), 'utf8');
|
||||
const changelogContents = readFileSync(new URL('./changelog.md', import.meta.url), 'utf8');
|
||||
const docsPackageContents = readFileSync(new URL('./package.json', import.meta.url), 'utf8');
|
||||
const ankiIntegrationContents = readFileSync(
|
||||
new URL('./anki-integration.md', import.meta.url),
|
||||
'utf8',
|
||||
@@ -101,15 +100,6 @@ test('docs state the real secondary-subtitle and Anki field-matching behavior',
|
||||
expect(ankiIntegrationContents).toContain('case-insensitively');
|
||||
});
|
||||
|
||||
test('docs dev server links version navigation to local dev routes', () => {
|
||||
expect(docsPackageContents).toContain('scripts/build-versioned-docs.ts');
|
||||
expect(docsPackageContents).toContain(
|
||||
'SUBMINER_DOCS_VERSION_LINK_ORIGIN=local bun run ../scripts/build-versioned-docs.ts',
|
||||
);
|
||||
expect(docsPackageContents).toContain('SUBMINER_DOCS_VERSION_LINK_ORIGIN=local');
|
||||
expect(docsPackageContents).toContain('SUBMINER_DOCS_VERSION_MANIFEST');
|
||||
});
|
||||
|
||||
test('docs changelog keeps the current minor release headings aligned with the root changelog', () => {
|
||||
const docsHeadings = extractCurrentMinorHeadings(changelogContents);
|
||||
expect(docsHeadings.length).toBeGreaterThan(0);
|
||||
|
||||
@@ -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",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"scripts": {
|
||||
"docs:dev": "SUBMINER_DOCS_VERSION_LINK_ORIGIN=local bun run ../scripts/build-versioned-docs.ts && SUBMINER_DOCS_VERSION_LINK_ORIGIN=local SUBMINER_DOCS_VERSION_MANIFEST=\"$(bun run ../scripts/print-docs-version-manifest.ts)\" VITE_EXTRA_EXTENSIONS=jsonc vitepress dev --host 0.0.0.0 --port 5173 --strictPort",
|
||||
"docs:dev": "VITE_EXTRA_EXTENSIONS=jsonc vitepress dev --host 0.0.0.0 --port 5173 --strictPort",
|
||||
"docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build",
|
||||
"docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview --host 0.0.0.0 --port 4173 --strictPort",
|
||||
"test": "bun test plausible.test.ts index.assets.test.ts docs-sync.test.ts links.test.ts seo.test.ts .vitepress/theme/status-line.test.ts ../scripts/docs-versioning.test.ts"
|
||||
"test": "bun test plausible.test.ts index.assets.test.ts archive-function.test.ts docs-sync.test.ts links.test.ts seo.test.ts .vitepress/theme/status-line.test.ts ../scripts/docs-versioning.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@catppuccin/vitepress": "^0.1.2",
|
||||
|
||||
@@ -42,27 +42,11 @@ test('versioned docs reuse current VitePress internals for old page snapshots',
|
||||
expect(versionedBuildContents).toContain('overlayCurrentVitePress(snapshotDocsSite)');
|
||||
});
|
||||
|
||||
test('versioned docs build reports archive cache hits and rebuilds', () => {
|
||||
expect(versionedBuildContents).toContain(
|
||||
'console.info(`[docs] archive cache key ${archiveCacheKey.slice(0, 12)}`)',
|
||||
);
|
||||
expect(versionedBuildContents).toContain('console.info(`[docs] cache hit ${version}`)');
|
||||
expect(versionedBuildContents).toContain('console.info(`[docs] rebuilding archive ${version}`)');
|
||||
});
|
||||
|
||||
test('versioned docs build deduplicates public assets and prunes stale workspaces', () => {
|
||||
test('versioned docs build deduplicates main public assets and removes build workspaces', () => {
|
||||
expect(versionedBuildContents).toContain('dedupeVersionedPublicAssets({');
|
||||
expect(versionedBuildContents).toContain('pruneArchiveCacheGenerations({');
|
||||
expect(versionedBuildContents).toContain('rmSync(buildRoot, { recursive: true, force: true });');
|
||||
});
|
||||
|
||||
test('versioned docs archive cache key ignores generated and test-only files', () => {
|
||||
expect(versionedBuildContents).toContain('isSharedInternalsHashIgnoredPath(path)');
|
||||
expect(versionedBuildContents).toContain('|| /\\.test\\.[cm]?[jt]s$/.test(path)');
|
||||
expect(versionedBuildContents).toContain('process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN');
|
||||
expect(versionedBuildContents).not.toContain('hash.update(String(stat.mode))');
|
||||
});
|
||||
|
||||
test('docs builds exclude the internal README from VitePress page entries', () => {
|
||||
expect(docsConfigContents).toContain("srcExclude: ['subagents/**', 'README.md']");
|
||||
});
|
||||
|
||||
+20
-316
@@ -1,7 +1,4 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { TransformContext } from 'vitepress';
|
||||
import docsConfig from './.vitepress/config';
|
||||
@@ -63,19 +60,17 @@ test('main docs canonical uses /main/ and emits noindex', async () => {
|
||||
});
|
||||
|
||||
test.each([
|
||||
['latest stable', 'v0.14.0', '/v/0.14.0/', 'https://docs.subminer.moe/v/0.14.0/usage'],
|
||||
['superseded', 'v0.12.0', '/v/0.12.0/', 'https://docs.subminer.moe/v/0.12.0/usage'],
|
||||
['v0.14.0', '/v/0.14.0/', 'https://docs.subminer.moe/v/0.14.0/usage'],
|
||||
['v0.12.0', '/v/0.12.0/', 'https://docs.subminer.moe/v/0.12.0/usage'],
|
||||
])(
|
||||
'%s archive keeps a self-referential canonical and stays out of the index',
|
||||
async (_label, version, base, expectedCanonical) => {
|
||||
async (version, base, expectedCanonical) => {
|
||||
const previousChannel = process.env.SUBMINER_DOCS_CHANNEL;
|
||||
const previousBase = process.env.SUBMINER_DOCS_BASE;
|
||||
const previousVersion = process.env.SUBMINER_DOCS_VERSION;
|
||||
const previousLatest = process.env.SUBMINER_DOCS_LATEST_STABLE;
|
||||
process.env.SUBMINER_DOCS_CHANNEL = 'stable-archive';
|
||||
process.env.SUBMINER_DOCS_BASE = base;
|
||||
process.env.SUBMINER_DOCS_VERSION = version;
|
||||
process.env.SUBMINER_DOCS_LATEST_STABLE = 'v0.14.0';
|
||||
try {
|
||||
const { default: archiveConfig } = await import(`./.vitepress/config?archive-${version}`);
|
||||
|
||||
@@ -89,36 +84,19 @@ test.each([
|
||||
process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
|
||||
process.env.SUBMINER_DOCS_BASE = previousBase;
|
||||
process.env.SUBMINER_DOCS_VERSION = previousVersion;
|
||||
process.env.SUBMINER_DOCS_LATEST_STABLE = previousLatest;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('stable archive theme links stay on the selected version', async () => {
|
||||
test('archive nav keeps page links in-version and version links release-independent', async () => {
|
||||
const previousCwd = process.cwd();
|
||||
const previousChannel = process.env.SUBMINER_DOCS_CHANNEL;
|
||||
const previousBase = process.env.SUBMINER_DOCS_BASE;
|
||||
const previousVersion = process.env.SUBMINER_DOCS_VERSION;
|
||||
const previousLatest = process.env.SUBMINER_DOCS_LATEST_STABLE;
|
||||
const previousManifest = process.env.SUBMINER_DOCS_VERSION_MANIFEST;
|
||||
const previousVersionLinkOrigin = process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN;
|
||||
process.chdir(docsSiteDir);
|
||||
process.env.SUBMINER_DOCS_CHANNEL = 'stable-archive';
|
||||
process.env.SUBMINER_DOCS_BASE = '/v/0.12.0/';
|
||||
process.env.SUBMINER_DOCS_VERSION = 'v0.12.0';
|
||||
process.env.SUBMINER_DOCS_LATEST_STABLE = 'v0.14.0';
|
||||
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = 'production';
|
||||
process.env.SUBMINER_DOCS_VERSION_MANIFEST = JSON.stringify({
|
||||
latestStable: 'v0.14.0',
|
||||
channels: [
|
||||
{ label: 'Latest stable', path: '/' },
|
||||
{ label: 'main', path: '/main/' },
|
||||
],
|
||||
versions: [
|
||||
{ version: 'v0.14.0', path: '/v/0.14.0/' },
|
||||
{ version: 'v0.12.0', path: '/v/0.12.0/' },
|
||||
],
|
||||
});
|
||||
try {
|
||||
const { default: archiveConfig } = await import('./.vitepress/config?stable-archive-links');
|
||||
|
||||
@@ -131,39 +109,24 @@ test('stable archive theme links stay on the selected version', async () => {
|
||||
text: string;
|
||||
items?: Array<{ text: string; link: string }>;
|
||||
}>;
|
||||
const configurationNav = nav.find((item) => item.text === 'Configuration');
|
||||
const versionNav = nav.find((item) => item.text === 'v0.12.0');
|
||||
const referenceSidebar = sidebar.find((item) => item.text === 'Reference');
|
||||
const configurationSidebar = referenceSidebar?.items?.find(
|
||||
(item) => item.text === 'Configuration',
|
||||
);
|
||||
const configurationSidebar = sidebar
|
||||
.find((item) => item.text === 'Reference')
|
||||
?.items?.find((item) => item.text === 'Configuration');
|
||||
|
||||
expect(configurationNav?.link).toBe('/configuration');
|
||||
expect(nav.find((item) => item.text === 'Configuration')?.link).toBe('/configuration');
|
||||
expect(configurationSidebar?.link).toBe('/configuration');
|
||||
expect(versionNav?.items).toContainEqual({
|
||||
text: 'Latest stable (v0.14.0)',
|
||||
link: 'https://docs.subminer.moe/',
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
});
|
||||
expect(versionNav?.items).toContainEqual({
|
||||
text: 'main',
|
||||
link: 'https://docs.subminer.moe/main/',
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
});
|
||||
expect(versionNav?.items).toContainEqual({
|
||||
text: 'v0.14.0',
|
||||
link: 'https://docs.subminer.moe/v/0.14.0/',
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
});
|
||||
expect(versionNav?.items).toContainEqual({
|
||||
text: 'v0.12.0',
|
||||
link: 'https://docs.subminer.moe/v/0.12.0/',
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
});
|
||||
// Frozen archives must not embed the release list, or every new tag would
|
||||
// invalidate them. They point at the root-only /versions page instead.
|
||||
expect(nav.find((item) => item.text === 'v0.12.0')?.items).toEqual([
|
||||
{ text: 'Latest stable', link: 'https://docs.subminer.moe/', target: '_self', noIcon: true },
|
||||
{ text: 'main', link: 'https://docs.subminer.moe/main/', target: '_self', noIcon: true },
|
||||
{
|
||||
text: 'All versions',
|
||||
link: 'https://docs.subminer.moe/versions',
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
},
|
||||
]);
|
||||
expect(archiveConfig.themeConfig?.logo).toEqual({
|
||||
light: '/assets/SubMiner.png',
|
||||
dark: '/assets/SubMiner.png',
|
||||
@@ -173,268 +136,9 @@ test('stable archive theme links stay on the selected version', async () => {
|
||||
process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
|
||||
process.env.SUBMINER_DOCS_BASE = previousBase;
|
||||
process.env.SUBMINER_DOCS_VERSION = previousVersion;
|
||||
process.env.SUBMINER_DOCS_LATEST_STABLE = previousLatest;
|
||||
process.env.SUBMINER_DOCS_VERSION_MANIFEST = previousManifest;
|
||||
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = previousVersionLinkOrigin;
|
||||
}
|
||||
});
|
||||
|
||||
test('local stable archive version links stay on the dev server', async () => {
|
||||
const previousCwd = process.cwd();
|
||||
const previousChannel = process.env.SUBMINER_DOCS_CHANNEL;
|
||||
const previousBase = process.env.SUBMINER_DOCS_BASE;
|
||||
const previousVersion = process.env.SUBMINER_DOCS_VERSION;
|
||||
const previousLatest = process.env.SUBMINER_DOCS_LATEST_STABLE;
|
||||
const previousManifest = process.env.SUBMINER_DOCS_VERSION_MANIFEST;
|
||||
const previousVersionLinkOrigin = process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN;
|
||||
process.chdir(docsSiteDir);
|
||||
process.env.SUBMINER_DOCS_CHANNEL = 'stable-archive';
|
||||
process.env.SUBMINER_DOCS_BASE = '/v/0.10.0/';
|
||||
process.env.SUBMINER_DOCS_VERSION = 'v0.10.0';
|
||||
process.env.SUBMINER_DOCS_LATEST_STABLE = 'v0.14.0';
|
||||
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = 'local';
|
||||
process.env.SUBMINER_DOCS_VERSION_MANIFEST = JSON.stringify({
|
||||
latestStable: 'v0.14.0',
|
||||
channels: [
|
||||
{ label: 'Latest stable', path: '/' },
|
||||
{ label: 'main', path: '/main/' },
|
||||
],
|
||||
versions: [
|
||||
{ version: 'v0.14.0', path: '/v/0.14.0/' },
|
||||
{ version: 'v0.10.0', path: '/v/0.10.0/' },
|
||||
],
|
||||
});
|
||||
try {
|
||||
const { default: archiveConfig } = await import('./.vitepress/config?local-archive-links');
|
||||
|
||||
const nav = archiveConfig.themeConfig?.nav as Array<{
|
||||
text: string;
|
||||
items?: Array<{ text: string; link: string }>;
|
||||
}>;
|
||||
const versionNav = nav.find((item) => item.text === 'v0.10.0');
|
||||
|
||||
expect(versionNav?.items).toContainEqual({
|
||||
text: 'Latest stable (v0.14.0)',
|
||||
link: '../../',
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
});
|
||||
expect(versionNav?.items).toContainEqual({
|
||||
text: 'main',
|
||||
link: '../../main/',
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
});
|
||||
expect(versionNav?.items).toContainEqual({
|
||||
text: 'v0.14.0',
|
||||
link: '../0.14.0/',
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
});
|
||||
expect(versionNav?.items).toContainEqual({
|
||||
text: 'v0.10.0',
|
||||
link: './',
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
});
|
||||
} finally {
|
||||
process.chdir(previousCwd);
|
||||
process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
|
||||
process.env.SUBMINER_DOCS_BASE = previousBase;
|
||||
process.env.SUBMINER_DOCS_VERSION = previousVersion;
|
||||
process.env.SUBMINER_DOCS_LATEST_STABLE = previousLatest;
|
||||
process.env.SUBMINER_DOCS_VERSION_MANIFEST = previousManifest;
|
||||
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = previousVersionLinkOrigin;
|
||||
}
|
||||
});
|
||||
|
||||
test('dev docs version links use local targets for version route testing', async () => {
|
||||
const previousCwd = process.cwd();
|
||||
const previousChannel = process.env.SUBMINER_DOCS_CHANNEL;
|
||||
const previousBase = process.env.SUBMINER_DOCS_BASE;
|
||||
const previousVersion = process.env.SUBMINER_DOCS_VERSION;
|
||||
const previousLatest = process.env.SUBMINER_DOCS_LATEST_STABLE;
|
||||
const previousManifest = process.env.SUBMINER_DOCS_VERSION_MANIFEST;
|
||||
const previousVersionLinkOrigin = process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN;
|
||||
process.chdir(docsSiteDir);
|
||||
delete process.env.SUBMINER_DOCS_CHANNEL;
|
||||
delete process.env.SUBMINER_DOCS_BASE;
|
||||
delete process.env.SUBMINER_DOCS_VERSION;
|
||||
// Set explicitly (like the sibling version-nav tests) so this assertion stays
|
||||
// pinned to the manifest under test instead of the config's fallback constant.
|
||||
process.env.SUBMINER_DOCS_LATEST_STABLE = 'v0.14.0';
|
||||
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = 'local';
|
||||
process.env.SUBMINER_DOCS_VERSION_MANIFEST = JSON.stringify({
|
||||
latestStable: 'v0.14.0',
|
||||
channels: [
|
||||
{ label: 'Latest stable', path: '/' },
|
||||
{ label: 'main', path: '/main/' },
|
||||
],
|
||||
versions: [
|
||||
{ version: 'v0.14.0', path: '/v/0.14.0/' },
|
||||
{ version: 'v0.12.0', path: '/v/0.12.0/' },
|
||||
{ version: 'v0.11.2', path: '/v/0.11.2/' },
|
||||
],
|
||||
});
|
||||
try {
|
||||
const { default: devConfig } = await import('./.vitepress/config?dev-version-links');
|
||||
|
||||
const nav = devConfig.themeConfig?.nav as Array<{
|
||||
text: string;
|
||||
items?: Array<{ text: string; link: string }>;
|
||||
}>;
|
||||
const versionNav = nav.find((item) => item.text === 'v0.14.0');
|
||||
|
||||
expect(versionNav?.items).toContainEqual({
|
||||
text: 'Latest stable (v0.14.0)',
|
||||
link: '/',
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
});
|
||||
expect(versionNav?.items).toContainEqual({
|
||||
text: 'main',
|
||||
link: '/main/',
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
});
|
||||
expect(versionNav?.items).toContainEqual({
|
||||
text: 'v0.12.0',
|
||||
link: '/v/0.12.0/',
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
});
|
||||
expect(versionNav?.items?.map((item) => item.text)).toEqual([
|
||||
'Latest stable (v0.14.0)',
|
||||
'main',
|
||||
'v0.14.0',
|
||||
'v0.12.0',
|
||||
'v0.11.2',
|
||||
]);
|
||||
} finally {
|
||||
process.chdir(previousCwd);
|
||||
process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
|
||||
process.env.SUBMINER_DOCS_BASE = previousBase;
|
||||
process.env.SUBMINER_DOCS_VERSION = previousVersion;
|
||||
process.env.SUBMINER_DOCS_LATEST_STABLE = previousLatest;
|
||||
process.env.SUBMINER_DOCS_VERSION_MANIFEST = previousManifest;
|
||||
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = previousVersionLinkOrigin;
|
||||
}
|
||||
});
|
||||
|
||||
test('dev server redirects unserved version routes to production docs', () => {
|
||||
let routeHandler:
|
||||
| ((req: { url?: string }, res: DevRedirectResponse, next: () => void) => void)
|
||||
| undefined;
|
||||
const fakeServer = {
|
||||
middlewares: {
|
||||
use(handler: typeof routeHandler) {
|
||||
routeHandler = handler;
|
||||
},
|
||||
},
|
||||
};
|
||||
const plugins = Array.isArray(docsConfig.vite?.plugins)
|
||||
? docsConfig.vite.plugins
|
||||
: [docsConfig.vite?.plugins].filter(Boolean);
|
||||
const redirectPlugin = plugins.find(
|
||||
(plugin): plugin is { name: string; configureServer: (server: never) => void } =>
|
||||
Boolean(plugin) &&
|
||||
typeof plugin === 'object' &&
|
||||
'name' in plugin &&
|
||||
plugin.name === 'subminer-docs-local-version-redirects' &&
|
||||
'configureServer' in plugin,
|
||||
);
|
||||
expect(redirectPlugin).toBeDefined();
|
||||
redirectPlugin?.configureServer(fakeServer as never);
|
||||
|
||||
const response = new DevRedirectResponse();
|
||||
let nextCalled = false;
|
||||
routeHandler?.({ url: '/v/0.14.0/?from=dev' }, response, () => {
|
||||
nextCalled = true;
|
||||
});
|
||||
|
||||
expect(nextCalled).toBe(false);
|
||||
expect(response.statusCode).toBe(302);
|
||||
expect(response.headers.location).toBe('https://docs.subminer.moe/v/0.14.0/?from=dev');
|
||||
|
||||
const rootResponse = new DevRedirectResponse();
|
||||
routeHandler?.({ url: '/configuration' }, rootResponse, () => {
|
||||
nextCalled = true;
|
||||
});
|
||||
expect(rootResponse.ended).toBe(false);
|
||||
expect(nextCalled).toBe(true);
|
||||
});
|
||||
|
||||
test('dev server serves local archive files for local version links', async () => {
|
||||
const previousVersionLinkOrigin = process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN;
|
||||
const previousArchiveDir = process.env.SUBMINER_DOCS_LOCAL_ARCHIVE_DIR;
|
||||
const archiveDir = mkdtempSync(join(tmpdir(), 'subminer-docs-archive-'));
|
||||
mkdirSync(join(archiveDir, 'v/0.14.0'), { recursive: true });
|
||||
writeFileSync(join(archiveDir, 'v/0.14.0/index.html'), '<h1>local archive</h1>');
|
||||
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = 'local';
|
||||
process.env.SUBMINER_DOCS_LOCAL_ARCHIVE_DIR = archiveDir;
|
||||
try {
|
||||
const { default: localDevConfig } = await import(
|
||||
`./.vitepress/config?local-dev-redirects-${Date.now()}`
|
||||
);
|
||||
let routeHandler:
|
||||
| ((req: { url?: string }, res: DevRedirectResponse, next: () => void) => void)
|
||||
| undefined;
|
||||
const fakeServer = {
|
||||
middlewares: {
|
||||
use(handler: typeof routeHandler) {
|
||||
routeHandler = handler;
|
||||
},
|
||||
},
|
||||
};
|
||||
const plugins = Array.isArray(localDevConfig.vite?.plugins)
|
||||
? localDevConfig.vite.plugins
|
||||
: [localDevConfig.vite?.plugins].filter(Boolean);
|
||||
const redirectPlugin = plugins.find(
|
||||
(plugin): plugin is { name: string; configureServer: (server: never) => void } =>
|
||||
Boolean(plugin) &&
|
||||
typeof plugin === 'object' &&
|
||||
'name' in plugin &&
|
||||
plugin.name === 'subminer-docs-local-version-redirects' &&
|
||||
'configureServer' in plugin,
|
||||
);
|
||||
redirectPlugin?.configureServer(fakeServer as never);
|
||||
|
||||
const response = new DevRedirectResponse();
|
||||
let nextCalled = false;
|
||||
routeHandler?.({ url: '/v/0.14.0/?from=dev' }, response, () => {
|
||||
nextCalled = true;
|
||||
});
|
||||
|
||||
expect(nextCalled).toBe(false);
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers['content-type']).toBe('text/html; charset=utf-8');
|
||||
expect(response.headers.location).toBeUndefined();
|
||||
expect(response.body).toBe('<h1>local archive</h1>');
|
||||
} finally {
|
||||
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = previousVersionLinkOrigin;
|
||||
process.env.SUBMINER_DOCS_LOCAL_ARCHIVE_DIR = previousArchiveDir;
|
||||
rmSync(archiveDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
class DevRedirectResponse {
|
||||
statusCode = 200;
|
||||
headers: Record<string, string> = {};
|
||||
ended = false;
|
||||
body = '';
|
||||
|
||||
setHeader(name: string, value: string) {
|
||||
this.headers[name.toLowerCase()] = value;
|
||||
}
|
||||
|
||||
end(chunk?: string | Uint8Array) {
|
||||
if (chunk) {
|
||||
this.body = typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk);
|
||||
}
|
||||
this.ended = true;
|
||||
}
|
||||
}
|
||||
|
||||
test('docs sitemap excludes duplicate README page from indexable URLs', async () => {
|
||||
const items = [{ url: '' }, { url: 'README' }, { url: 'usage' }];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user