@stephansama/auto-readme
Version:
Generate lists and tables for your README automagically based on your repository and comments
633 lines (622 loc) • 22.7 kB
JavaScript
import { fromMarkdown } from "mdast-util-from-markdown";
import * as cp from "node:child_process";
import * as fsp from "node:fs/promises";
import ora from "ora";
import debug from "debug";
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
import * as z from "zod";
import { commentMarker } from "mdast-comment-marker";
import toml from "@iarna/toml";
import { cosmiconfig, getDefaultSearchPlaces } from "cosmiconfig";
import deepmerge from "deepmerge";
import { getPackages } from "@manypkg/get-packages";
import * as fs from "node:fs";
import * as path$1 from "node:path";
import path from "node:path";
import { readPackageJSON } from "pkg-types";
import * as yaml from "yaml";
import { zod2md } from "zod2md";
import glob from "fast-glob";
import { remark } from "remark";
import remarkCodeImport from "remark-code-import";
import remarkCollapse from "remark-collapse";
import remarkToc from "remark-toc";
import remarkUsage from "remark-usage";
import { VFile } from "vfile";
import Handlebars from "handlebars";
import { markdownTable } from "markdown-table";
import { zone } from "mdast-zone";
//#region src/schema.ts
const actionsSchema = z.enum([
"ACTION",
"PKG",
"USAGE",
"WORKSPACE",
"ZOD"
]).meta({ description: "Comment action options" });
const formatsSchema = z.enum(["LIST", "TABLE"]).default("TABLE");
const languageSchema = z.enum(["JS", "RS"]).default("JS");
const headingsSchema = z.enum([
"default",
"description",
"devDependency",
"downloads",
"name",
"private",
"required",
"version"
]).meta({ description: "Table heading options" });
const tableHeadingsSchema = z.record(actionsSchema, headingsSchema.array().optional()).default({
ACTION: [
"name",
"required",
"default",
"description"
],
PKG: [
"name",
"version",
"devDependency"
],
USAGE: [],
WORKSPACE: [
"name",
"version",
"downloads",
"description"
],
ZOD: []
}).meta({ description: "Table heading action configuration" });
const templatesSchema = z.object({
downloadImage: z.string().default("https://img.shields.io/npm/dw/{{name}}?labelColor=211F1F"),
emojis: z.record(headingsSchema, z.string()).default({
default: "⚙️",
description: "📝",
devDependency: "💻",
downloads: "📥",
name: "🏷️",
private: "🔒",
required: "",
version: ""
}).meta({ description: "Table heading emojis used when enabled" }),
registryUrl: z.string().default("https://www.npmjs.com/package/{{name}}"),
versionImage: z.string().default("https://img.shields.io/npm/v/{{uri_name}}?logo=npm&logoColor=red&color=211F1F&labelColor=211F1F")
});
const defaultTemplates = templatesSchema.parse({});
const defaultTableHeadings = tableHeadingsSchema.parse(void 0);
const _configSchema = z.object({
affectedRegexes: z.string().array().default([]),
collapseHeadings: z.string().array().default([]),
defaultLanguage: languageSchema.meta({
alias: "l",
description: "Default language to infer projects from"
}),
disableEmojis: z.boolean().default(false).meta({
alias: "e",
description: "Whether or not to use emojis in markdown table headings"
}),
disableMarkdownHeadings: z.boolean().default(false).meta({ description: "Whether or not to display markdown headings" }),
enablePrettier: z.boolean().default(true).meta({ description: "Whether or not to use prettier to format the files" }),
enableToc: z.boolean().default(false).meta({
alias: "t",
description: "generate table of contents for readmes"
}),
enableUsage: z.boolean().default(false).meta({ description: "Whether or not to enable usage plugin" }),
headings: tableHeadingsSchema.optional().default(defaultTableHeadings).describe("List of headings for different table outputs"),
onlyReadmes: z.boolean().default(true).meta({
alias: "r",
description: "Whether or not to only traverse readmes"
}),
onlyShowPublicPackages: z.boolean().default(false).meta({
alias: "p",
description: "Only show public packages in workspaces"
}),
removeScope: z.string().default("").meta({ description: "Remove common workspace scope" }),
templates: templatesSchema.optional().default(defaultTemplates).describe("Handlebars templates used to fuel list and table generation"),
tocHeading: z.string().default("Table of contents").meta({ description: "Markdown heading used to generate table of contents" }),
usageFile: z.string().default("").meta({ description: "Workspace level usage file" }),
usageHeading: z.string().default("Usage").meta({ description: "Markdown heading used to generate usage example" }),
verbose: z.boolean().default(false).meta({
alias: "v",
description: "whether or not to display verbose logging"
})
});
const configSchema = _configSchema.optional();
//#endregion
//#region src/args.ts
const complexOptions = [
"affectedRegexes",
"collapseHeadings",
"headings",
"templates"
];
const args = {
...zodToYargs(),
changes: {
alias: "g",
default: false,
description: "Check only changed git files",
type: "boolean"
},
check: {
alias: "k",
default: false,
description: "Do not write to files. Only check for changes",
type: "boolean"
},
config: {
alias: "c",
description: "Path to config file",
type: "string"
}
};
async function parseArgs() {
const yargsInstance = yargs(hideBin(process.argv)).options(args).help("h").alias("h", "help").epilogue(`--> @stephansama open-source ${(/* @__PURE__ */ new Date()).getFullYear()}`);
const parsed = await yargsInstance.wrap(yargsInstance.terminalWidth()).parse();
if (parsed.verbose) debug.enable("autoreadme*");
return parsed;
}
function zodToYargs() {
const { shape } = configSchema.unwrap();
const entries = Object.entries(shape).map(([key, value]) => {
if (complexOptions.includes(key)) return [];
if (value.def.innerType instanceof z.ZodObject) return [];
const meta = value.meta();
const { innerType } = value.def;
const isBoolean = innerType instanceof z.ZodBoolean;
const isNumber = innerType instanceof z.ZodNumber;
const yargType = innerType instanceof z.ZodArray && "array" || isNumber && "number" || isBoolean && "boolean" || "string";
const options = {
default: value.def.defaultValue,
type: yargType
};
if (meta?.alias) options.alias = meta.alias;
if (meta?.description) options.description = meta.description;
return [key, options];
});
return Object.fromEntries(entries);
}
//#endregion
//#region src/log.ts
const error = debug("autoreadme:error");
const info = debug("autoreadme:info");
const warn = debug("autoreadme:warn");
function ERROR(...rest) {
const [first, ...remaining] = rest;
error(`${first} %O`, ...remaining);
}
function INFO(...rest) {
const [first, ...remaining] = rest;
info(`${first} %O`, ...remaining);
}
function WARN(...rest) {
const [first, ...remaining] = rest;
warn(`${first} %O`, ...remaining);
}
//#endregion
//#region src/comment.ts
const SEPARATOR = "-";
function loadAstComments(root) {
return root.children.map((child) => child.type === "html" && getComment(child)).filter((f) => f !== false);
}
function parseComment(comment) {
const [type, ...parameters] = trimComment(comment).split(" ");
const [first, second, third] = type.split(SEPARATOR);
INFO("parsing inputs", {
first,
second,
third
});
const languageInput = third ? first : void 0;
const actionInput = third ? second : first;
const formatInput = third ? third : second;
const language = languageSchema.parse(languageInput);
const parsed = {
action: actionsSchema.parse(actionInput),
format: formatsSchema.parse(formatInput),
isStart: comment.includes("start"),
language,
parameters
};
INFO(`Parsed comment ${comment}`, parsed);
return parsed;
}
const startComment = "<!--";
const endComment = "-->";
function trimComment(comment) {
return comment.replace(startComment, "").replace(/start|end/, "").replace(endComment, "").trim();
}
function getComment(comment) {
if (!isComment(comment.value)) return false;
if (!commentMarker(comment)) return false;
return parseComment(comment.value);
}
function isComment(comment) {
return comment.startsWith(startComment) && comment.endsWith(endComment);
}
//#endregion
//#region src/config.ts
const moduleName = "autoreadme";
const searchPlaces = getSearchPlaces();
const loaders = { [".toml"]: loadToml };
async function loadConfig(args$1) {
const opts$1 = {
loaders,
searchPlaces
};
if (args$1.config) opts$1.searchPlaces = [args$1.config];
const search = await cosmiconfig(moduleName, opts$1).search();
if (!search) {
WARN(`no config file found`, args$1.config ? " at location: " + args$1.config : "");
INFO("using default configuration");
} else {
INFO("found configuration file at: ", search.filepath);
INFO("loaded cosmiconfig", search.config);
}
args$1 = removeFalsy(args$1);
INFO("merging config with args", args$1);
return configSchema.parse(deepmerge(search?.config || {}, args$1, { arrayMerge: (_, sourceArray) => sourceArray }));
}
function loadToml(_filepath, content) {
return toml.parse(content);
}
function getSearchPlaces() {
return [
...getDefaultSearchPlaces(moduleName),
`.${moduleName}rc.toml`,
`.config/.${moduleName}rc`,
`.config/${moduleName}rc.toml`,
`.config/.${moduleName}rc.toml`,
`.config/.${moduleName}rc.json`,
`.config/.${moduleName}rc.yaml`,
`.config/.${moduleName}rc.yml`
];
}
function removeFalsy(obj) {
return Object.fromEntries(Object.entries(obj).map(([k, v]) => !v ? false : [k, v]).filter((e) => Boolean(e)));
}
//#endregion
//#region src/utils.ts
const sh = String.raw;
const opts = { encoding: "utf8" };
const ignore = ["**/node_modules/**"];
const matches = [
/.*README\.md$/gi,
/.*Cargo\.toml$/gi,
/.*action\.ya?ml$/gi,
/.*package\.json$/gi,
/.*pnpm-workspace\.yaml$/gi
];
async function fileExists(file) {
return await fsp.access(file).then(() => true).catch(() => false);
}
function findAffectedMarkdowns(root, config) {
const affected = cp.execSync(sh`git diff --cached --name-only --diff-filter=MACT`, opts).trim().split("\n").filter(Boolean);
if (!affected.length) ERROR("no staged files found");
if (config.affectedRegexes?.length) INFO("adding the following expressions: ", config.affectedRegexes);
const allMatches = [...matches, ...config.affectedRegexes?.map((r) => new RegExp(r)) || []];
INFO("Checking affected files against regexes", affected, allMatches);
const eligible = affected.filter((a) => allMatches.some((m) => a.match(m)));
INFO("Found the following eligible affected files", eligible);
const md = eligible.map((e) => findNearestReadme(root, path$1.resolve(e)));
const rootMd = path$1.join(root, "README.md");
const dedupe = [...new Set(md), rootMd].filter((s) => Boolean(s));
INFO("Found the following readmes", dedupe);
return dedupe;
}
function getGitRoot() {
const root = cp.execSync(sh`git rev-parse --show-toplevel`, opts).trim();
if (!root) throw new Error("must be ran within a git directory.");
INFO("found git root at location: ", root);
return root;
}
async function getMarkdownPaths(cwd, config) {
return (await glob(`**/${config?.onlyReadmes ? "README" : "*"}.md`, {
cwd,
ignore
})).map((readme) => path$1.resolve(cwd, readme));
}
async function getPrettierPaths(paths) {
return await Promise.all(paths.map(async (file) => {
if (!(await fsp.lstat(file)).isSymbolicLink()) return file;
const symlink = await fsp.readlink(file);
return path$1.join(path$1.dirname(file), symlink);
}));
}
function findNearestReadme(gitRoot, inputFile, maxRotations = 15) {
let dir = path$1.dirname(inputFile);
let rotations = 0;
while (true) {
const option = path$1.join(dir, "README.md");
if (fs.existsSync(option)) return option;
const parent = path$1.dirname(dir);
if (parent === dir || dir === gitRoot || ++rotations > maxRotations) break;
dir = parent;
}
return null;
}
//#endregion
//#region src/data.ts
function createFindParameter(parameterList) {
return function(parameterName) {
return parameterList?.find((p) => p.startsWith(parameterName))?.replace(parameterName + "=", "")?.replace(/"/gi, "")?.replace(/_/gi, " ");
};
}
async function loadActionData(actions, file, root) {
const startActions = actions.filter((action) => action.isStart);
return await Promise.all(startActions.map(async (action) => {
const find = createFindParameter(action.parameters);
switch (action.action) {
case "ACTION": {
const actionYaml = await loadActionYaml(path$1.dirname(file));
return {
action: action.action,
actionYaml,
parameters: action.parameters
};
}
case "PKG": {
const inputPath = find("path");
const pkgJson = await readPackageJSON(inputPath ? path$1.resolve(path$1.dirname(file), inputPath) : path$1.dirname(file));
return {
action: action.action,
parameters: action.parameters,
pkgJson
};
}
case "USAGE": return {
action: action.action,
parameters: action.parameters
};
case "WORKSPACE": {
const workspaces = await getPackages(process.cwd());
const pnpmPath = path$1.resolve(root, "pnpm-workspace.yaml");
const isPnpm = fs.existsSync(pnpmPath);
return {
action: action.action,
isPnpm,
parameters: action.parameters,
root,
workspaces
};
}
case "ZOD": {
if (action.format === "LIST") throw new Error("cannot display zod in list format");
const inputPath = find("path");
if (!inputPath) {
const error$1 = `no path found for zod table at markdown file ${file}`;
throw new Error(error$1);
}
const body = await zod2md({
entry: path$1.resolve(path$1.dirname(file), inputPath),
title: find("title") || "Zod Schema"
});
return {
action: action.action,
body,
parameters: action.parameters
};
}
default: throw new Error("feature not yet implemented");
}
}));
}
async function loadActionYaml(baseDir) {
const actionYmlPath = path$1.resolve(baseDir, "action.yml");
const actionYamlPath = path$1.resolve(baseDir, "action.yaml");
const actualPath = await fileExists(actionYamlPath) && actionYamlPath || await fileExists(actionYmlPath) && actionYmlPath;
if (!actualPath) {
const error$1 = `no yaml file found at locations: ${[actionYmlPath, actionYamlPath]}`;
throw new Error(error$1);
}
const actionFile = await fsp.readFile(actualPath, { encoding: "utf8" });
return yaml.parse(actionFile);
}
//#endregion
//#region src/plugin.ts
function createHeading(headings, disableEmojis = false, emojis = defaultTemplates.emojis) {
return headings.map((h) => `${disableEmojis ? "" : emojis[h] + " "}${h?.at(0)?.toUpperCase() + h?.slice(1)}`);
}
function wrapRequired(required, input) {
if (!required) return input;
return `<b>*${input}</b>`;
}
const autoReadmeRemarkPlugin = (config, data) => (tree) => {
zone(tree, /.*ZOD.*/gi, function(start, _, end) {
const zod = data.find((d) => d?.action === "ZOD");
if (!zod?.body) throw new Error("unable to load zod body");
return [
start,
fromMarkdown(zod.body),
end
];
});
zone(tree, /.*ACTION.*/gi, function(start, _, end) {
const value = start.type === "html" && start.value;
const options = value && parseComment(value);
if (!options) throw new Error("not able to parse comment");
const inputs = data.find((d) => d?.action === "ACTION")?.actionYaml?.inputs || {};
const heading = `### ${config.disableEmojis ? "" : "🧰"} actions`;
if (options.format === "LIST") return [
start,
fromMarkdown(`${heading}\n` + Object.entries(inputs).sort((a) => a[1].required ? -1 : 1).map(([key, value$1]) => {
return `- ${wrapRequired(value$1.required, key)}: (default: ${value$1.default})\n\n${value$1.description}`;
}).join("\n")),
end
];
const headings = config.headings?.ACTION?.length && config.headings.ACTION || defaultTableHeadings.ACTION;
return [
start,
fromMarkdown([
heading,
"",
markdownTable([createHeading(headings, config.disableEmojis, config.templates?.emojis), ...Object.entries(inputs).map(([k, v]) => headings.map((heading$1) => v[heading$1] || k).map(String))])
].join("\n")),
end
];
});
zone(tree, /.*WORKSPACE.*/gi, function(start, _, end) {
const value = start.type === "html" && start.value;
const comment = value && parseComment(value);
const workspace = data.find((d) => d?.action === "WORKSPACE");
const templates = loadTemplates(config.templates);
const packages = workspace?.workspaces?.packages || [];
const headings = config.headings?.WORKSPACE?.length && config.headings?.WORKSPACE || defaultTableHeadings.WORKSPACE;
if (comment && comment.format === "LIST") {}
const table = markdownTable([createHeading(headings, config.disableEmojis, config.templates?.emojis), ...packages.filter((pkg) => config.onlyShowPublicPackages ? !pkg.packageJson.private : true).map((pkg) => {
const { name } = pkg.packageJson;
return headings.map((heading) => {
if (heading === "name") return `[${config.removeScope ? name.replace(config.removeScope, "") : name}](${path.relative(process.cwd(), path.resolve(pkg.dir, "README.md"))})`;
if (heading === "version") return ` })})`;
if (heading === "downloads") return `})`;
if (heading === "description") return pkg.packageJson?.description;
return ``;
});
})]);
return [
start,
fromMarkdown([
`### ${config.disableEmojis ? "" : "🏭"} workspace`,
"",
table
].join("\n")),
end
];
});
zone(tree, /.*PKG.*/gi, function(start, _, end) {
const value = start.type === "html" && start.value;
const comment = value && parseComment(value);
const first = data.find((d) => d?.action === "PKG");
const templates = loadTemplates(config.templates);
const headings = config.headings?.PKG?.length && config.headings?.PKG || defaultTableHeadings.PKG;
if (comment && comment.format === "LIST") return [
start,
fromMarkdown(""),
end
];
function mapDependencies(isDev) {
return function([name, version]) {
const url = templates.registryUrl({ name });
return headings.map((key) => {
if (key === "devDependency") {
if (config.disableEmojis) return `\`${isDev}\``;
return `${isDev ? "⌨️" : "👥"}`;
}
if (key === "name") return `[${name}](${url})`;
if (key === "version") {
if ([
"workspace",
"catalog",
"*"
].some((type) => version.includes(type))) return `\`${version}\``;
return ` })})`;
}
});
};
}
const { dependencies = {}, devDependencies = {} } = first?.pkgJson || {};
const table = markdownTable([
createHeading(headings, config.disableEmojis, config.templates?.emojis),
...Object.entries(devDependencies).map(mapDependencies(true)),
...Object.entries(dependencies).map(mapDependencies(false))
]);
return [
start,
fromMarkdown([
`### ${config.disableEmojis ? "" : "📦"} packages`,
"",
table
].join("\n")),
end
];
});
};
function loadTemplates(templates) {
if (!templates) throw new Error("failed to load templates");
return Object.fromEntries(Object.entries(templates).map(([key, value]) => {
if (typeof value !== "string") return [];
return [key, Handlebars.compile(value)];
}));
}
//#endregion
//#region src/pipeline.ts
async function parse(file, filepath, root, config, data) {
const pipeline = remark().use(autoReadmeRemarkPlugin, config, data).use(remarkCodeImport, {});
const usage = data.find((d) => d.action === "USAGE");
if (usage?.action === "USAGE" || config.enableUsage) {
const examplePath = createFindParameter(usage?.parameters || [])("path");
const dirname = path$1.dirname(filepath);
const resolvePath = examplePath && path$1.resolve(dirname, examplePath);
const relativeProjectPath = config.usageFile && path$1.relative(root, path$1.resolve(dirname, config.usageFile));
const example = examplePath && resolvePath && path$1.relative(root, resolvePath) || relativeProjectPath || void 0;
if (example && await fileExists(example)) {
INFO("generating usage section");
pipeline.use(remarkUsage, {
example,
heading: config.usageHeading
});
} else WARN("not able to find example file for readme", filepath, example);
}
if (config.enableToc) {
INFO("generating table of contents section");
pipeline.use(remarkToc, { heading: config.tocHeading });
}
if (config.enableToc || config.collapseHeadings?.length) {
const headings = [...config.collapseHeadings?.length ? config.collapseHeadings : [], config.tocHeading];
pipeline.use(remarkCollapse, { test: {
ignoreFinalDefinitions: true,
test: (value, _) => {
return headings.some((i) => value.trim() === i?.trim());
}
} });
}
const vfile = new VFile({
path: path$1.resolve(filepath),
value: file
});
return (await pipeline.process(vfile)).toString();
}
//#endregion
//#region src/index.ts
async function run() {
const args$1 = await parseArgs();
const config = await loadConfig(args$1) || {};
INFO("Loaded the following configuration:", config);
const root = getGitRoot();
const isAffected = args$1.changes && "affected";
INFO(`Loading ${!isAffected ? "all " : "affected "}files`);
const paths = isAffected ? findAffectedMarkdowns(root, config) : await getMarkdownPaths(root, config);
INFO("Loaded the following files:", paths.join("\n"));
const type = args$1.onlyReadmes ? "readmes" : "all markdown files";
if (!paths.length) return ERROR(`no ${isAffected} readmes found to update`);
const spinner = !args$1.verbose && ora(`Updating ${type}`).start();
await Promise.all(paths.map(async (path$2) => {
const file = await fsp.readFile(path$2, { encoding: "utf8" });
const actions = (() => {
return loadAstComments(fromMarkdown(file));
})();
if (!actions.length) {
WARN(`no action comments found in`, path$2);
if (!config.enableUsage || !config.enableToc) return ERROR("no action or plugins found");
else INFO("plugins enabled. continuing parsing", path$2);
}
const data = await loadActionData(actions, path$2, root);
INFO("Loaded comment action data", data);
const content = await parse(file, path$2, root, config, data);
await fsp.writeFile(path$2, content);
}));
const opts$1 = { stdio: "inherit" };
if (config.enablePrettier) {
INFO("formatting with prettier");
const prettierPaths = await getPrettierPaths(paths);
cp.execFileSync("prettier", ["--write", ...prettierPaths], opts$1);
}
if (isAffected) {
INFO("adding affected files to git stage");
cp.execFileSync("git", ["add", ...paths], opts$1);
}
if (spinner) spinner.stop();
}
//#endregion
export { run };