dashboard/scripts/check-i18n

438 lines
13 KiB
JavaScript
Executable File

#!/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<ref.length;i++) {
if (inVar) {
if (ref[i] === '}') {
inVar = false;
} else {
variable += ref[i];
}
} else if (ref[i] === '{') {
inVar = true;
vars++;
variable = '';
} else {
out += ref[i];
}
}
let p = out.replaceAll('"', '').split('.');
// Check for patterns like this: resourceDetail.detailTop.${annotationsVisible? 'hideAnnotations' : 'showAnnotations'}
if (vars === 1 && variable.includes('?')) {
const options = variable.substr(variable.indexOf('?') + 1);
const opts = options.split(':').map((o) => 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);
}