diff --git a/I18N_ENFORCEMENT_GUIDE.md b/I18N_ENFORCEMENT_GUIDE.md index ddf36e132e..c509d40e96 100644 --- a/I18N_ENFORCEMENT_GUIDE.md +++ b/I18N_ENFORCEMENT_GUIDE.md @@ -3,13 +3,28 @@ How the OpenObserve web app guarantees that user-facing text is translatable, and what to do when a check fires. -**Status: enforcement complete and green.** `lint:ci` 0 errors · `type-check:app` -0 errors · spec-inclusive `type-check` 0 errors · `format:check` clean. +**Status: enforcement complete.** `lint:ci` 0 errors · `type-check:app` **exit 0** +(with `--composite false`, which the npm script passes, no TS6307 surfaces) · +`format:check` clean. + +> **Known measurement gap:** the "spec-inclusive" `type-check` (tsconfig.vitest.json) +> currently checks **no spec files at all** — its include is `src/**/*.spec.{ts,js}`, +> and TypeScript globs do **not** brace-expand, so the pattern matches nothing. +> Any past "spec-inclusive type-check 0 errors" claim was vacuous. Fixing it means +> splitting the include into `src/**/*.spec.ts` + `src/**/*.spec.js` AND then fixing +> the spec-only type errors that surface (e.g. `metricGrouping.spec.ts` assigns keys +> that don't exist in en-US to `I18nKey` fixtures). Tracked in §9. The mechanisms are done and so is the message cleanup — **0 fake plurals** remain in -en-US. What is left is narrower: **non-English plural rules**, the last **6 `gt` sites**, -and **one CI gap**. See [§9 Outstanding work](#9-outstanding-work) — that section is the -to-do list, kept in sync with measurements. +en-US, the 10 surviving `gt()` calls are each individually justified (§8), `TEXT_ATTRS` +has been retired in favour of prop types (§8), and the dead-key sweep is complete (§9). +What is left is narrower: **non-English plural rules** and **spec type-checking** +(a broken tsconfig glob plus a missing PR gate, §9.3). See +[§9 Outstanding work](#9-outstanding-work) — that section is the to-do list, kept in +sync with measurements. + +Converting the 71 dynamic key sites so they can be type-checked was **considered and +declined** — it is recorded under §4 as an accepted limit, not pending work. --- @@ -89,11 +104,11 @@ only two remaining `(s)` values are the seconds unit. Keep it that way. `{{ t('common.save') }}` · `:label="t('x')"` · `:label="row.name"` (a variable contributes no literal) · `:label="'—'"` (no letters). -### Two curated lists +### One curated list + +`TEXT_ATTRS` is **gone** — prop types now decide what counts as a text prop, so there +is no list to maintain (§8). Declaring a prop `I18nText` is what makes it enforced. -- **`TEXT_ATTRS`** (47 names) — props that carry user-facing text. Feeds both the - static and bound rules, so "what counts as a text prop" is defined once. **Add a - name here when a component takes UI text through a new prop.** - **`NON_TRANSLATABLE`** (39 entries) — tokens that must read identically in every language: units (`px`, `ms`, `ns`, `min`), symbols (`×`, `→`, `~`), protocol and spec identifiers (`GET`, `UTC`, `SQL`, `PromQL`, the OpenTelemetry statuses @@ -121,7 +136,7 @@ follows a pattern the library already used for icons (`iconLeft?: IconName`). | **`I18nKey`** | a field holding an **i18n key as data** (`titleKey`, `labelKey`) | | **`useI18nTyped()`** | replaces `useI18n()` in components — `t` returns `I18nText` | | **`TranslateFn`** | the type of `t` when a composable/util takes it as a **parameter** | -| **`gt()`** | last resort for genuinely context-free code — **1 file**, see below | +| **`gt()`** | last resort for genuinely context-free code — **4 files**, see below | | **`raw()`** | the explicit opt-out for text that must not be translated | ```ts @@ -155,7 +170,7 @@ presets.ts(76,5): error TS2820: Type '"emptyState.noLogs.titel"' is not assignab to type 'I18nKey'. Did you mean '"emptyState.noLogs.title"'? ``` -Cost: **~31 s** for the full app type-check with the 10,665-key union. Not a +Cost: **~31 s** for the full app type-check with the ~9,800-key union. Not a perf concern. ### Getting a `t`: three cases, in order of preference @@ -179,7 +194,7 @@ perf concern. } ``` - This is the standard for non-component code (**27 files**). It is preferred over + This is the standard for non-component code (**35 files**). It is preferred over `gt()` because these functions are also called directly by specs, outside any component, where `useI18n()` would throw. Specs pass the real translator: @@ -192,13 +207,27 @@ perf concern. composable translates, `t` belongs on that function, not on the composable. 3. **Genuinely context-free code** → `gt("some.key")`. Only where there is neither a - setup context nor a caller to thread `t` through. Exactly **one** file qualifies: - `useUnauthorizedErrorGrouper.ts`, reached from an axios 403 interceptor registered - once at module load (interceptor → 300 ms `setTimeout` → toast → click handler). + setup context nor a caller to thread `t` through. Exactly **four** files qualify + (10 call sites), each for a distinct structural reason: + + - `useUnauthorizedErrorGrouper.ts` (6) — reached from an axios 403 interceptor + registered once at module load (interceptor → 300 ms `setTimeout` → toast → + click handler). The founding case. + - `ingestion/setupCard/content/kubernetes.ts` (2) — the setup-card registry pins + every builder to `(subs: CardSubstitutions) => RichCardContent`; threading `t` + would change that shared contract for ~20 cards. Static prose in cards uses + `descriptionKey`/`helpKey` (renderer-resolved); `gt` covers only the one + description that interpolates a URL, translated at card-build time. + - `utils/query/searchError.ts` (1) — the translated **default** of an optional + `fallback: I18nText` parameter, evaluated per call; callers that hold `t` + override it (e.g. `parseSearchError(err, gt("search.unknownError"))`). + - `usePanelPromQLExecutor.ts` (1) — reached through composable chains that are + not guaranteed to run in a setup context, where `useI18n()` would throw. `gt` is deliberately a narrow escape hatch, not an alternative to case 2. Before - reaching for it, check whether a caller can pass `t` instead — 43 of the original - 49 `gt` sites turned out to be case 2. + reaching for it, check whether a caller can pass `t` instead — the overwhelming + majority of historical `gt` sites (including the import validators and + `useQualityDetailCharts`, converted since) turned out to be case 2. > **Keys resolved at display time, not module load.** When storing keys as data, keep > the `I18nKey` map at module scope but resolve it inside the function that renders. @@ -250,11 +279,18 @@ annotation is made by hand at the declaration rather than by a pattern match. | `t()` keys — literal | enforced (ESLint, `.vue` **and** `.ts`) | | i18n keys stored as data | enforced (`I18nKey`, 19 files) | | Toast / notification text | enforced (`I18nText`, incl. the store + wrappers) | -| Text reached via `useI18nTyped()` | branded across **663** files | +| Text reached via `useI18nTyped()` | branded across **668** files | | Composables / utils taking `t: TranslateFn` | **27** files | -**Numbers:** en-US grew 10,216 → **10,665 keys**; **311** sit under `toastMessages.*` -(28 module groups). 62 `raw()` opt-outs. **6** `gt()` call sites, all in one file. +**Numbers** (re-measured 2026-07-31, after the post-review debt cleanup): en-US holds +**9,837 keys** across **72 namespaces** (10,216 → 11,002 as text was migrated in, +→ 9,648 after the dead-key sweep §9, → 9,837 as the remaining hardcoded prose — +import validators, setup-card descriptions, status labels, dialog buttons — was +keyed); **309** sit under `toastMessages.*`. **977** `raw()` opt-outs — the count +rose steeply when props became `I18nText`, since every genuinely non-translatable +value (units, tokens, glyphs, API data) has to say so explicitly, then fell as +`raw()`-wrapped prose was converted to keys. **10** `gt()` call sites in **4** +files (see §3 case 3 for the per-file justification). **Where the two checkers divide.** `no-missing-keys` validates `t('x.y')` **calls**; it cannot see a key assigned to a field. Keys stored as **data** are validated instead @@ -264,27 +300,54 @@ be green to claim key coverage; neither alone is sufficient. ### Not covered -- **Dynamic keys** — ``t(`about.feature_${id}`)`` (**312** sites). No lint or type - check can resolve these. The `t` key parameter is deliberately permissive - (`I18nKey | (string & {})`) so they keep compiling. +- **Dynamic keys — 71 sites. Considered and DECLINED; do not re-open as pending work.** + + ``t(`about.feature_${id}`)`` and similar. A renamed or deleted key reached this way is + caught by nothing: ESLint sees no literal, `t`'s permissive + `I18nKey | (string & {})` parameter accepts any string, vue-i18n returns the key + instead of throwing, and the production build strips its dev warning. It renders the + raw dotted path on screen. + + Nothing is currently broken — every key these sites can request was expanded and + verified present. The exposure is a future rename, concentrated in `onlineEvals` + (40 of the 71 sites). + + Closing it would mean removing the `(string & {})` arm, which surfaces **117 errors**: + ~80 are unrelated consumers declaring their translator loosely as + `(key: string) => string` (a contravariance failure, not a key problem), 15 need a + variable widened to a literal union, 9 need a `Record` map, and a + handful are genuine key/code mismatches. Two-thirds of the work is therefore not about + dynamic keys at all, which is why this was judged not worth it as a standalone project. + + If the guarantee is ever wanted, the cheap route is a second strict export + (`(key: I18nKey)`) used by new code only — no flag day, no 98-file PR. + + > Count these with a word boundary: `grep 't(\`'` also matches `` fetch(`…`) `` and any +other call ending in `t`, which inflates the figure roughly fourfold. A further 21 + > sites use backticks but interpolate nothing — those are ordinary static keys. + - **Interfaces not yet annotated.** `I18nText` guards what it is applied to. That is _incomplete_, never _wrong_ — coverage grows one declaration at a time, at the definition site, with no central registry to keep in sync. - **Other locales.** The 14 non-en locales are generated from en-US and lag behind; `localeDir` points at en-US only, on purpose. Never hand-edit them. -- **Unused keys.** `@intlify/vue-i18n/no-unused-keys` is available but **not - enabled**. Measured on this repo it reports 2,421 keys of which only ~1,302 are - genuinely dead — 681 are reached via a key-string, 437 via a dynamic prefix. If - you enable it, use `warn`, never `enableFix`, and populate `ignores` with the - dynamic prefixes; its autofix would delete live translations. +- **Unused keys.** `@intlify/vue-i18n/no-unused-keys` is available but **not enabled**. + It reports 2,421 keys, but it only sees `t('literal')` calls — so keys reached + through a variable or a dynamic prefix look unused to it, and its autofix would + delete them. A direct measurement (literal tokens + dynamic prefixes + the one + concatenation site) put the genuinely dead set at **1,363**, since deleted (§9). + If you ever enable the rule, use `warn`, never `enableFix`, and populate `ignores` + with the dynamic prefixes. Note that even the direct measurement missed 9 live keys + (§9) — no automated sweep sees keys held in JSON data files. --- ## 5. Deliberately deferred: `strictTemplates` -Typing a component prop `label: I18nText` only gates `` if -Vue's `strictTemplates` is on. It is **not** enabled, which is why `TEXT_ATTRS` -still exists. +A **declared** prop typed `label: I18nText` gates `` with or +without `strictTemplates` — declared props are always checked. `strictTemplates` +governs **undeclared** attributes only. This was verified by probe, and it is what +allowed `TEXT_ATTRS` to be retired (§8) while `strictTemplates` stays off. Measured twice on this branch: @@ -297,10 +360,9 @@ Of those, ~303 are genuine `TS2322` type mismatches (real latent bugs); the rest undeclared pass-through attributes. **It was left out on purpose.** It is a large _type-safety_ migration, not i18n work, -and its i18n payoff is only retiring the `TEXT_ATTRS` list — whose real gap was -measured at **five sites** (all fixed here: `reveal-tooltip`, `hide-tooltip`, -`unstable-dimension-tooltip`, `date-disabled-tooltip`). Worth doing as its own PR; -not worth burying this one under 2,655 unrelated errors. +and it turned out to carry **no i18n payoff at all** — retiring `TEXT_ATTRS` did not +require it. Worth doing as its own PR for the ~303 genuine `TS2322` mismatches; not +worth burying this one under 2,655 unrelated errors. --- @@ -320,8 +382,17 @@ not worth burying this one under 2,655 unrelated errors. | A recurring unit/symbol/code token | add to `NON_TRANSLATABLE` with a one-line reason | | A whole file that is genuinely code | add to the SyntaxGuide exemption block | -New text-carrying **component prop** → add its name to `TEXT_ATTRS`. +New text-carrying **component prop** → declare it `I18nText`. In ` diff --git a/web/src/components/common/empty-states/QuickStartCard.vue b/web/src/components/common/empty-states/QuickStartCard.vue index 6593d5a30c..514b4f17ed 100644 --- a/web/src/components/common/empty-states/QuickStartCard.vue +++ b/web/src/components/common/empty-states/QuickStartCard.vue @@ -46,12 +46,13 @@ along with this program. If not, see . diff --git a/web/src/components/common/sidebar/SearchFieldList.spec.ts b/web/src/components/common/sidebar/SearchFieldList.spec.ts index d7a7d1eb0b..a65535a711 100644 --- a/web/src/components/common/sidebar/SearchFieldList.spec.ts +++ b/web/src/components/common/sidebar/SearchFieldList.spec.ts @@ -114,6 +114,11 @@ const mockI18n = createI18n({ search: { searchField: "Search field", }, + // The component passes t("common.valueCopiedToClipboard") to + // copyToClipboard; without the key this mock echoes the key path. + common: { + valueCopiedToClipboard: "Value copied to clipboard", + }, }, }, }); diff --git a/web/src/components/common/sidebar/SearchFieldList.vue b/web/src/components/common/sidebar/SearchFieldList.vue index c35e443022..2e05d54544 100644 --- a/web/src/components/common/sidebar/SearchFieldList.vue +++ b/web/src/components/common/sidebar/SearchFieldList.vue @@ -654,7 +654,7 @@ const addSearchTerm = (term: string) => { }; const copyContentValue = (value: string) => { - copyToClipboard(value, t, { successMessage: "Value copied to clipboard" }); + copyToClipboard(value, t, { successMessage: t("common.valueCopiedToClipboard") }); }; diff --git a/web/src/components/cross-linking/CrossLinkDialog.vue b/web/src/components/cross-linking/CrossLinkDialog.vue index 1659e94b65..a2282eccb7 100644 --- a/web/src/components/cross-linking/CrossLinkDialog.vue +++ b/web/src/components/cross-linking/CrossLinkDialog.vue @@ -112,7 +112,7 @@ diff --git a/web/src/lib/core/EmptyState/EmptyStateIngestionCard.vue b/web/src/lib/core/EmptyState/EmptyStateIngestionCard.vue index c252884b89..814d4ba78a 100644 --- a/web/src/lib/core/EmptyState/EmptyStateIngestionCard.vue +++ b/web/src/lib/core/EmptyState/EmptyStateIngestionCard.vue @@ -44,6 +44,7 @@ along with this program. If not, see . diff --git a/web/src/lib/core/Table/cells/statusVariant.ts b/web/src/lib/core/Table/cells/statusVariant.ts index 23a4ca874a..92fc376ab3 100644 --- a/web/src/lib/core/Table/cells/statusVariant.ts +++ b/web/src/lib/core/Table/cells/statusVariant.ts @@ -9,6 +9,8 @@ // const { variant, label, dot } = statusVariant("paused", "pipeline"); // {{ label }} +import { raw, type I18nText } from "@/types/i18n"; + import type { BadgeVariant } from "@/lib/core/Badge/OBadge.types"; /** Semantic tone — the calm, low-chroma "soft" badge family reads best at @@ -131,7 +133,7 @@ const DOMAIN_TONE: Record> = { export interface StatusVariantResult { variant: BadgeVariant; tone: StatusTone; - label: string; + label: I18nText; dot: boolean; } @@ -150,8 +152,8 @@ export function humanizeStatus(value: string): string { * @param domain optional feature key for overrides ("invoice"|"eval"|"service"|"node"|…). */ export function statusVariant(value: unknown, domain?: string): StatusVariantResult { - const raw = typeof value === "boolean" ? String(value) : String(value ?? "").trim(); - const key = raw.toLowerCase(); + const rawValue = typeof value === "boolean" ? String(value) : String(value ?? "").trim(); + const key = rawValue.toLowerCase(); let tone: StatusTone | undefined; @@ -174,7 +176,8 @@ export function statusVariant(value: unknown, domain?: string): StatusVariantRes return { variant: TONE_VARIANT[tone], tone, - label: raw ? humanizeStatus(raw) : "—", + // A humanized status token, or an em-dash placeholder — neither is prose. + label: raw(rawValue ? humanizeStatus(rawValue) : "—"), dot: true, }; } diff --git a/web/src/lib/core/Table/sub-components/OTableEmpty.vue b/web/src/lib/core/Table/sub-components/OTableEmpty.vue index b3e14a57c0..9277a11516 100644 --- a/web/src/lib/core/Table/sub-components/OTableEmpty.vue +++ b/web/src/lib/core/Table/sub-components/OTableEmpty.vue @@ -1,7 +1,7 @@ diff --git a/web/src/lib/core/ToggleGroup/OToggleGroup.types.ts b/web/src/lib/core/ToggleGroup/OToggleGroup.types.ts index 8322a1a9e0..38bcdebf6e 100644 --- a/web/src/lib/core/ToggleGroup/OToggleGroup.types.ts +++ b/web/src/lib/core/ToggleGroup/OToggleGroup.types.ts @@ -2,6 +2,8 @@ * OToggleGroup.types.ts — public types for OToggleGroup. */ +import type { I18nText } from "@/types/i18n"; + import type { AcceptableValue } from "reka-ui"; import type { ComputedRef, InjectionKey } from "vue"; @@ -35,7 +37,7 @@ export interface ToggleGroupProps { /** Visual variant — use 'primary' when the toggle sits on a primary-colored bar */ variant?: ToggleGroupVariant; /** Label text rendered next to the toggle bar. For richer content use the `label` slot. */ - label?: string; + label?: I18nText; /** Position of the label relative to the toggle bar */ labelPosition?: "left" | "right" | "top"; /** diff --git a/web/src/lib/core/ToggleGroup/OToggleGroupItem.types.ts b/web/src/lib/core/ToggleGroup/OToggleGroupItem.types.ts index b29b072c04..8e76c30f3e 100644 --- a/web/src/lib/core/ToggleGroup/OToggleGroupItem.types.ts +++ b/web/src/lib/core/ToggleGroup/OToggleGroupItem.types.ts @@ -2,6 +2,8 @@ * OToggleGroupItem.types.ts — public types for OToggleGroupItem. */ +import type { I18nText } from "@/types/i18n"; + import type { AcceptableValue } from "reka-ui"; /** Size controls padding and font-size — height is determined by content */ @@ -13,7 +15,7 @@ export interface ToggleGroupItemProps { /** Disables only this item */ disabled?: boolean; /** Tooltip shown on hover — especially useful when disabled is true to explain why */ - tooltip?: string; + tooltip?: I18nText; /** Size controls padding and font-size. md (default) = full toolbar; sm = small toolbar; xs = compact inline toggle */ size?: ToggleGroupItemSize; /** Icon name (from OIcon registry) placed before the label. For custom icon components, use the `icon-left` slot. */ diff --git a/web/src/lib/data/StatStrip/OStatCard.vue b/web/src/lib/data/StatStrip/OStatCard.vue index 14fb596328..86d19ec336 100644 --- a/web/src/lib/data/StatStrip/OStatCard.vue +++ b/web/src/lib/data/StatStrip/OStatCard.vue @@ -1,4 +1,5 @@