intor-translator 1.1.4 → 1.2.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/README.md CHANGED
@@ -2,7 +2,8 @@
2
2
 
3
3
  <div align="center">
4
4
 
5
- A type safe translator that knows what to say and how to handle the rest.
5
+ A modern **i18n engine** powered by a customizable, type-safe translation pipeline.
6
+ Easy to adopt, modular at its core, and fully extensible.
6
7
 
7
8
  </div>
8
9
 
@@ -10,44 +11,32 @@ A type safe translator that knows what to say and how to handle the rest.
10
11
 
11
12
  [![NPM version](https://img.shields.io/npm/v/intor-translator?style=flat&colorA=000000&colorB=000000)](https://www.npmjs.com/package/intor-translator)
12
13
  [![Bundle size](https://img.shields.io/bundlephobia/minzip/intor-translator?style=flat&colorA=000000&colorB=000000)](https://bundlephobia.com/package/intor-translator)
14
+ [![Coverage Status](https://img.shields.io/coveralls/github/yiming-liao/intor-translator.svg?branch=main&style=flat&colorA=000000&colorB=000000)](https://coveralls.io/github/yiming-liao/intor-translator?branch=main)
13
15
  [![License](https://img.shields.io/npm/l/intor-translator?style=flat&colorA=000000&colorB=000000)](LICENSE)
14
16
  [![TypeScript](https://img.shields.io/badge/TypeScript-%E2%9C%94-blue?style=flat&colorA=000000&colorB=000000)](https://www.typescriptlang.org/)
15
17
 
16
18
  </div>
17
19
 
18
- > Translate with confidence.
19
- > A type-safe i18n engine with fallback, scoped namespaces, and graceful loading.
20
-
21
- ---
20
+ > Structured 󠁯•󠁏 Predictable 󠁯•󠁏 Beautifully simple
22
21
 
23
22
  ## Features
24
23
 
25
- - 🌍 Fallback locale support for smooth language switching
26
- - Reactive translation logic that updates on the fly
27
- - 🧠 Type-safe nested key paths with full autocomplete
28
- - 🔁 Flexible replacement and interpolation support
29
- - 🎨 Rich formatting for complex replacement content
30
- - 🌀 Graceful handling of loading and async states
31
- - 🔧 Configurable handlers for fallback, loading, and missing keys
32
- - 🧩 Scoped translators for modules and namespaces
33
-
34
- ---
24
+ - 🔧 **Modular Pipeline** A pluggable, hook-driven flow for any translation logic.
25
+ - **Typed Autocomplete** Inferred keys and locales with precise, reliable completion.
26
+ - 🌐 **Framework-Agnostic** A lightweight engine that runs anywhere in JavaScript.
35
27
 
36
- ## <img src="https://raw.githubusercontent.com/Tarikul-Islam-Anik/Animated-Fluent-Emojis/master/Emojis/Symbols/Triangular%20Flag.png" alt="Triangular Flag" width="16" height="16" /> Installation
28
+ ## Installation
37
29
 
38
30
  ```bash
31
+ # npm
39
32
  npm install intor-translator
40
- ```
41
-
42
- or use **yarn**
43
-
44
- ```bash
33
+ # yarn
45
34
  yarn add intor-translator
35
+ # pnpm
36
+ pnpm add intor-translator
46
37
  ```
47
38
 
48
- ---
49
-
50
- ## <img src="https://raw.githubusercontent.com/Tarikul-Islam-Anik/Animated-Fluent-Emojis/master/Emojis/Travel%20and%20places/Rocket.png" alt="Rocket" width="25" height="25" /> Quick Start
39
+ ## Quick Start
51
40
 
52
41
  ```typescript
53
42
  import { Translator } from "intor-translator";
@@ -67,134 +56,35 @@ translator.t("hello"); // -> Hello World
67
56
  translator.t("greeting", { name: "John doe" }); // -> Hello, John doe!
68
57
  ```
69
58
 
70
- ---
59
+ ## Handlers & Hooks
71
60
 
72
- ## <img src="https://raw.githubusercontent.com/Tarikul-Islam-Anik/Animated-Fluent-Emojis/master/Emojis/Activities/Sparkles.png" alt="Sparkles" width="25" height="25" /> Advanced Features
61
+ Intor Translator is powered by **a flexible pipeline** that lets you control how translations behave and how they are rendered.
73
62
 
74
- - Fallback Locales, Placeholder & Custom Handlers
63
+ ### Handlers format the final output
75
64
 
76
- ```typescript
77
- const translator = new Translator({
78
- locale: "en",
79
- messages: {
80
- en: {
81
- welcome: "Welcome back, {name}",
82
- },
83
- zh: {
84
- welcome: "歡迎回來,{name}",
85
- notification: "你有 {count} 則新通知",
86
- },
87
- },
88
- fallbackLocales: { en: ["zh"] }, // Use zh if message not found in en
89
- placeholder: "Content unavailable", // Shown if key is missing in all locales
90
- handlers: {
91
- formatHandler: ({ locale, message }) =>
92
- locale === "zh" ? `${message}。` : `${message}.`, // Auto punctuation per locale
93
- },
94
- });
65
+ <sup>_changing how translations look_.</sup>
95
66
 
96
- // en has 'welcome'
97
- console.log(translator.t("welcome", { name: "John" })); // -> Welcome back, John.
67
+ Handlers operate on the resolved message, use them to:
98
68
 
99
- // en does not have 'notification', fallback to zh
100
- console.log(translator.t("notification", { count: 3 })); // -> 你有 3 則新通知。
69
+ - format ICU messages
70
+ - apply custom plural logic
71
+ - post-process output
72
+ - style or transform the final string
101
73
 
102
- // message does not exist in any locale
103
- console.log(translator.t("unknown.key")); // -> Content unavailable
104
- ```
74
+ ### Hooks shape the translation flow
105
75
 
106
- - With Custom ICU Formatter
76
+ <sup>_changing how translations work_.</sup>
107
77
 
108
- ```typescript
109
- import { Translator, FormatMessage } from "intor-translator";
110
- import { IntlMessageFormat } from "intl-messageformat";
78
+ Hooks run through the pipeline and can intercept any stage, use them to:
111
79
 
112
- // Create a custom handler
113
- const formatHandler: FormatMessage = ({ message, locale, replacements }) => {
114
- const formatter = new IntlMessageFormat(message, locale);
115
- return formatter.format(replacements);
116
- };
80
+ - transform keys or messages
81
+ - adjust fallback behavior
82
+ - implement loading or missing logic
83
+ - attach metadata or analytics
117
84
 
118
- const messages = {
119
- en: {
120
- notification:
121
- "{name} has {count, plural, =0 {no messages} one {1 message} other {# messages}}.",
122
- },
123
- };
124
-
125
- // Create a translator instance
126
- const translator = new Translator({
127
- locale: "en",
128
- messages,
129
- handlers: { formatHandler },
130
- });
131
-
132
- translator.t("notification", { name: "John", count: 0 }); // -> John has no messages.
133
- translator.t("notification", { name: "John", count: 5 }); // -> John has 5 messages.
134
- ```
135
-
136
- ---
137
-
138
- ## API Reference
139
-
140
- ### Translator Parameters
141
-
142
- | Option | Type | Description |
143
- | ----------------- | ------------------------------------- | ------------------------------------------------------------------------ |
144
- | `messages` | `Readonly<LocaleMessages>` | Translation messages grouped by locale and namespace |
145
- | `locale` | `string` | Active locale key |
146
- | `fallbackLocales` | `Record<Locale, Locale[]>` (optional) | Locales to fallback to when a key is missing |
147
- | `placeholder` | `string` (optional) | Message to display when a key is missing in all locales |
148
- | `loadingMessage` | `string` (optional) | Message to display during loading or async state |
149
- | `handlers` | `TranslateHandlers` (optional) | Custom functions for formatting, loading state, and missing key handling |
150
-
151
- **TranslateHandlers :**
152
-
153
- ```ts
154
- type TranslateHandlers = {
155
- formatHandler?: (
156
- ctx: TranslateHandlerContext & { message: string },
157
- ) => unknown;
158
- LoadingHandler?: (ctx: TranslateHandlerContext) => unknown;
159
- MissingHandler?: (ctx: TranslateHandlerContext) => unknown;
160
- };
161
- ```
162
-
163
- > Use handlers to control how messages are formatted, what to show during loading, and how to respond to missing keys.
164
- > Each handler receives a full translation context, including the current locale, key, and replacement values.
85
+ > Together, they form a customizable translation pipeline — structured, predictable, beautifully simple.
165
86
 
166
87
  ---
167
88
 
168
- ### Instance Properties
169
-
170
- | Property | Type | Description |
171
- | ----------- | ----------- | ------------------------------------------ |
172
- | `messages` | `M` | Current messages object |
173
- | `locale` | `Locale<M>` | Currently active locale |
174
- | `isLoading` | `boolean` | Whether the translator is in loading state |
175
-
176
- ---
177
-
178
- ### Instance Methods
179
-
180
- | Method | Signature | Description |
181
- | ------------- | ------------------------------------------------- | ------------------------------------------------------------------- |
182
- | `setMessages` | `(messages: M) => void` | Replaces the current message set |
183
- | `setLocale` | `(locale: Locale<M>) => boolean` | Sets a new locale and returns whether it changed |
184
- | `setLoading` | `(state: boolean) => void` | Sets the loading state manually |
185
- | `hasKey` | `(key, targetLocale?) => boolean` | Checks whether the given key exists in the target or current locale |
186
- | `t` | `<Result = string>(key, replacements?) => Result` | Translates a key with optional replacements |
187
- | `scoped` | `(preKey: string) => { hasKey(), t() }` | Creates a scoped translator with a namespace prefix |
188
-
189
- **translator.t(key, replacements?)**
190
-
191
- - Fully type-safe key access
192
- - Supports nested keys
193
- - Supports both string and rich replacements
194
-
195
- **translator.scoped(preKey)**
196
-
197
- - Returns a scoped translator instance based on a message subtree
198
- - Useful for organizing large sets of translations with shared prefixes
199
-
200
- ---
89
+ **_For more advanced usage, see the full examples._**
90
+ [View examples ↗](https://github.com/yiming-liao/intor-translator/tree/main/examples)
package/dist/index.cjs CHANGED
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- // src/utils/find-message-in-locales.ts
3
+ // src/translators/shared/utils/find-message-in-locales.ts
4
4
  var findMessageInLocales = ({
5
5
  messages,
6
6
  candidateLocales,
@@ -23,28 +23,50 @@ var findMessageInLocales = ({
23
23
  }
24
24
  };
25
25
 
26
- // src/utils/resolve-candidate-locales.ts
27
- var resolveCandidateLocales = (locale, fallbackLocalesMap) => {
28
- const fallbacks = fallbackLocalesMap?.[locale] || [];
29
- const filteredFallbacks = fallbacks.filter((l) => l !== locale);
30
- return [locale, ...filteredFallbacks];
26
+ // src/pipeline/hooks/find-message.hook.ts
27
+ var findMessageHook = {
28
+ name: "findMessage",
29
+ order: 200,
30
+ run(ctx) {
31
+ ctx.rawMessage = findMessageInLocales({
32
+ messages: ctx.messages,
33
+ candidateLocales: ctx.candidateLocales,
34
+ key: ctx.key
35
+ });
36
+ }
31
37
  };
32
38
 
33
- // src/translator-methods/has-key/has-key.ts
34
- var hasKey = ({
35
- messagesRef,
36
- localeRef,
37
- key,
38
- targetLocale
39
- }) => {
40
- const messages = messagesRef.current;
41
- const locale = localeRef.current;
42
- const candidateLocales = resolveCandidateLocales(targetLocale || locale);
43
- const message = findMessageInLocales({ messages, candidateLocales, key });
44
- return !!message;
39
+ // src/pipeline/utils/make-handler-context.ts
40
+ function makeHandlerContext(ctx) {
41
+ return Object.freeze({
42
+ locale: ctx.locale,
43
+ key: ctx.key,
44
+ replacements: ctx.replacements,
45
+ messages: ctx.messages,
46
+ candidateLocales: ctx.candidateLocales,
47
+ config: ctx.config,
48
+ isLoading: ctx.isLoading,
49
+ rawMessage: ctx.rawMessage,
50
+ formattedMessage: ctx.formattedMessage,
51
+ meta: ctx.meta
52
+ });
53
+ }
54
+
55
+ // src/pipeline/hooks/format.hook.ts
56
+ var formatHook = {
57
+ name: "format",
58
+ order: 500,
59
+ run(ctx) {
60
+ const { config, rawMessage } = ctx;
61
+ const { formatHandler } = config.handlers || {};
62
+ if (!formatHandler || rawMessage === void 0) return;
63
+ ctx.formattedMessage = formatHandler(
64
+ makeHandlerContext(ctx)
65
+ );
66
+ }
45
67
  };
46
68
 
47
- // src/utils/replace-values.ts
69
+ // src/translators/shared/utils/replace-values.ts
48
70
  var replaceValues = (message, params) => {
49
71
  if (!params || typeof params !== "object" || Object.keys(params).length === 0) {
50
72
  return message;
@@ -63,63 +85,117 @@ var replaceValues = (message, params) => {
63
85
  return replaced;
64
86
  };
65
87
 
66
- // src/translator-methods/translate/translate.ts
67
- var translate = ({
68
- messagesRef,
69
- localeRef,
70
- isLoadingRef,
71
- translateConfig,
72
- key,
73
- replacements
74
- }) => {
75
- const messages = messagesRef.current;
76
- const locale = localeRef.current;
77
- const isLoading = isLoadingRef.current;
78
- const { fallbackLocales, loadingMessage, placeholder, handlers } = translateConfig;
79
- const { formatHandler, loadingHandler, missingHandler } = handlers || {};
80
- const candidateLocales = resolveCandidateLocales(locale, fallbackLocales);
81
- const message = findMessageInLocales({ messages, candidateLocales, key });
82
- if (isLoading && (loadingHandler || loadingMessage)) {
83
- if (loadingHandler)
84
- return loadingHandler({ key, locale, replacements });
85
- if (loadingMessage) return loadingMessage;
86
- }
87
- if (message === void 0) {
88
- if (missingHandler)
89
- return missingHandler({ key, locale, replacements });
90
- if (placeholder) return placeholder;
91
- return key;
92
- }
93
- if (formatHandler) {
94
- return formatHandler({ message, key, locale, replacements });
95
- }
96
- return replacements ? replaceValues(message, replacements) : message;
88
+ // src/pipeline/hooks/interpolate.hook.ts
89
+ var interpolateHook = {
90
+ name: "interpolate",
91
+ order: 600,
92
+ run(ctx) {
93
+ const { rawMessage, formattedMessage, replacements } = ctx;
94
+ const message = formattedMessage ?? rawMessage;
95
+ if (typeof message !== "string" || !replacements) {
96
+ ctx.finalMessage = message;
97
+ return;
98
+ }
99
+ ctx.finalMessage = replaceValues(message, replacements);
100
+ }
97
101
  };
98
102
 
103
+ // src/pipeline/hooks/loading.hook.ts
104
+ var loadingHook = {
105
+ name: "loading",
106
+ order: 300,
107
+ run(ctx) {
108
+ const { config, isLoading } = ctx;
109
+ if (!isLoading) return;
110
+ const { loadingHandler } = config.handlers || {};
111
+ if (loadingHandler) {
112
+ return {
113
+ done: true,
114
+ value: loadingHandler(makeHandlerContext(ctx))
115
+ };
116
+ }
117
+ const { loadingMessage } = config;
118
+ if (loadingMessage) {
119
+ return { done: true, value: loadingMessage };
120
+ }
121
+ }
122
+ };
123
+
124
+ // src/pipeline/hooks/missing.hook.ts
125
+ var missingHook = {
126
+ name: "missing",
127
+ order: 400,
128
+ run(ctx) {
129
+ const { config, key, rawMessage } = ctx;
130
+ if (rawMessage !== void 0) return;
131
+ const { missingHandler } = config.handlers || {};
132
+ if (missingHandler) {
133
+ return {
134
+ done: true,
135
+ value: missingHandler(makeHandlerContext(ctx))
136
+ };
137
+ }
138
+ const { placeholder } = config;
139
+ if (placeholder) {
140
+ return { done: true, value: placeholder };
141
+ }
142
+ return { done: true, value: key };
143
+ }
144
+ };
145
+
146
+ // src/translators/shared/utils/resolve-candidate-locales.ts
147
+ var resolveCandidateLocales = (locale, fallbackLocalesMap) => {
148
+ const fallbacks = fallbackLocalesMap?.[locale] || [];
149
+ const filteredFallbacks = fallbacks.filter((l) => l !== locale);
150
+ return [locale, ...filteredFallbacks];
151
+ };
152
+
153
+ // src/pipeline/hooks/resolve-locales.hook.ts
154
+ var resolveLocalesHook = {
155
+ name: "resolveLocales",
156
+ order: 100,
157
+ run(ctx) {
158
+ ctx.candidateLocales = resolveCandidateLocales(
159
+ ctx.locale,
160
+ ctx.config.fallbackLocales
161
+ );
162
+ }
163
+ };
164
+
165
+ // src/pipeline/hooks/index.ts
166
+ var DEFAULT_HOOKS = [
167
+ resolveLocalesHook,
168
+ findMessageHook,
169
+ loadingHook,
170
+ missingHook,
171
+ formatHook,
172
+ interpolateHook
173
+ ];
174
+
99
175
  // src/translators/base-translator/base-translator.ts
100
176
  var BaseTranslator = class {
101
177
  /** Current messages for translation */
102
- messagesRef;
178
+ _messages;
103
179
  /** Current active locale */
104
- localeRef;
180
+ _locale;
105
181
  /** Current loading state */
106
- isLoadingRef;
182
+ _isLoading;
107
183
  constructor(options) {
108
- this.messagesRef = { current: options.messages ?? {} };
109
- this.localeRef = { current: options.locale };
110
- this.isLoadingRef = { current: options.isLoading ?? false };
184
+ this._messages = options.messages ?? {};
185
+ this._locale = options.locale;
186
+ this._isLoading = options.isLoading ?? false;
111
187
  }
112
188
  /** Get messages. */
113
189
  get messages() {
114
- return this.messagesRef.current;
190
+ return this._messages;
115
191
  }
116
192
  /** Get the current active locale. */
117
193
  get locale() {
118
- return this.localeRef.current;
194
+ return this._locale;
119
195
  }
120
196
  /** Get the current loading state. */
121
197
  get isLoading() {
122
- return this.isLoadingRef.current;
198
+ return this._isLoading;
123
199
  }
124
200
  /**
125
201
  * Replace messages with new ones.
@@ -128,7 +204,7 @@ var BaseTranslator = class {
128
204
  * The type cast bypasses TypeScript restrictions on dynamic messages.
129
205
  */
130
206
  setMessages(messages) {
131
- this.messagesRef.current = messages;
207
+ this._messages = messages;
132
208
  }
133
209
  /**
134
210
  * Set the active locale.
@@ -136,30 +212,85 @@ var BaseTranslator = class {
136
212
  * - Note: Unlike `setMessages`, the locale structure cannot be changed at runtime.
137
213
  */
138
214
  setLocale(newLocale) {
139
- this.localeRef.current = newLocale;
215
+ this._locale = newLocale;
140
216
  }
141
217
  /** Set the loading state. */
142
218
  setLoading(state) {
143
- this.isLoadingRef.current = state;
219
+ this._isLoading = state;
144
220
  }
145
221
  };
146
222
 
223
+ // src/translators/shared/has-key.ts
224
+ var hasKey = ({
225
+ messages,
226
+ locale,
227
+ key,
228
+ targetLocale
229
+ }) => {
230
+ const candidateLocales = resolveCandidateLocales(targetLocale || locale);
231
+ const message = findMessageInLocales({
232
+ messages,
233
+ candidateLocales,
234
+ key
235
+ });
236
+ return !!message;
237
+ };
238
+
239
+ // src/pipeline/run-pipeline.ts
240
+ function runPipeline(ctx, hooks) {
241
+ for (const hook of hooks) {
242
+ const result = hook.run(ctx);
243
+ if (result?.done) {
244
+ return result.value;
245
+ }
246
+ }
247
+ return ctx.finalMessage ?? ctx.rawMessage;
248
+ }
249
+
250
+ // src/translators/shared/translate.ts
251
+ function translate(options) {
252
+ const ctx = {
253
+ ...options,
254
+ config: options.translateConfig,
255
+ candidateLocales: [],
256
+ meta: {}
257
+ };
258
+ return runPipeline(ctx, options.hooks);
259
+ }
260
+
147
261
  // src/translators/core-translator/core-translator.ts
148
262
  var CoreTranslator = class extends BaseTranslator {
149
- options;
263
+ /** User-provided options including messages, locale, and config. */
264
+ translateConfig;
265
+ /** Active pipeline hooks applied during translation. */
266
+ hooks = [...DEFAULT_HOOKS];
150
267
  constructor(options) {
151
- super({
152
- locale: options.locale,
153
- messages: options.messages,
154
- isLoading: options.isLoading
155
- });
156
- this.options = options;
268
+ const { locale, messages, isLoading, plugins, ...translateConfig } = options;
269
+ super({ locale, messages, isLoading });
270
+ this.translateConfig = translateConfig;
271
+ if (plugins) {
272
+ for (const plugin of plugins) this.use(plugin);
273
+ }
274
+ this.sortHooks();
275
+ }
276
+ /** Sort hooks by order value (lower runs earlier). */
277
+ sortHooks() {
278
+ this.hooks.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
279
+ }
280
+ /** Register a plugin or a raw pipeline hook. */
281
+ use(plugin) {
282
+ if ("run" in plugin) this.hooks.push(plugin);
283
+ else if ("hook" in plugin && plugin.hook) {
284
+ const hooks = Array.isArray(plugin.hook) ? plugin.hook : [plugin.hook];
285
+ this.hooks.push(...hooks);
286
+ }
287
+ this.sortHooks();
157
288
  }
158
289
  /** Check if a key exists in the specified locale or current locale. */
159
290
  hasKey = (key, targetLocale) => {
160
291
  return hasKey({
161
- messagesRef: this.messagesRef,
162
- localeRef: this.localeRef,
292
+ messages: this._messages,
293
+ locale: this._locale,
163
294
  key,
164
295
  targetLocale
165
296
  });
@@ -167,17 +298,18 @@ var CoreTranslator = class extends BaseTranslator {
167
298
  /** Get the translated message for a key, with optional replacements. */
168
299
  t = (key, replacements) => {
169
300
  return translate({
170
- messagesRef: this.messagesRef,
171
- localeRef: this.localeRef,
172
- isLoadingRef: this.isLoadingRef,
173
- translateConfig: this.options,
301
+ hooks: this.hooks,
302
+ messages: this._messages,
303
+ locale: this._locale,
304
+ isLoading: this._isLoading,
305
+ translateConfig: this.translateConfig,
174
306
  key,
175
307
  replacements
176
308
  });
177
309
  };
178
310
  };
179
311
 
180
- // src/utils/get-full-key.ts
312
+ // src/translators/scope-translator/utils/get-full-key.ts
181
313
  var getFullKey = (preKey = "", key = "") => {
182
314
  if (!preKey) return key;
183
315
  if (!key) return preKey;
@@ -195,8 +327,8 @@ var ScopeTranslator = class extends CoreTranslator {
195
327
  hasKey: (key, targetLocale) => {
196
328
  const fullKey = getFullKey(preKey, key);
197
329
  return hasKey({
198
- messagesRef: this.messagesRef,
199
- localeRef: this.localeRef,
330
+ messages: this._messages,
331
+ locale: this._locale,
200
332
  key: fullKey,
201
333
  targetLocale
202
334
  });
@@ -204,10 +336,11 @@ var ScopeTranslator = class extends CoreTranslator {
204
336
  t: (key, replacements) => {
205
337
  const fullKey = getFullKey(preKey, key);
206
338
  return translate({
207
- messagesRef: this.messagesRef,
208
- localeRef: this.localeRef,
209
- isLoadingRef: this.isLoadingRef,
210
- translateConfig: this.options,
339
+ hooks: this.hooks,
340
+ messages: this._messages,
341
+ locale: this._locale,
342
+ isLoading: this._isLoading,
343
+ translateConfig: this.translateConfig,
211
344
  key: fullKey,
212
345
  replacements
213
346
  });
@@ -216,5 +349,4 @@ var ScopeTranslator = class extends CoreTranslator {
216
349
  }
217
350
  };
218
351
 
219
- exports.ScopeTranslator = ScopeTranslator;
220
352
  exports.Translator = ScopeTranslator;