Blog & RSS
The blog is two routes, one collection and one helper module. /blog/ lists posts newest first with the most recent one featured; /blog/<slug>/ renders the post with a table of contents, a byline and a related grid. Four sample posts ship with the theme.
Ordering and drafts
src/js/blog.ts exports exactly two functions, and everything reads through them:
export async function getAllPosts(): Promise<CollectionEntry<"blog">[]> {
const posts = await getCollection("blog", ({ data }) => data.draft !== true);
return posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
}
Newest first, drafts dropped. The filter is draft !== true, so both draft: false and a missing draft key count as published.
Dropping drafts here rather than at each call site is the point: published means one thing site-wide, and a draft cannot appear in a related grid at the bottom of a live post while being absent from the index.
The featured slot is derived, not flagged
There is deliberately no featured: true frontmatter field. The blog index takes the head of the date sort:
const [featured, ...rest] = await getAllPosts();
The newest post fills the large featured slot; everything else fills the grid. The split happens in the route, so the two sections cannot disagree about which post belongs where.
If you want an editorially chosen feature rather than the newest one, that is a one-line change in src/pages/blog/index.astro — add a boolean to the schema and partition on it instead of destructuring.
Related posts are recency, not relevance
export async function getRelatedPosts(
current: CollectionEntry<"blog">,
limit = RELATED_POSTS,
): Promise<CollectionEntry<"blog">[]> {
const posts = await getAllPosts();
return posts.filter((post) => post.id !== current.id).slice(0, limit);
}
That is the whole algorithm: every published post except this one, newest first, capped at four. No category matching, no tag weighting, no scoring.
The reason is stated in the helper’s own docblock. With a handful of posts, “related” that filters by category is mostly an empty list, and the design draws a full grid of four. The place to change it is that one filter — every caller keeps working, because they all call this function.
Categories are stored and displayed (the first one, as a label) but never consulted in ranking, and there are no category archive pages.
Reading time
Computed at build time from the raw markdown, not stored in frontmatter:
const WPM = 200;
export function readingTime(markdown: string | undefined): number {
const words = (markdown ?? "")
.replace(/```[\s\S]*?```/g, " ") // fenced code blocks
.replace(/`[^`]*`/g, " ") // inline code
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1") // links and images — keep the text
.replace(/^[#>\-*+\s]+/gm, " ") // heading, quote and list markers
.replace(/[*_~]/g, " ") // emphasis marks
.split(/\s+/)
.filter(Boolean).length;
return Math.max(1, Math.ceil(words / WPM));
}
Code blocks are removed entirely rather than counted as prose, link URLs are dropped while their text is kept, and the result is floored at one minute so a short post never reads “0 min”. It takes a string rather than an entry, which keeps the module free of astro:content so its self-check runs under plain Node.
The post page
/blog/<slug>/ renders a hero (title, category, date, reading time, byline, hero image), the article body, a related grid and the shared CTA.
The table of contents is built from the rendered headings, filtered to depth 2:
const { Content, headings } = await render(post);
const tocItems = headings.filter((heading) => heading.depth === 2);
So only ## headings appear in the contents list. ### and deeper are styled in the body but not linked. The whole nav is dropped when a post has no ## at all.
Article typography lives in src/styles/article.css — one .article-body class styling headings, paragraphs, links, lists, blockquotes, inline code, fenced blocks, rules and images by descendant selector. It is deliberately not @tailwindcss/typography: that would install a full opinionated scale and then need overriding back to the theme’s serif headings and muted pairs, which is a dependency bought in order to argue with it.
If you want to restyle article prose, that one file is where to do it.
RSS
/rss.xml is a dependency-free endpoint. The route maps the collection; src/js/rss.ts builds the document:
const body = buildRssFeed({
title: siteData.title,
description: siteData.description,
siteUrl: base.href,
feedUrl: new URL("rss.xml", base).href,
language: siteLocale,
items: posts.map((post) => ({
title: post.data.title,
description: post.data.description,
url: new URL(`blog/${post.id}/`, base).href,
pubDate: post.data.pubDate,
})),
});
It emits RSS 2.0 with an atom:link rel="self", and every interpolated value goes through escapeHtml. Item guids are the URLs themselves — stable and unique, with no second identity scheme to keep in sync.
The trailing slash on item URLs is load-bearing. The site is trailingSlash: "always", and a feed pointing at the redirect rather than the page is how a reader ends up with two entries for one post.
The feed is linked from BaseHead (<link rel="alternate">), from the footer, and from llms.txt. It has its own self-check in src/js/rss.test.ts.
Structured data and Open Graph
The post route sets both, from the same fields:
<BaseLayout
title={`${post.data.title} — ${siteData.name}`}
description={post.data.description}
image={post.data.heroImage}
schema={[schema]}
article={{
published: post.data.pubDate,
modified: post.data.updatedDate,
author: author.data.name,
}}
>
The article prop flips og:type to article and emits article:published_time and friends. The schema array carries a BlogPosting node built by getArticleSchema. They are independent mechanisms for different consumers, which is why both are set.
dateModified is never defaulted to datePublished. Set updatedDate when you actually revise a post; a stale modification date hurts more than none. See SEO & Structured Data.
Adding a post
src/data/blog/your-slug/
└── index.mdx
Frontmatter needs title, description, at least one resolvable author, a pubDate and a heroImage. Write the body as markdown or MDX below it. Use ## for anything that should appear in the contents.
Nothing else needs touching. The index, the feed, llms.txt and the related grids all derive from the collection.
If a new post fails to appear while pnpm dev is running, restart the dev server — the content layer caches entries and does not always notice a new folder.
Removing the blog
Delete src/pages/blog/, src/pages/rss.xml.ts, src/components/Sections/Blog/, src/components/Sections/Post/, src/components/Cards/ArticleCard.astro and RelatedArticleCard.astro, src/js/blog.ts, src/js/rss.ts, src/js/readingTime.ts, src/data/blog/, src/data/authors/, src/styles/article.css, and the blog and authors entries in src/content.config.ts.
Then remove the RSS link line from BaseHead.astro, the Blog and RSS links from navData.json.ts, the blog key from pageCopy.json.ts, and the Writing section from src/pages/llms.txt.ts.