@ai-i18n/eslint-plugin 1.0.0-alpha.2 → 1.0.0-alpha.21
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 +365 -19
- package/dist/index.d.ts +20 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1594 -134
- package/dist/index.js.map +1 -1
- package/package.json +25 -8
package/dist/index.js
CHANGED
|
@@ -1,8 +1,204 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
+
import { AI_I18N_VIRTUAL_MODULE_ID, Analyzer, diagnosticMessage, extractMessages, findInvalidDefineI18nMessagesReferences, findTranslationCalls, findUnboundCalls, validateRecommendedUsage } from "@ai-i18n/analyzer";
|
|
2
3
|
import fs from "node:fs";
|
|
3
4
|
import path from "node:path";
|
|
4
|
-
import {
|
|
5
|
+
import { createFilesMatcher, createPathsMatcher, parseTsconfig } from "get-tsconfig";
|
|
6
|
+
import picomatch from "picomatch";
|
|
5
7
|
import { analyzeVueSource } from "@ai-i18n/analyzer/vue";
|
|
8
|
+
import { defaultTreeAdapter, parse, parseFragment } from "parse5";
|
|
9
|
+
//#region src/auto-imports.ts
|
|
10
|
+
const RUNTIME_AUTO_IMPORTS = [
|
|
11
|
+
"t",
|
|
12
|
+
"setLang",
|
|
13
|
+
"getLang",
|
|
14
|
+
"getLangs",
|
|
15
|
+
"getLangLoadState",
|
|
16
|
+
"subscribe"
|
|
17
|
+
];
|
|
18
|
+
const VUE_AUTO_IMPORTS = [
|
|
19
|
+
"useI18n",
|
|
20
|
+
...RUNTIME_AUTO_IMPORTS,
|
|
21
|
+
"tRef",
|
|
22
|
+
"i18nComputed",
|
|
23
|
+
"tComputed"
|
|
24
|
+
];
|
|
25
|
+
const REACT_AUTO_IMPORTS = ["useI18n", ...RUNTIME_AUTO_IMPORTS];
|
|
26
|
+
const ALL_AUTO_IMPORT_APIS = [...VUE_AUTO_IMPORTS];
|
|
27
|
+
//#endregion
|
|
28
|
+
//#region src/tsconfig-projects.ts
|
|
29
|
+
const PROJECT_CACHE_TTL_MS = 250;
|
|
30
|
+
const MAX_PROJECT_CACHE_ENTRIES = 1e3;
|
|
31
|
+
const READ_FILE_CACHE_PREFIX = "readFileSync:";
|
|
32
|
+
const READ_FILE_CACHE_SUFFIX = ":utf8";
|
|
33
|
+
const projectGraphCache = /* @__PURE__ */ new Map();
|
|
34
|
+
const discoveryCache = /* @__PURE__ */ new Map();
|
|
35
|
+
function createTsconfigResolver(tsconfigPath) {
|
|
36
|
+
const explicitRoot = tsconfigPath ? path.normalize(path.resolve(tsconfigPath)) : void 0;
|
|
37
|
+
return (specifier, importer) => {
|
|
38
|
+
const rootConfig = explicitRoot ?? findNearestProjectConfig(importer);
|
|
39
|
+
if (!rootConfig) return [];
|
|
40
|
+
return selectProject(loadProjectGraph(rootConfig), importer)?.pathsMatcher?.(specifier) ?? [];
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function findNearestProjectConfig(importer) {
|
|
44
|
+
let directory = path.dirname(path.resolve(importer));
|
|
45
|
+
const now = Date.now();
|
|
46
|
+
const cached = discoveryCache.get(directory);
|
|
47
|
+
if (cached && cached.expiresAt > now) return cached.configPath;
|
|
48
|
+
const visited = [];
|
|
49
|
+
for (;;) {
|
|
50
|
+
visited.push(directory);
|
|
51
|
+
for (const filename of ["tsconfig.json", "jsconfig.json"]) {
|
|
52
|
+
const candidate = path.join(directory, filename);
|
|
53
|
+
if (isFile$1(candidate)) {
|
|
54
|
+
cacheDiscovery(visited, candidate, now);
|
|
55
|
+
return candidate;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const parent = path.dirname(directory);
|
|
59
|
+
if (parent === directory) {
|
|
60
|
+
cacheDiscovery(visited, null, now);
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
directory = parent;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function cacheDiscovery(directories, configPath, now) {
|
|
67
|
+
if (discoveryCache.size >= MAX_PROJECT_CACHE_ENTRIES) discoveryCache.clear();
|
|
68
|
+
for (const directory of directories) discoveryCache.set(directory, {
|
|
69
|
+
expiresAt: now + PROJECT_CACHE_TTL_MS,
|
|
70
|
+
configPath
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
function loadProjectGraph(rootConfig) {
|
|
74
|
+
const cached = projectGraphCache.get(rootConfig);
|
|
75
|
+
if (cached && projectGraphIsFresh(cached)) return cached.projects;
|
|
76
|
+
const projects = [];
|
|
77
|
+
const stamps = /* @__PURE__ */ new Map();
|
|
78
|
+
visitProject(rootConfig, projects, stamps, /* @__PURE__ */ new Map(), /* @__PURE__ */ new Set());
|
|
79
|
+
if (projectGraphCache.size >= MAX_PROJECT_CACHE_ENTRIES) projectGraphCache.clear();
|
|
80
|
+
projectGraphCache.set(rootConfig, {
|
|
81
|
+
expiresAt: Date.now() + PROJECT_CACHE_TTL_MS,
|
|
82
|
+
projects,
|
|
83
|
+
stamps
|
|
84
|
+
});
|
|
85
|
+
return projects;
|
|
86
|
+
}
|
|
87
|
+
function visitProject(filename, projects, stamps, parsingCache, visited) {
|
|
88
|
+
filename = path.normalize(path.resolve(filename));
|
|
89
|
+
if (visited.has(filename)) return;
|
|
90
|
+
visited.add(filename);
|
|
91
|
+
stamps.set(filename, readFileStamp(filename));
|
|
92
|
+
let config;
|
|
93
|
+
try {
|
|
94
|
+
config = {
|
|
95
|
+
path: filename,
|
|
96
|
+
config: parseTsconfig(filename, parsingCache)
|
|
97
|
+
};
|
|
98
|
+
captureParsedConfigStamps(parsingCache, stamps);
|
|
99
|
+
} catch {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
for (const reference of config.config.references ?? []) {
|
|
103
|
+
const referenced = resolveReference(filename, reference.path);
|
|
104
|
+
if (referenced) visitProject(referenced, projects, stamps, parsingCache, visited);
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
projects.push({
|
|
108
|
+
config,
|
|
109
|
+
filesMatcher: createProjectFilesMatcher(config),
|
|
110
|
+
pathsMatcher: createCompatiblePathsMatcher(config)
|
|
111
|
+
});
|
|
112
|
+
} catch {}
|
|
113
|
+
}
|
|
114
|
+
function createProjectFilesMatcher(config) {
|
|
115
|
+
if (path.basename(config.path).toLowerCase() !== "jsconfig.json" || config.config.compilerOptions?.allowJs !== void 0) return createFilesMatcher(config);
|
|
116
|
+
return createFilesMatcher({
|
|
117
|
+
...config,
|
|
118
|
+
config: {
|
|
119
|
+
...config.config,
|
|
120
|
+
compilerOptions: {
|
|
121
|
+
...config.config.compilerOptions,
|
|
122
|
+
allowJs: true
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
function captureParsedConfigStamps(parsingCache, stamps) {
|
|
128
|
+
for (const key of parsingCache.keys()) {
|
|
129
|
+
if (!key.startsWith(READ_FILE_CACHE_PREFIX) || !key.endsWith(READ_FILE_CACHE_SUFFIX)) continue;
|
|
130
|
+
const filename = key.slice(13, -5);
|
|
131
|
+
stamps.set(filename, readFileStamp(filename));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function createCompatiblePathsMatcher(config) {
|
|
135
|
+
const compilerOptions = config.config.compilerOptions;
|
|
136
|
+
const paths = compilerOptions?.paths;
|
|
137
|
+
if (!paths || compilerOptions.baseUrl) return createPathsMatcher(config);
|
|
138
|
+
const compatiblePaths = Object.fromEntries(Object.entries(paths).map(([pattern, targets]) => [pattern, targets.map((target) => path.isAbsolute(target) || target.startsWith(".") ? target : `./${target}`)]));
|
|
139
|
+
return createPathsMatcher({
|
|
140
|
+
path: config.path,
|
|
141
|
+
config: {
|
|
142
|
+
...config.config,
|
|
143
|
+
compilerOptions: {
|
|
144
|
+
...compilerOptions,
|
|
145
|
+
paths: compatiblePaths
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
function resolveReference(ownerConfig, referencePath) {
|
|
151
|
+
const candidate = path.resolve(path.dirname(ownerConfig), referencePath);
|
|
152
|
+
return [
|
|
153
|
+
candidate,
|
|
154
|
+
candidate.endsWith(".json") ? candidate : `${candidate}.json`,
|
|
155
|
+
path.join(candidate, "tsconfig.json")
|
|
156
|
+
].find(isFile$1) ?? null;
|
|
157
|
+
}
|
|
158
|
+
function selectProject(projects, importer) {
|
|
159
|
+
const absoluteImporter = path.resolve(importer);
|
|
160
|
+
return projects.find((project) => projectMatchesImporter(project, absoluteImporter));
|
|
161
|
+
}
|
|
162
|
+
function projectMatchesImporter(project, importer) {
|
|
163
|
+
if (path.extname(importer) !== ".vue") return Boolean(project.filesMatcher(importer));
|
|
164
|
+
return matchesExplicitVueInput(project.config, importer);
|
|
165
|
+
}
|
|
166
|
+
function matchesExplicitVueInput({ config, path: configPath }, importer) {
|
|
167
|
+
const directory = path.dirname(configPath);
|
|
168
|
+
const relative = normalizePath(path.relative(directory, importer));
|
|
169
|
+
if (relative.startsWith("../")) return false;
|
|
170
|
+
if (config.files?.some((file) => path.resolve(directory, file) === path.resolve(importer))) return true;
|
|
171
|
+
if (config.exclude?.some((pattern) => matchesExclude(relative, pattern))) return false;
|
|
172
|
+
return Boolean(config.include?.some((pattern) => pattern.includes(".vue") && picomatch.isMatch(relative, normalizePath(pattern), { dot: true })));
|
|
173
|
+
}
|
|
174
|
+
function matchesExclude(relative, pattern) {
|
|
175
|
+
pattern = normalizePath(pattern).replace(/\/+$/, "");
|
|
176
|
+
return picomatch.isMatch(relative, pattern, { dot: true }) || picomatch.isMatch(relative, `${pattern}/**`, { dot: true });
|
|
177
|
+
}
|
|
178
|
+
function projectGraphIsFresh(cached) {
|
|
179
|
+
if (cached.expiresAt <= Date.now()) return false;
|
|
180
|
+
for (const [filename, stamp] of cached.stamps) if (readFileStamp(filename) !== stamp) return false;
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
function readFileStamp(filename) {
|
|
184
|
+
try {
|
|
185
|
+
const stat = fs.statSync(filename);
|
|
186
|
+
return stat.isFile() ? `${stat.mtimeMs}:${stat.ctimeMs}:${stat.size}` : null;
|
|
187
|
+
} catch {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
function isFile$1(filename) {
|
|
192
|
+
try {
|
|
193
|
+
return fs.statSync(filename).isFile();
|
|
194
|
+
} catch {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function normalizePath(filename) {
|
|
199
|
+
return filename.replaceAll("\\", "/");
|
|
200
|
+
}
|
|
201
|
+
//#endregion
|
|
6
202
|
//#region src/resolve-import.ts
|
|
7
203
|
const SOURCE_EXTENSIONS = [
|
|
8
204
|
"",
|
|
@@ -11,51 +207,69 @@ const SOURCE_EXTENSIONS = [
|
|
|
11
207
|
".js",
|
|
12
208
|
".jsx",
|
|
13
209
|
".mts",
|
|
14
|
-
".
|
|
15
|
-
".mjs",
|
|
16
|
-
".cjs"
|
|
210
|
+
".mjs"
|
|
17
211
|
];
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
for (const [pattern, targets] of Object.entries(config.compilerOptions?.paths ?? {})) {
|
|
26
|
-
const star = pattern.indexOf("*");
|
|
27
|
-
aliases.push({
|
|
28
|
-
prefix: star < 0 ? pattern : pattern.slice(0, star),
|
|
29
|
-
suffix: star < 0 ? "" : pattern.slice(star + 1),
|
|
30
|
-
targets
|
|
31
|
-
});
|
|
32
|
-
}
|
|
33
|
-
}
|
|
212
|
+
const DIRECTORY_STAMP_TTL_MS = 250;
|
|
213
|
+
const MAX_PROBE_CACHE_ENTRIES = 1e4;
|
|
214
|
+
const directoryStampCache = /* @__PURE__ */ new Map();
|
|
215
|
+
const probeCache = /* @__PURE__ */ new Map();
|
|
216
|
+
function createImportResolver(tsconfigPath, alias) {
|
|
217
|
+
const aliases = normalizeAliases(alias);
|
|
218
|
+
const resolveTsconfig = createTsconfigResolver(tsconfigPath);
|
|
34
219
|
return (specifier, importer) => {
|
|
35
220
|
if (specifier === "virtual:ai-i18n") return specifier;
|
|
221
|
+
const aliasCandidate = resolveAlias(specifier, aliases);
|
|
222
|
+
if (aliasCandidate) return probeSource(aliasCandidate);
|
|
36
223
|
if (specifier.startsWith(".")) return probeSource(path.resolve(path.dirname(importer), specifier));
|
|
37
|
-
for (const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
for (const target of alias.targets) {
|
|
41
|
-
const candidate = target.includes("*") ? target.replace("*", value) : target;
|
|
42
|
-
const resolved = probeSource(path.resolve(baseUrl, candidate));
|
|
43
|
-
if (resolved) return resolved;
|
|
44
|
-
}
|
|
224
|
+
for (const candidate of resolveTsconfig(specifier, importer)) {
|
|
225
|
+
const resolved = probeSource(candidate);
|
|
226
|
+
if (resolved) return resolved;
|
|
45
227
|
}
|
|
46
228
|
return null;
|
|
47
229
|
};
|
|
48
230
|
}
|
|
231
|
+
function normalizeAliases(alias) {
|
|
232
|
+
return Object.entries(alias ?? {}).map(([find, replacement]) => {
|
|
233
|
+
if (!find) throw new TypeError(diagnosticMessage("ai-i18n alias 的匹配键不能为空。", "The ai-i18n alias match key must not be empty."));
|
|
234
|
+
if (typeof replacement !== "string" || !path.isAbsolute(replacement)) throw new TypeError(diagnosticMessage(`ai-i18n alias "${find}" 的 replacement 必须是绝对路径。`, `The replacement for ai-i18n alias "${find}" must be an absolute path.`));
|
|
235
|
+
return {
|
|
236
|
+
find,
|
|
237
|
+
replacement
|
|
238
|
+
};
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
function resolveAlias(specifier, aliases) {
|
|
242
|
+
for (const { find, replacement } of aliases) if (specifier === find || specifier.startsWith(`${find}/`)) return path.normalize(`${replacement}${specifier.slice(find.length)}`);
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
49
245
|
function probeSource(candidate) {
|
|
246
|
+
candidate = path.normalize(candidate);
|
|
247
|
+
if (/\.(?:cjs|cts)$/i.test(candidate)) return null;
|
|
248
|
+
const cached = probeCache.get(candidate);
|
|
249
|
+
if (cached && (cached.resolved !== null || cached.expiresAt > Date.now()) && cached.parentDirectory === readDirectoryStamp(path.dirname(candidate)) && cached.candidateDirectory === readDirectoryStamp(candidate)) return cached.resolved;
|
|
250
|
+
let resolved = null;
|
|
50
251
|
for (const extension of SOURCE_EXTENSIONS) {
|
|
51
252
|
const file = `${candidate}${extension}`;
|
|
52
|
-
if (isFile(file))
|
|
253
|
+
if (isFile(file)) {
|
|
254
|
+
resolved = path.normalize(file);
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
53
257
|
}
|
|
54
|
-
for (const extension of SOURCE_EXTENSIONS.slice(1)) {
|
|
258
|
+
if (!resolved) for (const extension of SOURCE_EXTENSIONS.slice(1)) {
|
|
55
259
|
const file = path.join(candidate, `index${extension}`);
|
|
56
|
-
if (isFile(file))
|
|
260
|
+
if (isFile(file)) {
|
|
261
|
+
resolved = path.normalize(file);
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
57
264
|
}
|
|
58
|
-
|
|
265
|
+
if (probeCache.size >= MAX_PROBE_CACHE_ENTRIES) probeCache.clear();
|
|
266
|
+
probeCache.set(candidate, {
|
|
267
|
+
parentDirectory: readDirectoryStamp(path.dirname(candidate)),
|
|
268
|
+
candidateDirectory: readDirectoryStamp(candidate),
|
|
269
|
+
expiresAt: Date.now() + DIRECTORY_STAMP_TTL_MS,
|
|
270
|
+
resolved
|
|
271
|
+
});
|
|
272
|
+
return resolved;
|
|
59
273
|
}
|
|
60
274
|
function isFile(file) {
|
|
61
275
|
try {
|
|
@@ -64,79 +278,103 @@ function isFile(file) {
|
|
|
64
278
|
return false;
|
|
65
279
|
}
|
|
66
280
|
}
|
|
67
|
-
function
|
|
281
|
+
function readDirectoryStamp(directory) {
|
|
282
|
+
const now = Date.now();
|
|
283
|
+
const cached = directoryStampCache.get(directory);
|
|
284
|
+
if (cached && cached.expiresAt > now) return cached.value;
|
|
285
|
+
let value = null;
|
|
68
286
|
try {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
for (let index = 0; index < source.length; index += 1) {
|
|
79
|
-
const current = source[index];
|
|
80
|
-
const next = source[index + 1];
|
|
81
|
-
if (quote) {
|
|
82
|
-
result += current;
|
|
83
|
-
if (escaped) escaped = false;
|
|
84
|
-
else if (current === "\\") escaped = true;
|
|
85
|
-
else if (current === quote) quote = "";
|
|
86
|
-
continue;
|
|
87
|
-
}
|
|
88
|
-
if (current === "\"" || current === "'") {
|
|
89
|
-
quote = current;
|
|
90
|
-
result += current;
|
|
91
|
-
continue;
|
|
92
|
-
}
|
|
93
|
-
if (current === "/" && next === "/") {
|
|
94
|
-
while (index < source.length && source[index] !== "\n") index += 1;
|
|
95
|
-
result += "\n";
|
|
96
|
-
continue;
|
|
97
|
-
}
|
|
98
|
-
if (current === "/" && next === "*") {
|
|
99
|
-
index += 2;
|
|
100
|
-
while (index < source.length && !(source[index] === "*" && source[index + 1] === "/")) {
|
|
101
|
-
if (source[index] === "\n") result += "\n";
|
|
102
|
-
index += 1;
|
|
103
|
-
}
|
|
104
|
-
index += 1;
|
|
105
|
-
continue;
|
|
106
|
-
}
|
|
107
|
-
result += current;
|
|
108
|
-
}
|
|
109
|
-
return result.replace(/,\s*([}\]])/g, "$1");
|
|
287
|
+
const stat = fs.statSync(directory);
|
|
288
|
+
if (stat.isDirectory()) value = `${stat.mtimeMs}:${stat.ctimeMs}:${stat.size}`;
|
|
289
|
+
} catch {}
|
|
290
|
+
if (directoryStampCache.size >= MAX_PROBE_CACHE_ENTRIES) directoryStampCache.clear();
|
|
291
|
+
directoryStampCache.set(directory, {
|
|
292
|
+
expiresAt: now + DIRECTORY_STAMP_TTL_MS,
|
|
293
|
+
value
|
|
294
|
+
});
|
|
295
|
+
return value;
|
|
110
296
|
}
|
|
111
297
|
//#endregion
|
|
112
298
|
//#region src/analyze.ts
|
|
113
|
-
|
|
114
|
-
|
|
299
|
+
const POTENTIAL_TRANSLATION_RE = /virtual:ai-i18n|\b(?:t|tRef|tComputed|useI18n|defineI18nMessages)\b/;
|
|
300
|
+
function analyzeStaticSource(code, filename, tsconfigPath, lang, autoImport = false, maxStaticCandidates = Number.POSITIVE_INFINITY, alias) {
|
|
301
|
+
if (!hasPotentialTranslationCandidate(code)) return {
|
|
302
|
+
warnings: [],
|
|
303
|
+
translationCalls: [],
|
|
304
|
+
messages: []
|
|
305
|
+
};
|
|
306
|
+
const autoImports = normalizeAutoImports$1(autoImport);
|
|
307
|
+
const translationAutoImports = runtimeAutoImports(autoImports);
|
|
308
|
+
const resolve = createImportResolver(tsconfigPath, alias);
|
|
115
309
|
const analyzer = new Analyzer({ resolve });
|
|
116
|
-
analyzer.addFile(AI_I18N_VIRTUAL_MODULE_ID, "export function t(source) { return source }");
|
|
310
|
+
analyzer.addFile(AI_I18N_VIRTUAL_MODULE_ID, "export function t(source) { return source } export function tRef(source) { return source } export function tComputed(source) { return source }");
|
|
117
311
|
const entryPath = normalizeFilename(filename);
|
|
118
312
|
const entry = analyzer.addFile(entryPath, code, lang ? { lang } : void 0);
|
|
119
|
-
if (!hasTranslationCandidate(entry,
|
|
313
|
+
if (!hasTranslationCandidate(entry, autoImports)) return {
|
|
314
|
+
warnings: [],
|
|
315
|
+
translationCalls: [],
|
|
316
|
+
messages: []
|
|
317
|
+
};
|
|
120
318
|
loadDependencies(analyzer, entry, resolve);
|
|
121
319
|
analyzer.link();
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
320
|
+
const hooks = translationHooks(autoImports);
|
|
321
|
+
const extraction = extractMessages(entry, AI_I18N_VIRTUAL_MODULE_ID, hooks, translationAutoImports, maxStaticCandidates);
|
|
322
|
+
const recommended = validateRecommendedUsage(entry, AI_I18N_VIRTUAL_MODULE_ID, hooks, translationAutoImports);
|
|
323
|
+
return {
|
|
324
|
+
warnings: (recommended.length ? [
|
|
325
|
+
...extraction.warnings.filter((warning) => warning.code === "parse-error"),
|
|
326
|
+
...extraction.warnings.filter((warning) => warning.code === "static-candidate-limit"),
|
|
327
|
+
...recommended
|
|
328
|
+
] : extraction.warnings).map(({ code: warningCode, line, column, message }) => ({
|
|
329
|
+
code: warningCode,
|
|
330
|
+
line,
|
|
331
|
+
column,
|
|
332
|
+
message
|
|
333
|
+
})),
|
|
334
|
+
translationCalls: findTranslationCalls(entry, AI_I18N_VIRTUAL_MODULE_ID, hooks, translationAutoImports),
|
|
335
|
+
messages: extraction.messages
|
|
336
|
+
};
|
|
128
337
|
}
|
|
129
|
-
function
|
|
130
|
-
return
|
|
338
|
+
function hasPotentialTranslationCandidate(code) {
|
|
339
|
+
return POTENTIAL_TRANSLATION_RE.test(code);
|
|
340
|
+
}
|
|
341
|
+
function normalizeAutoImports$1(autoImport) {
|
|
342
|
+
if (typeof autoImport === "boolean") return {
|
|
343
|
+
t: autoImport,
|
|
344
|
+
tRef: false,
|
|
345
|
+
tComputed: false,
|
|
346
|
+
useI18n: autoImport
|
|
347
|
+
};
|
|
348
|
+
return {
|
|
349
|
+
t: autoImport?.includes("t") ?? false,
|
|
350
|
+
tRef: autoImport?.includes("tRef") ?? false,
|
|
351
|
+
tComputed: autoImport?.includes("tComputed") ?? false,
|
|
352
|
+
useI18n: autoImport?.includes("useI18n") ?? false
|
|
353
|
+
};
|
|
131
354
|
}
|
|
132
|
-
function
|
|
355
|
+
function hasTranslationCandidate(module, autoImports) {
|
|
356
|
+
const unbound = ["defineI18nMessages"];
|
|
357
|
+
if (autoImports.t) unbound.push("t");
|
|
358
|
+
if (autoImports.tRef) unbound.push("tRef");
|
|
359
|
+
if (autoImports.tComputed) unbound.push("tComputed");
|
|
360
|
+
if (autoImports.useI18n) unbound.push("useI18n");
|
|
361
|
+
return module.imports.some((item) => !item.typeOnly && (item.specifier === AI_I18N_VIRTUAL_MODULE_ID || item.name === "t" || item.name === "tRef" || item.name === "tComputed" || item.name === "useI18n")) || findInvalidDefineI18nMessagesReferences(module).length > 0 || findUnboundCalls(module, new Set(unbound)).length > 0;
|
|
362
|
+
}
|
|
363
|
+
function translationHooks(autoImports) {
|
|
133
364
|
return [{
|
|
134
365
|
module: AI_I18N_VIRTUAL_MODULE_ID,
|
|
135
366
|
hook: "useI18n",
|
|
136
367
|
property: "t",
|
|
137
|
-
autoImport
|
|
368
|
+
autoImport: autoImports.useI18n
|
|
138
369
|
}];
|
|
139
370
|
}
|
|
371
|
+
function runtimeAutoImports(autoImports) {
|
|
372
|
+
const names = /* @__PURE__ */ new Set();
|
|
373
|
+
if (autoImports.t) names.add("t");
|
|
374
|
+
if (autoImports.tRef) names.add("tRef");
|
|
375
|
+
if (autoImports.tComputed) names.add("tComputed");
|
|
376
|
+
return names;
|
|
377
|
+
}
|
|
140
378
|
function loadDependencies(analyzer, entry, resolve) {
|
|
141
379
|
const queue = [entry];
|
|
142
380
|
const visited = /* @__PURE__ */ new Set();
|
|
@@ -162,36 +400,1180 @@ function normalizeFilename(filename) {
|
|
|
162
400
|
}
|
|
163
401
|
//#endregion
|
|
164
402
|
//#region src/vue-sfc.ts
|
|
165
|
-
const
|
|
403
|
+
const packageRequire = createRequire(import.meta.url);
|
|
166
404
|
function createVueAnalysisSource(source, filename, parserServices) {
|
|
167
|
-
if (!parserServices.getDocumentFragment?.()) throw new Error("检查 .vue
|
|
405
|
+
if (!parserServices.getDocumentFragment?.()) throw new Error(diagnosticMessage("检查 .vue 文件需要配置 vue-eslint-parser。", "Configure vue-eslint-parser to lint .vue files."));
|
|
168
406
|
try {
|
|
169
|
-
return analyzeVueSource(source, filename,
|
|
407
|
+
return analyzeVueSource(source, filename, loadVueCompiler(filename));
|
|
170
408
|
} catch (error) {
|
|
171
|
-
if (isMissingVueCompiler(error)) throw new Error("检查 .vue 文件需要安装 @vue/compiler-sfc");
|
|
409
|
+
if (isMissingVueCompiler(error)) throw new Error(diagnosticMessage("检查 .vue 文件需要安装 @vue/compiler-sfc。", "Install @vue/compiler-sfc to lint .vue files."));
|
|
410
|
+
throw error;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
function loadVueCompiler(filename) {
|
|
414
|
+
const hostRequire = createRequire(path.resolve(filename));
|
|
415
|
+
const compiler = tryRequire(hostRequire, "vue/compiler-sfc") ?? tryRequire(packageRequire, "vue/compiler-sfc") ?? tryRequire(hostRequire, "@vue/compiler-sfc") ?? packageRequire("@vue/compiler-sfc");
|
|
416
|
+
compiler.registerTS?.(() => {
|
|
417
|
+
const typescript = tryResolve(hostRequire, "typescript") ?? tryResolve(packageRequire, "typescript");
|
|
418
|
+
return packageRequire(typescript ?? "typescript");
|
|
419
|
+
});
|
|
420
|
+
return compiler;
|
|
421
|
+
}
|
|
422
|
+
function tryRequire(require, id) {
|
|
423
|
+
const resolved = tryResolve(require, id);
|
|
424
|
+
return resolved ? require(resolved) : null;
|
|
425
|
+
}
|
|
426
|
+
function tryResolve(require, id) {
|
|
427
|
+
try {
|
|
428
|
+
return require.resolve(id);
|
|
429
|
+
} catch (error) {
|
|
430
|
+
if (isMissingVueCompiler(error)) return null;
|
|
172
431
|
throw error;
|
|
173
432
|
}
|
|
174
433
|
}
|
|
175
434
|
function isMissingVueCompiler(error) {
|
|
176
|
-
return error instanceof Error && "code" in error && error.code === "MODULE_NOT_FOUND";
|
|
435
|
+
return error instanceof Error && "code" in error && (error.code === "MODULE_NOT_FOUND" || error.code === "ERR_PACKAGE_PATH_NOT_EXPORTED");
|
|
436
|
+
}
|
|
437
|
+
//#endregion
|
|
438
|
+
//#region src/rule-analysis.ts
|
|
439
|
+
const DEFAULT_CANDIDATE_LIMIT = 1e3;
|
|
440
|
+
const cache = /* @__PURE__ */ new WeakMap();
|
|
441
|
+
const reportedAnalysisFailures = /* @__PURE__ */ new WeakSet();
|
|
442
|
+
const EMPTY_ANALYSIS = {
|
|
443
|
+
warnings: [],
|
|
444
|
+
translationCalls: [],
|
|
445
|
+
messages: []
|
|
446
|
+
};
|
|
447
|
+
function analyzeRuleContext(context, options, maxStaticCandidates = Number.POSITIVE_INFINITY) {
|
|
448
|
+
return analyzeRuleContextResult(context, options, maxStaticCandidates).warnings;
|
|
449
|
+
}
|
|
450
|
+
function analyzeTranslationCalls(context, options) {
|
|
451
|
+
return analyzeRuleContextResult(context, options, DEFAULT_CANDIDATE_LIMIT).translationCalls;
|
|
452
|
+
}
|
|
453
|
+
function analyzeTranslationMessages(context, options) {
|
|
454
|
+
return analyzeRuleContextResult(context, options, Number.POSITIVE_INFINITY).messages;
|
|
455
|
+
}
|
|
456
|
+
function reportAnalysisFailureOnce(context, node, error) {
|
|
457
|
+
if (reportedAnalysisFailures.has(context.sourceCode)) return;
|
|
458
|
+
reportedAnalysisFailures.add(context.sourceCode);
|
|
459
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
460
|
+
context.report({
|
|
461
|
+
node,
|
|
462
|
+
message: diagnosticMessage(`静态分析失败:${detail}`, `Static analysis failed: ${detail}`)
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
function analyzeRuleContextResult(context, options, maxStaticCandidates) {
|
|
466
|
+
if (!hasPotentialTranslationCandidate(context.sourceCode.text)) return EMPTY_ANALYSIS;
|
|
467
|
+
const cached = cache.get(context.sourceCode) ?? {
|
|
468
|
+
source: createAnalysisSource(context),
|
|
469
|
+
analyses: /* @__PURE__ */ new Map()
|
|
470
|
+
};
|
|
471
|
+
cache.set(context.sourceCode, cached);
|
|
472
|
+
const alias = readImportAlias(context);
|
|
473
|
+
const analyze = (limit) => {
|
|
474
|
+
const autoImports = normalizeAutoImports$1(options.autoImport);
|
|
475
|
+
const key = JSON.stringify([
|
|
476
|
+
options.tsconfigPath ?? null,
|
|
477
|
+
autoImports.t,
|
|
478
|
+
autoImports.tRef,
|
|
479
|
+
autoImports.tComputed,
|
|
480
|
+
autoImports.useI18n,
|
|
481
|
+
Object.entries(alias ?? {}),
|
|
482
|
+
limit
|
|
483
|
+
]);
|
|
484
|
+
const existing = cached.analyses.get(key);
|
|
485
|
+
if (existing) return existing;
|
|
486
|
+
const result = analyzeStaticSource(cached.source.code, context.filename, options.tsconfigPath, cached.source.lang, options.autoImport, limit, alias);
|
|
487
|
+
const analysis = {
|
|
488
|
+
warnings: result.warnings.map((warning) => ({
|
|
489
|
+
...warning,
|
|
490
|
+
...cached.source.mapLocation(warning)
|
|
491
|
+
})),
|
|
492
|
+
translationCalls: result.translationCalls.map((call) => ({
|
|
493
|
+
...call,
|
|
494
|
+
...cached.source.mapLocation(call)
|
|
495
|
+
})),
|
|
496
|
+
messages: result.messages.map((message) => ({
|
|
497
|
+
...message,
|
|
498
|
+
locations: message.locations.map((location) => ({
|
|
499
|
+
...location,
|
|
500
|
+
...cached.source.mapLocation(location)
|
|
501
|
+
}))
|
|
502
|
+
}))
|
|
503
|
+
};
|
|
504
|
+
cached.analyses.set(key, analysis);
|
|
505
|
+
return analysis;
|
|
506
|
+
};
|
|
507
|
+
if (Number.isFinite(maxStaticCandidates)) return analyze(maxStaticCandidates);
|
|
508
|
+
const preflight = analyze(DEFAULT_CANDIDATE_LIMIT);
|
|
509
|
+
return preflight.warnings.some((warning) => warning.code === "static-candidate-limit") ? analyze(Number.POSITIVE_INFINITY) : preflight;
|
|
510
|
+
}
|
|
511
|
+
function readImportAlias(context) {
|
|
512
|
+
const settings = context.settings["ai-i18n"];
|
|
513
|
+
if (!settings || typeof settings !== "object" || Array.isArray(settings)) return;
|
|
514
|
+
const alias = settings.alias;
|
|
515
|
+
if (alias === void 0) return void 0;
|
|
516
|
+
if (!alias || typeof alias !== "object" || Array.isArray(alias)) throw new TypeError(diagnosticMessage("ai-i18n settings.alias 必须是字符串到绝对路径的对象。", "ai-i18n settings.alias must be an object mapping strings to absolute paths."));
|
|
517
|
+
return alias;
|
|
518
|
+
}
|
|
519
|
+
function createAnalysisSource(context) {
|
|
520
|
+
return context.filename.endsWith(".vue") ? createVueAnalysisSource(context.sourceCode.text, context.filename, context.sourceCode.parserServices) : {
|
|
521
|
+
code: context.sourceCode.text,
|
|
522
|
+
lang: void 0,
|
|
523
|
+
mapLocation: (location) => location
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
//#endregion
|
|
527
|
+
//#region src/rules/common/translation-call.ts
|
|
528
|
+
function matchTranslationCalls(context, options, candidates) {
|
|
529
|
+
const calls = new Map(analyzeTranslationCalls(context, options).map((call) => [locationKey(call.line, call.column, call.kind), call]));
|
|
530
|
+
return candidates.flatMap((candidate) => {
|
|
531
|
+
const location = candidate.node.loc?.start;
|
|
532
|
+
if (!location) return [];
|
|
533
|
+
const call = calls.get(locationKey(location.line, location.column, candidate.kind));
|
|
534
|
+
return call ? [{
|
|
535
|
+
...candidate,
|
|
536
|
+
call
|
|
537
|
+
}] : [];
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
function locationKey(line, column, kind) {
|
|
541
|
+
return `${kind}:${line}:${column}`;
|
|
542
|
+
}
|
|
543
|
+
//#endregion
|
|
544
|
+
//#region src/rules/common/ast-context.ts
|
|
545
|
+
const FUNCTION_TYPES = /* @__PURE__ */ new Set([
|
|
546
|
+
"ArrowFunctionExpression",
|
|
547
|
+
"FunctionDeclaration",
|
|
548
|
+
"FunctionExpression"
|
|
549
|
+
]);
|
|
550
|
+
const IMMEDIATE_CONSOLE_METHODS = /* @__PURE__ */ new Set([
|
|
551
|
+
"debug",
|
|
552
|
+
"error",
|
|
553
|
+
"info",
|
|
554
|
+
"log",
|
|
555
|
+
"warn"
|
|
556
|
+
]);
|
|
557
|
+
function isFunctionNode(node) {
|
|
558
|
+
return FUNCTION_TYPES.has(node.type);
|
|
559
|
+
}
|
|
560
|
+
function nearestFunction(node) {
|
|
561
|
+
let current = node;
|
|
562
|
+
while (current.parent) {
|
|
563
|
+
current = current.parent;
|
|
564
|
+
if (isFunctionNode(current)) return current;
|
|
565
|
+
}
|
|
566
|
+
return null;
|
|
567
|
+
}
|
|
568
|
+
function isImmediateConsoleEffect(node) {
|
|
569
|
+
const call = node.parent;
|
|
570
|
+
if (call?.type !== "CallExpression" || call.parent?.type !== "ExpressionStatement" || !call.arguments.some((argument) => argument === node) || call.callee.type !== "MemberExpression" || call.callee.object.type !== "Identifier" || call.callee.object.name !== "console") return false;
|
|
571
|
+
const property = call.callee.property;
|
|
572
|
+
const method = !call.callee.computed && property.type === "Identifier" ? property.name ?? null : call.callee.computed && property.type === "Literal" && typeof property.value === "string" ? property.value : null;
|
|
573
|
+
return method !== null && IMMEDIATE_CONSOLE_METHODS.has(method);
|
|
574
|
+
}
|
|
575
|
+
function isVueTemplateEventHandler(node) {
|
|
576
|
+
let current = node;
|
|
577
|
+
while (current.parent) {
|
|
578
|
+
current = current.parent;
|
|
579
|
+
if (current.type === "VOnExpression") return true;
|
|
580
|
+
}
|
|
581
|
+
return false;
|
|
582
|
+
}
|
|
583
|
+
//#endregion
|
|
584
|
+
//#region src/rules/vue/options.ts
|
|
585
|
+
function isOptionsComputedValue(node, context) {
|
|
586
|
+
let value = node;
|
|
587
|
+
while (value.parent && isTransparentExpression(value.parent) && expressionChild(value.parent) === value) value = value.parent;
|
|
588
|
+
const entry = value.parent;
|
|
589
|
+
if (entry?.type !== "Property" || entry.value !== value) return false;
|
|
590
|
+
const entries = entry.parent;
|
|
591
|
+
if (entries?.type !== "ObjectExpression") return false;
|
|
592
|
+
const computed = entries.parent;
|
|
593
|
+
return computed?.type === "Property" && computed.value === entries && propertyName(computed.key) === "computed" && computed.parent?.type === "ObjectExpression" && isVueComponentOptionsObject(computed.parent, context);
|
|
594
|
+
}
|
|
595
|
+
function isOptionsComputedSpread(node, context) {
|
|
596
|
+
const value = unwrapTransparentParent(node);
|
|
597
|
+
const spread = value.parent;
|
|
598
|
+
if (spread?.type !== "SpreadElement" || spread.argument !== value) return false;
|
|
599
|
+
const entries = spread.parent;
|
|
600
|
+
if (entries?.type !== "ObjectExpression") return false;
|
|
601
|
+
const computed = entries.parent;
|
|
602
|
+
return computed?.type === "Property" && computed.value === entries && propertyName(computed.key) === "computed" && computed.parent?.type === "ObjectExpression" && isVueComponentOptionsObject(computed.parent, context);
|
|
603
|
+
}
|
|
604
|
+
function isInsideOptionsComputedGetter(node, context) {
|
|
605
|
+
const owner = nearestFunction(node);
|
|
606
|
+
if (!owner) return false;
|
|
607
|
+
let current = owner;
|
|
608
|
+
while (current?.parent) {
|
|
609
|
+
const parent = current.parent;
|
|
610
|
+
if (current.type === "ObjectExpression" && parent.type === "Property" && parent.value === current && propertyName(parent.key) === "computed" && parent.parent?.type === "ObjectExpression" && isVueComponentOptionsObject(parent.parent, context)) return true;
|
|
611
|
+
current = current.parent;
|
|
612
|
+
if (current && current !== owner && isFunctionNode(current)) return false;
|
|
613
|
+
}
|
|
614
|
+
return false;
|
|
615
|
+
}
|
|
616
|
+
function vueOptionsSection(node, context) {
|
|
617
|
+
let current = node;
|
|
618
|
+
while (current.parent) {
|
|
619
|
+
const property = current.parent;
|
|
620
|
+
if (property.type === "Property" && property.value === current && property.parent?.type === "ObjectExpression" && isVueComponentOptionsObject(property.parent, context)) return propertyName(property.key) ?? null;
|
|
621
|
+
current = current.parent;
|
|
622
|
+
}
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
function isDirectOptionsFunction$1(node, context, name, allowBareExportDefault = true) {
|
|
626
|
+
const value = unwrapTransparentParent(node);
|
|
627
|
+
const property = value.parent;
|
|
628
|
+
if (property?.type !== "Property" || property.value !== value || propertyName(property.key) !== name) return false;
|
|
629
|
+
const options = property.parent;
|
|
630
|
+
return options?.type === "ObjectExpression" && isVueComponentOptionsObject(options, context, allowBareExportDefault);
|
|
631
|
+
}
|
|
632
|
+
function isVueComponentSetup(node, context, isVueSfc) {
|
|
633
|
+
const value = unwrapTransparentParent(node);
|
|
634
|
+
const directOwner = value.parent;
|
|
635
|
+
if (directOwner?.type === "CallExpression" && directOwner.arguments?.[0] === value) return isImportedVueDefineComponent(directOwner.callee, context);
|
|
636
|
+
const property = value.parent;
|
|
637
|
+
if (property?.type !== "Property" || property.value !== value || property.computed || propertyName(property.key) !== "setup") return false;
|
|
638
|
+
const options = property.parent;
|
|
639
|
+
if (options?.type !== "ObjectExpression") return false;
|
|
640
|
+
const owner = options.parent;
|
|
641
|
+
if (isVueSfc && owner?.type === "ExportDefaultDeclaration" && owner.declaration === options) return true;
|
|
642
|
+
return owner?.type === "CallExpression" && owner.arguments?.[0] === options && isImportedVueDefineComponent(owner.callee, context);
|
|
643
|
+
}
|
|
644
|
+
function isVueScriptSetupNode(node, context) {
|
|
645
|
+
if (!context.filename.toLowerCase().endsWith(".vue") || !node.range) return false;
|
|
646
|
+
const source = context.sourceCode.text;
|
|
647
|
+
const before = source.slice(0, node.range[0]);
|
|
648
|
+
const opening = before.lastIndexOf("<script");
|
|
649
|
+
if (opening <= before.lastIndexOf("<\/script>")) return false;
|
|
650
|
+
const closing = source.indexOf(">", opening);
|
|
651
|
+
if (closing < 0 || closing >= node.range[0]) return false;
|
|
652
|
+
return /\bsetup(?:\s|=|>)/u.test(source.slice(opening, closing + 1));
|
|
653
|
+
}
|
|
654
|
+
function isImportedVueDefineComponent(node, context) {
|
|
655
|
+
const callee = node;
|
|
656
|
+
if (callee?.type === "Identifier") return isVueImport(callee, context, "ImportSpecifier");
|
|
657
|
+
return callee?.type === "MemberExpression" && !callee.computed && propertyName(callee.property) === "defineComponent" && isVueImport(callee.object, context, "ImportNamespaceSpecifier");
|
|
658
|
+
}
|
|
659
|
+
function propertyName(node) {
|
|
660
|
+
const key = node;
|
|
661
|
+
if (key?.type === "Identifier") return key.name;
|
|
662
|
+
return key?.type === "Literal" && typeof key.value === "string" ? key.value : void 0;
|
|
663
|
+
}
|
|
664
|
+
function isVueComponentOptionsObject(options, context, allowBareExportDefault = true) {
|
|
665
|
+
const value = unwrapTransparentParent(options);
|
|
666
|
+
const owner = value.parent;
|
|
667
|
+
if (allowBareExportDefault && owner?.type === "ExportDefaultDeclaration" && owner.declaration === value) return true;
|
|
668
|
+
return owner?.type === "CallExpression" && owner.arguments?.[0] === value && isImportedVueDefineComponent(owner.callee, context);
|
|
669
|
+
}
|
|
670
|
+
function isVueImport(node, context, specifierType) {
|
|
671
|
+
const identifier = node;
|
|
672
|
+
if (identifier?.type !== "Identifier" || !identifier.name) return false;
|
|
673
|
+
let scope = context.sourceCode.getScope(identifier);
|
|
674
|
+
while (true) {
|
|
675
|
+
const variable = scope.set.get(identifier.name);
|
|
676
|
+
if (!variable) {
|
|
677
|
+
if (!scope.upper) return false;
|
|
678
|
+
scope = scope.upper;
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
return variable.defs.some((definition) => {
|
|
682
|
+
if (definition.type !== "ImportBinding" || definition.node.type !== specifierType || definition.parent.source.value !== "vue") return false;
|
|
683
|
+
return specifierType === "ImportNamespaceSpecifier" || propertyName(definition.node.imported) === "defineComponent";
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
function unwrapTransparentParent(node) {
|
|
688
|
+
let value = node;
|
|
689
|
+
while (value.parent && isTransparentExpression(value.parent) && expressionChild(value.parent) === value) value = value.parent;
|
|
690
|
+
return value;
|
|
691
|
+
}
|
|
692
|
+
function isTransparentExpression(node) {
|
|
693
|
+
return node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression" || node.type === "TSNonNullExpression" || node.type === "TypeCastExpression";
|
|
694
|
+
}
|
|
695
|
+
function expressionChild(node) {
|
|
696
|
+
return node.expression;
|
|
697
|
+
}
|
|
698
|
+
//#endregion
|
|
699
|
+
//#region src/rules/common/no-eager-translation.ts
|
|
700
|
+
const noEagerTranslation = {
|
|
701
|
+
meta: {
|
|
702
|
+
type: "problem",
|
|
703
|
+
docs: { description: "警告不会随语言切换更新的提前求值翻译结果" },
|
|
704
|
+
schema: [{
|
|
705
|
+
type: "object",
|
|
706
|
+
properties: {
|
|
707
|
+
tsconfigPath: { type: "string" },
|
|
708
|
+
autoImport: { anyOf: [{ type: "boolean" }, {
|
|
709
|
+
type: "array",
|
|
710
|
+
items: { enum: [
|
|
711
|
+
"t",
|
|
712
|
+
"tRef",
|
|
713
|
+
"tComputed",
|
|
714
|
+
"useI18n"
|
|
715
|
+
] },
|
|
716
|
+
uniqueItems: true
|
|
717
|
+
}] },
|
|
718
|
+
framework: { enum: ["vue"] }
|
|
719
|
+
},
|
|
720
|
+
additionalProperties: false
|
|
721
|
+
}],
|
|
722
|
+
messages: { eagerTranslation: diagnosticMessage("初始化期间保存的 t() 结果不会随语言切换更新。请改为函数或 Getter,在使用时调用 t();Vue setup 中使用 tRef(),纯 Options API 的 computed 使用 tComputed()。", "A t() result stored during initialization will not update when the language changes. Evaluate it lazily in a function or getter; use tRef() in Vue setup and tComputed() in pure Options API computed.") }
|
|
723
|
+
},
|
|
724
|
+
create(context) {
|
|
725
|
+
const options = context.options[0] ?? {};
|
|
726
|
+
const isVueSfc = context.filename.toLowerCase().endsWith(".vue");
|
|
727
|
+
const candidates = [];
|
|
728
|
+
return {
|
|
729
|
+
CallExpression(node) {
|
|
730
|
+
candidates.push({
|
|
731
|
+
kind: "call",
|
|
732
|
+
node
|
|
733
|
+
});
|
|
734
|
+
},
|
|
735
|
+
TaggedTemplateExpression(node) {
|
|
736
|
+
candidates.push({
|
|
737
|
+
kind: "tagged-template",
|
|
738
|
+
node
|
|
739
|
+
});
|
|
740
|
+
},
|
|
741
|
+
"Program:exit"(program) {
|
|
742
|
+
let matches;
|
|
743
|
+
try {
|
|
744
|
+
matches = matchTranslationCalls(context, options, candidates);
|
|
745
|
+
} catch (error) {
|
|
746
|
+
reportAnalysisFailureOnce(context, program, error);
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
for (const { call, node } of matches) {
|
|
750
|
+
if (call.origin === "vue-ref" || call.origin === "vue-computed") continue;
|
|
751
|
+
if (!storesOutsideFunction(node, context, isVueSfc, options.framework === "vue")) continue;
|
|
752
|
+
context.report({
|
|
753
|
+
node,
|
|
754
|
+
messageId: "eagerTranslation"
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
};
|
|
761
|
+
function storesOutsideFunction(node, context, isVueSfc, isVueFramework) {
|
|
762
|
+
let current = node;
|
|
763
|
+
let storesResult = false;
|
|
764
|
+
while (current.parent) {
|
|
765
|
+
const parent = current.parent;
|
|
766
|
+
if (isFunctionNode(parent)) {
|
|
767
|
+
const returnsExpression = parent.type === "ArrowFunctionExpression" && parent.body === current;
|
|
768
|
+
return (isVueComponentSetup(parent, context, isVueSfc) || isDirectOptionsFunction$1(parent, context, "data", isVueSfc || isVueFramework)) && (storesResult || returnsExpression);
|
|
769
|
+
}
|
|
770
|
+
if (storesTranslationResult(parent, current)) storesResult = true;
|
|
771
|
+
if (parent.type === "Program") return storesResult;
|
|
772
|
+
current = parent;
|
|
773
|
+
}
|
|
774
|
+
return storesResult;
|
|
775
|
+
}
|
|
776
|
+
function storesTranslationResult(parent, child) {
|
|
777
|
+
if (parent.type === "AccessorProperty" && parent.value === child) return true;
|
|
778
|
+
switch (parent.type) {
|
|
779
|
+
case "VariableDeclarator": return parent.init === child;
|
|
780
|
+
case "AssignmentExpression":
|
|
781
|
+
case "AssignmentPattern": return parent.right === child;
|
|
782
|
+
case "PropertyDefinition": return parent.value === child;
|
|
783
|
+
case "ReturnStatement": return parent.argument === child;
|
|
784
|
+
case "ExportDefaultDeclaration": return parent.declaration === child;
|
|
785
|
+
default: return false;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
//#endregion
|
|
789
|
+
//#region src/rules/common/no-embedded-markup.ts
|
|
790
|
+
const noEmbeddedMarkup = {
|
|
791
|
+
meta: {
|
|
792
|
+
type: "suggestion",
|
|
793
|
+
docs: { description: "警告翻译源文中内嵌的静态 HTML 或 SVG 结构" },
|
|
794
|
+
schema: [{
|
|
795
|
+
type: "object",
|
|
796
|
+
properties: {
|
|
797
|
+
tsconfigPath: { type: "string" },
|
|
798
|
+
autoImport: { anyOf: [{ type: "boolean" }, {
|
|
799
|
+
type: "array",
|
|
800
|
+
items: { enum: [
|
|
801
|
+
"t",
|
|
802
|
+
"tRef",
|
|
803
|
+
"tComputed",
|
|
804
|
+
"useI18n"
|
|
805
|
+
] },
|
|
806
|
+
uniqueItems: true
|
|
807
|
+
}] }
|
|
808
|
+
},
|
|
809
|
+
additionalProperties: false
|
|
810
|
+
}],
|
|
811
|
+
messages: { embeddedMarkup: diagnosticMessage("翻译源文包含静态 HTML 或 SVG 结构。请保留完整的自然语言,并将 markup 移出翻译调用或作为占位符传入。", "The translation source contains static HTML or SVG structure. Keep the complete natural-language message, and move markup outside the translation call or pass it as a placeholder.") }
|
|
812
|
+
},
|
|
813
|
+
create(context) {
|
|
814
|
+
const options = context.options[0] ?? {};
|
|
815
|
+
return { "Program:exit"(program) {
|
|
816
|
+
let messages;
|
|
817
|
+
try {
|
|
818
|
+
messages = analyzeTranslationMessages(context, options);
|
|
819
|
+
} catch (error) {
|
|
820
|
+
reportAnalysisFailureOnce(context, program, error);
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
const reported = /* @__PURE__ */ new Set();
|
|
824
|
+
for (const message of messages) {
|
|
825
|
+
if (!containsEmbeddedMarkup(message.source)) continue;
|
|
826
|
+
for (const location of message.locations) {
|
|
827
|
+
const key = `${location.line}:${location.column}`;
|
|
828
|
+
if (reported.has(key)) continue;
|
|
829
|
+
reported.add(key);
|
|
830
|
+
context.report({
|
|
831
|
+
node: program,
|
|
832
|
+
loc: {
|
|
833
|
+
start: location,
|
|
834
|
+
end: {
|
|
835
|
+
line: location.line,
|
|
836
|
+
column: location.column + 1
|
|
837
|
+
}
|
|
838
|
+
},
|
|
839
|
+
messageId: "embeddedMarkup"
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
} };
|
|
844
|
+
}
|
|
845
|
+
};
|
|
846
|
+
function containsEmbeddedMarkup(source) {
|
|
847
|
+
if (!source.includes("<")) return false;
|
|
848
|
+
const options = { sourceCodeLocationInfo: true };
|
|
849
|
+
return containsLocatedMarkup(parseFragment(source, options)) || containsLocatedMarkup(parse(source, options));
|
|
850
|
+
}
|
|
851
|
+
function containsLocatedMarkup(parent) {
|
|
852
|
+
return parent.childNodes.some((node) => {
|
|
853
|
+
if ((defaultTreeAdapter.isElementNode(node) || defaultTreeAdapter.isCommentNode(node)) && node.sourceCodeLocation) return true;
|
|
854
|
+
return defaultTreeAdapter.isElementNode(node) && containsLocatedMarkup(node);
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
//#endregion
|
|
858
|
+
//#region src/rules/common/no-redundant-auto-import.ts
|
|
859
|
+
const RUNTIME_MODULE$2 = "virtual:ai-i18n";
|
|
860
|
+
const noRedundantAutoImport = {
|
|
861
|
+
meta: {
|
|
862
|
+
type: "suggestion",
|
|
863
|
+
docs: { description: "禁止显式导入已由 ai-i18n autoImport 注入的 Runtime API" },
|
|
864
|
+
fixable: "code",
|
|
865
|
+
schema: [{
|
|
866
|
+
type: "object",
|
|
867
|
+
properties: { autoImport: {
|
|
868
|
+
type: "array",
|
|
869
|
+
items: { enum: ALL_AUTO_IMPORT_APIS },
|
|
870
|
+
minItems: 1,
|
|
871
|
+
uniqueItems: true
|
|
872
|
+
} },
|
|
873
|
+
required: ["autoImport"],
|
|
874
|
+
additionalProperties: false
|
|
875
|
+
}],
|
|
876
|
+
messages: { redundantImport: diagnosticMessage("ai-i18n autoImport 已注入以下 API:{{names}}。请删除来自 virtual:ai-i18n 的冗余导入。", "ai-i18n autoImport injects these APIs: {{names}}. Remove the redundant imports from virtual:ai-i18n.") }
|
|
877
|
+
},
|
|
878
|
+
create(context) {
|
|
879
|
+
const enabled = new Set(context.options[0]?.autoImport ?? []);
|
|
880
|
+
return { ImportDeclaration(rawNode) {
|
|
881
|
+
const node = rawNode;
|
|
882
|
+
if (node.importKind === "type" || node.source.value !== RUNTIME_MODULE$2) return;
|
|
883
|
+
const named = node.specifiers.filter((specifier) => specifier.type === "ImportSpecifier");
|
|
884
|
+
const redundant = named.filter((specifier) => {
|
|
885
|
+
const imported = importedName(specifier);
|
|
886
|
+
return specifier.importKind !== "type" && imported !== null && imported === specifier.local.name && enabled.has(imported);
|
|
887
|
+
});
|
|
888
|
+
if (!redundant.length) return;
|
|
889
|
+
const names = redundant.map((specifier) => importedName(specifier)).filter((name) => name !== null);
|
|
890
|
+
const fix = createFix(context, node, named, redundant);
|
|
891
|
+
context.report({
|
|
892
|
+
node,
|
|
893
|
+
messageId: "redundantImport",
|
|
894
|
+
data: { names: names.join(", ") },
|
|
895
|
+
fix
|
|
896
|
+
});
|
|
897
|
+
} };
|
|
898
|
+
}
|
|
899
|
+
};
|
|
900
|
+
function importedName(specifier) {
|
|
901
|
+
if (specifier.imported.type === "Identifier" && "name" in specifier.imported) return specifier.imported.name;
|
|
902
|
+
return typeof specifier.imported.value === "string" ? specifier.imported.value : null;
|
|
903
|
+
}
|
|
904
|
+
function createFix(context, node, named, redundant) {
|
|
905
|
+
if (context.sourceCode.getCommentsInside(node).length) return void 0;
|
|
906
|
+
const redundantSet = new Set(redundant);
|
|
907
|
+
if (!node.specifiers.filter((specifier) => !redundantSet.has(specifier)).length) return (fixer) => fixer.remove(node);
|
|
908
|
+
const remainingNamed = named.filter((specifier) => !redundantSet.has(specifier));
|
|
909
|
+
const firstNamed = named[0];
|
|
910
|
+
const lastNamed = named.at(-1);
|
|
911
|
+
if (!firstNamed || !lastNamed) return void 0;
|
|
912
|
+
const openBrace = context.sourceCode.getTokenBefore(firstNamed);
|
|
913
|
+
const closeBrace = context.sourceCode.getTokenAfter(lastNamed);
|
|
914
|
+
if (openBrace?.value !== "{" || closeBrace?.value !== "}") return void 0;
|
|
915
|
+
if (remainingNamed.length) {
|
|
916
|
+
const [, openEnd] = context.sourceCode.getRange(openBrace);
|
|
917
|
+
const [closeStart] = context.sourceCode.getRange(closeBrace);
|
|
918
|
+
const replacement = remainingNamed.map((specifier) => context.sourceCode.getText(specifier)).join(", ");
|
|
919
|
+
return (fixer) => fixer.replaceTextRange([openEnd, closeStart], ` ${replacement} `);
|
|
920
|
+
}
|
|
921
|
+
const beforeOpen = context.sourceCode.getTokenBefore(openBrace);
|
|
922
|
+
if (beforeOpen?.value !== ",") return void 0;
|
|
923
|
+
const [commaStart] = context.sourceCode.getRange(beforeOpen);
|
|
924
|
+
const [, closeEnd] = context.sourceCode.getRange(closeBrace);
|
|
925
|
+
return (fixer) => fixer.removeRange([commaStart, closeEnd]);
|
|
926
|
+
}
|
|
927
|
+
//#endregion
|
|
928
|
+
//#region src/rules/vue/runtime-state.ts
|
|
929
|
+
function misplacedI18nComputed(node, context, inTemplate, isVueSfc) {
|
|
930
|
+
if (inTemplate || isVueScriptSetupNode(node, context)) return true;
|
|
931
|
+
if (vueOptionsSection(node, context) !== null) return true;
|
|
932
|
+
const owner = nearestFunction(node);
|
|
933
|
+
return Boolean(owner && isVueComponentSetup(owner, context, isVueSfc));
|
|
934
|
+
}
|
|
935
|
+
function vueInitializationSnapshotKind(node, owner, context, isVueSfc) {
|
|
936
|
+
const kind = isVueComponentSetup(owner, context, isVueSfc) ? "setup" : isDirectOptionsFunction$1(owner, context, "data") ? "data" : null;
|
|
937
|
+
if (!kind || !storesResultBeforeOwner(node, owner)) return null;
|
|
938
|
+
return kind;
|
|
939
|
+
}
|
|
940
|
+
function reportVueInitializationSnapshot(context, node, api, kind) {
|
|
941
|
+
context.report({
|
|
942
|
+
node,
|
|
943
|
+
messageId: kind === "data" ? "optionsDataSnapshot" : "vueSetupSnapshot",
|
|
944
|
+
data: {
|
|
945
|
+
api,
|
|
946
|
+
replacement: api === "getLang" ? "currentLang" : "langLoadState"
|
|
947
|
+
}
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
function storesResultBeforeOwner(node, owner) {
|
|
951
|
+
let current = node;
|
|
952
|
+
let storesResult = false;
|
|
953
|
+
while (current.parent && current.parent !== owner) {
|
|
954
|
+
const parent = current.parent;
|
|
955
|
+
if (parent.type === "VariableDeclarator" && parent.init === current || (parent.type === "AssignmentExpression" || parent.type === "AssignmentPattern") && parent.right === current || parent.type === "PropertyDefinition" && parent.value === current || parent.type === "ReturnStatement" && parent.argument === current) storesResult = true;
|
|
956
|
+
current = parent;
|
|
957
|
+
}
|
|
958
|
+
return storesResult || owner.type === "ArrowFunctionExpression" && owner.body === current;
|
|
177
959
|
}
|
|
178
960
|
//#endregion
|
|
179
|
-
//#region src/rules/
|
|
961
|
+
//#region src/rules/common/no-unsubscribed-runtime-state.ts
|
|
962
|
+
const RUNTIME_MODULE$1 = "virtual:ai-i18n";
|
|
963
|
+
const STATE_APIS = ["getLang", "getLangLoadState"];
|
|
964
|
+
const VUE_COMPUTED_API = "i18nComputed";
|
|
965
|
+
const TRACKED_APIS = [...STATE_APIS, VUE_COMPUTED_API];
|
|
966
|
+
const noUnsubscribedRuntimeState = {
|
|
967
|
+
meta: {
|
|
968
|
+
type: "problem",
|
|
969
|
+
docs: { description: "检查初始化、模块和组件渲染路径中的 Runtime 状态快照" },
|
|
970
|
+
schema: [{
|
|
971
|
+
type: "object",
|
|
972
|
+
properties: {
|
|
973
|
+
autoImport: { anyOf: [{ type: "boolean" }, {
|
|
974
|
+
type: "array",
|
|
975
|
+
items: { enum: TRACKED_APIS },
|
|
976
|
+
uniqueItems: true
|
|
977
|
+
}] },
|
|
978
|
+
framework: { enum: ["react", "vue"] }
|
|
979
|
+
},
|
|
980
|
+
additionalProperties: false
|
|
981
|
+
}],
|
|
982
|
+
messages: {
|
|
983
|
+
moduleSnapshot: diagnosticMessage("模块顶层调用 {{api}}() 只会保存初始化时的状态快照,后续语言状态变化不会更新该值。请在需要时调用,或通过 subscribe() / useI18n() 建立订阅。", "Calling {{api}}() at module scope stores only the initialization snapshot, which will not update after language state changes. Read it when needed, or subscribe through subscribe() / useI18n()."),
|
|
984
|
+
renderSnapshot: diagnosticMessage("组件渲染期间调用 {{api}}() 不会订阅语言状态。请使用 useI18n() 返回的 {{replacement}}。", "Calling {{api}}() while rendering a component does not subscribe to language state. Use {{replacement}} from useI18n()."),
|
|
985
|
+
vueRenderSnapshot: diagnosticMessage("Vue 渲染期间调用 {{api}}() 不会订阅语言状态。Composition API 请使用 useI18n() 返回的 {{replacement}};纯 Options API 请在 computed 中展开 ...i18nComputed() 后使用 {{replacement}}。", "Calling {{api}}() while rendering Vue does not subscribe to language state. With the Composition API, use {{replacement}} from useI18n(); with the pure Options API, spread ...i18nComputed() into computed and use {{replacement}}."),
|
|
986
|
+
optionsComputedSnapshot: diagnosticMessage("纯 Options API 的 computed 中调用 {{api}}() 仍然只会读取快照。请改为在 computed 中展开 ...i18nComputed(),并直接使用响应式的 {{replacement}}。", "Calling {{api}}() inside a pure Options API computed getter still reads only a snapshot. Spread ...i18nComputed() into computed and use the reactive {{replacement}} instead."),
|
|
987
|
+
vueSetupSnapshot: diagnosticMessage("Vue setup 中保存 {{api}}() 只会保留初始化快照。请使用 useI18n() 返回的响应式 {{replacement}}。", "Storing {{api}}() in Vue setup keeps only the initialization snapshot. Use the reactive {{replacement}} returned by useI18n()."),
|
|
988
|
+
optionsDataSnapshot: diagnosticMessage("纯 Options API 的 data 中保存 {{api}}() 只会保留初始化快照。请在 computed 中展开 ...i18nComputed(),并使用响应式的 {{replacement}}。", "Storing {{api}}() in pure Options API data keeps only the initialization snapshot. Spread ...i18nComputed() into computed and use the reactive {{replacement}}."),
|
|
989
|
+
misplacedI18nComputed: diagnosticMessage("i18nComputed() 只应直接展开到纯 Options API 的 computed,例如 computed: { ...i18nComputed() }。", "Spread i18nComputed() directly into pure Options API computed, for example computed: { ...i18nComputed() }.")
|
|
990
|
+
}
|
|
991
|
+
},
|
|
992
|
+
create(context) {
|
|
993
|
+
const options = context.options[0] ?? {};
|
|
994
|
+
const autoImports = normalizeAutoImports(options.autoImport);
|
|
995
|
+
const importedBindings = /* @__PURE__ */ new Map();
|
|
996
|
+
const jsxOwners = /* @__PURE__ */ new Set();
|
|
997
|
+
const templateCalls = /* @__PURE__ */ new Set();
|
|
998
|
+
const candidates = [];
|
|
999
|
+
const isVueSfc = context.filename.toLowerCase().endsWith(".vue");
|
|
1000
|
+
const collectJsxOwner = (node) => {
|
|
1001
|
+
const owner = nearestFunction(node);
|
|
1002
|
+
if (owner) jsxOwners.add(owner);
|
|
1003
|
+
};
|
|
1004
|
+
const collectCall = (node) => {
|
|
1005
|
+
candidates.push(node);
|
|
1006
|
+
};
|
|
1007
|
+
const scriptVisitor = {
|
|
1008
|
+
CallExpression: collectCall,
|
|
1009
|
+
ImportDeclaration(node) {
|
|
1010
|
+
if (node.source.value !== RUNTIME_MODULE$1) return;
|
|
1011
|
+
for (const specifier of node.specifiers) {
|
|
1012
|
+
if (specifier.type !== "ImportSpecifier") continue;
|
|
1013
|
+
const imported = specifier.imported.type === "Identifier" ? specifier.imported.name : typeof specifier.imported.value === "string" ? specifier.imported.value : null;
|
|
1014
|
+
if (TRACKED_APIS.includes(imported)) importedBindings.set(specifier.local.name, imported);
|
|
1015
|
+
}
|
|
1016
|
+
},
|
|
1017
|
+
JSXElement: collectJsxOwner,
|
|
1018
|
+
JSXFragment: collectJsxOwner,
|
|
1019
|
+
"Program:exit"() {
|
|
1020
|
+
for (const node of candidates) {
|
|
1021
|
+
const api = runtimeStateApi(context, node, autoImports, importedBindings, templateCalls.has(node));
|
|
1022
|
+
if (!api) continue;
|
|
1023
|
+
if (api === VUE_COMPUTED_API) {
|
|
1024
|
+
if (options.framework === "vue" && !isOptionsComputedSpread(node, context) && misplacedI18nComputed(node, context, templateCalls.has(node), isVueSfc)) context.report({
|
|
1025
|
+
node,
|
|
1026
|
+
messageId: "misplacedI18nComputed"
|
|
1027
|
+
});
|
|
1028
|
+
continue;
|
|
1029
|
+
}
|
|
1030
|
+
if (templateCalls.has(node)) {
|
|
1031
|
+
if (!isVueTemplateEventHandler(node)) reportRenderSnapshot(context, node, api, true);
|
|
1032
|
+
continue;
|
|
1033
|
+
}
|
|
1034
|
+
const owner = nearestFunction(node);
|
|
1035
|
+
if (!owner) {
|
|
1036
|
+
context.report({
|
|
1037
|
+
node,
|
|
1038
|
+
messageId: "moduleSnapshot",
|
|
1039
|
+
data: { api }
|
|
1040
|
+
});
|
|
1041
|
+
continue;
|
|
1042
|
+
}
|
|
1043
|
+
if (options.framework === "vue" && isInsideOptionsComputedGetter(node, context)) {
|
|
1044
|
+
reportOptionsComputedSnapshot(context, node, api);
|
|
1045
|
+
continue;
|
|
1046
|
+
}
|
|
1047
|
+
const initializationKind = options.framework === "vue" ? vueInitializationSnapshotKind(node, owner, context, isVueSfc) : null;
|
|
1048
|
+
if (initializationKind) {
|
|
1049
|
+
reportVueInitializationSnapshot(context, node, api, initializationKind);
|
|
1050
|
+
continue;
|
|
1051
|
+
}
|
|
1052
|
+
if (jsxOwners.has(owner) && !isImmediateConsoleEffect(node)) reportRenderSnapshot(context, node, api, options.framework === "vue");
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
};
|
|
1056
|
+
const parserServices = context.sourceCode.parserServices;
|
|
1057
|
+
if (!parserServices.defineTemplateBodyVisitor) return scriptVisitor;
|
|
1058
|
+
return parserServices.defineTemplateBodyVisitor({ CallExpression(node) {
|
|
1059
|
+
candidates.push(node);
|
|
1060
|
+
templateCalls.add(node);
|
|
1061
|
+
} }, scriptVisitor, { templateBodyTriggerSelector: "Program" });
|
|
1062
|
+
}
|
|
1063
|
+
};
|
|
1064
|
+
function normalizeAutoImports(option) {
|
|
1065
|
+
if (option === true) return new Set(TRACKED_APIS);
|
|
1066
|
+
if (!option) return /* @__PURE__ */ new Set();
|
|
1067
|
+
return new Set(option);
|
|
1068
|
+
}
|
|
1069
|
+
function runtimeStateApi(context, rawNode, autoImports, importedBindings, inTemplate) {
|
|
1070
|
+
if (rawNode.type !== "CallExpression") return null;
|
|
1071
|
+
const callee = rawNode.callee;
|
|
1072
|
+
if (callee.type !== "Identifier") return null;
|
|
1073
|
+
if (inTemplate) {
|
|
1074
|
+
if (isTemplateLocalIdentifier(callee)) return null;
|
|
1075
|
+
const imported = importedBindings.get(callee.name);
|
|
1076
|
+
if (imported) return imported;
|
|
1077
|
+
if (hasTopLevelScriptBinding(context, callee.name)) return null;
|
|
1078
|
+
return autoImports.has(callee.name) ? callee.name : null;
|
|
1079
|
+
}
|
|
1080
|
+
const variable = findVariable$1(context.sourceCode.getScope(callee), callee.name);
|
|
1081
|
+
if (variable?.defs.length) return importedRuntimeStateApi(variable);
|
|
1082
|
+
return autoImports.has(callee.name) ? callee.name : null;
|
|
1083
|
+
}
|
|
1084
|
+
function importedRuntimeStateApi(variable) {
|
|
1085
|
+
for (const definition of variable.defs) {
|
|
1086
|
+
if (definition.type !== "ImportBinding") continue;
|
|
1087
|
+
const specifier = definition.node;
|
|
1088
|
+
const declaration = specifier.parent;
|
|
1089
|
+
if (specifier.type !== "ImportSpecifier" || declaration?.type !== "ImportDeclaration" || declaration.source.value !== RUNTIME_MODULE$1) continue;
|
|
1090
|
+
const imported = specifier.imported.type === "Identifier" ? specifier.imported.name : typeof specifier.imported.value === "string" ? specifier.imported.value : null;
|
|
1091
|
+
return TRACKED_APIS.includes(imported) ? imported : null;
|
|
1092
|
+
}
|
|
1093
|
+
return null;
|
|
1094
|
+
}
|
|
1095
|
+
function hasTopLevelScriptBinding(context, name) {
|
|
1096
|
+
return context.sourceCode.scopeManager.scopes.some((scope) => (scope.type === "global" || scope.type === "module") && scope.variables.some((variable) => variable.name === name && variable.defs.length > 0));
|
|
1097
|
+
}
|
|
1098
|
+
function isTemplateLocalIdentifier(identifier) {
|
|
1099
|
+
let current = identifier;
|
|
1100
|
+
while (current) {
|
|
1101
|
+
if (current.type === "VExpressionContainer") return current.references.some((reference) => reference.id === identifier && reference.variable !== null);
|
|
1102
|
+
current = current.parent;
|
|
1103
|
+
}
|
|
1104
|
+
return false;
|
|
1105
|
+
}
|
|
1106
|
+
function findVariable$1(scope, name) {
|
|
1107
|
+
let current = scope;
|
|
1108
|
+
while (current) {
|
|
1109
|
+
const variable = current.variables.find((item) => item.name === name);
|
|
1110
|
+
if (variable) return variable;
|
|
1111
|
+
current = current.upper;
|
|
1112
|
+
}
|
|
1113
|
+
return null;
|
|
1114
|
+
}
|
|
1115
|
+
function reportRenderSnapshot(context, node, api, isVue) {
|
|
1116
|
+
context.report({
|
|
1117
|
+
node,
|
|
1118
|
+
messageId: isVue ? "vueRenderSnapshot" : "renderSnapshot",
|
|
1119
|
+
data: {
|
|
1120
|
+
api,
|
|
1121
|
+
replacement: api === "getLang" ? "currentLang" : "langLoadState"
|
|
1122
|
+
}
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
1125
|
+
function reportOptionsComputedSnapshot(context, node, api) {
|
|
1126
|
+
context.report({
|
|
1127
|
+
node,
|
|
1128
|
+
messageId: "optionsComputedSnapshot",
|
|
1129
|
+
data: {
|
|
1130
|
+
api,
|
|
1131
|
+
replacement: api === "getLang" ? "currentLang" : "langLoadState"
|
|
1132
|
+
}
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
//#endregion
|
|
1136
|
+
//#region src/rules/react/no-unsubscribed-t.ts
|
|
1137
|
+
function reportReactUnsubscribedT(context, call, node, jsxOwners) {
|
|
1138
|
+
if (call.origin !== "runtime") return;
|
|
1139
|
+
const owner = nearestFunction(node);
|
|
1140
|
+
if (!owner || !jsxOwners.has(owner) || isImmediateConsoleEffect(node)) return;
|
|
1141
|
+
context.report({
|
|
1142
|
+
node,
|
|
1143
|
+
messageId: "unsubscribedT"
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
//#endregion
|
|
1147
|
+
//#region src/rules/vue/instance-member.ts
|
|
1148
|
+
const RUNTIME_MODULE = "virtual:ai-i18n";
|
|
1149
|
+
const TRANSPARENT_EXPRESSIONS = /* @__PURE__ */ new Set([
|
|
1150
|
+
"TSAsExpression",
|
|
1151
|
+
"TSSatisfiesExpression",
|
|
1152
|
+
"TSNonNullExpression",
|
|
1153
|
+
"TypeCastExpression"
|
|
1154
|
+
]);
|
|
1155
|
+
const STATIC_INSTANCE_SOURCES = /* @__PURE__ */ new Set([
|
|
1156
|
+
"computed",
|
|
1157
|
+
"inject",
|
|
1158
|
+
"props"
|
|
1159
|
+
]);
|
|
1160
|
+
const RETURNED_INSTANCE_SOURCES = /* @__PURE__ */ new Set(["data", "setup"]);
|
|
1161
|
+
const UNCERTAIN_INSTANCE_SOURCES = /* @__PURE__ */ new Set(["extends", "mixins"]);
|
|
1162
|
+
function resolveVueInstanceMemberOrigin(context, node, name, inTemplate) {
|
|
1163
|
+
const options = inTemplate ? defaultComponentOptions(context) : enclosingComponentOptions(node, context);
|
|
1164
|
+
if (options === "unknown") return "unknown";
|
|
1165
|
+
if (!options) return inTemplate ? "missing" : "not-component";
|
|
1166
|
+
if (!inTemplate && !usesComponentThis(node, options)) return "not-component";
|
|
1167
|
+
return resolveOptionsMember(options, name, context);
|
|
1168
|
+
}
|
|
1169
|
+
function resolveOptionsMember(options, name, context) {
|
|
1170
|
+
let methods = null;
|
|
1171
|
+
let localSource = false;
|
|
1172
|
+
let uncertainSource = false;
|
|
1173
|
+
for (const entry of options.properties ?? []) {
|
|
1174
|
+
if (entry.type === "SpreadElement") {
|
|
1175
|
+
methods = "unknown";
|
|
1176
|
+
uncertainSource = true;
|
|
1177
|
+
continue;
|
|
1178
|
+
}
|
|
1179
|
+
if (entry.type !== "Property") continue;
|
|
1180
|
+
const key = staticPropertyName(entry);
|
|
1181
|
+
if (key === null) {
|
|
1182
|
+
methods = "unknown";
|
|
1183
|
+
uncertainSource = true;
|
|
1184
|
+
continue;
|
|
1185
|
+
}
|
|
1186
|
+
if (key === "methods") {
|
|
1187
|
+
const value = unwrapExpression(entry.value);
|
|
1188
|
+
methods = value?.type === "ObjectExpression" ? value : "unknown";
|
|
1189
|
+
continue;
|
|
1190
|
+
}
|
|
1191
|
+
if (STATIC_INSTANCE_SOURCES.has(key)) {
|
|
1192
|
+
const origin = resolveStaticSourceMember(entry.value, name, context);
|
|
1193
|
+
if (origin === "local") localSource = true;
|
|
1194
|
+
if (origin === "unknown") uncertainSource = true;
|
|
1195
|
+
continue;
|
|
1196
|
+
}
|
|
1197
|
+
if (RETURNED_INSTANCE_SOURCES.has(key)) {
|
|
1198
|
+
const origin = resolveReturnedSourceMember(entry.value, name, context);
|
|
1199
|
+
if (origin === "local") localSource = true;
|
|
1200
|
+
if (origin === "unknown") uncertainSource = true;
|
|
1201
|
+
continue;
|
|
1202
|
+
}
|
|
1203
|
+
if (UNCERTAIN_INSTANCE_SOURCES.has(key)) uncertainSource = true;
|
|
1204
|
+
}
|
|
1205
|
+
if (methods === "unknown") return "unknown";
|
|
1206
|
+
const methodOrigin = methods ? resolveObjectMember(methods, name, context) : "missing";
|
|
1207
|
+
if (methodOrigin === "local" || localSource) return "local";
|
|
1208
|
+
if (methodOrigin === "unknown" || uncertainSource) return "unknown";
|
|
1209
|
+
return methodOrigin;
|
|
1210
|
+
}
|
|
1211
|
+
function resolveStaticSourceMember(rawValue, name, context) {
|
|
1212
|
+
const value = unwrapExpression(rawValue);
|
|
1213
|
+
if (!value) return "unknown";
|
|
1214
|
+
if (value.type === "ObjectExpression") {
|
|
1215
|
+
const origin = resolveObjectMember(value, name, context);
|
|
1216
|
+
return origin === "missing" ? "missing" : origin === "unknown" ? "unknown" : "local";
|
|
1217
|
+
}
|
|
1218
|
+
if (value.type !== "ArrayExpression") return "unknown";
|
|
1219
|
+
for (const element of value.elements ?? []) {
|
|
1220
|
+
if (propertyName(element) === name) return "local";
|
|
1221
|
+
if (element.type !== "Literal") return "unknown";
|
|
1222
|
+
}
|
|
1223
|
+
return "missing";
|
|
1224
|
+
}
|
|
1225
|
+
function resolveReturnedSourceMember(rawValue, name, context) {
|
|
1226
|
+
const fn = unwrapExpression(rawValue);
|
|
1227
|
+
if (!fn || fn.type !== "FunctionExpression" && fn.type !== "ArrowFunctionExpression") return "unknown";
|
|
1228
|
+
const rawBody = fn.body;
|
|
1229
|
+
if (!rawBody) return "unknown";
|
|
1230
|
+
if (rawBody.type === "ObjectExpression") return localObjectMemberOrigin(rawBody, name, context);
|
|
1231
|
+
if (rawBody.type !== "BlockStatement") return "unknown";
|
|
1232
|
+
const statements = rawBody.body ?? [];
|
|
1233
|
+
const returned = statements.at(-1);
|
|
1234
|
+
if (returned?.type !== "ReturnStatement" || statements.slice(0, -1).some((statement) => !isStraightLineStatement(statement))) return "unknown";
|
|
1235
|
+
const value = unwrapExpression(returned.argument);
|
|
1236
|
+
return value?.type === "ObjectExpression" ? localObjectMemberOrigin(value, name, context) : "unknown";
|
|
1237
|
+
}
|
|
1238
|
+
function localObjectMemberOrigin(object, name, context) {
|
|
1239
|
+
const origin = resolveObjectMember(object, name, context);
|
|
1240
|
+
return origin === "missing" ? "missing" : origin === "unknown" ? "unknown" : "local";
|
|
1241
|
+
}
|
|
1242
|
+
function isStraightLineStatement(node) {
|
|
1243
|
+
return node.type === "ExpressionStatement" || node.type === "FunctionDeclaration" || node.type === "VariableDeclaration";
|
|
1244
|
+
}
|
|
1245
|
+
function resolveObjectMember(object, name, context) {
|
|
1246
|
+
let origin = "missing";
|
|
1247
|
+
for (const entry of object.properties ?? []) {
|
|
1248
|
+
if (entry.type === "SpreadElement") {
|
|
1249
|
+
origin = "unknown";
|
|
1250
|
+
continue;
|
|
1251
|
+
}
|
|
1252
|
+
if (entry.type !== "Property") continue;
|
|
1253
|
+
const key = staticPropertyName(entry);
|
|
1254
|
+
if (key === null) {
|
|
1255
|
+
origin = "unknown";
|
|
1256
|
+
continue;
|
|
1257
|
+
}
|
|
1258
|
+
if (key !== name) continue;
|
|
1259
|
+
origin = methodValueOrigin(entry.value, context);
|
|
1260
|
+
}
|
|
1261
|
+
return origin;
|
|
1262
|
+
}
|
|
1263
|
+
function methodValueOrigin(rawValue, context) {
|
|
1264
|
+
const value = unwrapExpression(rawValue);
|
|
1265
|
+
if (!value) return "unknown";
|
|
1266
|
+
if (value.type !== "Identifier" || !value.name) return "local";
|
|
1267
|
+
const variable = findVariable(context, value);
|
|
1268
|
+
if (!variable) return "unknown";
|
|
1269
|
+
return variable.defs.some(isRuntimeTImport) ? "ai-i18n" : "local";
|
|
1270
|
+
}
|
|
1271
|
+
function findVariable(context, identifier) {
|
|
1272
|
+
let scope = context.sourceCode.getScope(identifier);
|
|
1273
|
+
while (scope) {
|
|
1274
|
+
const variable = scope.set.get(identifier.name);
|
|
1275
|
+
if (variable) return variable;
|
|
1276
|
+
scope = scope.upper;
|
|
1277
|
+
}
|
|
1278
|
+
return null;
|
|
1279
|
+
}
|
|
1280
|
+
function isRuntimeTImport(definition) {
|
|
1281
|
+
if (definition.type !== "ImportBinding") return false;
|
|
1282
|
+
const specifier = definition.node;
|
|
1283
|
+
const declaration = specifier.parent;
|
|
1284
|
+
return specifier.type === "ImportSpecifier" && declaration?.type === "ImportDeclaration" && declaration.source?.value === RUNTIME_MODULE && propertyName(specifier.imported) === "t";
|
|
1285
|
+
}
|
|
1286
|
+
function enclosingComponentOptions(node, context) {
|
|
1287
|
+
let current = node;
|
|
1288
|
+
while (current.parent) {
|
|
1289
|
+
current = current.parent;
|
|
1290
|
+
if (current.type === "ObjectExpression" && isComponentOptions(current, context)) return current;
|
|
1291
|
+
}
|
|
1292
|
+
return null;
|
|
1293
|
+
}
|
|
1294
|
+
function defaultComponentOptions(context) {
|
|
1295
|
+
const owner = context.sourceCode.ast.body?.find((entry) => entry.type === "ExportDefaultDeclaration");
|
|
1296
|
+
if (!owner) return null;
|
|
1297
|
+
const declaration = unwrapExpression(owner.declaration);
|
|
1298
|
+
if (!declaration) return "unknown";
|
|
1299
|
+
if (declaration.type === "ObjectExpression") return declaration;
|
|
1300
|
+
if (declaration.type !== "CallExpression" || !isImportedVueDefineComponent(declaration.callee, context)) return "unknown";
|
|
1301
|
+
const options = unwrapExpression(declaration.arguments?.[0]);
|
|
1302
|
+
return options?.type === "ObjectExpression" ? options : "unknown";
|
|
1303
|
+
}
|
|
1304
|
+
function isComponentOptions(options, context) {
|
|
1305
|
+
const value = unwrapParent(options);
|
|
1306
|
+
const owner = value.parent;
|
|
1307
|
+
if (owner?.type === "ExportDefaultDeclaration" && owner.declaration === value) return true;
|
|
1308
|
+
return owner?.type === "CallExpression" && owner.arguments?.[0] === value && isImportedVueDefineComponent(owner.callee, context);
|
|
1309
|
+
}
|
|
1310
|
+
function usesComponentThis(node, options) {
|
|
1311
|
+
let current = node;
|
|
1312
|
+
while (current.parent && current.parent !== options) {
|
|
1313
|
+
current = current.parent;
|
|
1314
|
+
if (current.type === "FunctionDeclaration" || current.type === "FunctionExpression") return isDirectOptionsFunction(current, options);
|
|
1315
|
+
}
|
|
1316
|
+
return false;
|
|
1317
|
+
}
|
|
1318
|
+
function isDirectOptionsFunction(fn, options) {
|
|
1319
|
+
const property = fn.parent;
|
|
1320
|
+
if (property?.type !== "Property" || unwrapExpression(property.value) !== fn) return false;
|
|
1321
|
+
if (property.parent === options) return true;
|
|
1322
|
+
const section = property.parent;
|
|
1323
|
+
const sectionProperty = section?.parent;
|
|
1324
|
+
return section?.type === "ObjectExpression" && sectionProperty?.type === "Property" && unwrapExpression(sectionProperty.value) === section && sectionProperty.parent === options;
|
|
1325
|
+
}
|
|
1326
|
+
function staticPropertyName(property) {
|
|
1327
|
+
return propertyName(property.key) ?? null;
|
|
1328
|
+
}
|
|
1329
|
+
function unwrapExpression(node) {
|
|
1330
|
+
let value = node;
|
|
1331
|
+
while (value && TRANSPARENT_EXPRESSIONS.has(value.type)) value = value.expression;
|
|
1332
|
+
return value;
|
|
1333
|
+
}
|
|
1334
|
+
function unwrapParent(node) {
|
|
1335
|
+
let value = node;
|
|
1336
|
+
while (value.parent && TRANSPARENT_EXPRESSIONS.has(value.parent.type) && value.parent.expression === value) value = value.parent;
|
|
1337
|
+
return value;
|
|
1338
|
+
}
|
|
1339
|
+
//#endregion
|
|
1340
|
+
//#region src/rules/vue/no-unsubscribed-t.ts
|
|
1341
|
+
function reportVueTranslationLifecycle(context, call, node, templateNodes, jsxOwners) {
|
|
1342
|
+
if (call.origin === "vue-computed") {
|
|
1343
|
+
if (!isOptionsComputedValue(node, context)) context.report({
|
|
1344
|
+
node,
|
|
1345
|
+
messageId: "misplacedTComputed"
|
|
1346
|
+
});
|
|
1347
|
+
return true;
|
|
1348
|
+
}
|
|
1349
|
+
if (call.origin !== "vue-ref") return false;
|
|
1350
|
+
if (templateNodes.has(node)) {
|
|
1351
|
+
if (!isVueTemplateEventHandler(node)) context.report({
|
|
1352
|
+
node,
|
|
1353
|
+
messageId: "renderTRef"
|
|
1354
|
+
});
|
|
1355
|
+
return true;
|
|
1356
|
+
}
|
|
1357
|
+
const section = vueOptionsSection(node, context);
|
|
1358
|
+
if (section === "computed" || section === "data" || section === "methods") {
|
|
1359
|
+
context.report({
|
|
1360
|
+
node,
|
|
1361
|
+
messageId: "optionsTRef"
|
|
1362
|
+
});
|
|
1363
|
+
return true;
|
|
1364
|
+
}
|
|
1365
|
+
if (section === "render") {
|
|
1366
|
+
context.report({
|
|
1367
|
+
node,
|
|
1368
|
+
messageId: "renderTRef"
|
|
1369
|
+
});
|
|
1370
|
+
return true;
|
|
1371
|
+
}
|
|
1372
|
+
const owner = nearestFunction(node);
|
|
1373
|
+
if (owner && jsxOwners.has(owner)) context.report({
|
|
1374
|
+
node,
|
|
1375
|
+
messageId: "renderTRef"
|
|
1376
|
+
});
|
|
1377
|
+
return true;
|
|
1378
|
+
}
|
|
1379
|
+
function reportUnsupportedVueInstanceTranslation(context, node, isVueFramework, inTemplate) {
|
|
1380
|
+
if (!isVueFramework) return;
|
|
1381
|
+
const name = vueInstanceTranslationMemberName(node);
|
|
1382
|
+
if (!name) return;
|
|
1383
|
+
const origin = resolveVueInstanceMemberOrigin(context, node, name, inTemplate);
|
|
1384
|
+
if (origin === "local" || origin === "unknown" || origin === "not-component") return;
|
|
1385
|
+
context.report({
|
|
1386
|
+
node,
|
|
1387
|
+
messageId: "unsupportedInstanceTranslation"
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
function vueInstanceTranslationMemberName(node) {
|
|
1391
|
+
if (node.type !== "MemberExpression" || node.object.type !== "ThisExpression") return null;
|
|
1392
|
+
const name = !node.computed && node.property.type === "Identifier" ? node.property.name : node.computed && node.property.type === "Literal" && typeof node.property.value === "string" ? node.property.value : null;
|
|
1393
|
+
return name === "t" || name === "$t" ? name : null;
|
|
1394
|
+
}
|
|
1395
|
+
//#endregion
|
|
1396
|
+
//#region src/rules/common/no-unsubscribed-t.ts
|
|
1397
|
+
const noUnsubscribedT = {
|
|
1398
|
+
meta: {
|
|
1399
|
+
type: "problem",
|
|
1400
|
+
docs: { description: "检查组件渲染路径中的翻译 API 生命周期" },
|
|
1401
|
+
schema: [{
|
|
1402
|
+
type: "object",
|
|
1403
|
+
properties: {
|
|
1404
|
+
tsconfigPath: { type: "string" },
|
|
1405
|
+
autoImport: { anyOf: [{ type: "boolean" }, {
|
|
1406
|
+
type: "array",
|
|
1407
|
+
items: { enum: [
|
|
1408
|
+
"t",
|
|
1409
|
+
"tRef",
|
|
1410
|
+
"tComputed",
|
|
1411
|
+
"useI18n"
|
|
1412
|
+
] },
|
|
1413
|
+
uniqueItems: true
|
|
1414
|
+
}] },
|
|
1415
|
+
framework: { enum: ["react", "vue"] }
|
|
1416
|
+
},
|
|
1417
|
+
additionalProperties: false
|
|
1418
|
+
}],
|
|
1419
|
+
messages: {
|
|
1420
|
+
unsubscribedT: diagnosticMessage("组件渲染期间使用顶层 t 不会订阅语言状态,语言切换不会主动刷新,缓存的渲染结果也可能继续使用旧译文。请使用 useI18n() 返回的 t。", "Top-level t does not subscribe the component to language updates, so language changes do not trigger a render and cached render results may remain stale. Use the t returned by useI18n()."),
|
|
1421
|
+
renderTRef: diagnosticMessage("不要在组件渲染或 template 中调用 tRef(),否则每次渲染都会创建新的 computed。请在 setup 中创建一次并使用返回的 Ref;Vue 渲染函数中请直接调用 t。", "Do not call tRef() during component rendering or in a template because each render creates a new computed. Create it once in setup and use the returned Ref; call t directly in Vue render functions."),
|
|
1422
|
+
misplacedTComputed: diagnosticMessage("tComputed() 只应直接作为纯 Options API 的 computed 属性值使用,例如 computed: { label: tComputed(\"保存\") }。setup 中请使用 tRef(),template 或 render 中请直接使用 t()。", "Use tComputed() only as the direct value of a pure Options API computed property, for example computed: { label: tComputed(\"Save\") }. Use tRef() in setup, and call t() directly in templates or render functions."),
|
|
1423
|
+
optionsTRef: diagnosticMessage("不要在纯 Options API 的 computed、data 或 methods 中调用 tRef()。computed 请直接使用 tComputed(),methods 请在执行时调用 t();tRef() 仅用于 setup 或 composable。", "Do not call tRef() in pure Options API computed, data, or methods. Use tComputed() directly in computed properties, call t() when a method runs, and reserve tRef() for setup or composables."),
|
|
1424
|
+
unsupportedInstanceTranslation: diagnosticMessage("ai-i18n 不支持把 Vue 组件实例成员 this.t / this.$t 当作翻译 API。请直接调用词法作用域中的 t();开启自动导入时无需 import,关闭时请从 virtual:ai-i18n 显式导入。", "ai-i18n does not support Vue instance members this.t or this.$t as translation APIs. Call lexical t() directly; no import is needed with auto import enabled, otherwise import it from virtual:ai-i18n.")
|
|
1425
|
+
}
|
|
1426
|
+
},
|
|
1427
|
+
create(context) {
|
|
1428
|
+
const options = context.options[0] ?? {};
|
|
1429
|
+
const candidates = [];
|
|
1430
|
+
const jsxOwners = /* @__PURE__ */ new Set();
|
|
1431
|
+
const templateNodes = /* @__PURE__ */ new Set();
|
|
1432
|
+
const collectJsxOwner = (node) => {
|
|
1433
|
+
const owner = nearestFunction(node);
|
|
1434
|
+
if (owner) jsxOwners.add(owner);
|
|
1435
|
+
};
|
|
1436
|
+
const scriptVisitor = {
|
|
1437
|
+
CallExpression(node) {
|
|
1438
|
+
candidates.push({
|
|
1439
|
+
kind: "call",
|
|
1440
|
+
node
|
|
1441
|
+
});
|
|
1442
|
+
},
|
|
1443
|
+
TaggedTemplateExpression(node) {
|
|
1444
|
+
candidates.push({
|
|
1445
|
+
kind: "tagged-template",
|
|
1446
|
+
node
|
|
1447
|
+
});
|
|
1448
|
+
},
|
|
1449
|
+
MemberExpression(node) {
|
|
1450
|
+
reportUnsupportedVueInstanceTranslation(context, node, options.framework === "vue", false);
|
|
1451
|
+
},
|
|
1452
|
+
JSXElement: collectJsxOwner,
|
|
1453
|
+
JSXFragment: collectJsxOwner,
|
|
1454
|
+
"Program:exit"(program) {
|
|
1455
|
+
let matches;
|
|
1456
|
+
try {
|
|
1457
|
+
matches = matchTranslationCalls(context, options, candidates);
|
|
1458
|
+
} catch (error) {
|
|
1459
|
+
reportAnalysisFailureOnce(context, program, error);
|
|
1460
|
+
return;
|
|
1461
|
+
}
|
|
1462
|
+
for (const { call, node } of matches) {
|
|
1463
|
+
if (reportVueTranslationLifecycle(context, call, node, templateNodes, jsxOwners)) continue;
|
|
1464
|
+
if (call.origin !== "runtime") continue;
|
|
1465
|
+
if (options.framework === "vue") continue;
|
|
1466
|
+
if (templateNodes.has(node)) continue;
|
|
1467
|
+
reportReactUnsubscribedT(context, call, node, jsxOwners);
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
};
|
|
1471
|
+
const parserServices = context.sourceCode.parserServices;
|
|
1472
|
+
if (!parserServices.defineTemplateBodyVisitor) return scriptVisitor;
|
|
1473
|
+
return parserServices.defineTemplateBodyVisitor({
|
|
1474
|
+
CallExpression(node) {
|
|
1475
|
+
candidates.push({
|
|
1476
|
+
kind: "call",
|
|
1477
|
+
node
|
|
1478
|
+
});
|
|
1479
|
+
templateNodes.add(node);
|
|
1480
|
+
},
|
|
1481
|
+
TaggedTemplateExpression(node) {
|
|
1482
|
+
candidates.push({
|
|
1483
|
+
kind: "tagged-template",
|
|
1484
|
+
node
|
|
1485
|
+
});
|
|
1486
|
+
templateNodes.add(node);
|
|
1487
|
+
},
|
|
1488
|
+
MemberExpression(node) {
|
|
1489
|
+
reportUnsupportedVueInstanceTranslation(context, node, options.framework === "vue", true);
|
|
1490
|
+
}
|
|
1491
|
+
}, scriptVisitor, { templateBodyTriggerSelector: "Program" });
|
|
1492
|
+
}
|
|
1493
|
+
};
|
|
1494
|
+
//#endregion
|
|
1495
|
+
//#region src/rules/common/static-candidate-limit.ts
|
|
1496
|
+
const staticCandidateLimit = {
|
|
1497
|
+
meta: {
|
|
1498
|
+
type: "suggestion",
|
|
1499
|
+
docs: { description: "警告单个翻译调用展开的静态候选数量过多" },
|
|
1500
|
+
schema: [{
|
|
1501
|
+
type: "object",
|
|
1502
|
+
properties: {
|
|
1503
|
+
tsconfigPath: { type: "string" },
|
|
1504
|
+
autoImport: { anyOf: [{ type: "boolean" }, {
|
|
1505
|
+
type: "array",
|
|
1506
|
+
items: { enum: [
|
|
1507
|
+
"t",
|
|
1508
|
+
"tRef",
|
|
1509
|
+
"tComputed",
|
|
1510
|
+
"useI18n"
|
|
1511
|
+
] },
|
|
1512
|
+
uniqueItems: true
|
|
1513
|
+
}] },
|
|
1514
|
+
maxStaticCandidates: {
|
|
1515
|
+
type: "integer",
|
|
1516
|
+
minimum: 1,
|
|
1517
|
+
maximum: Number.MAX_SAFE_INTEGER
|
|
1518
|
+
}
|
|
1519
|
+
},
|
|
1520
|
+
additionalProperties: false
|
|
1521
|
+
}],
|
|
1522
|
+
messages: { candidateLimit: "{{reason}}" }
|
|
1523
|
+
},
|
|
1524
|
+
create(context) {
|
|
1525
|
+
const options = context.options[0] ?? {};
|
|
1526
|
+
return { "Program:exit"(node) {
|
|
1527
|
+
try {
|
|
1528
|
+
const warnings = analyzeRuleContext(context, options, options.maxStaticCandidates ?? 1e3);
|
|
1529
|
+
for (const warning of warnings) {
|
|
1530
|
+
if (warning.code !== "static-candidate-limit") continue;
|
|
1531
|
+
context.report({
|
|
1532
|
+
node,
|
|
1533
|
+
loc: {
|
|
1534
|
+
start: warning,
|
|
1535
|
+
end: {
|
|
1536
|
+
line: warning.line,
|
|
1537
|
+
column: warning.column + 1
|
|
1538
|
+
}
|
|
1539
|
+
},
|
|
1540
|
+
messageId: "candidateLimit",
|
|
1541
|
+
data: { reason: warning.message }
|
|
1542
|
+
});
|
|
1543
|
+
}
|
|
1544
|
+
} catch (error) {
|
|
1545
|
+
reportAnalysisFailureOnce(context, node, error);
|
|
1546
|
+
}
|
|
1547
|
+
} };
|
|
1548
|
+
}
|
|
1549
|
+
};
|
|
1550
|
+
//#endregion
|
|
1551
|
+
//#region src/rules/common/t-static-args.ts
|
|
180
1552
|
const tStaticArgs = {
|
|
181
1553
|
meta: {
|
|
182
1554
|
type: "problem",
|
|
183
|
-
docs: { description: "要求 virtual:ai-i18n
|
|
1555
|
+
docs: { description: "要求 virtual:ai-i18n 的翻译 API 参数可被静态提取" },
|
|
184
1556
|
schema: [{
|
|
185
1557
|
type: "object",
|
|
186
1558
|
properties: {
|
|
187
1559
|
tsconfigPath: { type: "string" },
|
|
188
|
-
autoImport: { type: "boolean" }
|
|
1560
|
+
autoImport: { anyOf: [{ type: "boolean" }, {
|
|
1561
|
+
type: "array",
|
|
1562
|
+
items: { enum: [
|
|
1563
|
+
"t",
|
|
1564
|
+
"tRef",
|
|
1565
|
+
"tComputed",
|
|
1566
|
+
"useI18n"
|
|
1567
|
+
] },
|
|
1568
|
+
uniqueItems: true
|
|
1569
|
+
}] }
|
|
189
1570
|
},
|
|
190
1571
|
additionalProperties: false
|
|
191
1572
|
}],
|
|
192
1573
|
messages: {
|
|
193
|
-
analysisFailed: "
|
|
194
|
-
dynamicArg: "
|
|
1574
|
+
analysisFailed: "{{reason}}",
|
|
1575
|
+
dynamicArg: "{{reason}}",
|
|
1576
|
+
invalidUsage: "{{reason}}"
|
|
195
1577
|
}
|
|
196
1578
|
},
|
|
197
1579
|
create(context) {
|
|
@@ -199,26 +1581,14 @@ const tStaticArgs = {
|
|
|
199
1581
|
return { "Program:exit"(node) {
|
|
200
1582
|
let warnings;
|
|
201
1583
|
try {
|
|
202
|
-
|
|
203
|
-
code: context.sourceCode.text,
|
|
204
|
-
lang: void 0,
|
|
205
|
-
mapLocation: (location) => location
|
|
206
|
-
};
|
|
207
|
-
warnings = analyzeStaticArgs(source.code, context.filename, options.tsconfigPath, source.lang, options.autoImport);
|
|
208
|
-
warnings = warnings.map((warning) => ({
|
|
209
|
-
...warning,
|
|
210
|
-
...source.mapLocation(warning)
|
|
211
|
-
}));
|
|
1584
|
+
warnings = analyzeRuleContext(context, options);
|
|
212
1585
|
} catch (error) {
|
|
213
|
-
context
|
|
214
|
-
node,
|
|
215
|
-
messageId: "analysisFailed",
|
|
216
|
-
data: { reason: error instanceof Error ? error.message : String(error) }
|
|
217
|
-
});
|
|
1586
|
+
reportAnalysisFailureOnce(context, node, error);
|
|
218
1587
|
return;
|
|
219
1588
|
}
|
|
220
1589
|
for (const warning of warnings) {
|
|
221
1590
|
const analysisFailed = warning.code === "parse-error";
|
|
1591
|
+
const invalidUsage = warning.code !== "dynamic-argument" && warning.code !== "unresolved-argument" && !analysisFailed;
|
|
222
1592
|
context.report({
|
|
223
1593
|
node,
|
|
224
1594
|
loc: {
|
|
@@ -228,8 +1598,8 @@ const tStaticArgs = {
|
|
|
228
1598
|
column: warning.column + 1
|
|
229
1599
|
}
|
|
230
1600
|
},
|
|
231
|
-
messageId: analysisFailed ? "analysisFailed" : "dynamicArg",
|
|
232
|
-
|
|
1601
|
+
messageId: analysisFailed ? "analysisFailed" : invalidUsage ? "invalidUsage" : "dynamicArg",
|
|
1602
|
+
data: { reason: analysisFailed ? diagnosticMessage(`静态分析失败:${warning.message}`, `Static analysis failed: ${warning.message}`) : invalidUsage ? warning.message : diagnosticMessage("翻译调用的参数无法静态提取。source 请使用静态字符串,options 请使用只包含 comment 的静态对象。", "The translation-call arguments cannot be statically extracted. Use a static string for source and a static object containing only comment for options.") }
|
|
233
1603
|
});
|
|
234
1604
|
}
|
|
235
1605
|
} };
|
|
@@ -244,39 +1614,129 @@ const plugin = {
|
|
|
244
1614
|
version,
|
|
245
1615
|
namespace: "ai-i18n"
|
|
246
1616
|
},
|
|
247
|
-
rules: {
|
|
1617
|
+
rules: {
|
|
1618
|
+
"no-embedded-markup": noEmbeddedMarkup,
|
|
1619
|
+
"no-eager-translation": noEagerTranslation,
|
|
1620
|
+
"no-redundant-auto-import": noRedundantAutoImport,
|
|
1621
|
+
"no-unsubscribed-runtime-state": noUnsubscribedRuntimeState,
|
|
1622
|
+
"no-unsubscribed-t": noUnsubscribedT,
|
|
1623
|
+
"static-candidate-limit": staticCandidateLimit,
|
|
1624
|
+
"t-static-args": tStaticArgs
|
|
1625
|
+
},
|
|
248
1626
|
configs: {}
|
|
249
1627
|
};
|
|
250
1628
|
plugin.configs.recommended = [{
|
|
251
|
-
ignores: ["**/*.vue"],
|
|
1629
|
+
ignores: ["**/*.{vue,cjs,cts}"],
|
|
252
1630
|
plugins: { "ai-i18n": plugin },
|
|
253
|
-
|
|
1631
|
+
languageOptions: { globals: { defineI18nMessages: "readonly" } },
|
|
1632
|
+
rules: {
|
|
1633
|
+
"ai-i18n/t-static-args": "error",
|
|
1634
|
+
"ai-i18n/no-embedded-markup": "warn",
|
|
1635
|
+
"ai-i18n/no-eager-translation": "warn",
|
|
1636
|
+
"ai-i18n/no-unsubscribed-runtime-state": "warn",
|
|
1637
|
+
"ai-i18n/no-unsubscribed-t": "warn",
|
|
1638
|
+
"ai-i18n/static-candidate-limit": "warn"
|
|
1639
|
+
}
|
|
254
1640
|
}];
|
|
255
1641
|
plugin.configs.vue = [{
|
|
256
|
-
files: ["**/*.{js,mjs,
|
|
1642
|
+
files: ["**/*.{js,mjs,ts,mts,jsx,tsx,vue}"],
|
|
257
1643
|
plugins: { "ai-i18n": plugin },
|
|
258
|
-
languageOptions: { globals: {
|
|
259
|
-
rules: {
|
|
1644
|
+
languageOptions: { globals: { defineI18nMessages: "readonly" } },
|
|
1645
|
+
rules: {
|
|
1646
|
+
"ai-i18n/t-static-args": "error",
|
|
1647
|
+
"ai-i18n/no-embedded-markup": "warn",
|
|
1648
|
+
"ai-i18n/no-eager-translation": ["warn", { framework: "vue" }],
|
|
1649
|
+
"ai-i18n/no-unsubscribed-runtime-state": ["warn", { framework: "vue" }],
|
|
1650
|
+
"ai-i18n/no-unsubscribed-t": ["warn", { framework: "vue" }],
|
|
1651
|
+
"ai-i18n/static-candidate-limit": "warn"
|
|
1652
|
+
}
|
|
260
1653
|
}];
|
|
261
|
-
plugin.configs
|
|
262
|
-
files: ["**/*.{js,mjs,
|
|
1654
|
+
plugin.configs["vanilla-auto-import"] = [{
|
|
1655
|
+
files: ["**/*.{js,mjs,ts,mts}"],
|
|
263
1656
|
plugins: { "ai-i18n": plugin },
|
|
264
|
-
languageOptions: { globals: {
|
|
265
|
-
|
|
1657
|
+
languageOptions: { globals: {
|
|
1658
|
+
...Object.fromEntries(RUNTIME_AUTO_IMPORTS.map((name) => [name, "readonly"])),
|
|
1659
|
+
defineI18nMessages: "readonly"
|
|
1660
|
+
} },
|
|
1661
|
+
rules: {
|
|
1662
|
+
"ai-i18n/t-static-args": ["error", { autoImport: ["t"] }],
|
|
1663
|
+
"ai-i18n/no-embedded-markup": ["warn", { autoImport: ["t"] }],
|
|
1664
|
+
"ai-i18n/no-eager-translation": ["warn", { autoImport: ["t"] }],
|
|
1665
|
+
"ai-i18n/no-unsubscribed-runtime-state": ["warn", { autoImport: ["getLang", "getLangLoadState"] }],
|
|
1666
|
+
"ai-i18n/static-candidate-limit": ["warn", { autoImport: ["t"] }]
|
|
1667
|
+
}
|
|
1668
|
+
}];
|
|
1669
|
+
plugin.configs["vue-auto-import"] = [{
|
|
1670
|
+
files: ["**/*.{js,mjs,ts,mts,jsx,tsx,vue}"],
|
|
1671
|
+
plugins: { "ai-i18n": plugin },
|
|
1672
|
+
languageOptions: { globals: {
|
|
1673
|
+
...Object.fromEntries(VUE_AUTO_IMPORTS.map((name) => [name, "readonly"])),
|
|
1674
|
+
defineI18nMessages: "readonly"
|
|
1675
|
+
} },
|
|
1676
|
+
rules: {
|
|
1677
|
+
"ai-i18n/t-static-args": ["error", { autoImport: [
|
|
1678
|
+
"t",
|
|
1679
|
+
"tRef",
|
|
1680
|
+
"tComputed",
|
|
1681
|
+
"useI18n"
|
|
1682
|
+
] }],
|
|
1683
|
+
"ai-i18n/no-embedded-markup": ["warn", { autoImport: [
|
|
1684
|
+
"t",
|
|
1685
|
+
"tRef",
|
|
1686
|
+
"tComputed",
|
|
1687
|
+
"useI18n"
|
|
1688
|
+
] }],
|
|
1689
|
+
"ai-i18n/no-eager-translation": ["warn", {
|
|
1690
|
+
autoImport: [
|
|
1691
|
+
"t",
|
|
1692
|
+
"tRef",
|
|
1693
|
+
"tComputed",
|
|
1694
|
+
"useI18n"
|
|
1695
|
+
],
|
|
1696
|
+
framework: "vue"
|
|
1697
|
+
}],
|
|
1698
|
+
"ai-i18n/no-unsubscribed-runtime-state": ["warn", {
|
|
1699
|
+
autoImport: [
|
|
1700
|
+
"getLang",
|
|
1701
|
+
"getLangLoadState",
|
|
1702
|
+
"i18nComputed"
|
|
1703
|
+
],
|
|
1704
|
+
framework: "vue"
|
|
1705
|
+
}],
|
|
1706
|
+
"ai-i18n/no-unsubscribed-t": ["warn", {
|
|
1707
|
+
autoImport: [
|
|
1708
|
+
"t",
|
|
1709
|
+
"tRef",
|
|
1710
|
+
"tComputed",
|
|
1711
|
+
"useI18n"
|
|
1712
|
+
],
|
|
1713
|
+
framework: "vue"
|
|
1714
|
+
}],
|
|
1715
|
+
"ai-i18n/static-candidate-limit": ["warn", { autoImport: [
|
|
1716
|
+
"t",
|
|
1717
|
+
"tRef",
|
|
1718
|
+
"tComputed",
|
|
1719
|
+
"useI18n"
|
|
1720
|
+
] }]
|
|
1721
|
+
}
|
|
266
1722
|
}];
|
|
267
|
-
plugin.configs
|
|
268
|
-
files: ["**/*.{js,mjs,
|
|
1723
|
+
plugin.configs["react-auto-import"] = [{
|
|
1724
|
+
files: ["**/*.{js,mjs,ts,mts,jsx,tsx}"],
|
|
269
1725
|
plugins: { "ai-i18n": plugin },
|
|
270
1726
|
languageOptions: { globals: {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
getLang: "readonly",
|
|
274
|
-
getLangs: "readonly",
|
|
275
|
-
subscribe: "readonly"
|
|
1727
|
+
...Object.fromEntries(REACT_AUTO_IMPORTS.map((name) => [name, "readonly"])),
|
|
1728
|
+
defineI18nMessages: "readonly"
|
|
276
1729
|
} },
|
|
277
|
-
rules: {
|
|
1730
|
+
rules: {
|
|
1731
|
+
"ai-i18n/t-static-args": ["error", { autoImport: ["t", "useI18n"] }],
|
|
1732
|
+
"ai-i18n/no-embedded-markup": ["warn", { autoImport: ["t", "useI18n"] }],
|
|
1733
|
+
"ai-i18n/no-eager-translation": ["warn", { autoImport: ["t", "useI18n"] }],
|
|
1734
|
+
"ai-i18n/no-unsubscribed-runtime-state": ["warn", { autoImport: ["getLang", "getLangLoadState"] }],
|
|
1735
|
+
"ai-i18n/no-unsubscribed-t": ["warn", { autoImport: ["t", "useI18n"] }],
|
|
1736
|
+
"ai-i18n/static-candidate-limit": ["warn", { autoImport: ["t", "useI18n"] }]
|
|
1737
|
+
}
|
|
278
1738
|
}];
|
|
279
1739
|
//#endregion
|
|
280
|
-
export { plugin as default, tStaticArgs };
|
|
1740
|
+
export { plugin as default, noEagerTranslation, noEmbeddedMarkup, noRedundantAutoImport, noUnsubscribedRuntimeState, noUnsubscribedT, staticCandidateLimit, tStaticArgs };
|
|
281
1741
|
|
|
282
1742
|
//# sourceMappingURL=index.js.map
|