@itrocks/translate 0.2.2 → 0.2.3

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
@@ -29,6 +29,7 @@ You typically use it to:
29
29
 
30
30
  - declare the current UI language with `trInit()`,
31
31
  - load translation keys from a CSV file with `trLoad()`,
32
+ - isolate concurrent request languages with `trWithLanguage()`,
32
33
  - translate strings at runtime with `tr()`,
33
34
  - optionally inspect or extend the in-memory `translations` map.
34
35
 
@@ -103,6 +104,24 @@ async function initTranslations() {
103
104
  initTranslations().catch(console.error)
104
105
  ```
105
106
 
107
+ ### Concurrent request languages
108
+
109
+ Load each catalog once, then run request work in its own asynchronous language context.
110
+ The context is preserved through promises without changing other concurrent requests:
111
+
112
+ ```ts
113
+ trInit('en-US')
114
+ await trLoad('locales/fr-FR.csv', { language: 'fr-FR' })
115
+
116
+ await trWithLanguage('fr-FR', async () => {
117
+ console.log(tr('Hello')) // Bonjour
118
+ })
119
+ ```
120
+
121
+ `trLoad(file, { language, reverse: true })` loads the second CSV column as the
122
+ source and the first as the target. This is useful while migrating templates that
123
+ temporarily contain literals in both the source and target languages.
124
+
106
125
  > **Note**
107
126
  > This package is intentionally minimal: it does not manage locales, fallbacks,
108
127
  > or pluralization rules on its own. Those concerns are expected to be handled
@@ -240,7 +259,7 @@ active language and want to reload translation data.
240
259
  ### `trLoad()`
241
260
 
242
261
  ```ts
243
- async function trLoad(file: string): Promise<void | unknown>
262
+ async function trLoad(file: string, options?: LoadOptions): Promise<void | unknown>
244
263
  ```
245
264
 
246
265
  Loads translations from a **semicolon‑separated CSV file** at the given path.
@@ -253,6 +272,9 @@ Behavior:
253
272
  delimiter.
254
273
  - Each row is expected to have at least two columns: `row[0]` is the source
255
274
  string, `row[1]` is the translated string. Extra columns are ignored.
275
+ - `options.language` selects the catalog to populate; it defaults to the language
276
+ selected by `trInit()`.
277
+ - `options.reverse` swaps the source and translated columns while loading.
256
278
  - For each row, the pair is stored in `translations`.
257
279
  - If `row[0]` contains a placeholder like `$1`, an expression `RegExp` is
258
280
  created and added to `expressions` to support dynamic matching in `tr()`.
@@ -265,6 +287,16 @@ hello;Hello
265
287
  "You have $1 new messages";"You have $1 new messages"
266
288
  ```
267
289
 
290
+ ### `trWithLanguage()`
291
+
292
+ ```ts
293
+ function trWithLanguage<T>(language: string, callback: () => T): T
294
+ ```
295
+
296
+ Runs `callback` in an asynchronous language context. `lang()` and `tr()` use that
297
+ language for the callback and all asynchronous work it starts. Concurrent contexts
298
+ remain isolated.
299
+
268
300
  ## Typical use cases
269
301
 
270
302
  Here are some scenarios where `@itrocks/translate` is a good fit:
@@ -1,11 +1,16 @@
1
+ export type LoadOptions = {
2
+ language?: string;
3
+ reverse?: boolean;
4
+ };
5
+ export type Options = {
6
+ ucFirst?: boolean;
7
+ };
1
8
  export declare const DefaultOptions: Options;
2
9
  export declare const expressions: Set<RegExp>;
3
10
  export declare const translations: Map<string, string>;
4
11
  export declare function lang(): string;
5
- export type Options = {
6
- ucFirst?: boolean;
7
- };
8
12
  export declare function tr(text: string, options: Options): string;
9
13
  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>;
14
+ export declare function trInit(language: string): void;
15
+ export declare function trLoad(file: string, options?: LoadOptions): Promise<void>;
16
+ export declare function trWithLanguage<T>(language: string, callback: () => T): T;
package/cjs/translate.js CHANGED
@@ -5,6 +5,8 @@ exports.lang = lang;
5
5
  exports.tr = tr;
6
6
  exports.trInit = trInit;
7
7
  exports.trLoad = trLoad;
8
+ exports.trWithLanguage = trWithLanguage;
9
+ const node_async_hooks_1 = require("node:async_hooks");
8
10
  const promises_1 = require("node:fs/promises");
9
11
  const promises_2 = require("node:fs/promises");
10
12
  const parseCsv = require('papaparse').parse;
@@ -13,9 +15,21 @@ exports.DefaultOptions = {
13
15
  };
14
16
  exports.expressions = new Set;
15
17
  exports.translations = new Map;
16
- let language = 'en-US';
18
+ const catalogs = new Map;
19
+ const expressionData = new WeakMap;
20
+ const languageScope = new node_async_hooks_1.AsyncLocalStorage();
21
+ let defaultLanguage = 'en-US';
22
+ catalogs.set(defaultLanguage, { expressions: exports.expressions, translations: exports.translations });
23
+ function catalog(language = lang()) {
24
+ let value = catalogs.get(language);
25
+ if (!value) {
26
+ value = { expressions: new Set, translations: new Map };
27
+ catalogs.set(language, value);
28
+ }
29
+ return value;
30
+ }
17
31
  function lang() {
18
- return language;
32
+ return languageScope.getStore() ?? defaultLanguage;
19
33
  }
20
34
  function tr(text, parts, options) {
21
35
  if (!Array.isArray(parts)) {
@@ -27,10 +41,11 @@ function tr(text, parts, options) {
27
41
  let partsCount = parts.length;
28
42
  const firstChar = text[0];
29
43
  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);
44
+ const active = catalog();
45
+ let translated = active.translations.get(text)
46
+ ?? (ucFirst ? active.translations.get(firstChar.toLocaleLowerCase() + text.slice(1)) : undefined)
47
+ ?? active.translations.get(text.toLocaleLowerCase())
48
+ ?? trMatch(text, parts, active);
34
49
  if (!translated) {
35
50
  const separator = (text.length > 1)
36
51
  ? ['.', '?', '!', ';', ':', ',', '(', ')'].find(c => text.includes(c))
@@ -49,42 +64,74 @@ function tr(text, parts, options) {
49
64
  }
50
65
  return firstSpaces + translated + lastSpaces;
51
66
  }
52
- function trInit(lang) {
53
- language = lang;
67
+ function trInit(language) {
68
+ defaultLanguage = language;
69
+ catalogs.clear();
54
70
  exports.expressions.clear();
55
71
  exports.translations.clear();
72
+ catalogs.set(language, { expressions: exports.expressions, translations: exports.translations });
56
73
  }
57
- async function trLoad(file) {
74
+ async function trLoad(file, options = {}) {
58
75
  try {
59
76
  await (0, promises_1.access)(file);
60
77
  }
61
78
  catch {
62
79
  return;
63
80
  }
81
+ const active = catalog(options.language ?? defaultLanguage);
64
82
  return (0, promises_2.readFile)(file, 'utf-8')
65
83
  .then((data) => parseCsv(data, { delimiter: ';' }).data)
66
84
  .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]+)/, '(.*)') + '$'));
85
+ const [source, target] = options.reverse ? [row[1], row[0]] : row;
86
+ if ((typeof source !== 'string') || (typeof target !== 'string'))
87
+ return;
88
+ active.translations.set(source, target);
89
+ if (source.includes('$')) {
90
+ const indexes = [];
91
+ let last = 0;
92
+ let pattern = '^';
93
+ for (const match of source.matchAll(/\$([1-9][0-9]*)/g)) {
94
+ pattern += escapeRegExp(source.slice(last, match.index)) + '(.*?)';
95
+ indexes.push(Number(match[1]));
96
+ last = match.index + match[0].length;
97
+ }
98
+ pattern += escapeRegExp(source.slice(last)) + '$';
99
+ const expression = RegExp(pattern);
100
+ expressionData.set(expression, { indexes, source });
101
+ active.expressions.add(expression);
70
102
  }
71
103
  }));
