Skip to content

Commit ac82a5c

Browse files
authored
fix: simplify translation sync logic and maintain original order (#627)
1 parent cefc172 commit ac82a5c

File tree

1 file changed

+110
-137
lines changed

1 file changed

+110
-137
lines changed

scripts/compare-translations.ts

Lines changed: 110 additions & 137 deletions
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,6 @@ const COLORS = {
1818

1919
type NestedObject = { [key: string]: unknown }
2020

21-
const flattenObject = (obj: NestedObject, prefix = ''): Record<string, unknown> => {
22-
return Object.keys(obj).reduce<Record<string, unknown>>((acc, key) => {
23-
const propertyPath = prefix ? `${prefix}.${key}` : key
24-
const value = obj[key]
25-
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
26-
Object.assign(acc, flattenObject(value as NestedObject, propertyPath))
27-
} else {
28-
acc[propertyPath] = value
29-
}
30-
return acc
31-
}, {})
32-
}
33-
3421
const loadJson = (filePath: string): NestedObject => {
3522
if (!existsSync(filePath)) {
3623
console.error(`${COLORS.red}Error: File not found at ${filePath}${COLORS.reset}`)
@@ -39,62 +26,50 @@ const loadJson = (filePath: string): NestedObject => {
3926
return JSON.parse(readFileSync(filePath, 'utf-8')) as NestedObject
4027
}
4128

42-
const addMissingKeys = (
43-
obj: NestedObject,
44-
keysToAdd: string[],
45-
referenceFlat: Record<string, unknown>,
46-
): NestedObject => {
47-
const result: NestedObject = { ...obj }
48-
49-
for (const keyPath of keysToAdd) {
50-
const parts = keyPath.split('.')
51-
let current = result
52-
53-
for (let i = 0; i < parts.length - 1; i++) {
54-
const part = parts[i]!
55-
if (!(part in current) || typeof current[part] !== 'object') {
56-
current[part] = {}
57-
}
58-
current = current[part] as NestedObject
59-
}
60-
61-
const lastPart = parts[parts.length - 1]!
62-
if (!(lastPart in current)) {
63-
const enValue = referenceFlat[keyPath]
64-
current[lastPart] = `EN TEXT TO REPLACE: ${enValue}`
65-
}
66-
}
67-
68-
return result
29+
type SyncStats = {
30+
missing: string[]
31+
extra: string[]
32+
referenceKeys: string[]
6933
}
7034

71-
const removeKeysFromObject = (obj: NestedObject, keysToRemove: string[]): NestedObject => {
72-
const result: NestedObject = {}
35+
// Check if value is a non-null object and not array
36+
const isNested = (val: unknown): val is NestedObject =>
37+
val !== null && typeof val === 'object' && !Array.isArray(val)
7338

74-
for (const key of Object.keys(obj)) {
75-
const value = obj[key]
39+
const syncLocaleData = (
40+
reference: NestedObject,
41+
target: NestedObject,
42+
stats: SyncStats,
43+
fix: boolean,
44+
prefix = '',
45+
): NestedObject => {
46+
const result: NestedObject = {}
7647

77-
// Check if this key or any nested path starting with this key should be removed
78-
const shouldRemoveKey = keysToRemove.some(k => k === key || k.startsWith(`${key}.`))
79-
const hasNestedRemovals = keysToRemove.some(k => k.startsWith(`${key}.`))
48+
for (const key of Object.keys(reference)) {
49+
const propertyPath = prefix ? `${prefix}.${key}` : key
50+
const refValue = reference[key]
8051

81-
if (keysToRemove.includes(key)) {
82-
// Skip this key entirely
83-
continue
52+
if (isNested(refValue)) {
53+
const nextTarget = isNested(target[key]) ? target[key] : {}
54+
result[key] = syncLocaleData(refValue, nextTarget, stats, fix, propertyPath)
55+
} else {
56+
stats.referenceKeys.push(propertyPath)
57+
58+
if (key in target) {
59+
result[key] = target[key]
60+
} else {
61+
stats.missing.push(propertyPath)
62+
if (fix) {
63+
result[key] = `EN TEXT TO REPLACE: ${refValue}`
64+
}
65+
}
8466
}
67+
}
8568

86-
if (typeof value === 'object' && value !== null && !Array.isArray(value) && hasNestedRemovals) {
87-
// Recursively process nested objects
88-
const nestedKeysToRemove = keysToRemove
89-
.filter(k => k.startsWith(`${key}.`))
90-
.map(k => k.slice(key.length + 1))
91-
const cleaned = removeKeysFromObject(value as NestedObject, nestedKeysToRemove)
92-
// Only add if there are remaining keys
93-
if (Object.keys(cleaned).length > 0) {
94-
result[key] = cleaned
95-
}
96-
} else if (!shouldRemoveKey || hasNestedRemovals) {
97-
result[key] = value
69+
for (const key of Object.keys(target)) {
70+
const propertyPath = prefix ? `${prefix}.${key}` : key
71+
if (!(key in reference)) {
72+
stats.extra.push(propertyPath)
9873
}
9974
}
10075

@@ -118,42 +93,29 @@ const logSection = (
11893

11994
const processLocale = (
12095
localeFile: string,
121-
referenceKeys: string[],
122-
referenceFlat: Record<string, unknown>,
96+
referenceContent: NestedObject,
12397
fix = false,
124-
): { missing: string[]; removed: string[]; added: string[] } => {
98+
): SyncStats => {
12599
const filePath = join(LOCALES_DIRECTORY, localeFile)
126-
let content = loadJson(filePath)
127-
const flattenedKeys = Object.keys(flattenObject(content))
128-
129-
const missingKeys = referenceKeys.filter(key => !flattenedKeys.includes(key))
130-
const extraneousKeys = flattenedKeys.filter(key => !referenceKeys.includes(key))
131-
132-
let modified = false
100+
const targetContent = loadJson(filePath)
133101

134-
if (extraneousKeys.length > 0) {
135-
content = removeKeysFromObject(content, extraneousKeys)
136-
modified = true
102+
const stats: SyncStats = {
103+
missing: [],
104+
extra: [],
105+
referenceKeys: [],
137106
}
138107

139-
if (fix && missingKeys.length > 0) {
140-
content = addMissingKeys(content, missingKeys, referenceFlat)
141-
modified = true
142-
}
108+
const newContent = syncLocaleData(referenceContent, targetContent, stats, fix)
143109

144-
if (modified) {
145-
writeFileSync(filePath, JSON.stringify(content, null, 2) + '\n', 'utf-8')
110+
// Write if there are removals (always) or we are in fix mode
111+
if (stats.extra.length > 0 || fix) {
112+
writeFileSync(filePath, JSON.stringify(newContent, null, 2) + '\n', 'utf-8')
146113
}
147114

148-
return { missing: missingKeys, removed: extraneousKeys, added: fix ? missingKeys : [] }
115+
return stats
149116
}
150117

151-
const runSingleLocale = (
152-
locale: string,
153-
referenceKeys: string[],
154-
referenceFlat: Record<string, unknown>,
155-
fix = false,
156-
): void => {
118+
const runSingleLocale = (locale: string, referenceContent: NestedObject, fix = false): void => {
157119
const localeFile = locale.endsWith('.json') ? locale : `${locale}.json`
158120
const filePath = join(LOCALES_DIRECTORY, localeFile)
159121

@@ -162,79 +124,92 @@ const runSingleLocale = (
162124
process.exit(1)
163125
}
164126

165-
let content = loadJson(filePath)
166-
const flattenedKeys = Object.keys(flattenObject(content))
167-
const missingKeys = referenceKeys.filter(key => !flattenedKeys.includes(key))
127+
const { missing, extra, referenceKeys } = processLocale(localeFile, referenceContent, fix)
168128

169129
console.log(
170130
`${COLORS.cyan}=== Missing keys for ${localeFile}${fix ? ' (with --fix)' : ''} ===${COLORS.reset}`,
171131
)
172132
console.log(`Reference: ${REFERENCE_FILE_NAME} (${referenceKeys.length} keys)`)
173-
console.log(`Target: ${localeFile} (${flattenedKeys.length} keys)`)
174133

175-
if (missingKeys.length === 0) {
176-
console.log(`\n${COLORS.green}No missing keys!${COLORS.reset}\n`)
177-
} else if (fix) {
178-
content = addMissingKeys(content, missingKeys, referenceFlat)
179-
writeFileSync(filePath, JSON.stringify(content, null, 2) + '\n', 'utf-8')
180-
console.log(
181-
`\n${COLORS.green}Added ${missingKeys.length} missing key(s) with EN placeholder:${COLORS.reset}`,
182-
)
183-
missingKeys.forEach(key => console.log(` - ${key}`))
184-
console.log('')
134+
if (missing.length > 0) {
135+
if (fix) {
136+
console.log(
137+
`\n${COLORS.green}Added ${missing.length} missing key(s) with EN placeholder:${COLORS.reset}`,
138+
)
139+
missing.forEach(key => console.log(` - ${key}`))
140+
} else {
141+
console.log(`\n${COLORS.yellow}Missing ${missing.length} key(s):${COLORS.reset}`)
142+
missing.forEach(key => console.log(` - ${key}`))
143+
}
185144
} else {
186-
console.log(`\n${COLORS.yellow}Missing ${missingKeys.length} key(s):${COLORS.reset}`)
187-
missingKeys.forEach(key => console.log(` - ${key}`))
188-
console.log('')
145+
console.log(`\n${COLORS.green}No missing keys!${COLORS.reset}`)
189146
}
147+
148+
if (extra.length > 0) {
149+
console.log(`\n${COLORS.magenta}Removed ${extra.length} extra key(s):${COLORS.reset}`)
150+
extra.forEach(key => console.log(` - ${key}`))
151+
}
152+
console.log('')
190153
}
191154

192-
const runAllLocales = (
193-
referenceKeys: string[],
194-
referenceFlat: Record<string, unknown>,
195-
fix = false,
196-
): void => {
155+
const runAllLocales = (referenceContent: NestedObject, fix = false): void => {
197156
const localeFiles = readdirSync(LOCALES_DIRECTORY).filter(
198157
file => file.endsWith('.json') && file !== REFERENCE_FILE_NAME,
199158
)
200159

201-
console.log(`${COLORS.cyan}=== Translation Audit${fix ? ' (with --fix)' : ''} ===${COLORS.reset}`)
202-
console.log(`Reference: ${REFERENCE_FILE_NAME} (${referenceKeys.length} keys)`)
203-
console.log(`Checking ${localeFiles.length} locale(s)...`)
160+
const results: (SyncStats & { file: string })[] = []
204161

205162
let totalMissing = 0
206163
let totalRemoved = 0
207164
let totalAdded = 0
208165

209166
for (const localeFile of localeFiles) {
210-
const { missing, removed, added } = processLocale(localeFile, referenceKeys, referenceFlat, fix)
167+
const stats = processLocale(localeFile, referenceContent, fix)
168+
results.push({
169+
file: localeFile,
170+
...stats,
171+
})
172+
173+
if (fix) {
174+
if (stats.missing.length > 0) totalAdded += stats.missing.length
175+
} else {
176+
if (stats.missing.length > 0) totalMissing += stats.missing.length
177+
}
178+
if (stats.extra.length > 0) totalRemoved += stats.extra.length
179+
}
211180

212-
if (missing.length > 0 || removed.length > 0) {
213-
console.log(`\n${COLORS.cyan}--- ${localeFile} ---${COLORS.reset}`)
181+
const referenceKeysCount = results.length > 0 ? results[0]!.referenceKeys.length : 0
214182

215-
if (added.length > 0) {
216-
logSection('ADDED MISSING KEYS (with EN placeholder)', added, COLORS.green, '', '')
217-
totalAdded += added.length
218-
} else if (missing.length > 0) {
219-
logSection(
220-
'MISSING KEYS (in en.json but not in this locale)',
221-
missing,
222-
COLORS.yellow,
223-
'',
224-
'',
225-
)
226-
totalMissing += missing.length
183+
console.log(`${COLORS.cyan}=== Translation Audit${fix ? ' (with --fix)' : ''} ===${COLORS.reset}`)
184+
console.log(`Reference: ${REFERENCE_FILE_NAME} (${referenceKeysCount} keys)`)
185+
console.log(`Checking ${localeFiles.length} locale(s)...`)
186+
187+
for (const res of results) {
188+
if (res.missing.length > 0 || res.extra.length > 0) {
189+
console.log(`\n${COLORS.cyan}--- ${res.file} ---${COLORS.reset}`)
190+
191+
if (res.missing.length > 0) {
192+
if (fix) {
193+
logSection('ADDED MISSING KEYS (with EN placeholder)', res.missing, COLORS.green, '', '')
194+
} else {
195+
logSection(
196+
'MISSING KEYS (in en.json but not in this locale)',
197+
res.missing,
198+
COLORS.yellow,
199+
'',
200+
'',
201+
)
202+
}
227203
}
228204

229-
if (removed.length > 0) {
205+
if (res.extra.length > 0) {
230206
logSection(
231-
'REMOVED EXTRANEOUS KEYS (were in this locale but not in en.json)',
232-
removed,
207+
'REMOVED EXTRA KEYS (were in this locale but not in en.json)',
208+
res.extra,
233209
COLORS.magenta,
234210
'',
235211
'',
236212
)
237-
totalRemoved += removed.length
238213
}
239214
}
240215
}
@@ -249,7 +224,7 @@ const runAllLocales = (
249224
console.log(`${COLORS.yellow} Missing keys across all locales: ${totalMissing}${COLORS.reset}`)
250225
}
251226
if (totalRemoved > 0) {
252-
console.log(`${COLORS.magenta} Removed extraneous keys: ${totalRemoved}${COLORS.reset}`)
227+
console.log(`${COLORS.magenta} Removed extra keys: ${totalRemoved}${COLORS.reset}`)
253228
}
254229
if (totalMissing === 0 && totalRemoved === 0 && totalAdded === 0) {
255230
console.log(`${COLORS.green} All locales are in sync!${COLORS.reset}`)
@@ -260,19 +235,17 @@ const runAllLocales = (
260235
const run = (): void => {
261236
const referenceFilePath = join(LOCALES_DIRECTORY, REFERENCE_FILE_NAME)
262237
const referenceContent = loadJson(referenceFilePath)
263-
const referenceFlat = flattenObject(referenceContent)
264-
const referenceKeys = Object.keys(referenceFlat)
265238

266239
const args = process.argv.slice(2)
267240
const fix = args.includes('--fix')
268241
const targetLocale = args.find(arg => !arg.startsWith('--'))
269242

270243
if (targetLocale) {
271244
// Single locale mode
272-
runSingleLocale(targetLocale, referenceKeys, referenceFlat, fix)
245+
runSingleLocale(targetLocale, referenceContent, fix)
273246
} else {
274247
// All locales mode: check all and remove extraneous keys
275-
runAllLocales(referenceKeys, referenceFlat, fix)
248+
runAllLocales(referenceContent, fix)
276249
}
277250
}
278251

0 commit comments

Comments
 (0)