docs: add mpv.launchMode to config docs, add changelog:docs generator, format

- Document the new mpv.launchMode option in the configuration docs page
- Add changelog:docs command to auto-generate docs-site/changelog.md from root CHANGELOG.md
- Add breaking changes support to the changelog fragment generator
- Fix docs-sync test to only compare current minor release headings
- Apply prettier formatting to source files
This commit is contained in:
2026-04-07 01:06:43 -07:00
parent bc7dde3b02
commit de4f3efa30
19 changed files with 420 additions and 247 deletions

View File

@@ -12,10 +12,21 @@ area: overlay
- Added auto-pause toggle when opening the popup. - Added auto-pause toggle when opening the popup.
``` ```
For breaking changes, add `breaking: true`:
```md
type: changed
area: config
breaking: true
- Renamed `foo.bar` to `foo.baz`.
```
Rules: Rules:
- `type` required: `added`, `changed`, `fixed`, `docs`, or `internal` - `type` required: `added`, `changed`, `fixed`, `docs`, or `internal`
- `area` required: short product area like `overlay`, `launcher`, `release` - `area` required: short product area like `overlay`, `launcher`, `release`
- `breaking` optional: set to `true` to flag as a breaking change
- each non-empty body line becomes a bullet - each non-empty body line becomes a bullet
- `README.md` is ignored by the generator - `README.md` is ignored by the generator
- if a PR should not produce release notes, apply the `skip-changelog` label instead of adding a fragment - if a PR should not produce release notes, apply the `skip-changelog` label instead of adding a fragment

View File

