← Files Vocabloot Deck KitARCHIVED FILE
scripts/lib/registry.mjs
5.1 KB · Oct 2, 2026 · 00:34 UTC
// The app's language registry: which languages a creator may pick, and which
// pairs are allowed. The live endpoint is the only source of truth; the
// snapshot is an offline fallback that goes stale on purpose.
//
// node scripts/lib/registry.mjs print the live registry
// node scripts/lib/registry.mjs --refresh rewrite registry/snapshot.json from the live endpoint
import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url));
export const REGISTRY_URL = "https://vocabloot.com/api/capabilities";
export const SNAPSHOT_PATH = path.join(HERE, "..", "..", "registry", "snapshot.json");
export const LANGUAGES_PATH = path.join(HERE, "..", "..", "registry", "languages.json");
export const SNAPSHOT_MAX_AGE_DAYS = 30;
export function loadLanguageFacts() {
return JSON.parse(readFileSync(LANGUAGES_PATH, "utf8")).languages;
}
export function loadSnapshot(snapshotPath = SNAPSHOT_PATH) {
return JSON.parse(readFileSync(snapshotPath, "utf8"));
}
function isStringArray(v) {
return Array.isArray(v) && v.length > 0 && v.every((x) => typeof x === "string");
}
/** The live registry, or the snapshot when the endpoint is unreachable and the snapshot is fresh. Throws otherwise. */
export async function fetchRegistry({
fetchImpl = fetch,
snapshotPath = SNAPSHOT_PATH,
now = new Date(),
timeoutMs = 5000,
} = {}) {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const res = await fetchImpl(REGISTRY_URL, { signal: controller.signal });
clearTimeout(timer);
if (!res.ok) throw new Error(`registry answered ${res.status}`);
const json = await res.json();
if (!isStringArray(json.learning) || !isStringArray(json.known))
throw new Error("registry has no learning or known lists");
return { learning: json.learning, known: json.known, via: "live", checkedAt: now.toISOString().slice(0, 10) };
} catch (error) {
const snapshot = loadSnapshot(snapshotPath);
const ageDays = (now - new Date(snapshot.checkedAt)) / 86_400_000;
if (!(ageDays <= SNAPSHOT_MAX_AGE_DAYS)) {
throw new Error(
`The Vocabloot language registry is unreachable (${error.message}) and the snapshot from ${snapshot.checkedAt} is older than ${SNAPSHOT_MAX_AGE_DAYS} days. Connect to the internet or run npm run registry:refresh.`,
);
}
return { learning: snapshot.learning, known: snapshot.known, via: "snapshot", checkedAt: snapshot.checkedAt };
}
}
/** Display name for a code, from the facts file; the code itself when unknown. */
export function nameOf(code, facts = loadLanguageFacts()) {
return facts[code]?.name ?? code;
}
/** Accepts a name ("German", "Deutsch", "de") and returns the code, or null. */
export function resolveLanguage(input, codes, facts = loadLanguageFacts()) {
const q = String(input ?? "")
.trim()
.toLowerCase();
if (!q) return null;
for (const code of codes) {
const f = facts[code];
if (code.toLowerCase() === q) return code;
if (f && (f.name.toLowerCase() === q || f.nativeName.toLowerCase() === q)) return code;
}
// "English" alone is ambiguous between en-US and en-GB: prefer US, say so in the skill.
if (q === "english") return codes.includes("en-US") ? "en-US" : codes.includes("en-GB") ? "en-GB" : null;
return null;
}
/**
* The pair rule the app applies: both languages must be enabled in their own
* list and must differ by exact code (knowing en-US still allows learning en-GB).
*/
export function validatePair({ known, learning }, registry, facts = loadLanguageFacts()) {
const problems = [];
if (!registry.known.includes(known)) {
problems.push(
`Vocabloot does not offer ${nameOf(known, facts)} (${known}) as a language you speak yet. Available: ${registry.known.map((c) => nameOf(c, facts)).join(", ")}.`,
);
}
if (!registry.learning.includes(learning)) {
problems.push(
`Vocabloot does not offer ${nameOf(learning, facts)} (${learning}) as a language to learn yet. Available: ${registry.learning.map((c) => nameOf(c, facts)).join(", ")}.`,
);
}
if (known === learning) problems.push("The language you speak and the language you are learning must differ.");
return problems;
}
export function directionOf({ known, learning }) {
return `${known}-to-${learning}`;
}
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))) {
const registry = await fetchRegistry();
if (process.argv.includes("--refresh")) {
if (registry.via !== "live") {
console.error("registry: could not reach the live endpoint; snapshot unchanged");
process.exit(1);
}
writeFileSync(
SNAPSHOT_PATH,
JSON.stringify(
{ source: REGISTRY_URL, checkedAt: registry.checkedAt, learning: registry.learning, known: registry.known },
null,
2,
) + "\n",
);
console.log(`registry: snapshot refreshed (${registry.learning.length} learning, ${registry.known.length} known)`);
} else {
console.log(JSON.stringify(registry, null, 2));
}
}
SHA-256: 3c1ab96eebaf4ef5183c086d4aad818f700db911da3bdd51b0b90c9a4864ff58