test(i18n): every locale key must be read by the app; 75 dead keys removed

A contract test scans the source (tests excluded) for each key as a whole literal, or under a
dynamic prefix like `plex.sort.${k}`, with plural forms counted through their base key. It found 75
keys that had outlived the code showing them (the old sidebar sort vocabulary, the old login page, a
pager the DataTable replaced, …); they are gone from EN and HU. A key read in a way the scan cannot
see goes in an explicit ALLOWED map with its reason.
This commit is contained in:
2026-09-19 17:15:18 +02:00
parent 4f72b193aa
commit cedcbb1434
33 changed files with 76 additions and 170 deletions
@@ -72,11 +72,6 @@
},
"loading": "Loading channels…",
"empty": "No channels.",
"pager": {
"prev": "Previous",
"next": "Next",
"page": "Page {{page}} of {{total}}"
},
"cols": {
"priority": "Prio",
"channel": "Channel",
-1
View File
@@ -10,7 +10,6 @@
"undo": "Undo",
"redo": "Redo",
"loading": "Loading…",
"language": "Language",
"somethingWrong": "Something went wrong.",
"itemCount_one": "{{formattedCount}} item",
"itemCount_other": "{{formattedCount}} items",
-1
View File
@@ -270,7 +270,6 @@
"willReset": "will reset to default on save",
"reset": "Reset",
"resetHint": "Remove this override and fall back to the file/env default.",
"resetDone": "Reset to default",
"usingDefault": "using default",
"overridden": "overridden in DB",
"secretSet": "•••••••• stored",
@@ -8,7 +8,6 @@
"pager": {
"prev": "Previous",
"next": "Next",
"page": "Page {{page}} of {{total}}",
"pageLabel": "Page",
"ofTotal": "of {{total}}"
}
@@ -112,9 +112,6 @@
},
"share": {
"title": "Share download",
"label": "Recipient email",
"placeholder": "user@example.com",
"submit": "Share",
"done": "Shared with {{email}}",
"failed": "Couldn’t share.",
"userSection": "Share with a user",
@@ -162,7 +159,6 @@
"redownloadAction": "Re-download"
},
"profiles": {
"manage": "Manage formats",
"title": "Download formats",
"builtin": "Built-in",
"yours": "Your formats",
@@ -186,14 +182,11 @@
"deleteConfirm": "Delete the format “{{name}}”?"
},
"admin": {
"storageTitle": "Storage",
"readyFiles": "Ready files",
"totalSize": "Total size",
"cap": "Cache cap",
"noCap": "no cap",
"perUser": "Per-user footprint",
"user": "User",
"footprint": "Footprint",
"editQuota": "Edit quota",
"quotaTitle": "Download quota — {{email}}",
"maxBytes": "Storage limit (GB)",
@@ -202,20 +195,9 @@
"unlimited": "Unlimited (bypass storage limit)",
"reset": "Reset to default",
"save": "Save",
"custom": "custom",
"default": "default",
"quotaPickPlaceholder": "Set a user's quota…",
"noFootprint": "No downloads yet."
},
"cols": {
"title": "Title",
"channel": "Channel",
"format": "Format",
"size": "Size",
"status": "Status",
"added": "Added",
"user": "User"
},
"clipBadge": "Clip",
"errors": {
"quota": {
-2
View File
@@ -23,8 +23,6 @@
"noMatches": "No videos match these filters.",
"channelScopeHint": "This channel isn't in your Feed — it may be hidden. Its videos are in the shared library.",
"viewInLibrary": "View in Library",
"videoCount_one": "{{formattedCount}} video",
"videoCount_other": "{{formattedCount}} videos",
"sortLabel": "Sort",
"viewLabel": "View",
"showLabel": "Watch state",
-3
View File
@@ -1,13 +1,10 @@
{
"feed": "Feed",
"searchPlaceholder": "Search your subscriptions…",
"clearSearch": "Clear search",
"searchYoutube": "Search YouTube",
"searchYoutubeTip": "Search live on YouTube for this term (uses shared API quota). You can also press Enter.",
"go": "Go",
"goTip": "Search (or press Enter)",
"prevModule": "Previous module",
"nextModule": "Next module",
"channelManager": "Channel manager",
"usageStats": "Usage & stats",
"scheduler": "Scheduler",
-1
View File
@@ -2,7 +2,6 @@
"navLabel": "Notifications",
"title": "Notifications",
"subtitle": "Updates from your library and the system.",
"tabInbox": "Notifications",
"empty": "You're all caught up.",
"markAllRead": "Mark all read",
"clearAll": "Clear all",
+1 -12
View File
@@ -1,12 +1 @@
{
"tagline": "A self-hosted, filterable Feed of your YouTube subscriptions — sort, tag, and tune out the noise. Sign in with Google to get started; YouTube access is an optional, separate step you control.",
"signIn": "Sign in with Google",
"approved": "You're already approved — just sign in with Google above.",
"requested": "Thanks — your request is in. We'll email you when it's approved.",
"noAccessYet": "No access yet?",
"denied": "That Google account isn't approved. Request access below.",
"emailPlaceholder": "you@gmail.com",
"sending": "Sending…",
"requestAccess": "Request access",
"submitError": "Couldn't submit — check the address and try again."
}
{}
@@ -1,5 +1,4 @@
{
"tab": "Messages",
"navLabel": "Messages",
"popOut": "Open in pop-out",
"expand": "Expand",
-3
View File
@@ -1,8 +1,5 @@
{
"primary": "Primary navigation",
"pin": "Pin sidebar open",
"unpin": "Unpin sidebar",
"adminSection": "Admin",
"role": {
"admin": "Admin",
"user": "User",
@@ -1,15 +1,9 @@
{
"title": "Notifications",
"clearAll": "Clear all",
"clear": "Clear",
"empty": "No notifications yet.",
"actionNeeded": "Action needed",
"dismiss": "Dismiss",
"findInFeed": "Find in Feed",
"unhide": "Unhide",
"unwatch": "Unwatch",
"openInManager": "Channel manager",
"openOnYouTube": "Open on YouTube",
"unhidden": "Unhidden “{{title}}”",
"unwatched": "Unwatched “{{title}}”"
"openOnYouTube": "Open on YouTube"
}
@@ -6,7 +6,6 @@
"syncYoutube": "Sync from YouTube",
"syncedToast": "Synced {{count}} playlists from YouTube",
"syncFailed": "Couldn't sync from YouTube",
"ytReadonly": "Synced from YouTube — edits sync back",
"noneYet": "No playlists yet",
"newPlaylist": "New playlist…",
"searchPlaceholder": "Search playlists…",
-5
View File
@@ -1,6 +1,5 @@
{
"navLabel": "Plex",
"backToFeed": "YouTube Feed",
"backToLibrary": "Back to library",
"collection": {
"playAll": "Play all",
@@ -113,8 +112,6 @@
"transcode_sw": "Transcode · SW"
}
},
"count": "{{count}} titles",
"searchCount": "{{count}} matches",
"noMatches": "No matches.",
"noMatchesFiltered_one": "No matches — a filter is also narrowing the results.",
"noMatchesFiltered_other": "No matches — {{count}} filters are also narrowing the results.",
@@ -303,10 +300,8 @@
},
"playlist": {
"section": "Playlists",
"newPlaceholder": "New playlist…",
"pickerPlaceholder": "Find or create a playlist…",
"createNamed": "Create \"{{name}}\"",
"create": "Create playlist",
"none": "No playlists yet.",
"deleteConfirm": "Delete the playlist \"{{title}}\"?",
"rename": "Rename",
+1 -20
View File
@@ -6,10 +6,6 @@
"customize": "Customize sidebar",
"done": "Done",
"editHint": "Drag to reorder · eye to show/hide",
"channel": "Channel",
"loading": "Loading…",
"channelCount": "{{count}} channel",
"channelCount_other": "{{count}} channels",
"dragToReorder": "Drag to reorder",
"showWidget": "Show widget",
"hideWidget": "Hide widget",
@@ -19,8 +15,6 @@
"unpin": "Unpin panel",
"any": "Any",
"all": "All",
"tagModeTooltip": "Match any vs all selected tags",
"custom": "Custom",
"from": "From",
"to": "To",
"clearDates": "clear dates",
@@ -56,17 +50,6 @@
"saved": "Saved",
"hidden": "Hidden"
},
"sort": {
"newest": "Newest",
"oldest": "Oldest",
"views": "Most viewed",
"duration_desc": "Longest",
"duration_asc": "Shortest",
"title": "Name (A–Z)",
"subscribers": "Channel subscribers",
"priority": "Channel priority",
"shuffle": "Surprise me"
},
"content": {
"normal": "Normal",
"shorts": "Shorts",
@@ -91,7 +74,5 @@
"untilShort": "Until {{date}}"
},
"channelSearch": "Search channels…",
"noChannelMatch": "No channel matches.",
"channelOrder": "Priority first, then A–Z",
"channelRemove": "Remove channel: {{name}}"
"noChannelMatch": "No channel matches."
}
-4
View File
@@ -1,12 +1,8 @@
{
"save": "Save current view",
"namePlaceholder": "View name",
"saveAction": "Save",
"cancel": "Cancel",
"empty": "No saved views yet. Apply some filters, then save them.",
"apply": "Apply this view",
"makeDefault": "Set as default (loads on start)",
"isDefault": "Default view — loads on start",
"rename": "Rename",
"delete": "Delete",
"deleteConfirm": "Delete the saved view “{{name}}”?",
@@ -72,11 +72,6 @@
},
"loading": "Csatornák betöltése…",
"empty": "Nincsenek csatornák.",
"pager": {
"prev": "Előző",
"next": "Következő",
"page": "{{page}}. oldal / {{total}}"
},
"cols": {
"priority": "Prio",
"channel": "Csatorna",
-1
View File
@@ -10,7 +10,6 @@
"undo": "Visszavonás",
"redo": "Újra",
"loading": "Betöltés…",
"language": "Nyelv",
"somethingWrong": "Hiba történt.",
"itemCount_one": "{{formattedCount}} elem",
"itemCount_other": "{{formattedCount}} elem",
-1
View File
@@ -270,7 +270,6 @@
"willReset": "mentéskor visszaáll alapértékre",
"reset": "Visszaállítás",
"resetHint": "Eltávolítja ezt a felülírást, és visszatér a fájl/env alapértékhez.",
"resetDone": "Visszaállítva az alapértékre",
"usingDefault": "alapérték használatban",
"overridden": "felülírva az adatbázisban",
"secretSet": "•••••••• tárolva",
@@ -8,7 +8,6 @@
"pager": {
"prev": "Előző",
"next": "Következő",
"page": "{{page}}. oldal / {{total}}",
"pageLabel": "Oldal",
"ofTotal": "/ {{total}}"
}
@@ -112,9 +112,6 @@
},
"share": {
"title": "Letöltés megosztása",
"label": "Címzett e-mail címe",
"placeholder": "user@example.com",
"submit": "Megosztás",
"done": "Megosztva vele: {{email}}",
"failed": "A megosztás nem sikerült.",
"userSection": "Megosztás felhasználóval",
@@ -162,7 +159,6 @@
"redownloadAction": "Újraletöltés"
},
"profiles": {
"manage": "Formátumok kezelése",
"title": "Letöltési formátumok",
"builtin": "Beépített",
"yours": "Saját formátumaid",
@@ -186,14 +182,11 @@
"deleteConfirm": "Törlöd a(z) „{{name}}” formátumot?"
},
"admin": {
"storageTitle": "Tárhely",
"readyFiles": "Kész fájlok",
"totalSize": "Teljes méret",
"cap": "Gyorsítótár-korlát",
"noCap": "nincs korlát",
"perUser": "Felhasználónkénti tárhelyhasználat",
"user": "Felhasználó",
"footprint": "Használat",
"editQuota": "Kvóta szerkesztése",
"quotaTitle": "Letöltési kvóta — {{email}}",
"maxBytes": "Tárhelykorlát (GB)",
@@ -202,20 +195,9 @@
"unlimited": "Korlátlan (tárhelykorlát kikapcsolása)",
"reset": "Visszaállítás alapértékre",
"save": "Mentés",
"custom": "egyedi",
"default": "alapértelmezett",
"quotaPickPlaceholder": "Kvóta beállítása egy felhasználónak…",
"noFootprint": "Még nincs letöltés."
},
"cols": {
"title": "Cím",
"channel": "Csatorna",
"format": "Formátum",
"size": "Méret",
"status": "Állapot",
"added": "Hozzáadva",
"user": "Felhasználó"
},
"clipBadge": "Klip",
"errors": {
"quota": {
-2
View File
@@ -23,8 +23,6 @@
"noMatches": "Egy videó sem felel meg ezeknek a szűrőknek.",
"channelScopeHint": "Ez a csatorna nincs a Feededben – lehet, hogy el van rejtve. A videói a közös könyvtárban vannak.",
"viewInLibrary": "Megnézés a Könyvtárban",
"videoCount_one": "{{formattedCount}} videó",
"videoCount_other": "{{formattedCount}} videó",
"sortLabel": "Rendezés",
"viewLabel": "Nézet",
"showLabel": "Megtekintési állapot",
-3
View File
@@ -1,13 +1,10 @@
{
"feed": "Feed",
"searchPlaceholder": "Keresés a feliratkozásaid között…",
"clearSearch": "Keresés törlése",
"searchYoutube": "Keresés a YouTube-on",
"searchYoutubeTip": "Élő keresés a YouTube-on erre a kifejezésre (a közös API-kvótát fogyasztja). Entert is nyomhatsz.",
"go": "Mehet",
"goTip": "Keresés (vagy Enter)",
"prevModule": "Előző modul",
"nextModule": "Következő modul",
"channelManager": "Csatornakezelő",
"usageStats": "Használat és statisztika",
"scheduler": "Ütemező",
-1
View File
@@ -2,7 +2,6 @@
"navLabel": "Értesítések",
"title": "Értesítések",
"subtitle": "Frissítések a könyvtáradból és a rendszertől.",
"tabInbox": "Értesítések",
"empty": "Nincs új értesítés.",
"markAllRead": "Összes olvasott",
"clearAll": "Összes törlése",
+1 -12
View File
@@ -1,12 +1 @@
{
"tagline": "Saját üzemeltetésű, szűrhető Feed a YouTube-feliratkozásaidból — rendezd, címkézd, és szűrd ki a zajt. A kezdéshez jelentkezz be Google-fiókkal; a YouTube-hozzáférés külön, általad vezérelt lépés.",
"signIn": "Bejelentkezés Google-fiókkal",
"approved": "Már jóvá vagy hagyva — csak jelentkezz be a fenti Google-gombbal.",
"requested": "Köszönjük — a kérésedet rögzítettük. E-mailben értesítünk, ha jóváhagytuk.",
"noAccessYet": "Még nincs hozzáférésed?",
"denied": "Ez a Google-fiók nincs jóváhagyva. Lent kérhetsz hozzáférést.",
"emailPlaceholder": "te@gmail.com",
"sending": "Küldés…",
"requestAccess": "Hozzáférés kérése",
"submitError": "Nem sikerült elküldeni — ellenőrizd a címet, és próbáld újra."
}
{}
@@ -1,5 +1,4 @@
{
"tab": "Üzenetek",
"navLabel": "Üzenetek",
"popOut": "Megnyitás felugró ablakban",
"expand": "Kinyitás",
-3
View File
@@ -1,8 +1,5 @@
{
"primary": "Fő navigáció",
"pin": "Oldalsáv rögzítése nyitva",
"unpin": "Oldalsáv rögzítésének feloldása",
"adminSection": "Admin",
"role": {
"admin": "Admin",
"user": "Felhasználó",
@@ -1,15 +1,9 @@
{
"title": "Értesítések",
"clearAll": "Összes törlése",
"clear": "Törlés",
"empty": "Még nincs értesítés.",
"actionNeeded": "Teendő",
"dismiss": "Elvetés",
"findInFeed": "Keresés a Feedben",
"unhide": "Megjelenítés",
"unwatch": "Megtekintés visszavonása",
"openInManager": "Csatornakezelő",
"openOnYouTube": "Megnyitás a YouTube-on",
"unhidden": "Megjelenítve: „{{title}}”",
"unwatched": "Megtekintés visszavonva: „{{title}}”"
"openOnYouTube": "Megnyitás a YouTube-on"
}
@@ -6,7 +6,6 @@
"syncYoutube": "Szinkron YouTube-ról",
"syncedToast": "{{count}} lista szinkronizálva a YouTube-ról",
"syncFailed": "Nem sikerült a YouTube-szinkron",
"ytReadonly": "YouTube-ról szinkronizálva — a módosítások visszaszinkronizálhatók",
"noneYet": "Még nincs lejátszási lista",
"newPlaylist": "Új lista…",
"searchPlaceholder": "Listák keresése…",
-5
View File
@@ -1,6 +1,5 @@
{
"navLabel": "Plex",
"backToFeed": "YouTube Feed",
"backToLibrary": "Vissza a könyvtárhoz",
"collection": {
"playAll": "Összes lejátszása",
@@ -113,8 +112,6 @@
"transcode_sw": "Átkódolás · SW"
}
},
"count": "{{count}} cím",
"searchCount": "{{count}} találat",
"noMatches": "Nincs találat.",
"noMatchesFiltered_one": "Nincs találat — egy szűrő is szűkíti az eredményt.",
"noMatchesFiltered_other": "Nincs találat — {{count}} szűrő is szűkíti az eredményt.",
@@ -303,10 +300,8 @@
},
"playlist": {
"section": "Lejátszási listák",
"newPlaceholder": "Új lista…",
"pickerPlaceholder": "Lista keresése vagy létrehozása…",
"createNamed": "„{{name}}” létrehozása",
"create": "Lista létrehozása",
"none": "Még nincs lista.",
"deleteConfirm": "Törlöd a(z) „{{title}}” listát?",
"rename": "Átnevezés",
+1 -20
View File
@@ -6,10 +6,6 @@
"customize": "Oldalsáv testreszabása",
"done": "Kész",
"editHint": "Húzd az átrendezéshez · szem a megjelenítéshez/elrejtéshez",
"channel": "Csatorna",
"loading": "Betöltés…",
"channelCount": "{{count}} csatorna",
"channelCount_other": "{{count}} csatorna",
"dragToReorder": "Húzd az átrendezéshez",
"showWidget": "Modul megjelenítése",
"hideWidget": "Modul elrejtése",
@@ -19,8 +15,6 @@
"unpin": "Panel rögzítésének feloldása",
"any": "Bármelyik",
"all": "Összes",
"tagModeTooltip": "Bármelyik vagy az összes kijelölt címke illeszkedjen",
"custom": "Egyéni",
"from": "Ettől",
"to": "Eddig",
"clearDates": "dátumok törlése",
@@ -56,17 +50,6 @@
"saved": "Mentett",
"hidden": "Rejtett"
},
"sort": {
"newest": "Legújabb",
"oldest": "Legrégebbi",
"views": "Legnézettebb",
"duration_desc": "Leghosszabb",
"duration_asc": "Legrövidebb",
"title": "Név (A–Z)",
"subscribers": "Csatorna feliratkozói",
"priority": "Csatorna prioritás",
"shuffle": "Lepj meg"
},
"content": {
"normal": "Normál",
"shorts": "Shorts",
@@ -91,7 +74,5 @@
"untilShort": "{{date}}-ig"
},
"channelSearch": "Csatornák keresése…",
"noChannelMatch": "Nincs találat.",
"channelOrder": "Prioritás szerint, majd A–Z",
"channelRemove": "Csatorna eltávolítása: {{name}}"
"noChannelMatch": "Nincs találat."
}
-4
View File
@@ -1,12 +1,8 @@
{
"save": "Aktuális nézet mentése",
"namePlaceholder": "Nézet neve",
"saveAction": "Mentés",
"cancel": "Mégse",
"empty": "Még nincs mentett nézet. Állíts be szűrőket, majd mentsd el.",
"apply": "Nézet alkalmazása",
"makeDefault": "Beállítás alapértelmezettként (indításkor betölt)",
"isDefault": "Alapértelmezett nézet — indításkor betölt",
"rename": "Átnevezés",
"delete": "Törlés",
"deleteConfirm": "Törlöd a(z) „{{name}}” mentett nézetet?",
@@ -0,0 +1,70 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
// Every locale key is used by the app. The parity test proves EN and HU agree; nothing proved either
// one is still READ — 75 keys had outlived the code that showed them (a whole sidebar sort vocabulary,
// the old login page, a pager the DataTable replaced), each a string a translator would keep in step
// for nobody, and each one making "is this key live?" a grep instead of a fact.
//
// A key counts as used when the source (tests excluded — a test naming a key does not show it to
// anyone) holds it as a whole string literal, or holds a string that ends in `.` right before an
// interpolation or a concatenation — a DYNAMIC prefix like `plex.sort.${k}` — which then covers every
// key under it. Plural forms count through their base key. A key read in a way this cannot see goes
// in ALLOWED with the reason, rather than the check being loosened for everyone.
const ALLOWED: Record<string, string> = {};
const SRC = fileURLToPath(new URL("../", import.meta.url));
function flatten(obj: Record<string, unknown>, prefix: string, out: string[]): string[] {
for (const [k, v] of Object.entries(obj)) {
const key = `${prefix}.${k}`;
if (v && typeof v === "object" && !Array.isArray(v))
flatten(v as Record<string, unknown>, key, out);
else out.push(key);
}
return out;
}
function sources(dir: string, out: string[] = []): string[] {
for (const f of readdirSync(dir)) {
const p = join(dir, f);
if (statSync(p).isDirectory()) sources(p, out);
else if (/\.(ts|tsx)$/.test(f) && !/\.test\./.test(f)) out.push(readFileSync(p, "utf-8"));
}
return out;
}
const locales = join(SRC, "i18n/locales/en");
const keys = readdirSync(locales).flatMap((f) =>
flatten(JSON.parse(readFileSync(join(locales, f), "utf-8")), f.replace(/\.json$/, ""), []),
);
const code = sources(SRC).join("\n");
const literals = new Set([...code.matchAll(/["'`]([\w.-]+)["'`]/g)].map((m) => m[1]!));
const prefixes = [...code.matchAll(/["'`]([\w.]+\.)(?:\$\{|["'`]\s*\+)/g)].map((m) => m[1]!);
const base = (k: string) => k.replace(/_(zero|one|two|few|many|other)$/, "");
const used = (k: string) =>
literals.has(k) || literals.has(base(k)) || prefixes.some((p) => k.startsWith(p));
describe("i18n keys", () => {
it("finds keys and code to check them against", () => {
expect(keys.length).toBeGreaterThan(1000);
expect(literals.has("home.hero.reroll")).toBe(true);
expect(prefixes).toContain("plex.sort.");
});
it("are all read somewhere in the app", () => {
expect(keys.filter((k) => !used(k) && !(k in ALLOWED))).toEqual([]);
});
it("allow only keys that exist and the scan really cannot see", () => {
for (const k of Object.keys(ALLOWED)) {
expect({ k, exists: keys.includes(k), scanSees: used(k) }).toEqual({
k,
exists: true,
scanSees: false,
});
}
});
});