72
104
  }
73
- function trMatch(text, parts) {
74
- for (const expression of exports.expressions) {
105
+ function trWithLanguage(language, callback) {
106
+ return languageScope.run(language, callback);
107
+ }
108
+ function trMatch(text, parts, active) {
109
+ for (const expression of active.expressions) {
75
110
  const match = text.match(expression);
76
111
  if (!match)
77
112
  continue;
78
- let counter = parts.length;
79
- const replacements = [];
113
+ const data = expressionData.get(expression);
114
+ let counter = 0;
115
+ const source = data?.source
116
+ ?? expression.source.slice(1, -1).replace(/\(\.\*\??\)/g, () => '$' + ++counter);
117
+ const translated = active.translations.get(source);
118
+ if (!translated)
119
+ continue;
80
120
  const trParts = [...parts];
81
- for (const part of match.slice(1)) {
121
+ for (const [offset, part] of match.slice(1).entries()) {
82
122
  const translatedPart = tr(part);
83
- trParts.push(translatedPart[0].toLocaleLowerCase() + translatedPart.slice(1));
84
- replacements.push('$' + trParts.length);
123
+ trParts[data?.indexes[offset] ?? (offset + 1)] = (translatedPart && (translatedPart !== part))
124
+ ? translatedPart[0].toLocaleLowerCase() + translatedPart.slice(1)
125
+ : translatedPart;
126
+ }
127
+ let result = translated;
128
+ for (let index = trParts.length - 1; index > 0; index--) {
129
+ result = result.replaceAll('$' + index, trParts[index] ?? '');
85
130
  }
86
- const trText = expression.source.slice(1, -1).replaceAll('(.*)', () => '$' + ++counter);
87
- return tr(trText, trParts);
131
+ return result;
88
132
  }
89
133
  }
134
+ function escapeRegExp(text) {
135
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
136
+ }
90
137
  //# 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.3"
48
49
  }