@@ -18,19 +18,6 @@ const COLORS = {
1818
1919type 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-
3421const 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
11994const 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 = (
260235const 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