mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-25 12:55:18 -07:00
fix(docs): correct versioned nav links and local dev version routing (#74)
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
.PHONY: help deps build build-launcher install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-windows uninstall uninstall-linux uninstall-macos uninstall-windows print-dirs pretty lint ensure-bun generate-config generate-example-config dev-start dev-start-macos dev-watch dev-watch-macos dev-toggle dev-stop
|
||||
.PHONY: help deps build build-launcher install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-windows uninstall uninstall-linux uninstall-macos uninstall-windows print-dirs pretty lint ensure-bun generate-config generate-example-config dev-start dev-start-macos dev-watch dev-watch-macos dev-toggle dev-stop docs-test docs-build docs-build-versioned docs-dev
|
||||
|
||||
APP_NAME := subminer
|
||||
THEME_SOURCE := assets/themes/subminer.rasi
|
||||
@@ -62,6 +62,10 @@ help:
|
||||
" dev-watch-macos Start watch loop with forced macOS tracker backend" \
|
||||
" dev-toggle Toggle overlay in a running local Electron app" \
|
||||
" dev-stop Stop a running local Electron app" \
|
||||
" docs-test Run docs tests" \
|
||||
" docs-build Build the docs site" \
|
||||
" docs-build-versioned Build production versioned docs site" \
|
||||
" docs-dev Start the docs dev server" \
|
||||
" install-linux Install Linux wrapper/theme/app artifacts" \
|
||||
" install-macos Install macOS wrapper/theme/app artifacts" \
|
||||
" install-windows Print Windows packaging/install guidance" \
|
||||
@@ -200,6 +204,18 @@ dev-toggle: ensure-bun
|
||||
dev-stop: ensure-bun
|
||||
@bun run electron . --stop
|
||||
|
||||
docs-test: ensure-bun
|
||||
@bun run docs:test
|
||||
|
||||
docs-build: ensure-bun
|
||||
@bun run docs:build
|
||||
|
||||
docs-build-versioned: ensure-bun
|
||||
@bun run docs:build:versioned
|
||||
|
||||
docs-dev: ensure-bun
|
||||
@bun run docs:dev
|
||||
|
||||
|
||||
install-linux: build-launcher
|
||||
@printf '%s\n' "[INFO] Installing Linux wrapper/theme artifacts"
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
# Config Settings Window
|
||||
|
||||
Status: draft
|
||||
Owner: Kyle Yasuda
|
||||
Created: 2026-05-17
|
||||
|
||||
## Goal
|
||||
|
||||
Add a dedicated configuration window that groups settings by user workflow while saving back to the existing `config.jsonc` paths.
|
||||
|
||||
## Notes
|
||||
|
||||
- Full current config surface, excluding legacy/ignored compatibility keys.
|
||||
- Preserve JSONC comments/formatting when saving.
|
||||
- Surface hot-reload vs restart-required results.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: docs
|
||||
|
||||
- Fixed versioned docs navigation so archived pages keep local links under the selected version, the version switcher no longer nests targets under the current archive path, local dev version routes serve warmed archive files instead of redirecting to production or falling through to VitePress 404s, and internal README files do not break archived builds.
|
||||
@@ -1,5 +1,5 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { existsSync, readFileSync, statSync } from 'node:fs';
|
||||
import { extname, join, posix, resolve, sep } from 'node:path';
|
||||
import type { DefaultTheme, HeadConfig, TransformContext, UserConfig } from 'vitepress';
|
||||
|
||||
const DOCS_HOSTNAME = 'https://docs.subminer.moe';
|
||||
@@ -21,10 +21,16 @@ type VersionManifest = {
|
||||
|
||||
const base = normalizeBase(process.env.SUBMINER_DOCS_BASE ?? '/');
|
||||
const outDir = process.env.SUBMINER_DOCS_OUT_DIR;
|
||||
const docsSourceDir = process.env.SUBMINER_DOCS_SOURCE_DIR ?? process.cwd();
|
||||
const localArchiveDir = resolve(
|
||||
process.env.SUBMINER_DOCS_LOCAL_ARCHIVE_DIR ??
|
||||
join(docsSourceDir, '..', '.tmp/docs-versioned-site'),
|
||||
);
|
||||
const channel = normalizeChannel(process.env.SUBMINER_DOCS_CHANNEL);
|
||||
const docsVersion = process.env.SUBMINER_DOCS_VERSION;
|
||||
const latestStable = process.env.SUBMINER_DOCS_LATEST_STABLE ?? 'v0.14.0';
|
||||
const versionManifest = parseVersionManifest(process.env.SUBMINER_DOCS_VERSION_MANIFEST);
|
||||
const versionLinkOrigin = process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN ?? 'production';
|
||||
|
||||
function normalizeBase(value: string): string {
|
||||
if (!value || value === '/') return '/';
|
||||
@@ -113,7 +119,7 @@ function linkToPagePath(link: string): string | null {
|
||||
function hasPageForLink(link: string): boolean {
|
||||
const pagePath = linkToPagePath(link);
|
||||
if (!pagePath) return true;
|
||||
return existsSync(join(process.cwd(), pagePath));
|
||||
return existsSync(join(docsSourceDir, pagePath));
|
||||
}
|
||||
|
||||
function filterNav(items: DefaultTheme.NavItem[]): DefaultTheme.NavItem[] {
|
||||
@@ -141,17 +147,130 @@ 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 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 (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: '/',
|
||||
link: versionSwitchLink('/'),
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
},
|
||||
...versionManifest.channels
|
||||
.filter((entry) => entry.label !== 'Latest stable')
|
||||
.map((entry) => ({ text: entry.label, link: entry.path })),
|
||||
.map((entry) => ({
|
||||
text: entry.label,
|
||||
link: versionSwitchLink(entry.path),
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
})),
|
||||
...versionManifest.versions.map((entry) => ({
|
||||
text: entry.version,
|
||||
link: entry.path,
|
||||
link: versionSwitchLink(entry.path),
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
})),
|
||||
];
|
||||
|
||||
@@ -217,6 +336,33 @@ 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 }],
|
||||
[
|
||||
@@ -268,7 +414,7 @@ const config: UserConfig = {
|
||||
},
|
||||
transformHead: transformPageHead,
|
||||
lastUpdated: true,
|
||||
srcExclude: ['subagents/**'],
|
||||
srcExclude: ['subagents/**', 'README.md'],
|
||||
markdown: {
|
||||
theme: {
|
||||
light: 'catppuccin-latte',
|
||||
@@ -277,8 +423,8 @@ const config: UserConfig = {
|
||||
},
|
||||
themeConfig: {
|
||||
logo: {
|
||||
light: withDocsBase('/assets/SubMiner.png'),
|
||||
dark: withDocsBase('/assets/SubMiner.png'),
|
||||
light: '/assets/SubMiner.png',
|
||||
dark: '/assets/SubMiner.png',
|
||||
},
|
||||
siteTitle: 'SubMiner Docs',
|
||||
nav: filterNav(nav),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { useRoute, useData } from 'vitepress';
|
||||
import { computed } from 'vue';
|
||||
import { formatStatusLineFilePath } from '../status-line';
|
||||
|
||||
const route = useRoute();
|
||||
const { page, frontmatter } = useData();
|
||||
@@ -12,8 +13,7 @@ const mode = computed(() => {
|
||||
});
|
||||
|
||||
const filePath = computed(() => {
|
||||
const path = route.path;
|
||||
return path === '/' ? 'index.md' : `${path.replace(/^\//, '')}.md`;
|
||||
return formatStatusLineFilePath(route.path);
|
||||
});
|
||||
|
||||
const section = computed(() => {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { formatStatusLineFilePath } from './status-line';
|
||||
|
||||
test('status line file path formats root home as index markdown', () => {
|
||||
expect(formatStatusLineFilePath('/')).toBe('index.md');
|
||||
});
|
||||
|
||||
test('status line file path formats version archive home without trailing slash', () => {
|
||||
expect(formatStatusLineFilePath('/v/0.12.0/')).toBe('v/0.12.0.md');
|
||||
});
|
||||
|
||||
test('status line file path keeps normal docs routes as markdown files', () => {
|
||||
expect(formatStatusLineFilePath('/v/0.12.0/configuration')).toBe(
|
||||
'v/0.12.0/configuration.md',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
export function formatStatusLineFilePath(routePath: string): string {
|
||||
if (routePath === '/') return 'index.md';
|
||||
return `${routePath.replace(/^\/|\/$/g, '')}.md`;
|
||||
}
|
||||
+13
-12
@@ -119,7 +119,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.
|
||||
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.
|
||||
|
||||
Focused commands:
|
||||
|
||||
@@ -162,6 +162,7 @@ bun run format:check:src
|
||||
- `make pretty` runs the maintained Prettier allowlist only (`format:src`).
|
||||
- `bun run format:check:src` checks the same scoped set without writing changes.
|
||||
- `bun run format` remains the broad repo-wide Prettier command; use it intentionally.
|
||||
|
||||
## Config Generation
|
||||
|
||||
```bash
|
||||
@@ -205,17 +206,17 @@ Use Cloudflare's single `*` wildcard syntax for watch paths. `docs-site/*` cover
|
||||
|
||||
Run `make help` for a full list of targets. Key ones:
|
||||
|
||||
| Target | Description |
|
||||
| ---------------------- | ---------------------------------------------------------------- |
|
||||
| `make build` | Build platform package for detected OS |
|
||||
| `make build-launcher` | Generate Bun launcher wrapper at `dist/launcher/subminer` |
|
||||
| `make install` | Install platform artifacts (wrapper, theme, AppImage/app bundle) |
|
||||
| `make deps` | Install JS dependencies (root + stats + texthooker-ui) |
|
||||
| `make pretty` | Run scoped Prettier formatting for maintained source/config files |
|
||||
| `make generate-config` | Generate default config from centralized registry |
|
||||
| `make build-linux` | Convenience wrapper for Linux packaging |
|
||||
| `make build-macos` | Convenience wrapper for signed macOS packaging |
|
||||
| `make build-macos-unsigned` | Convenience wrapper for unsigned macOS packaging |
|
||||
| Target | Description |
|
||||
| --------------------------- | ----------------------------------------------------------------- |
|
||||
| `make build` | Build platform package for detected OS |
|
||||
| `make build-launcher` | Generate Bun launcher wrapper at `dist/launcher/subminer` |
|
||||
| `make install` | Install platform artifacts (wrapper, theme, AppImage/app bundle) |
|
||||
| `make deps` | Install JS dependencies (root + stats + texthooker-ui) |
|
||||
| `make pretty` | Run scoped Prettier formatting for maintained source/config files |
|
||||
| `make generate-config` | Generate default config from centralized registry |
|
||||
| `make build-linux` | Convenience wrapper for Linux packaging |
|
||||
| `make build-macos` | Convenience wrapper for signed macOS packaging |
|
||||
| `make build-macos-unsigned` | Convenience wrapper for unsigned macOS packaging |
|
||||
|
||||
## Contributor Notes
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ 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',
|
||||
@@ -57,6 +58,15 @@ test('docs reflect current launcher and release surfaces', () => {
|
||||
expect(changelogContents).toContain('v0.5.1 (2026-03-09)');
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
"description": "In-repo VitePress documentation site for SubMiner",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"scripts": {
|
||||
"docs:dev": "VITE_EXTRA_EXTENSIONS=jsonc vitepress dev --host 0.0.0.0 --port 5173 --strictPort",
|
||||
"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: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 seo.test.ts ../scripts/docs-versioning.test.ts"
|
||||
"test": "bun test plausible.test.ts index.assets.test.ts docs-sync.test.ts seo.test.ts .vitepress/theme/status-line.test.ts ../scripts/docs-versioning.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@catppuccin/vitepress": "^0.1.2",
|
||||
|
||||
@@ -41,3 +41,28 @@ test('versioned docs reuse current VitePress internals for old page snapshots',
|
||||
expect(versionedBuildContents).toContain("cpSync(join(currentDocsSite, '.vitepress')");
|
||||
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', () => {
|
||||
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']");
|
||||
});
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
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';
|
||||
|
||||
const docsSiteDir = fileURLToPath(new URL('.', import.meta.url));
|
||||
|
||||
function makeTransformContext(page: string): TransformContext {
|
||||
return {
|
||||
page,
|
||||
@@ -79,6 +85,343 @@ test('latest stable archive canonical points to root equivalent', async () => {
|
||||
process.env.SUBMINER_DOCS_LATEST_STABLE = previousLatest;
|
||||
});
|
||||
|
||||
test('stable archive theme links stay on the selected version', 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');
|
||||
|
||||
const nav = archiveConfig.themeConfig?.nav as Array<{
|
||||
text: string;
|
||||
link?: string;
|
||||
items?: Array<{ text: string; link: string }>;
|
||||
}>;
|
||||
const sidebar = archiveConfig.themeConfig?.sidebar as Array<{
|
||||
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',
|
||||
);
|
||||
|
||||
expect(configurationNav?.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,
|
||||
});
|
||||
expect(archiveConfig.themeConfig?.logo).toEqual({
|
||||
light: '/assets/SubMiner.png',
|
||||
dark: '/assets/SubMiner.png',
|
||||
});
|
||||
} 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('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;
|
||||
delete process.env.SUBMINER_DOCS_LATEST_STABLE;
|
||||
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');
|
||||
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' }];
|
||||
|
||||
|
||||
+1
-1
@@ -72,7 +72,7 @@
|
||||
"test:launcher": "bun run test:launcher:src",
|
||||
"test:core": "bun run test:core:src",
|
||||
"test:subtitle": "bun run test:subtitle:src",
|
||||
"test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/card-creation-manual-update.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/prerelease-workflow.test.ts src/ci-workflow.test.ts scripts/docs-versioning.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/get-mpv-window-macos.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
|
||||
"test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/card-creation-manual-update.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/prerelease-workflow.test.ts src/ci-workflow.test.ts scripts/docs-versioning.test.ts scripts/docs-versioned-assets.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/get-mpv-window-macos.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
|
||||
"generate:config-example": "bun run src/generate-config-example.ts",
|
||||
"verify:config-example": "bun run src/verify-config-example.ts",
|
||||
"start": "bun run build && electron . --start",
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
readlinkSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import {
|
||||
collectSharedAssetPaths,
|
||||
dedupeVersionedPublicAssets,
|
||||
pruneArchiveCacheGenerations,
|
||||
} from './docs-versioned-assets';
|
||||
import {
|
||||
buildVersionManifest,
|
||||
stableTagsWithDocs,
|
||||
versionArchiveCacheKey,
|
||||
versionArchiveCacheName,
|
||||
versionOutputPath,
|
||||
versionPath,
|
||||
@@ -18,7 +35,11 @@ const archiveCacheRoot = join(repoRoot, '.tmp/docs-versioned-archive-cache');
|
||||
const maxCloudflareFiles = 20_000;
|
||||
const maxCloudflareFileBytes = 25 * 1024 * 1024;
|
||||
|
||||
function run(command: string, args: string[], options: { cwd?: string; env?: NodeJS.ProcessEnv } = {}) {
|
||||
function run(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: { cwd?: string; env?: NodeJS.ProcessEnv } = {},
|
||||
) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options.cwd ?? repoRoot,
|
||||
env: options.env ?? process.env,
|
||||
@@ -144,12 +165,14 @@ function buildDocs(options: {
|
||||
latestStable: string;
|
||||
manifestJson: string;
|
||||
}) {
|
||||
console.info(`[docs] building ${options.version ?? options.channel} -> ${options.base}`);
|
||||
run('bun', ['run', '--cwd', currentDocsSite, 'vitepress', 'build', options.snapshotDocsSite], {
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
SUBMINER_DOCS_BASE: options.base,
|
||||
SUBMINER_DOCS_OUT_DIR: options.outDir,
|
||||
SUBMINER_DOCS_SOURCE_DIR: options.snapshotDocsSite,
|
||||
SUBMINER_DOCS_CHANNEL: options.channel,
|
||||
SUBMINER_DOCS_VERSION: options.version ?? '',
|
||||
SUBMINER_DOCS_LATEST_STABLE: options.latestStable,
|
||||
@@ -160,25 +183,28 @@ function buildDocs(options: {
|
||||
}
|
||||
|
||||
function updateHashWithPath(hash: ReturnType<typeof createHash>, path: string) {
|
||||
if (isGeneratedVitePressPath(path)) {
|
||||
if (isSharedInternalsHashIgnoredPath(path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stat = lstatSync(path);
|
||||
hash.update(path.replace(repoRoot, ''));
|
||||
hash.update(String(stat.mode));
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -186,8 +212,15 @@ function isGeneratedVitePressPath(path: string): boolean {
|
||||
return /[\\/]\\.vitepress[\\/](cache|dist)([\\/]|$)/.test(path);
|
||||
}
|
||||
|
||||
function isSharedInternalsHashIgnoredPath(path: string): boolean {
|
||||
return isGeneratedVitePressPath(path) || /\.test\.[cm]?[jt]s$/.test(path);
|
||||
}
|
||||
|
||||
function computeSharedInternalsHash(): string {
|
||||
const hash = createHash('sha256');
|
||||
hash.update(
|
||||
`version-link-origin:${process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN === 'local' ? 'local' : 'production'}`,
|
||||
);
|
||||
const paths = [
|
||||
join(currentDocsSite, '.vitepress'),
|
||||
join(currentDocsSite, 'public/assets/fonts'),
|
||||
@@ -216,6 +249,7 @@ function restoreCachedArchive(version: string, sharedInternalsHash: string): boo
|
||||
return false;
|
||||
}
|
||||
|
||||
console.info(`[docs] cache hit ${version}`);
|
||||
cpSync(cachedArchive, join(aggregateOutDir, versionOutputPath(version)), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
@@ -267,9 +301,7 @@ function assertCloudflarePagesLimits(root: string) {
|
||||
}
|
||||
|
||||
if (oversizedFiles.length > 0) {
|
||||
throw new Error(
|
||||
`Versioned docs output has files over 25 MiB:\n${oversizedFiles.join('\n')}`,
|
||||
);
|
||||
throw new Error(`Versioned docs output has files over 25 MiB:\n${oversizedFiles.join('\n')}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,6 +316,9 @@ function main() {
|
||||
const manifest = buildVersionManifest({ latestStable, stableVersions });
|
||||
const manifestJson = JSON.stringify(manifest);
|
||||
const sharedInternalsHash = computeSharedInternalsHash();
|
||||
const archiveCacheKey = versionArchiveCacheKey({ sharedInternalsHash, manifestJson });
|
||||
const sharedAssetPaths = collectSharedAssetPaths(join(currentDocsSite, 'public/assets'));
|
||||
console.info(`[docs] archive cache key ${archiveCacheKey.slice(0, 12)}`);
|
||||
|
||||
rmSync(buildRoot, { recursive: true, force: true });
|
||||
rmSync(aggregateOutDir, { recursive: true, force: true });
|
||||
@@ -302,11 +337,13 @@ function main() {
|
||||
});
|
||||
|
||||
for (const version of stableVersions) {
|
||||
if (restoreCachedArchive(version, sharedInternalsHash)) {
|
||||
if (restoreCachedArchive(version, archiveCacheKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const snapshot = version === latestStable ? latestStableSnapshot : prepareSnapshot(version, version);
|
||||
console.info(`[docs] rebuilding archive ${version}`);
|
||||
const snapshot =
|
||||
version === latestStable ? latestStableSnapshot : prepareSnapshot(version, version);
|
||||
buildDocs({
|
||||
snapshotDocsSite: snapshot,
|
||||
base: versionPath(version),
|
||||
@@ -316,7 +353,12 @@ function main() {
|
||||
latestStable,
|
||||
manifestJson,
|
||||
});
|
||||
saveArchiveCache(version, sharedInternalsHash);
|
||||
dedupeVersionedPublicAssets({
|
||||
outDir: join(aggregateOutDir, versionOutputPath(version)),
|
||||
base: versionPath(version),
|
||||
sharedAssetPaths,
|
||||
});
|
||||
saveArchiveCache(version, archiveCacheKey);
|
||||
}
|
||||
|
||||
const mainSnapshot = prepareSnapshot('main');
|
||||
@@ -329,9 +371,22 @@ function main() {
|
||||
latestStable,
|
||||
manifestJson,
|
||||
});
|
||||
dedupeVersionedPublicAssets({
|
||||
outDir: join(aggregateOutDir, 'main'),
|
||||
base: '/main/',
|
||||
sharedAssetPaths,
|
||||
});
|
||||
|
||||
writeFileSync(join(aggregateOutDir, 'versions.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
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 });
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
dedupeVersionedPublicAssets,
|
||||
pruneArchiveCacheGenerations,
|
||||
rewriteSharedAssetReferences,
|
||||
} from './docs-versioned-assets';
|
||||
|
||||
function tempDir() {
|
||||
return mkdtempSync(join(tmpdir(), 'subminer-docs-versioned-assets-'));
|
||||
}
|
||||
|
||||
describe('docs versioned asset dedupe', () => {
|
||||
test('rewrites version-scoped public asset references to shared root assets', () => {
|
||||
const html =
|
||||
'<link href="/v/0.14.0/assets/style.hash.css"><source src="/v/0.14.0/assets/minecard.webm">';
|
||||
const expected =
|
||||
'<link href="/v/0.14.0/assets/style.hash.css"><source src="/assets/minecard.webm">';
|
||||
|
||||
expect(rewriteSharedAssetReferences(html, '/v/0.14.0/', new Set(['minecard.webm']))).toBe(
|
||||
expected,
|
||||
);
|
||||
expect(rewriteSharedAssetReferences(html, '/v/0.14.0', new Set(['minecard.webm']))).toBe(
|
||||
expected,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not rewrite longer asset paths with a shared asset prefix', () => {
|
||||
expect(
|
||||
rewriteSharedAssetReferences(
|
||||
[
|
||||
'<script src="/v/0.14.0/assets/foo.js"></script>',
|
||||
'<script src="/v/0.14.0/assets/foo.js?v=1"></script>',
|
||||
'<script src="/v/0.14.0/assets/foo.js.map"></script>',
|
||||
].join(''),
|
||||
'/v/0.14.0/',
|
||||
new Set(['foo.js']),
|
||||
),
|
||||
).toBe(
|
||||
[
|
||||
'<script src="/assets/foo.js"></script>',
|
||||
'<script src="/assets/foo.js?v=1"></script>',
|
||||
'<script src="/v/0.14.0/assets/foo.js.map"></script>',
|
||||
].join(''),
|
||||
);
|
||||
});
|
||||
|
||||
test('removes duplicated version public assets while preserving generated VitePress assets', async () => {
|
||||
const dir = tempDir();
|
||||
try {
|
||||
mkdirSync(join(dir, 'assets/chunks'), { recursive: true });
|
||||
writeFileSync(join(dir, 'assets/style.hash.css'), 'body{}');
|
||||
writeFileSync(join(dir, 'assets/chunks/theme.hash.js'), 'export {};');
|
||||
writeFileSync(join(dir, 'assets/minecard.webm'), 'large video');
|
||||
writeFileSync(
|
||||
join(dir, 'index.html'),
|
||||
'<link href="/v/0.14.0/assets/style.hash.css"><source src="/v/0.14.0/assets/minecard.webm">',
|
||||
);
|
||||
|
||||
const result = dedupeVersionedPublicAssets({
|
||||
outDir: dir,
|
||||
base: '/v/0.14.0',
|
||||
sharedAssetPaths: new Set(['minecard.webm']),
|
||||
});
|
||||
|
||||
expect(result.rewrittenFiles).toEqual([join(dir, 'index.html')]);
|
||||
expect(existsSync(join(dir, 'assets/style.hash.css'))).toBe(true);
|
||||
expect(existsSync(join(dir, 'assets/chunks/theme.hash.js'))).toBe(true);
|
||||
expect(existsSync(join(dir, 'assets/minecard.webm'))).toBe(false);
|
||||
expect(readFileSync(join(dir, 'index.html'), 'utf8')).toBe(
|
||||
'<link href="/v/0.14.0/assets/style.hash.css"><source src="/assets/minecard.webm">',
|
||||
);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps root public assets because they are the shared copy', async () => {
|
||||
const dir = tempDir();
|
||||
try {
|
||||
mkdirSync(join(dir, 'assets'), { recursive: true });
|
||||
writeFileSync(join(dir, 'assets/minecard.webm'), 'large video');
|
||||
|
||||
const result = dedupeVersionedPublicAssets({
|
||||
outDir: dir,
|
||||
base: '/',
|
||||
sharedAssetPaths: new Set(['minecard.webm']),
|
||||
});
|
||||
|
||||
expect(result.removedAssetsDir).toBe(false);
|
||||
expect(existsSync(join(dir, 'assets'))).toBe(true);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { existsSync, lstatSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
|
||||
const textOutputPattern = /\.(?:css|html|js|json|map|mjs|txt|xml)$/;
|
||||
|
||||
function normalizeBase(base: string): string {
|
||||
return base === '/' ? '/' : `${base.replace(/\/+$/, '')}/`;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export function collectSharedAssetPaths(publicAssetsDir: string): Set<string> {
|
||||
if (!existsSync(publicAssetsDir)) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
return new Set(
|
||||
walkFiles(publicAssetsDir).map((file) => relative(publicAssetsDir, file).split('\\').join('/')),
|
||||
);
|
||||
}
|
||||
|
||||
export function rewriteSharedAssetReferences(
|
||||
content: string,
|
||||
base: string,
|
||||
sharedAssetPaths: Set<string>,
|
||||
): string {
|
||||
const normalizedBase = normalizeBase(base);
|
||||
if (normalizedBase === '/') {
|
||||
return content;
|
||||
}
|
||||
|
||||
let rewritten = content;
|
||||
const escapedBase = escapeRegExp(normalizedBase);
|
||||
for (const assetPath of sharedAssetPaths) {
|
||||
const escapedAssetPath = escapeRegExp(assetPath);
|
||||
rewritten = rewritten.replace(
|
||||
new RegExp(`${escapedBase}assets/${escapedAssetPath}(?=$|[?#"'()\\s<])`, 'g'),
|
||||
`/assets/${assetPath}`,
|
||||
);
|
||||
}
|
||||
return rewritten;
|
||||
}
|
||||
|
||||
function walkFiles(root: string): string[] {
|
||||
const files: string[] = [];
|
||||
|
||||
function walk(dir: string) {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const path = join(dir, entry);
|
||||
const stat = lstatSync(path);
|
||||
if (stat.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
walk(path);
|
||||
continue;
|
||||
}
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
walk(root);
|
||||
return files;
|
||||
}
|
||||
|
||||
export function dedupeVersionedPublicAssets(options: {
|
||||
outDir: string;
|
||||
base: string;
|
||||
sharedAssetPaths: Set<string>;
|
||||
}): {
|
||||
removedAssetsDir: boolean;
|
||||
rewrittenFiles: string[];
|
||||
} {
|
||||
const normalizedBase = normalizeBase(options.base);
|
||||
const rewrittenFiles: string[] = [];
|
||||
|
||||
for (const file of walkFiles(options.outDir)) {
|
||||
if (!textOutputPattern.test(file)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const before = readFileSync(file, 'utf8');
|
||||
const after = rewriteSharedAssetReferences(before, normalizedBase, options.sharedAssetPaths);
|
||||
if (after === before) {
|
||||
continue;
|
||||
}
|
||||
|
||||
writeFileSync(file, after);
|
||||
rewrittenFiles.push(file);
|
||||
}
|
||||
|
||||
const assetsDir = join(options.outDir, 'assets');
|
||||
if (normalizedBase !== '/') {
|
||||
for (const assetPath of options.sharedAssetPaths) {
|
||||
rmSync(join(assetsDir, assetPath), { force: true });
|
||||
}
|
||||
removeEmptyDirectories(assetsDir);
|
||||
}
|
||||
|
||||
const removedAssetsDir = !existsSync(assetsDir);
|
||||
return { removedAssetsDir, rewrittenFiles };
|
||||
}
|
||||
|
||||
function removeEmptyDirectories(root: string) {
|
||||
if (!existsSync(root) || !lstatSync(root).isDirectory()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of readdirSync(root)) {
|
||||
const path = join(root, entry);
|
||||
if (lstatSync(path).isDirectory()) {
|
||||
removeEmptyDirectories(path);
|
||||
}
|
||||
}
|
||||
|
||||
if (readdirSync(root).length === 0) {
|
||||
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,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
buildVersionManifest,
|
||||
compareStableVersionsDesc,
|
||||
versionArchiveCacheKey,
|
||||
isStableReleaseTag,
|
||||
stableTagsWithDocs,
|
||||
versionArchiveCacheName,
|
||||
@@ -50,6 +51,19 @@ describe('docs versioning helpers', () => {
|
||||
expect(versionArchiveCacheName('v0.14.0', 'abcdef1234567890')).toBe('abcdef123456-v0.14.0');
|
||||
});
|
||||
|
||||
test('archive cache keys change when manifest contents change', () => {
|
||||
const firstKey = versionArchiveCacheKey({
|
||||
sharedInternalsHash: 'abcdef1234567890',
|
||||
manifestJson: '{"latestStable":"v0.14.0"}',
|
||||
});
|
||||
const secondKey = versionArchiveCacheKey({
|
||||
sharedInternalsHash: 'abcdef1234567890',
|
||||
manifestJson: '{"latestStable":"v0.15.0"}',
|
||||
});
|
||||
|
||||
expect(firstKey).not.toBe(secondKey);
|
||||
});
|
||||
|
||||
test('archive output paths stay relative for filesystem joins', () => {
|
||||
expect(versionOutputPath('v0.14.0')).toBe('v/0.14.0');
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
export type DocsVersionEntry = {
|
||||
version: string;
|
||||
path: string;
|
||||
@@ -57,14 +59,23 @@ export function versionArchiveCacheName(version: string, sharedInternalsHash: st
|
||||
return `${sharedInternalsHash.slice(0, 12)}-${version}`;
|
||||
}
|
||||
|
||||
export function versionArchiveCacheKey(options: {
|
||||
sharedInternalsHash: string;
|
||||
manifestJson: string;
|
||||
}): string {
|
||||
const hash = createHash('sha256');
|
||||
hash.update('shared-internals:');
|
||||
hash.update(options.sharedInternalsHash);
|
||||
hash.update('\nmanifest:');
|
||||
hash.update(options.manifestJson);
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
export function stableTagsWithDocs(
|
||||
tags: string[],
|
||||
hasDocsSite: (tag: string) => boolean,
|
||||
): string[] {
|
||||
return tags
|
||||
.filter(isStableReleaseTag)
|
||||
.filter(hasDocsSite)
|
||||
.sort(compareStableVersionsDesc);
|
||||
return tags.filter(isStableReleaseTag).filter(hasDocsSite).sort(compareStableVersionsDesc);
|
||||
}
|
||||
|
||||
export function buildVersionManifest(options: {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
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 })));
|
||||
Reference in New Issue
Block a user