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 +14 -0
- package/package.json +1 -1
- package/server/core.js +17 -17
- package/server/index.js +73 -40
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.
|
|
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
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
const
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
|
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
|
|
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 =
|
|
235
|
-
const namespaceFiles = [...targets.keys()].filter((
|
|
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('
|
|
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
|
|
263
|
-
if (stem
|
|
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,
|
|
270
|
-
if (
|
|
271
|
-
|
|
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
|
|
277
|
-
const
|
|
283
|
+
function filesByPrefix(locale) {
|
|
284
|
+
const prefixes = new Map();
|
|
278
285
|
for (const file of locale.files || []) {
|
|
279
|
-
const
|
|
280
|
-
if (!
|
|
281
|
-
|
|
286
|
+
const prefix = locale.filePrefixes[file];
|
|
287
|
+
if (!prefixes.has(prefix)) prefixes.set(prefix, []);
|
|
288
|
+
prefixes.get(prefix).push(file);
|
|
282
289
|
}
|
|
283
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
650
|
-
|
|
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
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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
|
-
|
|
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
|
|
716
|
-
if (
|
|
717
|
-
return Object.fromEntries(Object.entries(flat).map(([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);
|