Skip to content

This is the multi-page printable view of this section. .

Return to the regular view of this page.

Customization

Site-level configuration — brand, navigation, layout, search, languages, versions, print and agent output.

This section covers site-level configuration: the parameters in hugo.yml, the data files under data/, and the style entry points under assets/. Writing an individual page and its front matter is in Authoring.

Find it by what you want to change

What you want to change Page
Site name, logo, favicon Brand and appearance
Colours, light and dark mode, fonts Brand and appearance
The navbar menu and its dropdowns Navigation and menus
Sidebar width, icon density, outline depth Layouts and page types
The home page and landing pages Home and landing pages
Full-text search and its index scope Search
What appears in the command palette Command palette
Keyboard shortcuts Keyboard navigation
Adding a language Languages
Multi-version sites and the archive banner Versions
Tags and categories Taxonomies
Edit this page, last modified, contributors Repository links and page info
Print and whole-chapter export Print
llms.txt and the per-page .md output AI-agent support
A parameter’s type and default Configuration

Comments, analytics and deployment need an external service; they are in Operations.

1 - Configuration

The one place site parameters are defined — every key the theme reads, with its type, default and the guide that covers it.

This is the single home of site parameters. Every key the theme reads has a row in one of the tables below, giving its type, default and a one-line description, and linking to the guide that covers it. The guides give pasteable snippets and never repeat the definitions. Page-level parameters (front matter) are in Page parameters.

The tables are grouped by function, one ## each, and the anchors are referenceable — for example /docs/customize/config/#sidebar. An empty default column means the theme has no default: leave the key out and the feature is off.

The layers of hugo.yml

An OINK site’s configuration has four kinds of key, and which layer you change depends on what you are changing:

Layer Examples Who defines it
Hugo’s own top-level keys baseURL title languages markup outputs taxonomies module Hugo itself; the behaviour is on gohugo.io
Top-level params logo offline_search github_repo version page_width comments Site-level options the theme reads
params.ui.* navbar_enabled sidebar_width_min typography pager_types The shell, navigation and reading interface
params.<runtime> mermaid plantuml drawio markmap Each content runtime’s own switch and endpoint

A minimal working configuration needs only the first two layers:

hugo.yml
title: Product Docs
baseURL: https://docs.example.com/
defaultContentLanguage: en
enableGitInfo: true

module:
  imports:
    - path: github.com/pgsty/oink
  hugoVersion:
    extended: true
    min: 0.160.1

params:
  offline_search: true
  github_repo: https://github.com/example/product-docs

Configuration principles

  • The theme’s defaults are conservative; write only the keys you change. Interactive features (local search, image zoom, comments, feedback, the light/dark menu) are off by default, because the theme does not make policy for a site. Trimming a “complete configuration” leaves behind keys you never needed more readily than adding them as you go.

  • There is no theme master switch. There is no oink.enabled, no params.oink.* namespace, and no option that swaps between a “Docsy shell” and an “OINK shell”. A switch you cannot find on this page does not exist.

  • An invalid value warns and falls back to the documented default. params.ui.typography: solarized reports invalid params.ui.typography "solarized" (allowed: technical | system) -- using "technical" and the site still builds; footer_style: thin, page_width: huge and section_index: grid behave the same way. One typo therefore degrades one setting instead of serving HTTP 500 on every URL under hugo server. It cannot ship silently either: every publishing gate builds with --panicOnWarning, which turns the warning back into a hard failure.

  • One warning keeps the value instead of dropping it. A theme_color the theme reads as below AA body text (4.5:1) against its own canvas still ships — a custom canvas or a brand mandate is the author’s call — but says so, and prints the ignoreLogs id that silences it. Treat it as advice, not a rejection: the fix is either a darker color or one line of configuration, and the publishing gate stops the build until you choose. Only an unparseable hex is dropped outright, and that one falls back to the default palette like every other invalid value.

  • The theme itself never stops the build. Its templates contain no errorf at all: every invalid value takes the warn-and-fall-back path above. A feature needing an external endpoint — PlantUML, Draw.io, Algolia — warns and stays off when the endpoint is missing, because the theme never connects to a public service on your behalf. An incomplete upstream attribution warns and omits the whole notice, because a partial one reads exactly like a complete one. What does stop a build comes from Hugo rather than the theme: a content reference that resolves to nothing, and a Hugo older than module.hugoVersion.min.

Page-level override precedence

Hugo’s .Param lookup lets most parameters be overridden per page, highest precedence first:

  1. The page’s own front matter;
  2. cascade in an ancestor section’s _index.md (nearer wins);
  3. Site params.

Drop the ui. prefix when writing it in front matter. The site’s params.ui.scroll_spy is simply scroll_spy on a page. A ui: block in front matter is read by nobody and reported by nobody, so a setting that seems to have no effect is worth checking against Page parameters first.

content/docs/wide-reference.md
---
title: Wide reference
page_width: wide
navbar_enabled: false
footer_style: slim
scroll_spy: true
---

A cascade sets a whole subtree at once:

content/docs/_index.md
---
title: Docs
cascade:
  type: docs
  footer_style: slim
  feedback: true
---

Overrides are for real differences in content. Rebuilding a visual system page by page tends to fall out of step at the next theme upgrade.

The three Goldmark prerequisites

Hugo does not merge a theme module’s markup configuration into the site, so these three must be in the site’s own hugo.yml, or attribute lines, component HTML and mathematics all stop working:

hugo.yml
markup:
  goldmark:
    parser:
      # block images may carry an attribute line ({caption=…}, numbered figures)
      wrapStandAloneImageWithinParagraph: false
      attribute:
        block: true
    renderer:
      # HTML emitted by `{{% … %}}` shortcodes has to survive
      unsafe: true
    extensions:
      passthrough:
        enable: true
        delimiters:
          block: [['\[', '\]'], ['$$', '$$']]
          inline: [['\(', '\)']]
  highlight:
    # class-based highlighting, so light and dark can each have a palette
    noClasses: false
  tableOfContents:
    endLevel: 4

Without attribute.block, {.fields}, {.steps} and {caption=…} render as literal text; without passthrough, \(x\) never becomes a formula; without unsafe, the structure of steps and cards is escaped away.

renderer.unsafe: true also lets raw HTML in Markdown through. It is meant for trusted authors, not as a submission filter. Where content comes from untrusted sources, the review belongs in the contribution process.

Site identity and brand

Hugo’s own top-level keys:

title , string
Site name, shown in the navbar, <title> and the footer
baseURL , string
The production domain; include the path segment for a subpath deployment
copyright , string
Fallback for the copyright line, rendered as HTML when params.copyright is unset
enableGitInfo , boolean , defaultfalse
Required before “last modified” and commit information exist
enableRobotsTXT , boolean , defaultfalse
Generates robots.txt
enableEmoji , boolean , defaultfalse
Allows :smile: shortcodes

Theme parameters:

params.logo , string , defaulticons/logo.svg
Brand mark; may point at an assets/ resource or a static/ path — see Brand and appearance
params.wordmark , string
Horizontal wordmark; when set, the navbar uses it instead of “icon + site name”
params.description , string
Site description, the meta fallback when a page has no description
params.copyright , string or map
A string renders as Markdown; a map takes authors, from_year and to_year (present means this year)
params.footer_center_info , string , defaultPowered by Oink
Inline Markdown in the centre of the footer; an empty string hides it
params.author , string or map
The RSS author; a map takes name and email
params.ui.theme_color , string
#rgb/#rrggbb hex tinting the shell’s accent grounds; prose links and inline code are unaffected — see Brand and appearance
params.ui.theme_color_dark , string , defaultderived
The dark half of the accent; omitted, it is derived from theme_color until it clears AA on the dark canvas

There is no favicon parameter: the theme scans static/ for conventional names (favicon.ico, favicon.svg, favicon-NxN.png, apple-touch-icon.png, apple-touch-icon-NxN.png) — see Brand and appearance.

Shell types and section roots

The shell follows the page type, not the path. Documentation can live in any directory, with a cascade giving it type: docs.

params.ui.shell_types , list , default[docs, book, blog, swagger]
Which types use the reading shell with a sidebar — see Layouts and page types
params.ui.docs_section , string , defaultdocs
The documentation section’s root directory name, used for navigation resolution only
params.ui.blog_section , string , defaultblog
The blog section’s root directory name
params.ui.docs_sidebar_root , enum , defaultsection
With section, a docs page’s sidebar roots at the documentation section; with home, at the site home. An invalid value warns and falls back
params.ui.quick_links , list , default[docs_section, blog_section]
Top-level menu identifiers listed by the command palette on an empty query — see Command palette
params.ui.sidebar_root_enabled , boolean , defaulttrue
Allows a subsection to become its own sidebar tree with sidebar_root_for: self
params.ui.sidebar_root_menu , boolean , defaulttrue
Shows the section switcher above the sidebar; it degrades to a plain link when there is only one entry
params.ui.section_index , enum , defaultlist
Child list style on a section index: list or cards, overridable per section
params.ui.section_index_columns , integer , default2
Column count when section_index: cards

Blog

Seven keys shape a blog section. They apply to the section named by params.ui.blog_section, and each can be overridden per section through front matter or a cascade on the blog root.

params.ui.featured_image , enum , defaultnone
How an article renders its own featured image: none renders nothing, banner frames it above the title in a 16:9 figure, wash lays it behind the article header at a tenth of its opacity, hero paints it as the shell’s own full-bleed backdrop and moves the opening down — on single pages and section indexes alike. The image is whichever one the page already shares in its card and og:image, so the two cannot disagree. An article with no image renders nothing in any mode
params.ui.blog_index , enum , defaultlist
The blog section’s list page: list is the row list, cards a grid of content cards with a 16:9 lead image, the date and section line, and a three-line summary, table one compact row per post — the whole section at once, with no year groups and no pagination. Year grouping, pagination and manual_link behave the same in list and cards
params.ui.blog_index_columns , integer , default3
Column count when blog_index: cards; two between the md and xl breakpoints, one below md, whatever this says
params.ui.blog_index_size , integer , default12
Posts per page on a list or cards index; the table form always shows everything. Twelve divides by two, three and four, so no card row is left short
params.ui.blog_index_toggle , boolean , defaultfalse
Lets a reader cycle the index through list, cards and table from the index toolbar. Off by default, because it puts all three forms in the document — the hidden ones load no images, but their markup is real
params.ui.toc_style , enum , defaultfixed
The right rail’s presentation: fixed is a panel pinned to the viewport, flow a wider panel in the content flow that starts where the article starts and pins only on scroll
params.ui.toc_taxonomies , boolean , defaulttrue
Taxonomy term clouds on the right rail. A rail left with neither a table of contents nor clouds renders nothing at all

Article authorship and series are taxonomies rather than parameters — see Taxonomies and Writing a blog.

params.ui.navbar_enabled , boolean , defaulttrue
Whether the site navbar renders; overridable with a top-level navbar_enabled on a page — see Navigation and menus
params.ui.navbar_autohide , boolean , defaultfalse
The navbar retracts above the viewport and returns when the pointer enters the wake zone; inactive below 768px and on coarse pointers
params.ui.footer_style , enum , defaultfat
fat is a multi-column grid plus the copyright line, slim is the copyright line only, none renders nothing. An invalid value warns and falls back
params.ui.dark_mode , boolean or map , defaultfalse
true enables both the dark palette and the theme control; for the control alone write dark_mode: { show_menu: true }
params.ui.breadcrumb , boolean , defaulttrue
Breadcrumbs; false turns them off. A top-level section already omits a one-level breadcrumb
params.ui.page_context_menu.enable , boolean , defaulttrue
The page action split button beside the title
params.ui.page_context_menu.assistant_links , boolean , defaultfalse
Shows “Open in ChatGPT / Claude”; clicking sends the full URL off-site
params.ui.page_context_menu.links , list , default[]
Custom external actions; url supports the {url}, {title} and {markdown_url} placeholders
params.ui.github_stars , string or number
The star count on the navbar GitHub mark; a local constant, never a request
params.ui.alt_site , map
A sibling-site link shown in the footer of a single-language site; label and an absolute http(s) url are both required

The fat footer’s column data comes from data/footer/<language>.yaml rather than from a parameter — see Navigation and menus.

Sidebar

params.ui.sidebar_menu_compact , boolean , defaulttrue
Expands only the current branch and its neighbours
params.ui.sidebar_menu_foldable , boolean , defaulttrue
Lets the reader expand and collapse sections
params.ui.sidebar_menu_truncate , integer , default2000
Maximum entries rendered in one section; the rest are truncated
params.ui.sidebar_cache_limit , integer , default500
Above this page count the site reuses shared navigation markup, and the browser restores the active state
params.ui.sidebar_width_min , integer , default220
Lower bound in pixels for drag-resizing on the desktop
params.ui.sidebar_width_max , integer , default480
Upper bound in pixels for drag-resizing
params.ui.sidebar_item_overflow , enum , defaultellipsis
ellipsis truncates a long title, wrap wraps it
params.ui.sidebar_icon_policy , enum , defaultall
Icon density: all everywhere, groups only on the root and nodes with children, none nowhere. An invalid value warns and falls back to all
params.ui.sidebar_expand_levels , integer , default2
Tree levels expanded by default
params.ui.sidebar_headings , boolean or integer , defaultfalse
type: book only: expands a heading branch under the current sidebar row; an integer from 2 to 4, and true means 2
params.ui.sidebar_enabled , boolean , defaulttrue
The left sidebar; false turns it off, usually per page rather than per site
params.ui.taxonomy_icons , map
Right-column group icons by taxonomy plural, for example tags: fa-solid fa-tags

How to use the sidebar is in Layouts and page types; the tree itself comes from the shape of content/ — see Organizing content.

Table of contents

The outline’s levels come from Hugo’s own configuration; the theme controls only the tracking behaviour:

markup.tableOfContents.startLevel , integer , default2
Hugo’s own: the highest heading level collected
markup.tableOfContents.endLevel , integer , default3
Hugo’s own: the lowest heading level collected
params.ui.scroll_spy , boolean , defaultfalse
Scroll position tracking; true highlights the active entry

Hide the outline on one page with the front matter notoc: true — see Page parameters.

Pager and page end

The page-end components are in a fixed order — share → feedback → page information → pager → comments — and each has its own switch; backlinks sit in the right rail beside the table of contents.

params.ui.share , list , default[]
Page-end share targets, in the order given, from x bluesky mastodon facebook linkedin reddit hackernews telegram whatsapp line pinterest weibo chatgpt claude email copy. Empty means no bar. Every target is a plain intent link — no SDK, no iframe, no third-party script, no share counts — see Writing a blog. An unknown target warns and is dropped
params.ui.pager_types , list , default[docs, book, blog]
Which types show previous / next; a page opts out with the front matter pager: false. An unknown type warns and is dropped
params.ui.annotation , boolean , defaulttrue
The “last modified” and provenance block at the end of the body; the upstream attribution line is driven by the page’s upstream_link family — see Page parameters
params.ui.backlinks , boolean , defaultfalse
Lists the pages that link to this one as a “Backlinks” group in the right rail beside the table of contents, derived at build time from ordinary links — see Navigation and menus
params.ui.translation_notice , language code or false , defaultfalse
The language code of the authoritative version, so a translated page shows a line pointing back at it; a page opts out with translation_notice: false
params.ui.reading_time , boolean , defaultfalse
Shows a reading time under the page title
params.ui.book_draft_banner , boolean , defaultfalse
Adds a banner at the top of a draft Book page

Local search is off by default, and the command palette appears only once it is on (the navbar magnifier, Cmd/Ctrl with K, /, \).

params.offline_search , boolean , defaultfalse
Generates one local index per language and enables the command palette — see Search
params.offline_search_on_serve , boolean , defaulttrue
Builds the index under hugo server too, so the preview behaves like production; set false on a very large site to speed up local rebuilds
params.offline_search_index , enum , defaultcontent
Index scope, cumulative: title, heading, summary, content. An invalid value warns and uses content
params.offline_search_summary_length , integer , default70
Word cut-off for the summary scope’s excerpt
params.offline_search_max_results , integer , default10
Result cap, bounding both Lunr and the CJK substring fallback
params.ui.landing_search , boolean , defaulttrue
Whether a layout: landing page keeps a search entry point
params.ui.command_palette.commands , list , default[]
Custom commands, each with either url or a built-in action — see Command palette
params.gcs_engine_id , string
A Google Programmable Search engine ID; enabling it brings in an external service
params.search.algolia , map
Algolia DocSearch; appId, apiKey and indexName must all be given explicitly, or it warns and DocSearch stays off

A custom command record accepts seven keys only — id, title, description, icon, keywords, url, action — and id must match ^[a-z][a-z0-9_-]*$ and must not collide with a built-in action ID. Per-language titles go under languages.<lang>.params.ui.command_palette.commands.

Keyboard

params.ui.keyboard_nav , boolean , defaulttrue
Single-key navigation (WASD / arrows walk the tree, j/k jump headings, q/e page, palette and shell switches). With false the runtime never enters the bundle — see Keyboard navigation

Image zoom

params.ui.image_zoom , boolean , defaultfalse
Lets body images open full size; a page overrides it with the front matter image_zoom. A non-boolean warns and falls back

Which images become zoom candidates is in Images.

Typography

params.ui.typography , enum , defaulttechnical
technical uses the bundled Inter / Chakra Petch / IBM Plex Mono; system uses the platform stack only and requests no brand font. An invalid value warns and falls back
params.ui.fonts , map
Font-family names for the ui, body, heading, code, display, meta, and print roles. The theme validates names but never loads font files; every list should end in a generic family
params.page_width , enum , defaultnormal
Overall shell width: normal, wide, full; overridable per page
params.reading_width , enum , defaultnormal
Reading measure of a Book page’s body: slim, normal, wide; it does not affect the shell

Use params.ui.fonts when the faces already exist on the reader’s system or the site has declared them with @font-face. Bundling font files and changing lower-level typography still use the SCSS/CSS entry points — see Brand and appearance.

Comments and feedback

