locl-js 1.0.2 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,540 @@
1
+ # LocL.js 🌐
2
+
3
+ [![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen)](https://github.com/sztsam/LocL.js) [![TypeScript](https://img.shields.io/badge/TypeScript-Ready-blue)](https://www.typescriptlang.org/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)](https://www.npmjs.com/package/locl-js)
4
+
5
+ **LocL** is an ultra-fast, lightweight, and fully-featured TypeScript internationalization (i18n) library. Designed for modern web applications, backend services, and multi-framework setups, it provides compile-time type-safety, automatic pluralization, ICU-style select branching, built-in formatters, and reactive subscriptions with **zero external dependencies**.
6
+
7
+ It is inspired by libraries like `i18next` and `react-i18next`, but engineered with a focus on strict TypeScript type checking, sub-millisecond execution, clean APIs, and **100% test coverage across every statement, branch, and function**.
8
+
9
+ ---
10
+
11
+ ## Features ✨
12
+
13
+ - **💪 Strict Compile-Time Safety**: Auto-extracts parameters (`{name}`, `{{name}}`, `{gender, select, ...}`) and enforces required arguments at compile time with IDE autocomplete.
14
+ - **🏷️ `defineResources` Helper**: Preserves literal types automatically without having to append `as const` manually.
15
+ - **⚛️ First-Class React Integration**: Official React bindings (`<LocLProvider>`, `useTranslation`, `useLocL`, `<Trans />`) via `locl-js/react` with two type-safe workflows:
16
+ - **`createLocLReact` Hook Factory**: Zero-declaration, pre-bound React suite.
17
+ - **`LocLRegister` Declaration Merging**: Ambient global autocomplete for standalone imports.
18
+ - **📦 Zero Dependencies & Lightweight**: Pure TypeScript with no runtime dependencies.
19
+ - **🔄 Drop-in i18next Compat**: Swap existing `i18next` codebases effortlessly using `locl-js/compat` without rewriting translation call sites. Supports colon namespaces (`auth:login`), strict parameter checking, default fallbacks, and event listener cleanup.
20
+ - **🌐 Multi-Framework Reactive Store**: Implements the standard reactive store contract via `translator.subscribe()`, working out of the box in Svelte, Vue, Solid, and Node.js.
21
+ - **🔢 Automatic Plurals**: Handles object plurals (`{ zero, one, other }`) and suffix plurals (`_zero`, `_one`, `_other`) directly via `t()` or dedicated `plural()`.
22
+ - **🔀 ICU Select Support**: Conditional branching (`{gender, select, male {He} female {She} other {They}}`) with nested parameter interpolation (`{gender, select, male {He has {count} items}}`).
23
+ - **🔧 Built-in & Custom Formatters**: Format dates, numbers, currencies, strings, and booleans inline via pipe syntax (`{val | number}`, `{date | date:short}`).
24
+ - **⚡ High Performance Caching**: Granular scope-based translation caching and multi-proxy instance reuse with zero proxy-of-proxy overhead.
25
+ - **🛡️ Prototype-Safe Dynamic Resources**: Safely merge resources dynamically at runtime with built-in prototype pollution guards.
26
+ - **💯 100% Test Coverage**: Fully verified across every line, branch, function, and edge case.
27
+
28
+ ---
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ npm install locl-js
34
+ ```
35
+
36
+ ---
37
+
38
+ ## Quick Start
39
+
40
+ ```typescript
41
+ import { initLocL, defineResources } from "locl-js";
42
+
43
+ // defineResources automatically infers literal types for compile-time safety
44
+ const resources = defineResources({
45
+ en: {
46
+ hello: "Hello, {name}!",
47
+ messages: {
48
+ one: "You have one message.",
49
+ other: "You have {count} messages.",
50
+ },
51
+ },
52
+ fr: {
53
+ hello: "Bonjour, {name}!",
54
+ messages: {
55
+ one: "Vous avez un message.",
56
+ other: "Vous avez {count} messages.",
57
+ },
58
+ },
59
+ });
60
+
61
+ const translator = initLocL({
62
+ resources,
63
+ fallbackLanguage: "en",
64
+ });
65
+
66
+ // Interpolation
67
+ console.log(translator.t("hello", { name: "Alice" })); // "Hello, Alice!"
68
+
69
+ // Plurals via t() or plural()
70
+ console.log(translator.t("messages", { count: 1 })); // "You have one message."
71
+ console.log(translator.plural("messages", { count: 5 })); // "You have 5 messages."
72
+
73
+ // Switch language
74
+ translator.changeLanguage("fr");
75
+ console.log(translator.t("hello", { name: "Alice" })); // "Bonjour, Alice!"
76
+ ```
77
+
78
+ ---
79
+
80
+ ## Strict Parameter Type-Checking
81
+
82
+ When defining resources with `defineResources` (or `as const`), `LocL` infers parameter names and types at compile time:
83
+
84
+ ```typescript
85
+ import { defineResources, initLocL } from "locl-js";
86
+
87
+ const resources = defineResources({
88
+ en: {
89
+ welcome: "Welcome back, {name}!",
90
+ notification: "Hello {{user}}, you have {count} items.",
91
+ staticNotice: "Server maintenance scheduled for tonight.",
92
+ },
93
+ });
94
+
95
+ const translator = initLocL({ resources, fallbackLanguage: "en" });
96
+
97
+ // ✅ TypeScript validates parameters and provides IDE autocomplete:
98
+ translator.t("welcome", { name: "Alice" });
99
+ translator.t("notification", { user: "Bob", count: 3 });
100
+
101
+ // ❌ TypeScript errors on missing or invalid parameters:
102
+ // translator.t("welcome"); // Error: Expected 2 arguments, got 1
103
+ // translator.t("welcome", { wrongKey: "Alice" }); // Error: Property 'name' is missing
104
+
105
+ // ✅ Static templates allow omitting options:
106
+ translator.t("staticNotice");
107
+ ```
108
+
109
+ ---
110
+
111
+ ## React Integration (`locl-js/react`)
112
+
113
+ `LocL` provides flicker-free React bindings powered by `useSyncExternalStore` and an XSS-safe `<Trans />` component. You can choose between two type-safe workflows:
114
+
115
+ ### Workflow A: Zero-Declaration Factory (`createLocLReact`) ⭐ *Recommended*
116
+
117
+ No `.d.ts` declaration merging required. Pass your translator instance to `createLocLReact` once and export pre-bound, 100% typed components and hooks:
118
+
119
+ ```tsx
120
+ // src/i18n-react.ts
121
+ import { initLocL, defineResources } from "locl-js";
122
+ import { createLocLReact } from "locl-js/react";
123
+
124
+ export const translator = initLocL({
125
+ resources: defineResources({
126
+ en: {
127
+ welcome: "Welcome, {name}!",
128
+ user: { title: "Profile", greeting: "Hello, {name}!" },
129
+ },
130
+ }),
131
+ fallbackLanguage: "en",
132
+ });
133
+
134
+ export const { LocLProvider, useTranslation, useLocL, Trans } = createLocLReact(translator);
135
+ ```
136
+
137
+ In your application components:
138
+
139
+ ```tsx
140
+ import React from "react";
141
+ import { LocLProvider, useTranslation, Trans } from "./i18n-react";
142
+
143
+ export function App() {
144
+ return (
145
+ <LocLProvider>
146
+ <Header />
147
+ <Profile />
148
+ </LocLProvider>
149
+ );
150
+ }
151
+
152
+ function Header() {
153
+ // 100% Autocomplete across root keys!
154
+ const { t, language, changeLanguage } = useTranslation();
155
+
156
+ return (
157
+ <header>
158
+ <h1>{t("welcome", { name: "Alice" })}</h1>
159
+ <button onClick={() => changeLanguage("de")}>Deutsch ({language})</button>
160
+ </header>
161
+ );
162
+ }
163
+
164
+ function Profile() {
165
+ // Scoped autocomplete: keys are restricted strictly to the "user" namespace!
166
+ const { t } = useTranslation("user");
167
+
168
+ return (
169
+ <section>
170
+ <h2>{t("title")}</h2>
171
+ <Trans
172
+ scope="user"
173
+ i18nKey="greeting"
174
+ values={{ name: "Alice" }}
175
+ components={{ bold: <strong className="font-bold" /> }}
176
+ />
177
+ </section>
178
+ );
179
+ }
180
+ ```
181
+
182
+ ---
183
+
184
+ ### Workflow B: Global Type Registry (`LocLRegister`)
185
+
186
+ If you prefer importing hooks and components directly from `"locl-js/react"`, augment `LocLRegister` once in your project (e.g. `src/i18n.d.ts`):
187
+
188
+ ```typescript
189
+ import { translator } from "./i18n";
190
+
191
+ declare module "locl-js/react" {
192
+ interface LocLRegister {
193
+ translator: typeof translator;
194
+ }
195
+ }
196
+ ```
197
+
198
+ Now, standalone imports automatically have full autocomplete everywhere without passing any generic arguments:
199
+
200
+ ```tsx
201
+ import { useTranslation, Trans } from "locl-js/react";
202
+
203
+ function UserProfile() {
204
+ const { t } = useTranslation("user"); // ✅ Scopes and keys autocompleted globally!
205
+ return <h1>{t("title")}</h1>;
206
+ }
207
+ ```
208
+
209
+ ---
210
+
211
+ ### The `<Trans />` Component
212
+
213
+ Renders complex translations containing formatting tags (`<bold>read</bold>`, `<link>terms</link>`) into React elements safely without `dangerouslySetInnerHTML`:
214
+
215
+ ```tsx
216
+ <Trans
217
+ i18nKey="notice" // "Please <bold>read</bold> our <link>terms</link>."
218
+ values={{ name: "Alice" }}
219
+ components={{
220
+ bold: <strong className="text-primary font-bold" />,
221
+ link: <a href="/terms" className="underline" target="_blank" rel="noopener noreferrer" />,
222
+ }}
223
+ fallback="Please read our terms."
224
+ />
225
+ ```
226
+
227
+ #### Key Capabilities
228
+
229
+ - **Strict Key Validation**: Autocompletes valid keys and flags typos at compile time.
230
+ - **Strict Value Validation**: If the template contains `{name}`, `values={{ name: ... }}` is required at compile time.
231
+ - **Dynamic Key Fallback**: Unregistered or dynamic keys are allowed whenever an explicit `fallback` string is provided (`fallback="Default string"`).
232
+ - **Scope Support**: Narrow keys using the `scope` prop: `<Trans scope="user" i18nKey="greeting" values={{ name: "Alice" }} />`.
233
+ - **Standalone `translator` Prop**: When passing `translator={translator}`, `Trans` automatically infers the dictionary and provides autocomplete for that translator's keys.
234
+ - **Tag Element Replacement**: Clones JSX elements (e.g. `<strong />`) while preserving inner text as children.
235
+ - **Function Renderers**: Supports custom render functions: `(content) => <span style={{ fontWeight: "bold" }}>{content}</span>`.
236
+ - **Reactive Updates**: Subscribes to `useSyncExternalStore` so language changes re-render smoothly.
237
+
238
+ ---
239
+
240
+ ## Drop-in `i18next` Compatibility (`locl-js/compat`)
241
+
242
+ Migrate existing `i18next` codebases effortlessly without refactoring callsites. `locl-js/compat` preserves `i18next` runtime flexibility while offering full compile-time strict type checking:
243
+
244
+ ```typescript
245
+ import { initLocL, defineResources } from "locl-js";
246
+ import { toI18next, createI18nextCompat } from "locl-js/compat";
247
+
248
+ const resources = defineResources({
249
+ en: {
250
+ common: {
251
+ hello: "Hello, {name}!",
252
+ save: "Save"
253
+ },
254
+ apple_one: "1 apple",
255
+ apple_other: "{count} apples"
256
+ }
257
+ });
258
+ const locl = initLocL({ resources, fallbackLanguage: "en" });
259
+ export const i18n = toI18next(locl);
260
+
261
+ // Colon and dot syntax autocompleted and strictly enforced:
262
+ i18n.t("common:hello", { name: "Maria" }); // Requires { name }!
263
+ i18n.t("common.save"); // Static key: options optional!
264
+
265
+ // Plurals strictly enforce { count: number }:
266
+ i18n.t("apple", { count: 5 });
267
+
268
+ // Dynamic and fallback keys:
269
+ i18n.t("missing.key", "Default Fallback");
270
+ i18n.t("missing.key", { defaultValue: "Fallback Text" });
271
+
272
+ // Key existence check (with autocomplete for colon and dot keys):
273
+ i18n.exists("common:save"); // true
274
+
275
+ // Resource bundle management:
276
+ i18n.addResourceBundle("en", "dynamic", { title: "Title" });
277
+ i18n.hasResourceBundle("en", "dynamic"); // true
278
+ i18n.getResourceBundle("en", "dynamic"); // { title: "Title" }
279
+
280
+ // Event listeners with cleanup callback:
281
+ const unbind = i18n.on("languageChanged", (lng) => {
282
+ console.log("Language changed to", lng);
283
+ });
284
+ unbind(); // Unsubscribes listener
285
+
286
+ await i18n.changeLanguage("es");
287
+ ```
288
+
289
+ ---
290
+
291
+ ## Pluralization
292
+
293
+ `LocL` supports pluralization directly inside `translator.t()` and through `translator.plural()`. Both approaches automatically resolve CLDR plural rules (`zero`, `one`, `two`, `few`, `many`, `other`).
294
+
295
+ ### 1. Object Pluralization
296
+
297
+ Define plurals as structured objects:
298
+
299
+ ```typescript
300
+ const en = {
301
+ items: {
302
+ zero: "No items",
303
+ one: "1 item",
304
+ other: "{count} items",
305
+ },
306
+ };
307
+
308
+ translator.t("items", { count: 0 }); // "No items"
309
+ translator.t("items", { count: 1 }); // "1 item"
310
+ translator.t("items", { count: 5 }); // "5 items"
311
+ ```
312
+
313
+ ### 2. Suffix Pluralization
314
+
315
+ Define plural forms using category suffixes:
316
+
317
+ ```typescript
318
+ const en = {
319
+ apple_zero: "Zero apples",
320
+ apple_one: "1 apple",
321
+ apple_other: "{count} apples",
322
+ };
323
+
324
+ translator.t("apple", { count: 1 }); // "1 apple"
325
+ translator.t("apple", { count: 4 }); // "4 apples"
326
+ ```
327
+
328
+ > **Note**: Both single `{var}` and double `{{var}}` placeholders are supported across all templates.
329
+
330
+ ---
331
+
332
+ ## ICU Select Branching
333
+
334
+ Support gender or contextual choice branching with ICU `select` syntax, including **nested placeholders** inside branch bodies:
335
+
336
+ ```typescript
337
+ const en = {
338
+ feedback: "{gender, select, male {He liked your photo} female {She liked your photo} other {They liked your photo}}.",
339
+ items: "{gender, select, male {He has {count} items} female {She has {count} items} other {They have {count} items}}",
340
+ };
341
+
342
+ translator.t("feedback", { gender: "female" });
343
+ // "She liked your photo."
344
+
345
+ // Nested parameters (gender AND count) are strictly typed!
346
+ translator.t("items", { gender: "male", count: 42 });
347
+ // "He has 42 items"
348
+ ```
349
+
350
+ ---
351
+
352
+ ## Built-in & Custom Formatters
353
+
354
+ Use pipe syntax (`{variable | formatter[:args]}`) inside your translations:
355
+
356
+ ```typescript
357
+ const en = {
358
+ profile: "Member since {joined | date:short}. Balance: {balance | currency:USD}.",
359
+ user: "Username: {name | upper | truncate:10, ...}",
360
+ };
361
+ ```
362
+
363
+ ### Built-in Formatters
364
+
365
+ | Formatter | Description | Example |
366
+ | :--- | :--- | :--- |
367
+ | `upper` | Uppercase string | `{name \| upper}` |
368
+ | `lower` | Lowercase string | `{name \| lower}` |
369
+ | `capitalize` | Capitalize first letter | `{name \| capitalize}` |
370
+ | `trim` | Strip leading/trailing whitespace | `{name \| trim}` |
371
+ | `truncate` | Truncate string with suffix | `{text \| truncate:10, ...}` |
372
+ | `number` | `Intl.NumberFormat` decimal | `{val \| number:en-US}` |
373
+ | `currency` | `Intl.NumberFormat` currency | `{val \| currency:EUR, de-DE}` |
374
+ | `date` | `Intl.DateTimeFormat` date | `{val \| date:en-US, short}` |
375
+ | `relativeDate` | Formats relative time | `{val \| relativeDate:en-US}` |
376
+ | `json` | Converts object to JSON string | `{data \| json}` |
377
+ | `yesNo` | Returns "Yes" / "No" or custom labels | `{flag \| yesNo:Y, N}` |
378
+ | `boolean` | Returns "true" / "false" or custom labels | `{flag \| boolean:Active, Inactive}` |
379
+ | `padStart` | Left pad string | `{code \| padStart:5, 0}` |
380
+ | `padEnd` | Right pad string | `{code \| padEnd:5, _}` |
381
+
382
+ ### Custom Formatters
383
+
384
+ Add custom formatters during initialization:
385
+
386
+ ```typescript
387
+ const translator = initLocL({
388
+ resources,
389
+ fallbackLanguage: "en",
390
+ formatters: {
391
+ reverse: (val) => String(val).split("").reverse().join(""),
392
+ highlight: (val) => `***${val}***`,
393
+ },
394
+ });
395
+
396
+ translator.t("greeting", { name: "Alice" }, { formatter: "highlight" });
397
+ ```
398
+
399
+ ---
400
+
401
+ ## Multi-Framework Reactive Store (Svelte, Vue, Solid, Node.js)
402
+
403
+ `LocL` implements the standard reactive store contract (`subscribe`):
404
+
405
+ ```typescript
406
+ const unsubscribe = translator.subscribe((newLang, prevLang) => {
407
+ console.log(`Language updated from ${prevLang} to ${newLang}`);
408
+ });
409
+
410
+ translator.changeLanguage("es");
411
+ unsubscribe();
412
+ ```
413
+
414
+ - **Svelte**: Reference directly as an auto-subscribing readable store (`$translator.t("key")`).
415
+ - **Vue**: Bind to a `shallowRef` inside `translator.subscribe()`.
416
+ - **Node.js**: Cleanly isolate translations per request or per guild.
417
+
418
+ ---
419
+
420
+ ## Rich Tag Interpolation (`translator.rich`)
421
+
422
+ Interpolate custom inline components, terminal styling, or HTML strings using `<tag>content</tag>`:
423
+
424
+ ```typescript
425
+ const en = {
426
+ notice: "Click <link>here</link> to view details or <bold>read more</bold>.",
427
+ };
428
+
429
+ const elements = translator.rich("notice", {
430
+ link: (text) => `<a href="/details">${text}</a>`,
431
+ bold: (text) => `<b>${text}</b>`,
432
+ });
433
+
434
+ console.log(elements.join(""));
435
+ // "Click <a href="/details">here</a> to view details or <b>read more</b>."
436
+ ```
437
+
438
+ ---
439
+
440
+ ## Dynamic Resources
441
+
442
+ Add or deep-merge translation bundles at runtime safely with built-in prototype pollution guards:
443
+
444
+ ```typescript
445
+ // Add a single dotted key
446
+ translator.addResource("en", "dashboard.title", "Analytics Dashboard");
447
+
448
+ // Deep-merge an entire namespace
449
+ translator.addResources("en", {
450
+ checkout: {
451
+ pay: "Pay Now",
452
+ cancel: "Cancel",
453
+ },
454
+ });
455
+ ```
456
+
457
+ ---
458
+
459
+ ## Scopes & Proxies (`withConfig`)
460
+
461
+ Create lightweight, isolated translator instances for specific namespaces or fixed languages without duplicating resources:
462
+
463
+ ```typescript
464
+ const resources = defineResources({
465
+ en: {
466
+ auth: { login: "Log In", register: "Sign Up" },
467
+ dashboard: { welcome: "Welcome, {name}" },
468
+ },
469
+ de: {
470
+ auth: { login: "Anmelden", register: "Registrieren" },
471
+ dashboard: { welcome: "Willkommen, {name}" },
472
+ },
473
+ });
474
+
475
+ const translator = initLocL({ resources, fallbackLanguage: "en" });
476
+
477
+ // 1. Scoped proxy (inherits active language dynamically)
478
+ const authT = translator.withConfig({ scope: "auth" });
479
+ console.log(authT.t("login")); // "Log In"
480
+
481
+ // 2. Fixed-language proxy (pinned to German regardless of global language)
482
+ const deDashboardT = translator.withConfig({ scope: "dashboard", language: "de" });
483
+ console.log(deDashboardT.t("welcome", { name: "Hans" })); // "Willkommen, Hans"
484
+
485
+ // 3. Array scope (combines multiple namespaces)
486
+ const multiT = translator.withConfig({ scope: ["auth", "dashboard"] });
487
+ console.log(multiT.t("dashboard.welcome", { name: "Alice" })); // "Welcome, Alice"
488
+ ```
489
+
490
+ ---
491
+
492
+ ## API Reference
493
+
494
+ ### Top-Level Exports (`locl-js`)
495
+
496
+ - **`defineResources(resources)`**: Identity helper using `<const T>` to enforce compile-time literal types without writing `as const`.
497
+ - **`initLocL(config)`**: Creates and returns a new `LocL` translator instance.
498
+
499
+ ### React Exports (`locl-js/react`)
500
+
501
+ - **`createLocLReact(translator)`**: Returns a pre-bound, zero-declaration React suite (`LocLProvider`, `useTranslation`, `useLocL`, `Trans`, `LocLContext`).
502
+ - **`LocLProvider`**: React Context Provider for the active translator.
503
+ - **`useTranslation(scope?, customTranslator?)`**: Translation hook with namespace scoping support.
504
+ - **`useLocL(customTranslator?)`**: Hook returning translator instance and reactive language state.
505
+ - **`<Trans />`**: Rich JSX component interpolation component supporting strict key/param checking, fallbacks, and scope narrowing.
506
+ - **`LocLRegister`**: Ambient interface for global type registration.
507
+
508
+ ### Compat Exports (`locl-js/compat`)
509
+
510
+ - **`toI18next(translator)`**: Wraps a LocL instance into an `i18next`-compatible API with strict parameter checking.
511
+ - **`createI18nextCompat(translator)`**: Alias of `toI18next`.
512
+
513
+ ### `LocL` Instance Methods
514
+
515
+ - **`t(key, values?, format?)`**: Translates a key, handles interpolation, formatters, and plurals.
516
+ - **`plural(key, values, format?)`**: Translates a key with pluralization.
517
+ - **`rich(key, tagRenderers, values?)`**: Replaces `<tag>` wrappers using custom renderer functions.
518
+ - **`changeLanguage(lang)`**: Updates current language and notifies all subscribers.
519
+ - **`getLanguage()`**: Returns the currently active language code.
520
+ - **`subscribe(listener)`**: Subscribes to language changes. Returns an unsubscribe function.
521
+ - **`withConfig(config)`**: Returns a cached proxy translator instance with scoped configuration.
522
+ - **`clone(language?, scope?)`**: Clones the translator instance with optional language/scope overrides.
523
+ - **`addResource(lang, key, value)`**: Adds or overrides a single translation key at runtime.
524
+ - **`addResources(lang, bundle)`**: Deep-merges a dictionary into the target language.
525
+ - **`format(value, formatterName, args?)`**: Executes a formatter function directly.
526
+ - **`get(key?)`**: Returns the raw translation value or the entire active scoped dictionary.
527
+ - **`getObj(key)`**: Returns a typed nested object from the translation tree.
528
+ - **`tt(key?, values?, format?)`**: Untyped translation alias.
529
+
530
+ ---
531
+
532
+ ## Contributing
533
+
534
+ Contributions, feedback, and pull requests are warmly welcomed! Please feel free to open an issue or submit a pull request on GitHub.
535
+
536
+ ---
537
+
538
+ ## License
539
+
540
+ This project is licensed under the [MIT License](LICENSE).