@itrocks/translate 0.2.2 → 0.2.4

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
@@ -6,7 +6,7 @@
6
6
 
7
7
  # translate
8
8
 
9
- Manage dynamic string translations with support for variables and composite patterns.
9
+ Translate English source strings with small, language-specific CSV catalogs.
10
10
 
11
11
  *This documentation was written by an artificial intelligence and may contain errors or approximations.
12
12
  It has not yet been fully reviewed by a human. If anything seems unclear or incomplete,
@@ -18,281 +18,110 @@ please feel free to contact the author of this package.*
18
18
  npm i @itrocks/translate
19
19
  ```
20
20
 
21
- This package has a runtime dependency on `papaparse`, which is installed automatically
22
- as a transitive dependency when you install `@itrocks/translate`.
23
-
24
21
  ## Usage
25
22
 
26
- `@itrocks/translate` provides a tiny in-memory translation engine for Node.js.
27
-
28
- You typically use it to:
29
-
30
- - declare the current UI language with `trInit()`,
31
- - load translation keys from a CSV file with `trLoad()`,
32
- - translate strings at runtime with `tr()`,
33
- - optionally inspect or extend the in-memory `translations` map.
34
-
35
- The focus is on **dynamic translations of small text snippets** (labels, button
36
- texts, messages) with support for:
37
-
38
- - automatic case handling (uppercasing the first letter),
39
- - placeholders like `$1`, `$2`, ... replaced by runtime values,
40
- - composite expressions that can themselves contain expressions.
41
-
42
- ### Minimal example
43
-
44
- ```ts
45
- import { tr, trInit, trLoad } from '@itrocks/translate'
46
-
47
- async function main() {
48
- // 1. Select the language and reset internal state
49
- trInit('en-US')
50
-
51
- // 2. Load translations from a ;‑separated CSV file
52
- await trLoad('locales/en-US.csv')
53
-
54
- // 3. Translate a simple key
55
- console.log(tr('hello')) // e.g. "Hello"
56
-
57
- // 4. Translate a key with placeholders
58
- console.log(tr('welcome.user.$1', ['John'])) // e.g. "Welcome, John"
59
- }
23
+ Translation sources stay in English. Each semicolon-separated CSV file maps them to one language:
60
24
 
61
- main().catch(console.error)
25
+ ```csv
26
+ hello;bonjour
27
+ private: $1 recipients;privée : $1 destinataires
62
28
  ```
63
29
 
64
- ### Complete example with composite patterns
30
+ Catalog entries are context-free fragments: start both columns with a lowercase letter unless the word requires a
31
+ capital, and keep sentence-separating periods in the caller rather than at either end of a CSV value. The translator
32
+ restores an initial capital and translates each period-separated fragment independently.
65
33
 
66
- `tr()` can both translate **simple keys** and **composite expressions**. Composite
67
- expressions are translated by splitting them around punctuation and translating
68
- each part separately while preserving spaces.
34
+ An isolated `:` or `;` entry may translate to the same separator prefixed with a space when a target language requires
35
+ different spacing. The caller still owns the separator itself.
69
36
 
70
- You can also store patterns with placeholders in the translation file. When a
71
- pattern matches the source text, `tr()` automatically:
72
-
73
- - translates each captured part,
74
- - appends the translated parts to the `parts` array,
75
- - and reuses them to build the final translated string.
37
+ Initialize the default language and load every translated catalog once:
76
38
 
