8 Hours and 2,000 Lines of Code: A Blog-Category Tool’s Runaway Journey
Last weekend I planned to spend five minutes writing a tiny script; instead, I spent eight hours and produced over 2,000 lines of code.
The initial goal was simple: create a category-spell-checker for my blog. I ended up shipping a full-blown TypeScript application featuring dependency injection, layered architecture, Git integration, an interactive CLI, and a file-locking mechanism.
This post chronicles that development process, reflecting on technical passion, overengineering, and how to convert the experience into something of enduring value.
Genesis: A Five-Minute Fix
It started with a minor annoyance I spotted while reviewing code. My blog is built with Astro; every post is a Markdown file. To keep categories consistent, I maintain a categories.json file that lists every valid category and its translations.
{
"web-development": {
"aliases": ["Web开发"],
"translations": {
"en": "Web Development",
"zh-cn": "Web 开发",
"zh-tw": "Web 開發"
}
},
"cognitive-science": {
"aliases": ["认知科学"],
"translations": {
"en": "Cognitive Science",
"zh-cn": "认知科学",
"zh-tw": "認知科學"
}
}
}When writing a new post, I manually reference these categories in the front-matter—an easy place to make a typo.
---
title: "My New Post"
pubDate: 2025-06-21
categories:
- "Web Development"
- "Cognitive Science" # What if I fat-finger this as "Cognitive-Science"?
---The site won’t crash, but the category page will now show two almost-identical links—an eyesore.
I thought: No problem—thirty lines of code in a pre-commit hook. Scan staged Markdown files, extract the categories field, compare it against categories.json, and fail the commit on mismatch. Five minutes, tops.
That was 10 a.m. on Sunday. I assumed it was a tiny task.
Act I: A Snowball Out of Control
10:30 a.m.—From One-Off Script to CLI Tool
I hacked the first script quickly, but it looked too crude—unprofessional.
Running it with bun run check.ts is clunky. Why not wrap it with commander.js and make a real CLI? With -v, --help, all the standard flags, easy to extend later.
I created core/cli.ts and blog-categories/main.ts. A project structure emerged; the original logic became a validate command.
// scripts/blog-categories/main.ts
program
.command("validate")
.description("Validate blog post categories")
.option("--staged", "Only validate staged files") // ... other options
.action(async (options) => {
// ... validation logic
});That mindset shift was decisive. I was no longer writing a script; I was building a product.
12 p.m.—Feature Creep: Adding sync
validate found problems, but fixing them was manual—an incomplete experience. If I can detect an unknown category, why not add it automatically?
Practical enough. I began the sync command. But adding isn’t just “create the key.” For a new category like “Large Language Models,” what’s the English translation? What should the URL slug be?
To make the workflow interactive, I pulled in inquirer.js and wrote an InteractiveService to handle prompts.
// scripts/blog-categories/services/interactive-service.ts
class InteractiveService {
async promptForTranslation(
categoryName: string,
language: string
): Promise<string> {
return await input({
message: `${language} translation for "${categoryName}" (optional):`,
});
}
// ... other interactive helpers
}When sync spots a new category, it will:
- Ask for translations in multiple languages.
- Generate a URL-safe slug from the English name.
- Ask for optional aliases.
- Check similarity (e.g.,
LLMvs.Large Language Model) withfastest-levenshteinvia aSimilarityServiceand suggest aliasing.
Lunch slipped by unnoticed as I polished the interactive flow.
3 p.m.—Refactor: Embracing Layered Architecture
sync had grown complex—file I/O, Markdown parsing, user prompts, Levenshtein checks, data writes—all tangled together.
This is messy—violates single-responsibility. Time to layer up!
Another inflection point. I rebuilt the directory as if it were a large app:
blog-categories
├── handlers/ # Data layer: file I/O, Git
├── services/ # Business logic: algorithms, rules
├── processors/ # Application layer: orchestration
├── utils/ # Stateless helpers
├── main.ts # Entry & CLI
└── types.ts # Type definitionsThen I hand-rolled a DI container to wire everything up.
// scripts/blog-categories/main.ts
// --- Composition Root ---
let configManager: ConfigManager;
let categoryHandler: CategoryHandler;
// ... lots more
async function initializeDependencies(verbose = false) {
configManager = new ConfigManager(verbose);
await configManager.load();
categoryHandler = new CategoryHandler(configManager, verbose);
// ... instantiate services and processors ...
}I even drew an architecture diagram:
The code was now clear, decoupled, testable. I was thoroughly immersed.
5 p.m.—Rounding Out Features: fixup and File Locks
Elegant though it was, the system lacked polish. If I created a category in Chinese first (say “随笔”), its slug defaulted to chinese-category-12345. After adding the English “Essay,” I wanted the slug to auto-update to essay.
Enter the fixup command—dedicated to such data inconsistencies.
While coding the save logic, another thought hit me: What if I run sync in two terminals at once—will it corrupt categories.json?
Logically, impossible. Yet perfectionism prevailed: A robust system must handle concurrency.
The final hour went into an arguably over-complex yet technically satisfying withFileLock function.
// scripts/blog-categories/handlers/category-handler.ts
private async withFileLock<T>(
lockFile: string,
action: () => Promise<T>,
): Promise<T> {
const maxWaitMs = 5000;
const checkIntervalMs = 100;
const staleLockTimeoutMs = 30000;
// 1. Clean up stale lock
await this.cleanupStaleLock(lockFile, staleLockTimeoutMs);
// 2. Try to acquire lock
const startTime = Date.now();
while (Date.now() - startTime < maxWaitMs) {
try {
// 3. Atomic file creation with ‘wx’
const lockData = { pid: process.pid, timestamp: Date.now() };
const fs = await import("node:fs/promises");
const fd = await fs.open(lockFile, "wx");
await fd.writeFile(JSON.stringify(lockData));
await fd.close();
// 4. Execute protected operation
try {
return await action();
} finally {
// 5. Release lock
const { unlink } = await import("node:fs/promises");
await unlink(lockFile).catch(() => {});
}
} catch (error: any) {
if (error.code === "EEXIST") {
await new Promise((r) => setTimeout(r, checkIntervalMs));
} else {
throw error;
}
}
}
throw new Error(`Failed to acquire lock on ${lockFile} within ${maxWaitMs} ms.`);
}Atomic ops, timeouts, retries, zombie-lock cleanup—a production-grade concurrency guard for a single-user local script.
When the last line compiled, the clock read 6 p.m. Eight hours later I had a fully-featured, architecturally sound blog-category management system.
Act II: Reflection & Re-evaluation
Sunday evening, staring at 2,000+ lines, I asked myself: did I really just fix a “typo in categories” problem?
Efficiency: The ROI Is Terrible
Engineering lens, cold numbers:
- Investment: 8 hours of senior-developer time. Opportunity cost: writing 2–3 high-quality posts or learning a new framework.
- Return: Saves me about five minutes per year fixing typos.
ROI ≈ 0. Like manufacturing a jet engine to tighten a bicycle screw.
Experience: The Process Has Intrinsic Value
But developers aren’t efficiency machines alone.
I recalled the intense focus of those eight hours. It wasn’t painful—it was joyous.
- A mental workout: The satisfaction of untangling mess into layers.
- A coding kata: A playground for building a modern, reliable CLI end-to-end.
- Craftsmanship: Writing the file lock felt like a carpenter hand-carving a dovetail joint—hidden, yet exquisite.
In that light, the chief value wasn’t the tool—it was the skills honed and the pleasure of crafting it.
Content: An Unexpected Bonus
If the story ended there, it’s just a perfectionist’s diary. Then I spotted the conversion key.
This over-engineered, well-structured, heavily-commented project is perfect content.
The real value isn’t how many minutes it saves me, but how much value it can deliver to readers. I didn’t just build a CLI; I created a case study to share, dissect, and learn from.
The ultimate output of the project is this article itself.
That realization reframed everything. The “wasted” eight hours turned into a meaningful investment: I enjoyed the process, leveled up, and forged unique, in-depth blog content.
Epilogue: Rethinking “overengineering”
Next time you catch yourself writing heaps of code for a simple need, don’t dismiss it outright.
Ask three questions:
- Did I enjoy it? (experience)
- Did I learn something new? (learning)
- Can I turn the process or result into shareable knowledge? (sharing)
If all three are yes, you may not be wasting time—you’re making a valuable personal investment.
Progress in tech stems not only from solving problems but also from love of the craft, pursuit of elegance, and the willingness to share insights.
Postscript: Balancing Idealism and Reality
Soon after publishing, real-world use surfaced issues.
The hand-rolled file-locking mechanism—an hour’s labor—handled atomic ops but faltered on edge cases. I eventually switched to the battle-tested proper-lockfile library.
Likewise, my custom string-similarity algorithm under-performed, so I replaced it with the more powerful fuse.js.
Ironic, yet honest: sometimes reinventing the wheel really is worse than using a proven one. But in reinventing it, I gained a deep grasp of file-lock principles and fuzzy-search algorithms—knowledge sure to pay dividends later.
Perhaps that is the true worth of overengineering: not the final product, but the profound understanding and technical insight won in the exploration.