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 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
- | `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. |
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 `inlayHints` unless they override them.
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.11.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
- if (isSourceFile(document.uri)) {
305
- connection.sendDiagnostics({ uri: document.uri, diagnostics: getDiagnostics(document.getText(), locales) });
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));