@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.
- package/README.md +322 -0
- package/dist/components/I18nProvider.svelte +62 -0
- package/dist/components/I18nProvider.svelte.d.ts +28 -0
- package/dist/components/T.svelte +31 -0
- package/dist/components/T.svelte.d.ts +11 -0
- package/dist/components/index.d.ts +2 -0
- package/dist/components/index.js +2 -0
- package/dist/i18n/__fixtures__/SetLocaleChild.svelte +20 -0
- package/dist/i18n/__fixtures__/SetLocaleChild.svelte.d.ts +18 -0
- package/dist/i18n/__fixtures__/SetLocaleHarness.svelte +14 -0
- package/dist/i18n/__fixtures__/SetLocaleHarness.svelte.d.ts +6 -0
- package/dist/i18n/__fixtures__/SsrChild.svelte +7 -0
- package/dist/i18n/__fixtures__/SsrChild.svelte.d.ts +18 -0
- package/dist/i18n/__fixtures__/SsrHarness.svelte +15 -0
- package/dist/i18n/__fixtures__/SsrHarness.svelte.d.ts +7 -0
- package/dist/i18n/context.svelte.d.ts +107 -0
- package/dist/i18n/context.svelte.js +167 -0
- package/dist/i18n/package-integration.d.ts +118 -0
- package/dist/i18n/package-integration.js +211 -0
- package/dist/i18n/registry.svelte.d.ts +93 -0
- package/dist/i18n/registry.svelte.js +460 -0
- package/dist/i18n/resolve-locale.d.ts +41 -0
- package/dist/i18n/resolve-locale.js +81 -0
- package/dist/i18n/types.d.ts +127 -0
- package/dist/i18n/types.js +18 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +13 -0
- package/dist/utils/deep-keys.d.ts +27 -0
- package/dist/utils/deep-keys.js +54 -0
- package/package.json +74 -0
|
@@ -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
|
+
}
|