import Path from "node:path"; import { parseArgs } from "node:util"; import EMDY from "@endeavorance/emdy"; import { Glob, type BunFile } from "bun"; import DEFAULT_STYLESHEET from "./defaults/default-style.css" with { type: "text", }; import DEFAULT_TEMPLATE from "./defaults/default-template.html" with { type: "text", }; import { CLIError } from "./error"; import { parseMarkdown } from "./markdown"; import { watch } from "node:fs/promises"; const DEFAULT_TEMPLATE_FILE = "_template.html"; const DEFAULT_STYLESHEET_FILE = "_style.css"; async function isDirectory(path: string): Promise { const file = Bun.file(path); const stat = await file.stat(); return stat.isDirectory(); } /** * Given a file path, attempt to read the text of the file * @param filePath - The path to the file to read * @returns The text content of the file * * @throws CLIError if the file does not exist */ async function readFile(filePath: string): Promise { const file = Bun.file(filePath); const exists = await file.exists(); if (!exists) { throw new CLIError(`File ${filePath} does not exist.`); } return file.text(); } /** * Renders the provided template with the given data. * @param template - The template string containing placeholders like %key% * @param data - An object containing key-value pairs to replace in the template * @returns The rendered string with all placeholders replaced by their corresponding values */ function renderTemplate( template: string, data: Record, ): string { let output = template; for (const [key, value] of Object.entries(data)) { output = output.replace(new RegExp(`%${key}%`, "g"), String(value)); } return output; } /** * A collection of options parsed from the command line arguments. */ interface CLIOptions { /** A path to the output file */ outfile: string | null; /** A path to the output directory */ outdir: string | null; /** If true, force output to stdout */ stdout: boolean; /** The path to the template file */ templateFilePath: string | null; /** The path to the stylesheet file */ stylesheetFilePath: string | null; /** If the default stylesheet should be included regardless of the provided stylesheet */ includeDefaultStylesheet: boolean; /** If true, show help message */ help: boolean; /** If provided, overrides the document title */ title: string | null; /** If provided, overrides the current working directory (default: .) */ cwd: string; } /** * Parse the command line arguments and return the options and positional arguments. * @returns An object containing the parsed options and positional arguments */ function parseCLIArgs(): { options: CLIOptions; args: string[] } { const { values: flags, positionals } = parseArgs({ args: Bun.argv.slice(2), options: { outfile: { type: "string", short: "o", }, outdir: { type: "string", short: "d", }, stdout: { type: "boolean", }, template: { type: "string", short: "t", }, title: { type: "string", short: "T", }, stylesheet: { type: "string", short: "s", }, "include-default-stylesheet": { type: "boolean", short: "S", }, cwd: { type: "string", default: process.cwd(), }, help: { type: "boolean", short: "h", }, }, allowPositionals: true, }); // == Argument Validation == // Outfile requires only one argument if (flags.outfile && positionals.length > 1) { throw new CLIError("--outfile cannot be used with multiple inputs."); } // Outfile and Outdir cannot be used together if (flags.outdir && flags.outfile) { throw new CLIError("--outdir and --outfile cannot be used together."); } return { options: { cwd: flags.cwd, outfile: flags.outfile ?? null, outdir: flags.outdir ?? null, stdout: flags.stdout ?? false, templateFilePath: flags.template ?? null, stylesheetFilePath: flags.stylesheet ?? null, includeDefaultStylesheet: flags["include-default-stylesheet"] ?? false, help: flags.help ?? false, title: flags.title ?? null, }, args: positionals, }; } /** * Attempt to load a custom template, falling back to a default * @param options - The CLI options containing the template file path * @returns The template string */ async function loadTemplate(options: CLIOptions): Promise { if (options.templateFilePath) { return readFile(options.templateFilePath); } const checkTemplateFile = Bun.file( Path.join(options.cwd, DEFAULT_TEMPLATE_FILE), ); if (await checkTemplateFile.exists()) { return checkTemplateFile.text(); } return DEFAULT_TEMPLATE; } /** * Attempt to load a custom stylesheet, falling back to a default * @param options - The CLI options containing the stylesheet file path * @returns The stylesheet string */ async function loadStylesheet(options: CLIOptions): Promise { const preamble = options.includeDefaultStylesheet ? DEFAULT_STYLESHEET : ""; if (options.stylesheetFilePath) { const loadedSheet = await readFile(options.stylesheetFilePath); return [preamble, loadedSheet].join("\n"); } const checkStylesheetFile = Bun.file( Path.join(options.cwd, DEFAULT_STYLESHEET_FILE), ); if (await checkStylesheetFile.exists()) { const loadedSheet = await checkStylesheetFile.text(); return [preamble, loadedSheet].join("\n"); } return DEFAULT_STYLESHEET; } /** * Build and write a file * @param infile - The input file to read from * @param outfile - The output file to write to * @param options - The CLI options containing template and stylesheet paths */ async function buildFile( infile: BunFile, outfile: BunFile, options: CLIOptions, ): Promise { const input = await infile.text(); const template = await loadTemplate(options); const stylesheet = await loadStylesheet(options); const { content, ...props } = EMDY.parse(input) as Record & { content: string; }; const html = await parseMarkdown(content); const title = options.title ?? props.title ?? "Untitled"; const templateData = { content: html, title, stylesheet: stylesheet, timestamp: new Date().toISOString(), datetime: new Date().toLocaleString(), ...props, }; await Bun.write(outfile, renderTemplate(template, templateData)); } /** * Get the output path based on the input path and options * @param inputPath - The path of the input file * @param options - The CLI options containing output directory * @returns The resolved output path */ async function getOutputPath(inputPath: string, options: CLIOptions) { const inputDirname = Path.dirname(inputPath); const inputBasename = Path.basename(inputPath); const outputBasename = inputBasename.replace(/\.md$/, ".html"); if (options.outdir) { const dirPath = Path.relative(options.cwd, inputDirname); const outputPath = Path.join(options.outdir, dirPath, outputBasename); return Path.resolve(outputPath); } return Path.join(inputDirname, outputBasename); } /** * Get the output file based on the input path and options * @param inputPath - The path of the input file * @param options - The CLI options containing output file and directory * @returns The BunFile object representing the output file */ async function getOutputFile( inputPath: string, options: CLIOptions, ): Promise { // --stdout option -> Force stdout if (options.stdout) { return Bun.stdout; } // Exact outfile defined -> write to file if (options.outfile) { return Bun.file(options.outfile); } // Input path with --outdir -> calculate output path const outputPath = await getOutputPath(inputPath, options); return Bun.file(outputPath); } function processStdin(options: CLIOptions): Promise { const outfile = options.outfile ? Bun.file(options.outfile) : Bun.stdout; return buildFile(Bun.stdin, outfile, options); } async function processFile( options: CLIOptions, filePath: string, ): Promise { console.log(`Building ${filePath}...`); const file = Bun.file(filePath); // Ensure input files exist if (!(await file.exists())) { throw new CLIError(`File ${filePath} does not exist.`); } const outfile = await getOutputFile(filePath, options); await buildFile(file, outfile, options); } async function main() { const { options, args } = parseCLIArgs(); if (options.help) { console.log( ` buildmd - Build markdown files to html with templates and metadata Usage: buildmd file.md buildmd file.md -o output.html echo "some markdown" | buildmd Options: --outfile, -o Output path --outdir, -d Output directory --stdout Force output to stdout --template, -t Template path (default: _template.html) --stylesheet, -s Stylesheet path (default: _style.css) --include-default-stylesheet, -S Extend default CSS instead of overwriting --title, -T Document title override --help, -h Show this help message `.trim(), ); process.exit(0); } // stdin mode if (args.length === 0) { return processStdin(options); } // file mode for (const arg of args) { const isDir = await isDirectory(arg); if (isDir) { const dirGlob = new Glob(`${arg}/**/*.md`); for await (const file of dirGlob.scan()) { await processFile(options, file); } } else { await processFile(options, arg); } } } try { await main(); } catch (error) { if (error instanceof CLIError) { console.error(error.message); process.exit(1); } throw error; }