/** * lib/i18n-keys.ts — i18n key extraction + resolution for skill CLIs. * * Promoted from the inlined logic in * `development/frontend/component/cli/validate-page/execute.ts` * (rule `i18n-keys-resolve`) so the per-page gate and the module-wide * DEV-UI-038 audit share ONE implementation of: * - `extractTCalls` — every `t('key')` / `t('ns:key')` of a TSX source, * with the default namespace resolved from `useTranslation('NS')` and a * `hasDefaultValue` flag (each consumer decides whether to skip those); * - `resolveI18nKey` — walk a dotted key through a nested catalogue and * report 'string' | 'object' | 'missing' (an OBJECT result is the shipped * label/children collision — i18next renders the raw key on screen); * - `flattenI18nLeaves` — flat `dotted.key → value` map of a catalogue's * string leaves (locale key-set parity checks). */ export const LOCALES = ['fr', 'en', 'it', 'de'] as const export type Locale = (typeof LOCALES)[number] export interface TCall { /** Explicit `ns:` prefix, else the source's default namespace (null when none). */ namespace: string | null /** Dotted key (namespace prefix stripped). */ key: string /** 1-based source line of the `t(` call. */ line: number /** True when the call carries an inline `{ defaultValue: … }` fallback. */ hasDefaultValue: boolean } /** * Extract every `t('…')` call of a TSX/TS source. The default namespace of * prefix-less calls is the source's first `useTranslation('NS')`; * `fallbackNamespace` applies only when the source declares none (null → * such calls carry `namespace: null`). */ export function extractTCalls(source: string, fallbackNamespace: string | null = null): TCall[] { const nsCall = source.match(/useTranslation\(\s*['"]([^'"]+)['"]\s*\)/) const defaultNs = nsCall ? nsCall[1]! : fallbackNamespace const calls: TCall[] = [] const tRe = /\bt\(\s*['"]([^'"]+)['"]/g let m: RegExpExecArray | null while ((m = tRe.exec(source)) !== null) { const raw = m[1]! // `t('key', { defaultValue: … })` renders the inline default when the // catalogue entry is absent — flagged, not silently dropped, so gates can // choose their own severity for it. const afterKey = source.slice(m.index + m[0].length, m.index + m[0].length + 160) const hasDefaultValue = /^\s*,\s*\{[^}]*defaultValue/.test(afterKey) const line = (source.slice(0, m.index).match(/\n/g) ?? []).length + 1 if (raw.includes(':')) { const [ns, ...rest] = raw.split(':') calls.push({ namespace: ns!, key: rest.join(':'), line, hasDefaultValue }) } else { calls.push({ namespace: defaultNs, key: raw, line, hasDefaultValue }) } } return calls } export type KeyResolution = 'string' | 'object' | 'missing' /** * Walk `dottedKey` through a nested catalogue. 'object' means the key exists * but points at a subtree (label/children collision — i18next renders the raw * key); 'missing' means a segment is absent or crosses a string leaf. */ export function resolveI18nKey(tree: Record, dottedKey: string): KeyResolution { let node: unknown = tree for (const segment of dottedKey.split('.')) { if (node === null || typeof node !== 'object' || Array.isArray(node)) return 'missing' node = (node as Record)[segment] if (node === undefined) return 'missing' } if (typeof node === 'string') return 'string' if (node !== null && typeof node === 'object' && !Array.isArray(node)) return 'object' return 'missing' } /** Flat `dotted.key → value` map of a catalogue's STRING leaves (non-string * leaves are skipped — generated catalogues are string-only). */ export function flattenI18nLeaves(node: unknown, prefix = ''): Map { const out = new Map() if (node === null || typeof node !== 'object' || Array.isArray(node)) return out for (const [k, v] of Object.entries(node as Record)) { const key = prefix === '' ? k : `${prefix}.${k}` if (typeof v === 'string') out.set(key, v) else if (v !== null && typeof v === 'object' && !Array.isArray(v)) { for (const [ck, cv] of flattenI18nLeaves(v, key)) out.set(ck, cv) } } return out }