@urbicon-ui/i18n 6.3.8 → 6.3.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,207 @@
1
+ /**
2
+ * `auditTranslations` — data-level translation quality & parity audit.
3
+ *
4
+ * The richer successor to {@link validatePackageTranslations}: a pure function
5
+ * over a package's locale bundles (no source scan, no I/O, deterministic, zero
6
+ * false positives) that reports structured findings instead of opaque strings.
7
+ *
8
+ * Consumers run it in a vitest test to fail CI on drift —
9
+ * `expect(auditTranslations('app', bundles).ok).toBe(true)` — or via the
10
+ * `urbicon i18n parity` CLI, which formats the same findings. Key parity is the
11
+ * baseline (missing/extra keys); on top of it sit the checks a structural diff
12
+ * can't see: empty values, interpolation-param drift between locales, malformed
13
+ * or CLDR-incomplete `_plural` objects, placeholder leftovers, and (opt-in)
14
+ * not-yet-translated strings identical to the base locale.
15
+ */
16
+ import { isLocaleSupported, SUPPORTED_LOCALES } from '../i18n/types.js';
17
+ import { collectDeepKeys, getDeepValue } from '../utils/deep-keys.js';
18
+ import { makeGlobMatcher } from './glob.js';
19
+ /** Defaults: everything on except the FP-prone, opt-in `same-as-base`. */
20
+ const DEFAULT_CHECKS = {
21
+ 'missing-key': true,
22
+ 'extra-key': true,
23
+ 'empty-value': true,
24
+ 'wrong-type': true,
25
+ 'param-mismatch': true,
26
+ 'plural-shape-invalid': true,
27
+ 'plural-category-incomplete': true,
28
+ 'value-equals-key': true,
29
+ 'same-as-base': false,
30
+ 'invalid-locale': true,
31
+ 'no-translations': true
32
+ };
33
+ /** Same `{{param}}` syntax the runtime interpolates (`registry.interpolate`). */
34
+ const PARAM_RE = /\{\{([^}]+)\}\}/g;
35
+ /** The interpolation params a template references, e.g. `Hello {{name}}` → `{name}`. */
36
+ function extractParamNames(template) {
37
+ const names = new Set();
38
+ for (const match of template.matchAll(PARAM_RE))
39
+ names.add(match[1].trim());
40
+ return names;
41
+ }
42
+ /** CLDR cardinal categories required for `locale`, e.g. `en` → [one, other]. */
43
+ function requiredPluralCategories(locale) {
44
+ return new Intl.PluralRules(locale).resolvedOptions().pluralCategories;
45
+ }
46
+ /** A leaf value is "translatable" (worth a same-as-base check) if it has a letter. */
47
+ function isTranslatable(value) {
48
+ return value.trim().length >= 2 && /\p{L}/u.test(value);
49
+ }
50
+ /** Readable type label for a wrong-type finding (`array`/`null` over the bare typeof). */
51
+ function describeType(value) {
52
+ if (value === null)
53
+ return 'null';
54
+ if (Array.isArray(value))
55
+ return 'array';
56
+ return typeof value;
57
+ }
58
+ const LOCALE_ORDER = ['en', 'de', 'fr', 'es', 'it', 'nl'];
59
+ function localeRank(locale) {
60
+ const i = LOCALE_ORDER.indexOf(locale);
61
+ return i === -1 ? LOCALE_ORDER.length : i;
62
+ }
63
+ /**
64
+ * Audit a package's locale bundles for parity and translation-quality issues.
65
+ *
66
+ * @param packageName Used only to prefix `detail` messages (e.g. `[blocks]`).
67
+ * @param translations Per-locale bundles, exactly as passed to `createPackageI18n`.
68
+ */
69
+ export function auditTranslations(packageName, translations, options = {}) {
70
+ const checks = { ...DEFAULT_CHECKS, ...options.checks, 'missing-key': true };
71
+ const isIgnored = makeGlobMatcher(options.ignoreKeys);
72
+ const findings = [];
73
+ const add = (code, severity, locale, key, message) => {
74
+ if (!checks[code])
75
+ return;
76
+ findings.push({ code, severity, locale, key, detail: `[${packageName}] ${message}` });
77
+ };
78
+ const locales = Object.keys(translations);
79
+ const baseLocale = options.baseLocale ?? (locales.includes('en') ? 'en' : locales[0]) ?? 'en';
80
+ const baseBundle = translations[baseLocale];
81
+ if (!baseBundle || locales.length === 0) {
82
+ add('no-translations', 'error', baseLocale, '', locales.length === 0
83
+ ? 'no translations provided'
84
+ : `base locale ${baseLocale} has no translations`);
85
+ return finalize(findings);
86
+ }
87
+ const baseKeys = collectDeepKeys(baseBundle);
88
+ const baseKeySet = new Set(baseKeys);
89
+ const baseValueOf = (key) => {
90
+ const value = getDeepValue(baseBundle, key);
91
+ return typeof value === 'string' ? value : undefined;
92
+ };
93
+ // Value-level checks run on EVERY locale (the base included — an empty or
94
+ // malformed base entry is a defect regardless). Parity checks compare each
95
+ // non-base locale against the base.
96
+ for (const locale of locales) {
97
+ const bundle = translations[locale];
98
+ if (!bundle)
99
+ continue;
100
+ // Guard before any per-value work: an unsupported tag (a typo like `de_DE`)
101
+ // would otherwise crash `Intl.PluralRules(locale)` and take the whole audit
102
+ // down. Report it as a finding and skip the locale — fail-loud, not fatal.
103
+ if (!isLocaleSupported(locale)) {
104
+ add('invalid-locale', 'error', locale, '', `unknown locale "${locale}" — not one of ${SUPPORTED_LOCALES.join(', ')}`);
105
+ continue;
106
+ }
107
+ const keys = collectDeepKeys(bundle);
108
+ const keySet = new Set(keys);
109
+ for (const key of keys) {
110
+ if (isIgnored(key))
111
+ continue;
112
+ const value = getDeepValue(bundle, key);
113
+ if (typeof value !== 'string') {
114
+ // `collectDeepKeys` treats empty objects and arrays as leaves, so a
115
+ // non-string leaf shares its path across bundles and the key-diff can't
116
+ // see it. Translations leaves must be strings — report, don't skip.
117
+ add('wrong-type', 'error', locale, key, `non-string value at "${key}" (${describeType(value)})`);
118
+ continue;
119
+ }
120
+ if (value.trim() === '') {
121
+ // A non-base hole over a non-empty base is a user-facing regression
122
+ // (empty string renders); an empty base entry is merely suspicious.
123
+ const baseHasContent = locale !== baseLocale && (baseValueOf(key) ?? '').trim() !== '';
124
+ add('empty-value', baseHasContent ? 'error' : 'warning', locale, key, `empty value at "${key}"`);
125
+ }
126
+ if (value === key) {
127
+ add('value-equals-key', 'warning', locale, key, `value equals its key at "${key}"`);
128
+ }
129
+ if (key.endsWith('_plural')) {
130
+ auditPluralValue(value, locale, key, add, checks);
131
+ }
132
+ }
133
+ if (locale === baseLocale)
134
+ continue;
135
+ for (const key of baseKeys) {
136
+ if (isIgnored(key))
137
+ continue;
138
+ if (!keySet.has(key))
139
+ add('missing-key', 'error', locale, key, `missing key "${key}"`);
140
+ }
141
+ for (const key of keys) {
142
+ if (isIgnored(key))
143
+ continue;
144
+ if (!baseKeySet.has(key))
145
+ add('extra-key', 'warning', locale, key, `extra key "${key}"`);
146
+ }
147
+ // Param / same-as-base checks need the same leaf present (as a string) in both.
148
+ for (const key of keys) {
149
+ if (isIgnored(key) || !baseKeySet.has(key))
150
+ continue;
151
+ const value = getDeepValue(bundle, key);
152
+ const baseValue = baseValueOf(key);
153
+ if (typeof value !== 'string' || baseValue === undefined)
154
+ continue;
155
+ const baseParams = extractParamNames(baseValue);
156
+ const localeParams = extractParamNames(value);
157
+ const missing = [...baseParams].filter((p) => !localeParams.has(p));
158
+ const extra = [...localeParams].filter((p) => !baseParams.has(p));
159
+ if (missing.length || extra.length) {
160
+ const parts = [
161
+ missing.length ? `missing {{${missing.join('}}, {{')}}}` : '',
162
+ extra.length ? `unexpected {{${extra.join('}}, {{')}}}` : ''
163
+ ].filter(Boolean);
164
+ add('param-mismatch', 'error', locale, key, `param drift at "${key}": ${parts.join('; ')}`);
165
+ }
166
+ if (value === baseValue && isTranslatable(baseValue)) {
167
+ add('same-as-base', 'warning', locale, key, `value identical to ${baseLocale} at "${key}"`);
168
+ }
169
+ }
170
+ }
171
+ return finalize(findings);
172
+ }
173
+ /** Parse a `<key>_plural` JSON object and flag malformed shape / missing CLDR forms. */
174
+ function auditPluralValue(value, locale, key, add, checks) {
175
+ let parsed;
176
+ try {
177
+ parsed = JSON.parse(value);
178
+ }
179
+ catch {
180
+ add('plural-shape-invalid', 'error', locale, key, `_plural value at "${key}" is not valid JSON`);
181
+ return;
182
+ }
183
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
184
+ add('plural-shape-invalid', 'error', locale, key, `_plural value at "${key}" is not an object`);
185
+ return;
186
+ }
187
+ const forms = parsed;
188
+ if (typeof forms.other !== 'string') {
189
+ add('plural-shape-invalid', 'error', locale, key, `_plural object at "${key}" lacks a string "other" form`);
190
+ return;
191
+ }
192
+ if (!checks['plural-category-incomplete'])
193
+ return;
194
+ const missing = requiredPluralCategories(locale).filter((cat) => typeof forms[cat] !== 'string');
195
+ if (missing.length) {
196
+ add('plural-category-incomplete', 'warning', locale, key, `_plural object at "${key}" is missing CLDR form(s) for ${locale}: ${missing.join(', ')}`);
197
+ }
198
+ }
199
+ /** Sort deterministically (locale → key → code) and split by severity. */
200
+ function finalize(findings) {
201
+ findings.sort((a, b) => localeRank(a.locale) - localeRank(b.locale) ||
202
+ a.key.localeCompare(b.key) ||
203
+ a.code.localeCompare(b.code));
204
+ const errors = findings.filter((f) => f.severity === 'error');
205
+ const warnings = findings.filter((f) => f.severity === 'warning');
206
+ return { ok: errors.length === 0, findings, errors, warnings };
207
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Unused-key reconciler (B3) — decide which *defined* keys no source references.
3
+ *
4
+ * A key counts as used if ANY usage layer reaches it, biased hard toward "used"
5
+ * so a live key is never flagged for deletion: exact static call, dynamic-prefix
6
+ * coverage, the loose literal-pool harvest (any string literal equal to a key),
7
+ * an explicit dynamic-key allowlist, or a runtime-observed key. What survives all
8
+ * layers is reported — `confirmed` when no opaque `t(variable)` site could be
9
+ * hiding it, else `suspect`. Separately, static render keys absent from the
10
+ * defined set surface as `used-but-undefined` (a typo / stale rename that renders
11
+ * its raw key at runtime).
12
+ */
13
+ import type { KeyUsageSite, UsageScan } from './scan/types.js';
14
+ export interface UnusedKeyFinding {
15
+ key: string;
16
+ /** `confirmed`: no usage signal and no opaque sites. `suspect`: opaque `t(var)` sites exist. */
17
+ tier: 'confirmed' | 'suspect';
18
+ }
19
+ export interface UsedButUndefinedFinding {
20
+ key: string;
21
+ sites: KeyUsageSite[];
22
+ }
23
+ export interface DynamicPrefixCoverage {
24
+ prefix: string;
25
+ /** Defined keys this prefix shields from the unused list (no other usage signal). */
26
+ shieldedKeys: number;
27
+ }
28
+ export interface UnusedReport {
29
+ /** Defined keys reached by no usage layer. */
30
+ unused: UnusedKeyFinding[];
31
+ /** Static render keys (`t('x')`) with no matching defined key. */
32
+ usedButUndefined: UsedButUndefinedFinding[];
33
+ /** Per-prefix blast radius, so a human sees what each dynamic family hides. */
34
+ dynamicPrefixCoverage: DynamicPrefixCoverage[];
35
+ /** Count of unresolved `t(variable)` sites — why `suspect` exists. */
36
+ opaqueSiteCount: number;
37
+ }
38
+ export interface FindUnusedOptions {
39
+ /** Key globs (`errors.*`) built dynamically — always treated as used. */
40
+ dynamicKeys?: string[];
41
+ /** Key globs excluded from the report entirely (intentionally retained, e.g. `legacy.*`). */
42
+ ignoreKeys?: string[];
43
+ /** Keys observed at runtime (e.g. from `createMissingKeyCollector`) — treated as used. */
44
+ runtimeUsedKeys?: Iterable<string>;
45
+ }
46
+ export declare function findUnusedKeys(definedKeys: Iterable<string>, scan: UsageScan, options?: FindUnusedOptions): UnusedReport;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Unused-key reconciler (B3) — decide which *defined* keys no source references.
3
+ *
4
+ * A key counts as used if ANY usage layer reaches it, biased hard toward "used"
5
+ * so a live key is never flagged for deletion: exact static call, dynamic-prefix
6
+ * coverage, the loose literal-pool harvest (any string literal equal to a key),
7
+ * an explicit dynamic-key allowlist, or a runtime-observed key. What survives all
8
+ * layers is reported — `confirmed` when no opaque `t(variable)` site could be
9
+ * hiding it, else `suspect`. Separately, static render keys absent from the
10
+ * defined set surface as `used-but-undefined` (a typo / stale rename that renders
11
+ * its raw key at runtime).
12
+ */
13
+ import { makeGlobMatcher } from './glob.js';
14
+ export function findUnusedKeys(definedKeys, scan, options = {}) {
15
+ const defined = new Set(definedKeys);
16
+ const isIgnored = makeGlobMatcher(options.ignoreKeys);
17
+ const isAllowlisted = makeGlobMatcher(options.dynamicKeys);
18
+ const runtimeUsed = new Set(options.runtimeUsedKeys ?? []);
19
+ // `.filter(Boolean)`: an empty prefix would `startsWith`-match every key and
20
+ // silently shield the whole codebase. The walkers already guard, but be safe.
21
+ const prefixes = [...new Set(scan.dynamicPrefixes.map((entry) => entry.prefix))].filter(Boolean);
22
+ const hasOpaqueSites = scan.opaqueSites.length > 0;
23
+ const matchesPrefix = (key) => prefixes.some((prefix) => key.startsWith(prefix));
24
+ const isUsed = (key) => scan.staticKeys.has(key) || // layer 1 — exact static render call
25
+ scan.probeKeys.has(key) || // exists() probe
26
+ matchesPrefix(key) || // layer 2 — dynamic-prefix coverage
27
+ scan.literalPool.has(key) || // layer 3 — loose literal harvest
28
+ isAllowlisted(key) || // layer 4 — explicit dynamic allowlist
29
+ runtimeUsed.has(key); // layer 5 — runtime-observed
30
+ const unused = [];
31
+ for (const key of defined) {
32
+ if (isIgnored(key) || isUsed(key))
33
+ continue;
34
+ unused.push({ key, tier: hasOpaqueSites ? 'suspect' : 'confirmed' });
35
+ }
36
+ unused.sort((a, b) => a.key.localeCompare(b.key));
37
+ const usedButUndefined = [];
38
+ for (const [key, sites] of scan.staticKeys) {
39
+ if (isIgnored(key) || isAllowlisted(key) || defined.has(key))
40
+ continue;
41
+ usedButUndefined.push({ key, sites });
42
+ }
43
+ usedButUndefined.sort((a, b) => a.key.localeCompare(b.key));
44
+ const dynamicPrefixCoverage = prefixes
45
+ .map((prefix) => ({
46
+ prefix,
47
+ // Keys this prefix is the SOLE reason for keeping (no other usage layer hits
48
+ // them); a prefix that shields nothing (e.g. a non-i18n template) is dropped.
49
+ shieldedKeys: [...defined].filter((key) => key.startsWith(prefix) &&
50
+ !isIgnored(key) &&
51
+ !scan.staticKeys.has(key) &&
52
+ !scan.probeKeys.has(key) &&
53
+ !scan.literalPool.has(key) &&
54
+ !isAllowlisted(key) &&
55
+ !runtimeUsed.has(key)).length
56
+ }))
57
+ .filter((coverage) => coverage.shieldedKeys > 0)
58
+ .sort((a, b) => a.prefix.localeCompare(b.prefix));
59
+ return {
60
+ unused,
61
+ usedButUndefined,
62
+ dynamicPrefixCoverage,
63
+ opaqueSiteCount: scan.opaqueSites.length
64
+ };
65
+ }
@@ -1,4 +1,4 @@
1
- import type { I18nError, Locale, PluralParams, TranslationOptions, TranslationParams } from './types.js';
1
+ import type { I18nError, I18nMissingKey, Locale, PluralParams, TranslationOptions, TranslationParams } from './types.js';
2
2
  /**
3
3
  * The constant base locale used for read-tolerant resolution when no
4
4
  * `<I18nProvider>` is mounted. A constant — never global mutable state — so a
@@ -93,6 +93,15 @@ export interface I18nConfigureOptions {
93
93
  * `console.warn`. The hook for telemetry (Sentry, structured logging).
94
94
  */
95
95
  onError?: (error: I18nError) => void;
96
+ /**
97
+ * Invoked when `t`/`translate` resolves a key *nowhere* — not the active
98
+ * locale, not the fallback, in no package or the global bundle — and falls back
99
+ * to rendering the key string itself. Off by default (read-tolerant: a
100
+ * provider-less render legitimately misses keys), so this only fires when you
101
+ * opt in — the loud signal for "this string ships as its raw key". Pair with
102
+ * `createMissingKeyCollector` to assert "no misses" across a test/E2E run.
103
+ */
104
+ onMissingKey?: (info: I18nMissingKey) => void;
96
105
  }
