react-intl-locale-chain 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 i18nagent.ai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,295 @@
1
+ # react-intl-locale-chain
2
+
3
+ [![npm version](https://img.shields.io/npm/v/react-intl-locale-chain)](https://www.npmjs.com/package/react-intl-locale-chain)
4
+ [![license](https://img.shields.io/npm/l/react-intl-locale-chain)](LICENSE)
5
+
6
+ Smart locale fallback chains for react-intl — because pt-BR users deserve pt-PT, not English.
7
+
8
+ ## The Problem
9
+
10
+ react-intl falls back directly to `defaultMessage` or the message ID when a translation key is missing. There is no intermediate locale fallback.
11
+
12
+ **Example:** Your app has `pt-PT` translations but no `pt-BR` messages file. A Brazilian Portuguese user sees English (or raw message IDs) instead of the perfectly good `pt-PT` translations.
13
+
14
+ The same thing happens with `es-MX` -> `es`, `fr-CA` -> `fr`, `de-AT` -> `de`, and every other regional variant.
15
+
16
+ Your users see English when a perfectly good translation exists in a sibling locale.
17
+
18
+ ## The Solution
19
+
20
+ Drop-in replacement for `IntlProvider`. Zero changes to your existing react-intl components.
21
+
22
+ `LocaleChainProvider` wraps `IntlProvider` and automatically deep-merges messages from a configurable fallback chain before passing them to react-intl.
23
+
24
+ ## Installation
25
+
26
+ ```bash
27
+ npm install react-intl-locale-chain
28
+ # or
29
+ pnpm add react-intl-locale-chain
30
+ # or
31
+ yarn add react-intl-locale-chain
32
+ ```
33
+
34
+ **Peer dependencies:** `react >= 16.8` and `react-intl >= 5.0.0`
35
+
36
+ ## Quick Start
37
+
38
+ ```tsx
39
+ import { LocaleChainProvider } from 'react-intl-locale-chain';
40
+
41
+ function App() {
42
+ return (
43
+ <LocaleChainProvider
44
+ locale="pt-BR"
45
+ defaultLocale="en"
46
+ loadMessages={(locale) => import(`./messages/${locale}.json`).then(m => m.default)}
47
+ fallback={<div>Loading...</div>}
48
+ >
49
+ <YourApp />
50
+ </LocaleChainProvider>
51
+ );
52
+ }
53
+ ```
54
+
55
+ All default fallback chains are active. A `pt-BR` user will now see `pt-PT` translations when `pt-BR` keys are missing.
56
+
57
+ ## Custom Configuration
58
+
59
+ ### Default (zero config)
60
+
61
+ ```tsx
62
+ <LocaleChainProvider
63
+ locale="pt-BR"
64
+ defaultLocale="en"
65
+ loadMessages={loadMessages}
66
+ />
67
+ ```
68
+
69
+ Uses all built-in fallback chains. Covers Portuguese, Spanish, French, German, Italian, Dutch, Norwegian, and Malay regional variants.
70
+
71
+ ### With overrides (merge with defaults)
72
+
73
+ ```tsx
74
+ // Override specific chains while keeping all defaults
75
+ <LocaleChainProvider
76
+ locale="pt-BR"
77
+ defaultLocale="en"
78
+ loadMessages={loadMessages}
79
+ overrides={{ 'pt-BR': ['pt'] }} // skip pt-PT, go straight to pt
80
+ />
81
+ ```
82
+
83
+ Your overrides replace matching keys in the default map. All other defaults remain.
84
+
85
+ ### Full custom (replace defaults)
86
+
87
+ ```tsx
88
+ // Full control — only use your chains
89
+ <LocaleChainProvider
90
+ locale="pt-BR"
91
+ defaultLocale="en"
92
+ loadMessages={loadMessages}
93
+ fallbacks={{
94
+ 'pt-BR': ['pt-PT', 'pt'],
95
+ 'es-MX': ['es-419', 'es']
96
+ }}
97
+ mergeDefaults={false}
98
+ />
99
+ ```
100
+
101
+ Only the chains you specify will be active. No defaults.
102
+
103
+ ## Advanced: Pure Utility
104
+
105
+ For advanced setups where you manage your own `IntlProvider`, use the pure merge function:
106
+
107
+ ```tsx
108
+ import { mergeMessagesFromChain } from 'react-intl-locale-chain';
109
+ import { IntlProvider } from 'react-intl';
110
+
111
+ // In your setup code (works in Server Components too)
112
+ const messages = await mergeMessagesFromChain({
113
+ locale: 'pt-BR',
114
+ defaultLocale: 'en',
115
+ loadMessages: (locale) => fetch(`/api/messages/${locale}`).then(r => r.json()),
116
+ });
117
+
118
+ // Pass merged messages to vanilla IntlProvider
119
+ <IntlProvider locale="pt-BR" messages={messages}>
120
+ <App />
121
+ </IntlProvider>
122
+ ```
123
+
124
+ ## Default Fallback Map
125
+
126
+ ### Portuguese
127
+
128
+ | Locale | Fallback Chain |
129
+ |--------|---------------|
130
+ | pt-BR | pt-PT -> pt -> (default locale) |
131
+ | pt-PT | pt -> (default locale) |
132
+
133
+ ### Spanish
134
+
135
+ | Locale | Fallback Chain |
136
+ |--------|---------------|
137
+ | es-419 | es -> (default locale) |
138
+ | es-MX | es-419 -> es -> (default locale) |
139
+ | es-AR | es-419 -> es -> (default locale) |
140
+ | es-CO | es-419 -> es -> (default locale) |
141
+ | es-CL | es-419 -> es -> (default locale) |
142
+ | es-PE | es-419 -> es -> (default locale) |
143
+ | es-VE | es-419 -> es -> (default locale) |
144
+ | es-EC | es-419 -> es -> (default locale) |
145
+ | es-GT | es-419 -> es -> (default locale) |
146
+ | es-CU | es-419 -> es -> (default locale) |
147
+ | es-BO | es-419 -> es -> (default locale) |
148
+ | es-DO | es-419 -> es -> (default locale) |
149
+ | es-HN | es-419 -> es -> (default locale) |
150
+ | es-PY | es-419 -> es -> (default locale) |
151
+ | es-SV | es-419 -> es -> (default locale) |
152
+ | es-NI | es-419 -> es -> (default locale) |
153
+ | es-CR | es-419 -> es -> (default locale) |
154
+ | es-PA | es-419 -> es -> (default locale) |
155
+ | es-UY | es-419 -> es -> (default locale) |
156
+ | es-PR | es-419 -> es -> (default locale) |
157
+
158
+ ### French
159
+
160
+ | Locale | Fallback Chain |
161
+ |--------|---------------|
162
+ | fr-CA | fr -> (default locale) |
163
+ | fr-BE | fr -> (default locale) |
164
+ | fr-CH | fr -> (default locale) |
165
+ | fr-LU | fr -> (default locale) |
166
+ | fr-MC | fr -> (default locale) |
167
+ | fr-SN | fr -> (default locale) |
168
+ | fr-CI | fr -> (default locale) |
169
+ | fr-ML | fr -> (default locale) |
170
+ | fr-CM | fr -> (default locale) |
171
+ | fr-MG | fr -> (default locale) |
172
+ | fr-CD | fr -> (default locale) |
173
+
174
+ ### German
175
+
176
+ | Locale | Fallback Chain |
177
+ |--------|---------------|
178
+ | de-AT | de -> (default locale) |
179
+ | de-CH | de -> (default locale) |
180
+ | de-LU | de -> (default locale) |
181
+ | de-LI | de -> (default locale) |
182
+
183
+ ### Italian
184
+
185
+ | Locale | Fallback Chain |
186
+ |--------|---------------|
187
+ | it-CH | it -> (default locale) |
188
+
189
+ ### Dutch
190
+
191
+ | Locale | Fallback Chain |
192
+ |--------|---------------|
193
+ | nl-BE | nl -> (default locale) |
194
+
195
+ ### Norwegian
196
+
197
+ | Locale | Fallback Chain |
198
+ |--------|---------------|
199
+ | nb | no -> (default locale) |
200
+ | nn | nb -> no -> (default locale) |
201
+
202
+ ### Malay
203
+
204
+ | Locale | Fallback Chain |
205
+ |--------|---------------|
206
+ | ms-MY | ms -> (default locale) |
207
+ | ms-SG | ms -> (default locale) |
208
+ | ms-BN | ms -> (default locale) |
209
+
210
+ ## How It Works
211
+
212
+ 1. `LocaleChainProvider` wraps react-intl's `IntlProvider`.
213
+ 2. When rendered, it resolves the fallback chain for the requested locale.
214
+ 3. It calls your `loadMessages` function for each locale in the chain (in parallel for async loaders).
215
+ 4. Messages are deep-merged in priority order: default locale (base) -> chain locales -> requested locale (highest priority).
216
+ 5. If `loadMessages` throws for any chain locale (e.g., file doesn't exist), it silently skips that locale and continues.
217
+ 6. The merged messages are passed to `IntlProvider`, which sees a complete message object with no missing keys.
218
+
219
+ ## FAQ
220
+
221
+ **Performance impact?**
222
+ Minimal. The fallback map is resolved once via `useMemo`. Message loading for async loaders happens in parallel (`Promise.allSettled`). Deep merge is fast for typical message objects.
223
+
224
+ **Does it work with nested message keys?**
225
+ Yes. Deep merge is recursive — it walks all nesting levels. If `pt-BR` has `common.save` but not `common.cancel`, `common.cancel` will be filled from the next locale in the chain.
226
+
227
+ **What if my `loadMessages` is synchronous?**
228
+ Fully supported. If `loadMessages` returns plain objects (not Promises), `LocaleChainProvider` renders immediately with no loading flash. The sync path is detected automatically.
229
+
230
+ **What if my `loadMessages` is async?**
231
+ Also fully supported. `LocaleChainProvider` shows the `fallback` prop (or `null`) until messages resolve. Dynamic `import()`, `fetch()` — all work.
232
+
233
+ **Can I load messages from a CMS or API?**
234
+ Yes. `loadMessages` is just a function — it can load from anywhere:
235
+
236
+ ```tsx
237
+ <LocaleChainProvider
238
+ locale="pt-BR"
239
+ defaultLocale="en"
240
+ loadMessages={async (locale) => {
241
+ const res = await fetch(`https://my-cms.com/messages/${locale}`)
242
+ return res.json()
243
+ }}
244
+ />
245
+ ```
246
+
247
+ **What if a chain locale doesn't have a messages file?**
248
+ It's silently skipped. The chain continues to the next locale. This is by design — you don't need message files for every locale in every chain.
249
+
250
+ **react-intl version compatibility?**
251
+ Works with react-intl v5+ and v6+.
252
+
253
+ **React 19 / Server Components?**
254
+ `LocaleChainProvider` uses `useState` and `useEffect`, so it's a client component. For Server Component architectures, use `mergeMessagesFromChain()` in a Server Component and pass the result to vanilla `IntlProvider`.
255
+
256
+ **Should `loadMessages` be a stable reference?**
257
+ Yes. Like any function prop in React, define it outside the component or wrap in `useCallback` to avoid unnecessary re-resolution:
258
+
259
+ ```tsx
260
+ // Good — stable reference
261
+ const loadMessages = (locale: string) =>
262
+ import(`./messages/${locale}.json`).then(m => m.default);
263
+
264
+ function App() {
265
+ return (
266
+ <LocaleChainProvider loadMessages={loadMessages} ... />
267
+ );
268
+ }
269
+ ```
270
+
271
+ **Should `fallbacks` and `overrides` props be stable references?**
272
+ Yes, like any object prop in React. Define them outside the component or wrap in `useMemo` to avoid unnecessary re-resolution:
273
+
274
+ ```tsx
275
+ // Good — stable reference
276
+ const myOverrides = { 'pt-BR': ['pt'] };
277
+
278
+ function App() {
279
+ return (
280
+ <LocaleChainProvider overrides={myOverrides} ... />
281
+ );
282
+ }
283
+ ```
284
+
285
+ ## Contributing
286
+
287
+ - Open issues for bugs or feature requests.
288
+ - PRs welcome, especially for adding new locale fallback chains.
289
+ - Run `npm test` before submitting.
290
+
291
+ ## License
292
+
293
+ MIT License - see [LICENSE](LICENSE) file.
294
+
295
+ Built by [i18nagent.ai](https://i18nagent.ai)
@@ -0,0 +1,37 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import { IntlConfig } from 'react-intl';
3
+
4
+ type Messages = Record<string, any>;
5
+ type FallbackMap = Record<string, string[]>;
6
+ type LoadMessages = (locale: string) => Messages | Promise<Messages>;
7
+ interface LocaleChainConfig {
8
+ locale: string;
9
+ defaultLocale: string;
10
+ loadMessages: LoadMessages;
11
+ overrides?: FallbackMap;
12
+ fallbacks?: FallbackMap;
13
+ mergeDefaults?: boolean;
14
+ }
15
+ /**
16
+ * Derive IntlProvider props from react-intl's own types for forward compatibility.
17
+ * We control locale, messages, and defaultLocale — pass everything else through.
18
+ */
19
+ type IntlProviderPassthroughProps = Omit<Partial<IntlConfig>, 'locale' | 'messages' | 'defaultLocale'>;
20
+ interface LocaleChainProviderProps extends LocaleChainConfig, IntlProviderPassthroughProps {
21
+ children: React.ReactNode;
22
+ fallback?: React.ReactNode;
23
+ }
24
+
25
+ declare function LocaleChainProvider({ locale, defaultLocale, loadMessages, overrides, fallbacks: fallbacksProp, mergeDefaults: mergeDefaultsProp, children, fallback, ...intlProviderProps }: LocaleChainProviderProps): react_jsx_runtime.JSX.Element;
26
+
27
+ declare function deepMerge(target: Messages, source: Messages): Messages;
28
+ /**
29
+ * Resolve effective fallback map from config, then resolve messages.
30
+ * Pure utility — no React dependency.
31
+ */
32
+ declare function mergeMessagesFromChain(config: LocaleChainConfig): Promise<Messages>;
33
+
34
+ declare const defaultFallbacks: FallbackMap;
35
+ declare function mergeFallbacks(defaults: FallbackMap, overrides: FallbackMap): FallbackMap;
36
+
37
+ export { type FallbackMap, type IntlProviderPassthroughProps, type LoadMessages, type LocaleChainConfig, LocaleChainProvider, type LocaleChainProviderProps, type Messages, deepMerge, defaultFallbacks, mergeFallbacks, mergeMessagesFromChain };
@@ -0,0 +1,37 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import { IntlConfig } from 'react-intl';
3
+
4
+ type Messages = Record<string, any>;
5
+ type FallbackMap = Record<string, string[]>;
6
+ type LoadMessages = (locale: string) => Messages | Promise<Messages>;
7
+ interface LocaleChainConfig {
8
+ locale: string;
9
+ defaultLocale: string;
10
+ loadMessages: LoadMessages;
11
+ overrides?: FallbackMap;
12
+ fallbacks?: FallbackMap;
13
+ mergeDefaults?: boolean;
14
+ }
15
+ /**
16
+ * Derive IntlProvider props from react-intl's own types for forward compatibility.
17
+ * We control locale, messages, and defaultLocale — pass everything else through.
18
+ */
19
+ type IntlProviderPassthroughProps = Omit<Partial<IntlConfig>, 'locale' | 'messages' | 'defaultLocale'>;
20
+ interface LocaleChainProviderProps extends LocaleChainConfig, IntlProviderPassthroughProps {
21
+ children: React.ReactNode;
22
+ fallback?: React.ReactNode;
23
+ }
24
+
25
+ declare function LocaleChainProvider({ locale, defaultLocale, loadMessages, overrides, fallbacks: fallbacksProp, mergeDefaults: mergeDefaultsProp, children, fallback, ...intlProviderProps }: LocaleChainProviderProps): react_jsx_runtime.JSX.Element;
26
+
27
+ declare function deepMerge(target: Messages, source: Messages): Messages;
28
+ /**
29
+ * Resolve effective fallback map from config, then resolve messages.
30
+ * Pure utility — no React dependency.
31
+ */
32
+ declare function mergeMessagesFromChain(config: LocaleChainConfig): Promise<Messages>;
33
+
34
+ declare const defaultFallbacks: FallbackMap;
35
+ declare function mergeFallbacks(defaults: FallbackMap, overrides: FallbackMap): FallbackMap;
36
+
37
+ export { type FallbackMap, type IntlProviderPassthroughProps, type LoadMessages, type LocaleChainConfig, LocaleChainProvider, type LocaleChainProviderProps, type Messages, deepMerge, defaultFallbacks, mergeFallbacks, mergeMessagesFromChain };
package/dist/index.js ADDED
@@ -0,0 +1,257 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ LocaleChainProvider: () => LocaleChainProvider,
24
+ deepMerge: () => deepMerge,
25
+ defaultFallbacks: () => defaultFallbacks,
26
+ mergeFallbacks: () => mergeFallbacks,
27
+ mergeMessagesFromChain: () => mergeMessagesFromChain
28
+ });
29
+ module.exports = __toCommonJS(index_exports);
30
+
31
+ // src/locale-chain-provider.tsx
32
+ var import_react = require("react");
33
+ var import_react_intl = require("react-intl");
34
+
35
+ // src/fallback-map.ts
36
+ var defaultFallbacks = {
37
+ // Portuguese
38
+ "pt-BR": ["pt-PT", "pt"],
39
+ "pt-PT": ["pt"],
40
+ // Spanish (Latin America uses es-419 as intermediate)
41
+ "es-419": ["es"],
42
+ "es-MX": ["es-419", "es"],
43
+ "es-AR": ["es-419", "es"],
44
+ "es-CO": ["es-419", "es"],
45
+ "es-CL": ["es-419", "es"],
46
+ "es-PE": ["es-419", "es"],
47
+ "es-VE": ["es-419", "es"],
48
+ "es-EC": ["es-419", "es"],
49
+ "es-GT": ["es-419", "es"],
50
+ "es-CU": ["es-419", "es"],
51
+ "es-BO": ["es-419", "es"],
52
+ "es-DO": ["es-419", "es"],
53
+ "es-HN": ["es-419", "es"],
54
+ "es-PY": ["es-419", "es"],
55
+ "es-SV": ["es-419", "es"],
56
+ "es-NI": ["es-419", "es"],
57
+ "es-CR": ["es-419", "es"],
58
+ "es-PA": ["es-419", "es"],
59
+ "es-UY": ["es-419", "es"],
60
+ "es-PR": ["es-419", "es"],
61
+ // French
62
+ "fr-CA": ["fr"],
63
+ "fr-BE": ["fr"],
64
+ "fr-CH": ["fr"],
65
+ "fr-LU": ["fr"],
66
+ "fr-MC": ["fr"],
67
+ "fr-SN": ["fr"],
68
+ "fr-CI": ["fr"],
69
+ "fr-ML": ["fr"],
70
+ "fr-CM": ["fr"],
71
+ "fr-MG": ["fr"],
72
+ "fr-CD": ["fr"],
73
+ // German
74
+ "de-AT": ["de"],
75
+ "de-CH": ["de"],
76
+ "de-LU": ["de"],
77
+ "de-LI": ["de"],
78
+ // Italian
79
+ "it-CH": ["it"],
80
+ // Dutch
81
+ "nl-BE": ["nl"],
82
+ // Norwegian
83
+ nb: ["no"],
84
+ nn: ["nb", "no"],
85
+ // Malay
86
+ "ms-MY": ["ms"],
87
+ "ms-SG": ["ms"],
88
+ "ms-BN": ["ms"]
89
+ };
90
+ function mergeFallbacks(defaults, overrides) {
91
+ return { ...defaults, ...overrides };
92
+ }
93
+
94
+ // src/message-resolver.ts
95
+ function deepMerge(target, source) {
96
+ const result = { ...target };
97
+ for (const key of Object.keys(source)) {
98
+ if (source[key] !== null && typeof source[key] === "object" && !Array.isArray(source[key]) && target[key] !== null && typeof target[key] === "object" && !Array.isArray(target[key])) {
99
+ result[key] = deepMerge(target[key], source[key]);
100
+ } else {
101
+ result[key] = source[key];
102
+ }
103
+ }
104
+ return result;
105
+ }
106
+ function buildLoadOrder({
107
+ locale,
108
+ chain,
109
+ defaultLocale
110
+ }) {
111
+ const seen = /* @__PURE__ */ new Set();
112
+ const loadOrder = [];
113
+ for (const l of [defaultLocale, ...chain.slice().reverse(), locale]) {
114
+ if (!seen.has(l)) {
115
+ seen.add(l);
116
+ loadOrder.push(l);
117
+ }
118
+ }
119
+ return loadOrder;
120
+ }
121
+ async function resolveMessages(opts) {
122
+ const loadOrder = buildLoadOrder(opts);
123
+ const results = await Promise.allSettled(
124
+ loadOrder.map(async (l) => opts.loadMessages(l))
125
+ );
126
+ let merged = {};
127
+ for (const result of results) {
128
+ if (result.status === "fulfilled") {
129
+ merged = deepMerge(merged, result.value);
130
+ }
131
+ }
132
+ return merged;
133
+ }
134
+ function resolveMessagesSync(opts) {
135
+ const loadOrder = buildLoadOrder(opts);
136
+ let result = {};
137
+ for (const l of loadOrder) {
138
+ try {
139
+ const messages = opts.loadMessages(l);
140
+ if (messages && typeof messages.then === "function") {
141
+ return null;
142
+ }
143
+ result = deepMerge(result, messages);
144
+ } catch {
145
+ }
146
+ }
147
+ return result;
148
+ }
149
+ async function mergeMessagesFromChain(config) {
150
+ const { locale, defaultLocale, loadMessages } = config;
151
+ let effectiveFallbacks;
152
+ if (config.fallbacks) {
153
+ effectiveFallbacks = config.mergeDefaults === false ? config.fallbacks : mergeFallbacks(defaultFallbacks, config.fallbacks);
154
+ } else if (config.overrides) {
155
+ effectiveFallbacks = mergeFallbacks(defaultFallbacks, config.overrides);
156
+ } else {
157
+ effectiveFallbacks = defaultFallbacks;
158
+ }
159
+ const chain = effectiveFallbacks[locale] || [];
160
+ return resolveMessages({ locale, chain, defaultLocale, loadMessages });
161
+ }
162
+
163
+ // src/locale-chain-provider.tsx
164
+ var import_jsx_runtime = require("react/jsx-runtime");
165
+ function LocaleChainProvider({
166
+ locale,
167
+ defaultLocale,
168
+ loadMessages,
169
+ overrides,
170
+ fallbacks: fallbacksProp,
171
+ mergeDefaults: mergeDefaultsProp,
172
+ children,
173
+ fallback = null,
174
+ ...intlProviderProps
175
+ }) {
176
+ const effectiveFallbacks = (0, import_react.useMemo)(() => {
177
+ if (fallbacksProp) {
178
+ return mergeDefaultsProp === false ? fallbacksProp : mergeFallbacks(defaultFallbacks, fallbacksProp);
179
+ }
180
+ if (overrides) {
181
+ return mergeFallbacks(defaultFallbacks, overrides);
182
+ }
183
+ return defaultFallbacks;
184
+ }, [fallbacksProp, overrides, mergeDefaultsProp]);
185
+ const chain = (0, import_react.useMemo)(
186
+ () => effectiveFallbacks[locale] || [],
187
+ [effectiveFallbacks, locale]
188
+ );
189
+ const [state, setState] = (0, import_react.useState)(() => {
190
+ try {
191
+ const result = resolveMessagesSync({
192
+ locale,
193
+ chain,
194
+ defaultLocale,
195
+ loadMessages
196
+ });
197
+ if (result !== null) {
198
+ return { messages: result, loading: false, resolvedLocale: locale };
199
+ }
200
+ } catch {
201
+ }
202
+ return { messages: null, loading: true, resolvedLocale: null };
203
+ });
204
+ (0, import_react.useEffect)(() => {
205
+ try {
206
+ const syncResult = resolveMessagesSync({
207
+ locale,
208
+ chain,
209
+ defaultLocale,
210
+ loadMessages
211
+ });
212
+ if (syncResult !== null) {
213
+ setState({ messages: syncResult, loading: false, resolvedLocale: locale });
214
+ return;
215
+ }
216
+ } catch {
217
+ }
218
+ let cancelled = false;
219
+ setState((prev) => ({ ...prev, loading: true }));
220
+ resolveMessages({ locale, chain, defaultLocale, loadMessages }).then(
221
+ (merged) => {
222
+ if (!cancelled) {
223
+ setState({ messages: merged, loading: false, resolvedLocale: locale });
224
+ }
225
+ },
226
+ () => {
227
+ if (!cancelled) {
228
+ setState({ messages: {}, loading: false, resolvedLocale: locale });
229
+ }
230
+ }
231
+ );
232
+ return () => {
233
+ cancelled = true;
234
+ };
235
+ }, [locale, defaultLocale, loadMessages, chain]);
236
+ if (state.loading || state.messages === null) {
237
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_jsx_runtime.Fragment, { children: fallback });
238
+ }
239
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
240
+ import_react_intl.IntlProvider,
241
+ {
242
+ locale,
243
+ messages: state.messages,
244
+ defaultLocale,
245
+ ...intlProviderProps,
246
+ children
247
+ }
248
+ );
249
+ }
250
+ // Annotate the CommonJS export names for ESM import in node:
251
+ 0 && (module.exports = {
252
+ LocaleChainProvider,
253
+ deepMerge,
254
+ defaultFallbacks,
255
+ mergeFallbacks,
256
+ mergeMessagesFromChain
257
+ });
package/dist/index.mjs ADDED
@@ -0,0 +1,226 @@
1
+ // src/locale-chain-provider.tsx
2
+ import { useState, useEffect, useMemo } from "react";
3
+ import { IntlProvider } from "react-intl";
4
+
5
+ // src/fallback-map.ts
6
+ var defaultFallbacks = {
7
+ // Portuguese
8
+ "pt-BR": ["pt-PT", "pt"],
9
+ "pt-PT": ["pt"],
10
+ // Spanish (Latin America uses es-419 as intermediate)
11
+ "es-419": ["es"],
12
+ "es-MX": ["es-419", "es"],
13
+ "es-AR": ["es-419", "es"],
14
+ "es-CO": ["es-419", "es"],
15
+ "es-CL": ["es-419", "es"],
16
+ "es-PE": ["es-419", "es"],
17
+ "es-VE": ["es-419", "es"],
18
+ "es-EC": ["es-419", "es"],
19
+ "es-GT": ["es-419", "es"],
20
+ "es-CU": ["es-419", "es"],
21
+ "es-BO": ["es-419", "es"],
22
+ "es-DO": ["es-419", "es"],
23
+ "es-HN": ["es-419", "es"],
24
+ "es-PY": ["es-419", "es"],
25
+ "es-SV": ["es-419", "es"],
26
+ "es-NI": ["es-419", "es"],
27
+ "es-CR": ["es-419", "es"],
28
+ "es-PA": ["es-419", "es"],
29
+ "es-UY": ["es-419", "es"],
30
+ "es-PR": ["es-419", "es"],
31
+ // French
32
+ "fr-CA": ["fr"],
33
+ "fr-BE": ["fr"],
34
+ "fr-CH": ["fr"],
35
+ "fr-LU": ["fr"],
36
+ "fr-MC": ["fr"],
37
+ "fr-SN": ["fr"],
38
+ "fr-CI": ["fr"],
39
+ "fr-ML": ["fr"],
40
+ "fr-CM": ["fr"],
41
+ "fr-MG": ["fr"],
42
+ "fr-CD": ["fr"],
43
+ // German
44
+ "de-AT": ["de"],
45
+ "de-CH": ["de"],
46
+ "de-LU": ["de"],
47
+ "de-LI": ["de"],
48
+ // Italian
49
+ "it-CH": ["it"],
50
+ // Dutch
51
+ "nl-BE": ["nl"],
52
+ // Norwegian
53
+ nb: ["no"],
54
+ nn: ["nb", "no"],
55
+ // Malay
56
+ "ms-MY": ["ms"],
57
+ "ms-SG": ["ms"],
58
+ "ms-BN": ["ms"]
59
+ };
60
+ function mergeFallbacks(defaults, overrides) {
61
+ return { ...defaults, ...overrides };
62
+ }
63
+
64
+ // src/message-resolver.ts
65
+ function deepMerge(target, source) {
66
+ const result = { ...target };
67
+ for (const key of Object.keys(source)) {
68
+ if (source[key] !== null && typeof source[key] === "object" && !Array.isArray(source[key]) && target[key] !== null && typeof target[key] === "object" && !Array.isArray(target[key])) {
69
+ result[key] = deepMerge(target[key], source[key]);
70
+ } else {
71
+ result[key] = source[key];
72
+ }
73
+ }
74
+ return result;
75
+ }
76
+ function buildLoadOrder({
77
+ locale,
78
+ chain,
79
+ defaultLocale
80
+ }) {
81
+ const seen = /* @__PURE__ */ new Set();
82
+ const loadOrder = [];
83
+ for (const l of [defaultLocale, ...chain.slice().reverse(), locale]) {
84
+ if (!seen.has(l)) {
85
+ seen.add(l);
86
+ loadOrder.push(l);
87
+ }
88
+ }
89
+ return loadOrder;
90
+ }
91
+ async function resolveMessages(opts) {
92
+ const loadOrder = buildLoadOrder(opts);
93
+ const results = await Promise.allSettled(
94
+ loadOrder.map(async (l) => opts.loadMessages(l))
95
+ );
96
+ let merged = {};
97
+ for (const result of results) {
98
+ if (result.status === "fulfilled") {
99
+ merged = deepMerge(merged, result.value);
100
+ }
101
+ }
102
+ return merged;
103
+ }
104
+ function resolveMessagesSync(opts) {
105
+ const loadOrder = buildLoadOrder(opts);
106
+ let result = {};
107
+ for (const l of loadOrder) {
108
+ try {
109
+ const messages = opts.loadMessages(l);
110
+ if (messages && typeof messages.then === "function") {
111
+ return null;
112
+ }
113
+ result = deepMerge(result, messages);
114
+ } catch {
115
+ }
116
+ }
117
+ return result;
118
+ }
119
+ async function mergeMessagesFromChain(config) {
120
+ const { locale, defaultLocale, loadMessages } = config;
121
+ let effectiveFallbacks;
122
+ if (config.fallbacks) {
123
+ effectiveFallbacks = config.mergeDefaults === false ? config.fallbacks : mergeFallbacks(defaultFallbacks, config.fallbacks);
124
+ } else if (config.overrides) {
125
+ effectiveFallbacks = mergeFallbacks(defaultFallbacks, config.overrides);
126
+ } else {
127
+ effectiveFallbacks = defaultFallbacks;
128
+ }
129
+ const chain = effectiveFallbacks[locale] || [];
130
+ return resolveMessages({ locale, chain, defaultLocale, loadMessages });
131
+ }
132
+
133
+ // src/locale-chain-provider.tsx
134
+ import { Fragment, jsx } from "react/jsx-runtime";
135
+ function LocaleChainProvider({
136
+ locale,
137
+ defaultLocale,
138
+ loadMessages,
139
+ overrides,
140
+ fallbacks: fallbacksProp,
141
+ mergeDefaults: mergeDefaultsProp,
142
+ children,
143
+ fallback = null,
144
+ ...intlProviderProps
145
+ }) {
146
+ const effectiveFallbacks = useMemo(() => {
147
+ if (fallbacksProp) {
148
+ return mergeDefaultsProp === false ? fallbacksProp : mergeFallbacks(defaultFallbacks, fallbacksProp);
149
+ }
150
+ if (overrides) {
151
+ return mergeFallbacks(defaultFallbacks, overrides);
152
+ }
153
+ return defaultFallbacks;
154
+ }, [fallbacksProp, overrides, mergeDefaultsProp]);
155
+ const chain = useMemo(
156
+ () => effectiveFallbacks[locale] || [],
157
+ [effectiveFallbacks, locale]
158
+ );
159
+ const [state, setState] = useState(() => {
160
+ try {
161
+ const result = resolveMessagesSync({
162
+ locale,
163
+ chain,
164
+ defaultLocale,
165
+ loadMessages
166
+ });
167
+ if (result !== null) {
168
+ return { messages: result, loading: false, resolvedLocale: locale };
169
+ }
170
+ } catch {
171
+ }
172
+ return { messages: null, loading: true, resolvedLocale: null };
173
+ });
174
+ useEffect(() => {
175
+ try {
176
+ const syncResult = resolveMessagesSync({
177
+ locale,
178
+ chain,
179
+ defaultLocale,
180
+ loadMessages
181
+ });
182
+ if (syncResult !== null) {
183
+ setState({ messages: syncResult, loading: false, resolvedLocale: locale });
184
+ return;
185
+ }
186
+ } catch {
187
+ }
188
+ let cancelled = false;
189
+ setState((prev) => ({ ...prev, loading: true }));
190
+ resolveMessages({ locale, chain, defaultLocale, loadMessages }).then(
191
+ (merged) => {
192
+ if (!cancelled) {
193
+ setState({ messages: merged, loading: false, resolvedLocale: locale });
194
+ }
195
+ },
196
+ () => {
197
+ if (!cancelled) {
198
+ setState({ messages: {}, loading: false, resolvedLocale: locale });
199
+ }
200
+ }
201
+ );
202
+ return () => {
203
+ cancelled = true;
204
+ };
205
+ }, [locale, defaultLocale, loadMessages, chain]);
206
+ if (state.loading || state.messages === null) {
207
+ return /* @__PURE__ */ jsx(Fragment, { children: fallback });
208
+ }
209
+ return /* @__PURE__ */ jsx(
210
+ IntlProvider,
211
+ {
212
+ locale,
213
+ messages: state.messages,
214
+ defaultLocale,
215
+ ...intlProviderProps,
216
+ children
217
+ }
218
+ );
219
+ }
220
+ export {
221
+ LocaleChainProvider,
222
+ deepMerge,
223
+ defaultFallbacks,
224
+ mergeFallbacks,
225
+ mergeMessagesFromChain
226
+ };
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "react-intl-locale-chain",
3
+ "version": "0.1.0",
4
+ "description": "Smart locale fallback chains for react-intl",
5
+ "main": "./dist/index.js",
6
+ "module": "./dist/index.mjs",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": {
11
+ "types": "./dist/index.d.mts",
12
+ "default": "./dist/index.mjs"
13
+ },
14
+ "require": {
15
+ "types": "./dist/index.d.ts",
16
+ "default": "./dist/index.js"
17
+ }
18
+ }
19
+ },
20
+ "files": [
21
+ "dist"
22
+ ],
23
+ "sideEffects": false,
24
+ "peerDependencies": {
25
+ "react": ">=16.8.0",
26
+ "react-intl": ">=5.0.0"
27
+ },
28
+ "devDependencies": {
29
+ "react": "^18.0.0",
30
+ "react-dom": "^18.0.0",
31
+ "react-intl": "^6.0.0",
32
+ "@testing-library/react": "^14.0.0",
33
+ "@testing-library/jest-dom": "^6.0.0",
34
+ "typescript": "^5.5.0",
35
+ "vitest": "^2.0.0",
36
+ "tsup": "^8.0.0",
37
+ "@types/react": "^18.0.0",
38
+ "jsdom": "^24.0.0"
39
+ },
40
+ "keywords": [
41
+ "react-intl",
42
+ "formatjs",
43
+ "i18n",
44
+ "locale",
45
+ "fallback",
46
+ "chain",
47
+ "localization",
48
+ "internationalization"
49
+ ],
50
+ "author": "i18nagent.ai",
51
+ "license": "MIT",
52
+ "repository": {
53
+ "type": "git",
54
+ "url": "https://github.com/i18n-agent/react-intl-localechain"
55
+ },
56
+ "homepage": "https://github.com/i18n-agent/react-intl-localechain#readme",
57
+ "bugs": {
58
+ "url": "https://github.com/i18n-agent/react-intl-localechain/issues"
59
+ },
60
+ "scripts": {
61
+ "build": "tsup",
62
+ "test": "vitest run",
63
+ "test:watch": "vitest",
64
+ "lint": "tsc --noEmit"
65
+ }
66
+ }