buildmd/src/index.ts

364 lines
9.7 KiB
TypeScript

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<boolean> {
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<string> {
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, unknown>,
): 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<string> {
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<string> {
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<void> {
const input = await infile.text();
const template = await loadTemplate(options);
const stylesheet = await loadStylesheet(options);
const { content, ...props } = EMDY.parse(input) as Record<string, unknown> & {
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<BunFile> {
// --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<void> {
const outfile = options.outfile ? Bun.file(options.outfile) : Bun.stdout;
return buildFile(Bun.stdin, outfile, options);
}
async function processFile(
options: CLIOptions,
filePath: string,
): Promise<void> {
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 <file> Output path
--outdir, -d <dir> Output directory
--stdout Force output to stdout
--template, -t <file> Template path (default: _template.html)
--stylesheet, -s <file> Stylesheet path (default: _style.css)
--include-default-stylesheet, -S Extend default CSS instead of overwriting
--title, -T <string> 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;
}