@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 +21 -0
- package/README.md +444 -0
- package/dist/config.d.ts +8 -0
- package/dist/config.js +193 -0
- package/dist/formatters.d.ts +20 -0
- package/dist/formatters.js +163 -0
- package/dist/hooks.d.ts +111 -0
- package/dist/hooks.js +31 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +7 -0
- package/dist/parser-config.d.ts +11 -0
- package/dist/parser-config.js +100 -0
- package/dist/parser-hooks.d.ts +23 -0
- package/dist/parser-hooks.js +125 -0
- package/dist/remote-catalog.d.ts +18 -0
- package/dist/remote-catalog.js +51 -0
- package/dist/types.d.ts +123 -0
- package/dist/types.js +1 -0
- package/jest.config.cjs +19 -0
- package/package.json +45 -0
- package/tsconfig.test.json +9 -0
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.
|
package/dist/config.d.ts
ADDED
|
@@ -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
|
+
}
|