i18n-lens-language-server 0.13.0 → 0.14.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
@@ -13,6 +13,7 @@ Zed extension plus Language Server for Vue, TypeScript, TSX, JavaScript and JSX.
13
13
  - Completion: suggest existing i18n keys with default locale text only inside supported i18n key positions
14
14
  - Definition: jump from a source key to its locale definition; when several locales define the key, pick which language file to open
15
15
  - References: from a locale definition or source usage, find references within the same project/package context, including open source files not yet saved to disk
16
+ - Recursive locale directories: nested JSON, TS, and JS files map to dot-separated namespaces, with `index` files contributing to their parent namespace
16
17
 
17
18
  ## Installation
18
19
 
@@ -220,6 +221,19 @@ src/locales/ja-JP/common.json
220
221
 
221
222
  A file named `common.json` contributes keys with the `common.` prefix. A file named `index.json` contributes keys without an `index.` prefix.
222
223
 
224
+ Locale directories are scanned recursively. Subdirectories contribute to the namespace:
225
+
226
+ ```text
227
+ src/locales/en-US/system/user.json -> system.user.*
228
+ src/locales/en-US/system/index.json -> system.*
229
+ src/locales/en-US/system/forms/fields.ts -> system.forms.fields.*
230
+ src/locales/en-US/index/index.json -> index.*
231
+ ```
232
+
233
+ Nested JSON, TS, and JS locale files support translation previews, definition, references, and unsaved buffer updates. Adding files in nested directories refreshes translations automatically. Missing-key quick fixes retain the existing namespace directory layout when the target is unambiguous and its parent directory exists.
234
+
235
+ If `localeDirs` overlap, an explicitly configured nested root is excluded from its ancestor's recursive scan, regardless of configuration order or nesting depth. For example, with `["locales", "locales/modules/features"]`, `locales/modules/features/en/user.json` belongs to locale `en` with keys `user.*`, not locale `modules`. Intermediate directories without locale files are watched for new files but do not count as locales. Empty locale files still count as locales and report missing keys.
236
+
223
237
  Top-level TS/JS modules such as `locales/en.ts` are also loaded, without adding an `en.` prefix. Directory modules such as `locales/en/common.ts` retain the `common.` prefix; `index.ts` has no file-stem prefix. Modules are parsed statically, never executed. Their definitions support Find References and cross-language parameter diagnostics, not source-code completions or inlay hints. TS/JS key positioning remains best-effort rather than a full syntax-tree analysis.
224
238
 
225
239
  ## Monorepo workspaces
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "i18n-lens-language-server",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "type": "module",
5
5
  "description": "Language server for configurable i18n inlay hints, hover, completion, diagnostics, definition, and references in Vue/TS/TSX/JS.",
6
6
  "license": "MIT",
