Blog & RSS
The blog is four routes over two collections, plus a feed. /blog/ draws the featured post and page one, /blog/page/<n>/ continues it, /blog/<slug>/ renders a post, /blog/category/<slug>/ filters it, and /rss.xml serialises the lot. Eight sample posts, four authors and four categories ship.
The routes
| Route | File | Builds |
|---|---|---|
/blog/ |
blog/index.astro |
one page — the featured card plus six |
/blog/page/<n>/ |
blog/page/[page].astro |
one per page from two upward |
/blog/<slug>/ |
blog/[slug].astro |
one per published post |
/blog/category/<slug>/ |
blog/category/[category].astro |
one per label, always |
Page one is its own file rather than a paginate() rest route, and that is a routing fix rather than a preference. blog/[...page].astro sitting beside blog/[slug].astro puts two patterns on /blog/<x>/, and the dynamic segment wins the match — so /blog/2/ would resolve to the post route and 404. Pages two and up therefore live under /blog/page/<n>/, where nothing else can claim them. paginate() has a second problem too: it emits a page one under the rest prefix, a duplicate of the index at a second URL, and points page two’s “newer” link at it.
The arithmetic is restPages in @js/listing, shared with the customer-story pager:
export async function getStaticPaths() {
return restPages(stream(await publishedPosts()), POSTS_PER_PAGE, "/blog/");
}
It hands each page { page, total, entries, newerUrl, olderUrl } — with page two’s newerUrl pointing at the index rather than a page/1/ that does not exist, and olderUrl left undefined on the last page so the caller emits no link at all.
It is shared because it was written twice and the two copies had already disagreed: one pager returned { page, total, posts } and derived “is there an older page” as page < total, the other returned { page, stories, hasOlder } and derived it as start + perPage < length. Both were correct, and neither could be changed with confidence that the other still matched.
The featured post
Exactly one post may set featured: true. The index draws it as a wide card above the grid, and stream() removes it from the paginated list — otherwise the first screen prints the same headline twice.
pickFeatured throws at build time when two posts claim it, because Zod cannot see across entries and picking one silently would make the flag mean “whichever the loader happened to read first”.
A flag rather than “the newest post”, because the design features a January post above six newer ones: recency is demonstrably not the rule here.
Categories
CATEGORY_LABELS in src/config/blogData.json.ts is the closed set — four labels today — and it does three jobs at once: it types the collection’s category field through z.enum(), it renders the index’s filter row, and it enumerates the category routes.
export const CATEGORY_LABELS = [
"Product Updates",
"Industry Insights",
"Engineering",
"Company News",
] as const;
Slugs are derived from those labels by categoryHref, never stored, so a tab and the route it points at cannot disagree about what “Company News” is called.
A page is built for every label, including one no post has claimed yet. A tab that leads to a 404 is worse than one leading to an honest empty state, and the tab row is built from the same list the route enumerates.
The category listings are deliberately not paginated. With six posts per page per category the second page is a hypothetical, and pagination here would mean a third listing route to keep in step. When a category outgrows one screen, lift the arithmetic out of the page route rather than copying it.
The filter row is four links, not a Tabs primitive — they are four real routes, so they must be navigable, shareable and crawlable.
A post page
/blog/<slug>/ composes three sections: PostHeader, PostBody and RelatedPosts. Four values are derived rather than authored.
The byline comes from postAuthor, which resolves the post’s first reference("authors"). The reference already failed the build if the id were wrong, so the throw inside it exists to name the post rather than hand a reader undefined two frames later.
Reading time is computed from the raw markdown by readingMinutes, so it cannot drift from the post it describes — an author who adds three sections does not also have to remember to raise a number. Fenced code blocks are dropped before counting, because nobody reads a JSON sample at 200 words a minute and a long fence would otherwise dominate the estimate for an engineering post. It rounds up with a floor of one.
The breadcrumb trail is built once, by postCrumbs, and used twice: PostHeader renders it as the visible nav, and getBreadcrumbSchema turns the same array into JSON-LD. It was written out in both places before, which meant the markup and the schema agreed by coincidence.
Related posts are same-category-first, then whatever else is newest — not same-category-only, so a post in a thin category still shows a full row instead of one card and two gaps. The rule itself is relatedBy in @js/listing, shared with the customer-story strip, which wants the identical shape one field over.
The post’s heroImage is passed as the page image, so the Open Graph card, the Twitter card and their real width and height all come from the same bundled asset the article draws. That is why the collection schema makes it required.
The prose stylesheet
Rendered markdown goes through Sections/Global/Prose.astro, which is the one place the prose rules are defined. It was PostBody’s own <style> block until the customer-story pages arrived wanting the identical treatment; two components carrying a copy of ~40 lines of :global selectors meant re-tuning the type scale would fix one page and leave the other behind.
There is still no typography plugin. This is one design’s worth of rules against the project’s own tokens, where a plugin would be a dependency, a config and an override sheet to fight. The styles are scoped to .post-prose and reach the slotted children with :global, which is what compiled markdown needs — there is no class to hang on the elements the renderer produced.
Code blocks stay inside the token system. Shiki is configured with theme: "css-variables" in astro.config.mjs, so it writes its colours as inline var(--astro-code-*) references rather than as hard hex, and those variables are defined against the palette aliases in PostBody.astro. With a named theme the hexes are inline, which no stylesheet can override without !important — and a GitHub-blue block on a warm amber page is the one element that never matches the brand.
One more Shiki setting is worth knowing before it confuses you. langAlias: { timeline: "text" } exists because timeline is not a programming language — it is the label the customer-story callout prints, since the prose stylesheet draws a fence’s own language as its heading via content: attr(data-language). Aliasing it to plaintext stops Shiki logging “the language doesn’t exist” on every build while still letting the fence name the block. Add an entry there for any future label-only fence.
The feed
/rss.xml is an endpoint, not a static file, so every URL resolves against site and stays absolute; it prerenders to a static file at build like robots.txt and llms.txt beside it.
Nothing in it formats a date or escapes a string by hand — that is @js/rss, which carries its own self-check. Drafts are excluded by publishedPosts, the same call the listing routes make, which is the whole reason that helper exists: a draft that stays hidden on the listings and quietly ships in the feed is the failure nobody notices.
The feed is advertised in three places, and the theme treats that as a rule rather than a nicety: BaseHead emits <link rel="alternate"> on every page — a reader subscribes from wherever it found the site, not only from /blog/ — llms.txt names it, and the footer’s Resources column links it.
Adding a post
- Create
src/data/blog/<slug>/index.md. The folder name is the URL. - Fill the seven required fields:
title,description,authors,pubDate,heroImage,heroImageAlt,category. - Drop the hero image into
src/assets/images/blog/and reference it relatively. - Write the body.
.mdxworks if you want components in it.
The post appears on the index, in its category listing, in the feed, in llms.txt and in the sitemap without another edit. Set draft: true to hold it back from all five at once.