|
| 1 | +/* eslint-disable */ |
| 2 | +import { readFile, writeFile, opendir } from 'node:fs/promises' |
| 3 | +import { createInterface } from 'node:readline' |
| 4 | +import { join } from 'node:path' |
| 5 | +import minimist from 'minimist' |
| 6 | +import _ from 'lodash' |
| 7 | + |
| 8 | +const args = minimist(process.argv.slice(2)) |
| 9 | + |
| 10 | +/** |
| 11 | + * Console colors |
| 12 | + */ |
| 13 | +const Reset = '\x1b[0m' |
| 14 | +const Bright = '\x1b[1m' |
| 15 | +const Dim = '\x1b[2m' |
| 16 | +const Underscore = '\x1b[4m' |
| 17 | +const Blink = '\x1b[5m' |
| 18 | +const Reverse = '\x1b[7m' |
| 19 | +const Hidden = '\x1b[8m' |
| 20 | +const FgBlack = '\x1b[30m' |
| 21 | +const FgRed = '\x1b[31m' |
| 22 | +const FgGreen = '\x1b[32m' |
| 23 | +const FgYellow = '\x1b[33m' |
| 24 | +const FgBlue = '\x1b[34m' |
| 25 | +const FgMagenta = '\x1b[35m' |
| 26 | +const FgCyan = '\x1b[36m' |
| 27 | +const FgWhite = '\x1b[37m' |
| 28 | +const BgBlack = '\x1b[40m' |
| 29 | +const BgRed = '\x1b[41m' |
| 30 | +const BgGreen = '\x1b[42m' |
| 31 | +const BgYellow = '\x1b[43m' |
| 32 | +const BgBlue = '\x1b[44m' |
| 33 | +const BgMagenta = '\x1b[45m' |
| 34 | +const BgCyan = '\x1b[46m' |
| 35 | +const BgWhite = '\x1b[47m' |
| 36 | + |
| 37 | +/** |
| 38 | + * Paths and regexs |
| 39 | + */ |
| 40 | +const ROOT_PATH = './src/client' |
| 41 | +const LOCALES_DIR_NAME = 'locales' |
| 42 | +const LOCALES_PATH = './src/client/locales' |
| 43 | +const EXTENSION_MATCHER = /.+\.ts/ |
| 44 | +// matches 'asd:asd' |
| 45 | +const TRANSLATION_KEY_REFERENCE_MATCHER = new RegExp(/['"`]\w+(?::\w+)+['"`]/, 'g') |
| 46 | +// matches t('asd' |
| 47 | +const TRANSLATION_KEY_REFERENCE_MATCHER_2 = new RegExp(/\bt\(['"`]\w+(?::\w+)*['"`]/, 'g') |
| 48 | + |
| 49 | +const LANGUAGES = ['fi', 'sv', 'en'] |
| 50 | + |
| 51 | +const log0 = (...msg) => { |
| 52 | + if (!args.quiet) { |
| 53 | + console.log(...msg) |
| 54 | + } |
| 55 | +} |
| 56 | + |
| 57 | +const log = (...msg) => { |
| 58 | + console.log(...msg) |
| 59 | +} |
| 60 | + |
| 61 | +/** |
| 62 | + * Main execution block |
| 63 | + */ |
| 64 | +;(async () => { |
| 65 | + if (args.help) { |
| 66 | + printHelp() |
| 67 | + return |
| 68 | + } |
| 69 | + |
| 70 | + const argLangs = args.lang ? args.lang.split(',') : LANGUAGES |
| 71 | + |
| 72 | + const translationKeyReferences = new Map() |
| 73 | + let fileCount = 0 |
| 74 | + log0(`Analyzing ${ROOT_PATH}...`) |
| 75 | + |
| 76 | + // Walk through the directory structure and analyze files |
| 77 | + for await (const file of walk(ROOT_PATH)) { |
| 78 | + fileCount += 1 |
| 79 | + const contents = await readFile(file, 'utf8') |
| 80 | + let lineNumber = 1 |
| 81 | + for (const line of contents.split('\n')) { |
| 82 | + // Match translation keys using regex and store their locations |
| 83 | + ;[...line.matchAll(TRANSLATION_KEY_REFERENCE_MATCHER)] |
| 84 | + .concat([...line.matchAll(TRANSLATION_KEY_REFERENCE_MATCHER_2)]) |
| 85 | + .flat() |
| 86 | + .forEach(match => { |
| 87 | + const t = match.startsWith('t') |
| 88 | + const common = !match.includes(':') |
| 89 | + const location = new Location(file, lineNumber) |
| 90 | + const reference = `${common ? 'common:' : ''}${match.slice(t ? 3 : 1, match.length - 1)}` |
| 91 | + if (translationKeyReferences.has(reference)) { |
| 92 | + translationKeyReferences.get(reference).push(location) |
| 93 | + } else { |
| 94 | + translationKeyReferences.set(reference, [location]) |
| 95 | + } |
| 96 | + }) |
| 97 | + |
| 98 | + lineNumber += 1 |
| 99 | + } |
| 100 | + } |
| 101 | + log0(`Found ${translationKeyReferences.size} references in ${fileCount} files`) |
| 102 | + |
| 103 | + const locales = {} |
| 104 | + |
| 105 | + // Load translation files for each language |
| 106 | + for await (const lang of LANGUAGES) { |
| 107 | + locales[lang] = await readJSON(`${LOCALES_PATH}/${lang}.json`) |
| 108 | + } |
| 109 | + log0('Imported translation modules') |
| 110 | + |
| 111 | + const translationsNotUsed = new Set() |
| 112 | + |
| 113 | + /** |
| 114 | + * Recursively finds all keys in a nested object. |
| 115 | + * @param {Object} obj - The object to traverse. |
| 116 | + * @param {string} path - The current path in the object. |
| 117 | + * @returns {string[]} An array of keys found in the object. |
| 118 | + */ |
| 119 | + const findKeysRecursively = (obj, path) => { |
| 120 | + const keys = [] |
| 121 | + Object.keys(obj).forEach(k => { |
| 122 | + if (typeof obj[k] === 'object') { |
| 123 | + keys.push(...findKeysRecursively(obj[k], `${path}:${k}`)) // Go deeper... |
| 124 | + } else if (typeof obj[k] === 'string' && obj[k].trim().length > 0) { |
| 125 | + keys.push(`${path}:${k}`) // Key seems legit |
| 126 | + } |
| 127 | + }) |
| 128 | + return keys |
| 129 | + } |
| 130 | + |
| 131 | + // Collect all translation keys from the loaded locales |
| 132 | + Object.entries(locales).forEach(([_, t]) => { |
| 133 | + findKeysRecursively(t, '').forEach(k => translationsNotUsed.add(k.slice(1))) |
| 134 | + }) |
| 135 | + |
| 136 | + const numberOfTranslations = translationsNotUsed.size |
| 137 | + log0('Generated translation keys\n') |
| 138 | + log0(`${Underscore}Listing references with missing translations${Reset}\n`) |
| 139 | + |
| 140 | + let longestKey = 0 |
| 141 | + translationKeyReferences.forEach((v, k) => { |
| 142 | + if (k.length > longestKey) longestKey = k.length |
| 143 | + }) |
| 144 | + |
| 145 | + let missingCount = 0 |
| 146 | + const missingByLang = Object.fromEntries(argLangs.map(l => [l, []])) |
| 147 | + |
| 148 | + // Check for missing translations |
| 149 | + translationKeyReferences.forEach((v, k) => { |
| 150 | + const missing = [] |
| 151 | + const parts = k.split(':') |
| 152 | + |
| 153 | + Object.entries(locales).forEach(([lang, t]) => { |
| 154 | + let obj = t |
| 155 | + for (const p of parts) { |
| 156 | + obj = obj[p] |
| 157 | + if (!obj) break |
| 158 | + } |
| 159 | + if (typeof obj !== 'string') { |
| 160 | + missing.push(lang) |
| 161 | + } else { |
| 162 | + translationsNotUsed.delete(k) |
| 163 | + } |
| 164 | + }) |
| 165 | + |
| 166 | + if (missing.length > 0 && missing.some(l => argLangs.includes(l))) { |
| 167 | + missingCount += printMissing(k, v, missing, longestKey) |
| 168 | + missing.forEach(l => argLangs.includes(l) && missingByLang[l].push(k)) |
| 169 | + } |
| 170 | + }) |
| 171 | + |
| 172 | + if (missingCount > 0) { |
| 173 | + log(`\n${FgRed}${Bright}Error:${Reset} ${missingCount} translations missing\n`) |
| 174 | + const langsOpt = args.lang ? `--lang ${argLangs.join(',')}` : '' |
| 175 | + const recommendedCmd = `${FgCyan}npm run translations -- --create ${langsOpt}${Reset}` |
| 176 | + log(`Run to populate missing translations now:\n> ${recommendedCmd}\n`) |
| 177 | + } else { |
| 178 | + log(`${FgGreen}${Bright}Success:${Reset} All translations found\n`) |
| 179 | + } |
| 180 | + |
| 181 | + if (args.unused) { |
| 182 | + printUnused(translationsNotUsed, numberOfTranslations) |
| 183 | + } |
| 184 | + |
| 185 | + if (args.create) { |
| 186 | + await createMissingTranslations(missingByLang) |
| 187 | + } |
| 188 | + |
| 189 | + if (missingCount > 0) { |
| 190 | + process.exit(1) |
| 191 | + } else { |
| 192 | + process.exit(0) |
| 193 | + } |
| 194 | +})() |
| 195 | + |
| 196 | +/** |
| 197 | + * Prints missing translations for a given key. |
| 198 | + * @param {string} translationKey - The translation key. |
| 199 | + * @param {Location[]} referenceLocations - Locations where the key is referenced. |
| 200 | + * @param {string[]} missingLangs - Languages missing the translation. |
| 201 | + * @param {number} longestKey - The length of the longest key for padding. |
| 202 | + * @returns {number} The number of missing languages. |
| 203 | + */ |
| 204 | +const printMissing = (translationKey, referenceLocations, missingLangs, longestKey) => { |
| 205 | + let msg = translationKey |
| 206 | + // Add padding |
| 207 | + for (let i = 0; i < longestKey - translationKey.length; i++) { |
| 208 | + msg += ' ' |
| 209 | + } |
| 210 | + |
| 211 | + msg += ['fi', 'en', 'sv'] |
| 212 | + .map(l => (missingLangs.includes(l) ? `${FgRed}${l}${Reset}` : `${FgGreen}${l}${Reset}`)) |
| 213 | + .join(', ') |
| 214 | + |
| 215 | + if (args.detailed) { |
| 216 | + msg += `\n${FgCyan}${referenceLocations.join('\n')}\n` |
| 217 | + } |
| 218 | + |
| 219 | + console.log(msg, Reset) |
| 220 | + |
| 221 | + return missingLangs.length |
| 222 | +} |
| 223 | + |
| 224 | +/** |
| 225 | + * Prints potentially unused translations. |
| 226 | + * @param {Set<string>} translationsNotUsed - Set of unused translation keys. |
| 227 | + * @param {number} numberOfTranslations - Total number of translations. |
| 228 | + */ |
| 229 | +const printUnused = (translationsNotUsed, numberOfTranslations) => { |
| 230 | + console.log( |
| 231 | + `${Underscore}Potentially unused translations (${translationsNotUsed.size}/${numberOfTranslations}): ${Reset}` |
| 232 | + ) |
| 233 | + console.log(`${FgMagenta}please check if they are used before deleting${Reset}`) |
| 234 | + translationsNotUsed.forEach(t => console.log(` ${t.split(':').join(`${FgMagenta}:${Reset}`)}`)) |
| 235 | +} |
| 236 | + |
| 237 | +/** |
| 238 | + * Prompts the user to create missing translations and writes them to files. |
| 239 | + * @param {Object} missingByLang - Object mapping languages to missing keys. |
| 240 | + */ |
| 241 | +const createMissingTranslations = async missingByLang => { |
| 242 | + const rl = createInterface({ |
| 243 | + input: process.stdin, |
| 244 | + output: process.stdout, |
| 245 | + }) |
| 246 | + |
| 247 | + const prompt = query => new Promise(resolve => rl.question(query, resolve)) |
| 248 | + |
| 249 | + rl.on('close', () => { |
| 250 | + console.log('Cancelled') |
| 251 | + process.exit(1) |
| 252 | + }) |
| 253 | + |
| 254 | + const promptInfosByKeys = {} |
| 255 | + |
| 256 | + // Group missing keys by language |
| 257 | + Object.entries(missingByLang).forEach(([lang, missingKeys]) => { |
| 258 | + missingKeys.forEach(k => { |
| 259 | + if (!promptInfosByKeys[k]) { |
| 260 | + promptInfosByKeys[k] = [] |
| 261 | + } |
| 262 | + |
| 263 | + promptInfosByKeys[k].push({ |
| 264 | + lang, |
| 265 | + value: '', |
| 266 | + }) |
| 267 | + }) |
| 268 | + }) |
| 269 | + |
| 270 | + // Prompt user for translations |
| 271 | + for (const [k, info] of Object.entries(promptInfosByKeys)) { |
| 272 | + console.log(`\nAdd translations for ${FgYellow}${k}${Reset}`) |
| 273 | + for (const i of info) { |
| 274 | + const value = await prompt(`${FgCyan}${i.lang}${Reset}: `) |
| 275 | + i.value = value |
| 276 | + } |
| 277 | + } |
| 278 | + |
| 279 | + const newTranslationsByLang = {} |
| 280 | + |
| 281 | + // Organize new translations into a nested structure |
| 282 | + Object.entries(promptInfosByKeys).forEach(([k, info]) => { |
| 283 | + info.forEach(i => { |
| 284 | + if (!i.value) { |
| 285 | + return |
| 286 | + } |
| 287 | + |
| 288 | + if (!newTranslationsByLang[i.lang]) { |
| 289 | + newTranslationsByLang[i.lang] = {} |
| 290 | + } |
| 291 | + |
| 292 | + const parts = k.split(':') |
| 293 | + let obj = newTranslationsByLang[i.lang] |
| 294 | + |
| 295 | + for (let i = 0; i < parts.length - 1; i++) { |
| 296 | + if (!obj[parts[i]]) { |
| 297 | + obj[parts[i]] = {} |
| 298 | + } |
| 299 | + obj = obj[parts[i]] |
| 300 | + } |
| 301 | + |
| 302 | + obj[parts[parts.length - 1]] = i.value |
| 303 | + }) |
| 304 | + }) |
| 305 | + |
| 306 | + // Write new translations to files |
| 307 | + console.log('Writing new translations to files...') |
| 308 | + await Promise.all( |
| 309 | + Object.entries(newTranslationsByLang).map(async ([lang, translations]) => { |
| 310 | + const filePath = join(LOCALES_PATH, `${lang}.json`) |
| 311 | + |
| 312 | + const translationObject = await readJSON(`${LOCALES_PATH}/${lang}.json`) |
| 313 | + |
| 314 | + // Deep merge |
| 315 | + const merged = _.merge(translationObject, translations) |
| 316 | + |
| 317 | + await writeFile(filePath, JSON.stringify(merged, null, 2)) |
| 318 | + }) |
| 319 | + ) |
| 320 | +} |
| 321 | + |
| 322 | +/** |
| 323 | + * Prints help information for the script. |
| 324 | + */ |
| 325 | +function printHelp() { |
| 326 | + console.log('Usage:') |
| 327 | + console.log('--lang fi,sv,en') |
| 328 | + console.log('--unused: print all potentially unused translation fields') |
| 329 | + console.log('--detailed: Show usage locations') |
| 330 | + console.log('--quiet: Print less stuff') |
| 331 | + console.log('--create: Populate missing translations in translation files') |
| 332 | +} |
| 333 | + |
| 334 | +/** |
| 335 | + * Recursively walks through a directory and yields file paths. |
| 336 | + * @param {string} dir - The directory to walk. |
| 337 | + * @returns {AsyncGenerator<string>} An async generator yielding file paths. |
| 338 | + */ |
| 339 | +async function* walk(dir) { |
| 340 | + for await (const d of await opendir(dir)) { |
| 341 | + const entry = join(dir, d.name) |
| 342 | + if (d.isDirectory() && d.name !== LOCALES_DIR_NAME) yield* walk(entry) |
| 343 | + else if (d.isFile() && EXTENSION_MATCHER.test(d.name)) yield entry |
| 344 | + } |
| 345 | +} |
| 346 | + |
| 347 | +/** |
| 348 | + * Represents a line location in a file. |
| 349 | + */ |
| 350 | +class Location { |
| 351 | + constructor(file, line) { |
| 352 | + this.file = file |
| 353 | + this.line = line |
| 354 | + } |
| 355 | + |
| 356 | + toString() { |
| 357 | + return `${this.file}:${this.line}` |
| 358 | + } |
| 359 | +} |
| 360 | + |
| 361 | +const readJSON = async (filePath) => { |
| 362 | + const fileContent = await readFile(filePath, 'utf8') |
| 363 | + return JSON.parse(fileContent) |
| 364 | +} |
0 commit comments