@urbicon-ui/i18n 6.34.0 → 6.35.0

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
@@ -107,9 +107,12 @@ Each Urbicon package registers its own namespaced keys so consumers get a merged
107
107
  // Inside @urbicon-ui/blocks — src/lib/i18n/index.ts
108
108
  import { createPackageI18n } from '@urbicon-ui/i18n';
109
109
  import en from '../translations/en';
110
- import de from '../translations/de';
111
110
 
112
- export const blocksI18n = createPackageI18n('blocks', { en, de });
111
+ // en is the eager base; de is a lazy dynamic-import loader (see "Locale
112
+ // code-splitting" below), so English-only apps never bundle the de catalog.
113
+ export const blocksI18n = createPackageI18n('blocks', { en }, {
114
+ loaders: { de: () => import('../translations/de').then((m) => m.default) }
115
+ });
113
116
 
114
117
  // The context-scoped hook (re-exported for components)
115
118
  export const useBlocksI18n = blocksI18n.useTranslate;
@@ -205,7 +208,25 @@ export const blocksI18n = createPackageI18n(
205
208
  );
206
209
  ```
207
210
 
208
- Vite/Rollup splits each dynamic import into its own chunk, so only the active locale is in the initial bundle. The provider loads the active + fallback locale on mount; `setLocale` loads a target on switch. A lazy non-base initial locale renders the fallback until its chunk lands, then re-resolves reactively. Worth it past a handful of locales; eager is simpler for `en`/`de`.
211
+ Vite/Rollup splits each dynamic import into its own chunk, so only the active locale is in the initial bundle. The provider loads the active + fallback locale on mount; `setLocale` loads a target on switch. A lazy non-base initial locale renders the fallback until its chunk lands, then re-resolves reactively.
212
+
213
+ > **`@urbicon-ui/blocks` ships this way** — `en` eager, `de` lazy — so an English-only app doesn't bundle the `de` catalog.
214
+
215
+ ### SSR: eager-register the lazy locale for non-base apps
216
+
217
+ The provider's on-mount load runs in a **client-only** `$effect`. So under SSR a lazy non-base initial locale (e.g. a German app) renders the *fallback* (English) on the server and the first client paint, then flips to German once the chunk lands — a text flash and a possible hydration text mismatch. That is not acceptable as the default for a server-rendered app in that locale.
218
+
219
+ The fix is to register the bundle **eagerly, once at server/app start**. The registry is module-global and holds only static, request-identical translation data, so a single startup registration is SSR-safe (it carries no per-request state). Every package factory returns `registerLocale(locale, bundle)` for this; `@urbicon-ui/blocks` re-exports it as `registerBlocksLocale`:
220
+
221
+ ```ts
222
+ // src/hooks.server.ts (or any module evaluated once at server start)
223
+ import { registerBlocksLocale } from '@urbicon-ui/blocks';
224
+ import de from '@urbicon-ui/blocks/i18n/de'; // the public per-locale subpath
225
+
226
+ registerBlocksLocale('de', de);
227
+ ```
228
+
229
+ `registerLocale` is **additive** (it merges the locale in without dropping the eager base) and **write-strict** (throws on an unsupported locale or a non-object bundle). The `de` catalog stays out of English-only client bundles — you only pull it in where it is actually rendered. Worth the loader split past a handful of locales; a fully eager `createPackageI18n(name, { en, de })` is simpler when you always ship both.
209
230
 
210
231
  ## Coexisting with an app-level i18n (e.g. Paraglide)
211
232
 
@@ -244,10 +265,11 @@ configureI18n({ onError: (e) => reportToSentry(e) });
244
265
 
245
266
  ```ts
246
267
  import { validatePackageTranslations } from '@urbicon-ui/i18n';
247
- import { blocksTranslations } from '$lib/i18n';
268
+ import en from '$lib/translations/en';
269
+ import de from '$lib/translations/de'; // import lazy bundles directly for the check
248
270
 
249
271
  it('en/de key parity', () => {
250
- expect(validatePackageTranslations('blocks', blocksTranslations).errors).toEqual([]);
272
+ expect(validatePackageTranslations('blocks', { en, de }).errors).toEqual([]);
251
273
  });
