@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.
- package/dist/audit/glob.d.ts +7 -0
- package/dist/audit/glob.js +19 -0
- package/dist/audit/index.d.ts +26 -0
- package/dist/audit/index.js +24 -0
- package/dist/audit/missing-key-collector.d.ts +39 -0
- package/dist/audit/missing-key-collector.js +46 -0
- package/dist/audit/scan/hardcoded.d.ts +29 -0
- package/dist/audit/scan/hardcoded.js +86 -0
- package/dist/audit/scan/recognize.d.ts +25 -0
- package/dist/audit/scan/recognize.js +110 -0
- package/dist/audit/scan/scanner.d.ts +21 -0
- package/dist/audit/scan/scanner.js +26 -0
- package/dist/audit/scan/svelte-ast.d.ts +21 -0
- package/dist/audit/scan/svelte-ast.js +61 -0
- package/dist/audit/scan/svelte-walker.d.ts +11 -0
- package/dist/audit/scan/svelte-walker.js +208 -0
- package/dist/audit/scan/ts-walker.d.ts +10 -0
- package/dist/audit/scan/ts-walker.js +183 -0
- package/dist/audit/scan/types.d.ts +48 -0
- package/dist/audit/scan/types.js +9 -0
- package/dist/audit/translations.d.ts +59 -0
- package/dist/audit/translations.js +207 -0
- package/dist/audit/unused.d.ts +46 -0
- package/dist/audit/unused.js +65 -0
- package/dist/i18n/context.svelte.d.ts +10 -1
- package/dist/i18n/context.svelte.js +3 -1
- package/dist/i18n/registry.svelte.d.ts +11 -1
- package/dist/i18n/registry.svelte.js +33 -1
- package/dist/i18n/types.d.ts +25 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +10 -1
- package/package.json +12 -2
|
@@ -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()
|
|
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 {
|
package/dist/i18n/types.d.ts
CHANGED
|
@@ -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
|
-
//
|
|
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.
|
|
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.
|
|
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",
|