97
106
  /**
98
107
  * App-global i18n configuration. Call **once at startup** (module scope or root
@@ -163,5 +163,7 @@ export function useI18n() {
163
163
  * ```
164
164
  */
165
165
  export function configureI18n(options) {
166
- getRegistry().onError = options.onError;
166
+ const registry = getRegistry();
167
+ registry.onError = options.onError;
168
+ registry.onMissingKey = options.onMissingKey;
167
169
  }
@@ -15,32 +15,39 @@ import { getRegistry } from './registry.svelte.js';
15
15
  * out of the initial bundle as dynamic-import chunks, loaded only when activated.
16
16
  */
17
17
  export function createPackageI18n(packageName, translations, options) {
18
- // Eager registration at module-init time. The previous lazy variant
19
- // (queueMicrotask inside t()) returned the raw key on first call and
20
- // never re-triggered the reactive expression that read it, so consumers
21
- // saw `filter.button.add` instead of the translated string.
18
+ // Lazy, first-use registration — deliberately NOT at module-eval (Codeberg #22).
19
+ // A consumer's top-level `export const x = createPackageI18n(...)` must not call
20
+ // getRegistry() during module initialisation: in a reordered *production* chunk
21
+ // (Rollup, `sideEffects: false`) that call can run before the registry module's
22
+ // `class I18nRegistry` statement, hitting the class temporal dead zone →
23
+ // `new (undefined)()` → "is not a constructor" → blank page on hydration. The
24
+ // hoisted getRegistry() keeps the *getter* reachable, but the *class* it
25
+ // constructs is still a TDZ binding, so the hoist guard alone was insufficient.
22
26
  //
23
- // Running synchronously here is safe: `createPackageI18n` is invoked at
24
- // module top-level (`export const tableI18n = createPackageI18n(...)`),
25
- // which is outside any $derived/$effect so the SvelteMap mutation in
26
- // `registerPackage` cannot trip the `state_unsafe_mutation` rule.
27
- //
28
- // Goes through `getRegistry()` (a hoisted function), NOT a module-const binding:
29
- // this call fires at consumer module-eval time, and under Vite 8 / Rolldown a
30
- // reordered chunk can run it before the registry module's body ran. A hoisted
31
- // function binding survives that; the lazy getter then builds the registry on
32
- // first touch, in whatever order the chunks happen to fire.
33
- getRegistry().registerPackage(packageName, translations);
34
- // Opt-in lazy locales (WP4): register dynamic-import loaders. The eager bundle
35
- // above is the base; these cover the rest, loaded on demand by the provider /
36
- // setLocale. Parity for lazy bundles is a runtime concern (validatePackageTranslations).
37
- if (options?.loaders) {
27
+ // Deferring registration to the first useTranslate()/t()/exists() call removes
28
+ // the ordering dependency at the source: by render / first-call time every module
29
+ // is fully evaluated, so `new I18nRegistry()` is always safe. Registration is
30
+ // synchronous (it runs before the translate read), so the first call resolves the
31
+ // real string — unlike the old queueMicrotask variant that returned the raw key.
32
+ // registerPackage mutates the reactive SvelteMap, but first touch is a component
33
+ // init (`useTranslate` body) or a non-reactive call (`t`/tests), never inside a
34
+ // `$derived`, so it cannot trip `state_unsafe_mutation`.
35
+ let registered = false;
36
+ const ensureRegistered = () => {
37
+ if (registered)
38
+ return;
39
+ registered = true;
38
40
  const registry = getRegistry();
39
- for (const [locale, loader] of Object.entries(options.loaders)) {
40
- if (loader)
41
- registry.registerPackageLoader(packageName, locale, loader);
41
+ registry.registerPackage(packageName, translations);
42
+ // Opt-in lazy locales (WP4): dynamic-import loaders alongside the eager base
43
+ // bundle, loaded on demand by the provider / setLocale.
44
+ if (options?.loaders) {
45
+ for (const [locale, loader] of Object.entries(options.loaders)) {
46
+ if (loader)
47
+ registry.registerPackageLoader(packageName, locale, loader);
48
+ }
42
49
  }
43
- }
50
+ };
44
51
  // Context-scoped hook — the SSR-correct, reactive accessor. Captures the
45
52
  // request-scoped locale state at component init (or `undefined` without a
46
53
  // provider → base locale), then resolves against the static registry. Reading
@@ -48,6 +55,7 @@ export function createPackageI18n(packageName, translations, options) {
48
55
  // call-sites re-render on locale change; reading the registry's SvelteMap makes
49
56
  // them re-render when a package registers more translations.
50
57
  const useTranslate = () => {
58
+ ensureRegistered();
51
59
  const state = useI18nState();
52
60
  const registry = getRegistry();
53
61
  return ((key, params, options) => registry.translate(key, state?.locale ?? BASE_LOCALE, state?.fallbackLocale ?? BASE_LOCALE, params, { packageName, ...options }));
@@ -56,15 +64,25 @@ export function createPackageI18n(packageName, translations, options) {
56
64
  // against the base locale — there is no request-scoped state outside a
57
65
  // component. Components use `useTranslate` for the reactive, provider-scoped
58
66
  // locale.
59
- const t = ((key, params, options) => getRegistry().translate(key, BASE_LOCALE, BASE_LOCALE, params, {
60
- packageName,
61
- ...options
62
- }));
63
- const exists = (key) => getRegistry().exists(key, BASE_LOCALE, packageName);
64
- const getLocales = () => getRegistry().getPackageLocales(packageName);
65
- // No-op kept for API back-compat with callers that used to invoke `register()`
66
- // before reading translations. Registration now happens eagerly above.
67
- const register = () => { };
67
+ const t = ((key, params, options) => {
68
+ ensureRegistered();
69
+ return getRegistry().translate(key, BASE_LOCALE, BASE_LOCALE, params, {
70
+ packageName,
71
+ ...options
72
+ });
73
+ });
74
+ const exists = (key) => {
75
+ ensureRegistered();
76
+ return getRegistry().exists(key, BASE_LOCALE, packageName);
77
+ };
78
+ const getLocales = () => {
79
+ ensureRegistered();
80
+ return getRegistry().getPackageLocales(packageName);
81
+ };
82
+ // Eager-register escape hatch: a consumer that wants the package present before
83
+ // the first useTranslate()/t() (e.g. to preload a lazy locale) can call this.
84
+ // Previously a no-op; now it performs the idempotent first-use registration.
85
+ const register = () => ensureRegistered();
68
86
  return {
69
87
  useTranslate,
70
88
  t,
@@ -1,4 +1,4 @@
1
- import type { I18nError, Locale, PackageTranslations, PluralParams, TranslationLoader, TranslationOptions, TranslationParams, Translations } from './types.js';
1
+ import type { I18nError, I18nMissingKey, Locale, PackageTranslations, PluralParams, TranslationLoader, TranslationOptions, TranslationParams, Translations } from './types.js';
2
2
  import { isLocaleSupported } from './types.js';
3
3
  /**
4
4
  * Module-global translation **registry** — the static, request-identical half of
@@ -31,9 +31,19 @@ export declare class I18nRegistry {
31
31
  * failures surface somewhere; defaults to `console.warn`.
32
32
  */
33
33
  onError?: (error: I18nError) => void;
34
+ /**
35
+ * Optional missing-key sink. Set once by the app via `configureI18n`. Unlike
36
+ * `onError` there is NO default `console.warn`: a provider-less, read-tolerant
37
+ * render legitimately misses keys, so warning on every miss would be noise.
38
+ * Opt-in only — the loud signal for "this key resolved nowhere" when a consumer
39
+ * wants it (dev overlay, telemetry, a test collector). Fires exactly once per
40
+ * resolved-nowhere `translate` call, just before the key-as-itself fallback.
41
+ */
42
+ onMissingKey?: (info: I18nMissingKey) => void;
34
43
  get registeredPackages(): string[];
35
44
  get isLoading(): boolean;
36
45
  private reportError;
46
+ private reportMissingKey;
37
47
  registerPackage(packageName: string, translations: PackageTranslations): void;
38
48
  registerTranslationLoader(locale: Locale, loader: TranslationLoader): void;
39
49
  /**
@@ -49,6 +49,15 @@ export class I18nRegistry {
49
49
  * failures surface somewhere; defaults to `console.warn`.
50
50
  */
51
51
  onError;
52
+ /**
53
+ * Optional missing-key sink. Set once by the app via `configureI18n`. Unlike
54
+ * `onError` there is NO default `console.warn`: a provider-less, read-tolerant
55
+ * render legitimately misses keys, so warning on every miss would be noise.
56
+ * Opt-in only — the loud signal for "this key resolved nowhere" when a consumer
57
+ * wants it (dev overlay, telemetry, a test collector). Fires exactly once per
58
+ * resolved-nowhere `translate` call, just before the key-as-itself fallback.
59
+ */
60
+ onMissingKey;
52
61
  get registeredPackages() {
53
62
  return Array.from(this.packageTranslations.keys());
54
63
  }
@@ -72,6 +81,19 @@ export class I18nRegistry {
72
81
  break;
73
82
  }
74
83
  }
84
+ reportMissingKey(key, locale, fallbackLocale, packageName) {
85
+ if (!this.onMissingKey)
86
+ return;
87
+ try {
88
+ this.onMissingKey({ key, locale, fallbackLocale, packageName, reason: 'no-translation' });
89
+ }
90
+ catch (error) {
91
+ // The miss sink is observability, never load-bearing: a throwing consumer
92
+ // handler must not break rendering or skip the read-tolerant key-as-itself
93
+ // fallback. Mirrors interpolate()'s defensive treatment of consumer callbacks.
94
+ console.warn(`onMissingKey handler threw for key "${key}"`, error);
95
+ }
96
+ }
75
97
  // --- registration / loading (static data; idempotent, request-identical) ---
76
98
  registerPackage(packageName, translations) {
77
99
  this.packageTranslations.set(packageName, translations);
@@ -230,6 +252,12 @@ export class I18nRegistry {
230
252
  translation = this.getTranslation(key, locale) || this.getTranslation(key, fallbackLocale);
231
253
  }
232
254
  if (!translation) {
255
+ // Resolved nowhere — package, fallback, and global all missed. Surface the
256
+ // loud signal (opt-in) before the read-tolerant key-as-itself fallback.
257
+ // `reportMissing: false` callers (the optional `_plural` probe) opt out.
258
+ if (opts.reportMissing !== false) {
259
+ this.reportMissingKey(key, locale, fallbackLocale, opts.packageName);
260
+ }
233
261
  translation = key;
234
262
  }
235
263
  return opts.interpolate ? this.interpolate(translation, locale, params) : translation;
@@ -237,9 +265,13 @@ export class I18nRegistry {
237
265
  pluralize(key, params, locale, fallbackLocale, options) {
238
266
  const count = params.count;
239
267
  const pluralKey = `${key}_plural`;
268
+ // `reportMissing: false`: a `<key>_plural` object is optional, so this probe
269
+ // legitimately resolves nowhere for keys without CLDR forms — it must not fire
270
+ // onMissingKey. A miss on the base `key` below still reports (it's a real one).
240
271
  const pluralTranslation = this.translate(pluralKey, locale, fallbackLocale, undefined, {
241
272
  ...options,
242
- interpolate: false
273
+ interpolate: false,
274
+ reportMissing: false
243
275
  });
244
276
  if (pluralTranslation !== pluralKey) {
245
277
  try {
@@ -69,6 +69,24 @@ export type I18nError = {
69
69
  type: 'load-failed-no-fallback';
70
70
  locale: Locale;
71
71
  };
72
+ /**
73
+ * Surfaced through {@link I18nConfigureOptions.onMissingKey} when `translate`
74
+ * resolves a key *nowhere* — neither the active nor the fallback locale, in any
75
+ * package or the global bundle — and falls back to returning the key string
76
+ * itself. The loud signal for "this will render as its raw key in production".
77
+ */
78
+ export interface I18nMissingKey {
79
+ /** The unresolved key, exactly as passed to `t` / `translate`. */
80
+ key: string;
81
+ /** Active locale at the time of the miss. */
82
+ locale: Locale;
83
+ /** Fallback locale that was also tried and also missed. */
84
+ fallbackLocale: Locale;
85
+ /** Package scope, when the call was package-scoped (`useTranslate` / `packageName`). */
86
+ packageName?: string;
87
+ /** Always `no-translation` today; a discriminant reserved for future miss reasons. */
88
+ reason: 'no-translation';
89
+ }
72
90
  type WidenStringLiteralsDeep<T> = T extends string ? string : T extends Array<infer U> ? Array<WidenStringLiteralsDeep<U>> : T extends object ? {
73
91
  [K in keyof T]: WidenStringLiteralsDeep<T[K]>;
74
92
  } : T;
@@ -77,6 +95,13 @@ export interface TranslationOptions {
77
95
  packageName?: string;
78
96
  fallbackToGlobal?: boolean;
79
97
  interpolate?: boolean;
98
+ /**
99
+ * @internal Whether an unresolved key reports through `onMissingKey`. Defaults
100
+ * to `true`. Set `false` for internal probes that expect a miss — the
101
+ * `pluralize` lookup of an *optional* `<key>_plural` object, which legitimately
102
+ * resolves nowhere and must not masquerade as a missing translation.
103
+ */
104
+ reportMissing?: boolean;
80
105
  }
81
106
  export type TranslationLoader = (locale: Locale) => Promise<Translations>;
82
107
  export type PackageTranslationLoader = (packageName: string, locale: Locale) => Promise<Translations>;
package/dist/index.d.ts CHANGED
@@ -1,3 +1,7 @@
1
+ export type { MissingKeyCollector, MissingKeyRecord } from './audit/missing-key-collector.js';
2
+ export { createMissingKeyCollector } from './audit/missing-key-collector.js';
3
+ export type { AuditTranslationsOptions, TranslationAuditReport, TranslationFinding, TranslationFindingCode, TranslationFindingSeverity } from './audit/translations.js';
4
+ export { auditTranslations } from './audit/translations.js';
1
5
  export { I18nProvider, T } from './components/index.js';
2
6
  export type { I18nApi, I18nConfigureOptions } from './i18n/context.svelte.js';
3
7
  export { BASE_LOCALE, configureI18n, provideI18n, useI18n } from './i18n/context.svelte.js';
@@ -5,7 +9,7 @@ export type { CreatePackageI18nOptions } from './i18n/package-integration.js';
5
9
  export { createComponentI18n, createPackageI18n, createPackageTranslations, createTypedPackage, registerPackages, registerTranslationLoaders, validatePackageTranslations } from './i18n/package-integration.js';
6
10
  export type { LocaleSource, ResolveLocaleOptions } from './i18n/resolve-locale.js';
7
11
  export { resolveLocale } from './i18n/resolve-locale.js';
8
- export type { CreatePackageTypes, I18nComponentProps, I18nConfig, I18nError, I18nStore, Locale, PackageI18n, PackageTranslations, PluralParams, PluralRules, TranslationFunction, TranslationLoader, TranslationOptions, TranslationParams, Translations, TypedTranslationFunction } from './i18n/types.js';
12
+ export type { CreatePackageTypes, I18nComponentProps, I18nConfig, I18nError, I18nMissingKey, I18nStore, Locale, PackageI18n, PackageTranslations, PluralParams, PluralRules, TranslationFunction, TranslationLoader, TranslationOptions, TranslationParams, Translations, TypedTranslationFunction } from './i18n/types.js';
9
13
  export { isLocaleSupported, SUPPORTED_LOCALES } from './i18n/types.js';
10
14
  export type { DeepKeys, DeepValue } from './utils/deep-keys.js';
11
15
  export { collectDeepKeys, getDeepValue, hasDeepKey } from './utils/deep-keys.js';
package/dist/index.js CHANGED
@@ -1,4 +1,13 @@
1
- // --- Request-scoped i18n (WP2: SSR-correct, Context-based) ---
1
+ // Public surface of @urbicon-ui/i18n — request-scoped runtime (SSR-correct,
2
+ // context-based), package integration, and the data-level audit utilities.
3
+ // (Export order is Biome-sorted by module path; see the per-group comments.)
4
+ // Runtime missing-key sink: wire `createMissingKeyCollector().onMissingKey` into
5
+ // configureI18n to assert "no raw-key renders" across a test/E2E run.
6
+ export { createMissingKeyCollector } from './audit/missing-key-collector.js';
7
+ // Translation audit (data-level parity & quality). Pure, dependency-free — run in
8
+ // a vitest test (`expect(auditTranslations(bundles).ok).toBe(true)`) or via the
9
+ // `urbicon i18n parity` CLI. The richer successor to validatePackageTranslations.
10
+ export { auditTranslations } from './audit/translations.js';
2
11
  // Provider + general hook: mount one <I18nProvider> at the app root, read locale
3
12
  // control and locale-aware formatting via useI18n() inside components.
4
13
  export { I18nProvider, T } from './components/index.js';