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!