Skip to content

Commit 4f21bb5

Browse files
author
Eric Wheeler
committed
fix: reduce i18n key false positives by handling dynamic keys
Improve i18n key detection by adding path segment matching to handle dynamic keys like: - chat:codeblock.tooltips.${windowShade ? "expand" : "collapse"} - prompts:supportPrompts.types.${type}.label This reduces false positives by recognizing that static keys in translation files (like chat:codeblock.tooltips.expand) are actually used via dynamic keys in the source code. The solution: - Splits keys into segments and marks dynamic parts as wildcards - Matches static keys against these patterns - Reduces reported unused keys from 108 to 78 Signed-off-by: Eric Wheeler <[email protected]>
1 parent 6ab5faa commit 4f21bb5

File tree

1 file changed

+60
-21
lines changed

1 file changed

+60
-21
lines changed

locales/__tests__/find-missing-i18n-keys.test.ts

Lines changed: 60 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ const i18nScanPatterns = [
2323
]
2424

2525
// Track line numbers for source code keys
26-
const lineMap = new Map<string, {file: string, line: number}>()
26+
const lineMap = new Map<string, { file: string; line: number }>()
2727

2828
// Check if the key exists in all official language files, return a list of missing language files
2929

@@ -53,12 +53,12 @@ function accumulateSourceKeys(): Set<string> {
5353
let match
5454
while ((match = pattern.exec(content)) !== null) {
5555
const key = match[1]
56-
const lineNumber = content.slice(0, match.index).split('\n').length
56+
const lineNumber = content.slice(0, match.index).split("\n").length
5757
matches.add(key)
5858
sourceCodeKeys.add(key)
5959
lineMap.set(key, {
6060
file: path.relative(process.cwd(), filePath),
61-
line: lineNumber
61+
line: lineNumber,
6262
})
6363
}
6464
}
@@ -139,24 +139,57 @@ function getKeysInSourceNotInTranslation(sourceKeys: Set<string>, translationKey
139139
.sort()
140140
}
141141