77
39
  ```ts
78
- import { expressions, lang, tr, trInit, trLoad, translations } from '@itrocks/translate'
79
-
80
- async function initTranslations() {
81
- // Initialize the language (any BCP 47 code string is accepted)
82
- trInit('fr-FR')
40
+ import { tr, trInit, trLoad, trReverse, trWithLanguage } from '@itrocks/translate'
83
41
 
84
- // Load a ;‑separated CSV file with two columns: source;translation
85
- // Example content:
86
- // hello;Bonjour
87
- // "Hello, $1";"Bonjour, $1"
88
- // "You have $1 new messages";"Vous avez $1 nouveaux messages"
89
- await trLoad('locales/fr-FR.csv')
42
+ trInit('en-US')
43
+ await trLoad('locales/fr-FR.csv', 'fr-FR')
90
44
 
91
- console.log('Current language:', lang())
92
-
93
- console.log(tr('hello')) // "Bonjour"
94
- console.log(tr('Hello, $1', ['Marie'])) // "Bonjour, Marie"
95
- console.log(tr('You have $1 new messages', ['3']))
96
- // => "Vous avez 3 nouveaux messages"
97
-
98
- // The underlying maps are available if you need to inspect or extend them
99
- console.log('Loaded translations:', translations.size)
100
- console.log('Expression patterns:', expressions.size)
101
- }
102
-
103
- initTranslations().catch(console.error)
45
+ await trWithLanguage('fr-FR', async () => {
46
+ console.log(tr('Hello')) // Bonjour
47
+ console.log(tr('Private: $1 recipients.', ['2'])) // Privée : 2 destinataires.
48
+ console.log(trReverse('Bonjour')) // Hello
49
+ })
104
50
  ```
105
51
 
106
- > **Note**
107
- > This package is intentionally minimal: it does not manage locales, fallbacks,
108
- > or pluralization rules on its own. Those concerns are expected to be handled
109
- > by your application or higher‑level framework.
52
+ The asynchronous language context is isolated between concurrent requests. A language with no catalog, such as the
53
+ English source language above, leaves source strings unchanged.
110
54
 
111
55
  ## API
112
56
 
113
- ### `DefaultOptions`
114
-
115
- ```ts
116
- export const DefaultOptions: Options
117
- ```
118
-
119
- The default options used by `tr()` when no explicit `options` are provided.
120
-
121
- Currently only one option is defined:
122
-
123
- - `ucFirst: boolean` (default `true`): when `true`, if the input text starts
124
- with an uppercase ASCII letter (`A`–`Z`), the translated string is forced to
125
- start with an uppercase letter as well.
126
-
127
- You can override this behavior per call using the `options` argument of `tr()`.
128
-
129
- ### `expressions`
57
+ ### `lang()`
130
58
 
131
59
  ```ts
132
- export const expressions: Set<RegExp>
60
+ function lang(): string
133
61
  ```
134
62
 
135
- The set of **compiled expression patterns** used for advanced matching in `tr()`.
136
-
137
- You normally do not need to modify this set manually. It is populated by
138
- `trLoad()` when a source key in the CSV file contains placeholders like `$1`.
63
+ Returns the language of the current asynchronous context, or the default language set by `trInit()`.
139
64
 
140
- Each such key generates a regular expression that is later used by `tr()` to
141
- match dynamic sentences and extract sub‑parts for translation.
142
-
143
- ### `translations`
65
+ ### `tr()`
144
66
 
145
67
  ```ts
146
- export const translations: Map<string, string>
68
+ function tr(text: string, options: Options): string
69
+ function tr(text: string, parts?: string[], options?: Options): string
147
70
  ```
148
71
 
149
- The in‑memory translation dictionary. Keys are **source texts** (usually
150
- English strings or stable identifiers), and values are their translated
151
- counterparts in the currently active language.
72
+ Translates `text` with the current language catalog. It supports `$1`, `$2`, … placeholders, preserves surrounding
73
+ spaces and can match catalog sources containing placeholders. If no translation exists, it returns the source text.
152
74
 
153
- This map is cleared each time you call `trInit()`. It is filled by `trLoad()`
154
- and can be extended or inspected manually if needed.
155
-
156
- ### `type Options`
75
+ When `ucFirst` is enabled, an uppercase source initial also produces an uppercase translated initial:
157
76
 
158
77
  ```ts
159
78
  export type Options = {
160
79
  ucFirst?: boolean
161
80
  }
162
- ```
163
81
 
164
- Additional options that can be passed to `tr()`:
165
-
166
- - `ucFirst` (default: `DefaultOptions.ucFirst`): whether the first character of
167
- the translated string should be uppercased when the original first character
168
- is an uppercase ASCII letter.
82
+ export const DefaultOptions: Options = {
83
+ ucFirst: true
84
+ }
85
+ ```
169
86
 
170
- ### `lang()`
87
+ ### `trInit()`
171
88
 
172
89
  ```ts
