@urbicon-ui/i18n 6.3.9 → 6.3.11

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/README.md CHANGED
@@ -302,6 +302,41 @@ import type {
302
302
 
303
303
  `en`, `de` ship data. `fr`, `es`, `it`, `nl` are declared target locales (in the `Locale` union / `SUPPORTED_LOCALES`) — register your own bundles for them via `createPackageI18n`.
304
304
 
305
+ ## Translation Auditing
306
+
307
+ Three layers catch i18n problems — untranslated strings, unused keys, and copy that bypassed i18n entirely. The data-level audit and the runtime sink ship from the main entry (dependency-free, usable in a Vitest test); the source scanner lives on the dev-only `@urbicon-ui/i18n/audit` subpath; the `urbicon i18n` CLI (`@urbicon-ui/design`) is the filesystem front end over all three.
308
+
309
+ **1. Data-level parity & quality** — `auditTranslations(packageName, bundles)` diffs locale bundles for missing/extra keys, empty values, interpolation-param drift (`{{name}}` in one locale but not another), value-equals-key placeholders, and malformed / CLDR-incomplete `_plural` objects. Pure and deterministic — run it as a test (the richer successor to `validatePackageTranslations`, kept for back-compat):
310
+
311
+ ```ts
312
+ import { auditTranslations } from '@urbicon-ui/i18n';
313
+ import { appTranslations } from '$lib/i18n';
314
+
315
+ it('translations are in parity', () => {
316
+ expect(auditTranslations('app', appTranslations).ok).toBe(true);
317
+ });
318
+ ```
319
+
320
+ **2. Runtime missing-key sink** — `onMissingKey` (via `configureI18n`) fires when a key resolves nowhere and would render as its raw string. `createMissingKeyCollector()` packages it for tests/E2E — assert that nothing rendered a raw key, including dynamically-built keys a static scan can't see:
321
+
322
+ ```ts
323
+ import { configureI18n, createMissingKeyCollector } from '@urbicon-ui/i18n';
324
+
325
+ const misses = createMissingKeyCollector();
326
+ configureI18n({ onMissingKey: misses.onMissingKey });
327
+ // … render / exercise the app …
328
+ expect(misses.isClean()).toBe(true);
329
+ ```
330
+
331
+ **3. Source scan & CLI** — `urbicon i18n` scans your sources for **unused** keys (defined but referenced nowhere), **used-but-undefined** keys (a typo that renders raw), and **hardcoded** UI strings. Run it under Bun (it loads `.ts` locale bundles):
332
+
333
+ ```bash
334
+ urbicon i18n audit src/ --translations src/lib/translations # parity + unused + hardcoded
335
+ urbicon i18n unused --dynamic-keys 'errors.*' --json # just the scan, allowlisting dynamic key families
336
+ ```
337
+
338
+ It gates (exit 1) on parity errors + used-but-undefined; unused keys and hardcoded strings are advisory (`--strict` gates them too). The pure scanner core — `scanSources`, `findUnusedKeys`, `findHardcodedStrings` — is on the `@urbicon-ui/i18n/audit` subpath for programmatic use, with `typescript` + `svelte` as optional peers it lazily imports. See the [CI gate template](../design/templates/ci-github.yml).
339
+
305
340
  ## Development
306
341
 
307
342
  ```bash
@@ -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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@urbicon-ui/i18n",
3
- "version": "6.3.9",
3
+ "version": "6.3.11",
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.9",
27
+ "@urbicon-ui/shared-types": "6.3.11",
28
28
  "prettier": "^3.8.4",
29
29
  "prettier-plugin-svelte": "^4.1.1",
30
30
  "prettier-plugin-tailwindcss": "^0.8.0",