Markdown versions of all docs pages are available by appending .md to any docs URL.
Versions and sections
The params.versions and params.sections contract every consumer config must follow.
Two params drive every version-aware and section-aware behavior in this module:
the version dropdown and its mobile chips, the section selector, the left nav,
the version banner, noindex on superseded versions, the search “other
versions” filter, {{< version-cards >}}, {{< version >}} gating, and
link/link-hextra version inference.
There is exactly one list of versions, and sections are a flat registry of keys. Write it the same way in every repo. The shape below is the contract; the only thing that varies between consumers is TOML versus YAML syntax.
The pattern
# hugo-<product>.toml (solo-io/docs)
# THE version list. Newest first — TOML order is release order, and several
# readers take "the first entry" to mean "the newest version".
[[params.versions]]
version = "latest" # canonical version
linkVersion = "latest" # URL segment (may differ)
dropdown = "2026.8.1 (latest)" # menu label; whitespace = hidden
sections = ["kubernetes", "standalone"] # which sections ship it
[[params.versions]]
version = "2.3.x"
linkVersion = "2.3.x"
dropdown = "2.3.x"
sections = ["kubernetes"]
# Section registry, ALPHABETICAL. A section exists because it is a key here.
[params.sections.kubernetes]
[params.sections.standalone]# hugo.yaml (agentgateway.dev, kgateway.dev, kagent.dev)
params:
versions:
- version: "1.5.x"
linkVersion: "main"
dropdown: "main"
sections: ["kubernetes", "standalone"]
- version: "1.4.x"
linkVersion: "latest"
dropdown: "1.4 (latest)"
sections: ["kubernetes", "standalone"]
sections:
kubernetes: {}
standalone: {}An empty table ([params.sections.x]) and an empty map (x: {}) are the same
thing: registration with no options. That is the normal case.
Rules
One entry per version, listing every section it applies to — never one entry per section/version pair.
latestshipping in two sections is a single entry tagged with both. Two entries sharing alinkVersionis a config error: the dropdown would offer the same version twice, and any resolver matching a URL segment bylinkVersionbecomes order-dependent.Tag every entry explicitly, even when the tags are identical — for a product that registers sections at all. A product with no
params.sectionshas nothing to tag, and leavessectionsoff entirely (that is most of them:agentregistry,istio,kagent,ambientmesh.io). Where sections do exist, omittingsectionsmeans “every section”, which is a live footgun: add a section later and every untagged version silently claims to exist there. That is not hypothetical — it put/test/nested/v3/in a version dropdown for a tree that was never built (CHANGELOG [0.2.2]). Explicit tags make it unreachable, and they cost nothing: adding them where the sets already match changes no rendered output.Newest first. With no
mainversion, the description lint and the book pipeline both read the first entry as “the newest tree”.Register sections alphabetically. The theme sorts keys itself, so order is cosmetic — which is exactly why it should be consistent rather than arbitrary.
A section with no content needs
externalURL.kgateway.devregistersagentgatewaypurely so its selector can point off-site; it has no/docs/agentgateway/tree, so there is nothing to link to internally:sections: agentgateway: externalURL: "https://agentgateway.dev/docs/kubernetes/latest/" envoy: {}titleis the other supported key, overriding the label that otherwise comes from the section’s landing page title.An imported module’s sections come along with its content. Hugo deep-merges an imported module’s params into the project’s, so a consumer that imports a module for its FILES also inherits whatever sections that module declares — and an empty table cannot delete a merged key.
solo-io/docsimportsgithub.com/kgateway-dev/kgateway.devfor conrefs, snippets, pages, images and the glossary in both itskgatewayandgatewayproducts, and so inherits that module’ssections.envoy.Nothing is required of the consumer. Section detection is by position (
utils/section-segment.html), so an inherited key that matches no/<section>/<version>/path is inert — including where the same word appears as an ordinary content directory, which is the case here (content/en/kgateway/2.3.x/setup/customize/envoy/). The theme does not report it either, because it cannot distinguish an inherited key from a typo’d one: Hugo exposes no param provenance to templates.If you want the registry to state the truth anyway, stock Hugo config merge control declines the inheritance —
[params.sections]with_merge = "none". Measured on the hub: it changes zero bytes of output, so treat it as documentation, not a fix.A registered section should nest its version trees, or set
externalURL. A section backed only by a landing page still works — its selector entry links the landing page, and itssectionstags still scope version dropdowns — but no tree answers/<section>/<version>/URLs, which usually means a half-done migration. The theme warns (extras-section-hollow) because that state is otherwise silent: the build stays green and the selector looks right. If the scope-only shape is intended, allowlist the warning; the bundled fixture’sdemoandaltsections use the shape deliberately and do exactly that.A key with nothing behind it at all (no landing page, no
externalURL) is inert, and deliberately NOT warned about: that is the shape an inherited key takes (rule 6), and the theme cannot tell an inherited key from a typo’d one.Sections do not require versions. A site can ship parallel doc sets with no version axis at all, and it registers them exactly as a versioned product does — one key per doc set, no
params.versionsanywhere.kagent.devis the worked example:content/kagent/andcontent/kmcp/, two doc sets, no releases to version.params: # No params.versions. Registering sections is all a version-less site needs. sections: kagent: icon: icons/nav-kagent.svg # optional, see below kmcp: icon: icons/nav-kmcp.svg product: "Docs" # label on the selector button; else it falls back to site.TitleVersion-less sites get the same three behaviors a versioned one does: the navbar section selector, the mobile drawer’s section chips, and a left nav rooted at the current doc set rather than at
site.Home. That last one is the reason to bother — without a section registry the theme roots the tree atsite.Homewhenever the home page istype: docs(which acascadecommonly makes true), so every doc set renders on every page, merged.Two consequences worth knowing:
- Detection is positional, and version-less sites use position ALONE. A
section must sit exactly one segment below the docs root —
/<section>/,/docs/<section>/,/<product>/<section>/,/<lang>/<section>/, read offsite.Home.RelPermalink. A versioned site additionally recognizes a section directly above a version tree, or as a trailing landing segment; neither rule means anything without versions, and the trailing-segment one is actively wrong there — it would match any page whose last segment happens to share a section’s name, at any depth. - A page in no doc set gets no left nav. Taxonomy terms and any top-level
directory that is not a registered section have no single tree to show, so
the nav is suppressed rather than falling back to the merged
site.Hometree. This applies only once 2+ sections are registered; a version-less site with no sections (agentregistry.dev,ambientmesh.io) keepssite.Homerooting, which is correct there — every page belongs to the one tree.
Do not add a
params.versionsentry to a version-less site to make something else work. One entry moves the whole site onto the versioned code paths, where a section is only recognized above a version tree — of which there are none.What a version-less site does not get: the version dropdown (there is nothing to put in it), and the
extras-section-hollowwarning, which is vacuous when there are no versions to nest.- Detection is positional, and version-less sites use position ALONE. A
section must sit exactly one segment below the docs root —
A section may set an
icon, and it is optional per section. It appears on the navbar selector entry and on the matching mobile chip. The value is resolved byutils/render-icon.html, the same resolver behind left-nav icons and section-card icons, so a section accepts every source the rest of the theme does. First match wins, in this order:Value Resolved as foo.svgpresent understatic/inlined with readFileicons/foo.svgresolvable inassets/inlined via resources.Geta key in site.Data.icons(e.g.solo)Hextra’s named-icon partial anything else (e.g. rocket_launch)a Material Icons ligature static/deliberately precedesassets/, because every call site resolved static first before this was one shared partial, and reordering would change which file an existingicon:value picks up.Two things to know. A value that matches nothing renders as a Material Icons ligature, which shows the literal string when the font has no glyph for it — that is the failure mode for a typo, not a build error. And mixing icon’d with icon-less sections in one selector is allowed; entries without an icon simply render as text, and a selector where no section sets one is byte-for-byte what it was before icons existed.
The selector’s BUTTON can name the current section, or carry a fixed title, instead of the product name. By default it shows
params.product, falling back tosite.Title. That suits a short name, crowds the navbar with a long one, and either way never changes as the reader moves around. Two keys adjust it, and they compose:[params.sectionDropdown] showCurrentSection = true # the button names the section being read title = "Docs" # what it falls back to when none isparams: sectionDropdown: showCurrentSection: true title: DocsshowCurrentSectionis the version dropdown’s own behavior applied to sections: closed, the control reports where you are, and the menu is the list you move with. On docs-hub agentgateway the button reads “Kubernetes” under/kubernetes/and “Standalone” under/standalone/.Set
titleas well if you setshowCurrentSection. Pages with no current section are normal, not edge cases: a product landing page renders the selector with every entry inactive, because the reader is above the sections choosing one. Resolution falls through in this order:Page Button shows inside a section, showCurrentSectionsetthat section’s label in no section, titlesetthe title in no section, no title params.product, thensite.TitleEither key is button-only. The menu entries always carry each section’s own label, so the sections stay named where the reader is choosing between them. An empty
titleis treated as unset rather than as a blank button; a bare chevron would give the reader nothing to aim at.Neither key touches the version dropdown. It drops its own product-name prefix whenever a selector renders at all, so it reads a bare “2026.8.1 (latest)” regardless of what the section button says. That means a product registering sections shows its name in the navbar through the sidebar logo rather than through either control, which is the intent: two controls side by side each prefixed with a long product name is most of the row spent on naming.
Do not re-document this in a consumer config. Point at this file. Every repo grew its own explanation of the same rules, and they drifted — one still claimed a section carried its own version list a release after that was removed. A one-line comment naming this section ages better than a paragraph.
What NOT to write
# REMOVED in 0.2.2 — a second, hand-maintained version list per section.
[[params.sections.standalone.versions]]
version = "latest"Nothing reads it, so it fails silently rather than erroring. It caused four
distinct bugs while it existed: link/link-hextra dropped the version segment
from 311 hrefs, the search version filter went inert, the noindex partial
emitted nothing at all, and the section-link version remap was dead code for a
release. Move those versions into params.versions and tag them.
# INERT on a same-product entry — every reader constructs the href instead,
# which is what preserves the reader's current page across a version switch.
[[params.versions]]
url = "https://docs.solo.io/agentgateway/latest"Another product’s versions go in params.relatedDocs, which is the one place a
version URL is written by hand.
Where the rules live in code
| Behavior | File |
|---|---|
| Is this URL segment a section? | utils/section-segment.html |
| Which versions apply to a section? | utils/resolve-section-versions.html |
| Which entry does this URL segment match? | utils/match-version-entry.html |
| What section/version is this page in? | utils/version-root.html |
| The section selector’s items | utils/resolve-sections.html |
| Other products’ version groups | utils/resolve-related-docs.html |
Read config through these rather than reaching into site.Params directly.
tests/link-hextra-shapes.spec.ts fails the build if any template reads a
per-section versions list again.