Skip to content

Commit 35e76fe

Browse files
committed
add translation script and fix missing translations
1 parent 9ef7894 commit 35e76fe

File tree

5 files changed

+392
-12
lines changed

5 files changed

+392
-12
lines changed

.github/workflows/staging.yaml

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,9 @@ jobs:
3939
- name: Copy dependencies from the container
4040
run: docker cp gptwrapper:/opt/app-root/src/node_modules ./node_modules
4141

42-
#- run: npm run tsc
43-
#- run: npm run lint
42+
- run: npm run tsc
43+
- run: npm run lint
44+
- run: npm run translations -- --lang fi,en
4445

4546
# https://playwrightsolutions.com/playwright-github-action-to-cache-the-browser-binaries/
4647
- name: Get installed Playwright version
@@ -60,8 +61,6 @@ jobs:
6061
- run: npx playwright install-deps
6162
if: steps.playwright-cache.outputs.cache-hit != 'true'
6263

63-
- run: curl localhost:8000
64-
6564
- name: Run tests
6665
env:
6766
CI: true

.husky/pre-commit

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11

22
npx lint-staged
3+
npm run translations -- --lang fi,en --quiet

scripts/analyzeTranslations.js

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

Comments
 (0)