@urbicon-ui/i18n 6.1.4
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 +322 -0
- package/dist/components/I18nProvider.svelte +62 -0
- package/dist/components/I18nProvider.svelte.d.ts +28 -0
- package/dist/components/T.svelte +31 -0
- package/dist/components/T.svelte.d.ts +11 -0
- package/dist/components/index.d.ts +2 -0
- package/dist/components/index.js +2 -0
- package/dist/i18n/__fixtures__/SetLocaleChild.svelte +20 -0
- package/dist/i18n/__fixtures__/SetLocaleChild.svelte.d.ts +18 -0
- package/dist/i18n/__fixtures__/SetLocaleHarness.svelte +14 -0
- package/dist/i18n/__fixtures__/SetLocaleHarness.svelte.d.ts +6 -0
- package/dist/i18n/__fixtures__/SsrChild.svelte +7 -0
- package/dist/i18n/__fixtures__/SsrChild.svelte.d.ts +18 -0
- package/dist/i18n/__fixtures__/SsrHarness.svelte +15 -0
- package/dist/i18n/__fixtures__/SsrHarness.svelte.d.ts +7 -0
- package/dist/i18n/context.svelte.d.ts +107 -0
- package/dist/i18n/context.svelte.js +167 -0
- package/dist/i18n/package-integration.d.ts +118 -0
- package/dist/i18n/package-integration.js +211 -0
- package/dist/i18n/registry.svelte.d.ts +93 -0
- package/dist/i18n/registry.svelte.js +460 -0
- package/dist/i18n/resolve-locale.d.ts +41 -0
- package/dist/i18n/resolve-locale.js +81 -0
- package/dist/i18n/types.d.ts +127 -0
- package/dist/i18n/types.js +18 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +13 -0
- package/dist/utils/deep-keys.d.ts +27 -0
- package/dist/utils/deep-keys.js +54 -0
- package/package.json +74 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { I18nError, Locale, PluralParams, TranslationOptions, TranslationParams } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* The constant base locale used for read-tolerant resolution when no
|
|
4
|
+
* `<I18nProvider>` is mounted. A constant — never global mutable state — so a
|
|
5
|
+
* provider-less render is SSR-safe and identical on server and client (no
|
|
6
|
+
* hydration mismatch), and reads never leak across requests. Consumers who need
|
|
7
|
+
* a different or switchable locale mount a provider.
|
|
8
|
+
*/
|
|
9
|
+
export declare const BASE_LOCALE: Locale;
|
|
10
|
+
/**
|
|
11
|
+
* Request-scoped reactive locale state. The *only* mutable per-request i18n
|
|
12
|
+
* value — created by `<I18nProvider>` and read through the context. Encapsulated:
|
|
13
|
+
* `locale` is exposed read-only; switching goes through `setLocale`.
|
|
14
|
+
*/
|
|
15
|
+
export declare class I18nState {
|
|
16
|
+
#private;
|
|
17
|
+
/** Fallback locale used when a key is missing in the active locale. */
|
|
18
|
+
readonly fallbackLocale: Locale;
|
|
19
|
+
constructor(locale?: Locale, fallbackLocale?: Locale);
|
|
20
|
+
get locale(): Locale;
|
|
21
|
+
/**
|
|
22
|
+
* Switch the active locale in place (reactive — no reload). Returns `false`
|
|
23
|
+
* (and reports `unsupported-locale`) for an unsupported locale, without
|
|
24
|
+
* switching. For a supported locale the switch is applied immediately and
|
|
25
|
+
* `true` is returned ("switch initiated"): if a lazy loader is registered and
|
|
26
|
+
* the data isn't present yet, the load is triggered (not awaited) so the
|
|
27
|
+
* `$derived` reads re-resolve once the chunk lands. Use `registry.loadLocale`
|
|
28
|
+
* directly when you need to await.
|
|
29
|
+
*
|
|
30
|
+
* The async failure is not silent: if that load rejects AND no data exists for
|
|
31
|
+
* the locale (so reads can't even fall back to it), `load-failed-no-fallback`
|
|
32
|
+
* is reported to the error sink — the loud signal that "the language you
|
|
33
|
+
* switched to can't be rendered". (No-op today, where all bundles are eager.)
|
|
34
|
+
*/
|
|
35
|
+
setLocale(locale: Locale): boolean;
|
|
36
|
+
}
|
|
37
|
+
/** Set the request-scoped locale state. Called by `<I18nProvider>`. */
|
|
38
|
+
export declare function provideI18nState(state: I18nState): I18nState;
|
|
39
|
+
/**
|
|
40
|
+
* Read the request-scoped locale state, or `undefined` when no `<I18nProvider>`
|
|
41
|
+
* is mounted above (read-tolerant). Must be called during component init.
|
|
42
|
+
*/
|
|
43
|
+
export declare function useI18nState(): I18nState | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* Provide the request-scoped locale state from a component's own init, and return
|
|
46
|
+
* it. This is the primitive behind `<I18nProvider>`; reach for it directly when
|
|
47
|
+
* the **same** component both provides i18n and renders translated content —
|
|
48
|
+
* e.g. a root `+layout.svelte` whose own chrome uses `t`. (A child
|
|
49
|
+
* `<I18nProvider>` cannot serve the parent that mounts it, because context only
|
|
50
|
+
* flows downward; calling `provideI18n` in the parent's script puts the state on
|
|
51
|
+
* that component's own context map, which its own `useI18n()`/`use<Pkg>I18n()`
|
|
52
|
+
* then reads.)
|
|
53
|
+
*
|
|
54
|
+
* Pass `locale` as a reactive getter (`() => data.locale`) to keep it controlled:
|
|
55
|
+
* a prop/load change is synced into the state, while an in-place `setLocale`
|
|
56
|
+
* switch (which doesn't change the getter's value) is never clobbered.
|
|
57
|
+
*
|
|
58
|
+
* Must be called during component initialisation.
|
|
59
|
+
*/
|
|
60
|
+
export declare function provideI18n(locale: Locale | (() => Locale), fallbackLocale?: Locale): I18nState;
|
|
61
|
+
/**
|
|
62
|
+
* General i18n hook for locale control and locale-aware formatting/resolution.
|
|
63
|
+
*
|
|
64
|
+
* Read paths (`locale`, `t`, formatters) are tolerant: without a provider they
|
|
65
|
+
* resolve against {@link BASE_LOCALE}. The write path (`setLocale`) is strict:
|
|
66
|
+
* without a provider it throws, because there is no request-scoped state to
|
|
67
|
+
* mutate — "you asked to switch the language but never mounted an
|
|
68
|
+
* `<I18nProvider>`". Call during component initialisation.
|
|
69
|
+
*/
|
|
70
|
+
export interface I18nApi {
|
|
71
|
+
/** Active locale (reactive). Falls back to {@link BASE_LOCALE} without a provider. */
|
|
72
|
+
readonly locale: Locale;
|
|
73
|
+
/** Locales with registered data or a loader (reactive). */
|
|
74
|
+
readonly availableLocales: Locale[];
|
|
75
|
+
/** Whether a lazy locale load is in flight (reactive). */
|
|
76
|
+
readonly isLoading: boolean;
|
|
77
|
+
/** Switch the active locale. Throws without a provider. */
|
|
78
|
+
setLocale(locale: Locale): boolean;
|
|
79
|
+
t(key: string, params?: TranslationParams, options?: TranslationOptions | string): string;
|
|
80
|
+
plural(key: string, params: PluralParams, options?: TranslationOptions): string;
|
|
81
|
+
exists(key: string, packageName?: string): boolean;
|
|
82
|
+
formatNumber(value: number, options?: Intl.NumberFormatOptions): string;
|
|
83
|
+
formatDate(date: Date, options?: Intl.DateTimeFormatOptions): string;
|
|
84
|
+
formatRelativeTime(value: number, unit: Intl.RelativeTimeFormatUnit): string;
|
|
85
|
+
formatTimeAgo(date: Date): string;
|
|
86
|
+
}
|
|
87
|
+
export declare function useI18n(): I18nApi;
|
|
88
|
+
export interface I18nConfigureOptions {
|
|
89
|
+
/**
|
|
90
|
+
* Routes i18n errors — a lazy locale load that rejects (`load-failed`,
|
|
91
|
+
* `load-failed-no-fallback`), or a `setLocale` with an unsupported code
|
|
92
|
+
* (`unsupported-locale`) — to your handler instead of the default
|
|
93
|
+
* `console.warn`. The hook for telemetry (Sentry, structured logging).
|
|
94
|
+
*/
|
|
95
|
+
onError?: (error: I18nError) => void;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* App-global i18n configuration. Call **once at startup** (module scope or root
|
|
99
|
+
* setup), not per-request: the handler lives on the process-wide registry, so a
|
|
100
|
+
* per-request assignment under concurrent SSR would race. Without it, errors fall
|
|
101
|
+
* back to `console.warn`.
|
|
102
|
+
*
|
|
103
|
+
* ```ts
|
|
104
|
+
* configureI18n({ onError: (e) => reportToSentry(e) });
|
|
105
|
+
* ```
|
|
106
|
+
*/
|
|
107
|
+
export declare function configureI18n(options: I18nConfigureOptions): void;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { getContext, hasContext, setContext, untrack } from 'svelte';
|
|
2
|
+
import { getRegistry } from './registry.svelte';
|
|
3
|
+
import { isLocaleSupported } from './types';
|
|
4
|
+
/**
|
|
5
|
+
* The constant base locale used for read-tolerant resolution when no
|
|
6
|
+
* `<I18nProvider>` is mounted. A constant — never global mutable state — so a
|
|
7
|
+
* provider-less render is SSR-safe and identical on server and client (no
|
|
8
|
+
* hydration mismatch), and reads never leak across requests. Consumers who need
|
|
9
|
+
* a different or switchable locale mount a provider.
|
|
10
|
+
*/
|
|
11
|
+
export const BASE_LOCALE = 'en';
|
|
12
|
+
/**
|
|
13
|
+
* Request-scoped reactive locale state. The *only* mutable per-request i18n
|
|
14
|
+
* value — created by `<I18nProvider>` and read through the context. Encapsulated:
|
|
15
|
+
* `locale` is exposed read-only; switching goes through `setLocale`.
|
|
16
|
+
*/
|
|
17
|
+
export class I18nState {
|
|
18
|
+
// Private $state field: external code can read `.locale` but must go through
|
|
19
|
+
// `setLocale` to change it (validation + lazy-load trigger live there).
|
|
20
|
+
#locale = $state(BASE_LOCALE);
|
|
21
|
+
/** Fallback locale used when a key is missing in the active locale. */
|
|
22
|
+
fallbackLocale;
|
|
23
|
+
constructor(locale = BASE_LOCALE, fallbackLocale = BASE_LOCALE) {
|
|
24
|
+
this.#locale = locale;
|
|
25
|
+
this.fallbackLocale = fallbackLocale;
|
|
26
|
+
}
|
|
27
|
+
get locale() {
|
|
28
|
+
return this.#locale;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Switch the active locale in place (reactive — no reload). Returns `false`
|
|
32
|
+
* (and reports `unsupported-locale`) for an unsupported locale, without
|
|
33
|
+
* switching. For a supported locale the switch is applied immediately and
|
|
34
|
+
* `true` is returned ("switch initiated"): if a lazy loader is registered and
|
|
35
|
+
* the data isn't present yet, the load is triggered (not awaited) so the
|
|
36
|
+
* `$derived` reads re-resolve once the chunk lands. Use `registry.loadLocale`
|
|
37
|
+
* directly when you need to await.
|
|
38
|
+
*
|
|
39
|
+
* The async failure is not silent: if that load rejects AND no data exists for
|
|
40
|
+
* the locale (so reads can't even fall back to it), `load-failed-no-fallback`
|
|
41
|
+
* is reported to the error sink — the loud signal that "the language you
|
|
42
|
+
* switched to can't be rendered". (No-op today, where all bundles are eager.)
|
|
43
|
+
*/
|
|
44
|
+
setLocale(locale) {
|
|
45
|
+
const registry = getRegistry();
|
|
46
|
+
if (!isLocaleSupported(locale)) {
|
|
47
|
+
registry.reportLoadError({ type: 'unsupported-locale', locale });
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
if (registry.hasLoader(locale) && !registry.isLoaded(locale)) {
|
|
51
|
+
// loadLocale never rejects (it reports `load-failed` internally); inspect
|
|
52
|
+
// its boolean result to surface the harder "switched but unrenderable" case.
|
|
53
|
+
registry.loadLocale(locale).then((ok) => {
|
|
54
|
+
if (!ok && !registry.hasTranslations(locale)) {
|
|
55
|
+
registry.reportLoadError({ type: 'load-failed-no-fallback', locale });
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
this.#locale = locale;
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Symbol key + explicit typed accessors rather than svelte's `createContext`:
|
|
64
|
+
// its generated getter calls `e.missing_context()` (throws) when no provider is
|
|
65
|
+
// mounted, which is incompatible with the read-tolerant contract below (a
|
|
66
|
+
// provider-less <Button> must still render its ARIA strings in baseLocale). A
|
|
67
|
+
// Symbol is collision-free and type-safe — the convention's intent ("no string
|
|
68
|
+
// keys") holds; only the throw-on-missing semantics are traded for tolerance.
|
|
69
|
+
const I18N_CONTEXT_KEY = Symbol('urbicon-ui-i18n');
|
|
70
|
+
/** Set the request-scoped locale state. Called by `<I18nProvider>`. */
|
|
71
|
+
export function provideI18nState(state) {
|
|
72
|
+
return setContext(I18N_CONTEXT_KEY, state);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Read the request-scoped locale state, or `undefined` when no `<I18nProvider>`
|
|
76
|
+
* is mounted above (read-tolerant). Must be called during component init.
|
|
77
|
+
*/
|
|
78
|
+
export function useI18nState() {
|
|
79
|
+
return hasContext(I18N_CONTEXT_KEY) ? getContext(I18N_CONTEXT_KEY) : undefined;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Provide the request-scoped locale state from a component's own init, and return
|
|
83
|
+
* it. This is the primitive behind `<I18nProvider>`; reach for it directly when
|
|
84
|
+
* the **same** component both provides i18n and renders translated content —
|
|
85
|
+
* e.g. a root `+layout.svelte` whose own chrome uses `t`. (A child
|
|
86
|
+
* `<I18nProvider>` cannot serve the parent that mounts it, because context only
|
|
87
|
+
* flows downward; calling `provideI18n` in the parent's script puts the state on
|
|
88
|
+
* that component's own context map, which its own `useI18n()`/`use<Pkg>I18n()`
|
|
89
|
+
* then reads.)
|
|
90
|
+
*
|
|
91
|
+
* Pass `locale` as a reactive getter (`() => data.locale`) to keep it controlled:
|
|
92
|
+
* a prop/load change is synced into the state, while an in-place `setLocale`
|
|
93
|
+
* switch (which doesn't change the getter's value) is never clobbered.
|
|
94
|
+
*
|
|
95
|
+
* Must be called during component initialisation.
|
|
96
|
+
*/
|
|
97
|
+
export function provideI18n(locale, fallbackLocale = BASE_LOCALE) {
|
|
98
|
+
const getLocale = typeof locale === 'function' ? locale : () => locale;
|
|
99
|
+
const state = new I18nState(untrack(getLocale), fallbackLocale);
|
|
100
|
+
provideI18nState(state);
|
|
101
|
+
if (typeof locale === 'function') {
|
|
102
|
+
// Controlled sync: depend on the getter only (untrack the state read) so a
|
|
103
|
+
// prop change flows in but setLocale() is not reverted.
|
|
104
|
+
$effect(() => {
|
|
105
|
+
const next = getLocale();
|
|
106
|
+
if (next !== untrack(() => state.locale)) {
|
|
107
|
+
state.setLocale(next);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
// Ensure the initial active + fallback locales' lazy bundles are loaded (WP4) —
|
|
112
|
+
// a no-op when everything is eager. Client-only (effects don't run during SSR),
|
|
113
|
+
// so a lazy non-base initial locale renders the fallback on the server and the
|
|
114
|
+
// first client paint, then re-resolves once the chunk lands. Subsequent switches
|
|
115
|
+
// load via setLocale. Read untracked → runs once on mount, not on every switch.
|
|
116
|
+
$effect(() => {
|
|
117
|
+
const registry = getRegistry();
|
|
118
|
+
void registry.loadLocale(untrack(() => state.locale));
|
|
119
|
+
void registry.loadLocale(state.fallbackLocale);
|
|
120
|
+
});
|
|
121
|
+
return state;
|
|
122
|
+
}
|
|
123
|
+
export function useI18n() {
|
|
124
|
+
const state = useI18nState();
|
|
125
|
+
const registry = getRegistry();
|
|
126
|
+
const localeOf = () => state?.locale ?? BASE_LOCALE;
|
|
127
|
+
const fallbackOf = () => state?.fallbackLocale ?? BASE_LOCALE;
|
|
128
|
+
return {
|
|
129
|
+
get locale() {
|
|
130
|
+
return localeOf();
|
|
131
|
+
},
|
|
132
|
+
get availableLocales() {
|
|
133
|
+
return registry.getAvailableLocales();
|
|
134
|
+
},
|
|
135
|
+
get isLoading() {
|
|
136
|
+
return registry.isLoading;
|
|
137
|
+
},
|
|
138
|
+
setLocale(locale) {
|
|
139
|
+
if (!state) {
|
|
140
|
+
throw new Error('[i18n] setLocale() requires an <I18nProvider>. Without a provider the locale ' +
|
|
141
|
+
'is the constant base locale and cannot change. Wrap your app root in ' +
|
|
142
|
+
'<I18nProvider locale={…}> to enable locale switching.');
|
|
143
|
+
}
|
|
144
|
+
return state.setLocale(locale);
|
|
145
|
+
},
|
|
146
|
+
t: (key, params, options) => registry.translate(key, localeOf(), fallbackOf(), params, options),
|
|
147
|
+
plural: (key, params, options) => registry.pluralize(key, params, localeOf(), fallbackOf(), options),
|
|
148
|
+
exists: (key, packageName) => registry.exists(key, localeOf(), packageName),
|
|
149
|
+
formatNumber: (value, options) => registry.formatNumber(value, localeOf(), options),
|
|
150
|
+
formatDate: (date, options) => registry.formatDate(date, localeOf(), options),
|
|
151
|
+
formatRelativeTime: (value, unit) => registry.formatRelativeTime(value, unit, localeOf()),
|
|
152
|
+
formatTimeAgo: (date) => registry.formatTimeAgo(date, localeOf(), fallbackOf())
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* App-global i18n configuration. Call **once at startup** (module scope or root
|
|
157
|
+
* setup), not per-request: the handler lives on the process-wide registry, so a
|
|
158
|
+
* per-request assignment under concurrent SSR would race. Without it, errors fall
|
|
159
|
+
* back to `console.warn`.
|
|
160
|
+
*
|
|
161
|
+
* ```ts
|
|
162
|
+
* configureI18n({ onError: (e) => reportToSentry(e) });
|
|
163
|
+
* ```
|
|
164
|
+
*/
|
|
165
|
+
export function configureI18n(options) {
|
|
166
|
+
getRegistry().onError = options.onError;
|
|
167
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { CreatePackageTypes, Locale, PackageI18n, PackageTranslations, TranslationParams, TranslationSchema, Translations, TypedTranslationFunction } from './types';
|
|
2
|
+
export interface CreatePackageI18nOptions {
|
|
3
|
+
/**
|
|
4
|
+
* Per-locale lazy loaders (WP4 code-splitting). Each returns the package's
|
|
5
|
+
* bundle for that locale as a dynamic-import chunk, e.g.
|
|
6
|
+
* `() => import('./translations/de').then((m) => m.default)`. Listed locales
|
|
7
|
+
* stay out of the initial bundle until activated by the provider / `setLocale`;
|
|
8
|
+
* the eager bundle passed in `translations` (typically `en`) is the base/
|
|
9
|
+
* fallback. Compile-time key parity is NOT checked for lazy locales — pair with
|
|
10
|
+
* `validatePackageTranslations` in a test to guard parity at runtime/CI.
|
|
11
|
+
*/
|
|
12
|
+
loaders?: Partial<Record<Locale, () => Promise<Translations>>>;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Creates a standardized i18n integration for a package.
|
|
16
|
+
*
|
|
17
|
+
* Generic over the `en` bundle: with `<const T>` the literal key/param types of
|
|
18
|
+
* `en` flow through `PackageI18n<T>` into a fully typed `t` — real `DeepKeys<T>`
|
|
19
|
+
* plus `ExtractParams`, so `t('dialog.close')` autocompletes and
|
|
20
|
+
* `t('dialog.nope')` is a compile error. Other locales are checked against
|
|
21
|
+
* `TranslationSchema<T>` (T's structure with its string values widened), which
|
|
22
|
+
* enforces key parity at compile time while allowing locale-specific strings.
|
|
23
|
+
*
|
|
24
|
+
* Opt-in code-splitting (WP4): pass `options.loaders` to keep non-base locales
|
|
25
|
+
* out of the initial bundle as dynamic-import chunks, loaded only when activated.
|
|
26
|
+
*/
|
|
27
|
+
export declare function createPackageI18n<const T extends Translations>(packageName: string, translations: {
|
|
28
|
+
en: T;
|
|
29
|
+
} & Partial<Record<Locale, TranslationSchema<T>>>, options?: CreatePackageI18nOptions): PackageI18n<T>;
|
|
30
|
+
/**
|
|
31
|
+
* Creates a package-translations descriptor (data only — does NOT register).
|
|
32
|
+
*
|
|
33
|
+
* @deprecated Superseded by {@link createPackageI18n}, which registers and
|
|
34
|
+
* returns a typed `t` in one step. Retained for back-compat; the `types` field
|
|
35
|
+
* is a non-functional placeholder (`CreatePackageTypes<Translations>` degenerates
|
|
36
|
+
* to `string` keys, predating the generic factory).
|
|
37
|
+
*/
|
|
38
|
+
export declare function createPackageTranslations(packageName: string, translations: Partial<Record<Locale, Translations>>): {
|
|
39
|
+
packageName: string;
|
|
40
|
+
translations: Partial<Record<"en" | "de" | "fr" | "es" | "it" | "nl", Translations>>;
|
|
41
|
+
types: CreatePackageTypes<Translations>;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Creates a typed translation package with auto-registration.
|
|
45
|
+
*
|
|
46
|
+
* @deprecated Use {@link createPackageI18n} directly. Since that factory became
|
|
47
|
+
* generic (`<const T>`) it already infers literal keys and returns a fully typed
|
|
48
|
+
* `t`; this wrapper adds only a redundant `tt` alias and a `packageName`/
|
|
49
|
+
* `translations` passthrough. Kept as a thin, typed shim for back-compat — it
|
|
50
|
+
* will be removed in a future major.
|
|
51
|
+
*/
|
|
52
|
+
export declare function createTypedPackage<const T extends Translations>(packageName: string, translations: {
|
|
53
|
+
en: T;
|
|
54
|
+
} & Partial<Record<Locale, TranslationSchema<T>>>): {
|
|
55
|
+
packageName: string;
|
|
56
|
+
translations: {
|
|
57
|
+
en: T;
|
|
58
|
+
} & Partial<Record<"en" | "de" | "fr" | "es" | "it" | "nl", TranslationSchema<T>>>;
|
|
59
|
+
tt: TypedTranslationFunction<T>;
|
|
60
|
+
useTranslate: () => TypedTranslationFunction<T>;
|
|
61
|
+
t: TypedTranslationFunction<T>;
|
|
62
|
+
exists: (key: string) => boolean;
|
|
63
|
+
getLocales: () => Locale[];
|
|
64
|
+
register: () => void;
|
|
65
|
+
types: CreatePackageTypes<T>;
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Register translation loaders for lazy loading
|
|
69
|
+
* Useful for larger packages with many translations
|
|
70
|
+
*/
|
|
71
|
+
export declare function registerTranslationLoaders(loaders: Record<Locale, () => Promise<Translations>>): void;
|
|
72
|
+
/**
|
|
73
|
+
* Smart component integration helper
|
|
74
|
+
* Provides common patterns for component i18n integration
|
|
75
|
+
*/
|
|
76
|
+
export declare function createComponentI18n<const T extends Translations>(packageName: string, translations: {
|
|
77
|
+
en: T;
|
|
78
|
+
} & Partial<Record<Locale, TranslationSchema<T>>>, defaultOptions?: {
|
|
79
|
+
useI18n?: boolean;
|
|
80
|
+
fallbackToGlobal?: boolean;
|
|
81
|
+
}): {
|
|
82
|
+
getText: (key: string, customText?: string, params?: TranslationParams, options?: {
|
|
83
|
+
useI18n?: boolean;
|
|
84
|
+
}) => string;
|
|
85
|
+
maybeT: (key: string, params?: TranslationParams, options?: {
|
|
86
|
+
useI18n?: boolean;
|
|
87
|
+
fallback?: string;
|
|
88
|
+
}) => string;
|
|
89
|
+
useTranslate: () => TypedTranslationFunction<T>;
|
|
90
|
+
t: TypedTranslationFunction<T>;
|
|
91
|
+
exists: (key: string) => boolean;
|
|
92
|
+
getLocales: () => Locale[];
|
|
93
|
+
register: () => void;
|
|
94
|
+
types: CreatePackageTypes<T>;
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* Batch register multiple packages
|
|
98
|
+
* Useful for apps that use many packages
|
|
99
|
+
*/
|
|
100
|
+
export declare function registerPackages(packages: Array<{
|
|
101
|
+
name: string;
|
|
102
|
+
translations: PackageTranslations;
|
|
103
|
+
}>): void;
|
|
104
|
+
/**
|
|
105
|
+
* Validates deep-key parity across a package's locale bundles.
|
|
106
|
+
*
|
|
107
|
+
* Compares the full recursive leaf-key set (not just top-level keys) of every
|
|
108
|
+
* locale against the base locale (`en` first by convention). A missing nested
|
|
109
|
+
* key is an error; an extra nested key is a warning. Pair with a per-package
|
|
110
|
+
* vitest assertion (`expect(errors).toEqual([])`) to fail CI on drift —
|
|
111
|
+
* complementing the compile-time parity `satisfies`/generic factory enforce for
|
|
112
|
+
* statically-typed bundles, and covering dynamically/lazily loaded ones.
|
|
113
|
+
*/
|
|
114
|
+
export declare function validatePackageTranslations(packageName: string, translations: Partial<Record<Locale, Translations>>): {
|
|
115
|
+
isValid: boolean;
|
|
116
|
+
errors: string[];
|
|
117
|
+
warnings: string[];
|
|
118
|
+
};
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { collectDeepKeys } from '../utils/deep-keys';
|
|
2
|
+
import { BASE_LOCALE, useI18nState } from './context.svelte';
|
|
3
|
+
import { getRegistry } from './registry.svelte';
|
|
4
|
+
/**
|
|
5
|
+
* Creates a standardized i18n integration for a package.
|
|
6
|
+
*
|
|
7
|
+
* Generic over the `en` bundle: with `<const T>` the literal key/param types of
|
|
8
|
+
* `en` flow through `PackageI18n<T>` into a fully typed `t` — real `DeepKeys<T>`
|
|
9
|
+
* plus `ExtractParams`, so `t('dialog.close')` autocompletes and
|
|
10
|
+
* `t('dialog.nope')` is a compile error. Other locales are checked against
|
|
11
|
+
* `TranslationSchema<T>` (T's structure with its string values widened), which
|
|
12
|
+
* enforces key parity at compile time while allowing locale-specific strings.
|
|
13
|
+
*
|
|
14
|
+
* Opt-in code-splitting (WP4): pass `options.loaders` to keep non-base locales
|
|
15
|
+
* out of the initial bundle as dynamic-import chunks, loaded only when activated.
|
|
16
|
+
*/
|
|
17
|
+
export function createPackageI18n(packageName, translations, options) {
|
|
18
|
+
// Eager registration at module-init time. The previous lazy variant
|
|
19
|
+
// (queueMicrotask inside t()) returned the raw key on first call and
|
|
20
|
+
// never re-triggered the reactive expression that read it, so consumers
|
|
21
|
+
// saw `filter.button.add` instead of the translated string.
|
|
22
|
+
//
|
|
23
|
+
// Running synchronously here is safe: `createPackageI18n` is invoked at
|
|
24
|
+
// module top-level (`export const tableI18n = createPackageI18n(...)`),
|
|
25
|
+
// which is outside any $derived/$effect — so the SvelteMap mutation in
|
|
26
|
+
// `registerPackage` cannot trip the `state_unsafe_mutation` rule.
|
|
27
|
+
//
|
|
28
|
+
// Goes through `getRegistry()` (a hoisted function), NOT a module-const binding:
|
|
29
|
+
// this call fires at consumer module-eval time, and under Vite 8 / Rolldown a
|
|
30
|
+
// reordered chunk can run it before the registry module's body ran. A hoisted
|
|
31
|
+
// function binding survives that; the lazy getter then builds the registry on
|
|
32
|
+
// first touch, in whatever order the chunks happen to fire.
|
|
33
|
+
getRegistry().registerPackage(packageName, translations);
|
|
34
|
+
// Opt-in lazy locales (WP4): register dynamic-import loaders. The eager bundle
|
|
35
|
+
// above is the base; these cover the rest, loaded on demand by the provider /
|
|
36
|
+
// setLocale. Parity for lazy bundles is a runtime concern (validatePackageTranslations).
|
|
37
|
+
if (options?.loaders) {
|
|
38
|
+
const registry = getRegistry();
|
|
39
|
+
for (const [locale, loader] of Object.entries(options.loaders)) {
|
|
40
|
+
if (loader)
|
|
41
|
+
registry.registerPackageLoader(packageName, locale, loader);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// Context-scoped hook — the SSR-correct, reactive accessor. Captures the
|
|
45
|
+
// request-scoped locale state at component init (or `undefined` without a
|
|
46
|
+
// provider → base locale), then resolves against the static registry. Reading
|
|
47
|
+
// `state.locale` inside the returned closure (called from a `$derived`) makes
|
|
48
|
+
// call-sites re-render on locale change; reading the registry's SvelteMap makes
|
|
49
|
+
// them re-render when a package registers more translations.
|
|
50
|
+
const useTranslate = () => {
|
|
51
|
+
const state = useI18nState();
|
|
52
|
+
const registry = getRegistry();
|
|
53
|
+
return ((key, params, options) => registry.translate(key, state?.locale ?? BASE_LOCALE, state?.fallbackLocale ?? BASE_LOCALE, params, { packageName, ...options }));
|
|
54
|
+
};
|
|
55
|
+
// Non-hook `t` for non-component use (tests, server utilities). Resolves
|
|
56
|
+
// against the base locale — there is no request-scoped state outside a
|
|
57
|
+
// component. Components use `useTranslate` for the reactive, provider-scoped
|
|
58
|
+
// locale.
|
|
59
|
+
const t = ((key, params, options) => getRegistry().translate(key, BASE_LOCALE, BASE_LOCALE, params, {
|
|
60
|
+
packageName,
|
|
61
|
+
...options
|
|
62
|
+
}));
|
|
63
|
+
const exists = (key) => getRegistry().exists(key, BASE_LOCALE, packageName);
|
|
64
|
+
const getLocales = () => getRegistry().getPackageLocales(packageName);
|
|
65
|
+
// No-op kept for API back-compat with callers that used to invoke `register()`
|
|
66
|
+
// before reading translations. Registration now happens eagerly above.
|
|
67
|
+
const register = () => { };
|
|
68
|
+
return {
|
|
69
|
+
useTranslate,
|
|
70
|
+
t,
|
|
71
|
+
exists,
|
|
72
|
+
getLocales,
|
|
73
|
+
register,
|
|
74
|
+
types: {}
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Creates a package-translations descriptor (data only — does NOT register).
|
|
79
|
+
*
|
|
80
|
+
* @deprecated Superseded by {@link createPackageI18n}, which registers and
|
|
81
|
+
* returns a typed `t` in one step. Retained for back-compat; the `types` field
|
|
82
|
+
* is a non-functional placeholder (`CreatePackageTypes<Translations>` degenerates
|
|
83
|
+
* to `string` keys, predating the generic factory).
|
|
84
|
+
*/
|
|
85
|
+
export function createPackageTranslations(packageName, translations) {
|
|
86
|
+
return {
|
|
87
|
+
packageName,
|
|
88
|
+
translations,
|
|
89
|
+
types: {}
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Creates a typed translation package with auto-registration.
|
|
94
|
+
*
|
|
95
|
+
* @deprecated Use {@link createPackageI18n} directly. Since that factory became
|
|
96
|
+
* generic (`<const T>`) it already infers literal keys and returns a fully typed
|
|
97
|
+
* `t`; this wrapper adds only a redundant `tt` alias and a `packageName`/
|
|
98
|
+
* `translations` passthrough. Kept as a thin, typed shim for back-compat — it
|
|
99
|
+
* will be removed in a future major.
|
|
100
|
+
*/
|
|
101
|
+
export function createTypedPackage(packageName, translations) {
|
|
102
|
+
const packageI18n = createPackageI18n(packageName, translations);
|
|
103
|
+
return {
|
|
104
|
+
...packageI18n,
|
|
105
|
+
packageName,
|
|
106
|
+
translations,
|
|
107
|
+
// Convenient alias
|
|
108
|
+
tt: packageI18n.t
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Register translation loaders for lazy loading
|
|
113
|
+
* Useful for larger packages with many translations
|
|
114
|
+
*/
|
|
115
|
+
export function registerTranslationLoaders(loaders) {
|
|
116
|
+
Object.entries(loaders).forEach(([locale, loader]) => {
|
|
117
|
+
getRegistry().registerTranslationLoader(locale, loader);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Smart component integration helper
|
|
122
|
+
* Provides common patterns for component i18n integration
|
|
123
|
+
*/
|
|
124
|
+
export function createComponentI18n(packageName, translations, defaultOptions) {
|
|
125
|
+
const packageI18n = createPackageI18n(packageName, translations);
|
|
126
|
+
return {
|
|
127
|
+
...packageI18n,
|
|
128
|
+
// Smart text resolution for components
|
|
129
|
+
getText: (key, customText, params, options) => {
|
|
130
|
+
// If custom text provided and i18n disabled, use custom text
|
|
131
|
+
if (customText && options?.useI18n === false) {
|
|
132
|
+
return customText;
|
|
133
|
+
}
|
|
134
|
+
const loose = packageI18n.t;
|
|
135
|
+
return loose(key, params, {
|
|
136
|
+
fallbackToGlobal: defaultOptions?.fallbackToGlobal ?? true
|
|
137
|
+
});
|
|
138
|
+
},
|
|
139
|
+
// Conditional translation (useful for optional i18n)
|
|
140
|
+
maybeT: (key, params, options) => {
|
|
141
|
+
if (options?.useI18n === false) {
|
|
142
|
+
return options.fallback || key;
|
|
143
|
+
}
|
|
144
|
+
const loose = packageI18n.t;
|
|
145
|
+
return loose(key, params);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Batch register multiple packages
|
|
151
|
+
* Useful for apps that use many packages
|
|
152
|
+
*/
|
|
153
|
+
export function registerPackages(packages) {
|
|
154
|
+
packages.forEach(({ name, translations }) => {
|
|
155
|
+
getRegistry().registerPackage(name, translations);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Validates deep-key parity across a package's locale bundles.
|
|
160
|
+
*
|
|
161
|
+
* Compares the full recursive leaf-key set (not just top-level keys) of every
|
|
162
|
+
* locale against the base locale (`en` first by convention). A missing nested
|
|
163
|
+
* key is an error; an extra nested key is a warning. Pair with a per-package
|
|
164
|
+
* vitest assertion (`expect(errors).toEqual([])`) to fail CI on drift —
|
|
165
|
+
* complementing the compile-time parity `satisfies`/generic factory enforce for
|
|
166
|
+
* statically-typed bundles, and covering dynamically/lazily loaded ones.
|
|
167
|
+
*/
|
|
168
|
+
// Non-generic on purpose: this is a runtime structural check. A generic
|
|
169
|
+
// `<T>(…: Partial<Record<Locale, T>>)` would bind every locale to the SAME `T`
|
|
170
|
+
// inferred from `en`, so a `de` bundle with different string literals (the
|
|
171
|
+
// normal case under `as const`) fails to assign. `Translations` widens the
|
|
172
|
+
// values, decoupling the locales — parity is verified at runtime via keys.
|
|
173
|
+
export function validatePackageTranslations(packageName, translations) {
|
|
174
|
+
const errors = [];
|
|
175
|
+
const warnings = [];
|
|
176
|
+
const locales = Object.keys(translations);
|
|
177
|
+
if (locales.length === 0) {
|
|
178
|
+
errors.push(`[${packageName}] No translations provided`);
|
|
179
|
+
return { isValid: false, errors, warnings };
|
|
180
|
+
}
|
|
181
|
+
// Prefer `en` as the base: the error (missing) vs warning (extra) asymmetry
|
|
182
|
+
// is only meaningful against the canonical source locale, not whichever key
|
|
183
|
+
// Object.keys happens to return first.
|
|
184
|
+
const baseLocale = locales.includes('en') ? 'en' : locales[0];
|
|
185
|
+
const baseBundle = translations[baseLocale];
|
|
186
|
+
if (!baseBundle) {
|
|
187
|
+
errors.push(`[${packageName}] Base locale ${baseLocale} has no translations`);
|
|
188
|
+
return { isValid: false, errors, warnings };
|
|
189
|
+
}
|
|
190
|
+
const baseKeys = collectDeepKeys(baseBundle);
|
|
191
|
+
const baseKeySet = new Set(baseKeys);
|
|
192
|
+
locales.slice(1).forEach((locale) => {
|
|
193
|
+
const bundle = translations[locale];
|
|
194
|
+
if (!bundle)
|
|
195
|
+
return;
|
|
196
|
+
const localeKeySet = new Set(collectDeepKeys(bundle));
|
|
197
|
+
const missingKeys = baseKeys.filter((key) => !localeKeySet.has(key));
|
|
198
|
+
const extraKeys = [...localeKeySet].filter((key) => !baseKeySet.has(key));
|
|
199
|
+
if (missingKeys.length > 0) {
|
|
200
|
+
errors.push(`[${packageName}] Locale ${locale} missing keys: ${missingKeys.join(', ')}`);
|
|
201
|
+
}
|
|
202
|
+
if (extraKeys.length > 0) {
|
|
203
|
+
warnings.push(`[${packageName}] Locale ${locale} has extra keys: ${extraKeys.join(', ')}`);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
return {
|
|
207
|
+
isValid: errors.length === 0,
|
|
208
|
+
errors,
|
|
209
|
+
warnings
|
|
210
|
+
};
|
|
211
|
+
}
|