I reinvented the wheel, again

It is somewhat of a trend of mine. As time goes by, my urge to reinvent the wheel and "fix" my website by completely rewriting the whole things is asymptotic to one. So this is a summary of how, and why, I did it this time around.

The beginning of this whole thing was a modest little idea of adding a section for some of the many pictures I have taken over the years. After my last trip to New Zealand I used the Adobe Portfolio included in Creative Cloud, but I was never very impressed with it and it was always going to be a temporary solution. After looking around for alternatives I figured out that I might as well build it myself, as it seems that is the only way to retain full control over both content and presentation these days.

My first idea was to simply extend the site I have with a new section and continue to use Hugo as the static site generator and Azure Static Web Apps for hosting, but I quickly ran into the storage limitations where the free and standard plans only give you 250/500mb. It does not take a lot of content to reach that limit when you want to store high resolution images and use the build process to create variants for different viewports.

Now, I could of course do the easy thing and switch hosting to something like Netlify instead, but why choose quick and easy over the joy of learning something new?

In the end I landed on this new Tech stack:

  • Sanity as my headless CMS because it was the first one I remembered and decided to play around with, they provide an image pipeline, a globally distributed asset CDN, and as a bonus they have Norwegian origins. At first I was tempted to only use Sanity as a backend for the Photography section and keep the Markdown-based workflow for the rest, but in the end I switched over everything
  • Switching to Astro as my static site generator. I didn't figure out a good way of integrating Hugo with Sanity's asset CDN, and I played around with a few other options before I landed on Astro as my choice. I like the "Islands" concept (even though I'm not currently using it) and their design principles that give me freedom and flexibility of choice
  • Keeping TailwindCSS to make it look somewhat pretty and usable. I am well aware that frontend people who know way more than me about these things compare it to using smart steering in Mario Kart or aim assist in first person shooters. But shipped beats perfect any day.

Challenges

Dynamic routing

Moving from a pure markdown oriented with static routing where a document placed at the right path ends up being a page with the correct layout to using a headless CMS and dynamic routing took a bit of time to understand properly. Again, the curse of speedrunning tutorials does not give me the complete understanding of how things actually work, and it was not until I realized that when using Astro as a static site generator, it needs to know about all the routes at build time I got that Edison-esque lightbulb above my head. As the routes and data structures grow more complex this also creates the next challenge:

Transforming data

I realize that I have been a bit spoiled by some of Hugo's built in features. E.g. in my blog posts section I want to display all posts grouped by year. In Hugo I can simply use {{ range .Data.Pages.GroupByDate "2006" }} to wrangle my data into a suitable shape to iterate over. In Astro I have to do something like this instead:

import _ from 'lodash';
import moment from 'moment';

const postsByYear = _.chain(posts)
    .groupBy(date => moment(date.publishedAt ? date.publishedAt : date._createdAt).format("YYYY"))
    .map((posts, year) => ({year, posts}))
    .value()
    .sort((a, b) => Date.parse(b.year) - Date.parse(a.year));

(Yes. This can probably be done in better ways. On my "todo" list of things I really should do is learning Javascript/Typescript properly instead of speedrunning tutorials and trying to brute force thingss until they do what I want)

I also got stuck for some hours trying to figure out how to create the getStaticPaths() function for displaying a single photo. I wanted my routes to be like this: /album/<album name>/<image id> which means I have to query Sanity for all the albums and their associated images and flatten/transform the results of this query into something Astro understands. My solution for this is so ugly I don't even show it to people.

Trigger updates on content change

Again, this is very easy and straight forward in a markdown oriented workflow: An update is made to one or more files in the git repository. Preferably first in a separate branch and then merged through a pull request. With every new push to the main branch, a GitHub Actions is triggered, the static site is rebuilt, and the new version uploaded and made available.

However, when using a headless CMS this works differently. Changes to templates, styling and configuration still follow the git workflow, but content is updated elsewhere.

The good thing is that Sanity has a solution for this: GROQ-powered webhooks | Content Lake (sanity.io)

In this case the webhook is used to notify GitHub that content has changed, and trigger the GitHub Action. To make this work we need three steps:

  • Add repository_dispatch as a trigger in our workflow file:
on:
  push:
    branches:
      - main

  workflow_dispatch:
  repository_dispatch:
  • Create a new Fine-Grained token at https://github.com/settings/personal-access-tokens/ to access GitHub.

    Tokens should always be scoped as narrow as possible to follow the Principle of least privilege. Only grant the token access to the repository it needs and the minimal repository permissions
  • Go to "API" in your Sanity project's management blade and set up a new webhook with the following settings:
    • url: https:/(/api.github.com/repos/<github username>/<repository name>/dispatches
    • Trigger on: Create, Update and Delete
    • Projection: { "event_type": "Sanity update" }
    • HTTP method: POST
    • HTTP headers:
      • Accept: application/vnd.github+json
      • authorization: <your own github token>
      • X-GitHub-Api-Version: 2022-11-28
    • The rest of the settings can keep their default values unless you want to customize them further.