@sveltekit-i18n/base 3.1.0-next.0 → 3.1.0-next.2

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 CHANGED
@@ -34,7 +34,9 @@ Core i18n functionality for SvelteKit with support for custom message parsers. T
34
34
 
35
35
  Svelte 5 or newer, and one of Node 22+, Bun 1.2+ or Deno 2+. The package is
36
36
  ESM-only and imports no `node:` module, so every runtime that runs your
37
- SvelteKit build runs it.
37
+ SvelteKit build runs it. The [`/kit`](#sveltekit) subpath needs SvelteKit 2;
38
+ the SvelteKit behaviour the docs describe is checked against 2.70 and the 3.0
39
+ prerelease.
38
40
 
39
41
  ## Installation
40
42
 
@@ -171,8 +173,10 @@ import i18n from '@sveltekit-i18n/base';
171
173
 
172
174
  const customParser = () => ({
173
175
  parse: (value, params) => {
174
- // Your custom interpolation logic
175
- return value.replace(/\{(\w+)\}/g, (_, key) => params[0]?.[key] ?? key);
176
+ // Your custom interpolation logic; `parse` must not throw on a non-string value
177
+ return typeof value === 'string'
178
+ ? value.replace(/\{(\w+)\}/g, (_, key) => params[0]?.[key] ?? key)
179
+ : value;
176
180
  },
177
181
  });
178
182
 
@@ -236,6 +240,19 @@ A loader that throws is logged, and the rest of the load lands without its data;
236
240
 
237
241
  Both `loaders` and a loader's `routes` accept readonly arrays, so a whole-config `as const` is fine.
238
242
 
243
+ ### `basePath`
244
+
245
+ The path the app is served under — SvelteKit's `kit.paths.base`. Every route handed in loses it on the way in, on a segment boundary only (under `/repo`, `/repo/about` is `/about`), so loader `routes` name the app's own paths. Set both from one environment variable:
246
+
247
+ ```javascript
248
+ // svelte.config.js: kit: { paths: { base: process.env.PUBLIC_BASE_PATH ?? '' } }
249
+ import { PUBLIC_BASE_PATH } from '$env/static/public';
250
+
251
+ basePath: PUBLIC_BASE_PATH
252
+ ```
253
+
254
+ See [`basePath`](./docs/README.md#basepath).
255
+
239
256
  ### `translations`
240
257
 
241
258
  Synchronous translations, available immediately. They seed the tables: the loaders of a namespace they name still run and merge into it. Hand a server's state over with [`hydrate()`](./docs/README.md#hydrateenvelope) instead:
@@ -256,6 +273,8 @@ Initialize with a specific locale immediately:
256
273
  initLocale: 'en'
257
274
  ```
258
275
 
276
+ With [`defineI18n()`](#sveltekit) it loads nothing: it is a negotiation candidate. Leave it out of a config whose instance you [`hydrate()`](./docs/README.md#hydrateenvelope) by hand — its load starts in the constructor, before the hand-off can be applied.
277
+
259
278
  ### `fallbackLocale`
260
279
 
261
280
  Fallback when translation is missing:
@@ -274,6 +293,10 @@ Default return value when translation key is not found:
274
293
  fallbackValue: '...' // Default: returns the key itself
275
294
  ```
276
295
 
296
+ ### `sanitizeLocales`
297
+
298
+ How locale identifiers are normalized before they key anything: `true` (default) resolves them to their ISO form through `Intl` (`'en-us'` is `'en-US'`), `false` keeps them as authored, and a function normalizes them your way. See [`sanitizeLocales`](./docs/README.md#sanitizelocales).
299
+
277
300
  ### `preprocess`
278
301
 
279
302
  Transform translations after loading:
@@ -289,18 +312,29 @@ preprocess: 'full' // 'full' | 'preserveArrays' | 'none' | custom function
289
312
 
290
313
  ### `schema`
291
314
 
292
- A map of translation key to the payload its message expects (`never` for a message that takes none). Supplying it types `t`/`l` — keys autocomplete, an unknown key is a type error, and the payload argument is checked. Only its type is read, so the value can stay empty at runtime:
315
+ A map of translation key to the payload its message expects (`never` for a message that takes none). It types `t`/`l` — keys autocomplete, an unknown key is a type error, and the payload argument is checked. Register it once for the app, in a global script, and every instance whose config states no `schema` is typed by it:
293
316
 
294
317
  ```typescript
295
- type TranslationSchema = {
318
+ // src/i18n-schema.d.ts — no top-level import or export
319
+ interface TranslationSchema {
296
320
  'common.greeting': { name: string };
297
321
  'common.farewell': never;
298
- };
322
+ }
299
323
 
324
+ declare namespace SvelteKitI18n {
325
+ interface Register {
326
+ schema: TranslationSchema;
327
+ }
328
+ }
329
+ ```
330
+
331
+ Or state it per instance — only its type is read, so the value can stay empty at runtime, and a stated schema wins over the registry:
332
+
333
+ ```typescript
300
334
  const i18n = new I18n({ ...config, schema: {} as TranslationSchema });
301
335
  ```
302
336
 
303
- Hand-write it for a small set of messages, or point the slot at a generated artifact. A schema whose keys are not a closed set is ignored, and keys stay plain strings. See [`schema`](./docs/README.md#schema) for the full rules.
337
+ Hand-write it for a small set of messages, or generate it — [@sveltekit-i18n/typegen](https://github.com/sveltekit-i18n/typegen), a separate package, generates one. A schema whose keys are not a closed set (`schema: {}`) types nothing and keeps the registry out, so keys stay plain strings. A library never registers. The registry needs base 3.1 or newer. See [`schema`](./docs/README.md#schema) for the full rules.
304
338
 
305
339
  ### `cache`
306
340
 
@@ -309,10 +343,10 @@ Time in milliseconds the loaded translations stay fresh for. By default, loaded
309
343
  Set a finite value when your loaders fetch from a source that can change at runtime (e.g. a CMS):
310
344
 
311
345
  ```javascript
312
- cache: 3600000 // Translations older than 1 hour refetch on the next load
346
+ cache: 3600000 // Translations older than 1 hour refetch on the next activating load
313
347
  ```
314
348
 
315
- Set to `0` to treat translations as always stale (refetch on every load trigger). You can also drop the loaded state manually at any time with [`invalidate()`](#methods).
349
+ Expiry is evaluated by the next activating load trigger (`setLocale`, `setRoute`, `loadTranslations`); a warm load — `loadTranslations(…, { activate: false })` or `loadNamespace()` — fills the tables without evaluating it. Set to `0` to treat translations as always stale (refetch on every activating load trigger). A loader with `cache: false` is outside the window. You can also drop the loaded state manually at any time with [`invalidate()`](#methods).
316
350
 
317
351
  ### `extensions`
318
352
 
@@ -423,11 +457,12 @@ Full API documentation: [docs/README.md](./docs/README.md)
423
457
 
424
458
  ```typescript
425
459
  import { I18n, type Config } from '@sveltekit-i18n/base';
426
- import parser from '@sveltekit-i18n/parser-curly';
460
+ import parser, { type Parser } from '@sveltekit-i18n/parser-curly';
427
461
 
428
462
  // The parser's params – the rest parameters of `t`/`l`. Annotate only when the
429
- // config lives on its own; `new I18n({ ... })` infers them.
430
- type Params = [payload?: Record<string, unknown>];
463
+ // config lives on its own; `new I18n({ ... })` infers them. Take the tuple from
464
+ // the parser rather than spelling it by hand.
465
+ type Params = Parser.Params;
431
466
 
432
467
  const config: Config.T<Params> = {
433
468
  parser: parser({ onReport: null }),
@@ -435,7 +470,7 @@ const config: Config.T<Params> = {
435
470
  };
436
471
  ```
437
472
 
438
- Two more things are inferred from the config itself. [`schema`](#schema) types the keys and payloads of `t`/`l`, and every locale the config names — loader locales, `initLocale`, `fallbackLocale` and the keys of `translations` — completes the locale arguments and reads (`setLocale`, `loadTranslations`, `invalidate`, `l`, `locale`, `locales`):
473
+ Two more things are inferred from the config itself. [`schema`](#schema) types the keys and payloads of `t`/`l`, and every locale the config names — loader locales, `initLocale`, `fallbackLocale` and the keys of `translations` — completes the locale arguments and reads (`setLocale`, `loadTranslations`, `loadNamespace`, `invalidate`, `l`, `locale`, `locales`):
439
474
 
440
475
  ```typescript
441
476
  const i18n = new I18n({ parser: parser({ onReport: null }), initLocale: 'en', fallbackLocale: 'de' });
@@ -454,6 +489,7 @@ The locales survive only when the config reaches the constructor as a literal
454
489
  - [@sveltekit-i18n/parser-mf2](https://github.com/sveltekit-i18n/parsers/tree/master/parser-mf2) – [Unicode MessageFormat 2](https://unicode.org/reports/tr35/tr35-messageFormat.html) parser
455
490
  - [@sveltekit-i18n/parser-i18next](https://github.com/sveltekit-i18n/parsers/tree/master/parser-i18next) – [i18next](https://www.i18next.com) interpolation and formatting syntax parser
456
491
  - [Extensions](https://github.com/sveltekit-i18n/extensions) – Official extensions for the `config.extensions` pipe
492
+ - [@sveltekit-i18n/typegen](https://github.com/sveltekit-i18n/typegen) – Generates the [`schema`](#schema) type from your translation files
457
493
 
458
494
  ## Contributing
459
495
 
@@ -74,7 +74,13 @@ export const defineI18n = (config, options = {}) => {
74
74
  const negotiate = (event, ranges) => {
75
75
  // First: it sets the config's logger, which `preferred` reports through.
76
76
  const available = locales();
77
- return [visitorSanitized(preferred(event)), ranges, ...defaults].reduce((found, candidate) => found ?? matchLocale(candidate, available), undefined);
77
+ const chosen = matchLocale(visitorSanitized(preferred(event)), available);
78
+ if (chosen !== undefined)
79
+ return { locale: chosen, preferred: true };
80
+ return {
81
+ locale: [ranges, ...defaults].reduce((found, candidate) => found ?? matchLocale(candidate, available), undefined),
82
+ preferred: false,
83
+ };
78
84
  };
79
85
  const server = serverHalf({ create, negotiate, locales, basePath: config.basePath });
80
86
  // Browser only: the tab's instance, the server's answer at the last commit,
@@ -96,11 +102,13 @@ export const defineI18n = (config, options = {}) => {
96
102
  const seen = heading(i18n);
97
103
  // A live server sends the tables on a page render only, so a later pass
98
104
  // that carries them read a prerendered file, whose locale was negotiated
99
- // at build time, without the visitor. Node and Deno define
100
- // `navigator.languages` too, from the server's own environment.
101
- const answer = !fresh && payload?.translations
102
- ? tab.answer
103
- : payload ? payload.locale : negotiate(event, BROWSER ? navigator.languages : undefined);
105
+ // at build time, without the visitor: it counts only when the build's
106
+ // `preferredLocale` gave it. Node and Deno define `navigator.languages`
107
+ // too, from the server's own environment.
108
+ const prerendered = !fresh && payload?.translations;
109
+ const answer = prerendered
110
+ ? (payload.preferred ? payload.locale : tab.answer)
111
+ : payload ? payload.locale : negotiate(event, BROWSER ? navigator.languages : undefined).locale;
104
112
  if (BROWSER)
105
113
  Object.assign(tab, { i18n, surface });
106
114
  if (fresh) {
@@ -1,9 +1,14 @@
1
1
  import type { I18n } from '../I18n.svelte.js';
2
2
  import type { Kit } from './types.js';
3
+ /** The negotiated locale, and whether `preferredLocale` gave it. */
4
+ export type Negotiated = {
5
+ locale: string | undefined;
6
+ preferred: boolean;
7
+ };
3
8
  /** What the server half needs from the factory. */
4
9
  export type Shared = {
5
10
  create: () => I18n;
6
- negotiate: (event: Kit.Event, ranges: string | readonly string[] | null | undefined) => string | undefined;
11
+ negotiate: (event: Kit.Event, ranges: string | readonly string[] | null | undefined) => Negotiated;
7
12
  /** The locales the config serves. */
8
13
  locales: () => string[];
9
14
  basePath: string | undefined;
@@ -32,7 +32,7 @@ export const serverHalf = ({ create, negotiate, locales, basePath }) => {
32
32
  const negotiated = () => {
33
33
  if (lang === undefined) {
34
34
  check(event);
35
- lang = answer(event) ?? '';
35
+ lang = answer(event).locale ?? '';
36
36
  }
37
37
  return lang;
38
38
  };
@@ -61,12 +61,12 @@ export const serverHalf = ({ create, negotiate, locales, basePath }) => {
61
61
  // navigation only for what it read.
62
62
  const route = event.url.pathname;
63
63
  check(event);
64
- const locale = answer(event);
64
+ const { locale, preferred } = answer(event);
65
65
  if (event.isDataRequest)
66
66
  return { i18n: { locale, route: withoutBasePath(route, basePath) } };
67
67
  const i18n = create();
68
68
  await (locale ? i18n.loadTranslations(locale, route) : i18n.setRoute(route));
69
- return { i18n: i18n.snapshot({ records: true }) };
69
+ return { i18n: { ...i18n.snapshot({ records: true }), ...(preferred ? { preferred: true } : {}) } };
70
70
  },
71
71
  };
72
72
  };
@@ -37,7 +37,14 @@ export declare namespace Kit {
37
37
  * locale and the route always, the tables and their records on a page
38
38
  * render only. Plain data, for `devalue`.
39
39
  */
40
- type Payload = Omit<Snapshot.Envelope, 'translations'> & Partial<Pick<Snapshot.Envelope, 'translations'>>;
40
+ type Payload = Omit<Snapshot.Envelope, 'translations'> & Partial<Pick<Snapshot.Envelope, 'translations'>> & {
41
+ /**
42
+ * Set on a page render when `preferredLocale` gave the locale. A
43
+ * prerendered page's data is that render, so a navigation to one takes
44
+ * its locale only when the build's `preferredLocale` gave it.
45
+ */
46
+ preferred?: true;
47
+ };
41
48
  type Options = {
42
49
  /**
43
50
  * The visitor's choice, read from the event: a cookie, a route param, a
@@ -46,6 +53,9 @@ export declare namespace Kit {
46
53
  * locale matches is skipped; a custom `sanitizeLocales` is applied to it
47
54
  * first. It runs on every navigation and every
48
55
  * preload, so it must be pure: it reads the event and writes nothing.
56
+ * With a server load it runs on the server only: a navigation to a
57
+ * prerendered page takes the locale it gave at build time, and otherwise
58
+ * keeps the tab's.
49
59
  *
50
60
  * @example
51
61
  * preferredLocale: (event) => event.cookies?.get('lang')
package/dist/types.d.ts CHANGED
@@ -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.
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.
87
87
  */
88
88
  initLocale?: InitLocale;
89
89
  /**
@@ -129,20 +129,21 @@ export declare namespace Config {
129
129
  parser: Parser.T<P, O>;
130
130
  /**
131
131
  * A key schema — a map of translation key to the payload its message
132
- * expects (`never` for a message without parameters). Supplying it types
133
- * `t`/`l`: keys autocomplete and a wrong payload is a type error. Only its
134
- * TYPE is read, so a generated artifact may export a value that is empty
135
- * at runtime — as long as that value is TYPED, e.g.
136
- * `export const schema = {} as TranslationSchema`. A schema whose keys are not a
137
- * closed set (an open index signature, or no keys at all) is ignored and
138
- * keys stay plain strings. Read at construction time only: a later
139
- * `loadConfig()` cannot retype the instance, and `config.extensions`
140
- * erases the instance's type parameters entirely.
132
+ * expects (`never` for a message without parameters). It types `t`/`l`:
133
+ * keys autocomplete and a wrong payload is a type error. Left out, the
134
+ * schema the app registers in `SvelteKitI18n.Register` types the
135
+ * instance; stated, it wins over the registry. Only its TYPE is read, so
136
+ * the value may be empty at runtime — as long as it is TYPED, e.g.
137
+ * `{} as TranslationSchema`. A schema whose keys are not a closed set (an
138
+ * open index signature, or no keys at all, as in `schema: {}`) types
139
+ * nothing and keeps the registry out: keys stay plain strings. Read at
140
+ * construction time only: a later `loadConfig()` cannot retype the
141
+ * instance, and an extension typed by a fixed return type erases the
142
+ * instance's type parameters, while one typed by an `Extension.Operator`
143
+ * (`Extension.Generic`) keeps them.
141
144
  *
142
145
  * @example
143
- * import { schema } from './generated/i18n-schema.js';
144
- *
145
- * const i18n = new I18n({ ...config, schema });
146
+ * const i18n = new I18n({ ...config, schema: {} as TranslationSchema });
146
147
  */
147
148
  schema?: S;
148
149
  /**
@@ -162,11 +163,11 @@ export declare namespace Config {
162
163
  */
163
164
  basePath?: string;
164
165
  /**
165
- * Time in milliseconds the loaded translations stay fresh for. Once a locale's translations are older, the next load trigger runs its loaders again. 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.
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
167
  *
167
168
  * @default Number.POSITIVE_INFINITY
168
169
  *
169
- * @tip Set to `0` to treat translations as always stale (refetch on every load trigger).
170
+ * @tip Set to `0` to treat translations as always stale (refetch on every activating load trigger).
170
171
  */
171
172
  cache?: number;
172
173
  /**
@@ -442,7 +443,7 @@ export declare namespace Parser {
442
443
  * with anything yields the other kind. `'date'` covers both date and time
443
444
  * formatting and means `Date | number`. `'function'` is a rich-text callback,
444
445
  * the shape ICU tags require. `'boolean'` is here for parsers that can prove
445
- * it – neither official parser can, since both compare stringified values.
446
+ * it – no official parser can, since each compares stringified values.
446
447
  */
447
448
  type ParamKind = 'unknown' | 'string' | 'number' | 'boolean' | 'date' | 'function';
448
449
  /**
@@ -462,7 +463,7 @@ export declare namespace Parser {
462
463
  kind?: ParamKind | readonly ParamKind[];
463
464
  /**
464
465
  * Values the message names explicitly – a hint for authoring tools, never
465
- * an exhaustive set. Both official parsers fall back to a default branch
466
+ * an exhaustive set. Every official parser falls back to a default branch
466
467
  * for anything unlisted, so this must not be used to close a union. Omit it
467
468
  * where the listed values are not values at all (numeric thresholds, plural
468
469
  * categories) or mean the opposite (an inequality's operands).
@@ -485,7 +486,7 @@ export declare namespace Parser {
485
486
  branch: string;
486
487
  }[];
487
488
  };
488
- /** Diagnostic context for an extractor. Neither official parser needs it to extract. */
489
+ /** Diagnostic context for an extractor. No official parser needs it to extract. */
489
490
  type ExtractContext = {
490
491
  key?: Key;
491
492
  locale?: Locale;
@@ -509,6 +510,19 @@ export declare namespace Parser {
509
510
  */
510
511
  type ExtractParamsFactory<O = unknown> = (options?: O) => ExtractParams;
511
512
  }
513
+ declare global {
514
+ namespace SvelteKitI18n {
515
+ /**
516
+ * The app's type registry, filled by a generated global script
517
+ * (`interface Register { schema: TranslationSchema }`). Its `schema` types
518
+ * every instance whose config states none. Every copy of the core declares
519
+ * it empty, and a library never registers: the registry covers the whole
520
+ * program.
521
+ */
522
+ interface Register {
523
+ }
524
+ }
525
+ }
512
526
  export declare namespace Schema {
513
527
  /**
514
528
  * A schema types calls only when its keys form a specific, closed set. An
@@ -517,10 +531,25 @@ export declare namespace Schema {
517
531
  * they degrade to no schema at all.
518
532
  */
519
533
  type HasClosedKeys<S> = [keyof S & string] extends [never] ? false : string extends keyof S ? false : true;
520
- /** The key schema carried by a config; `never` when there is none to use. */
521
- export type FromConfig<C> = C extends {
522
- schema?: infer S extends object;
523
- } ? (HasClosedKeys<S> extends true ? S : never) : never;
534
+ /** `S` when its keys are a closed set, `never` otherwise. A union schema is taken whole. */
535
+ type Closed<S> = [S] extends [object] ? (HasClosedKeys<S> extends true ? S : never) : never;
536
+ /** What a config's `schema` slot holds; `unknown` when it is absent or typed `any`. */
537
+ type Slot<C> = C extends {
538
+ schema?: infer S;
539
+ } ? S : unknown;
540
+ /** The key schema `SvelteKitI18n.Register` carries; `never` when it carries none to use. */
541
+ export type Registered = SvelteKitI18n.Register extends {
542
+ schema: infer S;
543
+ } ? Closed<S> : never;
544
+ /**
545
+ * The key schema a config types its instance with; `never` when there is
546
+ * none to use. A slot the config states decides — a closed schema is that
547
+ * schema, one without a closed key set (`{}`) opts out to plain string keys
548
+ * — while an absent slot, or one typed `any` (a plain `Config.T`
549
+ * annotation), reads the registry. A union of configs yields the union of
550
+ * their schemas.
551
+ */
552
+ export type FromConfig<C> = C extends unknown ? (unknown extends Slot<C> ? Registered : Closed<Slot<C>>) : never;
524
553
  /**
525
554
  * The key schema an instance was built with; `never` when it carries none.
526
555
  * The counterpart of `FromConfig` for code handed a constructed surface
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sveltekit-i18n/base",
3
- "version": "3.1.0-next.0",
3
+ "version": "3.1.0-next.2",
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",