package/server/core.js CHANGED
@@ -642,25 +642,22 @@ export function collectLocaleWatchPaths(localeCache, localeDirs = []) {
642
642
 
643
643
  export function findLocaleKeyTarget(locale, key, localeTexts = {}) {
644
644
  if (!locale || !Object.prototype.hasOwnProperty.call(locale.flat || {}, key)) return undefined;
645
- const files = Array.isArray(locale.files) && locale.files.length
646
- ? locale.files
647
- : (locale.path ? [locale.path] : []);
648
- const candidates = files
649
- .map((filePath) => ({ filePath, lookupKey: lookupKeyForLocaleFile(locale, filePath, key) }))
650
- .sort((a, b) => Number(a.lookupKey === key) - Number(b.lookupKey === key));
651
-
652
- for (const candidate of candidates) {
653
- const text = localeTexts[candidate.filePath];
654
- if (!text) continue;
655
- const range = localeKeyRange(text, candidate.lookupKey, /\.json$/i.test(candidate.filePath));
656
- if (range) return { filePath: candidate.filePath, position: range.start, range };
657
- }
658
-
659
- const fallback = candidates[0];
660
- return fallback ? { filePath: fallback.filePath } : undefined;
645
+ // Namespace specificity does not determine which file won during loading.
646
+ const filePath = locale.keyFiles?.[key];
647
+ if (!filePath) return undefined;
648
+ const lookupKey = lookupKeyForLocaleFile(locale, filePath, key);
649
+ if (lookupKey === undefined) return undefined;
650
+ const text = localeTexts[filePath];
651
+ const range = text ? localeKeyRange(text, lookupKey, /\.json$/i.test(filePath)) : undefined;
652
+ return range ? { filePath, position: range.start, range } : { filePath };
661
653
  }
662
654
 
663
655
  function lookupKeyForLocaleFile(locale, filePath, key) {
656
+ const prefix = locale.filePrefixes?.[filePath];
657
+ if (prefix !== undefined) {
658
+ if (!prefix) return key;
659
+ return key.startsWith(prefix + '.') ? key.slice(prefix.length + 1) : undefined;
660
+ }
664
661
  const isNestedLocaleFile = !locale.topLevelFiles?.includes(filePath) && normalizeFsPath(locale.path) !== normalizeFsPath(filePath);
665
662
  const stem = fileStem(filePath);
666
663
  if (isNestedLocaleFile && stem && stem !== 'index' && key.startsWith(stem + '.')) return key.slice(stem.length + 1);
@@ -771,6 +768,7 @@ export function getLocaleParamDiagnostics(locale, filePath, text, locales) {
771
768
  const consistency = getLocaleParamConsistency(key, locales);
772
769
  if (consistency.consistent) continue;
773
770
  const lookupKey = lookupKeyForLocaleFile(locale, filePath, key);
771
+ if (lookupKey === undefined) continue;
774
772
  const range = localeKeyRange(text, lookupKey, isJson, jsonRoot);
775
773
  if (range) diagnostics.push(paramInconsistencyDiagnostic(key, range, consistency.perLocale));
776
774
  }
@@ -921,6 +919,7 @@ export function localeKeyAtPosition(locale, filePath, text, position) {
921
919
  let lineFallbackCount = 0;
922
920
  for (const fullKey of Object.keys(locale.flat)) {
923
921
  const lookupKey = lookupKeyForLocaleFile(locale, filePath, fullKey);
922
+ if (lookupKey === undefined) continue;
924
923
  const range = localeKeyRange(text, lookupKey, isJson, jsonRoot);
925
924
  if (!range || range.start.line !== position.line) continue;
926
925
  if (comparePosition(position, range.start) >= 0 && comparePosition(position, range.end) < 0) return fullKey;
@@ -1086,12 +1085,13 @@ function buildNestedProperty(parts, value, indent, unit, eol) {
1086
1085
  }
1087
1086
 
1088
1087
 
1088
+ // File and directory namespaces are applied by the loader, not the parser.
1089
1089
  export function parseTsLocaleModule(text, fileName = 'index.ts') {
1090
1090
  const result = {};
1091
1091
  const stem = fileName.replace(/\.[cm]?[tj]sx?$/i, '');
1092
1092
  const exportObject = extractObjectAfter(text, /export\s+default\s*/g);
1093
1093
  if (exportObject) {
1094
- Object.assign(result, flattenJsObjectLiteral(exportObject, stem === 'index' ? '' : stem));
1094
+ Object.assign(result, flattenJsObjectLiteral(exportObject));
1095
1095
  }
1096
1096
 
1097
1097
  if (stem === 'index') {
package/server/index.js CHANGED
@@ -220,7 +220,7 @@ function getCreateKeyActions(diagnostics, context) {
220
220
  return actions;
221
221
  }
222
222
 
223
- // Namespace routing follows loaded file stems. Index-owned prefixes and
223
+ // Namespace routing follows loaded file prefixes. Index-owned prefixes and
224
224
  // overlapping layouts are not guessed; ambiguous targets disable the action.
225
225
  function planKeyInsertion(key, locale, locales, localeTexts) {
226
226
  if (!locale) return undefined;
@@ -231,10 +231,10 @@ function planKeyInsertion(key, locale, locales, localeTexts) {
231
231
  }
232
232
  if (locale.directories.length !== 1 || locale.topLevelFiles?.length) return undefined;
233
233
  const directory = locale.directories[0];
234
- const targets = filesByStem(locale);
235
- const namespaceFiles = [...targets.keys()].filter((stem) => stem !== 'index' && key.startsWith(stem + '.'));
234
+ const targets = filesByPrefix(locale);
235
+ const namespaceFiles = [...targets.keys()].filter((prefix) => prefix && key.startsWith(prefix + '.'));
236
236
  if (namespaceFiles.length > 1 || targets.has(key)) return undefined;
237
- const indexFiles = targets.get('index') || [];
237
+ const indexFiles = targets.get('') || [];
238
238
  const indexFile = indexFiles.length === 1 && /\.json$/i.test(indexFiles[0]) ? indexFiles[0] : undefined;
239
239
  let indexOwnsPrefix = false;
240
240
  if (indexFiles.length) {
@@ -259,28 +259,35 @@ function planKeyInsertion(key, locale, locales, localeTexts) {
259
259
  const siblingStems = new Map();
260
260
  for (const sibling of Object.values(locales)) {
261
261
  if (sibling === locale || sibling.directories?.length !== 1 || sibling.topLevelFiles?.length) continue;
262
- for (const [stem, files] of filesByStem(sibling)) {
263
- if (stem === 'index' || !key.startsWith(stem + '.')) continue;
264
- siblingStems.set(stem, (siblingStems.get(stem) || []).concat(files));
262
+ for (const [stem, files] of filesByPrefix(sibling)) {
263
+ if (!stem || !key.startsWith(stem + '.')) continue;
264
+ siblingStems.set(stem, (siblingStems.get(stem) || []).concat(files.map((file) => ({
265
+ file,
266
+ relativePath: path.relative(sibling.directories[0], file),
267
+ }))));
265
268
  }
266
269
  }
267
270
  if (siblingStems.size > 1) return undefined;
268
271
  if (siblingStems.size === 1) {
269
- const [stem, files] = siblingStems.entries().next().value;
270
- if (files.some((file) => !/\.json$/i.test(file))) return undefined;
271
- return insertOrCreate(path.join(directory, stem + '.json'), key.slice(stem.length + 1));
272
+ const [stem, targets] = siblingStems.entries().next().value;
273
+ if (targets.some(({ file }) => !/\.json$/i.test(file))) return undefined;
274
+ const relativePaths = new Set(targets.map(({ relativePath }) => relativePath));
275
+ if (relativePaths.size !== 1) return undefined;
276
+ const targetPath = path.join(directory, [...relativePaths][0]);
277
+ return fs.existsSync(path.dirname(targetPath))
278
+ ? insertOrCreate(targetPath, key.slice(stem.length + 1)) : undefined;
272
279
  }
273
280
  return indexFile ? insertInto(indexFile, key) : undefined;
274
281
  }
275
282
 
276
- function filesByStem(locale) {
277
- const stems = new Map();
283
+ function filesByPrefix(locale) {
284
+ const prefixes = new Map();
278
285
  for (const file of locale.files || []) {
279
- const stem = path.basename(file, path.extname(file));
280
- if (!stems.has(stem)) stems.set(stem, []);
281
- stems.get(stem).push(file);
286
+ const prefix = locale.filePrefixes[file];
287
+ if (!prefixes.has(prefix)) prefixes.set(prefix, []);
288
+ prefixes.get(prefix).push(file);
282
289
  }
283
- return stems;
290
+ return prefixes;
284
291
  }
285
292
 
286
293
  function insertInto(filePath, lookupKey) {
@@ -370,7 +377,7 @@ function watchLocalePaths() {
370
377
  .map((dir) => path.join(context.root, dir))
371
378
  .filter((dir) => fs.existsSync(dir));
372
379
  const loaded = loadLocalesForContext(context);
373
- for (const watchPath of collectLocaleWatchPaths(loaded.locales, configuredDirs)) nextPaths.add(watchPath);
380
+ for (const watchPath of collectLocaleWatchPaths(loaded.locales, [...configuredDirs, ...loaded.watchDirectories])) nextPaths.add(watchPath);
374
381
  }
375
382
 
376
383
  for (const watchedPath of watchedLocalePaths) {
@@ -480,10 +487,7 @@ function localeDirOfFile(filePath) {
480
487
  const absDir = normalizeFsPathLocal(path.resolve(contextRoot, dir));
481
488
  if (!isPathInsidePath(normalized, absDir) || normalized === absDir) continue;
482
489
  const relative = normalized.slice(absDir.length + 1);
483
- const depth = relative.split('/').length - 1;
484
- // Only files directly under the localeDir or one level deep (the locale
485
- // directory itself) are locale files, matching the loaders.
486
- if (depth <= 1) return { context, dir: absDir, relative };
490
+ return { context, dir: absDir, relative };
487
491
  }
488
492
  }
489
493
  return undefined;
@@ -646,35 +650,52 @@ function loadLocalesForContext(context) {
646
650
 
647
651
  const locales = {};
648
652
  const localeTexts = {};
649
- for (const dir of context.config.localeDirs) {
650
- const fullDir = path.join(context.root, dir);
653
+ const watchDirectories = new Set();
654
+ const localeRoots = context.config.localeDirs.map((dir) => path.resolve(context.root, dir));
655
+ for (const fullDir of localeRoots) {
651
656
  if (!fs.existsSync(fullDir)) continue;
652
657
  loadLocaleFiles(fullDir, locales, localeTexts);
653
658
  for (const entry of fs.readdirSync(fullDir, { withFileTypes: true })) {
654
- if (entry.isDirectory()) loadLocaleDirectory(path.join(fullDir, entry.name), entry.name, locales, localeTexts);
659
+ if (entry.isDirectory()) loadLocaleDirectory(path.join(fullDir, entry.name), entry.name, locales, localeTexts, localeRoots, watchDirectories);
655
660
  }
656
661
  }
657
- const loaded = { locales, localeTexts };
662
+ const loaded = { locales, localeTexts, watchDirectories: [...watchDirectories] };
658
663
  localeCacheByContext.set(cacheKey, loaded);
659
664
  return loaded;
660
665
  }
661
666
 
662
- function loadLocaleDirectory(localeDir, localeName, next, localeTexts) {
663
- next[localeName] ||= { path: localeDir, flat: {}, files: [] };
664
- next[localeName].directories ||= [];
665
- if (!next[localeName].directories.includes(localeDir)) next[localeName].directories.push(localeDir);
666
- for (const file of fs.readdirSync(localeDir).sort()) {
667
+ function loadLocaleDirectory(localeDir, localeName, next, localeTexts, localeRoots, watchDirectories, rootDir = localeDir) {
668
+ // An explicit locale root owns its subtree, independently of config order.
669
+ if (localeRoots.includes(localeDir)) return;
670
+ // Fileless directories still need watching, but must not create phantom locales.
671
+ watchDirectories.add(localeDir);
672
+ for (const entry of fs.readdirSync(localeDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
673
+ const file = entry.name;
667
674
  const fullPath = path.join(localeDir, file);
668
- if (fs.statSync(fullPath).isDirectory()) continue;
675
+ if (entry.isDirectory()) {
676
+ loadLocaleDirectory(fullPath, localeName, next, localeTexts, localeRoots, watchDirectories, rootDir);
677
+ continue;
678
+ }
669
679
  if (!LOCALE_FILE_RE.test(file)) continue;
670
- if (!next[localeName].files.includes(fullPath)) next[localeName].files.push(fullPath);
680
+ if (entry.isSymbolicLink() && fs.statSync(fullPath).isDirectory()) continue;
681
+ next[localeName] ||= { path: rootDir, flat: {}, keyFiles: {}, files: [] };
682
+ const locale = next[localeName];
683
+ locale.directories ||= [];
684
+ if (!locale.directories.includes(rootDir)) locale.directories.push(rootDir);
685
+ locale.filePrefixes ||= {};
686
+ const relativeFile = path.relative(rootDir, fullPath);
687
+ if (!locale.files.includes(fullPath)) locale.files.push(fullPath);
688
+ locale.filePrefixes[fullPath] = localeFilePrefix(relativeFile);
671
689
  try {
672
690
  const text = localeText(fullPath);
673
691
  if (text === undefined) continue;
674
692
  localeTexts[fullPath] = text;
675
- if (file.endsWith('.json')) Object.assign(next[localeName].flat, prefixByFile(file, flattenLocale(JSON.parse(text))));
676
- else if (/\.[cm]?[tj]s$/i.test(file)) Object.assign(next[localeName].flat, parseTsLocaleModule(text, file));
677
- else continue;
693
+ const flat = prefixByFile(relativeFile, file.endsWith('.json')
694
+ ? flattenLocale(JSON.parse(text)) : parseTsLocaleModule(text, file));
695
+ for (const [key, value] of Object.entries(flat)) {
696
+ locale.flat[key] = value;
697
+ locale.keyFiles[key] = fullPath;
698
+ }
678
699
  } catch (error) { connection.console.warn('Failed to load locale file ' + fullPath + ': ' + error.message); }
679
700
  }
680
701
  }
@@ -698,7 +719,9 @@ function loadLocaleFiles(fullDir, next, localeTexts) {
698
719
  const isModule = /\.[cm]?[tj]s$/i.test(file);
699
720
  if (!isJson && !isModule) continue;
700
721
  const name = file.replace(/\.(json|[cm]?[tj]s)$/i, '');
701
- next[name] ||= { path: fullPath, flat: {}, files: [], topLevelFiles: [] };
722
+ next[name] ||= { path: fullPath, flat: {}, keyFiles: {}, files: [], topLevelFiles: [] };
723
+ next[name].filePrefixes ||= {};
724
+ next[name].filePrefixes[fullPath] = '';
702
725
  next[name].topLevelFiles ||= [];
703
726
  if (!next[name].files.includes(fullPath)) next[name].files.push(fullPath);
704
727
  if (!next[name].topLevelFiles.includes(fullPath)) next[name].topLevelFiles.push(fullPath);
@@ -706,15 +729,25 @@ function loadLocaleFiles(fullDir, next, localeTexts) {
706
729
  if (text === undefined) continue;
707
730
  localeTexts[fullPath] = text;
708
731
  const flat = isJson ? flattenLocale(JSON.parse(text)) : parseTsLocaleModule(text, 'index.ts');
709
- Object.assign(next[name].flat, flat);
732
+ for (const [key, value] of Object.entries(flat)) {
733
+ next[name].flat[key] = value;
734
+ next[name].keyFiles[key] = fullPath;
735
+ }
710
736
  } catch (error) { connection.console.warn('Failed to load locale file ' + fullPath + ': ' + error.message); }
711
737
  }
712
738
  }
713
739
 
714
740
  function prefixByFile(file, flat) {
715
- const stem = path.basename(file, path.extname(file));
716
- if (stem === 'index') return flat;
717
- return Object.fromEntries(Object.entries(flat).map(([key, value]) => [stem + '.' + key, value]));
741
+ const prefix = localeFilePrefix(file);
742
+ if (!prefix) return flat;
743
+ return Object.fromEntries(Object.entries(flat).map(([key, value]) => [prefix + '.' + key, value]));
744
+ }
745
+
746
+ function localeFilePrefix(file) {
747
+ const segments = file.replace(/\\/g, '/').split('/');
748
+ const stem = segments.pop().replace(/\.[^.]+$/, '');
749
+ if (stem !== 'index') segments.push(stem);
750
+ return segments.join('.');
718
751
  }
719
752
 
720
753
  documents.listen(connection);