@sveltekit-i18n/base 3.0.0 → 3.1.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 +122 -48
- package/dist/I18n.svelte.d.ts +101 -23
- package/dist/I18n.svelte.js +1127 -237
- package/dist/exports/kit.d.ts +2 -0
- package/dist/exports/kit.js +1 -0
- package/dist/exports/utils.d.ts +1 -1
- package/dist/exports/utils.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/kit/define.svelte.d.ts +9 -0
- package/dist/kit/define.svelte.js +178 -0
- package/dist/kit/env.browser.d.ts +1 -0
- package/dist/kit/env.browser.js +1 -0
- package/dist/kit/env.d.ts +1 -0
- package/dist/kit/env.js +1 -0
- package/dist/kit/internal.d.ts +16 -0
- package/dist/kit/internal.js +1 -0
- package/dist/kit/server.browser.d.ts +2 -0
- package/dist/kit/server.browser.js +6 -0
- package/dist/kit/server.d.ts +2 -0
- package/dist/kit/server.js +72 -0
- package/dist/kit/types.d.ts +83 -0
- package/dist/kit/types.js +1 -0
- package/dist/types.d.ts +167 -22
- package/dist/utils.d.ts +82 -3
- package/dist/utils.js +452 -31
- package/package.json +23 -3
package/dist/I18n.svelte.js
CHANGED
|
@@ -1,28 +1,96 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { untrack } from 'svelte';
|
|
2
|
+
import { capturesParams, fetchTranslation, hasOwn, loaderName, mergeFetched, mergeTranslations, omitProtoKeys, paramsSignature, read, resolveLoaders, routeParams, sanitizerFactory, sanitizeTranslationLocales, serialize, servedLocales, toDotNotation, translate, unique, withoutBasePath } from './utils.js';
|
|
2
3
|
import { logError, logger, loggerFactory, setLogger } from './logger.js';
|
|
3
4
|
const defaultCache = Number.POSITIVE_INFINITY;
|
|
5
|
+
/**
|
|
6
|
+
* A top-level key holding part of `namespace` — the namespace itself or a key
|
|
7
|
+
* flattened out of it. Keys are strings, so an off-contract namespace that is
|
|
8
|
+
* not one holds none of them.
|
|
9
|
+
*/
|
|
10
|
+
const isNamespaceKey = (key, namespace) => typeof namespace === 'string' && (key === namespace || key.startsWith(`${namespace}.`));
|
|
4
11
|
class I18nCore {
|
|
5
12
|
// -- reactive state ---------------------------------------------------------
|
|
6
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Raw, as are the tables: each is replaced whole, never changed in place,
|
|
15
|
+
* and deep state would hand back proxies of what it holds in the browser —
|
|
16
|
+
* loaders no key of `#deliveries` or the records matches, and tables a
|
|
17
|
+
* structured clone rejects.
|
|
18
|
+
*/
|
|
19
|
+
#config = $state.raw(undefined);
|
|
7
20
|
/** The ACTIVE locale — advances only after its translations resolved. */
|
|
8
21
|
#locale = $state(undefined);
|
|
9
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* The locale the next trigger loads: the one most recently asked for, unless
|
|
24
|
+
* control flow failed that call and put back what it replaced. Loads fire
|
|
25
|
+
* once a route exists too.
|
|
26
|
+
*/
|
|
10
27
|
#requestedLocale = $state(undefined);
|
|
11
28
|
#route = $state(undefined);
|
|
12
|
-
#rawTranslations = $state({});
|
|
13
|
-
#translations = $state({});
|
|
29
|
+
#rawTranslations = $state.raw({});
|
|
30
|
+
#translations = $state.raw({});
|
|
14
31
|
/** Replaced immutably on every change so `loading` recomputes. */
|
|
15
32
|
#pending = $state(new Set());
|
|
16
33
|
/** Locale normalization, as `config.sanitizeLocales` asks for it. */
|
|
17
34
|
#sanitize = $derived(sanitizerFactory(this.#config?.sanitizeLocales));
|
|
18
35
|
// -- plain internal state ---------------------------------------------------
|
|
36
|
+
// Load records keep a loader from running twice. A loader's own record holds
|
|
37
|
+
// the params signature it last delivered for; a namespace record stands for
|
|
38
|
+
// a namespace a plain hand-off delivered, and keeps its loaders from fetching
|
|
39
|
+
// it again unless their params ask for other data. Seeded data records
|
|
40
|
+
// nothing.
|
|
41
|
+
#loaderRecords = new Map();
|
|
19
42
|
// Null prototype: these tables are indexed by user-supplied locales, and a
|
|
20
43
|
// plain object would resolve a '__proto__' assignment via the setter.
|
|
21
|
-
#
|
|
44
|
+
#namespaceRecords = Object.create(null);
|
|
45
|
+
// What each source last put into a namespace, so a loader whose params
|
|
46
|
+
// changed can replace its own part and leave its siblings' in place. Kept
|
|
47
|
+
// apart from the records: invalidation drops those, not what is displayed,
|
|
48
|
+
// and a reconfiguration hands them on to the loaders with the same id.
|
|
49
|
+
#deliveries = new Map();
|
|
50
|
+
#externalTranslations = {};
|
|
51
|
+
// The params signature the next trigger asks each loader for:
|
|
52
|
+
// the one the most recent call asked for, unless a failed call put an
|
|
53
|
+
// earlier one's back, and `null` for a loader its route does not select.
|
|
54
|
+
// Loads settle out of order, and a delivery for params the route no longer
|
|
55
|
+
// asks for must not replace what it displays; a warm load asks for nothing.
|
|
56
|
+
#wanted = new Map();
|
|
57
|
+
// The latest delivery of each loader for params nothing wanted yet — a warm
|
|
58
|
+
// load of another route's params, typically a preload — so the trigger that
|
|
59
|
+
// wants them applies it instead of fetching again. One per loader, as a
|
|
60
|
+
// router keeps one preload. It starts its locale's `cache` window, and
|
|
61
|
+
// invalidation drops it with the records.
|
|
62
|
+
#parked = new Map();
|
|
63
|
+
// What a hand-off delivered for the loaders with `cache: false`, whose
|
|
64
|
+
// records keep no trigger that selects them from running them. It serves the
|
|
65
|
+
// pass it arrived with: until an activating trigger asks for another locale
|
|
66
|
+
// or route than the hand-off named, or than the first activating trigger
|
|
67
|
+
// after it where the hand-off named none.
|
|
68
|
+
#handedOff = new Map();
|
|
69
|
+
#handOffPass = {};
|
|
22
70
|
/** When each locale first received data — drives the `cache` expiry. */
|
|
23
71
|
#loadedAt = Object.create(null);
|
|
24
|
-
/**
|
|
25
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Loads in flight, each under the key of what its trigger selected; a
|
|
74
|
+
* trigger with the same key shares the promise of one that delivers all it
|
|
75
|
+
* has to fetch. `calls` holds the activating calls that share it, so a
|
|
76
|
+
* warm load joined by one activates when it settles, or fails them as an
|
|
77
|
+
* activating load does. `unparked` holds the parked deliveries its calls
|
|
78
|
+
* claimed, which it applies when it settles. `severed` holds the loaders, of
|
|
79
|
+
* either kind, an invalidation cut off while the load was in flight.
|
|
80
|
+
*/
|
|
81
|
+
#inflight = new Set();
|
|
82
|
+
// The loaders' fetches in flight, which loads from the same route share
|
|
83
|
+
// wherever their selections differ.
|
|
84
|
+
#fetches = new Set();
|
|
85
|
+
// The control flow of a shared fetch already reported: every load it
|
|
86
|
+
// rejects gets it, and it was thrown once.
|
|
87
|
+
#reported = new WeakSet();
|
|
88
|
+
/**
|
|
89
|
+
* The activating calls since the last one whose load resolved without
|
|
90
|
+
* failing, oldest first. A failed call is undone while it is the last, so a
|
|
91
|
+
* call that came later and has not failed keeps what it asked for.
|
|
92
|
+
*/
|
|
93
|
+
#calls = [];
|
|
26
94
|
#destroyed = false;
|
|
27
95
|
constructor(config) {
|
|
28
96
|
if (config)
|
|
@@ -37,7 +105,9 @@ class I18nCore {
|
|
|
37
105
|
/**
|
|
38
106
|
* The active locale. Reading it is reactive; assigning it is a shorthand for
|
|
39
107
|
* a fire-and-forget `setLocale()` — the value therefore updates once the
|
|
40
|
-
* locale's translations resolved, not synchronously on assignment.
|
|
108
|
+
* locale's translations resolved, not synchronously on assignment. It does
|
|
109
|
+
* not advance when a loader throws SvelteKit's `redirect()` or an `error()`
|
|
110
|
+
* below 500, which an assignment only logs.
|
|
41
111
|
*/
|
|
42
112
|
get locale() {
|
|
43
113
|
return this.#locale;
|
|
@@ -56,13 +126,10 @@ class I18nCore {
|
|
|
56
126
|
locales = $derived.by(() => {
|
|
57
127
|
if (!this.#config)
|
|
58
128
|
return [];
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
return
|
|
63
|
-
...this.#sanitize(...loaderLocales),
|
|
64
|
-
...this.#sanitize(...translationLocales),
|
|
65
|
-
]));
|
|
129
|
+
// Loader locales are sanitized once, when the config resolves them, and
|
|
130
|
+
// table locales once, when their data arrives; a custom `sanitizeLocales`
|
|
131
|
+
// need not be idempotent.
|
|
132
|
+
return servedLocales(this.#config.loaders ?? [], this.#translations);
|
|
66
133
|
});
|
|
67
134
|
initialized = $derived(this.#locale !== undefined && this.#route !== undefined && Object.keys(this.#translations).length > 0);
|
|
68
135
|
/**
|
|
@@ -88,11 +155,11 @@ class I18nCore {
|
|
|
88
155
|
};
|
|
89
156
|
});
|
|
90
157
|
// -- configuration ----------------------------------------------------------
|
|
91
|
-
/** Applies a config. The public entry is `loadConfig`. */
|
|
92
|
-
|
|
158
|
+
/** Applies a config and returns the `initLocale` load. The public entry is `loadConfig`. */
|
|
159
|
+
#configLoader(config) {
|
|
93
160
|
if (!config) {
|
|
94
161
|
logger.error('No config provided!');
|
|
95
|
-
return;
|
|
162
|
+
return Promise.resolve();
|
|
96
163
|
}
|
|
97
164
|
// `extensions` is a construction-time directive, not configuration state —
|
|
98
165
|
// it is consumed by the constructor and must not land in `#config`.
|
|
@@ -102,7 +169,7 @@ class I18nCore {
|
|
|
102
169
|
const sanitize = sanitizerFactory(rest.sanitizeLocales);
|
|
103
170
|
const [sanitizedInitLocale] = sanitize(initLocale);
|
|
104
171
|
const [sanitizedFallbackLocale] = sanitize(fallbackLocale);
|
|
105
|
-
const loaders = resolveLoaders(rest.loaders);
|
|
172
|
+
const loaders = resolveLoaders(rest.loaders, rest.sanitizeLocales);
|
|
106
173
|
logger.debug('Setting config.');
|
|
107
174
|
this.#config = {
|
|
108
175
|
initLocale: sanitizedInitLocale,
|
|
@@ -112,147 +179,327 @@ class I18nCore {
|
|
|
112
179
|
loaders,
|
|
113
180
|
};
|
|
114
181
|
// Report-only: the loader still runs, but `.` is the dot-notation
|
|
115
|
-
// separator, so a dotted
|
|
182
|
+
// separator, so a dotted namespace collides with the flattened one.
|
|
116
183
|
// `String` rather than a template literal — interpolating a Symbol throws,
|
|
117
184
|
// and a config-time report must not abort the rest of the config load.
|
|
118
|
-
loaders.forEach(({
|
|
119
|
-
const name =
|
|
185
|
+
loaders.forEach(({ namespace }) => {
|
|
186
|
+
const name = namespace == null ? '' : String(namespace);
|
|
120
187
|
if (name.includes('.')) {
|
|
121
|
-
logger.error(`Invalid '${name}' loader
|
|
188
|
+
logger.error(`Invalid '${name}' loader namespace. It shouldn't include the '.' character.`);
|
|
122
189
|
}
|
|
123
190
|
});
|
|
124
191
|
// A reconfiguration can swap loaders or cache policy — bookkeeping from
|
|
125
192
|
// the previous config must not suppress the new loaders.
|
|
126
193
|
this.invalidate();
|
|
194
|
+
this.#wanted.clear();
|
|
195
|
+
this.#handOnDeliveries(loaders);
|
|
127
196
|
if (translations)
|
|
128
197
|
this.addTranslations(translations);
|
|
129
|
-
|
|
130
|
-
await this.loadTranslations(sanitizedInitLocale);
|
|
198
|
+
return sanitizedInitLocale ? this.loadTranslations(initLocale) : Promise.resolve();
|
|
131
199
|
}
|
|
132
200
|
/**
|
|
133
|
-
* Public entry for (re)configuration.
|
|
134
|
-
*
|
|
135
|
-
*
|
|
201
|
+
* Public entry for (re)configuration. It returns the promise of the
|
|
202
|
+
* `initLocale` load, which reports its own failure; a config that fails to
|
|
203
|
+
* apply is reported here. Either way the promise is marked handled, so a
|
|
204
|
+
* fire-and-forget call cannot become an unhandled rejection; an awaiting
|
|
205
|
+
* caller still receives it.
|
|
136
206
|
*/
|
|
137
|
-
loadConfig = (config) => {
|
|
207
|
+
loadConfig = (config) => untrack(() => {
|
|
138
208
|
if (this.#inert('loadConfig'))
|
|
139
209
|
return Promise.resolve();
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
210
|
+
// Not async: the `initLocale` load's own promise is returned as it is, and
|
|
211
|
+
// a config that throws is caught here instead of escaping the constructor.
|
|
212
|
+
try {
|
|
213
|
+
return this.#configLoader(config);
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
logError('Failed to load the i18n config.', error);
|
|
217
|
+
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- passed on as thrown
|
|
218
|
+
const promise = Promise.reject(error);
|
|
219
|
+
promise.catch(() => undefined);
|
|
220
|
+
return promise;
|
|
221
|
+
}
|
|
222
|
+
});
|
|
144
223
|
// -- loading ----------------------------------------------------------------
|
|
145
|
-
|
|
146
|
-
|
|
224
|
+
// The loading calls — these, `loadNamespace()` and `loadConfig()` — and
|
|
225
|
+
// `addTranslations()` and `hydrate()` read the state they write, untracked:
|
|
226
|
+
// called from an effect, they must not make it depend on that state, or the
|
|
227
|
+
// effect runs them again whenever a later call, a load or an undo changes it.
|
|
228
|
+
setLocale = (locale) => untrack(() => {
|
|
229
|
+
if (!locale || this.#inert('setLocale') || this.#unserved(locale))
|
|
147
230
|
return Promise.resolve();
|
|
231
|
+
const call = this.#ask();
|
|
148
232
|
if (locale !== this.#requestedLocale) {
|
|
149
233
|
logger.debug(`Setting '${locale}' locale.`);
|
|
150
234
|
this.#requestedLocale = locale;
|
|
151
235
|
}
|
|
152
236
|
// Delegated even for a repeated value — the caller awaits "this locale is
|
|
153
237
|
// loaded", which may mean joining a load already in flight.
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
};
|
|
158
|
-
setRoute = (route) => {
|
|
238
|
+
return this.#stand(call, this.#route !== undefined ? this.#load(locale, this.#route, call) : Promise.resolve());
|
|
239
|
+
});
|
|
240
|
+
setRoute = (input) => untrack(() => {
|
|
159
241
|
if (this.#inert('setRoute'))
|
|
160
242
|
return Promise.resolve();
|
|
243
|
+
const route = withoutBasePath(input, this.#config?.basePath);
|
|
244
|
+
const call = this.#ask();
|
|
161
245
|
if (route !== this.#route) {
|
|
162
246
|
logger.debug(`Setting '${route}' route.`);
|
|
163
247
|
this.#route = route;
|
|
164
248
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
249
|
+
return this.#stand(call, this.#requestedLocale !== undefined ? this.#load(this.#requestedLocale, route, call) : Promise.resolve());
|
|
250
|
+
});
|
|
251
|
+
/**
|
|
252
|
+
* `{ activate: false }` only fills the tables: it leaves the requested
|
|
253
|
+
* locale, the route and `locale` untouched and does not count towards
|
|
254
|
+
* `loading` — what is rendered does not change: data of a loader whose route
|
|
255
|
+
* params differ from the ones the current route asks for is kept aside, the
|
|
256
|
+
* latest per loader, and the activating trigger that asks for them applies
|
|
257
|
+
* it. It
|
|
258
|
+
* leaves `cache` expiry to the next activating trigger. A loader's
|
|
259
|
+
* `redirect()` or `error()` below 500 rejects it all the same.
|
|
260
|
+
*/
|
|
261
|
+
loadTranslations = (locale, route, { activate = true } = {}) => untrack(() => {
|
|
262
|
+
if (!locale || this.#inert('loadTranslations') || this.#unserved(locale))
|
|
171
263
|
return Promise.resolve();
|
|
264
|
+
const target = route === undefined ? this.#route ?? '' : withoutBasePath(route, this.#config?.basePath);
|
|
265
|
+
if (!activate)
|
|
266
|
+
return this.#load(locale, target);
|
|
267
|
+
const call = this.#ask();
|
|
172
268
|
this.#requestedLocale = locale;
|
|
173
|
-
this.#route =
|
|
174
|
-
return this.#load(locale,
|
|
175
|
-
};
|
|
269
|
+
this.#route = target;
|
|
270
|
+
return this.#stand(call, this.#load(locale, target, call));
|
|
271
|
+
});
|
|
176
272
|
/**
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
273
|
+
* Loads one namespace for the active locale (or `locale`), whatever the
|
|
274
|
+
* routes of its loaders say — for what an interaction needs rather than a
|
|
275
|
+
* route: a modal, a panel, an editor. Warm, like `{ activate: false }`: it
|
|
276
|
+
* changes neither the locale nor `loading`, and it leaves `cache` expiry to
|
|
277
|
+
* the next activating trigger. It honours the load records, so calling it
|
|
278
|
+
* on every interaction fetches once — a loader with `cache: false` runs each
|
|
279
|
+
* time on its routes — and what it loads stays loaded across routes. A
|
|
280
|
+
* loader's `redirect()` or `error()` below 500 rejects it.
|
|
182
281
|
*/
|
|
183
|
-
|
|
282
|
+
loadNamespace = (namespace, locale) => untrack(() => {
|
|
283
|
+
if (this.#inert('loadNamespace'))
|
|
284
|
+
return Promise.resolve();
|
|
285
|
+
const target = locale ?? this.#locale;
|
|
286
|
+
if (!target)
|
|
287
|
+
return Promise.resolve();
|
|
288
|
+
return this.#load(target, this.#route ?? '', undefined, namespace);
|
|
289
|
+
});
|
|
290
|
+
/**
|
|
291
|
+
* Marks loaded translations stale — for one locale or all of them, and for
|
|
292
|
+
* one namespace or all of them. Loaders run again on the NEXT load trigger;
|
|
293
|
+
* the call itself starts no load and keeps the currently displayed
|
|
294
|
+
* translations in place. A loader still in flight for what was invalidated
|
|
295
|
+
* is severed: its load settles, but what it returns or throws is discarded —
|
|
296
|
+
* it predates the invalidation — and an activating trigger fetches it again,
|
|
297
|
+
* once, before its locale activates. It leaves it to the next trigger when
|
|
298
|
+
* another loader of its load threw SvelteKit's control flow that still
|
|
299
|
+
* counts, which rejects the trigger, and when the refetch is severed too. A
|
|
300
|
+
* namespace invalidation leaves the locale's `cache` window where it was.
|
|
301
|
+
*/
|
|
302
|
+
invalidate = (locale, namespace) => {
|
|
184
303
|
if (this.#inert('invalidate'))
|
|
185
304
|
return;
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
if (sanitized !== undefined) {
|
|
189
|
-
delete this.#loadedKeys[sanitized];
|
|
190
|
-
delete this.#loadedAt[sanitized];
|
|
191
|
-
// Sever matching in-flight loads — applying their pre-invalidation
|
|
192
|
-
// data would resurrect the bookkeeping dropped above, permanently
|
|
193
|
-
// suppressing the promised refetch.
|
|
194
|
-
this.#inflight.forEach((_, key) => {
|
|
195
|
-
if (key.startsWith(`${sanitized}\u0000`))
|
|
196
|
-
this.#inflight.delete(key);
|
|
197
|
-
});
|
|
198
|
-
}
|
|
305
|
+
const [sanitized] = locale === undefined ? [] : this.#sanitize(locale);
|
|
306
|
+
if (locale !== undefined && sanitized === undefined)
|
|
199
307
|
return;
|
|
200
|
-
|
|
201
|
-
this.#loadedKeys = Object.create(null);
|
|
202
|
-
this.#loadedAt = Object.create(null);
|
|
203
|
-
this.#inflight.clear();
|
|
308
|
+
this.#invalidate(sanitized, namespace);
|
|
204
309
|
};
|
|
205
|
-
addTranslations = (translations) => {
|
|
310
|
+
addTranslations = (translations) => untrack(() => {
|
|
206
311
|
if (this.#inert('addTranslations'))
|
|
207
312
|
return;
|
|
208
313
|
this.#addTranslations(translations);
|
|
209
|
-
};
|
|
314
|
+
});
|
|
315
|
+
/**
|
|
316
|
+
* Drops the bookkeeping `invalidate()` names and severs its loaders in
|
|
317
|
+
* flight. `config.cache` does not cover a loader with `cache: false`, so
|
|
318
|
+
* expiry leaves it alone.
|
|
319
|
+
*/
|
|
320
|
+
#invalidate(sanitized, namespace, { expiry = false } = {}) {
|
|
321
|
+
const locales = sanitized === undefined ? Object.keys(this.#namespaceRecords) : [sanitized];
|
|
322
|
+
locales.forEach((recorded) => {
|
|
323
|
+
if (namespace === undefined)
|
|
324
|
+
delete this.#namespaceRecords[recorded];
|
|
325
|
+
else
|
|
326
|
+
this.#namespaceRecords[recorded] = (read(this.#namespaceRecords, recorded) || []).filter((name) => name !== namespace);
|
|
327
|
+
});
|
|
328
|
+
if (namespace === undefined) {
|
|
329
|
+
if (sanitized === undefined)
|
|
330
|
+
this.#loadedAt = Object.create(null);
|
|
331
|
+
else
|
|
332
|
+
delete this.#loadedAt[sanitized];
|
|
333
|
+
}
|
|
334
|
+
const covers = (loader) => !(expiry && loader.cache === false)
|
|
335
|
+
&& (sanitized === undefined || loader.locale === sanitized)
|
|
336
|
+
&& (namespace === undefined || loader.namespace === namespace);
|
|
337
|
+
this.#loaderRecords.forEach((_, loader) => {
|
|
338
|
+
if (covers(loader))
|
|
339
|
+
this.#loaderRecords.delete(loader);
|
|
340
|
+
});
|
|
341
|
+
this.#handedOff.forEach((_, loader) => {
|
|
342
|
+
if (covers(loader))
|
|
343
|
+
this.#handedOff.delete(loader);
|
|
344
|
+
});
|
|
345
|
+
this.#parked.forEach((_, loader) => {
|
|
346
|
+
if (covers(loader))
|
|
347
|
+
this.#parked.delete(loader);
|
|
348
|
+
});
|
|
349
|
+
// Applying their pre-invalidation data would resurrect the bookkeeping
|
|
350
|
+
// dropped above, permanently suppressing the promised refetch.
|
|
351
|
+
this.#sever(covers);
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Restores the state `snapshot({ records: true })` captured on another
|
|
355
|
+
* instance: its data, its load records, the active locale and the route.
|
|
356
|
+
* A loader named by a record does not run again for the same params — one
|
|
357
|
+
* with `cache: false` only for the pass the envelope arrived with; data no
|
|
358
|
+
* record names is displayed but keeps no loader from running. An envelope
|
|
359
|
+
* without `records` is a plain hand-off instead: every namespace its data
|
|
360
|
+
* names keeps its loaders without params from running, and holds one with
|
|
361
|
+
* `cache: false` back for that pass. Nothing
|
|
362
|
+
* happens for `undefined`, so a load whose server half sent nothing can call
|
|
363
|
+
* it unconditionally.
|
|
364
|
+
*/
|
|
365
|
+
hydrate = (envelope) => untrack(() => {
|
|
366
|
+
if (!envelope || this.#inert('hydrate'))
|
|
367
|
+
return;
|
|
368
|
+
// Typically `initLocale`: the constructor started its load before the
|
|
369
|
+
// records could keep the loaders from running.
|
|
370
|
+
if (this.#inflight.size)
|
|
371
|
+
logger.warn('Hydrating after a load started: its loaders ran regardless of the hand-off.');
|
|
372
|
+
const { translations = {}, records, seeds = {}, locale, route } = envelope;
|
|
373
|
+
if (records)
|
|
374
|
+
this.#hydrateRecords(translations, records, seeds);
|
|
375
|
+
else
|
|
376
|
+
this.#hydratePlain(translations);
|
|
377
|
+
this.#handOffPass = { locale, route };
|
|
378
|
+
// A hand-off stands: no call before it is put back.
|
|
379
|
+
this.#calls = [];
|
|
380
|
+
if (route !== undefined)
|
|
381
|
+
this.#route = route;
|
|
382
|
+
if (locale !== undefined) {
|
|
383
|
+
this.#requestedLocale = locale;
|
|
384
|
+
this.#locale = locale;
|
|
385
|
+
}
|
|
386
|
+
const resolved = this.#resolveLocale(locale);
|
|
387
|
+
if (resolved !== undefined && route !== undefined)
|
|
388
|
+
this.#want(this.#matchLoaders(resolved, route));
|
|
389
|
+
});
|
|
210
390
|
/**
|
|
211
391
|
* Serializes what this instance holds for the active locale and the fallback
|
|
212
|
-
* locale
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
392
|
+
* locale. The result is shaped like `config.translations`, and a client
|
|
393
|
+
* hands it over with `hydrate({ translations })`: a plain hand-off, whose
|
|
394
|
+
* namespace records keep the matching loaders without params from fetching
|
|
395
|
+
* it again and hold one with `cache: false` back for that pass. Passed to
|
|
396
|
+
* `addTranslations()` or assigned to `config.translations` it only seeds,
|
|
397
|
+
* and every loader runs again.
|
|
398
|
+
* A namespace plain data cannot hand over is left out, for the client to
|
|
399
|
+
* load: one fed by several loaders, whose record would suppress a part the
|
|
400
|
+
* payload lacks; one whose loader's routes can capture params, whose data a
|
|
401
|
+
* plain hand-off keeps as data no loader delivered, so the next params could
|
|
402
|
+
* not replace it; and one none of whose loaders delivered here and no
|
|
403
|
+
* hand-off named, whose seeded data would keep the client's loaders from
|
|
404
|
+
* ever running.
|
|
405
|
+
* A literal `__proto__` key is left out too, and with records its
|
|
406
|
+
* namespace's loaders are, and a namespace whose loader captures params,
|
|
407
|
+
* without them a namespace a loader serves, so the client loads it whole: the serializer SvelteKit hands load data to refuses an
|
|
408
|
+
* object that carries one. A locale
|
|
409
|
+
* named `__proto__` is left out altogether.
|
|
410
|
+
*
|
|
411
|
+
* `{ records: true }` returns an envelope for `hydrate()` instead: the same
|
|
412
|
+
* data, the loaders that delivered it, the active locale and the route. The
|
|
413
|
+
* records name each loader, so a namespace fed by several loaders is handed
|
|
414
|
+
* over too, and so is one a loader delivered for route params while its
|
|
415
|
+
* record says so — not a namespace with both, whose data the client could
|
|
416
|
+
* not split between them. What was seeded into the namespace of a loader
|
|
417
|
+
* whose routes capture params travels apart as `seeds`, so it outlives new
|
|
418
|
+
* params there, and reaches the client where the namespace is left out.
|
|
217
419
|
*/
|
|
218
|
-
snapshot = () => {
|
|
219
|
-
const
|
|
220
|
-
const
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
420
|
+
snapshot = ((options) => {
|
|
421
|
+
const withRecords = options?.records === true;
|
|
422
|
+
const { fallbackLocale, loaders = [] } = this.#config ?? {};
|
|
423
|
+
// Both are held sanitized, the way the loaders key their data.
|
|
424
|
+
const locales = unique([this.#locale, fallbackLocale].filter((locale) => !!locale));
|
|
425
|
+
const omitted = new Map(locales.map((locale) => [locale, this.#unsnapshottable(locale, withRecords)]));
|
|
426
|
+
const isOmitted = (locale, key) => (omitted.get(locale) ?? []).some((namespace) => isNamespaceKey(key, namespace));
|
|
427
|
+
// The top-level keys each locale lost a literal `__proto__` key under: a
|
|
428
|
+
// record of their namespace would suppress the part the payload lacks.
|
|
429
|
+
const stripped = new Map();
|
|
430
|
+
const translations = locales.reduce((acc, locale) => {
|
|
227
431
|
const data = read(this.#rawTranslations, locale);
|
|
228
432
|
if (!data)
|
|
229
433
|
return acc;
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
434
|
+
if (locale === '__proto__') {
|
|
435
|
+
logger.warn('Leaving the \'__proto__\' locale out of the snapshot: load data cannot carry it.');
|
|
436
|
+
return acc;
|
|
437
|
+
}
|
|
438
|
+
const handable = Object.fromEntries(Object.entries(data).filter(([key]) => !isOmitted(locale, key)));
|
|
439
|
+
const relevant = omitProtoKeys(handable);
|
|
440
|
+
let kept = relevant;
|
|
441
|
+
if (relevant !== handable) {
|
|
442
|
+
logger.warn(`Leaving a '__proto__' key of locale '${locale}' out of the snapshot: load data cannot carry it.`);
|
|
443
|
+
const lost = Object.keys(handable).filter((key) => !hasOwn(relevant, key) || relevant[key] !== handable[key]);
|
|
444
|
+
stripped.set(locale, lost);
|
|
445
|
+
// The namespace goes without its record, whole: data plain or unrecorded
|
|
446
|
+
// is kept on the client as data no loader delivered, which a plain
|
|
447
|
+
// hand-off marks loaded and new params could not replace. It goes for
|
|
448
|
+
// the client to load instead — with records, only where params can
|
|
449
|
+
// change. One no loader serves keeps the rest.
|
|
450
|
+
const served = loaders
|
|
451
|
+
.filter((loader) => loader.locale === locale && (!withRecords || capturesParams(loader.routes)))
|
|
452
|
+
.filter((loader) => lost.some((key) => isNamespaceKey(key, loader.namespace)))
|
|
453
|
+
.map(({ namespace }) => namespace);
|
|
454
|
+
kept = Object.fromEntries(Object.entries(relevant).filter(([key]) => !served.some((namespace) => isNamespaceKey(key, namespace))));
|
|
455
|
+
}
|
|
234
456
|
// An empty entry would still stamp the locale's freshness on the client,
|
|
235
457
|
// starting its `cache` window on data it never received.
|
|
236
|
-
if (!Object.keys(
|
|
458
|
+
if (!Object.keys(kept).length)
|
|
237
459
|
return acc;
|
|
238
|
-
return { ...acc, [locale]:
|
|
460
|
+
return { ...acc, [locale]: kept };
|
|
239
461
|
}, {});
|
|
240
|
-
|
|
462
|
+
if (!withRecords)
|
|
463
|
+
return translations;
|
|
464
|
+
// A loader without an id cannot be named off-process: its data travels as
|
|
465
|
+
// data alone, and the client runs it again.
|
|
466
|
+
const records = loaders.flatMap((loader) => {
|
|
467
|
+
const { id, locale, namespace } = loader;
|
|
468
|
+
const signature = this.#loaderRecords.get(loader);
|
|
469
|
+
if (id === null || signature === undefined || !omitted.has(locale) || locale === '__proto__' || isOmitted(locale, namespace))
|
|
470
|
+
return [];
|
|
471
|
+
if ((stripped.get(locale) ?? []).some((key) => isNamespaceKey(key, namespace)))
|
|
472
|
+
return [];
|
|
473
|
+
return [signature ? { id, signature } : { id }];
|
|
474
|
+
});
|
|
475
|
+
// Where params can change, the client takes a recorded namespace whole as
|
|
476
|
+
// its loader's delivery and loads one left out itself, so what was seeded
|
|
477
|
+
// into it travels apart: the seed has to outlive the delivery new params
|
|
478
|
+
// replace there as it does here.
|
|
479
|
+
const seeds = omitProtoKeys(this.#externalOf(loaders.filter(({ locale, routes }) => omitted.has(locale) && locale !== '__proto__' && capturesParams(routes))));
|
|
480
|
+
return {
|
|
481
|
+
translations,
|
|
482
|
+
records,
|
|
483
|
+
...(Object.keys(seeds).length ? { seeds } : {}),
|
|
484
|
+
...(this.#locale === undefined ? {} : { locale: this.#locale }),
|
|
485
|
+
...(this.#route === undefined ? {} : { route: this.#route }),
|
|
486
|
+
};
|
|
487
|
+
});
|
|
241
488
|
/**
|
|
242
489
|
* Detaches the instance from its loading lifecycle: in-flight loads settle
|
|
243
|
-
* with their
|
|
244
|
-
* load or mutation call is ignored with a warning.
|
|
245
|
-
* `translations`, `snapshot`) keep working, so a
|
|
246
|
-
* renders its last state instead of breaking.
|
|
490
|
+
* with whatever their loaders return or throw discarded, `loading` drops to
|
|
491
|
+
* `false`, and every further load or mutation call is ignored with a warning.
|
|
492
|
+
* Reads (`t`, `l`, `locale`, `translations`, `snapshot`) keep working, so a
|
|
493
|
+
* component still tearing down renders its last state instead of breaking.
|
|
494
|
+
* Idempotent.
|
|
247
495
|
*/
|
|
248
496
|
destroy = () => {
|
|
249
497
|
if (this.#destroyed)
|
|
250
498
|
return;
|
|
251
499
|
logger.debug('Destroying the i18n instance.');
|
|
252
500
|
this.#destroyed = true;
|
|
253
|
-
// Severed rather than awaited
|
|
254
|
-
|
|
255
|
-
this.#inflight.clear();
|
|
501
|
+
// Severed rather than awaited: a settled load then applies nothing.
|
|
502
|
+
this.#sever(() => true);
|
|
256
503
|
this.#pending = new Set();
|
|
257
504
|
};
|
|
258
505
|
// -- internals --------------------------------------------------------------
|
|
@@ -269,77 +516,259 @@ class I18nCore {
|
|
|
269
516
|
});
|
|
270
517
|
}
|
|
271
518
|
/**
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
519
|
+
* The fetches of `requests` from `route`, WITHOUT applying their data: each
|
|
520
|
+
* joins the one in flight for the same loader, params and route, and `run`
|
|
521
|
+
* starts the rest. Those are registered before `run` calls any loader, so a
|
|
522
|
+
* loader that starts a load of its own route joins its own fetch instead of
|
|
523
|
+
* running again. A fetch that delivered stays in the table until a load
|
|
524
|
+
* waiting on it has applied or parked the delivery; any other, and one of a
|
|
525
|
+
* loader with `cache: false`, leaves it with its outcome, so the next load
|
|
526
|
+
* runs the loader again. The `cache` expiry is evaluated by load triggers,
|
|
527
|
+
* not here.
|
|
275
528
|
*/
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
.
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
(
|
|
291
|
-
.
|
|
529
|
+
#fetch(requests, route) {
|
|
530
|
+
const started = [];
|
|
531
|
+
const fetches = requests.map((request) => {
|
|
532
|
+
const shared = Array.from(this.#fetches).find((fetch) => fetch.route === route
|
|
533
|
+
&& fetch.request.loader === request.loader
|
|
534
|
+
&& fetch.request.signature === request.signature);
|
|
535
|
+
if (shared)
|
|
536
|
+
return shared;
|
|
537
|
+
let outcome;
|
|
538
|
+
const fetch = { request, route, outcome: new Promise((resolve, reject) => { outcome = { resolve, reject }; }) };
|
|
539
|
+
this.#fetches.add(fetch);
|
|
540
|
+
started.push({ fetch, outcome });
|
|
541
|
+
return fetch;
|
|
542
|
+
});
|
|
543
|
+
const run = () => {
|
|
544
|
+
if (started.length)
|
|
545
|
+
logger.debug('Fetching translations...');
|
|
546
|
+
started.forEach(({ fetch, outcome }) => {
|
|
547
|
+
const release = (fetched) => {
|
|
548
|
+
if (!fetched?.deliveries.length || fetch.request.loader.cache === false)
|
|
549
|
+
this.#fetches.delete(fetch);
|
|
550
|
+
};
|
|
551
|
+
fetchTranslation(fetch.request, route).then((fetched) => {
|
|
552
|
+
release(fetched);
|
|
553
|
+
outcome.resolve(fetched);
|
|
554
|
+
}, (reason) => {
|
|
555
|
+
release();
|
|
556
|
+
outcome.reject(reason);
|
|
557
|
+
});
|
|
558
|
+
});
|
|
559
|
+
};
|
|
560
|
+
return { fetches, run };
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Gives each delivery of the previous config to the loader of the new one
|
|
564
|
+
* with the same id, so its params can still replace it. What no loader can
|
|
565
|
+
* take over is kept as data supplied without a loader, so a namespace rebuilt
|
|
566
|
+
* later keeps it rather than losing it.
|
|
567
|
+
*/
|
|
568
|
+
#handOnDeliveries(loaders) {
|
|
569
|
+
const takers = new Map(loaders.flatMap((loader) => (loader.id === null ? [] : [[loader.id, loader]])));
|
|
570
|
+
const deliveries = Array.from(this.#deliveries.values());
|
|
571
|
+
const orphaned = deliveries.filter(({ loader }) => loader.id === null || !takers.has(loader.id));
|
|
572
|
+
this.#deliveries = new Map(deliveries.flatMap((delivery) => {
|
|
573
|
+
const taker = delivery.loader.id === null ? undefined : takers.get(delivery.loader.id);
|
|
574
|
+
return taker ? [[taker, { ...delivery, loader: taker }]] : [];
|
|
575
|
+
}));
|
|
576
|
+
this.#keepExternal(serialize(orphaned.map(({ loader, data }) => ({ ...loader, data }))));
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Applies what loaders delivered and records them as loaded. A loader whose
|
|
580
|
+
* params changed replaces the part of its namespace it delivered before:
|
|
581
|
+
* the namespace is rebuilt from the data supplied without a loader and from
|
|
582
|
+
* what each of its loaders last delivered, so no key of the previous params
|
|
583
|
+
* survives and a sibling's part stays in place. The preprocessed table of a
|
|
584
|
+
* locale that lost data is derived again from the raw one, since a custom
|
|
585
|
+
* `preprocess` may have renamed the keys that would have to go. Both tables
|
|
586
|
+
* are computed before anything is written, so a `preprocess` that throws
|
|
587
|
+
* records no loader and the next trigger fetches it again.
|
|
588
|
+
*/
|
|
589
|
+
#applyDeliveries(deliveries) {
|
|
590
|
+
const replaced = deliveries
|
|
591
|
+
.filter(({ loader, signature }) => {
|
|
592
|
+
const previous = this.#deliveries.get(loader);
|
|
593
|
+
return previous !== undefined && previous.signature !== signature;
|
|
594
|
+
})
|
|
595
|
+
.map(({ loader }) => loader);
|
|
596
|
+
const delivered = new Map(deliveries.map((delivery) => [delivery.loader, delivery]));
|
|
597
|
+
const isReplaced = ({ locale, namespace }) => replaced.some((loader) => loader.locale === locale && loader.namespace === namespace);
|
|
598
|
+
const { loaders = [] } = this.#config ?? {};
|
|
599
|
+
const rebuilt = loaders
|
|
600
|
+
.filter(isReplaced)
|
|
601
|
+
.map((loader) => delivered.get(loader) ?? this.#deliveries.get(loader))
|
|
602
|
+
.filter((delivery) => delivery !== undefined);
|
|
603
|
+
const raw = replaced.reduce((acc, { locale, namespace }) => ({
|
|
292
604
|
...acc,
|
|
293
|
-
[
|
|
294
|
-
}),
|
|
295
|
-
|
|
605
|
+
[locale]: Object.fromEntries(Object.entries(read(acc, locale) ?? {}).filter(([key]) => !isNamespaceKey(key, namespace))),
|
|
606
|
+
}), this.#rawTranslations);
|
|
607
|
+
const seeded = this.#merged({ raw, translations: this.#translations }, this.#externalOf(replaced));
|
|
608
|
+
const merged = this.#merged(seeded, serialize([
|
|
609
|
+
...deliveries.filter(({ loader }) => !isReplaced(loader)),
|
|
610
|
+
...rebuilt,
|
|
611
|
+
].map(({ loader, data }) => ({ ...loader, data }))));
|
|
612
|
+
const translations = unique(replaced.map(({ locale }) => locale)).reduce((acc, locale) => ({ ...acc, [locale]: this.#preprocess(read(merged.raw, locale)) }), merged.translations);
|
|
613
|
+
deliveries.forEach((delivery) => this.#record(delivery));
|
|
614
|
+
this.#rawTranslations = merged.raw;
|
|
615
|
+
this.#translations = translations;
|
|
616
|
+
this.#stamp(deliveries.filter(({ loader }) => loader.cache !== false).map(({ loader }) => loader.locale));
|
|
296
617
|
}
|
|
297
618
|
/**
|
|
298
|
-
* Merges
|
|
299
|
-
*
|
|
300
|
-
*
|
|
619
|
+
* Merges seeded data. It records no namespace, so the namespace's loaders
|
|
620
|
+
* still run and merge into it, and it starts no `cache` window. It is kept to
|
|
621
|
+
* rebuild a namespace a loader later replaces its part of.
|
|
301
622
|
*/
|
|
302
|
-
#addTranslations(translations
|
|
623
|
+
#addTranslations(translations) {
|
|
303
624
|
if (!translations)
|
|
304
625
|
return;
|
|
305
|
-
const { preprocess } = this.#config ?? {};
|
|
306
|
-
logger.debug('Adding translations...');
|
|
307
626
|
const sanitized = sanitizeTranslationLocales(translations, this.#sanitize);
|
|
308
|
-
|
|
309
|
-
|
|
627
|
+
this.#addSanitized(sanitized);
|
|
628
|
+
}
|
|
629
|
+
#addSanitized(sanitized) {
|
|
630
|
+
this.#mergeTranslations(sanitized);
|
|
631
|
+
this.#keepExternal(sanitized);
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* Applies plain hand-off data. It records every namespace the data names,
|
|
635
|
+
* which keeps its loaders without params from running, and a loader with
|
|
636
|
+
* `cache: false` is served by its namespace for the pass the data arrived
|
|
637
|
+
* with.
|
|
638
|
+
*/
|
|
639
|
+
#hydratePlain(translations) {
|
|
640
|
+
const { loaders = [] } = this.#config ?? {};
|
|
641
|
+
const served = loaders.filter((loader) => loader.cache === false
|
|
642
|
+
&& Object.keys(read(translations, loader.locale) ?? {}).some((key) => isNamespaceKey(key, loader.namespace)));
|
|
643
|
+
this.#addSanitized(translations);
|
|
644
|
+
Object.keys(translations).forEach((locale) => {
|
|
645
|
+
// A `null` payload for a locale must not take the whole call down —
|
|
646
|
+
// every step of the merge tolerates it, so this bookkeeping does too.
|
|
647
|
+
const data = read(translations, locale) ?? {};
|
|
648
|
+
this.#namespaceRecords[locale] = Array.from(new Set([
|
|
649
|
+
...(read(this.#namespaceRecords, locale) || []),
|
|
650
|
+
...Object.keys(data).map((key) => `${key}`.split('.')[0]),
|
|
651
|
+
]));
|
|
652
|
+
});
|
|
653
|
+
served.forEach((loader) => this.#handedOff.set(loader, ''));
|
|
654
|
+
this.#stampHandOff(translations);
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Applies hand-off data with the records of the loaders that delivered it.
|
|
658
|
+
* A recorded loader's namespace is kept as that loader's delivery, so params
|
|
659
|
+
* that change later replace it; the rest is kept as data supplied without a
|
|
660
|
+
* loader and, like a seed, records no namespace — the hand-off says which loaders it
|
|
661
|
+
* covers, and anything else loads again rather than going missing. `seeds`
|
|
662
|
+
* is what was seeded where params can change, displayed and kept as a seed
|
|
663
|
+
* so that it outlives a delivery. A record naming no loader of this config
|
|
664
|
+
* is dropped, and its loader runs again.
|
|
665
|
+
*/
|
|
666
|
+
#hydrateRecords(translations, records, seeds) {
|
|
667
|
+
const { loaders = [] } = this.#config ?? {};
|
|
668
|
+
const named = new Map(loaders.flatMap((loader) => (loader.id === null ? [] : [[loader.id, loader]])));
|
|
669
|
+
const deliveries = records.flatMap(({ id, signature = '' }) => {
|
|
670
|
+
const loader = named.get(id);
|
|
671
|
+
if (!loader) {
|
|
672
|
+
logger.debug(`No loader is named '${id}'. It loads again.`);
|
|
673
|
+
return [];
|
|
674
|
+
}
|
|
675
|
+
return [{ loader, signature, data: read(read(translations, loader.locale), loader.namespace) ?? {} }];
|
|
676
|
+
});
|
|
677
|
+
// Under the data: it holds the seeds of the namespaces it carries already.
|
|
678
|
+
this.#mergeTranslations(seeds);
|
|
679
|
+
this.#mergeTranslations(translations);
|
|
680
|
+
deliveries.forEach((delivery) => {
|
|
681
|
+
this.#record(delivery);
|
|
682
|
+
if (delivery.loader.cache === false)
|
|
683
|
+
this.#handedOff.set(delivery.loader, delivery.signature);
|
|
684
|
+
});
|
|
685
|
+
const external = Object.keys(translations).reduce((acc, locale) => {
|
|
686
|
+
const rest = Object.fromEntries(Object.entries(read(translations, locale) ?? {}).filter(([key]) => !deliveries.some(({ loader }) => loader.locale === locale && loader.namespace === key)));
|
|
687
|
+
return Object.keys(rest).length ? { ...acc, [locale]: rest } : acc;
|
|
688
|
+
}, {});
|
|
689
|
+
this.#keepExternal(external);
|
|
690
|
+
this.#keepExternal(seeds);
|
|
691
|
+
this.#stampHandOff(translations);
|
|
692
|
+
}
|
|
693
|
+
/** Records `delivery` as its loader's; what was parked for its params is older. */
|
|
694
|
+
#record(delivery) {
|
|
695
|
+
this.#deliveries.set(delivery.loader, delivery);
|
|
696
|
+
this.#loaderRecords.set(delivery.loader, delivery.signature);
|
|
697
|
+
if (this.#parked.get(delivery.loader)?.signature === delivery.signature)
|
|
698
|
+
this.#parked.delete(delivery.loader);
|
|
699
|
+
}
|
|
700
|
+
/** The data held without a loader in the namespaces of `loaders`. */
|
|
701
|
+
#externalOf(loaders) {
|
|
702
|
+
return loaders.reduce((acc, { locale, namespace }) => {
|
|
703
|
+
const data = read(this.#externalTranslations, locale) ?? {};
|
|
704
|
+
const own = Object.fromEntries(Object.entries(data).filter(([key]) => isNamespaceKey(key, namespace)));
|
|
705
|
+
if (!Object.keys(own).length)
|
|
706
|
+
return acc;
|
|
707
|
+
return { ...acc, [locale]: { ...read(acc, locale), ...own } };
|
|
708
|
+
}, {});
|
|
709
|
+
}
|
|
710
|
+
/** Keeps data held without a loader, to rebuild a namespace from. */
|
|
711
|
+
#keepExternal(sanitized) {
|
|
712
|
+
this.#externalTranslations = Object.keys(sanitized).reduce((acc, locale) => ({
|
|
310
713
|
...acc,
|
|
311
714
|
[locale]: mergeTranslations(read(acc, locale) || {}, read(sanitized, locale) ?? {}, locale),
|
|
312
|
-
}), this.#
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
715
|
+
}), this.#externalTranslations);
|
|
716
|
+
}
|
|
717
|
+
/** A locale's table as `config.preprocess` asks for it. */
|
|
718
|
+
#preprocess(input) {
|
|
719
|
+
const { preprocess } = this.#config ?? {};
|
|
720
|
+
if (typeof preprocess === 'function')
|
|
721
|
+
return preprocess(input) ?? {};
|
|
722
|
+
if (preprocess === 'none')
|
|
723
|
+
return input ?? {};
|
|
724
|
+
return toDotNotation(input, preprocess === 'preserveArrays') ?? {};
|
|
725
|
+
}
|
|
726
|
+
/** Merges data keyed by sanitized locales into both tables. */
|
|
727
|
+
#mergeTranslations(sanitized) {
|
|
728
|
+
const { raw, translations } = this.#merged({ raw: this.#rawTranslations, translations: this.#translations }, sanitized);
|
|
729
|
+
this.#rawTranslations = raw;
|
|
730
|
+
this.#translations = translations;
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Both tables with data keyed by sanitized locales merged in. Pure, so a
|
|
734
|
+
* caller that writes only once both are computed keeps them consistent when
|
|
735
|
+
* a `preprocess` throws.
|
|
736
|
+
*/
|
|
737
|
+
#merged(tables, sanitized) {
|
|
738
|
+
logger.debug('Adding translations...');
|
|
739
|
+
const translationLocales = Object.keys(sanitized);
|
|
740
|
+
return {
|
|
741
|
+
raw: translationLocales.reduce((acc, locale) => ({
|
|
323
742
|
...acc,
|
|
324
|
-
[locale]: mergeTranslations(read(acc, locale) || {}, (
|
|
325
|
-
})
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
743
|
+
[locale]: mergeTranslations(read(acc, locale) || {}, read(sanitized, locale) ?? {}, locale),
|
|
744
|
+
}), tables.raw),
|
|
745
|
+
translations: translationLocales.reduce((acc, locale) => ({
|
|
746
|
+
...acc,
|
|
747
|
+
[locale]: mergeTranslations(read(acc, locale) || {}, this.#preprocess(read(sanitized, locale)), locale),
|
|
748
|
+
}), tables.translations),
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* Starts the `cache` window of each locale that has none. Freshness is
|
|
753
|
+
* measured from the locale's FIRST data — later partial loads (other
|
|
754
|
+
* routes) must not extend the window.
|
|
755
|
+
*/
|
|
756
|
+
#stamp(locales) {
|
|
757
|
+
locales.forEach((locale) => {
|
|
339
758
|
if (read(this.#loadedAt, locale) === undefined)
|
|
340
759
|
this.#loadedAt[locale] = Date.now();
|
|
341
760
|
});
|
|
342
761
|
}
|
|
762
|
+
/**
|
|
763
|
+
* Stamps each locale hand-off data holds something for that a caching
|
|
764
|
+
* loader feeds: seeded data and a namespace only loaders with `cache: false`
|
|
765
|
+
* feed start no window.
|
|
766
|
+
*/
|
|
767
|
+
#stampHandOff(translations) {
|
|
768
|
+
const { loaders = [] } = this.#config ?? {};
|
|
769
|
+
const cached = (locale, key) => loaders.some((loader) => loader.locale === locale && loader.cache !== false && isNamespaceKey(key, loader.namespace));
|
|
770
|
+
this.#stamp(Object.keys(translations).filter((locale) => Object.keys(read(translations, locale) ?? {}).some((key) => cached(locale, key))));
|
|
771
|
+
}
|
|
343
772
|
/** Reports a call on a destroyed instance; `true` means "ignore the call". */
|
|
344
773
|
#inert(action) {
|
|
345
774
|
if (!this.#destroyed)
|
|
@@ -349,23 +778,33 @@ class I18nCore {
|
|
|
349
778
|
}
|
|
350
779
|
#resolveLocale(inputLocale) {
|
|
351
780
|
const { fallbackLocale } = this.#config ?? {};
|
|
352
|
-
|
|
353
|
-
if (!locale)
|
|
781
|
+
if (!inputLocale && !fallbackLocale)
|
|
354
782
|
return undefined;
|
|
355
783
|
const all = this.locales;
|
|
356
784
|
// Nothing to match against yet; sanitizing here would only emit a
|
|
357
785
|
// non-standard warning for a lookup that cannot succeed anyway.
|
|
358
786
|
if (!all.length)
|
|
359
787
|
return undefined;
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
return all.
|
|
788
|
+
if (inputLocale) {
|
|
789
|
+
// Sanitized once per lookup rather than once per candidate locale.
|
|
790
|
+
const sanitized = this.#sanitize(inputLocale);
|
|
791
|
+
const match = all.find((known) => sanitized.includes(known));
|
|
792
|
+
if (match)
|
|
793
|
+
return match;
|
|
794
|
+
}
|
|
795
|
+
// The fallback is held sanitized.
|
|
796
|
+
return fallbackLocale && all.includes(fallbackLocale) ? fallbackLocale : undefined;
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* Whether nothing serves `locale` — no loader, no translations and no
|
|
800
|
+
* `fallbackLocale` — so a request for it changes nothing. Until a locale is
|
|
801
|
+
* known, any request is kept: a config loaded later may serve it.
|
|
802
|
+
*/
|
|
803
|
+
#unserved(locale) {
|
|
804
|
+
if (!this.locales.length || this.#resolveLocale(locale) !== undefined)
|
|
805
|
+
return false;
|
|
806
|
+
logger.debug(`Ignoring '${locale}' locale — nothing serves it.`);
|
|
807
|
+
return true;
|
|
369
808
|
}
|
|
370
809
|
#cacheValue() {
|
|
371
810
|
const { cache = defaultCache } = this.#config ?? {};
|
|
@@ -380,103 +819,554 @@ class I18nCore {
|
|
|
380
819
|
const loadedAt = read(this.#loadedAt, locale);
|
|
381
820
|
if (loadedAt !== undefined && Date.now() >= loadedAt + cacheValue) {
|
|
382
821
|
logger.debug(`'${locale}' translations expired. Loaders will run again.`);
|
|
383
|
-
this
|
|
822
|
+
this.#invalidate(locale, undefined, { expiry: true });
|
|
384
823
|
}
|
|
385
824
|
});
|
|
386
825
|
}
|
|
387
|
-
/**
|
|
388
|
-
|
|
826
|
+
/**
|
|
827
|
+
* Whether the locale the next trigger loads is other than `locale`: a later
|
|
828
|
+
* request asked for another, or a failed one put an earlier back.
|
|
829
|
+
*/
|
|
830
|
+
#superseded(locale) {
|
|
389
831
|
const requested = this.#resolveLocale(this.#requestedLocale);
|
|
390
832
|
// An unresolvable most-recent request supersedes nothing — it must not
|
|
391
833
|
// block a completed load from activating.
|
|
392
|
-
|
|
834
|
+
return requested !== undefined && requested !== locale;
|
|
835
|
+
}
|
|
836
|
+
/** Activates `locale` unless another request superseded its load meanwhile. */
|
|
837
|
+
#activate(locale) {
|
|
838
|
+
if (this.#superseded(locale))
|
|
393
839
|
return;
|
|
394
840
|
if (this.#locale !== locale)
|
|
395
841
|
this.#locale = locale;
|
|
396
842
|
}
|
|
843
|
+
/** The namespaces of `sanitizedLocale` `snapshot()` leaves out — see there. */
|
|
844
|
+
#unsnapshottable(sanitizedLocale, withRecords) {
|
|
845
|
+
const { loaders = [] } = this.#config ?? {};
|
|
846
|
+
const own = loaders.filter(({ locale }) => locale === sanitizedLocale);
|
|
847
|
+
return unique(own.map(({ namespace }) => namespace)).filter((namespace) => {
|
|
848
|
+
const feeding = own.filter((loader) => loader.namespace === namespace);
|
|
849
|
+
const several = feeding.length > 1;
|
|
850
|
+
const params = feeding.some(({ routes }) => capturesParams(routes));
|
|
851
|
+
// Seeded data alone would keep the client's loaders from ever running.
|
|
852
|
+
const delivered = feeding.some((loader) => this.#loaderRecords.has(loader))
|
|
853
|
+
|| (read(this.#namespaceRecords, sanitizedLocale) || []).includes(namespace);
|
|
854
|
+
if (!withRecords)
|
|
855
|
+
return several || params || !delivered;
|
|
856
|
+
// Only a record lets the client replace the data once the params change.
|
|
857
|
+
return params && (several || feeding.some((loader) => loader.id === null || !this.#loaderRecords.has(loader)));
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* The loaders of `sanitizedLocale` (and the fallback locale) whose routes
|
|
862
|
+
* match `route`, with the params the route yields for each.
|
|
863
|
+
*/
|
|
864
|
+
#matchLoaders(sanitizedLocale, route) {
|
|
865
|
+
const { loaders = [], fallbackLocale } = this.#config ?? {};
|
|
866
|
+
return loaders.flatMap((loader) => {
|
|
867
|
+
if (loader.locale !== sanitizedLocale && loader.locale !== fallbackLocale)
|
|
868
|
+
return [];
|
|
869
|
+
const params = routeParams(loader.routes, route);
|
|
870
|
+
return params ? [{ loader, params, signature: paramsSignature(params) }] : [];
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* The loaders of `namespace` for `sanitizedLocale` (and the fallback
|
|
875
|
+
* locale), whatever their routes. A loader whose routes match `route` gets
|
|
876
|
+
* the params they yield. Any other has no params to ask for, so whatever it
|
|
877
|
+
* delivered last serves; only a loader with no record is asked, for none.
|
|
878
|
+
*/
|
|
879
|
+
#matchNamespace(sanitizedLocale, namespace, route) {
|
|
880
|
+
const { loaders = [], fallbackLocale } = this.#config ?? {};
|
|
881
|
+
return loaders.flatMap((loader) => {
|
|
882
|
+
if (loader.namespace !== namespace)
|
|
883
|
+
return [];
|
|
884
|
+
if (loader.locale !== sanitizedLocale && loader.locale !== fallbackLocale)
|
|
885
|
+
return [];
|
|
886
|
+
const params = routeParams(loader.routes, route);
|
|
887
|
+
if (params)
|
|
888
|
+
return [{ loader, params, signature: paramsSignature(params) }];
|
|
889
|
+
return this.#loaderRecords.has(loader) ? [] : [{ loader, params: {}, signature: '' }];
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
/**
|
|
893
|
+
* Cuts the loaders `covers` matches off the loads in flight: a load running
|
|
894
|
+
* one discards that loader's data when it settles while the rest of it
|
|
895
|
+
* lands, and no trigger that has to fetch that loader joins it.
|
|
896
|
+
*/
|
|
897
|
+
#sever(covers) {
|
|
898
|
+
this.#inflight.forEach(({ loaders, unparked, severed }) => {
|
|
899
|
+
[...loaders, ...unparked.map(({ request }) => request.loader)].filter(covers).forEach((loader) => severed.add(loader));
|
|
900
|
+
});
|
|
901
|
+
this.#fetches.forEach((fetch) => {
|
|
902
|
+
if (covers(fetch.request.loader))
|
|
903
|
+
this.#fetches.delete(fetch);
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* Ends the pass a hand-off serves once an activating trigger asks for
|
|
908
|
+
* another locale or route; the first trigger after a hand-off that named
|
|
909
|
+
* neither settles them.
|
|
910
|
+
*/
|
|
911
|
+
#passHandOff(locale, route) {
|
|
912
|
+
if (!this.#handedOff.size)
|
|
913
|
+
return;
|
|
914
|
+
const { locale: passLocale = locale, route: passRoute = route } = this.#handOffPass;
|
|
915
|
+
if (passLocale === locale && passRoute === route)
|
|
916
|
+
this.#handOffPass = { locale, route };
|
|
917
|
+
else
|
|
918
|
+
this.#handedOff.clear();
|
|
919
|
+
}
|
|
397
920
|
/**
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
*
|
|
921
|
+
* Records the params the current route asks each matching loader for, and
|
|
922
|
+
* none of every other loader: a loader of a locale the request left must not
|
|
923
|
+
* keep params its route asked for. Returns what it replaced.
|
|
401
924
|
*/
|
|
402
|
-
#
|
|
925
|
+
#want(matching) {
|
|
403
926
|
const { loaders = [] } = this.#config ?? {};
|
|
404
|
-
const
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
927
|
+
const wants = new Map([
|
|
928
|
+
...loaders.map((loader) => [loader, null]),
|
|
929
|
+
...matching.map(({ loader, signature }) => [loader, signature]),
|
|
930
|
+
]);
|
|
931
|
+
const replaced = new Map(Array.from(wants.keys(), (loader) => [loader, this.#wanted.get(loader)]));
|
|
932
|
+
wants.forEach((signature, loader) => this.#wanted.set(loader, signature));
|
|
933
|
+
return replaced;
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* Starts an activating call: keeps the requested locale and the route it is
|
|
937
|
+
* about to replace — with the params `#load` adds, what is put back should
|
|
938
|
+
* it fail.
|
|
939
|
+
*/
|
|
940
|
+
#ask() {
|
|
941
|
+
const call = { replaced: { requestedLocale: this.#requestedLocale, route: this.#route, wanted: new Map() }, failed: false, threw: [], resumed: false };
|
|
942
|
+
this.#calls = [...this.#calls, call];
|
|
943
|
+
return call;
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* Lets a call whose load resolved without failing stand, with every call
|
|
947
|
+
* before it. Dropping them only bounds the chain: a later call that has not
|
|
948
|
+
* failed keeps them from being undone either way.
|
|
949
|
+
*/
|
|
950
|
+
#stand(call, promise) {
|
|
951
|
+
call.settled = promise.then(() => {
|
|
952
|
+
if (!call.failed)
|
|
953
|
+
this.#calls = this.#calls.slice(this.#calls.indexOf(call) + 1);
|
|
954
|
+
}, () => undefined);
|
|
955
|
+
return promise;
|
|
956
|
+
}
|
|
957
|
+
/**
|
|
958
|
+
* Marks the calls a load failed for, then undoes and drops failed calls from
|
|
959
|
+
* the end of the chain: a failed call behind one that has not failed stays,
|
|
960
|
+
* and stays applied, until that one fails too. Returns whether the undo put
|
|
961
|
+
* another request back, with the control flow of the calls it undid and of
|
|
962
|
+
* this load, or `undefined` when it undid nothing.
|
|
963
|
+
*/
|
|
964
|
+
#fail(calls, thrown) {
|
|
965
|
+
calls.forEach((call) => {
|
|
966
|
+
call.failed = true;
|
|
967
|
+
call.threw = [...call.threw, ...thrown];
|
|
968
|
+
});
|
|
969
|
+
const before = { locale: this.#requestedLocale, route: this.#route };
|
|
970
|
+
let threw;
|
|
971
|
+
for (let last = this.#calls.at(-1); last?.failed; last = this.#calls.at(-1)) {
|
|
972
|
+
this.#undo(last);
|
|
973
|
+
this.#calls = this.#calls.slice(0, -1);
|
|
974
|
+
threw = [...threw ?? [], ...last.threw];
|
|
975
|
+
}
|
|
976
|
+
if (!threw)
|
|
977
|
+
return undefined;
|
|
978
|
+
this.#rewant();
|
|
979
|
+
const changed = before.locale !== this.#requestedLocale || before.route !== this.#route;
|
|
980
|
+
if (changed) {
|
|
981
|
+
const onRoute = this.#route ? ` on '${this.#route}' route` : '';
|
|
982
|
+
logger.debug(`Undoing the failed calls: the request is back to '${this.#requestedLocale}' locale${onRoute}.`);
|
|
983
|
+
}
|
|
984
|
+
return { changed, threw: [...threw, ...thrown] };
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* Puts back what a failed call replaced. Whichever of the locale and the
|
|
988
|
+
* route was not asked for before it stands: it is all there is for the next
|
|
989
|
+
* trigger to load.
|
|
990
|
+
*/
|
|
991
|
+
#undo({ replaced: { requestedLocale, route, wanted } }) {
|
|
992
|
+
if (requestedLocale !== undefined)
|
|
993
|
+
this.#requestedLocale = requestedLocale;
|
|
994
|
+
if (route !== undefined)
|
|
995
|
+
this.#route = route;
|
|
996
|
+
wanted.forEach((signature, loader) => {
|
|
997
|
+
if (signature === undefined)
|
|
998
|
+
this.#wanted.delete(loader);
|
|
999
|
+
else
|
|
1000
|
+
this.#wanted.set(loader, signature);
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* The loaders the request an undo left in place selects: wanted for the
|
|
1005
|
+
* params its route yields, and no longer recorded as loaded for others —
|
|
1006
|
+
* whatever another load delivered for them meanwhile stays displayed until
|
|
1007
|
+
* the request loads them again. Runs before the failed load applies what
|
|
1008
|
+
* its other loaders delivered, so that is filtered by what is wanted now.
|
|
1009
|
+
*/
|
|
1010
|
+
#rewant() {
|
|
1011
|
+
const locale = this.#resolveLocale(this.#requestedLocale);
|
|
1012
|
+
if (!locale || this.#route === undefined)
|
|
1013
|
+
return;
|
|
1014
|
+
const matching = this.#matchLoaders(locale, this.#route);
|
|
1015
|
+
this.#want(matching);
|
|
1016
|
+
matching.forEach(({ loader, signature }) => {
|
|
1017
|
+
if (this.#loaderRecords.has(loader) && this.#loaderRecords.get(loader) !== signature)
|
|
1018
|
+
this.#loaderRecords.delete(loader);
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
/**
|
|
1022
|
+
* Once the failed load has applied what its other loaders delivered, the
|
|
1023
|
+
* request the undo put back activates if its data is there — a loader with
|
|
1024
|
+
* `cache: false` counts once its record holds the params — unless an
|
|
1025
|
+
* activating load of it is in flight that activates it or fails. It is
|
|
1026
|
+
* loaded otherwise, by an activating call nobody awaits: the load that would
|
|
1027
|
+
* have activated it may have resolved without activating while it was
|
|
1028
|
+
* replaced, and a load still waiting to resume joins this one. It loads
|
|
1029
|
+
* nothing when the undo put back the request that failed, or when that
|
|
1030
|
+
* request needs a loader for the params an undone call's load, or this one,
|
|
1031
|
+
* threw control flow for: a failure never runs again by itself.
|
|
1032
|
+
*/
|
|
1033
|
+
#settleUndo({ changed, threw: controlFlow }) {
|
|
1034
|
+
const locale = this.#resolveLocale(this.#requestedLocale);
|
|
1035
|
+
const route = this.#route;
|
|
1036
|
+
if (!locale || route === undefined)
|
|
1037
|
+
return;
|
|
1038
|
+
const matching = this.#matchLoaders(locale, route);
|
|
1039
|
+
const missing = this.#unloaded(matching).filter(({ loader, signature }) => loader.cache !== false || this.#loaderRecords.get(loader) !== signature);
|
|
1040
|
+
if (!missing.length) {
|
|
1041
|
+
const key = this.#inflightKey(locale, route, matching);
|
|
1042
|
+
// A severed load, or one of a replaced config, activates nothing when
|
|
1043
|
+
// it settles.
|
|
1044
|
+
const activating = Array.from(this.#inflight).some((entry) => entry.key === key && entry.calls.length && !entry.severed.size && entry.config === this.#config);
|
|
1045
|
+
if (activating)
|
|
408
1046
|
return;
|
|
409
|
-
(
|
|
1047
|
+
this.#applyWanted(this.#claimParked(matching).map(({ delivery }) => delivery));
|
|
1048
|
+
this.#activate(locale);
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
const threw = missing.some(({ loader, signature }) => controlFlow.some((flow) => flow.loader === loader && flow.signature === signature));
|
|
1052
|
+
if (!changed || threw)
|
|
1053
|
+
return;
|
|
1054
|
+
const call = this.#ask();
|
|
1055
|
+
void this.#stand(call, this.#load(locale, route, call));
|
|
1056
|
+
}
|
|
1057
|
+
/**
|
|
1058
|
+
* The matching loaders a load has to run: all but those whose own record
|
|
1059
|
+
* holds the params the route yields now. A namespace a plain hand-off
|
|
1060
|
+
* delivered stands in for the record of a loader without params that has
|
|
1061
|
+
* none.
|
|
1062
|
+
* A loader with `cache: false` runs unless a hand-off serves it: its record
|
|
1063
|
+
* only names what it delivered.
|
|
1064
|
+
*/
|
|
1065
|
+
#unloaded(matching) {
|
|
1066
|
+
return matching.filter(({ loader, signature }) => {
|
|
1067
|
+
if (loader.cache === false)
|
|
1068
|
+
return this.#handedOff.get(loader) !== signature;
|
|
1069
|
+
if (this.#parked.get(loader)?.signature === signature)
|
|
1070
|
+
return false;
|
|
1071
|
+
if (this.#loaderRecords.has(loader))
|
|
1072
|
+
return this.#loaderRecords.get(loader) !== signature;
|
|
1073
|
+
return signature !== '' || !(read(this.#namespaceRecords, loader.locale) || []).includes(loader.namespace);
|
|
410
1074
|
});
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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) {
|
|
1075
|
+
}
|
|
1076
|
+
/**
|
|
1077
|
+
* Starts (or joins) a load. A load already in flight for the same locale and
|
|
1078
|
+
* route that selected the same loaders for the same params is returned as-is
|
|
1079
|
+
* while it delivers everything the trigger has to fetch, so concurrent
|
|
1080
|
+
* duplicate triggers share one fetch — whether they selected by route or by
|
|
1081
|
+
* `namespace`. The pending entry is registered
|
|
1082
|
+
* synchronously, so `loading` is observable right after the triggering call;
|
|
1083
|
+
* a load with nothing to fetch never registers at all, so cache-served
|
|
1084
|
+
* navigations do not flicker the flag. A warm load — one without an
|
|
1085
|
+
* activating `call` — never registers either, until an activating trigger
|
|
1086
|
+
* joins it.
|
|
1087
|
+
*/
|
|
1088
|
+
#load(requestedLocale, route, call, namespace) {
|
|
433
1089
|
const locale = this.#resolveLocale(requestedLocale);
|
|
434
1090
|
if (!locale)
|
|
435
1091
|
return Promise.resolve();
|
|
436
|
-
// Expiry is evaluated per
|
|
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
|
-
|
|
440
|
-
//
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
1092
|
+
// Expiry is evaluated per activating trigger, BEFORE the in-flight check.
|
|
1093
|
+
// That order is safe: a locale is stamped only once its data arrived, so a
|
|
1094
|
+
// shared in-flight load cannot be invalidated by its own duplicates. A
|
|
1095
|
+
// warm trigger fills the tables and leaves their freshness to the next
|
|
1096
|
+
// activating one.
|
|
1097
|
+
if (call)
|
|
1098
|
+
this.#invalidateExpired(locale, this.#config?.fallbackLocale);
|
|
1099
|
+
const matching = namespace === undefined ? this.#matchLoaders(locale, route) : this.#matchNamespace(locale, namespace, route);
|
|
1100
|
+
// Recorded before the in-flight check, so a trigger joining a load, or one
|
|
1101
|
+
// served from the records, still decides which params the route shows.
|
|
1102
|
+
if (call) {
|
|
1103
|
+
call.replaced.wanted = this.#want(matching);
|
|
1104
|
+
this.#passHandOff(locale, route);
|
|
1105
|
+
}
|
|
1106
|
+
return this.#loadSelection(locale, route, this.#inflightKey(locale, route, matching), matching, call ? [call] : []);
|
|
1107
|
+
}
|
|
1108
|
+
/**
|
|
1109
|
+
* The key of a load in flight: what the trigger selected and the route it
|
|
1110
|
+
* came from. A loader receives the route, so a load for another route may
|
|
1111
|
+
* deliver, or throw, what does not fit this one — and one awaiting a
|
|
1112
|
+
* navigation to it would wait on itself.
|
|
1113
|
+
*/
|
|
1114
|
+
#inflightKey(locale, route, matching) {
|
|
1115
|
+
const { loaders = [] } = this.#config ?? {};
|
|
1116
|
+
return JSON.stringify([locale, route, ...matching.map(({ loader, signature }) => [loaders.indexOf(loader), signature])]);
|
|
1117
|
+
}
|
|
1118
|
+
/** Joins the load in flight under `key` that delivers what `selected` lacks, or starts one. */
|
|
1119
|
+
#loadSelection(locale, route, key, selected, calls) {
|
|
1120
|
+
const requests = this.#unloaded(selected);
|
|
1121
|
+
const unparked = calls.length ? this.#claimParked(selected) : [];
|
|
1122
|
+
if (!requests.length) {
|
|
446
1123
|
// Nothing to fetch — the locale still becomes active (its data is
|
|
447
|
-
// already present or it has no loaders).
|
|
448
|
-
|
|
1124
|
+
// already present, parked or it has no loaders).
|
|
1125
|
+
if (calls.length) {
|
|
1126
|
+
this.#applyWanted(unparked.map(({ delivery }) => delivery));
|
|
1127
|
+
this.#activate(locale);
|
|
1128
|
+
}
|
|
449
1129
|
return Promise.resolve();
|
|
450
1130
|
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
1131
|
+
// What is parked lands with the load, so a call it fails puts it back.
|
|
1132
|
+
const inflight = this.#joinable(key, requests);
|
|
1133
|
+
if (inflight)
|
|
1134
|
+
return this.#join(inflight, calls, unparked);
|
|
1135
|
+
return this.#start(locale, route, key, requests, calls, unparked);
|
|
1136
|
+
}
|
|
1137
|
+
/** The load in flight under `key` that delivers every one of `requests`. */
|
|
1138
|
+
#joinable(key, requests) {
|
|
1139
|
+
return Array.from(this.#inflight).find(({ key: inflightKey, loaders, severed }) => inflightKey === key
|
|
1140
|
+
&& requests.every(({ loader }) => loaders.includes(loader) && !severed.has(loader)));
|
|
1141
|
+
}
|
|
1142
|
+
/**
|
|
1143
|
+
* Joins a load in flight. The activating calls that join make it activate,
|
|
1144
|
+
* and fail with it when a loader of it throws control flow that was not
|
|
1145
|
+
* severed.
|
|
1146
|
+
*/
|
|
1147
|
+
#join(entry, calls, unparked) {
|
|
1148
|
+
if (!calls.length)
|
|
1149
|
+
return entry.promise;
|
|
1150
|
+
if (!entry.calls.length)
|
|
1151
|
+
this.#pending = new Set(this.#pending).add(entry.promise);
|
|
1152
|
+
entry.calls = [...entry.calls, ...calls];
|
|
1153
|
+
entry.unparked = [...entry.unparked, ...unparked];
|
|
1154
|
+
return entry.promise;
|
|
1155
|
+
}
|
|
1156
|
+
/**
|
|
1157
|
+
* Whether the next trigger asks `loader` for `signature`: the params its
|
|
1158
|
+
* route yields, none while the route does not select the loader, or any
|
|
1159
|
+
* before a trigger asked.
|
|
1160
|
+
*/
|
|
1161
|
+
#isWanted({ loader, signature }) {
|
|
1162
|
+
const wanted = this.#wanted.get(loader);
|
|
1163
|
+
if (wanted === undefined)
|
|
1164
|
+
return true;
|
|
1165
|
+
return (wanted ?? '') === signature;
|
|
1166
|
+
}
|
|
1167
|
+
/**
|
|
1168
|
+
* Applies the deliveries of the params the next trigger asks for, and parks
|
|
1169
|
+
* the rest. A loader no trigger asked for params keeps what it delivered for
|
|
1170
|
+
* others: a warm load parks what would replace it, unless it asks for no
|
|
1171
|
+
* params — `loadNamespace()` off the loader's routes. What an activating load
|
|
1172
|
+
* counts on stays parked until it settles. A loader with `cache: false` runs
|
|
1173
|
+
* on every trigger that selects it, so nothing of it is parked.
|
|
1174
|
+
*/
|
|
1175
|
+
#applyWanted(deliveries) {
|
|
1176
|
+
const wanted = deliveries.filter(({ loader, signature }) => {
|
|
1177
|
+
const wanted = this.#wanted.get(loader);
|
|
1178
|
+
if (typeof wanted === 'string')
|
|
1179
|
+
return wanted === signature;
|
|
1180
|
+
const previous = this.#deliveries.get(loader);
|
|
1181
|
+
return !previous || previous.signature === signature || signature === '';
|
|
1182
|
+
});
|
|
1183
|
+
const parked = deliveries.filter((delivery) => !wanted.includes(delivery)
|
|
1184
|
+
&& delivery.loader.cache !== false
|
|
1185
|
+
&& !this.#claimed(delivery.loader));
|
|
1186
|
+
parked.forEach((delivery) => this.#parked.set(delivery.loader, delivery));
|
|
1187
|
+
this.#stamp(parked.map(({ loader }) => loader.locale));
|
|
1188
|
+
if (wanted.length)
|
|
1189
|
+
this.#applyDeliveries(wanted);
|
|
1190
|
+
}
|
|
1191
|
+
/** Takes the fetches `entry` waited on out of the table, once what they delivered is applied or parked. */
|
|
1192
|
+
#release({ fetches }) {
|
|
1193
|
+
fetches.forEach((fetch) => this.#fetches.delete(fetch));
|
|
1194
|
+
}
|
|
1195
|
+
/** What is parked for `requests`, for the load that applies it once it settles. */
|
|
1196
|
+
#claimParked(requests) {
|
|
1197
|
+
return requests.flatMap((request) => {
|
|
1198
|
+
const delivery = this.#parked.get(request.loader);
|
|
1199
|
+
return delivery?.signature === request.signature ? [{ request, delivery }] : [];
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
/** Whether a load in flight counts on what is parked for `loader`. */
|
|
1203
|
+
#claimed(loader) {
|
|
1204
|
+
const delivery = this.#parked.get(loader);
|
|
1205
|
+
return delivery !== undefined && Array.from(this.#inflight).some(({ unparked }) => unparked.some((claim) => claim.delivery === delivery));
|
|
1206
|
+
}
|
|
1207
|
+
/**
|
|
1208
|
+
* The control flow a settled load rejects with: the first its locale's
|
|
1209
|
+
* loaders threw, in `loaders` order, then the fallback locale's. What a
|
|
1210
|
+
* severed loader threw predates the invalidation, and an activating load
|
|
1211
|
+
* counts control flow only while it serves the request the next trigger
|
|
1212
|
+
* loads — not once another locale or other params of that loader were asked
|
|
1213
|
+
* for.
|
|
1214
|
+
*/
|
|
1215
|
+
#rejection({ severed, calls }, locale, controlFlow) {
|
|
1216
|
+
const discarded = (flow) => {
|
|
1217
|
+
if (severed.has(flow.loader))
|
|
1218
|
+
return 'an invalidation or destroy() severed it';
|
|
1219
|
+
if (!calls.length)
|
|
1220
|
+
return undefined;
|
|
1221
|
+
if (this.#superseded(locale))
|
|
1222
|
+
return 'another locale was asked for';
|
|
1223
|
+
return this.#isWanted(flow) ? undefined : 'other params were asked for';
|
|
1224
|
+
};
|
|
1225
|
+
const counted = controlFlow.filter((flow) => {
|
|
1226
|
+
const reason = discarded(flow);
|
|
1227
|
+
if (reason)
|
|
1228
|
+
logger.debug(`Discarding what the ${loaderName(flow.loader)} loader threw: ${reason}.`, flow.value);
|
|
1229
|
+
return !reason;
|
|
1230
|
+
});
|
|
1231
|
+
const [first, ...rest] = [
|
|
1232
|
+
...counted.filter(({ loader }) => loader.locale === locale),
|
|
1233
|
+
...counted.filter(({ loader }) => loader.locale !== locale),
|
|
1234
|
+
];
|
|
1235
|
+
if (!first)
|
|
1236
|
+
return undefined;
|
|
1237
|
+
rest.forEach(({ loader, value }) => logger.debug(`Discarding what the ${loaderName(loader)} loader threw: the load rejects with what the ${loaderName(first.loader)} loader threw.`, value));
|
|
1238
|
+
return first;
|
|
1239
|
+
}
|
|
1240
|
+
/** Fetches `requests` as a load in flight under `key`, and settles it. */
|
|
1241
|
+
#start(locale, route, key, requests, calls, unparked) {
|
|
1242
|
+
const onRoute = route ? ` and '${route}' route` : '';
|
|
1243
|
+
let rejection;
|
|
1244
|
+
let outcome;
|
|
1245
|
+
const promise = new Promise((resolve, reject) => { outcome = { resolve, reject }; });
|
|
1246
|
+
const { fetches, run } = this.#fetch(requests, route);
|
|
1247
|
+
const entry = { key, config: this.#config, promise, loaders: requests.map(({ loader }) => loader), fetches, severed: new Set(), calls, unparked };
|
|
1248
|
+
// Registered before any loader is called, so one that invalidates or
|
|
1249
|
+
// destroys the instance before its first `await` severs its own load too.
|
|
1250
|
+
this.#inflight.add(entry);
|
|
1251
|
+
if (calls.length)
|
|
1252
|
+
this.#pending = new Set(this.#pending).add(promise);
|
|
1253
|
+
run();
|
|
1254
|
+
const settled = Promise.all(fetches.map(({ outcome }) => outcome)).then(mergeFetched).then(({ deliveries, controlFlow }) => {
|
|
1255
|
+
// Released before the load settles: a trigger arriving in between must
|
|
1256
|
+
// not join a load whose activation step has already run — it finds the
|
|
1257
|
+
// data recorded and activates at once.
|
|
1258
|
+
this.#inflight.delete(entry);
|
|
1259
|
+
// An `invalidate()` — explicit, via expiry, via reconfiguration or via
|
|
1260
|
+
// `destroy()` — that raced this load severed some of its loaders. Their
|
|
1261
|
+
// data predates the invalidation: applying it would resurrect the
|
|
1262
|
+
// dropped bookkeeping and permanently suppress the promised refetch.
|
|
1263
|
+
const current = deliveries.filter(({ loader }) => !entry.severed.has(loader));
|
|
1264
|
+
// What it counts on applies while still parked: a newer record of the
|
|
1265
|
+
// same params dropped it, and an invalidation severed it.
|
|
1266
|
+
const held = entry.unparked
|
|
1267
|
+
.filter(({ request, delivery }) => !entry.severed.has(request.loader) && this.#parked.get(request.loader) === delivery)
|
|
1268
|
+
.map(({ delivery }) => delivery);
|
|
1269
|
+
const served = [...requests, ...entry.unparked.map(({ request }) => request)];
|
|
1270
|
+
rejection = this.#rejection(entry, locale, controlFlow);
|
|
1271
|
+
// The calls that share the load failed, whether its control flow rejects
|
|
1272
|
+
// them or a later call superseded it: the locale does not advance, and
|
|
1273
|
+
// what they replaced is put back unless a later call that has not failed
|
|
1274
|
+
// came since.
|
|
1275
|
+
const thrown = controlFlow.filter(({ loader }) => !entry.severed.has(loader));
|
|
1276
|
+
const undone = thrown.length ? this.#fail(entry.calls, thrown) : undefined;
|
|
1277
|
+
// With control flow, what the other loaders delivered lands as a warm
|
|
1278
|
+
// load's does. Should applying it fail, that is only logged: the caller
|
|
1279
|
+
// gets the control flow.
|
|
1280
|
+
let failure;
|
|
1281
|
+
try {
|
|
1282
|
+
this.#applyWanted([...held, ...current]);
|
|
1283
|
+
}
|
|
1284
|
+
catch (error) {
|
|
1285
|
+
if (rejection)
|
|
1286
|
+
logError(`Failed to load translations for '${locale}' locale${onRoute}.`, error);
|
|
1287
|
+
else
|
|
1288
|
+
failure = { error };
|
|
1289
|
+
}
|
|
1290
|
+
this.#release(entry);
|
|
1291
|
+
if (undone)
|
|
1292
|
+
this.#settleUndo(undone);
|
|
1293
|
+
if (failure)
|
|
1294
|
+
throw failure.error;
|
|
1295
|
+
if (rejection)
|
|
1296
|
+
throw rejection.value;
|
|
1297
|
+
// A load of params the route no longer asks for, whatever it returned,
|
|
1298
|
+
// leaves the activation to the load of the params it asks for. One whose
|
|
1299
|
+
// control flow a later call superseded failed: an undo that puts its
|
|
1300
|
+
// request back loads it, unless that control flow still covers it.
|
|
1301
|
+
const replaced = served.some((request) => !this.#isWanted(request));
|
|
1302
|
+
if (!entry.calls.length || replaced || thrown.length)
|
|
1303
|
+
return [];
|
|
1304
|
+
if (!entry.severed.size)
|
|
1305
|
+
this.#activate(locale);
|
|
1306
|
+
return served.filter(({ loader }) => entry.severed.has(loader));
|
|
461
1307
|
});
|
|
462
|
-
|
|
463
|
-
|
|
1308
|
+
// Reported here so a load nobody awaits is still visible. What a resume
|
|
1309
|
+
// adopts is a load of its own, which reports itself; control flow a
|
|
1310
|
+
// shared fetch threw is reported by the first load it rejects.
|
|
1311
|
+
settled.catch((error) => {
|
|
1312
|
+
if (rejection) {
|
|
1313
|
+
if (this.#reported.has(rejection))
|
|
1314
|
+
return;
|
|
1315
|
+
this.#reported.add(rejection);
|
|
1316
|
+
}
|
|
1317
|
+
logError(rejection
|
|
1318
|
+
? `Rejecting the load of '${locale}' locale${onRoute} with what the ${loaderName(rejection.loader)} loader threw.`
|
|
1319
|
+
: `Failed to load translations for '${locale}' locale${onRoute}.`, error);
|
|
1320
|
+
});
|
|
1321
|
+
// Resumed once per call: a loader that invalidates what it loads each time
|
|
1322
|
+
// it runs would otherwise be fetched again for as long as it keeps doing
|
|
1323
|
+
// so, while a call that joined a resumed load still gets its own.
|
|
1324
|
+
settled
|
|
1325
|
+
.then((severed) => (severed.length && entry.calls.some(({ resumed }) => !resumed) ? this.#resume(entry, locale, route, severed) : undefined))
|
|
1326
|
+
.then(outcome.resolve, outcome.reject);
|
|
464
1327
|
const settle = () => {
|
|
465
|
-
//
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
1328
|
+
// Only a settle step that threw before its release skipped the one above.
|
|
1329
|
+
this.#inflight.delete(entry);
|
|
1330
|
+
this.#release(entry);
|
|
1331
|
+
if (!this.#pending.has(promise))
|
|
1332
|
+
return;
|
|
469
1333
|
const next = new Set(this.#pending);
|
|
470
1334
|
next.delete(promise);
|
|
471
1335
|
this.#pending = next;
|
|
472
1336
|
};
|
|
1337
|
+
// Also marks the load handled, so one nobody awaits cannot terminate the
|
|
1338
|
+
// process; an awaiting caller still receives the rejection.
|
|
473
1339
|
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
1340
|
return promise;
|
|
479
1341
|
}
|
|
1342
|
+
/**
|
|
1343
|
+
* Finishes an activating load an invalidation cut `severed` off: it fetches
|
|
1344
|
+
* them again, so a trigger's promise that resolves still means its locale
|
|
1345
|
+
* is loaded, and control flow the refetch throws rejects it as any load's
|
|
1346
|
+
* does. It runs once per call: what severs the refetch of every call it
|
|
1347
|
+
* serves is left to the next trigger, as is the locale when the instance
|
|
1348
|
+
* was destroyed or reconfigured. Once a
|
|
1349
|
+
* later call asked for another locale or route, it waits for the calls since
|
|
1350
|
+
* its own to settle: it stands down should the request stay replaced, and
|
|
1351
|
+
* resumes should an undo put it back. A load whose params a later request
|
|
1352
|
+
* replaced, or whose control flow it superseded, does not get here: an undo
|
|
1353
|
+
* that puts it back loads it itself.
|
|
1354
|
+
*/
|
|
1355
|
+
#resume(entry, locale, route, severed) {
|
|
1356
|
+
if (this.#destroyed || entry.config !== this.#config)
|
|
1357
|
+
return undefined;
|
|
1358
|
+
if (this.#superseded(locale) || this.#route !== route) {
|
|
1359
|
+
// A resumed call joins a load after the calls it started, so the latest
|
|
1360
|
+
// of them is found by its place in the chain, not in the load.
|
|
1361
|
+
const last = Math.max(-1, ...entry.calls.map((call) => this.#calls.indexOf(call)));
|
|
1362
|
+
const later = last === -1 ? [] : this.#calls.slice(last + 1);
|
|
1363
|
+
if (!later.length)
|
|
1364
|
+
return undefined;
|
|
1365
|
+
return Promise.all(later.flatMap(({ settled }) => settled ?? [])).then(() => this.#resume(entry, locale, route, severed));
|
|
1366
|
+
}
|
|
1367
|
+
entry.calls.forEach((call) => { call.resumed = true; });
|
|
1368
|
+
return this.#loadSelection(locale, route, entry.key, severed, entry.calls);
|
|
1369
|
+
}
|
|
480
1370
|
}
|
|
481
1371
|
// The raw class is deliberately not exported — every consumer constructs
|
|
482
1372
|
// through the extension-aware signature. The exported name carries both
|