i18ntk 4.0.0 → 4.2.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/CHANGELOG.md +116 -29
- package/README.md +83 -18
- package/SECURITY.md +13 -5
- package/main/i18ntk-analyze.js +10 -20
- package/main/i18ntk-backup.js +227 -111
- package/main/i18ntk-init.js +153 -157
- package/main/i18ntk-scanner.js +9 -7
- package/main/i18ntk-setup.js +36 -13
- package/main/i18ntk-sizing.js +18 -50
- package/main/i18ntk-translate.js +169 -21
- package/main/i18ntk-usage.js +298 -154
- package/main/i18ntk-validate.js +49 -37
- package/main/manage/commands/AnalyzeCommand.js +7 -17
- package/main/manage/commands/CommandRouter.js +6 -6
- package/main/manage/commands/TranslateCommand.js +65 -56
- package/main/manage/commands/ValidateCommand.js +34 -26
- package/main/manage/index.js +11 -42
- package/main/manage/managers/InteractiveMenu.js +11 -40
- package/main/manage/services/InitService.js +114 -118
- package/main/manage/services/UsageService.js +244 -85
- package/package.json +55 -4
- package/runtime/enhanced.d.ts +5 -5
- package/runtime/enhanced.js +49 -25
- package/runtime/i18ntk.d.ts +30 -7
- package/runtime/index.d.ts +48 -19
- package/runtime/index.js +188 -97
- package/settings/settings-cli.js +115 -38
- package/settings/settings-manager.js +24 -6
- package/ui-locales/de.json +192 -11
- package/ui-locales/en.json +182 -8
- package/ui-locales/es.json +193 -12
- package/ui-locales/fr.json +189 -8
- package/ui-locales/ja.json +190 -8
- package/ui-locales/ru.json +191 -9
- package/ui-locales/zh.json +194 -9
- package/utils/cli-helper.js +8 -12
- package/utils/config-helper.js +1 -1
- package/utils/config-manager.js +8 -6
- package/utils/localized-confirm.js +55 -0
- package/utils/menu-layout.js +41 -0
- package/utils/report-writer.js +110 -0
- package/utils/security.js +15 -22
- package/utils/translate/api.js +31 -3
- package/utils/translate/placeholder.js +42 -1
- package/utils/translate/protection.js +17 -12
- package/utils/translate/report.js +3 -2
- package/utils/translate/safe-network.js +24 -4
- package/utils/usage-insights.js +435 -0
- package/utils/usage-source.js +50 -0
- package/utils/watch-locales.js +13 -9
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
|
|
3
|
+
const KEY_BOUNDARY = /[A-Za-z0-9_.:*-]/;
|
|
4
|
+
const HUMAN_TEXT_MIN = 3;
|
|
5
|
+
const HUMAN_TEXT_MAX = 120;
|
|
6
|
+
const MAX_DYNAMIC_EXPANSIONS = 25;
|
|
7
|
+
|
|
8
|
+
function stripComments(content) {
|
|
9
|
+
return String(content || '')
|
|
10
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
11
|
+
.replace(/(^|[^:])\/\/.*$/gm, '$1');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function findLineColumn(content, index) {
|
|
15
|
+
const before = content.slice(0, Math.max(0, index));
|
|
16
|
+
const lines = before.split(/\r?\n/);
|
|
17
|
+
return {
|
|
18
|
+
line: lines.length,
|
|
19
|
+
column: lines[lines.length - 1].length + 1,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isBoundaryAt(content, index) {
|
|
24
|
+
if (index < 0 || index >= content.length) return true;
|
|
25
|
+
return !KEY_BOUNDARY.test(content[index]);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function findLiteralKeyReferences(content, availableKeys) {
|
|
29
|
+
const source = stripComments(content);
|
|
30
|
+
const references = [];
|
|
31
|
+
|
|
32
|
+
for (const key of Array.from(availableKeys || []).sort((a, b) => b.length - a.length)) {
|
|
33
|
+
if (!key || typeof key !== 'string') continue;
|
|
34
|
+
let fromIndex = 0;
|
|
35
|
+
|
|
36
|
+
while (fromIndex < source.length) {
|
|
37
|
+
const index = source.indexOf(key, fromIndex);
|
|
38
|
+
if (index === -1) break;
|
|
39
|
+
|
|
40
|
+
const beforeOk = isBoundaryAt(source, index - 1);
|
|
41
|
+
const afterOk = isBoundaryAt(source, index + key.length);
|
|
42
|
+
const lineStart = source.lastIndexOf('\n', index) + 1;
|
|
43
|
+
const beforeOnLine = source.slice(lineStart, index);
|
|
44
|
+
const looksLikeObjectValue = /:\s*['"`]?$/.test(beforeOnLine);
|
|
45
|
+
if (beforeOk && afterOk && !looksLikeObjectValue) {
|
|
46
|
+
references.push({
|
|
47
|
+
key,
|
|
48
|
+
matchType: 'literal',
|
|
49
|
+
...findLineColumn(source, index),
|
|
50
|
+
});
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
fromIndex = index + key.length;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return references;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseStringList(raw) {
|
|
62
|
+
const values = [];
|
|
63
|
+
const source = String(raw || '');
|
|
64
|
+
const stringPattern = /['"`]([^'"`\r\n$]+)['"`]/g;
|
|
65
|
+
let match;
|
|
66
|
+
|
|
67
|
+
while ((match = stringPattern.exec(source)) !== null) {
|
|
68
|
+
const value = String(match[1] || '').trim();
|
|
69
|
+
if (/^[A-Za-z0-9_.:-]+$/.test(value)) values.push(value);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return values;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseObjectKeyValues(raw) {
|
|
76
|
+
const entries = [];
|
|
77
|
+
const source = String(raw || '');
|
|
78
|
+
const propertyPattern = /(?:^|[,{])\s*['"]?([A-Za-z_$][\w$-]*)['"]?\s*:\s*['"`]([^'"`\r\n$]+)['"`]/g;
|
|
79
|
+
let match;
|
|
80
|
+
|
|
81
|
+
while ((match = propertyPattern.exec(source)) !== null) {
|
|
82
|
+
const value = String(match[2] || '').trim();
|
|
83
|
+
if (/^[A-Za-z0-9_.:-]+$/.test(value)) {
|
|
84
|
+
entries.push([match[1], value]);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return entries;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function collectSimpleBindings(content) {
|
|
92
|
+
const source = stripComments(content);
|
|
93
|
+
const bindings = new Map();
|
|
94
|
+
const declarationPattern = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*([^;\n]+)/g;
|
|
95
|
+
let match;
|
|
96
|
+
|
|
97
|
+
while ((match = declarationPattern.exec(source)) !== null) {
|
|
98
|
+
const name = match[1];
|
|
99
|
+
const rawValue = String(match[2] || '').trim();
|
|
100
|
+
|
|
101
|
+
if (rawValue.startsWith('[') || rawValue.startsWith('{')) {
|
|
102
|
+
const values = parseStringList(rawValue);
|
|
103
|
+
if (values.length > 0 && values.length <= MAX_DYNAMIC_EXPANSIONS) {
|
|
104
|
+
bindings.set(name, values);
|
|
105
|
+
}
|
|
106
|
+
if (rawValue.startsWith('{')) {
|
|
107
|
+
for (const [property, value] of parseObjectKeyValues(rawValue)) {
|
|
108
|
+
bindings.set(`${name}.${property}`, [value]);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const values = parseStringList(rawValue);
|
|
115
|
+
if (values.length === 1 && /^['"`]/.test(rawValue)) {
|
|
116
|
+
bindings.set(name, values);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const iteratorPattern = /\b([A-Za-z_$][\w$]*)\s*\.\s*(?:map|forEach|filter|some|every)\s*\(\s*([A-Za-z_$][\w$]*)\s*=>/g;
|
|
121
|
+
while ((match = iteratorPattern.exec(source)) !== null) {
|
|
122
|
+
const sourceValues = bindings.get(match[1]);
|
|
123
|
+
if (sourceValues && sourceValues.length <= MAX_DYNAMIC_EXPANSIONS) {
|
|
124
|
+
bindings.set(match[2], sourceValues);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return bindings;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function expandTemplateLiteral(template, bindings) {
|
|
132
|
+
const parts = [];
|
|
133
|
+
let index = 0;
|
|
134
|
+
const expressionPattern = /\$\{\s*([A-Za-z_$][\w$]*)\s*\}/g;
|
|
135
|
+
let match;
|
|
136
|
+
|
|
137
|
+
while ((match = expressionPattern.exec(template)) !== null) {
|
|
138
|
+
parts.push([template.slice(index, match.index)]);
|
|
139
|
+
const values = bindings.get(match[1]);
|
|
140
|
+
if (!values || values.length === 0) return [];
|
|
141
|
+
parts.push(values);
|
|
142
|
+
index = match.index + match[0].length;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
parts.push([template.slice(index)]);
|
|
146
|
+
|
|
147
|
+
let expanded = [''];
|
|
148
|
+
for (const values of parts) {
|
|
149
|
+
const next = [];
|
|
150
|
+
for (const prefix of expanded) {
|
|
151
|
+
for (const value of values) {
|
|
152
|
+
next.push(prefix + value);
|
|
153
|
+
if (next.length > MAX_DYNAMIC_EXPANSIONS) return [];
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
expanded = next;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return expanded.filter(key => /^[A-Za-z0-9_.:*-]+$/.test(key));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function expressionToAvailableKeys(expression, bindings, availableKeys) {
|
|
163
|
+
const available = availableKeys || new Set();
|
|
164
|
+
const source = String(expression || '').trim();
|
|
165
|
+
const results = [];
|
|
166
|
+
|
|
167
|
+
const literal = source.match(/^['"`]([^'"`\r\n$]+)['"`]$/);
|
|
168
|
+
if (literal && available.has(literal[1])) return [literal[1]];
|
|
169
|
+
|
|
170
|
+
const identifier = source.match(/^([A-Za-z_$][\w$]*)$/);
|
|
171
|
+
if (identifier) {
|
|
172
|
+
const values = bindings.get(identifier[1]) || [];
|
|
173
|
+
return values.filter(value => available.has(value));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const memberLookup = source.match(/^([A-Za-z_$][\w$]*)\s*\[[^\]]+\]$/);
|
|
177
|
+
if (memberLookup) {
|
|
178
|
+
const values = bindings.get(memberLookup[1]) || [];
|
|
179
|
+
return values.filter(value => available.has(value));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const propertyLookup = source.match(/^([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$-]*)$/);
|
|
183
|
+
if (propertyLookup) {
|
|
184
|
+
const values = bindings.get(`${propertyLookup[1]}.${propertyLookup[2]}`) || [];
|
|
185
|
+
return values.filter(value => available.has(value));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const ternary = source.match(/^[^?]+?\?\s*([^:]+)\s*:\s*(.+)$/);
|
|
189
|
+
if (ternary) {
|
|
190
|
+
return [
|
|
191
|
+
...expressionToAvailableKeys(ternary[1], bindings, available),
|
|
192
|
+
...expressionToAvailableKeys(ternary[2], bindings, available),
|
|
193
|
+
].filter((key, index, keys) => keys.indexOf(key) === index);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const template = source.match(/^`([^`]*)`$/);
|
|
197
|
+
if (template) {
|
|
198
|
+
return expandTemplateLiteral(template[1], bindings).filter(key => available.has(key));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return results;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function inferDynamicKeyReferences(content, availableKeys) {
|
|
205
|
+
const source = stripComments(content);
|
|
206
|
+
const bindings = collectSimpleBindings(source);
|
|
207
|
+
const references = [];
|
|
208
|
+
const seen = new Set();
|
|
209
|
+
const callPattern = /(?:\bt|\bi18n\.t|\$t|\btranslate)\s*\(\s*(`[^`]*`|['"][^'"\r\n]+['"]|[^,\)\r\n]+)/g;
|
|
210
|
+
let match;
|
|
211
|
+
|
|
212
|
+
while ((match = callPattern.exec(source)) !== null) {
|
|
213
|
+
const keys = expressionToAvailableKeys(match[1], bindings, availableKeys);
|
|
214
|
+
if (keys.length === 0) continue;
|
|
215
|
+
|
|
216
|
+
const location = findLineColumn(source, match.index);
|
|
217
|
+
for (const key of keys) {
|
|
218
|
+
if (seen.has(key)) continue;
|
|
219
|
+
seen.add(key);
|
|
220
|
+
references.push({
|
|
221
|
+
key,
|
|
222
|
+
matchType: match[1].startsWith('`') ? 'dynamic-template' : 'dynamic-variable',
|
|
223
|
+
...location,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return references;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function inferDynamicPrefix(expression) {
|
|
232
|
+
const source = String(expression || '').trim();
|
|
233
|
+
const template = source.match(/^`([^`]*)\$\{[^}]+\}/);
|
|
234
|
+
if (template && /^[A-Za-z0-9_.:-]+$/.test(template[1])) return template[1];
|
|
235
|
+
|
|
236
|
+
const concat = source.match(/^['"]([A-Za-z0-9_.:-]+)['"]\s*\+/);
|
|
237
|
+
if (concat) return concat[1];
|
|
238
|
+
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function findUnresolvedDynamicReferences(content, availableKeys) {
|
|
243
|
+
const source = stripComments(content);
|
|
244
|
+
const bindings = collectSimpleBindings(source);
|
|
245
|
+
const unresolved = [];
|
|
246
|
+
const seen = new Set();
|
|
247
|
+
const callPattern = /(?:\bt|\bi18n\.t|\$t|\btranslate)\s*\(\s*(`[^`]*`|['"][^'"\r\n]+['"]|[^,\)\r\n]+)/g;
|
|
248
|
+
let match;
|
|
249
|
+
|
|
250
|
+
while ((match = callPattern.exec(source)) !== null) {
|
|
251
|
+
const expression = String(match[1] || '').trim();
|
|
252
|
+
if (!expression || expressionToAvailableKeys(expression, bindings, availableKeys).length > 0) continue;
|
|
253
|
+
if (!/[`+$[\]?]/.test(expression) && !/^[A-Za-z_$][\w$]*$/.test(expression)) continue;
|
|
254
|
+
|
|
255
|
+
const location = findLineColumn(source, match.index);
|
|
256
|
+
const key = `${expression}\0${location.line}\0${location.column}`;
|
|
257
|
+
if (seen.has(key)) continue;
|
|
258
|
+
seen.add(key);
|
|
259
|
+
|
|
260
|
+
unresolved.push({
|
|
261
|
+
expression,
|
|
262
|
+
prefix: inferDynamicPrefix(expression),
|
|
263
|
+
reason: 'Could not resolve expression to literal translation keys without executing code.',
|
|
264
|
+
line: location.line,
|
|
265
|
+
column: location.column,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return unresolved;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function normalizeSegment(segment) {
|
|
273
|
+
return String(segment || '')
|
|
274
|
+
.replace(/\.(jsx?|tsx?|vue|svelte|html|py)$/i, '')
|
|
275
|
+
.replace(/^\[+|\]+$/g, '')
|
|
276
|
+
.replace(/^\(+|\)+$/g, '')
|
|
277
|
+
.replace(/[^A-Za-z0-9_-]/g, '')
|
|
278
|
+
.toLowerCase();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function deriveNamespaceCandidates(relativePath) {
|
|
282
|
+
const rawParts = String(relativePath || '').split(/[\\/]+/).map(normalizeSegment).filter(Boolean);
|
|
283
|
+
const ignored = new Set([
|
|
284
|
+
'src', 'app', 'pages', 'page', 'route', 'routes', 'index', 'layout',
|
|
285
|
+
'components', 'component', 'ui', 'lib', 'utils', 'hooks', 'shared',
|
|
286
|
+
]);
|
|
287
|
+
const candidates = [];
|
|
288
|
+
|
|
289
|
+
for (const part of rawParts) {
|
|
290
|
+
if (ignored.has(part)) continue;
|
|
291
|
+
if (/^\d+$/.test(part)) continue;
|
|
292
|
+
if (!candidates.includes(part)) candidates.push(part);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return candidates.slice(0, 3);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function createTextKey(text, namespace = 'ui') {
|
|
299
|
+
const slug = String(text || '')
|
|
300
|
+
.trim()
|
|
301
|
+
.toLowerCase()
|
|
302
|
+
.replace(/['"]/g, '')
|
|
303
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
304
|
+
.replace(/^_+|_+$/g, '')
|
|
305
|
+
.slice(0, 60);
|
|
306
|
+
|
|
307
|
+
return `${namespace || 'ui'}.${slug || 'text'}`;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function looksLikeHumanText(text) {
|
|
311
|
+
const value = String(text || '').replace(/\s+/g, ' ').trim();
|
|
312
|
+
if (value.length < HUMAN_TEXT_MIN || value.length > HUMAN_TEXT_MAX) return false;
|
|
313
|
+
if (!/[A-Za-z]/.test(value)) return false;
|
|
314
|
+
if (/^[A-Z0-9_./:-]+$/.test(value)) return false;
|
|
315
|
+
if (/^[a-z0-9_.:-]+$/.test(value) && value.includes('.')) return false;
|
|
316
|
+
if (/^(true|false|null|undefined|className|href|src|type)$/i.test(value)) return false;
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function collectHardcodedText(content, relativePath, translationValueIndex) {
|
|
321
|
+
const source = stripComments(content);
|
|
322
|
+
const namespace = deriveNamespaceCandidates(relativePath)[0] || 'ui';
|
|
323
|
+
const results = [];
|
|
324
|
+
const seen = new Set();
|
|
325
|
+
|
|
326
|
+
const patterns = [
|
|
327
|
+
/>\s*([^<>{}\n][^<>{}\n]*?[A-Za-z][^<>{}\n]*?)\s*</g,
|
|
328
|
+
/\b(?:aria-label|title|alt|placeholder|label)=["']([^"']+)["']/g,
|
|
329
|
+
];
|
|
330
|
+
|
|
331
|
+
for (const pattern of patterns) {
|
|
332
|
+
let match;
|
|
333
|
+
while ((match = pattern.exec(source)) !== null) {
|
|
334
|
+
const text = String(match[1] || '').replace(/\s+/g, ' ').trim();
|
|
335
|
+
if (!looksLikeHumanText(text)) continue;
|
|
336
|
+
|
|
337
|
+
const location = findLineColumn(source, match.index);
|
|
338
|
+
const key = `${text}\0${location.line}`;
|
|
339
|
+
if (seen.has(key)) continue;
|
|
340
|
+
seen.add(key);
|
|
341
|
+
|
|
342
|
+
results.push({
|
|
343
|
+
text,
|
|
344
|
+
suggestedKey: createTextKey(text, namespace),
|
|
345
|
+
existingKey: translationValueIndex && translationValueIndex.get(text),
|
|
346
|
+
filePath: relativePath,
|
|
347
|
+
line: location.line,
|
|
348
|
+
column: location.column,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return results;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function buildNamespaceRecommendation(relativePath, keyReferences, availableKeys) {
|
|
357
|
+
const namespace = deriveNamespaceCandidates(relativePath)[0];
|
|
358
|
+
if (!namespace) return null;
|
|
359
|
+
|
|
360
|
+
const keys = keyReferences.map(ref => ref.key).filter(Boolean);
|
|
361
|
+
const namespacePrefix = `${namespace}.`;
|
|
362
|
+
const hasNamespaceKey = keys.some(key => key.startsWith(namespacePrefix));
|
|
363
|
+
const hasAvailableNamespace = Array.from(availableKeys || []).some(key => key.startsWith(namespacePrefix));
|
|
364
|
+
|
|
365
|
+
if (hasNamespaceKey) return null;
|
|
366
|
+
if (keys.length === 0 && !hasAvailableNamespace) return null;
|
|
367
|
+
|
|
368
|
+
return {
|
|
369
|
+
namespace,
|
|
370
|
+
expectedFile: `${namespace}.json`,
|
|
371
|
+
message: `Source path suggests namespace "${namespace}". Prefer ${namespace}.* keys and a ${namespace}.json locale file for this page/feature.`,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function analyzeSourceForUsageInsights({
|
|
376
|
+
content,
|
|
377
|
+
relativePath,
|
|
378
|
+
availableKeys,
|
|
379
|
+
directKeys = [],
|
|
380
|
+
translationValueIndex = new Map(),
|
|
381
|
+
}) {
|
|
382
|
+
const references = [];
|
|
383
|
+
const seen = new Set();
|
|
384
|
+
const dynamicReferences = inferDynamicKeyReferences(content, availableKeys);
|
|
385
|
+
const unresolvedDynamicReferences = findUnresolvedDynamicReferences(content, availableKeys);
|
|
386
|
+
const literalReferences = findLiteralKeyReferences(content, availableKeys);
|
|
387
|
+
const exactInferredKeys = new Set([
|
|
388
|
+
...dynamicReferences.map(ref => ref.key),
|
|
389
|
+
...literalReferences.map(ref => ref.key),
|
|
390
|
+
]);
|
|
391
|
+
|
|
392
|
+
for (const key of directKeys || []) {
|
|
393
|
+
if (!key || seen.has(key)) continue;
|
|
394
|
+
if (key.endsWith('*')) {
|
|
395
|
+
const prefix = key.slice(0, -1);
|
|
396
|
+
if (Array.from(exactInferredKeys).some(inferredKey => inferredKey.startsWith(prefix))) {
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
if (unresolvedDynamicReferences.some(ref => ref.prefix === prefix)) {
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
seen.add(key);
|
|
404
|
+
references.push({ key, matchType: 'direct', line: null, column: null });
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
for (const ref of dynamicReferences) {
|
|
408
|
+
if (seen.has(ref.key)) continue;
|
|
409
|
+
seen.add(ref.key);
|
|
410
|
+
references.push(ref);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
for (const ref of literalReferences) {
|
|
414
|
+
if (seen.has(ref.key)) continue;
|
|
415
|
+
seen.add(ref.key);
|
|
416
|
+
references.push(ref);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return {
|
|
420
|
+
keyReferences: references,
|
|
421
|
+
unresolvedDynamicReferences,
|
|
422
|
+
hardcodedTexts: collectHardcodedText(content, relativePath, translationValueIndex),
|
|
423
|
+
namespaceRecommendation: buildNamespaceRecommendation(relativePath, references, availableKeys),
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
module.exports = {
|
|
428
|
+
analyzeSourceForUsageInsights,
|
|
429
|
+
createTextKey,
|
|
430
|
+
deriveNamespaceCandidates,
|
|
431
|
+
findLiteralKeyReferences,
|
|
432
|
+
inferDynamicKeyReferences,
|
|
433
|
+
findUnresolvedDynamicReferences,
|
|
434
|
+
looksLikeHumanText,
|
|
435
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const SecurityUtils = require('./security');
|
|
5
|
+
|
|
6
|
+
const DEFAULT_SOURCE_DIRS = ['src', 'app', 'lib', 'source'];
|
|
7
|
+
|
|
8
|
+
function resolveUsageSourceDir(options = {}) {
|
|
9
|
+
const projectRoot = path.resolve(options.projectRoot || process.cwd());
|
|
10
|
+
const sourceDir = options.sourceDir ? path.resolve(projectRoot, options.sourceDir) : null;
|
|
11
|
+
const i18nDir = options.i18nDir ? path.resolve(projectRoot, options.i18nDir) : null;
|
|
12
|
+
|
|
13
|
+
if (options.explicitSourceDir) {
|
|
14
|
+
return {
|
|
15
|
+
sourceDir,
|
|
16
|
+
disabled: false,
|
|
17
|
+
reason: null,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (sourceDir && i18nDir && sourceDir === i18nDir) {
|
|
22
|
+
for (const candidate of DEFAULT_SOURCE_DIRS) {
|
|
23
|
+
const candidatePath = path.resolve(projectRoot, candidate);
|
|
24
|
+
if (SecurityUtils.safeExistsSync(candidatePath, projectRoot)) {
|
|
25
|
+
return {
|
|
26
|
+
sourceDir: candidatePath,
|
|
27
|
+
disabled: false,
|
|
28
|
+
reason: `sourceDir equals i18nDir; using ${candidate}`,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
sourceDir: null,
|
|
35
|
+
disabled: true,
|
|
36
|
+
reason: 'sourceDir equals i18nDir and no application source directory was found',
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
sourceDir,
|
|
42
|
+
disabled: !sourceDir,
|
|
43
|
+
reason: sourceDir ? null : 'No source directory configured',
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = {
|
|
48
|
+
DEFAULT_SOURCE_DIRS,
|
|
49
|
+
resolveUsageSourceDir,
|
|
50
|
+
};
|
package/utils/watch-locales.js
CHANGED
|
@@ -99,7 +99,7 @@ function watchDirectory(dir, emitter, watchers, options = {}) {
|
|
|
99
99
|
return;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
watchers.push({ watcher, path: dir });
|
|
102
|
+
watchers.push({ watcher, path: dir, debounceTimers });
|
|
103
103
|
watchState.count++;
|
|
104
104
|
|
|
105
105
|
try {
|
|
@@ -126,6 +126,11 @@ function watchLocales(dirs, onChange, options = {}) {
|
|
|
126
126
|
if (typeof onChange === 'function') {
|
|
127
127
|
emitter.on('change', onChange);
|
|
128
128
|
emitter.on('add', onChange);
|
|
129
|
+
emitter.on('unlink', onChange);
|
|
130
|
+
} else if (typeof onChange === 'object' && onChange !== null && typeof onChange.onChange === 'function') {
|
|
131
|
+
emitter.on('change', onChange.onChange);
|
|
132
|
+
emitter.on('add', onChange.onChange);
|
|
133
|
+
emitter.on('unlink', onChange.onChange);
|
|
129
134
|
}
|
|
130
135
|
|
|
131
136
|
const {
|
|
@@ -142,24 +147,23 @@ function watchLocales(dirs, onChange, options = {}) {
|
|
|
142
147
|
}
|
|
143
148
|
|
|
144
149
|
const resolved = path.resolve(d);
|
|
145
|
-
const validated = SecurityUtils.validatePath(resolved,
|
|
150
|
+
const validated = SecurityUtils.validatePath(resolved, path.dirname(resolved));
|
|
146
151
|
if (!validated) {
|
|
147
152
|
emitter.emit('error', new Error(`Path validation failed for: ${d}`));
|
|
148
153
|
continue;
|
|
149
154
|
}
|
|
150
155
|
|
|
151
|
-
const projectRoot = path.resolve(process.cwd());
|
|
152
|
-
const rel = path.relative(projectRoot, validated);
|
|
153
|
-
if (rel.startsWith('..')) {
|
|
154
|
-
emitter.emit('error', new Error(`Directory outside project root: ${d}`));
|
|
155
|
-
continue;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
156
|
watchDirectory(validated, emitter, watchers, { debounceMs, hashTracking, watchState });
|
|
159
157
|
}
|
|
160
158
|
|
|
161
159
|
const stop = () => {
|
|
162
160
|
for (const entry of watchers) {
|
|
161
|
+
if (entry.debounceTimers) {
|
|
162
|
+
for (const timer of entry.debounceTimers.values()) {
|
|
163
|
+
clearTimeout(timer);
|
|
164
|
+
}
|
|
165
|
+
entry.debounceTimers.clear();
|
|
166
|
+
}
|
|
163
167
|
try { entry.watcher.close(); } catch (_) { /* ignore */ }
|
|
164
168
|
}
|
|
165
169
|
watchers.length = 0;
|