johnke.me

Importing Linkding bookmarks into Hugo

One of my goals for the second half of 2025 is to reduce the amount of duplication in my life. I have a half a dozen read/watch/listen-it-later services. My tasks are strewn across four different productivity systems. I’m too old for this kind of nonsense, and I’d like to get this down into something more manageable.

A nice easy starting point for decluttering: the links system on this site. I migrated from Pinboard to Raindrop to a Linkding instance at bookmarks.johnke.me. That’s where I store my bookmarks. But what about if I find something I want to share on this site? Well for that, I run a bash script (bin/newlink.sh) passing the URL and it will generate a Hugo post for that link. Last year, with version v0.126.0, Hugo introduced a new feature: content adapters. This allows Hugo, a static site generator, to dynamically generate content from another source. In their examples, they just use a dict but you can also use the output of an API. An API like the one provided by Linkding?

Setting this up was shockingly easy!

First, I put some Linkding config in Hugo’s top-level config.yaml:

params:
  ...
  linkding:
    url: "https://bookmarks.johnke.me/api/bookmarks/?q=%23johnke.me"
    token: "MY_API_TOKEN"
  ...

The ?q==%23johnke.me means that any bookmark I tag with johnke.me will be picked up by the content adapter.

Then I put a _content.gotmpl at the top-level of the section where I want my content created. So in my case, I have a content/link/_content.gotmpl that looks like this:

{{/* Fetch bookmarks from Linkding */}}
{{/* Set using HUGO_PARAMS_LINKDING_URL, HUGO_PARAMS_LINKDING_TOKEN or in Hugo config */}}
{{/* Full endpoint URL with any filters, e.g. "https://bookmarks.johnke.me/api/bookmarks/?q=%23dorkus" */}}
{{ $URL := .Site.Params.linkding.url }}
{{ $TOKEN := .Site.Params.linkding.token }}

{{/* Get remote data. */}}
{{ $data := dict }}
{{ $headers := dict "Authorization" (print "Token " $TOKEN) }}
{{ $opts := (dict "headers" $headers) }}
{{ with try (resources.GetRemote $URL $opts) }}
  {{ with .Err }}
    {{ errorf "Unable to get remote resource %s: %s" $URL . }}
  {{ else with .Value }}
    {{ $data = . | transform.Unmarshal }}
  {{ end }}
{{ else }}
  {{ errorf "Unable to get remote resource %s" $URL }}
{{ end }}

{{/* Add pages and page resources. */}}
{{ range $data.results }}
  {{/* Skip entries with empty titles */}}
  {{ if .title }}
    {{/* Add page. */}}
    {{ $content := dict "mediaType" "text/markdown" "value" .notes }}
  }}
    {{ $dates := dict
      "date" (time.AsTime .date_added)
      "lastmod" (time.AsTime .date_modified)
    }}

    {{/* For tags, remove `johnke.me` */}}
    {{ $tags := slice }}
    {{ range .tag_names }}
      {{ if ne . "johnke.me" }}
        {{/* Dashes to spaces */}}
        {{ $tag := replace . "-" " " }}
        {{ $tags = $tags | append $tag }}
      {{ end }}
    {{ end }}

    {{/* I use the 'link' parameter to point to the URL */}}
    {{ $params := dict
      "link" .url
      "tags" $tags
    }}

    {{/* Create a safe filename from the title */}}
    {{ $safePath := .title | urlize }}
    {{ if not $safePath }}
      {{ $safePath = printf "bookmark-%d" (time.AsTime .date_added).Unix }}
    {{ end }}

    {{ $page := dict
      "path" $safePath
      "kind" "page"
      "title" .title
      "dates" $dates
      "params" $params
      "content" $content
    }}
    {{ $.AddPage $page }}
  {{ else }}
    {{ warnf "Skipping bookmark with empty title: %s" .url }}
  {{ end }}
{{ end }}

Finally, since this whole thing is driven by github actions, I added a schedule to my action definition so twice a day, it will regenerate the site with any new links I’ve added:

