#!/usr/bin/env bun
// pb - render source files as syntax-highlighted HTML and upload to S3 or a remote host
//
// usage: pb -f|--file <file> [-l|--lang <language>] [--s3 | -d|--dest user@host:/path]
// example: pb -f main.go
// pb -f main.go --lang go --s3
// pb -f main.go --dest user@example.com:/var/www/html/
//
// s3 env vars (required when using --s3):
// AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION
// S3_BUCKET or AWS_BUCKET (bucket name)
// AWS_ENDPOINT_URL (optional, for S3-compatible stores)
import { S3Client } from "bun";
import { basename } from "node:path";
import { bundledLanguages, codeToHtml, type BundledLanguage } from "shiki";
import template from "./index.html" with { type: "text" };
type Upload = { kind: "none" } | { kind: "s3" } | { kind: "rsync"; dest: string };
type Options = {
language: BundledLanguage;
file: string;
upload: Upload;
};
function usage(exitCode = 0): never {
const command = basename(Bun.argv[1] ?? "pb");
const output = exitCode === 0 ? console.log : console.error;
output(`usage: ${command} -f|--file <file> [-l|--lang <language>] [--s3 | -d|--dest user@host:/path]
options:
-f, --file <file> Source file to render
-l, --lang <language> Shiki language override
--s3 Upload the generated HTML to S3 using env vars
-d, --dest <user@host:/path> Upload the generated HTML via rsync over SSH
-h, --help Show this help`);
process.exit(exitCode);
}
function parseArgs(argv: string[]): Options {
const options: Partial<Options> = { upload: { kind: "none" } };
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]!;
switch (arg) {
case "--help":
case "-h":
usage();
case "-f":
case "--file":
options.file = argv[++i];
break;
case "-l":
case "--lang":
options.language = parseLanguage(argv[++i]!);
break;
case "--s3":
if (options.upload?.kind === "rsync") {
console.error("--s3 and --dest are mutually exclusive");
usage(2);
}
options.upload = { kind: "s3" };
break;
case "--dest":
case "-d":
if (options.upload?.kind === "s3") {
console.error("--s3 and --dest are mutually exclusive");
usage(2);
}
options.upload = { kind: "rsync", dest: argv[++i]! };
break;
default:
console.error(`unknown option: ${arg}`);
usage(2);
}
}
if (!options.file) {
console.error("missing required option: -f");
usage(2);
}
options.language ??= detectLanguage(options.file);
return options as Options;
}
async function render(
template: string,
language: BundledLanguage,
file: string,
code: string,
): Promise<string> {
const highlighted = await codeToHtml(code, {
lang: language,
themes: { light: "vitesse-light", dark: "vitesse-black" },
transformers: [
{
line(node, line) {
node.properties ||= {};
node.properties.id = `L${line}`;
node.children.unshift({
type: "element",
tagName: "a",
properties: {
class: "line-number",
href: `#L${line}`,
ariaLabel: `Line ${line}`,
dataLine: String(line),
},
children: [],
});
},
},
],
});
return template
.replaceAll("{{title}}", basename(file))
.replaceAll("{{rawHref}}", encodeURIComponent(basename(file)))
.replaceAll("{{highlighted}}", highlighted);
}
function parseLanguage(language: string): BundledLanguage {
if (language in bundledLanguages) {
return language as BundledLanguage;
}
console.error(`unsupported language: ${language}`);
usage(2);
}
function detectLanguage(file: string): BundledLanguage {
const base = basename(file).toLowerCase();
const extension = base.split(".").at(-1);
const language = extension ? languageFromCandidate(extension) : undefined;
if (language) {
return language;
}
console.error(`could not detect language from file extension: ${file}`);
console.error("pass -l with a Shiki bundled language id");
usage(2);
}
function languageFromCandidate(candidate: string): BundledLanguage | undefined {
if (candidate in bundledLanguages) {
return candidate as BundledLanguage;
}
}
async function uploadToS3(inputFile: string, outputFile: string, html: string) {
const client = new S3Client();
await client.write(outputFile, html, { type: "text/html; charset=utf-8" });
await client.write(inputFile, Bun.file(inputFile));
console.log(`uploaded ${outputFile}`);
console.log(`uploaded ${inputFile}`);
}
async function uploadViaRsync(inputFile: string, outputFile: string, dest: string) {
const proc = Bun.spawn(["rsync", "-e", "ssh", outputFile, inputFile, dest], {
stdout: "inherit",
stderr: "inherit",
});
const code = await proc.exited;
if (code !== 0) {
console.error(`rsync exited with code ${code}`);
process.exit(code);
}
console.log(`uploaded ${outputFile} to ${dest}`);
console.log(`uploaded ${inputFile} to ${dest}`);
}
async function main() {
const options = parseArgs(Bun.argv.slice(2));
const code = await Bun.file(options.file).text();
const html = await render(String(template), options.language, options.file, code);
const outputFile = `${options.file}.html`;
await Bun.write(outputFile, html);
console.log(`wrote ${outputFile}`);
switch (options.upload.kind) {
case "s3":
await uploadToS3(options.file, outputFile, html);
break;
case "rsync":
await uploadViaRsync(options.file, outputFile, options.upload.dest);
break;
}
}
await main();