6
6
* ZH: ./src/assets/localization/zh
7
7
*
8
8
* CLI:
9
- * npx tsx scripts/compare-l10n .ts
9
+ * npx tsx scripts/localizationCompare .ts
10
10
* # or
11
- * npx ts-node scripts/compare-l10n .ts
11
+ * npx ts-node scripts/localizationCompare .ts
12
12
*
13
13
* Optional args:
14
14
* --en <path> --zh <path>
@@ -29,126 +29,196 @@ import { promises as fs } from 'node:fs'
29
29
import * as path from 'node:path'
30
30
import process from 'node:process'
31
31
32
- type Dict = Record < string , unknown >
32
+ // More specific types for i18n data
33
+ type I18nValue = string | number | boolean | null
34
+ interface I18nObject {
35
+ [ key : string ] : I18nValue | I18nObject
36
+ }
37
+ type FlattenedI18nData = Record < string , I18nValue >
38
+ type GlobalKeyMap = Record < string , I18nValue >
33
39
34
40
interface CompareResult {
35
- missingInZh : string [ ]
36
- extraInZh : string [ ]
37
- emptyEn : string [ ]
38
- emptyZh : string [ ]
41
+ readonly missingInZh : readonly string [ ]
42
+ readonly extraInZh : readonly string [ ]
43
+ readonly emptyEn : readonly string [ ]
44
+ readonly emptyZh : readonly string [ ]
45
+ }
46
+
47
+ interface ParsedArgs {
48
+ readonly enDir : string
49
+ readonly zhDir : string
39
50
}
40
51
52
+ // Exit codes for better error handling
53
+ const EXIT_CODES = {
54
+ SUCCESS : 0 ,
55
+ VALIDATION_FAILED : 1 ,
56
+ RUNTIME_ERROR : 2 ,
57
+ } as const
58
+
41
59
const DEFAULT_EN_DIR = path . resolve ( 'src/assets/localization/en' )
42
60
const DEFAULT_ZH_DIR = path . resolve ( 'src/assets/localization/zh' )
43
61
44
62
/** Parse CLI args for --en and --zh overrides */
45
- const parseArgs = ( ) : { enDir : string ; zhDir : string } => {
63
+ const parseArgs = ( ) : ParsedArgs => {
46
64
const args = process . argv . slice ( 2 )
47
65
let enDir = DEFAULT_EN_DIR
48
66
let zhDir = DEFAULT_ZH_DIR
49
67
50
68
for ( let i = 0 ; i < args . length ; i ++ ) {
51
- const a = args [ i ]
52
- if ( a === '--en' ) {
53
- enDir = path . resolve ( args [ i + 1 ] )
69
+ const arg = args [ i ]
70
+ const nextArg = args [ i + 1 ]
71
+
72
+ if ( arg === '--en' && nextArg !== undefined && nextArg . length > 0 ) {
73
+ enDir = path . resolve ( nextArg )
54
74
i ++
55
- } else if ( a === '--zh' ) {
56
- zhDir = path . resolve ( args [ i + 1 ] )
75
+ } else if ( arg === '--zh' && nextArg !== undefined && nextArg . length > 0 ) {
76
+ zhDir = path . resolve ( nextArg )
57
77
i ++
58
78
}
59
79
}
60
- return { enDir, zhDir }
80
+ return { enDir, zhDir } as const
61
81
}
62
82
63
- /** Recursively collect all .json files under a dir */
83
+ /**
84
+ * Recursively collect all .json files under a directory.
85
+ * @param dir - The directory to search
86
+ * @returns Promise resolving to sorted array of file paths
87
+ */
64
88
const collectJsonFiles = async ( dir : string ) : Promise < string [ ] > => {
65
- const out : string [ ] = [ ]
89
+ const jsonFiles : string [ ] = [ ]
66
90
const entries = await fs . readdir ( dir , { withFileTypes : true } )
67
- for ( const e of entries ) {
68
- const p = path . join ( dir , e . name )
69
- if ( e . isDirectory ( ) ) {
70
- out . push ( ...( await collectJsonFiles ( p ) ) )
71
- } else if ( e . isFile ( ) && e . name . toLowerCase ( ) . endsWith ( '.json' ) ) {
72
- out . push ( p )
91
+
92
+ for ( const entry of entries ) {
93
+ const fullPath = path . join ( dir , entry . name )
94
+ if ( entry . isDirectory ( ) ) {
95
+ jsonFiles . push ( ...( await collectJsonFiles ( fullPath ) ) )
96
+ } else if ( entry . isFile ( ) && entry . name . toLowerCase ( ) . endsWith ( '.json' ) ) {
97
+ jsonFiles . push ( fullPath )
73
98
}
74
99
}
75
- return out . sort ( )
100
+ return jsonFiles . sort ( )
76
101
}
77
102
78
- /** Read and parse JSON. Returns {} if file missing (when allowedMissing=true). */
79
- const readJson = async ( file : string , allowMissing = false ) : Promise < Dict > => {
103
+ /**
104
+ * Read and parse JSON file with error handling.
105
+ * @param file - Path to the JSON file
106
+ * @param allowMissing - If true, returns empty object when file is missing
107
+ * @returns Promise resolving to parsed JSON as I18nObject
108
+ */
109
+ const readJson = async (
110
+ file : string ,
111
+ allowMissing = false
112
+ ) : Promise < I18nObject > => {
80
113
try {
81
114
const raw = await fs . readFile ( file , 'utf8' )
82
- return JSON . parse ( raw ) as Dict
83
- } catch ( err : any ) {
84
- if ( allowMissing && err ?. code === 'ENOENT' ) return { }
85
- throw new Error ( `Failed reading ${ file } : ${ err ?. message ?? String ( err ) } ` )
115
+ return JSON . parse ( raw ) as I18nObject
116
+ } catch ( err ) {
117
+ if (
118
+ allowMissing &&
119
+ err instanceof Error &&
120
+ 'code' in err &&
121
+ err . code === 'ENOENT'
122
+ ) {
123
+ return { }
124
+ }
125
+ const message = err instanceof Error ? err . message : String ( err )
126
+ throw new Error ( `Failed reading ${ file } : ${ message } ` )
86
127
}
87
128
}
88
129
89
- /** Flatten nested objects into dotted keys */
90
- const flatten = ( obj : Dict , prefix = '' ) : Dict => {
91
- const out : Record < string , unknown > = { }
92
- for ( const [ k , v ] of Object . entries ( obj ?? { } ) ) {
93
- const key = prefix ? `${ prefix } .${ k } ` : k
94
- if ( v && typeof v === 'object' && ! Array . isArray ( v ) ) {
95
- Object . assign ( out , flatten ( v as Dict , key ) )
130
+ /**
131
+ * Type guard to check if a value is an I18nObject.
132
+ * @param value - Value to check
133
+ * @returns True if value is an I18nObject
134
+ */
135
+ const isI18nObject = ( value : unknown ) : value is I18nObject => {
136
+ return value !== null && typeof value === 'object' && ! Array . isArray ( value )
137
+ }
138
+
139
+ /**
140
+ * Flatten nested objects into dotted keys.
141
+ * @param obj - The object to flatten
142
+ * @param prefix - Key prefix for nested keys
143
+ * @returns Flattened object with dotted keys
144
+ */
145
+ const flatten = ( obj : I18nObject , prefix = '' ) : FlattenedI18nData => {
146
+ const result : FlattenedI18nData = { }
147
+
148
+ for ( const [ key , value ] of Object . entries ( obj ) ) {
149
+ const fullKey = prefix . length > 0 ? `${ prefix } .${ key } ` : key
150
+ if ( isI18nObject ( value ) ) {
151
+ Object . assign ( result , flatten ( value , fullKey ) )
96
152
} else {
97
- out [ key ] = v
153
+ result [ fullKey ] = value as I18nValue
98
154
}
99
155
}
100
- return out
156
+ return result
101
157
}
102
158
103
- /** Build a global key map: "<namespace>.<flattenedKey>" -> value */
104
- const buildGlobalMap = async (
105
- dir : string
106
- ) : Promise < Record < string , unknown > > => {
159
+ /**
160
+ * Build a global key map from all JSON files in a directory.
161
+ * @param dir - Directory containing JSON localization files
162
+ * @returns Promise resolving to global key map
163
+ */
164
+ const buildGlobalMap = async ( dir : string ) : Promise < GlobalKeyMap > => {
107
165
const files = await collectJsonFiles ( dir )
108
- const map : Record < string , unknown > = { }
166
+ const globalMap : GlobalKeyMap = { }
109
167
110
168
for ( const file of files ) {
111
- // namespace = file name without extension (keep subdir part to avoid collisions)
169
+ // Use filename ( without extension) as namespace
112
170
// e.g., ".../en/common.json" -> "common"
113
- // If you want subdirs in namespace, you can use relative path without extension:
114
- // const rel = path.relative(dir, file).replace(/\.json$/i, '').replaceAll(path.sep, '/')
115
- const ns = path . basename ( file , '.json' )
116
- const json = await readJson ( file )
117
- const flat = flatten ( json )
118
- for ( const [ k , v ] of Object . entries ( flat ) ) {
119
- const gk = `${ ns } .${ k } `
120
- map [ gk ] = v
171
+ const namespace = path . basename ( file , '.json' )
172
+ const jsonContent = await readJson ( file )
173
+ const flattenedContent = flatten ( jsonContent )
174
+
175
+ for ( const [ key , value ] of Object . entries ( flattenedContent ) ) {
176
+ const globalKey = `${ namespace } .${ key } `
177
+ globalMap [ globalKey ] = value
121
178
}
122
179
}
123
- return map
180
+ return globalMap
124
181
}
125
182
126
- const compare = (
127
- enMap : Record < string , unknown > ,
128
- zhMap : Record < string , unknown >
129
- ) : CompareResult => {
183
+ /**
184
+ * Compare English and Chinese localization maps.
185
+ * @param enMap - English localization key-value map
186
+ * @param zhMap - Chinese localization key-value map
187
+ * @returns Comparison result with differences and issues
188
+ */
189
+ const compare = ( enMap : GlobalKeyMap , zhMap : GlobalKeyMap ) : CompareResult => {
130
190
const enKeys = new Set ( Object . keys ( enMap ) )
131
191
const zhKeys = new Set ( Object . keys ( zhMap ) )
132
192
133
- const missingInZh = enKeys . difference ( zhKeys )
134
- const extraInZh = zhKeys . difference ( enKeys )
193
+ // Use manual set difference since Set.difference might not be available
194
+ const missingInZh : string [ ] = [ ]
195
+ const extraInZh : string [ ] = [ ]
135
196
const emptyEn : string [ ] = [ ]
136
197
const emptyZh : string [ ] = [ ]
137
198
138
- // Missing in zh + empty strings in en
139
- for ( const k of enKeys ) {
140
- if ( ! zhKeys . has ( k ) ) missingInZh . push ( k )
141
- const v = enMap [ k ]
142
- if ( v === '' ) emptyEn . push ( k )
199
+ // Find keys missing in zh and empty strings in en
200
+ for ( const key of enKeys ) {
201
+ if ( ! zhKeys . has ( key ) ) {
202
+ missingInZh . push ( key )
203
+ }
204
+ const value = enMap [ key ]
205
+ if ( value === '' ) {
206
+ emptyEn . push ( key )
207
+ }
143
208
}
144
209
145
- // Extra in zh + empty strings in zh
146
- for ( const k of zhKeys ) {
147
- if ( ! enKeys . has ( k ) ) extraInZh . push ( k )
148
- const v = zhMap [ k ]
149
- if ( v === '' ) emptyZh . push ( k )
210
+ // Find keys extra in zh and empty strings in zh
211
+ for ( const key of zhKeys ) {
212
+ if ( ! enKeys . has ( key ) ) {
213
+ extraInZh . push ( key )
214
+ }
215
+ const value = zhMap [ key ]
216
+ if ( value === '' ) {
217
+ emptyZh . push ( key )
218
+ }
150
219
}
151
220
221
+ // Sort all arrays for consistent output
152
222
missingInZh . sort ( )
153
223
extraInZh . sort ( )
154
224
emptyEn . sort ( )
@@ -157,26 +227,35 @@ const compare = (
157
227
return { missingInZh, extraInZh, emptyEn, emptyZh }
158
228
}
159
229
160
- /** Pretty print a section with count and items */
161
- const printSection = ( title : string , items : string [ ] ) => {
230
+ /**
231
+ * Pretty print a section with count and items.
232
+ * @param title - Section title
233
+ * @param items - Array of items to display
234
+ */
235
+ const printSection = ( title : string , items : readonly string [ ] ) : void => {
162
236
const count = items . length
163
237
const header = `${ title } (${ count } )`
164
238
console . log ( '\n' + header )
165
239
console . log ( '' . padEnd ( header . length , '-' ) )
166
- if ( count ) {
167
- for ( const k of items ) console . log ( k )
240
+ if ( count > 0 ) {
241
+ for ( const item of items ) {
242
+ console . log ( item )
243
+ }
168
244
} else {
169
245
console . log ( 'none' )
170
246
}
171
247
}
172
248
249
+ /**
250
+ * Main execution function.
251
+ */
173
252
const main = async ( ) : Promise < void > => {
174
253
const { enDir, zhDir } = parseArgs ( )
175
254
176
255
console . log ( `EN dir: ${ enDir } ` )
177
256
console . log ( `ZH dir: ${ zhDir } ` )
178
257
179
- // Build global key spaces
258
+ // Build global key maps for both locales
180
259
const [ enMap , zhMap ] = await Promise . all ( [
181
260
buildGlobalMap ( enDir ) ,
182
261
buildGlobalMap ( zhDir ) ,
@@ -189,12 +268,16 @@ const main = async (): Promise<void> => {
189
268
printSection ( 'Empty strings in en' , emptyEn )
190
269
printSection ( 'Empty strings in zh' , emptyZh )
191
270
271
+ // Exit with error code if any issues were found
192
272
const hasProblems =
193
- missingInZh . length || extraInZh . length || emptyEn . length || emptyZh . length
194
- process . exit ( hasProblems ? 1 : 0 )
273
+ missingInZh . length > 0 ||
274
+ extraInZh . length > 0 ||
275
+ emptyEn . length > 0 ||
276
+ emptyZh . length > 0
277
+ process . exit ( hasProblems ? EXIT_CODES . VALIDATION_FAILED : EXIT_CODES . SUCCESS )
195
278
}
196
279
197
280
main ( ) . catch ( err => {
198
281
console . error ( err )
199
- process . exit ( 2 )
282
+ process . exit ( EXIT_CODES . RUNTIME_ERROR )
200
283
} )
0 commit comments