Files
kandev/apps/web/eslint.i18n.options.mjs
T
2026-08-01 23:14:50 +01:00

184 lines
8.1 KiB
JavaScript

/**
* Options for `i18next/no-literal-string`, the guard against hardcoded
* user-facing strings. Kept in its own module because the list is long enough to
* bury the rest of eslint.config.mjs, and because it needs the explanatory
* comments below to stay maintainable.
*/
export const noLiteralStringOptions = {
// `jsx-only` (not `jsx-text-only`) so the guard also sees copy that
// never appears as a JSX text node: ternary button labels
// (`{saving ? "Saving..." : "Save"}`) and display props on internal
// components (`label=`, `description=`, `tooltip=`). Those are the
// majority of user-facing strings in this codebase, and the narrower
// mode reported them as clean. The cost is that every attribute is
// now checked, so the `words.exclude` list below has to carry the
// weight of separating copy from prop enum values.
mode: "jsx-only",
// Template literals in JSX ARE copy: `Select task ${title}`, `${label} tasks,
// over WIP limit`. Leaving them unchecked was the largest hole in the guard, and
// turning it on measured cheap — +2 to +5 findings per un-migrated directory,
// every one of them a real string, and zero new findings across the allowlist
// (className templates are already covered by the Tailwind patterns below).
"should-validate-template": true,
// Brand/proper nouns and symbol-only strings are not translatable copy.
//
// NOTE: the plugin wraps each pattern as `^<pattern>$`
// (helper/generateFullMatchRegExp), so every entry must match the WHOLE
// literal. A prefix-only pattern like "^https?://" silently never
// matches — add an explicit `.*` instead.
words: {
exclude: [
"^\\s*$",
"^[^A-Za-z]*$",
"^(Kandev|GitHub|GitLab|Jira|Linear|Slack|Sentry|Azure DevOps|Docker|Codex|OpenCode|Claude|Copilot|Amp|Sprites\\.dev)$",
"^(ACP|MCP|SSH|URL|ID|PR|CI|AI|API|JSON|YAML|LSP|TLS|SQL|JQL)$",
// Units, version prefixes, and keyboard glyphs — not translatable
// copy; these show up as fragments beside an interpolated value.
"^(v|ms\\)?|s|m|h|d|K|B|KB|MB|GB|TB|esc)$",
"^\\+[A-Z]\\)?$",
"^[·+\\-|/(),.:\\s]+$",
// All-caps acronym badges (ENTRY, KAN, MTD, WIQL, JQL) label a
// field or entity; they are identifiers, not prose.
"^[A-Z][A-Z0-9_]+$",
// Multi-word ALL-CAPS tokens are type-to-confirm phrases ("DELETE ALL NOW").
// The user must type them verbatim and they are compared with `===`, so
// translating one makes the dialog impossible to satisfy — see
// docs/i18n.md ("Do not translate").
"[A-Z][A-Z0-9_]*( [A-Z][A-Z0-9_]*)+",
// Terminal control glyphs (^C, ^D) and repeat counts (3x) are
// symbols, and "id · vN.N" is a version line, not prose.
"^\\^[A-Z]$",
"^·?\\s*v?$",
// Keyboard key names label a physical key, so they are not translatable
// copy — the spec lists keyboard glyphs as out of scope. They appear as
// <Kbd> children and on keybar config.
"(Esc|Escape|Tab|Home|End|PgUp|PgDn|Enter|Return|Space|Backspace|Delete|Del|Ins|Shift|Ctrl|Alt|Cmd|Meta|F\\d{1,2})",
// URLs, home-relative paths, dotted placeholder tokens, and a
// single letter (an avatar initial) are values, not prose.
"(https?|file|ssh|git)://.*",
"^~?/[\\w./~-]*$",
"^[a-z][a-z0-9]*(\\.[a-z0-9<>]+)+$",
"^[A-Za-z]$",
// Tailwind class lists with variants/important modifiers.
".*[!\\[].*",
// Example values shown in placeholders: emails, CSS functions,
// inline JSON, and ALLCAPS filename stand-ins.
"[\\w.+-]+@[\\w-]+\\.[\\w.-]+",
".*(calc|env|url|var)\\(.*",
"\\{.*\\}",
"[A-Z][A-Z0-9_]*\\.[a-z]{2,4}",
// Static chunks of an interpolated DOM id. With
// `should-validate-template` on, the plugin checks each literal chunk of a
// template separately, so `startup-page-${v}-label` arrives as
// "startup-page-" and "-label" — kebab/snake fragments with a dangling
// separator, which the token patterns above do not cover. Copy chunks
// ("Select task ", " tasks, over WIP limit") carry a capital, a space or
// punctuation and so still get flagged.
"[a-z0-9]+(?:[-_][a-z0-9]*)*|[-_][a-z0-9]+(?:[-_][a-z0-9]*)*",
// Single lowercase/camel/kebab tokens are prop enum values,
// classnames, and identifiers (variant="ghost", side="top",
// value="work-items") — never display copy, which is capitalized
// or multi-word.
"^[a-z][a-zA-Z0-9]*$",
"^[a-z0-9]+([-_][a-z0-9]+)+$",
// CSS lengths, colors, Tailwind class lists, link rel/target
// keywords, route paths, and `__sentinel__` option values.
"^\\d+(\\.\\d+)?(px|rem|em|%|vh|vw|ch|fr|s|ms|d|h|m|w|y)$",
"^#[0-9a-fA-F]{3,8}$",
"^_(blank|self|parent|top)$",
"^(noopener|noreferrer)( (noopener|noreferrer))*$",
"^__[a-z_]+__$",
"^/[\\w/\\-\\[\\]:.]*(\\?[\\w=&%.\\-]*)?$",
"(?:-?[a-z0-9]+(?:[:/-][a-z0-9.]+)*\\s+)*-?[a-z0-9]+(?:[:/-][a-z0-9.]+)*",
],
},
"jsx-attributes": {
// Attributes that carry display copy and must be translated.
include: ["placeholder", "aria-label", "aria-description", "title", "alt"],
exclude: [
".*[Cc]lassName$",
"class",
"id",
"key",
"type",
"name",
"role",
"href",
"src",
"to",
"htmlFor",
"data-.*",
// Identifiers and prefixes the caller composes into ids/testids.
"id",
"k",
// Option/badge values are data the app compares and submits.
"value",
"cmd",
".*[Ii]dPrefix$",
".*[Ii]dSuffix$",
".*SaveId$",
"aria-labelledby",
"aria-controls",
"aria-describedby",
],
},
callees: {
// String args to these are identifiers/classnames, not copy.
exclude: [
"cn",
"clsx",
// `skipAll("User skipped")` records a reason sent to the server
// alongside the skip; it is stored data, not rendered copy.
".*\\.skipAll",
"cva",
"tv",
"t",
"i18n(ext)?.*",
"require",
"console\\.\\w+",
".*\\.(getAttribute|setAttribute|matches|closest|querySelector)",
],
},
};
/**
* Allowlist of paths the `i18next/no-literal-string` ERROR applies to.
*
* Deliberately NOT `components/**` + `app/**`. A repo-wide error means every PR
* that lands a hardcoded string anywhere — including PRs that have nothing to do
* with i18n — breaks. The first attempt at this migration shipped the global
* form and spent two days in conflict-resolution rounds because `main` kept
* moving underneath it.
*
* Instead the guard is opt-in per path, so it can only ever tighten:
*
* - Migrating a page or directory? Externalize its strings, then append its
* path here in the SAME PR. That path is now permanently protected.
* - Everything not listed is unaffected, so unrelated PRs never see this rule
* and there is no treadmill.
*
* Entries may be single files or directory globs — use a file glob while a
* directory is partially migrated, and collapse to `dir/**` once it is done.
* The list only shrinks by mistake; adding to it is the whole point.
*
* A clean lint is NOT proof a path is fully migrated — the rule only sees plain
* literals in JSX. Template literals, `confirm()`/`alert()` arguments, and copy
* returned from plain `.ts` helpers are invisible to it. The pseudo-locale is
* the completeness check. See docs/i18n.md.
*/
export const i18nGuardFiles = [
// The i18n runtime itself.
"lib/i18n/**/*.{ts,tsx}",
// Settings → General → Appearance, migrated end-to-end as the worked example:
// the page, the two sections it owns, and the settings chrome around them.
"app/settings/general/appearance/**/*.{ts,tsx}",
"components/app-sidebar/sections/settings/general-group.tsx",
"components/settings/general-settings.tsx",
"components/settings/general-nav.ts",
"components/settings/language-settings.tsx",
"components/settings/settings-floating-save.tsx",
"components/settings/settings-layout-client.tsx",
"components/settings/startup-page-settings-card.tsx",
"components/settings/system-metrics-settings-card.tsx",
];