Building rwblickhan.org in 2024
Last updated: Sun Oct 20 2024
Last time I discussed how this site is built was two and a half years (!) ago. The site has changed quite a bit since then. Here’s how it’s built today.
Goals
Here are my ongoing goals for this site’s tech stack:
- Fast: The site is mostly text, so it should load as fast as bytes can get pushed through the Internet tubes. Also, building it locally should be fast; if I’m keeping this site around for the rest of my life, it shouldn’t take exponentially longer to build over time.
- Text-first: I’m a writer and programmer, so my site will always be mostly text, with minimal interactivity on most pages.
- Maintainable…: I want to use standard frontend tools, like TypeScript and CSS, and I don’t want the site to randomly break every few months due to upgrades. (Looking at you, Swift version upgrades…)
- … but fiddleable: As Ethan Marcotte once said, “let a website be a worry stone”. I want to play with the site — I want to refactor every so often and mess around with how everything looks.
Astro
Back in 2022, I was using the Swift-based Publish static site generator, mostly because I was an iOS engineer. Shortly after publishing that article, I moved to frontend development and decided to rewrite this site. After a brief, frustrating dalliance with 11ty, I switched to Astro.
Astro is a nice fit for my goals. By default, it outputs fully-static HTML and CSS, with no bulky framework like React that takes forever to load. As a bonus, the site is fully accessible, since it’s all just semantic HTML. However, Astro also supports islands of interactivity when necessary, which is how my search page is built.1
Astro also has a nice developer experience. It’s all built in modern TypeScript, with typechecked content collections and a React-ish template system, and it uses common TypeScript libraries for Markdown parsing. As a frontend engineer, that’s much easier to deal with than, say, 11ty’s string-based templating system or Publish’s homegrown Swift-based Markdown parser.
Site Setup
Astro’s directory structure is straightforward, but a little hard to follow without an example.
URL routes are rendered from pages. Each page can include its own content, or they can be dynamically populated by pulling content from a content collection, which is just a set of Markdown files defined by a schema. Reusable components are written in Astro’s React-ish template language; most pages render a layout, which is just a special case of an Astro component.
As mentioned, that may sound a little complicated, but let’s follow the example of my pattern language page.
The written content is in a Markdown file at /content/misc/pattern-language
:
---
title: Pattern Language
description: Patterns I keep coming back to
lastUpdatedDate: 2024-10-20
---
These are various “patterns” that I tend to use and reuse in my thinking. This page is inspired in no small part by Jacky Zhao’s [“A Pattern Language”](https://jzhao.xyz/thoughts/A-Pattern-Language/) and her [list of patterns](https://jzhao.xyz/tags/pattern/). Is this really accurate to Christopher Alexander’s [_A Pattern Language_](https://en.wikipedia.org/wiki/A_Pattern_Language)? No idea, but I find this useful.
The YAML frontmatter at the top is important for reasons I’ll discuss later. This is rendered into HTML by Astro’s Markdown parser. The rest of the page is in pages/misc/[...slug].astro
— one page is generated for each file in /content/misc
. That page file is pretty short:
---
import { type CollectionEntry, getCollection } from "astro:content";
import ContentLayout from "../../layouts/ContentLayout.astro";
export async function getStaticPaths() {
const posts = await getCollection("misc");
return posts.map((post) => ({
params: { slug: post.slug },
props: post,
}));
}
type Props = CollectionEntry<"misc">;
const post = Astro.props;
const { Content } = await post.render();
---
<ContentLayout parentCollection="misc" {...post.data}>
<Content />
</ContentLayout>
Everything in the frontmatter (bracketed by ---
) runs at build time. getStaticPaths()
gets all the posts in the “misc” content collection via getCollection(“misc”)
, then renders the post for the current route with post.render()
.
In the actual body, using Astro’s React-like components, the rendered <Content />
is wrapped in a <ContentLayout>
. ContentLayout
is a layout, but that’s basically just a special class of components in Astro. ContentLayout
lives at layouts/ContentLayout.astro
:
---
import type { Collection } from "../content/config";
import { collectionMetadataMap } from "../consts";
import BaseLayout from "./BaseLayout.astro";
export interface Props {
title: string;
description: string;
lastUpdatedDate?: Date;
parentCollection?: Collection;
}
const {
title = "R. W. Blickhan",
description = "The personal site of R. W. Blickhan",
lastUpdatedDate,
parentCollection,
} = Astro.props;
const parentCollectionMetadata =
parentCollection && collectionMetadataMap.get(parentCollection);
---
<BaseLayout
title={title}
description={description}
lastUpdatedDate={lastUpdatedDate}
width="small"
>
{
parentCollectionMetadata && (
<a href={parentCollectionMetadata.slug}>
← {parentCollectionMetadata.title}
</a>
)
}
<h1>{title}</h1>
{lastUpdatedDate && <h3>Last updated: {lastUpdatedDate.toDateString()}</h3>}
<slot />
</BaseLayout>
This has a bit of templating logic, e.g. to handle the presence or absence of a last-updated-date. The syntax should be familiar to anyone that’s used React.
ContentLayout
references another layout, <BaseLayout>
:
---
import BaseHead from "../components/BaseHead.astro";
import Header from "../components/Header.astro";
import "../styles/index.css";
export interface Props {
title: string;
description: string;
width: "small" | "medium" | "large";
}
const { title, description, width, lastUpdatedDate } =
Astro.props.frontmatter || Astro.props;
---
<!doctype html>
<html lang="en">
<BaseHead
title={title}
description={description}
lastUpdatedDate={lastUpdatedDate}
/>
<Header />
<article class={width}>
<slot />
</article>
</html>
I can define typechecked Props
which are passed from component to component. Again, this is all done at build time, so the final output is a completely static site.
How does Astro know how the YAML frontmatter should be converted to Astro.props
for use in Astro components? I’ve configured the content collection in content/config.ts
:
import { defineCollection } from "astro/content/runtime";
import { z } from "astro/zod";
const misc = defineCollection({
schema: z.object({
title: z.string(),
description: z.string(),
lastUpdatedDate: z
.string()
.or(z.date())
.transform((val) => new Date(val)),
}),
});
For styling purposes, I have a giant index.css
with all my semantic styling rules, which is imported into BaseLayout
. There’s some other Astro-specific boilerplate and plugins and a few other pieces of TypeScript logic to power things like RSS, but… that’s really all there is to my site!
Information Architecture
I’ve gone through a different taxonomies on my site, but I’ve ended up split into:
- fiction (self-explanatory),
- poetry (because it doesn’t quite fit neatly into fiction),
- essays (long-form, non-programming-related essays),
- technical articles (programming-related pieces, which includes my TILs),
- newsletters (an archive of newsletters sent via Buttondown),
- logs (append-only logs of everything I read, watch, play, and listen to),
- and “misc” (an unusual category that contains various non-essay “evergreen” pages that I update regularly, like the aforementioned pattern languages page or my list of favorite quotes, plus a subcategory of recipe pages).
Each of these categories is backed by an Astro content collection, with each page in the collection rendered like the above page. Each category also has an index page that links to all posts in that collection, chronologically ordered by last update time.
I also have a few one-off pages, like the main page, search, “Map”(which links to all the index pages), a gallery of photos I’ve taken, and an internal style guide to test CSS changes.
Deploying
The whole site is one GitHub repo. I have Prettier and ESLint set up, in keeping with standard frontend developer practice. I have a very short pre-commit hook that runs every time I commit, which uses a regex to update the lastUpdatedDate
frontmatter field on Markdown files I’ve changed.
I use Cloudflare Pages to deploy; because the output is completely static, all I needed to do was connect to GitHub and specify the build command (npm run build
in this case) and the name of the output directory (dist/
by default for Astro), and Cloudflare redeploys the site on every Git push
Footnotes
-
Actually, to be strictly correct, I use the astro-pagefind plugin, which doesn’t actually use islands. But a previous implementation of search did use a Preact-based island on the search page. ↩