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

This commit is contained in:
2026-09-24 15:28:58 -07:00
committed by GitHub
parent 5096ca217b
commit bc73a02623
19 changed files with 651 additions and 835 deletions
+13 -193
View File
@@ -1,6 +1,6 @@
import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync, statSync } from 'node:fs';
import { extname, join, posix, resolve, sep } from 'node:path';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import type { DefaultTheme, HeadConfig, TransformContext, UserConfig } from 'vitepress';
const DOCS_HOSTNAME = 'https://docs.subminer.moe';
@@ -14,12 +14,6 @@ const PLAUSIBLE_INIT_SCRIPT = [
type DocsChannel = 'stable-root' | 'stable-archive' | 'main';
type VersionManifest = {
latestStable: string;
channels: Array<{ label: string; path: string }>;
versions: Array<{ version: string; path: string }>;
};
function optionalEnv(value: string | undefined): string | undefined {
return value && value !== 'undefined' ? value : undefined;
}
@@ -32,17 +26,6 @@ const docsSourceDir = optionalEnv(process.env.SUBMINER_DOCS_SOURCE_DIR) ?? proce
const repoDocsDir = optionalEnv(process.env.SUBMINER_DOCS_REPO_DIR) ?? process.cwd();
const channel = normalizeChannel(optionalEnv(process.env.SUBMINER_DOCS_CHANNEL));
const docsVersion = optionalEnv(process.env.SUBMINER_DOCS_VERSION);
const latestStable = optionalEnv(process.env.SUBMINER_DOCS_LATEST_STABLE) ?? 'v0.18.0';
const versionManifest = parseVersionManifest(process.env.SUBMINER_DOCS_VERSION_MANIFEST);
const versionLinkOrigin =
optionalEnv(process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN) ?? 'production';
function getLocalArchiveDir(): string {
return resolve(
optionalEnv(process.env.SUBMINER_DOCS_LOCAL_ARCHIVE_DIR) ??
join(docsSourceDir, '..', '.tmp/docs-versioned-site'),
);
}
function normalizeBase(value: string): string {
if (!value || value === '/') return '/';
@@ -54,21 +37,6 @@ function normalizeChannel(value: string | undefined): DocsChannel {
return 'stable-root';
}
function parseVersionManifest(value: string | undefined): VersionManifest {
if (!value || value === 'undefined') {
return {
latestStable,
channels: [
{ label: 'Latest stable', path: '/' },
{ label: 'main', path: '/main/' },
],
versions: [{ version: latestStable, path: `/v/${latestStable.replace(/^v/, '')}/` }],
};
}
return JSON.parse(value) as VersionManifest;
}
function withDocsBase(path: string): string {
if (/^[a-z]+:\/\//i.test(path)) return path;
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
@@ -164,137 +132,16 @@ function filterSidebar(items: DefaultTheme.SidebarItem[]): DefaultTheme.SidebarI
.filter((item): item is DefaultTheme.SidebarItem => Boolean(item));
}
function versionSwitchLink(path: string): string {
if (/^[a-z]+:\/\//i.test(path)) return path;
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
if (versionLinkOrigin === 'local') return localVersionSwitchLink(normalizedPath);
return `${DOCS_HOSTNAME}${normalizedPath}`;
}
function localVersionSwitchLink(path: string): string {
if (base === '/') return path;
const basePath = base.replace(/\/$/, '');
const targetPath = path === '/' ? '/' : path.replace(/\/$/, '');
const relativePath = posix.relative(basePath, targetPath) || '.';
return path.endsWith('/') ? `${relativePath}/` : relativePath;
}
function shouldHandleLocalVersionRoute(pathname: string): boolean {
if (base !== '/' || channel !== 'stable-root') return false;
return /^\/main(?:\/|$)/.test(pathname) || /^\/v\/[^/]+(?:\/|$)/.test(pathname);
}
function contentTypeForPath(path: string): string {
switch (extname(path)) {
case '.css':
return 'text/css; charset=utf-8';
case '.gif':
return 'image/gif';
case '.ico':
return 'image/x-icon';
case '.jpg':
case '.jpeg':
return 'image/jpeg';
case '.js':
case '.mjs':
return 'text/javascript; charset=utf-8';
case '.json':
case '.jsonc':
return 'application/json; charset=utf-8';
case '.mp4':
return 'video/mp4';
case '.png':
return 'image/png';
case '.svg':
return 'image/svg+xml';
case '.ttf':
return 'font/ttf';
case '.webm':
return 'video/webm';
case '.woff':
return 'font/woff';
case '.woff2':
return 'font/woff2';
case '.xml':
return 'application/xml; charset=utf-8';
default:
return 'text/html; charset=utf-8';
}
}
function isFile(path: string): boolean {
try {
return statSync(path).isFile();
} catch {
return false;
}
}
function archiveFileForPathname(pathname: string): string | null {
if (!shouldHandleLocalVersionRoute(pathname)) return null;
const localArchiveDir = getLocalArchiveDir();
const routePath = decodeURIComponent(pathname).replace(/^\/+/, '');
const filePath = resolve(localArchiveDir, routePath);
if (filePath !== localArchiveDir && !filePath.startsWith(`${localArchiveDir}${sep}`)) {
return null;
}
const candidates = pathname.endsWith('/')
? [join(filePath, 'index.html')]
: extname(filePath)
? [filePath]
: [`${filePath}.html`, join(filePath, 'index.html')];
return candidates.find(isFile) ?? null;
}
function serveLocalArchiveRoute(pathname: string, response: DevServerResponse): boolean {
if (
(optionalEnv(process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN) ?? versionLinkOrigin) !== 'local'
) {
return false;
}
const filePath = archiveFileForPathname(pathname);
if (!filePath) return false;
response.statusCode = 200;
response.setHeader('Content-Type', contentTypeForPath(filePath));
response.end(readFileSync(filePath));
return true;
}
type DevServerResponse = {
statusCode: number;
setHeader(name: string, value: string): void;
end(chunk?: string | Uint8Array): void;
};
const versionItems = [
{
text: `Latest stable (${versionManifest.latestStable})`,
link: versionSwitchLink('/'),
target: '_self',
noIcon: true,
},
...versionManifest.channels
.filter((entry) => entry.label !== 'Latest stable')
.map((entry) => ({
text: entry.label,
link: versionSwitchLink(entry.path),
target: '_self',
noIcon: true,
})),
...versionManifest.versions.map((entry) => ({
text: entry.version,
link: versionSwitchLink(entry.path),
target: '_self',
noIcon: true,
})),
];
// Version navigation targets other builds (root, `/main/`, `/versions`), so it links to
// production by absolute URL: base-relative links would stay inside this build, and
// `target: '_self'` makes the VitePress router do a full page load. The list is
// deliberately static; the full release list lives on the root-only `/versions` page
// so frozen `/v/<version>/` archives never need a rebuild when a new tag ships.
const versionItems: DefaultTheme.NavItemWithLink[] = [
{ text: 'Latest stable', link: `${DOCS_HOSTNAME}/` },
{ text: 'main', link: `${DOCS_HOSTNAME}/main/` },
{ text: 'All versions', link: `${DOCS_HOSTNAME}/versions` },
].map((item) => ({ ...item, target: '_self', noIcon: true }));
function sitemapUrlToPage(url: string): string {
const route = url.replace(/\.html$/, '').replace(/^\/+|\/+$/g, '');
@@ -336,7 +183,7 @@ const nav: DefaultTheme.NavItem[] = [
{ text: 'Configuration', link: '/configuration' },
{ text: 'Changelog', link: '/changelog' },
{ text: 'Troubleshooting', link: '/troubleshooting' },
{ text: docsVersion ?? (channel === 'main' ? 'main' : latestStable), items: versionItems },
{ text: docsVersion ?? 'main', items: versionItems },
];
const sidebar: DefaultTheme.SidebarItem[] = [
@@ -394,33 +241,6 @@ const config: UserConfig = {
'SubMiner: an MPV immersion-mining overlay with Yomitan and AnkiConnect integration.',
base,
...(outDir ? { outDir } : {}),
vite: {
plugins: [
{
name: 'subminer-docs-local-version-redirects',
configureServer(server) {
server.middlewares.use((request, response, next) => {
const requestUrl = new URL(request.url ?? '/', 'http://localhost');
if (serveLocalArchiveRoute(requestUrl.pathname, response)) {
return;
}
if (!shouldHandleLocalVersionRoute(requestUrl.pathname)) {
next();
return;
}
response.statusCode = 302;
response.setHeader(
'Location',
`${DOCS_HOSTNAME}${requestUrl.pathname}${requestUrl.search}`,
);
response.end();
});
},
},
],
},
head: [
['link', { rel: 'preconnect', href: PLAUSIBLE_PROXY_HOSTNAME }],
[