i18n-lens-language-server 0.14.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 CHANGED
@@ -10,6 +10,7 @@ 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
@@ -106,9 +107,10 @@ The natural-language heuristics are inspired by i18n Ally's raw-string detection
106
107
 
107
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.
108
109
 
109
- 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.
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.
110
111
 
111
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
+
112
114
  ## Creating missing keys safely
113
115
 
114
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.
@@ -121,6 +123,53 @@ The missing-key quick fix supports a single JSON file per locale and directory l
121
123
 
122
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.
123
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
+
124
173
  ## Project configuration
125
174
 
126
175
  I18n Lens supports two configuration sources:
@@ -190,7 +239,7 @@ Supported options:
190
239
 
191
240
  | Option | Type | Default | Description |
192
241
  |---|---|---|---|
193
- | `defaultLocale` | string | `zh-CN` | Locale used for completion details and inline translation hints. Supports exact names such as `en-US`, case-insensitive matching, `_`/`-` normalization, and language aliases such as `en` matching `en-US`. |
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`. |
194
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. |
195
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. |
196
245
  | `inlayHints.maxLength` | number | `24` | Maximum inline hint label length before truncation. Hover still shows the full text. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "i18n-lens-language-server",
3
- "version": "0.14.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 (!isLikelyUserFacingText(value)) return;
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 the sorted ranges that are
307
- // NOT real code — comments, plain strings, template-literal raw text, HTML
308
- // comments, and ordinary (non-directive) attribute values. Vue directive
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 inside template literals stay transparent so real
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) { ranges.push({ start: f.start, end: i + 1 }); stack.pop(); }
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 !== undefined) ranges.push({ start: f.start, end: text.length });
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
- return ranges;
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
- if (isOpaque(opaque, match.index)) continue;
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
 
@@ -823,6 +851,511 @@ export function getParamCodeActions(text, diagnostics = []) {
823
851
  });
824
852
  }
825
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 &lt; 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, '&#123;').replace(/\}/g, '&#125;'));
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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, '&#13;');
1339
+ }
1340
+
1341
+ function escapeHtmlAttribute(value, quote) {
1342
+ const escaped = escapeHtml(value);
1343
+ return quote === "'" ? escaped.replace(/'/g, '&#39;') : 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
+
826
1359
  function sameRange(a, b) {
827
1360
  return a?.start?.line === b?.start?.line
828
1361
  && a?.start?.character === b?.start?.character
@@ -931,7 +1464,7 @@ export function localeKeyAtPosition(locale, filePath, text, position) {
931
1464
 
932
1465
  export function getCompletionPrefix(text, position) {
933
1466
  const before = text.slice(0, positionToOffset(text, position));
934
- const opaque = computeOpaqueRanges(before);
1467
+ const { ranges: opaque } = computeOpaqueRanges(before);
935
1468
  const patterns = [
936
1469
  /(?:\x24tc?|\btc?|\bi18n\.tc?)\s*\(\s*['"]([A-Za-z0-9_.:-]*)$/,
937
1470
  /\bformatMessage\s*\(\s*\{[^}]*?\bid\s*:\s*['"]([A-Za-z0-9_.:-]*)$/,
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,14 +230,56 @@ function getCreateKeyActions(diagnostics, context) {
220
230
  return actions;
221
231
  }
222
232
 
223
- // Namespace routing follows loaded file prefixes. Index-owned prefixes and
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
+
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];
@@ -252,9 +304,9 @@ 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)) {
@@ -275,9 +327,9 @@ function planKeyInsertion(key, locale, locales, localeTexts) {
275
327
  if (relativePaths.size !== 1) return undefined;
276
328
  const targetPath = path.join(directory, [...relativePaths][0]);
277
329
  return fs.existsSync(path.dirname(targetPath))
278
- ? insertOrCreate(targetPath, key.slice(stem.length + 1)) : undefined;
330
+ ? insertOrCreate(targetPath, key.slice(stem.length + 1), value) : undefined;
279
331
  }
280
- return indexFile ? insertInto(indexFile, key) : undefined;
332
+ return indexFile ? insertInto(indexFile, key, value) : undefined;
281
333
  }
282
334
 
283
335
  function filesByPrefix(locale) {
@@ -290,7 +342,7 @@ function filesByPrefix(locale) {
290
342
  return prefixes;
291
343
  }
292
344
 
293
- function insertInto(filePath, lookupKey) {
345
+ function insertInto(filePath, lookupKey, value = '') {
294
346
  if (!lookupKey) return undefined;
295
347
  const text = openDocumentText(filePath) ?? readFileSafe(filePath);
296
348
  if (text === undefined) return undefined;
@@ -298,7 +350,7 @@ function insertInto(filePath, lookupKey) {
298
350
  // an empty object (matching its line-ending style) so the fix still lands
299
351
  // instead of silently disappearing.
300
352
  const source = text.trim() ? text : (text.includes('\r\n') ? '{}\r\n' : '{}\n');
301
- const newText = insertNestedJsonKey(source, lookupKey, '');
353
+ const newText = insertNestedJsonKey(source, lookupKey, value);
302
354
  if (newText === undefined) return undefined;
303
355
  return toEditPlan(filePath, text, newText, false);
304
356
  }
@@ -306,17 +358,17 @@ function insertInto(filePath, lookupKey) {
306
358
  // Prefer an in-place insert when the file exists or is open as a buffer (the
307
359
  // file may have been deleted on disk while its document stays alive); fall
308
360
  // back to a CreateFile only when there is truly nothing to edit.
309
- function insertOrCreate(filePath, lookupKey) {
310
- if (fs.existsSync(filePath) || openDocument(filePath)) return insertInto(filePath, lookupKey);
311
- 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);
312
364
  }
313
365
 
314
366
  // Create a missing namespace JSON file: only ever proposed with a CreateFile
315
367
  // operation ahead of the text edit, and only when the file truly does not
316
368
  // exist — never as a fallback guess.
317
- function createIn(filePath, lookupKey) {
369
+ function createIn(filePath, lookupKey, value = '') {
318
370
  if (!lookupKey || fs.existsSync(filePath) || openDocument(filePath)) return undefined;
319
- const newText = insertNestedJsonKey('{}\n', lookupKey, '');
371
+ const newText = insertNestedJsonKey('{}\n', lookupKey, value);
320
372
  if (newText === undefined) return undefined;
321
373
  return toEditPlan(filePath, '', newText, true);
322
374
  }
@@ -455,8 +507,7 @@ function normalizeIncomingSettings(value) {
455
507
  function validate(document) {
456
508
  const context = getProjectContext(document.uri);
457
509
  const { locales } = loadLocalesForContext(context);
458
- const templateLanguage = /\.vue$/i.test(document.uri) ? 'vue'
459
- : /\.html?$/i.test(document.uri) || document.languageId === 'html' ? 'html' : undefined;
510
+ const templateLanguage = templateLanguageOf(document);
460
511
  if ((isSourceFile(document.uri) || templateLanguage) && !isLocaleFileUri(document.uri)) {
461
512
  const text = document.getText();
462
513
  const diagnostics = [