params.comments.enable , boolean , defaultfalse
The site-level comment switch; a page overrides it with the front matter comments — see Comments
params.comments.type , string , defaultgiscus
Only giscus actually renders today
params.comments.giscus.repo , string
The GitHub repository hosting the discussions; required
params.comments.giscus.repoId , string
The repository ID; required
params.comments.giscus.category , string
The discussion category name; required
params.comments.giscus.categoryId , string
The discussion category ID; required
params.comments.giscus.mapping , string , defaultpathname
How pages map to discussions
params.comments.giscus.term , string
The discussion title or number when mapping is specific or number; the attribute is omitted when unset
params.comments.giscus.strict , string , default0
Strict title matching
params.comments.giscus.reactionsEnabled , string , default1
Shows reactions on the main post
params.comments.giscus.emitMetadata , string , default0
Sends discussion metadata to the parent page
params.comments.giscus.inputPosition , string , defaulttop
Whether the input box sits above or below the list
params.comments.giscus.theme , string , defaultauto
The giscus theme; auto follows the site’s light/dark state
params.comments.giscus.lightTheme , string , defaultlight
The giscus theme or custom CSS URL used in light mode
params.comments.giscus.darkTheme , string , defaultdark
The giscus theme or custom CSS URL used in dark mode
params.comments.giscus.loading , string , defaultlazy
The iframe loading strategy
params.comments.giscus.lang , string , defaultderived from the site language
The giscus interface language. Unset, a Chinese site resolves zh-CN / zh-TW / zh-HK, other languages take the base language code, and anything giscus does not support falls back to en
params.comments.giscus.ariaLabel , string , defaultComments
The aria-label on the comment container; the default is English, so a multilingual site writes one per language
params.comments.giscus.errorMessage , string , defaultComments could not be loaded.
Text shown when loading fails; the default is English, so a multilingual site writes one per language
params.ui.feedback.enable , boolean , defaultfalse
The two “was this page helpful?” buttons at the page end; there is no backend, and a structured event is recorded when gtag is present
params.ui.feedback.reasons , boolean , defaulttrue
Expands four optional reasons after “no”

Missing any one of the four required giscus values leaves the comment section unrendered: no error, and nothing appears.

Repository links and page information

params.github_repo , string
The content repository URL, resolving “edit this page”, “view history”, “create child page” and “open a documentation issue” — see Repository links and page info
params.github_project_repo , string , defaultgithub_repo
The product repository URL, for “open a project issue” and the navbar GitHub entry
params.github_branch , string , defaultmain
The branch edit links point at
params.github_subdir , string
The content site’s subdirectory inside a monorepo
params.path_base_for_github_subdir , string or map
Source path rewriting; the map form takes from and to
params.github_url , , default
Removed; write params.github_repo. The migration registry that used to name the replacement is gone, so an old key is now simply an unread key
params.ui.lastmod_commit , enum , defaultsubject
What follows “last modified”: subject the commit subject, hash the short hash, none nothing. An invalid value warns and falls back
params.images , string array , default
The site-level social card: fills og:image when a page has no image of its own. Metadata only; never rendered as a list thumbnail
params.upstream_source , string , default
Default data/upstreams record name for pages that declare upstream_link; page front matter can override it
params.upstream_modified , boolean , defaultfalse
Site default for whether attributed material is adapted; a page can override it, and no attribution renders without upstream_link
params.default_featured , , default
Removed; write params.images, or a section cascade carrying images. As above, an old key is now simply an unread key

Content runtimes

Mermaid, KaTeX, ECharts, Infographic, Asciinema, Swagger UI and Redoc are detected from the content and load only where a page uses them, and only in that page’s HTML output; they have no site switch. Only these need a switch or an external endpoint:

params.markmap , boolean , defaultfalse
Enables the mind map fence site-wide — see Markmap
params.mermaid , map
Configuration passed to mermaid.initialize(); keys are lowercase, and dark mode overrides theme automatically
params.plantuml.enable , boolean , defaultfalse
Enables the PlantUML fence — see PlantUML
params.plantuml.svg_image_url , string
The PlantUML service’s SVG endpoint; required when enabled, and its absence warns and leaves PlantUML off
params.plantuml.svg , boolean
Renders inline SVG instead of an <img>
params.drawio.enable , boolean , defaultfalse
Enables the edit button on .drawio.svg images — see Draw.io
params.drawio.drawio_server , string
The Draw.io editor address; required when enabled, and its absence warns and leaves Diagrams.net off
params.highlight_classes , boolean , defaulttrue
Emits Chroma classes for highlighting; false returns to Hugo’s inline styles
params.ui.code_copy , boolean , defaulttrue
The copy button on code blocks; false removes it globally, and a fence’s own copy= still wins

Mathematics needs no parameter, only the passthrough prerequisite.

Output formats

The theme declares its custom output formats but does not enable them for a site: request what you want under outputs. Expensive aggregate and machine-readable outputs remain explicit opt-ins.

hugo.yml
outputs:
  home: [HTML, markdown, LLMS, NAVJSON]
  page: [HTML, markdown]
  section: [HTML, RSS, print, markdown]
Format Output Description
HTML index.html The interactive form; required
markdown index.md Each page’s plain Markdown twin, which “copy Markdown” and “view source” depend on — see AI-agent support
LLMS llms.txt A plain-text format the theme declares, usually attached to home only
LLMSFULL llms-full.txt A top-level section opt-in: the same per-page Markdown concatenated in sidebar reading order, one bundle per language
NAVJSON navigation.json A home opt-in: the sidebar/pager navigation authority serialized once per language, validated by schema/nav.v1.schema.json
print _print/index.html The whole-section print page the theme declares — see Print
BookManifest book.json A Book-root opt-in JSON handoff for the EPUB/PDF packaging tools; it is not itself an ebook
RSS index.xml Hugo’s own; attach it to section so every section has a feed

LLMSFULL and BookManifest are enabled in the relevant top-level section’s front matter rather than globally. NAVJSON belongs on outputs.home. The complete examples and constraints are in AI-agent support and Books.

Two parameters for print output:

params.print.toc , boolean , defaulttrue
Generates a table of contents at the top of the print page; false omits it
params.print.section_break_wordcount , integer , default50
How many words a section needs before it starts a new print page

Languages and versions

Languages are defined with Hugo’s own languages block, and the theme only reads the translation relationships it establishes:

defaultContentLanguage , string , defaulten
The primary language, served without a path prefix
languages.<lang>.label , string
The language’s endonym, shown in the language menu
languages.<lang>.locale , string
The full locale, used for <html lang> and SEO
languages.<lang>.weight , integer
Language order, and the cycle order when clicking the language icon
languages.<lang>.title , string
The site name in that language
languages.<lang>.direction , string , defaultltr
Set rtl for a right-to-left language

Paired files, anchor alignment and fallback for untranslated pages are in Languages.

Version parameters:

params.version , string
The identifier of this site variant, which need not be a Git ref — see Versions
params.version_menu , string , defaultVersion
The version menu’s title
params.version_menu_pagelinks , boolean
On switching version, try the same path on the target site first
params.versions , list
Version entries: version, url, kind; name: '---' is a divider
params.archived_version , boolean
Shows the “this is an archived version” banner at the top
params.url_latest_version , string
The link to the current version inside that banner
params.time_format_blog , string , default2006-01-02
Blog date format, overridable per language
params.time_format_default , string , default2006-01-02
All other date formats, overridable per language

Miscellaneous

taxonomies , map
Hugo’s own: enables tag: tags / category: categories — see Taxonomies
params.taxonomy.page_header , list
Shows only these taxonomies in a post header; unset shows all
services.googleAnalytics.id , string
Hugo’s own: the analytics script is injected in production builds only — see Analytics and SEO
module.hugoVersion.min , string , default0.160.1
The Hugo floor the theme declares; anything older fails the build
module.hugoVersion.extended , boolean , defaulttrue
Hugo Extended is required (SCSS has to be compiled)

Editor completion via generated schemas

The theme ships two generated JSON Schemas under its schema/ directory: site-params.schema.json for a site’s hugo.yaml and front-matter.schema.json for page front matter. They are projections of the theme’s own hugo.yaml defaults (with the comment documentation as hover text) and its parameter-scan registry; the theme’s CI regenerates them and fails on drift, so they can never disagree with the theme you have pinned.

With the VS Code YAML extension, map the site schema in your settings:

.vscode/settings.json
{
  "yaml.schemas": {
    "https://raw.githubusercontent.com/pgsty/oink/main/schema/site-params.schema.json": "hugo.yaml"
  }
}

Pin the URL to your release tag instead of main to match your go.mod pin. Front matter completion depends on your Markdown tooling; point it at front-matter.schema.json the same way. The front-matter schema deliberately omits type constraints, because keys like share and theme_color accept a bare-boolean opt-out beside their ordinary type.

Verifying a configuration change

Run a strict build after changing configuration:

hugo --printPathWarnings --panicOnWarning

It passes only when the output reads Total in … with no ERROR and no WARN. Common errors and what they mean:

Error fragment Cause
invalid params.ui.typography The presets are technical and system
invalid footer_style … (allowed: fat | slim | none) A bad footer style; the error names the page
invalid page_width … (allowed: normal | wide | full) A bad page width
invalid params.ui.section_index … (allowed: list | cards) A bad section index style
invalid params.offline_search_index The scopes are title, heading, summary, content
params.plantuml.enable requires an explicit params.plantuml.svg_image_url PlantUML enabled with no endpoint
params.drawio.enable requires an explicit params.drawio.drawio_server Draw.io enabled with no server address
params.search.algolia requires explicit appId, apiKey, and indexName All three Algolia values are required
params.ui.image_zoom must be a boolean Written as the string "true"
theme_color … is not a #rgb or #rrggbb hex color The value is not a hex color; the default palette is kept
theme_color … reads at about N:1 against the theme's … canvas Advisory: the color ships, and the message prints the id that silences it
theme_color_dark … has no theme_color to pair with The dark half was set without a valid theme_color; it is ignored and the default palette is kept in both modes
command … must define exactly one of url or action A custom command gave both url and action, or neither
invalid params.ui.sidebar_icon_policy …; using all Only a warning, but the value is misspelled

A configuration change also needs at least three checks: one page in each language, a page with no translation to see the fallback, and the links under the production baseURL (easy to miss on a subpath deployment).

The theme’s declared Hugo floor is 0.160.1. OINK’s continuous test toolchain is pinned to Hugo Extended 0.165.0; configuration changes are tested once with that pinned version instead of against a version matrix:

# require v0.165.0+extended in this output
hugo version
hugo --printPathWarnings --panicOnWarning

The floor is declared in the theme’s hugo.yaml and theme.toml, and a site’s own module.hugoVersion.min should agree with it. It remains a consumer compatibility declaration, not a second routine CI test leg.

2 - Brand and appearance

Replace the site name, logo, favicon, accent colour, light and dark palettes and fonts, using configuration and two SCSS entry points.

This page covers a site’s appearance: the name and logo live in hugo.yml, colours and fonts go through SCSS entry points, and page width and footer shape are parameters. It assumes the site already builds (Quick start).

There are four things to change: hugo.yml, the icons under static/, assets/scss/_variables_project.scss, and assets/scss/_styles_project.scss. Do not edit files inside the theme directory: the theme is a Hugo Module, and an upgrade replaces the whole directory.

Site name

The site name appears in the navbar, the browser title and the footer. A multilingual site writes one per language:

hugo.yml
title: Product Docs

languages:
  en:
    title: Product Docs
    label: English
    locale: en-US
    weight: 1
  zh:
    title: 产品文档
    label: 简体中文
    locale: zh-CN
    weight: 2

The top-level title is the fallback, and languages.<lang>.title wins.

The theme ships assets/icons/logo.svg and uses it by default. To replace it, put the icon file in the site’s assets/ or static/ and point the configuration at it.

hugo.yml
params:
  logo: images/product-mark.svg
  wordmark: logo.svg
  • params.logo is the square mark, shared by the navbar, the sidebar and the footer. Under assets/ it goes through Hugo’s resource pipeline (and can be fingerprinted); under static/ it is published as is. Either way the path is relative to the assets/ or static/ root.
  • params.wordmark is the horizontal wordmark. Once set, the navbar uses it instead of “icon + site name”, falling back to params.logo when the screen is too narrow. Left unset, “icon + site name” stays.

Crop the source SVG tight to the artwork, or the sizes will not line up. An SVG needs a viewBox, and its colours should inherit currentColor or hold enough contrast in both light and dark.

This site leaves both unset: the navbar pairs the theme’s own assets/icons/logo.svg with the site title, drawn in the display font.

favicon

The favicon has no parameter. The theme scans the site’s static/ directory for conventional filenames and emits the matching <link> on every page for whichever it finds:

File Link generated
static/favicon.ico rel="icon"
static/favicon.svg rel="icon" type="image/svg+xml"
static/favicon-32x32.png rel="icon" with sizes, emitted in ascending size order
static/apple-touch-icon.png rel="apple-touch-icon"
static/apple-touch-icon-180x180.png rel="apple-touch-icon" with sizes

A sufficient minimum is favicon.ico plus favicon.svg plus apple-touch-icon.png. A file with a size suffix has to be square (NxN) or it is not recognized.

Generate these with any graphics tool. The theme needs no Node.js, and Hugo simply publishes what is already in static/.

Extra head metadata such as a Web App Manifest is outside the scan; emit it yourself through the layouts/_partials/hooks/head-end.html hook. To change the discovery rules themselves (a different directory, more filenames), override layouts/_partials/favicons.html in the site’s layouts/.

Accent colour and palette

Colour comes in two layers: Bootstrap’s semantic colours (Sass variables, at compile time) and OINK’s brand layer (CSS custom properties, at run time).

Change the semantic colours first; they decide the tone of buttons, links and callouts:

assets/scss/_variables_project.scss
$primary: #315f8f;
$secondary: #b4762e;
$success: #2c7a4b;
$warning: #9a6700;
$danger: #b42318;

This file is loaded before Bootstrap and the OINK defaults, which is where Sass variables are overridden. To reference a variable or map Bootstrap has already defined, use _variables_project_after_bs.scss instead.

The brand layer is a set of CSS custom properties, and light and dark must be overridden in pairs or one mode leaks the original colour:

assets/scss/_styles_project.scss
:root {
  --td-brand-copper: #a66722;
  --td-brand-mark-from: #1d588c;
  --td-brand-mark-to: #a66722;
}

[data-bs-theme='dark'] {
  --td-brand-copper: #e0a35c;
  --td-brand-mark-from: #7fb8e8;
  --td-brand-mark-to: #e0a35c;
}

The brand properties available are --td-brand-elev (overlay ground), --td-brand-silk (secondary text), --td-brand-copper and --td-brand-copper-dim (the accent and its muted form), --td-brand-line-strong (rules), --td-brand-header-bg (navbar background), --td-brand-shadow-sm / --td-brand-shadow-md (shadows), and --td-brand-mark-from / --td-brand-mark-to / --td-brand-mark-gradient (the brand gradient).

Section theme colour

The brand palette above sets the colour of the whole site. theme_color is the smaller instrument beside it: one hex that tints the accent grounds of the shell, so a reader can tell which part of the site they are standing in without being told.

hugo.yml
params:
  ui:
    theme_color: '#6d28d9'
    theme_color_dark: '#a78bfa' # optional

It is more useful per section than site-wide. Written into a section root’s cascade, it gives that whole section an identity — a navy Docs beside a violet Blog and an orange Book — while the site default stays the brand colour:

content/blog/_index.md
cascade:
  theme_color: '#6d28d9'
  theme_color_dark: '#a78bfa'

Hugo resolves these cascade values on the section page as well as its descendants, so the pair is declared once. The same resolved pair drives the page’s accent and that section’s mark in the root switcher.

What it touches: the selected sidebar row and the greyed ground its neighbours take under the pointer, hover washes, the outline pill and its travelling rail and dot, a Book chapter’s headings under the pointer, tag and chip hovers, a content card’s hovered edge, a share button’s hover fill, text selection, focus rings, and each root’s mark in the sidebar switcher.

What it deliberately does not touch: prose links, external URLs, and inline code. Those are reading conventions, not brand surfaces — a page dense in identifiers should read as code and prose in every section, and a link should look like a link wherever it is. This is why the accent is its own custom property rather than a repaint of Bootstrap’s link colour.

The dark half is optional. Left out, it is derived by lightening the light colour toward white until it clears AA body text on the dark canvas, so a single-colour author cannot produce an unreadable dark palette. Name it yourself when the derived value no longer matches the brand hue you want. The light colour is the key: theme_color_dark on its own, or beside an invalid theme_color, colours nothing in either mode — the theme warns and keeps the default palette rather than tint dark mode alone.

A page inside a coloured section can decline the colour with the theme’s bare-boolean idiom: theme_color: false in front matter opts that page out — inherited dark half included — and reverts to the default palette without a warning. Any other non-hex value (a number, true, a named colour) warns.

Contrast is checked, not enforced

The theme reads your colour against its own canvases and warns when it falls below AA body text (4.5:1). The colour still ships: a custom canvas or a brand mandate is your call. The warning carries an ignoreLogs id that silences it, and because publishing builds with --panicOnWarning, the gate stops until you either darken the colour or silence the check.

The check reads the colour against the page canvas. Some interactive surfaces reuse it as both ink and a translucent wash — a linked solid badge, for example, uses accent ink over a 12% accent wash — and that pairing is tighter than the canvas check. If a colour only just passes, inspect those surfaces and go one step darker when needed.

Hugo merges params by key, so a page overriding theme_color inside a section whose cascade also sets theme_color_dark inherits that dark value. Override both, or neither.

Light and dark mode

The theme does not show a light/dark control by default. To enable it:

hugo.yml
params:
  ui:
    dark_mode: true

A theme control then appears in the navbar: clicking it toggles light and dark, and hovering or focusing it expands “follow system / light / dark”. The reader’s choice is stored locally in the browser, and with no choice it follows prefers-color-scheme. The switching script sets data-bs-theme before the first paint, so there is no theme flash.

For the dark palette without the control, write dark_mode: { show_menu: false, enable: true }; dark_mode: false (the default) enables neither.

Custom components need readable hover, focus, disabled and selected states in both modes, with at least 4.5:1 contrast for body text and 3:1 for large text.

Fonts

There are two font presets, decided at build time with no JavaScript involved:

hugo.yml
params:
  ui:
    typography: technical # technical | system
  • technical (the default): interface and body text use the bundled Inter (variable weight, with Latin / Cyrillic / Greek / Vietnamese subsets, while Chinese and emoji fall through to platform fonts), display headings use Chakra Petch, and code uses IBM Plex Mono. All font files are local, and Google Fonts is never requested.
  • system: the interface, display, metadata, print and monospace roles all fall back to the platform stack, and the browser requests no brand font. The font files still ship with the theme; they are simply not referenced.

An invalid value warns and falls back to technical, so an ordinary hugo server stays usable; publishing gates run --panicOnWarning, which is where that warning becomes a hard failure. The chosen value is written to <html data-td-typography="…"> and can be confirmed in the browser.

Custom fonts

The font roles are seven CSS custom properties. Override them rather than hunting for component selectors:

Property Config key Where it is used
--td-ui-font-family ui Navigation, controls and interface text
--td-body-font-family body Body text and blog posts
--td-heading-font-family heading Headings in the body
--td-code-font-family code Code and terminals
--td-display-font-family display Wordmark and display headings
--td-meta-font-family meta Technical labels and metadata
--td-print-font-family print Print body text

