@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.
@@ -0,0 +1,2 @@
1
+ export { defineI18n } from '../kit/define.svelte.js';
2
+ export type { Kit } from '../kit/types.js';
@@ -0,0 +1 @@
1
+ export { defineI18n } from '../kit/define.svelte.js';
@@ -1,2 +1,2 @@
1
- export { sanitizeLocales, toDotNotation } from '../utils.js';
1
+ export { matchLocale, resolveLoaders, sanitizeLocales, textDirection, toDotNotation } from '../utils.js';
2
2
  export type { DotNotation } from '../types.js';
@@ -1 +1 @@
1
- export { sanitizeLocales, toDotNotation } from '../utils.js';
1
+ export { matchLocale, resolveLoaders, sanitizeLocales, textDirection, toDotNotation } from '../utils.js';
package/dist/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  export { default, I18n } from './I18n.svelte.js';
2
- export type { Config, Extension, Loader, Logger, Parser, Schema, Translations } from './types.js';
2
+ export type { Config, Extension, Loader, Logger, Parser, Schema, Snapshot, Translations } from './types.js';
@@ -0,0 +1,9 @@
1
+ import { I18n } from '../I18n.svelte.js';
2
+ import type { Config } from '../types.js';
3
+ import type { Kit } from './types.js';
4
+ /**
5
+ * Wires SvelteKit to an instance of `config`: a `handle` hook, the root
6
+ * layout's `load`, and `use()` / `get()` for components. The server builds an
7
+ * instance per request; the browser keeps one per tab.
8
+ */
9
+ export declare const defineI18n: <const C extends Config.T<any, any> = Config.T<any, any>>(config: C, options?: Kit.Options) => Kit.T<InstanceType<typeof I18n<C>>>;
@@ -0,0 +1,178 @@
1
+ import { getContext, setContext, untrack } from 'svelte';
2
+ import { BROWSER } from '#kit-env';
3
+ import { serverHalf } from '#kit-server';
4
+ import { I18n } from '../I18n.svelte.js';
5
+ import { logError, loggerFactory, setLogger } from '../logger.js';
6
+ import { configLocales, matchLocale, sanitizerFactory, textDirection } from '../utils.js';
7
+ // Registry-wide, so two copies of this package meet: the context key, and the
8
+ // key of the pass the universal branch hands `use()` in `data`.
9
+ const KEY = Symbol.for('@sveltekit-i18n/base/kit');
10
+ const passOf = (data) => data?.[KEY];
11
+ const isServerEvent = (event) => 'cookies' in event;
12
+ /**
13
+ * Wires SvelteKit to an instance of `config`: a `handle` hook, the root
14
+ * layout's `load`, and `use()` / `get()` for components. The server builds an
15
+ * instance per request; the browser keeps one per tab.
16
+ */
17
+ export const defineI18n = (config, options = {}) => {
18
+ // The constructor would start an `initLocale` load of its own, next to the
19
+ // negotiated one; here `initLocale` is a negotiation candidate instead. The
20
+ // wiring drives the core itself, whatever the extensions make of it.
21
+ const create = () => new I18n({ ...config, initLocale: undefined, extensions: undefined });
22
+ const pipe = (i18n) => (config.extensions ?? []).reduce((acc, extension) => extension(acc), i18n);
23
+ const sanitize = sanitizerFactory(config.sanitizeLocales);
24
+ const sanitized = (locale) => (locale ? sanitize(locale)[0] : undefined);
25
+ let configured;
26
+ // `initLocale` and `fallbackLocale`, spelled as the config's locales are and
27
+ // so sanitized alike.
28
+ let defaults = [];
29
+ // Resolved on first use, never at import, and once: resolving the loaders
30
+ // and sanitizing reports what is wrong with them.
31
+ const locales = () => {
32
+ if (configured)
33
+ return configured;
34
+ if (config.log)
35
+ setLogger(loggerFactory(config.log));
36
+ try {
37
+ configured = configLocales(config);
38
+ }
39
+ catch {
40
+ // The instance reports a malformed config itself.
41
+ configured = [];
42
+ }
43
+ defaults = [sanitized(config.initLocale), sanitized(config.fallbackLocale)];
44
+ return configured;
45
+ };
46
+ let reported = false;
47
+ const preferred = (event) => {
48
+ try {
49
+ return options.preferredLocale?.(event);
50
+ }
51
+ catch (error) {
52
+ if (!reported)
53
+ logError('`preferredLocale` failed. Negotiating without it.', error);
54
+ reported = true;
55
+ return undefined;
56
+ }
57
+ };
58
+ // What `preferredLocale` returns is the visitor's, so it goes through a
59
+ // custom sanitizer silently — a value it rejects is matched as it is — and
60
+ // never through the default one, which warns on every tag `Intl` does not
61
+ // know: either would turn a visitor's cookie into a report per request.
62
+ const visitorSanitized = (locale) => {
63
+ const { sanitizeLocales: custom } = config;
64
+ if (!locale || typeof custom !== 'function')
65
+ return locale;
66
+ try {
67
+ const result = custom(`${locale}`);
68
+ return result ? `${result}` : locale;
69
+ }
70
+ catch {
71
+ return locale;
72
+ }
73
+ };
74
+ const negotiate = (event, ranges) => {
75
+ // First: it sets the config's logger, which `preferred` reports through.
76
+ const available = locales();
77
+ return [visitorSanitized(preferred(event)), ranges, ...defaults].reduce((found, candidate) => found ?? matchLocale(candidate, available), undefined);
78
+ };
79
+ const server = serverHalf({ create, negotiate, locales, basePath: config.basePath });
80
+ // Browser only: the tab's instance, the server's answer at the last commit,
81
+ // and the locale that commit is switching to with the one it switches from,
82
+ // which only `use()` writes. A server keeps nothing between requests.
83
+ const tab = { committed: false };
84
+ // The locale the tab shows once the wiring's own switch lands: that switch
85
+ // is no client change, while a locale that moved from where it started is.
86
+ const heading = (i18n) => {
87
+ const active = untrack(() => i18n.locale);
88
+ return tab.switching && tab.switching.from === active ? tab.switching.to : active;
89
+ };
90
+ const universalLoad = async (event) => {
91
+ const route = event.url.pathname;
92
+ const payload = event.data?.i18n;
93
+ const fresh = !BROWSER || !tab.i18n;
94
+ const i18n = fresh ? create() : tab.i18n;
95
+ const surface = fresh ? pipe(i18n) : tab.surface;
96
+ const seen = heading(i18n);
97
+ // A live server sends the tables on a page render only, so a later pass
98
+ // 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);
104
+ if (BROWSER)
105
+ Object.assign(tab, { i18n, surface });
106
+ if (fresh) {
107
+ if (payload?.translations)
108
+ i18n.hydrate({ ...payload, translations: payload.translations });
109
+ // No preload runs before the first navigation completes, so the pass
110
+ // that builds the instance may activate it.
111
+ await (answer ? i18n.loadTranslations(answer, route) : i18n.setRoute(route));
112
+ }
113
+ else {
114
+ // Warm only: this pass may be a preload, which must not change what is
115
+ // shown. The commit switches to a changed answer, or else stays.
116
+ const target = (answer !== tab.answer ? answer : undefined) ?? heading(i18n);
117
+ if (target)
118
+ await i18n.loadTranslations(target, route, { activate: false });
119
+ }
120
+ const pass = { i18n, surface, locale: answer, route, seen };
121
+ return { ...event.data, i18n: surface, [KEY]: pass };
122
+ };
123
+ const load = ((event) => (isServerEvent(event) ? server.load(event) : universalLoad(event)));
124
+ const use = (data) => {
125
+ const first = passOf(data());
126
+ if (!first)
127
+ throw new Error('[i18n]: `use()` found no data from `load`. Export `load` from the root `+layout.js`.');
128
+ const { i18n, surface } = first;
129
+ setContext(KEY, surface);
130
+ // `data` moves only when a navigation commits; a preload leaves it. The
131
+ // answer is compared with the last commit's, so a client `setLocale()`
132
+ // stands until the server answers differently, and an answer given before
133
+ // the active locale changed is stale.
134
+ $effect.pre(() => {
135
+ const pass = passOf(data());
136
+ if (!pass)
137
+ return;
138
+ if (!tab.committed || (pass.locale !== tab.answer && heading(pass.i18n) === pass.seen)) {
139
+ const { locale } = pass;
140
+ const previous = tab.answer;
141
+ tab.committed = true;
142
+ tab.answer = locale;
143
+ if (locale !== undefined) {
144
+ const switching = { from: untrack(() => pass.i18n.locale), to: locale };
145
+ const done = () => {
146
+ if (tab.switching === switching)
147
+ tab.switching = undefined;
148
+ };
149
+ tab.switching = switching;
150
+ // A switch that failed, and that no later call landed either, leaves
151
+ // the answer to the next commit.
152
+ pass.i18n.loadTranslations(locale, pass.route).then(done, () => {
153
+ done();
154
+ if (tab.answer === locale && untrack(() => pass.i18n.locale) !== locale)
155
+ tab.answer = previous;
156
+ });
157
+ return;
158
+ }
159
+ }
160
+ void pass.i18n.setRoute(pass.route);
161
+ });
162
+ $effect(() => {
163
+ const { locale } = i18n;
164
+ if (!locale)
165
+ return;
166
+ document.documentElement.lang = locale;
167
+ document.documentElement.dir = textDirection(locale);
168
+ });
169
+ return surface;
170
+ };
171
+ const get = () => {
172
+ const surface = getContext(KEY);
173
+ if (!surface)
174
+ throw new Error('[i18n]: `get()` found no instance. Call `use(() => data)` in the root `+layout.svelte`.');
175
+ return surface;
176
+ };
177
+ return { handle: server.handle, load, use, get };
178
+ };
@@ -0,0 +1 @@
1
+ export declare const BROWSER: boolean;
@@ -0,0 +1 @@
1
+ export const BROWSER = true;
@@ -0,0 +1 @@
1
+ export declare const BROWSER: boolean;
@@ -0,0 +1 @@
1
+ export const BROWSER = false;
@@ -0,0 +1,16 @@
1
+ import type { I18n } from '../I18n.svelte.js';
2
+ import type { Kit } from './types.js';
3
+ /** What the server half needs from the factory. */
4
+ export type Shared = {
5
+ create: () => I18n;
6
+ negotiate: (event: Kit.Event, ranges: string | readonly string[] | null | undefined) => string | undefined;
7
+ /** The locales the config serves. */
8
+ locales: () => string[];
9
+ basePath: string | undefined;
10
+ };
11
+ export type ServerHalf = {
12
+ handle: Kit.T['handle'];
13
+ load: (event: Kit.ServerLoadEvent) => Promise<{
14
+ i18n: Kit.Payload;
15
+ }>;
16
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ import type { serverHalf as ServerHalf } from './server.js';
2
+ export declare const serverHalf: typeof ServerHalf;
@@ -0,0 +1,6 @@
1
+ const serverOnly = () => {
2
+ throw new Error('[i18n]: `handle` and the server branch of `load` run on the server only.');
3
+ };
4
+ // What `#kit-server` resolves to under the `browser` condition: the browser
5
+ // bundle carries this instead of the server half.
6
+ export const serverHalf = () => ({ handle: serverOnly, load: serverOnly });
@@ -0,0 +1,2 @@
1
+ import type { ServerHalf, Shared } from './internal.js';
2
+ export declare const serverHalf: ({ create, negotiate, locales, basePath }: Shared) => ServerHalf;
@@ -0,0 +1,72 @@
1
+ import { logger } from '../logger.js';
2
+ import { matchLocale, routePrefix, textDirection, withoutBasePath } from '../utils.js';
3
+ export const serverHalf = ({ create, negotiate, locales, basePath }) => {
4
+ const answer = (event) => negotiate(event, event.request.headers.get('accept-language'));
5
+ let warned = false;
6
+ // A base path SvelteKit strips and `basePath` does not keeps every
7
+ // route-scoped loader from matching, silently. Here, on the server only, so
8
+ // the matcher stays out of the browser bundle.
9
+ const check = (event) => {
10
+ if (warned)
11
+ return;
12
+ const available = locales();
13
+ const found = routePrefix(event.url.pathname, event.route.id);
14
+ if (!found)
15
+ return;
16
+ const segments = found.split('/');
17
+ const last = segments[segments.length - 1].toLowerCase();
18
+ // A locale segment in front of the route is a `reroute` that strips it,
19
+ // spelled in the URL as a language (`en`) or a region (`en-gb`) of one.
20
+ const isLocale = matchLocale(last, available) !== undefined;
21
+ const prefix = isLocale ? segments.slice(0, -1).join('/') : found;
22
+ if (!prefix || withoutBasePath(prefix, basePath) === '/')
23
+ return;
24
+ warned = true;
25
+ logger.warn(`'${prefix}' precedes the route SvelteKit matched. If it is kit.paths.base, set basePath: '${prefix}'.`);
26
+ };
27
+ return {
28
+ handle: ({ event, resolve }) => {
29
+ let lang;
30
+ // Negotiated only for a chunk that asks for it, so a data request never
31
+ // negotiates here; a function replacement is inserted as it is.
32
+ const negotiated = () => {
33
+ if (lang === undefined) {
34
+ check(event);
35
+ lang = answer(event) ?? '';
36
+ }
37
+ return lang;
38
+ };
39
+ let filled = false;
40
+ // Only the `<html>` start tag is filled: the template writes it, while
41
+ // the head and the body carry the app's content, where a placeholder
42
+ // stays as it is written.
43
+ return Promise.resolve(resolve(event, {
44
+ transformPageChunk: ({ html }) => {
45
+ if (filled)
46
+ return html;
47
+ filled = true;
48
+ const start = html.search(/<html[\s>]/i);
49
+ const end = start === -1 ? -1 : html.indexOf('>', start) + 1;
50
+ if (end < 1)
51
+ return html;
52
+ const tag = html.slice(start, end)
53
+ .replaceAll('%lang%', negotiated)
54
+ .replaceAll('%dir%', () => textDirection(negotiated()));
55
+ return html.slice(0, start) + tag + html.slice(end);
56
+ },
57
+ }));
58
+ },
59
+ load: async (event) => {
60
+ // Read before anything returns: SvelteKit re-runs a load on a
61
+ // navigation only for what it read.
62
+ const route = event.url.pathname;
63
+ check(event);
64
+ const locale = answer(event);
65
+ if (event.isDataRequest)
66
+ return { i18n: { locale, route: withoutBasePath(route, basePath) } };
67
+ const i18n = create();
68
+ await (locale ? i18n.loadTranslations(locale, route) : i18n.setRoute(route));
69
+ return { i18n: i18n.snapshot({ records: true }) };
70
+ },
71
+ };
72
+ };
@@ -0,0 +1,83 @@
1
+ import type { I18n } from '../I18n.svelte.js';
2
+ import type { Snapshot } from '../types.js';
3
+ export declare namespace Kit {
4
+ /**
5
+ * The members of a SvelteKit load or request event the wiring reads.
6
+ * SvelteKit's own event types are assignable to it. The server-only members
7
+ * are optional, since an app without a server load hands the universal one,
8
+ * which has none.
9
+ */
10
+ type Event = {
11
+ url: URL;
12
+ params: Partial<Record<string, string>>;
13
+ route: {
14
+ id: string | null;
15
+ };
16
+ cookies?: {
17
+ get: (name: string) => string | undefined;
18
+ };
19
+ request?: Request;
20
+ locals?: Record<string, any>;
21
+ };
22
+ type RequestEvent = Event & Required<Pick<Event, 'cookies' | 'request'>>;
23
+ type ServerLoadEvent = RequestEvent & {
24
+ isDataRequest: boolean;
25
+ };
26
+ type UniversalLoadEvent = Event & {
27
+ data: Record<string, any> | null;
28
+ };
29
+ type Resolve = (event: any, options?: {
30
+ transformPageChunk?: (input: {
31
+ html: string;
32
+ done: boolean;
33
+ }) => string | undefined;
34
+ }) => Response | Promise<Response>;
35
+ /**
36
+ * What the server branch of `load` returns under `i18n`: the negotiated
37
+ * locale and the route always, the tables and their records on a page
38
+ * render only. Plain data, for `devalue`.
39
+ */
40
+ type Payload = Omit<Snapshot.Envelope, 'translations'> & Partial<Pick<Snapshot.Envelope, 'translations'>>;
41
+ type Options = {
42
+ /**
43
+ * The visitor's choice, read from the event: a cookie, a route param, a
44
+ * profile in `locals`. It is tried before `Accept-Language` (without a
45
+ * server load, before `navigator.languages`), and a value no configured
46
+ * locale matches is skipped; a custom `sanitizeLocales` is applied to it
47
+ * first. It runs on every navigation and every
48
+ * preload, so it must be pure: it reads the event and writes nothing.
49
+ *
50
+ * @example
51
+ * preferredLocale: (event) => event.cookies?.get('lang')
52
+ */
53
+ preferredLocale?: (event: Event) => string | null | undefined;
54
+ };
55
+ /** What `defineI18n()` returns. Each member is a plain function, so it can be exported on its own. */
56
+ type T<Instance = I18n> = {
57
+ /** A `handle` hook: fills `%lang%` in the `<html>` tag of `app.html` with the negotiated locale, and `%dir%` with its direction. */
58
+ handle: (input: {
59
+ event: RequestEvent;
60
+ resolve: Resolve;
61
+ }) => Promise<Response>;
62
+ /** The root layout's `load`, exported from `+layout.server.js` and `+layout.js` alike. */
63
+ load: {
64
+ (event: ServerLoadEvent): Promise<{
65
+ i18n: Payload;
66
+ }>;
67
+ <E extends UniversalLoadEvent>(event: E): Promise<Omit<NonNullable<E['data']>, 'i18n'> & {
68
+ i18n: Instance;
69
+ }>;
70
+ (event: UniversalLoadEvent): Promise<{
71
+ i18n: Instance;
72
+ }>;
73
+ };
74
+ /**
75
+ * Called once, in the root layout's script, with a getter of its `data`.
76
+ * Provides the instance to every component below, and follows each
77
+ * navigation as it commits. Returns the instance.
78
+ */
79
+ use: (data: () => object | null | undefined) => Instance;
80
+ /** The instance `use()` provided, in any component below the root layout. */
81
+ get: () => Instance;
82
+ };
83
+ }
@@ -0,0 +1 @@
1
+ export {};