import Path from "node:path"; import { parseArgs } from "node:util"; import EMDY from "@endeavorance/emdy"; import { marked } from "marked"; import defaultStylesheet from "./defaults/default-style.css" with { type: "text", }; import defaultTemplate from "./defaults/default-template.html" with { type: "text", }; import { CLIError } from "./error"; import type { BunFile } from "bun"; const DEFAULT_TEMPLATE_FILE = "_template.html"; const DEFAULT_STYLESHEET_FILE = "_style.css"; 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(); } function replacePlaceholders( 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; } interface CLIOptions { outfile: string | null; outdir: string | null; stdout: boolean; templateFilePath: string | null; stylesheetFilePath: string | null; help: boolean; title: string | null; cwd: string; } 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", }, cwd: { type: "string", default: process.cwd(), }, help: { type: "boolean", short: "h", }, }, allowPositionals: true, }); // Validation if (positionals.length > 1 && flags.outfile) { throw new CLIError("--outfile cannot be used with multiple inputs."); } 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, help: flags.help ?? false, title: flags.title ?? null, }, args: positionals, }; } async function loadTemplate(options: CLIOptions): Promise { if (options.templateFilePath) { return readFile(options.templateFilePath); } const defaultTemplateFilePath = Path.join(options.cwd, DEFAULT_TEMPLATE_FILE); const defaultTemplateFile = Bun.file(defaultTemplateFilePath); if (await defaultTemplateFile.exists()) { return defaultTemplateFile.text(); } return defaultTemplate; } async function loadStylesheet(options: CLIOptions): Promise { if (options.stylesheetFilePath) { return readFile(options.stylesheetFilePath); } const defaultStylesheetFilePath = Path.join( options.cwd, DEFAULT_STYLESHEET_FILE, ); const defaultStylesheetFile = Bun.file(defaultStylesheetFilePath); if (await defaultStylesheetFile.exists()) { return defaultStylesheetFile.text(); } return defaultStylesheet; } 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 marked.parse(content, { gfm: true, }); const title = [options.title, props.title].find((t) => t) || "Untitled"; const replacers = { content: html, title, stylesheet: stylesheet, timestamp: new Date().toISOString(), datetime: new Date().toLocaleString(), ...props, }; const output = replacePlaceholders(template, replacers); Bun.write(outfile, output); return output; } 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); } 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); } 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) --title, -T Document title override --help, -h Show this help message `.trim(), ); process.exit(0); } // stdin mode if (args.length === 0) { const outfile = options.outfile ? Bun.file(options.outfile) : Bun.stdout; await buildFile(Bun.stdin, outfile, options); process.exit(0); } // file mode for (const arg of args) { const file = Bun.file(arg); // Ensure input files exist if (!(await file.exists())) { throw new CLIError(`File ${arg} does not exist.`); } const outfile = await getOutputFile(arg, options); await buildFile(file, outfile, options); } } try { await main(); } catch (error) { if (error instanceof CLIError) { console.error(error.message); process.exit(1); } throw error; }