hereticles

hereticles

heresy, ticles, and

26 May 2026

Point-in-time source links across Hugo modules

When referencing source code that I’d written, I wanted to be able to link to the file and have it reference the file at “that point in time.”

render-link.html

Assuming no bugs and that I have kept this site well maintained - that link should show the current version of my link render logic on hugo.

Why?

  • I might move my files around (in fact, I intend to move my /blog dir to /site at some point)
  • The code might have moved on.
  • I might one day, have removed the bit of code or functionality that I was pointing to

I had this issue enough times on other blogs and it annoyed me something fierce.

The linking itself is fairly straightforward, though there is a bunch of logic to figure out if it is link to source code:

1
2
3
{{- $repoUrl := or .Page.Params.repo_url "https://codeberg.org/hereticles/wordsonsand" -}}
{{- $commit := or .Page.Params.link_commit (and .Page.GitInfo .Page.GitInfo.Hash) -}}
<a href="{{ $repoUrl }}/src/commit/{{ $commit }}/{{ $fullRepoPath }}" {{ with .Title }}title="{{ . }}"{{ end }}>{{ $text }}</a>

The main problem with this approach is that code needs to be in the same repo. This was fine for a while - but I felt it was unreasonable to expect people to download the entire monorepo if they were only interested in a small part of it.

I considered ditching it. There were only a handful of posts that actually linked to source code, but I was convinced that it was worth it.

The plan

The core idea is to split out the repos, then put the blog posts in the repos with the code. We then bring all of these posts into one central place before publishing.

The core requirements were:

  • The commit id tagged to the post needs to come from the source repo
  • Ideally, live refresh should continue to work so you can edit files and see the changes live

hugo supports bringing in content from other places. However, GitInfo did not work for it.

The constraint throughout: whatever the solution, I wanted to add as few moving parts as possible.

stamp and stage pipeline

One solution I thought about was to implement a pipeline of some form. Each downstream repo would be put through a preprocessor, which would stamp file frontmatter with the commit id and repo url before bringing it into hugo.

It would then be a simple case of updating the templates and letting hugo render it.

It would be too complicated to maintain though:

  • script to “stamp” frontmatter
  • copying all the content across to one place
  • then, the usual build and deploy

It would also not support live refresh.

It didn’t quite resonate, so I let it sit.

content adapters

A few days ago, I decided to take another stab at solving this. Content adapters came up as one option. This could keep it all in hugo.

It would do the same as above - stamp it, then bring it in, but no extra script or pipelines to manage or maintain - hugo could do the whole thing.

However:

  • There is still the stamping step,
    • but it could be self-contained code in a repo
  • No live refresh on editing downstream posts

I was going to go ahead with this plan, before I decided to do one last check to see if hugo had improved their GitInfo support for external repos.

back to hugo submodule

When I checked whether hugo had fixed the issue with GitInfo in downstream modules, I found #5533 which was closed. I took it this as a good sign.

It took a bunch of trial and error to understand the following:

  • This only worked with git repos
    • local module mounting did not provide correct GitInfo
  • I discovered a bug (reported as #1492 )
    • it required the downstream go module to be at the root

I could work around these issues.

I had written a post for henge which seemed like the perfect candidate to test this on.

hugo.toml

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
[module]
  # other mounts

  # without this, only the downstream posts showed up
  [[module.mounts]]
    source = "content"
    target = "content"

  # only one downstream site right now
  [[module.imports]]
    path = 'codeberg.org/hereticles/henge'
    [[module.imports.mounts]]
      source = "site/content"
      target = "content"

for henge (oops, I can’t link to that one from here, with a commit id, but I’ll live)

I created a go.mod (don’t get excited - I added the full link manually) in the root (and will later move it into /site)

In the same repo, you will find site/content which has the henge endeavour as well as the post.

It worked. The only problem - it didn’t do live refresh.

There was an easy fix for it though:

1
HUGO_MODULE_REPLACEMENTS="codeberg.org/hereticles/henge -> ../../../henge" hugo server

I had the henge repo locally anyway, so it just worked.

I’ll put the hugo server command into a shell so that it can define all the mod replacements in there when I have more downstream ones.

Automating the build had a couple of steps.

refresh downstream modules on build

In the forgejo actions, , I added a step to get the module @ main, before building.

Otherwise, it’ll only pull in the version from the last hugo mod get

1
2
3
4
5
- name: Build
  working-directory: ./blog
  run: |
    hugo mod get codeberg.org/hereticles/henge@main
    hugo --minify --gc

trigger upstream build on content push

Triggering an upstream build on content changes was also easy:

icle-es.yaml

1
2
3
4
5
6
7
- name: Trigger icle-es blog rebuild
  run: |
    curl -X POST \
      -H "Authorization: token ${{ secrets.ICLE_ES_DEPLOY_TRIGGER_TOKEN }}" \
      -H "Content-Type: application/json" \
      https://codeberg.org/api/v1/repos/hereticles/icle-es/actions/workflows/deploy.yaml/dispatches \
      -d '{"ref": "main"}'

You’ll need to get an access token from your upstream repo and add it as a secret here.

Conclusion

The final solution met both requirements and as an added bonus has minimal maintenance burden.

Compared to a monorepo, however, I cannot reference code if it is in another “project.” I can work around this by linking it manually if the other repo content is already live.

Linking manually is trickier if the code is in the same repo because the post and the code is often published in the same PR, which is one of the reasons for this little bit of functionality.