@@ -127,6 +127,7 @@ The configuration file includes several main sections:
- [**Discord Rich Presence**](#discord-rich-presence) - Optional Discord activity card updates - [**Discord Rich Presence**](#discord-rich-presence) - Optional Discord activity card updates
- [**Immersion Tracking**](#immersion-tracking) - Track subtitle sessions and mining activity in SQLite - [**Immersion Tracking**](#immersion-tracking) - Track subtitle sessions and mining activity in SQLite
- [**Stats Dashboard**](#stats-dashboard) - Local dashboard and overlay for immersion progress - [**Stats Dashboard**](#stats-dashboard) - Local dashboard and overlay for immersion progress
- [**MPV Launcher**](#mpv-launcher) - mpv executable path and window launch mode
- [**YouTube Playback Settings**](#youtube-playback-settings) - Defaults for YouTube subtitle loading - [**YouTube Playback Settings**](#youtube-playback-settings) - Defaults for YouTube subtitle loading
## Core Settings ## Core Settings
@@ -238,7 +239,7 @@ This stream includes subtitle text plus token metadata (N+1, known-word, frequen
``` ```
| Option | Values | Description | | Option | Values | Description |
| --------- | ------------------ | -------------------------------------------------------- | | --------- | --------------- | -------------------------------------------------------------- |
| `enabled` | `true`, `false` | Toggle annotated websocket stream (independent of `websocket`) | | `enabled` | `true`, `false` | Toggle annotated websocket stream (independent of `websocket`) |
| `port` | number | Annotation websocket port (default: 6678) | | `port` | number | Annotation websocket port (default: 6678) |
@@ -258,8 +259,8 @@ See `config.example.jsonc` for detailed configuration options.
``` ```
| Option | Values | Description | | Option | Values | Description |
| ---------------- | --------------- | ------------------------------------------------------------------------------------------------ | | ----------------- | --------------- | ---------------------------------------------------------------------- |
| `launchAtStartup`| `true`, `false` | Start texthooker automatically with SubMiner startup (default: `true`) | | `launchAtStartup` | `true`, `false` | Start texthooker automatically with SubMiner startup (default: `true`) |
| `openBrowser` | `true`, `false` | Open browser tab when texthooker starts (default: `false`) | | `openBrowser` | `true`, `false` | Open browser tab when texthooker starts (default: `false`) |
## Subtitle Display ## Subtitle Display
@@ -366,7 +367,7 @@ Configure the parsed-subtitle sidebar modal.
``` ```
| Option | Values | Description | | Option | Values | Description |
| --------------------------- | ---------------- | -------------------------------------------------------------------------------- | | --------------------------- | --------- | ------------------------------------------------------------------------------------------------------- |
| `enabled` | boolean | Enable subtitle sidebar support (`true` by default) | | `enabled` | boolean | Enable subtitle sidebar support (`true` by default) |
| `autoOpen` | boolean | Open sidebar automatically on overlay startup (`false` by default) | | `autoOpen` | boolean | Open sidebar automatically on overlay startup (`false` by default) |
| `layout` | string | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space to mimic browser-like layout | | `layout` | string | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space to mimic browser-like layout |
@@ -467,7 +468,7 @@ See `config.example.jsonc` for detailed configuration options and more examples.
**Default keybindings:** **Default keybindings:**
| Key | Command | Description | | Key | Command | Description |
| -------------------- | ---------------------------- | ------------------------------------- | | -------------------- | ----------------------------- | --------------------------------------- |
| `Space` | `["cycle", "pause"]` | Toggle pause | | `Space` | `["cycle", "pause"]` | Toggle pause |
| `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track | | `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track |
| `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track | | `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track |
@@ -604,7 +605,7 @@ Important behavior:
"leftStickPress": 9, "leftStickPress": 9,
"rightStickPress": 10, "rightStickPress": 10,
"leftTrigger": 6, "leftTrigger": 6,
"rightTrigger": 7 "rightTrigger": 7,
}, },
"bindings": { "bindings": {
"toggleLookup": { "kind": "button", "buttonIndex": 0 }, "toggleLookup": { "kind": "button", "buttonIndex": 0 },
@@ -619,9 +620,9 @@ Important behavior:
"leftStickHorizontal": { "kind": "axis", "axisIndex": 0, "dpadFallback": "horizontal" }, "leftStickHorizontal": { "kind": "axis", "axisIndex": 0, "dpadFallback": "horizontal" },
"leftStickVertical": { "kind": "axis", "axisIndex": 1, "dpadFallback": "vertical" }, "leftStickVertical": { "kind": "axis", "axisIndex": 1, "dpadFallback": "vertical" },
"rightStickHorizontal": { "kind": "axis", "axisIndex": 3, "dpadFallback": "none" }, "rightStickHorizontal": { "kind": "axis", "axisIndex": 3, "dpadFallback": "none" },
"rightStickVertical": { "kind": "axis", "axisIndex": 4, "dpadFallback": "none" } "rightStickVertical": { "kind": "axis", "axisIndex": 4, "dpadFallback": "none" },
} },
} },
} }
``` ```
@@ -649,9 +650,9 @@ If you bind a discrete action to an axis manually, include `direction`:
{ {
"controller": { "controller": {
"bindings": { "bindings": {
"toggleLookup": { "kind": "axis", "axisIndex": 5, "direction": "positive" } "toggleLookup": { "kind": "axis", "axisIndex": 5, "direction": "positive" },
} },
} },
} }
``` ```
@@ -759,7 +760,7 @@ Anki reads this provider directly. Legacy subtitle fallback keeps the same provi
``` ```
| Option | Values | Description | | Option | Values | Description |
| ------------------ | --------------------- | ---------------------------------------------------- | | ------------------ | -------------------- | ------------------------------------------------------------- |
| `enabled` | `true`, `false` | Enable shared AI provider features | | `enabled` | `true`, `false` | Enable shared AI provider features |
| `apiKey` | string | Static API key for the shared provider | | `apiKey` | string | Static API key for the shared provider |
| `apiKeyCommand` | string | Shell command used to resolve the API key | | `apiKeyCommand` | string | Shell command used to resolve the API key |
@@ -845,7 +846,7 @@ This example is intentionally compact. The option table below documents availabl
**Requirements:** [AnkiConnect](https://github.com/FooSoft/anki-connect) plugin must be installed and running in Anki. ffmpeg must be installed for media generation. **Requirements:** [AnkiConnect](https://github.com/FooSoft/anki-connect) plugin must be installed and running in Anki. ffmpeg must be installed for media generation.
| Option | Values | Description | | Option | Values | Description |
| --------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) | | `enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) |
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) | | `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) | | `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) |
@@ -1056,7 +1057,7 @@ AniList integration is opt-in and disabled by default. Enable it to allow SubMin
``` ```
| Option | Values | Description | | Option | Values | Description |
| ------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------ | | -------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------- |
| `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) | | `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
| `accessToken` | string | Optional explicit AniList access token override (default: empty string) | | `accessToken` | string | Optional explicit AniList access token override (default: empty string) |
| `characterDictionary.enabled` | `true`, `false` | Enable automatic import/update of the merged SubMiner character dictionary for recent AniList media | | `characterDictionary.enabled` | `true`, `false` | Enable automatic import/update of the merged SubMiner character dictionary for recent AniList media |
@@ -1123,7 +1124,7 @@ For GameSentenceMiner on Linux, the default overlay profile path is typically `~
``` ```
| Option | Values | Description | | Option | Values | Description |
| --------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------- | | --------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `externalProfilePath` | string path | Optional absolute path, or a path beginning with `~` (expanded to your home directory), to another app's Yomitan Electron profile. SubMiner loads that profile read-only and reuses its dictionaries/settings. | | `externalProfilePath` | string path | Optional absolute path, or a path beginning with `~` (expanded to your home directory), to another app's Yomitan Electron profile. SubMiner loads that profile read-only and reuses its dictionaries/settings. |
External-profile mode behavior: External-profile mode behavior:
@@ -1209,7 +1210,7 @@ Discord Rich Presence is enabled by default. SubMiner publishes a polished activ
``` ```
| Option | Values | Description | | Option | Values | Description |
| ------------------ | ------------------------------------------------- | ---------------------------------------------------------- | | ------------------ | ------------------------------------------------ | ---------------------------------------------------------- |
| `enabled` | `true`, `false` | Enable Discord Rich Presence updates (default: `true`) | | `enabled` | `true`, `false` | Enable Discord Rich Presence updates (default: `true`) |
| `presenceStyle` | `"default"`, `"meme"`, `"japanese"`, `"minimal"` | Card text preset (default: `"default"`) | | `presenceStyle` | `"default"`, `"meme"`, `"japanese"`, `"minimal"` | Card text preset (default: `"default"`) |
| `updateIntervalMs` | number | Minimum interval between activity updates in milliseconds | | `updateIntervalMs` | number | Minimum interval between activity updates in milliseconds |
@@ -1226,11 +1227,11 @@ Setup steps:
While playing media, the **Details** line always shows the current media title and **State** shows `Playing mm:ss / mm:ss` or `Paused mm:ss / mm:ss`. The preset controls what appears when idle and the tooltip text on images. While playing media, the **Details** line always shows the current media title and **State** shows `Playing mm:ss / mm:ss` or `Paused mm:ss / mm:ss`. The preset controls what appears when idle and the tooltip text on images.
| Preset | Idle details | Small image text | Vibe | | Preset | Idle details | Small image text | Vibe |
| ------------ | ----------------------------------- | ------------------ | --------------------------------------- | | ------------- | ---------------------------------- | ------------------ | --------------------------------------- |
| **`default`**| `Sentence Mining` | `日本語学習中` | Clean, bilingual flair | | **`default`** | `Sentence Mining` | `日本語学習中` | Clean, bilingual flair |
| `meme` | `Mining and crafting (Anki cards)` | `Sentence Mining` | Minecraft-inspired joke | | `meme` | `Mining and crafting (Anki cards)` | `Sentence Mining` | Minecraft-inspired joke |
| `japanese` | `文の採掘中` | `イマージョン学習` | Fully Japanese | | `japanese` | `文の採掘中` | `イマージョン学習` | Fully Japanese |
| `minimal` | `SubMiner` | *(none)* | Bare essentials, no small image overlay | | `minimal` | `SubMiner` | _(none)_ | Bare essentials, no small image overlay |
All presets use the `subminer-logo` large image with `SubMiner` tooltip. No activity button is shown by default. All presets use the `subminer-logo` large image with `SubMiner` tooltip. No activity button is shown by default.
@@ -1274,7 +1275,7 @@ Enable or disable local immersion analytics stored in SQLite for mined subtitles
``` ```
| Option | Values | Description | | Option | Values | Description |
| ------------------------------ | ----------------------------- | ----------------------------------------------------------------------------------------------------------- | | ------------------------------ | ----------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `enabled` | `true`, `false` | Enable immersion tracking. Defaults to `true`. | | `enabled` | `true`, `false` | Enable immersion tracking. Defaults to `true`. |
| `dbPath` | string | Optional SQLite database path. Leave empty to use default app-data path at `<config dir>/immersion.sqlite`. | | `dbPath` | string | Optional SQLite database path. Leave empty to use default app-data path at `<config dir>/immersion.sqlite`. |
| `batchSize` | integer (`1`-`10000`) | Buffered writes per transaction. Default `25`. | | `batchSize` | integer (`1`-`10000`) | Buffered writes per transaction. Default `25`. |
@@ -1327,7 +1328,7 @@ Configure the local stats UI served from SubMiner and the in-app stats overlay t
``` ```
| Option | Values | Description | | Option | Values | Description |
| ----------------- | ----------------- | --------------------------------------------------------------------------- | | ----------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------- |
| `toggleKey` | Electron key code | Overlay-local key code used to toggle the stats overlay. Default `Backquote`. | | `toggleKey` | Electron key code | Overlay-local key code used to toggle the stats overlay. Default `Backquote`. |
| `serverPort` | integer | Localhost port for the browser stats UI. Default `6969`. | | `serverPort` | integer | Localhost port for the browser stats UI. Default `6969`. |
| `autoStartServer` | `true`, `false` | Start the local stats HTTP server automatically once immersion tracking is active. Default `true`. | | `autoStartServer` | `true`, `false` | Start the local stats HTTP server automatically once immersion tracking is active. Default `true`. |
@@ -1340,6 +1341,30 @@ Usage notes:
- The dashboard reads from the same immersion-tracking database, so keep `immersionTracking.enabled` on if you want data to appear. - The dashboard reads from the same immersion-tracking database, so keep `immersionTracking.enabled` on if you want data to appear.
- The UI includes Overview, Library, Trends, Vocabulary, and Sessions tabs. - The UI includes Overview, Library, Trends, Vocabulary, and Sessions tabs.
### MPV Launcher
Configure the mpv executable and window state for SubMiner-managed mpv launches (launcher playback, Windows `--launch-mpv`, and Jellyfin idle mpv startup):
```json
{
"mpv": {
"executablePath": "",
"launchMode": "normal"
}
}
```
| Option | Values | Description |
| ---------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) |
| `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) |
Launch mode behavior:
- **`normal`** — mpv opens at its default window size with no extra flags.
- **`maximized`** — mpv starts maximized via `--window-maximized=yes`, keeping taskbar access.
- **`fullscreen`** — mpv starts in true fullscreen via `--fullscreen`.
### YouTube Playback Settings ### YouTube Playback Settings
Set defaults used by managed subtitle auto-selection and the `subminer` launcher YouTube flow: Set defaults used by managed subtitle auto-selection and the `subminer` launcher YouTube flow:
@@ -1353,7 +1378,7 @@ Set defaults used by managed subtitle auto-selection and the `subminer` launcher
``` ```
| Option | Values | Description | | Option | Values | Description |
| --------------------- | -------------------- | ---------------------------------------------------------------------------------------------- | | --------------------- | -------- | ------------------------------------------------------------------------------------------------ |
| `primarySubLanguages` | string[] | Primary subtitle language priority for managed subtitle auto-selection (default `["ja", "jpn"]`) | | `primarySubLanguages` | string[] | Primary subtitle language priority for managed subtitle auto-selection (default `["ja", "jpn"]`) |
Current launcher behavior: Current launcher behavior:

View File

@@ -8,7 +8,10 @@ const installationContents = readFileSync(new URL('./installation.md', import.me
const mpvPluginContents = readFileSync(new URL('./mpv-plugin.md', import.meta.url), 'utf8'); const mpvPluginContents = readFileSync(new URL('./mpv-plugin.md', import.meta.url), 'utf8');
const developmentContents = readFileSync(new URL('./development.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 changelogContents = readFileSync(new URL('./changelog.md', import.meta.url), 'utf8');
const ankiIntegrationContents = readFileSync(new URL('./anki-integration.md', import.meta.url), 'utf8'); const ankiIntegrationContents = readFileSync(
new URL('./anki-integration.md', import.meta.url),
'utf8',
);
const configurationContents = readFileSync(new URL('./configuration.md', import.meta.url), 'utf8'); const configurationContents = readFileSync(new URL('./configuration.md', import.meta.url), 'utf8');
function extractReleaseHeadings(content: string, count: number): string[] { function extractReleaseHeadings(content: string, count: number): string[] {
@@ -17,6 +20,13 @@ function extractReleaseHeadings(content: string, count: number): string[] {
.slice(0, count); .slice(0, count);
} }
function extractCurrentMinorHeadings(content: string): string[] {
const allHeadings = Array.from(content.matchAll(/^## v(\d+\.\d+)\.\d+[^\n]*$/gm));
if (allHeadings.length === 0) return [];
const currentMinor = allHeadings[0]![1];
return allHeadings.filter(([, minor]) => minor === currentMinor).map(([heading]) => heading);
}
test('docs reflect current launcher and release surfaces', () => { test('docs reflect current launcher and release surfaces', () => {
expect(usageContents).not.toContain('--mode preprocess'); expect(usageContents).not.toContain('--mode preprocess');
expect(usageContents).not.toContain('"automatic" (default)'); expect(usageContents).not.toContain('"automatic" (default)');
@@ -44,9 +54,11 @@ test('docs reflect current launcher and release surfaces', () => {
expect(configurationContents).toContain('youtube.primarySubLanguages'); expect(configurationContents).toContain('youtube.primarySubLanguages');
expect(configurationContents).toContain('### Shared AI Provider'); expect(configurationContents).toContain('### Shared AI Provider');
expect(changelogContents).toContain('## v0.5.1 (2026-03-09)'); expect(changelogContents).toContain('v0.5.1 (2026-03-09)');
}); });
test('docs changelog keeps the newest release headings aligned with the root changelog', () => { test('docs changelog keeps the current minor release headings aligned with the root changelog', () => {
expect(extractReleaseHeadings(changelogContents, 3)).toEqual(extractReleaseHeadings(rootChangelogContents, 3)); const docsHeadings = extractCurrentMinorHeadings(changelogContents);
expect(docsHeadings.length).toBeGreaterThan(0);
expect(docsHeadings).toEqual(extractReleaseHeadings(rootChangelogContents, docsHeadings.length));
}); });

View File

@@ -125,10 +125,7 @@ function titleOverlapScore(expectedTitle: string, candidateTitle: string): numbe
if (!expected || !candidate) return 0; if (!expected || !candidate) return 0;
if (candidate.includes(expected)) return 120; if (candidate.includes(expected)) return 120;
if ( if (candidate.split(' ').length >= 2 && ` ${expected} `.includes(` ${candidate} `)) {
candidate.split(' ').length >= 2 &&
` ${expected} `.includes(` ${candidate} `)
) {
return 90; return 90;
} }

View File

@@ -715,18 +715,18 @@ function runFindAppBinaryWindowsInstallDirCase(): void {
process.env.SUBMINER_BINARY_PATH = installDir; process.env.SUBMINER_BINARY_PATH = installDir;
withPlatform('win32', () => { withPlatform('win32', () => {
withExistsAndStatSyncStubs( withExistsAndStatSyncStubs({ existingPaths: [appExe], directoryPaths: [installDir] }, () => {
{ existingPaths: [appExe], directoryPaths: [installDir] },
() => {
withAccessSyncStub( withAccessSyncStub(
(filePath) => filePath === appExe, (filePath) => filePath === appExe,
() => { () => {
const result = findAppBinary(path.win32.join(baseDir, 'launcher', 'SubMiner.exe'), path.win32); const result = findAppBinary(
path.win32.join(baseDir, 'launcher', 'SubMiner.exe'),
path.win32,
);
assert.equal(result, appExe); assert.equal(result, appExe);
}, },
); );
}, });
);
}); });
} finally { } finally {
os.homedir = originalHomedir; os.homedir = originalHomedir;

View File

@@ -264,10 +264,7 @@ function getLinuxDesktopEnv(env: NodeJS.ProcessEnv): LinuxDesktopEnv {
}; };
} }
function shouldForceX11MpvBackend( function shouldForceX11MpvBackend(args: Pick<Args, 'backend'>, env: NodeJS.ProcessEnv): boolean {
args: Pick<Args, 'backend'>,
env: NodeJS.ProcessEnv,
): boolean {
if (process.platform !== 'linux' || !env.DISPLAY?.trim()) { if (process.platform !== 'linux' || !env.DISPLAY?.trim()) {
return false; return false;
} }

View File

@@ -20,8 +20,9 @@
"dev:stats": "cd stats && bun run dev", "dev:stats": "cd stats && bun run dev",
"build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:launcher && bun run build:assets", "build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:launcher && bun run build:assets",
"build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap", "build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap",
"changelog:build": "bun run scripts/build-changelog.ts build", "changelog:build": "bun run scripts/build-changelog.ts build && bun run changelog:docs",
"changelog:check": "bun run scripts/build-changelog.ts check", "changelog:check": "bun run scripts/build-changelog.ts check",
"changelog:docs": "bun run scripts/build-changelog.ts docs",
"changelog:lint": "bun run scripts/build-changelog.ts lint", "changelog:lint": "bun run scripts/build-changelog.ts lint",
"changelog:pr-check": "bun run scripts/build-changelog.ts pr-check", "changelog:pr-check": "bun run scripts/build-changelog.ts pr-check",
"changelog:release-notes": "bun run scripts/build-changelog.ts release-notes", "changelog:release-notes": "bun run scripts/build-changelog.ts release-notes",

View File

@@ -197,6 +197,49 @@ test('verifyChangelogReadyForRelease rejects explicit release versions that do n
} }
}); });
test('writeChangelogArtifacts renders breaking changes section above type sections', async () => {
const { writeChangelogArtifacts } = await loadModule();
const workspace = createWorkspace('breaking-changes');
const projectRoot = path.join(workspace, 'SubMiner');
fs.mkdirSync(projectRoot, { recursive: true });
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8');
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
['type: changed', 'area: config', 'breaking: true', '', '- Renamed `foo` to `bar`.'].join('\n'),
'utf8',
);
fs.writeFileSync(
path.join(projectRoot, 'changes', '002.md'),
['type: fixed', 'area: overlay', '', '- Fixed subtitle rendering.'].join('\n'),
'utf8',
);
try {
writeChangelogArtifacts({
cwd: projectRoot,
version: '0.5.0',
date: '2026-04-06',
});
const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8');
const breakingIndex = changelog.indexOf('### Breaking Changes');
const changedIndex = changelog.indexOf('### Changed');
const fixedIndex = changelog.indexOf('### Fixed');
assert.notEqual(breakingIndex, -1, 'Breaking Changes section should exist');
assert.notEqual(changedIndex, -1, 'Changed section should exist');
assert.notEqual(fixedIndex, -1, 'Fixed section should exist');
assert.ok(breakingIndex < changedIndex, 'Breaking Changes should appear before Changed');
assert.ok(changedIndex < fixedIndex, 'Changed should appear before Fixed');
assert.match(changelog, /### Breaking Changes\n- Config: Renamed `foo` to `bar`\./);
assert.match(changelog, /### Changed\n- Config: Renamed `foo` to `bar`\./);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('verifyChangelogFragments rejects invalid metadata', async () => { test('verifyChangelogFragments rejects invalid metadata', async () => {
const { verifyChangelogFragments } = await loadModule(); const { verifyChangelogFragments } = await loadModule();
const workspace = createWorkspace('lint-invalid'); const workspace = createWorkspace('lint-invalid');

View File

@@ -23,6 +23,7 @@ type FragmentType = 'added' | 'changed' | 'fixed' | 'docs' | 'internal';
type ChangeFragment = { type ChangeFragment = {
area: string; area: string;
breaking: boolean;
bullets: string[]; bullets: string[];
path: string; path: string;
type: FragmentType; type: FragmentType;
@@ -144,6 +145,7 @@ function parseFragmentMetadata(
): { ): {
area: string; area: string;
body: string; body: string;
breaking: boolean;
type: FragmentType; type: FragmentType;
} { } {
const lines = content.split(/\r?\n/); const lines = content.split(/\r?\n/);
@@ -186,9 +188,12 @@ function parseFragmentMetadata(
throw new Error(`${fragmentPath} must include at least one changelog bullet.`); throw new Error(`${fragmentPath} must include at least one changelog bullet.`);
} }
const breaking = metadata.get('breaking')?.toLowerCase() === 'true';
return { return {
area, area,
body, body,
breaking,
type: type as FragmentType, type: type as FragmentType,
}; };
} }
@@ -199,6 +204,7 @@ function readChangeFragments(cwd: string, deps?: ChangelogFsDeps): ChangeFragmen
const parsed = parseFragmentMetadata(readFileSync(fragmentPath, 'utf8'), fragmentPath); const parsed = parseFragmentMetadata(readFileSync(fragmentPath, 'utf8'), fragmentPath);
return { return {
area: parsed.area, area: parsed.area,
breaking: parsed.breaking,
bullets: normalizeFragmentBullets(parsed.body), bullets: normalizeFragmentBullets(parsed.body),
path: fragmentPath, path: fragmentPath,
type: parsed.type, type: parsed.type,
@@ -219,10 +225,22 @@ function renderFragmentBullet(fragment: ChangeFragment, bullet: string): string
} }
function renderGroupedChanges(fragments: ChangeFragment[]): string { function renderGroupedChanges(fragments: ChangeFragment[]): string {
const sections = CHANGE_TYPES.flatMap((type) => { const sections: string[] = [];
const breakingFragments = fragments.filter((fragment) => fragment.breaking);
if (breakingFragments.length > 0) {
const bullets = breakingFragments
.flatMap((fragment) =>
fragment.bullets.map((bullet) => renderFragmentBullet(fragment, bullet)),
)
.join('\n');
sections.push(`### Breaking Changes\n${bullets}`);
}
for (const type of CHANGE_TYPES) {
const typeFragments = fragments.filter((fragment) => fragment.type === type); const typeFragments = fragments.filter((fragment) => fragment.type === type);
if (typeFragments.length === 0) { if (typeFragments.length === 0) {
return []; continue;
} }
const bullets = typeFragments const bullets = typeFragments
@@ -230,8 +248,8 @@ function renderGroupedChanges(fragments: ChangeFragment[]): string {
fragment.bullets.map((bullet) => renderFragmentBullet(fragment, bullet)), fragment.bullets.map((bullet) => renderFragmentBullet(fragment, bullet)),
) )
.join('\n'); .join('\n');
return [`### ${CHANGE_TYPE_HEADINGS[type]}\n${bullets}`]; sections.push(`### ${CHANGE_TYPE_HEADINGS[type]}\n${bullets}`);
}); }
return sections.join('\n\n'); return sections.join('\n\n');
} }
@@ -487,6 +505,99 @@ function resolveChangedPathsFromGit(
.filter((entry) => entry.path); .filter((entry) => entry.path);
} }
const DOCS_CHANGELOG_PATH = path.join('docs-site', 'changelog.md');
type VersionSection = {
version: string;
date: string;
minor: string;
body: string;
};
function parseVersionSections(changelog: string): VersionSection[] {
const sectionPattern = /^## v(\d+\.\d+\.\d+) \((\d{4}-\d{2}-\d{2})\)$/gm;
const sections: VersionSection[] = [];
let match: RegExpExecArray | null;
while ((match = sectionPattern.exec(changelog)) !== null) {
const version = match[1]!;
const date = match[2]!;
const minor = version.replace(/\.\d+$/, '');
const headingEnd = match.index + match[0].length;
sections.push({ version, date, minor, body: '' });
if (sections.length > 1) {
const prev = sections[sections.length - 2]!;
prev.body = changelog.slice(prev.body as unknown as number, match.index).trim();
}
(sections[sections.length - 1] as { body: unknown }).body = headingEnd;
}
if (sections.length > 0) {
const last = sections[sections.length - 1]!;
last.body = changelog.slice(last.body as unknown as number).trim();
}
return sections;
}
export function generateDocsChangelog(options?: Pick<ChangelogOptions, 'cwd' | 'deps'>): string {
const cwd = options?.cwd ?? process.cwd();
const readFileSync = options?.deps?.readFileSync ?? fs.readFileSync;
const writeFileSync = options?.deps?.writeFileSync ?? fs.writeFileSync;
const log = options?.deps?.log ?? console.log;
const changelogPath = path.join(cwd, 'CHANGELOG.md');
const changelog = readFileSync(changelogPath, 'utf8');
const sections = parseVersionSections(changelog);
if (sections.length === 0) {
throw new Error('No version sections found in CHANGELOG.md');
}
const currentMinor = sections[0]!.minor;
const currentSections = sections.filter((s) => s.minor === currentMinor);
const olderSections = sections.filter((s) => s.minor !== currentMinor);
const lines: string[] = ['# Changelog', ''];
for (const section of currentSections) {
const body = section.body.replace(/^### (.+)$/gm, '**$1**');
lines.push(`## v${section.version} (${section.date})`, '', body, '');
}
if (olderSections.length > 0) {
lines.push('## Previous Versions', '');
const minorGroups = new Map<string, VersionSection[]>();
for (const section of olderSections) {
const group = minorGroups.get(section.minor) ?? [];
group.push(section);
minorGroups.set(section.minor, group);
}
for (const [minor, group] of minorGroups) {
lines.push('<details>', `<summary>v${minor}.x</summary>`, '');
for (const section of group) {
const htmlBody = section.body.replace(/^### (.+)$/gm, '**$1**');
lines.push(`<h2>v${section.version} (${section.date})</h2>`, '', htmlBody, '');
}
lines.push('</details>', '');
}
}
const output =
lines
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trimEnd() + '\n';
const outputPath = path.join(cwd, DOCS_CHANGELOG_PATH);
writeFileSync(outputPath, output, 'utf8');
log(`Generated ${outputPath}`);
return outputPath;
}
export function writeReleaseNotesForVersion(options?: ChangelogOptions): string { export function writeReleaseNotesForVersion(options?: ChangelogOptions): string {
const cwd = options?.cwd ?? process.cwd(); const cwd = options?.cwd ?? process.cwd();
const readFileSync = options?.deps?.readFileSync ?? fs.readFileSync; const readFileSync = options?.deps?.readFileSync ?? fs.readFileSync;
@@ -599,6 +710,11 @@ function main(): void {
return; return;
} }
if (command === 'docs') {
generateDocsChangelog(options);
return;
}
throw new Error(`Unknown changelog command: ${command}`); throw new Error(`Unknown changelog command: ${command}`);
} }

View File

@@ -2125,10 +2125,7 @@ test('template generator includes known keys', () => {
/"dpadFallback": "horizontal",? \/\/ Optional D-pad fallback used when this analog controller action should also read D-pad input\. Values: none \| horizontal \| vertical/, /"dpadFallback": "horizontal",? \/\/ Optional D-pad fallback used when this analog controller action should also read D-pad input\. Values: none \| horizontal \| vertical/,
); );
assert.match(output, /"port": 6678,? \/\/ Annotated subtitle websocket server port\./); assert.match(output, /"port": 6678,? \/\/ Annotated subtitle websocket server port\./);
assert.match( assert.match(output, /"openBrowser": false,? \/\/ Open browser setting\. Values: true \| false/);
output,
/"openBrowser": false,? \/\/ Open browser setting\. Values: true \| false/,
);
assert.match( assert.match(
output, output,
/"enabled": false,? \/\/ Enable overlay controller support through the Chrome Gamepad API\. Values: true \| false/, /"enabled": false,? \/\/ Enable overlay controller support through the Chrome Gamepad API\. Values: true \| false/,

View File

@@ -251,8 +251,7 @@ export function buildIntegrationConfigOptionRegistry(
kind: 'enum', kind: 'enum',
enumValues: MPV_LAUNCH_MODE_VALUES, enumValues: MPV_LAUNCH_MODE_VALUES,
defaultValue: defaultConfig.mpv.launchMode, defaultValue: defaultConfig.mpv.launchMode,
description: description: 'Default window state for SubMiner-managed mpv launches.',
'Default window state for SubMiner-managed mpv launches.',
}, },
{ {
path: 'jellyfin.enabled', path: 'jellyfin.enabled',

View File

@@ -237,10 +237,7 @@ test('detectInstalledFirstRunPlugin detects plugin installed in canonical mpv co
fs.mkdirSync(path.dirname(pluginEntrypointPath), { recursive: true }); fs.mkdirSync(path.dirname(pluginEntrypointPath), { recursive: true });
fs.writeFileSync(pluginEntrypointPath, '-- plugin'); fs.writeFileSync(pluginEntrypointPath, '-- plugin');
assert.equal( assert.equal(detectInstalledFirstRunPlugin(installPaths), true);
detectInstalledFirstRunPlugin(installPaths),
true,
);
}); });
}); });
@@ -256,10 +253,7 @@ test('detectInstalledFirstRunPlugin ignores scoped plugin layout path', () => {
fs.mkdirSync(path.dirname(pluginEntrypointPath), { recursive: true }); fs.mkdirSync(path.dirname(pluginEntrypointPath), { recursive: true });
fs.writeFileSync(pluginEntrypointPath, '-- plugin'); fs.writeFileSync(pluginEntrypointPath, '-- plugin');
assert.equal( assert.equal(detectInstalledFirstRunPlugin(installPaths), false);
detectInstalledFirstRunPlugin(installPaths),
false,
);
}); });
}); });
@@ -272,10 +266,7 @@ test('detectInstalledFirstRunPlugin ignores legacy loader file', () => {
fs.mkdirSync(path.dirname(legacyLoaderPath), { recursive: true }); fs.mkdirSync(path.dirname(legacyLoaderPath), { recursive: true });
fs.writeFileSync(legacyLoaderPath, '-- plugin'); fs.writeFileSync(legacyLoaderPath, '-- plugin');
assert.equal( assert.equal(detectInstalledFirstRunPlugin(installPaths), false);
detectInstalledFirstRunPlugin(installPaths),
false,
);
}); });
}); });
@@ -288,9 +279,6 @@ test('detectInstalledFirstRunPlugin requires main.lua in subminer directory', ()
fs.mkdirSync(pluginDir, { recursive: true }); fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(path.join(pluginDir, 'not_main.lua'), '-- plugin'); fs.writeFileSync(path.join(pluginDir, 'not_main.lua'), '-- plugin');
assert.equal( assert.equal(detectInstalledFirstRunPlugin(installPaths), false);
detectInstalledFirstRunPlugin(installPaths),
false,
);
}); });
}); });

