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 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
- 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.
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` | 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`. |
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.13.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
 
@@ -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
- const files = Array.isArray(locale.files) && locale.files.length
646
- ? locale.files
647
- : (locale.path ? [locale.path] : []);
648
- const candidates = files
649
- .map((filePath) => ({ filePath, lookupKey: lookupKeyForLocaleFile(locale, filePath, key) }))
650
- .sort((a, b) => Number(a.lookupKey === key) - Number(b.lookupKey === key));
651
-
652
- for (const candidate of candidates) {
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 &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
+
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, stem === 'index' ? '' : stem));
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 = filesByStem(locale);
235
- const namespaceFiles = [...targets.keys()].filter((stem) => stem !== 'index' && key.startsWith(stem + '.'));
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('index') || [];
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 filesByStem(sibling)) {
263
- if (stem === 'index' || !key.startsWith(stem + '.')) continue;
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, files] = siblingStems.entries().next().value;
270
- if (files.some((file) => !/\.json$/i.test(file))) return undefined;
271
- return insertOrCreate(path.join(directory, stem + '.json'), key.slice(stem.length + 1));
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 filesByStem(locale) {
277
- const stems = new Map();
335
+ function filesByPrefix(locale) {
336
+ const prefixes = new Map();
278
337
  for (const file of locale.files || []) {
279
- const stem = path.basename(file, path.extname(file));
280
- if (!stems.has(stem)) stems.set(stem, []);
281
- stems.get(stem).push(file);
338
+ const prefix = locale.filePrefixes[file];
339
+ if (!prefixes.has(prefix)) prefixes.set(prefix, []);
340
+ prefixes.get(prefix).push(file);
282
341
  }
283
- return stems;
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 = /\.vue$/i.test(document.uri) ? 'vue'
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
- const depth = relative.split('/').length - 1;
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
- for (const dir of context.config.localeDirs) {
650
- const fullDir = path.join(context.root, dir);
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
- next[localeName] ||= { path: localeDir, flat: {}, files: [] };
664
- next[localeName].directories ||= [];
665
- if (!next[localeName].directories.includes(localeDir)) next[localeName].directories.push(localeDir);
666
- for (const file of fs.readdirSync(localeDir).sort()) {
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 (fs.statSync(fullPath).isDirectory()) continue;
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 (!next[localeName].files.includes(fullPath)) next[localeName].files.push(fullPath);
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
- if (file.endsWith('.json')) Object.assign(next[localeName].flat, prefixByFile(file, flattenLocale(JSON.parse(text))));
676
- else if (/\.[cm]?[tj]s$/i.test(file)) Object.assign(next[localeName].flat, parseTsLocaleModule(text, file));
677
- else continue;
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
- Object.assign(next[name].flat, flat);
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 stem = path.basename(file, path.extname(file));
716
- if (stem === 'index') return flat;
717
- return Object.fromEntries(Object.entries(flat).map(([key, value]) => [stem + '.' + 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);