@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/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
- export const resolveLoaders = (input = []) => (input.reduce((acc, descriptor) => {
162
- try {
163
- const { key, locale, loader, routes } = descriptor;
164
- return [...acc, { key, locale, loader, routes }];
165
- }
166
- catch (error) {
167
- logError('Skipping a loader that cannot be read.', error);
168
- return acc;
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, { key, data, locale }) => {
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
- [key]: hasOwn(namespaces, key) ? mergeTranslations(read(namespaces, key), data, `${key}`, reportLoaderConflict) : data,
529
+ [namespace]: hasOwn(namespaces, namespace) ? mergeTranslations(read(namespaces, namespace), data, String(namespace), reportLoaderConflict) : data,
223
530
  },
224
531
  });
225
532
  }, {});
226
533
  };
227
- export const fetchTranslations = async (loaders, route) => {
228
- const response = await Promise.all(loaders.map(async ({ loader, ...rest }) => {
229
- let data;
230
- try {
231
- data = await loader({ locale: rest.locale, route });
232
- }
233
- catch (error) {
234
- logError(`Failed to load translation. Verify your '${rest.locale}' > '${rest.key}' Loader.`, error);
235
- }
236
- return { loader, ...rest, data };
237
- }));
238
- return serialize(response);
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
- // `test` advances `lastIndex` on a `g`/`y` pattern, so a route object reused
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 instanceof RegExp && (input.global || input.sticky)
604
+ const withoutMatchState = (input) => (input.global || input.sticky
244
605
  ? new RegExp(input.source, input.flags)
245
606
  : input);
246
- export const testRoute = (route) => (input) => {
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
- return withoutMatchState(input).test(route);
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 false;
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.1",
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://github.com/sveltekit-i18n/base#readme",
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",