Skip to content

Commit 16512ec

Browse files
ericyangpanclaude
andcommitted
test(i18n): add validation for translation locale alignment with English
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 7340262 commit 16512ec

File tree

1 file changed

+208
-0
lines changed

1 file changed

+208
-0
lines changed
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import fs from 'node:fs'
2+
import path from 'node:path'
3+
4+
import { describe, it } from 'vitest'
5+
6+
/**
7+
* Discover locales by scanning translations/* directories that contain index.ts.
8+
*/
9+
function discoverLocales(translationsDir: string): string[] {
10+
if (!fs.existsSync(translationsDir)) {
11+
return []
12+
}
13+
const entries = fs.readdirSync(translationsDir, { withFileTypes: true })
14+
const locales: string[] = []
15+
16+
for (const entry of entries) {
17+
if (!entry.isDirectory()) continue
18+
const name = entry.name
19+
if (name.startsWith('_') || name.startsWith('.')) continue
20+
const indexPath = path.join(translationsDir, name, 'index.ts')
21+
if (fs.existsSync(indexPath)) locales.push(name)
22+
}
23+
24+
return locales.sort()
25+
}
26+
27+
/**
28+
* Recursively collect nested keys from a JSON value into a set.
29+
* Returns a set of all key paths (e.g., "actions.backTo", "common.articles").
30+
*/
31+
function collectKeys(value: unknown, prefix: string, keys: Set<string>) {
32+
if (value === null || typeof value !== 'object') return
33+
34+
if (Array.isArray(value)) {
35+
value.forEach((item, index) => {
36+
if (item !== null && typeof item === 'object') {
37+
collectKeys(item, `${prefix}[${index}]`, keys)
38+
}
39+
})
40+
return
41+
}
42+
43+
for (const [key, val] of Object.entries(value)) {
44+
const fullPath = prefix ? `${prefix}.${key}` : key
45+
keys.add(fullPath)
46+
if (val !== null && typeof val === 'object') {
47+
collectKeys(val, fullPath, keys)
48+
}
49+
}
50+
}
51+
52+
/**
53+
* Read and parse JSON from disk.
54+
*/
55+
function readJsonFile(filePath: string): unknown {
56+
const content = fs.readFileSync(filePath, 'utf8')
57+
return JSON.parse(content) as unknown
58+
}
59+
60+
/**
61+
* Get the structure (file list + key sets) of a locale translation folder.
62+
*/
63+
function getLocaleStructure(
64+
translationsDir: string,
65+
locale: string
66+
): {
67+
fileList: string[]
68+
files: Map<string, Set<string>>
69+
} {
70+
const localeDir = path.join(translationsDir, locale)
71+
const files = new Map<string, Set<string>>()
72+
const fileList: string[] = []
73+
74+
/**
75+
* Process a JSON file and collect its keys.
76+
*/
77+
function processJson(filePath: string, relativePath: string) {
78+
const json = readJsonFile(filePath)
79+
const keys = new Set<string>()
80+
collectKeys(json, '', keys)
81+
files.set(relativePath, keys)
82+
fileList.push(relativePath)
83+
}
84+
85+
const rootFiles = fs.readdirSync(localeDir).filter(file => {
86+
const filePath = path.join(localeDir, file)
87+
return fs.statSync(filePath).isFile() && file.endsWith('.json')
88+
})
89+
for (const file of rootFiles) {
90+
processJson(path.join(localeDir, file), file)
91+
}
92+
93+
const pagesDir = path.join(localeDir, 'pages')
94+
if (fs.existsSync(pagesDir) && fs.statSync(pagesDir).isDirectory()) {
95+
const pageFiles = fs.readdirSync(pagesDir).filter(file => file.endsWith('.json'))
96+
for (const file of pageFiles) {
97+
processJson(path.join(pagesDir, file), `pages/${file}`)
98+
}
99+
}
100+
101+
fileList.sort()
102+
return { fileList, files }
103+
}
104+
105+
/**
106+
* Compare sets and return differences.
107+
*/
108+
function diffSets(a: Set<string>, b: Set<string>): { onlyInA: string[]; onlyInB: string[] } {
109+
const onlyInA: string[] = []
110+
const onlyInB: string[] = []
111+
112+
for (const item of a) if (!b.has(item)) onlyInA.push(item)
113+
for (const item of b) if (!a.has(item)) onlyInB.push(item)
114+
115+
return { onlyInA: onlyInA.sort(), onlyInB: onlyInB.sort() }
116+
}
117+
118+
/**
119+
* Validate all locales have identical structure to English (en) locale.
120+
*/
121+
function validateEnglishAlignment(rootDir: string): string[] {
122+
const failures: string[] = []
123+
const translationsDir = path.join(rootDir, 'translations')
124+
const locales = discoverLocales(translationsDir)
125+
126+
// English must exist as the reference
127+
if (!locales.includes('en')) {
128+
failures.push('English (en) locale not found - cannot validate alignment')
129+
return failures
130+
}
131+
132+
const referenceLocale = 'en'
133+
const reference = getLocaleStructure(translationsDir, referenceLocale)
134+
if (!reference) {
135+
failures.push(`Reference locale '${referenceLocale}' structure not found`)
136+
return failures
137+
}
138+
139+
// Validate all other locales against English
140+
for (const locale of locales) {
141+
if (locale === referenceLocale) continue
142+
143+
const current = getLocaleStructure(translationsDir, locale)
144+
if (!current) {
145+
failures.push(`[${locale}] structure not found`)
146+
continue
147+
}
148+
149+
// Check file list alignment
150+
const fileDiff = diffSets(new Set(reference.fileList), new Set(current.fileList))
151+
if (fileDiff.onlyInA.length > 0 || fileDiff.onlyInB.length > 0) {
152+
const parts: string[] = []
153+
if (fileDiff.onlyInA.length > 0) {
154+
parts.push(
155+
`missing files (present in en):\n${fileDiff.onlyInA.map(f => ` - ${f}`).join('\n')}`
156+
)
157+
}
158+
if (fileDiff.onlyInB.length > 0) {
159+
parts.push(`extra files (not in en):\n${fileDiff.onlyInB.map(f => ` - ${f}`).join('\n')}`)
160+
}
161+
failures.push(`[${locale}] file list mismatch vs en:\n${parts.join('\n')}`)
162+
}
163+
164+
// Check key structure alignment for each file
165+
const allFiles = new Set([...reference.fileList, ...current.fileList])
166+
for (const file of allFiles) {
167+
const refKeys = reference.files.get(file)
168+
const curKeys = current.files.get(file)
169+
170+
if (refKeys && !curKeys) {
171+
failures.push(`[${locale}] missing file '${file}' (present in en)`)
172+
continue
173+
}
174+
if (!refKeys && curKeys) {
175+
failures.push(`[${locale}] extra file '${file}' (not present in en)`)
176+
continue
177+
}
178+
if (!refKeys || !curKeys) continue
179+
180+
const keyDiff = diffSets(refKeys, curKeys)
181+
if (keyDiff.onlyInA.length > 0 || keyDiff.onlyInB.length > 0) {
182+
const parts: string[] = []
183+
if (keyDiff.onlyInA.length > 0) {
184+
parts.push(
185+
`missing keys (present in en):\n${keyDiff.onlyInA.map(k => ` - ${k}`).join('\n')}`
186+
)
187+
}
188+
if (keyDiff.onlyInB.length > 0) {
189+
parts.push(`extra keys (not in en):\n${keyDiff.onlyInB.map(k => ` - ${k}`).join('\n')}`)
190+
}
191+
failures.push(`[${locale}] key structure mismatch in '${file}' vs en:\n${parts.join('\n')}`)
192+
}
193+
}
194+
}
195+
196+
return failures
197+
}
198+
199+
describe('validate: translations English alignment', () => {
200+
it('all locales have identical key and structure to English (en)', () => {
201+
const failures = validateEnglishAlignment(process.cwd())
202+
if (failures.length > 0) {
203+
throw new Error(
204+
`Translation English alignment validation failed:\n\n${failures.join('\n\n')}`
205+
)
206+
}
207+
})
208+
})

0 commit comments

Comments
 (0)