mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-25 12:55:18 -07:00
[codex] add versioned Pages deployment (#73)
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
type: docs
|
||||
area: docs
|
||||
|
||||
- Published stable docs at the site root with current development docs under `/main/`.
|
||||
+210
-66
@@ -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 PLAUSIBLE_PROXY_HOSTNAME = 'https://worker.sudacode.com';
|
||||
const PLAUSIBLE_SITE_SCRIPT_PATH = '/js/pa-h28Pn9ppgTJRmiSJlyPT6.js';
|
||||
@@ -7,20 +11,212 @@ const PLAUSIBLE_INIT_SCRIPT = [
|
||||
`plausible.init({ endpoint: '${PLAUSIBLE_ENDPOINT}' });`,
|
||||
].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;
|
||||
|
||||
const route = page
|
||||
.replace(/(^|\/)index\.md$/, '')
|
||||
.replace(/\.md$/, '')
|
||||
.replace(/\/$/, '');
|
||||
return route ? `${DOCS_HOSTNAME}/${route}` : `${DOCS_HOSTNAME}/`;
|
||||
return route ? `/${route}` : '/';
|
||||
}
|
||||
|
||||
export default {
|
||||
function pageToCanonicalHref(page: string): string | null {
|
||||
const route = pageToRoute(page);
|
||||
if (!route) return null;
|
||||
|
||||
if (channel === 'main') {
|
||||
return `${DOCS_HOSTNAME}${canonicalRouteWithBase(route)}`;
|
||||
}
|
||||
|
||||
if (channel === 'stable-archive' && docsVersion !== latestStable) {
|
||||
return `${DOCS_HOSTNAME}${canonicalRouteWithBase(route)}`;
|
||||
}
|
||||
|
||||
return route === '/' ? `${DOCS_HOSTNAME}/` : `${DOCS_HOSTNAME}${route}`;
|
||||
}
|
||||
|
||||
function canonicalRouteWithBase(route: string): string {
|
||||
const routeWithBase = withDocsBase(route);
|
||||
return route === '/' ? routeWithBase : routeWithBase.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function transformPageHead({ page }: TransformContext): HeadConfig[] {
|
||||
const href = pageToCanonicalHref(page);
|
||||
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: '/',
|
||||
},
|
||||
...versionManifest.channels
|
||||
.filter((entry) => entry.label !== 'Latest stable')
|
||||
.map((entry) => ({ text: entry.label, link: entry.path })),
|
||||
...versionManifest.versions.map((entry) => ({
|
||||
text: entry.version,
|
||||
link: entry.path,
|
||||
})),
|
||||
];
|
||||
|
||||
const nav: DefaultTheme.NavItem[] = [
|
||||
{ text: 'Home', link: '/' },
|
||||
{ text: 'Get Started', link: '/installation' },
|
||||
{ text: 'Mining', link: '/mining-workflow' },
|
||||
{ text: 'Configuration', link: '/configuration' },
|
||||
{ text: 'Changelog', link: '/changelog' },
|
||||
{ text: 'Troubleshooting', link: '/troubleshooting' },
|
||||
{ text: docsVersion ?? (channel === 'main' ? 'main' : latestStable), items: versionItems },
|
||||
];
|
||||
|
||||
const sidebar: DefaultTheme.SidebarItem[] = [
|
||||
{
|
||||
text: 'Getting Started',
|
||||
items: [
|
||||
{ text: 'Overview', link: '/' },
|
||||
{ text: 'Installation', link: '/installation' },
|
||||
{ text: 'Usage', link: '/usage' },
|
||||
{ text: 'Mining Workflow', link: '/mining-workflow' },
|
||||
{ text: 'Launcher Script', link: '/launcher-script' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Reference',
|
||||
items: [
|
||||
{ text: 'Configuration', link: '/configuration' },
|
||||
{ text: 'Keyboard Shortcuts', link: '/shortcuts' },
|
||||
{ text: 'Subtitle Annotations', link: '/subtitle-annotations' },
|
||||
{ text: 'Subtitle Sidebar', link: '/subtitle-sidebar' },
|
||||
{ text: 'Immersion Tracking', link: '/immersion-tracking' },
|
||||
{ text: 'Troubleshooting', link: '/troubleshooting' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Integrations',
|
||||
items: [
|
||||
{ text: 'MPV Plugin', link: '/mpv-plugin' },
|
||||
{ text: 'Anki', link: '/anki-integration' },
|
||||
{ text: 'Jellyfin', link: '/jellyfin-integration' },
|
||||
{ text: 'YouTube', link: '/youtube-integration' },
|
||||
{ text: 'Jimaku', link: '/jimaku-integration' },
|
||||
{ text: 'AniList', link: '/anilist-integration' },
|
||||
{ text: 'Character Dictionary', link: '/character-dictionary' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Development',
|
||||
items: [
|
||||
{ text: 'Building & Testing', link: '/development' },
|
||||
{ text: 'Architecture', link: '/architecture' },
|
||||
{ text: 'IPC + Runtime Contracts', link: '/ipc-contracts' },
|
||||
{ text: 'WebSocket + Texthooker API', link: '/websocket-texthooker-api' },
|
||||
{ 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 }],
|
||||
[
|
||||
@@ -31,13 +227,13 @@ export default {
|
||||
},
|
||||
],
|
||||
['script', {}, PLAUSIBLE_INIT_SCRIPT],
|
||||
['link', { rel: 'icon', href: '/favicon.ico', sizes: 'any' }],
|
||||
['link', { rel: 'icon', href: withDocsBase('/favicon.ico'), sizes: 'any' }],
|
||||
[
|
||||
'link',
|
||||
{
|
||||
rel: 'icon',
|
||||
type: 'image/png',
|
||||
href: '/favicon-32x32.png',
|
||||
href: withDocsBase('/favicon-32x32.png'),
|
||||
sizes: '32x32',
|
||||
},
|
||||
],
|
||||
@@ -46,7 +242,7 @@ export default {
|
||||
{
|
||||
rel: 'icon',
|
||||
type: 'image/png',
|
||||
href: '/favicon-16x16.png',
|
||||
href: withDocsBase('/favicon-16x16.png'),
|
||||
sizes: '16x16',
|
||||
},
|
||||
],
|
||||
@@ -54,7 +250,7 @@ export default {
|
||||
'link',
|
||||
{
|
||||
rel: 'apple-touch-icon',
|
||||
href: '/apple-touch-icon.png',
|
||||
href: withDocsBase('/apple-touch-icon.png'),
|
||||
sizes: '180x180',
|
||||
},
|
||||
],
|
||||
@@ -70,10 +266,7 @@ export default {
|
||||
);
|
||||
},
|
||||
},
|
||||
transformHead({ page }) {
|
||||
const href = pageToCanonicalHref(page);
|
||||
return href ? [['link', { rel: 'canonical', href }]] : [];
|
||||
},
|
||||
transformHead: transformPageHead,
|
||||
lastUpdated: true,
|
||||
srcExclude: ['subagents/**'],
|
||||
markdown: {
|
||||
@@ -84,63 +277,12 @@ export default {
|
||||
},
|
||||
themeConfig: {
|
||||
logo: {
|
||||
light: '/assets/SubMiner.png',
|
||||
dark: '/assets/SubMiner.png',
|
||||
light: withDocsBase('/assets/SubMiner.png'),
|
||||
dark: withDocsBase('/assets/SubMiner.png'),
|
||||
},
|
||||
siteTitle: 'SubMiner Docs',
|
||||
nav: [
|
||||
{ text: 'Home', link: '/' },
|
||||
{ text: 'Get Started', link: '/installation' },
|
||||
{ text: 'Mining', link: '/mining-workflow' },
|
||||
{ text: 'Configuration', link: '/configuration' },
|
||||
{ text: 'Changelog', link: '/changelog' },
|
||||
{ text: 'Troubleshooting', link: '/troubleshooting' },
|
||||
],
|
||||
sidebar: [
|
||||
{
|
||||
text: 'Getting Started',
|
||||
items: [
|
||||
{ text: 'Overview', link: '/' },
|
||||
{ text: 'Installation', link: '/installation' },
|
||||
{ text: 'Usage', link: '/usage' },
|
||||
{ text: 'Mining Workflow', link: '/mining-workflow' },
|
||||
{ text: 'Launcher Script', link: '/launcher-script' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Reference',
|
||||
items: [
|
||||
{ text: 'Configuration', link: '/configuration' },
|
||||
{ text: 'Keyboard Shortcuts', link: '/shortcuts' },
|
||||
{ text: 'Subtitle Annotations', link: '/subtitle-annotations' },
|
||||
{ text: 'Subtitle Sidebar', link: '/subtitle-sidebar' },
|
||||
{ text: 'Immersion Tracking', link: '/immersion-tracking' },
|
||||
{ text: 'Troubleshooting', link: '/troubleshooting' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Integrations',
|
||||
items: [
|
||||
{ text: 'MPV Plugin', link: '/mpv-plugin' },
|
||||
{ text: 'Anki', link: '/anki-integration' },
|
||||
{ text: 'Jellyfin', link: '/jellyfin-integration' },
|
||||
{ text: 'YouTube', link: '/youtube-integration' },
|
||||
{ text: 'Jimaku', link: '/jimaku-integration' },
|
||||
{ text: 'AniList', link: '/anilist-integration' },
|
||||
{ text: 'Character Dictionary', link: '/character-dictionary' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Development',
|
||||
items: [
|
||||
{ text: 'Building & Testing', link: '/development' },
|
||||
{ text: 'Architecture', link: '/architecture' },
|
||||
{ text: 'IPC + Runtime Contracts', link: '/ipc-contracts' },
|
||||
{ text: 'WebSocket + Texthooker API', link: '/websocket-texthooker-api' },
|
||||
{ text: 'Changelog', link: '/changelog' },
|
||||
],
|
||||
},
|
||||
],
|
||||
nav: filterNav(nav),
|
||||
sidebar: filterSidebar(sidebar),
|
||||
search: {
|
||||
provider: 'local',
|
||||
},
|
||||
@@ -159,3 +301,5 @@ export default {
|
||||
socialLinks: [{ icon: 'github', link: 'https://github.com/ksyasuda/SubMiner' }],
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
@font-face {
|
||||
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-style: normal;
|
||||
font-display: swap;
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
@font-face {
|
||||
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-style: normal;
|
||||
font-display: swap;
|
||||
|
||||
+12
-5
@@ -30,9 +30,16 @@ bun run docs:dev
|
||||
## Cloudflare Pages
|
||||
|
||||
- Git repo: `ksyasuda/SubMiner`
|
||||
- Root directory: `docs-site`
|
||||
- Build command: `bun run docs:build`
|
||||
- Build output directory: `.vitepress/dist`
|
||||
- Build watch paths: `docs-site/*`
|
||||
- Production branch: `main`
|
||||
- Automatic production and preview deployments: disabled
|
||||
- Custom domain: `docs.subminer.moe` attached to Production
|
||||
- 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.
|
||||
|
||||
@@ -4,6 +4,10 @@ outline: [2, 3]
|
||||
|
||||
# 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).
|
||||
On Windows, the default path is `%APPDATA%\SubMiner\config.jsonc`.
|
||||
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.
|
||||
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;">
|
||||
<source :src="'/assets/kiku-integration.webm'" type="video/webm" />
|
||||
<video controls playsinline preload="metadata" :poster="withBase('/assets/kiku-integration-poster.jpg')" style="width: 100%; max-width: 960px;">
|
||||
<source :src="withBase('/assets/kiku-integration.webm')" type="video/webm" />
|
||||
Your browser does not support the video tag.
|
||||
</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
|
||||
|
||||
|
||||
+16
-14
@@ -3,6 +3,8 @@
|
||||
Short recordings of SubMiner's key features and integrations from real playback sessions.
|
||||
|
||||
<script setup>
|
||||
import { withBase } from 'vitepress';
|
||||
|
||||
const v = '20260301-1';
|
||||
</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.
|
||||
|
||||
<video controls playsinline preload="metadata" :poster="`/assets/minecard-poster.jpg?v=${demoAssetVersion}`">
|
||||
<source :src="`/assets/minecard.webm?v=${demoAssetVersion}`" type="video/webm" />
|
||||
<source :src="`/assets/minecard.mp4?v=${demoAssetVersion}`" type="video/mp4" />
|
||||
<a :href="`/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;" />
|
||||
<video controls playsinline preload="metadata" :poster="withBase(`/assets/minecard-poster.jpg?v=${v}`)">
|
||||
<source :src="withBase(`/assets/minecard.webm?v=${v}`)" type="video/webm" />
|
||||
<source :src="withBase(`/assets/minecard.mp4?v=${v}`)" type="video/mp4" />
|
||||
<a :href="withBase(`/assets/minecard.webm?v=${v}`)" target="_blank" rel="noreferrer">
|
||||
<img :src="withBase(`/assets/minecard.webp?v=${v}`)" alt="SubMiner demo Animated fallback" style="width: 100%; height: auto;" />
|
||||
</a>
|
||||
</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.
|
||||
|
||||
<!-- <video controls playsinline preload="metadata" :poster="`/assets/demos/subtitle-sync-poster.jpg?v=${v}`">
|
||||
<source :src="`/assets/demos/subtitle-sync.webm?v=${v}`" type="video/webm" />
|
||||
<source :src="`/assets/demos/subtitle-sync.mp4?v=${v}`" type="video/mp4" />
|
||||
<!-- <video controls playsinline preload="metadata" :poster="withBase(`/assets/demos/subtitle-sync-poster.jpg?v=${v}`)">
|
||||
<source :src="withBase(`/assets/demos/subtitle-sync.webm?v=${v}`)" type="video/webm" />
|
||||
<source :src="withBase(`/assets/demos/subtitle-sync.mp4?v=${v}`)" type="video/mp4" />
|
||||
</video> -->
|
||||
|
||||
::: 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.
|
||||
|
||||
<!-- <video controls playsinline preload="metadata" :poster="`/assets/demos/jellyfin-poster.jpg?v=${v}`">
|
||||
<source :src="`/assets/demos/jellyfin.webm?v=${v}`" type="video/webm" />
|
||||
<source :src="`/assets/demos/jellyfin.mp4?v=${v}`" type="video/mp4" />
|
||||
<!-- <video controls playsinline preload="metadata" :poster="withBase(`/assets/demos/jellyfin-poster.jpg?v=${v}`)">
|
||||
<source :src="withBase(`/assets/demos/jellyfin.webm?v=${v}`)" type="video/webm" />
|
||||
<source :src="withBase(`/assets/demos/jellyfin.mp4?v=${v}`)" type="video/mp4" />
|
||||
</video> -->
|
||||
|
||||
::: 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.
|
||||
|
||||
<!-- <video controls playsinline preload="metadata" :poster="`/assets/demos/texthooker-poster.jpg?v=${v}`">
|
||||
<source :src="`/assets/demos/texthooker.webm?v=${v}`" type="video/webm" />
|
||||
<source :src="`/assets/demos/texthooker.mp4?v=${v}`" type="video/mp4" />
|
||||
<!-- <video controls playsinline preload="metadata" :poster="withBase(`/assets/demos/texthooker-poster.jpg?v=${v}`)">
|
||||
<source :src="withBase(`/assets/demos/texthooker.webm?v=${v}`)" type="video/webm" />
|
||||
<source :src="withBase(`/assets/demos/texthooker.mp4?v=${v}`)" type="video/mp4" />
|
||||
</video> -->
|
||||
|
||||
::: info VIDEO COMING SOON
|
||||
|
||||
@@ -113,6 +113,14 @@ bun run docs:test
|
||||
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:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -37,13 +37,13 @@ test('docs reflect current launcher and release surfaces', () => {
|
||||
|
||||
expect(mpvPluginContents).toContain('\\\\.\\pipe\\subminer-socket');
|
||||
|
||||
expect(readmeContents).toContain('Root directory: `docs-site`');
|
||||
expect(readmeContents).toContain('Build output directory: `.vitepress/dist`');
|
||||
expect(readmeContents).toContain('Build watch paths: `docs-site/*`');
|
||||
expect(readmeContents).toContain('Automatic production and preview deployments: disabled');
|
||||
expect(readmeContents).toContain('/main/');
|
||||
expect(readmeContents).toContain('GitHub Actions direct upload with Wrangler');
|
||||
expect(developmentContents).not.toContain('../subminer-docs');
|
||||
expect(developmentContents).toContain('bun run docs:build');
|
||||
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).toContain('bun run build:win');
|
||||
|
||||
|
||||
@@ -7,18 +7,18 @@ const docsIndexContents = readFileSync(docsIndexPath, 'utf8');
|
||||
test('docs demo media uses shared cache-busting asset version token', () => {
|
||||
expect(docsIndexContents).toMatch(/const demoAssetVersion = ['"][^'"]+['"]/);
|
||||
expect(docsIndexContents).toContain(
|
||||
':poster="`/assets/minecard-poster.jpg?v=${demoAssetVersion}`"',
|
||||
':poster="withBase(`/assets/minecard-poster.jpg?v=${demoAssetVersion}`)"',
|
||||
);
|
||||
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(
|
||||
'<source :src="`/assets/minecard.mp4?v=${demoAssetVersion}`" type="video/mp4" />',
|
||||
'<source :src="withBase(`/assets/minecard.mp4?v=${demoAssetVersion}`)" type="video/mp4" />',
|
||||
);
|
||||
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(
|
||||
'<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
@@ -86,6 +86,8 @@ features:
|
||||
---
|
||||
|
||||
<script setup>
|
||||
import { withBase } from 'vitepress';
|
||||
|
||||
const demoAssetVersion = '20260223-2';
|
||||
</script>
|
||||
|
||||
@@ -135,11 +137,11 @@ const demoAssetVersion = '20260223-2';
|
||||
<span class="demo-window__dot"></span>
|
||||
<span class="demo-window__title">subminer -- playback</span>
|
||||
</div>
|
||||
<video controls playsinline preload="metadata" :poster="`/assets/minecard-poster.jpg?v=${demoAssetVersion}`">
|
||||
<source :src="`/assets/minecard.webm?v=${demoAssetVersion}`" type="video/webm" />
|
||||
<source :src="`/assets/minecard.mp4?v=${demoAssetVersion}`" type="video/mp4" />
|
||||
<a :href="`/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;" />
|
||||
<video controls playsinline preload="metadata" :poster="withBase(`/assets/minecard-poster.jpg?v=${demoAssetVersion}`)">
|
||||
<source :src="withBase(`/assets/minecard.webm?v=${demoAssetVersion}`)" type="video/webm" />
|
||||
<source :src="withBase(`/assets/minecard.mp4?v=${demoAssetVersion}`)" type="video/mp4" />
|
||||
<a :href="withBase(`/assets/minecard.webm?v=${demoAssetVersion}`)" target="_blank" rel="noreferrer">
|
||||
<img :src="withBase(`/assets/minecard.webp?v=${demoAssetVersion}`)" alt="SubMiner demo Animated fallback" style="width: 100%; height: auto;" />
|
||||
</a>
|
||||
</video>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"docs:dev": "VITE_EXTRA_EXTENSIONS=jsonc vitepress dev --host 0.0.0.0 --port 5173 --strictPort",
|
||||
"docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build",
|
||||
"docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview --host 0.0.0.0 --port 4173 --strictPort",
|
||||
"test": "bun test plausible.test.ts index.assets.test.ts docs-sync.test.ts 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": {
|
||||
"@catppuccin/vitepress": "^0.1.2",
|
||||
|
||||
@@ -4,9 +4,11 @@ import { readFileSync } from 'node:fs';
|
||||
const docsConfigPath = new URL('./.vitepress/config.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 versionedBuildPath = new URL('../scripts/build-versioned-docs.ts', import.meta.url);
|
||||
const docsConfigContents = readFileSync(docsConfigPath, 'utf8');
|
||||
const docsThemeContents = readFileSync(docsThemePath, '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', () => {
|
||||
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(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)');
|
||||
});
|
||||
|
||||
@@ -31,6 +31,54 @@ test('docs pages emit stable self-referential canonical URLs', async () => {
|
||||
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 () => {
|
||||
const items = [{ url: '' }, { url: 'README' }, { url: 'usage' }];
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
8. If `docs-site/` changed, also run:
|
||||
`bun run docs:test`
|
||||
`bun run docs:build`
|
||||
`bun run docs:build:versioned`
|
||||
9. Commit release prep.
|
||||
10. Tag the commit: `git tag v<version>`.
|
||||
11. Push commit + tag.
|
||||
@@ -66,6 +67,7 @@
|
||||
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 also do not update `https://docs.subminer.moe/`.
|
||||
|
||||
Notes:
|
||||
|
||||
@@ -81,6 +83,8 @@ Notes:
|
||||
- If you need to repair a published release body (for example, a prior version’s 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
+2
-1
@@ -40,6 +40,7 @@
|
||||
"lint": "bun run lint:stats",
|
||||
"docs:dev": "bun run --cwd docs-site docs:dev",
|
||||
"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:test": "bun run --cwd docs-site test",
|
||||
"test:docs:kb": "bun test scripts/docs-knowledge-base.test.ts",
|
||||
@@ -71,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/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",
|
||||
"verify:config-example": "bun run src/verify-config-example.ts",
|
||||
"start": "bun run build && electron . --start",
|
||||
|
||||
@@ -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();
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { resolve } from 'node:path';
|
||||
|
||||
const ciWorkflowPath = resolve(__dirname, '../.github/workflows/ci.yml');
|
||||
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 packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {
|
||||
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, /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'/);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,8 @@ import { resolve } from 'node:path';
|
||||
|
||||
const releaseWorkflowPath = resolve(__dirname, '../.github/workflows/release.yml');
|
||||
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 makefile = readFileSync(makefilePath, 'utf8');
|
||||
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\.\*'/);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
assert.ok(releaseWorkflow.includes('--draft=false'));
|
||||
});
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"sourceMap": false
|
||||
},
|
||||
"include": ["src/**/*", "launcher/**/*.ts", "scripts/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "vendor"]
|
||||
"exclude": ["node_modules", "dist", "vendor", "scripts/*.test.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user