ui is the main face: body resolves through it and heading through body, so a single line moves the interface, the prose and the headings together.

From configuration

To swap font families and nothing else, skip SCSS and write params.ui.fonts:

hugo.yml
params:
  ui:
    fonts:
      # The main face: interface, body and headings follow it
      ui: "'Source Han Sans SC', 'PingFang SC', sans-serif"
      # Monospace needs a CJK fallback, or mixed code blocks stop aligning
      code: "'Sarasa Mono SC', 'Noto Sans Mono CJK SC', monospace"

These are family names, not font files. The theme never downloads or loads a font because of this key: a family here must be one the reader already has, or one the site declared in an @font-face of its own. End every list with a generic family (sans-serif, monospace, serif) — that is where a reader without your face lands.

Values are gated to plain font family syntax: quoted names, bare identifiers, a leading hyphen (-apple-system), and names spelled in any script (苹方 is a family name). Semicolons, braces, parentheses, url() and angle brackets do not pass. An unknown role or an unsafe value warns and is dropped on its own; the rest of the map still ships. A site that sets nothing gets no style element in <head> at all.

The block renders after the stylesheet, which is what lets an authored face outrank the typography preset at equal specificity.

From a stylesheet

To ship a font file of your own, or to change the face for one kind of content only, use a stylesheet. Put the .woff2 in the site’s static/webfonts/, declare the face in the project stylesheet, then rewrite the roles:

assets/scss/_styles_project.scss
@font-face {
  font-family: 'My Sans';
  font-display: swap;
  font-style: normal;
  font-weight: 400 800;
  src: url('../webfonts/my-sans-variable.woff2') format('woff2');
}

:root {
  --td-ui-font-family: 'My Sans', 'Noto Sans SC', sans-serif;
  --td-body-font-family: var(--td-ui-font-family);
  --td-heading-font-family: var(--td-ui-font-family);
  --td-display-font-family: var(--td-heading-font-family);
}

Roles inherit by ordinary CSS rules, so changing the font for one kind of content needs no component selectors either:

assets/scss/_styles_project.scss
body.td-blog {
  --td-body-font-family: 'My Serif', 'Noto Serif SC', serif;
  --td-heading-font-family: var(--td-body-font-family);
}

A monospace stack needs a CJK fallback, or mixed code blocks fail to align:

assets/scss/_styles_project.scss
:root {
  --td-code-font-family: 'My Mono', 'Sarasa Mono SC', 'Noto Sans Mono CJK SC', monospace;
}

A site migrating from Docsy need not change how it writes this. The old Sass variables still feed the corresponding roles, still work from _variables_project.scss, and take precedence over the preset defaults:

Legacy Sass variable Font role it feeds Note
$td-fonts-serif --td-ui-font-family / --td-body-font-family Docsy’s interface stack, assigned to $font-family-sans-serif
$font-family-sans-serif --td-ui-font-family / --td-body-font-family Once a project supplies its own stack, the technical preset stops putting Inter in front of it
$font-family-base --td-ui-font-family / --td-body-font-family Bootstrap’s body variable, reaching the role through --bs-body-font-family
$headings-font-family --td-heading-font-family Unset, headings inherit the body role
$font-family-code --td-code-font-family Code, terminals and pre / code / kbd
$td-font-family-monospace --bs-font-monospace Assigned to $font-family-monospace
$font-family-monospace --bs-font-monospace Under the system preset, an explicit project value beats the platform monospace stack

Docsy’s three Google Fonts variables — $td-enable-google-fonts, $td-google-font-name and $td-web-font-path — are no longer read by the theme. Leaving them in _variables_project.scss breaks nothing and does nothing: what ships with the theme is Inter, Chakra Petch and IBM Plex Mono, and neither preset requests anything from Google Fonts. The print role --td-print-font-family follows the body role, and the theme ships no separate font for paper.

YAML accepts font family names only — neither a remote font URL nor arbitrary CSS. Font files and styles must both be auditable local inputs, and an ordinary build makes no network request for a font.

Page width

hugo.yml
params:
  page_width: normal # normal | wide | full

page_width controls the overall shell width and can be overridden per page or per section by cascade. Book pages additionally have reading_width (slim / normal / wide), which changes the reading measure of the body rather than the shell. An invalid value in either key warns and falls back during ordinary preview; the warning fails a publishing build with --panicOnWarning.

hugo.yml
params:
  ui:
    footer_style: fat # fat | slim | none
  copyright:
    authors: '[The product team](https://example.com/)'
    from_year: 2026
    to_year: present
  footer_center_info: 'Powered by [Oink](https://oink.pgsty.com)'
  • fat (the default): a multi-column link grid plus the copyright line;
  • slim: the copyright line only;
  • none: no footer at all.

Page front matter (including a section cascade) can override it; this site’s documentation section uses footer_style: slim. An unrecognized value warns and falls back to fat in ordinary preview; strict publishing rejects the warning.

The grid’s data lives in data/footer/<language>.yaml — see Navigation and menus. With fat configured but no data, it degrades to slim automatically, so it can be enabled before the content exists.

params.copyright accepts a Markdown string, or a map of authors / from_year / to_year (present means this year). footer_center_info is inline Markdown in the centre of the footer, and setting it explicitly to an empty string hides that region.

SCSS entry points, and what not to do

A site’s SCSS overrides join the theme’s single style bundle, and a production build still emits one fingerprinted stylesheet with an integrity attribute. Three entry files go under the site’s assets/scss/:

File When to use it
_variables_project.scss Sass variables set before Bootstrap and the OINK defaults ($primary, the font variables)
_variables_project_after_bs.scss Variables or maps that depend on Bootstrap’s own definitions
_styles_project.scss Selectors and CSS custom properties written after the theme’s component styles

The compilation order is: Bootstrap functions → project variables → OINK defaults and Bootstrap → post-Bootstrap project variables → OINK components and the brand layer → project styles.

The CSS interface has a defined boundary. The seven font roles in Fonts and the --td-brand-* properties are public, and the theme keeps their names and meanings across minor versions. Component aliases such as --td-asciinema-font-family promise only to work within that component, and undocumented variables such as the --td-shell-* family are implementation detail that may be renamed or removed at any time.

What not to do:

  • Do not edit any file inside the theme directory (hugo mod overwrites it);
  • Do not @import the theme’s internal partials individually — they are not a public Sass interface and their import order may change;
  • Do not override baseof.html to change one colour. Use a design variable where one exists, and otherwise write the narrowest selector that works;
  • Do not reference a remote stylesheet or a font CDN.

For additional third-party CSS, publish a local resource through the layouts/_partials/hooks/head-end.html hook rather than writing a <link> in Markdown.

Verify

hugo --printPathWarnings --panicOnWarning
  • The build prints Total in … with no ERROR and no WARN;
  • The page source has data-td-typography="technical" (or your chosen preset) on <html>;
  • In the browser the navbar shows your logo and site name, and the tab shows your favicon;
  • Switch to dark mode and look again at body text, tables, callouts, code blocks and focus rings. A colour change is easy to verify in only one mode;
  • Switch language and confirm the site name changes with it.

To check whether the font really was replaced, inspect any paragraph’s font-family in the browser’s developer tools: it should be the face you declared rather than Inter.

3 - Home and landing pages

Assemble a home page from one local YAML file — hero, cards, capability panels, timeline, pricing, case studies, downloads. Any page can become a landing page with the same sections.

The home page is not a template but a data file: the sections list in data/home/<language>.yaml decides which sections the page has from top to bottom, and each section’s content is looked up by name in the same file. An ordinary page with layout: landing uses the same sections.

Every section is rendered on the server. Prices, star counts, screenshots, avatars and download states all have to exist in the repository before Hugo starts; no section fetches data in the browser.

