66 * ZH: ./src/assets/localization/zh
77 *
88 * CLI:
9- * npx tsx scripts/compare-l10n .ts
9+ * npx tsx scripts/localizationCompare .ts
1010 * # or
11- * npx ts-node scripts/compare-l10n .ts
11+ * npx ts-node scripts/localizationCompare .ts
1212 *
1313 * Optional args:
1414 * --en <path> --zh <path>
@@ -29,126 +29,196 @@ import { promises as fs } from 'node:fs'
2929import * as path from 'node:path'
3030import process from 'node:process'
3131
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 >
3339
3440interface 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
3950}
4051
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+
4159const DEFAULT_EN_DIR = path . resolve ( 'src/assets/localization/en' )
4260const DEFAULT_ZH_DIR = path . resolve ( 'src/assets/localization/zh' )
4361
4462/** Parse CLI args for --en and --zh overrides */
45- const parseArgs = ( ) : { enDir : string ; zhDir : string } => {
63+ const parseArgs = ( ) : ParsedArgs => {
4664 const args = process . argv . slice ( 2 )
4765 let enDir = DEFAULT_EN_DIR
4866 let zhDir = DEFAULT_ZH_DIR
4967
5068 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 )
5474 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 )
5777 i ++
5878 }
5979 }
60- return { enDir, zhDir }
80+ return { enDir, zhDir } as const
6181}
6282
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+ */
6488const collectJsonFiles = async ( dir : string ) : Promise < string [ ] > => {
65- const out : string [ ] = [ ]
89+ const jsonFiles : string [ ] = [ ]
6690 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 )
7398 }
7499 }
75- return out . sort ( )
100+ return jsonFiles . sort ( )
76101}
77102
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 > => {
80113 try {
81114 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 } ` )
86127 }
87128}
88129
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 ) )
96152 } else {
97- out [ key ] = v
153+ result [ fullKey ] = value as I18nValue
98154 }
99155 }
100- return out
156+ return result
101157}
102158
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 > => {
107165 const files = await collectJsonFiles ( dir )
108- const map : Record < string , unknown > = { }
166+ const globalMap : GlobalKeyMap = { }
109167
110168 for ( const file of files ) {
111- // namespace = file name without extension (keep subdir part to avoid collisions)
169+ // Use filename ( without extension) as namespace
112170 // 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
121178 }
122179 }
123- return map
180+ return globalMap
124181}
125182
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 => {
130190 const enKeys = new Set ( Object . keys ( enMap ) )
131191 const zhKeys = new Set ( Object . keys ( zhMap ) )
132192
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 [ ] = [ ]
135196 const emptyEn : string [ ] = [ ]
136197 const emptyZh : string [ ] = [ ]
137198
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+ }
143208 }
144209
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+ }
150219 }
151220
221+ // Sort all arrays for consistent output
152222 missingInZh . sort ( )
153223 extraInZh . sort ( )
154224 emptyEn . sort ( )
@@ -157,26 +227,35 @@ const compare = (
157227 return { missingInZh, extraInZh, emptyEn, emptyZh }
158228}
159229
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 => {
162236 const count = items . length
163237 const header = `${ title } (${ count } )`
164238 console . log ( '\n' + header )
165239 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+ }
168244 } else {
169245 console . log ( 'none' )
170246 }
171247}
172248
249+ /**
250+ * Main execution function.
251+ */
173252const main = async ( ) : Promise < void > => {
174253 const { enDir, zhDir } = parseArgs ( )
175254
176255 console . log ( `EN dir: ${ enDir } ` )
177256 console . log ( `ZH dir: ${ zhDir } ` )
178257
179- // Build global key spaces
258+ // Build global key maps for both locales
180259 const [ enMap , zhMap ] = await Promise . all ( [
181260 buildGlobalMap ( enDir ) ,
182261 buildGlobalMap ( zhDir ) ,
@@ -189,12 +268,16 @@ const main = async (): Promise<void> => {
189268 printSection ( 'Empty strings in en' , emptyEn )
190269 printSection ( 'Empty strings in zh' , emptyZh )
191270
271+ // Exit with error code if any issues were found
192272 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 )
195278}
196279
197280main ( ) . catch ( err => {
198281 console . error ( err )
199- process . exit ( 2 )
282+ process . exit ( EXIT_CODES . RUNTIME_ERROR )
200283} )
0 commit comments