From 0dbba6f238c75872ae6ad4b08b7542f518ba5edd Mon Sep 17 00:00:00 2001 From: Endeavorance Date: Sat, 7 Jun 2025 13:13:18 -0400 Subject: [PATCH] Restructure files --- package.json | 4 + src/build.ts | 42 ++++++ src/file-util.ts | 77 +++++++++++ src/index.ts | 334 +++------------------------------------------- src/markdown.ts | 30 ++++- src/options.ts | 128 ++++++++++++++++++ src/stylesheet.ts | 33 +++++ src/template.ts | 46 +++++++ 8 files changed, 375 insertions(+), 319 deletions(-) create mode 100644 src/build.ts create mode 100644 src/file-util.ts create mode 100644 src/options.ts create mode 100644 src/stylesheet.ts create mode 100644 src/template.ts diff --git a/package.json b/package.json index 2d67dd2..b14fa37 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,10 @@ "type": "module", "private": true, "version": "0.3.1", + "scripts": { + "build": ".run/build", + "fmt": "bunx --bun biome check --fix" + }, "devDependencies": { "@types/bun": "latest" }, diff --git a/src/build.ts b/src/build.ts new file mode 100644 index 0000000..cb8d5c9 --- /dev/null +++ b/src/build.ts @@ -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 { + // 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 & { + 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); +} diff --git a/src/file-util.ts b/src/file-util.ts new file mode 100644 index 0000000..f4a0d78 --- /dev/null +++ b/src/file-util.ts @@ -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 { + 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 { + 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 { + // --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); +} diff --git a/src/index.ts b/src/index.ts index 3a6e9ba..e6bee9a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,297 +1,18 @@ -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 { Glob } from "bun"; +import { buildFile } from "./build"; import { CLIError } from "./error"; -import { parseMarkdown } from "./markdown"; -import { watch } from "node:fs/promises"; +import { getOutputFile, isDirectory } from "./file-util"; +import { type CLIOptions, USAGE, parseCLIArgs } from "./options"; -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 { +async function processStdin(options: CLIOptions): Promise { 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( - options: CLIOptions, +async function processFileAtPath( filePath: string, + options: CLIOptions, ): Promise { console.log(`Building ${filePath}...`); const file = Bun.file(filePath); @@ -302,34 +23,16 @@ async function processFile( } 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() { 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); + console.log(USAGE); + return; } // stdin mode @@ -338,22 +41,23 @@ Options: } // file mode - for (const arg of args) { - const isDir = await isDirectory(arg); + for (const inputPath of args) { + const isDir = await isDirectory(inputPath); if (isDir) { - const dirGlob = new Glob(`${arg}/**/*.md`); + const dirGlob = new Glob(`${inputPath}/**/*.md`); for await (const file of dirGlob.scan()) { - await processFile(options, file); + await processFileAtPath(file, options); } } else { - await processFile(options, arg); + await processFileAtPath(inputPath, options); } } } try { await main(); + process.exit(0); } catch (error) { if (error instanceof CLIError) { console.error(error.message); diff --git a/src/markdown.ts b/src/markdown.ts index 8d497e2..3e9aef2 100644 --- a/src/markdown.ts +++ b/src/markdown.ts @@ -1,22 +1,44 @@ +import rehypeCallouts from "rehype-callouts"; import rehypeRaw from "rehype-raw"; -import remarkGfm from "remark-gfm"; +import rehypeSlug from "rehype-slug"; import rehypeStringify from "rehype-stringify"; +import { remarkHeadingId } from "remark-custom-heading-id"; +import remarkGfm from "remark-gfm"; import remarkParse from "remark-parse"; import remarkRehype from "remark-rehype"; -import rehypeSlug from "rehype-slug"; 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 { const file = await unified() + + // Parse markdown .use(remarkParse) + + // Support github-flavored markdown .use(remarkGfm) + + // Accept custom heading ids: # My heading {#custom-id} .use(remarkHeadingId) + + // Pass-thru HTML .use(remarkRehype, { allowDangerousHtml: true }) + + // Process to html/html .use(rehypeRaw) + + // Apply heading IDs to headings without a custom ID .use(rehypeSlug) + + // Support callouts/admonitions .use(rehypeCallouts) + + // Render to string .use(rehypeStringify) .process(markdown); diff --git a/src/options.ts b/src/options.ts new file mode 100644 index 0000000..d0b6f3e --- /dev/null +++ b/src/options.ts @@ -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 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(); diff --git a/src/stylesheet.ts b/src/stylesheet.ts new file mode 100644 index 0000000..595bae3 --- /dev/null +++ b/src/stylesheet.ts @@ -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 { + 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; +} diff --git a/src/template.ts b/src/template.ts new file mode 100644 index 0000000..c340a1a --- /dev/null +++ b/src/template.ts @@ -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 { + 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 { + 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; +}