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

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:
@@ -300,7 +323,7 @@ type TranslationSchema = {
300
323
  const i18n = new I18n({ ...config, schema: {} as TranslationSchema });
301
324
  ```
302
325
 
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.
326
+ Hand-write it for a small set of messages, or point the slot at a generated artifact — [@sveltekit-i18n/typegen](https://github.com/sveltekit-i18n/typegen), a separate package, generates one. 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.
304
327
 
305
328
  ### `cache`
306
329
 
@@ -309,10 +332,10 @@ Time in milliseconds the loaded translations stay fresh for. By default, loaded
309
332
  Set a finite value when your loaders fetch from a source that can change at runtime (e.g. a CMS):
310
333
 
311
334
  ```javascript
312
- cache: 3600000 // Translations older than 1 hour refetch on the next load
335
+ cache: 3600000 // Translations older than 1 hour refetch on the next activating load
313
336
  ```
314
337
 
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).
338
+ 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
339
 
317
340
  ### `extensions`
318
341
 
@@ -423,11 +446,12 @@ Full API documentation: [docs/README.md](./docs/README.md)
423
446
 
424
447
  ```typescript
425
448
  import { I18n, type Config } from '@sveltekit-i18n/base';
426
- import parser from '@sveltekit-i18n/parser-curly';
449
+ import parser, { type Parser } from '@sveltekit-i18n/parser-curly';
427
450
 
428
451
  // 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>];
452
+ // config lives on its own; `new I18n({ ... })` infers them. Take the tuple from
453
+ // the parser rather than spelling it by hand.
454
+ type Params = Parser.Params;
431
455
 
432
456
  const config: Config.T<Params> = {
433
457
  parser: parser({ onReport: null }),
@@ -435,7 +459,7 @@ const config: Config.T<Params> = {
435
459
  };
436
460
  ```
437
461
 
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`):
462
+ 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
463
 
440
464
  ```typescript
441
465
  const i18n = new I18n({ parser: parser({ onReport: null }), initLocale: 'en', fallbackLocale: 'de' });
@@ -454,6 +478,7 @@ The locales survive only when the config reaches the constructor as a literal
454
478
  - [@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
479
  - [@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
480
  - [Extensions](https://github.com/sveltekit-i18n/extensions) – Official extensions for the `config.extensions` pipe
481
+ - [@sveltekit-i18n/typegen](https://github.com/sveltekit-i18n/typegen) – Generates the [`schema`](#schema) type from your translation files
457
482
 
458
483
  ## Contributing
459
484
 
@@ -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
  /**
@@ -136,8 +136,9 @@ export declare namespace Config {
136
136
  * `export const schema = {} as TranslationSchema`. A schema whose keys are not a
137
137
  * closed set (an open index signature, or no keys at all) is ignored and
138
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.
139
+ * `loadConfig()` cannot retype the instance, and an extension typed by a
140
+ * fixed return type erases the instance's type parameters, while one
141
+ * typed by an `Extension.Operator` (`Extension.Generic`) keeps them.
141
142
  *
142
143
  * @example
143
144
  * import { schema } from './generated/i18n-schema.js';
@@ -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;
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.1",
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",