vue-i18n-lint 0.0.1

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/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ruben Gees
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,216 @@
1
+ # vue-i18n-lint
2
+
3
+ Fast and accurate linting for Vue i18n. Detects missing and unused translation keys across your locale files and source
4
+ code.
5
+
6
+ Intended for use in CI pipelines, pre-commit hooks, or as part of your development workflow to maintain translation
7
+ integrity.
8
+
9
+ ## Quick start
10
+
11
+ ```sh
12
+ npx vue-i18n-lint [command] [options] [path]
13
+ ```
14
+
15
+ `path` defaults to the current working directory.
16
+
17
+ <details>
18
+ <summary>pnpm</summary>
19
+
20
+ ```sh
21
+ pnpm dlx vue-i18n-lint [command] [options] [path]
22
+ ```
23
+
24
+ </details>
25
+
26
+ <details>
27
+ <summary>yarn</summary>
28
+
29
+ ```sh
30
+ yarn dlx vue-i18n-lint [command] [options] [path]
31
+ ```
32
+
33
+ </details>
34
+
35
+ <details>
36
+ <summary>Bun</summary>
37
+
38
+ ```sh
39
+ bunx vue-i18n-lint [command] [options] [path]
40
+ ```
41
+
42
+ </details>
43
+
44
+ ## Installation
45
+
46
+ The cli can also be installed and run locally in your project:
47
+
48
+ ```sh
49
+ npm install -D vue-i18n-lint
50
+ ```
51
+
52
+ <details>
53
+ <summary>pnpm</summary>
54
+
55
+ ```sh
56
+ pnpm add -D vue-i18n-lint
57
+ ```
58
+
59
+ </details>
60
+
61
+ <details>
62
+ <summary>yarn</summary>
63
+
64
+ ```sh
65
+ yarn add -D vue-i18n-lint
66
+ ```
67
+
68
+ </details>
69
+
70
+ <details>
71
+ <summary>Bun</summary>
72
+
73
+ ```sh
74
+ bun add -D vue-i18n-lint
75
+ ```
76
+
77
+ </details>
78
+
79
+ You can run it like this:
80
+
81
+ ```sh
82
+ vue-i18n-lint [command] [options] [path]
83
+ ```
84
+
85
+ ### Subcommands
86
+
87
+ | Command | Description |
88
+ | ---------------- | ------------------------------------------------ |
89
+ | `lint` (default) | Detect missing and unused translation keys |
90
+ | `remove-unused` | Remove unused translation keys from locale files |
91
+ | `init` | Scaffold a configuration file |
92
+
93
+ ### Options
94
+
95
+ | Option | Description | Default |
96
+ | ------------------------- | ------------------------------------------------------------- | ---------------------------------- |
97
+ | `--format` | Output format: `text`, `json`, or `toon` | `text` |
98
+ | `--locale-pattern` | Glob pattern for i18n locale files | `**/locales/*.json` |
99
+ | `--src-pattern` | Glob pattern for source files | `**/*.{ts,cts,mts,js,cjs,mjs,vue}` |
100
+ | `--ignore-patterns` | Comma-separated glob patterns to ignore | |
101
+ | `--ignore-keys` | Comma-separated keys to ignore in all checks | |
102
+ | `--ignore-missing-keys` | Comma-separated keys to ignore only in the missing keys check | |
103
+ | `--ignore-unused-keys` | Comma-separated keys to ignore only in the unused keys check | |
104
+ | `--missing-keys-severity` | Severity for missing keys: `error`, `warning`, or `off` | `error` |
105
+ | `--unused-keys-severity` | Severity for unused keys: `error`, `warning`, or `off` | `warning` |
106
+
107
+ ### Example
108
+
109
+ ```sh
110
+ npx vue-i18n-lint --locale-pattern "**/i18n/*.{json}" ./my-project
111
+ ```
112
+
113
+ ## Configuration file
114
+
115
+ In addition to CLI options, vue-i18n-lint can be configured via a config file in your project root.
116
+ [c12](https://github.com/unjs/c12) is used for config loading, so the following file names are supported:
117
+
118
+ - `vue-i18n-lint.config.ts`
119
+ - `vue-i18n-lint.config.js`
120
+ - `vue-i18n-lint.config.json`
121
+ - `vue-i18n-lint.config.yaml`
122
+
123
+ ```ts
124
+ // vue-i18n-lint.config.ts
125
+ import { defineConfig } from "vue-i18n-lint"
126
+
127
+ export default defineConfig({
128
+ localePattern: "**/i18n/*.json",
129
+ srcPattern: "**/*.{ts,vue}",
130
+ ignorePatterns: ["**/fixtures/**"],
131
+ ignoreKeys: ["dynamic.key"],
132
+ checks: {
133
+ missingKeys: {
134
+ severity: "error",
135
+ ignore: ["only.missing"],
136
+ },
137
+ unusedKeys: {
138
+ severity: "warning",
139
+ ignore: ["only.unused"],
140
+ },
141
+ },
142
+ })
143
+ ```
144
+
145
+ Use the exported `defineConfig` helper for TypeScript autocompletion.
146
+
147
+ Dynamic missing keys are reported with `<dynamic>` as a placeholder (e.g. `status.<dynamic>`).
148
+ Use that string in `ignoreKeys` or `checks.missingKeys.ignore` to suppress them.
149
+
150
+ ### gitignore
151
+
152
+ Files matched by any `.gitignore` in your project are automatically excluded when scanning locale and source files,
153
+ in addition to any `ignorePatterns` you configure.
154
+
155
+ ## What it checks
156
+
157
+ - **Missing keys**: Translation keys used in source code but not defined in any locale file
158
+ - **Unused keys**: Translation keys defined in locale files but never referenced in source code
159
+
160
+ ## Dynamic keys
161
+
162
+ Keys built from template literals or string concatenation with runtime variables are understood:
163
+
164
+ ```ts
165
+ t(`status.${type}`) // matched against locale keys as "status.*"
166
+ t("prefix." + key) // matched against locale keys as "prefix.*"
167
+ ```
168
+
169
+ A dynamic key is not reported missing if at least one locale key matches its pattern. Locale keys
170
+ that match a dynamic pattern are not reported as unused.
171
+
172
+ If no locale key matches, the missing key is reported with `<dynamic>` as a placeholder,
173
+ e.g. `status.<dynamic>`.
174
+
175
+ Purely dynamic expressions with no static fragments (e.g. `t(variable)`) are ignored.
176
+
177
+ Dynamic key support is best-effort. There are many more complex cases that can't be detected by vue-i18n-lint (yet).
178
+
179
+ > [!TIP]
180
+ > Ignoring dynamic keys is done using the `<dynamic>` placeholder, e.g. `ignoreKeys: ["status.<dynamic>"]`
181
+
182
+ ## Supported locale file formats
183
+
184
+ - JSON, JSONC, JSON5
185
+ - YAML
186
+ - JS/TS modules (expecting a default export of an object)
187
+ - `<i18n>` blocks in Vue SFCs
188
+
189
+ ## Severity levels
190
+
191
+ Each check (`missingKeys`, `unusedKeys`) supports a `severity` setting:
192
+
193
+ | Value | Behavior |
194
+ | ----------- | ----------------------------------------------------------- |
195
+ | `"error"` | Prints output and sets exit code to `1` if issues are found |
196
+ | `"warning"` | Prints output but does not set exit code to `1` |
197
+ | `"off"` | Does not print output and does not affect exit code |
198
+
199
+ ## Removing unused keys
200
+
201
+ The `remove-unused` subcommand removes unused translation keys directly from your locale files. Only the simple
202
+ file types (JSON, JSONC, JSON5, YAML) are supported (JS/TS files and `<i18n>` blocks are ignored).
203
+
204
+ ```sh
205
+ npx vue-i18n-lint remove-unused [options] [path]
206
+ ```
207
+
208
+ Pass `--dry-run` to see how many keys would be removed without making changes:
209
+
210
+ ```sh
211
+ npx vue-i18n-lint remove-unused --dry-run
212
+ ```
213
+
214
+ ## Requirements
215
+
216
+ - Node.js >= 22
package/dist/cli.mjs ADDED
@@ -0,0 +1,963 @@
1
+ #!/usr/bin/env node
2
+ import { buildApplication, buildCommand, buildRouteMap, run, text_en } from "@stricli/core";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
5
+ import { basename, extname, join, resolve } from "node:path";
6
+ import { createDefu, defu } from "defu";
7
+ import { loadConfig } from "c12";
8
+ import { z } from "zod";
9
+ import { styleText } from "node:util";
10
+ import { codeFrameColumns } from "@babel/code-frame";
11
+ import { encode } from "@toon-format/toon";
12
+ import { table } from "table";
13
+ import escape from "regexp.escape";
14
+ import { globby } from "globby";
15
+ import { parseJSON, parseJSON5, parseJSONC, parseYAML, stringifyJSON, stringifyJSON5, stringifyJSONC, stringifyYAML } from "confbox";
16
+ import { createJiti } from "jiti";
17
+ import { parse } from "@vue/compiler-sfc";
18
+ import { Visitor, parseSync } from "oxc-parser";
19
+ import { NodeTypes } from "@vue/compiler-core";
20
+ //#region src/utils.ts
21
+ const merge = createDefu((obj, key, value) => {
22
+ if (Array.isArray(value)) {
23
+ obj[key] = value;
24
+ return true;
25
+ }
26
+ return false;
27
+ });
28
+ function newPrefixSet(keys = []) {
29
+ const set = /* @__PURE__ */ new Set();
30
+ for (const key of keys) {
31
+ const parts = key.split(".");
32
+ for (let i = 1; i <= parts.length; i++) set.add(parts.slice(0, i).join("."));
33
+ }
34
+ return set;
35
+ }
36
+ function mapGetOrInsert(map, key, defaultValue) {
37
+ if (!map.has(key)) map.set(key, defaultValue);
38
+ return map.get(key);
39
+ }
40
+ function getOrInsertComputed(map, key, callback) {
41
+ if (!map.has(key)) map.set(key, callback(key));
42
+ return map.get(key);
43
+ }
44
+ function offsetToPosition(source, offset) {
45
+ const lines = source.slice(0, offset).split("\n");
46
+ return {
47
+ line: lines.length,
48
+ column: (lines[lines.length - 1]?.length ?? 0) + 1
49
+ };
50
+ }
51
+ function writeLine(writer, line = "") {
52
+ writer.write(`${line}\n`);
53
+ }
54
+ function formatFilePath(filePath, line, column) {
55
+ const base = `file://${resolve(filePath)}`;
56
+ if (line != null && column != null) return `${base}:${line}:${column}`;
57
+ if (line != null) return `${base}:${line}`;
58
+ return base;
59
+ }
60
+ function isPlainObject(value) {
61
+ return value != null && typeof value === "object" && !Array.isArray(value);
62
+ }
63
+ //#endregion
64
+ //#region src/command/init.ts
65
+ const configTemplate = `import { defineConfig } from "vue-i18n-lint"
66
+
67
+ export default defineConfig({
68
+ localePattern: "**/locales/*.json",
69
+ srcPattern: "**/*.{ts,cts,mts,js,cjs,mjs,vue}",
70
+ })
71
+ `;
72
+ const initCommand = buildCommand({
73
+ async func(_flags, path) {
74
+ const targetPath = path || ".";
75
+ const configPath = join(targetPath, "vue-i18n-lint.config.ts");
76
+ if (existsSync(configPath)) {
77
+ writeLine(this.process.stderr, `vue-i18n-lint.config.ts already exists in ${formatFilePath(targetPath)}.`);
78
+ this.process.exitCode = 1;
79
+ return;
80
+ }
81
+ await mkdir(targetPath, { recursive: true });
82
+ await writeFile(configPath, configTemplate, "utf-8");
83
+ writeLine(this.process.stdout, `Created vue-i18n-lint.config.ts in ${formatFilePath(targetPath)}.`);
84
+ },
85
+ parameters: { positional: {
86
+ kind: "tuple",
87
+ parameters: [{
88
+ brief: "Target path for the config file",
89
+ parse: String,
90
+ placeholder: "path",
91
+ optional: true
92
+ }]
93
+ } },
94
+ docs: { brief: "Create a default vue-i18n-lint configuration file" }
95
+ });
96
+ //#endregion
97
+ //#region src/config/schema.ts
98
+ const severityEnum = z.enum([
99
+ "error",
100
+ "warning",
101
+ "off"
102
+ ]);
103
+ const formatEnum = z.enum([
104
+ "text",
105
+ "json",
106
+ "toon"
107
+ ]);
108
+ const checkSchema = (defaultSeverity) => z.union([z.object({
109
+ severity: severityEnum.default(defaultSeverity),
110
+ ignore: z.array(z.string().nonempty()).default([])
111
+ }), z.literal(false).transform(() => ({
112
+ severity: "off",
113
+ ignore: []
114
+ }))]).default({
115
+ severity: defaultSeverity,
116
+ ignore: []
117
+ });
118
+ const configSchema = z.object({
119
+ path: z.string().nonempty(),
120
+ format: formatEnum.default("text"),
121
+ localePattern: z.string().nonempty().default("**/locales/*.json"),
122
+ srcPattern: z.string().nonempty().default("**/*.{ts,cts,mts,js,cjs,mjs,vue}"),
123
+ ignorePatterns: z.array(z.string().nonempty()).default([]),
124
+ ignoreKeys: z.array(z.string().nonempty()).default([]),
125
+ checks: z.object({
126
+ missingKeys: checkSchema("error"),
127
+ unusedKeys: checkSchema("warning")
128
+ }).default({
129
+ missingKeys: {
130
+ severity: "error",
131
+ ignore: []
132
+ },
133
+ unusedKeys: {
134
+ severity: "warning",
135
+ ignore: []
136
+ }
137
+ })
138
+ });
139
+ z.object({
140
+ path: z.string().optional(),
141
+ format: formatEnum.optional(),
142
+ localePattern: z.string().optional(),
143
+ srcPattern: z.string().optional(),
144
+ ignorePatterns: z.array(z.string()).optional(),
145
+ ignoreKeys: z.array(z.string()).optional(),
146
+ ignoreMissingKeys: z.array(z.string()).optional(),
147
+ ignoreUnusedKeys: z.array(z.string()).optional(),
148
+ missingKeysSeverity: severityEnum.optional(),
149
+ unusedKeysSeverity: severityEnum.optional()
150
+ });
151
+ //#endregion
152
+ //#region src/config/load.ts
153
+ async function loadVueI18nLintConfig(cliArgs) {
154
+ const path = cliArgs?.path || process.cwd();
155
+ const cliConfig = buildCliConfig(path, cliArgs);
156
+ const rawFileConfig = await loadFileConfig(path) ?? {};
157
+ const parsedFileConfig = configSchema.safeParse({
158
+ path,
159
+ ...rawFileConfig
160
+ });
161
+ if (!parsedFileConfig.success) throw new Error(`Failed to load config:\n${z.prettifyError(parsedFileConfig.error)}`);
162
+ const mergedConfig = merge(cliConfig, parsedFileConfig.data);
163
+ const parsedMergedConfig = configSchema.safeParse(mergedConfig);
164
+ if (!parsedMergedConfig.success) throw new Error(`Failed to load config:\n${z.prettifyError(parsedMergedConfig.error)}`);
165
+ return parsedMergedConfig.data;
166
+ }
167
+ function buildCliConfig(path, cliArgs) {
168
+ return {
169
+ path,
170
+ format: cliArgs?.format,
171
+ localePattern: cliArgs?.localePattern,
172
+ srcPattern: cliArgs?.srcPattern,
173
+ ignorePatterns: cliArgs?.ignorePatterns,
174
+ ignoreKeys: cliArgs?.ignoreKeys,
175
+ checks: {
176
+ missingKeys: {
177
+ ignore: cliArgs?.ignoreMissingKeys,
178
+ severity: cliArgs?.missingKeysSeverity
179
+ },
180
+ unusedKeys: {
181
+ ignore: cliArgs?.ignoreUnusedKeys,
182
+ severity: cliArgs?.unusedKeysSeverity
183
+ }
184
+ }
185
+ };
186
+ }
187
+ async function loadFileConfig(path) {
188
+ try {
189
+ return (await loadConfig({
190
+ name: "vue-i18n-lint",
191
+ cwd: path
192
+ })).config;
193
+ } catch (e) {
194
+ throw new Error(`Failed to load config file`, { cause: e });
195
+ }
196
+ }
197
+ //#endregion
198
+ //#region src/formatter.ts
199
+ function formatSummaryPart(value, severity) {
200
+ return styleText(value === 0 ? "green" : severity === "warning" ? "yellow" : "red", String(value));
201
+ }
202
+ function outputTypeWarnings(process, warnings) {
203
+ writeLine(process.stdout, styleText("bold", `Warnings (${warnings.length}):\n`));
204
+ const grouped = Object.groupBy(warnings, (it) => it.file);
205
+ for (const [file, fileTypeWarnings] of Object.entries(grouped)) {
206
+ writeLine(process.stdout, formatFilePath(file));
207
+ for (const typeWarning of fileTypeWarnings ?? []) writeLine(process.stdout, ` • Unexpected type ${styleText("italic", typeWarning.type)} for key ${typeWarning.key}`);
208
+ writeLine(process.stdout);
209
+ }
210
+ }
211
+ function outputMissingKeys(process, keys) {
212
+ writeLine(process.stdout, styleText("bold", `Missing keys (${keys.length}):\n`));
213
+ for (const key of keys) outputMissingKey(process, key);
214
+ }
215
+ function outputMissingKey(process, key) {
216
+ for (const source of key.sources) {
217
+ writeLine(process.stdout, ` ${formatFilePath(source.file)}:${source.location.start.line}:${source.location.start.column}`);
218
+ const content = readSourceFile(source.file);
219
+ if (content != null) writeLine(process.stdout, codeFrameColumns(content, source.location, {
220
+ highlightCode: true,
221
+ linesAbove: 1,
222
+ linesBelow: 1,
223
+ message: `Missing in ${styleText("bold", key.locales.join(", "))}`
224
+ }));
225
+ writeLine(process.stdout);
226
+ }
227
+ }
228
+ function readSourceFile(file) {
229
+ try {
230
+ return readFileSync(file, { encoding: "utf-8" });
231
+ } catch {
232
+ return null;
233
+ }
234
+ }
235
+ function outputUnusedKeys(process, keys) {
236
+ writeLine(process.stdout, styleText("bold", `Unused keys (${keys.length}):\n`));
237
+ const rows = keys.toSorted((a, b) => b.files.length - a.files.length || a.key.localeCompare(b.key)).map((unusedKey) => [unusedKey.key, unusedKey.files.map((file) => file.locale).join(", ")]);
238
+ const config = { drawHorizontalLine(index, size) {
239
+ return index === 0 || index === 1 || index === size;
240
+ } };
241
+ writeLine(process.stdout, table([["Key", "Locales"], ...rows], config));
242
+ }
243
+ function outputJson(process, data) {
244
+ writeLine(process.stdout, JSON.stringify(data, null, 2));
245
+ }
246
+ function outputToon(process, data) {
247
+ writeLine(process.stdout, encode(data));
248
+ }
249
+ //#endregion
250
+ //#region src/processor.ts
251
+ function processFiles(localeFiles, sourceFiles, config) {
252
+ const result = { typeWarnings: calcTypeWarnings(localeFiles) };
253
+ if (config.checks.missingKeys.severity !== "off") {
254
+ const ignoreSet = /* @__PURE__ */ new Set([...config.ignoreKeys, ...config.checks.missingKeys.ignore ?? []]);
255
+ result.missing = calcMissingKeys(localeFiles, sourceFiles).filter((entry) => !ignoreSet.has(entry.key));
256
+ }
257
+ if (config.checks.unusedKeys.severity !== "off") {
258
+ const ignoreSet = /* @__PURE__ */ new Set([...config.ignoreKeys, ...config.checks.unusedKeys.ignore ?? []]);
259
+ result.unused = calcUnusedKeys(localeFiles, sourceFiles).filter((entry) => !ignoreSet.has(entry.key));
260
+ }
261
+ return result;
262
+ }
263
+ function calcTypeWarnings(localeFiles) {
264
+ return localeFiles.flatMap((localeFile) => localeFile.keys.filter((key) => key.type !== "string" && key.type !== "function").map((key) => ({
265
+ key: key.key,
266
+ locale: localeFile.locale,
267
+ file: localeFile.file,
268
+ type: key.type
269
+ })));
270
+ }
271
+ function calcMissingKeys(localeFiles, sourceFiles) {
272
+ const globalLocalePrefixes = new Map(localeFiles.map((it) => [it.locale, newPrefixSet(it.keys.map((k) => k.key))]));
273
+ const locales = /* @__PURE__ */ new Set([...globalLocalePrefixes.keys(), ...sourceFiles.flatMap((sf) => sf.localeFiles.map((lf) => lf.locale))]);
274
+ const missingKeys = /* @__PURE__ */ new Map();
275
+ const regexCache = /* @__PURE__ */ new Map();
276
+ for (const sourceFile of sourceFiles) {
277
+ const localLocalePrefixes = new Map(sourceFile.localeFiles.map((it) => [it.locale, newPrefixSet(it.keys.map((k) => k.key))]));
278
+ for (const { key, file, location } of sourceFile.keys) if (typeof key !== "string") {
279
+ const keyStr = dynamicKeyToString(key);
280
+ const regex = getOrInsertComputed(regexCache, keyStr, () => buildDynamicKeyRegex(key));
281
+ const missingLocales = Array.from(locales).filter((locale) => !setMatchesRegex(localLocalePrefixes.get(locale), regex) && !setMatchesRegex(globalLocalePrefixes.get(locale), regex));
282
+ if (missingLocales.length > 0) mapGetOrInsert(missingKeys, keyStr, {
283
+ key: keyStr,
284
+ locales: missingLocales,
285
+ sources: []
286
+ }).sources.push({
287
+ file,
288
+ location
289
+ });
290
+ } else {
291
+ const missingLocales = Array.from(locales).filter((locale) => !localLocalePrefixes.get(locale)?.has(key) && !globalLocalePrefixes.get(locale)?.has(key));
292
+ if (missingLocales.length > 0) mapGetOrInsert(missingKeys, key, {
293
+ key,
294
+ locales: missingLocales,
295
+ sources: []
296
+ }).sources.push({
297
+ file,
298
+ location
299
+ });
300
+ }
301
+ }
302
+ return Array.from(missingKeys.values());
303
+ }
304
+ function dynamicKeyToString(dynamicKey) {
305
+ return dynamicKey.map((part) => typeof part === "string" ? part : "<dynamic>").join("");
306
+ }
307
+ function buildDynamicKeyRegex(dynamicKey) {
308
+ return new RegExp("^" + dynamicKey.map((part) => typeof part === "string" ? escape(part) : ".*").join("") + "$");
309
+ }
310
+ function setMatchesRegex(set, regex) {
311
+ if (!set) return false;
312
+ return set.values().some((item) => regex.test(item));
313
+ }
314
+ function calcUnusedKeys(localeFiles, sourceFiles) {
315
+ const unusedKeys = /* @__PURE__ */ new Map();
316
+ const sourceKeys = /* @__PURE__ */ new Set();
317
+ const sourceDynamicKeys = /* @__PURE__ */ new Map();
318
+ for (const sourceFile of sourceFiles) {
319
+ const sourceFileKeys = /* @__PURE__ */ new Set();
320
+ const sourceFileDynamicKeys = /* @__PURE__ */ new Map();
321
+ for (const k of sourceFile.keys) if (typeof k.key === "string") sourceFileKeys.add(k.key);
322
+ else {
323
+ const keyStr = dynamicKeyToString(k.key);
324
+ if (!sourceFileDynamicKeys.has(keyStr)) sourceFileDynamicKeys.set(keyStr, buildDynamicKeyRegex(k.key));
325
+ }
326
+ const sourceDynamicRegex = combineRegexes(Array.from(sourceFileDynamicKeys.values()));
327
+ calcUnusedKeysInLocaleFiles(unusedKeys, sourceFile.localeFiles, sourceFileKeys, sourceDynamicRegex);
328
+ for (const key of sourceFileKeys) sourceKeys.add(key);
329
+ for (const [keyStr, regex] of sourceFileDynamicKeys) if (!sourceDynamicKeys.has(keyStr)) sourceDynamicKeys.set(keyStr, regex);
330
+ }
331
+ calcUnusedKeysInLocaleFiles(unusedKeys, localeFiles, sourceKeys, combineRegexes(Array.from(sourceDynamicKeys.values())));
332
+ return Array.from(unusedKeys.values());
333
+ }
334
+ function combineRegexes(regexes) {
335
+ if (regexes.length === 0) return null;
336
+ return new RegExp(regexes.map((r) => `(?:${r.source})`).join("|"));
337
+ }
338
+ function calcUnusedKeysInLocaleFiles(unusedKeys, localeFiles, sourceKeys, sourceDynamicRegex) {
339
+ for (const localeFile of localeFiles) for (const { key } of localeFile.keys) {
340
+ const parts = key.split(".");
341
+ if (!parts.some((_, i) => {
342
+ const joined = parts.slice(0, i + 1).join(".");
343
+ return sourceKeys.has(joined) || sourceDynamicRegex?.test(joined);
344
+ })) mapGetOrInsert(unusedKeys, key, {
345
+ key,
346
+ files: []
347
+ }).files.push({
348
+ locale: localeFile.locale,
349
+ file: localeFile.file,
350
+ scope: localeFile.scope
351
+ });
352
+ }
353
+ }
354
+ //#endregion
355
+ //#region src/error.ts
356
+ var ParseError = class extends Error {
357
+ file;
358
+ options;
359
+ constructor(message, file, options) {
360
+ super(message, { cause: options?.cause });
361
+ this.file = file;
362
+ this.options = options;
363
+ this.name = "ParseError";
364
+ }
365
+ };
366
+ function formatErrorMessage(e) {
367
+ if (!(e instanceof Error)) return String(e);
368
+ if (e instanceof ParseError) {
369
+ const location = formatFilePath(e.file, e.options?.line, e.options?.column);
370
+ const detail = e.cause != null ? `: ${formatErrorMessage(e.cause)}` : "";
371
+ return `${location}: ${e.message}${detail}`;
372
+ }
373
+ if (e.cause != null) return `${e.message}: ${formatErrorMessage(e.cause)}`;
374
+ else return e.message;
375
+ }
376
+ //#endregion
377
+ //#region src/parser/localeParser.ts
378
+ const jiti = createJiti(import.meta.url);
379
+ const supportedJsExtensions = [
380
+ ".js",
381
+ ".mjs",
382
+ ".cjs",
383
+ ".ts",
384
+ ".mts",
385
+ ".cts"
386
+ ];
387
+ const fileParsers = {
388
+ ".json": parseJSON,
389
+ ".jsonc": parseJSONC,
390
+ ".json5": parseJSON5,
391
+ ".yaml": parseYAML,
392
+ ".yml": parseYAML
393
+ };
394
+ async function parseLocaleFile(filePath) {
395
+ if (supportedJsExtensions.includes(extname(filePath))) return await readLocaleScriptFile(filePath);
396
+ return parseLocale(await readFile(filePath, { encoding: "utf-8" }), filePath);
397
+ }
398
+ async function readLocaleScriptFile(filePath) {
399
+ let result;
400
+ try {
401
+ result = await jiti.import(resolve(filePath), { default: true });
402
+ } catch (e) {
403
+ throw new ParseError("Failed to import locale module", filePath, { cause: e });
404
+ }
405
+ if (!isPlainObject(result)) throw new ParseError("Locale module default export is not an object", filePath);
406
+ return result;
407
+ }
408
+ function parseLocale(content, file, options = {}) {
409
+ const ext = options.ext ?? extname(file);
410
+ const parse = fileParsers[ext];
411
+ if (!parse) throw new ParseError(`Unsupported locale type: ${ext}`, file);
412
+ let result;
413
+ try {
414
+ result = parse(content);
415
+ } catch (e) {
416
+ throw new ParseError("Failed to parse locale content", file, { cause: e });
417
+ }
418
+ if (!isPlainObject(result)) throw new ParseError("Locale content is not an object", file);
419
+ return result;
420
+ }
421
+ //#endregion
422
+ //#region src/collector/localeExtractor.ts
423
+ function extractLocaleKeys(data, prefix = "") {
424
+ return Object.entries(data).flatMap(([key, value]) => {
425
+ if (typeof value === "object") {
426
+ if (value === null) return {
427
+ key: `${prefix}${key}`,
428
+ type: "null"
429
+ };
430
+ return extractLocaleKeys(value, `${prefix}${key}.`);
431
+ }
432
+ return {
433
+ key: `${prefix}${key}`,
434
+ type: typeof value
435
+ };
436
+ });
437
+ }
438
+ //#endregion
439
+ //#region src/collector/localeCollector.ts
440
+ async function collectLocaleFile(filePath) {
441
+ const locale = basename(filePath, extname(filePath));
442
+ const data = await parseLocaleFile(filePath);
443
+ return {
444
+ locale,
445
+ file: filePath,
446
+ rawData: data,
447
+ keys: extractLocaleKeys(data),
448
+ scope: "global"
449
+ };
450
+ }
451
+ //#endregion
452
+ //#region src/parser/scriptParser.ts
453
+ function parseScript(file, content, options = {}) {
454
+ const source = options.wrapInParens ? `(${content})` : content;
455
+ const parseOptions = {};
456
+ const lang = asLang(options.lang);
457
+ if (lang) parseOptions.lang = lang;
458
+ const { program, errors } = parseSync(file, source, parseOptions);
459
+ if (errors.length > 0) {
460
+ const messages = errors.map((e) => ` • ${e.message}`).join("\n");
461
+ const rawOffset = errors[0].labels?.[0]?.start ?? 0;
462
+ const { line, column } = offsetToPosition(options.fileSource ?? content, rawOffset + (options.offset ?? 0));
463
+ throw new ParseError(`Failed to parse script:\n${messages}`, file, {
464
+ line,
465
+ column
466
+ });
467
+ }
468
+ return program;
469
+ }
470
+ function asLang(lang) {
471
+ if (lang === "js" || lang === "jsx" || lang === "ts" || lang === "tsx" || lang === "dts") return lang;
472
+ }
473
+ //#endregion
474
+ //#region src/types.ts
475
+ const DYNAMIC_PART = Symbol("dynamic");
476
+ //#endregion
477
+ //#region src/collector/translationFunctions.ts
478
+ const TRANSLATION_FUNCTIONS = /* @__PURE__ */ new Set([
479
+ "t",
480
+ "te",
481
+ "tm",
482
+ "tc",
483
+ "$t",
484
+ "$te",
485
+ "$tm",
486
+ "$tc"
487
+ ]);
488
+ const TRANSLATION_CALL_REGEX = /\$?t[emc]?\s*\(/;
489
+ //#endregion
490
+ //#region src/collector/jsCollector.ts
491
+ function collectJsKeys(program, offset = 0) {
492
+ const result = [];
493
+ new Visitor({ CallExpression(node) {
494
+ if (!isTranslationFunction(node)) return;
495
+ const arg = node.arguments[0];
496
+ if (!arg) return;
497
+ const keys = extractKey(arg, offset);
498
+ result.push(...keys);
499
+ } }).visit(program);
500
+ return result;
501
+ }
502
+ function isTranslationFunction(node) {
503
+ if (node.callee.type === "Identifier") return TRANSLATION_FUNCTIONS.has(node.callee.name);
504
+ if (node.callee.type === "MemberExpression" && !node.callee.computed && node.callee.property.type === "Identifier") return TRANSLATION_FUNCTIONS.has(node.callee.property.name);
505
+ return false;
506
+ }
507
+ function extractKey(arg, offset) {
508
+ if (arg.type === "ConditionalExpression") return [...extractKey(arg.consequent, offset), ...extractKey(arg.alternate, offset)];
509
+ const normalizedParts = collect(arg).reduce((acc, curr) => {
510
+ const last = acc[acc.length - 1];
511
+ if (typeof last === "string" && typeof curr === "string") acc[acc.length - 1] = last + curr;
512
+ else if (last !== curr) acc.push(curr);
513
+ return acc;
514
+ }, []);
515
+ if (normalizedParts.length === 0) return [];
516
+ if (normalizedParts.every((p) => typeof p !== "string")) return [];
517
+ const start = offset + arg.start;
518
+ const end = offset + arg.end;
519
+ if (normalizedParts.length === 1 && typeof normalizedParts[0] === "string") {
520
+ const isQuoted = arg.type === "Literal" && typeof arg.value === "string" || arg.type === "TemplateLiteral";
521
+ return [{
522
+ key: normalizedParts[0],
523
+ start: isQuoted ? start + 1 : start,
524
+ end: isQuoted ? end - 1 : end
525
+ }];
526
+ }
527
+ return [{
528
+ key: normalizedParts,
529
+ start,
530
+ end
531
+ }];
532
+ }
533
+ function collect(arg) {
534
+ if (arg.type === "Literal" && (typeof arg.value === "string" || typeof arg.value === "number" || typeof arg.value === "boolean")) return [String(arg.value)];
535
+ if (arg.type === "TemplateLiteral") {
536
+ const parts = [];
537
+ for (let i = 0; i < arg.quasis.length; i++) {
538
+ const quasi = arg.quasis[i];
539
+ const expression = arg.expressions[i];
540
+ if (!quasi.tail && quasi.value.cooked) parts.push(quasi.value.cooked);
541
+ if (expression) parts.push(...collect(expression));
542
+ if (quasi.tail && quasi.value.cooked) parts.push(quasi.value.cooked);
543
+ }
544
+ return parts;
545
+ }
546
+ if (arg.type === "BinaryExpression" && arg.operator === "+") return [...collect(arg.left), ...collect(arg.right)];
547
+ return [DYNAMIC_PART];
548
+ }
549
+ //#endregion
550
+ //#region src/collector/vueCollector.ts
551
+ function collectVueKeys(file, templateAst, options) {
552
+ return templateAst.children.flatMap((c) => walkVueNode(file, c, options));
553
+ }
554
+ function walkVueNode(file, node, options) {
555
+ switch (node.type) {
556
+ case NodeTypes.ELEMENT: return [
557
+ ...node.children.flatMap((c) => walkVueNode(file, c, options)),
558
+ ...node.props.flatMap((c) => walkVueNode(file, c, options)),
559
+ ...collectFromElementNode(node)
560
+ ];
561
+ case NodeTypes.INTERPOLATION: return walkVueNode(file, node.content, options);
562
+ case NodeTypes.DIRECTIVE:
563
+ if (node.name === "t") return collectFromDirective(file, node, options);
564
+ return node.exp ? walkVueNode(file, node.exp, options) : [];
565
+ case NodeTypes.SIMPLE_EXPRESSION: return node.isStatic ? [] : collectFromExpression(file, node, options);
566
+ default: return [];
567
+ }
568
+ }
569
+ function collectFromElementNode(node) {
570
+ if (node.tag === "i18n-t") {
571
+ for (const prop of node.props) if (prop.type === NodeTypes.ATTRIBUTE && (prop.name === "keypath" || prop.name === "path") && prop.value?.content) return [{
572
+ key: prop.value.content,
573
+ start: prop.value.loc.start.offset + 1,
574
+ end: prop.value.loc.end.offset - 1
575
+ }];
576
+ }
577
+ return [];
578
+ }
579
+ function collectFromDirective(file, node, options) {
580
+ if (!node.exp || node.exp.type !== NodeTypes.SIMPLE_EXPRESSION) return [];
581
+ const content = node.exp.content.trim();
582
+ if (!content) return [];
583
+ const bodyPart = parseScript(file, content, {
584
+ wrapInParens: true,
585
+ offset: node.exp.loc.start.offset - 1,
586
+ fileSource: options?.fileSource
587
+ }).body[0];
588
+ if (!bodyPart || bodyPart.type !== "ExpressionStatement") return [];
589
+ return walkDirective(bodyPart.expression, node.exp.loc.start.offset - 1);
590
+ }
591
+ function walkDirective(expression, offset) {
592
+ if (expression.type === "ParenthesizedExpression") return walkDirective(expression.expression, offset);
593
+ if (expression.type === "Literal" && typeof expression.value === "string") return [{
594
+ key: expression.value,
595
+ start: expression.start + offset + 1,
596
+ end: expression.end + offset - 1
597
+ }];
598
+ if (expression.type === "ObjectExpression") {
599
+ for (const prop of expression.properties) if (prop.type === "Property" && prop.key.type === "Identifier" && prop.key.name === "path" && prop.value.type === "Literal" && typeof prop.value.value === "string") return [{
600
+ key: prop.value.value,
601
+ start: prop.value.start + offset + 1,
602
+ end: prop.value.end + offset - 1
603
+ }];
604
+ }
605
+ return [];
606
+ }
607
+ function collectFromExpression(file, node, options) {
608
+ const content = node.content;
609
+ if (!content.trim()) return [];
610
+ if (!TRANSLATION_CALL_REGEX.test(content)) return [];
611
+ return collectJsKeys(parseScript(file, content, {
612
+ wrapInParens: true,
613
+ offset: node.loc.start.offset - 1,
614
+ fileSource: options?.fileSource
615
+ }), node.loc.start.offset - 1);
616
+ }
617
+ //#endregion
618
+ //#region src/collector/sourceCollector.ts
619
+ async function collectSourceFile(file) {
620
+ const source = await readFile(file, { encoding: "utf-8" });
621
+ if (extname(file) === ".vue") return collectFromVue(source, file);
622
+ return {
623
+ keys: rawKeysToFileKeys(collectFromScript(source, file), file, source),
624
+ localeFiles: []
625
+ };
626
+ }
627
+ function collectFromScript(source, file) {
628
+ if (!TRANSLATION_CALL_REGEX.test(source)) return [];
629
+ return collectJsKeys(parseScript(file, source));
630
+ }
631
+ function collectFromVue(source, file) {
632
+ const { descriptor, errors } = parse(source, {
633
+ filename: basename(file),
634
+ templateParseOptions: { prefixIdentifiers: false }
635
+ });
636
+ if (errors.length > 0 && !descriptor.template && !descriptor.script && !descriptor.scriptSetup) throw new ParseError(`Failed to parse Vue file:\n${errors.map((e) => ` • ${e.message}`).join("\n")}`, file);
637
+ const rawKeys = [];
638
+ const templateAst = descriptor.template?.ast;
639
+ if (templateAst) rawKeys.push(...collectVueKeys(file, templateAst, { fileSource: source }));
640
+ for (const script of [descriptor.script, descriptor.scriptSetup]) {
641
+ if (!script) continue;
642
+ if (!TRANSLATION_CALL_REGEX.test(script.content)) continue;
643
+ const program = parseScript(file, script.content, {
644
+ lang: script.lang,
645
+ offset: script.loc.start.offset,
646
+ fileSource: source
647
+ });
648
+ rawKeys.push(...collectJsKeys(program, script.loc.start.offset));
649
+ }
650
+ return {
651
+ keys: rawKeysToFileKeys(rawKeys, file, source),
652
+ localeFiles: collectI18nBlocks(descriptor, file)
653
+ };
654
+ }
655
+ function collectI18nBlocks(descriptor, file) {
656
+ const localeFiles = [];
657
+ for (const block of descriptor.customBlocks) {
658
+ if (block.type !== "i18n") continue;
659
+ localeFiles.push(...parseI18nBlock(block, file));
660
+ }
661
+ return localeFiles;
662
+ }
663
+ function parseI18nBlock(block, file) {
664
+ const lang = typeof block.attrs["lang"] === "string" ? block.attrs["lang"] : "json";
665
+ const data = parseLocale(block.content, file, { ext: `.${lang}` });
666
+ return Object.entries(data).map(([locale, localeData]) => {
667
+ if (!isPlainObject(localeData)) return {
668
+ locale,
669
+ file,
670
+ keys: [],
671
+ rawData: {},
672
+ scope: "local"
673
+ };
674
+ return {
675
+ locale,
676
+ file,
677
+ scope: "local",
678
+ sourceFile: file,
679
+ rawData: localeData,
680
+ keys: extractLocaleKeys(localeData)
681
+ };
682
+ });
683
+ }
684
+ function rawKeysToFileKeys(rawKeys, file, source) {
685
+ return rawKeys.map((k) => ({
686
+ key: k.key,
687
+ file,
688
+ location: {
689
+ start: offsetToPosition(source, k.start),
690
+ end: offsetToPosition(source, k.end)
691
+ }
692
+ }));
693
+ }
694
+ //#endregion
695
+ //#region src/command/shared.ts
696
+ async function collectFiles(config, process) {
697
+ const globOptions = {
698
+ cwd: config.path,
699
+ ignore: config.ignorePatterns,
700
+ gitignore: true
701
+ };
702
+ const [rawLocalePaths, rawSrcPaths] = await Promise.all([globby(config.localePattern, globOptions), globby(config.srcPattern, globOptions)]);
703
+ const [localeFiles, sourceFiles] = await Promise.all([Promise.all(rawLocalePaths.map((p) => collectFile(resolve(config.path, p), collectLocaleFile, process))), Promise.all(rawSrcPaths.map((p) => collectFile(resolve(config.path, p), collectSourceFile, process)))]);
704
+ const validLocaleFiles = localeFiles.filter((f) => f != null);
705
+ const validSourceFiles = sourceFiles.filter((f) => f != null);
706
+ return {
707
+ localeFiles: validLocaleFiles,
708
+ sourceFiles: validSourceFiles,
709
+ parseErrors: localeFiles.length - validLocaleFiles.length + (sourceFiles.length - validSourceFiles.length)
710
+ };
711
+ }
712
+ async function collectFile(file, collect, process) {
713
+ try {
714
+ return await collect(file);
715
+ } catch (e) {
716
+ writeLine(process.stderr, `Failed to process: ${formatErrorMessage(e)}`);
717
+ return null;
718
+ }
719
+ }
720
+ //#endregion
721
+ //#region src/command/lint.ts
722
+ const lintCommand = buildCommand({
723
+ async func(flags, path) {
724
+ const startTime = performance.now();
725
+ const config = await loadVueI18nLintConfig({
726
+ path,
727
+ ...flags
728
+ });
729
+ const { localeFiles, sourceFiles, parseErrors } = await collectFiles(config, this.process);
730
+ const result = processFiles(localeFiles, sourceFiles, config);
731
+ const elapsed = Math.round(performance.now() - startTime);
732
+ const missingSeverity = config.checks.missingKeys.severity;
733
+ const unusedSeverity = config.checks.unusedKeys.severity;
734
+ if (config.format === "text") {
735
+ if (parseErrors > 0) writeLine(this.process.stdout);
736
+ if (result.typeWarnings.length > 0) outputTypeWarnings(this.process, result.typeWarnings);
737
+ if (result.missing && result.missing.length > 0) outputMissingKeys(this.process, result.missing);
738
+ if (result.unused && result.unused.length > 0) outputUnusedKeys(this.process, result.unused);
739
+ const summaryParts = [];
740
+ if (result.missing != null) summaryParts.push(`${formatSummaryPart(result.missing.length, config.checks.missingKeys.severity)} missing`);
741
+ if (result.unused != null) summaryParts.push(`${formatSummaryPart(result.unused.length, config.checks.unusedKeys.severity)} unused`);
742
+ if (summaryParts.length > 0) writeLine(this.process.stdout, `Found ${summaryParts.join(" and ")} keys.`);
743
+ const errorSummary = parseErrors > 0 ? ` (${parseErrors} file${parseErrors === 1 ? "" : "s"} skipped due to errors)` : "";
744
+ writeLine(this.process.stdout, `Processed ${localeFiles.length} locale files and ${sourceFiles.length} source files in ${elapsed}ms${errorSummary}.`);
745
+ } else {
746
+ const outputData = {
747
+ missingKeys: result.missing,
748
+ unusedKeys: result.unused
749
+ };
750
+ if (config.format === "json") outputJson(this.process, outputData);
751
+ else if (config.format === "toon") outputToon(this.process, outputData);
752
+ }
753
+ const missingIsError = missingSeverity === "error" && result.missing && result.missing.length > 0;
754
+ const unusedIsError = unusedSeverity === "error" && result.unused && result.unused.length > 0;
755
+ if (missingIsError || unusedIsError || parseErrors > 0) this.process.exitCode = 1;
756
+ },
757
+ parameters: {
758
+ flags: {
759
+ format: {
760
+ kind: "enum",
761
+ values: formatEnum.options,
762
+ optional: true,
763
+ brief: "Output format"
764
+ },
765
+ localePattern: {
766
+ kind: "parsed",
767
+ parse: String,
768
+ optional: true,
769
+ brief: "Glob pattern for i18n locale files"
770
+ },
771
+ srcPattern: {
772
+ kind: "parsed",
773
+ parse: String,
774
+ optional: true,
775
+ brief: "Glob pattern for source files"
776
+ },
777
+ ignorePatterns: {
778
+ kind: "parsed",
779
+ parse: String,
780
+ optional: true,
781
+ variadic: ",",
782
+ brief: "Comma-separated glob patterns to ignore"
783
+ },
784
+ ignoreKeys: {
785
+ kind: "parsed",
786
+ parse: String,
787
+ optional: true,
788
+ variadic: ",",
789
+ brief: "Comma-separated keys to ignore in both missing and unused checks"
790
+ },
791
+ ignoreMissingKeys: {
792
+ kind: "parsed",
793
+ parse: String,
794
+ optional: true,
795
+ variadic: ",",
796
+ brief: "Comma-separated keys to ignore in the missing keys check"
797
+ },
798
+ ignoreUnusedKeys: {
799
+ kind: "parsed",
800
+ parse: String,
801
+ optional: true,
802
+ variadic: ",",
803
+ brief: "Comma-separated keys to ignore in the unused keys check"
804
+ },
805
+ missingKeysSeverity: {
806
+ kind: "enum",
807
+ values: severityEnum.options,
808
+ optional: true,
809
+ brief: "Severity for missing keys"
810
+ },
811
+ unusedKeysSeverity: {
812
+ kind: "enum",
813
+ values: severityEnum.options,
814
+ optional: true,
815
+ brief: "Severity for unused keys"
816
+ }
817
+ },
818
+ positional: {
819
+ kind: "tuple",
820
+ parameters: [{
821
+ brief: "Working directory",
822
+ parse: String,
823
+ placeholder: "path",
824
+ optional: true
825
+ }]
826
+ }
827
+ },
828
+ docs: { brief: "Lint i18n keys in your project" }
829
+ });
830
+ //#endregion
831
+ //#region src/command/removeUnused.ts
832
+ const removeUnusedCommand = buildCommand({
833
+ async func(flags, path) {
834
+ const config = await loadVueI18nLintConfig({
835
+ path,
836
+ localePattern: flags.localePattern,
837
+ srcPattern: flags.srcPattern,
838
+ ignorePatterns: flags.ignorePatterns,
839
+ ignoreKeys: flags.ignoreKeys,
840
+ ignoreUnusedKeys: flags.ignoreUnusedKeys
841
+ });
842
+ const { localeFiles, sourceFiles, parseErrors } = await collectFiles(config, this.process);
843
+ const { unused } = processFiles(localeFiles, sourceFiles, defu({ checks: {
844
+ missingKeys: { severity: "off" },
845
+ unusedKeys: { severity: "error" }
846
+ } }, config));
847
+ const unusedSet = new Set(unused?.filter((it) => it.files.some((file) => file.scope === "global")).map((it) => it.key) ?? []);
848
+ if (parseErrors > 0) writeLine(this.process.stdout);
849
+ if (unusedSet.size === 0) {
850
+ writeLine(this.process.stdout, "No unused keys found.");
851
+ return;
852
+ }
853
+ if (!flags.dryRun) await Promise.all(localeFiles.map(async (localeFile) => {
854
+ const stringifier = stringifiers[extname(localeFile.file)];
855
+ if (!stringifier) return;
856
+ removeKeysFromData(localeFile.rawData, unusedSet);
857
+ await writeFile(localeFile.file, stringifier(localeFile.rawData), "utf-8");
858
+ }));
859
+ writeLine(this.process.stdout, flags.dryRun ? `Would remove ${unusedSet.size} unused key${unusedSet.size === 1 ? "" : "s"}.` : `Removed ${unusedSet.size} unused key${unusedSet.size === 1 ? "" : "s"}.`);
860
+ },
861
+ parameters: {
862
+ flags: {
863
+ dryRun: {
864
+ kind: "boolean",
865
+ optional: true,
866
+ brief: "Print count without modifying files"
867
+ },
868
+ localePattern: {
869
+ kind: "parsed",
870
+ parse: String,
871
+ optional: true,
872
+ brief: "Glob pattern for i18n locale files"
873
+ },
874
+ srcPattern: {
875
+ kind: "parsed",
876
+ parse: String,
877
+ optional: true,
878
+ brief: "Glob pattern for source files"
879
+ },
880
+ ignorePatterns: {
881
+ kind: "parsed",
882
+ parse: String,
883
+ optional: true,
884
+ variadic: ",",
885
+ brief: "Comma-separated glob patterns to ignore"
886
+ },
887
+ ignoreKeys: {
888
+ kind: "parsed",
889
+ parse: String,
890
+ optional: true,
891
+ variadic: ",",
892
+ brief: "Comma-separated keys to ignore"
893
+ },
894
+ ignoreUnusedKeys: {
895
+ kind: "parsed",
896
+ parse: String,
897
+ optional: true,
898
+ variadic: ",",
899
+ brief: "Comma-separated keys to ignore in the unused keys check"
900
+ }
901
+ },
902
+ positional: {
903
+ kind: "tuple",
904
+ parameters: [{
905
+ brief: "Working directory",
906
+ parse: String,
907
+ placeholder: "path",
908
+ optional: true
909
+ }]
910
+ }
911
+ },
912
+ docs: { brief: "Remove unused i18n keys from locale files" }
913
+ });
914
+ const stringifiers = {
915
+ ".json": (data) => stringifyJSON(data, { indent: 2 }),
916
+ ".jsonc": (data) => stringifyJSONC(data, { indent: 2 }),
917
+ ".json5": (data) => stringifyJSON5(data, { indent: 2 }),
918
+ ".yaml": (data) => stringifyYAML(data, { indent: 2 }),
919
+ ".yml": (data) => stringifyYAML(data, { indent: 2 })
920
+ };
921
+ function removeKeysFromData(data, keys, prefix = "") {
922
+ if (typeof data !== "object" || data == null) return false;
923
+ if (isPlainObject(data)) {
924
+ for (const key of Object.keys(data)) {
925
+ const fullKey = prefix ? `${prefix}.${key}` : key;
926
+ if (keys.has(fullKey) || removeKeysFromData(data[key], keys, fullKey)) delete data[key];
927
+ }
928
+ return Object.keys(data).length === 0;
929
+ }
930
+ if (Array.isArray(data)) {
931
+ for (let i = data.length - 1; i >= 0; i--) {
932
+ const fullKey = prefix ? `${prefix}.${i}` : String(i);
933
+ if (keys.has(fullKey) || removeKeysFromData(data[i], keys, fullKey)) data.splice(i, 1);
934
+ }
935
+ return data.length === 0;
936
+ }
937
+ return false;
938
+ }
939
+ //#endregion
940
+ //#region src/cli.ts
941
+ await run(buildApplication(buildRouteMap({
942
+ routes: {
943
+ lint: lintCommand,
944
+ init: initCommand,
945
+ removeUnused: removeUnusedCommand
946
+ },
947
+ defaultCommand: "lint",
948
+ docs: { brief: "Fast and accurate linting for Vue i18n." }
949
+ }), {
950
+ name: "vue-i18n-lint",
951
+ scanner: { caseStyle: "allow-kebab-for-camel" },
952
+ documentation: { caseStyle: "convert-camel-to-kebab" },
953
+ localization: { text: {
954
+ ...text_en,
955
+ formatException: formatErrorMessage
956
+ } }
957
+ }), process.argv.slice(2), { process: {
958
+ stdout: process.stdout,
959
+ stderr: process.stderr,
960
+ env: process.env
961
+ } });
962
+ //#endregion
963
+ export {};
@@ -0,0 +1,46 @@
1
+ import { z } from "zod";
2
+
3
+ //#region src/config/schema.d.ts
4
+ declare const configSchema: z.ZodObject<{
5
+ path: z.ZodString;
6
+ format: z.ZodDefault<z.ZodEnum<{
7
+ json: "json";
8
+ text: "text";
9
+ toon: "toon";
10
+ }>>;
11
+ localePattern: z.ZodDefault<z.ZodString>;
12
+ srcPattern: z.ZodDefault<z.ZodString>;
13
+ ignorePatterns: z.ZodDefault<z.ZodArray<z.ZodString>>;
14
+ ignoreKeys: z.ZodDefault<z.ZodArray<z.ZodString>>;
15
+ checks: z.ZodDefault<z.ZodObject<{
16
+ missingKeys: z.ZodDefault<z.ZodUnion<readonly [z.ZodObject<{
17
+ severity: z.ZodDefault<z.ZodEnum<{
18
+ error: "error";
19
+ off: "off";
20
+ warning: "warning";
21
+ }>>;
22
+ ignore: z.ZodDefault<z.ZodArray<z.ZodString>>;
23
+ }, z.core.$strip>, z.ZodPipe<z.ZodLiteral<false>, z.ZodTransform<{
24
+ severity: "off";
25
+ ignore: string[];
26
+ }, false>>]>>;
27
+ unusedKeys: z.ZodDefault<z.ZodUnion<readonly [z.ZodObject<{
28
+ severity: z.ZodDefault<z.ZodEnum<{
29
+ error: "error";
30
+ off: "off";
31
+ warning: "warning";
32
+ }>>;
33
+ ignore: z.ZodDefault<z.ZodArray<z.ZodString>>;
34
+ }, z.core.$strip>, z.ZodPipe<z.ZodLiteral<false>, z.ZodTransform<{
35
+ severity: "off";
36
+ ignore: string[];
37
+ }, false>>]>>;
38
+ }, z.core.$strip>>;
39
+ }, z.core.$strip>;
40
+ type ConfigInput = z.input<typeof configSchema>;
41
+ //#endregion
42
+ //#region src/index.d.ts
43
+ type VueI18nLintConfig = Omit<ConfigInput, "path">;
44
+ declare const defineConfig: import("c12").DefineConfig<VueI18nLintConfig, import("c12").ConfigLayerMeta>;
45
+ //#endregion
46
+ export { VueI18nLintConfig, defineConfig };
package/dist/index.mjs ADDED
@@ -0,0 +1,5 @@
1
+ import { createDefineConfig } from "c12";
2
+ //#region src/index.ts
3
+ const defineConfig = createDefineConfig();
4
+ //#endregion
5
+ export { defineConfig };
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "vue-i18n-lint",
3
+ "version": "0.0.1",
4
+ "description": "Fast and accurate linting for Vue i18n.",
5
+ "keywords": [
6
+ "i18n",
7
+ "lint",
8
+ "vue"
9
+ ],
10
+ "license": "MIT",
11
+ "author": "Ruben Gees",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "rubengees/vue-i18n-lint"
15
+ },
16
+ "bin": {
17
+ "vue-i18n-lint": "./dist/cli.mjs"
18
+ },
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "type": "module",
23
+ "main": "dist/index.mjs",
24
+ "types": "dist/index.d.mts",
25
+ "exports": {
26
+ ".": "./dist/index.mjs"
27
+ },
28
+ "dependencies": {
29
+ "@babel/code-frame": "^8.0.0",
30
+ "@stricli/core": "^1.2.8",
31
+ "@toon-format/toon": "^2.3.0",
32
+ "@vue/compiler-core": "^3.5.39",
33
+ "@vue/compiler-sfc": "^3.5.39",
34
+ "c12": "4.0.0-beta.5",
35
+ "confbox": "^0.2.4",
36
+ "defu": "^6.1.7",
37
+ "globby": "^16.2.0",
38
+ "jiti": "^2.7.0",
39
+ "oxc-parser": "^0.137.0",
40
+ "regexp.escape": "^2.0.1",
41
+ "table": "^6.9.0",
42
+ "zod": "^4.4.3"
43
+ },
44
+ "devDependencies": {
45
+ "@tsconfig/node22": "22.0.5",
46
+ "@types/babel__code-frame": "7.27.0",
47
+ "@types/node": "22.19.21",
48
+ "@types/regexp.escape": "2.0.0",
49
+ "@vitest/coverage-v8": "4.1.9",
50
+ "husky": "9.1.7",
51
+ "lint-staged": "17.0.8",
52
+ "oxfmt": "0.56.0",
53
+ "oxlint": "1.71.0",
54
+ "oxlint-tsgolint": "0.23.0",
55
+ "tsdown": "0.22.3",
56
+ "typescript": "7.0.1-rc",
57
+ "vitest": "4.1.9"
58
+ },
59
+ "engines": {
60
+ "node": ">=22"
61
+ },
62
+ "scripts": {
63
+ "dev": "vitest",
64
+ "start": "jiti src/cli.ts",
65
+ "build": "tsdown",
66
+ "lint": "oxlint",
67
+ "format": "oxfmt",
68
+ "format:check": "oxfmt --check",
69
+ "check": "tsc --noEmit",
70
+ "test": "vitest run",
71
+ "bench": "vitest --run bench",
72
+ "verify": "pnpm format:check && pnpm lint && pnpm check && pnpm test"
73
+ }
74
+ }