A site migrating from Docsy’s blocks/* home page has to rewrite it: the theme has no blocks/cover, blocks/section or blocks/feature shortcodes, and keeping them fails the build with template for shortcode "blocks/cover" not found. The two ways forward are the data/home/<language>.yaml described here, or layout: landing on an ordinary page.

Where the home page’s data lives

The home page’s content file keeps only a title and a description:

content/_index.md
---
title: OINK
description: A local-first, Hugo-only theme for technical documentation
---

Section data is a file per language:

home page data

  • data/
    • home/
      • en.yamlEnglish home page
      • zh.yamlChinese home page

The lookup order is data/home/<current language>.yamldata/home/en.yamldata/home.yaml for a single-language site.

The file has only two levels: a sections list, and the same-named keys that list references.

the skeleton of data/home/en.yaml
sections:
  - hero          # uses the hero: key
  - capabilities
  - type: cards   # uses the cards section, reading the release: key
    key: release
  - cta

hero: { … }
capabilities: { … }
release: { … }
cta: { … }

That is how this site’s home page is written; the complete file is data/home/en.yaml in the repository.

A minimal working home page

Paste the following, replace the text and links, and it publishes. Write internal links as site paths without a leading slash, and the theme adds the current language prefix (docs/start//docs/start/).

data/home/en.yaml
sections:
  - hero
  - cards
  - cta

hero:
  eyebrow: Local-first · Hugo only
  title_lines:
    - words:
        - { text: PGSTY OINK }
  lead: Components are written in Markdown, assets ship with the theme, and one source produces four outputs.
  image:
    light: images/hero-light.webp
    dark: images/hero-dark.webp
    alt: OINK engineering documentation illustration
  actions:
    - { label: Quick start, url: docs/start/, icon: fa-solid fa-rocket, style: primary }
    - { label: See the components, url: docs/components/, style: ghost }

cards:
  eyebrow: What it does
  title: Everything engineering documentation needs
  columns: 3
  items:
    - title: Markdown-native components
      desc: Callouts, tabs, field lists and file trees are all part of Markdown syntax.
      icon: fa-solid fa-cubes
      url: docs/components/
    - title: Four outputs
      desc: HTML, print, Markdown and RSS from one source, losing nothing.
      icon: fa-solid fa-file-export
      url: docs/customize/agents/
    - title: Local-first
      desc: Fonts, icons, search and diagram runtimes all ship with the theme; no CDN.
      icon: fa-solid fa-plug-circle-xmark
      url: docs/about/features/

cta:
  title: Start from a bilingual site that already works.
  text: Start from OINK Starter, replace the project identity and content, then publish.
  label: Get started
  url: docs/start/
  style: primary

Hero

The hero is the first screen, and the only section with a large title and an illustration.

data/home/en.yaml
hero:
  eyebrow: PROJECT 1.0 · Local-first       # small text above the title, with a status dot
  title_lines:                             # the large title, controlled line by line
    - words:
        - { text: PGSTY OINK }
  lead: One sentence saying what this is.  # inline Markdown and <br> allowed
  note: No Node.js required                # a supplementary line with an icon
  note_icon: fa-solid fa-circle-check
  title_size: 4.25rem                      # rem / em / px only
  image:
    light: images/hero-light.webp
    dark: images/hero-dark.webp            # with only one, both modes share it
    alt: First-screen illustration
  media:
    ratio: '1fr 240px'                     # column widths for text and image
    max_width: 240px
    hide_below: md                         # hide the image below sm | md | lg | xl
  actions:
    - { label: Get started, url: docs/start/, icon: fa-solid fa-rocket, style: primary }
    - { label: GitHub, url: 'https://github.com/pgsty/oink', external: true, style: ghost }
  detail: { label: See what it looks like, url: docs/about/showcase/ }

Without title_lines it uses title, and without either the site title. The image is a CSS background: with an alt the container carries role="img", and without one it is hidden from assistive technology.

align: center gives a text-only centred first screen: the text block widens and centres, the title balances its line breaks, and note moves below the buttons. When an image is present too, ordinary preview warns and falls back to start so the image is preserved; a strict publishing build rejects the warning.

The section registry

There are 22 section types, named with hyphens (underscores in older data are normalized). Apart from the hero, each shares the three heading fields eyebrow / title / desc (or text) plus a class.

Type What it holds
hero The first screen: large title, buttons, a theme-following image
metrics Numeric facts, with optional count animation and source links
capabilities Alternating left-right capability narratives with a dedicated visual panel
principles Numbered product principles
cards A general card set: features, scenarios, entry points
logo-wall Tools and partners, as a grid or a pure-CSS marquee
gallery A wall of screenshots
testimonials Quotations with attribution
contributors People, roles, avatars and links
faq Questions and answers, collapsible or flat
markdown A stretch of free Markdown
cta The closing call to action
pricing Pricing tier cards
pricing-compare A tier-by-feature comparison matrix
command-box One copyable command
steps An ordered procedure, optionally with commands
timeline Dated milestones
code-plate Code inside a presentation panel
preview A stretch of Markdown source beside what it renders as
case-study A case: metrics plus a quotation plus a source
download One or more data/download/ records
bar-chart Numeric comparison without any chart JS

A misspelled type does not silently disappear: the build emits an unknown section type warning and skips the section. Adding --panicOnWarning in CI turns that into a build failure.

The commonest sections, minimally

Cards and capability panels are the two used most. cards controls its column count with columns:

data/home/en.yaml
cards:
  title: Use cases
  columns: 4
  link_label: Learn more
  items:
    - title: Book publishing
      meta: Long form
      icon: fa-solid fa-book-open
      desc: Numbered figures and examples, cross-references, indexes and whole-book print.
      url: docs/write/book/

capabilities is one capability per screen with a structured visual panel on the right, and visual.type must be one of shell, components, code, image or card:

data/home/en.yaml
capabilities:
  eyebrow: Value
  title: What engineering documentation needs, out of the box
  items:
    - ref: 01 / Engineering docs
      title: Built for engineers and their documentation sites
      url: docs/start/
      motto: No extra friction from the first build to long-term maintenance
      bullets:
        - 'A [deployment](docs/admin/deploy/) experience that works out of the box'
        - 'Built-in [search](docs/customize/search/) and [languages](docs/customize/i18n/)'
      value: Content teams spend their time on documentation rather than rebuilding a site.
      visual:
        type: code
        title: build.sh
        lines:
          - { class: c, prefix: '# ', text: One command, one deterministic output }
          - { class: p, prefix: '$ ', text: hugo --gc --minify }
          - { class: ok, prefix: '✓ ', text: public/ is ready to deploy }
Minimal YAML for the other ten scenario sections

These fragments come from the theme repository’s executable regression fixture tests/site/data/landing/demo/en.yaml, and the field names can be copied.

metrics:
  title: Facts
  animate: true
  items:
    - { value: 2189, compact: true, label: Stars, source: { label: Local CI data, url: 'https://example.org/' } }
    - { value: 32, suffix: '+', label: Languages }

command-box:
  title: Install
  code: hugo mod get github.com/pgsty/oink
  lang: bash
  note: The copy button comes from the on-demand landing runtime.

steps:
  title: Three steps to publish
  items:
    - { title: Clone, desc: Copy the documentation site repository. }
    - { title: Configure, desc: Change three settings., cmd: { code: hugo server } }
    - { title: Publish, desc: Push to GitHub Pages. }

timeline:
  title: Project history
  items:
    - { date: '2024', title: Prototype, desc: The first data-driven sections. }
    - { date: '2026', title: Scenario components, desc: Landing becomes a reusable shell. }

code-plate:
  title: Page configuration
  aria_label: Example configuration
  lang: yaml
  code: |
    layout: landing
    landing: pricing

preview:
  title: What you write is what you get
  file: guide.md            # the filename in the source panel header, default page.md
  source: |                 # the right side renders this Markdown with the site's own hooks
    > [!TIP] Markdown only
    > Callouts, steps and tabs are all ordinary syntax.

    1. Write Markdown
    2. Run `hugo`
    {.steps}

case-study:
  title: Migration outcome
  stats:
    - { value: 12, label: Reusable sections }
    - { value: 0, label: Remote requests }
  quote: "One YAML file replaced a bespoke page template."
  source: A site maintainer

pricing:
  title: Pricing
  tiers:
    - name: Community
      price: Free
      period: forever
      desc: The full open-source capability.
      features: [Every component, Community support]
      cta: { label: Download, url: docs/start/ }
    - name: Professional
      featured: true
      price: $3.4K
      period: /year
      features: [Priority response, Release packages]
      cta: { label: Contact us, url: 'mailto:example@example.org' }

pricing-compare:
  title: Tier comparison
  tiers: [Community, Professional]
  groups:
    - name: Support
      rows:
        - { name: Priority response, cells: [N, Y] }
        - { name: Annual fee, price_row: true, cells: [Free, $3.4K] }

download:
  title: Download
  keys: [prd5]

bar-chart:
  title: Build time
  unit: seconds
  items:
    - { label: Cold build, value: 12.3, group: cold }
    - { label: Warm cache, value: 1.6, group: warm, note: A repeat build on the same machine. }

The download section consumes exactly the data/download/<key>.yaml from Releases and downloads, introducing no second version model.

Turning any page into a landing page

Two lines of front matter make an ordinary content page a landing page: a full-width canvas that keeps the navbar, the command palette and the footer, and drops the sidebar and the outline.

content/pricing.md
---
title: Pricing
layout: landing
landing: pricing
---

The data lives in a directory parallel to the home page’s, likewise split by language:

landing page data

  • data/
    • landing/
      • pricing/
        • en.yaml
        • zh.yaml

A non-home landing page looks for its data in this order. When nothing is found, ordinary preview warns and renders the landing shell with no sections; a strict publishing build rejects the warning:

  1. sections in the page’s front matter;
  2. data/landing/<key>/<exact language>.yaml;
  3. The exact-language entry inside a single data/landing/<key>.yaml;
  4. The English or language-less record.

Small amounts of data can go in front matter, but landing: and sections: are mutually exclusive:

content/pricing.md
---
title: Pricing
layout: landing
sections:
  - type: hero
    data:
      title: Publish a product page with Hugo alone
      actions:
        - { label: Read the docs, url: docs/, style: primary }
  - type: download
    data: { title: Download, keys: [prd5] }
  - cta
---

Writing a section entry

Each item in sections is either a type-name string or a map:

Key What it does
type The section type; omitted, key is used as the type
key Which key to read data from, defaulting to the same name as type; use it to distinguish two uses of one section
data Inline data, so no top-level key is looked up
id The section’s anchor ID, generated from key / type by default
enabled: false Disables the section while keeping its data
partial Swaps in the site’s own partial. A local template convention, not portable landing data

Languages and local facts

Narrative text belongs in per-language files (zh.yaml / en.yaml). A shared record of facts can also fall back field by field: <field>_<exact language><field>_<base language><field>, with - in a language tag normalized to _. A Chinese site resolves title_zh_cn, then title_zh, then title. camelCase suffixes are not accepted.

Display text inside a section is site data, not the theme’s i18n strings. Only the theme’s own controls — marquee pause, pricing states — use translation keys. Configuring a multilingual site as a whole is in Languages.

A few optional facts on the landing shell are local too, written in hugo.yml and never fetched at runtime:

hugo.yml
params:
  offline_search: true
  ui:
    landing_search: true          # boolean; the palette appears only if the site enabled offline_search
    github_stars: 2189            # a committed number, never a GitHub API request
    alt_site: { label: 中文站, url: 'https://example.cn/' }

The footer is not home page data: it reads data/footer/<language>.yaml (or data/footer.yaml on a single-language site), and this site has one per language. A leftover footer key in data/home/<language>.yaml warns and is ignored in ordinary preview; --panicOnWarning rejects it while naming the new location. How to write it is in Navigation and menus.

Output

Output What appears
HTML The full static section content, plus landing.js loaded on demand for reveal, counting, copying and theme image switching
Print Content kept; dynamic surfaces such as the marquee become a static grid, and controls are removed
Markdown Titles, prose, lists, tables and code, with no component classes
RSS Landing sections are not emitted

With JavaScript disabled the server-rendered document is still complete. The marquee’s duplicate track stays out of the accessibility tree, and pausing uses a checkbox that needs no JavaScript; with the reader’s reduced-motion preference on, movement and reveal are switched off.

Verify

  1. The build is warning-free: hugo --printPathWarnings --panicOnWarning. A misspelled type, a missing data key, and landing alongside sections all surface here.
  2. Open the home page and any landing page, compare each section against the data file, and look at every language.
  3. Reload with JavaScript disabled: the content is still there, only without motion.
  4. Look at both light and dark, confirming image.light and image.dark are each correct.
  5. When deploying to a subpath, confirm internal links and images all carry the prefix.

4 - Navigation and menus

Configure the navbar menu and its dropdowns, the section switcher, breadcrumbs, page actions, the pager and the footer links.

This page covers the ways a reader moves between pages: the navbar menu, the section switcher, breadcrumbs, page actions, previous / next, and the footer. The sidebar tree and the outline belong to Layouts and page types.

Navigation has no second information architecture: the navbar comes from Hugo’s menus.main, and the sidebar from the shape of content/. The theme reads no parallel navigation tree such as a docs.json or a navigation.yaml.

The navbar menu

Top-level entries go in each language’s menus.main:

hugo.yml
languages:
  en:
    menus:
      main:
        - identifier: docs
          name: Docs
          pageRef: /docs
          weight: 20
        - identifier: blog
          name: Blog
          pageRef: /blog
          weight: 50
        - identifier: download
          name: Download
          pageRef: /download
          weight: 60
          params:
            icon: fa-solid fa-download

A lower weight comes first. pageRef points at a site page and url at an external one; an external link automatically gains target="_blank", rel="noopener noreferrer" and an external-link mark. identifier is the stable handle configuration uses to reference the entry (quick_links and sidebar_root_menu match on it), name is translated per language, and the identifier is not.

A menu entry can also hang off a page’s front matter, which suits “this page is itself a top-level entry”:

content/download/_index.md
---
title: Download
menu:
  main:
    weight: 30
---

The GitHub entry at the right of the navbar is not a menu item: it comes from params.github_project_repo (falling back to params.github_repo). A menu entry identified as github is skipped by the menu area and never shows. To change that entry’s target, change the repository parameters — see Repository links and page info.

Dropdowns

Use Hugo’s parent to establish a parent-child relationship. Only one level of children is supported:

hugo.yml
menus:
  main:
    - identifier: docs
      name: Docs
      pageRef: /docs
      weight: 20
    - identifier: docs-start
      parent: docs
      name: Get started
      pageRef: /docs/start
      weight: 10
      params:
        icon: fa-solid fa-rocket
        description: Start from OINK Starter, customize in layers, deploy
    - identifier: docs-components
      parent: docs
      name: Components
      pageRef: /docs/components
      weight: 20
      params:
        icon: fa-solid fa-cubes
  • Every entry is one icon and one title on its own row, in one moderate-width column. A child’s params.description is configuration data only; the panel never renders it.
  • The parent is itself an ordinary link: hovering or focusing it expands the panel, and clicking or pressing Enter goes to the parent page. There is no separate expand arrow, and a touch reader lands on the parent page, whose body lists the same links.
  • Keyboard: the down arrow expands and focuses the first item, Esc closes and returns focus to the link, and clicking outside closes it.
  • The 0.5 params.columns parameter is retired: setting it emits a build warning and the panel keeps its single column.
  • A third level warns at build time and degrades to a static group heading; it does not produce a third-level flyout. Put deeper levels in the sidebar.

Menu icons

Below lg a menu entry is reduced to its icon, so every top-level entry should have one. Icons resolve in this order:

  1. icon in the target page’s front matter;
  2. The menu entry’s own params.icon;
  3. A built-in default matched by identifier or section name (docs, blog, examples, community, about, download, github and others);
  4. fa-solid fa-link when none matched.

An icon is one Font Awesome class pair, with the free faces supplied locally by the theme:

hugo.yml
menus:
  main:
    - identifier: handbook
      name: Operations handbook
      pageRef: /handbook
      weight: 40
      params:
        icon: fa-solid fa-screwdriver-wrench

Taxonomy menus

A top-level entry pointing at a taxonomy page (/tags/, /categories/) needs no hand-written submenu: the panel renders a grid of “term + count” chips, ordered by descending count.

hugo.yml
menus:
  main:
    - identifier: tags
      name: Tags
      pageRef: /tags
      weight: 60

Enabling taxonomies is in Taxonomies.

Navbar controls

The navbar is 50px tall and holds, left to right: the brand (logo or wordmark), the menu area, search, version, language, theme, GitHub. Home and Landing pages keep a final drawer menu button at the right edge. The navbar renders on every layout; documentation, blog and taxonomy pages use the same controls without that Landing drawer.

The navbar has a full desktop tier and a compact icon tier:

Viewport State
lg and above Full: brand, menu entries with text, all utility controls; Home/Landing ends with the drawer button
Below lg Compact: the brand stays, everything else becomes right-aligned icons
Below md Only search and the drawer button remain in the navbar; version, language, theme and keyboard help remain in the footer’s bottom bar

The individual controls are switched on elsewhere: the search icon needs params.offline_search (see Search), the version menu needs params.versions (see Versions), the language menu appears automatically with two or more languages configured (see Languages), and the theme control needs params.ui.dark_mode (see Brand and appearance).

Auto-hide

hugo.yml
params:
  ui:
    navbar_autohide: true

With it on, the navbar leaves the normal flow and rests above the viewport, sliding out only when the pointer enters the middle 60% of the area above its original position (or keyboard focus arrives), and it overlays the body rather than pushing it down. 64px at each side is outside the wake zone, so it does not cover the collapsed sidebar and outline restore buttons.

It is disabled automatically below 768px, on a coarse pointer, and on a touch-only device, where the navbar stays visible. A top-level navbar_autohide in page front matter or a section cascade overrides it per section.

Turning the navbar off

hugo.yml
params:
  ui:
    navbar_enabled: false

It can also be turned off for one page or one section:

content/docs/_index.md
---
title: Docs
cascade:
  navbar_enabled: false
---

With it off, the theme restores the interface the navbar carried: mobile subnavigation, a brand and search row at the top of the sidebar, and utility buttons on the outline rail. The switch suits pages that must own the viewport; it is not a general layout preference. This site’s documentation section uses it: documentation pages navigate through the sidebar, and the navbar is one row too many.

The section switcher

The row at the top of the sidebar is the section switcher, deciding which tree is shown. Its entries are built in order and deduplicated: every top-level section → every section anywhere with sidebar_root_for: self → the currently resolved root.

hugo.yml
params:
  ui:
    sidebar_root_enabled: true
    sidebar_root_menu: true

To let a large subtree become a root of its own (a versioned API reference, a self-contained handbook), in its _index.md:

content/docs/api-v2/_index.md
---
title: API reference v2
sidebar_root_for: self
sidebar_root_link_self: true
---

self makes the section index and all its descendants use the new tree; children leaves the index in the parent tree and binds only the descendants. To keep a top-level section out of the switcher, set sidebar_root_menu: false in its front matter.

With one entry the switcher degrades to a borderless link; two or more make it a dropdown. The tree below it still has the section index as its first link: the switcher picks a tree, and the root link picks a document.

Breadcrumbs and page actions

An ordinary content page has a breadcrumb row above its title, and that row’s right end carries the page actions. A top-level section omits a single-level breadcrumb that would only repeat the title, and the action buttons stay where they are.

hugo.yml
params:
  ui:
    breadcrumb: false

Breadcrumb labels use the localized linkTitle, and the hierarchy matches the sidebar.

The page action menu

Page actions are the split button at the end of the title row: the left half copies this page’s Markdown in one click (turning into a green tick on success), and the arrow on the right expands the full menu. The menu has two groups — taking the content away, and changing or producing it:

Action When it appears
Copy as Markdown The site enabled the markdown output format
Open in ChatGPT page_context_menu.assistant_links: true
Open in Claude The same
View Markdown source The markdown output format
View history params.github_repo can resolve the source path
Edit this page params.github_repo
Create child page params.github_repo
Open a documentation issue params.github_repo
Open a project issue params.github_project_repo
Print the whole section The section enabled the print output format
hugo.yml
params:
  ui:
    page_context_menu:
      enable: true
      assistant_links: false
      links: []

The assistant entries are off by default: on a click the full current URL (query and fragment included) goes to a third party with a localized prompt, while the body is not uploaded. Before enabling it, confirm no sensitive information appears in URLs, and disclose the boundary in the privacy statement. A page can narrow the site policy with a boolean assistant_links in front matter, but cannot enable it on the site’s behalf.

Custom external actions come last in the menu, and url supports three URL-encoded placeholders:

hugo.yml
params:
  ui:
    page_context_menu:
      links:
        - name: Ask the internal assistant
          icon: fa-solid fa-wand-magic-sparkles
          url: https://assistant.example.com/new?source={markdown_url}&title={title}

The placeholders are {url} (the page’s full address), {title} (the page title) and {markdown_url} (the Markdown version’s address).

On the blog root section and its first-level subsections, the left half becomes the RSS subscription link while “copy as Markdown” stays in the menu. A page with no Markdown output loses the left half, and the arrow becomes an “Actions” button with a label.

These actions are also entries in the command palette.

The pager

Previous / next at the end of the body are two text links, ordered by the sidebar’s visible tree: root page → first page → through to the last. The root has no previous, and the last page has no next. Where a site provides data/docs_nav.json, that explicit tree decides the paging order too — and the section index on a docs or book section the file declares, so the sidebar, the pager and the index can no longer show the same children in three different orders. A section the file does not declare, and a site without the file, keep walking the content tree. See Layouts and page types.

hugo.yml
params:
  ui:
    pager_types: [docs, book, blog]

pager_types accepts only docs, book and blog; any other value warns and is dropped. A page opts out through front matter:

content/docs/appendix.md
---
title: Appendix
pager: false
---

The same order is written into <head>: with a previous or next page, it emits <link rel="prev"> and <link rel="next"> so browsers and crawlers can see the reading sequence.

page source
<link rel="prev" href="/docs/customize/home/">
<link rel="next" href="/docs/customize/layout/">

Paging applies to HTML output only. Print, Markdown and RSS have neither the links nor the two rel relationships.

The pager is the third of the four page-end components (feedback → annotation → pager → comments), in a fixed order with four independent switches.

The pages that link to a page can be listed in the right rail, as a “Linked from” group with a link icon below the table of contents and above the taxonomy clouds, expanded by default; below the xl breakpoint it moves into the sidebar drawer with the table of contents. A reader who arrived from search sees which pages consider this one worth pointing at, and where it sits in the rest of the site. It is off until a site asks for it:

hugo.yml
params:
  ui:
    backlinks: true

A page overrides it in front matter, and a section cascades it to everything below:

content/docs/_index.md
---
title: Docs
cascade:
  backlinks: true
---

The index is derived at build time from what authors already write: an ordinary Markdown link, or a ref / relref shortcode, in the page source. There is no new syntax to learn, nothing to migrate, and no JavaScript — the list is in the HTML. Fenced and inline code are stripped before scanning, several links to one target merge into a single entry, and self links, external links, mailto: and same-page anchors never count. A fragment is dropped when identifying the target page, and each language has its own graph, so a Chinese page never appears under an English one. Entries are sorted by stable page path, so the same content builds the same order every time, and the block is absent entirely — no heading, no empty container — when nothing links in.

The first eight entries are visible and the rest fold behind a native “Show N more” disclosure, so a heavily referenced page cannot swallow the rail; no JavaScript is involved. Each entry carries its source page’s description, shown on hover.

Reading the source has a known limit: a URL inside a custom shortcode’s parameters, or a raw <a href>, does not become an edge, and a destination that cannot be resolved is dropped without a warning. This is a navigation enhancement, not a link checker; keep using a link checker for broken links.

A non-boolean value warns, falls back to off and fails a build run with --panicOnWarning, while hugo server keeps working.

The page’s Markdown output carries the same list, introduced by “Backlinks:”. RSS omits it, and the print output format omits it with the rest of the rail.

This site enables it site-wide: look at this page’s right rail for the real thing, and the most-referenced page — Configuration — lists more than forty inbound links, most of them folded behind the disclosure.

The footer’s shape comes from params.ui.footer_style (fat / slim / none — see Brand and appearance). The fat link grid reads data/footer/<language>.yaml. It is not a menu, and the theme has no menus.footer:

data/footer/en.yaml
brand:
  name: Product Docs
  tagline: A short description that **supports Markdown**.
  slogan: Close to the product, with clear answers.
columns:
  - title: Docs
    links:
      - { label: Get started, url: /docs/start/ }
      - { label: Components, url: /docs/components/ }
  - title: Project
    links:
      - { label: GitHub, url: https://github.com/pgsty/oink, external: true }
      - { label: Releases, url: /blog/release/ }
  • Without brand.name and brand.logo it falls back to the site’s own brand name, logo and wordmark; tagline and slogan render Markdown.
  • An internal url resolves against the current language root; external: true opens in a new tab with rel="noopener noreferrer".
  • The grid has as many columns as the data does.
  • A single-language site can use data/footer.yaml.
  • With fat configured but no data, it degrades to slim automatically, so it can be enabled before the content exists.

The fat footer’s copyright row has a collapse arrow at its right end, hiding or restoring the link grid above it. It starts expanded, and the reader’s choice is kept in localStorage under td-footer-collapsed across pages. slim and none have no such button, and it is unrelated to focused reading mode.

Every rendered bottom bar ends with the same icon dock: version, language, theme, then keyboard help. Each configured menu opens upward; the version trigger stays icon-only while its choices keep their full labels. The fat footer’s collapse arrow follows those four controls. The sidebar has no second copy of the dock, and footer_style: none removes the bar with the footer.

The copyright row and the centre note are parameters — see Configuration.

Verify

hugo --printPathWarnings --panicOnWarning

After changing navigation, check each of these:

  • The build has no Navbar menu … supports one interactive child level warning; one means the menu is three levels deep;
  • On the desktop: clicking a parent goes to the parent page, hovering expands the panel, and Esc closes it;
  • Narrow the window below lg: every top-level entry still has an icon, and one without an icon is blank at this width;
  • Below md: Home and Landing navbars keep search and the drawer button on the right; version, language, theme and keyboard help stay in the persistent footer bottom bar;
  • The switcher at the top of the sidebar lists every top-level section, with the current one marked;
  • On any documentation page, E / Q page in sidebar order, and the page source has matching rel="prev" / rel="next";
  • With backlinks on, grep td-backlinks public/<a page that is linked to>/index.html finds the block, and a page nothing links to has no such markup at all;
  • Open the page action menu and confirm what should be there is, and what should not is not (for example “open a project issue” with no github_project_repo configured).

5 - Layouts and page types

Let type decide which shell a page uses, then adjust sidebar width and icons, outline depth, section index style and page width.

This page covers a page’s skeleton: whether it has a sidebar, how wide that is, how deep the outline goes, and whether a section index is a list or cards. Where content goes is in Organizing content; this page is only about the shell.

The rule is that the shell follows type, not the path. Documentation can live anywhere under content/ as long as it has type: docs.

Shell types

params.ui.shell_types lists the types that use the reading shell with a sidebar:

hugo.yml
params:
  ui:
    shell_types: [docs, book, blog, swagger]
type Shell
docs The documentation shell: left sidebar (section switcher + tree) + body + right-hand outline
book The documentation shell, plus numbered targets, the reading_width measure and the draft banner
blog The documentation shell, with the sidebar expanded by default and RSS as the left half of the title row
swagger The documentation shell, with the body handed to Swagger UI or Redoc — see API reference pages
Any other type An ordinary page: navbar + single-column body + footer, with no sidebar

Taxonomy and term pages are not in this table but use the same shell.

Assigning a type to a subtree uses a cascade, which is how documentation ends up at an arbitrary path:

content/handbook/_index.md
---
title: Operations handbook
type: docs
cascade:
  type: docs
---

Section roots are only navigation starting points

hugo.yml
params:
  ui:
    docs_section: docs
    blog_section: blog

These two keys do not decide the shell. They tell the theme where the documentation and blog trees are rooted, for resolving the sidebar root, quick links and default icons. The content/handbook/ example above still has the documentation shell, and leaving docs_section at docs does not affect it.

To make a docs page’s sidebar root the site home rather than the documentation section:

hugo.yml
params:
  ui:
    docs_sidebar_root: home # home | section

Those are the only two values. Anything else warns and uses section in ordinary preview; --panicOnWarning rejects the warning during publishing.

Documentation at the site root

A documentation-first site can publish the docs section at the URL root while the source stays in content/docs/. Three pieces of configuration are needed together.

The first uses Hugo’s own permalinks to drop the docs/ segment from URLs:

hugo.yml
permalinks:
  page:
    docs: /:sections[1:]/:slug/
  section:
    docs: /:sections[1:]

The second keeps the physical site root index usable as a link target while no longer competing for the same output path. Every language’s site root index (content/_index.md, content/_index.zh.md) needs it:

content/_index.md
---
title: Product Docs
build: { render: link }
---

The third declares the sidebar root to be the site home, so the sidebar and the pager share one tree:

hugo.yml
params:
  ui:
    sidebar_root_enabled: true
    docs_sidebar_root: home

With docs_sidebar_root: home, every top-level section of the site home enters that tree. Overview sections that are not part of the reading sequence — blog, community, download — opt out with toc_root: true in their own _index.md, which keeps them out of the tree and out of the paging order:

content/blog/_index.md
---
title: Blog
toc_root: true
---

Documentation then shares the URL root with blog, community and the rest. Build with --printPathWarnings and resolve every duplicate target before publishing.

Landing pages

Any page with layout: landing uses the landing layout: navbar + a body assembled from sections + footer, with no sidebar. How to write the data is in Home and landing pages.

hugo.yml
params:
  ui:
    landing_search: true

landing_search: false removes the search entry point from the landing shell and affects no other page.

Sidebar

The sidebar tree comes from the shape of content/, ordered by weight and labelled with linkTitle where one exists. What is adjustable is density and size:

hugo.yml
params:
  ui:
    sidebar_menu_compact: true
    sidebar_menu_foldable: true
    sidebar_menu_truncate: 2000
    sidebar_width_min: 220
    sidebar_width_max: 480
    sidebar_item_overflow: ellipsis # ellipsis | wrap
    sidebar_expand_levels: 2
  • sidebar_menu_compact expands only the current branch and its neighbours; false expands the whole tree.
  • sidebar_menu_foldable lets the reader expand and collapse sections manually. Blog sections are expanded by default; to collapse one by default, write sidebar_expanded: false in its _index.md.
  • sidebar_expand_levels is how many levels are expanded by default.
  • sidebar_menu_truncate is the maximum entries rendered per section, so a thousand-page tree does not inflate the HTML past usability.
  • sidebar_width_min / sidebar_width_max bound drag-resizing on the desktop, in pixels. The reader’s adjusted width is kept locally, and double-clicking the divider restores the default.
  • sidebar_item_overflow defaults to ellipsis (long titles truncate); a site with many long titles can use wrap.

Fold state, width and scroll position are stored locally per language. Below md the sidebar becomes a drawer with a backdrop.

To drop the sidebar on one page, use front matter:

content/docs/fullscreen-report.md
---
title: Full-screen report
sidebar_enabled: false
---

An explicit navigation tree, data/docs_nav.json

The sidebar tree is derived from content/ by default. A site may also supply an explicit navigation manifest, and the theme renders from it when three conditions hold together:

  • The site has a data/docs_nav.json containing a sections key;
  • The page’s type is docs or book;
  • The resolved sidebar root is not the site home.

The file is a nested node tree. Each node’s page points at a content path, url is its link, and children are its children; active_path_by_url records the ancestor chain for each URL, used for highlighting the current entry:

data/docs_nav.json
{
  "sections": [
    {
      "page": "/docs/start",
      "url": "/docs/start/",
      "children": [{ "page": "/docs/start/install", "url": "/docs/start/install/" }]
    }
  ],
  "active_path_by_url": {
    "/docs/start/install/": ["/docs/start/"]
  }
}

URLs have their language prefix stripped before comparison, so one file serves every language.

That tree also decides the paging order, so the sidebar and previous / next never disagree. An empty sections array warns and falls back to the content tree; a page that does not exist warns and skips that entry. Strict publishing rejects either warning. Placeholder nodes with manual_link and sidebar_divider rows stay in the sidebar without becoming paging targets.

It suits a site whose navigation order is generated by an external tool — a manual migrated from a Sphinx toctree that has to freeze its existing chapter order, say. Where order is maintained by weight in content/, the file is not needed.

Sidebar icon density

An icon in a page’s front matter appears in the sidebar. Icons on every leaf page reduce readability, so a density policy controls them:

hugo.yml
params:
  ui:
    sidebar_icon_policy: groups # all | groups | none
Value Effect
all Every entry with an icon shows it (the compatibility default when unset)
groups Only the root and nodes with children show icons
none No entry icons in the sidebar

An invalid value only warns and falls back to all rather than failing the build. This site uses groups.

Expanding headings in the sidebar

Book pages can expand an h2–h4 branch under the current sidebar row, which helps navigation inside a long chapter:

hugo.yml
params:
  ui:
    sidebar_headings: 3 # false | true | 2 | 3 | 4

An integer sets the deepest level expanded (2–4), true means 2 (h2 only), and false turns it off. Out of range warns and disables the branch in ordinary preview; strict publishing rejects the warning. It applies to type: book pages only, and expands only under the current sidebar row.

Table of contents

The right-hand outline is generated by Hugo from the Markdown headings, and the levels collected are Hugo’s own configuration:

hugo.yml
markup:
  tableOfContents:
    startLevel: 2
    endLevel: 4
    ordered: false

The theme governs only the tracking behaviour:

hugo.yml
params:
  ui:
    scroll_spy: false

Scroll tracking is off by default. Set to true, the outline draws a continuous rail, highlights the current section and marks the position. The reader can collapse the right column entirely, and that state is kept locally. Below xl the right column is hidden and its content moves into the sidebar drawer.

To hide the outline on one page, use the front matter notoc: true.

Only headings that reach Hugo’s table of contents appear in the outline: headings emitted by a Markdown-form shortcode ({{%/* … */%}}) do, and those from an ordinary shortcode ({{</* … */>}}) usually do not. Structural headings belong in the Markdown.

