Add support for multiple inputs

This commit is contained in:
Endeavorance 2025-05-27 08:09:34 -04:00
parent e84b04449e
commit aff3fb81a1
3 changed files with 176 additions and 96 deletions

View file

@ -9,7 +9,7 @@ import defaultTemplate from "./defaults/default-template.html" with {
type: "text",
};
import { CLIError } from "./error";
import { mkdir } from "node:fs/promises";
import type { BunFile } from "bun";
const DEFAULT_TEMPLATE_FILE = "_template.html";
const DEFAULT_STYLESHEET_FILE = "_style.css";
@ -35,15 +35,17 @@ function replacePlaceholders(
}
interface CLIOptions {
infile: string | null;
outfile: string | null;
outdir: string | null;
stdout: boolean;
templateFilePath: string | null;
stylesheetFilePath: string | null;
forceStdout: boolean;
help: boolean;
title: string | null;
cwd: string;
}
function parseCLIArgs(): CLIOptions {
function parseCLIArgs(): { options: CLIOptions; args: string[] } {
const { values: flags, positionals } = parseArgs({
args: Bun.argv.slice(2),
options: {
@ -51,16 +53,28 @@ function parseCLIArgs(): CLIOptions {
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",
},
stdout: {
type: "boolean",
cwd: {
type: "string",
default: process.cwd(),
},
help: {
type: "boolean",
@ -70,20 +84,136 @@ function parseCLIArgs(): CLIOptions {
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 {
infile: positionals[0] ?? null,
outfile: flags.outfile ?? null,
templateFilePath: flags.template ?? null,
stylesheetFilePath: flags.stylesheet ?? null,
forceStdout: flags.stdout ?? false,
help: flags.help ?? false,
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 main() {
const args = parseCLIArgs();
async function loadTemplate(options: CLIOptions): Promise<string> {
if (options.templateFilePath) {
return readFile(options.templateFilePath);
}
if (args.help) {
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<string> {
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<string> {
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 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<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);
}
async function main() {
const { options, args } = parseCLIArgs();
if (options.help) {
console.log(
`
buildmd - Build markdown files to html with templates and metadata
@ -95,103 +225,44 @@ Usage:
Options:
--outfile, -o <file> Output file path
--outdir, -d <dir> Output directory path
--template, -t <file> Template file path
--stylesheet, -s <file> Stylesheet file path
--stdout Force output to stdout
--title, -T <string> Override title in template
--help, -h Show this help message
`.trim(),
);
process.exit(0);
}
// == INPUT ==
const infile = args.infile ? Bun.file(args.infile) : Bun.stdin;
// If using a file for input, it must exist
if (infile !== Bun.stdin && !(await infile.exists())) {
throw new CLIError(`Input file ${args.infile} does not exist.`);
// 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);
}
const input = await infile.text();
// file mode
for (const arg of args) {
const file = Bun.file(arg);
// == TEMPLATE ==
let template = defaultTemplate;
if (args.templateFilePath) {
template = await readFile(args.templateFilePath);
} else {
const defaultTemplateFilePath = Path.join(
process.cwd(),
DEFAULT_TEMPLATE_FILE,
);
const defaultTemplateFile = Bun.file(defaultTemplateFilePath);
if (await defaultTemplateFile.exists()) {
template = await defaultTemplateFile.text();
// 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);
}
// == STYLESHEET ==
let stylesheet = defaultStylesheet;
if (args.stylesheetFilePath) {
stylesheet = await readFile(args.stylesheetFilePath);
} else {
const defaultStylesheetFilePath = Path.join(
process.cwd(),
DEFAULT_STYLESHEET_FILE,
);
const defaultStylesheetFile = Bun.file(defaultStylesheetFilePath);
if (await defaultStylesheetFile.exists()) {
stylesheet = await defaultStylesheetFile.text();
}
}
// == RENDER ==
const { content, ...props } = EMDY.parse(input) as Record<string, unknown> & {
content: string;
};
const html = await marked.parse(content, {
gfm: true,
});
// == REPALCE ==
const replacers = {
content: html,
title: props.title
? props.title
: args.infile
? Path.basename(args.infile)
: "Untitled",
stylesheet: stylesheet,
...props,
};
// == OUTPUT ==
const output = replacePlaceholders(template, replacers);
if (args.forceStdout || (!args.infile && !args.outfile)) {
Bun.write(Bun.stdout, output);
return;
}
// Compute the output file path
const writeFilePath =
args.outfile ?? args.infile?.replace(/\.md$/, ".html") ?? "out.html";
// Ensure the output path exists
const writeFileDirname = Path.dirname(writeFilePath);
await mkdir(writeFileDirname, { recursive: true });
// Write the output to disk
await Bun.write(writeFilePath, output);
}
try {
await main();
} catch (error) {
console.error(error);
process.exit(1);
if (error instanceof CLIError) {
console.error(error.message);
process.exit(1);
}
throw error;
}