Skip to content
AstroCraft Docs
On this theme

Blog & RSS

The blog is CH 04 for the index and CH 05 for each post. The theme calls a post a bulletin, and its number is its position in the broadcast order — newest first.

Eight bulletins ship with the theme. Unlike the work collection, the markdown body is the content here: it renders as the // BULLETIN column on the post page.

The index

/blog/ features the latest bulletin as a large card and lists the rest as an archive. The featured card uses the entry’s preview image when it has one, falling back to hero — which is why preview is optional on every entry and only the latest post ships one.

SubscribeRow sits at the foot of the index and links the RSS feed.

The post page

/blog/<slug>/ is the most involved route in the theme, and reading its frontmatter is a good way to understand the house style. Three things there are worth pulling out.

Neighbours ride along with getStaticPaths. Rather than re-querying the collection on each page to find the previous and next post, the path builder passes them as props:

export async function getStaticPaths() {
  const entries = await getBulletins();
  return entries.map((entry, i) => ({
    params: { slug: entry.id },
    props: { entry, index: i + 1, newer: entries[i - 1], older: entries[i + 1] },
  }));
}

Note the naming: because the list is newest-first, the previous entry is the newer one. Calling them prev and next is exactly how a blog ends up with backwards navigation, and the theme sidesteps it by naming the direction rather than the position.

Four independent reads run in parallel. The body render, the author lookup, the related entries and the hero’s getImage are all awaited together:

const [{ Content }, author, relatedEntries, heroSrc] = await Promise.all([
  render(entry),
  getEntry(entry.data.authors[0]),
  getEntries(entry.data.related),
  getImage({ src: hero.src, width: 1200 }),
]);

The getImage call is in there rather than further down beside the schema it feeds, and the comment says why: it is the slowest of the four, and awaiting it later is what made the page’s frontmatter serial.

A missing author is a build error, not a blank byline:

if (!author) throw new Error(`Author "${entry.data.authors[0].id}" not found for ${entry.id}`);

Reading time

Derived from the body by readingTime in @js/textUtils:

export function readingTime(body: string | undefined): number {
  const words = (body ?? "").trim().split(/\s+/).filter(Boolean).length;
  return Math.max(1, Math.round(words / 200));
}

200 words a minute, floored at one so a short post never reads “0 min”. It is one line of arithmetic and it carries a runnable check in textUtils.test.ts — which is the theme’s standard for non-trivial logic.

related is typed as reference("blog")[], so a slug that names no entry fails the build. That is the payoff of using a collection reference rather than an array of strings: the dead-link class of bug is gone at the type level rather than caught in review.

The page resolves them with getEntries and maps them to { title, href } rows for the // RELATED column.

The page furniture is frontmatter

The pull quote, the FIG.02 figure, the // IN THIS BULLETIN contents list and the related links are all frontmatter fields, not markdown in the body. The schema comment states the reason directly: it keeps the body as plain paragraphs, so every string still lands inside the tube’s own type scale.

contents and related both default to [], and an empty array drops the block entirely rather than rendering an empty heading.

RSS

/rss.xml is a dynamic endpoint that prerenders to a static file, built by @js/rss — no @astrojs/rss, no dependency:

const entries = await getBulletins();

const xml = buildRss({
  title: siteData.title,
  description: siteData.description,
  siteUrl: base.href,
  feedUrl: new URL("rss.xml", base).href,
  language: siteLocale,
  items: entries.map(({ id, data }) => ({
    title: data.title,
    description: data.description,
    url: new URL(`blog/${id}/`, base).href,
    pubDate: data.pubDate,
    categories: data.categories,
  })),
});

It reads the same getBulletins() every other surface reads, which is already newest-first — the order RSS expects — so the feed cannot disagree with the index about what is published or in what order. Drafts are filtered out by the helper, so an unpublished post cannot leak into the feed.

It is an endpoint rather than a file in public/ for the same reason robots.txt is: every URL resolves against site, so setting your domain once fixes the feed’s links along with everything else. rss.test.ts covers the builder, including XML escaping.

The feed is linked from three places — BaseHead’s rel="alternate", the index’s SubscribeRow, and llms.txt.

Structured data

Each post emits a BlogPosting via getArticleSchema, with every field the page actually draws: headline, description, the canonical URL, the hero image resolved to an absolute URL, published and modified dates, the author’s name and profile URL, the language, and a reference to the site’s Organization node as publisher.

Alongside it, BaseLayout receives an article prop, which is what makes BaseHead emit og:type="article" plus the article:published_time and article:modified_time meta tags. The two are separate props on purpose — JSON-LD and Open Graph are different consumers, and the theme keeps the mapping explicit rather than inferring one from the other.

The canonical URL is built with the same new URL(Astro.url.pathname, Astro.site) expression BaseHead uses, deliberately duplicated as the same expression so the page’s schema @id, its og:url and its <link rel="canonical"> cannot describe three different URLs.

Adding a bulletin

src/data/blog/my-post/
├── index.md
└── hero.webp
---
title: My post
description: The standfirst — featured-card copy and the line under the title.
authors: [alex-mercer]
pubDate: 2026-03-01
categories: [Performance]
hero:
  src: ./hero.webp
  caption: FIG.01 — What the capture shows
  note: Live capture
contents:
  - First section
  - Second section
related: [frame-budget-is-a-lie]
---

The body starts here, as plain paragraphs.

hero is required, and that is the one field people trip over. It is required because it is the OG image — making it mandatory is what lets the layout pass an image unconditionally, so every post ships a correct social card with real dimensions instead of falling back to the site-wide default.

Set draft: true to keep an entry out of getBulletins(), which removes it from the index, the feed and the route table in one go.

NEXT STEPCV & Downloads