Skip to content
Rezha Julio
Go back

Automating My Blog Workflow with Bun

7 min read

I have a confession: I hate writing frontmatter.

That metadata block at the top of every markdown file? The date, the title, the slug, the tags. It’s friction. And friction kills writing habits dead.

Every time I wanted to write a quick note, I had to:

  1. Copy an old post
  2. Delete the content
  3. Manually type out the ISO 8601 date (who remembers the current time in UTC?)
  4. Invent a slug

By the time I finished the setup, I’d lost the thought entirely.

So, in classic developer fashion, I wrote a script to save myself 30 seconds per post. Honestly? It was worth it.

Why Bun?

I recently migrated this blog to Astro. It’s fast, modern, and runs on JavaScript/TypeScript.

I didn’t want a heavy CMS. I just wanted a simple CLI command:

Terminal window
bun run new note

I went with Bun because it treats TypeScript as a first-class citizen. No build steps, no ts-node configuration hell. You just write .ts and run it. Plus, it’s incredibly fast for CLI scripts.

The script architecture

The goal was simple: a script that asks “What do you want to write?” and generates the file for me.

I split this into two files:

  1. scripts/new-content.ts - The main CLI logic
  2. scripts/content-types.ts - Configuration for different content types

Main script (scripts/new-content.ts)

Here’s the core of the implementation. It handles both CLI arguments (for speed) and interactive prompts (for when I’m feeling lazy):

import { contentTypes, getContentType } from "./content-types";
import type {
ContentInput,
ContentTypeConfig,
PromptConfig,
} from "./content-types";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
function parseCliArgs() {
const args = process.argv.slice(2);
const result: {
type?: string;
title?: string;
tags?: string;
help?: boolean;
} = {};
const positionals: string[] = [];
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "-h" || arg === "--help") {
result.help = true;
} else if (arg === "-t" || arg === "--title") {
result.title = args[++i];
} else if (arg === "--tags") {
result.tags = args[++i];
} else if (!arg.startsWith("-")) {
positionals.push(arg);
}
}
return {
type: positionals[0],
title: result.title,
tags: result.tags,
help: result.help,
};
}

Manual parsing because I wanted fine-grained control over flags like -t and --tags.

async function createContent(
type: ContentTypeConfig,
input: ContentInput
): Promise<string> {
const filename = type.generateFilename(input);
const frontmatter = type.generateFrontmatter(input);
const dirPath = join(process.cwd(), type.path);
const filePath = join(dirPath, filename);
await mkdir(dirPath, { recursive: true });
await writeFile(filePath, frontmatter);
return filePath;
}

This writes the file. The { recursive: true } flag creates parent directories if needed.

async function runInteractive() {
const rl = await createReadlineInterface();
const type = await selectContentType(rl);
if (!type) {
rl.close();
process.exit(1);
}
console.log(`\nCreating new ${type.name}...\n`);
const promptInputs = await collectPromptInputs(rl, type.prompts || []);
const input: ContentInput = {
...promptInputs,
now: new Date(),
};
const filePath = await createContent(type, input);
console.log(`\n✅ Created: ${filePath}\n`);
rl.close();
}

Interactive mode. Prompts you to pick a content type and fill in the fields.

async function runNonInteractive(
typeName: string,
title?: string,
tags?: string
) {
const type = getContentType(typeName.toLowerCase());
if (!type) {
console.error(`Unknown content type: ${typeName}`);
console.log(`Available types: ${contentTypes.map(t => t.name).join(", ")}`);
process.exit(1);
}
const hasRequiredTitle = type.prompts?.some(
p => p.key === "title" && p.required
);
if (hasRequiredTitle && !title) {
console.error(`Error: --title is required for ${type.name}`);
process.exit(1);
}
const input: ContentInput = {
title,
tags: tags
? tags
.split(",")
.map(t => t.trim())
.filter(Boolean)
: undefined,
now: new Date(),
};
const filePath = await createContent(type, input);
console.log(`✅ Created: ${filePath}`);
}

Non-interactive mode. If you pass arguments directly, it skips the prompts.

async function main() {
const args = parseCliArgs();
if (args.help) {
showHelp();
process.exit(0);
}
if (args.type) {
await runNonInteractive(args.type, args.title, args.tags);
} else {
await runInteractive();
}
}
main();

Entry point. If you pass a type, it runs non-interactive. Otherwise, it prompts.

Content type configuration (scripts/content-types.ts)

This is where a “Post” differs from a “Note”:

