Skip to content

Commit 192c398

Browse files
trueberrylessdanielroeautofix-ci[bot]
authored
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>
1 parent 2ebd12d commit 192c398

6 files changed

Lines changed: 227 additions & 2 deletions

File tree

.github/workflows/autofix.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ jobs:
3434
- name: 📦 Install browsers
3535
run: pnpm playwright install
3636

37+
- name: 🌐 Compare translations
38+
run: pnpm i18n:check
39+
3740
- name: 🌍 Update lunaria data
3841
run: pnpm build:lunaria
3942

CONTRIBUTING.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,23 @@ To add a new locale:
284284

285285
Check [Pluralization rule callback](https://vue-i18n.intlify.dev/guide/essentials/pluralization.html#custom-pluralization) for more info.
286286

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+
287304
#### Country variants (advanced)
288305

289306
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).

i18n/locales/de-DE.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -748,7 +748,6 @@
748748
"auth": {
749749
"modal": {
750750
"title": "Atmosphere",
751-
"close": "Schließen",
752751
"connected_as": "Verbunden als {'@'}{handle}",
753752
"disconnect": "Verbindung trennen",
754753
"connect_prompt": "Melde dich bei deinem Atmosphere-Konto an",

lunaria/files/de-DE.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -748,7 +748,6 @@
748748
"auth": {
749749
"modal": {
750750
"title": "Atmosphere",
751-
"close": "Schließen",
752751
"connected_as": "Verbunden als {'@'}{handle}",
753752
"disconnect": "Verbindung trennen",
754753
"connect_prompt": "Melde dich bei deinem Atmosphere-Konto an",

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"build:lunaria": "node --experimental-transform-types ./lunaria/lunaria.ts",
1414
"dev": "nuxt dev",
1515
"dev:docs": "pnpm run --filter npmx-docs dev --port=3001",
16+
"i18n:check": "node --experimental-transform-types scripts/compare-translations.ts",
1617
"knip": "knip",
1718
"knip:fix": "knip --fix",
1819
"knip:production": "knip --production",

scripts/compare-translations.ts

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
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()

0 commit comments

Comments
 (0)