i18ntk 3.2.0 → 4.0.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/runtime/index.js CHANGED
@@ -11,13 +11,32 @@ const { envManager } = require('../utils/env-manager');
11
11
  let configManager = null;
12
12
  try { configManager = require('../utils/config-manager'); } catch (_) { /* optional */ }
13
13
 
14
- const state = {
14
+ function createState(options = {}) {
15
+ return {
16
+ baseDir: resolveBaseDir(options.baseDir),
17
+ language: options.language || 'en',
18
+ fallbackLanguage: options.fallbackLanguage || 'en',
19
+ keySeparator: options.keySeparator || '.',
20
+ cache: new Map(),
21
+ lazy: options.lazy === true,
22
+ keyManifest: new Map(),
23
+ loadedFiles: new Set(),
24
+ eagerLoadedLanguages: new Set(),
25
+ };
26
+ }
27
+
28
+ const singletonState = {
15
29
  baseDir: null, // absolute path to locales dir (e.g., ./locales)
16
30
  language: 'en',
17
31
  fallbackLanguage: 'en',
18
- keySeparator: '.',
19
- cache: new Map(), // lang -> merged translations object
20
- };
32
+ keySeparator: '.',
33
+ cache: new Map(), // lang -> merged translations object
34
+ lazy: false,
35
+ keyManifest: new Map(), // lang -> Map: keyName -> filePath
36
+ loadedFiles: new Set(), // tracks loaded files in lazy mode
37
+ eagerLoadedLanguages: new Set(),
38
+ };
39
+ let singletonInitialized = false;
21
40
 
22
41
  // --- Utilities ---
23
42
  function stripBOMAndComments(s) {
@@ -98,12 +117,12 @@ function resolveBaseDir(explicitBaseDir) {
98
117
  }
99
118
  }
100
119
 