142+
// Function to convert a key into segments and mark dynamic parts as undefined
143+
function keyToSegments(key: string): (string | undefined)[] {
144+
return key.split(".").map((segment) => (segment.includes("${") ? undefined : segment))
145+
}
146+
147+
// Function to check if a static key matches a dynamic key pattern
148+
function matchesKeyPattern(staticKey: string, dynamicKey: string): boolean {
149+
const staticSegments = staticKey.split(".")
150+
const dynamicSegments = keyToSegments(dynamicKey)
151+
152+
if (staticSegments.length !== dynamicSegments.length) {
153+
return false
154+
}
155+
156+
return dynamicSegments.every((dynSeg, i) => dynSeg === undefined || dynSeg === staticSegments[i])
157+
}
158+
142159
// Function 4: Return all keys in translations that are not in source
143-
function getKeysInTranslationNotInSource(sourceKeys: Set<string>, translationKeys: Set<string>): string[] {
160+
function getKeysInTranslationNotInSource(
161+
sourceKeys: Set<string>,
162+
translationKeys: Set<string>,
163+
dynamicKeys: string[] = [],
164+
): string[] {
144165
return Array.from(translationKeys)
145-
.filter((key) => !sourceKeys.has(key))
166+
.filter((key) => {
167+
// If key is directly used in source, it's not unused
168+
if (sourceKeys.has(key)) {
169+
return false
170+
}
171+
172+
// If key matches any dynamic key pattern, it's not unused
173+
if (dynamicKeys.some((dynamicKey) => matchesKeyPattern(key, dynamicKey))) {
174+
return false
175+
}
176+
177+
return true
178+
})
146179
.sort()
147180
}
148181

149182
// Function to find dynamic i18n keys (containing ${...})
150183
function findDynamicKeys(sourceKeys: Set<string>): string[] {
151184
return Array.from(sourceKeys)
152-
.filter(key => key.includes("${"))
185+
.filter((key) => key.includes("${"))
153186
.sort()
154187
}
155188

156189
// Function to find non-namespaced t() calls
157190
export function findNonNamespacedI18nKeys(sourceKeys: Set<string>): string[] {
158191
return Array.from(sourceKeys)
159-
.filter(key => !key.includes(":"))
192+
.filter((key) => !key.includes(":"))
160193
.sort()
161194
}
162195

@@ -187,20 +220,21 @@ export function findMissingI18nKeys(): { output: string; nonNamespacedKeys: stri
187220
// Get source code keys and translation keys
188221
const sourceCodeKeys = accumulateSourceKeys()
189222
const translationFileKeys = accumulateTranslationKeys()
190-
223+
191224
// Find special keys
192225
const dynamicKeys = findDynamicKeys(sourceCodeKeys)
193226
const nonNamespacedKeys = findNonNamespacedI18nKeys(sourceCodeKeys)
194227

195228
// Create sets for set operations
196229
const dynamicSet = new Set(dynamicKeys)
197230
const nonNamespacedSet = new Set(nonNamespacedKeys)
198-
const remainingSourceKeys = new Set(Array.from(sourceCodeKeys)
199-
.filter(key => !dynamicSet.has(key) && !nonNamespacedSet.has(key)))
231+
const remainingSourceKeys = new Set(
232+
Array.from(sourceCodeKeys).filter((key) => !dynamicSet.has(key) && !nonNamespacedSet.has(key)),
233+
)
200234

201235
// Find keys in source not in translations and vice versa
202236
const missingTranslationKeys = getKeysInSourceNotInTranslation(remainingSourceKeys, translationFileKeys)
203-
const unusedTranslationKeys = getKeysInTranslationNotInSource(remainingSourceKeys, translationFileKeys)
237+
const unusedTranslationKeys = getKeysInTranslationNotInSource(remainingSourceKeys, translationFileKeys, dynamicKeys)
204238

205239
// Track unused keys in English locale files
206240
const unusedKeys: Array<{ key: string; file: string }> = []
@@ -242,13 +276,13 @@ export function findMissingI18nKeys(): { output: string; nonNamespacedKeys: stri
242276
// Add summary counts
243277
summaryOutput += `\nTotal source code keys: ${sourceCodeKeys.size}\n`
244278
summaryOutput += `Total translation file keys: ${translationFileKeys.size}\n`
245-
279+
246280
// Dynamic keys
247281
summaryOutput += `\n1. Dynamic i18n keys (${dynamicKeys.length}):\n`
248282
if (dynamicKeys.length === 0) {
249283
summaryOutput += " None - all i18n keys are static\n"
250284
} else {
251-
dynamicKeys.forEach(key => {
285+
dynamicKeys.forEach((key) => {
252286
const loc = lineMap.get(key)
253287
summaryOutput += ` - ${loc?.file}:${loc?.line}: ${key}\n`
254288
})
@@ -259,7 +293,7 @@ export function findMissingI18nKeys(): { output: string; nonNamespacedKeys: stri
259293
if (nonNamespacedKeys.length === 0) {
260294
summaryOutput += " None - all t() calls use namespaces\n"
261295
} else {
262-
nonNamespacedKeys.forEach(key => {
296+
nonNamespacedKeys.forEach((key) => {
263297
const loc = lineMap.get(key)
264298
summaryOutput += ` - ${loc?.file}:${loc?.line}: ${key}\n`
265299
})
@@ -275,10 +309,10 @@ export function findMissingI18nKeys(): { output: string; nonNamespacedKeys: stri
275309
})
276310
}
277311

278-
// Keys in translation files but not in source code
279-
summaryOutput += `\n4. Keys in translation files but not in source code (${unusedTranslationKeys.length}):\n`
312+
// Keys in translation files but not in source code (excluding dynamic matches)
313+
summaryOutput += `\n4. Unused translation keys (${unusedTranslationKeys.length}):\n`
280314
if (unusedTranslationKeys.length === 0) {
281-
summaryOutput += " None - all translation keys are used in source code\n"
315+
summaryOutput += " None - all translation keys are either directly used or matched by dynamic patterns\n"
282316
} else {
283317
// Group keys by locale directory and file
284318
const localeFileMap = new Map<string, Map<string, string[]>>()
@@ -348,7 +382,7 @@ export function findMissingI18nKeys(): { output: string; nonNamespacedKeys: stri
348382

349383
return {
350384
output: printLogs(),
351-
nonNamespacedKeys
385+
nonNamespacedKeys,
352386
}
353387
}
354388

@@ -371,12 +405,17 @@ describe("Find Missing i18n Keys", () => {
371405
// Create sets for set operations
372406
const dynamicSet = new Set(dynamicKeys)
373407
const nonNamespacedSet = new Set(nonNamespacedKeys)
374-
const remainingSourceKeys = new Set(Array.from(sourceKeys)
375-
.filter(key => !dynamicSet.has(key) && !nonNamespacedSet.has(key)))
408+
const remainingSourceKeys = new Set(
409+
Array.from(sourceKeys).filter((key) => !dynamicSet.has(key) && !nonNamespacedSet.has(key)),
410+
)
376411

377412
// Find differences
378413
keysInSourceNotInTranslation = getKeysInSourceNotInTranslation(remainingSourceKeys, translationKeys)
379-
keysInTranslationNotInSource = getKeysInTranslationNotInSource(remainingSourceKeys, translationKeys)
414+
keysInTranslationNotInSource = getKeysInTranslationNotInSource(
415+
remainingSourceKeys,
416+
translationKeys,
417+
dynamicKeys,
418+
)
380419

381420
// Clear logs at start
382421
clearLogs()

0 commit comments

Comments
 (0)