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
}
sync Command Interactive Flow

When sync spots a new category, it will:

  1. Ask for translations in multiple languages.
  2. Generate a URL-safe slug from the English name.
  3. Ask for optional aliases.
  4. Check similarity (e.g., LLM vs. Large Language Model) with fastest-levenshtein via a SimilarityService and 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 definitions

Then 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:

Blog Category Tool Architecture

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:

  1. Did I enjoy it? (experience)
  2. Did I learn something new? (learning)
  3. 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.