# The blog

The AutoBankConvert blog lives at `/blog`. This is the living document for the next agent: where things are, how they fit, how to add to them, and what the check enforces.

## Routes

| Route                         | Serves                                                                                                                                           | Code                                      |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------- |
| `/blog`                       | Index: one card per category with name, description and post count, links to both lists. No post list.                                           | `src/app/blog/page.tsx`                   |
| `/blog/recent`                | The 100 most recently published posts, newest first.                                                                                             | `src/app/blog/recent/page.tsx`            |
| `/blog/recent/updated`        | Posts with an `updatedAt`, latest revision first.                                                                                                | `src/app/blog/recent/updated/page.tsx`    |
| `/blog/category/<slug>`       | One category: intro, then its posts newest first with tags. Unknown slug: 404.                                                                   | `src/app/blog/category/[slug]/page.tsx`   |
| `/blog/<slug>`                | One post: breadcrumb, byline, sticky table of contents with the call to action under it, body, call to action, related posts. Unknown slug: 404. | `src/app/blog/[slug]/page.tsx`            |
| `/blog/<slug>.md`             | The Markdown copy (rewrite to `/blog/md/<slug>` in `next.config.ts`).                                                                            | `src/app/blog/md/[slug]/route.ts`         |
| `/blog/og/<slug>`             | The social preview image, drawn from the title.                                                                                                  | `src/app/blog/og/[slug]/route.tsx`        |
| `/blog/feed.xml`              | Atom feed, advertised in the head of every blog page.                                                                                            | `src/app/blog/feed.xml/route.ts`          |
| `/blog/llms.txt`              | Every post grouped by category, linked to its Markdown copy.                                                                                     | `src/app/blog/llms.txt/route.ts`          |
| `/sitemap.xml`, `/robots.txt` | Site pages plus every blog page with last-modified dates; robots allows everything and names the sitemap.                                        | `src/app/sitemap.ts`, `src/app/robots.ts` |
| `POST /api/blog/revalidate`   | Expires the cached blog pages. Needs `Authorization: Bearer $BLOG_REVALIDATE_SECRET`.                                                            | `src/app/api/blog/revalidate/route.ts`    |

The header nav (`nav` in `src/lib/site.ts`, plus a phone-only link in `src/components/site-header.tsx`) and the footer's Product column link to `/blog`.

## Where posts live, and why

Posts are rows in the `posts` table of this site's own Neon Postgres database (`NEON_URL`; schema in `src/db/schema.ts`, migrations in `drizzle/`, applied with `pnpm db:migrate`). The database was already provisioned for the project, so the blog uses it; each sibling site has its own database and its own posts.

Posts are authored as Markdown files with front matter in `content/blog/<slug>.md`, kept in git as the editable copy, and saved into the table by `pnpm blog:seed`. The database is what the pages read. A re-seed leaves unchanged posts alone and skips a row whose body was edited in the database since its last seed (`md5(body)` vs `seeded_hash`) unless `--force`.

## The shapes

- **Post**: `src/lib/blog/schema.ts` (zod). Slug (from the file name), title, description, category, primaryKeyword, secondaryKeywords, length (`full` or `short`), tags (2 to 4), author, image, draft, publishedAt, updatedAt, body.
- **Categories, author, call to action, blog name**: `src/lib/blog/config.ts`, the only blog file that differs between the sibling sites. Category post counts are deliberately uneven.
- **Colors**: the `--color-blog-*` tokens in `src/app/globals.css`.

## Read and write

- **One read path**: `src/lib/blog/loader.ts` (`listPublishedPosts`, `listPublishedPostsWithBody`, `getPublishedPost`). `"use cache"` with tag `blog` and `cacheLife("max")`, so pages prerender and stay cached until the tag is revalidated.
- **One write path**: `savePost()` in `src/lib/blog/write.ts`. Validates the shape, runs the per-post content rules, upserts by slug. The slug is the primary key and the primary keyword is unique, so the database refuses duplicates whatever code calls it. A post without `publishedAt` gets the time it was first saved.
- **Seed**: `pnpm blog:seed` (`--only a,b`, `--force`). Then it POSTs to `BLOG_REVALIDATE_URL` with `BLOG_REVALIDATE_SECRET` when both are set; otherwise pages refresh on the next deploy.

## The content check

`pnpm check:content` runs `scripts/check-content.ts` over the database rows (or the files with `--files`; `--slug a,b` checks a subset with links resolved against `docs/blog-keywords.md`; `--report` prints words, opening length, overlap and inbound links per post). Rules are in `src/lib/blog/rules.ts`, each with a failing fixture in `src/lib/blog/rules.test.ts`: valid shape, unique and non-reserved slugs, one owner per keyword (and every mapped keyword has a post), keyword in title and slug and its words in the description and first paragraph, a 40 to 60 word opening and section openers under 60 words, at least 2 question headings, title up to 60 and description 120 to 160 characters, no top-level heading in the body, 1,500 prose words (800 for short), 4 to 8 second-level headings with distinct anchors, no heading shared between posts, at most 20% shared 5-word sequences between sections of different posts, known category and author, balanced categories (largest at most 2x the smallest), internal links and anchors resolve, no orphans, https sources with descriptive link text, image alt text, honest dates.

`pnpm verify` runs lint, typecheck, tests, the content check and the build.

## Adding a post or a category

Follow `.claude/skills/add-blog-post/SKILL.md`. A new category is an entry in `src/lib/blog/config.ts` plus a row in `docs/blog-plan/CATEGORIES.md`; it needs at least 2 posts, must keep the balance rule, and keeps the counts uneven.

## Keeping posts fresh

A post is due a second look when a product change touches what it describes (the homepage in `src/app/page.tsx` is the source of product facts), or a year after it was last checked. `updatedAt` moves only when the substance changes, because the recently updated list, the feed and the sitemap all show it.

## What to measure once it is live

Search Console impressions and queries per post, visits referred by AI assistants, and whether an assistant asked one of the section-plan questions in `docs/blog-keywords.md` cites the post. None of these can be measured from the repository.

## Planning files

`docs/blog-keywords.md` (the keyword map, its status column is the ledger, and the section plan), `docs/blog-plan/CATEGORIES.md`, and the planner, writer and reviewer briefs in `docs/blog-plan/`. Keyword data: `ai/seo/keywords/keywords.md` (Google Ads Keyword Planner).