on:
  ...
  schedule:
    # Runs every day at 2:00 AM and 2:00 PM UTC
    - cron: '0 2,14 * * *'
  ...

And that’s it! I have links coming directly in from Linkding and I have one canonical source of truth for all my bookmarks and one less point of redundancy!

Neat!

28 Years Later

Poster for 28 Years Later
Watched on June 24, 2025
Rating:

Not so much a zombie film as much as a coming-of-age film dressed in folk horror clothing, and it’s no less effective for it. Newcomer Alfie Williams does a really impressive job as Spike, a 12 year old who born into an infected world, and the action takes up as his father brings him on his first hunting trip to the mainland. He’s given a complicated emotional arc and he portrays it heroically, with a wonderful, nuanced performance.

I’m a sucker for this kind of speculative setup. What does the world look like for the next generation after the initial film? Questions that made the recent Apes films so interesting. But once we get outside of this, the story of 28 Years Later doesn’t really take us to any surprising places and a lot of it can feel screenwriter-formulaic (oh you’re going to hit us with the duality of birth and death? Cool cool cool. Samson and Delilah? Oh wow). But one thing I love about Danny Boyle is how he is capable of elevating the blandest trash. Nowhere is this better demonstrated than in the final act: a wonderful, ecstatic celebration of life and death and mortality that hit me so hard.

But the coda. Yeesh. I didn’t realise this was already planned to be a trilogy, so the final minutes felt like a giant fuck you to Sony, daring them to use this tonally awful, morally misjudged bullshit as the starting point for the next film. But apparently the sequel has already been filmed? Like I said, yeesh.

Delirious

Poster for Delirious
Watched on June 22, 2025
Rating:

There’s no question about the greatness on display here, but even by 80s standards, the opening of this is a problematic watch in 2025.

Pirates of Silicon Valley

Poster for Pirates of Silicon Valley
Watched on June 22, 2025
Rating:

Hits all of the anecdotes and essential events through a series of incredibly brief, sometimes barely-connected vignettes. I know it’s going to be divisive but I personally like how sometimes the film can’t figure out a “show, don’t tell” way of highlighting the importance of a moment, so it will break the fourth wall and have characters pop out of a scene to talk about what we’re seeing. It’s cheap but it feels very 90s and very comforting. Also feels like it maybe could have benefited from a slightly better budget? This is especially true of the needle-drops (although Burnin’ Down the House to close out the film is an all-timer).

More entertaining than it should have been.

ØXN - The Trees They Do Grow High - YouTube

The Last Castle

Poster for The Last Castle
Watched on June 16, 2025
Rating:

I put this on because it was father’s day and someone said this was one of the great dad movies. Ehhh, not really. Better to describe it as a great American dad movie. Full of heroic martyrs and grizzled men weepily saluting the American flag and a logic that doesn’t bear any kind of scrutiny (first time I’ve ever seen a Trebuchet Ex Machina). Gandolfini’s delightfully scummy performance injects a bit of fun and saves it from being a complete boot-licking hagiography of Redford’s dickhead manipulative General Irwin.

Also, needed WAY more Lindo.

2025-06-16

What I’m Reading

Following Sue Jackson’s Big Book Summer Challenge, I’ve been reading Wayne A. Rebhorn’s translation of The Decameron. It’s a surprisingly breezy translation! And the individual stories are short enough that it always feels like I’m making progress.

What I’m Watching

What I’m Playing

My Switch 2 arrived and it’s been great! Except it only really has one game released so far, Mario Kart World, and the high-level play on this has already left me behind, so I’m going through old Switch games I missed first time around. Right now, I’m playing Jenny LeClue, a lovely fun cozy mystery.

What I’m Making

I recently upgraded my pizza oven into one with a much more spacious opening, so I’ve been trying to up my pizza game. As I type this, I’ve got 8 dough balls in the fridge on a 72 hour ferment. The last batch I made came out great, except note to self: the opening of the oven remains suuuuper hot even after the heat has been off for a while. Gave myself a pretty solid second-degree burn the last time I made pizza.