mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-25 12:55:18 -07:00
Compare commits
2 Commits
e84674e3b5
...
799cce6991
| Author | SHA1 | Date | |
|---|---|---|---|
| 799cce6991 | |||
| 6b2cb002ac |
@@ -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
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: help deps build build-launcher install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-windows uninstall uninstall-linux uninstall-macos uninstall-windows print-dirs pretty lint ensure-bun generate-config generate-example-config dev-start dev-start-macos dev-watch dev-watch-macos dev-toggle dev-stop
|
||||
.PHONY: help deps build build-launcher install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-windows uninstall uninstall-linux uninstall-macos uninstall-windows print-dirs pretty lint ensure-bun generate-config generate-example-config dev-start dev-start-macos dev-watch dev-watch-macos dev-toggle dev-stop docs-test docs-build docs-build-versioned docs-dev
|
||||
|
||||
APP_NAME := subminer
|
||||
THEME_SOURCE := assets/themes/subminer.rasi
|
||||
@@ -62,6 +62,10 @@ help:
|
||||
" dev-watch-macos Start watch loop with forced macOS tracker backend" \
|
||||
" dev-toggle Toggle overlay in a running local Electron app" \
|
||||
" dev-stop Stop a running local Electron app" \
|
||||
" docs-test Run docs tests" \
|
||||
" docs-build Build the docs site" \
|
||||
" docs-build-versioned Build production versioned docs site" \
|
||||
" docs-dev Start the docs dev server" \
|
||||
" install-linux Install Linux wrapper/theme/app artifacts" \
|
||||
" install-macos Install macOS wrapper/theme/app artifacts" \
|
||||
" install-windows Print Windows packaging/install guidance" \
|
||||
@@ -200,6 +204,18 @@ dev-toggle: ensure-bun
|
||||
dev-stop: ensure-bun
|
||||
@bun run electron . --stop
|
||||
|
||||
docs-test: ensure-bun
|
||||
@bun run docs:test
|
||||
|
||||
docs-build: ensure-bun
|
||||
@bun run docs:build
|
||||
|
||||
docs-build-versioned: ensure-bun
|
||||
@bun run docs:build:versioned
|
||||
|
||||
docs-dev: ensure-bun
|
||||
@bun run docs:dev
|
||||
|
||||
|
||||
install-linux: build-launcher
|
||||
@printf '%s\n' "[INFO] Installing Linux wrapper/theme artifacts"
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
# Config Settings Window
|
||||
|
||||
Status: draft
|
||||
Owner: Kyle Yasuda
|
||||
Created: 2026-05-17
|
||||
|
||||
## Goal
|
||||
|
||||
Add a dedicated configuration window that groups settings by user workflow while saving back to the existing `config.jsonc` paths.
|
||||
|
||||
## Notes
|
||||
|
||||
- Full current config surface, excluding legacy/ignored compatibility keys.
|
||||
- Preserve JSONC comments/formatting when saving.
|
||||
- Surface hot-reload vs restart-required results.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: docs
|
||||
area: docs
|
||||
|
||||
- Published stable docs at the site root with current development docs under `/main/`.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: docs
|
||||
|
||||
- Fixed versioned docs navigation so archived pages keep local links under the selected version, the version switcher no longer nests targets under the current archive path, local dev version routes serve warmed archive files instead of redirecting to production or falling through to VitePress 404s, and internal README files do not break archived builds.
|
||||
+355
-65
@@ -1,3 +1,7 @@
|
||||
import { existsSync, readFileSync, statSync } from 'node:fs';
|
||||
import { extname, join, posix, resolve, sep } from 'node:path';
|
||||
import type { DefaultTheme, HeadConfig, TransformContext, UserConfig } from 'vitepress';
|
||||
|
||||
const DOCS_HOSTNAME = 'https://docs.subminer.moe';
|
||||
const PLAUSIBLE_PROXY_HOSTNAME = 'https://worker.sudacode.com';
|
||||
const PLAUSIBLE_SITE_SCRIPT_PATH = '/js/pa-h28Pn9ppgTJRmiSJlyPT6.js';
|
||||
@@ -7,20 +11,358 @@ 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 docsSourceDir = process.env.SUBMINER_DOCS_SOURCE_DIR ?? process.cwd();
|
||||
const localArchiveDir = resolve(
|
||||
process.env.SUBMINER_DOCS_LOCAL_ARCHIVE_DIR ??
|
||||
join(docsSourceDir, '..', '.tmp/docs-versioned-site'),
|
||||
);
|
||||
const channel = normalizeChannel(process.env.SUBMINER_DOCS_CHANNEL);
|
||||
const docsVersion = process.env.SUBMINER_DOCS_VERSION;
|
||||
const latestStable = process.env.SUBMINER_DOCS_LATEST_STABLE ?? 'v0.14.0';
|
||||
const versionManifest = parseVersionManifest(process.env.SUBMINER_DOCS_VERSION_MANIFEST);
|
||||
const versionLinkOrigin = process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN ?? 'production';
|
||||
|
||||
function normalizeBase(value: string): string {
|
||||
if (!value || value === '/') return '/';
|
||||
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(docsSourceDir, 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));
|
||||
}
|
||||
|
||||
function versionSwitchLink(path: string): string {
|
||||
if (/^[a-z]+:\/\//i.test(path)) return path;
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||
if (versionLinkOrigin === 'local') return localVersionSwitchLink(normalizedPath);
|
||||
return `${DOCS_HOSTNAME}${normalizedPath}`;
|
||||
}
|
||||
|
||||
function localVersionSwitchLink(path: string): string {
|
||||
if (base === '/') return path;
|
||||
|
||||
const basePath = base.replace(/\/$/, '');
|
||||
const targetPath = path === '/' ? '/' : path.replace(/\/$/, '');
|
||||
const relativePath = posix.relative(basePath, targetPath) || '.';
|
||||
|
||||
return path.endsWith('/') ? `${relativePath}/` : relativePath;
|
||||
}
|
||||
|
||||
function shouldHandleLocalVersionRoute(pathname: string): boolean {
|
||||
if (base !== '/' || channel !== 'stable-root') return false;
|
||||
return /^\/main(?:\/|$)/.test(pathname) || /^\/v\/[^/]+(?:\/|$)/.test(pathname);
|
||||
}
|
||||
|
||||
function contentTypeForPath(path: string): string {
|
||||
switch (extname(path)) {
|
||||
case '.css':
|
||||
return 'text/css; charset=utf-8';
|
||||
case '.gif':
|
||||
return 'image/gif';
|
||||
case '.ico':
|
||||
return 'image/x-icon';
|
||||
case '.jpg':
|
||||
case '.jpeg':
|
||||
return 'image/jpeg';
|
||||
case '.js':
|
||||
case '.mjs':
|
||||
return 'text/javascript; charset=utf-8';
|
||||
case '.json':
|
||||
case '.jsonc':
|
||||
return 'application/json; charset=utf-8';
|
||||
case '.mp4':
|
||||
return 'video/mp4';
|
||||
case '.png':
|
||||
return 'image/png';
|
||||
case '.svg':
|
||||
return 'image/svg+xml';
|
||||
case '.ttf':
|
||||
return 'font/ttf';
|
||||
case '.webm':
|
||||
return 'video/webm';
|
||||
case '.woff':
|
||||
return 'font/woff';
|
||||
case '.woff2':
|
||||
return 'font/woff2';
|
||||
case '.xml':
|
||||
return 'application/xml; charset=utf-8';
|
||||
default:
|
||||
return 'text/html; charset=utf-8';
|
||||
}
|
||||
}
|
||||
|
||||
function isFile(path: string): boolean {
|
||||
try {
|
||||
return statSync(path).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function archiveFileForPathname(pathname: string): string | null {
|
||||
if (!shouldHandleLocalVersionRoute(pathname)) return null;
|
||||
|
||||
const routePath = decodeURIComponent(pathname).replace(/^\/+/, '');
|
||||
const filePath = resolve(localArchiveDir, routePath);
|
||||
if (filePath !== localArchiveDir && !filePath.startsWith(`${localArchiveDir}${sep}`)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = pathname.endsWith('/')
|
||||
? [join(filePath, 'index.html')]
|
||||
: extname(filePath)
|
||||
? [filePath]
|
||||
: [`${filePath}.html`, join(filePath, 'index.html')];
|
||||
|
||||
return candidates.find(isFile) ?? null;
|
||||
}
|
||||
|
||||
function serveLocalArchiveRoute(pathname: string, response: DevServerResponse): boolean {
|
||||
if (versionLinkOrigin !== 'local') return false;
|
||||
|
||||
const filePath = archiveFileForPathname(pathname);
|
||||
if (!filePath) return false;
|
||||
|
||||
response.statusCode = 200;
|
||||
response.setHeader('Content-Type', contentTypeForPath(filePath));
|
||||
response.end(readFileSync(filePath));
|
||||
return true;
|
||||
}
|
||||
|
||||
type DevServerResponse = {
|
||||
statusCode: number;
|
||||
setHeader(name: string, value: string): void;
|
||||
end(chunk?: string | Uint8Array): void;
|
||||
};
|
||||
|
||||
const versionItems = [
|
||||
{
|
||||
text: `Latest stable (${versionManifest.latestStable})`,
|
||||
link: versionSwitchLink('/'),
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
},
|
||||
...versionManifest.channels
|
||||
.filter((entry) => entry.label !== 'Latest stable')
|
||||
.map((entry) => ({
|
||||
text: entry.label,
|
||||
link: versionSwitchLink(entry.path),
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
})),
|
||||
...versionManifest.versions.map((entry) => ({
|
||||
text: entry.version,
|
||||
link: versionSwitchLink(entry.path),
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
})),
|
||||
];
|
||||
|
||||
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 } : {}),
|
||||
vite: {
|
||||
plugins: [
|
||||
{
|
||||
name: 'subminer-docs-local-version-redirects',
|
||||
configureServer(server) {
|
||||
server.middlewares.use((request, response, next) => {
|
||||
const requestUrl = new URL(request.url ?? '/', 'http://localhost');
|
||||
if (serveLocalArchiveRoute(requestUrl.pathname, response)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldHandleLocalVersionRoute(requestUrl.pathname)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
response.statusCode = 302;
|
||||
response.setHeader(
|
||||
'Location',
|
||||
`${DOCS_HOSTNAME}${requestUrl.pathname}${requestUrl.search}`,
|
||||
);
|
||||
response.end();
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
head: [
|
||||
['link', { rel: 'preconnect', href: PLAUSIBLE_PROXY_HOSTNAME }],
|
||||
[
|
||||
@@ -31,13 +373,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 +388,7 @@ export default {
|
||||
{
|
||||
rel: 'icon',
|
||||
type: 'image/png',
|
||||
href: '/favicon-16x16.png',
|
||||
href: withDocsBase('/favicon-16x16.png'),
|
||||
sizes: '16x16',
|
||||
},
|
||||
],
|
||||
@@ -54,7 +396,7 @@ export default {
|
||||
'link',
|
||||
{
|
||||
rel: 'apple-touch-icon',
|
||||
href: '/apple-touch-icon.png',
|
||||
href: withDocsBase('/apple-touch-icon.png'),
|
||||
sizes: '180x180',
|
||||
},
|
||||
],
|
||||
@@ -70,12 +412,9 @@ export default {
|
||||
);
|
||||
},
|
||||
},
|
||||
transformHead({ page }) {
|
||||
const href = pageToCanonicalHref(page);
|
||||
return href ? [['link', { rel: 'canonical', href }]] : [];
|
||||
},
|
||||
transformHead: transformPageHead,
|
||||
lastUpdated: true,
|
||||
srcExclude: ['subagents/**'],
|
||||
srcExclude: ['subagents/**', 'README.md'],
|
||||
markdown: {
|
||||
theme: {
|
||||
light: 'catppuccin-latte',
|
||||
@@ -88,59 +427,8 @@ export default {
|
||||
dark: '/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 +447,5 @@ export default {
|
||||
socialLinks: [{ icon: 'github', link: 'https://github.com/ksyasuda/SubMiner' }],
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { useRoute, useData } from 'vitepress';
|
||||
import { computed } from 'vue';
|
||||
import { formatStatusLineFilePath } from '../status-line';
|
||||
|
||||
const route = useRoute();
|
||||
const { page, frontmatter } = useData();
|
||||
@@ -12,8 +13,7 @@ const mode = computed(() => {
|
||||
});
|
||||
|
||||
const filePath = computed(() => {
|
||||
const path = route.path;
|
||||
return path === '/' ? 'index.md' : `${path.replace(/^\//, '')}.md`;
|
||||
return formatStatusLineFilePath(route.path);
|
||||
});
|
||||
|
||||
const section = computed(() => {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { formatStatusLineFilePath } from './status-line';
|
||||
|
||||
test('status line file path formats root home as index markdown', () => {
|
||||
expect(formatStatusLineFilePath('/')).toBe('index.md');
|
||||
});
|
||||
|
||||
test('status line file path formats version archive home without trailing slash', () => {
|
||||
expect(formatStatusLineFilePath('/v/0.12.0/')).toBe('v/0.12.0.md');
|
||||
});
|
||||
|
||||
test('status line file path keeps normal docs routes as markdown files', () => {
|
||||
expect(formatStatusLineFilePath('/v/0.12.0/configuration')).toBe(
|
||||
'v/0.12.0/configuration.md',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
export function formatStatusLineFilePath(routePath: string): string {
|
||||
if (routePath === '/') return 'index.md';
|
||||
return `${routePath.replace(/^\/|\/$/g, '')}.md`;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+20
-11
@@ -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. Public assets from `docs-site/public/assets` are shared from root `/assets/` so large demo media is not duplicated into every version archive; generated VitePress CSS and JS assets stay under each version route. Stale `.tmp/docs-versioned-archive-cache` generations are pruned after a successful build, and intermediate `.tmp/docs-versioned-build` workspaces are removed.
|
||||
|
||||
Focused commands:
|
||||
|
||||
```bash
|
||||
@@ -154,6 +162,7 @@ bun run format:check:src
|
||||
- `make pretty` runs the maintained Prettier allowlist only (`format:src`).
|
||||
- `bun run format:check:src` checks the same scoped set without writing changes.
|
||||
- `bun run format` remains the broad repo-wide Prettier command; use it intentionally.
|
||||
|
||||
## Config Generation
|
||||
|
||||
```bash
|
||||
@@ -197,17 +206,17 @@ Use Cloudflare's single `*` wildcard syntax for watch paths. `docs-site/*` cover
|
||||
|
||||
Run `make help` for a full list of targets. Key ones:
|
||||
|
||||
| Target | Description |
|
||||
| ---------------------- | ---------------------------------------------------------------- |
|
||||
| `make build` | Build platform package for detected OS |
|
||||
| `make build-launcher` | Generate Bun launcher wrapper at `dist/launcher/subminer` |
|
||||
| `make install` | Install platform artifacts (wrapper, theme, AppImage/app bundle) |
|
||||
| `make deps` | Install JS dependencies (root + stats + texthooker-ui) |
|
||||
| `make pretty` | Run scoped Prettier formatting for maintained source/config files |
|
||||
| `make generate-config` | Generate default config from centralized registry |
|
||||
| `make build-linux` | Convenience wrapper for Linux packaging |
|
||||
| `make build-macos` | Convenience wrapper for signed macOS packaging |
|
||||
| `make build-macos-unsigned` | Convenience wrapper for unsigned macOS packaging |
|
||||
| Target | Description |
|
||||
| --------------------------- | ----------------------------------------------------------------- |
|
||||
| `make build` | Build platform package for detected OS |
|
||||
| `make build-launcher` | Generate Bun launcher wrapper at `dist/launcher/subminer` |
|
||||
| `make install` | Install platform artifacts (wrapper, theme, AppImage/app bundle) |
|
||||
| `make deps` | Install JS dependencies (root + stats + texthooker-ui) |
|
||||
| `make pretty` | Run scoped Prettier formatting for maintained source/config files |
|
||||
| `make generate-config` | Generate default config from centralized registry |
|
||||
| `make build-linux` | Convenience wrapper for Linux packaging |
|
||||
| `make build-macos` | Convenience wrapper for signed macOS packaging |
|
||||
| `make build-macos-unsigned` | Convenience wrapper for unsigned macOS packaging |
|
||||
|
||||
## Contributor Notes
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ const installationContents = readFileSync(new URL('./installation.md', import.me
|
||||
const mpvPluginContents = readFileSync(new URL('./mpv-plugin.md', import.meta.url), 'utf8');
|
||||
const developmentContents = readFileSync(new URL('./development.md', import.meta.url), 'utf8');
|
||||
const changelogContents = readFileSync(new URL('./changelog.md', import.meta.url), 'utf8');
|
||||
const docsPackageContents = readFileSync(new URL('./package.json', import.meta.url), 'utf8');
|
||||
const ankiIntegrationContents = readFileSync(
|
||||
new URL('./anki-integration.md', import.meta.url),
|
||||
'utf8',
|
||||
@@ -37,13 +38,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');
|
||||
|
||||
@@ -57,6 +58,15 @@ test('docs reflect current launcher and release surfaces', () => {
|
||||
expect(changelogContents).toContain('v0.5.1 (2026-03-09)');
|
||||
});
|
||||
|
||||
test('docs dev server links version navigation to local dev routes', () => {
|
||||
expect(docsPackageContents).toContain('scripts/build-versioned-docs.ts');
|
||||
expect(docsPackageContents).toContain(
|
||||
'SUBMINER_DOCS_VERSION_LINK_ORIGIN=local bun run ../scripts/build-versioned-docs.ts',
|
||||
);
|
||||
expect(docsPackageContents).toContain('SUBMINER_DOCS_VERSION_LINK_ORIGIN=local');
|
||||
expect(docsPackageContents).toContain('SUBMINER_DOCS_VERSION_MANIFEST');
|
||||
});
|
||||
|
||||
test('docs changelog keeps the current minor release headings aligned with the root changelog', () => {
|
||||
const docsHeadings = extractCurrentMinorHeadings(changelogContents);
|
||||
expect(docsHeadings.length).toBeGreaterThan(0);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
"description": "In-repo VitePress documentation site for SubMiner",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"scripts": {
|
||||
"docs:dev": "VITE_EXTRA_EXTENSIONS=jsonc vitepress dev --host 0.0.0.0 --port 5173 --strictPort",
|
||||
"docs:dev": "SUBMINER_DOCS_VERSION_LINK_ORIGIN=local bun run ../scripts/build-versioned-docs.ts && SUBMINER_DOCS_VERSION_LINK_ORIGIN=local SUBMINER_DOCS_VERSION_MANIFEST=\"$(bun run ../scripts/print-docs-version-manifest.ts)\" VITE_EXTRA_EXTENSIONS=jsonc vitepress dev --host 0.0.0.0 --port 5173 --strictPort",
|
||||
"docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build",
|
||||
"docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview --host 0.0.0.0 --port 4173 --strictPort",
|
||||
"test": "bun test plausible.test.ts index.assets.test.ts docs-sync.test.ts seo.test.ts"
|
||||
"test": "bun test plausible.test.ts index.assets.test.ts docs-sync.test.ts seo.test.ts .vitepress/theme/status-line.test.ts ../scripts/docs-versioning.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@catppuccin/vitepress": "^0.1.2",
|
||||
|
||||
@@ -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,33 @@ 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)');
|
||||
});
|
||||
|
||||
test('versioned docs build reports archive cache hits and rebuilds', () => {
|
||||
expect(versionedBuildContents).toContain(
|
||||
'console.info(`[docs] archive cache key ${archiveCacheKey.slice(0, 12)}`)',
|
||||
);
|
||||
expect(versionedBuildContents).toContain('console.info(`[docs] cache hit ${version}`)');
|
||||
expect(versionedBuildContents).toContain('console.info(`[docs] rebuilding archive ${version}`)');
|
||||
});
|
||||
|
||||
test('versioned docs build deduplicates public assets and prunes stale workspaces', () => {
|
||||
expect(versionedBuildContents).toContain('dedupeVersionedPublicAssets({');
|
||||
expect(versionedBuildContents).toContain('pruneArchiveCacheGenerations({');
|
||||
expect(versionedBuildContents).toContain('rmSync(buildRoot, { recursive: true, force: true });');
|
||||
});
|
||||
|
||||
test('versioned docs archive cache key ignores generated and test-only files', () => {
|
||||
expect(versionedBuildContents).toContain('isSharedInternalsHashIgnoredPath(path)');
|
||||
expect(versionedBuildContents).toContain('|| /\\.test\\.[cm]?[jt]s$/.test(path)');
|
||||
expect(versionedBuildContents).toContain('process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN');
|
||||
expect(versionedBuildContents).not.toContain('hash.update(String(stat.mode))');
|
||||
});
|
||||
|
||||
test('docs builds exclude the internal README from VitePress page entries', () => {
|
||||
expect(docsConfigContents).toContain("srcExclude: ['subagents/**', 'README.md']");
|
||||
});
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { TransformContext } from 'vitepress';
|
||||
import docsConfig from './.vitepress/config';
|
||||
|
||||
const docsSiteDir = fileURLToPath(new URL('.', import.meta.url));
|
||||
|
||||
function makeTransformContext(page: string): TransformContext {
|
||||
return {
|
||||
page,
|
||||
@@ -31,6 +37,391 @@ 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('stable archive theme links stay on the selected version', async () => {
|
||||
const previousCwd = process.cwd();
|
||||
const previousChannel = process.env.SUBMINER_DOCS_CHANNEL;
|
||||
const previousBase = process.env.SUBMINER_DOCS_BASE;
|
||||
const previousVersion = process.env.SUBMINER_DOCS_VERSION;
|
||||
const previousLatest = process.env.SUBMINER_DOCS_LATEST_STABLE;
|
||||
const previousManifest = process.env.SUBMINER_DOCS_VERSION_MANIFEST;
|
||||
const previousVersionLinkOrigin = process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN;
|
||||
process.chdir(docsSiteDir);
|
||||
process.env.SUBMINER_DOCS_CHANNEL = 'stable-archive';
|
||||
process.env.SUBMINER_DOCS_BASE = '/v/0.12.0/';
|
||||
process.env.SUBMINER_DOCS_VERSION = 'v0.12.0';
|
||||
process.env.SUBMINER_DOCS_LATEST_STABLE = 'v0.14.0';
|
||||
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = 'production';
|
||||
process.env.SUBMINER_DOCS_VERSION_MANIFEST = JSON.stringify({
|
||||
latestStable: 'v0.14.0',
|
||||
channels: [
|
||||
{ label: 'Latest stable', path: '/' },
|
||||
{ label: 'main', path: '/main/' },
|
||||
],
|
||||
versions: [
|
||||
{ version: 'v0.14.0', path: '/v/0.14.0/' },
|
||||
{ version: 'v0.12.0', path: '/v/0.12.0/' },
|
||||
],
|
||||
});
|
||||
try {
|
||||
const { default: archiveConfig } = await import('./.vitepress/config?stable-archive-links');
|
||||
|
||||
const nav = archiveConfig.themeConfig?.nav as Array<{
|
||||
text: string;
|
||||
link?: string;
|
||||
items?: Array<{ text: string; link: string }>;
|
||||
}>;
|
||||
const sidebar = archiveConfig.themeConfig?.sidebar as Array<{
|
||||
text: string;
|
||||
items?: Array<{ text: string; link: string }>;
|
||||
}>;
|
||||
const configurationNav = nav.find((item) => item.text === 'Configuration');
|
||||
const versionNav = nav.find((item) => item.text === 'v0.12.0');
|
||||
const referenceSidebar = sidebar.find((item) => item.text === 'Reference');
|
||||
const configurationSidebar = referenceSidebar?.items?.find(
|
||||
(item) => item.text === 'Configuration',
|
||||
);
|
||||
|
||||
expect(configurationNav?.link).toBe('/configuration');
|
||||
expect(configurationSidebar?.link).toBe('/configuration');
|
||||
expect(versionNav?.items).toContainEqual({
|
||||
text: 'Latest stable (v0.14.0)',
|
||||
link: 'https://docs.subminer.moe/',
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
});
|
||||
expect(versionNav?.items).toContainEqual({
|
||||
text: 'main',
|
||||
link: 'https://docs.subminer.moe/main/',
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
});
|
||||
expect(versionNav?.items).toContainEqual({
|
||||
text: 'v0.14.0',
|
||||
link: 'https://docs.subminer.moe/v/0.14.0/',
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
});
|
||||
expect(versionNav?.items).toContainEqual({
|
||||
text: 'v0.12.0',
|
||||
link: 'https://docs.subminer.moe/v/0.12.0/',
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
});
|
||||
expect(archiveConfig.themeConfig?.logo).toEqual({
|
||||
light: '/assets/SubMiner.png',
|
||||
dark: '/assets/SubMiner.png',
|
||||
});
|
||||
} finally {
|
||||
process.chdir(previousCwd);
|
||||
process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
|
||||
process.env.SUBMINER_DOCS_BASE = previousBase;
|
||||
process.env.SUBMINER_DOCS_VERSION = previousVersion;
|
||||
process.env.SUBMINER_DOCS_LATEST_STABLE = previousLatest;
|
||||
process.env.SUBMINER_DOCS_VERSION_MANIFEST = previousManifest;
|
||||
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = previousVersionLinkOrigin;
|
||||
}
|
||||
});
|
||||
|
||||
test('local stable archive version links stay on the dev server', async () => {
|
||||
const previousCwd = process.cwd();
|
||||
const previousChannel = process.env.SUBMINER_DOCS_CHANNEL;
|
||||
const previousBase = process.env.SUBMINER_DOCS_BASE;
|
||||
const previousVersion = process.env.SUBMINER_DOCS_VERSION;
|
||||
const previousLatest = process.env.SUBMINER_DOCS_LATEST_STABLE;
|
||||
const previousManifest = process.env.SUBMINER_DOCS_VERSION_MANIFEST;
|
||||
const previousVersionLinkOrigin = process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN;
|
||||
process.chdir(docsSiteDir);
|
||||
process.env.SUBMINER_DOCS_CHANNEL = 'stable-archive';
|
||||
process.env.SUBMINER_DOCS_BASE = '/v/0.10.0/';
|
||||
process.env.SUBMINER_DOCS_VERSION = 'v0.10.0';
|
||||
process.env.SUBMINER_DOCS_LATEST_STABLE = 'v0.14.0';
|
||||
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = 'local';
|
||||
process.env.SUBMINER_DOCS_VERSION_MANIFEST = JSON.stringify({
|
||||
latestStable: 'v0.14.0',
|
||||
channels: [
|
||||
{ label: 'Latest stable', path: '/' },
|
||||
{ label: 'main', path: '/main/' },
|
||||
],
|
||||
versions: [
|
||||
{ version: 'v0.14.0', path: '/v/0.14.0/' },
|
||||
{ version: 'v0.10.0', path: '/v/0.10.0/' },
|
||||
],
|
||||
});
|
||||
try {
|
||||
const { default: archiveConfig } = await import('./.vitepress/config?local-archive-links');
|
||||
|
||||
const nav = archiveConfig.themeConfig?.nav as Array<{
|
||||
text: string;
|
||||
items?: Array<{ text: string; link: string }>;
|
||||
}>;
|
||||
const versionNav = nav.find((item) => item.text === 'v0.10.0');
|
||||
|
||||
expect(versionNav?.items).toContainEqual({
|
||||
text: 'Latest stable (v0.14.0)',
|
||||
link: '../../',
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
});
|
||||
expect(versionNav?.items).toContainEqual({
|
||||
text: 'main',
|
||||
link: '../../main/',
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
});
|
||||
expect(versionNav?.items).toContainEqual({
|
||||
text: 'v0.14.0',
|
||||
link: '../0.14.0/',
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
});
|
||||
expect(versionNav?.items).toContainEqual({
|
||||
text: 'v0.10.0',
|
||||
link: './',
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
});
|
||||
} finally {
|
||||
process.chdir(previousCwd);
|
||||
process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
|
||||
process.env.SUBMINER_DOCS_BASE = previousBase;
|
||||
process.env.SUBMINER_DOCS_VERSION = previousVersion;
|
||||
process.env.SUBMINER_DOCS_LATEST_STABLE = previousLatest;
|
||||
process.env.SUBMINER_DOCS_VERSION_MANIFEST = previousManifest;
|
||||
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = previousVersionLinkOrigin;
|
||||
}
|
||||
});
|
||||
|
||||
test('dev docs version links use local targets for version route testing', async () => {
|
||||
const previousCwd = process.cwd();
|
||||
const previousChannel = process.env.SUBMINER_DOCS_CHANNEL;
|
||||
const previousBase = process.env.SUBMINER_DOCS_BASE;
|
||||
const previousVersion = process.env.SUBMINER_DOCS_VERSION;
|
||||
const previousLatest = process.env.SUBMINER_DOCS_LATEST_STABLE;
|
||||
const previousManifest = process.env.SUBMINER_DOCS_VERSION_MANIFEST;
|
||||
const previousVersionLinkOrigin = process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN;
|
||||
process.chdir(docsSiteDir);
|
||||
delete process.env.SUBMINER_DOCS_CHANNEL;
|
||||
delete process.env.SUBMINER_DOCS_BASE;
|
||||
delete process.env.SUBMINER_DOCS_VERSION;
|
||||
delete process.env.SUBMINER_DOCS_LATEST_STABLE;
|
||||
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = 'local';
|
||||
process.env.SUBMINER_DOCS_VERSION_MANIFEST = JSON.stringify({
|
||||
latestStable: 'v0.14.0',
|
||||
channels: [
|
||||
{ label: 'Latest stable', path: '/' },
|
||||
{ label: 'main', path: '/main/' },
|
||||
],
|
||||
versions: [
|
||||
{ version: 'v0.14.0', path: '/v/0.14.0/' },
|
||||
{ version: 'v0.12.0', path: '/v/0.12.0/' },
|
||||
{ version: 'v0.11.2', path: '/v/0.11.2/' },
|
||||
],
|
||||
});
|
||||
try {
|
||||
const { default: devConfig } = await import('./.vitepress/config?dev-version-links');
|
||||
|
||||
const nav = devConfig.themeConfig?.nav as Array<{
|
||||
text: string;
|
||||
items?: Array<{ text: string; link: string }>;
|
||||
}>;
|
||||
const versionNav = nav.find((item) => item.text === 'v0.14.0');
|
||||
|
||||
expect(versionNav?.items).toContainEqual({
|
||||
text: 'Latest stable (v0.14.0)',
|
||||
link: '/',
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
});
|
||||
expect(versionNav?.items).toContainEqual({
|
||||
text: 'main',
|
||||
link: '/main/',
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
});
|
||||
expect(versionNav?.items).toContainEqual({
|
||||
text: 'v0.12.0',
|
||||
link: '/v/0.12.0/',
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
});
|
||||
expect(versionNav?.items?.map((item) => item.text)).toEqual([
|
||||
'Latest stable (v0.14.0)',
|
||||
'main',
|
||||
'v0.14.0',
|
||||
'v0.12.0',
|
||||
'v0.11.2',
|
||||
]);
|
||||
} finally {
|
||||
process.chdir(previousCwd);
|
||||
process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
|
||||
process.env.SUBMINER_DOCS_BASE = previousBase;
|
||||
process.env.SUBMINER_DOCS_VERSION = previousVersion;
|
||||
process.env.SUBMINER_DOCS_LATEST_STABLE = previousLatest;
|
||||
process.env.SUBMINER_DOCS_VERSION_MANIFEST = previousManifest;
|
||||
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = previousVersionLinkOrigin;
|
||||
}
|
||||
});
|
||||
|
||||
test('dev server redirects unserved version routes to production docs', () => {
|
||||
let routeHandler:
|
||||
| ((req: { url?: string }, res: DevRedirectResponse, next: () => void) => void)
|
||||
| undefined;
|
||||
const fakeServer = {
|
||||
middlewares: {
|
||||
use(handler: typeof routeHandler) {
|
||||
routeHandler = handler;
|
||||
},
|
||||
},
|
||||
};
|
||||
const plugins = Array.isArray(docsConfig.vite?.plugins)
|
||||
? docsConfig.vite.plugins
|
||||
: [docsConfig.vite?.plugins].filter(Boolean);
|
||||
const redirectPlugin = plugins.find(
|
||||
(plugin): plugin is { name: string; configureServer: (server: never) => void } =>
|
||||
Boolean(plugin) &&
|
||||
typeof plugin === 'object' &&
|
||||
'name' in plugin &&
|
||||
plugin.name === 'subminer-docs-local-version-redirects' &&
|
||||
'configureServer' in plugin,
|
||||
);
|
||||
expect(redirectPlugin).toBeDefined();
|
||||
redirectPlugin?.configureServer(fakeServer as never);
|
||||
|
||||
const response = new DevRedirectResponse();
|
||||
let nextCalled = false;
|
||||
routeHandler?.({ url: '/v/0.14.0/?from=dev' }, response, () => {
|
||||
nextCalled = true;
|
||||
});
|
||||
|
||||
expect(nextCalled).toBe(false);
|
||||
expect(response.statusCode).toBe(302);
|
||||
expect(response.headers.location).toBe('https://docs.subminer.moe/v/0.14.0/?from=dev');
|
||||
|
||||
const rootResponse = new DevRedirectResponse();
|
||||
routeHandler?.({ url: '/configuration' }, rootResponse, () => {
|
||||
nextCalled = true;
|
||||
});
|
||||
expect(rootResponse.ended).toBe(false);
|
||||
expect(nextCalled).toBe(true);
|
||||
});
|
||||
|
||||
test('dev server serves local archive files for local version links', async () => {
|
||||
const previousVersionLinkOrigin = process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN;
|
||||
const previousArchiveDir = process.env.SUBMINER_DOCS_LOCAL_ARCHIVE_DIR;
|
||||
const archiveDir = mkdtempSync(join(tmpdir(), 'subminer-docs-archive-'));
|
||||
mkdirSync(join(archiveDir, 'v/0.14.0'), { recursive: true });
|
||||
writeFileSync(join(archiveDir, 'v/0.14.0/index.html'), '<h1>local archive</h1>');
|
||||
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = 'local';
|
||||
process.env.SUBMINER_DOCS_LOCAL_ARCHIVE_DIR = archiveDir;
|
||||
try {
|
||||
const { default: localDevConfig } = await import('./.vitepress/config?local-dev-redirects');
|
||||
let routeHandler:
|
||||
| ((req: { url?: string }, res: DevRedirectResponse, next: () => void) => void)
|
||||
| undefined;
|
||||
const fakeServer = {
|
||||
middlewares: {
|
||||
use(handler: typeof routeHandler) {
|
||||
routeHandler = handler;
|
||||
},
|
||||
},
|
||||
};
|
||||
const plugins = Array.isArray(localDevConfig.vite?.plugins)
|
||||
? localDevConfig.vite.plugins
|
||||
: [localDevConfig.vite?.plugins].filter(Boolean);
|
||||
const redirectPlugin = plugins.find(
|
||||
(plugin): plugin is { name: string; configureServer: (server: never) => void } =>
|
||||
Boolean(plugin) &&
|
||||
typeof plugin === 'object' &&
|
||||
'name' in plugin &&
|
||||
plugin.name === 'subminer-docs-local-version-redirects' &&
|
||||
'configureServer' in plugin,
|
||||
);
|
||||
redirectPlugin?.configureServer(fakeServer as never);
|
||||
|
||||
const response = new DevRedirectResponse();
|
||||
let nextCalled = false;
|
||||
routeHandler?.({ url: '/v/0.14.0/?from=dev' }, response, () => {
|
||||
nextCalled = true;
|
||||
});
|
||||
|
||||
expect(nextCalled).toBe(false);
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers['content-type']).toBe('text/html; charset=utf-8');
|
||||
expect(response.headers.location).toBeUndefined();
|
||||
expect(response.body).toBe('<h1>local archive</h1>');
|
||||
} finally {
|
||||
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = previousVersionLinkOrigin;
|
||||
process.env.SUBMINER_DOCS_LOCAL_ARCHIVE_DIR = previousArchiveDir;
|
||||
rmSync(archiveDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
class DevRedirectResponse {
|
||||
statusCode = 200;
|
||||
headers: Record<string, string> = {};
|
||||
ended = false;
|
||||
body = '';
|
||||
|
||||
setHeader(name: string, value: string) {
|
||||
this.headers[name.toLowerCase()] = value;
|
||||
}
|
||||
|
||||
end(chunk?: string | Uint8Array) {
|
||||
if (chunk) {
|
||||
this.body = typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk);
|
||||
}
|
||||
this.ended = true;
|
||||
}
|
||||
}
|
||||
|
||||
test('docs sitemap excludes duplicate README page from indexable URLs', async () => {
|
||||
const items = [{ url: '' }, { url: 'README' }, { url: 'usage' }];
|
||||
|
||||
|
||||
@@ -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/docs-versioned-assets.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/get-mpv-window-macos.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
|
||||
"generate:config-example": "bun run src/generate-config-example.ts",
|
||||
"verify:config-example": "bun run src/verify-config-example.ts",
|
||||
"start": "bun run build && electron . --start",
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
readlinkSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import {
|
||||
collectSharedAssetPaths,
|
||||
dedupeVersionedPublicAssets,
|
||||
pruneArchiveCacheGenerations,
|
||||
} from './docs-versioned-assets';
|
||||
import {
|
||||
buildVersionManifest,
|
||||
stableTagsWithDocs,
|
||||
versionArchiveCacheKey,
|
||||
versionArchiveCacheName,
|
||||
versionOutputPath,
|
||||
versionPath,
|
||||
} 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;
|
||||
}) {
|
||||
console.info(`[docs] building ${options.version ?? options.channel} -> ${options.base}`);
|
||||
run('bun', ['run', '--cwd', currentDocsSite, 'vitepress', 'build', options.snapshotDocsSite], {
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
SUBMINER_DOCS_BASE: options.base,
|
||||
SUBMINER_DOCS_OUT_DIR: options.outDir,
|
||||
SUBMINER_DOCS_SOURCE_DIR: options.snapshotDocsSite,
|
||||
SUBMINER_DOCS_CHANNEL: options.channel,
|
||||
SUBMINER_DOCS_VERSION: options.version ?? '',
|
||||
SUBMINER_DOCS_LATEST_STABLE: options.latestStable,
|
||||
SUBMINER_DOCS_VERSION_MANIFEST: options.manifestJson,
|
||||
VITE_EXTRA_EXTENSIONS: 'jsonc',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function updateHashWithPath(hash: ReturnType<typeof createHash>, path: string) {
|
||||
if (isSharedInternalsHashIgnoredPath(path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stat = lstatSync(path);
|
||||
const relativePath = path.replace(repoRoot, '');
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
hash.update(`symlink:${relativePath}`);
|
||||
hash.update(readlinkSync(path));
|
||||
return;
|
||||
}
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
hash.update(`dir:${relativePath}`);
|
||||
for (const entry of readdirSync(path).sort()) {
|
||||
updateHashWithPath(hash, join(path, entry));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
hash.update(`file:${relativePath}`);
|
||||
hash.update(readFileSync(path));
|
||||
}
|
||||
|
||||
function isGeneratedVitePressPath(path: string): boolean {
|
||||
return /[\\/]\\.vitepress[\\/](cache|dist)([\\/]|$)/.test(path);
|
||||
}
|
||||
|
||||
function isSharedInternalsHashIgnoredPath(path: string): boolean {
|
||||
return isGeneratedVitePressPath(path) || /\.test\.[cm]?[jt]s$/.test(path);
|
||||
}
|
||||
|
||||
function computeSharedInternalsHash(): string {
|
||||
const hash = createHash('sha256');
|
||||
hash.update(
|
||||
`version-link-origin:${process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN === 'local' ? 'local' : 'production'}`,
|
||||
);
|
||||
const paths = [
|
||||
join(currentDocsSite, '.vitepress'),
|
||||
join(currentDocsSite, 'public/assets/fonts'),
|
||||
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;
|
||||
}
|
||||
|
||||
console.info(`[docs] cache hit ${version}`);
|
||||
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();
|
||||
const archiveCacheKey = versionArchiveCacheKey({ sharedInternalsHash, manifestJson });
|
||||
const sharedAssetPaths = collectSharedAssetPaths(join(currentDocsSite, 'public/assets'));
|
||||
console.info(`[docs] archive cache key ${archiveCacheKey.slice(0, 12)}`);
|
||||
|
||||
rmSync(buildRoot, { recursive: true, force: true });
|
||||
rmSync(aggregateOutDir, { recursive: true, force: true });
|
||||
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, archiveCacheKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
console.info(`[docs] rebuilding archive ${version}`);
|
||||
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,
|
||||
});
|
||||
dedupeVersionedPublicAssets({
|
||||
outDir: join(aggregateOutDir, versionOutputPath(version)),
|
||||
base: versionPath(version),
|
||||
sharedAssetPaths,
|
||||
});
|
||||
saveArchiveCache(version, archiveCacheKey);
|
||||
}
|
||||
|
||||
const mainSnapshot = prepareSnapshot('main');
|
||||
buildDocs({
|
||||
snapshotDocsSite: mainSnapshot,
|
||||
base: '/main/',
|
||||
outDir: join(aggregateOutDir, 'main'),
|
||||
channel: 'main',
|
||||
version: 'main',
|
||||
latestStable,
|
||||
manifestJson,
|
||||
});
|
||||
dedupeVersionedPublicAssets({
|
||||
outDir: join(aggregateOutDir, 'main'),
|
||||
base: '/main/',
|
||||
sharedAssetPaths,
|
||||
});
|
||||
|
||||
writeFileSync(join(aggregateOutDir, 'versions.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
assertCloudflarePagesLimits(aggregateOutDir);
|
||||
const prunedArchives = pruneArchiveCacheGenerations({
|
||||
cacheRoot: archiveCacheRoot,
|
||||
activeCacheKey: archiveCacheKey,
|
||||
});
|
||||
if (prunedArchives.length > 0) {
|
||||
console.info(`[docs] pruned ${prunedArchives.length} stale archive cache directories`);
|
||||
}
|
||||
rmSync(buildRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
dedupeVersionedPublicAssets,
|
||||
pruneArchiveCacheGenerations,
|
||||
rewriteSharedAssetReferences,
|
||||
} from './docs-versioned-assets';
|
||||
|
||||
function tempDir() {
|
||||
return mkdtempSync(join(tmpdir(), 'subminer-docs-versioned-assets-'));
|
||||
}
|
||||
|
||||
describe('docs versioned asset dedupe', () => {
|
||||
test('rewrites version-scoped public asset references to shared root assets', () => {
|
||||
const html =
|
||||
'<link href="/v/0.14.0/assets/style.hash.css"><source src="/v/0.14.0/assets/minecard.webm">';
|
||||
const expected =
|
||||
'<link href="/v/0.14.0/assets/style.hash.css"><source src="/assets/minecard.webm">';
|
||||
|
||||
expect(rewriteSharedAssetReferences(html, '/v/0.14.0/', new Set(['minecard.webm']))).toBe(
|
||||
expected,
|
||||
);
|
||||
expect(rewriteSharedAssetReferences(html, '/v/0.14.0', new Set(['minecard.webm']))).toBe(
|
||||
expected,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not rewrite longer asset paths with a shared asset prefix', () => {
|
||||
expect(
|
||||
rewriteSharedAssetReferences(
|
||||
[
|
||||
'<script src="/v/0.14.0/assets/foo.js"></script>',
|
||||
'<script src="/v/0.14.0/assets/foo.js?v=1"></script>',
|
||||
'<script src="/v/0.14.0/assets/foo.js.map"></script>',
|
||||
].join(''),
|
||||
'/v/0.14.0/',
|
||||
new Set(['foo.js']),
|
||||
),
|
||||
).toBe(
|
||||
[
|
||||
'<script src="/assets/foo.js"></script>',
|
||||
'<script src="/assets/foo.js?v=1"></script>',
|
||||
'<script src="/v/0.14.0/assets/foo.js.map"></script>',
|
||||
].join(''),
|
||||
);
|
||||
});
|
||||
|
||||
test('removes duplicated version public assets while preserving generated VitePress assets', async () => {
|
||||
const dir = tempDir();
|
||||
try {
|
||||
mkdirSync(join(dir, 'assets/chunks'), { recursive: true });
|
||||
writeFileSync(join(dir, 'assets/style.hash.css'), 'body{}');
|
||||
writeFileSync(join(dir, 'assets/chunks/theme.hash.js'), 'export {};');
|
||||
writeFileSync(join(dir, 'assets/minecard.webm'), 'large video');
|
||||
writeFileSync(
|
||||
join(dir, 'index.html'),
|
||||
'<link href="/v/0.14.0/assets/style.hash.css"><source src="/v/0.14.0/assets/minecard.webm">',
|
||||
);
|
||||
|
||||
const result = dedupeVersionedPublicAssets({
|
||||
outDir: dir,
|
||||
base: '/v/0.14.0',
|
||||
sharedAssetPaths: new Set(['minecard.webm']),
|
||||
});
|
||||
|
||||
expect(result.rewrittenFiles).toEqual([join(dir, 'index.html')]);
|
||||
expect(existsSync(join(dir, 'assets/style.hash.css'))).toBe(true);
|
||||
expect(existsSync(join(dir, 'assets/chunks/theme.hash.js'))).toBe(true);
|
||||
expect(existsSync(join(dir, 'assets/minecard.webm'))).toBe(false);
|
||||
expect(readFileSync(join(dir, 'index.html'), 'utf8')).toBe(
|
||||
'<link href="/v/0.14.0/assets/style.hash.css"><source src="/assets/minecard.webm">',
|
||||
);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps root public assets because they are the shared copy', async () => {
|
||||
const dir = tempDir();
|
||||
try {
|
||||
mkdirSync(join(dir, 'assets'), { recursive: true });
|
||||
writeFileSync(join(dir, 'assets/minecard.webm'), 'large video');
|
||||
|
||||
const result = dedupeVersionedPublicAssets({
|
||||
outDir: dir,
|
||||
base: '/',
|
||||
sharedAssetPaths: new Set(['minecard.webm']),
|
||||
});
|
||||
|
||||
expect(result.removedAssetsDir).toBe(false);
|
||||
expect(existsSync(join(dir, 'assets'))).toBe(true);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('docs archive cache pruning', () => {
|
||||
test('removes stale cache generations while keeping the active generation', async () => {
|
||||
const dir = tempDir();
|
||||
try {
|
||||
mkdirSync(join(dir, 'active123456-v0.14.0'), { recursive: true });
|
||||
mkdirSync(join(dir, 'stale654321-v0.14.0'), { recursive: true });
|
||||
mkdirSync(join(dir, 'stale654321-v0.13.0'), { recursive: true });
|
||||
|
||||
const removed = pruneArchiveCacheGenerations({
|
||||
cacheRoot: dir,
|
||||
activeCacheKey: 'active123456abcdef',
|
||||
});
|
||||
|
||||
expect(removed.sort()).toEqual([
|
||||
join(dir, 'stale654321-v0.13.0'),
|
||||
join(dir, 'stale654321-v0.14.0'),
|
||||
]);
|
||||
expect(existsSync(join(dir, 'active123456-v0.14.0'))).toBe(true);
|
||||
expect(existsSync(join(dir, 'stale654321-v0.14.0'))).toBe(false);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { existsSync, lstatSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
|
||||
const textOutputPattern = /\.(?:css|html|js|json|map|mjs|txt|xml)$/;
|
||||
|
||||
function normalizeBase(base: string): string {
|
||||
return base === '/' ? '/' : `${base.replace(/\/+$/, '')}/`;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export function collectSharedAssetPaths(publicAssetsDir: string): Set<string> {
|
||||
if (!existsSync(publicAssetsDir)) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
return new Set(
|
||||
walkFiles(publicAssetsDir).map((file) => relative(publicAssetsDir, file).split('\\').join('/')),
|
||||
);
|
||||
}
|
||||
|
||||
export function rewriteSharedAssetReferences(
|
||||
content: string,
|
||||
base: string,
|
||||
sharedAssetPaths: Set<string>,
|
||||
): string {
|
||||
const normalizedBase = normalizeBase(base);
|
||||
if (normalizedBase === '/') {
|
||||
return content;
|
||||
}
|
||||
|
||||
let rewritten = content;
|
||||
const escapedBase = escapeRegExp(normalizedBase);
|
||||
for (const assetPath of sharedAssetPaths) {
|
||||
const escapedAssetPath = escapeRegExp(assetPath);
|
||||
rewritten = rewritten.replace(
|
||||
new RegExp(`${escapedBase}assets/${escapedAssetPath}(?=$|[?#"'()\\s<])`, 'g'),
|
||||
`/assets/${assetPath}`,
|
||||
);
|
||||
}
|
||||
return rewritten;
|
||||
}
|
||||
|
||||
function walkFiles(root: string): string[] {
|
||||
const files: string[] = [];
|
||||
|
||||
function walk(dir: string) {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const path = join(dir, entry);
|
||||
const stat = lstatSync(path);
|
||||
if (stat.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
walk(path);
|
||||
continue;
|
||||
}
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
walk(root);
|
||||
return files;
|
||||
}
|
||||
|
||||
export function dedupeVersionedPublicAssets(options: {
|
||||
outDir: string;
|
||||
base: string;
|
||||
sharedAssetPaths: Set<string>;
|
||||
}): {
|
||||
removedAssetsDir: boolean;
|
||||
rewrittenFiles: string[];
|
||||
} {
|
||||
const normalizedBase = normalizeBase(options.base);
|
||||
const rewrittenFiles: string[] = [];
|
||||
|
||||
for (const file of walkFiles(options.outDir)) {
|
||||
if (!textOutputPattern.test(file)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const before = readFileSync(file, 'utf8');
|
||||
const after = rewriteSharedAssetReferences(before, normalizedBase, options.sharedAssetPaths);
|
||||
if (after === before) {
|
||||
continue;
|
||||
}
|
||||
|
||||
writeFileSync(file, after);
|
||||
rewrittenFiles.push(file);
|
||||
}
|
||||
|
||||
const assetsDir = join(options.outDir, 'assets');
|
||||
if (normalizedBase !== '/') {
|
||||
for (const assetPath of options.sharedAssetPaths) {
|
||||
rmSync(join(assetsDir, assetPath), { force: true });
|
||||
}
|
||||
removeEmptyDirectories(assetsDir);
|
||||
}
|
||||
|
||||
const removedAssetsDir = !existsSync(assetsDir);
|
||||
return { removedAssetsDir, rewrittenFiles };
|
||||
}
|
||||
|
||||
function removeEmptyDirectories(root: string) {
|
||||
if (!existsSync(root) || !lstatSync(root).isDirectory()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of readdirSync(root)) {
|
||||
const path = join(root, entry);
|
||||
if (lstatSync(path).isDirectory()) {
|
||||
removeEmptyDirectories(path);
|
||||
}
|
||||
}
|
||||
|
||||
if (readdirSync(root).length === 0) {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function pruneArchiveCacheGenerations(options: {
|
||||
cacheRoot: string;
|
||||
activeCacheKey: string;
|
||||
}): string[] {
|
||||
if (!existsSync(options.cacheRoot)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const activePrefix = options.activeCacheKey.slice(0, 12);
|
||||
const removed: string[] = [];
|
||||
|
||||
for (const entry of readdirSync(options.cacheRoot)) {
|
||||
const path = join(options.cacheRoot, entry);
|
||||
if (!lstatSync(path).isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
if (entry.startsWith(`${activePrefix}-`)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
rmSync(path, { recursive: true, force: true });
|
||||
removed.push(path);
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
buildVersionManifest,
|
||||
compareStableVersionsDesc,
|
||||
versionArchiveCacheKey,
|
||||
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 cache keys change when manifest contents change', () => {
|
||||
const firstKey = versionArchiveCacheKey({
|
||||
sharedInternalsHash: 'abcdef1234567890',
|
||||
manifestJson: '{"latestStable":"v0.14.0"}',
|
||||
});
|
||||
const secondKey = versionArchiveCacheKey({
|
||||
sharedInternalsHash: 'abcdef1234567890',
|
||||
manifestJson: '{"latestStable":"v0.15.0"}',
|
||||
});
|
||||
|
||||
expect(firstKey).not.toBe(secondKey);
|
||||
});
|
||||
|
||||
test('archive output paths stay relative for filesystem joins', () => {
|
||||
expect(versionOutputPath('v0.14.0')).toBe('v/0.14.0');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
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 versionArchiveCacheKey(options: {
|
||||
sharedInternalsHash: string;
|
||||
manifestJson: string;
|
||||
}): string {
|
||||
const hash = createHash('sha256');
|
||||
hash.update('shared-internals:');
|
||||
hash.update(options.sharedInternalsHash);
|
||||
hash.update('\nmanifest:');
|
||||
hash.update(options.manifestJson);
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
export function stableTagsWithDocs(
|
||||
tags: string[],
|
||||
hasDocsSite: (tag: string) => boolean,
|
||||
): string[] {
|
||||
return tags.filter(isStableReleaseTag).filter(hasDocsSite).sort(compareStableVersionsDesc);
|
||||
}
|
||||
|
||||
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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { resolve } from 'node:path';
|
||||
import { buildVersionManifest, stableTagsWithDocs } from './docs-versioning';
|
||||
|
||||
const repoRoot = resolve(__dirname, '..');
|
||||
|
||||
function capture(command: string, args: string[]): string {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(result.stderr || `Command failed: ${command} ${args.join(' ')}`);
|
||||
}
|
||||
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
function tagHasDocsSite(tag: string): boolean {
|
||||
const result = spawnSync('git', ['cat-file', '-e', `${tag}:docs-site/package.json`], {
|
||||
cwd: repoRoot,
|
||||
});
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
const stableVersions = stableTagsWithDocs(
|
||||
capture('git', ['tag', '--list', 'v*'])
|
||||
.split('\n')
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean),
|
||||
tagHasDocsSite,
|
||||
);
|
||||
|
||||
const latestStable = stableVersions[0];
|
||||
|
||||
if (!latestStable) {
|
||||
throw new Error('No stable release tags with docs-site/package.json found.');
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify(buildVersionManifest({ latestStable, stableVersions })));
|
||||
@@ -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