252
274
  ```
253
275
 
@@ -62,6 +62,7 @@ export declare function createTypedPackage<const T extends Translations>(package
62
62
  exists: (key: string) => boolean;
63
63
  getLocales: () => Locale[];
64
64
  register: () => void;
65
+ registerLocale: (locale: Locale, bundle: Translations) => void;
65
66
  types: CreatePackageTypes<T>;
66
67
  };
67
68
  /**
@@ -91,6 +92,7 @@ export declare function createComponentI18n<const T extends Translations>(packag
91
92
  exists: (key: string) => boolean;
92
93
  getLocales: () => Locale[];
93
94
  register: () => void;
95
+ registerLocale: (locale: Locale, bundle: Translations) => void;
94
96
  types: CreatePackageTypes<T>;
95
97
  };
96
98
  /**
@@ -1,6 +1,7 @@
1
1
  import { collectDeepKeys } from '../utils/deep-keys.js';
2
2
  import { BASE_LOCALE, useI18nState } from './context.svelte.js';
3
3
  import { getRegistry } from './registry.svelte.js';
4
+ import { isLocaleSupported, SUPPORTED_LOCALES } from './types.js';
4
5
  /**
5
6
  * Creates a standardized i18n integration for a package.
6
7
  *
@@ -83,12 +84,38 @@ export function createPackageI18n(packageName, translations, options) {
83
84
  // the first useTranslate()/t() (e.g. to preload a lazy locale) can call this.
84
85
  // Previously a no-op; now it performs the idempotent first-use registration.
85
86
  const register = () => ensureRegistered();
87
+ // Eager, additive registration of one locale's bundle — the SSR escape hatch for
88
+ // a locale that is otherwise declared as a lazy `options.loaders` entry. The
89
+ // provider only loads lazy chunks in a client-only `$effect`, so a lazy non-base
90
+ // locale renders the *fallback* (base) locale during SSR and the first client
91
+ // paint, then flips once the chunk lands. A server-rendered app in that locale
92
+ // therefore ships the wrong-language strings and risks a hydration text
93
+ // mismatch. Registering the imported bundle here — once at server/app start,
94
+ // where it is request-identical static data on the module-global registry —
95
+ // makes the locale present for the very first render instead. Merges (does NOT
96
+ // clobber the eager base bundle) via registry.registerPackageLocale.
97
+ //
98
+ // Write-strict: throws on an unsupported locale or a non-object bundle rather
99
+ // than silently registering garbage under a bogus key.
100
+ const registerLocale = (locale, bundle) => {
101
+ if (!isLocaleSupported(locale)) {
102
+ throw new Error(`[i18n] ${packageName}.registerLocale: unsupported locale "${String(locale)}". ` +
103
+ `Supported: ${SUPPORTED_LOCALES.join(', ')}.`);
104
+ }
105
+ if (!bundle || typeof bundle !== 'object' || Array.isArray(bundle)) {
106
+ const kind = bundle === null ? 'null' : Array.isArray(bundle) ? 'array' : typeof bundle;
107
+ throw new Error(`[i18n] ${packageName}.registerLocale("${locale}"): bundle must be a translations object, got ${kind}.`);
108
+ }
109
+ ensureRegistered();
110
+ getRegistry().registerPackageLocale(packageName, locale, bundle);
111
+ };
86
112
  return {
87
113
  useTranslate,
88
114
  t,
89
115
  exists,
90
116
  getLocales,
91
117
  register,
118
+ registerLocale,
92
119
  types: {}
93
120
  };
94
121
  }
@@ -53,6 +53,24 @@ export declare class I18nRegistry {
53
53
  * initial chunk until the locale is activated.
54
54
  */
55
55
  registerPackageLoader(packageName: string, locale: Locale, loader: () => Promise<Translations>): void;
56
+ /**
57
+ * Additively register one locale's already-loaded bundle for a package — the
58
+ * synchronous, eager counterpart to {@link loadPackageLocale}.
59
+ *
60
+ * Unlike {@link registerPackage} (which `.set`s the *whole* package entry and so
61
+ * drops any sibling locale already registered), this **merges** the single
62
+ * locale into the existing entry, preserving the eager base bundle. That makes
63
+ * it the correct primitive for an eager-register escape hatch: a consumer that
64
+ * has passed a locale as a lazy `loader` can register its imported bundle up
65
+ * front (e.g. once at SSR/app start) so the very first server render already
66
+ * resolves that locale — no fallback-locale flash, no hydration text mismatch —
67
+ * instead of waiting for the provider's client-only on-mount chunk load.
68
+ *
69
+ * A fresh object reference is written so the reactive SvelteMap notifies
70
+ * `$derived` readers (an in-place mutation would not). Idempotent-friendly:
71
+ * re-registering the same locale simply overwrites it with identical data.
72
+ */
73
+ registerPackageLocale(packageName: string, locale: Locale, data: Translations): void;
56
74
  hasLoader(locale: Locale): boolean;
57
75
  isLoaded(locale: Locale): boolean;
58
76
  private loaderKeyLocale;
@@ -109,6 +109,28 @@ export class I18nRegistry {
109
109
  registerPackageLoader(packageName, locale, loader) {
110
110
  this.packageLoaders.set(`${packageName}::${locale}`, loader);
111
111
  }
112
+ /**
113
+ * Additively register one locale's already-loaded bundle for a package — the
114
+ * synchronous, eager counterpart to {@link loadPackageLocale}.
115
+ *
116
+ * Unlike {@link registerPackage} (which `.set`s the *whole* package entry and so
117
+ * drops any sibling locale already registered), this **merges** the single
118
+ * locale into the existing entry, preserving the eager base bundle. That makes
119
+ * it the correct primitive for an eager-register escape hatch: a consumer that
120
+ * has passed a locale as a lazy `loader` can register its imported bundle up
121
+ * front (e.g. once at SSR/app start) so the very first server render already
122
+ * resolves that locale — no fallback-locale flash, no hydration text mismatch —
123
+ * instead of waiting for the provider's client-only on-mount chunk load.
124
+ *
125
+ * A fresh object reference is written so the reactive SvelteMap notifies
126
+ * `$derived` readers (an in-place mutation would not). Idempotent-friendly:
127
+ * re-registering the same locale simply overwrites it with identical data.
128
+ */
129
+ registerPackageLocale(packageName, locale, data) {
130
+ const existing = this.packageTranslations.get(packageName) ?? {};
131
+ this.packageTranslations.set(packageName, { ...existing, [locale]: data });
132
+ this.addTranslations(locale, { [packageName]: data });
133
+ }
112
134
  hasLoader(locale) {
113
135
  if (this.translationLoaders.has(locale))
114
136
  return true;
@@ -190,15 +212,13 @@ export class I18nRegistry {
190
212
  this.loadingPackageLocales.add(key);
191
213
  try {
192
214
  const data = await loader();
193
- // New object reference per merge so the SvelteMap notifies subscribers
194
- // (SvelteMap.set only signals when the value reference changesan in-place
195
- // mutation would NOT re-run the $derived reads). `existing` is read AFTER the
196
- // await, so two concurrent loads of different non-base locales for the same
215
+ // Delegate the additive merge to registerPackageLocale (same primitive the
216
+ // eager escape hatch uses). It reads `existing` freshhere, AFTER the
217
+ // await so two concurrent loads of different non-base locales for the same
197
218
  // package (e.g. de + fr) each see the other's already-merged result and don't
198
- // lose a write.
199
- const existing = this.packageTranslations.get(packageName) ?? {};
200
- this.packageTranslations.set(packageName, { ...existing, [locale]: data });
201
- this.addTranslations(locale, { [packageName]: data });
219
+ // lose a write, and it writes a new object reference so the SvelteMap
220
+ // notifies its `$derived` subscribers.
221
+ this.registerPackageLocale(packageName, locale, data);
202
222
  return true;
203
223
  }
204
224
  catch (error) {
@@ -142,6 +142,16 @@ export interface PackageI18n<T extends Translations> {
142
142
  exists: (key: string) => boolean;
143
143
  getLocales: () => Locale[];
144
144
  register: () => void;
145
+ /**
146
+ * Eagerly and **additively** register one locale's already-imported bundle for
147
+ * this package — the SSR escape hatch for a locale declared as a lazy
148
+ * `options.loaders` entry. Call once at server/app start (with e.g.
149
+ * `import de from '@urbicon-ui/blocks/i18n/de'`) so the first server render
150
+ * already resolves that locale instead of rendering the fallback until the
151
+ * client-only on-mount chunk load lands. Merges — it does not drop the eager
152
+ * base bundle. Throws on an unsupported locale or a non-object bundle.
153
+ */
154
+ registerLocale: (locale: Locale, bundle: Translations) => void;
145
155
  types: CreatePackageTypes<T>;
146
156
  }
147
157
  export interface I18nComponentProps {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@urbicon-ui/i18n",
3
- "version": "6.34.0",
3
+ "version": "6.35.0",
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.5",
27
- "@urbicon-ui/shared-types": "6.34.0",
27
+ "@urbicon-ui/shared-types": "6.35.0",
28
28
  "prettier": "^3.8.4",
29
29
  "prettier-plugin-svelte": "^4.1.1",
30
30
  "prettier-plugin-tailwindcss": "^0.8.0",