i18n-lens-language-server 0.8.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/LICENSE +21 -0
- package/README.md +219 -0
- package/package.json +26 -0
- package/server/core.js +473 -0
- package/server/index.js +272 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 yizixu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# I18n Lens for Zed
|
|
2
|
+
|
|
3
|
+
Zed extension plus Language Server for Vue, TypeScript, TSX, JavaScript and JSX. It shows the real translation text behind i18n keys while editing.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Inline translation display via LSP inlay hints
|
|
8
|
+
- Detect `$t`/`t`/`tc`/`$tc`/`i18n.t` calls, `v-t="'key'"`, `v-t="{ path: 'key' }"`, `<i18n-t keypath="key">` (Vue I18n), `<Trans i18nKey="key">` (react-i18next), and `formatMessage({ id: "key" })` (react-intl)
|
|
9
|
+
- Hover: show all locale values for a key
|
|
10
|
+
- Diagnostics: report keys missing from every locale file as errors and keys missing from some locales as warnings
|
|
11
|
+
- Completion: suggest existing i18n keys with default locale text
|
|
12
|
+
- Definition: jump from a source key to its locale definition; when several locales define the key, pick which language file to open
|
|
13
|
+
|
|
14
|
+
## Inline translations
|
|
15
|
+
|
|
16
|
+
When inlay hints are enabled in Zed, code like this:
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
t("order.pay_now")
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
will show the default locale translation inline after the key string, for example:
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
t("order.pay_now") 立即支付
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The inline hint uses `defaultLocale` from `.zed/i18nlensrc.json`, falling back to `zh-CN`. Missing keys are not shown inline because diagnostics already report them.
|
|
29
|
+
|
|
30
|
+
Long translations are truncated inline to keep the editor readable. Hover still shows the full locale table.
|
|
31
|
+
|
|
32
|
+
## Hover
|
|
33
|
+
|
|
34
|
+
Hovering over an i18n key shows every loaded locale value in a read-only table.
|
|
35
|
+
|
|
36
|
+
To open a specific language file, use Go to Definition (Ctrl/Cmd+click or F12) on the key. When the key exists in more than one locale, the editor offers each language's file — default locale first — so you can jump straight to the one you want instead of only the default locale.
|
|
37
|
+
|
|
38
|
+
## Diagnostics
|
|
39
|
+
|
|
40
|
+
Diagnostics refresh automatically when loaded locale files or locale directories change, so editing translation files should update warnings/errors without restarting the extension.
|
|
41
|
+
|
|
42
|
+
Diagnostics distinguish between two missing-key cases:
|
|
43
|
+
|
|
44
|
+
- If a key is missing from every loaded locale, it is reported as an error.
|
|
45
|
+
- If a key exists in at least one locale but is missing from other locales, it is reported as a warning listing the missing locale names.
|
|
46
|
+
|
|
47
|
+
For example, if `order.pay_now` exists in `zh-CN` but is missing from `en-US`, the warning message is:
|
|
48
|
+
|
|
49
|
+
```text
|
|
50
|
+
Missing i18n key "order.pay_now" in locales: en-US
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Project configuration
|
|
54
|
+
|
|
55
|
+
You can add `.zed/i18nlensrc.json` under your project root to override the defaults:
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
{
|
|
59
|
+
"defaultLocale": "zh-CN",
|
|
60
|
+
"localeDirs": ["src/locales", "src/i18n", "locales", "i18n"],
|
|
61
|
+
"inlayHints": {
|
|
62
|
+
"enabled": true,
|
|
63
|
+
"maxLength": 24
|
|
64
|
+
},
|
|
65
|
+
"packages": [
|
|
66
|
+
{
|
|
67
|
+
"root": "apps/web",
|
|
68
|
+
"defaultLocale": "zh-CN",
|
|
69
|
+
"localeDirs": ["src/locales"]
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"root": "packages/admin",
|
|
73
|
+
"defaultLocale": "en-US",
|
|
74
|
+
"localeDirs": ["src/i18n"]
|
|
75
|
+
}
|
|
76
|
+
]
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
A template is available in this repository:
|
|
81
|
+
|
|
82
|
+
```text
|
|
83
|
+
.zed/i18nlensrc.example.json
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Supported options:
|
|
87
|
+
|
|
88
|
+
| Option | Type | Default | Description |
|
|
89
|
+
|---|---|---|---|
|
|
90
|
+
| `defaultLocale` | string | `zh-CN` | Locale used for completion details and inline translation hints. Supports exact names such as `en-US`, case-insensitive matching, `_`/`-` normalization, and language aliases such as `en` matching `en-US`. |
|
|
91
|
+
| `localeDirs` | string[] | `src/locales`, `src/i18n`, `locales`, `i18n` | Workspace-relative directories to scan for locale files. |
|
|
92
|
+
| `inlayHints.enabled` | boolean | `true` | Enables or disables inline translation hints from this language server. |
|
|
93
|
+
| `inlayHints.maxLength` | number | `24` | Maximum inline hint label length before truncation. |
|
|
94
|
+
| `packages` | array | `[]` | Optional monorepo package contexts. Each item needs a workspace-relative `root` and can override `defaultLocale`, `localeDirs`, and `inlayHints`. The language server picks the longest package root matching the currently edited file. |
|
|
95
|
+
|
|
96
|
+
Invalid or missing config values fall back to defaults. If `.zed/i18nlensrc.json` contains invalid JSON, the language server logs a warning and continues with default config.
|
|
97
|
+
|
|
98
|
+
Configuration changes are watched automatically. After saving `.zed/i18nlensrc.json`, the language server reloads config, rebuilds the locale cache, refreshes diagnostics, and asks the editor to refresh inlay hints. You should not need to rebuild or reload the Zed extension just to apply config changes.
|
|
99
|
+
|
|
100
|
+
## Locale discovery
|
|
101
|
+
|
|
102
|
+
In a single-project workspace, `localeDirs` are resolved relative to the workspace root. The language server currently reads JSON files from:
|
|
103
|
+
|
|
104
|
+
- `src/locales/*.json`
|
|
105
|
+
- `src/i18n/*.json`
|
|
106
|
+
- `locales/*.json`
|
|
107
|
+
- `i18n/*.json`
|
|
108
|
+
|
|
109
|
+
Locale name comes from filename, for example `zh-CN.json` or `en-US.json`.
|
|
110
|
+
|
|
111
|
+
It also supports locale directories such as:
|
|
112
|
+
|
|
113
|
+
```text
|
|
114
|
+
src/locales/zh-CN/common.json
|
|
115
|
+
src/locales/en-US/common.json
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
A file named `common.json` contributes keys with the `common.` prefix. A file named `index.json` contributes keys without an `index.` prefix.
|
|
119
|
+
|
|
120
|
+
## Monorepo workspaces
|
|
121
|
+
|
|
122
|
+
For pnpm/turborepo and other monorepos, put `.zed/i18nlensrc.json` under the workspace root and add `packages` entries:
|
|
123
|
+
|
|
124
|
+
```json
|
|
125
|
+
{
|
|
126
|
+
"defaultLocale": "zh-CN",
|
|
127
|
+
"packages": [
|
|
128
|
+
{
|
|
129
|
+
"root": "apps/web",
|
|
130
|
+
"localeDirs": ["src/locales"]
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
"root": "packages/admin",
|
|
134
|
+
"defaultLocale": "en-US",
|
|
135
|
+
"localeDirs": ["src/i18n"]
|
|
136
|
+
}
|
|
137
|
+
]
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
When a document is opened, I18n Lens selects the package whose `root` is the longest path prefix of that document. `localeDirs` inside a package are resolved relative to that package root, so `apps/web/src/locales` and `packages/admin/src/i18n` are indexed independently. Package entries inherit root-level `defaultLocale`, `localeDirs`, and `inlayHints` unless they override them.
|
|
142
|
+
|
|
143
|
+
## Local development
|
|
144
|
+
|
|
145
|
+
Install dependencies:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
npm install
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Run tests:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
npm test
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
The language server is published to npm as
|
|
158
|
+
[`i18n-lens-language-server`](https://www.npmjs.com/package/i18n-lens-language-server).
|
|
159
|
+
A Zed extension must not ship its language server, so the Rust shell installs
|
|
160
|
+
that npm package into its work directory at runtime via the Zed Extension API
|
|
161
|
+
(`npm_install_package`), the same pattern the official Node-based extensions use.
|
|
162
|
+
|
|
163
|
+
Check the Zed extension adapter:
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
cargo check --target wasm32-wasip1
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
On this Windows setup, if `cargo` is not visible inside Git Bash / WSL, use:
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
"$HOME/.cargo/bin/cargo.exe" check --target wasm32-wasip1
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Test in Zed
|
|
176
|
+
|
|
177
|
+
1. Open Zed.
|
|
178
|
+
2. Run `Install Dev Extension` from the command palette.
|
|
179
|
+
3. Select this project root, for example the local `zed-i18n-lens` repository directory.
|
|
180
|
+
4. Open a Vue/TS/JS project with locale JSON files.
|
|
181
|
+
5. Test hover, completion, diagnostics, definition, and inlay hints.
|
|
182
|
+
|
|
183
|
+
If inline translations do not appear, check whether inlay hints are enabled in Zed settings.
|
|
184
|
+
|
|
185
|
+
### Iterating on the language server
|
|
186
|
+
|
|
187
|
+
The published npm version can lag behind your working tree. To test local `server/*.js` changes without publishing, pack the current tree and install it into the Zed work directory, then restart the server:
|
|
188
|
+
|
|
189
|
+
```powershell
|
|
190
|
+
./scripts/deploy-local.ps1
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
It runs `npm pack`, installs the tarball into `%LOCALAPPDATA%\Zed\extensions\work\i18n-lens\node_modules\i18n-lens-language-server` (the same layout Zed produces), then prompts you to run **restart language server** from the Zed command palette.
|
|
194
|
+
|
|
195
|
+
> To exercise the real install path instead, delete `%LOCALAPPDATA%\Zed\extensions\work\i18n-lens\node_modules` and restart the server — Zed will reinstall the published npm package.
|
|
196
|
+
|
|
197
|
+
## Releasing
|
|
198
|
+
|
|
199
|
+
The version is single-sourced across `Cargo.toml`, `extension.toml`, and
|
|
200
|
+
`package.json` (they must all match — the release workflow enforces this). To
|
|
201
|
+
cut a release:
|
|
202
|
+
|
|
203
|
+
1. Bump `version` in `Cargo.toml`, `extension.toml`, and `package.json`.
|
|
204
|
+
2. Tag and push:
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
git tag v<version> # e.g. v0.7.0
|
|
208
|
+
git push origin v<version>
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
The `.github/workflows/release.yml` workflow then verifies the versions
|
|
212
|
+
match, runs the tests, and publishes the language server to npm as
|
|
213
|
+
`i18n-lens-language-server`. This requires an `NPM_TOKEN` repository secret
|
|
214
|
+
with publish rights.
|
|
215
|
+
|
|
216
|
+
To publish manually instead: `npm publish --access public`.
|
|
217
|
+
3. Submit/update the extension in `zed-industries/extensions` (bump the
|
|
218
|
+
`version` in its `extensions.toml` to match) so Zed picks up the new
|
|
219
|
+
extension version, which installs the new npm package.
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "i18n-lens-language-server",
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Language server for configurable i18n inlay hints, hover, completion, diagnostics, and definition in Vue/TS/TSX/JS.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/yizixu/zed-i18n-lens.git"
|
|
10
|
+
},
|
|
11
|
+
"main": "server/index.js",
|
|
12
|
+
"bin": {
|
|
13
|
+
"i18n-lens-language-server": "./server/index.js"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"server/*.js"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "node --test tests/*.test.js",
|
|
20
|
+
"start": "node server/index.js"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"vscode-languageserver": "^9.0.1",
|
|
24
|
+
"vscode-languageserver-textdocument": "^1.0.12"
|
|
25
|
+
}
|
|
26
|
+
}
|
package/server/core.js
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
export const DEFAULT_CONFIG = Object.freeze({
|
|
2
|
+
defaultLocale: 'zh-CN',
|
|
3
|
+
localeDirs: Object.freeze(['src/locales', 'src/i18n', 'locales', 'i18n']),
|
|
4
|
+
inlayHints: Object.freeze({ enabled: true, maxLength: 24 }),
|
|
5
|
+
packages: Object.freeze([]),
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
export const CONFIG_RELATIVE_PATH = '.zed/i18nlensrc.json';
|
|
9
|
+
|
|
10
|
+
export function resolveConfigPath(workspaceRoot) {
|
|
11
|
+
return joinFsPath(normalizeFsPath(workspaceRoot), CONFIG_RELATIVE_PATH);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function normalizeI18nLensConfig(raw = {}) {
|
|
15
|
+
const base = normalizeProjectConfig(raw, DEFAULT_CONFIG);
|
|
16
|
+
const packages = Array.isArray(raw.packages)
|
|
17
|
+
? raw.packages
|
|
18
|
+
.map((pkg) => normalizePackageConfig(pkg, base))
|
|
19
|
+
.filter(Boolean)
|
|
20
|
+
: [];
|
|
21
|
+
|
|
22
|
+
return { ...base, packages };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function normalizeProjectConfig(raw = {}, defaults = DEFAULT_CONFIG) {
|
|
26
|
+
const defaultLocale = typeof raw.defaultLocale === 'string' && raw.defaultLocale.trim()
|
|
27
|
+
? raw.defaultLocale.trim()
|
|
28
|
+
: defaults.defaultLocale;
|
|
29
|
+
|
|
30
|
+
const localeDirs = Array.isArray(raw.localeDirs)
|
|
31
|
+
? raw.localeDirs.filter((dir) => typeof dir === 'string' && dir.trim()).map((dir) => normalizeRelativePath(dir.trim()))
|
|
32
|
+
: [];
|
|
33
|
+
|
|
34
|
+
const defaultInlayHints = defaults.inlayHints || DEFAULT_CONFIG.inlayHints;
|
|
35
|
+
const rawInlayHints = raw.inlayHints && typeof raw.inlayHints === 'object' ? raw.inlayHints : {};
|
|
36
|
+
const enabled = typeof rawInlayHints.enabled === 'boolean' ? rawInlayHints.enabled : defaultInlayHints.enabled;
|
|
37
|
+
const maxLength = Number.isInteger(rawInlayHints.maxLength) && rawInlayHints.maxLength > 0
|
|
38
|
+
? rawInlayHints.maxLength
|
|
39
|
+
: defaultInlayHints.maxLength;
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
defaultLocale,
|
|
43
|
+
localeDirs: localeDirs.length > 0 ? localeDirs : [...defaults.localeDirs],
|
|
44
|
+
inlayHints: { enabled, maxLength },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizePackageConfig(raw, inherited) {
|
|
49
|
+
if (!raw || typeof raw !== 'object') return undefined;
|
|
50
|
+
const root = typeof raw.root === 'string' && raw.root.trim()
|
|
51
|
+
? normalizeRelativePath(raw.root.trim())
|
|
52
|
+
: undefined;
|
|
53
|
+
if (!root) return undefined;
|
|
54
|
+
return { root, ...normalizeProjectConfig(raw, inherited) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function normalizeRelativePath(value) {
|
|
58
|
+
return value.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/g, '');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function resolveProjectContext(filePath, workspaceRoot, config) {
|
|
62
|
+
const normalizedWorkspaceRoot = normalizeFsPath(workspaceRoot);
|
|
63
|
+
const normalizedFilePath = normalizeFsPath(filePath);
|
|
64
|
+
const candidates = [
|
|
65
|
+
{ root: normalizedWorkspaceRoot, config: withoutPackages(config) },
|
|
66
|
+
...((config?.packages || []).map((pkg) => ({
|
|
67
|
+
root: normalizeFsPath(joinFsPath(normalizedWorkspaceRoot, pkg.root)),
|
|
68
|
+
config: withoutPackages(pkg),
|
|
69
|
+
}))),
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
return candidates
|
|
73
|
+
.filter((candidate) => isPathInside(normalizedFilePath, candidate.root))
|
|
74
|
+
.sort((a, b) => b.root.length - a.root.length)[0] || candidates[0];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function withoutPackages(config) {
|
|
78
|
+
const { packages: _packages, ...rest } = config || normalizeI18nLensConfig();
|
|
79
|
+
return rest;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function normalizeFsPath(value) {
|
|
83
|
+
return String(value || '').replace(/\\/g, '/').replace(/\/+$/g, '');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function joinFsPath(root, relative) {
|
|
87
|
+
return root + '/' + normalizeRelativePath(relative || '');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isPathInside(filePath, root) {
|
|
91
|
+
return filePath === root || filePath.startsWith(root + '/');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
export function didWatchedFileChange(previous, current) {
|
|
96
|
+
return previous?.mtimeMs !== current?.mtimeMs;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function flattenLocale(value, prefix = '', out = {}) {
|
|
100
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return out;
|
|
101
|
+
for (const [key, child] of Object.entries(value)) {
|
|
102
|
+
const fullKey = prefix ? prefix + '.' + key : key;
|
|
103
|
+
if (child && typeof child === 'object' && !Array.isArray(child)) flattenLocale(child, fullKey, out);
|
|
104
|
+
else out[fullKey] = String(child);
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function getValueByKey(obj, key) {
|
|
110
|
+
return key.split('.').reduce((acc, part) => (acc && Object.prototype.hasOwnProperty.call(acc, part) ? acc[part] : undefined), obj);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function offsetToPosition(text, offset) {
|
|
114
|
+
const before = text.slice(0, offset);
|
|
115
|
+
const lines = before.split('\n');
|
|
116
|
+
return { line: lines.length - 1, character: lines[lines.length - 1].length };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function makeRange(text, start, end) {
|
|
120
|
+
return { start: offsetToPosition(text, start), end: offsetToPosition(text, end) };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function extractI18nKeys(text) {
|
|
124
|
+
const found = [];
|
|
125
|
+
const patterns = [
|
|
126
|
+
// t/$t/tc/$tc/i18n.t/i18n.tc(...) — function calls, incl. Vue I18n plural tc
|
|
127
|
+
/(?:\x24tc?|\btc?|\bi18n\.tc?)\(\s*(['"])([A-Za-z0-9_.:-]+)\1/g,
|
|
128
|
+
// react-intl: formatMessage({ id: "key" }) (id may not be the first prop)
|
|
129
|
+
/\bformatMessage\s*\(\s*\{[^}]*?\bid\s*:\s*(['"])([A-Za-z0-9_.:-]+)\1/g,
|
|
130
|
+
// <i18n-t keypath="key"> (Vue I18n) and <Trans i18nKey="key"> (react-i18next)
|
|
131
|
+
/\b(?:keypath|i18nKey)\s*=\s*(['"])([A-Za-z0-9_.:-]+)\1/g,
|
|
132
|
+
/\bv-t\s*=\s*"'([A-Za-z0-9_.:-]+)'"/g,
|
|
133
|
+
/\bv-t\s*=\s*"\s*\{\s*path\s*:\s*(['"])([A-Za-z0-9_.:-]+)\1/g,
|
|
134
|
+
];
|
|
135
|
+
for (const re of patterns) {
|
|
136
|
+
let match;
|
|
137
|
+
while ((match = re.exec(text))) {
|
|
138
|
+
const key = match[2] || match[1];
|
|
139
|
+
const keyOffsetInMatch = match[0].indexOf(key);
|
|
140
|
+
const start = match.index + keyOffsetInMatch;
|
|
141
|
+
const end = start + key.length;
|
|
142
|
+
found.push({ key, range: makeRange(text, start, end), startOffset: start, endOffset: end });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
found.sort((a, b) => a.startOffset - b.startOffset);
|
|
146
|
+
return found;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function keyAtPosition(text, position) {
|
|
150
|
+
const offset = positionToOffset(text, position);
|
|
151
|
+
return extractI18nKeys(text).find((item) => item.startOffset <= offset && offset <= item.endOffset);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function positionToOffset(text, position) {
|
|
155
|
+
const lines = text.split('\n');
|
|
156
|
+
let offset = 0;
|
|
157
|
+
for (let i = 0; i < position.line; i++) offset += (lines[i] || '').length + 1;
|
|
158
|
+
return offset + position.character;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
export function collectLocaleWatchPaths(localeCache, localeDirs = []) {
|
|
163
|
+
const paths = new Set(localeDirs.filter(Boolean));
|
|
164
|
+
for (const locale of Object.values(localeCache || {})) {
|
|
165
|
+
if (locale.path) paths.add(locale.path);
|
|
166
|
+
for (const file of locale.files || []) paths.add(file);
|
|
167
|
+
}
|
|
168
|
+
return [...paths].sort();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function findLocaleKeyTarget(locale, key, localeTexts = {}) {
|
|
172
|
+
if (!locale || !Object.prototype.hasOwnProperty.call(locale.flat || {}, key)) return undefined;
|
|
173
|
+
const files = Array.isArray(locale.files) && locale.files.length
|
|
174
|
+
? locale.files
|
|
175
|
+
: (locale.path ? [locale.path] : []);
|
|
176
|
+
const candidates = files
|
|
177
|
+
.map((filePath) => ({ filePath, lookupKey: lookupKeyForLocaleFile(locale, filePath, key) }))
|
|
178
|
+
.sort((a, b) => Number(a.lookupKey === key) - Number(b.lookupKey === key));
|
|
179
|
+
|
|
180
|
+
for (const candidate of candidates) {
|
|
181
|
+
const text = localeTexts[candidate.filePath];
|
|
182
|
+
if (!text) continue;
|
|
183
|
+
const position = /\.json$/i.test(candidate.filePath)
|
|
184
|
+
? findJsonKeyLocation(text, candidate.lookupKey)
|
|
185
|
+
: findLocaleKeyLocation(text, candidate.lookupKey);
|
|
186
|
+
if (position) return { filePath: candidate.filePath, position };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const fallback = candidates[0];
|
|
190
|
+
return fallback ? { filePath: fallback.filePath } : undefined;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function lookupKeyForLocaleFile(locale, filePath, key) {
|
|
194
|
+
const isNestedLocaleFile = normalizeFsPath(locale.path) !== normalizeFsPath(filePath);
|
|
195
|
+
const stem = fileStem(filePath);
|
|
196
|
+
if (isNestedLocaleFile && stem && stem !== 'index' && key.startsWith(stem + '.')) return key.slice(stem.length + 1);
|
|
197
|
+
return key;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function fileStem(filePath) {
|
|
201
|
+
const fileName = String(filePath || '').replace(/\\/g, '/').split('/').at(-1) || '';
|
|
202
|
+
return fileName.replace(/\.[^.]+$/, '');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function buildHoverMarkdown(key, locales) {
|
|
206
|
+
const names = Object.keys(locales).sort();
|
|
207
|
+
if (names.length === 0) return '**' + key + '**\n\nNo locale JSON files found.';
|
|
208
|
+
const lines = ['**' + key + '**', '', '| Locale | Text |', '|---|---|'];
|
|
209
|
+
for (const name of names) {
|
|
210
|
+
const value = locales[name].flat[key];
|
|
211
|
+
lines.push('| ' + escapeMd(name) + ' | ' + escapeMd(value ?? 'Missing') + ' |');
|
|
212
|
+
}
|
|
213
|
+
return lines.join('\n');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function escapeMd(value) {
|
|
217
|
+
return String(value).replace(/\|/g, '\\|').replace(/\n/g, '<br>');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function getDiagnostics(text, locales) {
|
|
221
|
+
const localeEntries = Object.entries(locales);
|
|
222
|
+
if (localeEntries.length === 0) return [];
|
|
223
|
+
|
|
224
|
+
return extractI18nKeys(text)
|
|
225
|
+
.map((item) => {
|
|
226
|
+
const missingLocales = localeEntries
|
|
227
|
+
.filter(([, locale]) => !Object.prototype.hasOwnProperty.call(locale.flat, item.key))
|
|
228
|
+
.map(([name]) => name)
|
|
229
|
+
.sort();
|
|
230
|
+
|
|
231
|
+
if (missingLocales.length === 0) return undefined;
|
|
232
|
+
const missingInAllLocales = missingLocales.length === localeEntries.length;
|
|
233
|
+
return {
|
|
234
|
+
severity: missingInAllLocales ? 1 : 2,
|
|
235
|
+
range: item.range,
|
|
236
|
+
message: missingInAllLocales
|
|
237
|
+
? 'Missing i18n key: ' + item.key
|
|
238
|
+
: 'Missing i18n key "' + item.key + '" in locales: ' + missingLocales.join(', '),
|
|
239
|
+
source: 'i18n-lens',
|
|
240
|
+
data: { key: item.key, missingLocales, missingInAllLocales },
|
|
241
|
+
};
|
|
242
|
+
})
|
|
243
|
+
.filter(Boolean);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function resolvePreferredLocale(locales, defaultLocale) {
|
|
247
|
+
if (!locales || Object.keys(locales).length === 0) return undefined;
|
|
248
|
+
if (locales[defaultLocale]) return locales[defaultLocale];
|
|
249
|
+
|
|
250
|
+
const requested = normalizeLocaleName(defaultLocale);
|
|
251
|
+
const entries = Object.entries(locales);
|
|
252
|
+
const exactNormalized = entries.find(([name]) => normalizeLocaleName(name) === requested);
|
|
253
|
+
if (exactNormalized) return exactNormalized[1];
|
|
254
|
+
|
|
255
|
+
const requestedLanguage = requested.split('-')[0];
|
|
256
|
+
const languageMatch = entries.find(([name]) => {
|
|
257
|
+
const candidate = normalizeLocaleName(name);
|
|
258
|
+
const candidateLanguage = candidate.split('-')[0];
|
|
259
|
+
return candidateLanguage && requestedLanguage && candidateLanguage === requestedLanguage;
|
|
260
|
+
});
|
|
261
|
+
return languageMatch?.[1] || entries[0][1];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function normalizeLocaleName(name) {
|
|
265
|
+
return String(name || '').trim().replace(/_/g, '-').toLowerCase();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function getCompletions(prefix, locales, defaultLocale) {
|
|
269
|
+
const keys = new Set();
|
|
270
|
+
for (const locale of Object.values(locales)) for (const key of Object.keys(locale.flat)) keys.add(key);
|
|
271
|
+
const preferred = resolvePreferredLocale(locales, defaultLocale);
|
|
272
|
+
return [...keys]
|
|
273
|
+
.filter((key) => key.startsWith(prefix))
|
|
274
|
+
.sort()
|
|
275
|
+
.map((key) => ({ label: key, kind: 12, detail: preferred?.flat[key] || undefined, insertText: key }));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
export function getDefinitionLocaleOrder(locales, defaultLocale) {
|
|
280
|
+
const localeList = Object.values(locales || {});
|
|
281
|
+
const preferred = resolvePreferredLocale(locales, defaultLocale);
|
|
282
|
+
if (!preferred) return localeList;
|
|
283
|
+
return [preferred, ...localeList.filter((locale) => locale !== preferred)];
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Every locale that defines the key, with the default locale first, so
|
|
287
|
+
// Go to Definition can return one LSP Location per language file.
|
|
288
|
+
export function collectLocaleKeyTargets(locales, key, localeTexts = {}, defaultLocale) {
|
|
289
|
+
return getDefinitionLocaleOrder(locales, defaultLocale)
|
|
290
|
+
.map((locale) => findLocaleKeyTarget(locale, key, localeTexts))
|
|
291
|
+
.filter((target) => target?.position);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function getCompletionPrefix(text, position) {
|
|
295
|
+
const offset = positionToOffset(text, position);
|
|
296
|
+
const before = text.slice(0, offset);
|
|
297
|
+
const patterns = [
|
|
298
|
+
/(?:\x24tc?|\btc?|\bi18n\.tc?)\(\s*['"]([A-Za-z0-9_.:-]*)$/,
|
|
299
|
+
/\bformatMessage\s*\(\s*\{[^}]*?\bid\s*:\s*['"]([A-Za-z0-9_.:-]*)$/,
|
|
300
|
+
/\b(?:keypath|i18nKey)\s*=\s*['"]([A-Za-z0-9_.:-]*)$/,
|
|
301
|
+
/v-t\s*=\s*"'([A-Za-z0-9_.:-]*)$/,
|
|
302
|
+
];
|
|
303
|
+
for (const re of patterns) {
|
|
304
|
+
const match = before.match(re);
|
|
305
|
+
if (match) return match[1];
|
|
306
|
+
}
|
|
307
|
+
return '';
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function findJsonKeyLocation(jsonText, key) {
|
|
311
|
+
const parts = key.split('.');
|
|
312
|
+
let cursor = 0;
|
|
313
|
+
for (const part of parts) {
|
|
314
|
+
const re = new RegExp('"' + escapeRegExp(part) + '"\\s*:', 'g');
|
|
315
|
+
re.lastIndex = cursor;
|
|
316
|
+
const match = re.exec(jsonText);
|
|
317
|
+
if (!match) return undefined;
|
|
318
|
+
cursor = match.index + match[0].length;
|
|
319
|
+
if (part === parts[parts.length - 1]) return offsetToPosition(jsonText, match.index);
|
|
320
|
+
}
|
|
321
|
+
return undefined;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
export function parseTsLocaleModule(text, fileName = 'index.ts') {
|
|
326
|
+
const result = {};
|
|
327
|
+
const stem = fileName.replace(/\.[cm]?[tj]sx?$/i, '');
|
|
328
|
+
const exportObject = extractObjectAfter(text, /export\s+default\s*/g);
|
|
329
|
+
if (exportObject) {
|
|
330
|
+
Object.assign(result, flattenJsObjectLiteral(exportObject, stem === 'index' ? '' : stem));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (stem === 'index') {
|
|
334
|
+
const constRe = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*\{/g;
|
|
335
|
+
let match;
|
|
336
|
+
while ((match = constRe.exec(text))) {
|
|
337
|
+
const objectStart = text.indexOf('{', match.index);
|
|
338
|
+
const objectText = readBalanced(text, objectStart);
|
|
339
|
+
if (objectText) Object.assign(result, flattenJsObjectLiteral(objectText, match[1]));
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return result;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function extractObjectAfter(text, re) {
|
|
346
|
+
const match = re.exec(text);
|
|
347
|
+
if (!match) return undefined;
|
|
348
|
+
const start = text.indexOf('{', match.index + match[0].length);
|
|
349
|
+
if (start === -1) return undefined;
|
|
350
|
+
return readBalanced(text, start);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function readBalanced(text, start) {
|
|
354
|
+
let depth = 0;
|
|
355
|
+
let quote = '';
|
|
356
|
+
let escaped = false;
|
|
357
|
+
for (let i = start; i < text.length; i++) {
|
|
358
|
+
const ch = text[i];
|
|
359
|
+
if (quote) {
|
|
360
|
+
if (escaped) escaped = false;
|
|
361
|
+
else if (ch === '\\') escaped = true;
|
|
362
|
+
else if (ch === quote) quote = '';
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
if (ch === '"' || ch === "'" || ch === '`') { quote = ch; continue; }
|
|
366
|
+
if (ch === '{') depth++;
|
|
367
|
+
else if (ch === '}') {
|
|
368
|
+
depth--;
|
|
369
|
+
if (depth === 0) return text.slice(start, i + 1);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return undefined;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function flattenJsObjectLiteral(objectText, prefix = '', out = {}) {
|
|
376
|
+
const body = objectText.replace(/^\s*\{/, '').replace(/\}\s*$/, '');
|
|
377
|
+
let i = 0;
|
|
378
|
+
while (i < body.length) {
|
|
379
|
+
while (i < body.length && /[\s,;]/.test(body[i])) i++;
|
|
380
|
+
if (body.startsWith('//', i)) { const end = body.indexOf('\n', i); i = end === -1 ? body.length : end + 1; continue; }
|
|
381
|
+
if (body.startsWith('/*', i)) { const end = body.indexOf('*/', i + 2); i = end === -1 ? body.length : end + 2; continue; }
|
|
382
|
+
const keyMatch = body.slice(i).match(/^(?:['"]([^'"]+)['"]|([A-Za-z_$][\w$-]*))\s*:/);
|
|
383
|
+
if (!keyMatch) { i++; continue; }
|
|
384
|
+
const key = keyMatch[1] || keyMatch[2];
|
|
385
|
+
i += keyMatch[0].length;
|
|
386
|
+
while (i < body.length && /\s/.test(body[i])) i++;
|
|
387
|
+
const fullKey = prefix ? prefix + '.' + key : key;
|
|
388
|
+
if (body[i] === '{') {
|
|
389
|
+
const nested = readBalanced(body, i);
|
|
390
|
+
if (!nested) break;
|
|
391
|
+
flattenJsObjectLiteral(nested, fullKey, out);
|
|
392
|
+
i += nested.length;
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
const value = readJsValue(body, i);
|
|
396
|
+
if (value) {
|
|
397
|
+
if (value.kind === 'string') out[fullKey] = value.value;
|
|
398
|
+
i = value.end;
|
|
399
|
+
} else i++;
|
|
400
|
+
}
|
|
401
|
+
return out;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function readJsValue(text, start) {
|
|
405
|
+
const quote = text[start];
|
|
406
|
+
if (quote !== '"' && quote !== "'" && quote !== '`') return undefined;
|
|
407
|
+
let value = '';
|
|
408
|
+
let escaped = false;
|
|
409
|
+
for (let i = start + 1; i < text.length; i++) {
|
|
410
|
+
const ch = text[i];
|
|
411
|
+
if (escaped) { value += ch; escaped = false; continue; }
|
|
412
|
+
if (ch === '\\') { escaped = true; continue; }
|
|
413
|
+
if (ch === quote) return { kind: 'string', value, end: i + 1 };
|
|
414
|
+
value += ch;
|
|
415
|
+
}
|
|
416
|
+
return undefined;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export function findLocaleKeyLocation(text, key) {
|
|
420
|
+
const parts = key.split('.');
|
|
421
|
+
const last = parts[parts.length - 1];
|
|
422
|
+
const re = new RegExp('(?:["\\\']' + escapeRegExp(last) + '["\\\']|' + escapeRegExp(last) + ')\\s*:', 'g');
|
|
423
|
+
const match = re.exec(text);
|
|
424
|
+
return match ? offsetToPosition(text, match.index) : undefined;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
export function getInlayHints(text, range, locales, defaultLocale, options = {}) {
|
|
429
|
+
if (options.enabled === false) return [];
|
|
430
|
+
const maxLength = options.maxLength ?? DEFAULT_CONFIG.inlayHints.maxLength;
|
|
431
|
+
const preferred = resolvePreferredLocale(locales, defaultLocale);
|
|
432
|
+
if (!preferred) return [];
|
|
433
|
+
|
|
434
|
+
return extractI18nKeys(text)
|
|
435
|
+
.filter((item) => isPositionInRange(item.range.start, range))
|
|
436
|
+
.map((item) => {
|
|
437
|
+
const value = preferred.flat[item.key];
|
|
438
|
+
if (value === undefined) return undefined;
|
|
439
|
+
return {
|
|
440
|
+
position: inlayPositionAfterKeyLiteral(text, item),
|
|
441
|
+
label: truncateInlayText(value, maxLength),
|
|
442
|
+
paddingLeft: true,
|
|
443
|
+
tooltip: value,
|
|
444
|
+
};
|
|
445
|
+
})
|
|
446
|
+
.filter(Boolean);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function inlayPositionAfterKeyLiteral(text, item) {
|
|
450
|
+
const next = text[item.endOffset];
|
|
451
|
+
const offset = (next === '"' || next === "'") ? item.endOffset + 1 : item.endOffset;
|
|
452
|
+
return offsetToPosition(text, offset);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function isPositionInRange(position, range) {
|
|
456
|
+
if (!range) return true;
|
|
457
|
+
return comparePosition(position, range.start) >= 0 && comparePosition(position, range.end) <= 0;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function comparePosition(a, b) {
|
|
461
|
+
if (a.line !== b.line) return a.line - b.line;
|
|
462
|
+
return a.character - b.character;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function truncateInlayText(value, maxLength) {
|
|
466
|
+
const text = String(value).replace(/\s+/g, ' ').trim();
|
|
467
|
+
if (text.length <= maxLength) return text;
|
|
468
|
+
return text.slice(0, Math.max(0, maxLength - 1)) + '…';
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function escapeRegExp(value) {
|
|
472
|
+
return value.replace(/[.*+?^()|[\]\{}]/g, '\\$&');
|
|
473
|
+
}
|
package/server/index.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
5
|
+
import { createConnection, TextDocuments, ProposedFeatures, TextDocumentSyncKind, MarkupKind } from 'vscode-languageserver/node.js';
|
|
6
|
+
import { TextDocument } from 'vscode-languageserver-textdocument';
|
|
7
|
+
import {
|
|
8
|
+
flattenLocale,
|
|
9
|
+
buildHoverMarkdown,
|
|
10
|
+
getDiagnostics,
|
|
11
|
+
getCompletions,
|
|
12
|
+
getCompletionPrefix,
|
|
13
|
+
keyAtPosition,
|
|
14
|
+
parseTsLocaleModule,
|
|
15
|
+
collectLocaleKeyTargets,
|
|
16
|
+
getInlayHints,
|
|
17
|
+
normalizeI18nLensConfig,
|
|
18
|
+
resolveConfigPath,
|
|
19
|
+
CONFIG_RELATIVE_PATH,
|
|
20
|
+
didWatchedFileChange,
|
|
21
|
+
collectLocaleWatchPaths,
|
|
22
|
+
resolveProjectContext,
|
|
23
|
+
} from './core.js';
|
|
24
|
+
|
|
25
|
+
const connection = createConnection(ProposedFeatures.all);
|
|
26
|
+
const documents = new TextDocuments(TextDocument);
|
|
27
|
+
let workspaceRoot = process.cwd();
|
|
28
|
+
let config = normalizeI18nLensConfig();
|
|
29
|
+
let watchedConfigPath;
|
|
30
|
+
const watchedLocalePaths = new Set();
|
|
31
|
+
let reloadTimer;
|
|
32
|
+
let configMtimeMs;
|
|
33
|
+
let localeCacheByContext = new Map();
|
|
34
|
+
const configWatchIntervalMs = 1000;
|
|
35
|
+
const localeWatchIntervalMs = 1000;
|
|
36
|
+
const reloadDebounceMs = 100;
|
|
37
|
+
|
|
38
|
+
connection.onInitialize((params) => {
|
|
39
|
+
workspaceRoot = params.workspaceFolders?.[0]?.uri ? fileURLToPath(params.workspaceFolders[0].uri) : (params.rootUri ? fileURLToPath(params.rootUri) : process.cwd());
|
|
40
|
+
loadConfig();
|
|
41
|
+
watchConfigFile();
|
|
42
|
+
return { capabilities: { textDocumentSync: TextDocumentSyncKind.Incremental, hoverProvider: true, completionProvider: { triggerCharacters: ['.', '"', "'"] }, definitionProvider: true, inlayHintProvider: true } };
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
connection.onInitialized(() => {
|
|
46
|
+
// After (re)start the editor does not always re-request inlay hints for
|
|
47
|
+
// already-open documents, so features only appeared after the first edit.
|
|
48
|
+
// Proactively reload config/locales and ask the editor to refresh.
|
|
49
|
+
scheduleProjectReload();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
documents.onDidOpen((event) => {
|
|
53
|
+
validate(event.document);
|
|
54
|
+
// A freshly started server may receive the inlay-hint request before the
|
|
55
|
+
// locale cache is ready; prompt the editor to re-request once it is.
|
|
56
|
+
refreshInlayHints();
|
|
57
|
+
});
|
|
58
|
+
documents.onDidChangeContent((event) => validate(event.document));
|
|
59
|
+
documents.onDidSave(() => scheduleProjectReload());
|
|
60
|
+
|
|
61
|
+
connection.onHover((params) => {
|
|
62
|
+
const doc = documents.get(params.textDocument.uri);
|
|
63
|
+
if (!doc) return null;
|
|
64
|
+
const item = keyAtPosition(doc.getText(), params.position);
|
|
65
|
+
if (!item) return null;
|
|
66
|
+
const context = getProjectContext(params.textDocument.uri);
|
|
67
|
+
const { locales } = loadLocalesForContext(context);
|
|
68
|
+
return {
|
|
69
|
+
contents: { kind: MarkupKind.Markdown, value: buildHoverMarkdown(item.key, locales) },
|
|
70
|
+
range: item.range,
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
connection.onCompletion((params) => {
|
|
75
|
+
const doc = documents.get(params.textDocument.uri);
|
|
76
|
+
const context = getProjectContext(params.textDocument.uri);
|
|
77
|
+
const { locales } = loadLocalesForContext(context);
|
|
78
|
+
const prefix = doc ? getCompletionPrefix(doc.getText(), params.position) : '';
|
|
79
|
+
return getCompletions(prefix, locales, context.config.defaultLocale);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
connection.languages.inlayHint.on((params) => {
|
|
83
|
+
const doc = documents.get(params.textDocument.uri);
|
|
84
|
+
if (!doc) return [];
|
|
85
|
+
const context = getProjectContext(params.textDocument.uri);
|
|
86
|
+
const { locales } = loadLocalesForContext(context);
|
|
87
|
+
return getInlayHints(doc.getText(), params.range, locales, context.config.defaultLocale, context.config.inlayHints);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
connection.onDefinition((params) => {
|
|
91
|
+
const doc = documents.get(params.textDocument.uri);
|
|
92
|
+
if (!doc) return null;
|
|
93
|
+
const item = keyAtPosition(doc.getText(), params.position);
|
|
94
|
+
if (!item) return null;
|
|
95
|
+
const context = getProjectContext(params.textDocument.uri);
|
|
96
|
+
const { locales, localeTexts } = loadLocalesForContext(context);
|
|
97
|
+
const keyTokenLength = item.key.split('.').at(-1).length + 2;
|
|
98
|
+
const locations = collectLocaleKeyTargets(locales, item.key, localeTexts, context.config.defaultLocale)
|
|
99
|
+
.map(({ filePath, position }) => ({
|
|
100
|
+
uri: pathToFileURL(filePath).toString(),
|
|
101
|
+
range: { start: position, end: { line: position.line, character: position.character + keyTokenLength } },
|
|
102
|
+
}));
|
|
103
|
+
return locations.length ? locations : null;
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
function getProjectContext(uri) {
|
|
107
|
+
loadConfigIfChanged();
|
|
108
|
+
const filePath = uri ? fileURLToPath(uri) : workspaceRoot;
|
|
109
|
+
return resolveProjectContext(filePath, workspaceRoot, config);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function watchConfigFile() {
|
|
113
|
+
const configPath = resolveConfigPath(workspaceRoot);
|
|
114
|
+
if (watchedConfigPath === configPath) return;
|
|
115
|
+
if (watchedConfigPath) fs.unwatchFile(watchedConfigPath);
|
|
116
|
+
watchedConfigPath = configPath;
|
|
117
|
+
fs.watchFile(configPath, { interval: configWatchIntervalMs }, (current, previous) => {
|
|
118
|
+
if (didWatchedFileChange(previous, current)) scheduleProjectReload();
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function watchLocalePaths() {
|
|
123
|
+
const nextPaths = new Set();
|
|
124
|
+
const contexts = getAllConfiguredContexts();
|
|
125
|
+
for (const context of contexts) {
|
|
126
|
+
const configuredDirs = context.config.localeDirs
|
|
127
|
+
.map((dir) => path.join(context.root, dir))
|
|
128
|
+
.filter((dir) => fs.existsSync(dir));
|
|
129
|
+
const loaded = loadLocalesForContext(context);
|
|
130
|
+
for (const watchPath of collectLocaleWatchPaths(loaded.locales, configuredDirs)) nextPaths.add(watchPath);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
for (const watchedPath of watchedLocalePaths) {
|
|
134
|
+
if (nextPaths.has(watchedPath)) continue;
|
|
135
|
+
fs.unwatchFile(watchedPath);
|
|
136
|
+
watchedLocalePaths.delete(watchedPath);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (const watchedPath of nextPaths) {
|
|
140
|
+
if (watchedLocalePaths.has(watchedPath)) continue;
|
|
141
|
+
watchedLocalePaths.add(watchedPath);
|
|
142
|
+
fs.watchFile(watchedPath, { interval: localeWatchIntervalMs }, (current, previous) => {
|
|
143
|
+
if (didWatchedFileChange(previous, current)) scheduleProjectReload();
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function scheduleProjectReload() {
|
|
149
|
+
clearTimeout(reloadTimer);
|
|
150
|
+
reloadTimer = setTimeout(() => {
|
|
151
|
+
loadConfig();
|
|
152
|
+
localeCacheByContext = new Map();
|
|
153
|
+
watchLocalePaths();
|
|
154
|
+
refreshOpenDocuments();
|
|
155
|
+
refreshInlayHints();
|
|
156
|
+
}, reloadDebounceMs);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function refreshOpenDocuments() {
|
|
160
|
+
for (const doc of documents.all()) validate(doc);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function refreshInlayHints() {
|
|
164
|
+
try {
|
|
165
|
+
connection.languages.inlayHint.refresh?.();
|
|
166
|
+
} catch (error) {
|
|
167
|
+
connection.console.warn('Failed to refresh inlay hints: ' + error.message);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function loadConfigIfChanged() {
|
|
172
|
+
const configPath = resolveConfigPath(workspaceRoot);
|
|
173
|
+
const nextMtimeMs = fs.existsSync(configPath) ? fs.statSync(configPath).mtimeMs : 0;
|
|
174
|
+
if (configMtimeMs === nextMtimeMs) return;
|
|
175
|
+
loadConfig();
|
|
176
|
+
localeCacheByContext = new Map();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function loadConfig() {
|
|
180
|
+
const configPath = resolveConfigPath(workspaceRoot);
|
|
181
|
+
configMtimeMs = fs.existsSync(configPath) ? fs.statSync(configPath).mtimeMs : 0;
|
|
182
|
+
if (!configMtimeMs) {
|
|
183
|
+
config = normalizeI18nLensConfig();
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
config = normalizeI18nLensConfig(JSON.parse(fs.readFileSync(configPath, 'utf8')));
|
|
188
|
+
} catch (error) {
|
|
189
|
+
connection.console.warn('Failed to load ' + CONFIG_RELATIVE_PATH + ': ' + error.message);
|
|
190
|
+
config = normalizeI18nLensConfig();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function validate(document) {
|
|
195
|
+
if (!isSourceFile(document.uri)) return;
|
|
196
|
+
const context = getProjectContext(document.uri);
|
|
197
|
+
const { locales } = loadLocalesForContext(context);
|
|
198
|
+
connection.sendDiagnostics({ uri: document.uri, diagnostics: getDiagnostics(document.getText(), locales) });
|
|
199
|
+
}
|
|
200
|
+
function isSourceFile(uri) { return /\.(vue|tsx?|jsx?)$/i.test(uri); }
|
|
201
|
+
|
|
202
|
+
function getAllConfiguredContexts() {
|
|
203
|
+
loadConfigIfChanged();
|
|
204
|
+
return [resolveProjectContext(workspaceRoot, workspaceRoot, config), ...config.packages.map((pkg) => ({
|
|
205
|
+
root: path.resolve(workspaceRoot, pkg.root),
|
|
206
|
+
config: pkg,
|
|
207
|
+
}))];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function loadLocalesForContext(context) {
|
|
211
|
+
const cacheKey = context.root + '\0' + JSON.stringify({
|
|
212
|
+
defaultLocale: context.config.defaultLocale,
|
|
213
|
+
localeDirs: context.config.localeDirs,
|
|
214
|
+
inlayHints: context.config.inlayHints,
|
|
215
|
+
});
|
|
216
|
+
const cached = localeCacheByContext.get(cacheKey);
|
|
217
|
+
if (cached) return cached;
|
|
218
|
+
|
|
219
|
+
const locales = {};
|
|
220
|
+
const localeTexts = {};
|
|
221
|
+
for (const dir of context.config.localeDirs) {
|
|
222
|
+
const fullDir = path.join(context.root, dir);
|
|
223
|
+
if (!fs.existsSync(fullDir)) continue;
|
|
224
|
+
loadLocaleFiles(fullDir, locales, localeTexts);
|
|
225
|
+
for (const entry of fs.readdirSync(fullDir, { withFileTypes: true })) {
|
|
226
|
+
if (entry.isDirectory()) loadLocaleDirectory(path.join(fullDir, entry.name), entry.name, locales, localeTexts);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const loaded = { locales, localeTexts };
|
|
230
|
+
localeCacheByContext.set(cacheKey, loaded);
|
|
231
|
+
return loaded;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function loadLocaleDirectory(localeDir, localeName, next, localeTexts) {
|
|
235
|
+
next[localeName] ||= { path: localeDir, flat: {}, files: [] };
|
|
236
|
+
for (const file of fs.readdirSync(localeDir)) {
|
|
237
|
+
const fullPath = path.join(localeDir, file);
|
|
238
|
+
if (fs.statSync(fullPath).isDirectory()) continue;
|
|
239
|
+
try {
|
|
240
|
+
const text = fs.readFileSync(fullPath, 'utf8');
|
|
241
|
+
if (file.endsWith('.json')) Object.assign(next[localeName].flat, prefixByFile(file, flattenLocale(JSON.parse(text))));
|
|
242
|
+
else if (/\.[cm]?[tj]s$/i.test(file)) Object.assign(next[localeName].flat, parseTsLocaleModule(text, file));
|
|
243
|
+
else continue;
|
|
244
|
+
next[localeName].files.push(fullPath);
|
|
245
|
+
localeTexts[fullPath] = text;
|
|
246
|
+
} catch (error) { connection.console.warn('Failed to load locale file ' + fullPath + ': ' + error.message); }
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function loadLocaleFiles(fullDir, next, localeTexts) {
|
|
251
|
+
for (const file of fs.readdirSync(fullDir)) {
|
|
252
|
+
const fullPath = path.join(fullDir, file);
|
|
253
|
+
if (fs.statSync(fullPath).isDirectory()) continue;
|
|
254
|
+
try {
|
|
255
|
+
const text = fs.readFileSync(fullPath, 'utf8');
|
|
256
|
+
if (file.endsWith('.json')) {
|
|
257
|
+
const name = path.basename(file, '.json');
|
|
258
|
+
next[name] = { path: fullPath, flat: flattenLocale(JSON.parse(text)), files: [fullPath] };
|
|
259
|
+
localeTexts[fullPath] = text;
|
|
260
|
+
}
|
|
261
|
+
} catch (error) { connection.console.warn('Failed to load locale file ' + fullPath + ': ' + error.message); }
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function prefixByFile(file, flat) {
|
|
266
|
+
const stem = path.basename(file, path.extname(file));
|
|
267
|
+
if (stem === 'index') return flat;
|
|
268
|
+
return Object.fromEntries(Object.entries(flat).map(([key, value]) => [stem + '.' + key, value]));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
documents.listen(connection);
|
|
272
|
+
connection.listen();
|