@ayinza_dev/i18n-config 1.3.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) 2025 Ayinza Technologies
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
13
+ all 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
21
+ THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,444 @@
1
+ # ayinza-i18n-config
2
+
3
+ Shared i18next configuration and formatting utilities for Ayinza portals.
4
+
5
+ This package centralizes i18n initialization, sensible defaults, and a set of
6
+ formatters (currency, number, percent, date/time, relative time) that are
7
+ integrated with i18next's interpolation system. It also provides React hooks
8
+ for easy consumption in React apps.
9
+
10
+ ## Table of contents
11
+
12
+ - About
13
+ - Installation
14
+ - Quick start
15
+ - API
16
+ - Configuration
17
+ - Examples
18
+ - Translation extraction
19
+ - Testing
20
+ - Development
21
+ - License
22
+
23
+ ## About
24
+
25
+ The library bundles a default i18n configuration (detection, backend, namespaces,
26
+ and formatters) and exposes helpers to initialize i18next, access the global
27
+ i18n instance, and consume localization + formatting helpers in React
28
+ components.
29
+
30
+ It is intentionally lightweight and designed to be used as a shared
31
+ dependency across multiple portals that want consistent localization behavior.
32
+
33
+ ## Installation
34
+
35
+ Install the package and peer dependencies (peer dependencies are required by
36
+ consumers and not bundled):
37
+
38
+ ```bash
39
+ # Using npm
40
+ npm install @ayinza_dev/i18n-config i18next react react-i18next
41
+
42
+ # Using yarn
43
+ yarn add @ayinza_dev/i18n-config i18next react react-i18next
44
+ ```
45
+
46
+ Also install the optional runtime backends used by this package (the package
47
+ declares them as dependencies):
48
+
49
+ ```bash
50
+ npm install i18next-http-backend i18next-browser-languagedetector
51
+ ```
52
+
53
+ Note: This package declares `i18next`, `react`, and `react-i18next` as
54
+ peerDependencies — install versions compatible with your app.
55
+
56
+ ## Quick start
57
+
58
+ Initialize i18n at application startup (for example in `src/main.tsx` or
59
+ `src/index.tsx` in a React app):
60
+
61
+ ```ts
62
+ import React from "react";
63
+ import { createRoot } from "react-dom/client";
64
+ import App from "./App";
65
+ import { initializeI18n } from "@ayinza_dev/i18n-config";
66
+
67
+ // Optional: pass overrides to customize backend, supported languages, or
68
+ // formatters.
69
+ initializeI18n({
70
+ config: {
71
+ portalName: "My Portal",
72
+ backend: {
73
+ loadPath: "/locales/{{lng}}/{{ns}}.json",
74
+ customHeaders: { "X-Portal": "my-portal" },
75
+ },
76
+ supportedLngs: ["en", "ar", "fr"],
77
+ },
78
+ })
79
+ .then(() => {
80
+ const root = createRoot(document.getElementById("root")!);
81
+ root.render(<App />);
82
+ })
83
+ .catch((err) => console.error(err));
84
+ ```
85
+
86
+ The function returns the initialized i18next instance and also registers a set
87
+ of custom formatters so you may use them inside translations (interpolation
88
+ formatters) or via the provided hooks.
89
+
90
+ > **SSR note:** `initializeI18n` touches browser-only globals to set the
91
+ > `dir` attribute. The implementation now guards those calls, but you should
92
+ > still run initialization on the client (e.g., inside a `useEffect` or
93
+ > Next.js `use client` entry point) to ensure detectors and DOM updates work.
94
+
95
+ ## Loading translations from localization-service
96
+
97
+ Ayinza serves its shared translation catalog from `localization-service`, whose
98
+ responses are wrapped in an envelope: `{ "data": { "translations": { ... } } }`.
99
+ Pass a `localization` block and the library fetches that catalog for each active
100
+ language and overlays it (deep-merge, **remote values win**) on top of whatever
101
+ the bundled `backend.loadPath` provides as an offline base:
102
+
103
+ ```ts
104
+ import { initializeI18n } from "@ayinza_dev/i18n-config";
105
+
106
+ await initializeI18n({
107
+ config: {
108
+ localization: {
109
+ baseUrl: import.meta.env.VITE_LOCALIZATION_API_BASE_URL, // e.g. https://localization.example/api/v1
110
+ // path defaults to "/l10n/translations/{{lng}}"
111
+ // category: "sseris", // optional ?category= filter (notification-service scopes; UIs usually omit)
112
+ // headers: { ... },
113
+ },
114
+ supportedLngs: ["en-US", "ar-SA"],
115
+ fallbackLng: "en-US",
116
+ },
117
+ });
118
+ ```
119
+
120
+ Behavior:
121
+
122
+ - **Single fetch per language, lazy.** The active + fallback languages load
123
+ before init resolves; any other language loads the first time it's selected
124
+ (via `languageChanged`). No app should fetch the catalog itself.
125
+ - **Envelope-aware.** `{ data: { translations } }` (or a bare `{ translations }`
126
+ / flat map) is unwrapped for you.
127
+ - **Resilient.** A failed load never throws — the bundled base (and inline `t()`
128
+ defaults) remain; a later language switch retries.
129
+ - **RTL + formatters** continue to work unchanged.
130
+
131
+ This replaces the old per-app pattern of pointing `backend.loadPath` at the API
132
+ and manually re-fetching to unwrap the envelope.
133
+
134
+ ## API
135
+
136
+ Top-level exports (from `src/index.ts`):
137
+
138
+ - `initializeI18n(options?: I18nInitOptions): Promise<i18n>` — initialize the
139
+ i18next instance with defaults merged with your overrides.
140
+ - `getI18nInstance(): i18n` — access the i18next singleton.
141
+ - `getFormatters(): I18nFormatters` — get the formatter instance (throws if
142
+ not initialized).
143
+ - `defaultConfig` — the default configuration object used by
144
+ `initializeI18n`.
145
+ - `createI18nConfig(partial?: Partial<I18nConfig>)` — returns a fully merged
146
+ config object without initializing i18next; useful for building configs in
147
+ build-time tooling or sharing defaults across portals.
148
+ - `createTranslationSnapshot(options: CreateTranslationSnapshotOptions)` —
149
+ flatten translation JSON trees into a comparable snapshot for diffing.
150
+ - `collectNewTranslationKeys(options: CollectNewTranslationKeysOptions)` —
151
+ compute keys that were added between two snapshots.
152
+ - `handleNewTranslationKeys(options: HandleNewTranslationKeysOptions)` — push
153
+ detected keys to a remote endpoint or log them during dry runs.
154
+ - `useFormatting()` — React hook providing formatting helpers bound to the
155
+ current language.
156
+ - `useI18n()` — combined hook returning both `useTranslation()` props and
157
+ formatting helpers.
158
+ - Both hooks expect `initializeI18n` to have completed; call initialization in
159
+ your app bootstrap before rendering components that use them, otherwise
160
+ `getFormatters()` will throw.
161
+ - Re-exports: `useTranslation`, `Trans`, `Translation` from `react-i18next`.
162
+
163
+ Types exported (from `src/types.ts`):
164
+
165
+ - `I18nConfig` — top-level configuration object shape.
166
+ - `I18nInitOptions` — options for `initializeI18n`.
167
+ - `FormattersConfig`, `LocaleMapping` — configuration shapes for formatters.
168
+
169
+ Formatters class: `I18nFormatters` provides methods such as:
170
+
171
+ - `formatCurrency(amount, language, currency?, options?)`
172
+ - `formatNumber(value, language, options?)`
173
+ - `formatPercent(value, language, options?)`
174
+ - `formatDate(date, language, options?)`
175
+ - `formatTime(date, language, options?)`
176
+ - `formatDateTime(date, language, options?)`
177
+ - `formatRelativeTime(value, unit, language, options?)`
178
+
179
+ These are already wired into i18next as interpolation formatters named
180
+ `currency`, `number`, `percent`, `date`, `time`, `datetime`, and `relative`.
181
+
182
+ Every formatter catches `Intl` errors and falls back to simple strings (for
183
+ example, returning `INVALID 100` for a bad currency code or `toLocaleString()`
184
+ for an invalid date). This keeps your UI from crashing, but you may still see
185
+ console warnings when supplying malformed input.
186
+
187
+ ## Configuration
188
+
189
+ `defaultConfig` (summary):
190
+
191
+ - backend: { loadPath }
192
+ - detection: browser language detection configuration
193
+ - fallbackLng: `en`
194
+ - supportedLngs: `["en","ar","fr","es"]` (override to match your portal to
195
+ avoid loading unused bundles)
196
+ - defaultNS / ns: namespaces used (feel free to switch to `common` if that is
197
+ your primary namespace)
198
+ - interpolation.escapeValue: false
199
+ - formatters: default formatter configuration (defaultCurrency `USD`,
200
+ `fallbackLocale: "en-US"` when no locale mapping matches)
201
+ - react: `{ useSuspense: true }` but you can extend it with
202
+ `bindI18n`/`bindI18nStore` to match your React rendering mode
203
+
204
+ You can override only the pieces you need — `initializeI18n` merges defaults
205
+ with your partial config. If you need a pure helper (no side effects) to
206
+ assemble configs, use `createI18nConfig({ ...overrides })` and feed the result
207
+ into your own bootstrap logic.
208
+
209
+ Common overrides:
210
+
211
+ - `supportedLngs`: keep this list scoped to the locales your portal actually
212
+ serves so language detection stays predictable and bundles stay small.
213
+ - `ns` / `defaultNS`: if you share a `common` namespace across portals,
214
+ consider setting `defaultNS: "common"` and trimming the `ns` array.
215
+ - `react`: set `useSuspense: false` for legacy React renderers or provide
216
+ `bindI18n: "languageChanged"` when coordinating with data-fetching layers.
217
+ - `formatters.fallbackLocale`: change this if your organization defaults to a
218
+ locale other than English; it is used whenever a language code is missing
219
+ from the locale mapping tables.
220
+ - **Module format:** The published package currently ships as an ES module build
221
+ (per `tsconfig.json`). If your tooling expects CommonJS, configure your bundler
222
+ to transpile ESM or consider contributing a dual-build setup.
223
+
224
+ ## Examples
225
+
226
+ Use translation + formatting together in a React component:
227
+
228
+ ```tsx
229
+ import React from "react";
230
+ import { useI18n } from "@ayinza_dev/i18n-config";
231
+
232
+ function Price({ amount }: { amount: number }) {
233
+ const { t, formatCurrency } = useI18n();
234
+
235
+ return (
236
+ <div>
237
+ <h3>{t("priceHeading")}</h3>
238
+ <p>{formatCurrency(amount)}</p>
239
+ </div>
240
+ );
241
+ }
242
+ ```
243
+
244
+ Using formatters directly (non-React):
245
+
246
+ ```ts
247
+ import { initializeI18n, getFormatters } from "@ayinza_dev/i18n-config";
248
+
249
+ async function start() {
250
+ await initializeI18n();
251
+ const fmt = getFormatters();
252
+ console.log(fmt.formatCurrency(19.99, "en", "USD"));
253
+ }
254
+ ```
255
+
256
+ Using interpolation in translation strings (example `en/common.json`):
257
+
258
+ ```json
259
+ {
260
+ "price": "{{value, currency}}"
261
+ }
262
+ ```
263
+
264
+ Then `t('price', { value: 19.99, currency: 'EUR' })` will use the registered
265
+ `currency` formatter.
266
+
267
+ ## Integrating Across Multiple Portals
268
+
269
+ When sharing this package across portals, keep initialization centralized so
270
+ each shell bootstraps consistently:
271
+
272
+ 1. Create a thin wrapper (e.g., `packages/i18n/client.ts`) that calls
273
+ `initializeI18n` with portal-specific overrides such as namespace lists or
274
+ branding headers.
275
+ 2. Import only that wrapper from each portal entry point to keep behavior
276
+ aligned and avoid forgetting required detectors/backends.
277
+ 3. Re-export helpers (`useI18n`, `getFormatters`) from your shell layer so
278
+ downstream micro frontends consume the same singleton instance.
279
+ 4. For SSR/Next.js, run `initializeI18n` inside client components or a `useEffect`
280
+ guard to allow detectors to access browser APIs, then hydrate shared hooks.
281
+
282
+ Example shared bootstrap that portals can reuse:
283
+
284
+ ```ts
285
+ // packages/i18n/bootstrap.ts
286
+ import {
287
+ initializeI18n,
288
+ getFormatters,
289
+ useI18n,
290
+ } from "@ayinza_dev/i18n-config";
291
+
292
+ export async function setupPortalI18n(portalName: string) {
293
+ await initializeI18n({
294
+ config: {
295
+ portalName,
296
+ backend: {
297
+ loadPath: `/locales/${portalName}/{{lng}}/{{ns}}.json`,
298
+ },
299
+ supportedLngs: ["en", "fr", "sw"],
300
+ },
301
+ });
302
+
303
+ return {
304
+ i18n: getFormatters(),
305
+ useI18n,
306
+ };
307
+ }
308
+
309
+ // portal-a/src/main.tsx
310
+ import { setupPortalI18n } from "@ayinza/portal-shared/i18n";
311
+
312
+ setupPortalI18n("portal-a").then(() => {
313
+ // mount React app here, all child components can call useI18n()
314
+ });
315
+ ```
316
+
317
+ This pattern keeps each portal lightweight while ensuring updates to the core
318
+ localization stack propagate everywhere by upgrading just this package.
319
+
320
+ ## Translation extraction
321
+
322
+ The package now ships with light wrappers around
323
+ [`i18next-parser`](https://github.com/i18next/i18next-parser) so every portal
324
+ can reuse the same extraction defaults and push workflow:
325
+
326
+ 1. **Config helper.** Create `i18next-parser.config.mjs` (or `.cjs`) that simply
327
+ exports `createI18nextParserConfig({ /* overrides */ })`. The helper sets
328
+ consistent defaults (lexers, separators, indentation, `createOldCatalogs`,
329
+ etc.) so every portal parses sources the same way.
330
+
331
+ ```ts
332
+ // i18next-parser.config.mjs
333
+ import { createI18nextParserConfig } from "@ayinza_dev/i18n-config";
334
+
335
+ export default createI18nextParserConfig({
336
+ input: ["src/**/*.{ts,tsx}"],
337
+ locales: ["en"],
338
+ output: "locales/$LOCALE/$NAMESPACE.json",
339
+ });
340
+ ```
341
+
342
+ 2. **Detect new keys.** Capture a snapshot before and after running the parser
343
+ (usually for the default locale) by loading your locale JSON and passing it
344
+ to `createTranslationSnapshot`, then call `collectNewTranslationKeys` to
345
+ compute the delta.
346
+
347
+ ```ts
348
+ import { readFile } from "node:fs/promises";
349
+ import path from "node:path";
350
+ import {
351
+ createTranslationSnapshot,
352
+ collectNewTranslationKeys,
353
+ handleNewTranslationKeys,
354
+ } from "@ayinza_dev/i18n-config";
355
+
356
+ const localesRoot = path.resolve("locales");
357
+ const namespaces = ["translation", "common"];
358
+
359
+ async function loadNamespaces(locale: string) {
360
+ const entries = await Promise.all(
361
+ namespaces.map(async (namespace) => {
362
+ const filePath = path.join(localesRoot, locale, `${namespace}.json`);
363
+ const raw = await readFile(filePath, "utf8");
364
+ return [namespace, JSON.parse(raw) as Record<string, unknown>];
365
+ })
366
+ );
367
+
368
+ return Object.fromEntries(entries) as Record<
369
+ string,
370
+ Record<string, unknown>
371
+ >;
372
+ }
373
+
374
+ const before = createTranslationSnapshot({
375
+ locale: "en",
376
+ namespaces: await loadNamespaces("en"),
377
+ });
378
+
379
+ // Run `npx i18next --config i18next-parser.config.mjs "src/**/*.{ts,tsx}"`
380
+
381
+ const after = createTranslationSnapshot({
382
+ locale: "en",
383
+ namespaces: await loadNamespaces("en"),
384
+ });
385
+
386
+ const newKeys = collectNewTranslationKeys({ previous: before, next: after });
387
+ ```
388
+
389
+ 3. **Push or log.** Pass the detected keys to `handleNewTranslationKeys` to run
390
+ a dry-run or POST them to your translation management service. Configure the
391
+ helper with portal-specific metadata so CI logs stay readable.
392
+
393
+ ```ts
394
+ await handleNewTranslationKeys({
395
+ newKeys,
396
+ pushConfig: {
397
+ portalName: "admin-shell",
398
+ pushUrl: process.env.TRANSLATION_PUSH_URL,
399
+ authorizationToken: process.env.TRANSLATION_PUSH_TOKEN,
400
+ dryRun: process.env.CI === "true" && process.env.DRY_RUN === "true",
401
+ },
402
+ });
403
+ ```
404
+
405
+ If `pushUrl` is omitted or `dryRun` is `true`, the helper only logs detected
406
+ keys. Provide a custom `fetchImpl` via `ParserPushConfig` when running on Node
407
+ versions older than 18 (which lack `global.fetch`).
408
+
409
+ ## Testing
410
+
411
+ There are unit tests for `I18nFormatters` (see `src/formatters.test.ts`) and
412
+ for the config merge helper (see `src/config.test.ts`). Run tests with the
413
+ provided npm scripts:
414
+
415
+ ```bash
416
+ npm test
417
+ # watch mode during development
418
+ npm run test:watch
419
+ ```
420
+
421
+ Note: this repository includes Jest devDependencies configured for TypeScript.
422
+
423
+ ## Development
424
+
425
+ - Build: `npm run build` (compiles to `dist/` using `tsc`)
426
+ - Watch: `npm run build:watch`
427
+ - Test: `npm test` or `npm run test:watch`
428
+
429
+ If you intend to contribute, please run tests and add coverage for new
430
+ features.
431
+
432
+ ## License
433
+
434
+ MIT — see the `LICENSE` file in this repository.
435
+
436
+ ## Next steps & suggestions
437
+
438
+ - Add CI (GitHub Actions) to run tests and build on push/PR.
439
+ - Add usage examples / Storybook snippets for React components that depend on
440
+ formatting.
441
+ - Consider publishing with changelog and semantic-release for automated
442
+ releases.
443
+ - Allow consumers to provide a custom logger/debug handler so initialization
444
+ logs can be routed through their monitoring stack instead of console.
@@ -0,0 +1,8 @@
1
+ import { I18nConfig, I18nInitOptions } from "./types.js";
2
+ import { I18nFormatters } from "./formatters.js";
3
+ declare const defaultConfig: I18nConfig;
4
+ export declare const createI18nConfig: (override?: Partial<I18nConfig>) => I18nConfig;
5
+ export declare const initializeI18n: (options?: I18nInitOptions) => Promise<import("i18next").i18n>;
6
+ export declare const getI18nInstance: () => import("i18next").i18n;
7
+ export declare const getFormatters: () => I18nFormatters;
8
+ export { defaultConfig };
package/dist/config.js ADDED
@@ -0,0 +1,193 @@
1
+ import i18n from "i18next";
2
+ import { initReactI18next } from "react-i18next";
3
+ import Backend from "i18next-http-backend";
4
+ import LanguageDetector from "i18next-browser-languagedetector";
5
+ import { I18nFormatters } from "./formatters.js";
6
+ import { createRemoteCatalogLoader } from "./remote-catalog.js";
7
+ let formattersInstance = null;
8
+ // Default configuration that can be overridden
9
+ const defaultConfig = {
10
+ backend: {
11
+ loadPath: "/locales/{{lng}}/{{ns}}.json",
12
+ },
13
+ detection: {
14
+ order: ["querystring", "cookie", "localStorage", "navigator", "htmlTag"],
15
+ caches: ["localStorage", "cookie"],
16
+ lookupQuerystring: "lng",
17
+ lookupCookie: "i18next",
18
+ lookupLocalStorage: "i18nextLng",
19
+ },
20
+ fallbackLng: "en",
21
+ supportedLngs: ["en", "ar", "fr", "es"],
22
+ defaultNS: "translation",
23
+ ns: ["translation", "common", "validation", "errors"],
24
+ debug: false,
25
+ interpolation: {
26
+ escapeValue: false,
27
+ },
28
+ formatters: {
29
+ currency: {
30
+ defaultCurrency: "USD",
31
+ },
32
+ },
33
+ react: {
34
+ useSuspense: true,
35
+ },
36
+ };
37
+ const mergeFormatters = (base, override) => {
38
+ if (!base && !override) {
39
+ return undefined;
40
+ }
41
+ return {
42
+ currency: {
43
+ ...base?.currency,
44
+ ...override?.currency,
45
+ },
46
+ number: {
47
+ ...base?.number,
48
+ ...override?.number,
49
+ },
50
+ date: {
51
+ ...base?.date,
52
+ ...override?.date,
53
+ },
54
+ fallbackLocale: override?.fallbackLocale ?? base?.fallbackLocale,
55
+ };
56
+ };
57
+ function mergeConfig(base, override) {
58
+ return {
59
+ ...base,
60
+ ...override,
61
+ backend: {
62
+ ...base.backend,
63
+ ...override.backend,
64
+ },
65
+ detection: {
66
+ ...base.detection,
67
+ ...override.detection,
68
+ },
69
+ interpolation: {
70
+ ...base.interpolation,
71
+ ...override.interpolation,
72
+ },
73
+ react: {
74
+ ...base.react,
75
+ ...override.react,
76
+ },
77
+ formatters: mergeFormatters(base.formatters, override.formatters),
78
+ };
79
+ }
80
+ export const createI18nConfig = (override = {}) => mergeConfig(defaultConfig, override);
81
+ /**
82
+ * Register custom formatters with i18next
83
+ * This integrates our formatters into i18next's interpolation system
84
+ */
85
+ function registerFormatters(formattersInstance) {
86
+ i18n.services.formatter?.add("currency", (value, lng, options) => {
87
+ const amount = typeof value === "string" ? parseFloat(value) : value;
88
+ const currency = options?.currency;
89
+ const formatOptions = options?.options || {};
90
+ return formattersInstance.formatCurrency(amount, lng || "en", currency, formatOptions);
91
+ });
92
+ i18n.services.formatter?.add("number", (value, lng, options) => {
93
+ const num = typeof value === "string" ? parseFloat(value) : value;
94
+ return formattersInstance.formatNumber(num, lng || "en", options);
95
+ });
96
+ i18n.services.formatter?.add("percent", (value, lng, options) => {
97
+ const num = typeof value === "string" ? parseFloat(value) : value;
98
+ return formattersInstance.formatPercent(num, lng || "en", options);
99
+ });
100
+ i18n.services.formatter?.add("date", (value, lng, options) => {
101
+ const date = typeof value === "string" || typeof value === "number"
102
+ ? new Date(value)
103
+ : value;
104
+ return formattersInstance.formatDate(date, lng || "en", options);
105
+ });
106
+ i18n.services.formatter?.add("time", (value, lng, options) => {
107
+ const date = typeof value === "string" || typeof value === "number"
108
+ ? new Date(value)
109
+ : value;
110
+ return formattersInstance.formatTime(date, lng || "en", options);
111
+ });
112
+ i18n.services.formatter?.add("datetime", (value, lng, options) => {
113
+ const date = typeof value === "string" || typeof value === "number"
114
+ ? new Date(value)
115
+ : value;
116
+ return formattersInstance.formatDateTime(date, lng || "en", options);
117
+ });
118
+ i18n.services.formatter?.add("relative", (value, lng, options) => {
119
+ const num = typeof value === "string" ? parseFloat(value) : value;
120
+ const unit = options?.unit || "day";
121
+ const formatOptions = options?.options || {};
122
+ return formattersInstance.formatRelativeTime(num, unit, lng || "en", formatOptions);
123
+ });
124
+ }
125
+ export const initializeI18n = async (options = {}) => {
126
+ const { config = {}, onInitialized, onError } = options;
127
+ const finalConfig = mergeConfig(defaultConfig, config);
128
+ formattersInstance = new I18nFormatters(finalConfig.formatters);
129
+ try {
130
+ await i18n.use(Backend).use(LanguageDetector).use(initReactI18next).init({
131
+ backend: finalConfig.backend,
132
+ detection: finalConfig.detection,
133
+ fallbackLng: finalConfig.fallbackLng,
134
+ supportedLngs: finalConfig.supportedLngs,
135
+ defaultNS: finalConfig.defaultNS,
136
+ ns: finalConfig.ns,
137
+ debug: finalConfig.debug,
138
+ interpolation: finalConfig.interpolation,
139
+ react: finalConfig.react,
140
+ });
141
+ // Register custom formatters with i18next
142
+ registerFormatters(formattersInstance);
143
+ const updateDocumentDirection = (lng) => {
144
+ if (typeof document === "undefined") {
145
+ return;
146
+ }
147
+ document.documentElement.dir = i18n.dir(lng);
148
+ };
149
+ // Avoid referencing document during SSR; it's safe to no-op until hydration.
150
+ updateDocumentDirection(i18n.language);
151
+ i18n.on("languageChanged", updateDocumentDirection);
152
+ // Overlay the shared localization-service catalog on top of the bundled
153
+ // base. Lazy per language; failures keep the bundled base (never fatal).
154
+ if (finalConfig.localization?.baseUrl) {
155
+ const ns = finalConfig.defaultNS || "translation";
156
+ const loadRemoteCatalog = createRemoteCatalogLoader(i18n, finalConfig.localization, ns);
157
+ const initialLngs = new Set();
158
+ if (i18n.language)
159
+ initialLngs.add(i18n.language);
160
+ const fallback = finalConfig.fallbackLng;
161
+ if (typeof fallback === "string")
162
+ initialLngs.add(fallback);
163
+ else if (Array.isArray(fallback))
164
+ fallback.forEach((lng) => initialLngs.add(lng));
165
+ // Block init on the active/fallback languages so first paint has them;
166
+ // every other language loads on demand when switched to.
167
+ await Promise.all([...initialLngs].map((lng) => loadRemoteCatalog(lng)));
168
+ i18n.on("languageChanged", (lng) => {
169
+ void loadRemoteCatalog(lng);
170
+ });
171
+ }
172
+ if (onInitialized) {
173
+ onInitialized();
174
+ }
175
+ console.log(`[i18n] Initialized successfully for ${finalConfig.portalName || "portal"}`);
176
+ return i18n;
177
+ }
178
+ catch (error) {
179
+ console.error("[i18n] Initialization failed:", error);
180
+ if (onError) {
181
+ onError(error);
182
+ }
183
+ throw error;
184
+ }
185
+ };
186
+ export const getI18nInstance = () => i18n;
187
+ export const getFormatters = () => {
188
+ if (!formattersInstance) {
189
+ throw new Error("[i18n] Formatters not initialized. Call initializeI18n first.");
190
+ }
191
+ return formattersInstance;
192
+ };
193
+ export { defaultConfig };
@@ -0,0 +1,20 @@
1
+ import { FormattersConfig } from "./types.js";
2
+ export declare class I18nFormatters {
3
+ private currencyConfig;
4
+ private numberConfig;
5
+ private dateConfig;
6
+ private fallbackLocale;
7
+ private normalizeLanguage;
8
+ constructor(config?: FormattersConfig);
9
+ private getLocale;
10
+ /**
11
+ * Format with locale-specific formatting
12
+ */
13
+ formatCurrency(amount: number, language: string, currency?: string, options?: Intl.NumberFormatOptions): string;
14
+ formatNumber(value: number, language: string, options?: Intl.NumberFormatOptions): string;
15
+ formatPercent(value: number, language: string, options?: Intl.NumberFormatOptions): string;
16
+ formatDate(date: Date | number | string, language: string, options?: Intl.DateTimeFormatOptions): string;
17
+ formatTime(date: Date | number | string, language: string, options?: Intl.DateTimeFormatOptions): string;
18
+ formatDateTime(date: Date | number | string, language: string, options?: Intl.DateTimeFormatOptions): string;
19
+ formatRelativeTime(value: number, unit: Intl.RelativeTimeFormatUnit, language: string, options?: Intl.RelativeTimeFormatOptions): string;
20
+ }