@sveltekit-i18n/base 3.0.1 → 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 -28
- package/dist/I18n.svelte.js +1126 -245
- 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 +81 -3
- package/dist/utils.js +431 -31
- package/package.json +23 -3
package/dist/utils.js
CHANGED
|
@@ -124,6 +124,248 @@ export const sanitizeTranslationLocales = (input, sanitize) => (Object.keys(inpu
|
|
|
124
124
|
const [sanitized = locale] = sanitize(locale);
|
|
125
125
|
return { ...acc, [sanitized]: { ...read(acc, sanitized), ...read(input, locale) } };
|
|
126
126
|
}, {}));
|
|
127
|
+
// An `Accept-Language` field is visitor-controlled and unbounded, while a
|
|
128
|
+
// browser sends a handful of ranges — the tail of a flood carries no
|
|
129
|
+
// preference worth reading.
|
|
130
|
+
const MAX_RANGES = 100;
|
|
131
|
+
// RFC 4647 §4.4 lets a protocol cap a range but never below 35 characters:
|
|
132
|
+
// language(8) + script(5) + region(4) + two variants(18). The cap is also what
|
|
133
|
+
// bounds the truncation chain below.
|
|
134
|
+
const MAX_RANGE_LENGTH = 64;
|
|
135
|
+
const WILDCARD = '*';
|
|
136
|
+
const ALPHA = 'abcdefghijklmnopqrstuvwxyz';
|
|
137
|
+
const ALPHANUM = `${ALPHA}0123456789`;
|
|
138
|
+
const QVALUE = '0123456789.';
|
|
139
|
+
const isSubtag = (subtag, allowed) => (!!subtag.length && subtag.length <= 8 && [...subtag].every((char) => allowed.includes(char)));
|
|
140
|
+
// RFC 4647 §2.1: `(1*8ALPHA *("-" 1*8alphanum))`. Anything else is not a range,
|
|
141
|
+
// which is what keeps `constructor` and a half-parsed header from becoming one.
|
|
142
|
+
const isLanguageRange = (range) => {
|
|
143
|
+
const [primary = '', ...rest] = range.split('-');
|
|
144
|
+
return isSubtag(primary, ALPHA) && rest.every((subtag) => isSubtag(subtag, ALPHANUM));
|
|
145
|
+
};
|
|
146
|
+
const specificityOf = (range) => (range === WILDCARD ? 0 : range.split('-').length);
|
|
147
|
+
// RFC 9110 §12.4.2: `q` is case-insensitive and its absence means 1. A weight
|
|
148
|
+
// that is not a number drops its own range — reading it as 1 would promote
|
|
149
|
+
// junk to the strongest preference, and 0 would turn it into a refusal.
|
|
150
|
+
const readWeight = (parameters) => {
|
|
151
|
+
const value = parameters.reduce((acc, parameter) => {
|
|
152
|
+
if (acc !== undefined)
|
|
153
|
+
return acc;
|
|
154
|
+
const compact = parameter.split(' ').join('').split('\t').join('').toLowerCase();
|
|
155
|
+
return compact.startsWith('q=') ? compact.slice(2) : acc;
|
|
156
|
+
}, undefined);
|
|
157
|
+
if (value === undefined)
|
|
158
|
+
return 1;
|
|
159
|
+
// A qvalue is decimal digits and a dot; `Number` alone would also read
|
|
160
|
+
// `0x10` and `1e3`, and an empty one as zero.
|
|
161
|
+
if (!value.length || [...value].some((char) => !QVALUE.includes(char)))
|
|
162
|
+
return undefined;
|
|
163
|
+
const parsed = Number(value);
|
|
164
|
+
if (!Number.isFinite(parsed))
|
|
165
|
+
return undefined;
|
|
166
|
+
return Math.min(1, Math.max(0, parsed));
|
|
167
|
+
};
|
|
168
|
+
const parseRanges = (field) => (
|
|
169
|
+
// Bounded at the split rather than after it: materializing every member of a
|
|
170
|
+
// hostile field would spend exactly what the cap is there to refuse.
|
|
171
|
+
field.split(',', MAX_RANGES).reduce((acc, element, order) => {
|
|
172
|
+
const [head = '', ...parameters] = element.split(';');
|
|
173
|
+
const range = head.trim().toLowerCase();
|
|
174
|
+
if (!range || range.length > MAX_RANGE_LENGTH)
|
|
175
|
+
return acc;
|
|
176
|
+
if (range !== WILDCARD && !isLanguageRange(range))
|
|
177
|
+
return acc;
|
|
178
|
+
const q = readWeight(parameters);
|
|
179
|
+
if (q === undefined)
|
|
180
|
+
return acc;
|
|
181
|
+
const seen = acc.find((known) => known.range === range);
|
|
182
|
+
// A range repeated within one field is one preference, at the strongest
|
|
183
|
+
// weight it was given.
|
|
184
|
+
if (seen)
|
|
185
|
+
return acc.map((known) => (known === seen ? { ...known, q: Math.max(known.q, q) } : known));
|
|
186
|
+
return [...acc, { range, q, order, specificity: specificityOf(range) }];
|
|
187
|
+
}, []));
|
|
188
|
+
// RFC 5646 §4.4.2: a single-character subtag introduces an extension or a
|
|
189
|
+
// private-use sequence, so it never outlives the subtag it introduced.
|
|
190
|
+
const withoutTrailingSingleton = (subtags) => (subtags.at(-1)?.length === 1 ? withoutTrailingSingleton(subtags.slice(0, -1)) : subtags);
|
|
191
|
+
// The range, then its progressively shorter prefixes – `zh-hant-tw`, `zh-hant`,
|
|
192
|
+
// `zh`. Only the REQUESTED range is ever truncated; an available locale is
|
|
193
|
+
// compared as it was spelled.
|
|
194
|
+
const prefixes = (range) => {
|
|
195
|
+
const walk = (subtags) => (subtags.length ? [subtags.join('-'), ...walk(withoutTrailingSingleton(subtags.slice(0, -1)))] : []);
|
|
196
|
+
return walk(range.split('-'));
|
|
197
|
+
};
|
|
198
|
+
const covers = (range, folded) => range === WILDCARD || folded === range || folded.startsWith(`${range}-`);
|
|
199
|
+
// Weight first; at equal weight a concrete range is consulted before the
|
|
200
|
+
// wildcard, and the order the field spelled them in breaks what is left.
|
|
201
|
+
const byPreference = (a, b) => (b.q - a.q
|
|
202
|
+
|| Number(a.range === WILDCARD) - Number(b.range === WILDCARD)
|
|
203
|
+
|| a.order - b.order);
|
|
204
|
+
/**
|
|
205
|
+
* Matches what a visitor asked for against the locales an app actually has.
|
|
206
|
+
*
|
|
207
|
+
* `requested` is an `Accept-Language` field value, a single locale, or a
|
|
208
|
+
* preference list such as `navigator.languages`; `available` is the configured
|
|
209
|
+
* set, and the winner is returned as IT spells it. A miss is `undefined` rather
|
|
210
|
+
* than a guess – what a miss means is the caller's to decide.
|
|
211
|
+
*/
|
|
212
|
+
export const matchLocale = (requested, available) => {
|
|
213
|
+
// Narrowing `available` in place would widen it to `any[]` and take the
|
|
214
|
+
// locale union down with it, so the guard hands the value back as it is
|
|
215
|
+
// declared.
|
|
216
|
+
const locales = Array.isArray(available) ? available : [];
|
|
217
|
+
const candidates = locales.reduce((acc, locale) => (locale && typeof locale === 'string' ? [...acc, { locale, folded: locale.toLowerCase() }] : acc), []);
|
|
218
|
+
if (!candidates.length)
|
|
219
|
+
return undefined;
|
|
220
|
+
// Anything that is not a field value carries no preference – an absent
|
|
221
|
+
// header, a parsed body, a mistyped local. Reading one is a miss, not a throw.
|
|
222
|
+
const field = typeof requested === 'string'
|
|
223
|
+
? requested
|
|
224
|
+
: Array.isArray(requested)
|
|
225
|
+
? requested.slice(0, MAX_RANGES).filter((value) => typeof value === 'string').join(',')
|
|
226
|
+
: '';
|
|
227
|
+
const parsed = parseRanges(field);
|
|
228
|
+
const refusals = parsed.filter(({ q }) => !q);
|
|
229
|
+
// RFC 2616's longest-matching-range rule, reduced to the only comparison it
|
|
230
|
+
// ever needs: a refusal loses to a strictly more specific positive match. A
|
|
231
|
+
// refused candidate is skipped rather than blacklisted, so a later, more
|
|
232
|
+
// specific range can still select it.
|
|
233
|
+
const accepted = (folded, specificity) => refusals.every(({ range, specificity: refused }) => (!covers(range, folded) || refused < specificity));
|
|
234
|
+
const select = (range) => {
|
|
235
|
+
if (range === WILDCARD)
|
|
236
|
+
return candidates.find(({ folded }) => accepted(folded, 0))?.locale;
|
|
237
|
+
const found = prefixes(range).reduce((match, prefix) => match ?? candidates.find(({ folded }) => folded === prefix && accepted(folded, specificityOf(prefix)))?.locale, undefined);
|
|
238
|
+
if (found)
|
|
239
|
+
return found;
|
|
240
|
+
// Truncation answers nothing when the available set is FINER than the
|
|
241
|
+
// range, which is the other half of the gap. The range AS WRITTEN is then
|
|
242
|
+
// read as a prefix – never a truncated one, so `en` reaches `en-GB` while
|
|
243
|
+
// `en-GB` never reaches the sibling `en-US`.
|
|
244
|
+
return candidates.find(({ folded }) => folded.startsWith(`${range}-`) && accepted(folded, specificityOf(range)))?.locale;
|
|
245
|
+
};
|
|
246
|
+
return parsed
|
|
247
|
+
.filter(({ q }) => q > 0)
|
|
248
|
+
.sort(byPreference)
|
|
249
|
+
.reduce((match, { range }) => match ?? select(range), undefined);
|
|
250
|
+
};
|
|
251
|
+
// The scripts Unicode writes right to left, the historic ones and the ISO 15924
|
|
252
|
+
// variants of the living ones (`Aran`, `Syre`) included.
|
|
253
|
+
const RTL_SCRIPTS = new Set([
|
|
254
|
+
'Adlm', 'Arab', 'Aran', 'Armi', 'Avst', 'Chrs', 'Cprt', 'Elym', 'Gara', 'Hatr', 'Hebr', 'Hung', 'Khar', 'Lydi',
|
|
255
|
+
'Mand', 'Mani', 'Mend', 'Merc', 'Mero', 'Narb', 'Nbat', 'Nkoo', 'Orkh', 'Ougr', 'Palm', 'Phli', 'Phlp',
|
|
256
|
+
'Phnx', 'Prti', 'Rohg', 'Samr', 'Sarb', 'Sidt', 'Sogd', 'Sogo', 'Syrc', 'Syre', 'Syrj', 'Syrn', 'Thaa', 'Yezi',
|
|
257
|
+
]);
|
|
258
|
+
/**
|
|
259
|
+
* The direction `locale` is written in, for `dir` and `<html dir>`.
|
|
260
|
+
*
|
|
261
|
+
* Decided by the script alone: the one the tag spells (`az-Arab`, `pa-Guru`),
|
|
262
|
+
* or else the one `Intl.Locale#maximize()` adds (`dv` is `Thaa`, `pa-PK` is
|
|
263
|
+
* `Arab`). That likely script is the engine's CLDR data and can differ between
|
|
264
|
+
* engines (`prs` has none in JavaScriptCore), so a locale whose direction
|
|
265
|
+
* matters spells its script. The engines' own text info is not read –
|
|
266
|
+
* JavaScriptCore reports `dv` and `az-Arab` as left to right. A tag
|
|
267
|
+
* `Intl.Locale` rejects, and a missing locale, is `'ltr'`.
|
|
268
|
+
*/
|
|
269
|
+
export const textDirection = (locale) => {
|
|
270
|
+
if (typeof locale !== 'string')
|
|
271
|
+
return 'ltr';
|
|
272
|
+
try {
|
|
273
|
+
const tag = new Intl.Locale(locale);
|
|
274
|
+
const script = tag.script ?? tag.maximize().script;
|
|
275
|
+
return script && RTL_SCRIPTS.has(script) ? 'rtl' : 'ltr';
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
return 'ltr';
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
/**
|
|
282
|
+
* `route` without `basePath` in front of it, cut on a segment boundary only:
|
|
283
|
+
* under `/repo`, `/repo/about` is `/about`, `/repo` is `/` and `/repo?tab=1`
|
|
284
|
+
* is `/?tab=1`, while `/repository` and a route without the prefix pass
|
|
285
|
+
* through unchanged.
|
|
286
|
+
*/
|
|
287
|
+
export const withoutBasePath = (route, basePath) => {
|
|
288
|
+
if (typeof route !== 'string' || typeof basePath !== 'string')
|
|
289
|
+
return route;
|
|
290
|
+
let end = basePath.length;
|
|
291
|
+
while (end > 0 && basePath[end - 1] === '/')
|
|
292
|
+
end -= 1;
|
|
293
|
+
const base = basePath.slice(0, end);
|
|
294
|
+
if (!base || !route.startsWith(base))
|
|
295
|
+
return route;
|
|
296
|
+
const rest = route.slice(base.length);
|
|
297
|
+
if (!rest)
|
|
298
|
+
return '/';
|
|
299
|
+
if (rest[0] === '?' || rest[0] === '#')
|
|
300
|
+
return `/${rest}`;
|
|
301
|
+
return rest.startsWith('/') ? rest : route;
|
|
302
|
+
};
|
|
303
|
+
// A pathname is visitor input and the matcher backtracks over it, so a longer
|
|
304
|
+
// one is not examined at all.
|
|
305
|
+
const MAX_ROUTE_SEGMENTS = 64;
|
|
306
|
+
const decodeSegment = (segment) => {
|
|
307
|
+
try {
|
|
308
|
+
return decodeURIComponent(segment);
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
return segment;
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
/** Whether one pathname segment fits one route id segment, where `[x]` stands for one or more characters. */
|
|
315
|
+
const fitsSegment = (segment, pattern) => {
|
|
316
|
+
const parts = pattern.split(/(\[[^\]]+\])/);
|
|
317
|
+
if (parts.length === 1)
|
|
318
|
+
return segment === pattern;
|
|
319
|
+
const first = parts[0];
|
|
320
|
+
const last = parts[parts.length - 1];
|
|
321
|
+
if (!segment.startsWith(first) || !segment.endsWith(last))
|
|
322
|
+
return false;
|
|
323
|
+
// The leftmost placement of each literal leaves the most room to the rest.
|
|
324
|
+
const position = parts.slice(2, -1).filter((_, index) => index % 2 === 0).reduce((at, literal) => {
|
|
325
|
+
if (at < 0)
|
|
326
|
+
return at;
|
|
327
|
+
const found = segment.indexOf(literal, at + 1);
|
|
328
|
+
return found < 0 ? -1 : found + literal.length;
|
|
329
|
+
}, first.length);
|
|
330
|
+
return position >= 0 && segment.length - last.length - position >= 1;
|
|
331
|
+
};
|
|
332
|
+
/**
|
|
333
|
+
* What stands in front of the part of `pathname` that SvelteKit's `routeId`
|
|
334
|
+
* matched: `''` when nothing does, `undefined` when the id fits no suffix of
|
|
335
|
+
* it, when it is `null` (a 404), or when the pathname is too long to examine.
|
|
336
|
+
* Param matchers cannot run here, so an optional param on the first segment
|
|
337
|
+
* absorbs a prefix. No regex runs on the pathname.
|
|
338
|
+
*/
|
|
339
|
+
export const routePrefix = (pathname, routeId) => {
|
|
340
|
+
if (typeof pathname !== 'string' || typeof routeId !== 'string')
|
|
341
|
+
return undefined;
|
|
342
|
+
const raw = pathname.split('/').filter(Boolean);
|
|
343
|
+
if (raw.length > MAX_ROUTE_SEGMENTS)
|
|
344
|
+
return undefined;
|
|
345
|
+
const segments = raw.map(decodeSegment);
|
|
346
|
+
const patterns = routeId.split('/').filter((pattern) => pattern && !(pattern.startsWith('(') && pattern.endsWith(')')));
|
|
347
|
+
const fitted = new Map();
|
|
348
|
+
// Whether the segments from `at` on fit the patterns from `index` on. Each
|
|
349
|
+
// pair is decided once, which keeps rest and optional params polynomial.
|
|
350
|
+
const fits = (at, index) => {
|
|
351
|
+
const key = at * (patterns.length + 1) + index;
|
|
352
|
+
const known = fitted.get(key);
|
|
353
|
+
if (known !== undefined)
|
|
354
|
+
return known;
|
|
355
|
+
const pattern = patterns[index];
|
|
356
|
+
const result = pattern === undefined
|
|
357
|
+
? at === segments.length
|
|
358
|
+
: pattern.startsWith('[[') && pattern.endsWith(']]')
|
|
359
|
+
? fits(at, index + 1) || (at < segments.length && fits(at + 1, index + 1))
|
|
360
|
+
: pattern.startsWith('[...') && pattern.endsWith(']')
|
|
361
|
+
? segments.slice(at).some((_, skip) => fits(at + skip, index + 1)) || fits(segments.length, index + 1)
|
|
362
|
+
: at < segments.length && fitsSegment(segments[at], pattern) && fits(at + 1, index + 1);
|
|
363
|
+
fitted.set(key, result);
|
|
364
|
+
return result;
|
|
365
|
+
};
|
|
366
|
+
const at = [...segments.keys(), segments.length].find((skip) => fits(skip, 0));
|
|
367
|
+
return at === undefined ? undefined : raw.slice(0, at).map((segment) => `/${segment}`).join('');
|
|
368
|
+
};
|
|
127
369
|
export const toDotNotation = (input, preserveArrays, parentKey) => {
|
|
128
370
|
if (preserveArrays && Array.isArray(input)) {
|
|
129
371
|
return input.map((v) => toDotNotation(v, preserveArrays));
|
|
@@ -155,19 +397,84 @@ export const toDotNotation = (input, preserveArrays, parentKey) => {
|
|
|
155
397
|
}
|
|
156
398
|
return input;
|
|
157
399
|
};
|
|
400
|
+
const asList = (value) => (Array.isArray(value) ? value : [value]);
|
|
401
|
+
export const unique = (values) => Array.from(new Set(values));
|
|
402
|
+
// Tagged by form, so a string route and a pattern with the same text differ. A
|
|
403
|
+
// matcher's behavior cannot be read — only that it is one.
|
|
404
|
+
const describeRoute = (route) => {
|
|
405
|
+
if (typeof route === 'string')
|
|
406
|
+
return `s:${route}`;
|
|
407
|
+
if (route instanceof RegExp)
|
|
408
|
+
return `r:${String(route)}`;
|
|
409
|
+
return 'm';
|
|
410
|
+
};
|
|
411
|
+
// The content itself rather than a hash of it: equality stays exact, and the
|
|
412
|
+
// string repeats what a serialized payload already carries, so it compresses
|
|
413
|
+
// with it.
|
|
414
|
+
const loaderId = ({ locale, namespace, routes }) => JSON.stringify(routes ? [locale, namespace, routes.map(describeRoute)] : [locale, namespace]);
|
|
415
|
+
// A name shared by two loaders would hand one's records to the other, so
|
|
416
|
+
// neither keeps it.
|
|
417
|
+
const withIds = (loaders) => {
|
|
418
|
+
const ids = loaders.map((loader) => {
|
|
419
|
+
try {
|
|
420
|
+
return loaderId(loader);
|
|
421
|
+
}
|
|
422
|
+
catch (error) {
|
|
423
|
+
logError('Cannot derive an id for a loader.', error);
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
const counts = ids.reduce((acc, id) => acc.set(id, (acc.get(id) ?? 0) + 1), new Map());
|
|
428
|
+
return loaders.map((loader, index) => {
|
|
429
|
+
const id = ids[index] ?? null;
|
|
430
|
+
return { ...loader, id: counts.get(id) === 1 ? id : null };
|
|
431
|
+
});
|
|
432
|
+
};
|
|
158
433
|
// Loader properties are consumer code — an accessor may throw. Materialized
|
|
159
434
|
// once at the config boundary, so a single unreadable loader costs only itself
|
|
160
|
-
// instead of taking down every locale-keyed read downstream.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
435
|
+
// instead of taking down every locale-keyed read downstream. Everything a
|
|
436
|
+
// descriptor may spell more than one way is settled here, so nothing
|
|
437
|
+
// downstream knows there were several: the two names of the namespace, and a
|
|
438
|
+
// list of locales or namespaces, which expands into one loader per pair with
|
|
439
|
+
// its locale sanitized. Each loader is named by its content here too.
|
|
440
|
+
// A module-level config is read by an instance per request, so the deprecated
|
|
441
|
+
// name is reported once per descriptor rather than once per instance.
|
|
442
|
+
const reportedDeprecations = new WeakSet();
|
|
443
|
+
export const resolveLoaders = (input = [], sanitizeLocales = true) => {
|
|
444
|
+
const sanitize = sanitizerFactory(sanitizeLocales);
|
|
445
|
+
return withIds(input.reduce((acc, descriptor) => {
|
|
446
|
+
try {
|
|
447
|
+
const { namespace, key, locale, loader, routes, cache } = descriptor;
|
|
448
|
+
if (key !== undefined && !reportedDeprecations.has(descriptor)) {
|
|
449
|
+
reportedDeprecations.add(descriptor);
|
|
450
|
+
logger.warn(`Loader '${String(key)}' uses 'key', which is deprecated. Rename it to 'namespace'.`);
|
|
451
|
+
}
|
|
452
|
+
if (cache !== undefined && cache !== false) {
|
|
453
|
+
logger.error(`Ignoring the 'cache' of loader '${String(namespace ?? key)}': only 'false' is accepted.`);
|
|
454
|
+
}
|
|
455
|
+
const namespaces = unique(asList(namespace ?? key).filter((name) => name != null));
|
|
456
|
+
const locales = unique(sanitize(...asList(locale).filter((name) => name != null)));
|
|
457
|
+
if (!namespaces.length || !locales.length) {
|
|
458
|
+
logger.warn('Skipping a loader that names no locale or no namespace.');
|
|
459
|
+
return acc;
|
|
460
|
+
}
|
|
461
|
+
return [
|
|
462
|
+
...acc,
|
|
463
|
+
...locales.flatMap((pairLocale) => namespaces.map((pairNamespace) => ({
|
|
464
|
+
namespace: pairNamespace,
|
|
465
|
+
locale: pairLocale,
|
|
466
|
+
loader,
|
|
467
|
+
routes,
|
|
468
|
+
...(cache === false ? { cache } : {}),
|
|
469
|
+
}))),
|
|
470
|
+
];
|
|
471
|
+
}
|
|
472
|
+
catch (error) {
|
|
473
|
+
logError('Skipping a loader that cannot be read.', error);
|
|
474
|
+
return acc;
|
|
475
|
+
}
|
|
476
|
+
}, []));
|
|
477
|
+
};
|
|
171
478
|
const isMergeable = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
172
479
|
// Data reaching a namespace that already holds some — route-scoped chunks of
|
|
173
480
|
// one namespace, a later load, a second `addTranslations` — contributes to it
|
|
@@ -210,7 +517,7 @@ const reportLoaderConflict = (path) => {
|
|
|
210
517
|
logger.warn(`Conflicting translations for '${path}'. Keeping the value of the last loader.`);
|
|
211
518
|
};
|
|
212
519
|
export const serialize = (input) => {
|
|
213
|
-
return input.reduce((acc, {
|
|
520
|
+
return input.reduce((acc, { namespace, data, locale }) => {
|
|
214
521
|
if (!data)
|
|
215
522
|
return acc;
|
|
216
523
|
// The locale is already sanitized — loaders are normalized before the fetch.
|
|
@@ -219,38 +526,131 @@ export const serialize = (input) => {
|
|
|
219
526
|
...acc,
|
|
220
527
|
[locale]: {
|
|
221
528
|
...namespaces,
|
|
222
|
-
[
|
|
529
|
+
[namespace]: hasOwn(namespaces, namespace) ? mergeTranslations(read(namespaces, namespace), data, String(namespace), reportLoaderConflict) : data,
|
|
223
530
|
},
|
|
224
531
|
});
|
|
225
532
|
}, {});
|
|
226
533
|
};
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
534
|
+
/** The locales the loaders serve, then the ones the tables hold. */
|
|
535
|
+
export const servedLocales = (loaders, tables) => unique([
|
|
536
|
+
...loaders.map(({ locale }) => locale),
|
|
537
|
+
...Object.keys(tables),
|
|
538
|
+
]);
|
|
539
|
+
/**
|
|
540
|
+
* The locales a config serves, sanitized as an instance built from it
|
|
541
|
+
* sanitizes them. Resolving the loaders logs what is wrong with them, so a
|
|
542
|
+
* caller runs this once per config.
|
|
543
|
+
*/
|
|
544
|
+
export const configLocales = ({ loaders, translations, sanitizeLocales: strategy }) => servedLocales(resolveLoaders(loaders, strategy), translations ? sanitizeTranslationLocales(translations, sanitizerFactory(strategy)) : {});
|
|
545
|
+
/** A loader as a message names it. `String`, since interpolating a Symbol namespace throws. */
|
|
546
|
+
export const loaderName = ({ locale, namespace }) => `'${locale}' > '${String(namespace)}'`;
|
|
547
|
+
/**
|
|
548
|
+
* SvelteKit's control flow, told by the shape of its classes so nothing is
|
|
549
|
+
* imported from `@sveltejs/kit`: a `Redirect` (an integer `status` from 300 to
|
|
550
|
+
* 308 and a string `location`) and an `HttpError` below 500 (an integer
|
|
551
|
+
* `status` from 400 to 499 and an object `body`), each an own property of a
|
|
552
|
+
* value that is neither an `Error` of this realm nor tagged `'Error'`. An
|
|
553
|
+
* `HttpError` of 500 or more is a failure — a remote `query` throws one on the
|
|
554
|
+
* client whenever the server failed with an `Error` (during SSR, the query
|
|
555
|
+
* throws the server's own error) — and so is such an `Error`, whatever it
|
|
556
|
+
* carries, and a value that cannot be inspected.
|
|
557
|
+
*/
|
|
558
|
+
const isControlFlow = (value) => {
|
|
559
|
+
try {
|
|
560
|
+
// The tag covers another realm's `Error`; `instanceof` covers one whose
|
|
561
|
+
// tag its own class replaced, a `DOMException` included.
|
|
562
|
+
if (value instanceof Error || Object.prototype.toString.call(value) === '[object Error]')
|
|
563
|
+
return false;
|
|
564
|
+
const status = read(value, 'status');
|
|
565
|
+
if (typeof status !== 'number' || !Number.isInteger(status))
|
|
566
|
+
return false;
|
|
567
|
+
if (status >= 300 && status <= 308)
|
|
568
|
+
return typeof read(value, 'location') === 'string';
|
|
569
|
+
if (!(status >= 400 && status <= 499))
|
|
570
|
+
return false;
|
|
571
|
+
const body = read(value, 'body');
|
|
572
|
+
return typeof body === 'object' && body !== null;
|
|
573
|
+
}
|
|
574
|
+
catch {
|
|
575
|
+
return false;
|
|
576
|
+
}
|
|
239
577
|
};
|
|
240
|
-
//
|
|
578
|
+
// One loader's fetch. One that throws is logged and costs only its own data;
|
|
579
|
+
// SvelteKit's control flow is returned for the load to report and reject with
|
|
580
|
+
// once every loader of it has settled. Only a throw is retried: an empty
|
|
581
|
+
// answer is an answer, and it still replaces what the loader delivered for
|
|
582
|
+
// other params.
|
|
583
|
+
export const fetchTranslation = async ({ loader: resolved, params, signature }, route) => {
|
|
584
|
+
const { loader, locale, namespace } = resolved;
|
|
585
|
+
try {
|
|
586
|
+
const data = await loader({ locale, namespace, route, params });
|
|
587
|
+
return { deliveries: [{ loader: resolved, signature, data: data || {} }], controlFlow: [] };
|
|
588
|
+
}
|
|
589
|
+
catch (error) {
|
|
590
|
+
if (isControlFlow(error))
|
|
591
|
+
return { deliveries: [], controlFlow: [{ loader: resolved, signature, value: error }] };
|
|
592
|
+
logError(`Failed to load translation. Verify your ${loaderName(resolved)} Loader.`, error);
|
|
593
|
+
return { deliveries: [], controlFlow: [] };
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
/** The fetches of a load as one. */
|
|
597
|
+
export const mergeFetched = (fetched) => ({
|
|
598
|
+
deliveries: fetched.flatMap(({ deliveries }) => deliveries),
|
|
599
|
+
controlFlow: fetched.flatMap(({ controlFlow }) => controlFlow),
|
|
600
|
+
});
|
|
601
|
+
// `exec` advances `lastIndex` on a `g`/`y` pattern, so a route object reused
|
|
241
602
|
// across navigations would match only every other time — and writing to the
|
|
242
603
|
// consumer's own pattern is not ours to do, least of all when it is frozen.
|
|
243
|
-
const withoutMatchState = (input) => (input
|
|
604
|
+
const withoutMatchState = (input) => (input.global || input.sticky
|
|
244
605
|
? new RegExp(input.source, input.flags)
|
|
245
606
|
: input);
|
|
246
|
-
|
|
607
|
+
// A match yields the named groups of a pattern, copied onto a plain object
|
|
608
|
+
// (a match's `groups` has a null prototype) without the groups that did not
|
|
609
|
+
// take part. A string route and a matcher yield none. `undefined` is no match.
|
|
610
|
+
export const matchRoute = (route) => (input) => {
|
|
247
611
|
try {
|
|
248
612
|
if (typeof input === 'string')
|
|
249
|
-
return input === route;
|
|
250
|
-
|
|
613
|
+
return input === route ? {} : undefined;
|
|
614
|
+
if (input instanceof RegExp) {
|
|
615
|
+
const match = withoutMatchState(input).exec(route);
|
|
616
|
+
if (!match)
|
|
617
|
+
return undefined;
|
|
618
|
+
return Object.entries(match.groups ?? {}).reduce((acc, [name, value]) => (value === undefined ? acc : { ...acc, [name]: value }), {});
|
|
619
|
+
}
|
|
620
|
+
return input.test(route) ? {} : undefined;
|
|
251
621
|
}
|
|
252
622
|
catch (error) {
|
|
253
623
|
logError('Invalid route config!', error);
|
|
254
624
|
}
|
|
255
|
-
return
|
|
625
|
+
return undefined;
|
|
626
|
+
};
|
|
627
|
+
export const testRoute = (route) => (input) => matchRoute(route)(input) !== undefined;
|
|
628
|
+
/** The params a loader loads with on `route` – its first matching route's – or `undefined` when none matches. */
|
|
629
|
+
export const routeParams = (routes, route) => {
|
|
630
|
+
if (!routes)
|
|
631
|
+
return {};
|
|
632
|
+
const match = matchRoute(route);
|
|
633
|
+
return routes.reduce((found, input) => found ?? match(input), undefined);
|
|
634
|
+
};
|
|
635
|
+
// Whether a route can yield params: a pattern with a named group. Read off an
|
|
636
|
+
// empty match of the pattern made optional, whose `groups` names every group.
|
|
637
|
+
const namesGroups = (input) => {
|
|
638
|
+
if (!(input instanceof RegExp))
|
|
639
|
+
return false;
|
|
640
|
+
try {
|
|
641
|
+
const groups = new RegExp(`(?:${input.source})|`, input.flags.replace(/[gy]/g, '')).exec('')?.groups;
|
|
642
|
+
return !!groups && Object.keys(groups).length > 0;
|
|
643
|
+
}
|
|
644
|
+
catch {
|
|
645
|
+
return false;
|
|
646
|
+
}
|
|
647
|
+
};
|
|
648
|
+
/** Whether any of `routes` can yield params. */
|
|
649
|
+
export const capturesParams = (routes) => !!routes?.some(namesGroups);
|
|
650
|
+
// Keyed on the params alone, not the route: two routes yielding the same params
|
|
651
|
+
// describe the same data. Stable in key order, and '' for none, so a loader
|
|
652
|
+
// without params keys the way a namespace record does.
|
|
653
|
+
export const paramsSignature = (params) => {
|
|
654
|
+
const names = Object.keys(params).sort();
|
|
655
|
+
return names.length ? JSON.stringify(names.map((name) => [name, read(params, name)])) : '';
|
|
256
656
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sveltekit-i18n/base",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.1.0-next.0",
|
|
4
4
|
"description": "Base functionality of sveltekit-i18n library with a support for external message parsers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -15,8 +15,23 @@
|
|
|
15
15
|
"svelte": "./dist/exports/utils.js",
|
|
16
16
|
"default": "./dist/exports/utils.js"
|
|
17
17
|
},
|
|
18
|
+
"./kit": {
|
|
19
|
+
"types": "./dist/exports/kit.d.ts",
|
|
20
|
+
"svelte": "./dist/exports/kit.js",
|
|
21
|
+
"default": "./dist/exports/kit.js"
|
|
22
|
+
},
|
|
18
23
|
"./package.json": "./package.json"
|
|
19
24
|
},
|
|
25
|
+
"imports": {
|
|
26
|
+
"#kit-env": {
|
|
27
|
+
"browser": "./dist/kit/env.browser.js",
|
|
28
|
+
"default": "./dist/kit/env.js"
|
|
29
|
+
},
|
|
30
|
+
"#kit-server": {
|
|
31
|
+
"browser": "./dist/kit/server.browser.js",
|
|
32
|
+
"default": "./dist/kit/server.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
20
35
|
"scripts": {
|
|
21
36
|
"dev": "svelte-package -i src -o dist -w",
|
|
22
37
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
@@ -50,9 +65,12 @@
|
|
|
50
65
|
"bugs": {
|
|
51
66
|
"url": "https://github.com/sveltekit-i18n/lib/issues"
|
|
52
67
|
},
|
|
53
|
-
"homepage": "https://
|
|
68
|
+
"homepage": "https://sveltekit-i18n.github.io",
|
|
69
|
+
"funding": "https://github.com/sponsors/sveltekit-i18n",
|
|
54
70
|
"engines": {
|
|
55
|
-
"node": ">=22"
|
|
71
|
+
"node": ">=22",
|
|
72
|
+
"bun": ">=1.2",
|
|
73
|
+
"deno": ">=2"
|
|
56
74
|
},
|
|
57
75
|
"peerDependencies": {
|
|
58
76
|
"svelte": ">=5"
|
|
@@ -63,10 +81,12 @@
|
|
|
63
81
|
"@sveltejs/package": "^2.5.8",
|
|
64
82
|
"@sveltejs/vite-plugin-svelte": "^7.3.0",
|
|
65
83
|
"@types/node": "^26.2.0",
|
|
84
|
+
"devalue": "^5.9.4",
|
|
66
85
|
"esbuild": "^0.28.2",
|
|
67
86
|
"eslint": "^10.8.1",
|
|
68
87
|
"eslint-plugin-import-x": "^4.17.1",
|
|
69
88
|
"globals": "^17.11.0",
|
|
89
|
+
"happy-dom": "^20.14.5",
|
|
70
90
|
"simple-git-hooks": "^2.13.1",
|
|
71
91
|
"svelte": "^5.56.9",
|
|
72
92
|
"typescript": "^5.1.6",
|