Skip to content

Commit 15ef216

Browse files
committed
Update app/scripts/localizationCompare.ts
1 parent 9c83f13 commit 15ef216

File tree

10 files changed

+160
-77
lines changed

10 files changed

+160
-77
lines changed

.github/workflows/deploy-lint-test.yaml

Whitespace-only changes.

app/scripts/localizationCompare.ts

Lines changed: 160 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
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'
2929
import * as path from 'node:path'
3030
import 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

3440
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
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+
4159
const DEFAULT_EN_DIR = path.resolve('src/assets/localization/en')
4260
const 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+
*/
6488
const 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+
*/
173252
const 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

197280
main().catch(err => {
198281
console.error(err)
199-
process.exit(2)
282+
process.exit(EXIT_CODES.RUNTIME_ERROR)
200283
})

scripts/deploy/.env.example

Whitespace-only changes.

scripts/deploy/Makefile

Whitespace-only changes.

scripts/deploy/__init__.py

Whitespace-only changes.

scripts/deploy/deploy_config.py

Whitespace-only changes.

scripts/deploy/pyproject.toml

Whitespace-only changes.

scripts/deploy/tests/__init__.py

Whitespace-only changes.

scripts/deploy/tests/conftest.py

Whitespace-only changes.

scripts/deploy/tests/test_deploy_config.py

Whitespace-only changes.

0 commit comments

Comments
 (0)