@urbicon-ui/i18n 6.3.7 → 6.3.9

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
  }
@@ -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';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@urbicon-ui/i18n",
3
- "version": "6.3.7",
3
+ "version": "6.3.9",
4
4
  "description": "Runes-based localization for Svelte 5 apps and the Urbicon UI design system",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -24,7 +24,7 @@
24
24
  "@sveltejs/package": "^2.5.8",
25
25
  "@sveltejs/vite-plugin-svelte": "^7.0.0",
26
26
  "@types/node": "^25.9.4",
27
- "@urbicon-ui/shared-types": "6.3.7",
27
+ "@urbicon-ui/shared-types": "6.3.9",
28
28
  "prettier": "^3.8.4",
29
29
  "prettier-plugin-svelte": "^4.1.1",
30
30
  "prettier-plugin-tailwindcss": "^0.8.0",
@@ -39,6 +39,10 @@
39
39
  "types": "./dist/index.d.ts",
40
40
  "svelte": "./dist/index.js",
41
41
  "default": "./dist/index.js"
42
+ },
43
+ "./audit": {
44
+ "types": "./dist/audit/index.d.ts",
45
+ "default": "./dist/audit/index.js"
42
46
  }
43
47
  },
44
48
  "files": [
@@ -53,8 +57,14 @@
53
57
  "peerDependencies": {
54
58
  "@sveltejs/kit": "^2.67.0",
55
59
  "svelte": "^5.56.4",
60
+ "typescript": "^6.0.3",
56
61
  "@urbicon-ui/shared-types": "^6.0.0"
57
62
  },
63
+ "peerDependenciesMeta": {
64
+ "typescript": {
65
+ "optional": true
66
+ }
67
+ },
58
68
  "scripts": {
59
69
  "dev": "svelte-package --watch",
60
70
  "build": "svelte-kit sync && svelte-package && bun ../../scripts/complete-esm-specifiers.ts dist",