View File

@@ -152,13 +152,7 @@ export async function launchWindowsMpv(
try { try {
await deps.spawnDetached( await deps.spawnDetached(
mpvPath, mpvPath,
buildWindowsMpvLaunchArgs( buildWindowsMpvLaunchArgs(targets, extraArgs, binaryPath, pluginEntrypointPath, launchMode),
targets,
extraArgs,
binaryPath,
pluginEntrypointPath,
launchMode,
),
); );
return { ok: true, mpvPath }; return { ok: true, mpvPath };
} catch (error) { } catch (error) {

View File

@@ -126,9 +126,7 @@ export function createKeyboardHandlers(
} }
function acceleratorToKeyString(accelerator: string): string | null { function acceleratorToKeyString(accelerator: string): string | null {
const normalized = accelerator const normalized = accelerator.replace(/\s+/g, '').replace(/cmdorctrl/gi, 'CommandOrControl');
.replace(/\s+/g, '')
.replace(/cmdorctrl/gi, 'CommandOrControl');
if (!normalized) return null; if (!normalized) return null;
const parts = normalized.split('+').filter(Boolean); const parts = normalized.split('+').filter(Boolean);
const keyToken = parts.pop(); const keyToken = parts.pop();

View File

@@ -75,7 +75,10 @@ function createListStub() {
} }
test.afterEach(() => { test.afterEach(() => {
if (Object.prototype.hasOwnProperty.call(globalThis, 'window') && globalThis.window === undefined) { if (
Object.prototype.hasOwnProperty.call(globalThis, 'window') &&
globalThis.window === undefined
) {
Reflect.deleteProperty(globalThis, 'window'); Reflect.deleteProperty(globalThis, 'window');
} }
if ( if (

View File

@@ -22,17 +22,12 @@ function daysFromCivil(year: number, month: number, day: number): bigint {
const yearOfEra = adjustedYear - era * 400; const yearOfEra = adjustedYear - era * 400;
const monthIndex = month + (month > 2 ? -3 : 9); const monthIndex = month + (month > 2 ? -3 : 9);
const dayOfYear = floorDiv(153 * monthIndex + 2, 5) + day - 1; const dayOfYear = floorDiv(153 * monthIndex + 2, 5) + day - 1;
const dayOfEra = const dayOfEra = yearOfEra * 365 + floorDiv(yearOfEra, 4) - floorDiv(yearOfEra, 100) + dayOfYear;
yearOfEra * 365 + floorDiv(yearOfEra, 4) - floorDiv(yearOfEra, 100) + dayOfYear;
return BigInt(era * 146097 + dayOfEra - 719468); return BigInt(era * 146097 + dayOfEra - 719468);
} }
function dateToEpochMs(date: Date): bigint { function dateToEpochMs(date: Date): bigint {
const dayCount = daysFromCivil( const dayCount = daysFromCivil(date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate());
date.getUTCFullYear(),
date.getUTCMonth() + 1,
date.getUTCDate(),
);
const timeOfDayMs = BigInt( const timeOfDayMs = BigInt(
((date.getUTCHours() * 60 + date.getUTCMinutes()) * 60 + date.getUTCSeconds()) * 1000 + ((date.getUTCHours() * 60 + date.getUTCMinutes()) * 60 + date.getUTCSeconds()) * 1000 +
date.getUTCMilliseconds(), date.getUTCMilliseconds(),

View File

@@ -1,7 +1,10 @@
import type { MpvLaunchMode } from '../types/config'; import type { MpvLaunchMode } from '../types/config';
export const MPV_LAUNCH_MODE_VALUES = ['normal', 'maximized', 'fullscreen'] as const satisfies export const MPV_LAUNCH_MODE_VALUES = [
readonly MpvLaunchMode[]; 'normal',
'maximized',
'fullscreen',
] as const satisfies readonly MpvLaunchMode[];
export function parseMpvLaunchMode(value: unknown): MpvLaunchMode | undefined { export function parseMpvLaunchMode(value: unknown): MpvLaunchMode | undefined {
if (typeof value !== 'string') { if (typeof value !== 'string') {

View File

@@ -203,13 +203,7 @@ test('resolveDefaultMpvInstallPaths resolves linux, macOS, and Windows defaults'
'main.lua', 'main.lua',
), ),
pluginDir: path.posix.join(macHomeDir, '.config', 'mpv', 'scripts', 'subminer'), pluginDir: path.posix.join(macHomeDir, '.config', 'mpv', 'scripts', 'subminer'),
pluginConfigPath: path.posix.join( pluginConfigPath: path.posix.join(macHomeDir, '.config', 'mpv', 'script-opts', 'subminer.conf'),
macHomeDir,
'.config',
'mpv',
'script-opts',
'subminer.conf',
),
}); });
assert.deepEqual(resolveDefaultMpvInstallPaths('win32', 'C:\\Users\\tester', undefined), { assert.deepEqual(resolveDefaultMpvInstallPaths('win32', 'C:\\Users\\tester', undefined), {