Skip to content
Rezha Julio
Go back

Automating My Blog Workflow with Bun

10 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. Sometimes the best way to solve a problem is to automate it away completely.

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,
};
}

This function parses command-line arguments manually. It processes process.argv (all command-line arguments) starting from index 2 (skipping “node” and the script name). It looks for specific flags like -t or --title and --tags, and captures positional arguments (non-flag arguments) like the content type. I went with manual parsing to get fine-grained control over how arguments are handled.

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 function creates the actual content file. It takes a content type configuration and input data, then:

  1. Generates the filename using the type’s generateFilename function
  2. Creates the frontmatter using the type’s generateFrontmatter function
  3. Constructs the directory path by joining the current working directory with the type’s path
  4. Creates the directory if it doesn’t exist (with { recursive: true } to create parent directories too)
  5. Writes the frontmatter content to the file
  6. Returns the full path of the created file
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();
}

This function implements the interactive mode. It:

  1. Creates a readline interface for user input
  2. Prompts the user to select a content type
  3. Exits if no valid type was selected
  4. Displays a message indicating which type is being created
  5. Collects any required inputs based on the type’s prompts configuration
  6. Adds the current date/time to the input
  7. Creates the content file
  8. Displays success message and closes the readline interface
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}`);
}

This function handles the non-interactive mode (when arguments are provided). It:

  1. Looks up the content type by name (case-insensitive)
  2. Exits with an error if the type doesn’t exist
  3. Checks if the type requires a title and if one was provided
  4. Processes the tags by splitting them on commas and trimming whitespace
  5. Creates the content file with the provided data
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();

The main function orchestrates everything. It:

  1. Parses command-line arguments
  2. Shows help if requested
  3. Runs in non-interactive mode if a type was specified
  4. Falls back to interactive mode if no type was specified

Content Type Configuration (scripts/content-types.ts)

I put the configuration in content-types.ts. This is where the magic happens—defining how a “Post” differs from a “Note”. The configuration is extensible, so you can add new content types easily:

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[];
}

These TypeScript interfaces define the structure of our content types and related data:

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())}`
);
}

These utility functions handle common transformations:

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}
---
`;
},
};

The postType configuration defines how blog posts are created:

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
---
`;
},
};

The noteType configuration defines how quick notes are created:

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

These final exports make the content types available to the main script:

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

For those who prefer guided creation, the script also supports interactive mode:

Terminal window
bun run new

This will prompt you to select a content type and fill in the required fields step-by-step.

Advanced Features

The script includes several helpful features:

Validation and Best Practices

After generating a new post or note, always run the lint and build commands to catch any issues early:

Terminal window
bun run lint
bun run build

This ensures your content follows the project’s standards and builds successfully. The lint step checks for ESLint errors, while the build verifies everything compiles with Astro and Pagefind.

Extending the System

The modular design makes it easy to add new content types. For example, you could add a “snippet” type for code snippets or a “quote” type for daily quotes. Just define a new configuration object in content-types.ts with the appropriate path, filename generator, and frontmatter template.

What’s next?

Automation isn’t just about saving time. It’s about reducing cognitive load. The mental overhead of creating a new post just disappeared. If you’re running a static blog, stop copy-pasting your old posts. Write a script. Your future self will thank you. Trust me on this on. I’ve been there, staring at a blank markdown file, dreading the setup work more than the actual writing.


Share this post on:

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