[READ-ONLY] a fast, modern browser for the npm registry

feat(i18n): strip unused i18n keys (#471)

Co-authored-by: Daniel Roe <daniel@roe.dev>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>

authored by

Felix Schneider
Daniel Roe
autofix-ci[bot]
and committed by
GitHub
192c398f 2ebd12df

+227 -2
+3
.github/workflows/autofix.yml
··· 34 34 - name: 📦 Install browsers 35 35 run: pnpm playwright install 36 36 37 + - name: 🌐 Compare translations 38 + run: pnpm i18n:check 39 + 37 40 - name: 🌍 Update lunaria data 38 41 run: pnpm build:lunaria 39 42
+17
CONTRIBUTING.md
··· 284 284 285 285 Check [Pluralization rule callback](https://vue-i18n.intlify.dev/guide/essentials/pluralization.html#custom-pluralization) for more info. 286 286 287 + ### Update translation 288 + 289 + We track the current progress of translations with [Lunaria](https://lunaria.dev/) on this site: https://i18n.npmx.dev/ 290 + If you see any outdated translations in your language, feel free to update the keys to match then English version. 291 + 292 + In order to make sure you have everything up-to-date, you can run: 293 + 294 + ```bash 295 + pnpm i18n:check <country-code> 296 + ``` 297 + 298 + For example to check if all Japanese translation keys are up-to-date, run: 299 + 300 + ```bash 301 + pnpm i18n:check ja-JP 302 + ``` 303 + 287 304 #### Country variants (advanced) 288 305 289 306 Most languages only need a single locale file. Country variants are only needed when you want to support regional differences (e.g., `es-ES` for Spain vs `es-419` for Latin America).
-1
i18n/locales/de-DE.json
··· 748 748 "auth": { 749 749 "modal": { 750 750 "title": "Atmosphere", 751 - "close": "Schließen", 752 751 "connected_as": "Verbunden als {'@'}{handle}", 753 752 "disconnect": "Verbindung trennen", 754 753 "connect_prompt": "Melde dich bei deinem Atmosphere-Konto an",
-1
lunaria/files/de-DE.json
··· 748 748 "auth": { 749 749 "modal": { 750 750 "title": "Atmosphere", 751 - "close": "Schließen", 752 751 "connected_as": "Verbunden als {'@'}{handle}", 753 752 "disconnect": "Verbindung trennen", 754 753 "connect_prompt": "Melde dich bei deinem Atmosphere-Konto an",
+1
package.json
··· 13 13 "build:lunaria": "node --experimental-transform-types ./lunaria/lunaria.ts", 14 14 "dev": "nuxt dev", 15 15 "dev:docs": "pnpm run --filter npmx-docs dev --port=3001", 16 + "i18n:check": "node --experimental-transform-types scripts/compare-translations.ts", 16 17 "knip": "knip", 17 18 "knip:fix": "knip --fix", 18 19 "knip:production": "knip --production",
+206
scripts/compare-translations.ts
··· 1 + import process from 'node:process' 2 + import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' 3 + import { join } from 'node:path' 4 + import { fileURLToPath } from 'node:url' 5 + 6 + const LOCALES_DIRECTORY = fileURLToPath(new URL('../i18n/locales', import.meta.url)) 7 + const REFERENCE_FILE_NAME = 'en.json' 8 + 9 + const COLORS = { 10 + reset: '\x1b[0m', 11 + red: '\x1b[31m', 12 + green: '\x1b[32m', 13 + yellow: '\x1b[33m', 14 + magenta: '\x1b[35m', 15 + cyan: '\x1b[36m', 16 + } as const 17 + 18 + type NestedObject = { [key: string]: unknown } 19 + 20 + const flattenObject = (obj: NestedObject, prefix = ''): Record<string, unknown> => { 21 + return Object.keys(obj).reduce<Record<string, unknown>>((acc, key) => { 22 + const propertyPath = prefix ? `${prefix}.${key}` : key 23 + const value = obj[key] 24 + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { 25 + Object.assign(acc, flattenObject(value as NestedObject, propertyPath)) 26 + } else { 27 + acc[propertyPath] = value 28 + } 29 + return acc 30 + }, {}) 31 + } 32 + 33 + const loadJson = (filePath: string): NestedObject => { 34 + if (!existsSync(filePath)) { 35 + console.error(`${COLORS.red}Error: File not found at ${filePath}${COLORS.reset}`) 36 + process.exit(1) 37 + } 38 + return JSON.parse(readFileSync(filePath, 'utf-8')) as NestedObject 39 + } 40 + 41 + const removeKeysFromObject = (obj: NestedObject, keysToRemove: string[]): NestedObject => { 42 + const result: NestedObject = {} 43 + 44 + for (const key of Object.keys(obj)) { 45 + const value = obj[key] 46 + 47 + // Check if this key or any nested path starting with this key should be removed 48 + const shouldRemoveKey = keysToRemove.some(k => k === key || k.startsWith(`${key}.`)) 49 + const hasNestedRemovals = keysToRemove.some(k => k.startsWith(`${key}.`)) 50 + 51 + if (keysToRemove.includes(key)) { 52 + // Skip this key entirely 53 + continue 54 + } 55 + 56 + if (typeof value === 'object' && value !== null && !Array.isArray(value) && hasNestedRemovals) { 57 + // Recursively process nested objects 58 + const nestedKeysToRemove = keysToRemove 59 + .filter(k => k.startsWith(`${key}.`)) 60 + .map(k => k.slice(key.length + 1)) 61 + const cleaned = removeKeysFromObject(value as NestedObject, nestedKeysToRemove) 62 + // Only add if there are remaining keys 63 + if (Object.keys(cleaned).length > 0) { 64 + result[key] = cleaned 65 + } 66 + } else if (!shouldRemoveKey || hasNestedRemovals) { 67 + result[key] = value 68 + } 69 + } 70 + 71 + return result 72 + } 73 + 74 + const logSection = ( 75 + title: string, 76 + keys: string[], 77 + color: string, 78 + icon: string, 79 + emptyMessage: string, 80 + ): void => { 81 + console.log(`\n${color}${icon} ${title}${COLORS.reset}`) 82 + if (keys.length === 0) { 83 + console.log(` ${COLORS.green}${emptyMessage}${COLORS.reset}`) 84 + return 85 + } 86 + keys.forEach(key => console.log(` - ${key}`)) 87 + } 88 + 89 + const processLocale = ( 90 + localeFile: string, 91 + referenceKeys: string[], 92 + ): { missing: string[]; removed: string[] } => { 93 + const filePath = join(LOCALES_DIRECTORY, localeFile) 94 + const content = loadJson(filePath) 95 + const flattenedKeys = Object.keys(flattenObject(content)) 96 + 97 + const missingKeys = referenceKeys.filter(key => !flattenedKeys.includes(key)) 98 + const extraneousKeys = flattenedKeys.filter(key => !referenceKeys.includes(key)) 99 + 100 + if (extraneousKeys.length > 0) { 101 + // Remove extraneous keys and write back 102 + const cleaned = removeKeysFromObject(content, extraneousKeys) 103 + writeFileSync(filePath, JSON.stringify(cleaned, null, 2) + '\n', 'utf-8') 104 + } 105 + 106 + return { missing: missingKeys, removed: extraneousKeys } 107 + } 108 + 109 + const runSingleLocale = (locale: string, referenceKeys: string[]): void => { 110 + const localeFile = locale.endsWith('.json') ? locale : `${locale}.json` 111 + const filePath = join(LOCALES_DIRECTORY, localeFile) 112 + 113 + if (!existsSync(filePath)) { 114 + console.error(`${COLORS.red}Error: Locale file not found: ${localeFile}${COLORS.reset}`) 115 + process.exit(1) 116 + } 117 + 118 + const content = loadJson(filePath) 119 + const flattenedKeys = Object.keys(flattenObject(content)) 120 + const missingKeys = referenceKeys.filter(key => !flattenedKeys.includes(key)) 121 + 122 + console.log(`${COLORS.cyan}=== Missing keys for ${localeFile} ===${COLORS.reset}`) 123 + console.log(`Reference: ${REFERENCE_FILE_NAME} (${referenceKeys.length} keys)`) 124 + console.log(`Target: ${localeFile} (${flattenedKeys.length} keys)`) 125 + 126 + if (missingKeys.length === 0) { 127 + console.log(`\n${COLORS.green}No missing keys!${COLORS.reset}\n`) 128 + } else { 129 + console.log(`\n${COLORS.yellow}Missing ${missingKeys.length} key(s):${COLORS.reset}`) 130 + missingKeys.forEach(key => console.log(` - ${key}`)) 131 + console.log('') 132 + } 133 + } 134 + 135 + const runAllLocales = (referenceKeys: string[]): void => { 136 + const localeFiles = readdirSync(LOCALES_DIRECTORY).filter( 137 + file => file.endsWith('.json') && file !== REFERENCE_FILE_NAME, 138 + ) 139 + 140 + console.log(`${COLORS.cyan}=== Translation Audit ===${COLORS.reset}`) 141 + console.log(`Reference: ${REFERENCE_FILE_NAME} (${referenceKeys.length} keys)`) 142 + console.log(`Checking ${localeFiles.length} locale(s)...`) 143 + 144 + let totalMissing = 0 145 + let totalRemoved = 0 146 + 147 + for (const localeFile of localeFiles) { 148 + const { missing, removed } = processLocale(localeFile, referenceKeys) 149 + 150 + if (missing.length > 0 || removed.length > 0) { 151 + console.log(`\n${COLORS.cyan}--- ${localeFile} ---${COLORS.reset}`) 152 + 153 + if (missing.length > 0) { 154 + logSection( 155 + 'MISSING KEYS (in en.json but not in this locale)', 156 + missing, 157 + COLORS.yellow, 158 + '', 159 + '', 160 + ) 161 + totalMissing += missing.length 162 + } 163 + 164 + if (removed.length > 0) { 165 + logSection( 166 + 'REMOVED EXTRANEOUS KEYS (were in this locale but not in en.json)', 167 + removed, 168 + COLORS.magenta, 169 + '', 170 + '', 171 + ) 172 + totalRemoved += removed.length 173 + } 174 + } 175 + } 176 + 177 + console.log(`\n${COLORS.cyan}=== Summary ===${COLORS.reset}`) 178 + if (totalMissing > 0) { 179 + console.log(`${COLORS.yellow} Missing keys across all locales: ${totalMissing}${COLORS.reset}`) 180 + } 181 + if (totalRemoved > 0) { 182 + console.log(`${COLORS.magenta} Removed extraneous keys: ${totalRemoved}${COLORS.reset}`) 183 + } 184 + if (totalMissing === 0 && totalRemoved === 0) { 185 + console.log(`${COLORS.green} All locales are in sync!${COLORS.reset}`) 186 + } 187 + console.log('') 188 + } 189 + 190 + const run = (): void => { 191 + const referenceFilePath = join(LOCALES_DIRECTORY, REFERENCE_FILE_NAME) 192 + const referenceContent = loadJson(referenceFilePath) 193 + const referenceKeys = Object.keys(flattenObject(referenceContent)) 194 + 195 + const targetLocale = process.argv[2] 196 + 197 + if (targetLocale) { 198 + // Single locale mode: just show missing keys (no modifications) 199 + runSingleLocale(targetLocale, referenceKeys) 200 + } else { 201 + // All locales mode: check all and remove extraneous keys 202 + runAllLocales(referenceKeys) 203 + } 204 + } 205 + 206 + run()