ai-localize-validators 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +60 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.js +257 -0
- package/dist/index.mjs +216 -0
- package/package.json +34 -0
- package/src/__tests__/missing-key-validator.test.ts +45 -0
- package/src/duplicate-key-validator.ts +37 -0
- package/src/index.ts +5 -0
- package/src/locale-validator.ts +44 -0
- package/src/missing-key-validator.ts +46 -0
- package/src/placeholder-validator.ts +48 -0
- package/src/unused-key-validator.ts +50 -0
- package/tsconfig.json +6 -0
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { ValidationError, ValidationWarning, ValidationResult } from '@ai-localize/shared';
|
|
2
|
+
|
|
3
|
+
declare class MissingKeyValidator {
|
|
4
|
+
private localesDir;
|
|
5
|
+
private defaultLanguage;
|
|
6
|
+
private targetLanguages;
|
|
7
|
+
constructor(localesDir: string, defaultLanguage?: string, targetLanguages?: string[]);
|
|
8
|
+
validate(): {
|
|
9
|
+
errors: ValidationError[];
|
|
10
|
+
missingByLanguage: Record<string, string[]>;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
declare class DuplicateKeyValidator {
|
|
15
|
+
private localesDir;
|
|
16
|
+
private defaultLanguage;
|
|
17
|
+
constructor(localesDir: string, defaultLanguage?: string);
|
|
18
|
+
validate(): {
|
|
19
|
+
errors: ValidationError[];
|
|
20
|
+
duplicates: string[];
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
declare class PlaceholderValidator {
|
|
25
|
+
private localesDir;
|
|
26
|
+
private defaultLanguage;
|
|
27
|
+
constructor(localesDir: string, defaultLanguage?: string);
|
|
28
|
+
validate(): ValidationError[];
|
|
29
|
+
private extract;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
declare class UnusedKeyValidator {
|
|
33
|
+
private localesDir;
|
|
34
|
+
private sourceDir;
|
|
35
|
+
private defaultLanguage;
|
|
36
|
+
constructor(localesDir: string, sourceDir: string, defaultLanguage?: string);
|
|
37
|
+
validate(): {
|
|
38
|
+
warnings: ValidationWarning[];
|
|
39
|
+
unusedKeys: string[];
|
|
40
|
+
};
|
|
41
|
+
private collectLocaleKeys;
|
|
42
|
+
private collectSourceContent;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface ValidatorOptions {
|
|
46
|
+
localesDir: string;
|
|
47
|
+
sourceDir: string;
|
|
48
|
+
defaultLanguage?: string;
|
|
49
|
+
targetLanguages?: string[];
|
|
50
|
+
checkUnused?: boolean;
|
|
51
|
+
checkDuplicates?: boolean;
|
|
52
|
+
checkPlaceholders?: boolean;
|
|
53
|
+
}
|
|
54
|
+
declare class LocaleValidator {
|
|
55
|
+
private options;
|
|
56
|
+
constructor(options: ValidatorOptions);
|
|
57
|
+
validate(): ValidationResult;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export { DuplicateKeyValidator, LocaleValidator, MissingKeyValidator, PlaceholderValidator, UnusedKeyValidator, type ValidatorOptions };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { ValidationError, ValidationWarning, ValidationResult } from '@ai-localize/shared';
|
|
2
|
+
|
|
3
|
+
declare class MissingKeyValidator {
|
|
4
|
+
private localesDir;
|
|
5
|
+
private defaultLanguage;
|
|
6
|
+
private targetLanguages;
|
|
7
|
+
constructor(localesDir: string, defaultLanguage?: string, targetLanguages?: string[]);
|
|
8
|
+
validate(): {
|
|
9
|
+
errors: ValidationError[];
|
|
10
|
+
missingByLanguage: Record<string, string[]>;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
declare class DuplicateKeyValidator {
|
|
15
|
+
private localesDir;
|
|
16
|
+
private defaultLanguage;
|
|
17
|
+
constructor(localesDir: string, defaultLanguage?: string);
|
|
18
|
+
validate(): {
|
|
19
|
+
errors: ValidationError[];
|
|
20
|
+
duplicates: string[];
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
declare class PlaceholderValidator {
|
|
25
|
+
private localesDir;
|
|
26
|
+
private defaultLanguage;
|
|
27
|
+
constructor(localesDir: string, defaultLanguage?: string);
|
|
28
|
+
validate(): ValidationError[];
|
|
29
|
+
private extract;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
declare class UnusedKeyValidator {
|
|
33
|
+
private localesDir;
|
|
34
|
+
private sourceDir;
|
|
35
|
+
private defaultLanguage;
|
|
36
|
+
constructor(localesDir: string, sourceDir: string, defaultLanguage?: string);
|
|
37
|
+
validate(): {
|
|
38
|
+
warnings: ValidationWarning[];
|
|
39
|
+
unusedKeys: string[];
|
|
40
|
+
};
|
|
41
|
+
private collectLocaleKeys;
|
|
42
|
+
private collectSourceContent;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface ValidatorOptions {
|
|
46
|
+
localesDir: string;
|
|
47
|
+
sourceDir: string;
|
|
48
|
+
defaultLanguage?: string;
|
|
49
|
+
targetLanguages?: string[];
|
|
50
|
+
checkUnused?: boolean;
|
|
51
|
+
checkDuplicates?: boolean;
|
|
52
|
+
checkPlaceholders?: boolean;
|
|
53
|
+
}
|
|
54
|
+
declare class LocaleValidator {
|
|
55
|
+
private options;
|
|
56
|
+
constructor(options: ValidatorOptions);
|
|
57
|
+
validate(): ValidationResult;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export { DuplicateKeyValidator, LocaleValidator, MissingKeyValidator, PlaceholderValidator, UnusedKeyValidator, type ValidatorOptions };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
DuplicateKeyValidator: () => DuplicateKeyValidator,
|
|
34
|
+
LocaleValidator: () => LocaleValidator,
|
|
35
|
+
MissingKeyValidator: () => MissingKeyValidator,
|
|
36
|
+
PlaceholderValidator: () => PlaceholderValidator,
|
|
37
|
+
UnusedKeyValidator: () => UnusedKeyValidator
|
|
38
|
+
});
|
|
39
|
+
module.exports = __toCommonJS(index_exports);
|
|
40
|
+
|
|
41
|
+
// src/missing-key-validator.ts
|
|
42
|
+
var fs = __toESM(require("fs"));
|
|
43
|
+
var path = __toESM(require("path"));
|
|
44
|
+
var import_shared = require("@ai-localize/shared");
|
|
45
|
+
var MissingKeyValidator = class {
|
|
46
|
+
constructor(localesDir, defaultLanguage = "en", targetLanguages = []) {
|
|
47
|
+
this.localesDir = localesDir;
|
|
48
|
+
this.defaultLanguage = defaultLanguage;
|
|
49
|
+
this.targetLanguages = targetLanguages;
|
|
50
|
+
}
|
|
51
|
+
localesDir;
|
|
52
|
+
defaultLanguage;
|
|
53
|
+
targetLanguages;
|
|
54
|
+
validate() {
|
|
55
|
+
const errors = [];
|
|
56
|
+
const missingByLanguage = {};
|
|
57
|
+
const defaultDir = path.join(this.localesDir, this.defaultLanguage);
|
|
58
|
+
if (!fs.existsSync(defaultDir)) return { errors, missingByLanguage };
|
|
59
|
+
const nsFiles = fs.readdirSync(defaultDir).filter((f) => f.endsWith(".json"));
|
|
60
|
+
const langs = this.targetLanguages.length > 0 ? this.targetLanguages : fs.readdirSync(this.localesDir, { withFileTypes: true }).filter((e) => e.isDirectory() && e.name !== this.defaultLanguage).map((e) => e.name);
|
|
61
|
+
for (const nsFile of nsFiles) {
|
|
62
|
+
const defaultEntries = (0, import_shared.readJsonSafe)(path.join(defaultDir, nsFile));
|
|
63
|
+
if (!defaultEntries) continue;
|
|
64
|
+
const namespace = nsFile.replace(".json", "");
|
|
65
|
+
for (const lang of langs) {
|
|
66
|
+
const targetPath = path.join(this.localesDir, lang, nsFile);
|
|
67
|
+
const targetEntries = (0, import_shared.readJsonSafe)(targetPath) || {};
|
|
68
|
+
for (const key of Object.keys(defaultEntries)) {
|
|
69
|
+
if (!(key in targetEntries) || targetEntries[key] === "") {
|
|
70
|
+
const fullKey = `${namespace}.${key}`;
|
|
71
|
+
errors.push({ type: "missing-key", key: fullKey, language: lang, message: `Missing key "${fullKey}" in "${lang}"`, filePath: targetPath });
|
|
72
|
+
if (!missingByLanguage[lang]) missingByLanguage[lang] = [];
|
|
73
|
+
missingByLanguage[lang].push(fullKey);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { errors, missingByLanguage };
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// src/duplicate-key-validator.ts
|
|
83
|
+
var fs2 = __toESM(require("fs"));
|
|
84
|
+
var path2 = __toESM(require("path"));
|
|
85
|
+
var import_shared2 = require("@ai-localize/shared");
|
|
86
|
+
var DuplicateKeyValidator = class {
|
|
87
|
+
constructor(localesDir, defaultLanguage = "en") {
|
|
88
|
+
this.localesDir = localesDir;
|
|
89
|
+
this.defaultLanguage = defaultLanguage;
|
|
90
|
+
}
|
|
91
|
+
localesDir;
|
|
92
|
+
defaultLanguage;
|
|
93
|
+
validate() {
|
|
94
|
+
const errors = [];
|
|
95
|
+
const duplicates = [];
|
|
96
|
+
const defaultDir = path2.join(this.localesDir, this.defaultLanguage);
|
|
97
|
+
if (!fs2.existsSync(defaultDir)) return { errors, duplicates };
|
|
98
|
+
const nsFiles = fs2.readdirSync(defaultDir).filter((f) => f.endsWith(".json"));
|
|
99
|
+
for (const nsFile of nsFiles) {
|
|
100
|
+
const entries = (0, import_shared2.readJsonSafe)(path2.join(defaultDir, nsFile));
|
|
101
|
+
if (!entries) continue;
|
|
102
|
+
const namespace = nsFile.replace(".json", "");
|
|
103
|
+
const valueMap = /* @__PURE__ */ new Map();
|
|
104
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
105
|
+
if (!value) continue;
|
|
106
|
+
const existing = valueMap.get(value) || [];
|
|
107
|
+
existing.push(key);
|
|
108
|
+
valueMap.set(value, existing);
|
|
109
|
+
}
|
|
110
|
+
for (const [value, keys] of valueMap) {
|
|
111
|
+
if (keys.length > 1) {
|
|
112
|
+
const fullKeys = keys.map((k) => `${namespace}.${k}`);
|
|
113
|
+
duplicates.push(...fullKeys);
|
|
114
|
+
errors.push({ type: "duplicate-key", key: fullKeys.join(", "), message: `Duplicate value "${value}" for: ${fullKeys.join(", ")}` });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return { errors, duplicates };
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// src/placeholder-validator.ts
|
|
123
|
+
var fs3 = __toESM(require("fs"));
|
|
124
|
+
var path3 = __toESM(require("path"));
|
|
125
|
+
var import_shared3 = require("@ai-localize/shared");
|
|
126
|
+
var PATTERNS = [/\{\{([^}]+)\}\}/g, /\{([^}]+)\}/g, /%\(([^)]+)\)s/g, /%(\w+)/g];
|
|
127
|
+
var PlaceholderValidator = class {
|
|
128
|
+
constructor(localesDir, defaultLanguage = "en") {
|
|
129
|
+
this.localesDir = localesDir;
|
|
130
|
+
this.defaultLanguage = defaultLanguage;
|
|
131
|
+
}
|
|
132
|
+
localesDir;
|
|
133
|
+
defaultLanguage;
|
|
134
|
+
validate() {
|
|
135
|
+
const errors = [];
|
|
136
|
+
const defaultDir = path3.join(this.localesDir, this.defaultLanguage);
|
|
137
|
+
if (!fs3.existsSync(defaultDir)) return errors;
|
|
138
|
+
const nsFiles = fs3.readdirSync(defaultDir).filter((f) => f.endsWith(".json"));
|
|
139
|
+
const langs = fs3.readdirSync(this.localesDir, { withFileTypes: true }).filter((e) => e.isDirectory() && e.name !== this.defaultLanguage).map((e) => e.name);
|
|
140
|
+
for (const nsFile of nsFiles) {
|
|
141
|
+
const defaultEntries = (0, import_shared3.readJsonSafe)(path3.join(defaultDir, nsFile));
|
|
142
|
+
if (!defaultEntries) continue;
|
|
143
|
+
const namespace = nsFile.replace(".json", "");
|
|
144
|
+
for (const lang of langs) {
|
|
145
|
+
const targetEntries = (0, import_shared3.readJsonSafe)(path3.join(this.localesDir, lang, nsFile)) || {};
|
|
146
|
+
for (const [key, defaultVal] of Object.entries(defaultEntries)) {
|
|
147
|
+
const targetVal = targetEntries[key];
|
|
148
|
+
if (!targetVal) continue;
|
|
149
|
+
const defPh = this.extract(defaultVal);
|
|
150
|
+
const tgtPh = this.extract(targetVal);
|
|
151
|
+
const missing = defPh.filter((p) => !tgtPh.includes(p));
|
|
152
|
+
const extra = tgtPh.filter((p) => !defPh.includes(p));
|
|
153
|
+
if (missing.length > 0 || extra.length > 0) {
|
|
154
|
+
errors.push({ type: "placeholder-mismatch", key: `${namespace}.${key}`, language: lang, message: `Placeholder mismatch in "${lang}": missing [${missing.join(", ")}], extra [${extra.join(", ")}]` });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return errors;
|
|
160
|
+
}
|
|
161
|
+
extract(value) {
|
|
162
|
+
const result = [];
|
|
163
|
+
for (const p of PATTERNS) {
|
|
164
|
+
p.lastIndex = 0;
|
|
165
|
+
let m;
|
|
166
|
+
while ((m = p.exec(value)) !== null) result.push(m[0]);
|
|
167
|
+
}
|
|
168
|
+
return [...new Set(result)];
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// src/unused-key-validator.ts
|
|
173
|
+
var fs4 = __toESM(require("fs"));
|
|
174
|
+
var path4 = __toESM(require("path"));
|
|
175
|
+
var import_shared4 = require("@ai-localize/shared");
|
|
176
|
+
var UnusedKeyValidator = class {
|
|
177
|
+
constructor(localesDir, sourceDir, defaultLanguage = "en") {
|
|
178
|
+
this.localesDir = localesDir;
|
|
179
|
+
this.sourceDir = sourceDir;
|
|
180
|
+
this.defaultLanguage = defaultLanguage;
|
|
181
|
+
}
|
|
182
|
+
localesDir;
|
|
183
|
+
sourceDir;
|
|
184
|
+
defaultLanguage;
|
|
185
|
+
validate() {
|
|
186
|
+
const warnings = [];
|
|
187
|
+
const unusedKeys = [];
|
|
188
|
+
const allKeys = this.collectLocaleKeys();
|
|
189
|
+
const sourceContent = this.collectSourceContent();
|
|
190
|
+
for (const key of allKeys) {
|
|
191
|
+
const shortKey = key.includes(".") ? key.split(".").slice(1).join(".") : key;
|
|
192
|
+
const referenced = sourceContent.includes(`'${key}'`) || sourceContent.includes(`"${key}"`) || sourceContent.includes(`'${shortKey}'`) || sourceContent.includes(`"${shortKey}"`);
|
|
193
|
+
if (!referenced) {
|
|
194
|
+
unusedKeys.push(key);
|
|
195
|
+
warnings.push({ type: "unused-key", key, message: `Key "${key}" not referenced in source` });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return { warnings, unusedKeys };
|
|
199
|
+
}
|
|
200
|
+
collectLocaleKeys() {
|
|
201
|
+
const keys = [];
|
|
202
|
+
const defaultDir = path4.join(this.localesDir, this.defaultLanguage);
|
|
203
|
+
if (!fs4.existsSync(defaultDir)) return keys;
|
|
204
|
+
for (const file of fs4.readdirSync(defaultDir).filter((f) => f.endsWith(".json"))) {
|
|
205
|
+
const ns = file.replace(".json", "");
|
|
206
|
+
const entries = (0, import_shared4.readJsonSafe)(path4.join(defaultDir, file)) || {};
|
|
207
|
+
for (const key of Object.keys(entries)) keys.push(`${ns}.${key}`);
|
|
208
|
+
}
|
|
209
|
+
return keys;
|
|
210
|
+
}
|
|
211
|
+
collectSourceContent() {
|
|
212
|
+
const files = (0, import_shared4.collectFiles)(this.sourceDir, ["ts", "tsx", "js", "jsx", "vue", "html"], ["node_modules", "dist"]);
|
|
213
|
+
let content = "";
|
|
214
|
+
for (const f of files) {
|
|
215
|
+
try {
|
|
216
|
+
content += fs4.readFileSync(f, "utf-8") + "\n";
|
|
217
|
+
} catch {
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return content;
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// src/locale-validator.ts
|
|
225
|
+
var LocaleValidator = class {
|
|
226
|
+
constructor(options) {
|
|
227
|
+
this.options = options;
|
|
228
|
+
}
|
|
229
|
+
options;
|
|
230
|
+
validate() {
|
|
231
|
+
const errors = [];
|
|
232
|
+
const warnings = [];
|
|
233
|
+
const lang = this.options.defaultLanguage || "en";
|
|
234
|
+
const { errors: missingErrors } = new MissingKeyValidator(this.options.localesDir, lang, this.options.targetLanguages || []).validate();
|
|
235
|
+
errors.push(...missingErrors);
|
|
236
|
+
if (this.options.checkDuplicates !== false) {
|
|
237
|
+
const { errors: dupErrors } = new DuplicateKeyValidator(this.options.localesDir, lang).validate();
|
|
238
|
+
errors.push(...dupErrors);
|
|
239
|
+
}
|
|
240
|
+
if (this.options.checkPlaceholders !== false) {
|
|
241
|
+
errors.push(...new PlaceholderValidator(this.options.localesDir, lang).validate());
|
|
242
|
+
}
|
|
243
|
+
if (this.options.checkUnused !== false) {
|
|
244
|
+
const { warnings: unusedWarnings } = new UnusedKeyValidator(this.options.localesDir, this.options.sourceDir, lang).validate();
|
|
245
|
+
warnings.push(...unusedWarnings);
|
|
246
|
+
}
|
|
247
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
251
|
+
0 && (module.exports = {
|
|
252
|
+
DuplicateKeyValidator,
|
|
253
|
+
LocaleValidator,
|
|
254
|
+
MissingKeyValidator,
|
|
255
|
+
PlaceholderValidator,
|
|
256
|
+
UnusedKeyValidator
|
|
257
|
+
});
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// src/missing-key-validator.ts
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import { readJsonSafe } from "@ai-localize/shared";
|
|
5
|
+
var MissingKeyValidator = class {
|
|
6
|
+
constructor(localesDir, defaultLanguage = "en", targetLanguages = []) {
|
|
7
|
+
this.localesDir = localesDir;
|
|
8
|
+
this.defaultLanguage = defaultLanguage;
|
|
9
|
+
this.targetLanguages = targetLanguages;
|
|
10
|
+
}
|
|
11
|
+
localesDir;
|
|
12
|
+
defaultLanguage;
|
|
13
|
+
targetLanguages;
|
|
14
|
+
validate() {
|
|
15
|
+
const errors = [];
|
|
16
|
+
const missingByLanguage = {};
|
|
17
|
+
const defaultDir = path.join(this.localesDir, this.defaultLanguage);
|
|
18
|
+
if (!fs.existsSync(defaultDir)) return { errors, missingByLanguage };
|
|
19
|
+
const nsFiles = fs.readdirSync(defaultDir).filter((f) => f.endsWith(".json"));
|
|
20
|
+
const langs = this.targetLanguages.length > 0 ? this.targetLanguages : fs.readdirSync(this.localesDir, { withFileTypes: true }).filter((e) => e.isDirectory() && e.name !== this.defaultLanguage).map((e) => e.name);
|
|
21
|
+
for (const nsFile of nsFiles) {
|
|
22
|
+
const defaultEntries = readJsonSafe(path.join(defaultDir, nsFile));
|
|
23
|
+
if (!defaultEntries) continue;
|
|
24
|
+
const namespace = nsFile.replace(".json", "");
|
|
25
|
+
for (const lang of langs) {
|
|
26
|
+
const targetPath = path.join(this.localesDir, lang, nsFile);
|
|
27
|
+
const targetEntries = readJsonSafe(targetPath) || {};
|
|
28
|
+
for (const key of Object.keys(defaultEntries)) {
|
|
29
|
+
if (!(key in targetEntries) || targetEntries[key] === "") {
|
|
30
|
+
const fullKey = `${namespace}.${key}`;
|
|
31
|
+
errors.push({ type: "missing-key", key: fullKey, language: lang, message: `Missing key "${fullKey}" in "${lang}"`, filePath: targetPath });
|
|
32
|
+
if (!missingByLanguage[lang]) missingByLanguage[lang] = [];
|
|
33
|
+
missingByLanguage[lang].push(fullKey);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { errors, missingByLanguage };
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// src/duplicate-key-validator.ts
|
|
43
|
+
import * as fs2 from "fs";
|
|
44
|
+
import * as path2 from "path";
|
|
45
|
+
import { readJsonSafe as readJsonSafe2 } from "@ai-localize/shared";
|
|
46
|
+
var DuplicateKeyValidator = class {
|
|
47
|
+
constructor(localesDir, defaultLanguage = "en") {
|
|
48
|
+
this.localesDir = localesDir;
|
|
49
|
+
this.defaultLanguage = defaultLanguage;
|
|
50
|
+
}
|
|
51
|
+
localesDir;
|
|
52
|
+
defaultLanguage;
|
|
53
|
+
validate() {
|
|
54
|
+
const errors = [];
|
|
55
|
+
const duplicates = [];
|
|
56
|
+
const defaultDir = path2.join(this.localesDir, this.defaultLanguage);
|
|
57
|
+
if (!fs2.existsSync(defaultDir)) return { errors, duplicates };
|
|
58
|
+
const nsFiles = fs2.readdirSync(defaultDir).filter((f) => f.endsWith(".json"));
|
|
59
|
+
for (const nsFile of nsFiles) {
|
|
60
|
+
const entries = readJsonSafe2(path2.join(defaultDir, nsFile));
|
|
61
|
+
if (!entries) continue;
|
|
62
|
+
const namespace = nsFile.replace(".json", "");
|
|
63
|
+
const valueMap = /* @__PURE__ */ new Map();
|
|
64
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
65
|
+
if (!value) continue;
|
|
66
|
+
const existing = valueMap.get(value) || [];
|
|
67
|
+
existing.push(key);
|
|
68
|
+
valueMap.set(value, existing);
|
|
69
|
+
}
|
|
70
|
+
for (const [value, keys] of valueMap) {
|
|
71
|
+
if (keys.length > 1) {
|
|
72
|
+
const fullKeys = keys.map((k) => `${namespace}.${k}`);
|
|
73
|
+
duplicates.push(...fullKeys);
|
|
74
|
+
errors.push({ type: "duplicate-key", key: fullKeys.join(", "), message: `Duplicate value "${value}" for: ${fullKeys.join(", ")}` });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { errors, duplicates };
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// src/placeholder-validator.ts
|
|
83
|
+
import * as fs3 from "fs";
|
|
84
|
+
import * as path3 from "path";
|
|
85
|
+
import { readJsonSafe as readJsonSafe3 } from "@ai-localize/shared";
|
|
86
|
+
var PATTERNS = [/\{\{([^}]+)\}\}/g, /\{([^}]+)\}/g, /%\(([^)]+)\)s/g, /%(\w+)/g];
|
|
87
|
+
var PlaceholderValidator = class {
|
|
88
|
+
constructor(localesDir, defaultLanguage = "en") {
|
|
89
|
+
this.localesDir = localesDir;
|
|
90
|
+
this.defaultLanguage = defaultLanguage;
|
|
91
|
+
}
|
|
92
|
+
localesDir;
|
|
93
|
+
defaultLanguage;
|
|
94
|
+
validate() {
|
|
95
|
+
const errors = [];
|
|
96
|
+
const defaultDir = path3.join(this.localesDir, this.defaultLanguage);
|
|
97
|
+
if (!fs3.existsSync(defaultDir)) return errors;
|
|
98
|
+
const nsFiles = fs3.readdirSync(defaultDir).filter((f) => f.endsWith(".json"));
|
|
99
|
+
const langs = fs3.readdirSync(this.localesDir, { withFileTypes: true }).filter((e) => e.isDirectory() && e.name !== this.defaultLanguage).map((e) => e.name);
|
|
100
|
+
for (const nsFile of nsFiles) {
|
|
101
|
+
const defaultEntries = readJsonSafe3(path3.join(defaultDir, nsFile));
|
|
102
|
+
if (!defaultEntries) continue;
|
|
103
|
+
const namespace = nsFile.replace(".json", "");
|
|
104
|
+
for (const lang of langs) {
|
|
105
|
+
const targetEntries = readJsonSafe3(path3.join(this.localesDir, lang, nsFile)) || {};
|
|
106
|
+
for (const [key, defaultVal] of Object.entries(defaultEntries)) {
|
|
107
|
+
const targetVal = targetEntries[key];
|
|
108
|
+
if (!targetVal) continue;
|
|
109
|
+
const defPh = this.extract(defaultVal);
|
|
110
|
+
const tgtPh = this.extract(targetVal);
|
|
111
|
+
const missing = defPh.filter((p) => !tgtPh.includes(p));
|
|
112
|
+
const extra = tgtPh.filter((p) => !defPh.includes(p));
|
|
113
|
+
if (missing.length > 0 || extra.length > 0) {
|
|
114
|
+
errors.push({ type: "placeholder-mismatch", key: `${namespace}.${key}`, language: lang, message: `Placeholder mismatch in "${lang}": missing [${missing.join(", ")}], extra [${extra.join(", ")}]` });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return errors;
|
|
120
|
+
}
|
|
121
|
+
extract(value) {
|
|
122
|
+
const result = [];
|
|
123
|
+
for (const p of PATTERNS) {
|
|
124
|
+
p.lastIndex = 0;
|
|
125
|
+
let m;
|
|
126
|
+
while ((m = p.exec(value)) !== null) result.push(m[0]);
|
|
127
|
+
}
|
|
128
|
+
return [...new Set(result)];
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// src/unused-key-validator.ts
|
|
133
|
+
import * as fs4 from "fs";
|
|
134
|
+
import * as path4 from "path";
|
|
135
|
+
import { readJsonSafe as readJsonSafe4, collectFiles } from "@ai-localize/shared";
|
|
136
|
+
var UnusedKeyValidator = class {
|
|
137
|
+
constructor(localesDir, sourceDir, defaultLanguage = "en") {
|
|
138
|
+
this.localesDir = localesDir;
|
|
139
|
+
this.sourceDir = sourceDir;
|
|
140
|
+
this.defaultLanguage = defaultLanguage;
|
|
141
|
+
}
|
|
142
|
+
localesDir;
|
|
143
|
+
sourceDir;
|
|
144
|
+
defaultLanguage;
|
|
145
|
+
validate() {
|
|
146
|
+
const warnings = [];
|
|
147
|
+
const unusedKeys = [];
|
|
148
|
+
const allKeys = this.collectLocaleKeys();
|
|
149
|
+
const sourceContent = this.collectSourceContent();
|
|
150
|
+
for (const key of allKeys) {
|
|
151
|
+
const shortKey = key.includes(".") ? key.split(".").slice(1).join(".") : key;
|
|
152
|
+
const referenced = sourceContent.includes(`'${key}'`) || sourceContent.includes(`"${key}"`) || sourceContent.includes(`'${shortKey}'`) || sourceContent.includes(`"${shortKey}"`);
|
|
153
|
+
if (!referenced) {
|
|
154
|
+
unusedKeys.push(key);
|
|
155
|
+
warnings.push({ type: "unused-key", key, message: `Key "${key}" not referenced in source` });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return { warnings, unusedKeys };
|
|
159
|
+
}
|
|
160
|
+
collectLocaleKeys() {
|
|
161
|
+
const keys = [];
|
|
162
|
+
const defaultDir = path4.join(this.localesDir, this.defaultLanguage);
|
|
163
|
+
if (!fs4.existsSync(defaultDir)) return keys;
|
|
164
|
+
for (const file of fs4.readdirSync(defaultDir).filter((f) => f.endsWith(".json"))) {
|
|
165
|
+
const ns = file.replace(".json", "");
|
|
166
|
+
const entries = readJsonSafe4(path4.join(defaultDir, file)) || {};
|
|
167
|
+
for (const key of Object.keys(entries)) keys.push(`${ns}.${key}`);
|
|
168
|
+
}
|
|
169
|
+
return keys;
|
|
170
|
+
}
|
|
171
|
+
collectSourceContent() {
|
|
172
|
+
const files = collectFiles(this.sourceDir, ["ts", "tsx", "js", "jsx", "vue", "html"], ["node_modules", "dist"]);
|
|
173
|
+
let content = "";
|
|
174
|
+
for (const f of files) {
|
|
175
|
+
try {
|
|
176
|
+
content += fs4.readFileSync(f, "utf-8") + "\n";
|
|
177
|
+
} catch {
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return content;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// src/locale-validator.ts
|
|
185
|
+
var LocaleValidator = class {
|
|
186
|
+
constructor(options) {
|
|
187
|
+
this.options = options;
|
|
188
|
+
}
|
|
189
|
+
options;
|
|
190
|
+
validate() {
|
|
191
|
+
const errors = [];
|
|
192
|
+
const warnings = [];
|
|
193
|
+
const lang = this.options.defaultLanguage || "en";
|
|
194
|
+
const { errors: missingErrors } = new MissingKeyValidator(this.options.localesDir, lang, this.options.targetLanguages || []).validate();
|
|
195
|
+
errors.push(...missingErrors);
|
|
196
|
+
if (this.options.checkDuplicates !== false) {
|
|
197
|
+
const { errors: dupErrors } = new DuplicateKeyValidator(this.options.localesDir, lang).validate();
|
|
198
|
+
errors.push(...dupErrors);
|
|
199
|
+
}
|
|
200
|
+
if (this.options.checkPlaceholders !== false) {
|
|
201
|
+
errors.push(...new PlaceholderValidator(this.options.localesDir, lang).validate());
|
|
202
|
+
}
|
|
203
|
+
if (this.options.checkUnused !== false) {
|
|
204
|
+
const { warnings: unusedWarnings } = new UnusedKeyValidator(this.options.localesDir, this.options.sourceDir, lang).validate();
|
|
205
|
+
warnings.push(...unusedWarnings);
|
|
206
|
+
}
|
|
207
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
export {
|
|
211
|
+
DuplicateKeyValidator,
|
|
212
|
+
LocaleValidator,
|
|
213
|
+
MissingKeyValidator,
|
|
214
|
+
PlaceholderValidator,
|
|
215
|
+
UnusedKeyValidator
|
|
216
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ai-localize-validators",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Locale file validation engine",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"module": "./dist/index.mjs",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.mjs",
|
|
12
|
+
"require": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"ai-localize-shared": "1.0.0"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"tsup": "^8.0.1",
|
|
20
|
+
"typescript": "^5.3.3",
|
|
21
|
+
"vitest": "^1.2.1"
|
|
22
|
+
},
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "tsup src/index.ts --format cjs,esm --dts",
|
|
29
|
+
"dev": "tsup src/index.ts --format cjs,esm --dts --watch",
|
|
30
|
+
"typecheck": "tsc --noEmit",
|
|
31
|
+
"test": "vitest run",
|
|
32
|
+
"lint": "eslint src --ext .ts"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as os from 'os';
|
|
5
|
+
import { MissingKeyValidator } from '../missing-key-validator.js';
|
|
6
|
+
|
|
7
|
+
let tmpDir: string;
|
|
8
|
+
|
|
9
|
+
beforeAll(() => {
|
|
10
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-localize-test-'));
|
|
11
|
+
fs.mkdirSync(path.join(tmpDir, 'en'), { recursive: true });
|
|
12
|
+
fs.mkdirSync(path.join(tmpDir, 'fr'), { recursive: true });
|
|
13
|
+
fs.writeFileSync(
|
|
14
|
+
path.join(tmpDir, 'en', 'translation.json'),
|
|
15
|
+
JSON.stringify({ greeting: 'Hello', farewell: 'Goodbye' })
|
|
16
|
+
);
|
|
17
|
+
fs.writeFileSync(
|
|
18
|
+
path.join(tmpDir, 'fr', 'translation.json'),
|
|
19
|
+
JSON.stringify({ greeting: 'Bonjour' })
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterAll(() => {
|
|
24
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('MissingKeyValidator', () => {
|
|
28
|
+
it('detects missing keys in target language', () => {
|
|
29
|
+
const validator = new MissingKeyValidator(tmpDir, 'en', ['fr']);
|
|
30
|
+
const { errors } = validator.validate();
|
|
31
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
32
|
+
const missingFarewellInFr = errors.find((e) => e.key.includes('farewell') && e.language === 'fr');
|
|
33
|
+
expect(missingFarewellInFr).toBeDefined();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('returns no errors when all keys present', () => {
|
|
37
|
+
fs.writeFileSync(
|
|
38
|
+
path.join(tmpDir, 'fr', 'translation.json'),
|
|
39
|
+
JSON.stringify({ greeting: 'Bonjour', farewell: 'Au revoir' })
|
|
40
|
+
);
|
|
41
|
+
const validator = new MissingKeyValidator(tmpDir, 'en', ['fr']);
|
|
42
|
+
const { errors } = validator.validate();
|
|
43
|
+
expect(errors.length).toBe(0);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import type { ValidationError } from "@ai-localize/shared";
|
|
4
|
+
import { readJsonSafe } from "@ai-localize/shared";
|
|
5
|
+
|
|
6
|
+
export class DuplicateKeyValidator {
|
|
7
|
+
constructor(private localesDir: string, private defaultLanguage = "en") {}
|
|
8
|
+
|
|
9
|
+
validate(): { errors: ValidationError[]; duplicates: string[] } {
|
|
10
|
+
const errors: ValidationError[] = [];
|
|
11
|
+
const duplicates: string[] = [];
|
|
12
|
+
const defaultDir = path.join(this.localesDir, this.defaultLanguage);
|
|
13
|
+
if (!fs.existsSync(defaultDir)) return { errors, duplicates };
|
|
14
|
+
|
|
15
|
+
const nsFiles = fs.readdirSync(defaultDir).filter((f) => f.endsWith(".json"));
|
|
16
|
+
for (const nsFile of nsFiles) {
|
|
17
|
+
const entries = readJsonSafe<Record<string, string>>(path.join(defaultDir, nsFile));
|
|
18
|
+
if (!entries) continue;
|
|
19
|
+
const namespace = nsFile.replace(".json", "");
|
|
20
|
+
const valueMap = new Map<string, string[]>();
|
|
21
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
22
|
+
if (!value) continue;
|
|
23
|
+
const existing = valueMap.get(value) || [];
|
|
24
|
+
existing.push(key);
|
|
25
|
+
valueMap.set(value, existing);
|
|
26
|
+
}
|
|
27
|
+
for (const [value, keys] of valueMap) {
|
|
28
|
+
if (keys.length > 1) {
|
|
29
|
+
const fullKeys = keys.map((k) => `${namespace}.${k}`);
|
|
30
|
+
duplicates.push(...fullKeys);
|
|
31
|
+
errors.push({ type: "duplicate-key", key: fullKeys.join(", "), message: `Duplicate value "${value}" for: ${fullKeys.join(", ")}` });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return { errors, duplicates };
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ValidationResult } from "@ai-localize/shared";
|
|
2
|
+
import { MissingKeyValidator } from "./missing-key-validator.js";
|
|
3
|
+
import { DuplicateKeyValidator } from "./duplicate-key-validator.js";
|
|
4
|
+
import { PlaceholderValidator } from "./placeholder-validator.js";
|
|
5
|
+
import { UnusedKeyValidator } from "./unused-key-validator.js";
|
|
6
|
+
|
|
7
|
+
export interface ValidatorOptions {
|
|
8
|
+
localesDir: string;
|
|
9
|
+
sourceDir: string;
|
|
10
|
+
defaultLanguage?: string;
|
|
11
|
+
targetLanguages?: string[];
|
|
12
|
+
checkUnused?: boolean;
|
|
13
|
+
checkDuplicates?: boolean;
|
|
14
|
+
checkPlaceholders?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class LocaleValidator {
|
|
18
|
+
constructor(private options: ValidatorOptions) {}
|
|
19
|
+
|
|
20
|
+
validate(): ValidationResult {
|
|
21
|
+
const errors: ValidationResult["errors"] = [];
|
|
22
|
+
const warnings: ValidationResult["warnings"] = [];
|
|
23
|
+
const lang = this.options.defaultLanguage || "en";
|
|
24
|
+
|
|
25
|
+
const { errors: missingErrors } = new MissingKeyValidator(this.options.localesDir, lang, this.options.targetLanguages || []).validate();
|
|
26
|
+
errors.push(...missingErrors);
|
|
27
|
+
|
|
28
|
+
if (this.options.checkDuplicates !== false) {
|
|
29
|
+
const { errors: dupErrors } = new DuplicateKeyValidator(this.options.localesDir, lang).validate();
|
|
30
|
+
errors.push(...dupErrors);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (this.options.checkPlaceholders !== false) {
|
|
34
|
+
errors.push(...new PlaceholderValidator(this.options.localesDir, lang).validate());
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (this.options.checkUnused !== false) {
|
|
38
|
+
const { warnings: unusedWarnings } = new UnusedKeyValidator(this.options.localesDir, this.options.sourceDir, lang).validate();
|
|
39
|
+
warnings.push(...unusedWarnings);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import type { ValidationError } from "@ai-localize/shared";
|
|
4
|
+
import { readJsonSafe } from "@ai-localize/shared";
|
|
5
|
+
|
|
6
|
+
export class MissingKeyValidator {
|
|
7
|
+
constructor(
|
|
8
|
+
private localesDir: string,
|
|
9
|
+
private defaultLanguage = "en",
|
|
10
|
+
private targetLanguages: string[] = []
|
|
11
|
+
) {}
|
|
12
|
+
|
|
13
|
+
validate(): { errors: ValidationError[]; missingByLanguage: Record<string, string[]> } {
|
|
14
|
+
const errors: ValidationError[] = [];
|
|
15
|
+
const missingByLanguage: Record<string, string[]> = {};
|
|
16
|
+
|
|
17
|
+
const defaultDir = path.join(this.localesDir, this.defaultLanguage);
|
|
18
|
+
if (!fs.existsSync(defaultDir)) return { errors, missingByLanguage };
|
|
19
|
+
|
|
20
|
+
const nsFiles = fs.readdirSync(defaultDir).filter((f) => f.endsWith(".json"));
|
|
21
|
+
const langs = this.targetLanguages.length > 0
|
|
22
|
+
? this.targetLanguages
|
|
23
|
+
: fs.readdirSync(this.localesDir, { withFileTypes: true })
|
|
24
|
+
.filter((e) => e.isDirectory() && e.name !== this.defaultLanguage)
|
|
25
|
+
.map((e) => e.name);
|
|
26
|
+
|
|
27
|
+
for (const nsFile of nsFiles) {
|
|
28
|
+
const defaultEntries = readJsonSafe<Record<string, string>>(path.join(defaultDir, nsFile));
|
|
29
|
+
if (!defaultEntries) continue;
|
|
30
|
+
const namespace = nsFile.replace(".json", "");
|
|
31
|
+
for (const lang of langs) {
|
|
32
|
+
const targetPath = path.join(this.localesDir, lang, nsFile);
|
|
33
|
+
const targetEntries = readJsonSafe<Record<string, string>>(targetPath) || {};
|
|
34
|
+
for (const key of Object.keys(defaultEntries)) {
|
|
35
|
+
if (!(key in targetEntries) || targetEntries[key] === "") {
|
|
36
|
+
const fullKey = `${namespace}.${key}`;
|
|
37
|
+
errors.push({ type: "missing-key", key: fullKey, language: lang, message: `Missing key "${fullKey}" in "${lang}"`, filePath: targetPath });
|
|
38
|
+
if (!missingByLanguage[lang]) missingByLanguage[lang] = [];
|
|
39
|
+
missingByLanguage[lang].push(fullKey);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { errors, missingByLanguage };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import type { ValidationError } from "@ai-localize/shared";
|
|
4
|
+
import { readJsonSafe } from "@ai-localize/shared";
|
|
5
|
+
|
|
6
|
+
const PATTERNS = [/\{\{([^}]+)\}\}/g, /\{([^}]+)\}/g, /%\(([^)]+)\)s/g, /%(\w+)/g];
|
|
7
|
+
|
|
8
|
+
export class PlaceholderValidator {
|
|
9
|
+
constructor(private localesDir: string, private defaultLanguage = "en") {}
|
|
10
|
+
|
|
11
|
+
validate(): ValidationError[] {
|
|
12
|
+
const errors: ValidationError[] = [];
|
|
13
|
+
const defaultDir = path.join(this.localesDir, this.defaultLanguage);
|
|
14
|
+
if (!fs.existsSync(defaultDir)) return errors;
|
|
15
|
+
|
|
16
|
+
const nsFiles = fs.readdirSync(defaultDir).filter((f) => f.endsWith(".json"));
|
|
17
|
+
const langs = fs.readdirSync(this.localesDir, { withFileTypes: true })
|
|
18
|
+
.filter((e) => e.isDirectory() && e.name !== this.defaultLanguage)
|
|
19
|
+
.map((e) => e.name);
|
|
20
|
+
|
|
21
|
+
for (const nsFile of nsFiles) {
|
|
22
|
+
const defaultEntries = readJsonSafe<Record<string, string>>(path.join(defaultDir, nsFile));
|
|
23
|
+
if (!defaultEntries) continue;
|
|
24
|
+
const namespace = nsFile.replace(".json", "");
|
|
25
|
+
for (const lang of langs) {
|
|
26
|
+
const targetEntries = readJsonSafe<Record<string, string>>(path.join(this.localesDir, lang, nsFile)) || {};
|
|
27
|
+
for (const [key, defaultVal] of Object.entries(defaultEntries)) {
|
|
28
|
+
const targetVal = targetEntries[key];
|
|
29
|
+
if (!targetVal) continue;
|
|
30
|
+
const defPh = this.extract(defaultVal);
|
|
31
|
+
const tgtPh = this.extract(targetVal);
|
|
32
|
+
const missing = defPh.filter((p) => !tgtPh.includes(p));
|
|
33
|
+
const extra = tgtPh.filter((p) => !defPh.includes(p));
|
|
34
|
+
if (missing.length > 0 || extra.length > 0) {
|
|
35
|
+
errors.push({ type: "placeholder-mismatch", key: `${namespace}.${key}`, language: lang, message: `Placeholder mismatch in "${lang}": missing [${missing.join(", ")}], extra [${extra.join(", ")}]` });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return errors;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private extract(value: string): string[] {
|
|
44
|
+
const result: string[] = [];
|
|
45
|
+
for (const p of PATTERNS) { p.lastIndex = 0; let m; while ((m = p.exec(value)) !== null) result.push(m[0]); }
|
|
46
|
+
return [...new Set(result)];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import type { ValidationWarning } from "@ai-localize/shared";
|
|
4
|
+
import { readJsonSafe, collectFiles } from "@ai-localize/shared";
|
|
5
|
+
|
|
6
|
+
export class UnusedKeyValidator {
|
|
7
|
+
constructor(
|
|
8
|
+
private localesDir: string,
|
|
9
|
+
private sourceDir: string,
|
|
10
|
+
private defaultLanguage = "en"
|
|
11
|
+
) {}
|
|
12
|
+
|
|
13
|
+
validate(): { warnings: ValidationWarning[]; unusedKeys: string[] } {
|
|
14
|
+
const warnings: ValidationWarning[] = [];
|
|
15
|
+
const unusedKeys: string[] = [];
|
|
16
|
+
const allKeys = this.collectLocaleKeys();
|
|
17
|
+
const sourceContent = this.collectSourceContent();
|
|
18
|
+
|
|
19
|
+
for (const key of allKeys) {
|
|
20
|
+
const shortKey = key.includes(".") ? key.split(".").slice(1).join(".") : key;
|
|
21
|
+
const referenced =
|
|
22
|
+
sourceContent.includes(`'${key}'`) || sourceContent.includes(`"${key}"`) ||
|
|
23
|
+
sourceContent.includes(`'${shortKey}'`) || sourceContent.includes(`"${shortKey}"`);
|
|
24
|
+
if (!referenced) {
|
|
25
|
+
unusedKeys.push(key);
|
|
26
|
+
warnings.push({ type: "unused-key", key, message: `Key "${key}" not referenced in source` });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return { warnings, unusedKeys };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private collectLocaleKeys(): string[] {
|
|
33
|
+
const keys: string[] = [];
|
|
34
|
+
const defaultDir = path.join(this.localesDir, this.defaultLanguage);
|
|
35
|
+
if (!fs.existsSync(defaultDir)) return keys;
|
|
36
|
+
for (const file of fs.readdirSync(defaultDir).filter((f) => f.endsWith(".json"))) {
|
|
37
|
+
const ns = file.replace(".json", "");
|
|
38
|
+
const entries = readJsonSafe<Record<string, string>>(path.join(defaultDir, file)) || {};
|
|
39
|
+
for (const key of Object.keys(entries)) keys.push(`${ns}.${key}`);
|
|
40
|
+
}
|
|
41
|
+
return keys;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private collectSourceContent(): string {
|
|
45
|
+
const files = collectFiles(this.sourceDir, ["ts", "tsx", "js", "jsx", "vue", "html"], ["node_modules", "dist"]);
|
|
46
|
+
let content = "";
|
|
47
|
+
for (const f of files) { try { content += fs.readFileSync(f, 'utf-8') + '\n'; } catch { /**/ } }
|
|
48
|
+
return content;
|
|
49
|
+
}
|
|
50
|
+
}
|