@sveltekit-i18n/base 3.1.2 → 3.3.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.
@@ -3,7 +3,7 @@ import { BROWSER } from '#kit-env';
3
3
  import { serverHalf } from '#kit-server';
4
4
  import { I18n } from '../I18n.svelte.js';
5
5
  import { logError, loggerFactory, setLogger } from '../logger.js';
6
- import { configLocales, matchLocale, sanitizerFactory, textDirection } from '../utils.js';
6
+ import { configLocales, matchLocale, paramsSignature, resolveLoaders, routeParams, sanitizerFactory, textDirection, withoutBasePath } from '../utils.js';
7
7
  // Registry-wide, so two copies of this package meet: the context key, and the
8
8
  // key of the pass the universal branch hands `use()` in `data`.
9
9
  const KEY = Symbol.for('@sveltekit-i18n/base/kit');
@@ -28,21 +28,23 @@ export const defineI18n = (config, options = {}) => {
28
28
  let defaults = [];
29
29
  // Resolved on first use, never at import, and once: resolving the loaders
30
30
  // and sanitizing reports what is wrong with them.
31
- const locales = () => {
31
+ const resolved = () => {
32
32
  if (configured)
33
33
  return configured;
34
34
  if (config.log)
35
35
  setLogger(loggerFactory(config.log));
36
36
  try {
37
- configured = configLocales(config);
37
+ const loaders = resolveLoaders(config.loaders, config.sanitizeLocales);
38
+ configured = { locales: configLocales(config, loaders), handOver: !loaders.some(({ cache }) => cache === false), loaders };
38
39
  }
39
40
  catch {
40
41
  // The instance reports a malformed config itself.
41
- configured = [];
42
+ configured = { locales: [], handOver: false, loaders: [] };
42
43
  }
43
44
  defaults = [sanitized(config.initLocale), sanitized(config.fallbackLocale)];
44
45
  return configured;
45
46
  };
47
+ const locales = () => resolved().locales;
46
48
  let reported = false;
47
49
  const preferred = (event) => {
48
50
  try {
@@ -78,54 +80,120 @@ export const defineI18n = (config, options = {}) => {
78
80
  if (chosen !== undefined)
79
81
  return { locale: chosen, preferred: true };
80
82
  return {
81
- locale: [ranges, ...defaults].reduce((found, candidate) => found ?? matchLocale(candidate, available), undefined),
83
+ // Last, the first locale served: a config that serves one never renders
84
+ // a page without a locale.
85
+ locale: [ranges, ...defaults].reduce((found, candidate) => found ?? matchLocale(candidate, available), undefined) ?? available[0],
82
86
  preferred: false,
83
87
  };
84
88
  };
85
- const server = serverHalf({ create, negotiate, locales, basePath: config.basePath });
89
+ const server = serverHalf({ create, negotiate, locales, basePath: config.basePath, handOver: () => resolved().handOver });
86
90
  // Browser only: the tab's instance, the server's answer at the last commit,
87
- // and the locale that commit is switching to with the one it switches from,
88
- // which only `use()` writes. A server keeps nothing between requests.
89
- const tab = { committed: false };
90
- // The locale the tab shows once the wiring's own switch lands: that switch
91
- // is no client change, while a locale that moved from where it started is.
92
- const heading = (i18n) => {
93
- const active = untrack(() => i18n.locale);
94
- return tab.switching && tab.switching.from === active ? tab.switching.to : active;
91
+ // the switch under way, what the last commit waits on, how many commits
92
+ // there were, which only `use()` writes, and the tables the pass that built
93
+ // the instance left when it activated it without a hand-off for a config
94
+ // with a `cache: false` loader, until the first commit reads them. None of
95
+ // it lives in `data`, which the app may hold in deep state. A server keeps
96
+ // nothing between requests.
97
+ const tab = { commits: 0 };
98
+ // The wiring's own switch, while the locale has not moved from where it
99
+ // started: that switch is no client change, while a locale that moved is.
100
+ const underway = (i18n) => (tab.switching?.from === untrack(() => i18n.locale) ? tab.switching : undefined);
101
+ // The locale the tab shows once the wiring's own switch lands.
102
+ const heading = (i18n) => underway(i18n)?.to ?? untrack(() => i18n.locale);
103
+ // The records an activation of `locale` on `route` leaves once every loader
104
+ // it selects delivered, in `loaders` order as `snapshot()` lists them, or
105
+ // `undefined` when none of them has `cache: false`, so the commit runs
106
+ // nothing again, or one of them has no id, whose record no snapshot shows.
107
+ const records = (locale, route) => {
108
+ const selected = resolved().loaders.flatMap((loader) => {
109
+ const params = loader.locale === locale || loader.locale === defaults[1] ? routeParams(loader.routes, route) : undefined;
110
+ return params ? [{ id: loader.id, signature: paramsSignature(params), cache: loader.cache }] : [];
111
+ });
112
+ if (!selected.some(({ cache }) => cache === false))
113
+ return undefined;
114
+ const listed = selected.flatMap(({ id, signature }) => (id === null ? [] : [signature ? { id, signature } : { id }]));
115
+ return listed.length === selected.length ? listed : undefined;
116
+ };
117
+ // Whether an instance still stands where the activation of `locale` on
118
+ // `route` left it, with `tables`: nothing loading or landed since, its
119
+ // records whole and every loader delivered, which a failed loader, another
120
+ // load, a reconfiguration and an invalidation of what it loaded each undo.
121
+ const stands = (i18n, locale, route, tables) => untrack(() => {
122
+ if (i18n.loading || i18n.locale !== locale || i18n.rawTranslations !== tables)
123
+ return false;
124
+ const path = withoutBasePath(route, config.basePath);
125
+ const expected = records(locale, path);
126
+ if (!expected)
127
+ return false;
128
+ const envelope = i18n.snapshot({ records: true });
129
+ return envelope.route === path && JSON.stringify(envelope.records) === JSON.stringify(expected);
130
+ });
131
+ const fallBack = (commit, switching) => {
132
+ const lapse = { commit, switching };
133
+ tab.lapse = lapse;
134
+ void Promise.all([switching.landed, ...switching.stays]).then((outcomes) => { lapse.outcomes = outcomes; });
135
+ };
136
+ // The answer of the last commit. Once its switch and every stay committed
137
+ // while that switch was under way settled, a failure that left the tab
138
+ // short of the locale switched to leaves the answer to the next commit:
139
+ // short, as long as the tab has not shown that locale since the switch
140
+ // started, whoever's call landed it. A switch to the locale it started from
141
+ // fell short unless a call landed.
142
+ const answered = (i18n) => {
143
+ const { lapse } = tab;
144
+ if (!lapse?.outcomes || lapse.commit !== tab.commits || lapse.outcomes.every(Boolean))
145
+ return tab.answer;
146
+ const { outcomes, switching } = lapse;
147
+ const { from, to, previous } = switching;
148
+ if (untrack(() => i18n.locale) === to)
149
+ switching.reached = true;
150
+ return (from === to ? !outcomes.some(Boolean) : !switching.reached) ? previous : tab.answer;
95
151
  };
96
152
  const universalLoad = async (event) => {
97
153
  const route = event.url.pathname;
98
154
  const payload = event.data?.i18n;
155
+ // A page render's own instance: the server branch loaded it for this very
156
+ // payload, which SvelteKit hands over as it was returned.
157
+ const rendered = BROWSER ? undefined : server.take(payload);
99
158
  const fresh = !BROWSER || !tab.i18n;
100
- const i18n = fresh ? create() : tab.i18n;
159
+ const i18n = rendered ?? (fresh ? create() : tab.i18n);
101
160
  const surface = fresh ? pipe(i18n) : tab.surface;
102
- const seen = heading(i18n);
161
+ const via = underway(i18n);
162
+ const seen = via?.to ?? untrack(() => i18n.locale);
103
163
  // A live server sends the tables on a page render only, so a later pass
104
164
  // that carries them read a prerendered file, whose locale was negotiated
105
165
  // at build time, without the visitor: it counts only when the build's
106
166
  // `preferredLocale` gave it. Node and Deno define `navigator.languages`
107
167
  // too, from the server's own environment.
108
168
  const prerendered = !fresh && payload?.translations;
109
- const answer = prerendered
110
- ? (payload.preferred ? payload.locale : tab.answer)
169
+ const follows = Boolean(prerendered && !payload.preferred);
170
+ const answer = follows
171
+ ? answered(i18n)
111
172
  : payload ? payload.locale : negotiate(event, BROWSER ? navigator.languages : undefined).locale;
112
173
  if (BROWSER)
113
174
  Object.assign(tab, { i18n, surface });
175
+ let preloaded;
114
176
  if (fresh) {
115
- if (payload?.translations)
177
+ if (!rendered && payload?.translations)
116
178
  i18n.hydrate({ ...payload, translations: payload.translations });
117
179
  // No preload runs before the first navigation completes, so the pass
118
180
  // that builds the instance may activate it.
119
181
  await (answer ? i18n.loadTranslations(answer, route) : i18n.setRoute(route));
120
182
  }
121
183
  else {
122
- // Warm only: this pass may be a preload, which must not change what is
123
- // shown. The commit switches to a changed answer, or else stays.
124
- const target = (answer !== tab.answer ? answer : undefined) ?? heading(i18n);
184
+ // The request of a navigation that may never commit, a hover's
185
+ // included: it activates nothing, and its token lets the commit show
186
+ // what it fetched. The commit switches to a changed answer, or else
187
+ // stays.
188
+ const target = (answer !== answered(i18n) ? answer : undefined) ?? heading(i18n);
125
189
  if (target)
126
- await i18n.loadTranslations(target, route, { activate: false });
190
+ preloaded = await i18n.preload(target, route);
127
191
  }
128
- const pass = { i18n, surface, locale: answer, route, seen };
192
+ // Unless a hand-off holds it back, the commit would run a loader with
193
+ // `cache: false` again for the very request this pass activated.
194
+ if (BROWSER && fresh && !payload?.translations && !resolved().handOver)
195
+ tab.activated = untrack(() => i18n.rawTranslations);
196
+ const pass = { i18n, surface, locale: answer, follows, route, seen, via, preloaded };
129
197
  return { ...event.data, i18n: surface, [KEY]: pass };
130
198
  };
131
199
  const load = ((event) => (isServerEvent(event) ? server.load(event) : universalLoad(event)));
@@ -143,34 +211,68 @@ export const defineI18n = (config, options = {}) => {
143
211
  const pass = passOf(data());
144
212
  if (!pass)
145
213
  return;
146
- if (!tab.committed || (pass.locale !== tab.answer && heading(pass.i18n) === pass.seen)) {
147
- const { locale } = pass;
214
+ const first = !tab.commits;
215
+ const { activated } = tab;
216
+ // Read by the first commit alone: the tables would outlive their
217
+ // replacement for the life of the tab.
218
+ tab.activated = undefined;
219
+ tab.answer = answered(pass.i18n);
220
+ const commit = ++tab.commits;
221
+ const now = heading(pass.i18n);
222
+ // A switch that failed before this commit may have headed nowhere: its
223
+ // undo puts back the locale it started from or the one the tab was
224
+ // heading for, unless a later call landed the one it headed for.
225
+ const current = now === pass.seen || (pass.via?.failed === true && (now === pass.via.from || now === pass.via.back));
226
+ // A pass that takes the tab's answer never switches.
227
+ const locale = pass.follows ? tab.answer : pass.locale;
228
+ if (first || (locale !== tab.answer && current)) {
148
229
  const previous = tab.answer;
149
- tab.committed = true;
150
230
  tab.answer = locale;
151
231
  if (locale !== undefined) {
152
- const switching = { from: untrack(() => pass.i18n.locale), to: locale };
153
- const done = () => {
154
- if (tab.switching === switching)
155
- tab.switching = undefined;
232
+ // The pass that built the instance activated it for this request:
233
+ // while it stands there, that activation is the commit's.
234
+ const taken = first && activated !== undefined && stands(pass.i18n, locale, pass.route, activated);
235
+ const switching = {
236
+ from: untrack(() => pass.i18n.locale),
237
+ back: now,
238
+ to: locale,
239
+ previous,
240
+ failed: false,
241
+ stays: [],
242
+ reached: false,
243
+ landed: (taken ? Promise.resolve() : pass.i18n.loadTranslations(locale, pass.route, { preloaded: pass.preloaded })).then(() => true, () => {
244
+ switching.failed = true;
245
+ return false;
246
+ }),
156
247
  };
157
248
  tab.switching = switching;
158
- // A switch that failed, and that no later call landed either, leaves
159
- // the answer to the next commit.
160
- pass.i18n.loadTranslations(locale, pass.route).then(done, () => {
161
- done();
162
- if (tab.answer === locale && untrack(() => pass.i18n.locale) !== locale)
163
- tab.answer = previous;
249
+ void switching.landed.then(() => {
250
+ if (tab.switching === switching)
251
+ tab.switching = undefined;
164
252
  });
253
+ fallBack(commit, switching);
165
254
  return;
166
255
  }
167
256
  }
168
- void pass.i18n.setRoute(pass.route);
257
+ // A stay committed while what the previous commit waits on is under way
258
+ // carries it on, unless the locale moved from where the switch started.
259
+ const { lapse } = tab;
260
+ const switching = lapse && !lapse.outcomes && lapse.commit === commit - 1 && lapse.switching.from === untrack(() => pass.i18n.locale)
261
+ ? lapse.switching
262
+ : undefined;
263
+ const stayed = pass.i18n.setRoute(pass.route, { preloaded: pass.preloaded }).then(() => true, () => false);
264
+ if (!switching)
265
+ return;
266
+ switching.stays = [...switching.stays, stayed];
267
+ fallBack(commit, switching);
169
268
  });
170
269
  $effect(() => {
171
270
  const { locale } = i18n;
172
271
  if (!locale)
173
272
  return;
273
+ const switching = tab.lapse?.switching;
274
+ if (switching?.to === locale)
275
+ switching.reached = true;
174
276
  document.documentElement.lang = locale;
175
277
  document.documentElement.dir = textDirection(locale);
176
278
  });
@@ -12,10 +12,18 @@ export type Shared = {
12
12
  /** The locales the config serves. */
13
13
  locales: () => string[];
14
14
  basePath: string | undefined;
15
+ /**
16
+ * Whether the universal branch of a page render may take over the instance
17
+ * the server branch loaded: not while a loader has `cache: false`, which only
18
+ * a hand-off holds back for the rest of the render.
19
+ */
20
+ handOver: () => boolean;
15
21
  };
16
22
  export type ServerHalf = {
17
23
  handle: Kit.T['handle'];
18
24
  load: (event: Kit.ServerLoadEvent) => Promise<{
19
25
  i18n: Kit.Payload;
20
26
  }>;
27
+ /** The instance a page render loaded for `payload`, handed out once. */
28
+ take: (payload: Kit.Payload | undefined) => I18n | undefined;
21
29
  };
@@ -3,4 +3,4 @@ const serverOnly = () => {
3
3
  };
4
4
  // What `#kit-server` resolves to under the `browser` condition: the browser
5
5
  // bundle carries this instead of the server half.
6
- export const serverHalf = () => ({ handle: serverOnly, load: serverOnly });
6
+ export const serverHalf = () => ({ handle: serverOnly, load: serverOnly, take: serverOnly });
@@ -1,2 +1,2 @@
1
1
  import type { ServerHalf, Shared } from './internal.js';
2
- export declare const serverHalf: ({ create, negotiate, locales, basePath }: Shared) => ServerHalf;
2
+ export declare const serverHalf: ({ create, negotiate, locales, basePath, handOver }: Shared) => ServerHalf;
@@ -1,7 +1,11 @@
1
1
  import { logger } from '../logger.js';
2
2
  import { matchLocale, routePrefix, textDirection, withoutBasePath } from '../utils.js';
3
- export const serverHalf = ({ create, negotiate, locales, basePath }) => {
3
+ export const serverHalf = ({ create, negotiate, locales, basePath, handOver }) => {
4
4
  const answer = (event) => negotiate(event, event.request.headers.get('accept-language'));
5
+ // The instance each page render loaded, under the payload it returned:
6
+ // SvelteKit hands that very object to the universal load of the same
7
+ // request.
8
+ const built = new WeakMap();
5
9
  let warned = false;
6
10
  // A base path SvelteKit strips and `basePath` does not keeps every
7
11
  // route-scoped loader from matching, silently. Here, on the server only, so
@@ -66,7 +70,18 @@ export const serverHalf = ({ create, negotiate, locales, basePath }) => {
66
70
  return { i18n: { locale, route: withoutBasePath(route, basePath) } };
67
71
  const i18n = create();
68
72
  await (locale ? i18n.loadTranslations(locale, route) : i18n.setRoute(route));
69
- return { i18n: { ...i18n.snapshot({ records: true }), ...(preferred ? { preferred: true } : {}) } };
73
+ const payload = { ...i18n.snapshot({ records: true }), ...(preferred ? { preferred: true } : {}) };
74
+ if (handOver())
75
+ built.set(payload, i18n);
76
+ return { i18n: payload };
77
+ },
78
+ // Once, so a payload an app keeps and returns again shares no instance.
79
+ take: (payload) => {
80
+ if (!payload)
81
+ return undefined;
82
+ const i18n = built.get(payload);
83
+ built.delete(payload);
84
+ return i18n;
70
85
  },
71
86
  };
72
87
  };
package/dist/types.d.ts CHANGED
@@ -70,7 +70,7 @@ export declare namespace Config {
70
70
  export type SanitizeLocales = boolean | ((locale: Locale) => Locale);
71
71
  export type T<P extends Parser.Params = Parser.Params, O = Parser.Output, S = any> = {
72
72
  /**
73
- * You can use loaders to define your asyncronous translation load. All loaded data are stored so loader is triggered only once – in case there is no previous version of the translation. It can get triggered again when the params its `routes` capture change, once the `config.cache` window elapses, or after `invalidate()` is called. A loader with `cache: false` runs on every load trigger that selects it.
73
+ * You can use loaders to define your asyncronous translation load. All loaded data are stored so loader is triggered only once – in case there is no previous version of the translation. It can get triggered again when the params its `routes` capture change, once the `config.cache` window elapses, or after `invalidate()` is called. A loader with `cache: false` runs on every load trigger that selects it, except a call handed the token of a `preload()` that ran it.
74
74
  */
75
75
  loaders?: readonly Loader.LoaderModule[];
76
76
  /**
@@ -83,7 +83,7 @@ export declare namespace Config {
83
83
  */
84
84
  translations?: Translations.T;
85
85
  /**
86
- * If you set this property, translations will be initialized immediately using this locale. `defineI18n()` from `/kit` loads nothing for it: there it is a negotiation candidate. Leave it out of a config whose instance you `hydrate()` by hand – its load starts in the constructor, before the hand-off can be applied.
86
+ * The initial locale. With `defineI18n()` from `/kit`, the locale a visitor gets when neither `preferredLocale` nor what the visitor's browser asks for (`Accept-Language`, or `navigator.languages` without a server `load`) names a locale the config serves; it loads only when negotiation picks it, and without it, or when it matches no locale served, `fallbackLocale` and then the first locale served take that role. With `new I18n(config)`, the constructor loads it right away – leave it out of a config whose instance you `hydrate()` by hand, since its load starts before the hand-off can be applied.
87
87
  */
88
88
  initLocale?: InitLocale;
89
89
  /**
@@ -163,7 +163,7 @@ export declare namespace Config {
163
163
  */
164
164
  basePath?: string;
165
165
  /**
166
- * Time in milliseconds the loaded translations stay fresh for. Once a locale's translations are older, the next activating load trigger (`setLocale()`, `setRoute()`, `loadTranslations()`) runs its loaders again; a warm load – `loadTranslations(…, { activate: false })` or `loadNamespace()` – fills the tables without evaluating the window. By default, loaded translations never expire – call `invalidate()` (or set a finite `cache`) when your translation source can change at runtime, e.g. a CMS. A loader with `cache: false` is outside the window.
166
+ * Time in milliseconds the loaded translations stay fresh for. Once a locale's translations are older, the next activating load trigger (`setLocale()`, `setRoute()`, `loadTranslations()`) or `preload()` runs its loaders again; a warm load – `loadTranslations(…, { activate: false })` or `loadNamespace()` – fills the tables without evaluating the window, and a call handed a `preload()`'s token leaves it to that preload. By default, loaded translations never expire – call `invalidate()` (or set a finite `cache`) when your translation source can change at runtime, e.g. a CMS. A loader with `cache: false` is outside the window.
167
167
  *
168
168
  * @default Number.POSITIVE_INFINITY
169
169
  *
@@ -192,6 +192,7 @@ export declare namespace Config {
192
192
  export {};
193
193
  }
194
194
  declare const operator: unique symbol;
195
+ declare const preloaded: unique symbol;
195
196
  export declare namespace Extension {
196
197
  type Input = any;
197
198
  type Output = any;
@@ -316,7 +317,7 @@ export declare namespace Loader {
316
317
  */
317
318
  routes?: readonly Route[];
318
319
  /**
319
- * Set to `false` when the loader's source does the caching – a SvelteKit remote `query`, an SWR layer, an HTTP cache. The core then keeps no freshness of its own for it: it runs on every load trigger that selects it, its data is applied each time like any refetch, and `config.cache` does not apply to it – refreshing the source is the app's business. Data hydrated from a snapshot still holds it back for the pass it arrived with, until an activating trigger asks for another locale or route; `invalidate()` ends that hand-off and discards a fetch of it in flight. Only `false` is accepted.
320
+ * Set to `false` when the loader's source does the caching – a SvelteKit remote `query`, an SWR layer, an HTTP cache. The core then keeps no freshness of its own for it: it runs on every load trigger that selects it – unless a fetch of it for the same params and route is in flight, which it shares – its data is applied each time like any refetch, and `config.cache` does not apply to it – refreshing the source is the app's business. A call handed a `preload()`'s token shows what the preload fetched instead of running it again; what the preload shared from a fetch already in flight is shown, then fetched again. Data hydrated from a snapshot still holds it back for the pass it arrived with, until an activating trigger asks for another locale or route, or a `preload()` runs; `invalidate()` ends that hand-off and discards a fetch of it in flight. Only `false` is accepted.
320
321
  */
321
322
  cache?: false;
322
323
  };
@@ -364,6 +365,17 @@ export declare namespace Loader {
364
365
  */
365
366
  id: string | null;
366
367
  };
368
+ /**
369
+ * What `preload()` resolves to: a token for the next activating call of the
370
+ * same locale and route, which takes it as `{ preloaded }` and shows what the
371
+ * preload fetched instead of fetching it again. It serves one call, of the
372
+ * instance that made it: the first activating call that reads it spends it,
373
+ * whether or not it serves that call. Hold it by reference: it carries no
374
+ * data, and a copy is no token.
375
+ */
376
+ export type Preloaded = {
377
+ readonly [preloaded]: true;
378
+ };
367
379
  /**
368
380
  * Loads translation data. Receives the load context (`locale`, `namespace`, `route`, `params`) –
369
381
  * loaders that don't need it can simply take no parameters.
@@ -564,6 +576,13 @@ export declare namespace Schema {
564
576
  /** The keys a schema allows; any string when there is no schema. */
565
577
  export type Key<S> = [S] extends [never] ? string : keyof S & string;
566
578
  type IsAny<T> = 0 extends 1 & T ? true : false;
579
+ /**
580
+ * `keyof S`, read once per schema. TypeScript rebuilds `keyof` of an object
581
+ * type over every key each time it reads it, and a plain alias of it too;
582
+ * the instantiation of a conditional type is cached per type argument, so
583
+ * each call reads the keys this computed once.
584
+ */
585
+ type Keys<S> = [S] extends [unknown] ? keyof S : never;
567
586
  /**
568
587
  * What satisfies every key in `K` — see `Params`. The fold runs over the
569
588
  * KEYS, so each key's own payload reaches the intersection whole; folding
@@ -573,8 +592,21 @@ export declare namespace Schema {
573
592
  * empty union stays `never`: no member means no payload, not an
574
593
  * unconstrained one.
575
594
  */
576
- type BoxedPayload<S, K extends string> = K extends keyof S ? [Exclude<S[K], undefined>] extends [never] ? never : (payload: Exclude<S[K], undefined>) => void : never;
577
- type PayloadOf<S, K extends string> = [BoxedPayload<S, K>] extends [never] ? never : BoxedPayload<S, K> extends (payload: infer V) => void ? V : never;
595
+ type BoxedPayload<S, K extends string> = K extends Keys<S> ? [Exclude<S[K], undefined>] extends [never] ? never : (payload: Exclude<S[K], undefined>) => void : never;
596
+ /** Whether a key in `K` marks its payload optional; `boolean` when only some do. */
597
+ type OptionalOf<S, K extends string> = K extends Keys<S> ? (undefined extends S[K] ? true : false) : never;
598
+ /**
599
+ * The payloads fold into their intersection by inference: `V` stands as the
600
+ * parameter in the false branch of a conditional deferred on `V` itself, so
601
+ * a union of functions contributes each parameter as a contravariant
602
+ * candidate, and those combine into their intersection. Once `V` is
603
+ * inferred, that conditional reads `unknown` or `never`, so the union is
604
+ * never related to the intersection itself. Matched against a function over
605
+ * the intersection instead, the union would relate the intersection to each
606
+ * payload, which for a key outside the schema, typed over every key, costs
607
+ * time about cubic in their number.
608
+ */
609
+ type PayloadOf<S, K extends string> = [BoxedPayload<S, K>] extends [never] ? never : BoxedPayload<S, K> extends (infer V extends unknown ? unknown : (payload: infer V) => void) ? V : never;
578
610
  /**
579
611
  * The parser's own params minus the payload slot the schema takes over. A
580
612
  * params tuple that is unknown or open-ended contributes no trailing slots –
@@ -594,7 +626,7 @@ export declare namespace Schema {
594
626
  * A union of keys takes the INTERSECTION of their payloads, since one call
595
627
  * has to satisfy every key it might be.
596
628
  */
597
- export type Params<S, K extends string, P extends Parser.Params> = [S] extends [never] ? P : [K] extends [keyof S] ? Payload<P, PayloadOf<S, K>, undefined extends S[K & keyof S] ? true : false> : P;
629
+ export type Params<S, K extends string, P extends Parser.Params> = [S] extends [never] ? P : [K] extends [Keys<S>] ? Payload<P, PayloadOf<S, K>, OptionalOf<S, K>> : P;
598
630
  export {};
599
631
  }
600
632
  export declare namespace Snapshot {
package/dist/utils.d.ts CHANGED
@@ -1,15 +1,7 @@
1
1
  import type { Config, DotNotation, Translations, Loader, Parser } from './types.js';
2
2
  export declare const hasOwn: (obj: any, key: PropertyKey) => boolean;
3
3
  export declare const read: <T = any>(obj: any, key: PropertyKey) => T | undefined;
4
- export declare const translate: <P extends Parser.Params = Parser.Params, O = Parser.Output>({ parser, key, params, translations, locale, fallbackLocale, ...rest }: {
5
- parser: Parser.T<P, O>;
6
- key: string;
7
- params: Parser.Params;
8
- translations: Translations.SerializedTranslations;
9
- locale: Translations.Locales[number] | undefined;
10
- fallbackLocale?: Config.FallbackLocale;
11
- fallbackValue?: Config.FallbackValue;
12
- }) => Translations.Translated<O>;
4
+ export declare const translate: <P extends Parser.Params = Parser.Params, O = Parser.Output>(config: Pick<Config.T<P, O>, "parser" | "fallbackLocale" | "fallbackValue"> | undefined, locale: Translations.Locales[number] | undefined, key: string, params: Parser.Params, table: DotNotation.Input, fallbackTable: DotNotation.Input) => Translations.Translated<O>;
13
5
  type Sanitizer = (...locales: any[]) => Config.Locale[];
14
6
  export declare const sanitizeLocales: Sanitizer;
15
7
  export declare const sanitizerFactory: (sanitize?: Config.SanitizeLocales) => Sanitizer;
@@ -50,6 +42,8 @@ export declare const withoutBasePath: (route: string, basePath: string | undefin
50
42
  * absorbs a prefix. No regex runs on the pathname.
51
43
  */
52
44
  export declare const routePrefix: (pathname: string, routeId: string | null) => string | undefined;
45
+ /** Whether `config.preprocess` is the built-in dot notation. */
46
+ export declare const dotNotates: (preprocess: Config.T["preprocess"]) => boolean;
53
47
  export declare const toDotNotation: DotNotation.T;
54
48
  export declare const unique: <V>(values: readonly V[]) => V[];
55
49
  export declare const resolveLoaders: (input?: readonly Loader.LoaderModule[], sanitizeLocales?: Config.SanitizeLocales) => Loader.Resolved[];
@@ -63,11 +57,12 @@ export declare const serialize: (input: Array<Loader.Resolved & {
63
57
  /** The locales the loaders serve, then the ones the tables hold. */
64
58
  export declare const servedLocales: (loaders: readonly Loader.Resolved[], tables: Translations.SerializedTranslations) => Config.Locale[];
65
59
  /**
66
- * The locales a config serves, sanitized as an instance built from it
67
- * sanitizes them. Resolving the loaders logs what is wrong with them, so a
68
- * caller runs this once per config.
60
+ * The locales a config serves, from its loaders as `resolveLoaders` resolved
61
+ * them, sanitized as an instance built from it sanitizes them. Resolving the
62
+ * loaders logs what is wrong with them, so a caller resolves them once per
63
+ * config.
69
64
  */
70
- export declare const configLocales: ({ loaders, translations, sanitizeLocales: strategy }: Pick<Config.T, "loaders" | "translations" | "sanitizeLocales">) => Config.Locale[];
65
+ export declare const configLocales: ({ translations, sanitizeLocales: strategy }: Pick<Config.T, "translations" | "sanitizeLocales">, loaders: readonly Loader.Resolved[]) => Config.Locale[];
71
66
  /** A loader selected for a load, with the params its route yielded. */
72
67
  export type LoadRequest = {
73
68
  loader: Loader.Resolved;