li18nt
Version:
Locales linter, formatter, sorter and prettifier
747 lines (726 loc) • 24.1 kB
JavaScript
import { program } from 'commander';
import fs from 'fs';
import glob from 'glob';
import path from 'path';
import chalk from 'chalk';
import { exec } from 'child_process';
import * as os from 'os';
import { promisify } from 'util';
/**
* Iterates over the list providing a list-number as first and the array-item
* as second value.
* @param arr Source array.
*/
function* generateList(arr) {
const padding = Math.max(1, Math.log10(arr.length));
for (let i = 0; i < arr.length; i++) {
const str = String(i + 1).padStart(padding, '0');
yield [str, arr[i]];
}
}
/**
* Returns a set with all keys from the given objects
* @param objects
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
const keysFrom = (objects) => {
return new Set(objects.map(obj => Object.keys(obj)).flat());
};
/**
* Returns the type of a value. Limited to json types, excluding undefined.
* @param v
*/
function typeOfJsonValue(v) {
switch (typeof v) {
case 'undefined':
return 'undefined';
case 'object':
return (Array.isArray(v) ? 'array' : v === null ? 'null' : 'object');
case 'boolean':
return 'boolean';
case 'number':
return 'number';
case 'string':
return 'string';
}
}
const PATH_REGEX = /(\.|^)([a-zA-Z]\w*|\*)|\[(\d+|'(.*?)'|"(.*?)")]/g;
/**
* Parses a property path
* foo.bar[4].xy['test prop'] -> ['foo', 'bar', 4, 'xy', 'test prop']
* @param str
*/
const propertyPath = (str) => {
const path = [];
let lastIndex;
for (let match; (match = PATH_REGEX.exec(str));) {
const [full, , prop, arrayIndex, namedIndex, namedIndex2] = match;
const str = prop || namedIndex || namedIndex2;
lastIndex = match.index + full.length;
if (str) {
path.push(str);
}
else if (arrayIndex) {
path.push(Number(arrayIndex));
}
}
// Validate that the whole path has been parsed
if (lastIndex !== str.length) {
throw new Error(`Cannot parse "${str}", invalid character at index ${lastIndex}.`);
}
return path;
};
/**
* Checks if the given array contains another array, only works with primitives.
* @param paths
* @param target
*/
const containsDeep = (paths, target) => {
outer: for (const path of paths) {
if (path.length === target.length) {
for (let i = 0; i < paths.length; i++) {
if (path[i] !== target[i]) {
continue outer;
}
}
return true;
}
}
return false;
};
/**
* Same as containsDeep but only the beginning of the array needs to match the given target
* @param paths
* @param target
*/
const startsWithPattern = (paths, target) => {
outer: for (const path of paths) {
if (path.length <= target.length) {
for (let i = 0; i < path.length; i++) {
const prop = path[i];
if (prop === '*') {
return true;
}
else if (prop !== target[i]) {
continue outer;
}
}
return true;
}
}
return false;
};
/**
* Iterates over all properties (including nested ones) of an object
* @param obj Target object
* @param skipArrays Do not list array items (will still include objects in arrays)
*/
function* paths(obj, skipArrays) {
function* process(val, path = []) {
if (Array.isArray(val)) {
for (let i = 0; i < val.length; i++) {
const valuePath = [...path, i];
const value = val[i];
if (!skipArrays) {
yield { path: valuePath, key: i };
}
switch (typeOfJsonValue(value)) {
case 'array':
case 'object':
yield* process(value, valuePath);
}
}
}
else {
for (const [key, value] of Object.entries(val)) {
const valuePath = [...path, key];
yield { path: valuePath, key };
switch (typeOfJsonValue(value)) {
case 'array':
case 'object':
yield* process(value, valuePath);
}
}
}
}
yield* process(obj);
}
/**
* Pushes the item if not already present.
* @param arr
* @param el
*/
const pushUnique = (arr, el) => {
if (!containsDeep(arr, el)) {
arr.push(el);
return true;
}
return false;
};
/**
* Compares an object to others
* @param target
* @param others
*/
const compare = (target, others) => {
const con = {
conflicts: [],
missing: []
};
function handle(key, target, others, parent = []) {
const targetValue = target[key];
const targetType = typeOfJsonValue(targetValue);
// Property missing?
if (targetType === 'undefined') {
pushUnique(con.missing, [...parent, key]);
return;
}
// Compare with others
for (const obj of others) {
const objValue = obj[key];
const objType = typeOfJsonValue(objValue);
// Property missing, skip
if (objType === 'undefined') {
continue;
}
// Property-type mismatch?
if (objType !== targetType) {
pushUnique(con.conflicts, [...parent, key]);
continue;
}
// Child object?
if (objType === 'object' && objType === targetType) {
resolve(targetValue, [objValue], [...parent, key]);
continue;
}
// Child array?
if (objType === 'array') {
// Length mismatch
if (targetValue.length !== targetValue.length) {
pushUnique(con.conflicts, [...parent, key]);
continue;
}
// Resolve
resolve(targetValue, objValue, [...parent, key]);
}
}
}
function resolve(target, others, parent = []) {
if (Array.isArray(target) && Array.isArray(others)) {
const maxLength = Math.max(target.length, others.length);
for (let i = 0; i < maxLength; i++) {
handle(i, target, others, parent);
}
}
else {
for (const key of keysFrom([target, ...others])) {
handle(key, target, others, parent);
}
}
}
resolve(target, others);
return con;
};
/**
* Finds the difference between given objects
* @param objects
*/
const conflicts = (objects) => {
// Create result objects
const conflicts = [];
for (let i = 0; i < objects.length; i++) {
const target = objects[i];
const others = [...objects];
others.splice(i, 1);
conflicts.push(compare(target, others));
}
return conflicts;
};
/**
* Finds duplicate keys in the given object.
* @param object
* @param conf Optional configuration
*/
const duplicates = (object, conf) => {
var _a;
const duplicates = new Map();
const keys = new Map();
const ignored = ((_a = conf === null || conf === void 0 ? void 0 : conf.ignore) === null || _a === void 0 ? void 0 : _a.map(v => {
return Array.isArray(v) ? v : propertyPath(v);
})) || [];
const walk = (entry, parentPath) => {
if (Array.isArray(entry)) {
for (let i = 0; i < entry.length; i++) {
const value = entry[i];
if (typeOfJsonValue(value) === 'object') {
walk(value, [...parentPath, i]);
}
}
}
else {
for (const key in entry) {
if (Object.prototype.hasOwnProperty.call(entry, key)) {
const newKey = [...parentPath, key];
const value = entry[key];
const type = typeOfJsonValue(value);
if (type === 'object' || type === 'array') {
// Make it possible to ignore entire sub-trees
if (!startsWithPattern(ignored, newKey)) {
walk(value, newKey);
}
continue;
}
const existingPath = keys.get(key);
if (existingPath) {
const list = duplicates.get(key) || [existingPath];
// Check against ignored list
if (!startsWithPattern(ignored, newKey)) {
duplicates.set(key, [...list, newKey]);
}
}
else {
keys.set(key, newKey);
}
}
}
}
};
walk(object, []);
return duplicates;
};
const mapRegexp = (arr) => arr.map(v => v instanceof RegExp ? v : new RegExp(v));
/**
* Finds duplicate keys in the given object.
* @param object
* @param conf Optional configuration
*/
const pattern = (object, conf) => {
const errors = [];
const patterns = mapRegexp((conf === null || conf === void 0 ? void 0 : conf.patterns) || []);
for (const { key, path } of paths(object, true)) {
// Validate
const matches = patterns.filter(v => !v.test(key));
if (matches.length) {
errors.push({
failed: matches.map(v => v.toString()),
key, path
});
}
}
return errors;
};
/**
* Returns the sorted keys of an object as string array
* @param obj
*/
const sortedKeys = (obj) => {
return Object.keys(obj).sort((a, b) => a === b ? 0 : a.localeCompare(b));
};
/**
* Sorts an object by its keys, returns a string as ordering properties manually
* is slow and we can't rely on their order after inserting them.
* @param obj
* @param space
*/
const prettify = (obj, { indent = 4 }) => {
const spacer = indent === 'tab' ? '\t' : ' '.repeat(indent);
let str = '{\n';
const stringify = (v, indent) => {
const type = typeOfJsonValue(v);
const nextIndent = indent + spacer;
switch (type) {
case 'object': {
let str = '{\n';
for (const key of sortedKeys(v)) {
str += `${nextIndent}"${key}": ${stringify(v[key], nextIndent)},\n`;
}
return str.length > 2 ? `${str.slice(0, str.length - 2)}\n${indent}}` : '{}';
}
case 'array': {
let str = '[\n';
for (let i = 0; i < v.length; i++) {
str += `${nextIndent + stringify(v[i], nextIndent)},\n`;
}
return str.length > 2 ? `${str.slice(0, str.length - 2)}\n${indent}]` : '[]';
}
case 'boolean':
case 'number':
case 'null':
return v;
case 'string':
return JSON.stringify(v);
}
return null;
};
for (const key of sortedKeys(obj)) {
str += `${spacer}"${key}": ${stringify(obj[key], spacer)},\n`;
}
return str.length > 2 ? `${str.slice(0, str.length - 2)}\n}` : '{}';
};
const { stdout } = process;
const getLoggingSet = (mode) => {
switch (mode) {
case 'warn': {
return {
log: warn,
logLn: warnLn,
accent: chalk.yellowBright
};
}
case 'error': {
return {
log: error,
logLn: errorLn,
accent: chalk.redBright
};
}
}
throw new Error(`Unknown mode: ${mode}`);
};
const blankLn = (str) => blank(`${str}\n`);
const warnLn = (str) => warn(`${str}\n`);
const errorLn = (str) => error(`${str}\n`);
const successLn = (str) => success(`${str}\n`);
const debugLn = (str) => debug(`${str}\n`);
const blank = (str) => {
stdout.write(str);
};
const warn = (str) => {
stdout.write(`${chalk.yellowBright('[!]')} ${str}`);
};
const error = (str) => {
stdout.write(`${chalk.redBright('[X]')} ${str}`);
};
const success = (str) => {
stdout.write(`${chalk.greenBright('[✓]')} ${str}`);
};
const debug = (str) => {
stdout.write(`[-] ${str}`);
};
/**
* Pluralizes the given string and count
* @param str
* @param count
*/
const pluralize = (str, count) => {
return count === 1 ? `one ${str}` :
`${count} ${str.endsWith('h') ? `${str}e` : str}s`;
};
const NO_WHITESPACE = /^\S*$/;
/**
* Prettifies a property path
* @param path
* @param last
*/
const prettyPropertyPath = (path, last = null) => {
let str = '';
for (let i = 0; i < path.length; i++) {
const part = path[i];
let divider = false;
let snippet;
if (typeof part === 'string') {
if (NO_WHITESPACE.exec(part)) {
divider = !!i;
snippet = part;
}
else {
snippet = `['${part.replace(/'/g, '\\\'')}']`;
}
}
else {
snippet = `[${part}]`;
}
str += (i === path.length - 1) && last ?
(divider ? '.' : '') + last(snippet) :
(divider ? '.' : '') + snippet;
}
return str;
};
/* eslint-disable no-console */
const conflictsHandler = ({ files, cmd, rule }) => {
const diff = conflicts(files.map(v => v.content));
const [mode] = rule;
const { log, accent } = getLoggingSet(mode);
let errors = 0;
for (let i = 0; i < diff.length; i++) {
const { conflicts, missing } = diff[i];
const { name } = files[i];
if (conflicts.length) {
log(`${accent(`${name}:`)} Found ${pluralize('conflict', conflicts.length)}:`);
if (conflicts.length > 1) {
console.log();
for (const [num, path] of generateList(conflicts)) {
console.log(` ${num}. ${prettyPropertyPath(path, accent)}`);
}
}
else {
console.log(` ${prettyPropertyPath(conflicts[0], accent)}`);
}
}
if (missing.length) {
log(`${accent(`${name}:`)} Found ${pluralize('one missing value', missing.length)}:`);
if (missing.length > 1) {
console.log();
for (const [num, path] of generateList(missing)) {
console.log(` ${num}. ${prettyPropertyPath(path, accent)}`);
}
}
else {
console.log(` ${prettyPropertyPath(missing[0], accent)}`);
}
}
errors += conflicts.length + missing.length;
}
!errors && !cmd.quiet && successLn('No conflicts found!');
return !errors;
};
/* eslint-disable no-console */
const duplicatesHandler = ({ files, cmd, rule }) => {
const [mode, options] = rule;
const { logLn, accent } = getLoggingSet(mode);
let errors = 0;
for (const { name, content } of files) {
const dupes = duplicates(content, options);
if (dupes.size) {
logLn(`${accent(`${name}:`)} Found ${pluralize('duplicate', dupes.size)}:`);
for (const [initial, ...duplicates] of dupes.values()) {
process.stdout.write(` ${prettyPropertyPath(initial, chalk.cyanBright)} (${duplicates.length}x): `);
if (duplicates.length > 1) {
process.stdout.write('\n');
for (const [num, path] of generateList(duplicates)) {
console.log(` ${num}. ${prettyPropertyPath(path, accent)}`);
}
}
else if (duplicates.length) {
console.log(prettyPropertyPath(duplicates[0], accent));
}
}
errors++;
}
}
!errors && !cmd.quiet && successLn('No duplicates found!');
return !errors;
};
/* eslint-disable no-console */
const namingHandler = ({ files, cmd, rule }) => {
const [mode, options] = rule;
const { logLn, accent } = getLoggingSet(mode);
let errors = 0;
for (const { name, content } of files) {
const mismatches = pattern(content, options);
if (mismatches.length) {
logLn(`${accent(`${name}:`)} Found ${pluralize('mismatch', mismatches.length)}:`);
for (const { path, failed } of mismatches) {
process.stdout.write(` ${prettyPropertyPath(path, chalk.cyanBright)}: `);
if (failed.length > 1) {
process.stdout.write('\n');
for (const [num, pattern] of generateList(failed)) {
console.log(` ${num}. ${chalk.blueBright(pattern)}`);
}
}
else if (failed.length) {
console.log(chalk.blueBright(failed[0]));
}
}
errors++;
}
}
!errors && !cmd.quiet && successLn('No duplicates found!');
return !errors;
};
/* eslint-disable no-console */
const prettifyHandler = ({ files, cmd, rule }) => {
const [mode, options = { indent: 4 }] = rule;
let errors = 0;
for (const { content, source, name, filePath } of files) {
const str = `${prettify(content, options)}\n`;
if (str !== source) {
if (cmd.fix) {
fs.writeFileSync(filePath, str);
successLn(`Prettified: ${name}`);
}
else if (mode === 'warn') {
warnLn(`Unformatted: ${name}`);
}
else {
errorLn(`Unformatted: ${name}`);
errors++;
}
}
else if (!cmd.quiet && cmd.fix) {
successLn(`Already formatted: ${name}`);
}
}
!errors && !cmd.quiet && successLn('Everything prettified!');
return !errors;
};
/* eslint-disable @typescript-eslint/no-explicit-any */
const handler = [
['conflicts', conflictsHandler],
['duplicates', duplicatesHandler],
['naming', namingHandler],
['prettified', prettifyHandler]
];
// Entry point
/* eslint-disable no-console */
const entry = async (sources, cmd) => {
const cwd = process.cwd();
const { rules } = cmd;
// Resolve files
const files = [];
for (const source of sources) {
for (const file of glob.sync(source)) {
const filePath = path.resolve(cwd, file);
const name = path.basename(filePath);
// Check if file exists
if (!fs.existsSync(filePath)) {
warnLn(`File not found: ${filePath}`);
continue;
}
// Try to read and parse the locale file
try {
const source = fs.readFileSync(filePath, 'utf-8');
files.push({
content: JSON.parse(source),
source, name, filePath
});
}
catch (e) {
errorLn(`Couldn't read / parse file: ${filePath}`);
// Exit in case invalid files shouldn't be skipped
!cmd.skipInvalid && process.exit(2);
// Print error message during debug mode
cmd.debug && console.error(e);
continue;
}
cmd.debug && debugLn(`Loaded ${path.basename(filePath)} (${filePath})`);
}
}
// Nothing to process
if (!files.length) {
cmd.debug && debugLn('Nothing to process.');
return;
}
// Process files
let errored = false;
for (const [flag, func] of handler) {
const rule = rules[flag];
cmd.debug && debugLn(`Rule "${flag}": ${(rule === null || rule === void 0 ? void 0 : rule[0]) || 'off'}`);
if (rule && rule[0] !== 'off') {
// We need to check against false as undefined is falsy
const ok = func({ files, cmd, rule });
errored = (rule[0] === 'error' && !ok) || errored;
}
}
const exitCode = errored ? 1 : 0;
cmd.debug && debugLn(`Exiting with error: ${errored} (code: ${exitCode})`);
process.exit(exitCode);
};
const execAsync = promisify(exec);
/**
* Prints system information
*/
const printReport = async (version) => {
const { stdout: nodeVersion } = await execAsync('node -v');
const { stdout: npmVersion } = await execAsync('npm -v');
blankLn(`Li18nt: v${version}`);
blankLn(`Node: ${nodeVersion.trim()}`);
blankLn(`NPM: v${npmVersion.trim()}`);
blankLn(`OS: ${os.arch()} ${os.version()} (v${os.release()}, ${os.platform()})`);
};
const configFileNames = [
'.li18ntrc',
'.li18nt.json',
'.li18ntrc.json',
'li18nt.config.js'
];
const CAN_REQUIRE = /(\.json|\.js)$/;
const load = (filePath) => {
if (CAN_REQUIRE.exec(filePath)) {
return require(filePath);
}
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
};
/**
* Tries to find a configuration ile and parses it
* @param cmd
*/
const resolveConfiguration = (cmd) => {
const cwd = process.cwd();
// User specified a file-path
if (typeof cmd.config === 'string') {
const filePath = path.resolve(cwd, cmd.config);
if (!fs.existsSync(filePath)) {
error(`Couldn't find ${filePath}.`);
process.exit(1);
}
try {
return load(filePath);
}
catch (err) {
error(`Couldn't import ${filePath}.`);
cmd.debug && debugLn(err);
process.exit(1);
}
}
// Try finding a config file
for (const fileName of configFileNames) {
const filePath = path.resolve(cwd, fileName);
if (fs.existsSync(filePath)) {
cmd.debug && debugLn(`Found config: ${filePath}`);
try {
return load(filePath);
}
catch (err) {
error(`Couldn't load ${filePath}.`);
cmd.debug && debugLn(err);
process.exit(1);
}
}
}
return null;
};
const version = "5.0.0";
/* eslint-disable @typescript-eslint/no-explicit-any */
const processRule = (mode) => {
return typeof mode === 'string' ? [mode, undefined] : mode;
};
const undefinedOr = (a, b) => {
return typeof a !== 'undefined' ? a : b;
};
program
.version(version, '-v, --version', 'Output the current version')
.helpOption('-h, --help', 'Show this help text')
.name('lint-i18n')
.description('Lints your locales files, lint-i18n is an alias.')
.usage('[files...] [options]')
.arguments('[files...]')
.option('-q, --quiet', 'Print only errors and warnings')
.option('-d, --debug', 'Debug information')
.option('-f, --fix', 'Tries to fix existing errors')
.option('--config [path]', 'Configuration file path (it\'ll try to resolve one in the current directory)')
.option('--skip-invalid', 'Skip invalid files without exiting')
.option('--report', 'Print system information')
.action((args, cmd) => {
// Print report and exit immediately
if (cmd.report) {
return printReport(version);
}
// Try to resolve and load config file
const options = resolveConfiguration(cmd);
if (options) {
// Override options
cmd.quiet = undefinedOr(cmd.quiet, options.quiet);
cmd.skipInvalid = undefinedOr(cmd.skipInvalid, options.skipInvalid);
cmd.rules = options.rules || {};
for (const [name, value] of Object.entries(cmd.rules)) {
cmd.rules[name] = processRule(value);
}
}
else {
return warnLn('Missing configuration file.');
}
return entry(args, cmd);
})
.parse();
//# sourceMappingURL=cli.js.map