i18n-lens-language-server 0.13.0 → 0.15.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 +65 -2
- package/package.json +4 -1
- package/server/core.js +570 -37
- package/server/index.js +137 -53
package/README.md
CHANGED
|
@@ -10,9 +10,11 @@ Zed extension plus Language Server for Vue, TypeScript, TSX, JavaScript and JSX.
|
|
|
10
10
|
- Diagnostics: report missing keys, missing locale entries, mismatched interpolation params, and (on locale files) keys whose params differ across languages
|
|
11
11
|
- Optional raw-text diagnostics: flag likely untranslated Vue/HTML template text and selected user-facing attributes
|
|
12
12
|
- Quick fixes: add missing / remove unused interpolation params, and create missing keys in unambiguous JSON locale targets using versioned edits
|
|
13
|
+
- Extract / Inline code actions: explicitly extract Vue/HTML template text into a new locale key, or replace a supported translation call with one locale's stored text
|
|
13
14
|
- Completion: suggest existing i18n keys with default locale text only inside supported i18n key positions
|
|
14
15
|
- Definition: jump from a source key to its locale definition; when several locales define the key, pick which language file to open
|
|
15
16
|
- 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
|
|
17
|
+
- Recursive locale directories: nested JSON, TS, and JS files map to dot-separated namespaces, with `index` files contributing to their parent namespace
|
|
16
18
|
|
|
17
19
|
## Installation
|
|
18
20
|
|
|
@@ -105,9 +107,10 @@ The natural-language heuristics are inspired by i18n Ally's raw-string detection
|
|
|
105
107
|
|
|
106
108
|
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.
|
|
107
109
|
|
|
108
|
-
|
|
110
|
+
Raw-text diagnostics do not analyze JavaScript/TypeScript/JSX strings, scan the whole workspace, or extract text automatically. Only open Vue/HTML buffers receive these warnings. Explicit Extract actions are available separately from the editor's code-action menu, even when these warnings are disabled (see below). 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.
|
|
109
111
|
|
|
110
112
|
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.
|
|
113
|
+
|
|
111
114
|
## Creating missing keys safely
|
|
112
115
|
|
|
113
116
|
The missing-key quick fix supports a single JSON file per locale and directory layouts such as `locales/en/common.json`. For `t('common.submit')`, it inserts `submit` into `common.json`, without duplicating the `common.` prefix. `index.json` has no file-stem prefix.
|
|
@@ -120,6 +123,53 @@ The missing-key quick fix supports a single JSON file per locale and directory l
|
|
|
120
123
|
|
|
121
124
|
Recognition skips calls inside comments and ordinary strings, and does not treat a concatenated key such as `t('prefix.' + name)` as a static key. Vue directive expressions, JSX expressions, and calls inside template-literal interpolations remain supported. Recognition is lexical, not full framework binding or scope analysis.
|
|
122
125
|
|
|
126
|
+
## Extract and Inline code actions
|
|
127
|
+
|
|
128
|
+
These actions **rewrite source code**; they are separate from the read-only inline translation hints above. Open the editor's code-action menu at the text or call you want to change and review the proposed edits before applying them. Both actions require client support for `workspace.workspaceEdit.documentChanges`.
|
|
129
|
+
|
|
130
|
+
### Extract template text
|
|
131
|
+
|
|
132
|
+
In a Vue HTML `<template>` or an HTML document, place the caret in a supported static text fragment, or select a range that fully contains it, and choose **Extract to i18n key "…"**. A selection can offer separate actions for several fragments; it does not extract arbitrary substrings or apply a batch conversion. `rawTextDiagnostics` can remain disabled.
|
|
133
|
+
|
|
134
|
+
For example, in `Home.vue` with no existing source keys:
|
|
135
|
+
|
|
136
|
+
```html
|
|
137
|
+
<!-- Before -->
|
|
138
|
+
<button>Save changes</button>
|
|
139
|
+
<!-- After -->
|
|
140
|
+
<button>{{ $t('Home.save_changes') }}</button>
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Extract also adds `"Home": { "save_changes": "Save changes" }` to the selected locale's JSON, preserving unrelated entries. It writes **only one locale**: the current project/package's `defaultLocale`, including locale-name normalization and language aliases, or the first loaded locale if none matches. No loaded locales means no Extract action. It does not create a missing default language or populate every translation.
|
|
144
|
+
|
|
145
|
+
- Supported attributes are `title`, `alt`, `placeholder`, `label`, and `aria-label`, including whole, simple quoted literals in bound attributes. Static attributes become bindings such as `:title="$t('Home.save_changes')"`. HTML files receive the same **Vue-style syntax**; this is not a framework-neutral HTML rewrite.
|
|
146
|
+
- Key prefixes come from nearby existing template keys, then a file-wide common parent prefix, then the filename stem. The suffix uses a short ASCII text slug or a deterministic hash for text without ASCII words. Every extraction allocates a new key rather than reusing identical text; conflicts in the selected locale can change the prefix or add a numeric suffix.
|
|
147
|
+
- Extract shares the template exclusions described under raw-text diagnostics. It does not extract ordinary JS/TS/JSX strings, evaluate dynamic expressions, or turn interpolated text into parameterized messages. Fragments containing entities or `&`, `<`, `>`, `{`, or `}` are skipped, and repeated whitespace in the stored value is collapsed to a single space.
|
|
148
|
+
- Locale edits follow the JSON target rules in **Creating missing keys safely** above. Invalid JSON, duplicate properties, ambiguous layouts, conflicting paths, or TS/JS targets suppress the action. New namespace files additionally require client support for `create`, an established sibling-locale JSON layout, and an existing parent directory; Extract does not create directories.
|
|
149
|
+
|
|
150
|
+
### Inline a translation call
|
|
151
|
+
|
|
152
|
+
Place the caret or a selection inside a static-key call such as `t('Home.save_changes')` and choose an **Inline** action for the desired locale. One action is offered per loaded locale that defines the key. It changes only the source and leaves the locale entry intact.
|
|
153
|
+
|
|
154
|
+
Inline supports `t`, `$t`, `tc`, `$tc`, `i18n.t`, and `i18n.tc` calls with **only the key argument**, in Vue/HTML templates and JS/TS/JSX/TSX source. Passing any additional argument, even `{}`, suppresses the action. Other recognized forms such as `formatMessage`, `v-t`, or translation components are not supported by Inline.
|
|
155
|
+
|
|
156
|
+
Calls are located with real parsers: Vue's template compiler (`@vue/compiler-dom`) for `.vue` and HTML documents, and Babel for JS/TS/JSX/TSX source and `<script>` blocks. Only a genuine call expression is rewritten:
|
|
157
|
+
|
|
158
|
+
- **JS/TS/JSX/TSX and `<script>`**: the call becomes a JS string literal; JSX children and attributes keep braces, e.g. `title={'Save'}`. Look-alike text such as `<p>t('key')</p>` in JSX is not a call and is left alone.
|
|
159
|
+
- **Template text**: the call becomes HTML-escaped text; a whole `{{ t('key') }}` becomes plain text unless the translation contains braces, in which case a JS string stays inside the interpolation.
|
|
160
|
+
- **Attributes**: a whole `:title="t('key')"` / `v-bind:title` binding becomes `title="…"`; a static `title="$t('key')"` keeps its attribute.
|
|
161
|
+
- HTML `<script type="text/x-template">` contents are handled as a template; other non-JavaScript script types are skipped.
|
|
162
|
+
|
|
163
|
+
When the context cannot be proven safe, no action is offered: partial expressions such as `:title="t('key') + suffix"`, bindings with modifiers or dynamic arguments, other directives, unclosed tags or interpolations while typing, CDATA, `<style>` and custom SFC blocks, and `<template lang="pug">` or similar.
|
|
164
|
+
|
|
165
|
+
Inline is intended to insert the locale's **stored text**, not a resolved runtime message: `{name}`, plural forms, and linked messages are not evaluated. A call without extra arguments can still rely on runtime behavior, so check that replacing it with literal text is appropriate. Vue's own whitespace condensing still applies to text produced from a whole interpolation.
|
|
166
|
+
|
|
167
|
+
### Editing and failure boundaries
|
|
168
|
+
|
|
169
|
+
Extract returns the source and locale changes in one LSP `WorkspaceEdit`; the editor applies them. Open buffers use their unsaved text and document version. Closed files have no version check against external changes. **Atomic application and rollback after permission or other client-side failures are not guaranteed or fully tested**; inspect both files if an edit fails. Failure while generating Extract or Inline actions is logged without discarding unrelated quick fixes.
|
|
170
|
+
|
|
171
|
+
Rename is not included in this release; it remains tracked in [issue #2](https://github.com/yizixu/zed-i18n-lens/issues/2).
|
|
172
|
+
|
|
123
173
|
## Project configuration
|
|
124
174
|
|
|
125
175
|
I18n Lens supports two configuration sources:
|
|
@@ -189,7 +239,7 @@ Supported options:
|
|
|
189
239
|
|
|
190
240
|
| Option | Type | Default | Description |
|
|
191
241
|
|---|---|---|---|
|
|
192
|
-
| `defaultLocale` | string | `zh-CN` |
|
|
242
|
+
| `defaultLocale` | string | `zh-CN` | Preferred locale for Extract, 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`. |
|
|
193
243
|
| `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. |
|
|
194
244
|
| `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. |
|
|
195
245
|
| `inlayHints.maxLength` | number | `24` | Maximum inline hint label length before truncation. Hover still shows the full text. |
|
|
@@ -220,6 +270,19 @@ src/locales/ja-JP/common.json
|
|
|
220
270
|
|
|
221
271
|
A file named `common.json` contributes keys with the `common.` prefix. A file named `index.json` contributes keys without an `index.` prefix.
|
|
222
272
|
|
|
273
|
+
Locale directories are scanned recursively. Subdirectories contribute to the namespace:
|
|
274
|
+
|
|
275
|
+
```text
|
|
276
|
+
src/locales/en-US/system/user.json -> system.user.*
|
|
277
|
+
src/locales/en-US/system/index.json -> system.*
|
|
278
|
+
src/locales/en-US/system/forms/fields.ts -> system.forms.fields.*
|
|
279
|
+
src/locales/en-US/index/index.json -> index.*
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
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.
|
|
283
|
+
|
|
284
|
+
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.
|
|
285
|
+
|
|
223
286
|
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
287
|
|
|
225
288
|
## 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.15.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,9 @@
|
|
|
20
20
|
"start": "node server/index.js"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
+
"@babel/parser": "^7.29.13",
|
|
24
|
+
"@vue/compiler-dom": "^3.5.45",
|
|
25
|
+
"entities": "^7.0.1",
|
|
23
26
|
"htmlparser2": "^10.1.0",
|
|
24
27
|
"vscode-languageserver": "^9.0.1",
|
|
25
28
|
"vscode-languageserver-textdocument": "^1.0.12"
|
package/server/core.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { parseDocument, DomUtils } from 'htmlparser2';
|
|
2
|
+
import { parse as parseScript, parseExpression } from '@babel/parser';
|
|
3
|
+
import { parse as parseTemplate, NodeTypes, ErrorCodes } from '@vue/compiler-dom';
|
|
4
|
+
import { decodeHTML, decodeHTMLAttribute } from 'entities';
|
|
2
5
|
|
|
3
6
|
export const DEFAULT_CONFIG = Object.freeze({
|
|
4
7
|
defaultLocale: 'zh-CN',
|
|
@@ -67,11 +70,13 @@ function templateInterpolations(text, excludedRanges) {
|
|
|
67
70
|
|
|
68
71
|
// Intentionally conservative: natural-language candidates, not arbitrary strings
|
|
69
72
|
// or a dictionary. Single title-cased labels and non-Latin text are useful too.
|
|
70
|
-
export function isLikelyUserFacingText(value) {
|
|
73
|
+
export function isLikelyUserFacingText(value, loose = false) {
|
|
71
74
|
const text = value.trim();
|
|
72
75
|
if (!/\p{L}/u.test(text)) return false;
|
|
73
76
|
if (/^(?:https?:|mailto:|www\.|[/#])|\S+@\S+\.\S+/i.test(text)) return false;
|
|
74
77
|
if (/^[\w.-]+(?:[/\\][\w.-]+)+$|^[\w-]+(?:\.[\w-]+)+$/.test(text)) return false;
|
|
78
|
+
// Extraction is user-triggered: any remaining text with letters qualifies.
|
|
79
|
+
if (loose) return true;
|
|
75
80
|
if (/^[A-Z\d_-]+$|^[a-z\d]+(?:[_-][a-z\d]+)+$|^[a-z]+(?:[A-Z][a-z\d]*)+$/.test(text)) return false;
|
|
76
81
|
if (/[^\p{Script=Latin}\p{N}\p{P}\p{S}\p{Z}\p{M}\s]/u.test(text)) return true;
|
|
77
82
|
return (/\s/.test(text) && (text.match(/\p{L}[\p{L}\p{M}]*/gu) || []).length >= 2)
|
|
@@ -83,7 +88,7 @@ export function isLikelyUserFacingText(value) {
|
|
|
83
88
|
* UTF-16, matching LSP positions. Mask Vue expressions before HTML parsing so
|
|
84
89
|
* comparisons and string literals inside {{ ... }} cannot become markup.
|
|
85
90
|
*/
|
|
86
|
-
export function getRawTextDiagnostics(text, language, enabled = false) {
|
|
91
|
+
export function getRawTextDiagnostics(text, language, enabled = false, loose = false) {
|
|
87
92
|
if (!enabled || !['vue', 'html'].includes(language)) return [];
|
|
88
93
|
const parserOptions = { withStartIndices: true, withEndIndices: true, recognizeSelfClosing: true };
|
|
89
94
|
// An unmatched "{{" in a comment, script, style or custom SFC block must
|
|
@@ -107,10 +112,12 @@ export function getRawTextDiagnostics(text, language, enabled = false) {
|
|
|
107
112
|
}).join('') + text.slice(previous);
|
|
108
113
|
const document = parseDocument(masked, parserOptions);
|
|
109
114
|
const diagnostics = [];
|
|
115
|
+
const calls = extractI18nKeys(text);
|
|
110
116
|
function report(raw, start, value = raw) {
|
|
111
117
|
const leading = raw.length - raw.trimStart().length;
|
|
112
118
|
const trailing = raw.trimEnd().length;
|
|
113
|
-
if (
|
|
119
|
+
if (calls.some((c) => c.call && c.call.start <= start + leading && start + trailing <= c.call.end)) return;
|
|
120
|
+
if (!isLikelyUserFacingText(value, loose)) return;
|
|
114
121
|
diagnostics.push({
|
|
115
122
|
range: makeRange(text, start + leading, start + trailing),
|
|
116
123
|
severity: 2,
|
|
@@ -303,14 +310,16 @@ function offsetToPosition(text, offset) {
|
|
|
303
310
|
function makeRange(text, start, end) {
|
|
304
311
|
return { start: offsetToPosition(text, start), end: offsetToPosition(text, end) };
|
|
305
312
|
}
|
|
306
|
-
// Lexical mask over mixed Vue/TS/JSX text: returns
|
|
307
|
-
//
|
|
308
|
-
//
|
|
313
|
+
// Lexical mask over mixed Vue/TS/JSX text: returns opaque ranges (comments,
|
|
314
|
+
// plain strings, template-literal raw text, HTML comments, ordinary attribute
|
|
315
|
+
// values) plus interiors of plain HTML/JSX attributes. Those interiors look
|
|
316
|
+
// like `title="$t('…')"` but are static text, not real calls — extractI18nKeys
|
|
317
|
+
// skips them unless includeStaticAttrCalls (Inline only). Vue directive
|
|
309
318
|
// attribute values (`:x="…"`, `v-x="…"`, `@x="…"`, `#x="…"`) and `${…}`
|
|
310
|
-
// interpolations
|
|
311
|
-
// expressions inside them are still scanned. Best-effort lexing only — no AST.
|
|
319
|
+
// interpolations stay transparent. Best-effort lexing only — no AST.
|
|
312
320
|
function computeOpaqueRanges(text) {
|
|
313
321
|
const ranges = [];
|
|
322
|
+
const attrValues = [];
|
|
314
323
|
const stack = [{ type: 'code' }];
|
|
315
324
|
const top = () => stack[stack.length - 1];
|
|
316
325
|
let i = 0;
|
|
@@ -334,7 +343,11 @@ function computeOpaqueRanges(text) {
|
|
|
334
343
|
}
|
|
335
344
|
if (f.type === 'string') {
|
|
336
345
|
if (ch === '\\') { i += 2; continue; }
|
|
337
|
-
if (ch === f.quote) {
|
|
346
|
+
if (ch === f.quote) {
|
|
347
|
+
ranges.push({ start: f.start, end: i + 1 });
|
|
348
|
+
if (f.attrValue) attrValues.push({ start: f.start + 1, end: i });
|
|
349
|
+
stack.pop();
|
|
350
|
+
}
|
|
338
351
|
i++;
|
|
339
352
|
continue;
|
|
340
353
|
}
|
|
@@ -372,7 +385,7 @@ function computeOpaqueRanges(text) {
|
|
|
372
385
|
if (ch === '<' && text[i + 1] === '!' && text[i + 2] === '-' && text[i + 3] === '-') { stack.push({ type: 'htmlComment', start: i }); i += 4; continue; }
|
|
373
386
|
if (ch === '"' || ch === "'") {
|
|
374
387
|
if (f.type === 'code' && isAttrExprQuote(text, i)) stack.push({ type: 'attrExpr', quote: ch, depth: 0 });
|
|
375
|
-
else stack.push({ type: 'string', quote: ch, start: i });
|
|
388
|
+
else stack.push({ type: 'string', quote: ch, start: i, attrValue: f.type === 'code' && isPlainAttrQuote(text, i) });
|
|
376
389
|
i++;
|
|
377
390
|
continue;
|
|
378
391
|
}
|
|
@@ -381,19 +394,32 @@ function computeOpaqueRanges(text) {
|
|
|
381
394
|
}
|
|
382
395
|
while (stack.length > 1) {
|
|
383
396
|
const f = stack.pop();
|
|
384
|
-
if (f.start
|
|
397
|
+
if (f.start === undefined) continue;
|
|
398
|
+
ranges.push({ start: f.start, end: text.length });
|
|
399
|
+
if (f.attrValue) attrValues.push({ start: f.start + 1, end: text.length });
|
|
385
400
|
}
|
|
386
|
-
|
|
401
|
+
ranges.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
402
|
+
return { ranges, attrValues };
|
|
387
403
|
}
|
|
388
404
|
|
|
389
405
|
// A quote is a Vue directive attribute opener when preceded by `name=` where
|
|
390
406
|
// name is `:foo`, `@foo`, `#foo`, `v-foo`, or `ns:foo` (v-on:, v-bind:, …).
|
|
391
|
-
// Plain attributes (`title="…"`, JSX `attr="…"`) keep string semantics.
|
|
392
407
|
function isAttrExprQuote(text, quoteOffset) {
|
|
393
408
|
const before = text.slice(Math.max(0, quoteOffset - 200), quoteOffset);
|
|
394
409
|
return /(?:^|[\s<"'`])(?:[:@#][\w.:[\]-]*|[\w-]+:[\w.:[\]-]*|v-[\w.:[\]-]*)\s*=\s*$/.test(before);
|
|
395
410
|
}
|
|
396
411
|
|
|
412
|
+
// Plain HTML/JSX attributes (`title="…"`, `alt='…'`) stay opaque as strings so
|
|
413
|
+
// apostrophes in values cannot swallow later code. Lookalike t() text inside
|
|
414
|
+
// them is not a real call (see extractI18nKeys includeStaticAttrCalls).
|
|
415
|
+
function isPlainAttrQuote(text, quoteOffset) {
|
|
416
|
+
const before = text.slice(0, quoteOffset);
|
|
417
|
+
const lastLt = before.lastIndexOf('<');
|
|
418
|
+
const lastGt = before.lastIndexOf('>');
|
|
419
|
+
if (lastLt < 0 || lastLt < lastGt || !/^[A-Za-z]/.test(before.slice(lastLt + 1))) return false;
|
|
420
|
+
return /(?:^|[\s"'`/])[\w.:[\]-]+\s*=\s*$/.test(before.slice(-80));
|
|
421
|
+
}
|
|
422
|
+
|
|
397
423
|
function isOpaque(ranges, offset) {
|
|
398
424
|
let lo = 0;
|
|
399
425
|
let hi = ranges.length - 1;
|
|
@@ -408,10 +434,10 @@ function isOpaque(ranges, offset) {
|
|
|
408
434
|
}
|
|
409
435
|
|
|
410
436
|
|
|
411
|
-
export function extractI18nKeys(text) {
|
|
437
|
+
export function extractI18nKeys(text, { includeStaticAttrCalls = false } = {}) {
|
|
412
438
|
const found = [];
|
|
413
|
-
const opaque = computeOpaqueRanges(text);
|
|
414
|
-
collectFunctionCallKeys(text, found, opaque);
|
|
439
|
+
const { ranges: opaque, attrValues } = computeOpaqueRanges(text);
|
|
440
|
+
collectFunctionCallKeys(text, found, opaque, includeStaticAttrCalls ? attrValues : []);
|
|
415
441
|
collectFormatMessageKeys(text, found, opaque);
|
|
416
442
|
const patterns = [
|
|
417
443
|
// <i18n-t keypath="key"> (Vue I18n) and <Trans i18nKey="key"> (react-i18next)
|
|
@@ -439,14 +465,15 @@ export function extractI18nKeys(text) {
|
|
|
439
465
|
return found;
|
|
440
466
|
}
|
|
441
467
|
|
|
442
|
-
function collectFunctionCallKeys(text, found, opaque) {
|
|
468
|
+
function collectFunctionCallKeys(text, found, opaque, attrValues = []) {
|
|
443
469
|
// t/$t/tc/$tc/i18n.t/i18n.tc(...) — function calls, incl. Vue I18n plural tc
|
|
444
470
|
const re = /(?:\x24tc?|\btc?|\bi18n\.tc?)\s*\(/g;
|
|
445
471
|
let match;
|
|
446
472
|
while ((match = re.exec(text))) {
|
|
447
473
|
const parenOffset = re.lastIndex - 1;
|
|
448
474
|
if (/[\w$]/.test(text[match.index - 1] || '')) continue;
|
|
449
|
-
|
|
475
|
+
const inAttr = attrValues.some((r) => match.index >= r.start && match.index < r.end);
|
|
476
|
+
if (!inAttr && isOpaque(opaque, match.index)) continue;
|
|
450
477
|
const callText = readParens(text, parenOffset);
|
|
451
478
|
if (!callText) continue;
|
|
452
479
|
// First arg must be exactly one string literal — dynamic keys like
|
|
@@ -459,7 +486,8 @@ function collectFunctionCallKeys(text, found, opaque) {
|
|
|
459
486
|
const args = splitArgsWithOffsets(callText.slice(1, -1));
|
|
460
487
|
const paramsEdit = getParamsEdit(args.slice(1), parenOffset + 1, parenOffset + callText.length - 1);
|
|
461
488
|
const paramsObject = extractParamsObject(args.slice(1), parenOffset + 1);
|
|
462
|
-
found.push({ key, range: makeRange(text, start, end), startOffset: start, endOffset: end, providedParams: collectParamNames(args.slice(1)), paramsEdit, paramsObject
|
|
489
|
+
found.push({ key, range: makeRange(text, start, end), startOffset: start, endOffset: end, providedParams: collectParamNames(args.slice(1)), paramsEdit, paramsObject,
|
|
490
|
+
call: { start: match.index, end: parenOffset + callText.length, argCount: args.length, quote: keyMatch[1] } });
|
|
463
491
|
}
|
|
464
492
|
}
|
|
465
493
|
|
|
@@ -642,25 +670,22 @@ export function collectLocaleWatchPaths(localeCache, localeDirs = []) {
|
|
|
642
670
|
|
|
643
671
|
export function findLocaleKeyTarget(locale, key, localeTexts = {}) {
|
|
644
672
|
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;
|
|
673
|
+
// Namespace specificity does not determine which file won during loading.
|
|
674
|
+
const filePath = locale.keyFiles?.[key];
|
|
675
|
+
if (!filePath) return undefined;
|
|
676
|
+
const lookupKey = lookupKeyForLocaleFile(locale, filePath, key);
|
|
677
|
+
if (lookupKey === undefined) return undefined;
|
|
678
|
+
const text = localeTexts[filePath];
|
|
679
|
+
const range = text ? localeKeyRange(text, lookupKey, /\.json$/i.test(filePath)) : undefined;
|
|
680
|
+
return range ? { filePath, position: range.start, range } : { filePath };
|
|
661
681
|
}
|
|
662
682
|
|
|
663
683
|
function lookupKeyForLocaleFile(locale, filePath, key) {
|
|
684
|
+
const prefix = locale.filePrefixes?.[filePath];
|
|
685
|
+
if (prefix !== undefined) {
|
|
686
|
+
if (!prefix) return key;
|
|
687
|
+
return key.startsWith(prefix + '.') ? key.slice(prefix.length + 1) : undefined;
|
|
688
|
+
}
|
|
664
689
|
const isNestedLocaleFile = !locale.topLevelFiles?.includes(filePath) && normalizeFsPath(locale.path) !== normalizeFsPath(filePath);
|
|
665
690
|
const stem = fileStem(filePath);
|
|
666
691
|
if (isNestedLocaleFile && stem && stem !== 'index' && key.startsWith(stem + '.')) return key.slice(stem.length + 1);
|
|
@@ -771,6 +796,7 @@ export function getLocaleParamDiagnostics(locale, filePath, text, locales) {
|
|
|
771
796
|
const consistency = getLocaleParamConsistency(key, locales);
|
|
772
797
|
if (consistency.consistent) continue;
|
|
773
798
|
const lookupKey = lookupKeyForLocaleFile(locale, filePath, key);
|
|
799
|
+
if (lookupKey === undefined) continue;
|
|
774
800
|
const range = localeKeyRange(text, lookupKey, isJson, jsonRoot);
|
|
775
801
|
if (range) diagnostics.push(paramInconsistencyDiagnostic(key, range, consistency.perLocale));
|
|
776
802
|
}
|
|
@@ -825,6 +851,511 @@ export function getParamCodeActions(text, diagnostics = []) {
|
|
|
825
851
|
});
|
|
826
852
|
}
|
|
827
853
|
|
|
854
|
+
/**
|
|
855
|
+
* If [contentStart, contentEnd) is translatable text inside a static attribute
|
|
856
|
+
* value or a bound attribute's whole string literal, returns safe rewrite bounds.
|
|
857
|
+
*/
|
|
858
|
+
function attributeLiteralContext(text, contentStart, contentEnd) {
|
|
859
|
+
const before = text.slice(0, contentStart);
|
|
860
|
+
const after = text.slice(contentEnd);
|
|
861
|
+
let bound = /(?:^|[\s>])(?::|v-bind:)([\w-]+)\s*=\s*(["'])\s*(['"]?)\s*$/.exec(before);
|
|
862
|
+
let plain = !bound && /(?:^|[\s>])([\w-]+)\s*=\s*(["'])\s*$/.exec(before);
|
|
863
|
+
const unquoted = !bound && !plain
|
|
864
|
+
&& (/(?:^|[\s>])(?::|v-bind:)([\w-]+)\s*=\s*$/.exec(before)
|
|
865
|
+
|| /(?:^|[\s>])([\w-]+)\s*=\s*$/.exec(before));
|
|
866
|
+
if (unquoted && !/^[^\s"'=<>`]/.test(after)) {
|
|
867
|
+
const attrStart = contentStart - unquoted[0].length + unquoted[0].search(/\S/);
|
|
868
|
+
return { name: unquoted[1], openQuote: '"', innerQuote: '', attrStart, attrEnd: contentEnd };
|
|
869
|
+
}
|
|
870
|
+
const m = bound || plain;
|
|
871
|
+
if (!m) return null;
|
|
872
|
+
const name = m[1];
|
|
873
|
+
const openQuote = bound ? bound[2] : plain[2];
|
|
874
|
+
const innerQuote = bound?.[3] || '';
|
|
875
|
+
const closeRe = innerQuote
|
|
876
|
+
? new RegExp(`^\\s*${escapeRegExp(innerQuote)}\\s*${escapeRegExp(openQuote)}`)
|
|
877
|
+
: new RegExp(`^\\s*${escapeRegExp(openQuote)}`);
|
|
878
|
+
const closeMatch = closeRe.exec(after);
|
|
879
|
+
if (!closeMatch) return null;
|
|
880
|
+
const attrStart = contentStart - m[0].length + m[0].search(/\S/);
|
|
881
|
+
const attrEnd = contentEnd + closeMatch[0].length;
|
|
882
|
+
return { name, openQuote, innerQuote, attrStart, attrEnd };
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* "Extract to i18n key" for raw-text diagnostics (or raw text inside a selected
|
|
887
|
+
* range). Key = `<prefix>.<slug>` where the prefix comes from deriveKeyPrefix
|
|
888
|
+
* (nearest ancestor keys > file keys > file stem); always a new key (identical
|
|
889
|
+
* text elsewhere is not reused; it may need a different translation).
|
|
890
|
+
*/
|
|
891
|
+
export function getExtractCodeActions(text, language, filePath, range, diagnostics = [], defaultFlat = {}) {
|
|
892
|
+
let targets = diagnostics.filter((d) => d.code === 'raw-text');
|
|
893
|
+
const start = range ? positionToOffset(text, range.start) : 0;
|
|
894
|
+
const end = range ? positionToOffset(text, range.end) : 0;
|
|
895
|
+
if (!targets.length && range) {
|
|
896
|
+
// Selection: every loose candidate inside it. Cursor: the candidate under it.
|
|
897
|
+
targets = getRawTextDiagnostics(text, language, true, true).filter((d) => {
|
|
898
|
+
const s = positionToOffset(text, d.range.start);
|
|
899
|
+
const e = positionToOffset(text, d.range.end);
|
|
900
|
+
return end > start ? s >= start && e <= end : s <= start && start <= e;
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
const calls = extractI18nKeys(text, { includeStaticAttrCalls: true });
|
|
904
|
+
return targets.flatMap((diagnostic) => {
|
|
905
|
+
const s = positionToOffset(text, diagnostic.range.start);
|
|
906
|
+
const e = positionToOffset(text, diagnostic.range.end);
|
|
907
|
+
if (calls.some((c) => c.call && c.call.start <= s && e <= c.call.end)) return [];
|
|
908
|
+
const value = text.slice(s, e).replace(/\s+/g, ' ');
|
|
909
|
+
// v1: no entities or interpolation-like content; their decoded value is ambiguous.
|
|
910
|
+
if (!value || /[&<>{}]/.test(value)) return [];
|
|
911
|
+
const { prefix, source: keySource } = deriveKeyPrefix(text, language, filePath, s, e, defaultFlat);
|
|
912
|
+
const key = uniqueKey(prefix + '.' + slugify(value), defaultFlat);
|
|
913
|
+
if (!key) return [];
|
|
914
|
+
const attr = attributeLiteralContext(text, s, e);
|
|
915
|
+
let edit;
|
|
916
|
+
if (attr) {
|
|
917
|
+
edit = { range: makeRange(text, attr.attrStart, attr.attrEnd), newText: `:${attr.name}="$t('${key}')"` };
|
|
918
|
+
} else {
|
|
919
|
+
edit = { range: makeRange(text, s, e), newText: `{{ $t('${key}') }}` };
|
|
920
|
+
}
|
|
921
|
+
return [{ title: `Extract to i18n key "${key}"`, diagnostic, key, keySource, value, edit }];
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
const VOID_ELEMENTS = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
|
|
926
|
+
|
|
927
|
+
/** Lightweight tag-stack scan: returns elements { tag, start, end, attrs, openEnd }; void/self-closing ones span only their opening tag. */
|
|
928
|
+
function scanElements(text, opaque = computeOpaqueRanges(text).ranges) {
|
|
929
|
+
const elements = [];
|
|
930
|
+
const stack = [];
|
|
931
|
+
const re = /<!--[\s\S]*?-->|<\/([A-Za-z][\w:.-]*)\s*>|<([A-Za-z][\w:.-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/g;
|
|
932
|
+
let m;
|
|
933
|
+
while ((m = re.exec(text))) {
|
|
934
|
+
if (isOpaque(opaque, m.index)) continue;
|
|
935
|
+
if (m[1]) {
|
|
936
|
+
const tag = m[1].toLowerCase();
|
|
937
|
+
const idx = stack.map((x) => x.tag).lastIndexOf(tag);
|
|
938
|
+
if (idx < 0) continue;
|
|
939
|
+
while (stack.length > idx) {
|
|
940
|
+
const el = stack.pop();
|
|
941
|
+
el.end = stack.length === idx ? re.lastIndex : m.index;
|
|
942
|
+
elements.push(el);
|
|
943
|
+
}
|
|
944
|
+
} else if (m[2]) {
|
|
945
|
+
const tag = m[2].toLowerCase();
|
|
946
|
+
const el = { tag, start: m.index, end: text.length, attrs: m[3], openEnd: re.lastIndex };
|
|
947
|
+
if (m[4] || VOID_ELEMENTS.has(tag)) {
|
|
948
|
+
// Retain the opening span so attributes on <img> / <Component />
|
|
949
|
+
// are not mistaken for text in an enclosing element.
|
|
950
|
+
el.end = re.lastIndex;
|
|
951
|
+
elements.push(el);
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
954
|
+
if (tag === 'script' || tag === 'style') {
|
|
955
|
+
const close = text.toLowerCase().indexOf('</' + tag, re.lastIndex);
|
|
956
|
+
const stop = close < 0 ? text.length : text.indexOf('>', close) + 1 || text.length;
|
|
957
|
+
el.end = stop;
|
|
958
|
+
elements.push(el);
|
|
959
|
+
re.lastIndex = stop;
|
|
960
|
+
continue;
|
|
961
|
+
}
|
|
962
|
+
stack.push(el);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
while (stack.length) elements.push(stack.pop());
|
|
966
|
+
return elements;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
const SCRIPT_PARSE_OPTIONS = {
|
|
970
|
+
sourceType: 'unambiguous',
|
|
971
|
+
errorRecovery: true,
|
|
972
|
+
allowReturnOutsideFunction: true,
|
|
973
|
+
allowAwaitOutsideFunction: true,
|
|
974
|
+
allowImportExportEverywhere: true,
|
|
975
|
+
};
|
|
976
|
+
|
|
977
|
+
// Babel's modern (TC39) and legacy decorator plugins are mutually exclusive;
|
|
978
|
+
// a file only has to parse under one of them.
|
|
979
|
+
function scriptPluginSets(dialect) {
|
|
980
|
+
const syntax = dialect === 'typescript' ? ['typescript'] : dialect === 'typescriptreact' ? ['typescript', 'jsx'] : ['jsx'];
|
|
981
|
+
return [[...syntax, 'decorators', 'decoratorAutoAccessors'], [...syntax, 'decorators-legacy']];
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function hasCallAt(node, start, end) {
|
|
985
|
+
while (node) {
|
|
986
|
+
if (node.type === 'CallExpression' && node.start === start && node.end === end) return true;
|
|
987
|
+
let next = null;
|
|
988
|
+
for (const key of Object.keys(node)) {
|
|
989
|
+
if (key === 'loc' || key === 'leadingComments' || key === 'trailingComments' || key === 'innerComments') continue;
|
|
990
|
+
for (const child of [].concat(node[key])) {
|
|
991
|
+
if (child && typeof child.type === 'string' && child.start <= start && end <= child.end) { next = child; break; }
|
|
992
|
+
}
|
|
993
|
+
if (next) break;
|
|
994
|
+
}
|
|
995
|
+
node = next;
|
|
996
|
+
}
|
|
997
|
+
return false;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// Lexing cannot tell generics, comparisons, regexes, JSX text and code apart;
|
|
1001
|
+
// only a parsed CallExpression qualifies. Parse failures offer nothing.
|
|
1002
|
+
function isCallInScript(code, dialect, start, end) {
|
|
1003
|
+
return scriptPluginSets(dialect).some((plugins) => {
|
|
1004
|
+
try {
|
|
1005
|
+
return hasCallAt(parseScript(code, { ...SCRIPT_PARSE_OPTIONS, plugins }).program, start, end);
|
|
1006
|
+
} catch {
|
|
1007
|
+
return false;
|
|
1008
|
+
}
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
function isCallInExpression(code, dialect, start, end) {
|
|
1013
|
+
try {
|
|
1014
|
+
return hasCallAt(parseExpression(code, { plugins: dialect === 'typescript' ? ['typescript'] : [], errorRecovery: true }), start, end);
|
|
1015
|
+
} catch {
|
|
1016
|
+
return false;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* Vue (and the browser for in-DOM templates) decodes entities in `{{ }}` and
|
|
1022
|
+
* attribute values before evaluating them, so `a < b ? t('k') : ''` is
|
|
1023
|
+
* judged on its decoded code. Returns the call's span inside that code.
|
|
1024
|
+
*/
|
|
1025
|
+
function decodedCall(raw, from, to, decode) {
|
|
1026
|
+
const code = decode(raw);
|
|
1027
|
+
const call = raw.slice(from, to);
|
|
1028
|
+
const pos = decode(raw.slice(0, from)).length;
|
|
1029
|
+
return code.slice(pos, pos + call.length) === call ? { code, start: pos, end: pos + call.length } : null;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// HTML attribute names are ASCII case-insensitive (`TYPE`); compiler-sfc reads
|
|
1033
|
+
// SFC block attributes such as `lang` case-sensitively.
|
|
1034
|
+
function staticAttr(element, name, html) {
|
|
1035
|
+
const prop = element.props.find((p) => p.type === NodeTypes.ATTRIBUTE && (html ? p.name.toLowerCase() : p.name) === name);
|
|
1036
|
+
return prop?.value?.content;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// True when [start, end) lies in a CDATA section of this TEXT node's source.
|
|
1040
|
+
// Vue folds CDATA into the surrounding text node and its loc may start just
|
|
1041
|
+
// after the `<![CDATA[` marker.
|
|
1042
|
+
function inCdata(text, node, start, end) {
|
|
1043
|
+
let from = node.loc.start.offset;
|
|
1044
|
+
if (text.startsWith('<![CDATA[', from - 9)) from -= 9;
|
|
1045
|
+
const to = node.loc.end.offset;
|
|
1046
|
+
for (let i = text.indexOf('<![CDATA[', from); i >= 0 && i < to; i = text.indexOf('<![CDATA[', i)) {
|
|
1047
|
+
const close = text.indexOf(']]>', i + 9);
|
|
1048
|
+
const stop = close < 0 ? to : close + 3;
|
|
1049
|
+
if (i < end && start < stop) return true;
|
|
1050
|
+
i = stop;
|
|
1051
|
+
}
|
|
1052
|
+
return false;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
function scriptDialect(lang) {
|
|
1056
|
+
if (lang === 'ts') return 'typescript';
|
|
1057
|
+
return lang === 'tsx' ? 'typescriptreact' : 'javascript';
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// Vue reads any attribute name starting with these as a directive.
|
|
1061
|
+
const DIRECTIVE_NAME = /^(?:v-|[:@#.])/i;
|
|
1062
|
+
|
|
1063
|
+
function attributeTarget(text, prop, start, end, dialect, lowercaseNames) {
|
|
1064
|
+
const callText = text.slice(start, end);
|
|
1065
|
+
if (prop.type === NodeTypes.ATTRIBUTE) {
|
|
1066
|
+
// Browsers lowercase attribute names before Vue sees an in-DOM template,
|
|
1067
|
+
// so `V-BIND:TITLE` is a binding there even though Vue's case-sensitive
|
|
1068
|
+
// parser saw a static attribute.
|
|
1069
|
+
if (lowercaseNames && DIRECTIVE_NAME.test(prop.name)) return null;
|
|
1070
|
+
// Static attribute text such as title="$t('key')" is a look-alike, not a call.
|
|
1071
|
+
if (!prop.value) return null;
|
|
1072
|
+
const raw = text.slice(prop.value.loc.start.offset, prop.value.loc.end.offset).replace(/^(["'])([\s\S]*)\1$/, '$2');
|
|
1073
|
+
if (raw.trim() !== callText) return null;
|
|
1074
|
+
return { kind: 'attribute', name: prop.name, bound: false, quote: '"', start: prop.loc.start.offset, end: prop.loc.end.offset };
|
|
1075
|
+
}
|
|
1076
|
+
// Only a whole, unmodified `:name` / `v-bind:name` binding can become a plain attribute.
|
|
1077
|
+
if (prop.name !== 'bind' || !prop.arg?.isStatic || prop.modifiers.length || !prop.exp) return null;
|
|
1078
|
+
const expStart = prop.exp.loc.start.offset;
|
|
1079
|
+
const raw = text.slice(expStart, prop.exp.loc.end.offset);
|
|
1080
|
+
const decoded = decodedCall(raw, start - expStart, end - expStart, decodeHTMLAttribute);
|
|
1081
|
+
if (!decoded || decoded.code.trim() !== callText || !isCallInExpression(decoded.code, dialect, decoded.start, decoded.end)) return null;
|
|
1082
|
+
const quote = /["']/.test(text[expStart - 1]) ? text[expStart - 1] : '"';
|
|
1083
|
+
// `:v-bind:title` binds an attribute literally named `v-bind:title`; written
|
|
1084
|
+
// as a plain attribute it would turn into a directive, so it stays bound.
|
|
1085
|
+
return { kind: 'attribute', name: prop.arg.content, bound: true, keepBound: DIRECTIVE_NAME.test(prop.arg.content), quote, start: prop.loc.start.offset, end: prop.loc.end.offset };
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
const TOLERATED_TEMPLATE_ERRORS = new Set([ErrorCodes.X_MISSING_END_TAG, ErrorCodes.UNEXPECTED_CHARACTER_IN_UNQUOTED_ATTRIBUTE_VALUE]);
|
|
1089
|
+
// HTML's "JavaScript MIME type essence match" list plus module and JSX/Babel blocks.
|
|
1090
|
+
const JS_SCRIPT_TYPES = new Set([
|
|
1091
|
+
'', 'module', 'text/jsx', 'text/babel',
|
|
1092
|
+
'application/ecmascript', 'application/javascript', 'application/x-ecmascript', 'application/x-javascript',
|
|
1093
|
+
'text/ecmascript', 'text/javascript', 'text/javascript1.0', 'text/javascript1.1', 'text/javascript1.2', 'text/javascript1.3',
|
|
1094
|
+
'text/javascript1.4', 'text/javascript1.5', 'text/jscript', 'text/livescript', 'text/x-ecmascript', 'text/x-javascript',
|
|
1095
|
+
]);
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* Where a call sits in a Vue SFC or HTML document, from Vue's own template
|
|
1099
|
+
* parser (it understands `{{ }}`, directives and raw-text blocks). Returns
|
|
1100
|
+
* null whenever the context is not provably one of the rewritable kinds.
|
|
1101
|
+
*/
|
|
1102
|
+
function templateInlineTarget(text, language, start, end, lowercaseNames = language === 'html') {
|
|
1103
|
+
let root;
|
|
1104
|
+
let invalid = false;
|
|
1105
|
+
try {
|
|
1106
|
+
// Browsers end script/style raw text case-insensitively (`<SCRIPT>`);
|
|
1107
|
+
// Vue's parser only knows lowercase. Same-length rewrite keeps offsets.
|
|
1108
|
+
const source = language === 'vue' ? text : text.replace(/<(\/?)(script|style)(?=[\s/>])/gi, (_, slash, tag) => `<${slash}${tag.toLowerCase()}`);
|
|
1109
|
+
root = parseTemplate(source, {
|
|
1110
|
+
parseMode: language === 'vue' ? 'sfc' : 'html',
|
|
1111
|
+
comments: true,
|
|
1112
|
+
// Missing end tags and quotes inside unquoted values (browsers accept
|
|
1113
|
+
// both) keep node bounds intact; anything else, such as unclosed tags or
|
|
1114
|
+
// interpolations while typing, leaves the context unknown.
|
|
1115
|
+
onError: (error) => { if (!TOLERATED_TEMPLATE_ERRORS.has(error.code)) invalid = true; },
|
|
1116
|
+
onWarn: () => {},
|
|
1117
|
+
});
|
|
1118
|
+
} catch {
|
|
1119
|
+
return null;
|
|
1120
|
+
}
|
|
1121
|
+
if (invalid) return null;
|
|
1122
|
+
const sfcBlocks = language === 'vue' ? root.children.filter((n) => n.type === NodeTypes.ELEMENT) : [];
|
|
1123
|
+
const exprDialect = sfcBlocks.some((n) => n.tag === 'script' && /^tsx?$/.test(staticAttr(n, 'lang') || '')) ? 'typescript' : 'javascript';
|
|
1124
|
+
const ancestors = [];
|
|
1125
|
+
let parent = root;
|
|
1126
|
+
for (;;) {
|
|
1127
|
+
const node = parent.children?.find((n) => n.loc.start.offset <= start && end <= n.loc.end.offset);
|
|
1128
|
+
if (!node) return null;
|
|
1129
|
+
if (node.type === NodeTypes.ELEMENT) {
|
|
1130
|
+
if (language === 'vue' && parent === root && node.tag === 'template' && (staticAttr(node, 'lang') || 'html') !== 'html') return null;
|
|
1131
|
+
const prop = node.props.find((p) => p.loc.start.offset <= start && end <= p.loc.end.offset);
|
|
1132
|
+
if (prop) return attributeTarget(text, prop, start, end, exprDialect, lowercaseNames);
|
|
1133
|
+
ancestors.push(node);
|
|
1134
|
+
parent = node;
|
|
1135
|
+
continue;
|
|
1136
|
+
}
|
|
1137
|
+
if (node.type === NodeTypes.INTERPOLATION) {
|
|
1138
|
+
const exp = node.content;
|
|
1139
|
+
const expStart = exp.loc.start.offset;
|
|
1140
|
+
const decoded = decodedCall(text.slice(expStart, exp.loc.end.offset), start - expStart, end - expStart, decodeHTML);
|
|
1141
|
+
if (!decoded || !isCallInExpression(decoded.code, exprDialect, decoded.start, decoded.end)) return null;
|
|
1142
|
+
return { kind: 'interpolation', whole: decoded.code.trim() === text.slice(start, end), start: node.loc.start.offset, end: node.loc.end.offset };
|
|
1143
|
+
}
|
|
1144
|
+
if (node.type !== NodeTypes.TEXT) return null;
|
|
1145
|
+
if (parent.type === NodeTypes.ELEMENT && parent.tag === 'script') {
|
|
1146
|
+
const base = node.loc.start.offset;
|
|
1147
|
+
const code = text.slice(base, node.loc.end.offset);
|
|
1148
|
+
if (language === 'vue') return isCallInScript(code, scriptDialect(staticAttr(parent, 'lang')), start - base, end - base) ? { kind: 'script' } : null;
|
|
1149
|
+
const type = (staticAttr(parent, 'type', true) || '').trim().toLowerCase();
|
|
1150
|
+
if (JS_SCRIPT_TYPES.has(type)) return isCallInScript(code, 'javascript', start - base, end - base) ? { kind: 'script' } : null;
|
|
1151
|
+
if (type !== 'text/x-template') return null;
|
|
1152
|
+
// An in-DOM Vue template: its raw contents are compiled as a template,
|
|
1153
|
+
// without the browser lowercasing attribute names first.
|
|
1154
|
+
const inner = templateInlineTarget(code, 'html', start - base, end - base, false);
|
|
1155
|
+
return inner && ('start' in inner ? { ...inner, start: inner.start + base, end: inner.end + base } : inner);
|
|
1156
|
+
}
|
|
1157
|
+
if (parent.type === NodeTypes.ELEMENT && parent.tag === 'style') return null;
|
|
1158
|
+
// SFC text outside <template> (custom blocks, stray root text) is not markup.
|
|
1159
|
+
if (language === 'vue' && !ancestors.some((el) => el.tag === 'template' && sfcBlocks.includes(el))) return null;
|
|
1160
|
+
// CDATA exists only in SVG/MathML content and is never entity-decoded, so
|
|
1161
|
+
// HTML escaping would change it. Elsewhere (`<textarea>`) it is plain text.
|
|
1162
|
+
if (parent.type === NodeTypes.ELEMENT && parent.ns !== 0 && inCdata(text, node, start, end)) return null;
|
|
1163
|
+
return { kind: 'text' };
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
function commonKeyPrefix(keys) {
|
|
1168
|
+
let common;
|
|
1169
|
+
for (const key of keys) {
|
|
1170
|
+
const parts = key.split('.').slice(0, -1);
|
|
1171
|
+
if (common === undefined) common = parts;
|
|
1172
|
+
else {
|
|
1173
|
+
let i = 0;
|
|
1174
|
+
while (i < common.length && i < parts.length && common[i] === parts[i]) i++;
|
|
1175
|
+
common = common.slice(0, i);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
return common && common.length ? common.join('.') : '';
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
const normalizeSegment = (value) => value.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
1182
|
+
|
|
1183
|
+
/** Descend into an existing child group whose name matches an ancestor's id/data-anchor. */
|
|
1184
|
+
function descendByAnchor(prefix, ancestors, flat) {
|
|
1185
|
+
const children = new Set();
|
|
1186
|
+
for (const k of Object.keys(flat)) {
|
|
1187
|
+
if (!k.startsWith(prefix + '.')) continue;
|
|
1188
|
+
const rest = k.slice(prefix.length + 1).split('.');
|
|
1189
|
+
if (rest.length > 1) children.add(rest[0]);
|
|
1190
|
+
}
|
|
1191
|
+
for (const el of ancestors) {
|
|
1192
|
+
const a = /(?:^|\s)(?:id|data-anchor)\s*=\s*(["'])([^"']+)\1/.exec(el.attrs);
|
|
1193
|
+
if (!a) continue;
|
|
1194
|
+
const want = normalizeSegment(a[2]);
|
|
1195
|
+
const hit = [...children].find((c) => normalizeSegment(c) === want);
|
|
1196
|
+
if (hit) return prefix + '.' + hit;
|
|
1197
|
+
}
|
|
1198
|
+
return prefix;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
/**
|
|
1202
|
+
* Prefix for a new extracted key: common prefix of keys in the nearest ancestor
|
|
1203
|
+
* element that has any, else of all keys in the file, else the file stem.
|
|
1204
|
+
*/
|
|
1205
|
+
function deriveKeyPrefix(text, language, filePath, s, e, flat) {
|
|
1206
|
+
const keys = extractI18nKeys(text);
|
|
1207
|
+
if (keys.length && (language === 'vue' || language === 'html')) {
|
|
1208
|
+
const ancestors = scanElements(text)
|
|
1209
|
+
.filter((el) => el.start <= s && el.end >= e && el.tag !== 'template')
|
|
1210
|
+
.sort((a, b) => (a.end - a.start) - (b.end - b.start));
|
|
1211
|
+
for (let i = 0; i < ancestors.length; i++) {
|
|
1212
|
+
const el = ancestors[i];
|
|
1213
|
+
const inside = keys.filter((k) => k.startOffset >= el.start && k.endOffset <= el.end).map((k) => k.key);
|
|
1214
|
+
if (!inside.length) continue;
|
|
1215
|
+
const prefix = commonKeyPrefix(inside);
|
|
1216
|
+
if (prefix) return { prefix: descendByAnchor(prefix, ancestors.slice(0, i + 1), flat), source: 'ancestor' };
|
|
1217
|
+
break;
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
const filePrefix = commonKeyPrefix(keys.map((k) => k.key));
|
|
1221
|
+
if (filePrefix) return { prefix: filePrefix, source: 'file' };
|
|
1222
|
+
return { prefix: fileStem(filePath), source: 'filename' };
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
function slugify(value) {
|
|
1226
|
+
const words = value.toLowerCase().match(/[a-z0-9]+/g) || [];
|
|
1227
|
+
if (words.length) return words.slice(0, 4).join('_');
|
|
1228
|
+
// Non-Latin text: deterministic FNV-1a hash instead of a transliteration.
|
|
1229
|
+
let hash = 0x811c9dc5;
|
|
1230
|
+
for (const ch of value) hash = Math.imul(hash ^ ch.codePointAt(0), 0x01000193) >>> 0;
|
|
1231
|
+
return 'text_' + hash.toString(16);
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
function uniqueKey(base, flat) {
|
|
1235
|
+
const leaves = new Set(Object.keys(flat));
|
|
1236
|
+
const branches = new Set();
|
|
1237
|
+
for (const key of leaves) {
|
|
1238
|
+
for (let dot = key.indexOf('.'); dot !== -1; dot = key.indexOf('.', dot + 1)) branches.add(key.slice(0, dot));
|
|
1239
|
+
}
|
|
1240
|
+
const hasLeafAncestor = (key) => {
|
|
1241
|
+
for (let dot = key.indexOf('.'); dot !== -1; dot = key.indexOf('.', dot + 1)) {
|
|
1242
|
+
if (leaves.has(key.slice(0, dot))) return true;
|
|
1243
|
+
}
|
|
1244
|
+
return false;
|
|
1245
|
+
};
|
|
1246
|
+
const taken = (key) => leaves.has(key) || branches.has(key) || hasLeafAncestor(key);
|
|
1247
|
+
|
|
1248
|
+
function allocate(candidate) {
|
|
1249
|
+
if (!candidate) return undefined;
|
|
1250
|
+
if (!taken(candidate)) return candidate;
|
|
1251
|
+
// Suffixing the last segment cannot escape a leaf ancestor (`Home` vs
|
|
1252
|
+
// `Home.hello_world_2`), so give up this candidate rather than loop.
|
|
1253
|
+
if (hasLeafAncestor(candidate)) return undefined;
|
|
1254
|
+
for (let n = 2; n <= 10000; n++) {
|
|
1255
|
+
const key = candidate + '_' + n;
|
|
1256
|
+
if (!taken(key)) return key;
|
|
1257
|
+
}
|
|
1258
|
+
return undefined;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// Prefixes that are already string leaves cannot host children. Drop those
|
|
1262
|
+
// segments and keep the rest under the longest still-object (or empty)
|
|
1263
|
+
// prefix: `hello.hello_world` with leaf `hello` → `hello_world`; `a.b.c.d`
|
|
1264
|
+
// with leaf `a.b` → `a.c.d`. Exact / descendant collisions still suffix
|
|
1265
|
+
// the last segment (`d_2`, `d_3`, …).
|
|
1266
|
+
const parts = base.split('.');
|
|
1267
|
+
const kept = [];
|
|
1268
|
+
for (let i = 0; i < parts.length; i++) {
|
|
1269
|
+
const next = [...kept, parts[i]].join('.');
|
|
1270
|
+
if (i < parts.length - 1 && Object.hasOwn(flat, next)) continue;
|
|
1271
|
+
kept.push(parts[i]);
|
|
1272
|
+
}
|
|
1273
|
+
return allocate(kept.join('.'));
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
/**
|
|
1277
|
+
* "Inline as <locale>": replace a param-less t('key') call with locale text.
|
|
1278
|
+
* `language` is 'vue' | 'html' for template documents, otherwise
|
|
1279
|
+
* 'typescript' | 'typescriptreact' | 'javascript' (JSX enabled). Source files
|
|
1280
|
+
* only inline real call expressions, replaced by a JS string literal.
|
|
1281
|
+
*/
|
|
1282
|
+
export function getInlineCodeActions(text, language, range, locales = {}) {
|
|
1283
|
+
const isTemplate = language === 'vue' || language === 'html';
|
|
1284
|
+
const offset = positionToOffset(text, range.start);
|
|
1285
|
+
const endOffset = positionToOffset(text, range.end || range.start);
|
|
1286
|
+
// Cursor (or selection) must sit inside the call; a whole-file range offers nothing.
|
|
1287
|
+
const item = extractI18nKeys(text, { includeStaticAttrCalls: isTemplate }).find((c) => c.call && offset >= c.call.start && endOffset <= c.call.end);
|
|
1288
|
+
if (!item || item.call.argCount !== 1) return [];
|
|
1289
|
+
const { start, end, quote } = item.call;
|
|
1290
|
+
const target = isTemplate
|
|
1291
|
+
? templateInlineTarget(text, language, start, end)
|
|
1292
|
+
: isCallInScript(text, language, start, end) && { kind: 'script' };
|
|
1293
|
+
if (!target) return [];
|
|
1294
|
+
return Object.entries(locales).flatMap(([name, locale]) => {
|
|
1295
|
+
const value = locale?.flat?.[item.key];
|
|
1296
|
+
if (typeof value !== 'string') return [];
|
|
1297
|
+
return [{ title: `Inline i18n key "${item.key}" as ${name}`, edit: inlineEdit(text, target, start, end, quote, value) }];
|
|
1298
|
+
});
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
function inlineEdit(text, target, start, end, quote, value) {
|
|
1302
|
+
const replace = (from, to, newText) => ({ range: makeRange(text, from, to), newText });
|
|
1303
|
+
if (target.kind === 'script') return replace(start, end, escapeJsString(value, quote));
|
|
1304
|
+
// Template text: `{{`/`}}` in the translation must not become a Vue interpolation.
|
|
1305
|
+
if (target.kind === 'text') return replace(start, end, escapeMarkup(value).replace(/\{/g, '{').replace(/\}/g, '}'));
|
|
1306
|
+
if (target.kind === 'interpolation') {
|
|
1307
|
+
// Without braces the whole `{{ t('key') }}` becomes plain text.
|
|
1308
|
+
if (target.whole && !/[{}]/.test(value)) return replace(target.start, target.end, escapeMarkup(value));
|
|
1309
|
+
// A JS literal with no markup-significant characters at all: entities
|
|
1310
|
+
// would change whether browser Vue re-decodes the whole expression, `<`
|
|
1311
|
+
// is parsed as a tag in in-DOM templates, and `}}` ends the interpolation.
|
|
1312
|
+
return replace(start, end, markupFreeJsString(value, quote));
|
|
1313
|
+
}
|
|
1314
|
+
// Braces stay inside a bound JS string, not a plain attribute Vue could re-read.
|
|
1315
|
+
if (target.bound && (target.keepBound || /[{}]/.test(value))) {
|
|
1316
|
+
const jsQuote = target.quote === '"' ? "'" : '"';
|
|
1317
|
+
return replace(target.start, target.end, `:${target.name}=${target.quote}${escapeHtmlAttribute(escapeJsString(value, jsQuote), target.quote)}${target.quote}`);
|
|
1318
|
+
}
|
|
1319
|
+
return replace(target.start, target.end, `${target.name}="${escapeMarkup(value)}"`);
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
function markupFreeJsString(value, quote) {
|
|
1323
|
+
const escaped = value.replace(/[\\\n\r\u2028\u2029&<>"'`${}]/g, (ch) => {
|
|
1324
|
+
if (ch === '\\') return '\\\\';
|
|
1325
|
+
if (ch === '\n') return '\\n';
|
|
1326
|
+
if (ch === '\r') return '\\r';
|
|
1327
|
+
return '\\u' + ch.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0');
|
|
1328
|
+
});
|
|
1329
|
+
return quote + escaped + quote;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
function escapeHtml(value) {
|
|
1333
|
+
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
// HTML parsing turns a literal CR (or CRLF) into LF; a character reference keeps it.
|
|
1337
|
+
function escapeMarkup(value) {
|
|
1338
|
+
return escapeHtml(value).replace(/\r/g, ' ');
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
function escapeHtmlAttribute(value, quote) {
|
|
1342
|
+
const escaped = escapeHtml(value);
|
|
1343
|
+
return quote === "'" ? escaped.replace(/'/g, ''') : escaped;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
function escapeJsString(value, quote) {
|
|
1347
|
+
let escaped = String(value)
|
|
1348
|
+
.replace(/\\/g, '\\\\')
|
|
1349
|
+
.replace(new RegExp(escapeRegExp(quote), 'g'), '\\' + quote)
|
|
1350
|
+
.replace(/\r/g, '\\r')
|
|
1351
|
+
.replace(/\n/g, '\\n');
|
|
1352
|
+
if (quote === '`') escaped = escaped.replace(/\$\{/g, '\\${');
|
|
1353
|
+
// Break </script> / </style> so a generated literal cannot close an HTML
|
|
1354
|
+
// <script> / <style> element. `\/` is still `/` in JS string values.
|
|
1355
|
+
escaped = escaped.replace(/<\/(script|style)/gi, '<\\/$1');
|
|
1356
|
+
return quote + escaped + quote;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
828
1359
|
function sameRange(a, b) {
|
|
829
1360
|
return a?.start?.line === b?.start?.line
|
|
830
1361
|
&& a?.start?.character === b?.start?.character
|
|
@@ -921,6 +1452,7 @@ export function localeKeyAtPosition(locale, filePath, text, position) {
|
|
|
921
1452
|
let lineFallbackCount = 0;
|
|
922
1453
|
for (const fullKey of Object.keys(locale.flat)) {
|
|
923
1454
|
const lookupKey = lookupKeyForLocaleFile(locale, filePath, fullKey);
|
|
1455
|
+
if (lookupKey === undefined) continue;
|
|
924
1456
|
const range = localeKeyRange(text, lookupKey, isJson, jsonRoot);
|
|
925
1457
|
if (!range || range.start.line !== position.line) continue;
|
|
926
1458
|
if (comparePosition(position, range.start) >= 0 && comparePosition(position, range.end) < 0) return fullKey;
|
|
@@ -932,7 +1464,7 @@ export function localeKeyAtPosition(locale, filePath, text, position) {
|
|
|
932
1464
|
|
|
933
1465
|
export function getCompletionPrefix(text, position) {
|
|
934
1466
|
const before = text.slice(0, positionToOffset(text, position));
|
|
935
|
-
const opaque = computeOpaqueRanges(before);
|
|
1467
|
+
const { ranges: opaque } = computeOpaqueRanges(before);
|
|
936
1468
|
const patterns = [
|
|
937
1469
|
/(?:\x24tc?|\btc?|\bi18n\.tc?)\s*\(\s*['"]([A-Za-z0-9_.:-]*)$/,
|
|
938
1470
|
/\bformatMessage\s*\(\s*\{[^}]*?\bid\s*:\s*['"]([A-Za-z0-9_.:-]*)$/,
|
|
@@ -1086,12 +1618,13 @@ function buildNestedProperty(parts, value, indent, unit, eol) {
|
|
|
1086
1618
|
}
|
|
1087
1619
|
|
|
1088
1620
|
|
|
1621
|
+
// File and directory namespaces are applied by the loader, not the parser.
|
|
1089
1622
|
export function parseTsLocaleModule(text, fileName = 'index.ts') {
|
|
1090
1623
|
const result = {};
|
|
1091
1624
|
const stem = fileName.replace(/\.[cm]?[tj]sx?$/i, '');
|
|
1092
1625
|
const exportObject = extractObjectAfter(text, /export\s+default\s*/g);
|
|
1093
1626
|
if (exportObject) {
|
|
1094
|
-
Object.assign(result, flattenJsObjectLiteral(exportObject
|
|
1627
|
+
Object.assign(result, flattenJsObjectLiteral(exportObject));
|
|
1095
1628
|
}
|
|
1096
1629
|
|
|
1097
1630
|
if (stem === 'index') {
|
package/server/index.js
CHANGED
|
@@ -27,6 +27,9 @@ import {
|
|
|
27
27
|
didWatchedFileChange,
|
|
28
28
|
collectLocaleWatchPaths,
|
|
29
29
|
resolveProjectContext,
|
|
30
|
+
getExtractCodeActions,
|
|
31
|
+
resolvePreferredLocale,
|
|
32
|
+
getInlineCodeActions,
|
|
30
33
|
} from './core.js';
|
|
31
34
|
|
|
32
35
|
const connection = createConnection(ProposedFeatures.all);
|
|
@@ -187,6 +190,13 @@ connection.onCodeAction((params) => {
|
|
|
187
190
|
}
|
|
188
191
|
}
|
|
189
192
|
actions.push(...getCreateKeyActions(diagnostics, getProjectContext(params.textDocument.uri)));
|
|
193
|
+
if (clientSupportsDocumentChanges && doc && !isLocaleFileUri(doc.uri)) {
|
|
194
|
+
// A refactor failure must not discard existing quick fixes or its sibling action.
|
|
195
|
+
try { actions.push(...getExtractActions(doc, params, diagnostics)); }
|
|
196
|
+
catch (error) { connection.console.error(`Extract failed for ${doc.uri}: ${error.message}`); }
|
|
197
|
+
try { actions.push(...getInlineActions(doc, params)); }
|
|
198
|
+
catch (error) { connection.console.error(`Inline failed for ${doc.uri}: ${error.message}`); }
|
|
199
|
+
}
|
|
190
200
|
return actions;
|
|
191
201
|
});
|
|
192
202
|
|
|
@@ -220,21 +230,63 @@ function getCreateKeyActions(diagnostics, context) {
|
|
|
220
230
|
return actions;
|
|
221
231
|
}
|
|
222
232
|
|
|
233
|
+
function templateLanguageOf(doc) {
|
|
234
|
+
if (/\.vue$/i.test(doc.uri) || doc.languageId === 'vue') return 'vue';
|
|
235
|
+
return /\.html?$/i.test(doc.uri) || doc.languageId === 'html' ? 'html' : undefined;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Parser dialect for Inline in non-template documents; the extension wins over languageId.
|
|
239
|
+
function sourceLanguageOf(doc) {
|
|
240
|
+
if (/\.tsx$/i.test(doc.uri)) return 'typescriptreact';
|
|
241
|
+
if (/\.[cm]?ts$/i.test(doc.uri)) return 'typescript';
|
|
242
|
+
if (/\.[cm]?jsx?$/i.test(doc.uri)) return 'javascript';
|
|
243
|
+
if (doc.languageId === 'typescriptreact' || doc.languageId === 'tsx') return 'typescriptreact';
|
|
244
|
+
return doc.languageId === 'typescript' ? 'typescript' : 'javascript';
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function getExtractActions(doc, params, diagnostics) {
|
|
248
|
+
const language = templateLanguageOf(doc);
|
|
249
|
+
if (!language) return [];
|
|
250
|
+
const context = getProjectContext(doc.uri);
|
|
251
|
+
const { locales, localeTexts } = loadLocalesForContext(context);
|
|
252
|
+
const locale = resolvePreferredLocale(locales, context.config.defaultLocale);
|
|
253
|
+
if (!locale) return [];
|
|
254
|
+
return getExtractCodeActions(doc.getText(), language, fileURLToPath(doc.uri), params.range, diagnostics, locale.flat).flatMap((action) => {
|
|
255
|
+
const plan = planKeyInsertion(action.key, locale, locales, localeTexts, action.value);
|
|
256
|
+
if (!plan || (plan.create && !clientSupportsCreateFile)) return [];
|
|
257
|
+
const documentChanges = [];
|
|
258
|
+
if (plan.create) documentChanges.push(CreateFile.create(plan.uri));
|
|
259
|
+
documentChanges.push(TextDocumentEdit.create({ uri: plan.uri, version: plan.version }, [minimalTextEdit(plan.text, plan.newText)]));
|
|
260
|
+
documentChanges.push(TextDocumentEdit.create({ uri: doc.uri, version: doc.version }, [action.edit]));
|
|
261
|
+
return [{ title: action.title, kind: CodeActionKind.QuickFix, diagnostics: [action.diagnostic], edit: { documentChanges } }];
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function getInlineActions(doc, params) {
|
|
266
|
+
const { locales } = loadLocalesForContext(getProjectContext(doc.uri));
|
|
267
|
+
const language = templateLanguageOf(doc) || sourceLanguageOf(doc);
|
|
268
|
+
return getInlineCodeActions(doc.getText(), language, params.range, locales).map((action) => ({
|
|
269
|
+
title: action.title,
|
|
270
|
+
kind: CodeActionKind.RefactorInline,
|
|
271
|
+
edit: { documentChanges: [TextDocumentEdit.create({ uri: doc.uri, version: doc.version }, [action.edit])] },
|
|
272
|
+
}));
|
|
273
|
+
}
|
|
274
|
+
|
|
223
275
|
// Namespace routing follows loaded file stems. Index-owned prefixes and
|
|
224
276
|
// overlapping layouts are not guessed; ambiguous targets disable the action.
|
|
225
|
-
function planKeyInsertion(key, locale, locales, localeTexts) {
|
|
277
|
+
function planKeyInsertion(key, locale, locales, localeTexts, value = '') {
|
|
226
278
|
if (!locale) return undefined;
|
|
227
279
|
if (!locale.directories?.length) {
|
|
228
280
|
const files = locale.files || [];
|
|
229
281
|
return files.length === 1 && /\.json$/i.test(files[0])
|
|
230
|
-
? insertInto(files[0], key) : undefined;
|
|
282
|
+
? insertInto(files[0], key, value) : undefined;
|
|
231
283
|
}
|
|
232
284
|
if (locale.directories.length !== 1 || locale.topLevelFiles?.length) return undefined;
|
|
233
285
|
const directory = locale.directories[0];
|
|
234
|
-
const targets =
|
|
235
|
-
const namespaceFiles = [...targets.keys()].filter((
|
|
286
|
+
const targets = filesByPrefix(locale);
|
|
287
|
+
const namespaceFiles = [...targets.keys()].filter((prefix) => prefix && key.startsWith(prefix + '.'));
|
|
236
288
|
if (namespaceFiles.length > 1 || targets.has(key)) return undefined;
|
|
237
|
-
const indexFiles = targets.get('
|
|
289
|
+
const indexFiles = targets.get('') || [];
|
|
238
290
|
const indexFile = indexFiles.length === 1 && /\.json$/i.test(indexFiles[0]) ? indexFiles[0] : undefined;
|
|
239
291
|
let indexOwnsPrefix = false;
|
|
240
292
|
if (indexFiles.length) {
|
|
@@ -252,38 +304,45 @@ function planKeyInsertion(key, locale, locales, localeTexts) {
|
|
|
252
304
|
const stem = namespaceFiles[0];
|
|
253
305
|
const files = targets.get(stem);
|
|
254
306
|
return files.length === 1 && /\.json$/i.test(files[0])
|
|
255
|
-
? insertInto(files[0], key.slice(stem.length + 1)) : undefined;
|
|
307
|
+
? insertInto(files[0], key.slice(stem.length + 1), value) : undefined;
|
|
256
308
|
}
|
|
257
|
-
if (indexOwnsPrefix) return insertInto(indexFile, key);
|
|
309
|
+
if (indexOwnsPrefix) return insertInto(indexFile, key, value);
|
|
258
310
|
|
|
259
311
|
const siblingStems = new Map();
|
|
260
312
|
for (const sibling of Object.values(locales)) {
|
|
261
313
|
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)
|
|
314
|
+
for (const [stem, files] of filesByPrefix(sibling)) {
|
|
315
|
+
if (!stem || !key.startsWith(stem + '.')) continue;
|
|
316
|
+
siblingStems.set(stem, (siblingStems.get(stem) || []).concat(files.map((file) => ({
|
|
317
|
+
file,
|
|
318
|
+
relativePath: path.relative(sibling.directories[0], file),
|
|
319
|
+
}))));
|
|
265
320
|
}
|
|
266
321
|
}
|
|
267
322
|
if (siblingStems.size > 1) return undefined;
|
|
268
323
|
if (siblingStems.size === 1) {
|
|
269
|
-
const [stem,
|
|
270
|
-
if (
|
|
271
|
-
|
|
324
|
+
const [stem, targets] = siblingStems.entries().next().value;
|
|
325
|
+
if (targets.some(({ file }) => !/\.json$/i.test(file))) return undefined;
|
|
326
|
+
const relativePaths = new Set(targets.map(({ relativePath }) => relativePath));
|
|
327
|
+
if (relativePaths.size !== 1) return undefined;
|
|
328
|
+
const targetPath = path.join(directory, [...relativePaths][0]);
|
|
329
|
+
return fs.existsSync(path.dirname(targetPath))
|
|
330
|
+
? insertOrCreate(targetPath, key.slice(stem.length + 1), value) : undefined;
|
|
272
331
|
}
|
|
273
|
-
return indexFile ? insertInto(indexFile, key) : undefined;
|
|
332
|
+
return indexFile ? insertInto(indexFile, key, value) : undefined;
|
|
274
333
|
}
|
|
275
334
|
|
|
276
|
-
function
|
|
277
|
-
const
|
|
335
|
+
function filesByPrefix(locale) {
|
|
336
|
+
const prefixes = new Map();
|
|
278
337
|
for (const file of locale.files || []) {
|
|
279
|
-
const
|
|
280
|
-
if (!
|
|
281
|
-
|
|
338
|
+
const prefix = locale.filePrefixes[file];
|
|
339
|
+
if (!prefixes.has(prefix)) prefixes.set(prefix, []);
|
|
340
|
+
prefixes.get(prefix).push(file);
|
|
282
341
|
}
|
|
283
|
-
return
|
|
342
|
+
return prefixes;
|
|
284
343
|
}
|
|
285
344
|
|
|
286
|
-
function insertInto(filePath, lookupKey) {
|
|
345
|
+
function insertInto(filePath, lookupKey, value = '') {
|
|
287
346
|
if (!lookupKey) return undefined;
|
|
288
347
|
const text = openDocumentText(filePath) ?? readFileSafe(filePath);
|
|
289
348
|
if (text === undefined) return undefined;
|
|
@@ -291,7 +350,7 @@ function insertInto(filePath, lookupKey) {
|
|
|
291
350
|
// an empty object (matching its line-ending style) so the fix still lands
|
|
292
351
|
// instead of silently disappearing.
|
|
293
352
|
const source = text.trim() ? text : (text.includes('\r\n') ? '{}\r\n' : '{}\n');
|
|
294
|
-
const newText = insertNestedJsonKey(source, lookupKey,
|
|
353
|
+
const newText = insertNestedJsonKey(source, lookupKey, value);
|
|
295
354
|
if (newText === undefined) return undefined;
|
|
296
355
|
return toEditPlan(filePath, text, newText, false);
|
|
297
356
|
}
|
|
@@ -299,17 +358,17 @@ function insertInto(filePath, lookupKey) {
|
|
|
299
358
|
// Prefer an in-place insert when the file exists or is open as a buffer (the
|
|
300
359
|
// file may have been deleted on disk while its document stays alive); fall
|
|
301
360
|
// back to a CreateFile only when there is truly nothing to edit.
|
|
302
|
-
function insertOrCreate(filePath, lookupKey) {
|
|
303
|
-
if (fs.existsSync(filePath) || openDocument(filePath)) return insertInto(filePath, lookupKey);
|
|
304
|
-
return createIn(filePath, lookupKey);
|
|
361
|
+
function insertOrCreate(filePath, lookupKey, value = '') {
|
|
362
|
+
if (fs.existsSync(filePath) || openDocument(filePath)) return insertInto(filePath, lookupKey, value);
|
|
363
|
+
return createIn(filePath, lookupKey, value);
|
|
305
364
|
}
|
|
306
365
|
|
|
307
366
|
// Create a missing namespace JSON file: only ever proposed with a CreateFile
|
|
308
367
|
// operation ahead of the text edit, and only when the file truly does not
|
|
309
368
|
// exist — never as a fallback guess.
|
|
310
|
-
function createIn(filePath, lookupKey) {
|
|
369
|
+
function createIn(filePath, lookupKey, value = '') {
|
|
311
370
|
if (!lookupKey || fs.existsSync(filePath) || openDocument(filePath)) return undefined;
|
|
312
|
-
const newText = insertNestedJsonKey('{}\n', lookupKey,
|
|
371
|
+
const newText = insertNestedJsonKey('{}\n', lookupKey, value);
|
|
313
372
|
if (newText === undefined) return undefined;
|
|
314
373
|
return toEditPlan(filePath, '', newText, true);
|
|
315
374
|
}
|
|
@@ -370,7 +429,7 @@ function watchLocalePaths() {
|
|
|
370
429
|
.map((dir) => path.join(context.root, dir))
|
|
371
430
|
.filter((dir) => fs.existsSync(dir));
|
|
372
431
|
const loaded = loadLocalesForContext(context);
|
|
373
|
-
for (const watchPath of collectLocaleWatchPaths(loaded.locales, configuredDirs)) nextPaths.add(watchPath);
|
|
432
|
+
for (const watchPath of collectLocaleWatchPaths(loaded.locales, [...configuredDirs, ...loaded.watchDirectories])) nextPaths.add(watchPath);
|
|
374
433
|
}
|
|
375
434
|
|
|
376
435
|
for (const watchedPath of watchedLocalePaths) {
|
|
@@ -448,8 +507,7 @@ function normalizeIncomingSettings(value) {
|
|
|
448
507
|
function validate(document) {
|
|
449
508
|
const context = getProjectContext(document.uri);
|
|
450
509
|
const { locales } = loadLocalesForContext(context);
|
|
451
|
-
const templateLanguage =
|
|
452
|
-
: /\.html?$/i.test(document.uri) || document.languageId === 'html' ? 'html' : undefined;
|
|
510
|
+
const templateLanguage = templateLanguageOf(document);
|
|
453
511
|
if ((isSourceFile(document.uri) || templateLanguage) && !isLocaleFileUri(document.uri)) {
|
|
454
512
|
const text = document.getText();
|
|
455
513
|
const diagnostics = [
|
|
@@ -480,10 +538,7 @@ function localeDirOfFile(filePath) {
|
|
|
480
538
|
const absDir = normalizeFsPathLocal(path.resolve(contextRoot, dir));
|
|
481
539
|
if (!isPathInsidePath(normalized, absDir) || normalized === absDir) continue;
|
|
482
540
|
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 };
|
|
541
|
+
return { context, dir: absDir, relative };
|
|
487
542
|
}
|
|
488
543
|
}
|
|
489
544
|
return undefined;
|
|
@@ -646,35 +701,52 @@ function loadLocalesForContext(context) {
|
|
|
646
701
|
|
|
647
702
|
const locales = {};
|
|
648
703
|
const localeTexts = {};
|
|
649
|
-
|
|
650
|
-
|
|
704
|
+
const watchDirectories = new Set();
|
|
705
|
+
const localeRoots = context.config.localeDirs.map((dir) => path.resolve(context.root, dir));
|
|
706
|
+
for (const fullDir of localeRoots) {
|
|
651
707
|
if (!fs.existsSync(fullDir)) continue;
|
|
652
708
|
loadLocaleFiles(fullDir, locales, localeTexts);
|
|
653
709
|
for (const entry of fs.readdirSync(fullDir, { withFileTypes: true })) {
|
|
654
|
-
if (entry.isDirectory()) loadLocaleDirectory(path.join(fullDir, entry.name), entry.name, locales, localeTexts);
|
|
710
|
+
if (entry.isDirectory()) loadLocaleDirectory(path.join(fullDir, entry.name), entry.name, locales, localeTexts, localeRoots, watchDirectories);
|
|
655
711
|
}
|
|
656
712
|
}
|
|
657
|
-
const loaded = { locales, localeTexts };
|
|
713
|
+
const loaded = { locales, localeTexts, watchDirectories: [...watchDirectories] };
|
|
658
714
|
localeCacheByContext.set(cacheKey, loaded);
|
|
659
715
|
return loaded;
|
|
660
716
|
}
|
|
661
717
|
|
|
662
|
-
function loadLocaleDirectory(localeDir, localeName, next, localeTexts) {
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
718
|
+
function loadLocaleDirectory(localeDir, localeName, next, localeTexts, localeRoots, watchDirectories, rootDir = localeDir) {
|
|
719
|
+
// An explicit locale root owns its subtree, independently of config order.
|
|
720
|
+
if (localeRoots.includes(localeDir)) return;
|
|
721
|
+
// Fileless directories still need watching, but must not create phantom locales.
|
|
722
|
+
watchDirectories.add(localeDir);
|
|
723
|
+
for (const entry of fs.readdirSync(localeDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
724
|
+
const file = entry.name;
|
|
667
725
|
const fullPath = path.join(localeDir, file);
|
|
668
|
-
if (
|
|
726
|
+
if (entry.isDirectory()) {
|
|
727
|
+
loadLocaleDirectory(fullPath, localeName, next, localeTexts, localeRoots, watchDirectories, rootDir);
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
669
730
|
if (!LOCALE_FILE_RE.test(file)) continue;
|
|
670
|
-
if (
|
|
731
|
+
if (entry.isSymbolicLink() && fs.statSync(fullPath).isDirectory()) continue;
|
|
732
|
+
next[localeName] ||= { path: rootDir, flat: {}, keyFiles: {}, files: [] };
|
|
733
|
+
const locale = next[localeName];
|
|
734
|
+
locale.directories ||= [];
|
|
735
|
+
if (!locale.directories.includes(rootDir)) locale.directories.push(rootDir);
|
|
736
|
+
locale.filePrefixes ||= {};
|
|
737
|
+
const relativeFile = path.relative(rootDir, fullPath);
|
|
738
|
+
if (!locale.files.includes(fullPath)) locale.files.push(fullPath);
|
|
739
|
+
locale.filePrefixes[fullPath] = localeFilePrefix(relativeFile);
|
|
671
740
|
try {
|
|
672
741
|
const text = localeText(fullPath);
|
|
673
742
|
if (text === undefined) continue;
|
|
674
743
|
localeTexts[fullPath] = text;
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
744
|
+
const flat = prefixByFile(relativeFile, file.endsWith('.json')
|
|
745
|
+
? flattenLocale(JSON.parse(text)) : parseTsLocaleModule(text, file));
|
|
746
|
+
for (const [key, value] of Object.entries(flat)) {
|
|
747
|
+
locale.flat[key] = value;
|
|
748
|
+
locale.keyFiles[key] = fullPath;
|
|
749
|
+
}
|
|
678
750
|
} catch (error) { connection.console.warn('Failed to load locale file ' + fullPath + ': ' + error.message); }
|
|
679
751
|
}
|
|
680
752
|
}
|
|
@@ -698,7 +770,9 @@ function loadLocaleFiles(fullDir, next, localeTexts) {
|
|
|
698
770
|
const isModule = /\.[cm]?[tj]s$/i.test(file);
|
|
699
771
|
if (!isJson && !isModule) continue;
|
|
700
772
|
const name = file.replace(/\.(json|[cm]?[tj]s)$/i, '');
|
|
701
|
-
next[name] ||= { path: fullPath, flat: {}, files: [], topLevelFiles: [] };
|
|
773
|
+
next[name] ||= { path: fullPath, flat: {}, keyFiles: {}, files: [], topLevelFiles: [] };
|
|
774
|
+
next[name].filePrefixes ||= {};
|
|
775
|
+
next[name].filePrefixes[fullPath] = '';
|
|
702
776
|
next[name].topLevelFiles ||= [];
|
|
703
777
|
if (!next[name].files.includes(fullPath)) next[name].files.push(fullPath);
|
|
704
778
|
if (!next[name].topLevelFiles.includes(fullPath)) next[name].topLevelFiles.push(fullPath);
|
|
@@ -706,15 +780,25 @@ function loadLocaleFiles(fullDir, next, localeTexts) {
|
|
|
706
780
|
if (text === undefined) continue;
|
|
707
781
|
localeTexts[fullPath] = text;
|
|
708
782
|
const flat = isJson ? flattenLocale(JSON.parse(text)) : parseTsLocaleModule(text, 'index.ts');
|
|
709
|
-
|
|
783
|
+
for (const [key, value] of Object.entries(flat)) {
|
|
784
|
+
next[name].flat[key] = value;
|
|
785
|
+
next[name].keyFiles[key] = fullPath;
|
|
786
|
+
}
|
|
710
787
|
} catch (error) { connection.console.warn('Failed to load locale file ' + fullPath + ': ' + error.message); }
|
|
711
788
|
}
|
|
712
789
|
}
|
|
713
790
|
|
|
714
791
|
function prefixByFile(file, flat) {
|
|
715
|
-
const
|
|
716
|
-
if (
|
|
717
|
-
return Object.fromEntries(Object.entries(flat).map(([key, value]) => [
|
|
792
|
+
const prefix = localeFilePrefix(file);
|
|
793
|
+
if (!prefix) return flat;
|
|
794
|
+
return Object.fromEntries(Object.entries(flat).map(([key, value]) => [prefix + '.' + key, value]));
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function localeFilePrefix(file) {
|
|
798
|
+
const segments = file.replace(/\\/g, '/').split('/');
|
|
799
|
+
const stem = segments.pop().replace(/\.[^.]+$/, '');
|
|
800
|
+
if (stem !== 'index') segments.push(stem);
|
|
801
|
+
return segments.join('.');
|
|
718
802
|
}
|
|
719
803
|
|
|
720
804
|
documents.listen(connection);
|