Section index style

A section with an _index.md lists its child pages automatically, in one of two styles:

hugo.yml
params:
  ui:
    section_index: cards # list | cards
    section_index_columns: 2
  • list (the default): one title plus description paragraph per child page;
  • cards: a grid of cards reading each child’s title (or linkTitle), description and icon.

It can be overridden per section. An invalid value warns and falls back during ordinary preview; --panicOnWarning rejects it at the publication gate:

content/docs/components/_index.md
---
title: Components
section_index: cards
section_index_columns: 3
---

Related page-level switches: no_list: true lists no children; simple_list: true emits a bulleted list with no descriptions; and a child page with hide_summary: true removes itself from the list. Do not hand-write a child list: a hand-written one goes out of step with the sidebar.

Page width

hugo.yml
params:
  page_width: normal # normal | wide | full

normal is the usual reading width, wide widens the content column, and full fills the viewport. It can be overridden per page or per section; wide tables, large images and API reference pages often use wide:

content/docs/api/reference.md
---
title: API reference
page_width: wide
---

Book pages additionally have reading_width (slim / normal / wide), which changes the body’s own reading measure without touching the shell. An invalid value in either key warns and falls back during ordinary preview; strict publishing rejects the warning.

Navbar and footer switches

The navbar and footer are per-page layout decisions, written at the top level of front matter (not under ui), and can be set once with a section cascade:

content/docs/_index.md
---
title: Docs
cascade:
  navbar_enabled: false
  footer_style: slim
---

The behaviour is in Navigation and menus and Brand and appearance, and the key definitions in Page parameters.

Verify

hugo --printPathWarnings --panicOnWarning
  • The build prints Total in … with no ERROR and no WARN;
  • A newly created type: docs page has a left sidebar. If not, check whether the cascade reaches that page and whether shell_types contains the type;
  • Drag the sidebar divider, reload and confirm the width persists; double-click restores the default;
  • Below md the sidebar becomes a closable drawer, and below xl the outline moves into the drawer;
  • A section index has as many cards as the sidebar has child pages;
  • A page with page_width: wide is wider than its neighbours;
  • With documentation at the site root, hugo --printPathWarnings reports no duplicate output paths.

6 - Search

Turn on local search, control index size and result ranking, and make CJK queries land.

OINK’s search is local search: Hugo generates one JSON index per language at build time, the reader’s browser downloads it, and the search runs in the browser. No crawler, no account, no CDN, and no network access. The theme leaves it off, and one line of configuration turns it on.

The entry point to search is the command palette; how to open it and what else it holds are in Command palette.

Turning on local search

hugo.yml
params:
  offline_search: true

This one key decides whether the index, the Lunr runtime and the search dialog reach a page. Three conditions must hold together:

  • params.offline_search is true;
  • The page is the home page, or uses a shell layout (docs / book / blog / swagger — see Layouts and page types), or is a landing page with params.ui.landing_search on;
  • The current output is not print.

If any one fails, the build puts no dialog, no index reference and no Lunr into that page. Those resources are not hidden; they are never generated.

Under hugo server the index is generated as well by default, so the preview behaves like production. On a very large site, where rebuilding the whole index on every change slows the preview noticeably, turn it off:

hugo.yml
params:
  offline_search: true
  # skip index building during preview; only needed on very large sites
  offline_search_on_serve: false

Controlling index size

offline_search_index decides how much of each page goes into the index, and so decides two things at once: whether a reader can find words from the body, and how large the first search’s download is.

hugo.yml
params:
  offline_search: true
  offline_search_index: summary
  offline_search_summary_length: 70
  offline_search_max_results: 10
Value What is indexed When to use it
title Title, tags, categories, search_keywords A very large site where titles alone locate a page
heading The above plus every heading in the page When headings are specific enough
summary The above plus description and summary Sites in the thousands of pages; this site uses it
content The above plus the full plain text The default, suitable up to a few hundred pages

Any other value warns and uses content during ordinary preview; a strict publishing build fails on invalid params.offline_search_index.

offline_search_summary_length is where a result row’s excerpt is cut (default 70), and offline_search_max_results caps the number of results (default 10). The full definitions are in Configuration.

One index per language, budgeted at 2 MiB raw and 512 KiB gzipped.

The reader downloads the whole index before searching for a first word. Past that size, step offline_search_index down from content to summary.

Adjusting ranking

A page influences its own ranking from front matter:

content/docs/reference/pgsql.md
---
title: PostgreSQL parameters
search_keywords: [postgres, postgresql, pg, database parameters, GUC]
search_boost: 1.5
---

search_keywords adds matching terms and takes either a string or an array. It is the more useful of the two: a reader searching pg or GUC reaches a page whose title only says “PostgreSQL parameters”. In ranking, keywords weigh less than the title and more than the body.

search_boost is a positive multiplier on the final score, defaulting to 1.0 and applied on top of the text match score. 1.5 does not pin a page to first place; it moves the page up among results it already matched. Zero, a negative number and a non-number all warn and are treated as 1.0.

Set a section-wide default once with a cascade:

content/docs/_index.md
---
title: Docs
cascade:
  search_boost: 1.25
---

A page’s own value overrides the inherited one. Pages under this site’s docs/ use search_keywords in exactly this way: each lists the Chinese phrasing, the English term and the configuration key name.

Keeping a page out of the index

content/internal/draft-plan.md
---
title: Internal plan
search_exclude: true
---

search_exclude is the only spelling. The removed exclude_search and excludeSearch keys are not read and therefore do not protect a page; the migration checker reports them. A page with an empty body is not indexed.

The index is a static JSON file anyone can download; it is not access control.

Do not put content that should stay private on the site, and do not use search_exclude to protect it.

Chinese and CJK

Lunr cannot reliably tokenize Chinese. When the palette detects a CJK character in the query, the whole query switches to substring matching: it compares title, keywords, in-page headings, description and body in turn, scores whichever layer matched, and finally multiplies by search_boost as usual. Both paths rank by the same rules.

Three things follow:

  • A CJK query is a substring match. Searching 主从复制 finds only where those four characters appear consecutively; 复制主从 returns nothing.
  • search_keywords therefore pays off most on a Chinese site: write in the synonyms, English terms and abbreviations a reader might use.
  • While an input method is composing, the palette does not recompute; it searches once the text is committed, so typing Chinese does not refresh results character by character.

When a Chinese query finds nothing, first confirm the Chinese page reached the Chinese index (see Verify below) before suspecting tokenization.

Optional: hosted search

Besides local search, the theme keeps two hosted integrations, both off by default. Enable only one at a time: with more than one configured the build warns You have more than one site-search option configured.

Enabling hosted search means accepting that service’s crawling behaviour, availability and privacy boundary, all of which belong in the site’s privacy statement.

Algolia DocSearch

hugo.yml
params:
  search:
    algolia:
      appId: YOUR_APP_ID
      apiKey: YOUR_SEARCH_ONLY_KEY
      indexName: YOUR_INDEX

All three values must be written explicitly, and a missing one stops the build: OINK never falls back to another project’s public index. The DocSearch JS and CSS ship with the theme rather than loading from a CDN, but every query is a request to Algolia. Real credentials and a real index are needed for it to work, so nothing is rendered here.

Google Programmable Search

hugo.yml
params:
  gcs_engine_id: YOUR_ENGINE_ID

A landing page for the results is needed too:

content/search.md
---
title: Search results
layout: search
---

The search box submits the query to <baseURL>/search/?q=…, and Google’s script renders the results on that page, which needs access to cse.google.com. It is likewise an external service and is not rendered here.

