lambder 3.2.3 → 3.2.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.
@@ -25,7 +25,7 @@ export type LambderI18nExtractParams<S extends string> = S extends `${string}{${
25
25
  * `{tokens}`, a params object with exactly those tokens is required.
26
26
  */
27
27
  export type LambderI18nTranslator<TContract extends Record<string, string>> = <K extends keyof TContract & string>(...args: LambderI18nExtractParams<TContract[K]> extends never ? [key: K] : [key: K, params: Record<LambderI18nExtractParams<TContract[K]>, string | number>]) => string;
28
- export interface LambderI18nConfig<TLanguages extends Record<string, LambderLanguageMeta>, TDefault extends keyof TLanguages & string, TEnforced extends readonly (keyof TLanguages & string)[], TContract extends Record<string, string>> {
28
+ export interface LambderI18nConfig<TLanguages extends Record<string, LambderLanguageMeta>, TDefault extends keyof TLanguages & string, TEnforced extends readonly (keyof TLanguages & string)[], TBase extends Record<TDefault, Record<string, string>>> {
29
29
  /** Master registry of every supported language and its metadata. */
30
30
  languages: TLanguages;
31
31
  /** Final fallback language. Must be included in `enforced`. */
@@ -39,10 +39,8 @@ export interface LambderI18nConfig<TLanguages extends Record<string, LambderLang
39
39
  * App-wide base dictionary. Strict: every language in `languages` must
40
40
  * provide every key (the `defaultLanguage` block is the typed contract).
41
41
  */
42
- base: {
43
- [L in keyof TLanguages]: Record<keyof TContract, string>;
44
- } & {
45
- [D in TDefault]: TContract;
42
+ base: TBase & {
43
+ [L in keyof TLanguages]: Record<keyof TBase[TDefault], string>;
46
44
  };
47
45
  /**
48
46
  * Optional language detector, tried before browser detection. Return a
@@ -61,18 +59,22 @@ export interface LambderI18nInstance<TLanguages extends Record<string, LambderLa
61
59
  /** Translator bound to an explicit language (per-request backend use). */
62
60
  forLanguage(code: keyof TLanguages & string): LambderI18nTranslator<TContract>;
63
61
  /**
64
- * Strict extension: every language must provide every new key.
62
+ * Strict extension: every language must provide every new key. Keys must
63
+ * be new — redeclaring a parent key is a compile-time and runtime error.
65
64
  * Returns a new instance whose key space = parent keys + new keys.
66
65
  */
67
66
  extend<const TExt extends {
68
67
  [D in TDefault]: Record<string, string>;
69
68
  }>(dict: {
70
69
  [L in keyof TLanguages]: Record<keyof TExt[TDefault], string>;
70
+ } & {
71
+ [D in TDefault]: Partial<Record<keyof TContract, never>>;
71
72
  } & TExt): LambderI18nInstance<TLanguages, TDefault, TEnforced, TContract & TExt[TDefault]>;
72
73
  /**
73
74
  * Partial extension: only the `enforced` languages are required; all other
74
75
  * languages are optional (and may provide a subset of keys) — missing
75
- * translations fall back to the default language.
76
+ * translations fall back to the default language. Keys must be new —
77
+ * redeclaring a parent key is a compile-time and runtime error.
76
78
  */
77
79
  extendPartial<const TExt extends {
78
80
  [D in TDefault]: Record<string, string>;
@@ -80,8 +82,10 @@ export interface LambderI18nInstance<TLanguages extends Record<string, LambderLa
80
82
  [E in TEnforced[number]]: Record<keyof TExt[TDefault], string>;
81
83
  } & {
82
84
  [L in Exclude<keyof TLanguages & string, TEnforced[number]>]?: Partial<Record<keyof TExt[TDefault], string>>;
85
+ } & {
86
+ [D in TDefault]: Partial<Record<keyof TContract, never>>;
83
87
  } & TExt): LambderI18nInstance<TLanguages, TDefault, TEnforced, TContract & TExt[TDefault]>;
84
- /** Merge additional translations at runtime (e.g. fetched from an API). */
88
+ /** Merge additional translations at runtime (e.g. fetched from an API). Notifies change listeners. */
85
89
  registerDictionary(code: keyof TLanguages & string, dict: Record<string, string>): void;
86
90
  /** Override the active language (shared with all extended instances). */
87
91
  setLanguage(code: keyof TLanguages & string): void;
@@ -97,7 +101,10 @@ export interface LambderI18nInstance<TLanguages extends Record<string, LambderLa
97
101
  readonly currentDir: "ltr" | "rtl";
98
102
  /** BCP-47 locale of the active language for Intl APIs (defaults to the code). */
99
103
  readonly currentIntlLocale: string;
100
- /** Subscribe to language changes. Returns an unsubscribe function. */
104
+ /**
105
+ * Subscribe to changes (language switched, or runtime dictionaries
106
+ * registered). Returns an unsubscribe function.
107
+ */
101
108
  onLanguageChange(listener: (code: keyof TLanguages & string) => void): () => void;
102
109
  /**
103
110
  * Apply the active language to `<html lang>` and `<html dir>` (RTL support).
@@ -117,9 +124,15 @@ export interface LambderI18nInstance<TLanguages extends Record<string, LambderLa
117
124
  readonly enforced: TEnforced;
118
125
  }
119
126
  /** Language codes of an instance: `LambderI18nCodes<typeof i18n>`. */
120
- export type LambderI18nCodes<T> = T extends LambderI18nInstance<infer L, any, any, any> ? keyof L & string : never;
127
+ export type LambderI18nCodes<T extends {
128
+ languageList: readonly string[];
129
+ }> = T["languageList"][number];
121
130
  /** Translation keys of an instance: `LambderI18nKeys<typeof i18n>`. */
122
- export type LambderI18nKeys<T> = T extends LambderI18nInstance<any, any, any, infer C> ? keyof C & string : never;
131
+ export type LambderI18nKeys<T extends {
132
+ t: (...args: never[]) => string;
133
+ }> = Parameters<T["t"]>[0];
123
134
  /** Translator type of an instance: `LambderI18nTranslatorFor<typeof i18n>`. */
124
- export type LambderI18nTranslatorFor<T> = T extends LambderI18nInstance<any, any, any, infer C> ? LambderI18nTranslator<C> : never;
125
- export declare const createLambderI18n: <const TLanguages extends Record<string, LambderLanguageMeta>, const TDefault extends keyof TLanguages & string, const TEnforced extends readonly (keyof TLanguages & string)[], const TContract extends Record<string, string>>(config: LambderI18nConfig<TLanguages, TDefault, TEnforced, TContract>) => LambderI18nInstance<TLanguages, TDefault, TEnforced, TContract>;
135
+ export type LambderI18nTranslatorFor<T extends {
136
+ t: unknown;
137
+ }> = T["t"];
138
+ export declare const createLambderI18n: <const TLanguages extends Record<string, LambderLanguageMeta>, const TDefault extends keyof TLanguages & string, const TEnforced extends readonly (keyof TLanguages & string)[], const TBase extends Record<TDefault, Record<string, string>>>(config: LambderI18nConfig<TLanguages, TDefault, TEnforced, TBase>) => LambderI18nInstance<TLanguages, TDefault, TEnforced, TBase[TDefault]>;
@@ -42,7 +42,14 @@ class LanguageState {
42
42
  return this.override;
43
43
  if (this.detected)
44
44
  return this.detected;
45
- const custom = this.customDetect?.();
45
+ // Fail-open: a broken app detector must not take down every t() call.
46
+ let custom = null;
47
+ try {
48
+ custom = this.customDetect?.();
49
+ }
50
+ catch (err) {
51
+ console.error("LambderI18n: detectLanguage threw; continuing detection chain.", err);
52
+ }
46
53
  if (custom && this.isCode(custom)) {
47
54
  this.detected = custom;
48
55
  return custom;
@@ -64,13 +71,24 @@ class LanguageState {
64
71
  this.detected = null;
65
72
  this.notify(this.resolve());
66
73
  }
74
+ /** Re-notify listeners without a language change (e.g. dictionaries changed). */
75
+ emitChange() {
76
+ this.notify(this.resolve());
77
+ }
67
78
  subscribe(listener) {
68
79
  this.listeners.add(listener);
69
80
  return () => { this.listeners.delete(listener); };
70
81
  }
71
82
  notify(code) {
72
- for (const listener of this.listeners)
73
- listener(code);
83
+ for (const listener of this.listeners) {
84
+ // Isolate listeners: one bad subscriber must not block the others.
85
+ try {
86
+ listener(code);
87
+ }
88
+ catch (err) {
89
+ console.error("LambderI18n: onLanguageChange listener threw.", err);
90
+ }
91
+ }
74
92
  }
75
93
  }
76
94
  const interpolate = (text, params) => {
@@ -98,6 +116,8 @@ const buildInstance = (core, layer) => {
98
116
  return interpolate(text, params);
99
117
  };
100
118
  const t = (key, params) => translateIn(core.state.resolve(), key, params);
119
+ // forLanguage sits on the hot path of reactive T() bridges: cache per code.
120
+ const translatorCache = new Map();
101
121
  const validateExtension = (dict, requiredLanguages, label) => {
102
122
  for (const lang of Object.keys(dict)) {
103
123
  if (!core.isCode(lang))
@@ -107,40 +127,50 @@ const buildInstance = (core, layer) => {
107
127
  if (!dict[lang])
108
128
  throw new Error(`LambderI18n: ${label} is missing required language "${lang}".`);
109
129
  }
130
+ for (const block of Object.values(dict)) {
131
+ for (const key of Object.keys(block ?? {})) {
132
+ if (layerLookup(layer, core.defaultLanguage, key) !== undefined) {
133
+ throw new Error(`LambderI18n: ${label} redeclares existing key "${key}".`);
134
+ }
135
+ }
136
+ }
110
137
  };
111
138
  const instance = {
112
- t: t,
139
+ t,
113
140
  forLanguage(code) {
141
+ const cached = translatorCache.get(code);
142
+ if (cached)
143
+ return cached;
114
144
  if (!core.isCode(code))
115
145
  throw new Error(`LambderI18n: unsupported language code "${code}".`);
116
- return ((key, params) => translateIn(code, key, params));
146
+ const translator = (key, params) => translateIn(code, key, params);
147
+ translatorCache.set(code, translator);
148
+ return translator;
117
149
  },
118
150
  extend(dict) {
119
151
  validateExtension(dict, core.languageList, "extend() dictionary");
120
- return buildInstance(core, { dicts: dict, parent: layer });
152
+ return buildInstance(core, { dicts: { ...dict }, parent: layer });
121
153
  },
122
154
  extendPartial(dict) {
123
155
  validateExtension(dict, core.enforced, "extendPartial() dictionary");
124
- return buildInstance(core, { dicts: dict, parent: layer });
156
+ return buildInstance(core, { dicts: { ...dict }, parent: layer });
125
157
  },
126
158
  registerDictionary(code, dict) {
127
159
  if (!core.isCode(code))
128
160
  throw new Error(`LambderI18n: unsupported language code "${code}".`);
129
161
  layer.dicts[code] = { ...layer.dicts[code], ...dict };
162
+ core.state.emitChange();
130
163
  },
131
164
  setLanguage(code) { core.state.set(code); },
132
165
  resetLanguage() { core.state.reset(); },
133
166
  get currentLanguage() { return core.state.resolve(); },
134
- get currentLanguageMeta() {
135
- const code = core.state.resolve();
136
- return { code, ...core.languages[code] };
137
- },
167
+ get currentLanguageMeta() { return core.metaByCode.get(core.state.resolve()); },
138
168
  get currentDir() {
139
- return core.languages[core.state.resolve()]?.dir ?? "ltr";
169
+ return core.metaByCode.get(core.state.resolve())?.dir ?? "ltr";
140
170
  },
141
171
  get currentIntlLocale() {
142
172
  const code = core.state.resolve();
143
- return core.languages[code]?.intlLocale ?? code;
173
+ return core.metaByCode.get(code)?.intlLocale ?? code;
144
174
  },
145
175
  onLanguageChange(listener) { return core.state.subscribe(listener); },
146
176
  applyToDocument() {
@@ -149,14 +179,12 @@ const buildInstance = (core, layer) => {
149
179
  return;
150
180
  const code = core.state.resolve();
151
181
  doc.documentElement.lang = code;
152
- doc.documentElement.dir = core.languages[code]?.dir ?? "ltr";
182
+ doc.documentElement.dir = core.metaByCode.get(code)?.dir ?? "ltr";
153
183
  },
154
184
  isLanguageCode: core.isCode,
155
185
  languages: core.languages,
156
186
  languageList: core.languageList,
157
- get languageMetaList() {
158
- return core.languageList.map((code) => ({ code, ...core.languages[code] }));
159
- },
187
+ languageMetaList: core.metaList,
160
188
  defaultLanguage: core.defaultLanguage,
161
189
  enforced: core.enforced,
162
190
  };
@@ -180,6 +208,11 @@ export const createLambderI18n = (config) => {
180
208
  throw new Error(`LambderI18n: base dictionary is missing language "${lang}".`);
181
209
  }
182
210
  }
211
+ for (const lang of Object.keys(config.base)) {
212
+ if (!isCode(lang)) {
213
+ throw new Error(`LambderI18n: base dictionary contains unsupported language "${lang}".`);
214
+ }
215
+ }
183
216
  const customDetect = config.detectLanguage
184
217
  ? () => config.detectLanguage({
185
218
  isLanguageCode: isCode,
@@ -187,9 +220,12 @@ export const createLambderI18n = (config) => {
187
220
  defaultLanguage: config.defaultLanguage,
188
221
  })
189
222
  : null;
223
+ const metaByCode = new Map(languageList.map((code) => [code, { code, ...config.languages[code] }]));
190
224
  const core = {
191
225
  languages: config.languages,
192
226
  languageList,
227
+ metaByCode,
228
+ metaList: [...metaByCode.values()],
193
229
  defaultLanguage: config.defaultLanguage,
194
230
  enforced: config.enforced,
195
231
  state: new LanguageState(isCode, config.defaultLanguage, customDetect),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lambder",
3
- "version": "3.2.3",
3
+ "version": "3.2.4",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",