173
- function lang(): string
90
+ function trInit(language: string): void
174
91
  ```
175
92
 
176
- Returns the **current language code** previously set with `trInit()`.
93
+ Sets the default language and clears all previously loaded catalogs. Call it once before loading catalogs.
177
94
 
178
- The package does not interpret the value: you can use any string (for example
179
- `'en-US'`, `'fr-FR'`, `'de'`), as long as it is meaningful to your
180
- application.
181
-
182
- ### `tr()`
95
+ ### `trLoad()`
183
96
 
184
97
  ```ts
185
- // Overload 1: no parts array, only options
186
- function tr(text: string, options: Options): string
187
-
188
- // Overload 2: explicit parts and optional options
189
- function tr(text: string, parts?: string[], options?: Options): string
98
+ function trLoad(file: string, language?: string): Promise<void | unknown>
190
99
  ```
191
100
 
192
- Translates the given `text` using the current `translations` map and returns
193
- the translated string.
194
-
195
- Behavior details:
196
-
197
- 1. **Spacing preservation** – leading and trailing whitespace in `text` are
198
- preserved around the translated content.
199
- 2. **Lookup strategy** – for the trimmed `text`, `tr()` looks up, in order:
200
- - an exact match in `translations`,
201
- - if `ucFirst` is enabled and the first character is uppercase, the same key
202
- but with the first letter lower‑cased,
203
- - the lower‑cased key,
204
- - a match from expression patterns in `expressions` (see `trLoad()`).
205
- 3. **Composite sentences** – if no translation is found, `tr()` looks for a
206
- punctuation separator (`.?!;:,()`). When found, the text is split around the
207
- first such separator, each part is translated separately with `tr()`, and the
208
- final string is reassembled while preserving spaces (including non‑breaking
209
- spaces around the separator).
210
- 4. **Fallback** – if no translation or expression match is found, the original
211
- trimmed text is returned.
212
- 5. **Placeholders** – if a `parts` array is provided, elements are substituted
213
- into the translated string by replacing `$1`, `$2`, ... from the end of the
214
- array backwards.
215
-
216
- Usage patterns:
217
-
218
- ```ts
219
- tr('hello')
220
- tr('Hello', { ucFirst: false })
221
- tr('welcome.$1', ['John'])
222
- tr('You have $1 new messages', ['3'])
223
- ```
101
+ Loads a UTF-8, semicolon-separated `source;translation` file into `language`. The language defaults to the one passed
102
+ to `trInit()`. A missing file is ignored so applications can look for catalogs in several module directories.
224
103
 
225
- ### `trInit()`
104
+ ### `trReverse()`
226
105
 
227
106
  ```ts
228
- function trInit(lang: string): void
107
+ function trReverse(text: string): string
229
108
  ```
230
109
 
231
- Initializes or switches the current language. This function:
232
-
233
- - sets the internal language code returned by `lang()`,
234
- - clears all previously loaded `translations`,
235
- - clears all compiled `expressions`.
110
+ Looks for a translated value in the current language catalog and returns its English source, accepting an initial
111
+ capital added by `tr()`. If none matches, it returns `text` unchanged. This is intended for occasional input
112
+ normalization, such as converting a translated code entered in a search field back to the code stored in English. It
113
+ reads the current catalog backwards on demand; no reverse catalog is loaded or retained.
236
114
 
237
- Call this once per language at application startup, or whenever you change the
238
- active language and want to reload translation data.
239
-
240
- ### `trLoad()`
115
+ ### `trWithLanguage()`
241
116
 
242
117
  ```ts