Verify

  1. Build, and confirm one index per language was generated:

    hugo --printPathWarnings --panicOnWarning
    ls public/offline-search-index.*

    In a development build the filename is offline-search-index.zh.json; a production build fingerprints it, as in offline-search-index.zh.7ab….json. One file per language, and a missing one means that language’s pages never reached an index.

  2. Look inside the index — the first step in diagnosing “Chinese finds nothing”:

    python3 -c "import glob,json; f=sorted(glob.glob('public/offline-search-index.zh*.json'))[0]; \
      d=json.load(open(f)); print(f, len(d)); print(d[0])"

    The entry count should be close to the number of Chinese pages, and the keywords and boost fields should show what the front matter set.

  3. Open the site, press /, and search once with an English word and once with a Chinese one. Results are grouped by content root, each group named after the first breadcrumb segment.

  4. On a subpath deployment (the site under something like https://example.com/docs/), open the browser’s network panel and confirm the index request carries the subpath. An index request hitting the domain root and returning 404, while the rest of the page works, is the most common cause of “search returns nothing”.

  • Command palette — search’s entry point, and the commands and page actions beside it
  • Keyboard navigation — the four single keys that open search and commands
  • Languages — per-language indexes and untranslated fallback
  • Configuration — full definitions of the offline_search* keys
  • Page parameterssearch_keywords / search_boost / search_exclude

7 - Command palette

One dialog carrying page search, page actions and site commands — how to open it, what it groups, and how to add commands of your own.

The command palette is the site’s one modal entry point: searching pages, copying this page’s Markdown, switching language, switching version and jumping to a site’s own links all happen in one dialog. It is assembled together with local search: with params.offline_search off, the palette, the index and Lunr all stay out of the page — see Search.

Opening the palette

How to open it What opens
Click the search box in the navbar or sidebar Full search mode
/ Ctrl + K Full search mode; press again to close
/ Full search mode
The backslash key Command-only mode (equivalent to a prefilled >)
f / c The same two, provided by keyboard navigation
Typing a query beginning with > in the box Command-only mode

/, backslash, f and c are all bare single keys and stand down for typing: while focus is in an input, textarea, select or contenteditable, and while an input method is composing, they type an ordinary character. The modified /Ctrl + K has no such restriction and opens the palette even from inside a text box.

Inside the palette: select, Enter runs, and Esc closes and returns focus to whatever opened it.

What the palette holds

With nothing typed, the palette lists four groups in a fixed order:

Group Contents Decided by
Quick links A few entry points chosen from the navbar’s top-level menu params.ui.quick_links
Page actions Copy Markdown, view Markdown source, edit this page, view history, create a child page, open an issue, print the section Repository configuration and whether this page has a Markdown output
Preferences Switch version → switch language → switch theme Whether the site configures versions, languages and the light/dark menu
Commands Open the GitHub repository, then the site’s own commands params.github_project_repo (falling back to github_repo) and ui.command_palette.commands

The three preferences follow the same order as the navbar controls (version, language, theme); palette and navbar share one ordering. Choosing something like “switch language” does not jump immediately — the palette expands the options in place for a second choice.

As soon as text is typed, page results come first, grouped by content root (the group name is the first breadcrumb segment, and the groups follow the navbar’s top-level menu order), with commands and actions merged into one group at the end.

A query starting with > lists commands and actions only and searches no pages. Use it when you are unsure which menu holds a feature.

An unavailable item is still listed when the reason can be stated. With no repository configured, “edit this page” stays in the list with an “unavailable” note rather than disappearing.

Quick links are selected from Hugo’s main menu by identifier rather than written out a second time:

hugo.yml
params:
  ui:
    quick_links: [docs, blog]

The values are the identifier of entries in menus.main. Left unset, it defaults to the documentation and blog sections (params.ui.docs_section and blog_section). Configuring the menu itself is in Navigation and menus.

Custom commands

A site’s own commands go under params.ui.command_palette.commands, after the built-in ones, in the order written:

hugo.yml
params:
  ui:
    command_palette:
      commands:
        - id: theme_issues
          title: OINK issues
          description: Report or browse theme and documentation issues
          url: https://github.com/pgsty/oink/issues
          icon: fa-brands fa-github
          keywords: [bug, support, roadmap]

That is the one this site uses. There are seven fields. An unsupported key or invalid record warns and drops that command during ordinary preview; strict publishing rejects the warning:

  • id is required, starts with a lowercase letter, and holds only lowercase letters, digits, underscores and hyphens; it must not collide with a built-in action ID.
  • title is what the palette shows; description is the smaller line beneath it; icon is one Font Awesome class pair.
  • keywords is an array that takes part in matching without being displayed, for the search terms a reader might type.
  • url and action are mutually exclusive and one is required. url accepts a full http/https address, a site path, or an in-page anchor beginning with #; an address with a host opens in a new tab. action references a built-in action ID.
Do not alias a built-in action with action:

Built-in actions are already in the palette, and wrapping one makes the same feature appear twice under two names.

A multilingual site writes the commands under languages.<lang>.params.ui.command_palette.commands so titles and keywords can be localized. The order comes from the default language’s list: an entry with the same id in another language overrides fields only, and a new id is appended at the end. Command order is therefore identical across languages, and nothing moves when a reader switches.

Configuration can only supply a link or reference a built-in action; it cannot inject a JavaScript callback. What the palette reads is a plain data manifest.

Page actions

The palette’s “page actions” and the split button beside a documentation title are one implementation: the same action descriptors, the same URL generation, the same executor. The button’s left half copies this page’s Markdown, and the arrow on the right expands every action.

To turn the whole group off, or off on certain pages:

hugo.yml
params:
  ui:
    page_context_menu:
      enable: true
      # "Open in ChatGPT / Claude" appears only once this is on
      assistant_links: false
      links: []

enable: false removes only the button beside the title; the corresponding items stay in the palette, which is itself the command entry point. A single page overrides it with the front matter page_context_menu: false.

assistant_links is off by default because clicking one sends the current page’s full URL — including query string and anchor — to a third party, while the body is never uploaded. That is a site-level choice, and a page’s assistant_links in front matter can only narrow it, never enable it on the site’s behalf.

links adds external actions that appear only in the menu beside the title, not in the palette:

hugo.yml
params:
  ui:
    page_context_menu:
      links:
        - name: Ask in Discussions
          url: https://github.com/pgsty/oink/discussions/new?title={title}
          icon: fa-solid fa-comments

The three placeholders {url}, {title} and {markdown_url} are replaced with the current page’s values.

Whether “edit this page”, “view history” and “open an issue” are available depends on the repository configuration — see Repository links and page info. “Copy Markdown” and “view Markdown source” need the page to have the markdown output — see AI-agent support.

One dialog, two independent data sources:

  • Page results come from the local search index. When the index was never generated or fails to download, the palette still opens and still runs commands, and the page section reads “the page index is unavailable; actions still work”.
  • Commands and actions come from a JSON manifest embedded in the page and need no network.

The palette is not assembled in print state, so print output has none of it. With offline_search off there is likewise no palette, and f and c stay silent without disturbing normal typing.

Verify

  1. After a build, confirm the command manifest reached the page:

    grep -o 'id="oink-action-manifest"' public/docs/customize/panel/index.html

    Its absence means local search is off, or this page is not in a shell layout.

  2. Open the site and press /Ctrl + K without typing: quick links, page actions, preferences and commands should appear in that order.

  3. Type >: only commands and actions remain. A newly added command should sit after “open the GitHub repository”.

  4. Repeat step 3 in another language, and confirm the command titles changed while the order did not.

  5. A print preview (/Ctrl + P) should show no trace of the palette.

8 - Keyboard navigation

Every single-key shortcut, when each stands down for typing, and how to turn them off per site or per page.

OINK’s interactive pages come with a set of single-key shortcuts: WASD moves through the sidebar tree, J K jump between headings, Q E page back and forward, and a few more toggle the theme, the language and the command palette. They are on by default, every binding stands down for typing, and they can be turned off per site or per page.

Keyboard navigation keeps no second copy of any state: expanding and collapsing reuses the sidebar’s own arrow buttons, section jumps read the right-hand outline, and switching language and theme reuse the command palette’s actions. Keyboard order and mouse order are therefore the same order.

Sidebar

Key Behaviour
W S Move focus to the previous / next visible entry
A D Collapse / expand a group; on a leaf, A goes to the parent and D does nothing
Enter Space G Open the focused page
Esc Leave the tree; focus returns to the body

The four letter keys need no prior entry into the tree: with focus still in the body, S takes the current page’s sidebar entry as its starting point, moves down one and takes focus. The focused row is shaded a step darker than the “current page” shading, so the two are distinguishable.

When the sidebar is in a drawer on a narrow screen, or collapsed on the desktop, the first press of one of these keys opens it first. On a page with no sidebar tree they are silent.

The arrow keys act on the tree only after focus has entered the sidebar; in the body they keep native browser scrolling. In a right-to-left language swap with the reading direction, while A D always mean “collapse / expand”.

Reading

Key Behaviour
J K Jump to the next / previous section along the page outline
N Home page only: jump to the next top-level section (a mnemonic alias for J there)
Q E Previous / next page
H Focused reading: hide / restore the navigation shell

J K take their target sequence from the same source as the right-hand outline, so they land where clicking the outline lands. The jump is a fixed 100 ms ease regardless of distance, and successive presses need not wait for the previous animation. Once you have read some way into a section, K returns to that section’s start first and only jumps to the previous section on a second press. On a page with no headings it degrades to a short scroll.

Q E page in the sidebar tree’s visible order, not by date. A section index is itself an entry in the tree, so a blog’s section boundary reads as “last post of the previous section → next section’s index → first post of the next section”. A collapsed branch is not in that order: paging order and focus order are the same order. On a page with no sidebar tree it falls back to the page-end pager, and without one to rel=prev/next in <head>.

H hides only the navbar and footer on the home page, and on a documentation page hides the left and right columns and the floating buttons too. The state is kept in the tab’s session and restored before the first frame, so paging through with Q E neither loses it nor flickers. While the shell is hidden, WASD will not send focus into an invisible sidebar.

Appearance, language and routing

Key Behaviour
L Y Cycle the language (the two keys are equivalent)
T Toggle light and dark
R Cycle among same-origin top-level navbar entries

These three work on any interactive page, not only inside the documentation shell. L on a single-language site, T with the light/dark menu off, and R with only one top-level entry are all silent. R cycles only same-origin top-level menu items; external links and navbar utility controls take no part.

Key Behaviour
F or / Open the command palette in full search mode
C or the backslash key Open the command palette in command-only mode
with K or Ctrl with K Open the palette; press again to close

/ and backslash belong to search itself and keep working with keyboard navigation off; F C are aliases keyboard navigation adds, pointing at the same palette instance. Backslash is awkward on some non-US layouts, and typing a > prefix in the palette reaches command-only mode just as well. What the palette holds is in Command palette.

Keys deliberately left free

? is reserved and unbound. The cheatsheet hangs off the question mark button in the footer’s bottom bar, opens on hover, keyboard focus or touch, and lists the keys actually available on the current page: a single-language site never sees the language row.

G G, Shift with G and the digits are likewise reserved, as possible future jump sequences.

When shortcuts stand down

Every binding is a bare single key, and all of them are disabled wherever they could collide with typing or an overlay:

  • Focus is in an input, textarea, select or contenteditable region;
  • An input method is composing (a hard requirement on a Chinese site);
  • A modifier is held: with C is still copy, Shift with still belongs to the browser;
  • The command palette or another dialog is open, and the keyboard belongs to that overlay.

The comment section lives in an iframe, where key events do not bubble to the page, so no extra isolation is needed.

Focus order and accessibility

  • Skip link: the first Tab after landing on a page reveals “skip to main content”, stepping past the navbar and sidebar in one move.
  • Real focus: navigating the tree moves actual DOM focus rather than a virtual cursor. A screen reader therefore announces the link name and the “current page” marker, Enter is the link’s native behaviour, and the Tab order is not rewritten.
  • High contrast: the focused row’s background drops out under forced-colors and degrades to a system highlight outline.
  • Reduced motion: with prefers-reduced-motion on, section jumps and paging scroll become instant positioning rather than an ease.
  • The key caps in the cheatsheet share their styling with the Kbd component used in the body.

Turning it off

Site-wide:

hugo.yml
params:
  ui:
    keyboard_nav: false

For one page (interaction-heavy demonstration pages often need this), or for a whole section by cascade:

content/docs/playground.md
---
title: Interactive playground
keyboard_nav: false
---

The key accepts a boolean only; "false" or any other value warns and uses the site default during ordinary preview. A strict publishing build fails on params.ui.keyboard_nav must be a boolean. The full definition is in Configuration.

Turned off, the runtime never enters the JavaScript bundle rather than loading and then checking. /, backslash and with K belong to search and keep working; the arrows on the footer’s collapsible link grid are unaffected.

Verify

  1. After a build, confirm the cheatsheet button is in the page:

    grep -c 'td-shell-keyboard__trigger' public/docs/customize/keyboard/index.html

    With keyboard navigation off and local search not enabled, the button is not generated at all.

  2. Open a documentation page, leave the cursor in the body and press S repeatedly: the sidebar should step down from the current page’s entry while the body stays put.

  3. Press E several times and check the paging order matches the sidebar top to bottom; collapse a group and page again — the collapsed pages should be skipped.

  4. Click into the search box and press J: the page should not scroll, and the character should type normally. The same holds while typing with a Chinese input method.

  5. Turn on “reduce motion” in the system and press J: it should position instantly with no glide.

  • Command palette — the dialog F C open
  • Search — where the palette’s page results come from
  • Layouts and page types — which pages have a sidebar and outline, and so which keys apply
  • Kbd — writing key caps in your own documentation
  • Configuration — the full definition of ui.keyboard_nav

9 - Languages

Add a language, keep translations side by side, configure menus and interface strings per language, and align heading anchors across languages.

OINK uses Hugo’s multilingual model and adds no directory conventions of its own: configure a languages block, and keep a translation beside its original in the same directory, distinguished by a filename suffix. What follows covers what a single-language site has to change to become bilingual, plus the two things bilingual sites get wrong: resource ownership and heading anchors.

Enabling a second language

hugo.yml
defaultContentLanguage: en

languages:
  en:
    label: English
    locale: en-US
    weight: 1
    title: OINK
    params:
      description: A Hugo theme for engineering docs
  zh:
    label: 简体中文
    locale: zh-CN
    weight: 2
    title: OINK
    params:
      description: 为工程而设计的 Hugo 文档主题
      time_format_default: 2006年1月2日
      time_format_blog: 2006年1月2日

That is this site’s configuration. What the four fields do:

  • label is the name shown in the language picker, written in that language’s own script: 简体中文, not Chinese.
  • locale is the standard language tag, and reaches <html lang>, the hreflang alternate links and the Open Graph metadata.
  • weight decides both language order and the picker’s cycle order, lowest first.
  • params is a per-language override: a key not written here inherits the global value of the same name. Date formats usually need one per language.

The default language carries no path prefix (English at /docs/…), and each other language takes one (Chinese at /zh/docs/…). To give the default language a prefix too, add defaultContentLanguageInSubdir: true. That changes every URL on the site, so a live site needs redirects arranged at the same time.

File naming and resources

A translation sits beside its original, distinguished by suffix, and Hugo treats the shared base filename as making them two language versions of one page:

  • content/docs/
    • install.mdEnglish
    • install.zh.mdChinese
    • _index.md
    • _index.zh.md

Page bundles work the same way: index.md and index.zh.md in one directory.

Resources in a page bundle follow one rule: a resource whose filename has no language suffix is shared by every language, and one with a suffix belongs only to that language.

  • content/docs/install/
    • index.mdEnglish page
    • index.zh.mdChinese page
    • topology.webpavailable to both languages
    • screenshot.zh.webpavailable to the Chinese page only

When the body references a suffixed resource, write the name without the suffix: ![Screenshot](screenshot.webp), and Hugo resolves it for the current language.

That rule has a corollary: in a page bundle holding only index.zh.md with no English counterpart, unsuffixed resources are not handed to the Chinese page — they belong to the default language, which has no page in that bundle. Every resource then needs the .zh. suffix, which is how the Chinese page bundles under this site’s docs/ are arranged.

What needs translating:

  • Translate: title, description, summaries, menu labels, tag names, image alt text, callout bodies, and reader-facing shortcode parameters.
  • Keep identical: dates, weight, aliases, and any metadata affecting routing. A mismatch makes the sidebar order differ between languages.
  • Do not translate: commands, configuration keys, filenames, URLs, version numbers, product names, shortcode names.

Per-language configuration

Three things live outside content/ and need one copy per language.

Menus are written under their own language:

hugo.yml
languages:
  zh:
    menus:
      main:
        - identifier: docs
          name: 文档
          pageRef: /docs
          weight: 20

The identifier must match across languages: the command palette’s quick links and the grouping order of search results both match on it. Configuring menus fully is in Navigation and menus.

Home page data is chosen by language: data/home/en.yaml, data/home/zh.yaml. Without a file for the current language it falls back to en.yaml; a single-language site needs only one data/home.yaml. See Home and landing pages.

Interface strings: the theme ships 32 complete interface catalogs: the 31 locale filenames supported by Docsy, plus generic zh. Every catalog contains all 192 OINK keys in its native language; none relies on generated English fallback blocks. zh and zh-cn use Simplified Chinese, while zh-tw uses Traditional Chinese. The exact locale and placeholder contract is recorded in Architecture. To change a string, create a file of the same name under the site’s own i18n/ and write only the keys you override:

i18n/en.yaml
ui_search: Search the docs

Keep the concrete locale: zh-CN shown for the non-default language when supporting Hugo 0.160.x with regional Chinese catalogs present. Bare locale: zh is safe in the same configuration from Hugo 0.161 onward.

Untranslated fallback and the language picker

The language picker’s icon is itself a link: clicking it moves to the next language by weight (wrapping at the end), while hovering or focusing it expands a menu of every language. On a touch screen the menu does not expand and a tap switches directly. A bilingual site therefore toggles back and forth in one click.

The menu always lists every configured language, whether or not the current page is translated:

  • The target language has a translation → jump to that page;
  • The target language has none → jump to that language’s home page.

Falling back to the home page beats dropping the reader into a 404. The cost is that the reader may not notice being sent there, so a bilingual site should check “every page has a counterpart” as a constraint rather than relying on the fallback.

A missing translation is never filled in with the original

When a Chinese page does not exist, the Chinese site does not have that page at all: it is absent from the sidebar, the search index and the paging order.

Search indexes are also per language: searching from a Chinese page matches Chinese content only. CJK queries use substring matching, detailed in Search.

Heading anchors must align

Hugo derives an ID from the heading text, so a Chinese heading yields a Chinese ID: /docs/install/#prerequisites and /zh/docs/install/#前置条件 point at the same place through two anchors that do not connect, and cross-language deep links, contents and in-page jumps all break.

The remedy is to write the original’s ID explicitly on the translated heading:

install.zh.md
## 前置条件 {#prerequisites}

Two disciplines:

  1. Take the ID from the HTML the English page renders, not from the heading text. When a heading contains inline code, a badge or a shortcode, the generated ID does not match the heading text.
  2. Corresponding pages must have the same number of headings, in the same order, with the same IDs. Where a translation genuinely needs an extra section, give it an independent, stable ID that does not collide with the English side.

This site turns that constraint into a CI check with a script that compares rendered HTML rather than source:

node scripts/check-doc-translations.mjs --public public

Writing explicit English {#id} anchors from the moment a page is created costs less than retrofitting them.

Right-to-left languages

Declare the writing direction under the language:

hugo.yml
languages:
  ar:
    label: العربية
    locale: ar
    direction: rtl
    weight: 3

<html dir> changes with it, and the theme additionally loads Bootstrap’s RTL stylesheet. The theme’s own CSS uses logical properties throughout (margin-inline-start rather than margin-left), so mirroring happens by itself. A site’s own CSS needs logical properties too, or it will be misplaced under RTL.

Verify

  1. Build, and confirm both languages’ output and indexes exist:

    hugo --printPathWarnings --panicOnWarning
    ls public/index.html public/zh/index.html
    ls public/offline-search-index.*
  2. Check hreflang: each page’s <head> should carry one rel="alternate" per language plus a rel="canonical" pointing at itself.

    grep -o 'rel="alternate" hreflang="[^"]*"' public/zh/docs/index.html
  3. On a translated page, expand the language picker and choose the other language; confirm you stay on the same document. Repeat on an untranslated page and confirm you land on that language’s home page rather than a 404.

  4. Search the same concept once in each language and confirm both return results.

  5. Wire the heading alignment check into CI on a bilingual site, using the script above.

10 - Versions

Configure the version switcher and the archive banner, and choose how several versions are laid out across domains.

When a product has several supported versions, its documentation usually needs versions too. The theme provides two things: a version switcher in the navbar, and an archive banner on older sites. The deployment layout is the site’s decision — the theme does no cross-version build, and each version is its own Hugo build.

The version switcher

List the versions that should appear in the menu under params.versions. When that list is non-empty, a branch-icon menu appears in the navbar’s utility area, with the same content in an icon-only upward menu in the footer’s bottom bar.

hugo.yml
params:
  # which version this site is
  version: v2.1
  # the accessible menu name; bottom-bar trigger remains icon-only
  version_menu: v2.1
  versions:
    - version: v2.1
      url: https://docs.example.com
    - version: v2.0
      url: https://v2-0.docs.example.com
    - version: v1.9
      url: https://v1-9.docs.example.com

A menu entry shows its version value by default, or name when one is given. The current entry is marked selected, decided by either the entry’s version equalling params.version or the entry’s url equalling the site’s baseURL.

An entry with no url renders as an unclickable grey item, usable as a group heading; name: '---' is a divider (a url on a divider warns). name accepts inline Markdown:

hugo.yml
params:
  versions:
    - name: '**Current**'
    - version: v2.1
      url: https://docs.example.com
    - name: '---'
    - name: '**Older versions**'
    - version: v1.9
      url: https://v1-9.docs.example.com

The same list feeds “switch version” in the command palette, so menu and palette never disagree.

version_menu_pagelinks: true appends the current page’s path to the target version’s URL, so switching version keeps the reader on the same document.

The cost is that the target version may not have that page: documentation structure evolves between versions, an older version lacks a newly added page, and the reader who switches lands on a 404. This site leaves the option off.

A single entry can override the global setting:

hugo.yml
params:
  version_menu_pagelinks: true
  versions:
    - version: v2.1
      url: https://docs.example.com
    - version: v1.9
      url: https://v1-9.docs.example.com
      pagelinks: false # this version's structure differs; go to its home page
Judge by how stable the structure is, not by how far apart the versions are

Turn it on where structure is stable and off where it moved. One extra step to a version’s home page still beats a 404.

The archive banner

On the site of a version no longer maintained, tell the reader it is a snapshot:

hugo.yml
params:
  archived_version: true
  version: v1.9
  url_latest_version: https://docs.example.com

With archived_version: true, a banner appears at the top of the body on every documentation and book page, saying the current version is no longer actively maintained and linking to url_latest_version. The wording is localized to the site’s language and needs no authoring; version is the version number the banner shows.

The banner appears on documentation and book pages only, not on blog or landing pages.

params.version versus params.versions

Two similar names with different jobs:

  • params.versions is a cross-site list: which versions the menu can reach and where each lives. It describes other sites.
  • params.version is this build’s own version identifier. It decides which menu entry is marked selected and which version number the archive banner shows, and it is the fallback when data/download/*.yaml omits version (see Releases and downloads).

It need not be a Git ref. Where a resolvable release tag is needed — the one an install command references, say — declare a parameter of your own rather than reusing params.version. The full definitions of both keys are in Configuration.

Deployment layouts for multiple versions

Layout baseURL Characteristics
Subdomain https://v1-9.docs.example.com/ Versions are fully independent; each needs its own DNS and certificate
Subpath https://docs.example.com/v1.9/ One domain, SEO weight concentrated; the host must route by path to different artifacts

Each version is an independent build: check the content out from its branch or tag, build with that version’s own hugo.yml, and publish to the matching address. The current version’s site lists every version; an older version’s site lists them and adds the archive banner.

On a subpath deployment, baseURL must include the path segment

Otherwise the search index, page actions and asset links all point at the domain root: the page looks fine and search returns nothing. This is the most common subpath failure; deployment details are in Deploy.

Verify

  1. After a build, confirm the version menu reached the page:

    grep -c 'nav-version-menu' public/docs/customize/versions/index.html

    With params.versions empty or unset, the menu is not generated at all.

  2. Check whether the current version is marked selected:

    grep -o 'nav-hover-menu__option is-active[^>]*' public/index.html

    None at all means params.version does not match any entry’s version field, or baseURL does not match that entry’s url (mind the trailing slash).

  3. Visit each link in the menu. With version_menu_pagelinks on, try it once from a document an older version lacks and confirm the landing is acceptable.

  4. On an archived site, open any documentation page: the banner should sit at the top of the body, in the site’s language, linking to the current version.

  5. Press /Ctrl + K to open the command palette; “switch version” should list the same set.

11 - Taxonomies

Give pages a second index that cuts across the directory tree with tags and categories — term pages, filter chips, the rail cloud and the navbar panel are all automatic.

A directory tree gives a page one path; a taxonomy gives it a second. The same PostgreSQL backup document sits under an “Operations” directory and is also reachable from a “backup” tag page. Enabling it needs only Hugo’s taxonomies: configuration: the term pages, filter chips, rail cloud and navbar panel are all generated by the theme, with no template to write.

This page carries a category. The “Categories: Customization” line under the title, and the counted chips under the outline in the right column, need no configuration on the page itself.

Enabling a taxonomy

Taxonomies are Hugo’s, and the theme adds no switch of its own. Write taxonomies: at the top level of hugo.yml, with the singular name as the key and the plural as the value:

hugo.yml
taxonomies:
  tag: tags
  category: categories

That is this site’s configuration. Three things to note:

  • Writing taxonomies: makes it the complete list, not an addition. To keep tags / categories alongside a custom taxonomy, list them too.
  • The plural is also the URL segment: /tags/, /categories/.
  • To turn them all off: disableKinds: [taxonomy, term].

Adding one of your own, for instance grouping by product module:

hugo.yml
taxonomies:
  tag: tags
  category: categories
  module: modules

Display names: the six keys tag, tags, category, categories, module, modules have a localized title in every one of the theme’s language files. Any other taxonomy uses the humanized plural (productsProducts). To name one yourself, write title / linkTitle in content/<plural>/_index.md and _index.zh.md, and the theme prefers it:

content/modules/_index.md
---
title: Product modules
linkTitle: Modules
---

Tagging a page

The front matter key is the plural (the value column of taxonomies), and the value is always a list, even with one entry:

content/docs/ha/patroni.md
---
title: Patroni high availability
description: Managing PostgreSQL failover with Patroni.
categories: [High availability]
tags: [PostgreSQL, Patroni, failover]
---

Where a whole section shares one category, write it in the section index’s cascade rather than repeating it on every page:

content/docs/customize/_index.md
---
title: Customization
linkTitle: Customization
icon: fa-solid fa-sliders
cascade:
  categories: [Customization]
---

All six documentation sections on this site are configured that way. A page’s own categories: replaces the cascade rather than merging with it: to add one beside the section’s category, write both.

The term line on a page

Documentation and blog pages render a line of assigned terms under the title and summary, each linking to its term page — the “Categories: Customization” at the top of this page. Its container is .taxonomy-terms-article, with an additional .taxo-<plural> class per taxonomy; use those two selectors to style it.

By default it lists every taxonomy on the page, except the two reserved plurals authors and series — each of those has a surface of its own (a byline and a series strip), so repeating them as chips would say the same thing twice. Naming one in page_header puts it back.

To show only some, in a fixed order:

hugo.yml
params:
  taxonomy:
    page_header: [categories]

The key is catalogued in Configuration. It cannot be used to hide the line — see Limits.

Two taxonomies the theme knows by name

authors and series are ordinary Hugo taxonomies, declared the ordinary way — the theme adds no parameter for either. What it adds is a rendering of each, so the declaration alone is the whole switch:

hugo.yml
taxonomies:
  category: categories
  tag: tags
  author: authors
  series: series
Plural What the declaration turns on The term page becomes
authors Portraits and linked names in the article head, names on list rows, one <dc:creator> per author in the feed The author’s profile: the display name is the term page’s link title (linkTitle, else title), description the one-line introduction, the body the long one, and the avatar whatever the featured-image resolver picks
series A strip above the article body naming the series, this article’s position, the next part, and the whole list behind a <details> The series introduction, listing its members in reading order rather than newest-first

Both are covered in full, with the front matter each expects, in Writing a blog. Two things worth knowing here:

  • There is deliberately no data/authors file. The profile is the term page, so nothing can disagree with it.
  • A series term page is the one term page that is not in reverse-date order. Members with a series_weight come first in ascending order, the rest by ascending date. A term page cannot supply an order to Hugo, so the theme resolves it once and both surfaces read the result.

Term and taxonomy pages

Each taxonomy generates two levels of page:

Page URL Contents
Taxonomy list /categories/ Titled with the taxonomy’s localized name (“Categories”), followed by filter chips for every term, each with a count, the first being “All”
Term page /categories/customization/ Titled “Categories: Customization”, listing every page with that term newest first, styled like the blog list

A Chinese term’s URL uses Chinese characters (the address bar shows 定制站点 and the HTML is percent-encoded); Hugo does not transliterate. Where ASCII URLs are wanted, use English terms and give each a display name with title in content/categories/<term>/_index.zh.md — Hugo’s term page content file mechanism.

A term page has no fixed place in the content tree, so it borrows one: when every member of a term sits under one top-level section, the term page renders that section’s sidebar tree and root link, and a reader clicking a tag from the documentation stays inside the documentation navigation. Where members span sections, it falls back to the site-level tree. The “All” filter chip follows the same rule: with one section it points at that section’s index, and across sections at the taxonomy list page.

Filter chips appear on the taxonomy list page only; term pages carry the rail cloud instead.

The rail cloud

Documentation, blog and term pages carry one group per taxonomy in the right column (under the outline), with counted, collapsible chips. The group is automatic and has no switch: it appears wherever a taxonomy is defined and the current scope has terms.

The count is not site-wide but per top-level section: it first looks for a section named after the page’s type (a type: docs page uses the /docs/ tree), and otherwise uses the top-level section the page is in. “Tags: release 4” on a blog page means four posts in the blog, not four on the site.

Icons are configured by plural name:

hugo.yml
params:
  ui:
    taxonomy_icons:
      categories: fa-solid fa-folder
      tags: fa-solid fa-tags
      modules: fa-solid fa-cubes

Those two are already the defaults for categories and tags; any other taxonomy defaults to fa-solid fa-shapes. An icon is one Font Awesome class pair, written as everywhere else on the site.

The taxonomy panel in the navbar

A main-menu entry pointing at a taxonomy list page automatically becomes a panel of term chips (by descending usage, with counts), needing no hand-written dropdown:

hugo.yml
languages:
  en:
    menus:
      main:
        - identifier: tags
          name: Tags
          pageRef: /tags
          weight: 60

Both pageRef: /tags and the older url: /tags/ are recognized: a URL-form menu entry is resolved to a site page before its kind is judged, so migrating from an older configuration needs no rewrite. Other ways to write menus are in Navigation and menus.

Bilingual terms

Hugo counts and links taxonomies per language: /categories/ and /zh/categories/ are two unrelated trees, and a Chinese page enters only the Chinese one. Terms are written once per language in each front matter:

content/docs/ha/patroni.md
categories: [High availability]
tags: [PostgreSQL, Patroni, failover]
content/docs/ha/patroni.zh.md
categories: [高可用]
tags: [PostgreSQL, Patroni, 故障切换]

Two things to watch:

  • The same word written identically in both languages (say release) still yields two term pages, /categories/release/ and /zh/categories/release/, each counting only its own language’s pages. Do not write English terms on Chinese pages for the sake of uniformity: the rail chips would then show English.
  • A taxonomy’s display name follows the language (for the six built-in keys), but a term’s name does not: a term is exactly the string written in front matter, and the theme does not translate it. Write 高可用 on an English page and the English site’s chip reads 高可用.

The rest of running a multilingual site is in Languages.

Switching by content type

The theme has no “show on documentation, hide on blog” switch; the control is which pages you tag. What this site does:

Content categories tags Effect
content/docs/** Section-level cascade (the six sections) none The term line has one row, Categories
content/blog/** Per post (release, oink) Per post (Oink, Release) Two rows in the term line, two chip groups in the rail

To make a whole section disappear from the taxonomy, delete categories from the section index’s cascade; nothing else is needed. To keep one page out, write categories: [] in its own front matter — an empty list overrides the cascade.

Verify

Three things to look at on the page:

  • A “Categories: Customization” line under this page’s title;
  • Chips grouped by taxonomy under the outline in the right column, each with a count;
  • /categories/ showing filter chips for every term, each leading to its term page.

On the command line, check the output:

hugo -d public
ls public/categories/          # one directory per term
grep -c 'taxonomy-term' public/docs/customize/index.html

The theme repository has a dedicated check verifying that no taxonomy pages are generated without taxonomies:, and that term page titles are correct in both languages:

cd ~/pgsty/oink && python3 bin/check-taxonomy.py

Limits

  • page_header: [] does not hide the term line: an empty list is treated as unset and falls back to “list every taxonomy”. To remove the line, stop tagging those pages, or hide .taxonomy-terms-article in assets/scss/_styles_project.scss.
  • The rail cloud has neither a switch nor a cap; a site with very many terms should reduce its taxonomies, as there is no way to trim it in configuration.
  • Term pages have no cross-language pairing: switching language on a term page does not guarantee landing on the same term in the other language.

12 - Repository links and page info

Wire “edit this page”, “open an issue” and “view history” to your repository, and show the last-modified line, contributors and the feedback widget at the page end.

The repository-related entries in the action menu at the right of the breadcrumb row are derived from a few github_* parameters, and the “last modified” line at the page end comes from git history. Both assume the content lives in a GitHub-style repository.

Every repository-related entry in the action menu derives from these keys:

hugo.yml
params:
  github_repo: https://github.com/pgsty/oink.pgsty.com # the documentation source repository
  github_project_repo: https://github.com/pgsty/oink # the product repository (optional)
  github_branch: main # defaults to main
  github_subdir: '' # path from the repository root to the Hugo site root

That is this site’s real configuration. With it filled in, this page’s action menu points at:

Menu entry Target
Edit this page …/edit/main/content/docs/customize/repository.md
View history …/commits/main/content/docs/customize/repository.md
Create child page …/new/main/content/docs/customize?filename=change-me.md&value=<template>
Open a documentation issue …/issues/new?title=Repository links and page info
Open a project issue https://github.com/pgsty/oink/issues/new

A few conventions:

  • github_repo points at the repository holding the content, not the theme repository. Naming the theme repository sends a reader’s change to the wrong place. Omit it and all five rows above disappear.
  • github_project_repo is a second repository, receiving product bugs rather than documentation errors. Do not configure it where readers cannot tell the two apart.
  • github_branch defaults to main and names the content branch — not the deployment branch, and not the branch Pages generates.
  • github_subdir is the path inside the repository. Leave it empty when the site source is at the repository root; set it to website when the source sits in a subdirectory (a repository holding both code and website/, say).

All of these can be set at site level, per language, in a section cascade or in a page’s front matter, which matters when content comes from several repositories. The full definitions are in Configuration.

Content from another repository

When a subtree is mounted from an upstream repository, override the repository parameters with a section cascade, then use path_base_for_github_subdir to tell the theme: strip the local path prefix, and append what remains to github_subdir.

content/reference/_index.md
---
title: Upstream reference
cascade:
  github_repo: https://github.com/OWNER/UPSTREAM
  github_project_repo: https://github.com/OWNER/UPSTREAM
  github_subdir: docs
  path_base_for_github_subdir: content/reference
---

content/reference/api/client.md therefore maps to the upstream’s docs/api/client.md.

The value of path_base_for_github_subdir is a regular expression. Where the source filename differs from the local one, use a from / to mapping instead — for example, matching each section’s _index.md to the upstream README.md:

content/reference/_index.md
path_base_for_github_subdir:
  from: content/reference/(.*?)/_index.md
  to: $1/README.md

OINK keeps .md and .zh.md side by side in one directory, so both languages share a path prefix and the expression needs no language directory. After changing it, click “edit this page” once from a leaf page, once from a section index and once in each language: when the expression strips too much, the generated URL looks plausible and is a 404.

Turning individual entries off

Every menu entry carries a stable action ID:

Menu entry Action ID
Copy as Markdown copy_markdown
View Markdown source view_markdown
Open in ChatGPT / Claude open_chatgpt / open_claude
View history view_history
Edit this page edit_page
Create child page create_child_page
Open a documentation issue create_issue
Open a project issue create_project_issue
Print the whole section print_section

Where a host does not support one, hide it with CSS:

assets/scss/_styles_project.scss
.td-page-actions__item[data-oink-action='create_child_page'] {
  display: none;
}

The command palette uses the same IDs, so hiding a menu entry does not remove it from the palette. A target the whole site cannot use should have its key omitted from the configuration rather than covered with CSS: CSS can hide a link, but it cannot make a wrong link right.

The whole menu can also be turned off per page with page_context_menu: false in front matter — see Page parameters.

The new-page template that “create child page” prefills comes from the theme’s assets/stubs/new-page-template.md; a site replaces it with its own skeleton by placing a file of the same name at assets/stubs/new-page-template.md.

Last modified

This line’s data comes from git, not from a file’s mtime. Turn on Hugo’s git support:

hugo.yml
enableGitInfo: true
params:
  github_repo: https://github.com/pgsty/oink.pgsty.com
  ui:
    lastmod_commit: subject # subject | hash | none

The page end then reads “Last modified August 17, 2026 · (a1b2c3d)”, with the commit part linking to …/commit/<hash>. The three values of lastmod_commit:

Value What is shown
subject (default) The commit subject plus the abbreviated hash
hash commit a1b2c3d
none The date only, with no commit link

Any other value warns and uses subject during ordinary preview; a strict publishing build fails on invalid params.ui.lastmod_commit.

Two things to watch:

  • CI needs enough git history. A shallow clone (fetch-depth: 1) cannot reach a file’s last commit, and the date goes missing or wrong. Set fetch-depth: 0 in GitHub Actions.
  • An uncommitted file has no git time. Previewing a newly written page locally, this line is simply absent.

Where git history is unavailable, do not substitute the build time for “last modified”: build time is not when the content changed.

This line belongs to the annotation component, which is on by default and sits after feedback and before the pager. Turn it off for a page with annotation: false.

The line is not all the annotation block renders. The same block also carries two kinds of provenance, both driven by page front matter and needing no template override:

  • Upstream attribution: a page derived from elsewhere writes upstream_link plus the four required keys upstream_name, upstream_copyright, upstream_license and upstream_notice, and the page end gains an attribution line naming the work, the copyright holder, the licence and a link to the full notice. Adding upstream_modified: true appends a “modified downstream” line.
  • Translation notice: params.ui.translation_notice holds the language code of the authoritative version, and a translated page then shows a line pointing back at the original; a page authored natively in this language opts out with translation_notice: false.

Both families are defined in full in Page parameters.

Where customization really is needed, three override points cover one layer each:

Partial to override What it changes
layouts/_partials/annotation-items.html Add, remove or reorder the lines, keeping the theme’s markup, icons, print rules and accessible label
layouts/_partials/page-meta-lastmod.html Replace the markup those lines render as
layouts/_partials/page-annotation.html Replace the block’s outer container

What the page end is made of

The five components are in a fixed order, and every reading layout shares one implementation:

Order Component Theme default Page switch
1 Share Off (params.ui.share is empty) share: false, or the page’s own list
2 Feedback Off feedback: true / false
3 Annotation On annotation: false
4 Pager On for docs / book / blog pager: false
5 Comments On when fully configured comments: false

The order follows what a reader does after the last paragraph: hand the page on, say whether it helped, see where it came from, go to the next one, join the discussion. Share leads because it is the only block that points outward, and because a reader who has decided to pass a page on decided it before being asked how the page went. Configuring the bar is in Writing a blog.

Configuring comments is in Comments.

The feedback widget

One question and two buttons: “Did this page solve your problem?” → yes / no. Choosing no expands four optional reasons. It is off by default:

hugo.yml
params:
  ui:
    feedback:
      enable: true
      reasons: true # whether to ask for a reason after "no"

To enable it for the documentation section only, use a cascade (a blog usually keeps just comments):

content/docs/_index.md
---
title: Docs
cascade:
  feedback: true
---

Where the boundaries are:

  • A click completes it. There is no text box, no submit button and no sign-in.
  • The choice is written to the browser’s localStorage per page and language, so a returning reader sees and can change it.
  • Where the site already has Google Analytics (gtag), it sends a docs_feedback event with result (solved / not_solved), page_path and language; choosing a reason sends a second event carrying reason and refinement: true, distinguishing it from the first count. Without analytics the widget still works, simply reporting nothing — it needs no backend at all.
  • Where the page has comments enabled, an anchor link reading “add details in the comments” appears under the result. Feedback and giscus are two independent data flows, and the theme never writes a comment on the reader’s behalf.

This page sets feedback: true in its front matter (the docs section defaults it off), so the real widget is visible at the page end.

The contributor wall

The contributors shortcode renders a wall of GitHub avatars from a file under the site’s data/ directory, and never contacts GitHub at build time:

data/contributors.yaml
items:
  - github: Vonng
    name: Ruohang Feng
    role: Theme author
  - github: pgsty
    name: Pigsty
    role: Project organization
  - github: gohugoio
    role: Static site generator
    avatar: /icons/logo.svg
Source
{{</* contributors */>}}

