// ////////////////////////////////////////////////////////////////////////////// // // Locale verification script // // usage: // // node app/scripts/verify-locale-strings.js [] [--fix] [--quiet] // // This script will validate that locales have no unused messages. It will check // the English locale against string literals found under `ui/`, and it will check // other locales by comparing them to the English locale. // // A report will be printed to the console detailing any unused locales, and also // any missing messages in the non-English locales. // // The if the optional '--fix' parameter is given, locales will be automatically // updated to remove any unused messages. // // The optional '--quiet' parameter reduces the verbosity of the output, printing // just a single summary of results for each locale verified // // ////////////////////////////////////////////////////////////////////////////// const fs = require('fs') const path = require('path') const { promisify } = require('util') const log = require('loglevel') const matchAll = require('string.prototype.matchall').getPolyfill() const localeIndex = require('../app/_locales/index.json') const readdir = promisify(fs.readdir) const readFile = promisify(fs.readFile) const writeFile = promisify(fs.writeFile) log.setDefaultLevel('info') let fix = false let specifiedLocale for (const arg of process.argv.slice(2)) { if (arg === '--fix') { fix = true } else if (arg === '--quiet') { log.setLevel('error') } else { specifiedLocale = arg } } main().catch((error) => { log.error(error) process.exit(1) }) async function main() { if (specifiedLocale) { log.info(`Verifying selected locale "${specifiedLocale}":\n`) const locale = localeIndex.find( (localeMeta) => localeMeta.code === specifiedLocale, ) const failed = locale.code === 'en' ? await verifyEnglishLocale() : await verifyLocale(locale) if (failed) { process.exit(1) } } else { log.info('Verifying all locales:\n') let failed = await verifyEnglishLocale(fix) const localeCodes = localeIndex .filter((localeMeta) => localeMeta.code !== 'en') .map((localeMeta) => localeMeta.code) for (const code of localeCodes) { log.info() // Separate each locale report by a newline when not in '--quiet' mode const localeFailed = await verifyLocale(code, fix) failed = failed || localeFailed } if (failed) { process.exit(1) } } } function getLocalePath(code) { return path.resolve(__dirname, '..', 'app', '_locales', code, 'messages.json') } async function getLocale(code) { try { const localeFilePath = getLocalePath(code) const fileContents = await readFile(localeFilePath, 'utf8') return JSON.parse(fileContents) } catch (e) { if (e.code === 'ENOENT') { log.error('Locale file not found') } else { log.error(`Error opening your locale ("${code}") file: `, e) } process.exit(1) } } async function writeLocale(code, locale) { try { const localeFilePath = getLocalePath(code) return writeFile( localeFilePath, `${JSON.stringify(locale, null, 2)}\n`, 'utf8', ) } catch (e) { if (e.code === 'ENOENT') { log.error('Locale file not found') } else { log.error(`Error writing your locale ("${code}") file: `, e) } process.exit(1) } } async function verifyLocale(code) { const englishLocale = await getLocale('en') const targetLocale = await getLocale(code) const extraItems = compareLocalesForMissingItems({ base: targetLocale, subject: englishLocale, }) const missingItems = compareLocalesForMissingItems({ base: englishLocale, subject: targetLocale, }) const englishEntryCount = Object.keys(englishLocale).length const coveragePercent = (100 * (englishEntryCount - missingItems.length)) / englishEntryCount if (extraItems.length) { console.log(`**${code}**: ${extraItems.length} unused messages`) log.info('Extra items that should not be localized:') extraItems.forEach(function (key) { log.info(` - [ ] ${key}`) }) } else { log.info(`**${code}**: ${extraItems.length} unused messages`) } log.info(`${coveragePercent.toFixed(2)}% coverage`) if (missingItems.length) { log.info(`Missing items not present in localized file:`) missingItems.forEach(function (key) { log.info(` - [ ] ${key}`) }) } if (!extraItems.length && !missingItems.length) { log.info('Full coverage : )') } if (extraItems.length > 0) { if (fix) { const newLocale = { ...targetLocale } for (const item of extraItems) { delete newLocale[item] } await writeLocale(code, newLocale) } return true } return false } async function verifyEnglishLocale() { const englishLocale = await getLocale('en') const uiJSFiles = await findJavascriptFiles( path.resolve(__dirname, '..', 'ui'), ) const sharedJSFiles = await findJavascriptFiles( path.resolve(__dirname, '..', 'shared'), ) const javascriptFiles = sharedJSFiles.concat(uiJSFiles) // match "t(`...`)" because constructing message keys from template strings // prevents this script from finding the messages, and then inappropriately // deletes them const templateStringRegex = /\bt\(`.*`\)/gu const templateUsage = [] // match the keys from the locale file const keyRegex = /'(\w+)'|"(\w+)"/gu const usedMessages = new Set() for await (const fileContents of getFileContents(javascriptFiles)) { for (const match of matchAll.call(fileContents, keyRegex)) { usedMessages.add(match[1] || match[2]) } const templateMatches = fileContents.match(templateStringRegex) if (templateMatches) { // concat doesn't work here for some reason templateMatches.forEach((match) => templateUsage.push(match)) } } // never consider these messages as unused const messageExceptions = ['appName', 'appDescription'] const englishMessages = Object.keys(englishLocale) const unusedMessages = englishMessages.filter( (message) => !messageExceptions.includes(message) && !usedMessages.has(message), ) if (unusedMessages.length) { console.log(`**en**: ${unusedMessages.length} unused messages`) log.info(`Messages not present in UI:`) unusedMessages.forEach(function (key) { log.info(` - [ ] ${key}`) }) } if (templateUsage.length) { log.info(`Forbidden use of template strings in 't' function:`) templateUsage.forEach(function (occurrence) { log.info(` - ${occurrence}`) }) } if (!unusedMessages.length && !templateUsage.length) { log.info('Full coverage : )') return false // failed === false } if (unusedMessages.length > 0 && fix) { const newLocale = { ...englishLocale } for (const key of unusedMessages) { delete newLocale[key] } await writeLocale('en', newLocale) } return true // failed === true } async function findJavascriptFiles(rootDir) { const javascriptFiles = [] const contents = await readdir(rootDir, { withFileTypes: true }) for (const file of contents) { if (file.isDirectory()) { javascriptFiles.push( ...(await findJavascriptFiles(path.join(rootDir, file.name))), ) } else if (file.isFile() && file.name.endsWith('.js')) { javascriptFiles.push(path.join(rootDir, file.name)) } } return javascriptFiles } async function* getFileContents(filenames) { for (const filename of filenames) { yield readFile(filename, 'utf8') } } function compareLocalesForMissingItems({ base, subject }) { return Object.keys(base).filter((key) => !subject[key]) }