243
- async function trLoad(file: string): Promise<void | unknown>
118
+ function trWithLanguage<T>(language: string, callback: () => T): T
244
119
  ```
245
120
 
246
- Loads translations from a **semicolon‑separated CSV file** at the given path.
247
-
248
- Behavior:
249
-
250
- - If the file does not exist or cannot be accessed, the function simply
251
- returns without throwing.
252
- - It reads the file as UTF‑8 and parses it using `papaparse` with `;` as the
253
- delimiter.
254
- - Each row is expected to have at least two columns: `row[0]` is the source
255
- string, `row[1]` is the translated string. Extra columns are ignored.
256
- - For each row, the pair is stored in `translations`.
257
- - If `row[0]` contains a placeholder like `$1`, an expression `RegExp` is
258
- created and added to `expressions` to support dynamic matching in `tr()`.
121
+ Runs `callback` in an asynchronous language context used by `lang()`, `tr()` and `trReverse()`. The context is preserved
122
+ through promises without affecting concurrent callbacks.
259
123
 
260
- Typical CSV snippet:
261
-
262
- ```csv
263
- hello;Hello
264
- "Hello, $1";"Hello, $1"
265
- "You have $1 new messages";"You have $1 new messages"
266
- ```
124
+ ## Scope
267
125
 
268
- ## Typical use cases
269
-
270
- Here are some scenarios where `@itrocks/translate` is a good fit:
271
-
272
- 1. **Translating UI labels and messages in a Node.js application**
273
- - Keep a simple `locales/<lang>.csv` file with two columns: source and
274
- translation.
275
- - At startup, call `trInit('<lang>')` and `trLoad('locales/<lang>.csv')`.
276
- - Use `tr('settings')`, `tr('Save changes')`, etc., in your rendering
277
- or logging code.
278
-
279
- 2. **Integrating with a template or transformer system**
280
- - Combine `@itrocks/translate` with higher‑level packages such as
281
- `@itrocks/transformer` or `@itrocks/property-translate` to automatically
282
- translate values when rendering views or model properties.
283
-
284
- 3. **Dynamic messages with parameters**
285
- - Define entries in your CSV containing `$1`, `$2`, ... placeholders.
286
- - At runtime, call `tr('You have $1 new messages', ['3'])`.
287
- - The placeholders are replaced by the elements of the `parts` array.
288
-
289
- 4. **Expression‑based translations**
290
- - Use keys with `$1` in your CSV (for example, `"Hello, $1"`).
291
- - When you call `tr('Hello, John')`, `@itrocks/translate` matches the
292
- pattern, translates `"John"` if possible, and then builds the final
293
- sentence using the captured parts.
294
-
295
- 5. **Inspecting and debugging translations**
296
- - Use `translations.size` to quickly see how many entries were loaded.
297
- - Inspect `translations.get('some key')` or iterate over the map when
298
- debugging missing or incorrect translations.
126
+ The package deliberately does not manage locale negotiation, plural rules or fallback chains. Applications and
127
+ higher-level frameworks remain responsible for those policies.
@@ -0,0 +1,5 @@
1
+ export type Catalog = Map<string, string>;
2
+ export declare const translations: Map<string, string>;
3
+ export declare function catalog(language: string): Catalog;
4
+ export declare function catalogClear(language: string): void;
5
+ export declare function catalogLoad(file: string, language: string): Promise<void>;
package/cjs/catalog.js ADDED
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.translations = void 0;
4
+ exports.catalog = catalog;
5
+ exports.catalogClear = catalogClear;
6
+ exports.catalogLoad = catalogLoad;
7
+ const promises_1 = require("node:fs/promises");
8
+ const promises_2 = require("node:fs/promises");
9
+ const parseCsv = require('papaparse').parse;
10
+ const catalogs = new Map;
11
+ exports.translations = new Map;
12
+ catalogs.set('en-US', exports.translations);
13
+ function catalog(language) {
14
+ let result = catalogs.get(language);
15
+ if (!result) {
16
+ result = new Map;
17
+ catalogs.set(language, result);
18
+ }
19
+ return result;
20
+ }
21
+ function catalogClear(language) {
22
+ catalogs.clear();
23
+ exports.translations.clear();
24
+ catalogs.set(language, exports.translations);
25
+ }
26
+ async function catalogLoad(file, language) {
27
+ try {
28
+ await (0, promises_1.access)(file);
29
+ }
30
+ catch {
31
+ return;
32
+ }
33
+ const translations = catalog(language);
34
+ return (0, promises_2.readFile)(file, 'utf-8')
35
+ .then((data) => parseCsv(data, { delimiter: ';' }).data)
36
+ .then(data => data.forEach(([source, target]) => {
37
+ if ((typeof source !== 'string') || (typeof target !== 'string') || (source === target))
38
+ return;
39
+ translations.set(source, target);
40
+ }));
41
+ }
42
+ //# sourceMappingURL=catalog.js.map
@@ -1,11 +1,12 @@
1
- export declare const DefaultOptions: Options;
2
- export declare const expressions: Set<RegExp>;
3
- export declare const translations: Map<string, string>;
4
- export declare function lang(): string;
5
1
  export type Options = {
6
2
  ucFirst?: boolean;
7
3
  };
4
+ export declare const DefaultOptions: Options;
5
+ export declare function lang(): string;
8
6
  export declare function tr(text: string, options: Options): string;
9
7
  export declare function tr(text: string, parts?: string[], options?: Options): string;
10
- export declare function trInit(lang: string): void;
11
- export declare function trLoad(file: string): Promise<void>;
8
+ export declare function trInit(language: string): void;
9
+ export declare function trLoad(file: string, language?: string): Promise<void>;
10
+ export declare function trReverse(text: string): string;
11
+ export declare function trWithLanguage<T>(language: string, callback: () => T): T;
12
+ export { translations } from './catalog';
package/cjs/translate.js CHANGED
@@ -1,21 +1,26 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.translations = exports.expressions = exports.DefaultOptions = void 0;
3
+ exports.translations = exports.DefaultOptions = void 0;
4
4
  exports.lang = lang;
5
5
  exports.tr = tr;
6
6
  exports.trInit = trInit;
7
7
  exports.trLoad = trLoad;
8
- const promises_1 = require("node:fs/promises");
9
- const promises_2 = require("node:fs/promises");
10
- const parseCsv = require('papaparse').parse;
8
+ exports.trReverse = trReverse;
9
+ exports.trWithLanguage = trWithLanguage;
10
+ const node_async_hooks_1 = require("node:async_hooks");
11
+ const catalog_1 = require("./catalog");
12
+ const catalog_2 = require("./catalog");
13
+ const catalog_3 = require("./catalog");
11
14
  exports.DefaultOptions = {
12
15
  ucFirst: true
13
16
  };
14
- exports.expressions = new Set;
15
- exports.translations = new Map;
16
- let language = 'en-US';
17
+ const languageScope = new node_async_hooks_1.AsyncLocalStorage();
18
+ let defaultLanguage = 'en-US';
19
+ function escapeRegExp(text) {
20
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
21
+ }
17
22
  function lang() {
18
- return language;
23
+ return languageScope.getStore() ?? defaultLanguage;
19
24
  }
20
25
  function tr(text, parts, options) {
21
26
  if (!Array.isArray(parts)) {
@@ -27,10 +32,12 @@ function tr(text, parts, options) {
27
32
  let partsCount = parts.length;
28
33
  const firstChar = text[0];
29
34
  const ucFirst = (options?.ucFirst ?? exports.DefaultOptions.ucFirst) && (firstChar >= 'A') && (firstChar <= 'Z');
30
- let translated = exports.translations.get(text)
31
- ?? (ucFirst ? exports.translations.get(firstChar.toLocaleLowerCase() + text.slice(1)) : undefined)
32
- ?? exports.translations.get(text.toLocaleLowerCase())
33
- ?? trMatch(text, parts);
35
+ const active = (0, catalog_1.catalog)(lang());
36
+ let translated = active.get(text)
37
+ ?? (ucFirst ? active.get(firstChar.toLocaleLowerCase() + text.slice(1)) : undefined)
38
+ ?? active.get(text.toLocaleLowerCase())
39
+ ?? trMatch(text, parts, active)
40
+ ?? (ucFirst ? trMatch(firstChar.toLocaleLowerCase() + text.slice(1), parts, active) : undefined);
34
41
  if (!translated) {
35
42
  const separator = (text.length > 1)
36
43
  ? ['.', '?', '!', ';', ':', ',', '(', ')'].find(c => text.includes(c))
@@ -49,42 +56,55 @@ function tr(text, parts, options) {
49
56
  }
50
57
  return firstSpaces + translated + lastSpaces;
51
58
  }
52
- function trInit(lang) {
53
- language = lang;
54
- exports.expressions.clear();
55
- exports.translations.clear();
59
+ function trInit(language) {
60
+ defaultLanguage = language;
61
+ (0, catalog_2.catalogClear)(language);
56
62
  }
57
- async function trLoad(file) {
58
- try {
59
- await (0, promises_1.access)(file);
60
- }
61
- catch {
62
- return;
63
- }
64
- return (0, promises_2.readFile)(file, 'utf-8')
65
- .then((data) => parseCsv(data, { delimiter: ';' }).data)
66
- .then(data => data.forEach(row => {
67
- exports.translations.set(row[0], row[1]);
68
- if (row[0].includes('$')) {
69
- exports.expressions.add(RegExp('^' + row[0].replace(/(\$[0-9]+)/, '(.*)') + '$'));
70
- }
71
- }));
63
+ function trLoad(file, language = defaultLanguage) {
64
+ return (0, catalog_3.catalogLoad)(file, language);
72
65
  }
73
- function trMatch(text, parts) {
74
- for (const expression of exports.expressions) {
66
+ function trMatch(text, parts, active) {
67
+ for (const [source, translated] of active) {
68
+ if (!source.includes('$'))
69
+ continue;
70
+ const indexes = [];
71
+ let last = 0;
72
+ let pattern = '^';
73
+ for (const match of source.matchAll(/\$([1-9][0-9]*)/g)) {
74
+ pattern += escapeRegExp(source.slice(last, match.index)) + '(.*?)';
75
+ indexes.push(Number(match[1]));
76
+ last = match.index + match[0].length;
77
+ }
78
+ pattern += escapeRegExp(source.slice(last)) + '$';
79
+ const expression = RegExp(pattern);
75
80
  const match = text.match(expression);
76
81
  if (!match)
77
82
  continue;
78
- let counter = parts.length;
79
- const replacements = [];
80
83
  const trParts = [...parts];
81
- for (const part of match.slice(1)) {
84
+ for (const [offset, part] of match.slice(1).entries()) {
82
85
  const translatedPart = tr(part);
83
- trParts.push(translatedPart[0].toLocaleLowerCase() + translatedPart.slice(1));
84
- replacements.push('$' + trParts.length);
86
+ trParts[indexes[offset] ?? (offset + 1)] = (translatedPart && (translatedPart !== part))
87
+ ? translatedPart[0].toLocaleLowerCase() + translatedPart.slice(1)
88
+ : translatedPart;
89
+ }
90
+ let result = translated;
91
+ for (let index = trParts.length - 1; index > 0; index--) {
92
+ result = result.replaceAll('$' + index, trParts[index] ?? '');
85
93
  }
86
- const trText = expression.source.slice(1, -1).replaceAll('(.*)', () => '$' + ++counter);
87
- return tr(trText, trParts);
94
+ return result;
88
95
  }
89
96
  }
97
+ function trReverse(text) {
98
+ for (const [source, translated] of (0, catalog_1.catalog)(lang())) {
99
+ if ((translated === text)
100
+ || (translated === text[0].toLocaleLowerCase() + text.slice(1)))
101
+ return source;
102
+ }
103
+ return text;
104
+ }
105
+ function trWithLanguage(language, callback) {
106
+ return languageScope.run(language, callback);
107
+ }
108
+ var catalog_4 = require("./catalog");
109
+ Object.defineProperty(exports, "translations", { enumerable: true, get: function () { return catalog_4.translations; } });
90
110
  //# sourceMappingURL=translate.js.map
package/package.json CHANGED
@@ -42,7 +42,8 @@
42
42
  "url": "git+https://github.com/itrocks-ts/translate.git"
43
43
  },
44
44
  "scripts": {
45
- "build": "tsc"
45
+ "build": "tsc",
46
+ "test": "npm run build && node --test test/*.test.js"
46
47
  },
47
- "version": "0.2.2"
48
+ "version": "0.2.4"
48
49
  }