#15214·hugo

Add some pre/post build hooks + support for multiple publish destinations

Author: bepCreated Aug 16, 2026Updated Sep 10, 2026
LabelsProposal

[!CAUTION] This proposal is very much a work in progress.

  • This proposal comes out of #15213 -- but I have seen many questions about something similar, so I'm convinced this should be useful in general.
  • The examples below also uses #15212 which isn't implemented and #15209 and #15221 which isn't merged.

The names used in this proposal needs to be refined, but the general idea is that we introduce a new top-level folder inside layouts called _hooks, so you would have something like this in layouts/_hooks:

build-pre.gotmpl
build-post.gotmpl
build-post._cloudflareworkers_.gotmpl
  • Every build-pre hook above will run once before the builds starts and every build-post hook runs after the build ends.
  • We want to to allow the main project to e.g. override hooks of a module (this is how the templates works today), so the recommendation is for modules to add specific names as custom identifiers in the filenames, e.g. build-post._cloudflareworkersanalytics_.gotmpl. The last example would still be overridable from the project, but that's what we want (to disable a hook you would then just create an empty hook file with the same name).

The templates will be executed in mount order (so project first). A typical use case, though, is to have hooks that e.g. builds edge functions and then a hook that collects them into a Cloudflare main script, so we need a way to group the execution of these hook. One idea would be to add a naming convention with an optional hyphen and group name in the custom identifier:

build-post._cloudflareworkers_.gotmpl
build-post._cloudflareworkers-collect_.gotmpl

And if we then execute the groups in lexicographical order, the groups above would be executed in the order "" (empty) and then collect.

We also need a way to pass data upwards in the hook chain, in the examples below an .Append method (for now) on the template context that takes a key and a value (any) and stores it in a map of slices.

With that, a build-post._cloudflareworkersanalytics_.gotmpl could look like:

{{ .Append "cloudflare-workers" (dict
        "name" "analytics"
        "import" "workers/analytics.js"
        "params" (dict "measurementID" hugo.Sites.Default.Params.gaMeasurementID)
 ) }}

And build-post._georedirect_.gotmpl:

{{ .Append "cloudflare-workers" (dict "name" "georedirect" "import" "workers/georedirect.js") }}

And finally build-post._cloudflareworkers-collect_.gotmpl:

{{ $handlers := .Get "cloudflare-workers" }}
{{ if not $handlers }}{{ return }}{{ end }}

{{ $imports := slice }}
{{ $chain := slice }}
{{ $params := dict }}
{{ range $handlers }}
      {{ $imports = $imports | append (printf "import %s from '%s';" .name .import) }}
      {{ $chain = $chain | append (printf "(await %s(request, env, ctx))" .name) }}
      {{ $params = merge $params (dict .name (or .params dict)) }}
{{ end }}

{{ $src := printf `%s
export default {
      async fetch(request, env, ctx) {
              return %s ?? env.ASSETS.fetch(request);
      },
};` (delimit $imports "\n") (delimit $chain " ?? ") }}

{{ $opts := dict "targetPath" "_worker.js" "format" "esm" "minify" (not hugo.IsDevelopment) "params" $params }}
{{ resources.FromString "workers/_entry.js" $src | js.Build $opts | resources.Publish "default" }}
{{ resources.FromString ".assetsignore" "_worker.js\n" | resources.Publish "default" }}

For Netlify's edge functions, it would be more natural to skip the collect step and have each hook build and publish its own edge function.

These templates will not render anything (any printf will be discarded), but warnf and errorf would work as expected.

As to the names set in Publish, I imagine we add something like this to hugo.toml:

[publishers]
[publishers.default]
destination = ":public"
[publishers.cloudflare-worker]
destination = "worker"
[publishers.netlify-edge]
destination = ".netlify/edge-functions"
  • The special:public placeholder above refers to the current publishDir value.
  • For this to be useful, we need themes to be able to contribute new keys to this map, so we need to set a merge strategy that allows this.
  • The main project can use absolute paths, others must be relative (to the working dir).
  • There's an assumption in the above that we may get non-disk type of destination in the future, which I guess means the get a type discriminator of such.

Hook context

  • Index: 0 zero based index of the hooks being executed.
  • Len: The total number of hooks being executed.
  • OutputFormat: The current output format. Note that the pages you get via e.g. site.RegularPages will be shifted to this output format, so use this if you e.g. want to generate redirects for HTML only.
  • Site: The current Site. You can access all sites via hugo.Sites.
  • Contribute <key> <value>: Contribute the value, typically used to pass data to hooks in later groups. This is thread safe.
  • Contributions <key>: Contributions gets the slice with the key key ordered by the mount order (mount position, project first, then name) of the hook template of the hook that contributed.

TODO

  • The above outlines 1 execution before and after a build. That is in line with the build-pre hook name, but I think that is a mistake and would be too limiting. Main reason is how we shift out the page output formats (to save memory, mostly). That means that you cannot do common tasks like generating redirect files. Instead I think I will revert to my first instinct; we keep the idea of one template name with an optional custom identifier (so no language codes in the name), but we make it run for pre/post render hooks for iteration in Hugo's main render loop (so every site + output format combo). I suggest we also rename the hooks to render-pre and render-post. I was initially afraid that that would confuse them with the Markdown render hooks, but I guess we can/should be able to have render hooks on different levels.

That means that for a redirect hook I could do something ala:

{{ if ne .OutputFormat.Name "html" }}
   {{ return }}
{{ end }}

For hooks that want to run once I suggest we add a Index field to the context:

{{/* Only run for Index 0 */}}
{{ if .Index }}
   {{ return }}
{{ end }}

Or:

{{/* Only run for language "en" */}}
{{ if ne .Site.Language.Name "en" }}
   {{ return }}
{{ end }}
  • The above indicates a serialised order of the processing of the hooks. This throws a lot of performance out the window, assuming you'd to have multiple hooks process e.g. 50K pages or do some massive JSON unmarshalling. I suspect it would be good enough to guarantee that the hook groups are processed in order. It should be fairly trivial for others to add some sorting of the output. This is hard to change once we release this.