@sveltekit-i18n/base 1.3.7 → 3.0.0-next.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 +357 -82
- package/dist/I18n.svelte.d.ts +80 -0
- package/dist/I18n.svelte.js +486 -0
- package/dist/exports/utils.d.ts +2 -0
- package/dist/exports/utils.js +1 -0
- package/dist/index.d.ts +2 -227
- package/dist/index.js +1 -1
- package/dist/logger.d.ts +5 -0
- package/dist/logger.js +35 -0
- package/dist/types.d.ts +461 -0
- package/dist/types.js +1 -0
- package/dist/utils.d.ts +25 -0
- package/dist/utils.js +237 -0
- package/package.json +40 -25
- package/dist/index.cjs +0 -1
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
import { fetchTranslations, hasOwn, mergeTranslations, read, resolveLoaders, sanitizerFactory, sanitizeTranslationLocales, testRoute, toDotNotation, translate } from './utils.js';
|
|
2
|
+
import { logError, logger, loggerFactory, setLogger } from './logger.js';
|
|
3
|
+
const defaultCache = Number.POSITIVE_INFINITY;
|
|
4
|
+
class I18nCore {
|
|
5
|
+
// -- reactive state ---------------------------------------------------------
|
|
6
|
+
#config = $state(undefined);
|
|
7
|
+
/** The ACTIVE locale — advances only after its translations resolved. */
|
|
8
|
+
#locale = $state(undefined);
|
|
9
|
+
/** The locale most recently asked for; loads fire once a route exists too. */
|
|
10
|
+
#requestedLocale = $state(undefined);
|
|
11
|
+
#route = $state(undefined);
|
|
12
|
+
#rawTranslations = $state({});
|
|
13
|
+
#translations = $state({});
|
|
14
|
+
/** Replaced immutably on every change so `loading` recomputes. */
|
|
15
|
+
#pending = $state(new Set());
|
|
16
|
+
/** Locale normalization, as `config.sanitizeLocales` asks for it. */
|
|
17
|
+
#sanitize = $derived(sanitizerFactory(this.#config?.sanitizeLocales));
|
|
18
|
+
// -- plain internal state ---------------------------------------------------
|
|
19
|
+
// Null prototype: these tables are indexed by user-supplied locales, and a
|
|
20
|
+
// plain object would resolve a '__proto__' assignment via the setter.
|
|
21
|
+
#loadedKeys = Object.create(null);
|
|
22
|
+
/** When each locale first received data — drives the `cache` expiry. */
|
|
23
|
+
#loadedAt = Object.create(null);
|
|
24
|
+
/** In-flight loads keyed by locale and route; duplicate triggers share the promise. */
|
|
25
|
+
#inflight = new Map();
|
|
26
|
+
#destroyed = false;
|
|
27
|
+
constructor(config) {
|
|
28
|
+
if (config)
|
|
29
|
+
void this.loadConfig(config);
|
|
30
|
+
// A constructor may return a substitute object: the instance is folded
|
|
31
|
+
// through `config.extensions` left to right, so `new I18n(config)`
|
|
32
|
+
// evaluates to the last extension's output. The extensions run after the
|
|
33
|
+
// synchronous part of `configLoader` — they receive a configured instance.
|
|
34
|
+
return (config?.extensions ?? []).reduce((acc, extension) => extension(acc), this);
|
|
35
|
+
}
|
|
36
|
+
// -- reactive reads ---------------------------------------------------------
|
|
37
|
+
/**
|
|
38
|
+
* The active locale. Reading it is reactive; assigning it is a shorthand for
|
|
39
|
+
* a fire-and-forget `setLocale()` — the value therefore updates once the
|
|
40
|
+
* locale's translations resolved, not synchronously on assignment.
|
|
41
|
+
*/
|
|
42
|
+
get locale() {
|
|
43
|
+
return this.#locale;
|
|
44
|
+
}
|
|
45
|
+
set locale(value) {
|
|
46
|
+
if (value)
|
|
47
|
+
void this.setLocale(value);
|
|
48
|
+
}
|
|
49
|
+
get translations() {
|
|
50
|
+
return this.#translations;
|
|
51
|
+
}
|
|
52
|
+
get rawTranslations() {
|
|
53
|
+
return this.#rawTranslations;
|
|
54
|
+
}
|
|
55
|
+
loading = $derived(this.#pending.size > 0);
|
|
56
|
+
locales = $derived.by(() => {
|
|
57
|
+
if (!this.#config)
|
|
58
|
+
return [];
|
|
59
|
+
const { loaders = [] } = this.#config;
|
|
60
|
+
const loaderLocales = loaders.map(({ locale }) => locale);
|
|
61
|
+
const translationLocales = Object.keys(this.#translations);
|
|
62
|
+
return Array.from(new Set([
|
|
63
|
+
...this.#sanitize(...loaderLocales),
|
|
64
|
+
...this.#sanitize(...translationLocales),
|
|
65
|
+
]));
|
|
66
|
+
});
|
|
67
|
+
initialized = $derived(this.#locale !== undefined && this.#route !== undefined && Object.keys(this.#translations).length > 0);
|
|
68
|
+
/**
|
|
69
|
+
* Translates `key` for the active locale. Reactive wherever reads are
|
|
70
|
+
* tracked: the call reads the translation table and locale, so a component
|
|
71
|
+
* using `{i18n.t('key')}` re-renders when either changes.
|
|
72
|
+
*/
|
|
73
|
+
t = (key, ...params) => {
|
|
74
|
+
const { parser, fallbackLocale, ...rest } = this.#config ?? {};
|
|
75
|
+
return translate({
|
|
76
|
+
parser,
|
|
77
|
+
key,
|
|
78
|
+
params,
|
|
79
|
+
translations: this.#translations,
|
|
80
|
+
locale: this.#locale,
|
|
81
|
+
fallbackLocale,
|
|
82
|
+
...(hasOwn(rest, 'fallbackValue') ? { fallbackValue: rest.fallbackValue } : {}),
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
/** Like `t`, for an explicit locale. */
|
|
86
|
+
l = (locale, key, ...params) => {
|
|
87
|
+
const { parser, fallbackLocale, ...rest } = this.#config ?? {};
|
|
88
|
+
const [sanitizedLocale = locale] = this.#sanitize(locale);
|
|
89
|
+
return translate({
|
|
90
|
+
parser,
|
|
91
|
+
key,
|
|
92
|
+
params,
|
|
93
|
+
translations: this.#translations,
|
|
94
|
+
locale: sanitizedLocale,
|
|
95
|
+
fallbackLocale,
|
|
96
|
+
...(hasOwn(rest, 'fallbackValue') ? { fallbackValue: rest.fallbackValue } : {}),
|
|
97
|
+
});
|
|
98
|
+
};
|
|
99
|
+
// -- configuration ----------------------------------------------------------
|
|
100
|
+
/**
|
|
101
|
+
* Applies a config. Overridable extension seam — `sveltekit-i18n` wires its
|
|
102
|
+
* default parser by extending this method.
|
|
103
|
+
*/
|
|
104
|
+
async configLoader(config) {
|
|
105
|
+
if (!config) {
|
|
106
|
+
logger.error('No config provided!');
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
// `extensions` is a construction-time directive, not configuration state —
|
|
110
|
+
// it is consumed by the constructor and must not land in `#config`.
|
|
111
|
+
const { initLocale, fallbackLocale, translations, log, extensions, ...rest } = config;
|
|
112
|
+
if (log)
|
|
113
|
+
setLogger(loggerFactory(log));
|
|
114
|
+
const sanitize = sanitizerFactory(rest.sanitizeLocales);
|
|
115
|
+
const [sanitizedInitLocale] = sanitize(initLocale);
|
|
116
|
+
const [sanitizedFallbackLocale] = sanitize(fallbackLocale);
|
|
117
|
+
const loaders = resolveLoaders(rest.loaders);
|
|
118
|
+
logger.debug('Setting config.');
|
|
119
|
+
this.#config = {
|
|
120
|
+
initLocale: sanitizedInitLocale,
|
|
121
|
+
fallbackLocale: sanitizedFallbackLocale,
|
|
122
|
+
translations,
|
|
123
|
+
...rest,
|
|
124
|
+
loaders,
|
|
125
|
+
};
|
|
126
|
+
// Report-only: the loader still runs, but `.` is the dot-notation
|
|
127
|
+
// separator, so a dotted key collides with the flattened namespace.
|
|
128
|
+
// `String` rather than a template literal — interpolating a Symbol throws,
|
|
129
|
+
// and a config-time report must not abort the rest of the config load.
|
|
130
|
+
loaders.forEach(({ key }) => {
|
|
131
|
+
const name = key == null ? '' : String(key);
|
|
132
|
+
if (name.includes('.')) {
|
|
133
|
+
logger.error(`Invalid '${name}' loader key. It shouldn't include the '.' character.`);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
// A reconfiguration can swap loaders or cache policy — bookkeeping from
|
|
137
|
+
// the previous config must not suppress the new loaders.
|
|
138
|
+
this.invalidate();
|
|
139
|
+
if (translations)
|
|
140
|
+
this.addTranslations(translations);
|
|
141
|
+
if (sanitizedInitLocale)
|
|
142
|
+
await this.loadTranslations(sanitizedInitLocale);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Public entry for (re)configuration. The failure is reported here and the
|
|
146
|
+
* promise marked handled, so a fire-and-forget call cannot become an
|
|
147
|
+
* unhandled rejection; an awaiting caller still receives it.
|
|
148
|
+
*/
|
|
149
|
+
loadConfig = (config) => {
|
|
150
|
+
if (this.#inert('loadConfig'))
|
|
151
|
+
return Promise.resolve();
|
|
152
|
+
const promise = this.configLoader(config);
|
|
153
|
+
promise.catch((error) => logError('Failed to load the i18n config.', error));
|
|
154
|
+
return promise;
|
|
155
|
+
};
|
|
156
|
+
// -- loading ----------------------------------------------------------------
|
|
157
|
+
setLocale = (locale) => {
|
|
158
|
+
if (!locale || this.#inert('setLocale'))
|
|
159
|
+
return Promise.resolve();
|
|
160
|
+
if (locale !== this.#requestedLocale) {
|
|
161
|
+
logger.debug(`Setting '${locale}' locale.`);
|
|
162
|
+
this.#requestedLocale = locale;
|
|
163
|
+
}
|
|
164
|
+
// Delegated even for a repeated value — the caller awaits "this locale is
|
|
165
|
+
// loaded", which may mean joining a load already in flight.
|
|
166
|
+
if (this.#route !== undefined)
|
|
167
|
+
return this.#load(locale, this.#route);
|
|
168
|
+
return Promise.resolve();
|
|
169
|
+
};
|
|
170
|
+
setRoute = (route) => {
|
|
171
|
+
if (this.#inert('setRoute'))
|
|
172
|
+
return Promise.resolve();
|
|
173
|
+
if (route !== this.#route) {
|
|
174
|
+
logger.debug(`Setting '${route}' route.`);
|
|
175
|
+
this.#route = route;
|
|
176
|
+
}
|
|
177
|
+
if (this.#requestedLocale !== undefined)
|
|
178
|
+
return this.#load(this.#requestedLocale, route);
|
|
179
|
+
return Promise.resolve();
|
|
180
|
+
};
|
|
181
|
+
loadTranslations = (locale, route = this.#route ?? '') => {
|
|
182
|
+
if (!locale || this.#inert('loadTranslations'))
|
|
183
|
+
return Promise.resolve();
|
|
184
|
+
this.#requestedLocale = locale;
|
|
185
|
+
this.#route = route;
|
|
186
|
+
return this.#load(locale, route);
|
|
187
|
+
};
|
|
188
|
+
/**
|
|
189
|
+
* Marks loaded translations stale — for one locale, or all of them. Loaders
|
|
190
|
+
* run again on the NEXT load trigger; the call itself starts no load and
|
|
191
|
+
* keeps the currently displayed translations in place. A load still in
|
|
192
|
+
* flight for an invalidated locale is severed: it settles, but its data is
|
|
193
|
+
* discarded — it predates the invalidation.
|
|
194
|
+
*/
|
|
195
|
+
invalidate = (locale) => {
|
|
196
|
+
if (this.#inert('invalidate'))
|
|
197
|
+
return;
|
|
198
|
+
if (locale !== undefined) {
|
|
199
|
+
const [sanitized] = this.#sanitize(locale);
|
|
200
|
+
if (sanitized !== undefined) {
|
|
201
|
+
delete this.#loadedKeys[sanitized];
|
|
202
|
+
delete this.#loadedAt[sanitized];
|
|
203
|
+
// Sever matching in-flight loads — applying their pre-invalidation
|
|
204
|
+
// data would resurrect the bookkeeping dropped above, permanently
|
|
205
|
+
// suppressing the promised refetch.
|
|
206
|
+
this.#inflight.forEach((_, key) => {
|
|
207
|
+
if (key.startsWith(`${sanitized}\u0000`))
|
|
208
|
+
this.#inflight.delete(key);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
this.#loadedKeys = Object.create(null);
|
|
214
|
+
this.#loadedAt = Object.create(null);
|
|
215
|
+
this.#inflight.clear();
|
|
216
|
+
};
|
|
217
|
+
addTranslations = (translations) => {
|
|
218
|
+
if (this.#inert('addTranslations'))
|
|
219
|
+
return;
|
|
220
|
+
this.#addTranslations(translations);
|
|
221
|
+
};
|
|
222
|
+
/**
|
|
223
|
+
* Serializes what this instance holds for the active locale and the fallback
|
|
224
|
+
* locale, narrowed to the current route: a key owned only by loaders that do
|
|
225
|
+
* not match the route is left out. The result is shaped like
|
|
226
|
+
* `config.translations`, so a client hydrates by handing it back to the
|
|
227
|
+
* constructor — the bookkeeping derived from it then keeps the matching
|
|
228
|
+
* loaders from fetching the same data again.
|
|
229
|
+
*/
|
|
230
|
+
snapshot = () => {
|
|
231
|
+
const { fallbackLocale } = this.#config ?? {};
|
|
232
|
+
const route = this.#route ?? '';
|
|
233
|
+
// `#locale` is already sanitized; the fallback is normalized the same way
|
|
234
|
+
// the loaders key their data.
|
|
235
|
+
const locales = [this.#locale, ...this.#sanitize(fallbackLocale)].filter((locale) => !!locale);
|
|
236
|
+
return locales.reduce((acc, locale) => {
|
|
237
|
+
if (hasOwn(acc, locale))
|
|
238
|
+
return acc;
|
|
239
|
+
const data = read(this.#rawTranslations, locale);
|
|
240
|
+
if (!data)
|
|
241
|
+
return acc;
|
|
242
|
+
const offRoute = this.#offRouteKeys(locale, route);
|
|
243
|
+
const relevant = Object.keys(data)
|
|
244
|
+
.filter((key) => !offRoute.has(key))
|
|
245
|
+
.reduce((keep, key) => ({ ...keep, [key]: read(data, key) }), {});
|
|
246
|
+
// An empty entry would still stamp the locale's freshness on the client,
|
|
247
|
+
// starting its `cache` window on data it never received.
|
|
248
|
+
if (!Object.keys(relevant).length)
|
|
249
|
+
return acc;
|
|
250
|
+
return { ...acc, [locale]: relevant };
|
|
251
|
+
}, {});
|
|
252
|
+
};
|
|
253
|
+
/**
|
|
254
|
+
* Detaches the instance from its loading lifecycle: in-flight loads settle
|
|
255
|
+
* with their data discarded, `loading` drops to `false`, and every further
|
|
256
|
+
* load or mutation call is ignored with a warning. Reads (`t`, `l`, `locale`,
|
|
257
|
+
* `translations`, `snapshot`) keep working, so a component still tearing down
|
|
258
|
+
* renders its last state instead of breaking. Idempotent.
|
|
259
|
+
*/
|
|
260
|
+
destroy = () => {
|
|
261
|
+
if (this.#destroyed)
|
|
262
|
+
return;
|
|
263
|
+
logger.debug('Destroying the i18n instance.');
|
|
264
|
+
this.#destroyed = true;
|
|
265
|
+
// Severed rather than awaited — the identity guard in `#load` makes a
|
|
266
|
+
// settled load apply nothing once its entry is gone.
|
|
267
|
+
this.#inflight.clear();
|
|
268
|
+
this.#pending = new Set();
|
|
269
|
+
};
|
|
270
|
+
// -- internals --------------------------------------------------------------
|
|
271
|
+
/**
|
|
272
|
+
* Resolves loader data for a locale and route WITHOUT applying it. Returns
|
|
273
|
+
* `[]` when there is nothing to load. The `cache` expiry is evaluated by
|
|
274
|
+
* load triggers, not here.
|
|
275
|
+
*/
|
|
276
|
+
async #getTranslationProps(locale, route) {
|
|
277
|
+
if (!this.#config || !locale)
|
|
278
|
+
return [];
|
|
279
|
+
const [sanitizedLocale] = this.#sanitize(locale);
|
|
280
|
+
const filteredLoaders = this.#filterLoaders(sanitizedLocale, route);
|
|
281
|
+
if (!filteredLoaders.length)
|
|
282
|
+
return [];
|
|
283
|
+
logger.debug('Fetching translations...');
|
|
284
|
+
const rawTranslations = await fetchTranslations(filteredLoaders, route);
|
|
285
|
+
const loadedKeys = Object.entries(rawTranslations).reduce((acc, [translationLocale, data]) => ({ ...acc, [translationLocale]: Object.keys(data ?? {}) }), {});
|
|
286
|
+
const keys = filteredLoaders
|
|
287
|
+
.filter(({ key, locale: loaderLocale }) => (read(loadedKeys, loaderLocale) || []).some(
|
|
288
|
+
// Exact or namespaced match only — `navbar` data must not mark a
|
|
289
|
+
// sibling `nav` loader as loaded.
|
|
290
|
+
(loadedKey) => `${loadedKey}` === key || `${loadedKey}`.startsWith(`${key}.`)))
|
|
291
|
+
.reduce((acc, { key, locale: loaderLocale }) => ({
|
|
292
|
+
...acc,
|
|
293
|
+
[loaderLocale]: [...(read(acc, loaderLocale) || []), key],
|
|
294
|
+
}), {});
|
|
295
|
+
return [rawTranslations, keys];
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Merges translations into the tables and registers their bookkeeping.
|
|
299
|
+
* `keys` carries the exact loader keys of a load; without it (the public
|
|
300
|
+
* `addTranslations` path) loaded keys derive from the data's top-level keys.
|
|
301
|
+
*/
|
|
302
|
+
#addTranslations(translations, keys) {
|
|
303
|
+
if (!translations)
|
|
304
|
+
return;
|
|
305
|
+
const { preprocess } = this.#config ?? {};
|
|
306
|
+
logger.debug('Adding translations...');
|
|
307
|
+
const sanitized = sanitizeTranslationLocales(translations, this.#sanitize);
|
|
308
|
+
const translationLocales = Object.keys(sanitized);
|
|
309
|
+
this.#rawTranslations = translationLocales.reduce((acc, locale) => ({
|
|
310
|
+
...acc,
|
|
311
|
+
[locale]: mergeTranslations(read(acc, locale) || {}, read(sanitized, locale) ?? {}, locale),
|
|
312
|
+
}), this.#rawTranslations);
|
|
313
|
+
this.#translations = translationLocales.reduce((acc, locale) => {
|
|
314
|
+
let dotnotate = true;
|
|
315
|
+
let input = read(sanitized, locale);
|
|
316
|
+
if (typeof preprocess === 'function') {
|
|
317
|
+
input = preprocess(input);
|
|
318
|
+
}
|
|
319
|
+
if (typeof preprocess === 'function' || preprocess === 'none') {
|
|
320
|
+
dotnotate = false;
|
|
321
|
+
}
|
|
322
|
+
return ({
|
|
323
|
+
...acc,
|
|
324
|
+
[locale]: mergeTranslations(read(acc, locale) || {}, (dotnotate ? toDotNotation(input, preprocess === 'preserveArrays') : input) ?? {}, locale),
|
|
325
|
+
});
|
|
326
|
+
}, this.#translations);
|
|
327
|
+
translationLocales.forEach((locale) => {
|
|
328
|
+
// A `null` payload for a locale must not take the whole call down —
|
|
329
|
+
// every step above tolerates it, so this bookkeeping does too.
|
|
330
|
+
let localeKeys = Object.keys(read(sanitized, locale) ?? {}).map((key) => `${key}`.split('.')[0]);
|
|
331
|
+
if (keys)
|
|
332
|
+
localeKeys = read(keys, locale);
|
|
333
|
+
this.#loadedKeys[locale] = Array.from(new Set([
|
|
334
|
+
...(read(this.#loadedKeys, locale) || []),
|
|
335
|
+
...(localeKeys || []),
|
|
336
|
+
]));
|
|
337
|
+
// Freshness is measured from the locale's FIRST data — later partial
|
|
338
|
+
// loads (other routes) must not extend the window.
|
|
339
|
+
if (read(this.#loadedAt, locale) === undefined)
|
|
340
|
+
this.#loadedAt[locale] = Date.now();
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
/** Reports a call on a destroyed instance; `true` means "ignore the call". */
|
|
344
|
+
#inert(action) {
|
|
345
|
+
if (!this.#destroyed)
|
|
346
|
+
return false;
|
|
347
|
+
logger.warn(`Ignoring '${action}' — this i18n instance was destroyed.`);
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
#resolveLocale(inputLocale) {
|
|
351
|
+
const { fallbackLocale } = this.#config ?? {};
|
|
352
|
+
const locale = inputLocale || fallbackLocale;
|
|
353
|
+
if (!locale)
|
|
354
|
+
return undefined;
|
|
355
|
+
const all = this.locales;
|
|
356
|
+
// Nothing to match against yet; sanitizing here would only emit a
|
|
357
|
+
// non-standard warning for a lookup that cannot succeed anyway.
|
|
358
|
+
if (!all.length)
|
|
359
|
+
return undefined;
|
|
360
|
+
// Sanitized once per lookup rather than once per candidate locale.
|
|
361
|
+
const sanitized = this.#sanitize(locale);
|
|
362
|
+
const match = all.find((known) => sanitized.includes(known));
|
|
363
|
+
if (match || !fallbackLocale || fallbackLocale === locale)
|
|
364
|
+
return match;
|
|
365
|
+
// Evaluated lazily: the fallback (and any non-standard warning it emits)
|
|
366
|
+
// must not run when the requested locale resolves directly.
|
|
367
|
+
const sanitizedFallback = this.#sanitize(fallbackLocale);
|
|
368
|
+
return all.find((known) => sanitizedFallback.includes(known));
|
|
369
|
+
}
|
|
370
|
+
#cacheValue() {
|
|
371
|
+
const { cache = defaultCache } = this.#config ?? {};
|
|
372
|
+
return Number.isNaN(+cache) ? defaultCache : +cache;
|
|
373
|
+
}
|
|
374
|
+
/** Drops the bookkeeping of every given locale whose `cache` window elapsed. */
|
|
375
|
+
#invalidateExpired(...locales) {
|
|
376
|
+
const cacheValue = this.#cacheValue();
|
|
377
|
+
locales.forEach((locale) => {
|
|
378
|
+
if (!locale)
|
|
379
|
+
return;
|
|
380
|
+
const loadedAt = read(this.#loadedAt, locale);
|
|
381
|
+
if (loadedAt !== undefined && Date.now() >= loadedAt + cacheValue) {
|
|
382
|
+
logger.debug(`'${locale}' translations expired. Loaders will run again.`);
|
|
383
|
+
this.invalidate(locale);
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
/** Activates `locale` unless another request superseded its load meanwhile. */
|
|
388
|
+
#activate(locale) {
|
|
389
|
+
const requested = this.#resolveLocale(this.#requestedLocale);
|
|
390
|
+
// An unresolvable most-recent request supersedes nothing — it must not
|
|
391
|
+
// block a completed load from activating.
|
|
392
|
+
if (requested !== undefined && requested !== locale)
|
|
393
|
+
return;
|
|
394
|
+
if (this.#locale !== locale)
|
|
395
|
+
this.#locale = locale;
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Loader keys of `sanitizedLocale` that only ever load on OTHER routes. A key
|
|
399
|
+
* claimed by a route-matching loader — or by no loader at all — is not
|
|
400
|
+
* attributable to another route and is therefore absent here.
|
|
401
|
+
*/
|
|
402
|
+
#offRouteKeys(sanitizedLocale, route) {
|
|
403
|
+
const { loaders = [] } = this.#config ?? {};
|
|
404
|
+
const offRoute = new Set();
|
|
405
|
+
const onRoute = new Set();
|
|
406
|
+
loaders.forEach(({ key, locale, routes }) => {
|
|
407
|
+
if (this.#sanitize(locale)[0] !== sanitizedLocale)
|
|
408
|
+
return;
|
|
409
|
+
(routes && !routes.some(testRoute(route)) ? offRoute : onRoute).add(key);
|
|
410
|
+
});
|
|
411
|
+
onRoute.forEach((key) => offRoute.delete(key));
|
|
412
|
+
return offRoute;
|
|
413
|
+
}
|
|
414
|
+
#filterLoaders(sanitizedLocale, route) {
|
|
415
|
+
const { loaders, fallbackLocale = '' } = this.#config ?? {};
|
|
416
|
+
const [sanitizedFallbackLocale] = this.#sanitize(fallbackLocale);
|
|
417
|
+
const translationForLocale = read(this.#translations, sanitizedLocale);
|
|
418
|
+
const translationForFallbackLocale = read(this.#translations, sanitizedFallbackLocale);
|
|
419
|
+
return (loaders || [])
|
|
420
|
+
.map(({ locale, ...rest }) => ({ ...rest, locale: this.#sanitize(locale)[0] }))
|
|
421
|
+
.filter(({ routes }) => !routes || (routes || []).some(testRoute(route)))
|
|
422
|
+
.filter(({ key, locale }) => (locale === sanitizedLocale && (!translationForLocale || !(read(this.#loadedKeys, sanitizedLocale) || []).includes(key))) || (fallbackLocale && locale === sanitizedFallbackLocale && (!translationForFallbackLocale
|
|
423
|
+
|| !(read(this.#loadedKeys, sanitizedFallbackLocale) || []).includes(key))));
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Starts (or joins) a load. A load already in flight for the same locale
|
|
427
|
+
* and route is returned as-is, so concurrent duplicate triggers share one
|
|
428
|
+
* fetch. The pending entry is registered synchronously, so `loading` is
|
|
429
|
+
* observable right after the triggering call; a load with nothing to fetch
|
|
430
|
+
* never registers at all, so cache-served navigations do not flicker the flag.
|
|
431
|
+
*/
|
|
432
|
+
#load(requestedLocale, route) {
|
|
433
|
+
const locale = this.#resolveLocale(requestedLocale);
|
|
434
|
+
if (!locale)
|
|
435
|
+
return Promise.resolve();
|
|
436
|
+
// Expiry is evaluated per load trigger, BEFORE the in-flight check. That
|
|
437
|
+
// order is safe: a locale is stamped only once its data arrived, so a
|
|
438
|
+
// shared in-flight load cannot be invalidated by its own duplicates.
|
|
439
|
+
this.#invalidateExpired(locale, this.#sanitize(this.#config?.fallbackLocale)[0]);
|
|
440
|
+
// NUL never appears in a sanitized locale, so the key is unambiguous.
|
|
441
|
+
const inflightKey = `${locale}\u0000${route}`;
|
|
442
|
+
const inflight = this.#inflight.get(inflightKey);
|
|
443
|
+
if (inflight)
|
|
444
|
+
return inflight;
|
|
445
|
+
if (!this.#filterLoaders(locale, route).length) {
|
|
446
|
+
// Nothing to fetch — the locale still becomes active (its data is
|
|
447
|
+
// already present or it has no loaders).
|
|
448
|
+
this.#activate(locale);
|
|
449
|
+
return Promise.resolve();
|
|
450
|
+
}
|
|
451
|
+
const promise = this.#getTranslationProps(locale, route).then((props) => {
|
|
452
|
+
// An `invalidate()` — explicit, via expiry, or via reconfiguration —
|
|
453
|
+
// that raced this load severed it from `#inflight`. Its data predates
|
|
454
|
+
// the invalidation: applying it would resurrect the dropped bookkeeping
|
|
455
|
+
// and permanently suppress the promised refetch.
|
|
456
|
+
if (this.#inflight.get(inflightKey) !== promise)
|
|
457
|
+
return;
|
|
458
|
+
if (props.length)
|
|
459
|
+
this.#addTranslations(...props);
|
|
460
|
+
this.#activate(locale);
|
|
461
|
+
});
|
|
462
|
+
this.#inflight.set(inflightKey, promise);
|
|
463
|
+
this.#pending = new Set(this.#pending).add(promise);
|
|
464
|
+
const settle = () => {
|
|
465
|
+
// Guarded by identity — a later load under the same key must not be
|
|
466
|
+
// evicted by this one settling.
|
|
467
|
+
if (this.#inflight.get(inflightKey) === promise)
|
|
468
|
+
this.#inflight.delete(inflightKey);
|
|
469
|
+
const next = new Set(this.#pending);
|
|
470
|
+
next.delete(promise);
|
|
471
|
+
this.#pending = next;
|
|
472
|
+
};
|
|
473
|
+
promise.then(settle, settle);
|
|
474
|
+
// Reported here so a discarded load is still visible, and marked handled so
|
|
475
|
+
// it cannot terminate the process; an awaiting caller still receives the
|
|
476
|
+
// rejection from the same promise.
|
|
477
|
+
promise.catch((error) => logError(`Failed to load translations for '${locale}' locale and '${route}' route.`, error));
|
|
478
|
+
return promise;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
// The raw class is deliberately not exported — every consumer constructs
|
|
482
|
+
// through the extension-aware signature. The exported name carries both
|
|
483
|
+
// meanings: the value is the facade, the type is the un-piped instance.
|
|
484
|
+
const I18n = I18nCore;
|
|
485
|
+
export { I18n };
|
|
486
|
+
export default I18n;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { sanitizeLocales, toDotNotation } from '../utils.js';
|