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:
- Copy an old post
- Delete the content
- Manually type out the ISO 8601 date (who remembers the current time in UTC?)
- 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:
bun run new noteI 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:
scripts/new-content.ts- The main CLI logicscripts/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:
- Generates the filename using the type’s
generateFilenamefunction - Creates the frontmatter using the type’s
generateFrontmatterfunction - Constructs the directory path by joining the current working directory with the type’s path
- Creates the directory if it doesn’t exist (with
{ recursive: true }to create parent directories too) - Writes the frontmatter content to the file
- 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:
- Creates a readline interface for user input
- Prompts the user to select a content type
- Exits if no valid type was selected
- Displays a message indicating which type is being created
- Collects any required inputs based on the type’s prompts configuration
- Adds the current date/time to the input
- Creates the content file
- 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:
- Looks up the content type by name (case-insensitive)
- Exits with an error if the type doesn’t exist
- Checks if the type requires a title and if one was provided
- Processes the tags by splitting them on commas and trimming whitespace
- 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:
- Parses command-line arguments
- Shows help if requested
- Runs in non-interactive mode if a type was specified
- 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:
ContentTypeConfigdefines the properties of a content type (name, path, functions to generate filename and frontmatter, etc.)ContentInputdefines the data that gets passed to the generation functionsPromptConfigdefines how to prompt users for input (what field to populate, the message to show, whether it’s required, and an optional transformation function)
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:
toKebabCase: Converts a string to kebab-case (lowercase with hyphens) for slugsformatISOWithTimezone: Formats a date as an ISO string with timezone information (compliant with Astro’s datetime format)formatHumanReadableDate: Creates a human-friendly date string like “23 January 2026 at 18:30”formatTimestampFilename: Creates a timestamp-based filename like “202601231830”
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:
- It specifies the path
src/data/blogwhere posts will be saved - It requires a title and optionally accepts tags
- The
generateFilenamefunction creates a filename based on the slug or title - The
generateFrontmatterfunction creates the YAML frontmatter with all necessary fields for an Astro blog post
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:
- It specifies the path
src/data/noteswhere notes will be saved - It has no prompts, meaning notes are created automatically with timestamp-based information
- The
generateFilenamefunction creates a filename based on the current timestamp - The
generateFrontmatterfunction creates frontmatter with a human-readable title and timestamp slug
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:
contentTypesis an array of all available content typesgetContentTypeis a helper function that finds a content type by name, returning undefined if not found
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:
- Generates a filename based on the current timestamp (e.g.,
202601231830.md) - Sets the title to a human-readable format (“23 January 2026 at 18:30”)
- Sets the
slugto 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:
bun run newThis 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:
- Help option: Run
bun run new --helpto see usage instructions - Extensible design: Adding new content types is as simple as defining a new configuration object
- Validation: The script validates required fields and provides helpful error messages
- Flexible tagging: Tags can be provided via CLI or prompted interactively
Validation and Best Practices
After generating a new post or note, always run the lint and build commands to catch any issues early:
bun run lintbun run buildThis 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.