@urbicon-ui/i18n 6.1.4

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,93 @@
1
+ import type { I18nError, Locale, PackageTranslations, PluralParams, TranslationLoader, TranslationOptions, TranslationParams, Translations } from './types';
2
+ import { isLocaleSupported } from './types';
3
+ /**
4
+ * Module-global translation **registry** — the static, request-identical half of
5
+ * the old `I18nService`.
6
+ *
7
+ * It holds only read-only translation *data* (package bundles, the global
8
+ * mirror, loaded/loading status) and stateless resolution logic. Every resolver
9
+ * takes the active `locale`/`fallbackLocale` as **explicit arguments** instead of
10
+ * reading a stored value, so a single module-global instance is safe to share
11
+ * across SSR requests: there is no mutable per-request state here. The mutable
12
+ * locale lives in the request-scoped {@link I18nState} context instead.
13
+ *
14
+ * The data fields stay reactive (`SvelteMap`/`SvelteSet`/`$state`) so that a
15
+ * package registering or a locale chunk loading *after* first render still
16
+ * invalidates the `$derived` expressions that read them — but those mutations are
17
+ * idempotent and identical for every request, so they cannot leak request state.
18
+ */
19
+ export declare class I18nRegistry {
20
+ private packageTranslations;
21
+ private translations;
22
+ private loadedLocales;
23
+ private loadingLocales;
24
+ private translationLoaders;
25
+ private packageLoaders;
26
+ private loadingPackageLocales;
27
+ private pluralRulesCache;
28
+ private numberFormatCache;
29
+ /**
30
+ * Optional error sink. Set once by the app (e.g. via the provider) so loader
31
+ * failures surface somewhere; defaults to `console.warn`.
32
+ */
33
+ onError?: (error: I18nError) => void;
34
+ get registeredPackages(): string[];
35
+ get isLoading(): boolean;
36
+ private reportError;
37
+ registerPackage(packageName: string, translations: PackageTranslations): void;
38
+ registerTranslationLoader(locale: Locale, loader: TranslationLoader): void;
39
+ /**
40
+ * Register a per-package lazy loader for one locale (WP4 code-splitting). The
41
+ * loader returns that package's bundle for `locale` (typically
42
+ * `() => import('./translations/de').then((m) => m.default)`), kept out of the
43
+ * initial chunk until the locale is activated.
44
+ */
45
+ registerPackageLoader(packageName: string, locale: Locale, loader: () => Promise<Translations>): void;
46
+ hasLoader(locale: Locale): boolean;
47
+ isLoaded(locale: Locale): boolean;
48
+ private loaderKeyLocale;
49
+ addTranslations(locale: Locale, translations: Translations): void;
50
+ /**
51
+ * Load every lazy bundle registered for `locale` — the legacy global loader and
52
+ * all per-package loaders — merging each into the (reactive) registry so that
53
+ * `$derived` reads re-resolve once the chunks arrive. Idempotent: bundles
54
+ * already present (eager or previously loaded) are skipped. Returns `false` if
55
+ * any triggered load rejected.
56
+ */
57
+ loadLocale(locale: Locale): Promise<boolean>;
58
+ private loadGlobalLocale;
59
+ /**
60
+ * Load one package's bundle for `locale` via its registered loader and merge it
61
+ * into the package map (so the package-scoped hook lookup finds it) and the
62
+ * global mirror. Idempotent; no-op when the bundle is already present or no
63
+ * loader is registered.
64
+ */
65
+ loadPackageLocale(packageName: string, locale: Locale): Promise<boolean>;
66
+ /**
67
+ * Whether `locale` has any resolvable data (eager or already lazily loaded).
68
+ * Used by the request-scoped state to decide if switching to it is safe even
69
+ * when its loader rejected.
70
+ */
71
+ hasTranslations(locale: Locale): boolean;
72
+ translate(key: string, locale: Locale, fallbackLocale: Locale, params?: TranslationParams, options?: TranslationOptions | string): string;
73
+ pluralize(key: string, params: PluralParams, locale: Locale, fallbackLocale: Locale, options?: TranslationOptions): string;
74
+ private getPluralRule;
75
+ private interpolate;
76
+ private getNestedParam;
77
+ private getPackageTranslation;
78
+ private getTranslation;
79
+ private deepMerge;
80
+ getAvailableLocales(): Locale[];
81
+ getPackageLocales(packageName: string): Locale[];
82
+ hasPackage(packageName: string): boolean;
83
+ getPackageTranslations(packageName: string): Partial<Record<Locale, Translations>> | undefined;
84
+ exists(key: string, locale: Locale, packageName?: string): boolean;
85
+ formatNumber(value: number, locale: Locale, options?: Intl.NumberFormatOptions): string;
86
+ formatDate(date: Date, locale: Locale, options?: Intl.DateTimeFormatOptions): string;
87
+ formatRelativeTime(value: number, unit: Intl.RelativeTimeFormatUnit, locale: Locale): string;
88
+ formatTimeAgo(date: Date, locale: Locale, fallbackLocale: Locale): string;
89
+ /** Re-exported for callers that need the guard without importing from types. */
90
+ isLocaleSupported: typeof isLocaleSupported;
91
+ reportLoadError(error: I18nError): void;
92
+ }
93
+ export declare function getRegistry(): I18nRegistry;
@@ -0,0 +1,460 @@
1
+ import { SvelteMap, SvelteSet } from 'svelte/reactivity';
2
+ import { getDeepValue } from '../utils/deep-keys';
3
+ import { isLocaleSupported } from './types';
4
+ /**
5
+ * Module-global translation **registry** — the static, request-identical half of
6
+ * the old `I18nService`.
7
+ *
8
+ * It holds only read-only translation *data* (package bundles, the global
9
+ * mirror, loaded/loading status) and stateless resolution logic. Every resolver
10
+ * takes the active `locale`/`fallbackLocale` as **explicit arguments** instead of
11
+ * reading a stored value, so a single module-global instance is safe to share
12
+ * across SSR requests: there is no mutable per-request state here. The mutable
13
+ * locale lives in the request-scoped {@link I18nState} context instead.
14
+ *
15
+ * The data fields stay reactive (`SvelteMap`/`SvelteSet`/`$state`) so that a
16
+ * package registering or a locale chunk loading *after* first render still
17
+ * invalidates the `$derived` expressions that read them — but those mutations are
18
+ * idempotent and identical for every request, so they cannot leak request state.
19
+ */
20
+ export class I18nRegistry {
21
+ // SvelteMap/SvelteSet are reactive-wrapped so mutations (add/delete) trigger
22
+ // downstream $derived recomputation. Plain Map/Set inside $state are
23
+ // shallow-reactive only.
24
+ packageTranslations = new SvelteMap();
25
+ translations = $state({});
26
+ loadedLocales = new SvelteSet();
27
+ loadingLocales = new SvelteSet();
28
+ // Non-reactive per-instance cache; SvelteMap is unnecessary here.
29
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
30
+ translationLoaders = new Map();
31
+ // Per-package lazy loaders (WP4 code-splitting), keyed `${packageName}::${locale}`.
32
+ // Registered at module-eval, read in setLocale/loadLocale (not in a $derived), so
33
+ // a plain Map suffices. The *loaded data* lands in the reactive packageTranslations,
34
+ // which is what re-resolves `$derived` reads when a chunk arrives.
35
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
36
+ packageLoaders = new Map();
37
+ // Reactive: `isLoading` derives from its size.
38
+ loadingPackageLocales = new SvelteSet();
39
+ // Per-locale Intl caches. Constructing an Intl.* object negotiates the locale
40
+ // on every `new`; caching by locale avoids that cost when plural()/formatNumber
41
+ // run inside a large {#each}. Keyed by locale only (options-bearing calls skip
42
+ // the cache — they are rare and varied).
43
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
44
+ pluralRulesCache = new Map();
45
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
46
+ numberFormatCache = new Map();
47
+ /**
48
+ * Optional error sink. Set once by the app (e.g. via the provider) so loader
49
+ * failures surface somewhere; defaults to `console.warn`.
50
+ */
51
+ onError;
52
+ get registeredPackages() {
53
+ return Array.from(this.packageTranslations.keys());
54
+ }
55
+ get isLoading() {
56
+ return this.loadingLocales.size > 0 || this.loadingPackageLocales.size > 0;
57
+ }
58
+ reportError(error) {
59
+ if (this.onError) {
60
+ this.onError(error);
61
+ return;
62
+ }
63
+ switch (error.type) {
64
+ case 'load-failed':
65
+ console.warn(`Failed to load translations for locale: ${error.locale}`, error.cause);
66
+ break;
67
+ case 'unsupported-locale':
68
+ console.warn(`Locale ${error.locale} is not supported`);
69
+ break;
70
+ case 'load-failed-no-fallback':
71
+ console.warn(`Failed to load locale ${error.locale}, falling back to current locale`);
72
+ break;
73
+ }
74
+ }
75
+ // --- registration / loading (static data; idempotent, request-identical) ---
76
+ registerPackage(packageName, translations) {
77
+ this.packageTranslations.set(packageName, translations);
78
+ Object.entries(translations).forEach(([locale, trans]) => {
79
+ this.addTranslations(locale, { [packageName]: trans });
80
+ });
81
+ }
82
+ registerTranslationLoader(locale, loader) {
83
+ this.translationLoaders.set(locale, loader);
84
+ }
85
+ /**
86
+ * Register a per-package lazy loader for one locale (WP4 code-splitting). The
87
+ * loader returns that package's bundle for `locale` (typically
88
+ * `() => import('./translations/de').then((m) => m.default)`), kept out of the
89
+ * initial chunk until the locale is activated.
90
+ */
91
+ registerPackageLoader(packageName, locale, loader) {
92
+ this.packageLoaders.set(`${packageName}::${locale}`, loader);
93
+ }
94
+ hasLoader(locale) {
95
+ if (this.translationLoaders.has(locale))
96
+ return true;
97
+ for (const key of this.packageLoaders.keys()) {
98
+ if (this.loaderKeyLocale(key) === locale)
99
+ return true;
100
+ }
101
+ return false;
102
+ }
103
+ // Reflects only *global* loader loads (registerTranslationLoader), not
104
+ // per-package lazy loads — a purely-lazy-package locale stays `false` here. The
105
+ // setLocale gate pairs it with hasLoader() and relies on load*Locale being
106
+ // idempotent, so the narrowed meaning is safe there; don't reuse it as a general
107
+ // "is this locale's data present" check (use packageTranslations/getPackageLocales).
108
+ isLoaded(locale) {
109
+ return this.loadedLocales.has(locale);
110
+ }
111
+ loaderKeyLocale(key) {
112
+ return key.slice(key.lastIndexOf('::') + 2);
113
+ }
114
+ addTranslations(locale, translations) {
115
+ if (!this.translations[locale]) {
116
+ this.translations[locale] = {};
117
+ }
118
+ this.translations[locale] = this.deepMerge(this.translations[locale], translations);
119
+ }
120
+ /**
121
+ * Load every lazy bundle registered for `locale` — the legacy global loader and
122
+ * all per-package loaders — merging each into the (reactive) registry so that
123
+ * `$derived` reads re-resolve once the chunks arrive. Idempotent: bundles
124
+ * already present (eager or previously loaded) are skipped. Returns `false` if
125
+ * any triggered load rejected.
126
+ */
127
+ async loadLocale(locale) {
128
+ const tasks = [this.loadGlobalLocale(locale)];
129
+ for (const key of this.packageLoaders.keys()) {
130
+ if (this.loaderKeyLocale(key) === locale) {
131
+ tasks.push(this.loadPackageLocale(key.slice(0, key.lastIndexOf('::')), locale));
132
+ }
133
+ }
134
+ const results = await Promise.all(tasks);
135
+ return results.every(Boolean);
136
+ }
137
+ // Legacy global-loader path. Returns true when there is nothing to do (no
138
+ // global loader / already loaded) — "no loader" is not a failure.
139
+ async loadGlobalLocale(locale) {
140
+ const loader = this.translationLoaders.get(locale);
141
+ if (!loader || this.loadedLocales.has(locale) || this.loadingLocales.has(locale)) {
142
+ return true;
143
+ }
144
+ this.loadingLocales.add(locale);
145
+ try {
146
+ const translations = await loader(locale);
147
+ this.addTranslations(locale, translations);
148
+ this.loadedLocales.add(locale);
149
+ return true;
150
+ }
151
+ catch (error) {
152
+ this.reportError({ type: 'load-failed', locale, cause: error });
153
+ return false;
154
+ }
155
+ finally {
156
+ this.loadingLocales.delete(locale);
157
+ }
158
+ }
159
+ /**
160
+ * Load one package's bundle for `locale` via its registered loader and merge it
161
+ * into the package map (so the package-scoped hook lookup finds it) and the
162
+ * global mirror. Idempotent; no-op when the bundle is already present or no
163
+ * loader is registered.
164
+ */
165
+ async loadPackageLocale(packageName, locale) {
166
+ if (this.packageTranslations.get(packageName)?.[locale])
167
+ return true;
168
+ const key = `${packageName}::${locale}`;
169
+ const loader = this.packageLoaders.get(key);
170
+ if (!loader || this.loadingPackageLocales.has(key))
171
+ return true;
172
+ this.loadingPackageLocales.add(key);
173
+ try {
174
+ const data = await loader();
175
+ // New object reference per merge so the SvelteMap notifies subscribers
176
+ // (SvelteMap.set only signals when the value reference changes — an in-place
177
+ // mutation would NOT re-run the $derived reads). `existing` is read AFTER the
178
+ // await, so two concurrent loads of different non-base locales for the same
179
+ // package (e.g. de + fr) each see the other's already-merged result and don't
180
+ // lose a write.
181
+ const existing = this.packageTranslations.get(packageName) ?? {};
182
+ this.packageTranslations.set(packageName, { ...existing, [locale]: data });
183
+ this.addTranslations(locale, { [packageName]: data });
184
+ return true;
185
+ }
186
+ catch (error) {
187
+ this.reportError({ type: 'load-failed', locale, cause: error });
188
+ return false;
189
+ }
190
+ finally {
191
+ this.loadingPackageLocales.delete(key);
192
+ }
193
+ }
194
+ /**
195
+ * Whether `locale` has any resolvable data (eager or already lazily loaded).
196
+ * Used by the request-scoped state to decide if switching to it is safe even
197
+ * when its loader rejected.
198
+ */
199
+ hasTranslations(locale) {
200
+ return (!!this.translations[locale] ||
201
+ Array.from(this.packageTranslations.values()).some((pkg) => !!pkg[locale]));
202
+ }
203
+ // --- resolution (locale threaded explicitly — no stored locale) ---
204
+ translate(key, locale, fallbackLocale, params, options) {
205
+ const opts = typeof options === 'string'
206
+ ? { packageName: options, fallbackToGlobal: true, interpolate: true }
207
+ : { fallbackToGlobal: true, interpolate: true, ...options };
208
+ let translation;
209
+ if (!opts.packageName && key.includes('.')) {
210
+ const potentialPackage = key.split('.')[0];
211
+ if (this.packageTranslations.has(potentialPackage)) {
212
+ const packageKey = key.substring(potentialPackage.length + 1);
213
+ translation = this.getPackageTranslation(potentialPackage, packageKey, locale);
214
+ if (translation) {
215
+ return opts.interpolate ? this.interpolate(translation, locale, params) : translation;
216
+ }
217
+ }
218
+ }
219
+ if (opts.packageName) {
220
+ translation = this.getPackageTranslation(opts.packageName, key, locale);
221
+ if (translation) {
222
+ return opts.interpolate ? this.interpolate(translation, locale, params) : translation;
223
+ }
224
+ translation = this.getPackageTranslation(opts.packageName, key, fallbackLocale);
225
+ if (translation) {
226
+ return opts.interpolate ? this.interpolate(translation, locale, params) : translation;
227
+ }
228
+ }
229
+ if (opts.fallbackToGlobal !== false) {
230
+ translation = this.getTranslation(key, locale) || this.getTranslation(key, fallbackLocale);
231
+ }
232
+ if (!translation) {
233
+ translation = key;
234
+ }
235
+ return opts.interpolate ? this.interpolate(translation, locale, params) : translation;
236
+ }
237
+ pluralize(key, params, locale, fallbackLocale, options) {
238
+ const count = params.count;
239
+ const pluralKey = `${key}_plural`;
240
+ const pluralTranslation = this.translate(pluralKey, locale, fallbackLocale, undefined, {
241
+ ...options,
242
+ interpolate: false
243
+ });
244
+ if (pluralTranslation !== pluralKey) {
245
+ try {
246
+ const rules = JSON.parse(pluralTranslation);
247
+ const rule = this.getPluralRule(count, locale);
248
+ // `??`, not `||`: an intentional empty-string entry must survive. A
249
+ // well-formed object always provides `other`; JSON.parse does not enforce
250
+ // that, so if the parsed object lacks it (or holds a non-string), treat it
251
+ // as malformed and fall through to the base form via catch — never
252
+ // interpolate `undefined` (which would throw inside String.replace).
253
+ const selectedTranslation = rules[rule] ?? rules.other;
254
+ if (typeof selectedTranslation !== 'string') {
255
+ throw new Error(`_plural object for "${key}" lacks a string "other" entry`);
256
+ }
257
+ return this.interpolate(selectedTranslation, locale, params);
258
+ }
259
+ catch {
260
+ // Malformed `_plural` JSON: fall through to the base form below rather
261
+ // than guess. fail-honest, not fail-wrong.
262
+ }
263
+ }
264
+ // No CLDR `_plural` object for this key. Return the base translation as-is
265
+ // (deterministically the singular/`other` form the author wrote) instead of
266
+ // the old anglocentric `+'s'` heuristic, which produced wrong strings for
267
+ // every non-English locale. Correct pluralization requires a `<key>_plural`
268
+ // entry; see PluralRules.
269
+ const singularTranslation = this.translate(key, locale, fallbackLocale, undefined, {
270
+ ...options,
271
+ interpolate: false
272
+ });
273
+ return this.interpolate(singularTranslation, locale, params);
274
+ }
275
+ // CLDR plural category for `count` in `locale`, delegated to the platform's
276
+ // Intl.PluralRules. Covers zero/one/two/few/many/other for any BCP-47 locale —
277
+ // consistent with formatNumber/formatDate, which already use Intl.
278
+ getPluralRule(count, locale) {
279
+ let rules = this.pluralRulesCache.get(locale);
280
+ if (!rules) {
281
+ rules = new Intl.PluralRules(locale);
282
+ this.pluralRulesCache.set(locale, rules);
283
+ }
284
+ return rules.select(count);
285
+ }
286
+ interpolate(text, locale, params) {
287
+ if (!params)
288
+ return text;
289
+ return text.replace(/\{\{([^}]+)\}\}/g, (match, key) => {
290
+ const trimmedKey = key.trim();
291
+ const value = this.getNestedParam(params, trimmedKey);
292
+ if (value === undefined || value === null) {
293
+ console.warn(`Missing translation parameter: ${trimmedKey}`);
294
+ return match;
295
+ }
296
+ if (typeof value === 'function') {
297
+ try {
298
+ return value()?.toString() ?? match;
299
+ }
300
+ catch (error) {
301
+ console.warn(`Error executing function parameter: ${trimmedKey}`, error);
302
+ return match;
303
+ }
304
+ }
305
+ if (typeof value === 'number') {
306
+ return this.formatNumber(value, locale);
307
+ }
308
+ if (value instanceof Date) {
309
+ return this.formatDate(value, locale);
310
+ }
311
+ return String(value);
312
+ });
313
+ }
314
+ getNestedParam(params, path) {
315
+ return path.split('.').reduce((current, key) => {
316
+ return current && typeof current === 'object'
317
+ ? current[key]
318
+ : undefined;
319
+ }, params);
320
+ }
321
+ getPackageTranslation(packageName, key, locale) {
322
+ const pkgTranslations = this.packageTranslations.get(packageName);
323
+ const entry = pkgTranslations?.[locale];
324
+ if (entry) {
325
+ const value = getDeepValue(entry, key);
326
+ return typeof value === 'string' ? value : undefined;
327
+ }
328
+ return undefined;
329
+ }
330
+ getTranslation(key, locale) {
331
+ const entry = this.translations[locale];
332
+ if (entry) {
333
+ const value = getDeepValue(entry, key);
334
+ return typeof value === 'string' ? value : undefined;
335
+ }
336
+ return undefined;
337
+ }
338
+ deepMerge(target, source) {
339
+ const output = { ...target };
340
+ for (const key in source) {
341
+ if (Object.hasOwn(source, key)) {
342
+ const sourceValue = source[key];
343
+ const targetValue = target[key];
344
+ if (typeof sourceValue === 'object' &&
345
+ sourceValue !== null &&
346
+ !Array.isArray(sourceValue) &&
347
+ typeof targetValue === 'object' &&
348
+ targetValue !== null &&
349
+ !Array.isArray(targetValue)) {
350
+ output[key] = this.deepMerge(targetValue, sourceValue);
351
+ }
352
+ else {
353
+ output[key] = sourceValue;
354
+ }
355
+ }
356
+ }
357
+ return output;
358
+ }
359
+ // --- introspection ---
360
+ getAvailableLocales() {
361
+ // Local accumulator — not reactive state.
362
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
363
+ const allLocales = new Set();
364
+ Object.keys(this.translations).forEach((locale) => {
365
+ allLocales.add(locale);
366
+ });
367
+ this.packageTranslations.forEach((packageTrans) => {
368
+ Object.keys(packageTrans).forEach((locale) => {
369
+ allLocales.add(locale);
370
+ });
371
+ });
372
+ this.translationLoaders.forEach((_, locale) => {
373
+ allLocales.add(locale);
374
+ });
375
+ // Locales reachable only through a not-yet-loaded per-package lazy loader.
376
+ this.packageLoaders.forEach((_, key) => {
377
+ allLocales.add(this.loaderKeyLocale(key));
378
+ });
379
+ return Array.from(allLocales);
380
+ }
381
+ getPackageLocales(packageName) {
382
+ // Eager/loaded locales plus any registered lazy loaders for the package.
383
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
384
+ const locales = new Set(Object.keys(this.packageTranslations.get(packageName) ?? {}));
385
+ const prefix = `${packageName}::`;
386
+ this.packageLoaders.forEach((_, key) => {
387
+ if (key.startsWith(prefix))
388
+ locales.add(this.loaderKeyLocale(key));
389
+ });
390
+ return Array.from(locales);
391
+ }
392
+ hasPackage(packageName) {
393
+ return this.packageTranslations.has(packageName);
394
+ }
395
+ getPackageTranslations(packageName) {
396
+ return this.packageTranslations.get(packageName);
397
+ }
398
+ exists(key, locale, packageName) {
399
+ if (packageName) {
400
+ return this.getPackageTranslation(packageName, key, locale) !== undefined;
401
+ }
402
+ return this.getTranslation(key, locale) !== undefined;
403
+ }
404
+ // --- formatting (locale threaded explicitly) ---
405
+ formatNumber(value, locale, options) {
406
+ if (options) {
407
+ return new Intl.NumberFormat(locale, options).format(value);
408
+ }
409
+ let fmt = this.numberFormatCache.get(locale);
410
+ if (!fmt) {
411
+ fmt = new Intl.NumberFormat(locale);
412
+ this.numberFormatCache.set(locale, fmt);
413
+ }
414
+ return fmt.format(value);
415
+ }
416
+ formatDate(date, locale, options) {
417
+ return new Intl.DateTimeFormat(locale, options).format(date);
418
+ }
419
+ formatRelativeTime(value, unit, locale) {
420
+ return new Intl.RelativeTimeFormat(locale).format(value, unit);
421
+ }
422
+ formatTimeAgo(date, locale, fallbackLocale) {
423
+ // Local timestamp — not reactive state.
424
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
425
+ const now = new Date();
426
+ const seconds = Math.round((now.getTime() - date.getTime()) / 1000);
427
+ const minutes = Math.round(seconds / 60);
428
+ const hours = Math.round(minutes / 60);
429
+ const days = Math.round(hours / 24);
430
+ const tr = (key, params) => this.translate(key, locale, fallbackLocale, params);
431
+ if (seconds < 45)
432
+ return tr('time.ago.now');
433
+ if (minutes < 45)
434
+ return tr(minutes === 1 ? 'time.units.minute' : 'time.units.minutes', { count: minutes });
435
+ if (hours < 22)
436
+ return tr(hours === 1 ? 'time.units.hour' : 'time.units.hours', { count: hours });
437
+ if (days < 30)
438
+ return tr(days === 1 ? 'time.units.day' : 'time.units.days', { count: days });
439
+ return this.formatDate(date, locale);
440
+ }
441
+ /** Re-exported for callers that need the guard without importing from types. */
442
+ isLocaleSupported = isLocaleSupported;
443
+ reportLoadError(error) {
444
+ this.reportError(error);
445
+ }
446
+ }
447
+ // Lazy, hoisted accessor for the process-wide registry. Holding the registry at
448
+ // module scope is correct *because* it carries no per-request mutable locale —
449
+ // only static, request-identical translation data. But it is built on first
450
+ // touch (not at module top-level) and reached through this hoisted function so
451
+ // that a consumer chunk's top-level `createPackageI18n` side-effect — which
452
+ // Vite 8 / Rolldown may order before this module's body — still finds a callable
453
+ // binding and an initialised instance, instead of a value in the temporal dead
454
+ // zone (the `Cannot read properties of undefined (reading 'registerPackage')`
455
+ // class of bug the old lazy singleton guarded against).
456
+ let _registry;
457
+ export function getRegistry() {
458
+ _registry ??= new I18nRegistry();
459
+ return _registry;
460
+ }
@@ -0,0 +1,41 @@
1
+ import { type Locale } from './types';
2
+ /**
3
+ * Header-like source for {@link resolveLocale}. Pass a `Request` (its `cookie` and
4
+ * `accept-language` headers are read) or a plain object with the raw header
5
+ * strings — keeps the helper framework-agnostic.
6
+ */
7
+ export interface LocaleSource {
8
+ /** Raw `Cookie` request header, e.g. `theme=dark; urbicon-locale=de`. */
9
+ cookie?: string | null;
10
+ /** Raw `Accept-Language` request header, e.g. `de-DE,de;q=0.9,en;q=0.8`. */
11
+ acceptLanguage?: string | null;
12
+ }
13
+ export interface ResolveLocaleOptions {
14
+ /**
15
+ * Locales the app actually ships data for. Resolution never returns a locale
16
+ * outside this set. Defaults to the locales currently registered in the
17
+ * registry (so it tracks "data optional"), falling back to all
18
+ * {@link SUPPORTED_LOCALES} if nothing is registered yet.
19
+ */
20
+ supportedLocales?: readonly Locale[];
21
+ /** Returned when neither cookie nor Accept-Language yields a supported locale. @default 'en' */
22
+ defaultLocale?: Locale;
23
+ /** Name of the cookie holding the persisted locale choice. @default 'urbicon-locale' */
24
+ cookieName?: string;
25
+ }
26
+ /**
27
+ * Resolve the initial locale for a request, server-side: persisted cookie first,
28
+ * then the browser's `Accept-Language`, then the default. Feed the result to
29
+ * `<I18nProvider locale={…}>` so SSR and hydration agree (no client-only
30
+ * `navigator.language` guess, no hydration mismatch).
31
+ *
32
+ * ```ts
33
+ * // +layout.server.ts
34
+ * export const load = ({ request }) => ({ locale: resolveLocale(request) });
35
+ * ```
36
+ *
37
+ * Detection is the consumer's choice — this helper is optional. Persisting the
38
+ * cookie on switch is the consumer's job too (e.g. in the provider's
39
+ * `onLocaleChange`); this only reads it back.
40
+ */
41
+ export declare function resolveLocale(source: Request | LocaleSource, options?: ResolveLocaleOptions): Locale;
@@ -0,0 +1,81 @@
1
+ import { getRegistry } from './registry.svelte';
2
+ import { SUPPORTED_LOCALES } from './types';
3
+ function readHeaders(source) {
4
+ if (typeof Request !== 'undefined' && source instanceof Request) {
5
+ return {
6
+ cookie: source.headers.get('cookie'),
7
+ acceptLanguage: source.headers.get('accept-language')
8
+ };
9
+ }
10
+ return source;
11
+ }
12
+ function parseCookie(header, name) {
13
+ if (!header)
14
+ return undefined;
15
+ for (const part of header.split(';')) {
16
+ const eq = part.indexOf('=');
17
+ if (eq === -1)
18
+ continue;
19
+ if (part.slice(0, eq).trim() === name) {
20
+ return decodeURIComponent(part.slice(eq + 1).trim());
21
+ }
22
+ }
23
+ return undefined;
24
+ }
25
+ /**
26
+ * Parse `Accept-Language` and return the first base language (`en` from `en-US`)
27
+ * present in `supported`, honouring the `q` weighting order.
28
+ */
29
+ function parseAcceptLanguage(header, supported) {
30
+ if (!header)
31
+ return undefined;
32
+ const ranked = header
33
+ .split(',')
34
+ .map((part) => {
35
+ const [tag, ...params] = part.trim().split(';');
36
+ const q = params.find((p) => p.trim().startsWith('q='));
37
+ const weight = q ? Number.parseFloat(q.trim().slice(2)) : 1;
38
+ return { tag: tag.trim().toLowerCase(), weight: Number.isNaN(weight) ? 0 : weight };
39
+ })
40
+ .filter((entry) => entry.tag && entry.weight > 0)
41
+ .sort((a, b) => b.weight - a.weight);
42
+ for (const { tag } of ranked) {
43
+ const base = tag.split('-')[0];
44
+ if (supported.has(base))
45
+ return base;
46
+ }
47
+ return undefined;
48
+ }
49
+ /**
50
+ * Resolve the initial locale for a request, server-side: persisted cookie first,
51
+ * then the browser's `Accept-Language`, then the default. Feed the result to
52
+ * `<I18nProvider locale={…}>` so SSR and hydration agree (no client-only
53
+ * `navigator.language` guess, no hydration mismatch).
54
+ *
55
+ * ```ts
56
+ * // +layout.server.ts
57
+ * export const load = ({ request }) => ({ locale: resolveLocale(request) });
58
+ * ```
59
+ *
60
+ * Detection is the consumer's choice — this helper is optional. Persisting the
61
+ * cookie on switch is the consumer's job too (e.g. in the provider's
62
+ * `onLocaleChange`); this only reads it back.
63
+ */
64
+ export function resolveLocale(source, options = {}) {
65
+ const available = getRegistry().getAvailableLocales();
66
+ const supportedList = options.supportedLocales ?? (available.length > 0 ? available : SUPPORTED_LOCALES);
67
+ const supported = new Set(supportedList);
68
+ const fallback = options.defaultLocale ?? 'en';
69
+ const cookieName = options.cookieName ?? 'urbicon-locale';
70
+ const { cookie, acceptLanguage } = readHeaders(source);
71
+ // 1. Persisted explicit choice.
72
+ const fromCookie = parseCookie(cookie, cookieName);
73
+ if (fromCookie && supported.has(fromCookie))
74
+ return fromCookie;
75
+ // 2. Browser preference.
76
+ const fromHeader = parseAcceptLanguage(acceptLanguage, supported);
77
+ if (fromHeader)
78
+ return fromHeader;
79
+ // 3. Default.
80
+ return fallback;
81
+ }