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:

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:

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