export interface ContentTypeConfig {
name: string;
description: string;
path: string;
generateFilename: (input: ContentInput) => string;
generateFrontmatter: (input: ContentInput) => string;
prompts?: PromptConfig[];
}
export interface ContentInput {
title?: string;
slug?: string;
tags?: string[];
now: Date;
}
export interface PromptConfig {
key: keyof ContentInput;
message: string;
required: boolean;
transform?: (value: string) => string | string[];
}

The interfaces are self-explanatory. ContentTypeConfig tells the script where to save files and how to generate the frontmatter.

function toKebabCase(str: string): string {
return str
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
function formatISOWithTimezone(date: Date): string {
const pad = (n: number) => n.toString().padStart(2, "0");
const offsetMinutes = -date.getTimezoneOffset();
const offsetHours = Math.floor(Math.abs(offsetMinutes) / 60);
const offsetMins = Math.abs(offsetMinutes) % 60;
const offsetSign = offsetMinutes >= 0 ? "+" : "-";
return (
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
`T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` +
`${offsetSign}${pad(offsetHours)}:${pad(offsetMins)}`
);
}
function formatHumanReadableDate(date: Date): string {
const day = date.getDate();
const month = date.toLocaleString("en-US", { month: "long" });
const year = date.getFullYear();
const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, "0");
return `${day} ${month} ${year} at ${hours}:${minutes}`;
}
function formatTimestampFilename(date: Date): string {
const pad = (n: number) => n.toString().padStart(2, "0");
return (
`${date.getFullYear()}` +
`${pad(date.getMonth() + 1)}` +
`${pad(date.getDate())}` +
`${pad(date.getHours())}` +
`${pad(date.getMinutes())}`
);
}

Utility functions for slugs and date formatting. The ISO format with timezone is what Astro expects.

const postType: ContentTypeConfig = {
name: "post",
description: "Blog post with title and tags",
path: "src/data/blog",
prompts: [
{
key: "title",
message: "Post title:",
required: true,
},
{
key: "tags",
message: "Tags (comma-separated, optional):",
required: false,
transform: (value: string) =>
value
.split(",")
.map(t => t.trim())
.filter(Boolean),
},
],
generateFilename: (input: ContentInput) => {
const slug = input.slug || toKebabCase(input.title || "untitled");
return `${slug}.md`;
},
generateFrontmatter: (input: ContentInput) => {
const slug = input.slug || toKebabCase(input.title || "untitled");
const pubDatetime = formatISOWithTimezone(input.now);
const tags = input.tags?.length ? input.tags : [];
const tagsYaml =
tags.length > 0
? `tags:\n${tags.map(t => ` - ${t}`).join("\n")}`
: "tags: []";
return `---
title: "${input.title || "Untitled"}"
slug: "${slug}"
pubDatetime: ${pubDatetime}
description: ""
draft: true
${tagsYaml}
---
`;
},
};

Posts go to src/data/blog, require a title, and optionally accept tags.

const noteType: ContentTypeConfig = {
name: "note",
description: "Quick note with timestamp-based naming",
path: "src/data/notes",
prompts: [],
generateFilename: (input: ContentInput) => {
return `${formatTimestampFilename(input.now)}.md`;
},
generateFrontmatter: (input: ContentInput) => {
const timestamp = formatTimestampFilename(input.now);
const humanTitle = formatHumanReadableDate(input.now);
const pubDatetime = formatISOWithTimezone(input.now);
return `---
title: "${humanTitle}"
slug: "${timestamp}"
pubDatetime: ${pubDatetime}
description: ""
tags:
- note
---
`;
},
};

Notes have no prompts. They just use the current timestamp as filename and title.

export const contentTypes: ContentTypeConfig[] = [postType, noteType];
export function getContentType(name: string): ContentTypeConfig | undefined {
return contentTypes.find(t => t.name === name);
}

Export the types so the main script can use them.

The “note” workflow

My favorite part is the Note generator. Notes on this blog are time-based, like tweets. I don’t want to think about titles.

The script automatically:

  1. Generates a filename based on the current timestamp (e.g., 202601231830.md)
  2. Sets the title to a human-readable format (“23 January 2026 at 18:30”)
  3. Sets the slug to the timestamp

Now, capturing a thought is as fast as opening my terminal.

Interactive mode

If you just run bun run new without arguments, it prompts you step-by-step.

Adding new content types

Want a “snippet” or “quote” type? Add a new config object in content-types.ts with path, filename generator, and frontmatter template. Done.

Why bother?

The point isn’t saving 30 seconds. It’s removing the friction that stops you from writing in the first place. I used to stare at blank markdown files, dreading the frontmatter setup more than the actual writing.

Now I type bun run new note and start writing.


Related Posts


Previous Post
Pair Programming with a Lobster: My Week with Clawdbot
Next Post
Unlocking Frontier Power: How to Run Amp Using CLIProxyAPI Without Spending Credits