The fields: github is required and validated as a GitHub username; a duplicate warns and skips the repeated entry, and strict publishing rejects the warning. name defaults to github; role is optional; url defaults to https://github.com/<github>; avatar is optional, and without it an initial placeholder block is rendered with no network request at all, while a value must be http(s):// or a site-root-relative path.

Several lists mean several data files, selected with data=:

Source
{{</* contributors data="maintainers" */>}}

In Markdown and RSS output the wall degrades to a list of - [@handle](url) — role.

This site has no data/contributors.yaml

The example above therefore does not render on this page. Drop a data file into data/ to see it.

Verify

  • Open the action menu at the right of this page’s breadcrumb row: “edit this page” should point at github.com/<your repository>/edit/<branch>/<source path>, with the path matching the repository segment for segment.
  • Click it again from a section index (_index.md): a section index is the likeliest thing for a path_base_for_github_subdir expression to get wrong.
  • The page end should have a “last modified” line; its absence on a locally created, not-yet-committed page is expected.
  • Check the generated links from the command line:
hugo -d public
grep -o 'data-oink-action="edit_page" href="[^"]*"' \
  public/docs/customize/repository/index.html
  • Page parametersannotation / feedback / pager / page_context_menu and the other page switches
  • Configuration — full definitions of github_*, ui.lastmod_commit and ui.feedback
  • Comments — the last block at the page end
  • Analytics and SEO — where feedback events land
  • AI-agent support — the Markdown and assistant entries in the action menu

13 - Print

A single page goes to the browser’s Cmd/Ctrl+P; a whole section becomes one continuous document through the print output format.

Printing one page needs no configuration: the shell (sidebar, outline, navbar, buttons) all carries d-print-none, so the browser’s Cmd/Ctrl+P yields a clean body. That is why the theme has no per-page “print this page” button.

What does need configuration is the other thing: assembling a whole section (or a whole book) and all its pages into one continuous document with a table of contents. What follows covers enabling it, the structure of the print view, and how to exclude pages.

Enabling whole-section print

print is a custom output format the theme declares and does not enable for a site. Add it to section in the site’s own hugo.yml:

hugo.yml
outputs:
  home: [HTML, markdown, LLMS]
  page: [HTML, markdown]
  section: [HTML, RSS, print, markdown]

