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!