:::contentbit

Blog·

contentbit 0.3.0 — internal links your content can't outgrow

Author each page's place in the graph in frontmatter; contentbit derives backlinks, validates every reference, and heals renames with --fix. Internal linking that fails CI instead of your readers.

valid · 0 diagnostics6 blocks1113 words

@contentbit/react + styled pack

Every content site has a link graph whether you manage it or not. Posts reference other posts. A "related reading" list points three ways. Then someone renames a page, and four older articles quietly start pointing at nothing. The graph is real; nothing checks it. Broken internal links don't crash a build. They degrade, one rename at a time, until a reader hits a 404 you shipped six months ago.

0.3.0 makes that graph something you can validate. You author each page's place in it once, in frontmatter, and contentbit handles the bookkeeping: it works out the backlinks for you, rejects links to slugs that don't exist, and fixes stale references after a rename.

Links are content, not markup

An internal link is metadata about a page, not something you hand-write into the body. You don't sprinkle absolute URLs through your prose and hope they survive the next route refactor. You declare the relationship in frontmatter, and the renderer builds the URL however it wants to.

---
slug: dialing-in-espresso
linksTo:
  - grinder-setting-notes
  - espresso-recipe-log
aliases:
  - espresso-dial-in
keywords:
  primary: espresso dial in guide
  secondary: [coffee recipe, extraction workflow]
---

Four keys live in your source files: slug, linksTo, aliases, and keywords. Everything else is derived. You never write linkedFrom yourself — contentbit inverts the linksTo edges to figure out who points at each page. Move the post, swap its route helper, translate it; the relationship still holds, because it was never pinned to a URL to begin with.

One command builds the graph

contentbit links "content/**/*.md"

This reads every file's frontmatter, resolves aliases, derives backlinks, and writes a small, stable JSON index:

{
  "pages": [
    {
      "slug": "dialing-in-espresso",
      "title": "Dialing in espresso",
      "linksTo": ["grinder-setting-notes", "espresso-recipe-log"],
      "linkedFrom": ["espresso-recipe-log"],
      "aliases": ["espresso-dial-in"],
      "keywords": { "primary": "espresso dial in guide" }
    }
  ],
  "aliases": { "espresso-dial-in": "dialing-in-espresso" }
}

The linkedFrom array is where this pays off. It's a "related reading" section you never touch by hand. The starters render exactly that: their /blog route lists each post's backlinks straight from frontmatter. The index file is for the other kind of consumer — a pipeline or an LLM agent that wants the whole graph in one read before it writes a new page.

Broken references fail validation

A graph is only worth having if something checks it. contentbit validate now runs the link checks for you whenever a matched file declares a slug. Same file:line:col diagnostics as block validation, same exit code, same loop:

content/grinder-setting-notes.md:4:3 error CB_LINK_UNRESOLVED
linksTo target "dialing-in-esspresso" matches no slug or alias.
hint: Did you mean "dialing-in-espresso"?

A typo'd slug, two pages claiming the same slug, an alias that collides with a real page, a link left dangling after a rename: each one stops the build with a specific line and a fix hint instead of reaching a reader.

Hand-managed linkscontentbit links
BacklinksMaintained by hand, drift immediatelyDerived on every run
A renamed pageSilent 404s in old posts`CB_LINK_UNRESOLVED`, fails CI
Finding broken linksA reader's bug reportOne command, before merge
Source of truthURLs scattered through prose`slug` + `linksTo` in frontmatter

Renames heal themselves

Renaming a page is where link graphs usually rot. Here it's a two-step move. Keep the old slug as an alias on the page that changed:

---
slug: cold-fermentation-pizza
aliases:
  - overnight-pizza-dough
---

Then let --fix rewrite every stale outbound reference to the new slug:

contentbit links "content/**/*.md" --fix

--fix is intentionally timid. It only rewrites values inside linksTo that point at a known alias. It won't delete your aliases, invent related pages, touch a word of body Markdown, or write derived data back into source. The rename propagates and nothing else moves.

Multilingual, without a separate tool

If you run more than one locale, pick the resolver mode that matches your URL model. Global slugs, slugs scoped per locale, or stable content keys paired with locale-specific slugs are all covered:

  1. 1

    Pick a mode: --link-resolve global-slug (default), same-locale-slug, same-locale-key, or prefer-same-locale-key-fallback-slug

  2. 2

    For translated slugs, give each page a stable key and a locale-specific slug

  3. 3

    Link by key; the index keeps locale, slug, and key together so your app builds /{locale}/blog/{slug} with its own route helper

Point a link across locales on purpose and you get a warning rather than a silent edge, so the cross-locale jump shows up in review.

Your agent reads the graph

This is where it meets the 0.2.0 agent workflow. The contentbit agents integration now teaches skills and AGENTS.md to load the link index before writing, so an LLM agent picks linksTo values from slugs that actually exist instead of inventing a plausible-looking one. The same loop that keeps blocks valid now keeps the graph honest:

  1. 1

    Run contentbit links to read current slugs, aliases, and backlinks

  2. 2

    Write the page, choosing linksTo from real slugs in the index

  3. 3

    Run contentbit links --fix if aliases changed

  4. 4

    Validate until clean — broken links are diagnostics, not surprises

Common questions

The terse version of all this lives in the changelog. The full reference, with every diagnostic code, every resolver mode, and the exact --fix rules, is in the internal linking guide.