That is this site’s configuration. Each key under outputs is a wholesale replacement rather than a merge: adding print means writing back every format that kind already had (HTML, RSS, markdown), and omitting one loses that output.

Once on, every section gains a URL. The _print segment comes first, after the language prefix:

Page Print view
/docs/customize/ /_print/docs/customize/
/docs/ /_print/docs/
/blog/release/ /_print/blog/release/

“Print the whole section” also appears in the page action menu, and the same entry is searchable in the command palette (action ID print_section). It prints the current section: clicking it on /docs/customize/print/ produces the entire Customization section, not this one page.

The structure of a print view

Opening any of those links, from top to bottom:

  1. A notice bar: “This is the multi-page printable view of this section. Click here to print. Return to the regular view of this page.” It carries d-print-none and appears on screen only, never on paper.
  2. The section title and summary.
  3. A whole-section table of contents, numbered 1:, 2:, 2.1: by level, linking to in-document anchors.
  4. Each page in turn, its title becoming “number - title” as in `1 - Configuration", its description a standfirst, and its body rendered as it stands.

Page order is sidebar order (weight), with subsections expanded recursively. Every page from the second onwards starts a new sheet; whether the first does depends on whether the section index’s own body exceeds 50 words, so an index of one sentence does not take a sheet to itself. The threshold is adjustable:

hugo.yml
params:
  print:
    section_break_wordcount: 120

To drop the table of contents:

hugo.yml
params:
  print:
    toc: false

It can also be turned off for one section, in the section index’s front matter:

content/docs/components/_index.md
---
title: Components
print:
  toc: false
---

Excluding pages

Link-only pages, pages that are one redirect note, and pages that are one enormous screenshot are not worth paper. Give them no_print:

content/docs/about/showcase.md
---
title: Showcase
no_print: true
---

It affects the whole-section print view only; the page’s own HTML and the browser’s Cmd/Ctrl+P are unaffected. Sidebar dividers (sidebar_divider) are excluded automatically.

How components look in print

Print is one of the four outputs, and every component has a defined print shape. The whole-section print view and the browser printing one page follow the same rule: anything interactive degrades to static, and anything collapsible is expanded.

Component Print shape
Callouts Static blocks, with every collapsible kind (- / + / DETAILS) expanded; borders go grey and backgrounds drop
Tabs The tab bar disappears and every panel is expanded in turn, each with its own heading
Code Blocks Copy and fold controls removed, max height and scrolling dropped, long lines wrapped
Tables Full-width static tables with no horizontal scroll; headers repeat across pages
Images Image and caption kept, zoom attributes stripped, width brought inside the measure
Gallery The grid becomes a vertical stack
FileTree A static panel with every directory expanded and the split frozen at its build-time width
Fields A complete definition list, identical in both forms
Math Statically rendered KaTeX / MathML
Mermaid · Markmap · PlantUML Still rendered as diagrams: the print view is an HTML page, and these runtimes load as usual
ECharts · Infographic Degrade to the fence source block; no chart is drawn
Asciinema · OpenAPI A labelled static link showing the recording or specification address; none of the three runtimes loads
Cards / steps / badges / keys Static, with content unchanged

The page shell never reaches paper: sidebar, outline, navbar, the page action menu, the feedback widget, heading anchor links and inline copy buttons.

For the three diagram kinds above that a browser runtime draws (Mermaid, Markmap, PlantUML), confirm they have finished drawing before triggering print.

Browser print styles

The theme ships a layer of @media print rules shared by single-page and whole-section printing:

  • A4 paper with 18mm 16mm 20mm margins; 10.5pt body text; the light palette forced.
  • Fonts switch to the --td-print-font-family typography token — see Brand and appearance.
  • Headings do not separate from their body (break-after: avoid-page), and paragraphs and list items keep three-line orphan and widow control.
  • Tables, images, blockquotes, callouts, cards and tabs avoid breaking across pages where possible; code blocks may break, and wrap rather than truncate.
  • Links are underlined and turned dark blue, and the URL text is not printed after them. A site that wants that behaviour adds it:
assets/scss/_styles_project.scss
@media print {
  .td-content a[href^='http']::after {
    content: ' (' attr(href) ')';
    font-size: 0.85em;
    word-break: break-all;
  }
}
  • A closed <details> is always expanded: collapsed callouts and file tree directories are complete on paper.

Custom print styling goes in a @media print block in assets/scss/_styles_project.scss and needs no template change.

Replacing the print templates

To change the structure — adding a running header, or changing the numbering format — override the narrowest partial. They are all under layouts/_partials/print/:

Partial Responsibility
print/render.html The whole-section skeleton: notice bar, contents, recursive content
print/page-heading.html The title and standfirst at the top of the document
print/content.html How one page appears inside the whole-section view
print/toc-li.html One row of the table of contents

The last three additionally support per content type: create print/page-heading-blog.html or print/content-book.html and the theme prefers the type-suffixed one.

Printing a whole book (type: book) takes a different path, where chapter numbers, figure numbers and cross-references stay continuous across the book — see Books.

Verify

hugo -d public
ls public/_print/docs/          # one directory per section

Then look at the page:

  • Open /_print/docs/customize/ in a browser and confirm the contents has as many rows as the section has pages (minus those with no_print: true).
  • Press Cmd/Ctrl+P in that view: the print preview should show no notice bar, no navbar and no buttons.
  • Find a page with tabs and a collapsed callout (for example Tabs) and confirm every panel is expanded in the preview.
  • Print a PDF and read the pagination through, adjusting section_break_wordcount where the threshold does not suit.

14 - AI-agent support

Give every page a .md twin, the site root an llms.txt, and the reader a way to hand the current page to ChatGPT or Claude.

An HTML page carries a sidebar, scripts and styles, and a model has to strip that shell before reading it. OINK emits the same content a second time as plain Markdown: one .md per page, one llms.txt index at the site root, and a “copy as Markdown” button on the page. All three are build-time artifacts, with no runtime service and no content negotiation.

All three have to be declared by the site under outputs; the theme does not turn them on. Two further artifacts, equally opt-in, serve an agent that wants more than one page at a time: a full-text bundle per section and a navigation tree per language.

A .md per page

markdown is one of Hugo’s built-in output formats. Add it to the page kinds that need it:

hugo.yml
outputs:
  home: [HTML, markdown, LLMS]
  page: [HTML, markdown]
  section: [HTML, RSS, print, markdown]

That is this site’s configuration. Each key under outputs is a wholesale replacement rather than a merge: adding markdown means writing back every format that kind already had (RSS, print), and omitting one loses that output.

The URL rule is the page URL plus index.md:

Page Markdown
/docs/customize/agents/ /docs/customize/agents/index.md
/docs/customize/ (section index) /docs/customize/index.md
/ (site home) /index.md

Each HTML page’s <head> also carries a discovery link, so a crawler need not guess the URL:

<link rel="alternate" type="text/markdown" href="https://oink.pgsty.com/docs/customize/agents/index.md">

What the .md contains

It is not rendered HTML converted back to Markdown but the source you wrote: the front matter becomes an H1 and a blockquoted summary, and the body follows verbatim, with shortcodes expanded in place into their own Markdown forms.

the start of /docs/customize/print/index.md
# Print

> A single page goes to the browser's Cmd/Ctrl+P; a whole section becomes one continuous document through the print output format.

---

LLMS index: [llms.txt](/llms.txt)

---

Printing one page needs no configuration: the shell (sidebar, outline, navbar,
buttons) all carries `d-print-none`, so the browser's `Cmd/Ctrl+P` yields a
clean body.

Components in their native Markdown form (callouts, tables, field lists, image attribute lines, code fences, data fences) keep their source in the .md, so what the model reads is what you wrote. A section index additionally appends a Section pages: list of child links after the body.

Shortcode forms each have a defined degradation: a badge becomes emphasized text or a link, a key becomes Ctrl + K, tabs become a run of **Label** subsections, and fields become an item list. Each component page’s Output section states its own row.

Where the site has not enabled the LLMS output, that LLMS index: line does not appear: the theme never points at a file it did not publish.

llms.txt

llms.txt is a plain-text manifest at the site root telling a model what the site holds and where the machine-readable versions are. Add the LLMS output format to the home page to generate it:

hugo.yml
outputs:
  home: [HTML, markdown, LLMS]

A multilingual site gets one per language: /llms.txt and /zh/llms.txt. The content is a generated site index:

/llms.txt (excerpt)
# OINK

> A local-first, Hugo-only theme for technical documentation

## Site index

- [Home page](https://oink.pgsty.com/index.md)
- [Docs](https://oink.pgsty.com/docs/index.md): OINK is a local-first Hugo documentation framework…
- [Blog](https://oink.pgsty.com/blog/index.md): Docsy articles, OINK engineering stories, and OINK release notes

## Documentation index

- [Introduction](https://oink.pgsty.com/docs/about/index.md): A local-first Hugo documentation framework evolved from Docsy…
  - [Highlights](https://oink.pgsty.com/docs/about/features/index.md): What separates OINK from an ordinary Hugo theme…
  - [Cases](https://oink.pgsty.com/docs/about/showcase/index.md): Find the production case closest to your site…
- [Get started](https://oink.pgsty.com/docs/start/index.md): Start from the official OINK Starter, establish a local baseline, and customize it in layers.

## Site locales

- [English](https://oink.pgsty.com/index.md)
- [简体中文](https://oink.pgsty.com/zh/index.md)

Where the three sections come from: Site index is this language’s home page plus the site’s main menu (menus.main, linking the Markdown version where an entry has one, and carrying description where present); Documentation index is the docs section’s subsections and the level of pages beneath them, indented by level, each row carrying that page’s description; Site locales is every language in the site configuration. Menu entries pointing off-site (GitHub, an issue tracker) are dropped: they belong to the navigation shell rather than to this site’s content.

The way to improve llms.txt is through the main menu and each section index’s description, not through this template.

Full-text bundle

One .md per page suits an agent that already knows which page it wants; an agent that wants the whole manual has to crawl it page by page. The LLMSFULL output collapses that into one file per top-level section: llms-full.txt, holding every page of the section in reading order. It is new in OINK 0.8.0 and stays off until a section asks for it.

The switch is the section index’s own front matter rather than the site configuration:

content/docs/_index.md
---
title: Docs
outputs: [HTML, print, RSS, markdown, LLMSFULL]
---

Front matter outputs replaces the site-level list for that page, so write back the formats the section already had: omitting markdown or print here costs the section index those outputs. Front matter is per language, so a bilingual site repeats the line in _index.zh.md to get the Chinese bundle.

The result is one file per language at the section root — /docs/llms-full.txt and /zh/docs/llms-full.txt. The order is the reading order the sidebar and the pager present: the explicit data/docs_nav.json tree where a docs or book section declares one, the weighted content tree otherwise. Pages held out of the sidebar (toc_hide) stay out of the bundle too.

Each page is introduced by a separator carrying its source URL, and the body that follows is byte-identical to that page’s own .md:

/docs/llms-full.txt (excerpt)
================
Source: https://oink.pgsty.com/docs/customize/print/index.md
================

# Print

> A single page goes to the browser's Cmd/Ctrl+P; a whole section becomes one continuous document through the print output format.

================
Source: https://oink.pgsty.com/docs/customize/agents/index.md
================

# AI-agent support

Source: points at the page’s Markdown output, falling back to its HTML URL where the page publishes no .md.

Only a top-level section can carry a bundle. Listing LLMSFULL further down the tree warns — “LLMSFULL output requires a top-level section” — and emits nothing, so hugo server keeps working while a publishing build with --panicOnWarning stops there.

Where at least one section has a bundle, llms.txt grows a ## Full-text bundles list of this language’s bundles: discovery stays in the file an agent already fetches.

This site’s docs section has it enabled: https://oink.pgsty.com/docs/llms-full.txt is the entire English documentation in one fetch.

Navigation JSON

The sidebar is the site’s table of contents, and an agent that can read it plans a route before fetching anything. The NAVJSON output publishes it as data: navigation.json, one file per language at the language root. Like the bundle it is new in OINK 0.8.0 and off by default; the site turns it on for the home page:

hugo.yml
outputs:
  home: [HTML, markdown, LLMS, NAVJSON]

That yields /navigation.json and /zh/navigation.json. The tree is the one the sidebar and the pager already read — the explicit data/docs_nav.json tree where a docs or book section declares one, the weighted content tree everywhere else:

/navigation.json (excerpt)
{
  "baseURL": "https://oink.pgsty.com/",
  "language": "en",
  "root": {
    "children": [
      {
        "children": [
          {
            "description": "Give every page a .md twin, the site root an llms.txt…",
            "id": "/docs/customize/agents/",
            "kind": "page",
            "markdown": "https://oink.pgsty.com/docs/customize/agents/index.md",
            "title": "AI-agent support",
            "url": "https://oink.pgsty.com/docs/customize/agents/"
          }
        ],
        "id": "/docs/",
        "kind": "section",
        "title": "Docs",
        "url": "https://oink.pgsty.com/docs/"
      }
    ],
    "id": "/",
    "kind": "home",
    "title": "OINK",
    "url": "https://oink.pgsty.com/"
  },
  "schemaVersion": 1
}
Key What it holds
id The page’s path with the language prefix removed, so the same page carries the same id in every language
url The absolute URL of this language’s HTML page
markdown The absolute URL of the page’s .md, present only where the page publishes one
title The navigation title (linkTitle, falling back to title)
description The page’s description, where it has one
kind home, section or page for real pages; external or link for placeholders
children The ordered children, where the node has any

Array order is the contract, and weight is never serialized: the ordering has already been applied, and a consumer re-sorting the array would disagree with the sidebar the array came from.

Placeholder rows keep the shape the sidebar gives them: a manual_link entry becomes a node of kind external carrying the URL as authored, a manual_link_relref entry becomes kind link with the reference resolved. Neither has page identity, so neither carries an id or a markdown URL. Sidebar dividers and pages Hugo never renders drop out, while their children stay in place.

The contract is versioned: schemaVersion is 1, and the JSON Schema ships in the theme repository as schema/nav.v1.schema.json — validate against it if you consume the file. Where the site publishes it, llms.txt lists navigation.json for its own language in the site index.

This site has it enabled: https://oink.pgsty.com/navigation.json is a live instance of the tree.

Agent actions on the page

Four entries in the action menu at the right of the breadcrumb row relate to agents:

Entry What it does When it appears
Copy as Markdown Fetches this page’s .md into the clipboard (prefetched on hover, so a click has no perceptible wait) This page has a markdown output
View Markdown source Opens the .md in a new tab This page has a markdown output
Open in ChatGPT Jumps to ChatGPT with a prompt assistant_links: true
Open in Claude The same, to Claude assistant_links: true

The first two exist as soon as the markdown output is on. “Copy” is the left half of the split button (the clipboard icon), and shows a brief tick on success.

The last two are off by default and must be enabled explicitly:

hugo.yml
params:
  ui:
    page_context_menu:
      enable: true
      assistant_links: true

Where the boundary lies once enabled: on a click, the runtime composes a prompt using the full URL from the address bar (real domain, query string and anchor included) — in English, “Please read the contents of so that I can ask you about it.” — and then jumps to the other site. The URL is the only thing that leaves this site; the body is never uploaded, and the other side fetches the content itself. Do not put confidential information in a URL, and disclose this third-party boundary in the site’s privacy statement.

A page may narrow the site policy but not reverse it: front matter page_context_menu: { assistant_links: false } turns the assistant links off for that page, while writing true where the site has not enabled them has no effect. To turn the whole menu off for a page, use page_context_menu: false — see Page parameters.

Both assistant actions are also searchable in the command palette, from the same action manifest — see Command palette.

Opting a page out of .md output

Rewrite outputs in the page’s front matter. It is likewise a wholesale replacement, so write only the formats you keep:

content/legal/terms.md
---
title: Terms of service
outputs: [HTML]
---

To keep RSS and drop only Markdown, list the rest:

content/blog/_index.md
---
title: Blog
outputs: [HTML, RSS, print]
---

Customizing the output

The theme renders Markdown output with layouts/all.md, generates llms.txt with layouts/index.llms.txt, and owns the two opt-in formats in layouts/list.llmsfull.txt and layouts/index.navjson.json. A site replaces any of them wholesale by placing a file of the same name under its own layouts/, but consider a narrower approach first:

  • Per content type: a typed path such as layouts/blog/single.md or layouts/docs/list.md affects only that kind of content, which is how the theme’s own print templates are specialized (layouts/blog/single.print.html). Check the template lookup order for your combination.
  • Per shortcode: a site’s own shortcode can have an output-format-specific template giving it a more machine-readable form in Markdown output.
  • Per page: hand-writing the content of a few high-value pages costs less than changing a template.

The content of llms.txt follows the site’s structure, so before changing the template, confirm the problem is not in the main menu or a description. Replacing index.navjson.json also takes over the nav.v1 contract: whatever you emit still has to satisfy schema/nav.v1.schema.json for a consumer that validates.

Verify

hugo -d public
ls public/llms.txt public/docs/customize/agents/index.md
ls public/docs/llms-full.txt public/navigation.json   # where you opted in

With curl, against production or a local preview:

$ curl -s http://localhost:1313/docs/customize/agents/index.md | head -5
# AI-agent support

> Give every page a `.md` twin, the site root an `llms.txt`, and the reader a way to hand the current page to ChatGPT or Claude.

$ curl -sI http://localhost:1313/llms.txt | head -3

$ curl -s http://localhost:1313/docs/llms-full.txt | head -3
================
Source: http://localhost:1313/docs/index.md
================

Then check four things:

  • Any page’s HTML <head> has rel="alternate" type="text/markdown";
  • Clicking the copy button at the right of the breadcrumb row and pasting yields Markdown rather than HTML;
  • llms.txt contains no off-site links;
  • Where you enabled them: every page in llms-full.txt opens with a Source: line, and the same page carries the same id in each language’s navigation.json.

Limits

  • The machine-readable surface the theme produces is four build-time files: a .md per page, llms.txt, and — where you opt in — llms-full.txt per top-level section and navigation.json per language. The sitemap is still Hugo’s own sitemap.xml.
  • A bundle belongs to a top-level section. There is no whole-site llms-full.txt: an agent that wants everything reads one bundle per section, listed in llms.txt.
  • LLMS, LLMSFULL and NAVJSON are all declared as non-alternative formats, so none of them appears in the <head> alternate links or gains a page action. They are discovered by their conventional paths and by the entries llms.txt carries for them.
  • Server-side content negotiation (one URL returning Markdown for Accept: text/markdown) is outside the theme’s scope and belongs to the hosting layer.
  • Markdown output follows the source path: content generated only in the browser by JavaScript (a runtime-drawn chart) appears in the .md as fence source, not as a diagram.