[codex] add versioned Pages deployment (#73)

This commit is contained in:
2026-05-17 19:54:59 -07:00
committed by GitHub
parent e84674e3b5
commit 6b2cb002ac
22 changed files with 929 additions and 107 deletions
+76
View File
@@ -0,0 +1,76 @@
name: Docs Pages
on:
workflow_dispatch:
push:
branches:
- main
tags:
- 'v*'
paths:
- 'docs-site/**'
- 'scripts/docs-versioning.ts'
- 'scripts/build-versioned-docs.ts'
- '.github/workflows/docs-pages.yml'
- 'package.json'
- 'bun.lock'
concurrency:
group: docs-pages-production
cancel-in-progress: false
jobs:
deploy:
if: ${{ github.ref_type != 'tag' || !contains(github.ref_name, '-') }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Guard stable docs tag shape
id: tag_guard
if: github.ref_type == 'tag'
run: |
if [[ ! "${{ github.ref_name }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::notice::Skipping non-stable docs tag ${{ github.ref_name }}"
echo "stable_tag=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "stable_tag=true" >> "$GITHUB_OUTPUT"
- name: Setup Bun
if: steps.tag_guard.outputs.stable_tag != 'false'
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.5
- name: Install dependencies
if: steps.tag_guard.outputs.stable_tag != 'false'
run: |
bun install --frozen-lockfile
cd docs-site && bun install --frozen-lockfile
- name: Cache versioned docs archives
if: steps.tag_guard.outputs.stable_tag != 'false'
uses: actions/cache@v4
with:
path: .tmp/docs-versioned-archive-cache
key: docs-versioned-archives-${{ runner.os }}-${{ hashFiles('docs-site/.vitepress/**', 'docs-site/public/assets/fonts/**', 'docs-site/package.json', 'docs-site/bun.lock', 'scripts/build-versioned-docs.ts', 'scripts/docs-versioning.ts') }}
- name: Test docs
if: steps.tag_guard.outputs.stable_tag != 'false'
run: bun run docs:test
- name: Build versioned docs
if: steps.tag_guard.outputs.stable_tag != 'false'
run: bun run docs:build:versioned
- name: Deploy docs to Cloudflare Pages
if: steps.tag_guard.outputs.stable_tag != 'false'
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy .tmp/docs-versioned-site --project-name "${{ vars.CLOUDFLARE_PAGES_PROJECT_NAME }}" --branch main
+4
View File
@@ -0,0 +1,4 @@
type: docs
area: docs
- Published stable docs at the site root with current development docs under `/main/`.
+218 -74
View File
@@ -1,3 +1,7 @@
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'; const DOCS_HOSTNAME = 'https://docs.subminer.moe';
const PLAUSIBLE_PROXY_HOSTNAME = 'https://worker.sudacode.com'; const PLAUSIBLE_PROXY_HOSTNAME = 'https://worker.sudacode.com';
const PLAUSIBLE_SITE_SCRIPT_PATH = '/js/pa-h28Pn9ppgTJRmiSJlyPT6.js'; const PLAUSIBLE_SITE_SCRIPT_PATH = '/js/pa-h28Pn9ppgTJRmiSJlyPT6.js';
@@ -7,96 +11,161 @@ const PLAUSIBLE_INIT_SCRIPT = [
`plausible.init({ endpoint: '${PLAUSIBLE_ENDPOINT}' });`, `plausible.init({ endpoint: '${PLAUSIBLE_ENDPOINT}' });`,
].join('\n'); ].join('\n');
function pageToCanonicalHref(page: string): string | null { type DocsChannel = 'stable-root' | 'stable-archive' | 'main';
type VersionManifest = {
latestStable: string;
channels: Array<{ label: string; path: string }>;
versions: Array<{ version: string; path: string }>;
};
const base = normalizeBase(process.env.SUBMINER_DOCS_BASE ?? '/');
const outDir = process.env.SUBMINER_DOCS_OUT_DIR;
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);
function normalizeBase(value: string): string {
if (!value || value === '/') return '/';
return `/${value.replace(/^\/+|\/+$/g, '')}/`;
}
function normalizeChannel(value: string | undefined): DocsChannel {
if (value === 'main' || value === 'stable-archive') return value;
return 'stable-root';
}
function parseVersionManifest(value: string | undefined): VersionManifest {
if (!value) {
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}`;
if (base === '/') return normalizedPath;
return `${base.replace(/\/$/, '')}${normalizedPath}`;
}
function pageToRoute(page: string): string | null {
if (page === '404.md') return null; if (page === '404.md') return null;
const route = page const route = page
.replace(/(^|\/)index\.md$/, '') .replace(/(^|\/)index\.md$/, '')
.replace(/\.md$/, '') .replace(/\.md$/, '')
.replace(/\/$/, ''); .replace(/\/$/, '');
return route ? `${DOCS_HOSTNAME}/${route}` : `${DOCS_HOSTNAME}/`; return route ? `/${route}` : '/';
} }
export default { function pageToCanonicalHref(page: string): string | null {
title: 'SubMiner Docs', const route = pageToRoute(page);
description: if (!route) return null;
'SubMiner: an MPV immersion-mining overlay with Yomitan and AnkiConnect integration.',
head: [ if (channel === 'main') {
['link', { rel: 'preconnect', href: PLAUSIBLE_PROXY_HOSTNAME }], return `${DOCS_HOSTNAME}${canonicalRouteWithBase(route)}`;
[ }
'script',
{ if (channel === 'stable-archive' && docsVersion !== latestStable) {
async: '', return `${DOCS_HOSTNAME}${canonicalRouteWithBase(route)}`;
src: `${PLAUSIBLE_PROXY_HOSTNAME}${PLAUSIBLE_SITE_SCRIPT_PATH}`, }
},
], return route === '/' ? `${DOCS_HOSTNAME}/` : `${DOCS_HOSTNAME}${route}`;
['script', {}, PLAUSIBLE_INIT_SCRIPT], }
['link', { rel: 'icon', href: '/favicon.ico', sizes: 'any' }],
[ function canonicalRouteWithBase(route: string): string {
'link', const routeWithBase = withDocsBase(route);
{ return route === '/' ? routeWithBase : routeWithBase.replace(/\/$/, '');
rel: 'icon', }
type: 'image/png',
href: '/favicon-32x32.png', function transformPageHead({ page }: TransformContext): HeadConfig[] {
sizes: '32x32',
},
],
[
'link',
{
rel: 'icon',
type: 'image/png',
href: '/favicon-16x16.png',
sizes: '16x16',
},
],
[
'link',
{
rel: 'apple-touch-icon',
href: '/apple-touch-icon.png',
sizes: '180x180',
},
],
],
appearance: 'dark',
cleanUrls: true,
metaChunk: true,
sitemap: {
hostname: DOCS_HOSTNAME,
transformItems(items) {
return items.filter(
(item) => item.url !== 'README' && item.url !== `${DOCS_HOSTNAME}/README`,
);
},
},
transformHead({ page }) {
const href = pageToCanonicalHref(page); const href = pageToCanonicalHref(page);
return href ? [['link', { rel: 'canonical', href }]] : []; const head: HeadConfig[] = href ? [['link', { rel: 'canonical', href }]] : [];
if (channel === 'main') {
head.push(['meta', { name: 'robots', content: 'noindex,follow' }]);
}
return head;
}
function linkToPagePath(link: string): string | null {
if (!link.startsWith('/') || link.startsWith('/v/') || link.startsWith('/main/')) {
return null;
}
const withoutHash = link.split('#')[0] ?? '/';
const withoutQuery = withoutHash.split('?')[0] ?? '/';
const route = withoutQuery.replace(/^\/+|\/+$/g, '');
return route ? `${route}.md` : 'index.md';
}
function hasPageForLink(link: string): boolean {
const pagePath = linkToPagePath(link);
if (!pagePath) return true;
return existsSync(join(process.cwd(), pagePath));
}
function filterNav(items: DefaultTheme.NavItem[]): DefaultTheme.NavItem[] {
return items
.map((item) => {
if ('items' in item && item.items) {
return { ...item, items: filterNav(item.items as DefaultTheme.NavItem[]) };
}
if ('link' in item && item.link && !hasPageForLink(item.link)) {
return null;
}
return item;
})
.filter((item): item is DefaultTheme.NavItem => Boolean(item));
}
function filterSidebar(items: DefaultTheme.SidebarItem[]): DefaultTheme.SidebarItem[] {
return items
.map((item) => {
const filteredChildren = item.items ? filterSidebar(item.items) : undefined;
if (item.link && !hasPageForLink(item.link)) return null;
if (item.items && filteredChildren?.length === 0 && !item.link) return null;
return { ...item, items: filteredChildren };
})
.filter((item): item is DefaultTheme.SidebarItem => Boolean(item));
}
const versionItems = [
{
text: `Latest stable (${versionManifest.latestStable})`,
link: '/',
}, },
lastUpdated: true, ...versionManifest.channels
srcExclude: ['subagents/**'], .filter((entry) => entry.label !== 'Latest stable')
markdown: { .map((entry) => ({ text: entry.label, link: entry.path })),
theme: { ...versionManifest.versions.map((entry) => ({
light: 'catppuccin-latte', text: entry.version,
dark: 'catppuccin-macchiato', link: entry.path,
}, })),
}, ];
themeConfig: {
logo: { const nav: DefaultTheme.NavItem[] = [
light: '/assets/SubMiner.png',
dark: '/assets/SubMiner.png',
},
siteTitle: 'SubMiner Docs',
nav: [
{ text: 'Home', link: '/' }, { text: 'Home', link: '/' },
{ text: 'Get Started', link: '/installation' }, { text: 'Get Started', link: '/installation' },
{ text: 'Mining', link: '/mining-workflow' }, { text: 'Mining', link: '/mining-workflow' },
{ text: 'Configuration', link: '/configuration' }, { text: 'Configuration', link: '/configuration' },
{ text: 'Changelog', link: '/changelog' }, { text: 'Changelog', link: '/changelog' },
{ text: 'Troubleshooting', link: '/troubleshooting' }, { text: 'Troubleshooting', link: '/troubleshooting' },
], { text: docsVersion ?? (channel === 'main' ? 'main' : latestStable), items: versionItems },
sidebar: [ ];
const sidebar: DefaultTheme.SidebarItem[] = [
{ {
text: 'Getting Started', text: 'Getting Started',
items: [ items: [
@@ -140,7 +209,80 @@ export default {
{ text: 'Changelog', link: '/changelog' }, { text: 'Changelog', link: '/changelog' },
], ],
}, },
];
const config: UserConfig = {
title: 'SubMiner Docs',
description:
'SubMiner: an MPV immersion-mining overlay with Yomitan and AnkiConnect integration.',
base,
...(outDir ? { outDir } : {}),
head: [
['link', { rel: 'preconnect', href: PLAUSIBLE_PROXY_HOSTNAME }],
[
'script',
{
async: '',
src: `${PLAUSIBLE_PROXY_HOSTNAME}${PLAUSIBLE_SITE_SCRIPT_PATH}`,
},
], ],
['script', {}, PLAUSIBLE_INIT_SCRIPT],
['link', { rel: 'icon', href: withDocsBase('/favicon.ico'), sizes: 'any' }],
[
'link',
{
rel: 'icon',
type: 'image/png',
href: withDocsBase('/favicon-32x32.png'),
sizes: '32x32',
},
],
[
'link',
{
rel: 'icon',
type: 'image/png',
href: withDocsBase('/favicon-16x16.png'),
sizes: '16x16',
},
],
[
'link',
{
rel: 'apple-touch-icon',
href: withDocsBase('/apple-touch-icon.png'),
sizes: '180x180',
},
],
],
appearance: 'dark',
cleanUrls: true,
metaChunk: true,
sitemap: {
hostname: DOCS_HOSTNAME,
transformItems(items) {
return items.filter(
(item) => item.url !== 'README' && item.url !== `${DOCS_HOSTNAME}/README`,
);
},
},
transformHead: transformPageHead,
lastUpdated: true,
srcExclude: ['subagents/**'],
markdown: {
theme: {
light: 'catppuccin-latte',
dark: 'catppuccin-macchiato',
},
},
themeConfig: {
logo: {
light: withDocsBase('/assets/SubMiner.png'),
dark: withDocsBase('/assets/SubMiner.png'),
},
siteTitle: 'SubMiner Docs',
nav: filterNav(nav),
sidebar: filterSidebar(sidebar),
search: { search: {
provider: 'local', provider: 'local',
}, },
@@ -159,3 +301,5 @@ export default {
socialLinks: [{ icon: 'github', link: 'https://github.com/ksyasuda/SubMiner' }], socialLinks: [{ icon: 'github', link: 'https://github.com/ksyasuda/SubMiner' }],
}, },
}; };
export default config;
+2 -2
View File
@@ -5,7 +5,7 @@
@font-face { @font-face {
font-family: 'M PLUS 1'; font-family: 'M PLUS 1';
src: url('/assets/fonts/Mplus1-Medium.ttf') format('truetype'); src: url('../../public/assets/fonts/Mplus1-Medium.ttf') format('truetype');
font-weight: 500; font-weight: 500;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
@@ -13,7 +13,7 @@
@font-face { @font-face {
font-family: 'Manrope Default'; font-family: 'Manrope Default';
src: url('/assets/fonts/manrope-latin-600-normal.ttf') format('truetype'); src: url('../../public/assets/fonts/manrope-latin-600-normal.ttf') format('truetype');
font-weight: 600; font-weight: 600;
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
+12 -5
View File
@@ -30,9 +30,16 @@ bun run docs:dev
## Cloudflare Pages ## Cloudflare Pages
- Git repo: `ksyasuda/SubMiner` - Git repo: `ksyasuda/SubMiner`
- Root directory: `docs-site` - Production branch: `main`
- Build command: `bun run docs:build` - Automatic production and preview deployments: disabled
- Build output directory: `.vitepress/dist` - Custom domain: `docs.subminer.moe` attached to Production
- Build watch paths: `docs-site/*` - Deployment path: GitHub Actions direct upload with Wrangler
Cloudflare Pages watch paths use a single `*` wildcard for monorepo subdirectories. `docs-site/*` matches nested files under the docs site; `docs-site/**` can cause docs-only pushes to be skipped. The public docs root is stable-only:
- `/` serves the latest stable release docs.
- `/main/` serves development docs from `main` and is marked `noindex,follow`.
- `/v/<version>/` serves stable release archives.
- Prerelease tags do not update the docs site.
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.
+7 -3
View File
@@ -4,6 +4,10 @@ outline: [2, 3]
# Configuration # Configuration
<script setup>
import { withBase } from 'vitepress';
</script>
Settings are stored in `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc` when `XDG_CONFIG_HOME` is unset). Settings are stored in `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc` when `XDG_CONFIG_HOME` is unset).
On Windows, the default path is `%APPDATA%\SubMiner\config.jsonc`. On Windows, the default path is `%APPDATA%\SubMiner\config.jsonc`.
When both files exist, SubMiner prefers `config.jsonc` over `config.json`. When both files exist, SubMiner prefers `config.jsonc` over `config.json`.
@@ -1044,12 +1048,12 @@ To refresh roughly once per day, set:
`deleteDuplicateInAuto` controls whether `auto` mode deletes the duplicate after merge (default: `true`). In `manual` mode, the popup asks each time whether to delete the duplicate. `deleteDuplicateInAuto` controls whether `auto` mode deletes the duplicate after merge (default: `true`). In `manual` mode, the popup asks each time whether to delete the duplicate.
When the manual merge popup opens, SubMiner pauses playback and closes any open Yomitan popup first so the merge flow can take focus. When the manual merge popup opens, SubMiner pauses playback and closes any open Yomitan popup first so the merge flow can take focus.
<video controls playsinline preload="metadata" poster="/assets/kiku-integration-poster.jpg" style="width: 100%; max-width: 960px;"> <video controls playsinline preload="metadata" :poster="withBase('/assets/kiku-integration-poster.jpg')" style="width: 100%; max-width: 960px;">
<source :src="'/assets/kiku-integration.webm'" type="video/webm" /> <source :src="withBase('/assets/kiku-integration.webm')" type="video/webm" />
Your browser does not support the video tag. Your browser does not support the video tag.
</video> </video>
<a :href="'/assets/kiku-integration.webm'" target="_blank" rel="noreferrer">Open demo in a new tab</a> <a :href="withBase('/assets/kiku-integration.webm')" target="_blank" rel="noreferrer">Open demo in a new tab</a>
## External Integrations ## External Integrations
+16 -14
View File
@@ -3,6 +3,8 @@
Short recordings of SubMiner's key features and integrations from real playback sessions. Short recordings of SubMiner's key features and integrations from real playback sessions.
<script setup> <script setup>
import { withBase } from 'vitepress';
const v = '20260301-1'; const v = '20260301-1';
</script> </script>
@@ -10,11 +12,11 @@ const v = '20260301-1';
Mine vocabulary cards from Yomitan or directly from subtitle lines. SubMiner automatically attaches the sentence, a timing-accurate audio clip, a screenshot, and a translation. Mine vocabulary cards from Yomitan or directly from subtitle lines. SubMiner automatically attaches the sentence, a timing-accurate audio clip, a screenshot, and a translation.
<video controls playsinline preload="metadata" :poster="`/assets/minecard-poster.jpg?v=${demoAssetVersion}`"> <video controls playsinline preload="metadata" :poster="withBase(`/assets/minecard-poster.jpg?v=${v}`)">
<source :src="`/assets/minecard.webm?v=${demoAssetVersion}`" type="video/webm" /> <source :src="withBase(`/assets/minecard.webm?v=${v}`)" type="video/webm" />
<source :src="`/assets/minecard.mp4?v=${demoAssetVersion}`" type="video/mp4" /> <source :src="withBase(`/assets/minecard.mp4?v=${v}`)" type="video/mp4" />
<a :href="`/assets/minecard.webm?v=${demoAssetVersion}`" target="_blank" rel="noreferrer"> <a :href="withBase(`/assets/minecard.webm?v=${v}`)" target="_blank" rel="noreferrer">
<img :src="`/assets/minecard.webp?v=${demoAssetVersion}`" alt="SubMiner demo Animated fallback" style="width: 100%; height: auto;" /> <img :src="withBase(`/assets/minecard.webp?v=${v}`)" alt="SubMiner demo Animated fallback" style="width: 100%; height: auto;" />
</a> </a>
</video> </video>
@@ -25,9 +27,9 @@ Mine vocabulary cards from Yomitan or directly from subtitle lines. SubMiner aut
Search and download subtitles from Jimaku, then automatically synchronize them with alass or ffsubsync — all from within SubMiner. Search and download subtitles from Jimaku, then automatically synchronize them with alass or ffsubsync — all from within SubMiner.
<!-- <video controls playsinline preload="metadata" :poster="`/assets/demos/subtitle-sync-poster.jpg?v=${v}`"> <!-- <video controls playsinline preload="metadata" :poster="withBase(`/assets/demos/subtitle-sync-poster.jpg?v=${v}`)">
<source :src="`/assets/demos/subtitle-sync.webm?v=${v}`" type="video/webm" /> <source :src="withBase(`/assets/demos/subtitle-sync.webm?v=${v}`)" type="video/webm" />
<source :src="`/assets/demos/subtitle-sync.mp4?v=${v}`" type="video/mp4" /> <source :src="withBase(`/assets/demos/subtitle-sync.mp4?v=${v}`)" type="video/mp4" />
</video> --> </video> -->
::: info VIDEO COMING SOON ::: info VIDEO COMING SOON
@@ -37,9 +39,9 @@ Search and download subtitles from Jimaku, then automatically synchronize them w
Browse your Jellyfin library, cast to devices, and launch playback directly from SubMiner. Watch progress syncs back to your Jellyfin server. Browse your Jellyfin library, cast to devices, and launch playback directly from SubMiner. Watch progress syncs back to your Jellyfin server.
<!-- <video controls playsinline preload="metadata" :poster="`/assets/demos/jellyfin-poster.jpg?v=${v}`"> <!-- <video controls playsinline preload="metadata" :poster="withBase(`/assets/demos/jellyfin-poster.jpg?v=${v}`)">
<source :src="`/assets/demos/jellyfin.webm?v=${v}`" type="video/webm" /> <source :src="withBase(`/assets/demos/jellyfin.webm?v=${v}`)" type="video/webm" />
<source :src="`/assets/demos/jellyfin.mp4?v=${v}`" type="video/mp4" /> <source :src="withBase(`/assets/demos/jellyfin.mp4?v=${v}`)" type="video/mp4" />
</video> --> </video> -->
::: info VIDEO COMING SOON ::: info VIDEO COMING SOON
@@ -49,9 +51,9 @@ Browse your Jellyfin library, cast to devices, and launch playback directly from
Open subtitles in an external texthooker page for use with browser-based tools and extensions alongside the overlay. Open subtitles in an external texthooker page for use with browser-based tools and extensions alongside the overlay.
<!-- <video controls playsinline preload="metadata" :poster="`/assets/demos/texthooker-poster.jpg?v=${v}`"> <!-- <video controls playsinline preload="metadata" :poster="withBase(`/assets/demos/texthooker-poster.jpg?v=${v}`)">
<source :src="`/assets/demos/texthooker.webm?v=${v}`" type="video/webm" /> <source :src="withBase(`/assets/demos/texthooker.webm?v=${v}`)" type="video/webm" />
<source :src="`/assets/demos/texthooker.mp4?v=${v}`" type="video/mp4" /> <source :src="withBase(`/assets/demos/texthooker.mp4?v=${v}`)" type="video/mp4" />
</video> --> </video> -->
::: info VIDEO COMING SOON ::: info VIDEO COMING SOON
+8
View File
@@ -113,6 +113,14 @@ bun run docs:test
bun run docs:build bun run docs:build
``` ```
For production docs routing, run the versioned build:
```bash
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.
Focused commands: Focused commands:
```bash ```bash
+4 -4
View File
@@ -37,13 +37,13 @@ test('docs reflect current launcher and release surfaces', () => {
expect(mpvPluginContents).toContain('\\\\.\\pipe\\subminer-socket'); expect(mpvPluginContents).toContain('\\\\.\\pipe\\subminer-socket');
expect(readmeContents).toContain('Root directory: `docs-site`'); expect(readmeContents).toContain('Automatic production and preview deployments: disabled');
expect(readmeContents).toContain('Build output directory: `.vitepress/dist`'); expect(readmeContents).toContain('/main/');
expect(readmeContents).toContain('Build watch paths: `docs-site/*`'); expect(readmeContents).toContain('GitHub Actions direct upload with Wrangler');
expect(developmentContents).not.toContain('../subminer-docs'); expect(developmentContents).not.toContain('../subminer-docs');
expect(developmentContents).toContain('bun run docs:build'); expect(developmentContents).toContain('bun run docs:build');
expect(developmentContents).toContain('bun run docs:test'); expect(developmentContents).toContain('bun run docs:test');
expect(developmentContents).toContain('Build watch paths: `docs-site/*`'); expect(developmentContents).toContain('bun run docs:build:versioned');
expect(developmentContents).not.toContain('test:subtitle:dist'); expect(developmentContents).not.toContain('test:subtitle:dist');
expect(developmentContents).toContain('bun run build:win'); expect(developmentContents).toContain('bun run build:win');
+5 -5
View File
@@ -7,18 +7,18 @@ const docsIndexContents = readFileSync(docsIndexPath, 'utf8');
test('docs demo media uses shared cache-busting asset version token', () => { test('docs demo media uses shared cache-busting asset version token', () => {
expect(docsIndexContents).toMatch(/const demoAssetVersion = ['"][^'"]+['"]/); expect(docsIndexContents).toMatch(/const demoAssetVersion = ['"][^'"]+['"]/);
expect(docsIndexContents).toContain( expect(docsIndexContents).toContain(
':poster="`/assets/minecard-poster.jpg?v=${demoAssetVersion}`"', ':poster="withBase(`/assets/minecard-poster.jpg?v=${demoAssetVersion}`)"',
); );
expect(docsIndexContents).toContain( expect(docsIndexContents).toContain(
'<source :src="`/assets/minecard.webm?v=${demoAssetVersion}`" type="video/webm" />', '<source :src="withBase(`/assets/minecard.webm?v=${demoAssetVersion}`)" type="video/webm" />',
); );
expect(docsIndexContents).toContain( expect(docsIndexContents).toContain(
'<source :src="`/assets/minecard.mp4?v=${demoAssetVersion}`" type="video/mp4" />', '<source :src="withBase(`/assets/minecard.mp4?v=${demoAssetVersion}`)" type="video/mp4" />',
); );
expect(docsIndexContents).toContain( expect(docsIndexContents).toContain(
'<a :href="`/assets/minecard.webm?v=${demoAssetVersion}`" target="_blank" rel="noreferrer">', '<a :href="withBase(`/assets/minecard.webm?v=${demoAssetVersion}`)" target="_blank" rel="noreferrer">',
); );
expect(docsIndexContents).toContain( expect(docsIndexContents).toContain(
'<img :src="`/assets/minecard.webp?v=${demoAssetVersion}`" alt="SubMiner demo Animated fallback" style="width: 100%; height: auto;" />', '<img :src="withBase(`/assets/minecard.webp?v=${demoAssetVersion}`)" alt="SubMiner demo Animated fallback" style="width: 100%; height: auto;" />',
); );
}); });
+7 -5
View File
@@ -86,6 +86,8 @@ features:
--- ---
<script setup> <script setup>
import { withBase } from 'vitepress';
const demoAssetVersion = '20260223-2'; const demoAssetVersion = '20260223-2';
</script> </script>
@@ -135,11 +137,11 @@ const demoAssetVersion = '20260223-2';
<span class="demo-window__dot"></span> <span class="demo-window__dot"></span>
<span class="demo-window__title">subminer -- playback</span> <span class="demo-window__title">subminer -- playback</span>
</div> </div>
<video controls playsinline preload="metadata" :poster="`/assets/minecard-poster.jpg?v=${demoAssetVersion}`"> <video controls playsinline preload="metadata" :poster="withBase(`/assets/minecard-poster.jpg?v=${demoAssetVersion}`)">
<source :src="`/assets/minecard.webm?v=${demoAssetVersion}`" type="video/webm" /> <source :src="withBase(`/assets/minecard.webm?v=${demoAssetVersion}`)" type="video/webm" />
<source :src="`/assets/minecard.mp4?v=${demoAssetVersion}`" type="video/mp4" /> <source :src="withBase(`/assets/minecard.mp4?v=${demoAssetVersion}`)" type="video/mp4" />
<a :href="`/assets/minecard.webm?v=${demoAssetVersion}`" target="_blank" rel="noreferrer"> <a :href="withBase(`/assets/minecard.webm?v=${demoAssetVersion}`)" target="_blank" rel="noreferrer">
<img :src="`/assets/minecard.webp?v=${demoAssetVersion}`" alt="SubMiner demo Animated fallback" style="width: 100%; height: auto;" /> <img :src="withBase(`/assets/minecard.webp?v=${demoAssetVersion}`)" alt="SubMiner demo Animated fallback" style="width: 100%; height: auto;" />
</a> </a>
</video> </video>
</div> </div>
+1 -1
View File
@@ -8,7 +8,7 @@
"docs:dev": "VITE_EXTRA_EXTENSIONS=jsonc vitepress dev --host 0.0.0.0 --port 5173 --strictPort", "docs:dev": "VITE_EXTRA_EXTENSIONS=jsonc vitepress dev --host 0.0.0.0 --port 5173 --strictPort",
"docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build", "docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build",
"docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview --host 0.0.0.0 --port 4173 --strictPort", "docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview --host 0.0.0.0 --port 4173 --strictPort",
"test": "bun test plausible.test.ts index.assets.test.ts docs-sync.test.ts seo.test.ts" "test": "bun test plausible.test.ts index.assets.test.ts docs-sync.test.ts seo.test.ts ../scripts/docs-versioning.test.ts"
}, },
"dependencies": { "dependencies": {
"@catppuccin/vitepress": "^0.1.2", "@catppuccin/vitepress": "^0.1.2",
+7
View File
@@ -4,9 +4,11 @@ import { readFileSync } from 'node:fs';
const docsConfigPath = new URL('./.vitepress/config.ts', import.meta.url); const docsConfigPath = new URL('./.vitepress/config.ts', import.meta.url);
const docsThemePath = new URL('./.vitepress/theme/index.ts', import.meta.url); const docsThemePath = new URL('./.vitepress/theme/index.ts', import.meta.url);
const docsPackagePath = new URL('./package.json', import.meta.url); const docsPackagePath = new URL('./package.json', import.meta.url);
const versionedBuildPath = new URL('../scripts/build-versioned-docs.ts', import.meta.url);
const docsConfigContents = readFileSync(docsConfigPath, 'utf8'); const docsConfigContents = readFileSync(docsConfigPath, 'utf8');
const docsThemeContents = readFileSync(docsThemePath, 'utf8'); const docsThemeContents = readFileSync(docsThemePath, 'utf8');
const docsPackageContents = readFileSync(docsPackagePath, 'utf8'); const docsPackageContents = readFileSync(docsPackagePath, 'utf8');
const versionedBuildContents = readFileSync(versionedBuildPath, 'utf8');
test('docs site loads the docs.subminer.moe Plausible script through the analytics proxy', () => { test('docs site loads the docs.subminer.moe Plausible script through the analytics proxy', () => {
expect(docsConfigContents).toContain("const DOCS_HOSTNAME = 'https://docs.subminer.moe'"); expect(docsConfigContents).toContain("const DOCS_HOSTNAME = 'https://docs.subminer.moe'");
@@ -34,3 +36,8 @@ test('docs site loads the docs.subminer.moe Plausible script through the analyti
expect(docsThemeContents).not.toContain('initPlausibleTracker'); expect(docsThemeContents).not.toContain('initPlausibleTracker');
expect(docsPackageContents).not.toContain('@plausible-analytics/tracker'); expect(docsPackageContents).not.toContain('@plausible-analytics/tracker');
}); });
test('versioned docs reuse current VitePress internals for old page snapshots', () => {
expect(versionedBuildContents).toContain("cpSync(join(currentDocsSite, '.vitepress')");
expect(versionedBuildContents).toContain('overlayCurrentVitePress(snapshotDocsSite)');
});
+48
View File
@@ -31,6 +31,54 @@ test('docs pages emit stable self-referential canonical URLs', async () => {
expect(JSON.stringify(rootHead).toLowerCase()).not.toContain('noindex'); expect(JSON.stringify(rootHead).toLowerCase()).not.toContain('noindex');
}); });
test('main docs canonical uses /main/ and emits noindex', async () => {
const previousChannel = process.env.SUBMINER_DOCS_CHANNEL;
const previousBase = process.env.SUBMINER_DOCS_BASE;
process.env.SUBMINER_DOCS_CHANNEL = 'main';
process.env.SUBMINER_DOCS_BASE = '/main/';
const { default: mainDocsConfig } = await import('./.vitepress/config?main-docs');
const head = await mainDocsConfig.transformHead?.(makeTransformContext('usage.md'));
const rootHead = await mainDocsConfig.transformHead?.(makeTransformContext('index.md'));
expect(head).toContainEqual([
'link',
{ rel: 'canonical', href: 'https://docs.subminer.moe/main/usage' },
]);
expect(rootHead).toContainEqual([
'link',
{ rel: 'canonical', href: 'https://docs.subminer.moe/main/' },
]);
expect(head).toContainEqual(['meta', { name: 'robots', content: 'noindex,follow' }]);
process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
process.env.SUBMINER_DOCS_BASE = previousBase;
});
test('latest stable archive canonical points to root equivalent', async () => {
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 = '/v/0.14.0/';
process.env.SUBMINER_DOCS_VERSION = 'v0.14.0';
process.env.SUBMINER_DOCS_LATEST_STABLE = 'v0.14.0';
const { default: latestArchiveConfig } = await import('./.vitepress/config?latest-archive');
const head = await latestArchiveConfig.transformHead?.(makeTransformContext('usage.md'));
expect(head).toContainEqual([
'link',
{ rel: 'canonical', href: 'https://docs.subminer.moe/usage' },
]);
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('docs sitemap excludes duplicate README page from indexable URLs', async () => { test('docs sitemap excludes duplicate README page from indexable URLs', async () => {
const items = [{ url: '' }, { url: 'README' }, { url: 'usage' }]; const items = [{ url: '' }, { url: 'README' }, { url: 'usage' }];
+4
View File
@@ -37,6 +37,7 @@
8. If `docs-site/` changed, also run: 8. If `docs-site/` changed, also run:
`bun run docs:test` `bun run docs:test`
`bun run docs:build` `bun run docs:build`
`bun run docs:build:versioned`
9. Commit release prep. 9. Commit release prep.
10. Tag the commit: `git tag v<version>`. 10. Tag the commit: `git tag v<version>`.
11. Push commit + tag. 11. Push commit + tag.
@@ -66,6 +67,7 @@
7. Push commit + tag. 7. Push commit + tag.
Prerelease tags publish a GitHub prerelease only. They do not update `CHANGELOG.md`, `docs-site/changelog.md`, or the AUR package, and they do not consume `changes/*.md` fragments. The final stable release is still the point where `bun run changelog:build` consumes fragments into `CHANGELOG.md` and regenerates stable release notes. Prerelease tags publish a GitHub prerelease only. They do not update `CHANGELOG.md`, `docs-site/changelog.md`, or the AUR package, and they do not consume `changes/*.md` fragments. The final stable release is still the point where `bun run changelog:build` consumes fragments into `CHANGELOG.md` and regenerates stable release notes.
Prerelease tags also do not update `https://docs.subminer.moe/`.
Notes: Notes:
@@ -81,6 +83,8 @@ Notes:
- If you need to repair a published release body (for example, a prior versions section was omitted), regenerate notes from `CHANGELOG.md` and re-edit the release with `gh release edit --notes-file`. - If you need to repair a published release body (for example, a prior versions section was omitted), regenerate notes from `CHANGELOG.md` and re-edit the release with `gh release edit --notes-file`.
- Prerelease tags are handled by `.github/workflows/prerelease.yml`, which always publishes a GitHub prerelease with all current release platforms and never runs the AUR sync job. - Prerelease tags are handled by `.github/workflows/prerelease.yml`, which always publishes a GitHub prerelease with all current release platforms and never runs the AUR sync job.
- Tagged release workflow now also attempts to update `subminer-bin` on the AUR after GitHub Release publication. - Tagged release workflow now also attempts to update `subminer-bin` on the AUR after GitHub Release publication.
- Stable release tags update `https://docs.subminer.moe/` and `https://docs.subminer.moe/v/<version>/` through `.github/workflows/docs-pages.yml`; `/main/` continues to show development docs from `main`.
- Keep Cloudflare Pages Git auto-deploy disabled for `docs.subminer.moe`. Production docs are direct-uploaded by Wrangler from GitHub Actions with `--branch main`.
- AUR publish is best-effort: the workflow retries transient SSH clone/push failures, then warns and leaves the GitHub Release green if AUR still fails. Follow up with a manual `git push aur master` from the AUR checkout when needed. - AUR publish is best-effort: the workflow retries transient SSH clone/push failures, then warns and leaves the GitHub Release green if AUR still fails. Follow up with a manual `git push aur master` from the AUR checkout when needed.
- Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation. - Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation.
- Release and prerelease workflows upload updater metadata (`*.yml`) and blockmaps (`*.blockmap`) alongside platform artifacts. Do not remove those files while `electron-updater` is enabled. - Release and prerelease workflows upload updater metadata (`*.yml`) and blockmaps (`*.blockmap`) alongside platform artifacts. Do not remove those files while `electron-updater` is enabled.
+2 -1
View File
@@ -40,6 +40,7 @@
"lint": "bun run lint:stats", "lint": "bun run lint:stats",
"docs:dev": "bun run --cwd docs-site docs:dev", "docs:dev": "bun run --cwd docs-site docs:dev",
"docs:build": "bun run --cwd docs-site docs:build", "docs:build": "bun run --cwd docs-site docs:build",
"docs:build:versioned": "bun run scripts/build-versioned-docs.ts",
"docs:preview": "bun run --cwd docs-site docs:preview", "docs:preview": "bun run --cwd docs-site docs:preview",
"docs:test": "bun run --cwd docs-site test", "docs:test": "bun run --cwd docs-site test",
"test:docs:kb": "bun test scripts/docs-knowledge-base.test.ts", "test:docs:kb": "bun test scripts/docs-knowledge-base.test.ts",
@@ -71,7 +72,7 @@
"test:launcher": "bun run test:launcher:src", "test:launcher": "bun run test:launcher:src",
"test:core": "bun run test:core:src", "test:core": "bun run test:core:src",
"test:subtitle": "bun run test:subtitle: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/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/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", "generate:config-example": "bun run src/generate-config-example.ts",
"verify:config-example": "bun run src/verify-config-example.ts", "verify:config-example": "bun run src/verify-config-example.ts",
"start": "bun run build && electron . --start", "start": "bun run build && electron . --start",
+337
View File
@@ -0,0 +1,337 @@
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 { join, resolve } from 'node:path';
import {
buildVersionManifest,
stableTagsWithDocs,
versionArchiveCacheName,
versionOutputPath,
versionPath,
} from './docs-versioning';
const repoRoot = resolve(__dirname, '..');
const currentDocsSite = join(repoRoot, 'docs-site');
const buildRoot = join(repoRoot, '.tmp/docs-versioned-build');
const aggregateOutDir = join(repoRoot, '.tmp/docs-versioned-site');
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 } = {}) {
const result = spawnSync(command, args, {
cwd: options.cwd ?? repoRoot,
env: options.env ?? process.env,
stdio: 'inherit',
});
if (result.status !== 0) {
throw new Error(`Command failed: ${command} ${args.join(' ')}`);
}
}
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 archiveDocsSite(ref: string, targetDir: string) {
mkdirSync(targetDir, { recursive: true });
const archive = spawnSync('git', ['archive', '--format=tar', ref, 'docs-site'], {
cwd: repoRoot,
encoding: 'buffer',
maxBuffer: 1024 * 1024 * 1024,
});
if (archive.status !== 0 || !archive.stdout) {
throw new Error(`Unable to archive docs-site from ${ref}`);
}
const extract = spawnSync('tar', ['-x', '-C', targetDir], {
input: archive.stdout,
stdio: ['pipe', 'inherit', 'inherit'],
});
if (extract.status !== 0) {
throw new Error(`Unable to extract docs-site archive from ${ref}`);
}
}
function copyCurrentDocsSite(targetDir: string) {
mkdirSync(targetDir, { recursive: true });
cpSync(currentDocsSite, join(targetDir, 'docs-site'), {
recursive: true,
dereference: false,
filter: (source) =>
!/[\\/]node_modules([\\/]|$)/.test(source) &&
!/[\\/]\\.vitepress[\\/]dist([\\/]|$)/.test(source),
});
}
function overlayCurrentVitePress(snapshotDocsSite: string) {
const targetVitePress = join(snapshotDocsSite, '.vitepress');
rmSync(targetVitePress, { recursive: true, force: true });
cpSync(join(currentDocsSite, '.vitepress'), targetVitePress, {
recursive: true,
filter: (source) => !isGeneratedVitePressPath(source),
});
const currentThemeFonts = join(currentDocsSite, 'public/assets/fonts');
if (existsSync(currentThemeFonts)) {
cpSync(currentThemeFonts, join(snapshotDocsSite, 'public/assets/fonts'), {
recursive: true,
force: true,
});
}
}
function linkDocsDependencies(snapshotDocsSite: string) {
const currentNodeModules = join(currentDocsSite, 'node_modules');
const targetNodeModules = join(snapshotDocsSite, 'node_modules');
if (!existsSync(currentNodeModules) || existsSync(targetNodeModules)) {
return;
}
symlinkSync(currentNodeModules, targetNodeModules, 'dir');
}
function prepareSnapshot(name: string, ref?: string): string {
const snapshotRoot = join(buildRoot, name);
rmSync(snapshotRoot, { recursive: true, force: true });
if (ref) {
archiveDocsSite(ref, snapshotRoot);
} else {
copyCurrentDocsSite(snapshotRoot);
}
const snapshotDocsSite = join(snapshotRoot, 'docs-site');
overlayCurrentVitePress(snapshotDocsSite);
linkDocsDependencies(snapshotDocsSite);
return snapshotDocsSite;
}
function tagHasDocsSite(tag: string): boolean {
const result = spawnSync('git', ['cat-file', '-e', `${tag}:docs-site/package.json`], {
cwd: repoRoot,
});
return result.status === 0;
}
function getStableVersions(): string[] {
const tags = capture('git', ['tag', '--list', 'v*'])
.split('\n')
.map((tag) => tag.trim())
.filter(Boolean);
return stableTagsWithDocs(tags, tagHasDocsSite);
}
function buildDocs(options: {
snapshotDocsSite: string;
base: string;
outDir: string;
channel: string;
version?: string;
latestStable: string;
manifestJson: string;
}) {
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_CHANNEL: options.channel,
SUBMINER_DOCS_VERSION: options.version ?? '',
SUBMINER_DOCS_LATEST_STABLE: options.latestStable,
SUBMINER_DOCS_VERSION_MANIFEST: options.manifestJson,
VITE_EXTRA_EXTENSIONS: 'jsonc',
},
});
}
function updateHashWithPath(hash: ReturnType<typeof createHash>, path: string) {
if (isGeneratedVitePressPath(path)) {
return;
}
const stat = lstatSync(path);
hash.update(path.replace(repoRoot, ''));
hash.update(String(stat.mode));
if (stat.isSymbolicLink()) {
return;
}
if (stat.isDirectory()) {
for (const entry of readdirSync(path).sort()) {
updateHashWithPath(hash, join(path, entry));
}
return;
}
hash.update(readFileSync(path));
}
function isGeneratedVitePressPath(path: string): boolean {
return /[\\/]\\.vitepress[\\/](cache|dist)([\\/]|$)/.test(path);
}
function computeSharedInternalsHash(): string {
const hash = createHash('sha256');
const paths = [
join(currentDocsSite, '.vitepress'),
join(currentDocsSite, 'public/assets/fonts'),
join(currentDocsSite, 'package.json'),
join(currentDocsSite, 'bun.lock'),
join(repoRoot, 'scripts/build-versioned-docs.ts'),
join(repoRoot, 'scripts/docs-versioning.ts'),
];
for (const path of paths) {
if (existsSync(path)) {
updateHashWithPath(hash, path);
}
}
return hash.digest('hex');
}
function archiveCachePath(version: string, sharedInternalsHash: string): string {
return join(archiveCacheRoot, versionArchiveCacheName(version, sharedInternalsHash));
}
function restoreCachedArchive(version: string, sharedInternalsHash: string): boolean {
const cachedArchive = archiveCachePath(version, sharedInternalsHash);
if (!existsSync(cachedArchive)) {
return false;
}
cpSync(cachedArchive, join(aggregateOutDir, versionOutputPath(version)), {
recursive: true,
force: true,
});
return true;
}
function saveArchiveCache(version: string, sharedInternalsHash: string) {
const outputPath = join(aggregateOutDir, versionOutputPath(version));
if (!existsSync(outputPath)) {
return;
}
const cachedArchive = archiveCachePath(version, sharedInternalsHash);
rmSync(cachedArchive, { recursive: true, force: true });
mkdirSync(archiveCacheRoot, { recursive: true });
cpSync(outputPath, cachedArchive, { recursive: true, force: true });
}
function assertCloudflarePagesLimits(root: string) {
let fileCount = 0;
const oversizedFiles: 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;
}
fileCount += 1;
if (stat.size > maxCloudflareFileBytes) {
oversizedFiles.push(path);
}
}
}
walk(root);
if (fileCount > maxCloudflareFiles) {
throw new Error(
`Versioned docs output has ${fileCount} files; Cloudflare Pages free plan limit is ${maxCloudflareFiles}.`,
);
}
if (oversizedFiles.length > 0) {
throw new Error(
`Versioned docs output has files over 25 MiB:\n${oversizedFiles.join('\n')}`,
);
}
}
function main() {
const stableVersions = getStableVersions();
const latestStable = stableVersions[0];
if (!latestStable) {
throw new Error('No stable release tags with docs-site/package.json found.');
}
const manifest = buildVersionManifest({ latestStable, stableVersions });
const manifestJson = JSON.stringify(manifest);
const sharedInternalsHash = computeSharedInternalsHash();
rmSync(buildRoot, { recursive: true, force: true });
rmSync(aggregateOutDir, { recursive: true, force: true });
mkdirSync(buildRoot, { recursive: true });
mkdirSync(aggregateOutDir, { recursive: true });
const latestStableSnapshot = prepareSnapshot(latestStable, latestStable);
buildDocs({
snapshotDocsSite: latestStableSnapshot,
base: '/',
outDir: aggregateOutDir,
channel: 'stable-root',
version: latestStable,
latestStable,
manifestJson,
});
for (const version of stableVersions) {
if (restoreCachedArchive(version, sharedInternalsHash)) {
continue;
}
const snapshot = version === latestStable ? latestStableSnapshot : prepareSnapshot(version, version);
buildDocs({
snapshotDocsSite: snapshot,
base: versionPath(version),
outDir: join(aggregateOutDir, versionOutputPath(version)),
channel: 'stable-archive',
version,
latestStable,
manifestJson,
});
saveArchiveCache(version, sharedInternalsHash);
}
const mainSnapshot = prepareSnapshot('main');
buildDocs({
snapshotDocsSite: mainSnapshot,
base: '/main/',
outDir: join(aggregateOutDir, 'main'),
channel: 'main',
version: 'main',
latestStable,
manifestJson,
});
writeFileSync(join(aggregateOutDir, 'versions.json'), `${JSON.stringify(manifest, null, 2)}\n`);
assertCloudflarePagesLimits(aggregateOutDir);
}
main();
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, test } from 'bun:test';
import {
buildVersionManifest,
compareStableVersionsDesc,
isStableReleaseTag,
stableTagsWithDocs,
versionArchiveCacheName,
versionOutputPath,
versionPath,
} from './docs-versioning';
describe('docs versioning helpers', () => {
test('stable tag filtering excludes beta and rc tags', () => {
expect(isStableReleaseTag('v0.14.0')).toBe(true);
expect(isStableReleaseTag('v0.15.0-beta.3')).toBe(false);
expect(isStableReleaseTag('v0.15.0-rc.1')).toBe(false);
});
test('latest stable resolves to v0.14.0 when beta tags are present', () => {
const tags = ['v0.13.0', 'v0.15.0-beta.3', 'v0.14.0'].sort(compareStableVersionsDesc);
expect(tags[0]).toBe('v0.14.0');
});
test('tags before docs-site are skipped', () => {
const tags = ['v0.12.0', 'v0.13.0', 'v0.14.0'];
const hasDocsSite = (tag: string) => tag !== 'v0.12.0';
expect(stableTagsWithDocs(tags, hasDocsSite)).toEqual(['v0.14.0', 'v0.13.0']);
});
test('version manifest paths are normalized', () => {
expect(versionPath('v0.14.0')).toBe('/v/0.14.0/');
expect(
buildVersionManifest({
latestStable: 'v0.14.0',
stableVersions: ['v0.14.0'],
}),
).toEqual({
latestStable: 'v0.14.0',
channels: [
{ label: 'Latest stable', path: '/' },
{ label: 'main', path: '/main/' },
],
versions: [{ version: 'v0.14.0', path: '/v/0.14.0/' }],
});
});
test('archive cache names are normalized by version and shared internals hash', () => {
expect(versionArchiveCacheName('v0.14.0', 'abcdef1234567890')).toBe('abcdef123456-v0.14.0');
});
test('archive output paths stay relative for filesystem joins', () => {
expect(versionOutputPath('v0.14.0')).toBe('v/0.14.0');
});
});
+85
View File
@@ -0,0 +1,85 @@
export type DocsVersionEntry = {
version: string;
path: string;
};
export type DocsChannelEntry = {
label: string;
path: string;
};
export type DocsVersionManifest = {
latestStable: string;
channels: DocsChannelEntry[];
versions: DocsVersionEntry[];
};
const STABLE_TAG_PATTERN = /^v\d+\.\d+\.\d+$/;
export function isStableReleaseTag(tag: string): boolean {
return STABLE_TAG_PATTERN.test(tag);
}
function parseStableVersion(tag: string): [number, number, number] {
const match = /^v(\d+)\.(\d+)\.(\d+)$/.exec(tag);
if (!match) {
throw new Error(`Invalid stable SubMiner version tag: ${tag}`);
}
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
export function compareStableVersionsDesc(a: string, b: string): number {
if (!isStableReleaseTag(a) && !isStableReleaseTag(b)) return a.localeCompare(b);
if (!isStableReleaseTag(a)) return 1;
if (!isStableReleaseTag(b)) return -1;
const parsedA = parseStableVersion(a);
const parsedB = parseStableVersion(b);
for (let index = 0; index < parsedA.length; index += 1) {
const difference = parsedB[index]! - parsedA[index]!;
if (difference !== 0) return difference;
}
return 0;
}
export function versionPath(version: string): string {
return `/v/${version.replace(/^v/, '')}/`;
}
export function versionOutputPath(version: string): string {
return `v/${version.replace(/^v/, '')}`;
}
export function versionArchiveCacheName(version: string, sharedInternalsHash: string): string {
return `${sharedInternalsHash.slice(0, 12)}-${version}`;
}
export function stableTagsWithDocs(
tags: string[],
hasDocsSite: (tag: string) => boolean,
): string[] {
return tags
.filter(isStableReleaseTag)
.filter(hasDocsSite)
.sort(compareStableVersionsDesc);
}
export function buildVersionManifest(options: {
latestStable: string;
stableVersions: string[];
}): DocsVersionManifest {
return {
latestStable: options.latestStable,
channels: [
{ label: 'Latest stable', path: '/' },
{ label: 'main', path: '/main/' },
],
versions: options.stableVersions.map((version) => ({
version,
path: versionPath(version),
})),
};
}
+27
View File
@@ -5,6 +5,8 @@ import { resolve } from 'node:path';
const ciWorkflowPath = resolve(__dirname, '../.github/workflows/ci.yml'); const ciWorkflowPath = resolve(__dirname, '../.github/workflows/ci.yml');
const ciWorkflow = readFileSync(ciWorkflowPath, 'utf8'); const ciWorkflow = readFileSync(ciWorkflowPath, 'utf8');
const docsPagesWorkflowPath = resolve(__dirname, '../.github/workflows/docs-pages.yml');
const docsPagesWorkflow = readFileSync(docsPagesWorkflowPath, 'utf8');
const packageJsonPath = resolve(__dirname, '../package.json'); const packageJsonPath = resolve(__dirname, '../package.json');
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {
scripts: Record<string, string>; scripts: Record<string, string>;
@@ -36,3 +38,28 @@ test('ci workflow runs the maintained source coverage lane and uploads lcov outp
assert.match(ciWorkflow, /name: Upload coverage artifact/); assert.match(ciWorkflow, /name: Upload coverage artifact/);
assert.match(ciWorkflow, /path: coverage\/test-src\/lcov\.info/); assert.match(ciWorkflow, /path: coverage\/test-src\/lcov\.info/);
}); });
test('main docs deploy exists, serializes deploys, and uses Cloudflare credentials', () => {
assert.match(docsPagesWorkflow, /name: Docs Pages/);
assert.match(docsPagesWorkflow, /branches:\s*\n\s*-\s*main/);
assert.match(docsPagesWorkflow, /group:\s*docs-pages-production/);
assert.match(docsPagesWorkflow, /CLOUDFLARE_API_TOKEN/);
assert.match(docsPagesWorkflow, /CLOUDFLARE_ACCOUNT_ID/);
assert.match(docsPagesWorkflow, /CLOUDFLARE_PAGES_PROJECT_NAME/);
assert.match(docsPagesWorkflow, /pages deploy \.tmp\/docs-versioned-site/);
assert.match(docsPagesWorkflow, /--branch main/);
});
test('docs deploy caches stable archive builds between runs', () => {
assert.match(docsPagesWorkflow, /actions\/cache@v4/);
assert.match(docsPagesWorkflow, /\.tmp\/docs-versioned-archive-cache/);
assert.match(docsPagesWorkflow, /docs-versioned-archives-/);
assert.match(docsPagesWorkflow, /docs-site\/\.vitepress\/\*\*/);
});
test('docs deploy skips invalid release tags without failing the workflow', () => {
assert.match(docsPagesWorkflow, /id:\s*tag_guard/);
assert.match(docsPagesWorkflow, /stable_tag=false/);
assert.doesNotMatch(docsPagesWorkflow, /exit 78/);
assert.match(docsPagesWorkflow, /if:\s*steps\.tag_guard\.outputs\.stable_tag != 'false'/);
});
+10
View File
@@ -5,6 +5,8 @@ import { resolve } from 'node:path';
const releaseWorkflowPath = resolve(__dirname, '../.github/workflows/release.yml'); const releaseWorkflowPath = resolve(__dirname, '../.github/workflows/release.yml');
const releaseWorkflow = readFileSync(releaseWorkflowPath, 'utf8'); const releaseWorkflow = readFileSync(releaseWorkflowPath, 'utf8');
const docsPagesWorkflowPath = resolve(__dirname, '../.github/workflows/docs-pages.yml');
const docsPagesWorkflow = readFileSync(docsPagesWorkflowPath, 'utf8');
const makefilePath = resolve(__dirname, '../Makefile'); const makefilePath = resolve(__dirname, '../Makefile');
const makefile = readFileSync(makefilePath, 'utf8'); const makefile = readFileSync(makefilePath, 'utf8');
const packageJsonPath = resolve(__dirname, '../package.json'); const packageJsonPath = resolve(__dirname, '../package.json');
@@ -38,6 +40,14 @@ test('stable release workflow excludes prerelease beta and rc tags', () => {
assert.match(releaseWorkflow, /tags:\s*\n(?:.*\n)*\s*-\s*'!v\*-rc\.\*'/); assert.match(releaseWorkflow, /tags:\s*\n(?:.*\n)*\s*-\s*'!v\*-rc\.\*'/);
}); });
test('stable release tags publish docs and prereleases do not update stable docs', () => {
assert.match(docsPagesWorkflow, /tags:\s*\n\s*-\s*'v\*'/);
assert.match(docsPagesWorkflow, /github\.ref_name/);
assert.match(docsPagesWorkflow, /\^v\[0-9\]\+\\\.\[0-9\]\+\\\.\[0-9\]\+\$/);
assert.match(docsPagesWorkflow, /bun run docs:build:versioned/);
assert.doesNotMatch(docsPagesWorkflow, /beta/);
});
test('publish release forces an existing draft tag release to become public', () => { test('publish release forces an existing draft tag release to become public', () => {
assert.ok(releaseWorkflow.includes('--draft=false')); assert.ok(releaseWorkflow.includes('--draft=false'));
}); });
+1 -1
View File
@@ -8,5 +8,5 @@
"sourceMap": false "sourceMap": false
}, },
"include": ["src/**/*", "launcher/**/*.ts", "scripts/*.ts"], "include": ["src/**/*", "launcher/**/*.ts", "scripts/*.ts"],
"exclude": ["node_modules", "dist", "vendor"] "exclude": ["node_modules", "dist", "vendor", "scripts/*.test.ts"]
} }