fix(a11y): names that carry what they show; the unused-key check stops covering whole files
- The palette launcher's name replaced its visible "Jump to…" (and the key cap's its "Ctrl K"):
a voice-control "click Jump to" matched nothing (WCAG 2.5.3). Each name contains its text now.
- SegmentedToggle's `title` was only a tooltip on a non-focusable box — the live search's "how
many per batch" hint reached no keyboard or screen-reader user. It is the group's accessible
description, standalone and captioned alike.
- The unused-key contract let a template like `card.${status}` cover its whole namespace, which hid
five dead keys (deleted). Dynamic keys are matched by shape (`editor.*.hint`); a bare namespace
shape covers nothing, and the audit/quota namespaces are now held both ways by backendIds.
- CLAUDE.md's "FE control" row matches the lint rule it cites; each no-restricted-syntax entry has
its reason directly above it.
This commit is contained in:
@@ -201,6 +201,14 @@ describe("backend ids have their names in both languages", () => {
|
||||
it(`${lang}: every audit and quota action`, () => {
|
||||
expect(auditActions.filter((a) => !idsUnder(keys, "auditActions").has(a))).toEqual([]);
|
||||
expect(quotaActions.filter((a) => !idsUnder(keys, "quotaActions").has(a))).toEqual([]);
|
||||
// …and no name for an action the backend no longer has (the unused-key contract leaves
|
||||
// these two namespaces to this check).
|
||||
expect([...idsUnder(keys, "auditActions")].filter((a) => !auditActions.includes(a))).toEqual(
|
||||
[],
|
||||
);
|
||||
expect([...idsUnder(keys, "quotaActions")].filter((a) => !quotaActions.includes(a))).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it(`${lang}: every download failure-catalog entry`, () => {
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"saveForLater": "Save for later",
|
||||
"unhide": "Unhide",
|
||||
"hide": "Hide",
|
||||
"onlyThisChannel": "Only this channel",
|
||||
"resetState": "Reset — clear watch progress/status",
|
||||
"thisChannel": "This channel",
|
||||
"continueTitle": "Continue where you left off",
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
{
|
||||
"title": "Edit “{{name}}”",
|
||||
"playPause": "Play / pause",
|
||||
"setIn": "Set start",
|
||||
"setOut": "Set end",
|
||||
"in": "Start",
|
||||
"out": "End",
|
||||
"selected": "Selection",
|
||||
"cropOff": "Add crop",
|
||||
"cropOn": "Cropping",
|
||||
"cropReencode": "Cropping always re-encodes (frame-accurate).",
|
||||
"split": "Split into",
|
||||
"accurate": {
|
||||
"label": "Precise cut",
|
||||
"hint": "Frame-accurate. Re-encodes the clip."
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"saveForLater": "Mentés későbbre",
|
||||
"unhide": "Megjelenítés",
|
||||
"hide": "Elrejtés",
|
||||
"onlyThisChannel": "Csak ez a csatorna",
|
||||
"resetState": "Alaphelyzet — nézési előzmény/státusz törlése",
|
||||
"thisChannel": "Ez a csatorna",
|
||||
"continueTitle": "Folytatás ott, ahol abbahagytad",
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
{
|
||||
"title": "Szerkesztés: „{{name}}”",
|
||||
"playPause": "Lejátszás / szünet",
|
||||
"setIn": "Kezdet itt",
|
||||
"setOut": "Vég itt",
|
||||
"in": "Kezdet",
|
||||
"out": "Vég",
|
||||
"selected": "Kijelölés",
|
||||
"cropOff": "Vágókeret",
|
||||
"cropOn": "Vágókeret bekapcsolva",
|
||||
"cropReencode": "A vágókeret mindig újrakódol (frame-pontos).",
|
||||
"split": "Feldarabolás",
|
||||
"accurate": {
|
||||
"label": "Pontos vágás",
|
||||
"hint": "Frame-pontos. Újrakódolja a klipet."
|
||||
|
||||
@@ -9,11 +9,21 @@ import { describe, expect, it } from "vitest";
|
||||
// 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> = {};
|
||||
// anyone) holds it as a whole string literal, or matches the shape of a DYNAMIC key — a template
|
||||
// or concatenation that opens with a key head, each interpolation any segment: `plex.sort.${k}`
|
||||
// covers `plex.sort.*`, `editor.${m}.hint` only `editor.*.hint`. A shape that is nothing but the
|
||||
// namespace and one interpolation (`card.${status}`) would cover the whole file — it hid five dead
|
||||
// keys — so it covers nothing; a key it reads that nothing else names is listed below. 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 LOG_FAILURE = "AdminLogs reads `adminLogs.${failure}`, a word its query hook returns";
|
||||
const ALLOWED: Record<string, string> = {
|
||||
"adminLogs.olderFailed": LOG_FAILURE,
|
||||
"adminLogs.stale": LOG_FAILURE,
|
||||
"adminLogs.countsFailed": LOG_FAILURE,
|
||||
};
|
||||
// Namespaces that are one backend enum each, and held key-for-value to it by backendIds.contract.
|
||||
const BACKEND_ENUMS = ["auditActions.", "quotaActions."];
|
||||
|
||||
const SRC = fileURLToPath(new URL("../", import.meta.url));
|
||||
|
||||
@@ -42,16 +52,45 @@ const keys = readdirSync(locales).flatMap((f) =>
|
||||
);
|
||||
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]!);
|
||||
// Each dynamic key as a SHAPE, every interpolation a `*`: `plex.sort.*`, `editor.*.hint`,
|
||||
// `config.choices.*.*` — from a template literal that opens with a key head, or a head concatenated.
|
||||
const shapes = [
|
||||
...[...code.matchAll(/`([\w.]+\.(?:\$\{[^}`]*\}|[\w.-])*)`/g)].map((m) => m[1]!),
|
||||
...[...code.matchAll(/["'`]([\w.]+\.)["'`]\s*\+/g)].map((m) => `${m[1]!}\${}`),
|
||||
]
|
||||
.filter((s) => s.includes("${"))
|
||||
.map((s) => s.replace(/\$\{[^}]*\}/g, "*"));
|
||||
// A shape that is one namespace and one `*` covers nothing (see above).
|
||||
const namespaceWide = (shape: string) => /^[\w-]+\.\*$/.test(shape);
|
||||
const patterns = shapes
|
||||
.filter((s) => !namespaceWide(s))
|
||||
.map(
|
||||
(s) =>
|
||||
new RegExp(
|
||||
`^${s
|
||||
.split("*")
|
||||
.map((part) => part.replace(/[.$]/g, "\\$&"))
|
||||
.join("[\\w.-]+")}$`,
|
||||
),
|
||||
);
|
||||
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));
|
||||
literals.has(k) ||
|
||||
literals.has(base(k)) ||
|
||||
patterns.some((p) => p.test(k) || p.test(base(k))) ||
|
||||
BACKEND_ENUMS.some((ns) => k.startsWith(ns));
|
||||
|
||||
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.");
|
||||
expect(used("plex.sort.newest")).toBe(true); // `plex.sort.${k}`
|
||||
expect(shapes).toContain("editor.*.hint"); // `editor.${m}.hint`
|
||||
expect(used("editor.accurate.hint")).toBe(true);
|
||||
expect(used("editor.accurate.nope")).toBe(false);
|
||||
// A bare namespace head covers nothing — the hole that hid five dead keys.
|
||||
expect(shapes).toContain("card.*");
|
||||
expect(used("card.anythingAtAll")).toBe(false);
|
||||
});
|
||||
|
||||
it("are all read somewhere in the app", () => {
|
||||
|
||||
Reference in New Issue
Block a user