Skip to content

Commit 7befec5

Browse files
Add unused i18n keys detection to pre-commit hook (#4328)
1 parent a51c098 commit 7befec5

File tree

2 files changed

+182
-0
lines changed

2 files changed

+182
-0
lines changed

.husky/pre-commit

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,6 @@ if [[ "$OS" == "Windows_NT" ]]; then
33
else
44
npx lint-staged
55
fi
6+
7+
# Check for unused i18n keys in staged files
8+
npx tsx scripts/check-unused-i18n-keys.ts

scripts/check-unused-i18n-keys.ts

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
#!/usr/bin/env tsx
2+
import { execSync } from 'child_process'
3+
import * as fs from 'fs'
4+
import { globSync } from 'glob'
5+
6+
interface LocaleData {
7+
[key: string]: any
8+
}
9+
10+
// Configuration
11+
const SOURCE_PATTERNS = ['src/**/*.{js,ts,vue}', '!src/locales/**/*']
12+
const IGNORE_PATTERNS = [
13+
// Keys that might be dynamically constructed
14+
/^commands\./, // Command definitions are loaded dynamically
15+
/^settings\..*\.options\./, // Setting options are rendered dynamically
16+
/^nodeDefs\./, // Node definitions are loaded from backend
17+
/^templateWorkflows\./, // Template workflows are loaded dynamically
18+
/^dataTypes\./, // Data types might be referenced dynamically
19+
/^contextMenu\./, // Context menu items might be dynamic
20+
/^color\./ // Color names might be used dynamically
21+
]
22+
23+
// Get list of staged locale files
24+
function getStagedLocaleFiles(): string[] {
25+
try {
26+
const output = execSync('git diff --cached --name-only --diff-filter=AM', {
27+
encoding: 'utf-8'
28+
})
29+
return output
30+
.split('\n')
31+
.filter(
32+
(file) => file.startsWith('src/locales/') && file.endsWith('.json')
33+
)
34+
} catch {
35+
return []
36+
}
37+
}
38+
39+
// Extract all keys from a nested object
40+
function extractKeys(obj: any, prefix = ''): string[] {
41+
const keys: string[] = []
42+
43+
for (const [key, value] of Object.entries(obj)) {
44+
const fullKey = prefix ? `${prefix}.${key}` : key
45+
46+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
47+
keys.push(...extractKeys(value, fullKey))
48+
} else {
49+
keys.push(fullKey)
50+
}
51+
}
52+
53+
return keys
54+
}
55+
56+
// Get new keys added in staged files
57+
function getNewKeysFromStagedFiles(stagedFiles: string[]): Set<string> {
58+
const newKeys = new Set<string>()
59+
60+
for (const file of stagedFiles) {
61+
try {
62+
// Get the staged content
63+
const stagedContent = execSync(`git show :${file}`, { encoding: 'utf-8' })
64+
const stagedData: LocaleData = JSON.parse(stagedContent)
65+
const stagedKeys = new Set(extractKeys(stagedData))
66+
67+
// Get the current HEAD content (if file exists)
68+
let headKeys = new Set<string>()
69+
try {
70+
const headContent = execSync(`git show HEAD:${file}`, {
71+
encoding: 'utf-8'
72+
})
73+
const headData: LocaleData = JSON.parse(headContent)
74+
headKeys = new Set(extractKeys(headData))
75+
} catch {
76+
// File is new, all keys are new
77+
}
78+
79+
// Find keys that are in staged but not in HEAD
80+
stagedKeys.forEach((key) => {
81+
if (!headKeys.has(key)) {
82+
newKeys.add(key)
83+
}
84+
})
85+
} catch (error) {
86+
console.error(`Error processing ${file}:`, error)
87+
}
88+
}
89+
90+
return newKeys
91+
}
92+
93+
// Check if a key should be ignored
94+
function shouldIgnoreKey(key: string): boolean {
95+
return IGNORE_PATTERNS.some((pattern) => pattern.test(key))
96+
}
97+
98+
// Search for key usage in source files
99+
function isKeyUsed(key: string, sourceFiles: string[]): boolean {
100+
// Common patterns for i18n key usage
101+
const patterns = [
102+
// Direct usage: $t('key'), t('key'), i18n.t('key')
103+
new RegExp(`[t$]\\s*\\(\\s*['"\`]${key}['"\`]`, 'g'),
104+
// With namespace: $t('g.key'), t('namespace.key')
105+
new RegExp(
106+
`[t$]\\s*\\(\\s*['"\`][^'"]+\\.${key.split('.').pop()}['"\`]`,
107+
'g'
108+
),
109+
// Dynamic keys might reference parts of the key
110+
new RegExp(`['"\`]${key}['"\`]`, 'g')
111+
]
112+
113+
for (const file of sourceFiles) {
114+
const content = fs.readFileSync(file, 'utf-8')
115+
116+
for (const pattern of patterns) {
117+
if (pattern.test(content)) {
118+
return true
119+
}
120+
}
121+
}
122+
123+
return false
124+
}
125+
126+
// Main function
127+
async function checkNewUnusedKeys() {
128+
const stagedLocaleFiles = getStagedLocaleFiles()
129+
130+
if (stagedLocaleFiles.length === 0) {
131+
// No locale files staged, nothing to check
132+
process.exit(0)
133+
}
134+
135+
// Get all new keys from staged files
136+
const newKeys = getNewKeysFromStagedFiles(stagedLocaleFiles)
137+
138+
if (newKeys.size === 0) {
139+
// Silent success - no output needed
140+
process.exit(0)
141+
}
142+
143+
// Get all source files
144+
const sourceFiles = globSync(SOURCE_PATTERNS)
145+
146+
// Check each new key
147+
const unusedNewKeys: string[] = []
148+
149+
newKeys.forEach((key) => {
150+
if (!shouldIgnoreKey(key) && !isKeyUsed(key, sourceFiles)) {
151+
unusedNewKeys.push(key)
152+
}
153+
})
154+
155+
// Report results
156+
if (unusedNewKeys.length > 0) {
157+
console.log('\n❌ Found unused NEW i18n keys:\n')
158+
159+
for (const key of unusedNewKeys.sort()) {
160+
console.log(` - ${key}`)
161+
}
162+
163+
console.log(`\n✨ Total unused new keys: ${unusedNewKeys.length}`)
164+
console.log(
165+
'\nThese keys were added but are not used anywhere in the codebase.'
166+
)
167+
console.log('Please either use them or remove them before committing.')
168+
169+
process.exit(1)
170+
} else {
171+
// Silent success - no output needed
172+
}
173+
}
174+
175+
// Run the check
176+
checkNewUnusedKeys().catch((err) => {
177+
console.error('Error checking unused keys:', err)
178+
process.exit(1)
179+
})

0 commit comments

Comments
 (0)