UNPKG

@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
// 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 `![npm version image](${templates.versionImage( { uri_name: encodeURIComponent(name) } )})`; } if (heading2 === "downloads") { return `![npm downloads](${templates.downloadImage( { name } )})`; } 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 `![npm version](${templates.versionImage({ uri_name: encodeURIComponent(name) })})`; } }); }; } 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