i18n-lens-language-server 0.11.0 → 0.12.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 +30 -4
- package/package.json +2 -1
- package/server/core.js +172 -0
- package/server/index.js +10 -2
package/README.md
CHANGED
|
@@ -8,6 +8,7 @@ Zed extension plus Language Server for Vue, TypeScript, TSX, JavaScript and JSX.
|
|
|
8
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
9
|
- Hover: show all locale values for a key
|
|
10
10
|
- Diagnostics: report missing keys, missing locale entries, mismatched interpolation params, and (on locale files) keys whose params differ across languages
|
|
11
|
+
- Optional raw-text diagnostics: flag likely untranslated Vue/HTML template text and selected user-facing attributes
|
|
11
12
|
- Quick fixes: add missing / remove unused interpolation params on supported i18n calls, and create a missing key in every locale file that lacks it
|
|
12
13
|
- Completion: suggest existing i18n keys with default locale text
|
|
13
14
|
- Definition: jump from a source key to its locale definition; when several locales define the key, pick which language file to open
|
|
@@ -82,6 +83,30 @@ For example, if `order.pay_now` exists in `zh-CN` but is missing from `en-US`, t
|
|
|
82
83
|
Missing i18n key "order.pay_now" in locales: en-US
|
|
83
84
|
```
|
|
84
85
|
|
|
86
|
+
### Optional untranslated template text detection
|
|
87
|
+
|
|
88
|
+
Set `"rawTextDiagnostics": true` in `.zed/i18nlensrc.json` or under `lsp.i18n-lens.settings` to enable warnings with diagnostic code `raw-text`. It is **off by default**, works even without loaded locale files, and can be overridden per monorepo package.
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{
|
|
92
|
+
"rawTextDiagnostics": true
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
This first version uses `htmlparser2` to inspect HTML documents and the top-level HTML `<template>` of Vue single-file components:
|
|
97
|
+
|
|
98
|
+
- Literal text such as `<button>Save changes</button>`.
|
|
99
|
+
- Static `title`, `alt`, `placeholder`, `label`, and `aria-label` attributes.
|
|
100
|
+
- Whole, unescaped quoted literals on those bound attributes, such as `:placeholder="'Enter name'"` or `v-bind:title="'Helpful title'"`. Variables, translation calls, concatenations, template literals and other expressions are not evaluated.
|
|
101
|
+
|
|
102
|
+
The natural-language heuristics are inspired by i18n Ally's raw-string detection, but this is **not full Ally parity**. They recognize multi-word phrases, title-cased labels and non-Latin text, while ignoring numbers, punctuation, URLs, paths and obvious identifier-like tokens. Lowercase single words, acronyms, text split across elements and ambiguous labels may be missed; names or technical phrases can still produce false positives. Warnings are suggestions, not proof that text must be translated.
|
|
103
|
+
|
|
104
|
+
Comments, `script`/`style`, Vue custom blocks, interpolations (`{{ ... }}`), translation-component subtrees (`i18n-t`/`I18nT`, legacy `i18n`, `Trans`) and `v-t` elements are excluded. `code`/`pre`, `v-pre`, `v-html`, `v-text` and `translate="no"` subtrees are also skipped; use `translate="no"` to mark intentionally untranslated content. Vue templates using `src` or a non-HTML `lang` (such as Pug) are not inspected.
|
|
105
|
+
|
|
106
|
+
There is no Babel/JavaScript/TypeScript/JSX string analysis, workspace-wide scan, automatic extraction or raw-text quick fix. Only open Vue/HTML buffers receive these warnings. The parser is tolerant of incomplete markup, not a full Vue compiler; complex or malformed templates can be missed. Existing key/parameter diagnostics and quick fixes are unchanged.
|
|
107
|
+
|
|
108
|
+
For local development, HTML registration in `extension.toml` requires reinstalling the dev extension in Zed, then running `scripts/deploy-local.ps1` again and restarting the language server. Updating only the server does not register HTML buffers.
|
|
109
|
+
|
|
85
110
|
## Project configuration
|
|
86
111
|
|
|
87
112
|
I18n Lens supports two configuration sources:
|
|
@@ -155,7 +180,8 @@ Supported options:
|
|
|
155
180
|
| `localeDirs` | string[] | `src/locales`, `src/i18n`, `locales`, `i18n` | Workspace-relative directories to scan for locale files. In package configs, paths are relative to that package root. |
|
|
156
181
|
| `inlayHints.enabled` | boolean | `true` | Enables or disables inline translation hints from this language server. Hover, completion, definition, diagnostics, and quick fixes can still work when inline hints are disabled. |
|
|
157
182
|
| `inlayHints.maxLength` | number | `24` | Maximum inline hint label length before truncation. Hover still shows the full text. |
|
|
158
|
-
| `
|
|
183
|
+
| `rawTextDiagnostics` | boolean | `false` | Opt-in warnings for likely untranslated Vue/HTML template text and selected attributes. No automatic extraction. |
|
|
184
|
+
| `packages` | array | `[]` | Optional monorepo package contexts. Each item needs a workspace-relative `root` and can override `defaultLocale`, `localeDirs`, `inlayHints`, and `rawTextDiagnostics`. The language server picks the longest package root matching the currently edited file. |
|
|
159
185
|
|
|
160
186
|
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 the Zed settings or default config.
|
|
161
187
|
|
|
@@ -202,7 +228,7 @@ For pnpm/turborepo and other monorepos, put `.zed/i18nlensrc.json` under the wor
|
|
|
202
228
|
}
|
|
203
229
|
```
|
|
204
230
|
|
|
205
|
-
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 `
|
|
231
|
+
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`, `inlayHints`, and `rawTextDiagnostics` unless they override them.
|
|
206
232
|
|
|
207
233
|
## Local development
|
|
208
234
|
|
|
@@ -252,8 +278,8 @@ The published npm version can lag behind your working tree. To test local `serve
|
|
|
252
278
|
./scripts/deploy-local.ps1
|
|
253
279
|
```
|
|
254
280
|
|
|
255
|
-
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.
|
|
281
|
+
It runs `npm pack`, installs the tarball into `%LOCALAPPDATA%\Zed\extensions\work\i18n-lens-language-server\node_modules\i18n-lens-language-server` (the same layout Zed produces), then prompts you to run **restart language server** from the Zed command palette.
|
|
256
282
|
|
|
257
|
-
> 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.
|
|
283
|
+
> To exercise the real install path instead, delete `%LOCALAPPDATA%\Zed\extensions\work\i18n-lens-language-server\node_modules` and restart the server — Zed will reinstall the published npm package.
|
|
258
284
|
|
|
259
285
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "i18n-lens-language-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"start": "node server/index.js"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
+
"htmlparser2": "^10.1.0",
|
|
23
24
|
"vscode-languageserver": "^9.0.1",
|
|
24
25
|
"vscode-languageserver-textdocument": "^1.0.12"
|
|
25
26
|
}
|
package/server/core.js
CHANGED
|
@@ -1,12 +1,183 @@
|
|
|
1
|
+
import { parseDocument, DomUtils } from 'htmlparser2';
|
|
2
|
+
|
|
1
3
|
export const DEFAULT_CONFIG = Object.freeze({
|
|
2
4
|
defaultLocale: 'zh-CN',
|
|
3
5
|
localeDirs: Object.freeze(['src/locales', 'src/i18n', 'locales', 'i18n']),
|
|
4
6
|
inlayHints: Object.freeze({ enabled: true, maxLength: 24 }),
|
|
7
|
+
rawTextDiagnostics: false,
|
|
5
8
|
packages: Object.freeze([]),
|
|
6
9
|
});
|
|
7
10
|
|
|
8
11
|
export const CONFIG_RELATIVE_PATH = '.zed/i18nlensrc.json';
|
|
9
12
|
|
|
13
|
+
const TRANSLATABLE_ATTRIBUTES = new Set(['title', 'alt', 'placeholder', 'label', 'aria-label']);
|
|
14
|
+
const RAW_TEXT_SKIPPED_TAGS = new Set(['script', 'style', 'code', 'pre', 'i18n', 'i18n-t', 'i18nt', 'trans']);
|
|
15
|
+
|
|
16
|
+
function isExcludedTemplateNode(node) {
|
|
17
|
+
const attrs = node.attribs || {};
|
|
18
|
+
return node.type === 'comment' || RAW_TEXT_SKIPPED_TAGS.has(node.name)
|
|
19
|
+
|| attrs.translate?.toLowerCase() === 'no'
|
|
20
|
+
|| Object.keys(attrs).some((name) => /^(?:v-t(?:\.|$)|v-pre$|v-html$|v-text$)/.test(name));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isVueHtmlTemplate(node) {
|
|
24
|
+
return node.name === 'template' && !Object.hasOwn(node.attribs || {}, 'src')
|
|
25
|
+
&& (!node.attribs?.lang || /^html$/i.test(node.attribs.lang));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function templateInterpolations(text, excludedRanges) {
|
|
29
|
+
const ranges = [];
|
|
30
|
+
let cursor = 0;
|
|
31
|
+
while ((cursor = text.indexOf('{{', cursor)) !== -1) {
|
|
32
|
+
const excluded = excludedRanges.find(({ start, end }) => cursor >= start && cursor < end);
|
|
33
|
+
if (excluded) {
|
|
34
|
+
cursor = excluded.end;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const start = cursor;
|
|
38
|
+
cursor += 2;
|
|
39
|
+
let quote;
|
|
40
|
+
let depth = 0;
|
|
41
|
+
for (; cursor < text.length; cursor++) {
|
|
42
|
+
const char = text[cursor];
|
|
43
|
+
if (quote) {
|
|
44
|
+
if (char === '\\') cursor++;
|
|
45
|
+
else if (char === quote) quote = undefined;
|
|
46
|
+
} else if (char === '"' || char === "'" || char === '`') {
|
|
47
|
+
quote = char;
|
|
48
|
+
} else if (text.startsWith('/*', cursor)) {
|
|
49
|
+
const end = text.indexOf('*/', cursor + 2);
|
|
50
|
+
cursor = end === -1 ? text.length : end + 1;
|
|
51
|
+
} else if (text.startsWith('//', cursor)) {
|
|
52
|
+
const end = text.indexOf('\n', cursor + 2);
|
|
53
|
+
cursor = end === -1 ? text.length : end;
|
|
54
|
+
} else if (char === '{') {
|
|
55
|
+
depth++;
|
|
56
|
+
} else if (char === '}' && depth > 0) {
|
|
57
|
+
depth--;
|
|
58
|
+
} else if (text.startsWith('}}', cursor)) {
|
|
59
|
+
cursor += 2;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
ranges.push({ start, end: Math.min(cursor, text.length) });
|
|
64
|
+
}
|
|
65
|
+
return ranges;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Intentionally conservative: natural-language candidates, not arbitrary strings
|
|
69
|
+
// or a dictionary. Single title-cased labels and non-Latin text are useful too.
|
|
70
|
+
export function isLikelyUserFacingText(value) {
|
|
71
|
+
const text = value.trim();
|
|
72
|
+
if (!/\p{L}/u.test(text)) return false;
|
|
73
|
+
if (/^(?:https?:|mailto:|www\.|[/#])|\S+@\S+\.\S+/i.test(text)) return false;
|
|
74
|
+
if (/^[\w.-]+(?:[/\\][\w.-]+)+$|^[\w-]+(?:\.[\w-]+)+$/.test(text)) return false;
|
|
75
|
+
if (/^[A-Z\d_-]+$|^[a-z\d]+(?:[_-][a-z\d]+)+$|^[a-z]+(?:[A-Z][a-z\d]*)+$/.test(text)) return false;
|
|
76
|
+
if (/[^\p{Script=Latin}\p{N}\p{P}\p{S}\p{Z}\p{M}\s]/u.test(text)) return true;
|
|
77
|
+
return (/\s/.test(text) && (text.match(/\p{L}[\p{L}\p{M}]*/gu) || []).length >= 2)
|
|
78
|
+
|| /^\p{Lu}\p{Ll}{2,}[.!?…]?$/u.test(text);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Detect only template text/attributes. Parser indices and JS string offsets are
|
|
83
|
+
* UTF-16, matching LSP positions. Mask Vue expressions before HTML parsing so
|
|
84
|
+
* comparisons and string literals inside {{ ... }} cannot become markup.
|
|
85
|
+
*/
|
|
86
|
+
export function getRawTextDiagnostics(text, language, enabled = false) {
|
|
87
|
+
if (!enabled || !['vue', 'html'].includes(language)) return [];
|
|
88
|
+
const parserOptions = { withStartIndices: true, withEndIndices: true, recognizeSelfClosing: true };
|
|
89
|
+
// An unmatched "{{" in a comment, script, style or custom SFC block must
|
|
90
|
+
// never mask the template that follows it. Let the HTML parser locate these.
|
|
91
|
+
const excludedRanges = [];
|
|
92
|
+
function excludeNonTemplate(node, topLevel = false) {
|
|
93
|
+
if (isExcludedTemplateNode(node)
|
|
94
|
+
|| (language === 'vue' && topLevel && node.name && !isVueHtmlTemplate(node))) {
|
|
95
|
+
excludedRanges.push({ start: node.startIndex, end: node.endIndex + 1 });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
for (const child of node.children || []) excludeNonTemplate(child);
|
|
99
|
+
}
|
|
100
|
+
for (const node of parseDocument(text, parserOptions).children) excludeNonTemplate(node, true);
|
|
101
|
+
const interpolations = templateInterpolations(text, excludedRanges);
|
|
102
|
+
let previous = 0;
|
|
103
|
+
const masked = interpolations.map(({ start, end }) => {
|
|
104
|
+
const part = text.slice(previous, start) + text.slice(start, end).replace(/[^\r\n]/g, ' ');
|
|
105
|
+
previous = end;
|
|
106
|
+
return part;
|
|
107
|
+
}).join('') + text.slice(previous);
|
|
108
|
+
const document = parseDocument(masked, parserOptions);
|
|
109
|
+
const diagnostics = [];
|
|
110
|
+
function report(raw, start, value = raw) {
|
|
111
|
+
const leading = raw.length - raw.trimStart().length;
|
|
112
|
+
const trailing = raw.trimEnd().length;
|
|
113
|
+
if (!isLikelyUserFacingText(value)) return;
|
|
114
|
+
diagnostics.push({
|
|
115
|
+
range: makeRange(text, start + leading, start + trailing),
|
|
116
|
+
severity: 2,
|
|
117
|
+
code: 'raw-text',
|
|
118
|
+
source: 'i18n-lens',
|
|
119
|
+
message: 'Potential untranslated text: ' + JSON.stringify(value.trim()),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
function visit(node) {
|
|
123
|
+
if (node.type === 'text') {
|
|
124
|
+
const raw = text.slice(node.startIndex, node.endIndex + 1);
|
|
125
|
+
// Report each literal part separately, never an interpolation expression.
|
|
126
|
+
let cursor = 0;
|
|
127
|
+
for (const { start, end } of interpolations) {
|
|
128
|
+
if (end <= node.startIndex || start > node.endIndex) continue;
|
|
129
|
+
const part = raw.slice(cursor, Math.max(0, start - node.startIndex));
|
|
130
|
+
report(part, node.startIndex + cursor, DomUtils.textContent(parseDocument(part)));
|
|
131
|
+
cursor = Math.min(raw.length, end - node.startIndex);
|
|
132
|
+
}
|
|
133
|
+
const part = raw.slice(cursor);
|
|
134
|
+
report(part, node.startIndex + cursor, DomUtils.textContent(parseDocument(part)));
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (!node.name) return;
|
|
138
|
+
const attrs = node.attribs || {};
|
|
139
|
+
if (isExcludedTemplateNode(node)) return;
|
|
140
|
+
// HTML parser determines element boundaries; this small lexer only recovers
|
|
141
|
+
// exact source spans for attributes (including quoted > characters).
|
|
142
|
+
const opening = /^<[^\s/>]+((?:"[^"]*"|'[^']*'|[^'">])*)>/.exec(text.slice(node.startIndex));
|
|
143
|
+
if (opening) {
|
|
144
|
+
const prefixLength = opening[0].length - opening[1].length - 1;
|
|
145
|
+
for (const match of opening[1].matchAll(/([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g)) {
|
|
146
|
+
const name = match[1].toLowerCase();
|
|
147
|
+
const bound = name.startsWith(':') || name.startsWith('v-bind:');
|
|
148
|
+
const target = bound ? name.replace(/^(?::|v-bind:)/, '') : name;
|
|
149
|
+
if (!TRANSLATABLE_ATTRIBUTES.has(target) || !match[0].includes('=')) continue;
|
|
150
|
+
let raw = match[2] ?? match[3] ?? match[4] ?? '';
|
|
151
|
+
if (raw.includes('{{')) continue;
|
|
152
|
+
const valuePrefix = /^[^=]+=\s*["']?/.exec(match[0])[0].length;
|
|
153
|
+
let start = node.startIndex + prefixLength + match.index + valuePrefix;
|
|
154
|
+
let value = attrs[name] ?? raw;
|
|
155
|
+
if (bound) {
|
|
156
|
+
// No JS parsing: only a whole, unescaped quoted string is safe.
|
|
157
|
+
const literal = /^\s*(['"])([^\\\r\n]*?)\1\s*$/.exec(raw);
|
|
158
|
+
const decodedLiteral = /^\s*(['"])([^\\\r\n]*?)\1\s*$/.exec(value);
|
|
159
|
+
if (!literal || literal[2].includes(literal[1])
|
|
160
|
+
|| !decodedLiteral || decodedLiteral[2].includes(decodedLiteral[1])) continue;
|
|
161
|
+
start += raw.indexOf(literal[1]) + 1;
|
|
162
|
+
raw = literal[2];
|
|
163
|
+
value = decodedLiteral[2];
|
|
164
|
+
}
|
|
165
|
+
report(raw, start, value);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
for (const child of node.children || []) visit(child);
|
|
169
|
+
}
|
|
170
|
+
if (language === 'vue') {
|
|
171
|
+
for (const node of document.children) {
|
|
172
|
+
if (!isVueHtmlTemplate(node) || isExcludedTemplateNode(node)) continue;
|
|
173
|
+
for (const child of node.children || []) visit(child);
|
|
174
|
+
}
|
|
175
|
+
} else {
|
|
176
|
+
for (const node of document.children) visit(node);
|
|
177
|
+
}
|
|
178
|
+
return diagnostics;
|
|
179
|
+
}
|
|
180
|
+
|
|
10
181
|
export function resolveConfigPath(workspaceRoot) {
|
|
11
182
|
return joinFsPath(normalizeFsPath(workspaceRoot), CONFIG_RELATIVE_PATH);
|
|
12
183
|
}
|
|
@@ -54,6 +225,7 @@ function normalizeProjectConfig(raw = {}, defaults = DEFAULT_CONFIG) {
|
|
|
54
225
|
defaultLocale,
|
|
55
226
|
localeDirs: localeDirs.length > 0 ? localeDirs : [...defaults.localeDirs],
|
|
56
227
|
inlayHints: { enabled, maxLength },
|
|
228
|
+
rawTextDiagnostics: typeof raw.rawTextDiagnostics === 'boolean' ? raw.rawTextDiagnostics : defaults.rawTextDiagnostics,
|
|
57
229
|
};
|
|
58
230
|
}
|
|
59
231
|
|
package/server/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
flattenLocale,
|
|
9
9
|
buildHoverMarkdown,
|
|
10
10
|
getDiagnostics,
|
|
11
|
+
getRawTextDiagnostics,
|
|
11
12
|
getLocaleParamDiagnostics,
|
|
12
13
|
getParamCodeActions,
|
|
13
14
|
insertNestedJsonKey,
|
|
@@ -301,8 +302,15 @@ function normalizeIncomingSettings(value) {
|
|
|
301
302
|
function validate(document) {
|
|
302
303
|
const context = getProjectContext(document.uri);
|
|
303
304
|
const { locales } = loadLocalesForContext(context);
|
|
304
|
-
|
|
305
|
-
|
|
305
|
+
const templateLanguage = /\.vue$/i.test(document.uri) ? 'vue'
|
|
306
|
+
: /\.html?$/i.test(document.uri) || document.languageId === 'html' ? 'html' : undefined;
|
|
307
|
+
if (isSourceFile(document.uri) || templateLanguage) {
|
|
308
|
+
const text = document.getText();
|
|
309
|
+
const diagnostics = [
|
|
310
|
+
...getDiagnostics(text, locales),
|
|
311
|
+
...getRawTextDiagnostics(text, templateLanguage, context.config.rawTextDiagnostics),
|
|
312
|
+
];
|
|
313
|
+
connection.sendDiagnostics({ uri: document.uri, diagnostics });
|
|
306
314
|
return;
|
|
307
315
|
}
|
|
308
316
|
const locale = findLocaleForFile(locales, fileURLToPath(document.uri));
|