@stephansama/auto-readme
Version:
Generate lists and tables for your README automagically based on your repository and comments
691 lines (676 loc) • 24.9 kB
JavaScript
// src/index.ts
import { fromMarkdown as fromMarkdown2 } from "mdast-util-from-markdown";
import * as cp2 from "child_process";
import * as fsp3 from "fs/promises";
import ora from "ora";
// src/args.ts
import debug from "debug";
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
import z2 from "zod";
// src/schema.js
import { z } from "zod";
var actionsSchema = z.enum(["ACTION", "PKG", "USAGE", "WORKSPACE", "ZOD"]).describe("Comment action options");
var formatsSchema = z.enum(["LIST", "TABLE"]).default("TABLE").optional();
var languageSchema = z.enum(["JS", "RS"]).optional().default("JS");
var headingsSchema = z.enum([
"default",
"description",
"devDependency",
"downloads",
"name",
"private",
"required",
"version"
]).describe("Table heading options");
var tableHeadingsSchema = z.record(actionsSchema, headingsSchema.array().optional()).optional().describe("Table heading action configuration").default({
ACTION: ["name", "required", "default", "description"],
PKG: ["name", "version", "devDependency"],
WORKSPACE: ["name", "version", "downloads", "description"],
ZOD: []
});
var templatesSchema = z.object({
downloadImage: z.string().optional().default("https://img.shields.io/npm/dw/{{name}}?labelColor=211F1F"),
emojis: z.record(headingsSchema, z.string()).optional().describe("Table heading emojis used when enabled").default({
default: "\u2699\uFE0F",
description: "\u{1F4DD}",
devDependency: "\u{1F4BB}",
downloads: "\u{1F4E5}",
name: "\u{1F3F7}\uFE0F",
private: "\u{1F512}",
required: "",
version: ""
}),
registryUrl: z.string().optional().default("https://www.npmjs.com/package/{{name}}"),
versionImage: z.string().optional().default(
"https://img.shields.io/npm/v/{{uri_name}}?logo=npm&logoColor=red&color=211F1F&labelColor=211F1F"
)
});
var defaultTemplates = templatesSchema.parse({});
var defaultTableHeadings = tableHeadingsSchema.parse(void 0);
var _configSchema = z.object({
affectedRegexes: z.string().array().optional().default([]),
collapseHeadings: z.string().array().optional().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"
}),
enableToc: z.boolean().default(false).meta({
alias: "t",
description: "generate table of contents for readmes"
}),
enableUsage: z.boolean().optional().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().optional().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().optional().default("Table of contents").meta({
description: "Markdown heading used to generate table of contents"
}),
usageFile: z.string().optional().default("").meta({
description: "Workspace level usage file"
}),
usageHeading: z.string().optional().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"
})
});
var configSchema = _configSchema.optional();
// src/args.ts
var complexOptions = [
"affectedRegexes",
"collapseHeadings",
"headings",
"templates"
];
var 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 z2.ZodObject) return [];
const meta = value.meta();
const { innerType } = value.def;
const isBoolean = innerType instanceof z2.ZodBoolean;
const isNumber = innerType instanceof z2.ZodNumber;
const isArray = innerType instanceof z2.ZodArray;
const yargType = isArray && "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);
}
// src/comment.ts
import { commentMarker } from "mdast-comment-marker";
// src/log.ts
import debug2 from "debug";
var error = debug2("autoreadme:error");
var info = debug2("autoreadme:info");
var warn = debug2("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);
}
// src/comment.ts
var SEPARATOR = "-";
function loadAstComments(root) {
return root.children.map((child) => child.type === "html" && getComment(child)).filter((f) => f !== false);
}
function parseComment(comment) {
const input = trimComment(comment);
const [type, ...parameters] = input.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 action = actionsSchema.parse(actionInput);
const format = formatsSchema.parse(formatInput);
const isStart = comment.includes("start");
const parsed = { action, format, isStart, language, parameters };
INFO(`Parsed comment ${comment}`, parsed);
return parsed;
}
var startComment = "<!--";
var endComment = "-->";
function trimComment(comment) {
return comment.replace(startComment, "").replace(/start|end/, "").replace(endComment, "").trim();
}
function getComment(comment) {
if (!isComment(comment.value)) return false;
const marker = commentMarker(comment);
if (!marker) return false;
return parseComment(comment.value);
}
function isComment(comment) {
return comment.startsWith(startComment) && comment.endsWith(endComment);
}
// src/config.ts
import toml from "@iarna/toml";
import { cosmiconfig, getDefaultSearchPlaces } from "cosmiconfig";
import deepmerge from "deepmerge";
var moduleName = "autoreadme";
var searchPlaces = getSearchPlaces();
var loaders = { [".toml"]: loadToml };
async function loadConfig(args2) {
const opts2 = { loaders, searchPlaces };
if (args2.config) opts2.searchPlaces = [args2.config];
const explorer = cosmiconfig(moduleName, opts2);
const search = await explorer.search();
if (!search) {
const location = args2.config ? " at location: " + args2.config : "";
WARN(`no config file found`, location);
INFO("using default configuration");
} else {
INFO("found configuration file at: ", search.filepath);
INFO("loaded cosmiconfig", search.config);
}
args2 = removeFalsy(args2);
INFO("merging config with args", args2);
return configSchema.parse(
deepmerge(search?.config || {}, args2, {
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))
);
}
// src/data.ts
import { getPackages } from "@manypkg/get-packages";
import * as fs2 from "fs";
import * as fsp2 from "fs/promises";
import * as path2 from "path";
import { readPackageJSON } from "pkg-types";
import * as yaml from "yaml";
import { zod2md } from "zod2md";
// src/utils.ts
import glob from "fast-glob";
import * as cp from "child_process";
import * as fs from "fs";
import * as fsp from "fs/promises";
import * as path from "path";
var sh = String.raw;
var opts = { encoding: "utf8" };
var ignore = ["**/node_modules/**"];
var 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.resolve(e)));
const rootMd = path.join(root, "README.md");
const dedupe = [...new Set(md), rootMd].filter(
(s) => Boolean(s)
);
INFO("Found the following readmes", dedupe);
return dedupe;
}
function findNearestReadme(gitRoot, inputFile, maxRotations = 15) {
let dir = path.dirname(inputFile);
let rotations = 0;
while (true) {
const option = path.join(dir, "README.md");
if (fs.existsSync(option)) return option;
const parent = path.dirname(dir);
if (parent === dir || dir === gitRoot || ++rotations > maxRotations) {
break;
}
dir = parent;
}
return null;
}
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) {
const pattern = `**/${config?.onlyReadmes ? "README" : "*"}.md`;
const readmes = await glob(pattern, { cwd, ignore });
return readmes.map((readme) => path.resolve(cwd, readme));
}
// 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 baseDir = path2.dirname(file);
const actionYaml = await loadActionYaml(baseDir);
return {
action: action.action,
actionYaml,
parameters: action.parameters
};
}
case "PKG": {
const inputPath = find("path");
const filename = inputPath ? path2.resolve(path2.dirname(file), inputPath) : path2.dirname(file);
const pkgJson = await readPackageJSON(filename);
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 = path2.resolve(root, "pnpm-workspace.yaml");
const isPnpm = fs2.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 error2 = `no path found for zod table at markdown file ${file}`;
throw new Error(error2);
}
const body = await zod2md({
entry: path2.resolve(path2.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 = path2.resolve(baseDir, "action.yml");
const actionYamlPath = path2.resolve(baseDir, "action.yaml");
const actualPath = await fileExists(actionYamlPath) && actionYamlPath || await fileExists(actionYmlPath) && actionYmlPath;
if (!actualPath) {
const locations = [actionYmlPath, actionYamlPath];
const error2 = `no yaml file found at locations: ${locations}`;
throw new Error(error2);
}
const actionFile = await fsp2.readFile(actualPath, { encoding: "utf8" });
return yaml.parse(actionFile);
}
// src/pipeline.ts
import * as path4 from "path";
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";
// src/plugin.ts
import Handlebars from "handlebars";
import { markdownTable } from "markdown-table";
import { fromMarkdown } from "mdast-util-from-markdown";
import { zone } from "mdast-zone";
import path3 from "path";
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>`;
}
var 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");
}
const ast = fromMarkdown(zod.body);
return [start, ast, 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 first = data.find((d) => d?.action === "ACTION");
const inputs = first?.actionYaml?.inputs || {};
const heading = `### ${config.disableEmojis ? "" : "\u{1F9F0}"} actions`;
if (options.format === "LIST") {
const body2 = `${heading}
` + Object.entries(inputs).sort((a) => a[1].required ? -1 : 1).map(([key, value2]) => {
return `- ${wrapRequired(value2.required, key)}: (default: ${value2.default})
${value2.description}`;
}).join("\n");
const ast2 = fromMarkdown(body2);
return [start, ast2, end];
}
const headings = config.headings?.ACTION?.length && config.headings.ACTION || defaultTableHeadings.ACTION;
const table = markdownTable([
createHeading(
headings,
config.disableEmojis,
config.templates?.emojis
),
...Object.entries(inputs).map(
([k, v]) => headings.map((heading2) => v[heading2] || k).map(String)
)
]);
const body = [heading, "", table].join("\n");
const ast = fromMarkdown(body);
return [start, ast, 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 tableHeadings = createHeading(
headings,
config.disableEmojis,
config.templates?.emojis
);
const table = markdownTable([
tableHeadings,
...packages.filter(
(pkg) => config.onlyShowPublicPackages ? !pkg.packageJson.private : true
).map((pkg) => {
const { name } = pkg.packageJson;
return headings.map((heading2) => {
if (heading2 === "name") {
const scoped = config.removeScope ? name.replace(config.removeScope, "") : name;
return `[${scoped}](${path3.relative(
process.cwd(),
path3.resolve(pkg.dir, "README.md")
)})`;
}
if (heading2 === "version") {
return ` }
)})`;
}
if (heading2 === "downloads") {
return `})`;
}
if (heading2 === "description") {
return pkg.packageJson?.description;
}
return ``;
});
})
]);
const heading = `### ${config.disableEmojis ? "" : "\u{1F3ED}"} workspace`;
const body = [heading, "", table].join("\n");
const ast = fromMarkdown(body);
return [start, ast, 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") {
const ast = fromMarkdown("");
return [start, ast, 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 ? "\u2328\uFE0F" : "\u{1F465}"}`;
}
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))
]);
const heading = `### ${config.disableEmojis ? "" : "\u{1F4E6}"} packages`;
const body = [heading, "", table].join("\n");
const tableAst = fromMarkdown(body);
return [start, tableAst, 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)];
})
);
}
// src/pipeline.ts
async function parse2(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 find = createFindParameter(usage?.parameters || []);
const examplePath = find("path");
const dirname4 = path4.dirname(filepath);
const resolvePath = examplePath && path4.resolve(dirname4, examplePath);
const relativeProjectPath = config.usageFile && path4.relative(root, path4.resolve(dirname4, config.usageFile));
const example = examplePath && resolvePath && path4.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 additional = config.collapseHeadings?.length ? config.collapseHeadings : [];
const headings = [...additional, config.tocHeading];
pipeline.use(remarkCollapse, {
test: {
ignoreFinalDefinitions: true,
test: (value, _) => {
return headings.some((i) => value.trim() === i?.trim());
}
}
});
}
const vfile = new VFile({ path: path4.resolve(filepath), value: file });
const markdown = await pipeline.process(vfile);
return markdown.toString();
}
// src/index.ts
async function run() {
const args2 = await parseArgs();
const config = await loadConfig(args2) || {};
INFO("Loaded the following configuration:", config);
const root = getGitRoot();
const isAffected = args2.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 = args2.onlyReadmes ? "readmes" : "all markdown files";
if (!paths.length) {
return ERROR(`no ${isAffected} readmes found to update`);
}
const spinner = !args2.verbose && ora(`Updating ${type}`).start();
await Promise.all(
paths.map(async (path5) => {
const file = await fsp3.readFile(path5, { encoding: "utf8" });
const actions = (() => {
const ast = fromMarkdown2(file);
return loadAstComments(ast);
})();
if (!actions.length) {
WARN(`no action comments found in`, path5);
if (!config.enableUsage || !config.enableToc) {
return ERROR("no action or plugins found");
} else {
INFO("plugins enabled. continuing parsing", path5);
}
}
const data = await loadActionData(actions, path5, root);
INFO("Loaded comment action data", data);
const content = await parse2(file, path5, root, config, data);
await fsp3.writeFile(path5, content);
})
);
const opts2 = { stdio: "inherit" };
INFO("formatting with prettier");
cp2.execFileSync("prettier", ["--write", ...paths], opts2);
if (isAffected) {
INFO("adding affected files to git stage");
cp2.execFileSync("git", ["add", ...paths], opts2);
}
if (spinner) spinner.stop();
}
export {
run
};
//# sourceMappingURL=index.js.map