Multi-repo documentation
The mechanism
Each documented component repository is a git submodule under external/,
pinned to an exact commit. Hugo mounts that submodule’s docs/ directory into
assets/, and a content adapter turns the files into pages.
docs/
├── config/_default/hugo.toml module mounts live here
├── external/
│ └── pancake/ submodule, pinned by commit
│ └── docs/*.md upstream files, never edited here
├content/pancake/
│ ├── _index.md section index, authored here
│ └── _content.gotmpl adapter: files -> pages
└── layouts/
├── _markup/render-link.html rewrites `*.md` cross-links
└── _td-content-after-header.html provenance banner
Reading order, once you want to change something:
config/_default/hugo.toml[module]— which submodule directory maps to which asset path.content/<name>/_content.gotmpl— how files become pages (titles, order, slugs).layouts/_markup/render-link.html— how repo-relative links are resolved.
Why not mount straight into content/
The obvious wiring — mount external/pancake/docs to content/pancake — builds,
but every imported page comes out wrong. Upstream Markdown has no Hugo front matter,
and Hugo does not derive a title from the filename: .Title is simply empty. The
result is a blank <title>, an empty Docsy <h1> directly above the document’s own
# Heading, and blank sidebar labels.
The three ways out are to add front matter to the component repo (which pushes Hugo’s
weight: keys into a Java or Python repository), to override several Docsy templates
with a filename fallback, or to route the files through assets/ and synthesise the
pages. This site does the third. The adapter reads each file’s own first # H1 as the
title and strips it from the body so Docsy’s heading is the only one.
Why not symlinks
The kernelci.org
site — the best-known
example of this pattern — wires submodules in with tracked relative symlinks
(content/en/components/kci-dev → ../../../external/kci-dev/docs). That works, but
symlinked directories inside content/ are a fragile corner of Hugo that maintainers
have discussed removing outright, and that site is still pinned to Hugo 0.97.3. Module
mounts reach the same place through a supported path.
Adding a repository
Add the submodule, tracking a branch so
./site updatecan follow it:git submodule add -b main https://github.com/agstack/<repo>.git external/<repo>Mount its docs into
assets/inconfig/_default/hugo.toml:[[module.mounts]] source = "external/<repo>/docs" target = "assets/imported/<repo>"Do not remove the
content→contentandassets→assetsmounts above it. Declaring any mount for a component replaces Hugo’s default for that component, so both of the site’s own directories have to be listed explicitly or they vanish from the build — silently, since an absent mount is not an error. This bites hardest onassets/: the symptom is not a missing page but the theme’s own stylesheet and logo quietly winning, becauseassets/scss/_variables_project.scssandassets/icons/logo.svgare simply not there to override them.Copy the adapter and adjust the variables at the top —
$repo,$branch,$assetDir,$sectionDir, and$license/$licenseFile— plus the$orderlist that controls sidebar order:mkdir -p content/<repo> cp content/pancake/_content.gotmpl content/<repo>/$sectionDirmust match the adapter’s own directory. It is what repoints Docsy’s “View page source” and “Edit this page” links at the component repo; get it wrong and those links send readers to the adapter template instead of the Markdown they are reading.$licenseis the source repository’s SPDX identifier. Imported pages are not covered by this site’s CC BY-SA 4.0, so every page in the section states its own licence in the provenance banner — see Licensing .Write a section index at
content/<repo>/_index.mdwith atitle,weight, and adescription. This file is authored here, not imported.Build and check —
./site checkfails on unresolved cross-links:./site check
Keeping imports fresh
A submodule pins a commit, so imported docs would otherwise stay frozen at whatever
the component repo looked like when it was added. Dependabot’s gitsubmodule
ecosystem opens a weekly PR per submodule bumping the pointer; the diff is the
upstream documentation change, which makes it a useful editorial checkpoint. See
.github/dependabot.yml.
To bump everything by hand:
./site update # git submodule update --remote --merge -- external
Known costs
Worth knowing before adding the sixth repository:
- Cross-link rewriting is heuristic.
render-link.htmlmapsFOO.mdto the slug the adapter would have produced. Links into subdirectories of a component’sdocs/tree are not handled — the adapter globs one level (*.md). - Images need their own mount. The adapter handles Markdown only; a component repo
with
docs/images/needs a second mount intostatic/, and its relative image paths then no longer match.pancakehas no images, so this scaffold does not demonstrate a fix. - Cloning without submodules fails quietly.
git clonealone yields an emptyexternal/. The adapters callerrorfon an empty mount to turn that into a build failure rather than a site with missing sections../site initis the real fix locally; CI checks out withsubmodules: recursivefor the same reason. - No content transformation on the way in. Unlike a sync script, mounts are
read-only: badges, GitHub-specific admonitions and
README-style intros arrive as-is. Heading levels in particular are upstream’s: a document that uses#for every major section renders a page full of<h1>s, and the adapter only lifts the leading one. - Docsy’s “Create child page” link is wrong on imported sections. It prefills a
Hugo front-matter stub into the component repo’s
docs/directory, which is exactly the coupling this setup avoids. The “View page source” and “Edit this page” links beside it are correct; only this one should be ignored. .Fileis not nil on generated pages. It points at the adapter template, so any theme feature keyed off.Fileneeds the same remap treatment thatpath_base_for_github_subdirgives the repository links.