feat(dictionary): add Hachidori backend support

- Add backend selection, setup gating, Anki integration, and external host support
- Add launcher flags, documentation, packaging, and focused tests
- Open on-demand overlay modals on the first attempt
This commit is contained in:
2026-09-22 00:21:19 -07:00
parent 1508863dbb
commit d9fdc7ef6d
446 changed files with 109060 additions and 244 deletions
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env node
// Validate both manifests, supported-browser pins, and optional release tag.
// SPDX-License-Identifier: GPL-3.0-or-later
import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const CHROME_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u;
const MANIFEST_VERSION_COMPONENT = /^(0|[1-9]\d*)$/u;
const MAX_MANIFEST_VERSION_COMPONENT = 65_535;
const FIREFOX_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?$/u;
const FIREFOX_BUILD = /^stable_((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:\.(?:0|[1-9]\d*))?)$/u;
function fail(message) {
throw new Error(message);
}
export function chromeVersion(value, label) {
const match = CHROME_VERSION.exec(value);
if (match === null) fail(`${label} must be an exact four-part Chrome version`);
const components = match.slice(1).map(Number);
if (!components.every(Number.isSafeInteger)) {
fail(`${label} contains a version component that is too large`);
}
return components;
}
export function firefoxVersion(value, label) {
const match = FIREFOX_VERSION.exec(value);
if (match === null) fail(`${label} must be a Firefox version such as 153.0`);
return match.slice(1).filter(component => component !== undefined).map(Number);
}
export function firefoxBuildVersion(value, label) {
const match = FIREFOX_BUILD.exec(value ?? "");
if (match === null) fail(`${label} must be a pinned stable Firefox build such as stable_155.0.1`);
return firefoxVersion(match[1], label);
}
export function compareVersions(left, right) {
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
const difference = (left[index] ?? 0) - (right[index] ?? 0);
if (difference !== 0) return Math.sign(difference);
}
return 0;
}
// The Firefox XPI ships the same version, and the pinned test Firefox must
// satisfy the manifest's own minimum, or the smoke test proves nothing.
export function validateFirefoxContract(manifest, firefoxManifest, tooling) {
if (firefoxManifest?.manifest_version !== 2) fail("manifest.firefox.json must stay on manifest_version 2");
if (firefoxManifest.version !== manifest.version) {
fail("manifest.firefox.json version does not match manifest.json");
}
const gecko = firefoxManifest.browser_specific_settings?.gecko;
const minimum = firefoxVersion(gecko?.strict_min_version, "gecko.strict_min_version");
const current = firefoxBuildVersion(tooling?.config?.firefox, "config.firefox");
if (compareVersions(current, minimum) < 0) {
fail("the pinned Firefox test build is older than the manifest minimum");
}
return { minimumFirefox: gecko.strict_min_version, currentFirefox: tooling.config.firefox };
}
export function validateReleaseContract(manifest, tooling, tag = null, firefoxManifest = null) {
const versionComponents = String(manifest?.version ?? "").split(".");
if (versionComponents.length < 1 || versionComponents.length > 4
|| !versionComponents.every((component) =>
MANIFEST_VERSION_COMPONENT.test(component)
&& Number(component) <= MAX_MANIFEST_VERSION_COMPONENT)) {
fail("manifest.version is not a Chrome-compatible release version");
}
if (!/^(0|[1-9]\d*)$/u.test(manifest?.minimum_chrome_version ?? "")) {
fail("manifest.minimum_chrome_version must be one Chrome major");
}
const minimum = chromeVersion(tooling?.config?.minimumChrome, "config.minimumChrome");
const current = chromeVersion(tooling?.config?.chrome, "config.chrome");
if (minimum[0] !== Number(manifest.minimum_chrome_version)) {
fail("the minimum Chrome test build does not match the manifest minimum");
}
if (compareVersions(current, minimum) < 0) {
fail("the current Chrome test build is older than the minimum build");
}
const expectedTag = manifest.version;
if (tag !== null && tag !== expectedTag) {
fail(`release tag ${JSON.stringify(tag)} must be ${expectedTag}`);
}
return {
version: manifest.version,
expectedTag,
minimumChrome: tooling.config.minimumChrome,
currentChrome: tooling.config.chrome,
...(firefoxManifest === null ? {} : validateFirefoxContract(manifest, firefoxManifest, tooling)),
};
}
function readJson(path) {
return JSON.parse(readFileSync(path, "utf8"));
}
function commandLineTag(arguments_) {
if (arguments_.length === 0) return null;
if (arguments_.length !== 2 || arguments_[0] !== "--tag" || arguments_[1] === "") {
fail("usage: node scripts/check-release.mjs [--tag <manifest.version>]");
}
return arguments_[1];
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
try {
const contract = validateReleaseContract(
readJson(resolve(ROOT, "extension/manifest.json")),
readJson(resolve(ROOT, "test/tooling/package.json")),
commandLineTag(process.argv.slice(2)),
readJson(resolve(ROOT, "extension/manifest.firefox.json")),
);
console.log(
`Hachidori ${contract.version}: Chrome ${contract.minimumChrome} minimum, `
+ `${contract.currentChrome} current; Firefox ${contract.minimumFirefox} minimum, `
+ `${contract.currentFirefox} current; tag ${contract.expectedTag}`,
);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}