From 799cce699166d83e0a7eccc54966dd668d22cc0e Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 18 May 2026 01:07:17 -0700 Subject: [PATCH] fix(docs): correct versioned nav links and local dev version routing (#74) --- Makefile | 18 +- backlog/drafts/config-settings-window.md | 15 - changes/fix-versioned-doc-links.md | 4 + docs-site/.vitepress/config.ts | 164 ++++++++- .../theme/components/StatusLine.vue | 4 +- .../.vitepress/theme/status-line.test.ts | 16 + docs-site/.vitepress/theme/status-line.ts | 4 + docs-site/development.md | 25 +- docs-site/docs-sync.test.ts | 10 + docs-site/package.json | 4 +- docs-site/plausible.test.ts | 25 ++ docs-site/seo.test.ts | 343 ++++++++++++++++++ package.json | 2 +- scripts/build-versioned-docs.ts | 77 +++- scripts/docs-versioned-assets.test.ts | 124 +++++++ scripts/docs-versioned-assets.ts | 148 ++++++++ scripts/docs-versioning.test.ts | 14 + scripts/docs-versioning.ts | 19 +- scripts/print-docs-version-manifest.ts | 41 +++ 19 files changed, 1000 insertions(+), 57 deletions(-) delete mode 100644 backlog/drafts/config-settings-window.md create mode 100644 changes/fix-versioned-doc-links.md create mode 100644 docs-site/.vitepress/theme/status-line.test.ts create mode 100644 docs-site/.vitepress/theme/status-line.ts create mode 100644 scripts/docs-versioned-assets.test.ts create mode 100644 scripts/docs-versioned-assets.ts create mode 100644 scripts/print-docs-version-manifest.ts diff --git a/Makefile b/Makefile index 3c229cb9..49028a55 100644 --- a/Makefile +++ b/Makefile @@ -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" diff --git a/backlog/drafts/config-settings-window.md b/backlog/drafts/config-settings-window.md deleted file mode 100644 index 254f9beb..00000000 --- a/backlog/drafts/config-settings-window.md +++ /dev/null @@ -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. diff --git a/changes/fix-versioned-doc-links.md b/changes/fix-versioned-doc-links.md new file mode 100644 index 00000000..e20c9a85 --- /dev/null +++ b/changes/fix-versioned-doc-links.md @@ -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. diff --git a/docs-site/.vitepress/config.ts b/docs-site/.vitepress/config.ts index 825d15b3..6628c740 100644 --- a/docs-site/.vitepress/config.ts +++ b/docs-site/.vitepress/config.ts @@ -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), diff --git a/docs-site/.vitepress/theme/components/StatusLine.vue b/docs-site/.vitepress/theme/components/StatusLine.vue index 50237f04..0d523cc6 100644 --- a/docs-site/.vitepress/theme/components/StatusLine.vue +++ b/docs-site/.vitepress/theme/components/StatusLine.vue @@ -1,6 +1,7 @@ ', + '', + '', + ].join(''), + '/v/0.14.0/', + new Set(['foo.js']), + ), + ).toBe( + [ + '', + '', + '', + ].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'), + '', + ); + + 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( + '', + ); + } 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 }); + } + }); +}); diff --git a/scripts/docs-versioned-assets.ts b/scripts/docs-versioned-assets.ts new file mode 100644 index 00000000..38e1f988 --- /dev/null +++ b/scripts/docs-versioned-assets.ts @@ -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 { + 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 { + 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; +}): { + 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; +} diff --git a/scripts/docs-versioning.test.ts b/scripts/docs-versioning.test.ts index fbd209c7..379f0b54 100644 --- a/scripts/docs-versioning.test.ts +++ b/scripts/docs-versioning.test.ts @@ -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'); }); diff --git a/scripts/docs-versioning.ts b/scripts/docs-versioning.ts index 39b94b3c..517871b2 100644 --- a/scripts/docs-versioning.ts +++ b/scripts/docs-versioning.ts @@ -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: { diff --git a/scripts/print-docs-version-manifest.ts b/scripts/print-docs-version-manifest.ts new file mode 100644 index 00000000..e6fc107e --- /dev/null +++ b/scripts/print-docs-version-manifest.ts @@ -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 })));