101
- function listJsonFilesRecursively(dir) {
120
+ function listJsonFilesRecursively(dir, baseDir = dir) {
102
121
  const results = [];
103
122
  const stack = [dir];
104
123
  while (stack.length) {
105
124
  const d = stack.pop();
106
- if (!SecurityUtils.safeExistsSync(d)) continue;
125
+ if (!SecurityUtils.safeExistsSync(d, baseDir)) continue;
107
126
  try {
108
127
  for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
109
128
  const full = path.join(d, entry.name);
@@ -120,6 +139,86 @@ function listJsonFilesRecursively(dir) {
120
139
  return results;
121
140
  }
122
141
 
142
+ function loadKeyManifestFromDir(baseDir) {
143
+ const validatedBase = SecurityUtils.validatePath(baseDir, path.dirname(baseDir));
144
+ const baseStat = SecurityUtils.safeStatSync(validatedBase, path.dirname(validatedBase));
145
+ const baseRoot = baseStat && baseStat.isFile() ? path.dirname(validatedBase) : validatedBase;
146
+ const files = baseStat && baseStat.isFile() ? [validatedBase] : listJsonFilesRecursively(validatedBase, validatedBase);
147
+ const manifest = new Map();
148
+ const MAX_SIZE = 100 * 1024;
149
+ let currentSize = 0;
150
+
151
+ for (const file of files) {
152
+ let validated;
153
+ try { validated = SecurityUtils.validatePath(file, baseRoot); } catch (_) { continue; }
154
+ const rel = path.relative(baseRoot, validated);
155
+ if (rel.startsWith('..') || path.isAbsolute(rel)) continue;
156
+
157
+ try {
158
+ const data = readJsonSafe(validated);
159
+ if (!data || typeof data !== 'object') continue;
160
+
161
+ for (const key of Object.keys(data)) {
162
+ if (manifest.has(key)) continue;
163
+
164
+ const entrySize = JSON.stringify(key).length + JSON.stringify(validated).length + 5;
165
+ if (currentSize + entrySize > MAX_SIZE) break;
166
+
167
+ manifest.set(key, validated);
168
+ currentSize += entrySize;
169
+ }
170
+ } catch (_) { continue; }
171
+
172
+ if (currentSize >= MAX_SIZE) break;
173
+ }
174
+
175
+ return manifest;
176
+ }
177
+
178
+ function getLanguagePath(baseDir, lang) {
179
+ const langDir = path.join(baseDir, lang);
180
+ const langFile = path.join(baseDir, `${lang}.json`);
181
+ const langDirStat = SecurityUtils.safeStatSync(langDir, path.dirname(langDir));
182
+ if (langDirStat && langDirStat.isDirectory()) return langDir;
183
+ const langFileStat = SecurityUtils.safeStatSync(langFile, path.dirname(langFile));
184
+ if (langFileStat && langFileStat.isFile()) return langFile;
185
+ return null;
186
+ }
187
+
188
+ function getManifestForLanguage(runtimeState, lang) {
189
+ if (!runtimeState.keyManifest) runtimeState.keyManifest = new Map();
190
+ if (runtimeState.keyManifest.has(lang)) return runtimeState.keyManifest.get(lang);
191
+
192
+ const languagePath = getLanguagePath(runtimeState.baseDir, lang);
193
+ const manifest = languagePath ? loadKeyManifestFromDir(languagePath) : new Map();
194
+ runtimeState.keyManifest.set(lang, manifest);
195
+ return manifest;
196
+ }
197
+
198
+ function loadFileLazy(runtimeState, filePath, lang) {
199
+ const baseDir = runtimeState.baseDir;
200
+ if (!baseDir) throw new Error('baseDir not initialized');
201
+
202
+ let validatedPath;
203
+ try { validatedPath = SecurityUtils.validatePath(filePath, baseDir); } catch (e) {
204
+ throw new Error(`Invalid file path for lazy load: ${e.message}`);
205
+ }
206
+
207
+ const rel = path.relative(baseDir, validatedPath);
208
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
209
+ throw new Error(`File outside base directory: ${filePath}`);
210
+ }
211
+
212
+ const data = readJsonSafe(validatedPath);
213
+ if (data && typeof data === 'object') {
214
+ let cacheEntry = runtimeState.cache.get(lang);
215
+ if (!cacheEntry) cacheEntry = {};
216
+ deepMerge(cacheEntry, data);
217
+ runtimeState.cache.set(lang, cacheEntry);
218
+ }
219
+ return data;
220
+ }
221
+
123
222
  function readLanguageFromBase(baseDir, lang) {
124
223
  const merged = {};
125
224
  const langFile = path.join(baseDir, `${lang}.json`);
@@ -128,7 +227,7 @@ function readLanguageFromBase(baseDir, lang) {
128
227
  // Prefer folder if exists, otherwise single file
129
228
  const langDirStat = SecurityUtils.safeStatSync(langDir, path.dirname(langDir));
130
229
  if (langDirStat && langDirStat.isDirectory()) {
131
- const files = listJsonFilesRecursively(langDir);
230
+ const files = listJsonFilesRecursively(langDir, langDir);
132
231
  for (const file of files) {
133
232
  try {
134
233
  const data = readJsonSafe(file);
@@ -150,10 +249,19 @@ function readLanguageFromBase(baseDir, lang) {
150
249
  return merged;
151
250
  }
152
251
 
153
- function getTranslations(lang) {
154
- if (state.cache.has(lang)) return state.cache.get(lang);
155
- const data = readLanguageFromBase(state.baseDir, lang);
156
- state.cache.set(lang, data);
252
+ function getTranslationsForState(runtimeState, lang) {
253
+ if (!runtimeState.baseDir) runtimeState.baseDir = resolveBaseDir();
254
+
255
+ if (runtimeState.lazy) {
256
+ if (runtimeState.cache.has(lang)) return runtimeState.cache.get(lang);
257
+ runtimeState.cache.set(lang, {});
258
+ getManifestForLanguage(runtimeState, lang);
259
+ return runtimeState.cache.get(lang);
260
+ }
261
+
262
+ if (runtimeState.cache.has(lang)) return runtimeState.cache.get(lang);
263
+ const data = readLanguageFromBase(runtimeState.baseDir, lang);
264
+ runtimeState.cache.set(lang, data);
157
265
  return data;
158
266
  }
159
267
 
@@ -165,15 +273,38 @@ function interpolate(template, params) {
165
273
  }
166
274
 
167
275
  // Resolve a dotted key path from an object
168
- function resolveKey(obj, key, sep = '.') {
169
- if (!obj || typeof obj !== 'object') return undefined;
170
- if (!key || typeof key !== 'string') return undefined;
276
+ function resolveKey(obj, key, sep = '.', runtimeState = null, lang = null) {
277
+ if (!obj || typeof obj !== 'object') return undefined;
278
+ if (!key || typeof key !== 'string') return undefined;
171
279
  const parts = key.split(sep);
172
280
  let cur = obj;
173
281
  for (const p of parts) {
174
282
  if (cur && Object.prototype.hasOwnProperty.call(cur, p)) {
175
283
  cur = cur[p];
176
284
  } else {
285
+ if (runtimeState && lang && runtimeState.lazy) {
286
+ const manifest = getManifestForLanguage(runtimeState, lang);
287
+ for (let i = parts.length; i > 0; i--) {
288
+ const prefix = parts.slice(0, i).join(sep);
289
+ const filePath = manifest.get(prefix);
290
+ const loadedFileKey = `${lang}\0${filePath}`;
291
+ if (filePath && !runtimeState.loadedFiles.has(loadedFileKey)) {
292
+ loadFileLazy(runtimeState, filePath, lang);
293
+ runtimeState.loadedFiles.add(loadedFileKey);
294
+ const langData = runtimeState.cache.get(lang);
295
+ return resolveKey(langData, key, sep, runtimeState, lang);
296
+ }
297
+ }
298
+ if (!runtimeState.eagerLoadedLanguages.has(lang)) {
299
+ const fullData = readLanguageFromBase(runtimeState.baseDir, lang);
300
+ const langData = runtimeState.cache.get(lang) || {};
301
+ deepMerge(langData, fullData);
302
+ runtimeState.cache.set(lang, langData);
303
+ runtimeState.eagerLoadedLanguages.add(lang);
304
+ return resolveKey(langData, key, sep, runtimeState, lang);
305
+ }
306
+ return undefined;
307
+ }
177
308
  return undefined;
178
309
  }
179
310
  }
@@ -182,62 +313,110 @@ function resolveKey(obj, key, sep = '.') {
182
313
 
183
314
  // --- Public API ---
184
315
  function initRuntime(options = {}) {
185
- state.baseDir = resolveBaseDir(options.baseDir);
186
- state.language = options.language || state.language || 'en';
187
- state.fallbackLanguage = options.fallbackLanguage || state.fallbackLanguage || 'en';
188
- state.keySeparator = options.keySeparator || state.keySeparator || '.';
189
- // Optional prewarm caches
190
- state.cache.clear();
191
- if (options.preload === true) {
192
- getTranslations(state.language);
193
- if (state.fallbackLanguage && state.fallbackLanguage !== state.language) {
194
- getTranslations(state.fallbackLanguage);
316
+ const runtimeState = createState(options);
317
+ preload(runtimeState, options.preload);
318
+
319
+ if (!singletonInitialized) {
320
+ singletonState.baseDir = runtimeState.baseDir;
321
+ singletonState.language = runtimeState.language;
322
+ singletonState.fallbackLanguage = runtimeState.fallbackLanguage;
323
+ singletonState.keySeparator = runtimeState.keySeparator;
324
+ singletonState.lazy = runtimeState.lazy;
325
+ singletonState.keyManifest = new Map();
326
+ singletonState.loadedFiles = new Set();
327
+ singletonState.eagerLoadedLanguages = new Set();
328
+ singletonState.cache.clear();
329
+ preload(singletonState, options.preload);
330
+ singletonInitialized = true;
331
+ }
332
+
333
+ return createRuntime(runtimeState);
334
+ }
335
+
336
+ function preload(runtimeState, shouldPreload) {
337
+ if (shouldPreload === true) {
338
+ if (runtimeState.lazy) {
339
+ getManifestForLanguage(runtimeState, runtimeState.language);
340
+ if (!runtimeState.cache.has(runtimeState.language)) {
341
+ runtimeState.cache.set(runtimeState.language, {});
342
+ }
343
+ if (runtimeState.fallbackLanguage && runtimeState.fallbackLanguage !== runtimeState.language) {
344
+ getManifestForLanguage(runtimeState, runtimeState.fallbackLanguage);
345
+ if (!runtimeState.cache.has(runtimeState.fallbackLanguage)) {
346
+ runtimeState.cache.set(runtimeState.fallbackLanguage, {});
347
+ }
348
+ }
349
+ } else {
350
+ getTranslationsForState(runtimeState, runtimeState.language);
351
+ if (runtimeState.fallbackLanguage && runtimeState.fallbackLanguage !== runtimeState.language) {
352
+ getTranslationsForState(runtimeState, runtimeState.fallbackLanguage);
353
+ }
195
354
  }
196
355
  }
356
+ }
357
+
358
+ function createRuntime(runtimeState) {
359
+ const runtimeTranslate = (key, params) => translateWithState(runtimeState, key, params);
197
360
  return {
198
- t: translate,
199
- translate,
200
- setLanguage,
201
- getLanguage,
202
- getAvailableLanguages,
203
- refresh,
361
+ t: runtimeTranslate,
362
+ translate: runtimeTranslate,
363
+ setLanguage: (lang) => setLanguageForState(runtimeState, lang),
364
+ getLanguage: () => getLanguageForState(runtimeState),
365
+ getAvailableLanguages: () => getAvailableLanguagesForState(runtimeState),
366
+ refresh: (lang) => refreshForState(runtimeState, lang),
204
367
  };
205
368
  }
206
369
 
207
370
  function translate(key, params = {}) {
208
- const langData = getTranslations(state.language);
209
- let value = resolveKey(langData, key, state.keySeparator);
371
+ return translateWithState(singletonState, key, params);
372
+ }
210
373
 
211
- if (typeof value === 'undefined' && state.fallbackLanguage) {
212
- const fbData = getTranslations(state.fallbackLanguage);
213
- value = resolveKey(fbData, key, state.keySeparator);
214
- }
374
+ function translateWithState(runtimeState, key, params = {}) {
375
+ const langData = getTranslationsForState(runtimeState, runtimeState.language);
376
+ let value = resolveKey(langData, key, runtimeState.keySeparator, runtimeState, runtimeState.language);
377
+
378
+ if (typeof value === 'undefined' && runtimeState.fallbackLanguage) {
379
+ const fbData = getTranslationsForState(runtimeState, runtimeState.fallbackLanguage);
380
+ value = resolveKey(fbData, key, runtimeState.keySeparator, runtimeState, runtimeState.fallbackLanguage);
381
+ }
215
382
 
216
383
  if (typeof value === 'string') return interpolate(value, params);
217
384
  return typeof value === 'undefined' ? key : value;
218
385
  }
219
386
 
220
387
  function setLanguage(lang) {
388
+ setLanguageForState(singletonState, lang);
389
+ }
390
+
391
+ function setLanguageForState(runtimeState, lang) {
221
392
  if (!lang || typeof lang !== 'string') return;
222
- state.language = lang;
393
+ runtimeState.language = lang;
223
394
  }
224
395
 
225
396
  function getLanguage() {
226
- return state.language;
397
+ return getLanguageForState(singletonState);
398
+ }
399
+
400
+ function getLanguageForState(runtimeState) {
401
+ return runtimeState.language;
227
402
  }
228
403
 
229
404
  function getAvailableLanguages() {
405
+ return getAvailableLanguagesForState(singletonState);
406
+ }
407
+
408
+ function getAvailableLanguagesForState(runtimeState) {
230
409
  const langs = new Set();
231
- if (!state.baseDir) state.baseDir = resolveBaseDir();
232
- if (!SecurityUtils.safeExistsSync(state.baseDir)) return ['en'];
410
+ if (!runtimeState.baseDir) runtimeState.baseDir = resolveBaseDir();
411
+ if (!SecurityUtils.safeExistsSync(runtimeState.baseDir, path.dirname(runtimeState.baseDir))) return ['en'];
233
412
  try {
234
- for (const entry of fs.readdirSync(state.baseDir, { withFileTypes: true })) {
413
+ for (const entry of fs.readdirSync(runtimeState.baseDir, { withFileTypes: true })) {
235
414
  if (entry.isFile() && entry.name.toLowerCase().endsWith('.json')) {
236
415
  langs.add(entry.name.replace(/\.json$/i, ''));
237
416
  } else if (entry.isDirectory()) {
238
417
  const lang = entry.name;
239
- const idx = path.join(state.baseDir, lang, `${lang}.json`);
240
- if (SecurityUtils.safeExistsSync(idx)) langs.add(lang);
418
+ const idx = path.join(runtimeState.baseDir, lang, `${lang}.json`);
419
+ if (SecurityUtils.safeExistsSync(idx, path.dirname(idx))) langs.add(lang);
241
420
  else langs.add(lang); // be permissive
242
421
  }
243
422
  }
@@ -248,13 +427,23 @@ function getAvailableLanguages() {
248
427
  return Array.from(langs.size ? langs : new Set(['en']));
249
428
  }
250
429
 
251
- function refresh(lang = state.language) {
252
- if (state.cache.has(lang)) state.cache.delete(lang);
253
- if (lang !== state.fallbackLanguage && state.cache.has(state.fallbackLanguage)) {
254
- // do nothing; keep or clear on demand
255
- }
430
+ function refresh(lang) {
431
+ refreshForState(singletonState, lang);
256
432
  }
257
433
 
434
+ function refreshForState(runtimeState, lang = runtimeState.language) {
435
+ if (runtimeState.cache.has(lang)) runtimeState.cache.delete(lang);
436
+ if (runtimeState.eagerLoadedLanguages) runtimeState.eagerLoadedLanguages.delete(lang);
437
+ if (runtimeState.loadedFiles) {
438
+ for (const fileKey of Array.from(runtimeState.loadedFiles)) {
439
+ if (fileKey.startsWith(`${lang}\0`)) runtimeState.loadedFiles.delete(fileKey);
440
+ }
441
+ }
442
+ if (lang !== runtimeState.fallbackLanguage && runtimeState.cache.has(runtimeState.fallbackLanguage)) {
443
+ // do nothing; keep or clear on demand
444
+ }
445
+ }
446
+
258
447
  module.exports = {
259
448
  initRuntime,
260
449
  translate,
@@ -262,5 +451,6 @@ module.exports = {
262
451
  setLanguage,
263
452
  getLanguage,
264
453
  getAvailableLanguages,
265
- refresh,
266
- };
454
+ refresh,
455
+ loadKeyManifest: (baseDir) => loadKeyManifestFromDir(baseDir || singletonState.baseDir),
456
+ };