UNPKG

li18nt

Version:

Locales linter, formatter, sorter and prettifier

747 lines (726 loc) 24.1 kB
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