json-sort-cli
Version:
Command line app to deep sort JSON files, retains package.json special key order
491 lines (462 loc) • 14.3 kB
JavaScript
// VARS
// -----------------------------------------------------------------------------
import meow from "meow";
import path from "path";
import fs from "fs-extra";
import chalk from "chalk";
import isDirectory from "is-d";
import pReduce from "p-reduce";
import pFilter from "p-filter";
import { globby } from "globby";
import { createRequire } from "module";
import { isPlainObject, resolveEolSetting } from "codsen-utils";
import updateNotifier from "update-notifier";
import { traverse } from "ast-monkey-traverse";
import sortPackageJson, { sortOrder } from "sort-package-json";
const require1 = createRequire(import.meta.url);
const pkg = require1("./package.json");
function isStr(something) {
return typeof something === "string";
}
function format(obj) {
if (typeof obj !== "object") {
return obj;
}
let newSortOrder = sortOrder
// 1. delete tap and lect fields
.filter((field) => !["lect", "tap"].includes(field));
// 2. then, insert both after resolutions, first tap then lect
let idxOfResolutions = newSortOrder.indexOf("resolutions");
// console.log(idxOfResolutions);
// => 63
newSortOrder.splice(idxOfResolutions, 0, "tap", "lect");
// use custom array for sorting order:
return sortPackageJson(obj, {
sortOrder: newSortOrder,
});
}
function sortObj(obj) {
let res = {};
Object.keys(obj)
.sort()
.forEach((key) => {
res[key] = obj[key];
});
return res;
}
const prefix = "✨ json-sort-cli: ";
const { log } = console;
const cli = meow(
`
Usage
$ jsonsort YOURFILE.json
$ sortjson YOURFILE.json
$ sortjson templatesfolder1 templatesfolder2 package.json
or, just type "jsonsort" and it will let you pick a file.
Options
-n, --nodemodules Don't ignore any node_modules folders
-t, --tabs Use tabs for JSON file indentation
-i, --indentationCount How many spaces/tabs to use (default: 2 for spaces or 1 for tabs)
-s, --silent Does not show the result per-file, only totals in the end
-h, --help Shows this help
-v, --version Shows the current version
-a, --arrays Also sort any arrays if they contain only string elements
-d, --dry Only list all the files about to be processed
-p, --pack Exclude all package.json files
-c, --ci Only exits with non-zero code if files COULD BE sorted
-l, --lineEnding Set to "cr", "crlf" or "lf" to override the default
(which is either EOL format used in the file, or Mac LF)
Example
Call anywhere using glob patterns. If you put them as string, this library
will parse globs. If you put as system globs without quotes, your shell will expand them.
`,
{
importMeta: import.meta,
flags: {
nodemodules: {
type: "boolean",
shortFlag: "n",
default: false,
},
tabs: {
type: "boolean",
shortFlag: "t",
default: false,
},
silent: {
type: "boolean",
shortFlag: "s",
default: false,
},
arrays: {
type: "boolean",
shortFlag: "a",
default: false,
},
pack: {
type: "boolean",
shortFlag: "p",
default: false,
},
dry: {
type: "boolean",
shortFlag: "d",
default: false,
},
ci: {
type: "boolean",
shortFlag: "c",
default: false,
},
help: {
type: "boolean",
shortFlag: "h",
default: false,
},
version: {
type: "boolean",
shortFlag: "v",
default: false,
},
indentationCount: {
type: "number",
shortFlag: "i",
},
lineEnding: {
type: "string",
shortFlag: "l",
},
},
},
);
updateNotifier({ pkg }).notify();
const nonJsonFormats = ["yml", "toml", "yaml"]; // to save time
const badFiles = [
".DS_Store",
"npm-debug.log",
".svn",
"CVS",
"config.gypi",
".lock-wscript",
"package-lock.json",
"npm-shrinkwrap.json",
];
// 1. set defaults:
let indentationCount = 2;
if (cli.flags.tabs) {
indentationCount = 1;
}
// 2. overwrite defaults with explicitly set value:
if (cli.flags.indentationCount) {
indentationCount = +cli.flags.indentationCount;
}
// FUNCTIONS
// -----------------------------------------------------------------------------
function readSortAndWriteOverFile(oneOfPaths) {
return fs
.readFile(oneOfPaths, "utf8")
.then((filesContent) => {
let eolChar = resolveEolSetting(filesContent, cli.flags.lineEnding);
let parsedJson;
try {
// try to parse JSON
parsedJson = JSON.parse(filesContent);
} catch (err) {
// if it is not parseable, stop
if (!cli.flags.silent) {
log(`${chalk.grey(prefix)}${oneOfPaths} - ${chalk.red(err)}`);
}
return Promise.resolve(null);
}
let result;
if (isPlainObject(parsedJson)) {
result = sortObj(parsedJson);
} else if (
cli.flags.arrays &&
Array.isArray(parsedJson) &&
parsedJson.length &&
parsedJson.every(isStr)
) {
// if it was an array full of strings, it's an early ending:
return fs.writeJson(
oneOfPaths,
parsedJson.sort((a, b) => a.localeCompare(b)),
{
spaces: cli.flags.tabs
? "\t".repeat(indentationCount)
: indentationCount,
EOL: eolChar,
},
);
} else {
result = parsedJson;
}
return Promise.resolve(
!cli.flags.pack && path.basename(oneOfPaths) === "package.json"
? format(result)
: result,
).then((obj) => {
if (cli.flags.ci) {
// if it's CI mode, we only gather a list of files that differ from
// input after processing, then we return an array.
// In this function, readSortAndWriteOverFile(), path came in,
// we read it, now we return true if result differs after processing
let stringified = JSON.stringify(
// The traversal below will mutate, not just traverse, -
// whatever you return from the callback below, gets written
traverse(obj, (key, val) => {
let current = val !== undefined ? val : key;
if (isPlainObject(current)) {
return sortObj(current);
}
if (
cli.flags.arrays &&
Array.isArray(current) &&
current.length > 1 &&
current.every(isStr)
) {
// alphabetical sort, this value gets written
return current.sort((a, b) => a.localeCompare(b));
}
return current;
}),
null,
cli.flags.tabs ? "\t".repeat(indentationCount) : indentationCount,
);
if (eolChar === "\r\n") {
// CRLF
stringified = stringified
.replaceAll(/(?<!\r)\n/g, "\r\n")
.replaceAll(/\r(?!\n)/g, "\n");
} else {
// LF or CR
stringified = stringified.replaceAll(/(?:\r?\n)|\r/g, eolChar);
}
return (
stringified
.replaceAll(
/\r\n/g,
resolveEolSetting(filesContent, cli.flags.lineEnding),
)
.trimEnd() !== filesContent.trimEnd()
);
}
// ELSE,
return fs
.writeJson(
oneOfPaths,
traverse(obj, (key, val) => {
let current = val !== undefined ? val : key;
if (isPlainObject(current)) {
return sortObj(current);
}
if (
cli.flags.arrays &&
Array.isArray(current) &&
current.length > 1 &&
current.every(isStr)
) {
// alphabetical sort
return current.sort((a, b) => a.localeCompare(b));
}
return current;
}),
{
spaces: cli.flags.tabs
? "\t".repeat(indentationCount)
: indentationCount,
EOL: resolveEolSetting(filesContent, cli.flags.lineEnding),
},
)
.then(() => {
if (!cli.flags.silent) {
log(
`${chalk.grey(
prefix,
)}${oneOfPaths} - ${`\u001b[${32}m${"OK"}\u001b[${39}m`}`,
);
}
return true;
});
});
})
.catch((err) => {
console.log(
`${oneOfPaths} - ${`\u001b[${31}m${"BAD"}\u001b[${39}m`} - ${err}`,
);
});
}
// Step #0. take care of -v and -h flags that are left out in meow.
// -----------------------------------------------------------------------------
if (cli.flags.version) {
log(pkg.version);
process.exit(0);
} else if (cli.flags.help) {
log(cli.help);
process.exit(0);
}
// Step #1. set up the cli
// -----------------------------------------------------------------------------
const { input } = cli;
if (Array.isArray(input) && !input.length) {
input.push("**/*.json");
}
// Step #2. query the glob and follow the pipeline
// -----------------------------------------------------------------------------
globby(input, { dot: true })
.then((paths) => {
// flip out of the pipeline if there are no paths resolved
if (paths.length === 0 && !cli.flags.silent) {
log(
`${chalk.grey(prefix)}${chalk.red(
"The inputs don't lead to any json files! Exiting.",
)}`,
);
process.exit(0);
}
return paths;
})
// glob each directory, reduce'ing all results (in promise shape) until all are resolved
.then((paths) =>
pReduce(
paths,
(concatTotal, singleDirOrFilePath) =>
concatTotal.concat(
isDirectory(singleDirOrFilePath).then((bool) =>
bool
? globby(
cli.flags.nodemodules
? singleDirOrFilePath
: [singleDirOrFilePath, "!**/node_modules/**"],
{
expandDirectories: {
files: [".*", "*.json"],
},
},
)
: [singleDirOrFilePath],
),
),
[],
// then reduce again, now actually concatenating them all together
).then((received) =>
pReduce(received, (total, single) => total.concat(single), []),
),
)
.then((paths) =>
paths.filter(
(oneOfPaths) =>
!oneOfPaths.includes("package-lock.json") &&
!oneOfPaths.includes("yarn.lock"),
),
)
.then((paths) =>
!cli.flags.nodemodules
? paths.filter((oneOfPaths) => !oneOfPaths.includes("node_modules"))
: paths,
)
.then((paths) =>
cli.flags.pack
? paths.filter((oneOfPaths) => !oneOfPaths.includes("package.json"))
: paths,
)
.then((paths) =>
paths.filter((singlePath) => {
return (
path.extname(singlePath) === ".json" ||
(typeof path.basename(singlePath) === "string" &&
path.basename(singlePath).startsWith(".") &&
!nonJsonFormats.some((badExtension) =>
path.extname(singlePath).includes(badExtension),
) &&
!badFiles.some((badFile) =>
path.basename(singlePath).includes(badFile),
))
);
}),
)
.then((paths) => {
if (cli.flags.dry && !cli.flags.silent) {
log(
`${chalk.grey(prefix)}${chalk.yellow(
"We'd try to sort the following files:",
)}\n${paths.join("\n")}`,
);
} else {
if (cli.flags.ci) {
// CI setting
return pFilter(paths, (currentPath) =>
readSortAndWriteOverFile(currentPath),
).then((received2) => {
if (received2.length && !cli.flags.silent) {
log(
`${chalk.grey(prefix)}${chalk.red(
"Unsorted files:",
)}\n${received2.join("\n")}`,
);
process.exit(9);
} else if (!cli.flags.silent) {
log(
`${chalk.grey(prefix)}${chalk.white(
"All files were already sorted:",
)}\n${paths.join("\n")}`,
);
process.exit(0);
}
});
}
// not a CI setting
return pReduce(
paths,
(counter, currentPath) =>
readSortAndWriteOverFile(currentPath)
.then((res) =>
res
? {
good: counter.good.concat([currentPath]),
bad: counter.bad,
}
: {
good: counter.good,
bad: counter.bad.concat([currentPath]),
},
)
.catch((err) => {
if (!cli.flags.silent) {
log(
`${chalk.grey(prefix)}${chalk.red(
"Could not write out the sorted file:",
)} ${err}`,
);
}
}),
{ good: [], bad: [] },
).then((counter) => {
if (!cli.flags.silent) {
log(
`\n${chalk.grey(prefix)}${chalk.green(
`${counter?.bad.length === 0 ? "All " : ""}${
counter.good.length
} files sorted`,
)}${
counter?.bad.length
? `\n${chalk.grey(prefix)}${chalk.red(
`${counter.bad.length} file${
counter.bad.length === 1 ? "" : "s"
} could not be sorted`,
)} ${`\u001b[${90}m - ${counter.bad.join(
" - ",
)}\u001b[${39}m`}`
: ""
}`,
);
}
});
}
})
.catch((err) => {
if (!cli.flags.silent) {
log(`${chalk.grey(prefix)}${chalk.red("Oops!")} ${err}`);
}
});