mirror of https://github.com/rancher/dashboard.git
438 lines
13 KiB
JavaScript
Executable File
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);
|
|
}
|