How to integrate Site Pipeline with Hugo
Keep the hand-off boundary clear
The clean split is:
- Site Pipeline validates inputs, resolves publication paths, and writes the
staged tree under
site/.stage/ - Hugo renders pages, applies the theme, builds navigation, and serves or publishes the site
That means Hugo should consume:
- staged Markdown under
site/.stage/content/ - staged JSON under
site/.stage/data/ - staged assets under
site/.stage/static/
Hugo should not reconstruct provider state, infer routes from repository layout, or treat internal Python objects as renderer input. The staged tree is the supported downstream contract.
Minimal repository shape
Start with a normal Hugo site and add the stage root alongside it:
1site/
2 hugo.yaml
3 content/
4 data/
5 layouts/
6 static/
7 assets/
8 .stage/
9 content/
10 data/
11 static/
12 manifest.json
The local Hugo directories are still useful:
content/for consumer-owned landing pages, section roots, or cascadesdata/for helper files such as slug-to-repository mapslayouts/andassets/for renderer-specific templates and stylingstatic/for site-owned assets that are not staged by the pipeline
The main change from a vanilla hugo.yaml
Do not point contentDir directly at .stage/content. That throws away the
local content/, data/, and static/ roots that most Hugo sites still need.
Instead, switch to explicit mounts and merge the local roots with the staged roots:
1baseURL: https://docs.example.org/
2title: Example Docs
3
4module:
5 mounts:
6 # Keep consumer-authored section roots and cascades.
7 - source: content
8 target: content
9 # Add staged component and docs pages.
10 - source: .stage/content
11 target: content
12
13 # Expose pipeline-generated aggregate JSON files.
14 - source: .stage/data
15 target: data
16 # Keep consumer-owned helper data such as component_repos.json.
17 - source: data
18 target: data
19
20 # Publish staged component assets.
21 - source: .stage/static
22 target: static
23 # Keep site-owned static files.
24 - source: static
25 target: static
26
27 # Renderer-owned templates and Hugo Pipes inputs.
28 - source: layouts
29 target: layouts
30 - source: assets
31 target: assets
Two important details:
sourceis the real directory on disk.targetis the Hugo virtual root where that directory should appear.- Entries where
source == targetare intentional. Once you opt into explicit mounts, Hugo no longer assumes its implicit default roots. Any local root you still want Hugo to see must be mounted again explicitly.
If your site imports a theme as a Hugo module, keep the existing module.imports
configuration and add these mounts alongside it.
Keep the Hugo version boundary explicit
Site Pipeline writes renderer-neutral files and does not execute Hugo, so it does not impose a Hugo version requirement. The sibling Buildish site currently declares Hugo Extended 0.160.1 as its minimum and uses the integration pattern shown here. Treat that as a known consumer baseline, not as a Site Pipeline minimum; your theme and Hugo configuration may require a different version.
Recommended local development loop
Use Site Pipeline to keep the stage fresh, and let Hugo keep owning the preview server and live reload loop.
Validation and one-off stage refresh:
1site-pipeline check --workspace-root . --catalog site/catalog.yaml
2site-pipeline build --workspace-root . --catalog site/catalog.yaml
Hugo preview:
1hugo server --source site --config site/hugo.yaml
Live editing usually means two terminals:
1# terminal 1
2site-pipeline watch --workspace-root . --catalog site/catalog.yaml
3
4# terminal 2
5hugo server --source site --config site/hugo.yaml
There is intentionally no site-pipeline serve command. The pipeline owns the
staged output; the renderer owns the dev server.
Optional: wrap watch + Hugo in a serve-local target
Once the two-terminal loop is working, many teams add one consumer-side target
that coordinates both processes. The Buildish site does exactly that in its
own site/Makefile.
The pattern is straightforward:
- start
site-pipeline watchin the background - write watch events to a temporary JSONL file
- wait until the watcher reports a ready stage
- launch
hugo server - kill the watcher and delete the temporary events file when Hugo exits
A compact sketch looks like this:
1serve-local:
2 @watch_pid=''; events_file=''; \
3 cleanup() { status=$$?; if [ -n "$$watch_pid" ] && kill -0 "$$watch_pid" 2>/dev/null; then kill "$$watch_pid" 2>/dev/null || true; wait "$$watch_pid" 2>/dev/null || true; fi; if [ -n "$$events_file" ]; then rm -f "$$events_file"; fi; exit $$status; }; \
4 trap cleanup EXIT INT TERM; \
5 events_file="$$(mktemp .watch-events.XXXXXX.jsonl)"; \
6 site-pipeline watch --workspace-root . --catalog site/catalog.yaml --unstable-events jsonl --unstable-events-output "$$events_file" & \
7 watch_pid=$$!; \
8 wait-for-watch-ready --events-file "$$events_file" --pid "$$watch_pid" --timeout 60; \
9 hugo server --source site --config site/hugo.yaml --renderToMemory
Two details matter in practice:
- wait for watcher readiness before starting Hugo, otherwise the first page load may race the initial stage
- make cleanup explicit, otherwise the watch process may keep running after the preview server exits
wait-for-watch-ready is only a placeholder command name in this sketch. In the
Buildish site the consumer repo owns that helper
as site/scripts/wait_for_watch_ready.py, and the helper waits for the first
usable ready event in the JSONL stream written by --unstable-events-output.
If you do not already have such a helper, add a small wrapper that blocks until
the first ready event before launching Hugo.
This section is intentionally about consumer ergonomics, not about the pipeline contract itself. The contract is still the same: Site Pipeline stages content, and Hugo serves the rendered site.
Use manifest.json for discovery, not for templates
site/.stage/manifest.json is the discovery entry point for wrapper scripts,
build tooling, or deployment adapters. It tells you where the stage roots and
aggregate files live for this stage.
Do not treat it as normal template data. In Hugo templates, render from the staged pages and the aggregate JSON files instead.
Read staged front matter directly from pages
Every staged page carries normalized pipeline front matter. One real example
looks like this:
1pipeline:
2 component:
3 slug: site-pipeline
4 displayName: Site Pipeline
5 publication:
6 paths:
7 component: /components/site-pipeline/
8 docs: /components/site-pipeline/development/
9 page:
10 kind: component-page
11 path: /components/site-pipeline/_index
12 canonicalUrl: https://docs.example.org/components/site-pipeline/_index
That means a Hugo layout can learn the component identity, route shape, and page kind without reverse-engineering file paths.
One practical template pattern is:
1{{ $pipeline := index .Params "pipeline" }}
2{{ with $pipeline }}
3 {{ $component := index . "component" }}
4 {{ $page := index . "page" }}
5 <p class="eyebrow">{{ or (index $component "displayName") (humanize (index $component "slug")) }}</p>
6 {{ with index $page "version" }}
7 <p class="text-muted">Version: {{ index . "label" }}</p>
8 {{ end }}
9{{ end }}
This is the simplest way to make page chrome react to the staged publication model.
Normalize component context once in a helper partial
The Buildish site uses a dedicated partial to normalize page metadata before any breadcrumb, list, or shortcode tries to render it.
That helper does three useful things:
- reads
pipeline.componentandpipeline.pagefrom front matter - normalizes paths from either
component.pathsorcomponent.publication.paths - joins consumer-owned helper data, such as a slug-to-repository map in
data/component_repos.json
That pattern is worth copying. It keeps compatibility logic in one place and makes the rest of the templates much simpler.
Make title and description fallbacks explicit
Landing pages, section cards, and breadcrumbs should prefer authored page metadata first, then fall back to pipeline-derived metadata.
The Buildish site uses this pattern in its resolved title and description partials:
- prefer
.Titleand.Descriptionwhen authors set them - then try
pipeline.page.derivedTitleandpipeline.page.derivedDescription - then fall back to
pipeline.component.displayName - finally fall back to a slug or path-derived label
Using one shared fallback helper for page <title>, breadcrumb labels, and
section cards prevents blank navigation labels when staged pages rely on derived
metadata.
Read aggregate JSON through hugo.Data
The staged data/*.json files answer cross-page questions that page front
matter cannot answer by itself.
Useful aggregates include:
components.jsonfor component inventories and publication pathscontent-index.jsonfor cross-page discoveryroutes.jsonfor the published route inventoryredirects.jsonfor redirect behavior
In modern Hugo, prefer hugo.Data over .Site.Data.
One practical component-grid example is:
1{{ $components := index (index hugo.Data "components") "items" }}
2{{ range $component := $components }}
3 {{ $paths := index (index $component "publication") "paths" }}
4 <div class="entry">
5 <h3><a href="{{ index $paths "component" }}">{{ index $component "displayName" }}</a></h3>
6 </div>
7{{ end }}
For file names that contain a hyphen, use index explicitly:
1{{ $contentIndex := index (index hugo.Data "content-index") "items" }}
2{{ $currentSlug := index (index (index .Params "pipeline") "component") "slug" }}
3<ul>
4 {{ range $entry := $contentIndex }}
5 {{ if eq (index $entry "componentSlug") $currentSlug }}
6 <li><a href="{{ index $entry "path" }}">{{ index $entry "title" }}</a></li>
7 {{ end }}
8 {{ end }}
9</ul>
This is a good way to build component landing pages, section indexes, or custom search/bootstrap data without scanning the whole content tree yourself.
Keep consumer-owned data separate from pipeline-owned data
The Buildish site keeps a local site/data/component_repos.json file with a
simple component-slug to repository-URL mapping. The template helper merges that
consumer-owned file with the staged pipeline metadata.
That is a useful general rule:
- let the pipeline own normalized publication and lifecycle metadata
- let the consumer site own renderer-specific or branding-specific helper data
- avoid filename collisions between local
data/files and staged aggregate files
Turn repeated component links into shortcodes
If authors frequently need links such as “Overview”, “Read docs”, or “Source repository”, do not hard-code those URLs into Markdown. Read them from pipeline metadata.
The Buildish site wraps this in shortcodes backed by the shared component context helper. A small example looks like this:
1{{- $component := or .Page.Params.sitePipelineComponent (index (index .Page.Params "pipeline") "component") -}}
2{{- $paths := index (index $component "publication") "paths" -}}
3{{- $kind := or (.Get "kind") "docs" -}}
4{{- $label := or (.Get "label") "Read docs" -}}
5{{- $href := index $paths $kind -}}
6{{- with $href -}}
7 <a href="{{ . }}">{{ $label }}</a>
8{{- end -}}
This keeps authored Markdown clean and makes route changes flow through the renderer automatically when publication paths change.
Render release summaries from staged metadata
If your component metadata includes version lines or latest-stable information, prefer a small helper or shortcode that reads the staged artifact metadata instead of hard-coding release banners into content.
The Buildish site uses this pattern to render release summaries from
pipeline.component.artifacts[*].releaseLines and latestStable, while still
rendering nothing when a component does not publish release-line metadata yet.
That “optional when absent” behavior is important when one renderer template is shared by both versioned and non-versioned components.
Deployment hand-off
For CI or publication jobs, the simplest contract is still:
1site-pipeline build --workspace-root . --catalog site/catalog.yaml
2hugo --source site --config site/hugo.yaml
Deployment adapters may additionally read manifest.json, routes.json, or
redirects.json, but the Hugo render itself should continue to use staged pages,
staged data, and staged assets as its direct inputs.
What to copy first
If you are integrating Hugo for the first time, the highest-value pieces to copy from the Buildish site are:
- explicit
module.mountsfor local + staged roots - one shared component-context partial
- one shared title/description fallback helper
- one or two shortcodes for component-aware links
- one component-grid or section-index template that reads
components.jsonorcontent-index.json
That gives you a working renderer integration without pushing Hugo-specific logic back into the pipeline or hard-coding public URLs in page content.