Restructure files
This commit is contained in:
parent
459f144bef
commit
0dbba6f238
8 changed files with 375 additions and 319 deletions
|
@ -4,6 +4,10 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.3.1",
|
"version": "0.3.1",
|
||||||
|
"scripts": {
|
||||||
|
"build": ".run/build",
|
||||||
|
"fmt": "bunx --bun biome check --fix"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest"
|
"@types/bun": "latest"
|
||||||
},
|
},
|
||||||
|
|
42
src/build.ts
Normal file
42
src/build.ts
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import EMDY from "@endeavorance/emdy";
|
||||||
|
import type { BunFile } from "bun";
|
||||||
|
import { parseMarkdown } from "./markdown";
|
||||||
|
import type { CLIOptions } from "./options";
|
||||||
|
import { loadStylesheet } from "./stylesheet";
|
||||||
|
import { loadTemplate, renderTemplate } from "./template";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a file and return the rendered HTML string
|
||||||
|
* @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
|
||||||
|
*/
|
||||||
|
export async function buildFile(
|
||||||
|
infile: BunFile,
|
||||||
|
options: CLIOptions,
|
||||||
|
): Promise<string> {
|
||||||
|
// Load resources
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render markdown
|
||||||
|
const html = await parseMarkdown(content);
|
||||||
|
|
||||||
|
// Prepare data
|
||||||
|
const title = options.title ?? props.title ?? "Untitled";
|
||||||
|
const templateData = {
|
||||||
|
content: html,
|
||||||
|
title,
|
||||||
|
stylesheet: stylesheet,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
datetime: new Date().toLocaleString(),
|
||||||
|
...props,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render template
|
||||||
|
return renderTemplate(template, templateData);
|
||||||
|
}
|
77
src/file-util.ts
Normal file
77
src/file-util.ts
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
import Path from "node:path";
|
||||||
|
import type { BunFile } from "bun";
|
||||||
|
import { CLIError } from "./error";
|
||||||
|
import type { CLIOptions } from "./options";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a given path is a directory
|
||||||
|
*
|
||||||
|
* @param path - The path to check
|
||||||
|
* @returns A promise that resolves to true if the path is a directory, false otherwise
|
||||||
|
*/
|
||||||
|
export 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
|
||||||
|
*/
|
||||||
|
export 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
export 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);
|
||||||
|
}
|
334
src/index.ts
334
src/index.ts
|
@ -1,297 +1,18 @@
|
||||||
import Path from "node:path";
|
import { Glob } from "bun";
|
||||||
import { parseArgs } from "node:util";
|
import { buildFile } from "./build";
|
||||||
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 { CLIError } from "./error";
|
||||||
import { parseMarkdown } from "./markdown";
|
import { getOutputFile, isDirectory } from "./file-util";
|
||||||
import { watch } from "node:fs/promises";
|
import { type CLIOptions, USAGE, parseCLIArgs } from "./options";
|
||||||
|
|
||||||
const DEFAULT_TEMPLATE_FILE = "_template.html";
|
async function processStdin(options: CLIOptions): Promise<void> {
|
||||||
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;
|
const outfile = options.outfile ? Bun.file(options.outfile) : Bun.stdout;
|
||||||
return buildFile(Bun.stdin, outfile, options);
|
const renderedHTML = await buildFile(Bun.stdin, options);
|
||||||
|
await Bun.write(outfile, renderedHTML);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function processFile(
|
async function processFileAtPath(
|
||||||
options: CLIOptions,
|
|
||||||
filePath: string,
|
filePath: string,
|
||||||
|
options: CLIOptions,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
console.log(`Building ${filePath}...`);
|
console.log(`Building ${filePath}...`);
|
||||||
const file = Bun.file(filePath);
|
const file = Bun.file(filePath);
|
||||||
|
@ -302,34 +23,16 @@ async function processFile(
|
||||||
}
|
}
|
||||||
|
|
||||||
const outfile = await getOutputFile(filePath, options);
|
const outfile = await getOutputFile(filePath, options);
|
||||||
await buildFile(file, outfile, options);
|
const renderedHTML = await buildFile(file, options);
|
||||||
|
await Bun.write(outfile, renderedHTML);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const { options, args } = parseCLIArgs();
|
const { options, args } = parseCLIArgs();
|
||||||
|
|
||||||
if (options.help) {
|
if (options.help) {
|
||||||
console.log(
|
console.log(USAGE);
|
||||||
`
|
return;
|
||||||
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
|
// stdin mode
|
||||||
|
@ -338,22 +41,23 @@ Options:
|
||||||
}
|
}
|
||||||
|
|
||||||
// file mode
|
// file mode
|
||||||
for (const arg of args) {
|
for (const inputPath of args) {
|
||||||
const isDir = await isDirectory(arg);
|
const isDir = await isDirectory(inputPath);
|
||||||
|
|
||||||
if (isDir) {
|
if (isDir) {
|
||||||
const dirGlob = new Glob(`${arg}/**/*.md`);
|
const dirGlob = new Glob(`${inputPath}/**/*.md`);
|
||||||
for await (const file of dirGlob.scan()) {
|
for await (const file of dirGlob.scan()) {
|
||||||
await processFile(options, file);
|
await processFileAtPath(file, options);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await processFile(options, arg);
|
await processFileAtPath(inputPath, options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await main();
|
await main();
|
||||||
|
process.exit(0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof CLIError) {
|
if (error instanceof CLIError) {
|
||||||
console.error(error.message);
|
console.error(error.message);
|
||||||
|
|
|
@ -1,22 +1,44 @@
|
||||||
|
import rehypeCallouts from "rehype-callouts";
|
||||||
import rehypeRaw from "rehype-raw";
|
import rehypeRaw from "rehype-raw";
|
||||||
import remarkGfm from "remark-gfm";
|
import rehypeSlug from "rehype-slug";
|
||||||
import rehypeStringify from "rehype-stringify";
|
import rehypeStringify from "rehype-stringify";
|
||||||
|
import { remarkHeadingId } from "remark-custom-heading-id";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
import remarkParse from "remark-parse";
|
import remarkParse from "remark-parse";
|
||||||
import remarkRehype from "remark-rehype";
|
import remarkRehype from "remark-rehype";
|
||||||
import rehypeSlug from "rehype-slug";
|
|
||||||
import { unified } from "unified";
|
import { unified } from "unified";
|
||||||
import rehypeCallouts from "rehype-callouts";
|
|
||||||
import { remarkHeadingId } from "remark-custom-heading-id";
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the provided markdown into an HTML string
|
||||||
|
*
|
||||||
|
* @param markdown - The markdown string to parse
|
||||||
|
* @returns The parsed HTML string in a promise
|
||||||
|
*/
|
||||||
export async function parseMarkdown(markdown: string): Promise<string> {
|
export async function parseMarkdown(markdown: string): Promise<string> {
|
||||||
const file = await unified()
|
const file = await unified()
|
||||||
|
|
||||||
|
// Parse markdown
|
||||||
.use(remarkParse)
|
.use(remarkParse)
|
||||||
|
|
||||||
|
// Support github-flavored markdown
|
||||||
.use(remarkGfm)
|
.use(remarkGfm)
|
||||||
|
|
||||||
|
// Accept custom heading ids: # My heading {#custom-id}
|
||||||
.use(remarkHeadingId)
|
.use(remarkHeadingId)
|
||||||
|
|
||||||
|
// Pass-thru HTML
|
||||||
.use(remarkRehype, { allowDangerousHtml: true })
|
.use(remarkRehype, { allowDangerousHtml: true })
|
||||||
|
|
||||||
|
// Process to html/html
|
||||||
.use(rehypeRaw)
|
.use(rehypeRaw)
|
||||||
|
|
||||||
|
// Apply heading IDs to headings without a custom ID
|
||||||
.use(rehypeSlug)
|
.use(rehypeSlug)
|
||||||
|
|
||||||
|
// Support callouts/admonitions
|
||||||
.use(rehypeCallouts)
|
.use(rehypeCallouts)
|
||||||
|
|
||||||
|
// Render to string
|
||||||
.use(rehypeStringify)
|
.use(rehypeStringify)
|
||||||
.process(markdown);
|
.process(markdown);
|
||||||
|
|
||||||
|
|
128
src/options.ts
Normal file
128
src/options.ts
Normal file
|
@ -0,0 +1,128 @@
|
||||||
|
import { parseArgs } from "node:util";
|
||||||
|
import { CLIError } from "./error";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A collection of options parsed from the command line arguments.
|
||||||
|
*/
|
||||||
|
export 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
|
||||||
|
*/
|
||||||
|
export 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const USAGE = `
|
||||||
|
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();
|
33
src/stylesheet.ts
Normal file
33
src/stylesheet.ts
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import Path from "node:path";
|
||||||
|
import DEFAULT_STYLESHEET from "./defaults/default-style.css" with {
|
||||||
|
type: "text",
|
||||||
|
};
|
||||||
|
import { readFile } from "./file-util";
|
||||||
|
import type { CLIOptions } from "./options";
|
||||||
|
|
||||||
|
const DEFAULT_STYLESHEET_FILE = "_style.css";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
export 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;
|
||||||
|
}
|
46
src/template.ts
Normal file
46
src/template.ts
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
import Path from "node:path";
|
||||||
|
import DEFAULT_TEMPLATE from "./defaults/default-template.html" with {
|
||||||
|
type: "text",
|
||||||
|
};
|
||||||
|
import { readFile } from "./file-util";
|
||||||
|
import type { CLIOptions } from "./options";
|
||||||
|
|
||||||
|
const DEFAULT_TEMPLATE_FILE = "_template.html";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
export 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;
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue