#!/usr/bin/env node /** * This script reads and parses all of the source code files, reads the US English * translation files and compares the two to identify referenced strings that * do not have a corresponding translation. * * This script is used in the PR gate to ensure code is not added without the * corresponding translations. * */ const fs = require('fs'); const path = require('path'); const yaml = require('js-yaml'); const base = path.resolve(__dirname, '..'); const srcFolder = path.resolve(base, 'shell'); const pkgFolder = path.resolve(base, 'pkg'); // Simple shell colors const reset = "\x1b[0m"; const cyan = `\x1b[96m`; const yellow = `\x1b[33m`; const white = `\x1b[97m`; const bold = `\x1b[1m`; const bg_red = `\x1b[41m`; const includeExtensions = ['.ts', '.vue', '.js']; const excludeExtensions = ['.spec.ts', '.test.ts', '.d.ts', 'vue.config.js']; // Regex list we use to detect uses of localisations const I18N_REGEX_LIST = [ /[\.\[\s|=\"]t\(\s*`([0-9a-zA-Z-_\,\."{}\s$\[\]]+)`/g, /[\.\[\s]t\(\s*'([0-9a-zA-Z-_\,\."]+)'/g, /[\.\[\s|=]t\(\s*"([0-9a-zA-Z-_\,\."]+)"/g, /\st\(\s*'([0-9a-zA-Z-_\,\.]+)'/g, /{{\s*t\(\s*'([0-9a-zA-Z-_\,\.]+)'/g, /{{\s*\$t\(\s*'([0-9a-zA-Z-_\,\.]+)'/g, /{{\s*t\(\s*`([0-9a-zA-Z-_\,\.${}\?\s:']+)`/g, /\s+k="([0-9a-zA-Z-_\,\.]+)"/g, /="t\('([0-9a-zA-Z-_\,\.]+)'/g, /[^:^-]label-key="([0-9a-zA-Z-_\,\.]+)"/g, /:label="t\('([0-9a-zA-Z-_\,\.]+)'\)"/g, /labelKey:\s*['|`]([0-9a-zA-Z-_\,\.{}\s$\[\]]+)['|`]/g, /tooltipKey:\s*'([0-9a-zA-Z-_\,\.]+)'/g, /placeholderKey:\s*'([0-9a-zA-Z-_\,\.]+)'/g, /[^:^-]tooltip-key="([0-9a-zA-Z-_\,\.]+)"/g, /tooltip="t\(\s*'([0-9a-zA-Z-_\,\.]+)'/g, /placeholder-key="([0-9a-zA-Z-_\,\.]+)"/g, /no-rows-key="([0-9a-zA-Z-_\,\.]+)"/g, /title-key="([0-9a-zA-Z-_\,\.]+)"/g, /\s+add-label="([0-9a-zA-Z-_\,\.]+)"/g, /v-t="'([0-9a-zA-Z-_\,\.]+)'"/g, /\['i18n\/t'\]\('([0-9a-zA-Z-_\,\.]+)'/g, /\['i18n\/withFallback'\]\(`([0-9a-zA-Z-_\,\.$\s{}\"\(\)']+)`/g, /\s+k="([0-9a-zA-Z-_\,\.]+)"/g, /this\.createOSOption\('([0-9a-zA-Z-_\,\.]+)'/g, ]; // Regex to use to manually indicate that an i18n string is used const I18N_USES = /^\s*(?:.*)\/\/ i18n-uses\s?([0-9a-zA-Z-_\,\.\"'\s\*\\]*)$/; // Regex to use to manually indicate that an i18n string should be ignores const I18N_IGNORE = /^\s*(.*)\/\/ i18n-ignore\s?([0-9a-zA-Z-_\,\.\"'\s\*]*)/; // -s flag will show the details of all of the unused strings // -x flag will cause script to return 0, even if there are errors let showUnused = false; let doNotReturnError = false; // Simple arg parsing if (process.argv.length > 2) { process.argv.shift(); process.argv.shift(); process.argv.forEach((arg) => { if (arg === '-s') { showUnused = true; } else if (arg === '-x') { doNotReturnError = true; } }); } function parseReference(ref) { if (!ref.includes('$') || ref.startsWith('${')) { return ref; } let out = ''; let inVar = false; let variable = ''; let vars = 0; for (let i=0;i o.trim().replaceAll('\'', '')); if (opts.length === 2) { const a = p.map((o) => o === '$' ? opts[0] : o).join('.'); const b = p.map((o) => o === '$' ? opts[1] : o).join('.'); return [a, b]; } } p = p.map((a) => a.startsWith('$') ? `.*${ a.substr(1) }` : a); return new RegExp(p.join('\\.'), 'g'); } function readAndParseTranslations(filePath) { const data = fs.readFileSync(filePath, 'utf8'); try { const i18n = yaml.load(fs.readFileSync(filePath), 'utf8'); return parseTranslations(i18n); } catch (e) { console.log('Can not read i18n file'); // eslint-disable-line no-console console.log(e); // eslint-disable-line no-console process.exit(1); } } function parseTranslations(obj, parent) { let res = {}; Object.keys(obj).forEach((key) => { const v = obj[key]; const pKey = parent ? `${parent}.${key}` : key; if (v === null) { // ignore } else if (typeof v === 'object') { res = { ...res, ...parseTranslations(v, pKey) } } else { // Ensure empty strings work res[pKey] = v.length === 0 ? '[empty]' : v; } }); return res; } function makeRegex(str) { if (str.startsWith('/') && str.endsWith('/')) { return new RegExp(str.substr(1, str.length - 2), 'g'); } else { // String // Support .* for a simple wildcard match if (str.includes('*')) { const parts = str.split('.'); const p = parts.map((s, i) => s === '*' ? (i === parts.length -1 ? '.*' : '[^\.]*') : s); return new RegExp(`^${ p.join('\\.') }$`); } else { return str; } } } function doesMatch(stringOrRegex, str) { if (typeof stringOrRegex === 'string') { return str === stringOrRegex; } else { return str.match(stringOrRegex); } } function processSourceFile(filePath) { const data = fs.readFileSync(filePath, 'utf8'); let lineNum = 0; const strings = {}; const fileIgnores = []; const fileUses = []; let ignoreNextLine = false; let ignoreThisLine = false; // Process file line by line data.split('\n').forEach((line) => { lineNum++; // Make a note of any uses comments - this allows a file to indicate any strings it uses that // can not be determined easily - typically because of dynamic look-up const uses = line.match(I18N_USES); if (uses) { const list = uses[1].split(',').map((i) => i.trim()); fileUses.push(...list.map((i) => makeRegex(i))); } ignoreThisLine = false; const ignore = line.match(I18N_IGNORE); if (ignore && ignore.length === 3) { // 3 possible usages: // 1. All matches empty = ignore next line // 2. Last match set and first match empty - ignore globally // 3. First match set and Last match empty - ignore this line const before = ignore[1]; const after = ignore[2]; if (!before.length && !after.length) { // Ignore the next line ignoreNextLine = true; } else if (before.length && !after.length) { // Ignore this line ignoreThisLine = true; } else if (!before.length && after.length) { const list = after.split(',').map((i) => i.trim()); fileIgnores.push(...list.map((i) => makeRegex(i))); } } // Ignore comments if (ignoreThisLine || !line.trim().startsWith('//')) { if (ignoreNextLine) { ignoreNextLine = false; } else { // Loop through the regexes I18N_REGEX_LIST.forEach((r) => { const m = [...line.matchAll(r)]; m.forEach((item) => { if (item) { const key = item[1].replaceAll('"', ''); const parsed = parseReference(key); if (typeof parsed === 'string' || Array.isArray(parsed)) { const items = Array.isArray(parsed) ? parsed: [parsed]; items.forEach((k) => { // Ignore? const ignore = !!fileIgnores.find((i) => doesMatch(i, k)); if (!ignore && !k.startsWith('${')) { strings[k] = { file: filePath, line: lineNum, col: item.index }; } }); } else { // regex fileUses.push(parsed); } } }); }); } } }); return { uses: fileUses, strings }; } function findCodeFiles(result, folder) { // Find all of the test files fs.readdirSync(folder).forEach((file) => { const filePath = path.resolve(folder, file) const isFolder = fs.lstatSync(filePath).isDirectory(); // Ignore hidden folders and files if (!file.startsWith('.')) { if (isFolder && file !== '__tests__' && file !== 'node_modules') { findCodeFiles(result, filePath); } else { const exclude = excludeExtensions.find((ext) => file.endsWith(ext)); if (!exclude) { const include = includeExtensions.find((ext) => file.endsWith(ext)); if (include) { result.push(processSourceFile(filePath)); } } } } }); } function loadI18nFiles(folder) { let res = {}; // Find all of the test files fs.readdirSync(folder).forEach((file) => { const filePath = path.resolve(folder, file) const isFolder = fs.lstatSync(filePath).isDirectory(); if (isFolder) { res = { ...res, ...loadI18nFiles(filePath) }; } else if (file === 'en-us.yaml') { console.log(` ... ${ path.relative(base, filePath) }`); const translations = readAndParseTranslations(filePath); res = { ...res, ...translations }; } }); return res; } console.log('======================================'); // eslint-disable-line no-console console.log(`${cyan}Checking source files for i18n strings${reset}`); // eslint-disable-line no-console console.log('======================================'); // eslint-disable-line no-console console.log(''); // eslint-disable-line no-console console.log('Reading translation files:'); // eslint-disable-line no-console let i18n = loadI18nFiles(srcFolder); i18n = { ...i18n, ...loadI18nFiles(pkgFolder) }; console.log(`Read ${cyan}${ Object.keys(i18n).length }${reset} translations`); // eslint-disable-line no-console const files = []; findCodeFiles(files, srcFolder); findCodeFiles(files, pkgFolder); console.log(`Read ${cyan}${ files.length }${reset} source files`); // eslint-disable-line no-console const references = {}; let uses = []; files.forEach((f) => { uses.push(...f.uses); }); files.forEach((f) => { Object.keys(f.strings).forEach((key) => { if (!references[key]) { references[key] = []; } references[key].push(f.strings[key]); }); }); console.log(`Found ${cyan}${ Object.keys(references).length }${reset} i18n references in code`); // eslint-disable-line no-console let unused = 0; const unusedKeys = []; // Look for translations that are not used Object.keys(i18n).forEach((str) => { // Should it be ignored? const ignore = !!uses.find((i) => doesMatch(i, str)); if (!ignore && !references[str]) { unused++; unusedKeys.push(str); } }); if (unusedKeys.length) { if (showUnused) { console.log(''); // eslint-disable-line no-console console.log('Found the following unused translation strings:'); // eslint-disable-line no-console console.log(''); // eslint-disable-line no-console unusedKeys.sort(); unusedKeys.forEach((k) => console.log(k)); console.log(''); // eslint-disable-line no-console } console.log(`Found ${yellow}${bold}${unused}${reset} unused translation strings`); // eslint-disable-line no-console } // Apply the ignores const filteredUses = Object.keys(references).filter((r) => !uses.find((i) => r.match(i))); const missingTranslations = filteredUses.filter((r) => !i18n[r]); if (missingTranslations.length) { console.log(''); // eslint-disable-line no-console console.log(`${bold}Found the following references without a matching translation:${reset}`); // eslint-disable-line no-console console.log(''); // eslint-disable-line no-console console.log(`${ 'Translation Key'.padEnd(64) } Location`); // eslint-disable-line no-console console.log(`${ ''.padEnd(64, '-') } ${ ''.padEnd(32, '-') }`); // eslint-disable-line no-console missingTranslations.forEach((key) => { references[key].forEach((ref) => { console.log(`${yellow}${ key.padEnd(64) } ${cyan}${ path.relative(base, ref.file) }${reset} (Line: ${ ref.line }, Col: ${ ref.col })`); // eslint-disable-line no-console }); }); console.log(''); // eslint-disable-line no-console console.log(`${bold}${bg_red}${white}Error: Found ${missingTranslations.length} i18n references without a matching translation${reset}`); // eslint-disable-line no-console } console.log(''); // eslint-disable-line no-console if (doNotReturnError) { if (missingTranslations.length) { console.log(`${bold}${bg_red}${white}-x specified - returning 0 from script even though errors were found${reset}`); // eslint-disable-line no-console } } else { process.exit(missingTranslations.length == 0 ? 0 : 1); }