---
title: "8 Hours and 2,000 Lines of Code: A Blog-Category Tool’s Runaway Journey"
description: "A five-minute script to fix category typos ballooned into a 2,000-line TypeScript project with layered architecture and production-grade features. This deep dive retraces that overengineering episode and how to turn the process into knowledge assets."
pubDate: "2025-06-23"
categories: ["Software Development", "TypeScript", "Project Management", "Overengineering", "Software Craftsmanship"]
---

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.

```json
{
  "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.

```yaml
---
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.

```typescript
// 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.

```typescript
// 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
}
```

```d2 title="sync Command Interactive Flow"
direction: down
style: { fill: transparent }

start: "User runs ‘sync’ command" { shape: person }
detect: "Detects a new category in a file"
check_similarity: "Is it similar to an existing category?" { shape: diamond }

interactive_flow: "Interactive Creation Process" {
  prompt_translations: "1. Prompt for translations"
  generate_slug: "2. Generate URL-friendly slug"
  prompt_aliases: "3. Ask for optional aliases (e.g., ‘LLM’)"
  prompt_translations -> generate_slug -> prompt_aliases
}

prompt_alias_suggestion: "Suggest adding as alias to similar entry" { shape: callout }

update_as_alias: "User confirms → update categories.json"
create_new_entry: "User declines → create new entry"
write_new_entry: "Write new category to categories.json"

start -> detect -> check_similarity
check_similarity -> prompt_alias_suggestion: "Yes"
check_similarity -> interactive_flow: "No"

prompt_alias_suggestion -> update_as_alias: "Confirm"
prompt_alias_suggestion -> interactive_flow: "Decline"

interactive_flow -> write_new_entry
write_new_entry -> end
update_as_alias -> end
end: "End"
```

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

```text
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.

```typescript
// 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:

```d2 title="Blog Category Tool Architecture"
direction: down
style: { fill: transparent }

presentation: "Presentation Layer" {
  main: "main.ts (Commander.js)" { shape: rectangle }
}

application: "Application / Orchestration" {
  validate: ValidateProcessor
  sync: SyncProcessor
  fixup: FixupProcessor
}

business: "Business Logic (Services)" {
  factory: CategoryFactoryService
  similarity: SimilarityService
  merge: MergeService
  etc: "..."
}

data: "Data Access (Handlers)" {
  category: CategoryHandler
  scanner: ContentScanner
  git: GitService
}

infrastructure: "Infrastructure" {
  json: "categories.json" { shape: stored_data }
  md: "*.md files" { shape: document }
  repo: "Git repository" { shape: cloud }
}

presentation.main -> application.validate
presentation.main -> application.sync
presentation.main -> application.fixup

application.validate -> business.similarity
application.validate -> data.scanner
application.validate -> data.git

application.sync -> business.factory
application.sync -> data.category
application.sync -> data.scanner

business.factory -> business.similarity
business.factory -> data.category

data.category -> infrastructure.json
data.scanner -> infrastructure.md
data.git -> infrastructure.repo
```

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.

```typescript
// 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.
