Skip to content

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

Return to the regular view of this page.

OINK Documentation

OINK is a documentation theme that needs nothing but Hugo Extended — components are written in Markdown, assets ship with the theme, both languages work out of the box, and one source produces four outputs.

OINK is a Hugo theme for technical documentation. Components are part of the Markdown syntax rather than a second template language; the fonts, icons, search and diagram runtimes the browser needs ship with the theme; the only build dependency is one Hugo Extended binary, with no Node.js and no CDN request. The current release is v0.6.0.

Five ways in

  • Quick start — install Hugo, clone this site, replace the site details, deploy.
  • Components — one page per component, source first and rendered result after it.
  • Write Beautiful Docs — a hands-on tutorial from first preview to a maintained publication.
  • Case studies — production sites explained as reusable design and migration patterns.
  • Design and development — contracts, accepted decisions, research evidence, and active proposals for OINK maintainers.

Find it by task

What you want to do Where to go
Decide whether it fits What is OINK
Install and preview Quick start
Write a documentation page Writing pages
Turn a directory tree into a sidebar Organizing content
Look up a component’s syntax Components
Change the name, logo, colours and fonts Brand and appearance
Look up a configuration key’s default Configuration
Run a bilingual or multilingual site Languages
Learn OINK end to end Write Beautiful Docs
Study a production implementation Case studies
Deploy Deploy
Upgrade, or migrate from Docsy Upgrade
Maintain the theme, review a contract, or write a PRD Design and development

The seven Docs sections are ordered the way they are read: understand, install, write content, look up components, adjust the site, run the release, then study or maintain the contracts and design records behind it.

1 - What is OINK

A documentation theme that needs nothing but Hugo Extended. Evolved from Docsy, its components are written in Markdown, its assets ship with the theme, and fourteen production sites run on it.

OINK is a standalone Hugo theme for medium and large technical documentation sites. It evolved from Docsy: the content model and the multilingual behaviour are kept, while the shell, navigation, search and content components are replaced.

A consuming site’s only build dependency is one Hugo Extended binary. There is no Node.js, no npm, no PostCSS and no CDN request. Bootstrap, Font Awesome, the fonts, local search, the diagram runtimes and the API reference runtimes are all committed to the theme repository and shipped only to the pages that use them.

Components are not a second template language: > [!NOTE] is a callout, a table with a {.fields} line is a parameter list, and an image followed by {caption=} has a caption. Fourteen production sites run on it today, this one among them.

OINK turns Markdown content, configuration and local assets into one static documentation site
One Hugo build produces a static site ready to host

What the theme provides

  • The documentation and blog shell: navigation, sidebar tree, table of contents, breadcrumbs, pager, dark mode, print view and accessible interaction.
  • The multilingual frame: translation routing, fallback for untranslated pages, language weighting, RTL, and 32 interface language packs.
  • Local runtimes: Mermaid, KaTeX, Markmap, Swagger UI, Redoc, Asciinema, ECharts, Infographic and local full-text search.
  • Content components: callouts, tabs, steps, cards, field lists, file trees, galleries, badges, keys and more — most with a native Markdown form.
  • Content types: beyond ordinary documentation, built-in book numbering and cross-references, release and download pages, data-driven landing pages, and OpenAPI reference pages.

The theme does not handle source hosting or deployment: a site can live on GitHub, GitLab or a private Git server, and the static files Hugo produces can be published anywhere. A site’s own content, brand and business components stay with the site; the theme supplies the shell and the reusable components.

Is OINK for me

A good fit when A poor fit when
There are many pages and mixed content types: documentation, blog, a book, release pages and an API reference in one site There are one or two pages and no need for structured navigation; a README or a lighter Hugo theme is simpler
You need real multilingual support, not a translation link bolted onto an English site The site is mostly application UI rather than documentation: OINK can carry the documentation part while business components stay at the site layer
Reproducible builds and network isolation matter, and the build machine has no outbound access You need interactive components inside the prose (React / MDX)
Several sites share one shell, so layouts and shortcodes are not copied around You want one switch that swaps in a different look: the theme has no brand switch, and appearance changes go through CSS tokens and partial overrides
The team has no front-end engineers and maintains no Node toolchain You need a built-in CMS or a WYSIWYG editor

How it differs from other documentation systems

The table below lists structural differences only, and only what can be confirmed from each project’s own documentation and repository. Versions change; check each project’s current documentation before choosing.

Dimension OINK Docsy Hextra Docusaurus
Build tool Hugo Extended, one binary Hugo Extended + Node/npm Hugo Node.js toolchain
Does a consuming site need npm No Yes: Bootstrap and Font Awesome are mounted from node_modules/ No Yes
Where front-end assets come from All committed to the theme repository; VENDOR.json records version, source, licence and checksum jQuery is loaded from a CDN on every page unconditionally; Mermaid, KaTeX and others also fetch from a CDN at build time Prebuilt artifacts committed to the repository npm dependencies
How components are written Native Markdown attributes and fences first, 29 shortcodes as the fallback Shortcodes (19) Shortcodes (29) first; callouts also have a > [!NOTE] native form MDX (React components)
Multilingual Hugo multilingual + 32 interface language packs Hugo multilingual (OINK’s packs are inherited from it) Hugo multilingual + 21 interface language packs Built-in i18n framework
Book numbering and cross-references / release and download pages / data-driven landing pages Built into the theme None None Build your own or find a plugin

Two qualifications. Per-page Markdown output and llms.txt are not unique to OINK — Docsy and Hextra have them too, and all three need the site to opt in under outputs. Only the last row is exclusive to OINK, and it comes from PGSTY’s own production sites rather than from what a general documentation site needs. The theme’s interactive features are off by default: search, zoom, comments and feedback all require the site to turn them on.

OINK is not a skin layered over Docsy but a theme that forked and evolved separately. Docsy’s source history, its Apache-2.0 obligations and its attribution are kept intact; the details are in License and acknowledgements.

Start here

  • Quick start — install Hugo, clone this site, replace the site details, publish to GitHub Pages.
  • Components — one page per component, source first and rendered result after.
  • Showcase — fourteen production sites and which part of OINK each one uses.

Highlights lists what the theme provides capability by capability, each entry linking to the guide that covers it.

1.1 - Highlights

What separates OINK from an ordinary Hugo theme, one item at a time, each linking to the guide that covers it.

This page lists what separates OINK from an ordinary Hugo theme, each item ending with the guide that covers it. To install straight away, see Quick start.

Components are written in Markdown

A callout is a > [!NOTE] blockquote (ten semantic types plus one neutral disclosure). A field list is a table with a {.fields} line. Steps and cards are lists with {.steps} / {.cards}. A caption is a {caption="…"} line under an image. Tabs are adjacent fences each carrying a {tab="…"}; file trees, galleries, Mermaid and ECharts are data fences named after their language. On GitHub or in any plain Markdown reader these degrade to blockquotes, tables, lists and code blocks, and nothing is lost.

29 shortcodes cover what the native forms cannot express: cards with icons and images, field entries whose body is several paragraphs of Markdown.

Components

One Hugo binary is enough

A consuming site’s entire build dependency is Hugo Extended 0.160.1 or newer. SCSS is compiled by Hugo’s embedded Sass transpiler; the theme never invokes postCSS. There is no npm, no webpack and no build-time download. Installing the theme as a Hugo Module needs Go on the machine to resolve the module; an offline archive or a submodule does not.

“Hugo only” refers to the build dependency. The interface still runs JavaScript in the browser: search, the command palette, diagrams and tabs are page scripts. The difference is that those scripts ship with the theme and are delivered per page according to what that page actually uses.

Quick start

Local-first

Everything the browser needs is committed to the theme repository: Bootstrap, Font Awesome, four fonts, Lunr, Mermaid, KaTeX, Markmap, Swagger UI, Redoc, Asciinema, ECharts, Infographic. VENDOR.json records the version, source, licence file and SHA-256 checksum of each of the 28 dependencies; updating a runtime means updating artifact, licence and checksum together.

Where a feature could cause a network request, the theme fails the build rather than reaching out silently: PlantUML without params.plantuml.svg_image_url, Diagrams.net without params.drawio.drawio_server, and Algolia without appId / apiKey / indexName all stop the build.

Local-first does not extend to what an author adds. All of these are explicit network choices: external links, remote images and video, iframes, remote API specifications; hosted search such as Algolia or Google Programmable Search; analytics, comments and other SaaS integrations; and PlantUML or Diagrams.net once the author configures a remote renderer. Pages using them are still valid pages, but a site should stop claiming those pages work fully offline.

License and acknowledgements · Configuration

One source, four outputs

Every component has a defined shape in all four outputs: interactive HTML; a print page with zoom and copy controls stripped and disclosures fully expanded; plain Markdown; and RSS. The print view is generated per section (this one is /_print/docs/about/), and the Markdown version is the same page address plus index.md.

A site chooses which of them it wants under outputs; the theme does not decide for it.

Print · AI-agent support

Two languages and 32 interface locales

Multilingual support uses Hugo’s own mechanism: translation routing, a language picker ordered by weight, fallback for untranslated pages, RTL, and canonical and alternate metadata. Interface strings come in 32 language packs sharing one key schema. English, Simplified Chinese (zh-cn and the generic zh) and Traditional Chinese (zh-tw) are human-reviewed; the other locales keep the translations inherited from Docsy, with English fallbacks for the keys OINK added.

Languages

With params.offline_search on, Hugo generates one index per language. The browser searches Latin text with a local Lunr index and falls back to substring matching for CJK text; no query leaves for a third party. A page can adjust its weight with search_boost and add synonyms with search_keywords.

Search

Command palette

Cmd/Ctrl + K opens the command palette; a bare / enters search mode and a bare \ enters command-only mode. The palette holds pages, commands and page actions (switch language, switch theme, copy Markdown) together, so searching and acting share one entry point.

Command palette

Keyboard navigation

On by default, and switchable off per site or per section. w and s move up and down the sidebar tree, a and d collapse and expand, q and e go to the previous and next page, j and k jump along the page’s table of contents, t toggles light and dark, l switches language, h hides the reading shell. Every single-key shortcut stands down while an input or textarea has focus or an input method is composing. The question-mark button in the footer’s bottom bar opens the cheatsheet.

Keyboard navigation

Four content types beyond documentation

The theme also has four kinds of page that need extra structure:

  • Books: chapter numbering, figures / tables / equations / examples numbered with {#id num=} and cross-referenced with xref, indexes generated by book-toc and book-figures and friends, and a printable whole.
  • Release and download pages: data/download/*.yaml produces release cards, asset tables and checksums, with a controlled publication state.
  • Landing pages: data/home/<lang>.yaml assembles the home page sections; any page with layout: landing can use data under data/landing/.
  • API references: Swagger UI and Redoc are both local runtimes, and the specification can live on the site.

Books · Releases and downloads · Home and landing pages · API reference pages

Output for AI assistants

Add markdown to outputs and every page gains a .md twin, the HTML <head> gains a rel="alternate" pointing at it, and the page actions gain “Copy Markdown” and “View source”. The LLMS output format writes an llms.txt inventory at the site root (this site’s is https://oink.pgsty.com/llms.txt).

“Open in ChatGPT / Claude” is off by default: clicking it hands the current URL to a third party, so the site must turn on params.ui.page_context_menu.assistant_links explicitly.

AI-agent support

Versions

Configure params.versions and a version menu appears in the navbar, while an archived version shows a banner at the top of the page pointing readers at the current one; whether the menu jumps page-for-page is the site’s choice. The versions are separately built and separately deployed static sites, so nothing is needed at runtime.

Versions

See for yourself

This site has most of the above enabled. Three checks:

  1. Press Cmd/Ctrl + K on any page and type postgres to see local search results; press \ for command-only mode.
  2. Append index.md to the current page address to get this page’s Markdown version.
  3. Open https://oink.pgsty.com/llms.txt, the site inventory written for AI assistants.

1.2 - Case Guide

Find the OINK production case closest to your documentation, book, landing page, or interactive tool.

The canonical Case library turns fifteen production sites into short, reusable implementation patterns, and the home page mirrors the same fifteen. All of them run OINK, including this documentation site itself as a self-referential case.

Use this guide when you know the shape of the site you want to build. Follow a case for its architecture and trade-offs, then use the linked documentation for the exact configuration. Counts in individual cases describe the audited snapshot rather than a permanent property of a live site.

Distribution documentation

pigsty.io

A very large English site combining a distribution manual, editorial blog, extension catalogue, taxonomies, version navigation, and pricing landing pages.

pigsty.cc

The Chinese peer deployed as an independent single-language site—a useful trade-off when both language corpora have become products in their own right.

pgsty.pro

A bilingual version archive that renders many release pages from reusable, structured release data.

Product documentation

PIG

A compact bilingual CLI manual with a data-driven home page and a much larger companion blog.

SOW

A bilingual operations manual with a dedicated download content type fed by release metadata.

SILO

A large upstream migration whose checked manifest generates the bilingual documentation navigation.

PG Exporter

A metrics manual combining generated navigation, a structured catalogue, and a system-font presentation.

Books

Designing Data-Intensive Applications

A multilingual, multi-edition book and the strongest example of numbered figures, cross-references, chapter navigation, and indexes.

The Product-Minded Engineer

A focused bilingual publication that needs only OINK’s Book shell.

PG Internal

A finished Chinese translation published as a deliberately single-language Book, with no documentation tree and nothing to switch languages to.

Aggregate, landing, and custom sites

pgsql.cc

An aggregate operations library where several upstream manuals and partially translated language trees share one search and visual system.

pgsty.com

A small bilingual corporate site showing that OINK can primarily be a data-driven landing-page system.

Capslock

A two-page-per-language project whose custom shell hosts an interactive, data-driven configuration generator.

oink.pgsty.com

The full reference site: public documentation, live component examples, design contracts, multiple content shells, and regression coverage in one repository.

pgext.cloud

The PostgreSQL extension catalog: a queryable dataset as the primary object of a site, indexing 2,241 extensions and 576 packaged builds across 16 platforms.

Choosing a starting point

  • For a conventional product manual, begin with PIG or SOW.
  • For a large migration, compare SILO and pgsql.cc.
  • For a book, compare TPME with the more elaborate DDIA implementation, or PG Internal for a single-language one.
  • For a landing or interactive site, start with pgsty.com or Capslock.
  • For the broadest reference, use OINK Docs.
  • For a queryable dataset presented as the primary object of a site, see ext.pgsty.com.

The theme repository’s tests/site/ is an internal CI fixture, not a starter template. Its pages exist to exercise rendering behavior; the production cases above are the better design references.

Browse all cases · Quick start · Repository tour

1.3 - License and acknowledgements

Which licence applies to which layer — Apache-2.0 for the theme, CC BY 4.0 for the documentation, and their own terms for every third-party runtime shipped with the theme.

OINK is three layers of material: the theme source, the documentation content, and the third-party assets shipped with the theme. None of them is relicensed into a single combined work. Every table below points at the authoritative file in the repository; where a summary and the licence text disagree, the file wins.

Which licence covers what

Scope Licence Authoritative file
OINK theme source (layouts, partials, shortcodes, SCSS, JS, i18n) Apache License 2.0 Theme LICENSE, NOTICE
This site’s own code, build scripts and material derived from Docsy Apache License 2.0 Site LICENSE, NOTICE
This site’s original documentation content, except where stated otherwise Creative Commons Attribution 4.0 International Site LICENSE-CC-BY-4.0
Browser libraries, fonts and icons shipped with the theme Each component’s own licence Theme VENDOR.json and the licence files beside each asset

Two boundaries are worth keeping straight. CC BY 4.0 covers the original documentation content only, not the theme code, the trademarks, the screenshots or the third-party assets. And the theme being Apache-2.0 does not turn its bundled dependencies into Apache-licensed works.

Upstream: Docsy

What the theme’s NOTICE records:

  • OINK is derived from Docsy, Copyright 2018 Google LLC and Docsy contributors.
  • OINK’s own theme work is Copyright 2026 PGSTY contributors.
  • The project and its upstream are both under Apache License 2.0. The licence, source, version and checksum of every third-party browser dependency are recorded in VENDOR.json, and each NOTICE file a dependency requires is distributed beside the asset it belongs to.
  • The Docsy name and Google’s trademarks belong to their respective holders; naming them here identifies the upstream project and implies no endorsement.

This site is likewise derived from the Docsy project website, and that lineage is recorded in the site’s own NOTICE. Docsy is OINK’s only code upstream: the source history, the Apache-2.0 obligations and the copyright notices are kept intact, and as Apache-2.0 requires, modified files carry a modification notice.

Third-party runtimes shipped with the theme

The theme commits everything the browser needs to the repository (assets/third_party/, assets/js/third_party/, static/webfonts/), so a consuming site needs no npm and downloads nothing at build time. VENDOR.json is the machine-readable manifest for that material: for each entry it records the name, the pinned version, the source URL, the licence file path and the SHA-256 of every selected artifact, plus an aggregate checksum for each of the three asset trees.

The table below is a snapshot of that manifest (VENDOR.json generated 2026-08-17, schema 1, 26 entries). Versions change with each theme release, so the VENDOR.json in the repository is authoritative. Every source is the npm registry (https://registry.npmjs.org/…).

Package Version Licence What it does in the theme
bootstrap 5.3.8 MIT Grid, components and the RTL stylesheet
@popperjs/core 2.11.8 MIT Overlay positioning for Bootstrap
@fortawesome/fontawesome-free 7.3.1 CC-BY-4.0 AND OFL-1.1 AND MIT Icons throughout the site
@fontsource-variable/inter 5.3.0 OFL-1.1 Interface and body font
@fontsource/chakra-petch 5.3.0 OFL-1.1 Brand display font
@fontsource/ibm-plex-mono 5.3.0 OFL-1.1 Code font
lunr 2.3.9 MIT Local full-text search
@docsearch/js 5.0.1 MIT The optional Algolia DocSearch front end
@docsearch/css 5.0.1 MIT Its stylesheet
mermaid 11.16.1 MIT Mermaid diagrams
katex 0.18.4 MIT Mathematics
markmap-autoloader 0.18.12 MIT Mind maps
markmap-lib 0.18.12 MIT Mind maps
markmap-view 0.18.12 MIT Mind maps
markmap-toolbar 0.18.12 MIT Mind map toolbar
d3 7.9.0 ISC Markmap dependency
@highlightjs/cdn-assets 11.12.0 BSD-3-Clause Markmap dependency
webfontloader 1.6.28 Apache-2.0 Markmap dependency
swagger-ui-dist 5.32.13 Apache-2.0 OpenAPI reference pages
redoc 2.5.3 MIT OpenAPI reference pages
asciinema-player 3.17.0 Apache-2.0 Terminal recording playback
echarts 6.1.0 Apache-2.0 Charts
@antv/infographic 0.2.19 MIT Infographics
pako 3.0.1 MIT AND Zlib Decompression (diagram data)
external-svg-loader 1.7.1 MIT Inlining external SVG
idb-keyval 6.2.0 Apache-2.0 Browser-side caching

Licence texts sit beside the asset they belong to — for example assets/third_party/bootstrap/LICENSE and assets/third_party/katex/LICENSE — and Swagger UI, Redoc and ECharts also ship their own NOTICE or bundled-declaration files. Lunr is the one exception: its code is in assets/js/third_party/ while its licence is at assets/third_party/lunr/LICENSE.

Redistributing the theme means carrying all of this licence and notice material with it. Updating a runtime means updating the artifact, the licence file, the source and the checksum in the same change.

Fonts and icons

All three fonts (Inter, Chakra Petch, IBM Plex Mono) are under the SIL Open Font License 1.1, and the font files are committed to static/webfonts/: fourteen Inter subset files, four brand-font files, and Font Awesome’s three, twenty-one in all. Font Awesome Free 7.3.1 carries a composite licence — CC BY 4.0 for the icon artwork, SIL OFL 1.1 for the font files, MIT for the code — with the text in assets/third_party/Font-Awesome/LICENSE.txt.

The theme makes no request to a remote font service: there is no Google Fonts link in the repository, and fonts are always served from the site’s own baseURL. To change fonts or switch to the platform stack, see Brand and appearance.

Design references

Docsy is the only code upstream. The projects below are references for the design language. They are neither a source of code nor a runtime dependency, and OINK has ported no code from them:

Project What was learned from it
Fumadocs Content-first presentation, information hierarchy, and writing components such as file trees and field lists (the theme’s NOTICE records this acknowledgement)
Nextra A spare documentation shell, filename and copy affordances on code blocks, per-page layout switches
Hextra A Hugo-native approach to implementation, file trees, badges, tabs
Mintlify Layered navigation structure, synchronized code groups, the reading experience of an API reference

Hugo is the build platform, and Go resolves modules when the theme is installed as a Hugo Module. Both are prerequisites, and the theme redistributes neither binary.

Naming these projects describes lineage, dependency or inspiration and implies no endorsement by them; project and product names belong to their respective holders.

Reusing this documentation

CC BY 4.0 permits sharing and adaptation for any purpose, provided you give attribution, link to the licence, state whether you made changes, and do not imply that OINK, PGSTY or any upstream project endorses your adaptation. A sufficient attribution reads:

Adapted from the OINK documentation by PGSTY contributors, licensed under CC BY 4.0, with modifications.

Images or quotations that carry their own attribution on a page keep their own credit and licence; removing the footer does not discharge the attribution obligation.

Reusing the theme

Apache-2.0 permits using, modifying and distributing the theme source and its build output under its terms, provided you keep the licence, copyright and attribution notices, keep the contents of NOTICE, and state which files you changed when distributing modified source. A theme distribution should include LICENSE, NOTICE, VENDOR.json, and every third-party licence file the manifest references.

Apache-2.0 grants no trademark rights, and it does not turn third-party assets into Apache-licensed works.

2 - Quick start

Clone the OINK documentation site, preview it locally, replace the site details, and deploy to GitHub Pages.

This path does not start from an empty directory. It starts by cloning the site you are reading, deleting what you do not need, and replacing the rest with your own details. This site is OINK’s regression site: it contains every component and every page type, and it tracks the theme version. Trimming it down is less writing than adding configuration and examples one at a time to an empty directory.

Prerequisites: a machine that can install Hugo Extended and Go, a GitHub account, and ten minutes. No Node.js and no other front-end toolchain.

What you end up with

A bilingual documentation site: your directory tree in the left sidebar, this page’s outline on the right, full-text search and a command palette in the navbar, and light or dark following the system. One Markdown source produces the web page, the print page, plain Markdown and RSS. Hosted on GitHub Pages.

How content, configuration and the theme combine into a static site at build time
One source, four outputs: HTML, print, Markdown, RSS

Walkthrough

  1. Install Hugo Extended and Go

    Besides Git you need two things. Hugo Extended must be 0.160.1 or newer: the standard Hugo build has no embedded Sass compiler, cannot compile the theme’s styles, and fails the build. Go resolves modules: OINK is published as a Hugo Module, and Hugo uses Go’s module machinery to download and verify github.com/pgsty/oink.

    macOS
    brew install hugo go git
    Linux
    # Distribution repositories often carry a Hugo that is too old; use the official deb (this site's CI does the same)
    curl -LO https://github.com/gohugoio/hugo/releases/download/v0.164.0/hugo_extended_0.164.0_linux-amd64.deb
    sudo dpkg -i hugo_extended_0.164.0_linux-amd64.deb
    sudo apt install -y golang-go git
    Windows
    winget install Hugo.Hugo.Extended
    winget install GoLang.Go
    winget install Git.Git

    Check once afterwards; the output must contain extended:

    $ hugo version
    hugo v0.164.0+extended+withdeploy darwin/arm64 BuildDate=2026-07-06T16:39:30Z
    $ go version
    go version go1.26.6 darwin/arm64
    

    On other platforms follow the Hugo installation guide and go.dev/dl, taking care to pick the extended build.

  2. Clone the documentation site and preview it

    git clone https://github.com/pgsty/oink.pgsty.com my-docs
    cd my-docs
    hugo server

    Open http://localhost:1313/; the Chinese site is at http://localhost:1313/zh/. The first start downloads the theme module (a few seconds to a minute, depending on the network); after that, edits hot-reload in milliseconds.

    The committed go.mod pins the theme version, so the clone builds as it is, with no extra install script.

    Note

    The Makefile in the repository is only a set of command aliases. make dev and make check use a sibling ../oink theme checkout through HUGO_MODULE_REPLACEMENTS; make build and make serve always use the published version pinned in go.mod. For a new site, hugo server is enough.

  3. Replace the site details

    Site identity lives entirely in hugo.yml. baseURL is a YAML anchor — the real address is written on params.productionURL, and that is the only place to change it:

    hugo.yml
    title: Product Docs # navbar site name and <title>
    
    params:
      productionURL: &productionURL https://docs.example.com/
      github_repo: https://github.com/example/product-docs # where "Edit this page" points
      copyright:
        authors: '[Example Inc.](https://example.com/)'
        from_year: 2026
      footer_center_info: ''
    
    baseURL: *productionURL

    languages.en.title and languages.zh.title override the top-level title, so change both. Every parameter’s meaning and default is in Configuration.

    Delete the configuration specific to this site. Keeping it points your site at OINK’s repositories and accounts.

    Key in hugo.yml What to do
    services.googleAnalytics.id OINK’s analytics ID. Delete it, or replace it with your own when you want analytics
    params.comments giscus pointing at discussions in pgsty/oink.pgsty.com. Delete the block or point it at your repository
    params.tdVersion params.version params.version_menu params.versions OINK’s version menu. Delete
    params.github_project_repo A link to the theme repository. Delete
    languages.<lang>.menus.main Navbar entries pointing at this site’s sections such as /docs/tutorial. Rewrite for your tree

    Replace the logo and icons. Replace these three files, keeping the filenames — the theme mounts them by name:

    static/
    static/favicon.svg           # browser tab icon
    static/favicon.ico
    static/apple-touch-icon.png  # iOS add-to-home-screen

    static/logo.svg is this site’s own brand lockup and no parameter points at it. Delete it, or replace it with a horizontal wordmark and set params.wordmark.

    Replace the content. content/docs/ is OINK’s own theme documentation; delete the whole tree and write your first page:

    rm -rf content/docs && mkdir -p content/docs
    content/docs/_index.md
    ---
    title: Docs
    linkTitle: Docs
    description: Product documentation.
    weight: 20
    ---
    
    Everything about running Product in production.

    content/blog/ can keep one post as a template or be deleted entirely (if you delete it, remove the blog entry from menus.main as well). Which directories must stay and which belong to the documentation site itself is in Repository tour.

    For an English-only site, delete the whole languages.zh block and every .zh.md file, leaving one language:

    hugo.yml
    defaultContentLanguage: en
    languages:
      en:
        label: English
        locale: en-US
        weight: 1
        title: Product Docs
        menus:
          main:
            - { name: Docs, pageRef: /docs, weight: 20 }
    find content -name '*.zh.md' -delete

    To keep both languages or swap in a different pair, see Languages.

  4. Deploy

    Create an empty repository on GitHub and replace the local history with your own:

    rm -rf .git && git init -b main
    git add . && git commit -m "Initial documentation site"
    git remote add origin [email protected]:example/product-docs.git
    git push -u origin main

    The repository ships with .github/workflows/pages.yml: a push to main builds and publishes, and it can also be triggered by hand from the Actions page (workflow_dispatch). It pins the Hugo Extended and Go versions, builds with --printPathWarnings --panicOnWarning, and takes baseURL from GitHub Pages, so publishing to a subpath such as example.github.io/product-docs/ needs no configuration change.

    In the repository, go to Settings → Pages → Build and deployment → Source and choose GitHub Actions. The default is Deploy from a branch, and leaving it will make the workflow fail at the deploy step.

    Deleting scripts/ means editing the workflow

    The Verify advertised and pinned release match step in pages.yml runs node scripts/check-release-pin.mjs to check that the version the site advertises matches the one pinned in go.mod. Once scripts/ is gone, remove that step and Set up Node.js from pages.yml.

    For Cloudflare Pages, Netlify, Nginx and offline packaging, see Deploy: the build command is always hugo --gc --minify, and only baseURL and the environment variables differ.

Verify

Run a production build locally. It is stricter than the development server, and path warnings fail the build:

hugo --gc --minify --printPathWarnings --panicOnWarning

It passes when it prints Total in … with no WARN or ERROR. Then check against the preview:

  • The navbar shows your site name and logo, and the browser tab shows your favicon
  • The sidebar is your own tree, and every page opens
  • Ctrl with K (or with K on macOS) opens the command palette and finds the page you just wrote
  • In the menu beside the page title, “Edit this page” points at your repository, not pgsty/oink.pgsty.com
  • After deploying, Deploy Oink site to GitHub Pages is green on the repository’s Actions page

For build errors, see Troubleshooting.

Next steps

  • Repository tour — what each cloned directory is, and what can go.
  • Writing pages — what a documentation page is made of: front matter, heading anchors, links and images.
  • Components — callouts, tabs, field lists, file trees and the rest, one page each.
  • Brand and appearance — accent colour, font preset, page width and custom styles.
  • Deploy — hosting beyond GitHub Pages, and the acceptance checklist.

Instructions for a coding assistant

The four steps above can be handed to a coding assistant (Claude Code, Codex and the like). Copy the block below and replace the three bracketed items with your own details:

Instructions to copy wholesale
Please set up a documentation site for me with the OINK theme, following the process
below. Where something is unclear, apply "only ask when information is missing".

1. Check the environment: run `hugo version` and require the output to contain `extended`
   with a version >= 0.160.1; run `go version` and require a version number. If either
   fails, install it per the official documentation first — `brew install hugo go` on
   macOS, the hugo_extended deb from GitHub Releases on Debian/Ubuntu.
2. Clone the site template: `git clone https://github.com/pgsty/oink.pgsty.com [target directory]`
   and change into it.
3. Change three things in hugo.yml: the top-level `title` and `languages.<lang>.title` to
   [site name]; `params.productionURL` to [site domain] (baseURL is a YAML anchor pointing
   at it, so do not change baseURL separately); `params.github_repo` to the repository this
   site will live in. Also delete this site-specific configuration:
   `services.googleAnalytics`, `params.comments`, `params.tdVersion`, `params.version`,
   `params.version_menu`, `params.versions`, `params.github_project_repo`, and reduce
   `menus.main` to /docs and /blog only.
4. Clear the example content: delete the whole `content/docs/` tree and create a new
   `content/docs/_index.md` (front matter with at least title / description / weight);
   keep one post under `content/blog/` as a template. Delete the scaffolding that belongs
   to the documentation site itself: `tests/`, `scripts/`, `playwright.config.mjs`,
   `package.json`, `package-lock.json`, `AGENTS.md`, `TRANSLATION.md`, `CONTRIBUTING.md`,
   `agent-docs.config.yml`; delete every workflow under `.github/workflows/` except
   `pages.yml`; and remove the `Set up Node.js` and
   `Verify advertised and pinned release match` steps from `pages.yml`.
5. Start `hugo server` in the background and confirm http://localhost:1313/ returns 200
   and the page title is the new site name.
6. Validate: run `hugo --gc --minify --printPathWarnings --panicOnWarning` and require it
   to end with `Total in ...` and no WARN/ERROR. Fix any error rather than working around
   it by suppressing warnings.
7. Only ask me when [site name], [site domain] or the repository address is missing;
   otherwise proceed with the defaults above.

The resulting site is easy for an assistant to read: every page has a .md plain-text output, the site root has llms.txt, and the menu beside the page title offers “Copy as Markdown” and “Open in Claude”. See AI-agent support.

To start from an empty directory instead of this repository, see From scratch and other install methods.

2.1 - Repository tour

What every cloned directory is — what must stay, what to replace with your own details, and what belongs to the documentation site and can go entirely.

This page goes through every file and directory of a pgsty/oink.pgsty.com clone: what must stay, what to replace with your own details, what belongs to the documentation site and can be deleted whole — plus a safe order to delete in.

The theme’s code is not in this repository. It is a Hugo Module pinned by go.mod and stored in Go’s module cache. This repository holds content, configuration, and a small number of site-level overrides.

Top-level structure

the cloned my-docs/

  • my-docs/
    • hugo.ymlthe site's only configuration: identity, languages, menus, params, module imports
    • go.modpins the theme version
    • go.sumchecksums for the theme module
    • content/all content; the directory structure is the sidebar structure
      • _index.mdhome page; _index.zh.md is its Chinese counterpart
      • search.mdresults page for Google Programmable Search; delete if unused
      • docs/documentation tree: OINK's own theme documentation
      • blog/blog: engineering notes and release announcements
    • assets/resources that go through Hugo processing
      • scss/site style overrides, three partials
      • images/images that need resizing or cropping
      • parts/Markdown and YAML fragments pulled in by the include shortcode
    • static/copied to the site root as is, unprocessed
      • logo.svgbrand lockup; no parameter points at it
      • favicon.svgbrowser tab icon
      • favicon.ico
      • apple-touch-icon.pngiOS add-to-home-screen
      • images/screenshots and diagrams
    • layouts/site template overrides: override the narrowest one
      • _shortcodes/the site's own shortcodes
    • data/data-driven pages
      • home/home page sections: en.yaml / zh.yaml
      • landing/landing page data
      • download/release and download page data
    • .github/
      • workflows/pages.yml deploys; the other two are this site's regression tests
    • tests/documentation site only: Playwright, goldens, build assertions
      • browser/Playwright specs
      • hugo-build/build assertions
      • md-output/Markdown output goldens
      • alt-site/alternate-configuration builds
      • favicons/
      • release-pin/
      • fixtures/
    • scripts/documentation site only: translation parity and link checks
      • check-doc-translations.mjs
      • check-markdown-style.mjs
      • check-rendered-links.mjs
      • check-rendered-markdown.mjs
      • check-release-pin.mjs
    • Makefilebuild / serve call Hugo directly; dev / check point at a sibling ../oink
    • package.jsonthe test toolchain; not used to build the site
    • package-lock.json
    • playwright.config.mjs
    • agent-docs.config.ymlconfiguration for the agent-documentation scoring tool
    • AGENTS.mdrepository notes for coding agents
    • TRANSLATION.mdthe bilingual translation process
    • CONTRIBUTING.md
    • README.md
    • LICENSEApache-2.0, for the site code
    • LICENSE-CC-BY-4.0content licence
    • NOTICE

Not listed above: .gitignore, .gitattributes, .nvmrc, .npmrc, and the generated output excluded by .gitignorepublic/ (build output), resources/ (Hugo’s resource cache) and node_modules/. That last group never enters version control.

There is no i18n/ in the repository: interface strings (“Previous”, “On this page” and the like) come from the theme’s 32 language files. To change one of them, create i18n/zh.yaml at the site root and write only the keys you override.

What to do with each entry

Path What it is What to do after forking
hugo.yml The site’s only configuration file — no config/ directory and no per-environment overrides Replace with your details: identity, languages, menus, brand
go.mod go.sum Pin the theme version and record its checksums Must stay, and both are committed
content/ All content; the directory structure decides the sidebar structure Must stay; replace the docs/ and blog/ inside it with your own
content/search.md A full-page search results page (layout: search); it only has content when Google Programmable Search is configured (params.gcs_engine_id) Delete it when using the theme’s local search
assets/scss/ Site style overrides (_variables_project.scss and friends) Keep it to change colours and fonts; empty it if you change neither
assets/images/ Images that need Hugo processing (resize, crop) Replace with your own
assets/parts/ Fragments the include shortcode pulls in Replace or delete with the pages that use them
static/ Copied to the site root as is Replace with yours: logo, favicon, screenshots
layouts/_shortcodes/ This site’s four shortcodes, none referenced by current content Can be deleted
data/home/ Home page section data (hero, capability panels) Change to yours; delete it and the home page falls back to an ordinary page
data/landing/ data/download/ Data for landing pages and the release/download page Delete if unused
.github/workflows/pages.yml Builds and publishes to GitHub Pages on a push to main Keep, adjusted for your repository
.github/workflows/site-checks.yml browser-quality.yml This site’s regression pipelines Documentation site only; can be deleted
tests/ scripts/ playwright.config.mjs package.json package-lock.json This site’s regression tests and check tooling Documentation site only; can be deleted
Makefile Shortcuts for developing the theme and the site together (expects a sibling ../oink) Documentation site only; can be deleted
AGENTS.md TRANSLATION.md CONTRIBUTING.md agent-docs.config.yml This site’s collaboration conventions Replace with your own, or delete
README.md LICENSE LICENSE-CC-BY-4.0 NOTICE Description and licences Replace with your own
.nvmrc .npmrc Node version and npm configuration Delete along with package.json
Building a site with OINK needs no Node.js

The package.json, tests/ and scripts/ in this repository maintain the documentation site itself. Building your site is one command: hugo --gc --minify.

Deletion order

Delete the periphery first, then the content, then the data. Build after each step so a problem points back at one step.

  1. Drop the scaffolding

    None of this takes part in rendering, and removing it affects no page.

    rm -rf tests scripts node_modules
    rm -f package.json package-lock.json playwright.config.mjs .nvmrc .npmrc
    rm -f AGENTS.md TRANSLATION.md CONTRIBUTING.md agent-docs.config.yml Makefile
    rm -f .github/workflows/site-checks.yml .github/workflows/browser-quality.yml

    After deleting scripts/ you must edit .github/workflows/pages.yml: remove the Set up Node.js and Verify advertised and pinned release match steps, or the deploy fails there.

  2. Drop the example content

    content/docs/ is OINK’s own theme documentation and content/blog/ its engineering blog; neither has anything to do with your product.

    rm -rf content/docs
    mkdir -p content/docs
    rm -rf content/blog        # if you want no blog; otherwise keep one post as a template

    Edit menus.main under each language in hugo.yml at the same time: those entries point at paths such as /docs/tutorial and /blog/release that no longer exist. content/_index.md is the home page — keep it and replace the body with yours.

  3. Trim the data

    The three groups under data/ feed the home page, landing pages and release pages. Keep the home page data and edit it; delete the other two if unused.

    rm -rf data/landing data/download

    data/home/en.yaml and data/home/zh.yaml decide which sections the home page has; each entry is explained in Home and landing pages. Deleting data/home/ entirely still builds, and the home page falls back to an ordinary content page.

  4. Swap the identity

    Finally, change the site name, params.productionURL, params.github_repo and the brand parameters in hugo.yml to yours, replace the logo and favicon under static/, and delete the OINK-specific configuration: services.googleAnalytics, params.comments and the params.version* keys. The itemized list is in step 3 of Quick start.

Where the theme lives

The theme is referenced as a Hugo Module, and two places point at it:

hugo.yml
module:
  imports:
    - path: github.com/pgsty/oink
  hugoVersion:
    extended: true
    min: '0.160.1'
go.mod
require github.com/pgsty/oink v0.6.0

hugo.yml declares which theme to use, go.mod pins which version of it, and go.sum records that version’s checksums. All three files are committed. The theme source never enters your repository: Hugo downloads it into Go’s module cache, and hugo mod graph shows what actually resolved.

Upgrade to the newest version:

hugo mod get -u github.com/pgsty/oink

Pin to one version:

hugo mod get github.com/pgsty/[email protected]

Both commands rewrite go.mod and go.sum. A production site pins a release tag rather than following main. What to check before and after an upgrade, and how to roll back, is in Upgrade.

Site overrides

Files under layouts/ shadow the theme’s files of the same name, following Hugo’s template lookup order. This site has only one kind:

  • layouts/_shortcodes/*.html — the site’s own shortcodes. Product documentation that needs a shortcode with business meaning puts it here too.

Self-linking heading anchors come from the theme’s own _markup/render-heading.html; a site does not need to build that hook.

To change the shell (sidebar, footer, page end), override the narrowest partial rather than copying baseof.html wholesale — a copy has to be merged by hand at every theme upgrade.

Verify

Build after each deletion step so an error points at what you just removed:

hugo --gc --minify --printPathWarnings --panicOnWarning

Once the deleting is done, all of this should hold:

  • The build ends with Total in … and no WARN or ERROR
  • No navbar entry links to a deleted directory
  • Headings still have their self-link anchors (the theme’s own heading render hook; the site needs no override)
  • git status shows no public/ or resources/

2.2 - From scratch and other install methods

Build a minimal OINK site in an empty directory, and weigh the four install methods — Module, submodule, offline archive, pinned clone.

This page builds a minimal OINK site in an empty directory: a dozen lines of hugo.yml plus one hugo mod get gives a single-language site you can preview. The cost is that the home page, the example content and any component usage to copy from are all yours to write.

An existing Hugo site needs no scaffolding: install the theme module, add the three Goldmark prerequisites (see Writing hugo.yml), and leave the content alone. For an existing Docsy site, see Upgrade.

The second half weighs four install methods: Hugo Module, Git submodule, offline archive, pinned clone.

From an empty directory to the first page

  1. Create the skeleton and fetch the theme

    hugo new site --format yaml my-docs
    cd my-docs
    hugo mod init github.com/example/my-docs
    hugo mod get github.com/pgsty/[email protected]

    What follows hugo mod init is your own site’s module path, usually the repository address. hugo mod get writes go.mod and go.sum, and both are committed.

    The newest version number is on GitHub Releases; the v0.6.0 on this page is what this site currently pins. A production site pins a release tag rather than following main: @latest is a one-off resolution, not a version policy.

  2. Writing hugo.yml

    Rename the hugo.yaml that hugo new site generated to hugo.yml (Hugo accepts both; this documentation uses the latter throughout) and replace its contents with the following, which builds as it stands:

    hugo.yml
    title: Product Docs
    baseURL: https://docs.example.com/
    defaultContentLanguage: en
    # enableGitInfo: true        # the "last modified" time comes from git; run git init before enabling
    
    languages:
      en:
        label: English
        locale: en-US
        weight: 1
        title: Product Docs
        params:
          description: Everything about running Product in production
        menus:
          main:
            - { name: Docs, pageRef: /docs, weight: 20 }
            - { name: Blog, pageRef: /blog, weight: 50 }
    
    # The three Goldmark prerequisites: OINK's native Markdown components depend on them
    markup:
      goldmark:
        renderer:
          unsafe: true # allow inline HTML in content
        parser:
          attribute:
            block: true # attribute lines such as {.steps} {.cards} {caption=}
          wrapStandAloneImageWithinParagraph: false # only a block-level image can carry an attribute line
      highlight:
        noClasses: false # code colours follow light and dark mode
    
    params:
      offline_search: true
      github_repo: https://github.com/example/product-docs
      copyright:
        authors: '[Example Inc.](https://example.com/)'
        from_year: 2026
      ui:
        dark_mode: true
        sidebar_menu_foldable: true
        section_index: cards
    
    outputs:
      home: [HTML, markdown, LLMS]
      page: [HTML, markdown]
      section: [HTML, RSS, print, markdown]
    
    module:
      imports:
        - path: github.com/pgsty/oink
      hugoVersion:
        extended: true
        min: '0.160.1'

    What each of the five blocks governs:

    Block Governs Consequence of omitting it
    Top level + languages Site name, domain, languages and navbar menu A wrong baseURL sends every absolute link astray in production
    markup.goldmark The three component prerequisites An attribute line becomes a literal {.steps} in the prose
    params Search, repository links, shell switches Interactive features stay off; the theme does not decide for the site
    outputs The per-page .md, llms.txt and print pages No “Copy as Markdown” in the page menu, and no print view
    module References the theme and declares the Hugo floor The build cannot find the theme

    Mathematics additionally needs Goldmark’s passthrough extension; see Math. Every key’s full meaning and default is in Configuration.

  3. Write the first page

    Every top-level directory under content/ is a section, and the directory structure is the sidebar structure. A documentation section needs at least an _index.md:

    content/docs/_index.md
    ---
    title: Docs
    linkTitle: Docs
    description: Everything about running Product in production.
    weight: 20
    ---
    
    Start with [Install](/docs/install/).
    content/docs/install.md
    ---
    title: Install
    description: Install Product on a fresh machine.
    weight: 10
    ---
    
    ## Prerequisites {#prerequisites}
    
    > [!IMPORTANT]
    > Product needs PostgreSQL 18 or newer.
    
    ## Install {#install}
    
    ```bash
    curl -fsSL https://get.example.com | bash
    ```

    Write explicit {#id} anchors on headings: when a translation is added later, the two languages’ anchors have to correspond. How to write a page is in Writing pages.

  4. Preview

    hugo server

    Open http://localhost:1313/ and the sidebar shows Docs → Install. Edits hot-reload in milliseconds.

Other install methods

The steps above use a Hugo Module. The other three address particular constraints: network isolation, a platform that requires the build input to contain the whole theme tree, or an organization that reviews its own copy of the theme. Apart from hugo mod vendor, none of them creates a Go module, and the site references the theme with theme: oink rather than module.imports. The shared cost is that version resolution and integrity checking become your responsibility.

Hugo Module (recommended)

hugo mod init github.com/example/product-docs
hugo mod get github.com/pgsty/[email protected]
hugo.yml
module:
  imports:
    - path: github.com/pgsty/oink

The only method where Hugo resolves the version itself, verifies the checksum, and leaves an audit record in go.sum. hugo mod graph shows what actually resolved and hugo mod get -u upgrades. It needs Go on the machine.

Git submodule

Record an exact theme commit in the site repository:

git submodule add https://github.com/pgsty/oink.git themes/oink
git -C themes/oink fetch --tags
git -C themes/oink checkout v0.6.0
git add .gitmodules themes/oink
hugo.yml
theme: oink

CI must initialize the submodule before running Hugo, or themes/oink is an empty directory:

git submodule update --init --recursive

Offline archive

For network-isolated environments. Two paths, both prepared on a connected machine and carried in whole.

With hugo mod vendor, the resolved theme source is frozen into the site directory, and later builds need neither the network nor Go.

hugo mod vendor          # writes _vendor/, holding the theme's full source tree
tar czf my-docs.tgz .    # carry _vendor/ into the isolated environment with everything else

When _vendor/ exists Hugo prefers it (hugo mod graph prints +vendor), and module.imports in hugo.yml stays as it is. This step needs Go; the builds after it do not. Upgrading the theme means returning to a connected environment and running hugo mod get and hugo mod vendor again.

_vendor/ collects only the directories the theme mounts (assets, data, i18n, layouts, static) plus hugo.yaml and theme.toml. It does not include LICENSE, NOTICE or VENDOR.json. To redistribute that archive, take those three files from the theme repository as well.

With a tag source archive, no Go module is created; a version of the theme is simply unpacked into themes/oink/.

curl -L -o oink.tar.gz \
  https://github.com/pgsty/oink/archive/refs/tags/v0.6.0.tar.gz
mkdir -p themes/oink
tar xzf oink.tar.gz -C themes/oink --strip-components=1
hugo.yml
theme: oink

The theme repository’s root is the module root, so unpacking lands directly on layouts/, assets/, i18n/ and static/ with no further level to descend into. Redistribution must keep LICENSE, NOTICE and VENDOR.json; the last records each third-party runtime’s version, source, licence path and SHA-256, and is what an offline audit rests on.

When moving between machines, generate the archive and its checksum from an immutable tag on the connected side:

git clone --branch v0.6.0 --depth 1 \
  https://github.com/pgsty/oink.git oink
git -C oink archive --format=tar.gz --prefix=oink/ \
  --output=../oink-v0.6.0.tar.gz v0.6.0
shasum -a 256 oink-v0.6.0.tar.gz \
  > oink-v0.6.0.tar.gz.sha256

Carry the archive and its .sha256 into the isolated environment, verify, then unpack:

shasum -a 256 -c oink-v0.6.0.tar.gz.sha256
mkdir -p themes
tar -xzf oink-v0.6.0.tar.gz -C themes

An archive produced this way is your own artifact, not a project release. Whether a given tag’s release page carries an archive and a checksum file varies by release; verify the checksum independently when using a public attachment.

Before building offline, confirm the archive is complete. All eleven of these must be present:

themes/oink/

  • oink/
    • go.modmodule path declaration, used when resolving as a Hugo Module
    • hugo.yamltheme default parameters and the Hugo version floor
    • theme.tomltheme metadata, required by the theme: oink method
    • LICENSEApache-2.0
    • NOTICEupstream attribution; must be kept on redistribution
    • VENDOR.jsonthird-party runtime manifest: version, source, licence path, SHA-256
    • assets/SCSS, JS and the third-party runtimes shipped with the theme
    • layouts/templates, partials, shortcodes, render hooks
    • static/font files, published as is
    • i18n/32 interface language files
    • data/the SPDX licence table behind the page-end attribution line

Pinned clone

For a hosting platform that requires the build input to contain the whole theme tree:

git clone https://github.com/pgsty/oink.git themes/oink
git -C themes/oink checkout v0.6.0

The difference from a submodule is that the theme files enter your repository history directly, without the .gitmodules indirection. Record the commit that was finally resolved and the procedure for restoring it.

The four methods compared

Method Needs Go Version auditable Theme source in your repository Use when
Hugo Module Yes go.sum verifies automatically No The default
Git submodule No The repository records the commit By reference The theme source has to be in the repository
Offline archive No Checksums verified by hand Yes Network isolation
Pinned clone No You record it yourself Yes The platform requires a complete tree
A consuming site needs no front-end toolchain

Bootstrap, Font Awesome, the fonts, and the search and diagram runtimes all ship with the theme. A site needs no node_modules, no PostCSS, no RTLCSS and no CDN. Tutorials that install npm dependencies for a Docsy site describe upstream Docsy’s process and do not apply to OINK.

Developing against a local theme checkout

This section applies only when changing the theme and the site together. Clone the two repositories as siblings:

sibling directory layout
~/pgsty/
├── oink/            # the theme
└── product-docs/    # your site

Use the HUGO_MODULE_REPLACEMENTS environment variable to substitute the local checkout temporarily, leaving go.mod untouched:

cd ~/pgsty/product-docs
HUGO_MODULE_REPLACEMENTS='github.com/pgsty/oink -> ../oink' hugo server

The documentation site’s Makefile is an alias for exactly these commands, and make dev and make check expect the theme checkout at the sibling ../oink:

Makefile: as the documentation site writes it
build:
	hugo --cleanDestinationDir --minify

check:
	HUGO_MODULE_REPLACEMENTS='github.com/pgsty/oink -> $(abspath ../oink)' npm test

dev:
	HUGO_MODULE_REPLACEMENTS='github.com/pgsty/oink -> $(abspath ../oink)' hugo server --renderToMemory

A Go workspace (go work init plus HUGO_MODULE_WORKSPACE=go.work) is an equivalent alternative. Both apply to the local machine only: CI and production builds use the version in go.mod, and go.work is never committed.

Verify

hugo mod graph                                       # which theme version actually resolved
hugo --gc --minify --printPathWarnings --panicOnWarning

It passes when the build ends with Total in … and no WARN or ERROR. Then confirm:

  • /docs/ opens and the sidebar holds the page you wrote
  • The navbar has a search box that finds the heading you just wrote
  • The light/dark toggle is present, and code block colours follow it (which shows markup.highlight.noClasses: false took effect)
  • git status shows go.mod and go.sum, and no public/ or resources/
  • Quick start — the other path: clone the documentation site and trim it
  • Repository tour — what each directory of the documentation site is
  • Configuration — every hugo.yml key and its default
  • Writing pages — how to keep writing after the first page
  • Upgrade — upgrading the theme module, and migrating from Docsy

3 - Authoring

Writing documentation pages, blog posts, books, release pages and API references — what a page looks like, and how content is organized.

This section covers the content types OINK supports: documentation pages, blog posts, books, release and download pages, and OpenAPI references. They share one Markdown dialect and one front matter schema, and each adds its own conventions.

What a documentation page is made of

A documentation page is one Markdown file. Between the two --- lines at the top is the front matter — the page’s metadata: title, short sidebar name, description, ordering. The rest is the body: ordinary Markdown plus OINK’s native components. Here is a complete page:

content/docs/install.md
---
title: Install Pigsty
linkTitle: Install
description: Get a working PostgreSQL cluster onto a clean EL 9 machine.
weight: 20
---

## Prerequisites {#prerequisites}

A Linux machine you can reach over SSH, passwordless `sudo`, and Python 3.11 or
newer.

> [!IMPORTANT]
> The installer rewrites `/etc/yum.repos.d/`. Back it up first.

Save it as content/docs/install.md, run hugo server, and the page appears at /docs/install/ with an “Install” entry in the sidebar.

Content types and where they are covered

What you are writing Where to go
A documentation page: front matter, heading anchors, links, images, drafts Writing pages
The tree and the sidebar: _index.md, weight, icons, folding, multiple sidebar roots Organizing content
Looking up what a front matter key means Page parameters
A blog post, a release announcement, RSS Blog posts
A book: chapter numbering, figures and tables, cross-references, whole-book print Books
A release and download page: version cards, asset tables, checksums Releases and downloads
An OpenAPI reference page API reference pages
Writing in two languages: paired files, aligned anchors, fallback for untranslated pages Languages
A component’s syntax and parameters Components

3.1 - Writing pages

Creating a documentation page — where the file goes, what the front matter says, why heading anchors are written by hand, how links and images work, and what appears at the end of a page on its own.

This page covers writing a documentation page end to end: where the file goes, the front matter, heading anchors, links, images, drafts, and the page end. It assumes the site already builds locally; if it does not yet, start with Quick start.

Creating a page

A page is a Markdown file under content/, and its URL follows its position there: content/docs/install.md is published as /docs/install/. The Chinese translation is a .zh.md file of the same name in the same directory, sharing one logical path with the English page.

A page with no attached resources is a single file. When a page carries images, cast files or example configuration, make it a directory instead, name the page itself index.md, and put the resources beside it — Hugo calls this a page bundle:

the two page shapes inside content/

  • content/
    • docs/
      • _index.mdsection index, English
      • _index.zh.mdsection index, Chinese
      • install.mdsingle-file page → /docs/install/
      • install.zh.mdits Chinese translation
      • anatomy/page bundle → /docs/anatomy/
        • index.md
        • index.zh.md
        • shell.webppage resource, shared by both languages

hugo new content docs/install.md generates an empty file with front matter from an archetype — see the Hugo documentation — and writing the file by hand works just as well.

Important

When a Chinese page has no English counterpart, Hugo does not hand it resources that carry no language suffix. In that case the resource filename needs the .zh. infix (shell.zh.webp) while the body still writes shell.webp.

The front matter you need

Between the two --- lines at the top of the file is YAML front matter. Four keys belong on every page:

content/docs/install.md
---
title: Install Pigsty       # page heading, browser title, search result title
linkTitle: Install          # short name in the sidebar and breadcrumbs; falls back to title
description: Get a working PostgreSQL cluster onto a clean EL 9 machine.
weight: 20                  # ordering among siblings; use multiples of 10 to leave room
---

Let description say in one sentence what the page lets the reader accomplish. It appears on the section index cards, in search results and on social cards. weight decides the sidebar order, and only equal weights fall back to alphabetical order.

The remaining keys are optional — icon, draft, search weight, comment switch, page shell and so on. The full table is in Page parameters.

Heading levels and stable anchors

Start sections at ## in the body and leave # to title. The theme already renders the page heading, so another # in the body produces two top-level headings. The outline in the right column starts at ##, and how deep it goes is decided by Hugo’s markup.tableOfContents#### on this site.

Write an explicit English anchor {#id} on every ## and ###:

Source
## Prerequisites {#prerequisites}

### Disk and memory {#disk-and-memory}

There are two reasons:

  • Cross-language alignment. 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 semantic place through two different anchors, which no translation audit can compare. Give the translated heading the English page’s ID and both sides share one fragment.
  • Link stability. Heading text changes as wording is revised, and a public link should not break with it. An explicit ID is a public route once published; when a rename is needed, leave an empty anchor for the old ID:
Source: leaving a target behind for the old anchor
## Getting started <a id="get-started"></a> {#quickstart}

Use lowercase English with hyphens, unique within the page. This site’s translation audit compares the heading IDs rendered by the English and Chinese pages and fails on a mismatch.

Three forms, for different purposes:

Form Example When to use it
Absolute site path [Configuration](/docs/customize/config/) The default. It points at a published route, is easy to audit and replace site-wide, and survives source files moving
Relative path [another page](../organize/), ![diagram](shell.webp) Resources inside the same page bundle, or a neighbouring page that should deliberately follow the source directory
The ref / relref shortcode [Configuration]({{</* ref "/docs/configure/overview" */>}}) When the target’s existence must be checked at build time; a missing target fails the build instead of leaving a dead link

All three carry a trailing slash and point at directory-style routes (/docs/write/pages/), matching Hugo’s default permalinks.

The theme has no link render hook: links go to Goldmark untouched. External links get no automatic target="_blank"; write HTML where a new tab is needed, or handle it in the site’s own layouts/_markup/render-link.html.

Plain Markdown links are not checked for existence. So:

  • Prefer absolute paths for internal links, and grep to replace them site-wide after a restructure;
  • When moving a page, add aliases for the old path and update internal links to the new route — do not let an alias carry navigation indefinitely;
  • Use ref for a target you are unsure of, and let the build check it for you.

In a bilingual site, link to the logical page (/docs/write/pages/) rather than to a .zh.md filename, and keep fragment IDs language-neutral.

Where images go

A page’s own screenshots go in its page bundle, images shared by several pages go in assets/images/, and large files that need no processing go in static/. All three are written ![alt text](source) in the source, and an attribute line controls caption, size, zoom and numbering — see Images.

Drafts and publishing

A page with draft: true never reaches the build output:

front matter
---
title: Migration guide, not yet final
draft: true
---

Preview with hugo server -D to show drafts (-D is --buildDrafts). A page whose date is in the future is excluded too; -F shows those. A production build uses neither switch, and plain hugo publishes only finished content.

OINK’s Markdown extensions at a glance

The body is standard Markdown (Goldmark) plus the native forms below. Each is ordinary Markdown syntax with one attribute line, and each stays readable as source on GitHub:

Component Shortest syntax Page
Callouts > [!NOTE] on the first line of a blockquote Callouts
Tabs Two adjacent fences each carrying {tab="Homebrew"} Tabs
Steps An ordered list followed by a {.steps} line Steps
Cards A list of links followed by a {.cards} line Cards
Field lists A table followed by {.fields meta="type default"} Fields
Table extras A table followed by {.matrix} or {caption="…"} Tables
Code blocks {title="hugo.yml" copy=false} on the fence info line Code Blocks
Images A standalone image followed by {caption="…" width="600"} Images
File trees A filetree fence, one - name/ # comment per line FileTree
Mathematics A math fence, or display maths wrapped in $$ Math
Diagrams A mermaid fence (also plantuml, markmap, echarts) Mermaid

The few remaining components — badges, keys, file includes, terminal recordings, the Book figure and table family — are shortcodes, with syntax and parameters in Components.

A combined example: code fences and a callout inside steps.

Source
1. Install Hugo Extended, 0.160.1 at the oldest:
   ```bash
   brew install hugo
   ```
1. Clone the documentation site and preview it:
   ```bash
   git clone https://github.com/pgsty/oink.pgsty.com my-docs
   cd my-docs && hugo server
   ```
   > [!TIP]
   > Add `-D` to preview drafts as well.
{.steps}
  1. Install Hugo Extended, 0.160.1 at the oldest:
    brew install hugo
  2. Clone the documentation site and preview it:
    git clone https://github.com/pgsty/oink.pgsty.com my-docs
    cd my-docs && hugo server
    Tip

    Add -D to preview drafts as well.

What appears at the end of a page

Four blocks are generated by the theme in a fixed order, and none is written in the body:

Position What it is Default Where to configure
1 Feedback: the two “Was this page helpful?” buttons Off Repository links and page info
2 Last modified: the time and the most recent commit subject, linked to GitHub On when Git information is available Repository links and page info
3 Pager: previous and next, in sidebar tree order On for docs / book / blog Navigation and menus
4 Comments: giscus When configured and enabled Comments

The action menu beside the title (copy Markdown, edit this page, view history, open an issue, print) is automatic too, and is configured in the same place, Repository links and page info.

To turn one of them off for a single page, use front matter: feedback: false, annotation: false, pager: false, comments: false. The keys are described in Page parameters.

Verify

After writing a page, run a strict build:

hugo --printPathWarnings --panicOnWarning
  • The output must end with Total in … and no ERROR and no WARN. A disallowed key on an attribute line, an invalid component parameter, or a ref whose target is missing all fail here naming the file and the line; the theme never degrades silently.
  • --printPathWarnings reports two pages resolving to the same output path, which turns up most often in multilingual sites or after changing permalinks.

Then confirm three things in the browser:

  1. The page is in the sidebar, in the position weight implies;
  2. The right-hand outline lists the ## headings you wrote, and clicking one puts an English anchor in the URL;
  3. The English and Chinese versions of the same heading share an anchor (this site audits that with node scripts/check-doc-translations.mjs --public public).

3.2 - Organizing content

The directory structure is the sidebar tree — _index.md and weight, section index styles, icons and folding, hiding pages, and putting documentation at any path.

OINK needs no separate navigation configuration: the directory structure under content/ is the sidebar tree. This page covers how directories and files are arranged, section indexes, ordering, icons, folding, hiding, and multiple sidebar roots.

Directories are the sidebar

A directory is a section (Hugo’s term), the Markdown files inside it are its pages, and a nested directory is a subsection. The sidebar renders that tree level by level, ordered by weight, labelled with linkTitle and falling back to title. The tree on the left comes from this source:

the first two levels of content/docs/

  • content/
    • docs/
      • _index.mdsection root: type: docs + cascade
      • about/Introduction
        • _index.md
        • features.md
      • start/Get started
        • _index.md
      • write/Authoring (this section)
        • _index.mdweight: 30
        • pages.mdweight: 10
        • organize.mdweight: 20
        • frontmatter.mdweight: 30
      • components/Components
        • _index.md

Every directory needs an _index.md

A section index is the _index.md inside the directory (_index.zh.md for Chinese). Without one Hugo still creates the section, but it has no title, description, icon or weight: the sidebar row shows the directory name and the ordering is out of your control.

content/docs/deploy/_index.md
---
title: Deploy
linkTitle: Deploy
description: Publish the site to GitHub Pages, Cloudflare Pages or your own Nginx.
weight: 50
icon: fa-solid fa-cloud-arrow-up
---

A section _index.md has one further power: cascade pushes shared settings down the whole subtree once, instead of repeating them on every page.

content/docs/reference/_index.md
---
title: Reference
weight: 90
cascade:
  pager: false        # no previous / next on any page in this subtree
  search_boost: 0.8   # reference pages rank slightly lower in search
---

Ordering: use multiples of 10 for weight

Pages in a section are sorted by ascending weight, and only equal weights fall back to date and linkTitle. Always use multiples of 10 (10, 20, 30) so a page can be inserted between two others without touching the rest. A section’s own weight decides its position among its siblings.

A page with no weight counts as 0, and Hugo places those after every page that does have one, ordered among themselves by date and title. That order drifts as content changes, so give every page a weight.

Single file or page bundle

A page with no resources of its own is a single slug.md. A page carrying images, cast files or example files becomes a directory with an index.md and the resources beside it. The two shapes look identical in the sidebar and produce the same URL. See Writing pages.

List or cards on a section index

After the body of an _index.md, the theme appends an index of the child pages in one of two styles:

hugo.yml: the site-wide default
params:
  ui:
    section_index: cards # list | cards

list is the theme default — one line per child page with its title and description. cards is a grid of link cards reading each child’s icon, linkTitle and description. This site uses cards, and this section’s index page is the example. Override it in a single section’s front matter when that section needs the other style:

content/docs/reference/_index.md
section_index: list
cascade:
  section_index: list   # and its descendant sections too

Two page-level switches are independent of the style: simple_list: true renders a compact bulleted list, and no_list: true generates no index at all, for a page whose body writes its own navigation.

Tip

In the card style, description is the card body. Keep it to one sentence that fits on a single line.

Sidebar icons

Write one Font Awesome class pair in a page’s or section’s front matter:

content/docs/deploy/_index.md
icon: fa-solid fa-cloud-arrow-up

Icon density is a site-level policy, so that leaf pages do not all carry icons:

hugo.yml
params:
  ui:
    sidebar_icon_policy: groups # all | groups | none
Value Effect
all Every entry that declares an icon shows it (the compatibility default when unset)
groups Only the root and nodes that have children show icons; ordinary leaf pages do not
none No entry icons in the sidebar

A new site is better off writing groups explicitly: the semantic markers on groups stay and the leaf-level icons go. This site uses that setting, so only the six sections on the left carry icons.

Expanding and folding

A section with children carries a fold arrow in the sidebar, and the reader’s expansion state is kept locally. The default behaviour: the path containing the current page is expanded and everything else is collapsed; blog-type sections are expanded by default.

content/docs/reference/_index.md
sidebar_expanded: true   # this section is always expanded by default

Site-level folding, compact mode, initial expansion depth, width and truncation are configured in Layouts and page types; the full key definitions are in Configuration.

Hiding from the sidebar

Front matter Effect
toc_hide: true The page is absent from the sidebar tree (it is still published, and links to it still work)
hide_summary: true The page is absent from the section index
sidebar_divider: true The entry stops being a link and becomes a group heading in the sidebar
manual_link: https://… The sidebar row points elsewhere; pair it with manual_link_title and manual_link_target: _blank

toc_hide and hide_summary control two different entry points, so set both only when the page should appear in neither.

The shell follows type, not the path

The documentation shell (sidebar, table of contents, breadcrumbs, pager) does not depend on the directory name. It depends only on whether the page’s type is listed in params.ui.shell_types:

hugo.yml: the theme default
params:
  ui:
    shell_types: [docs, book, blog, swagger]

Documentation can therefore live at any path, with type assigned by a cascade. To put a handbook at content/handbook/, the section root reads:

content/handbook/_index.md
---
title: Operations handbook
type: docs
sidebar_root_for: self      # the sidebar tree roots here rather than falling back to /docs
cascade:
  type: docs                # the whole subtree uses the documentation shell
---
Important

When the documentation directory is not called docs, sidebar_root_for: self is needed alongside type: docs. Otherwise the sidebar looks for its root at params.ui.docs_section (default docs), and a reader under /handbook/ sees the /docs/ tree.

Multiple sidebar roots

By default the sidebar tree roots at the top-level section the reader is in, and a row above the tree names the current root. A large subtree can become a root of its own — a versioned API reference, say, or a self-contained handbook:

content/docs/api/v2/_index.md
---
title: API reference v2
sidebar_root_for: self   # self | children
---
Value Meaning
self The section’s index page and all its descendants take it as their sidebar root
children The index page stays in the parent tree; only the descendants root here

The switcher above the root is site-wide: it lists every top-level section plus every section anywhere that declares sidebar_root_for: self. With only one entry it degrades to a plain link; two or more make it a dropdown. To keep a top-level section out of the switcher, write sidebar_root_menu: false in its _index.md.

Below the switcher, the section index remains the first link in the tree: the switcher picks a tree and the root link points at a document. sidebar_root_link_self: false makes that row point at the parent section instead.

Verify

hugo --printPathWarnings --panicOnWarning

It must reach Total in … with no ERROR and no WARN. --printPathWarnings reports two pages resolving to the same output path, which happens most often while changing the directory structure.

Then confirm each of these in the browser:

  1. The sidebar order matches the weight values you wrote, and a new section appears where expected;
  2. The section index lists every child (a missing one comes from hide_summary or a missing _index.md);
  3. Breadcrumbs and the pager follow the same order as the sidebar, because the pager reads the same tree;
  4. The tree has the same shape after switching language (every _index.md needs a .zh.md counterpart).

When sidebar entries exceed params.ui.sidebar_menu_truncate, the build warns and says what to raise it to. That warning cannot be ignored: truncated entries never appear in the sidebar.

3.3 - Page parameters

The full front matter table — every page key the theme actually reads, grouped by sidebar, shell, search, output, page end, Book, landing and release pages.

This page is the complete table of page-level parameters, listing only the keys the OINK theme reads. Hugo’s own front matter fields (slug, url, build, sitemap, expiryDate and the rest) work as usual; their meaning is in the Hugo documentation. Site parameters (params.* in hugo.yml) are in Configuration.

How to read the tables

Precedence, highest first:

  1. The page’s own front matter;
  2. The nearest cascade (when several cascade layers set the same key, the one closest to the page wins);
  3. The site parameter in hugo.yml.

Keys whose Default column says “site value” fall back to the site parameter of the same name when unset.

Page keys are written at the top level of the front matter, and the key name is the site key with its ui. prefix dropped: the site’s params.ui.section_index is the page’s section_index. Front matter never carries a ui: block; the keys sit at the top level. A ui: block written there is not read and not reported, so check the key name against this page when a setting seems to have no effect.

content/docs/wide-reference.md
---
title: Compatibility matrix
weight: 40
page_width: wide
footer_style: slim
image_zoom: true
section_index: list
---

Inside a cascade the key names are unchanged, just one level deeper:

content/docs/reference/_index.md
cascade:
  pager: false
  section_index: list

An invalid value does not stop the build. The theme warns — naming the key, the value it got and the fallback it used — and renders the page with the default in the table, so one typo degrades one setting instead of serving HTTP 500 on every URL under hugo server. It still never ships: every publishing gate builds with --panicOnWarning, which turns that warning back into a hard failure where it counts.

A few keys do stop the build, and their rows say so. They are the ones where carrying on would publish something wrong rather than merely plain: an incomplete upstream attribution (a partial notice reads exactly like a complete one), translation_notice, the release facts, landing sections, and any reference that cannot resolve.

Basics

title , string , default
Page heading, browser title, search result title. Required on every page
linkTitle , string , defaulttitle
Short name in the sidebar, breadcrumbs, pager and cards
description , string , default
One-sentence summary: section cards, search snippet, meta description; rendered as a standfirst above the body on blog pages
weight , integer , default0
Ordering among siblings; use multiples of 10. 0 (unset) sorts after every page that has a weight — see Organizing content
draft , boolean , defaultfalse
A draft never reaches the build output; hugo server -D previews it — see Writing pages
date , date , default
Blog date, and the sort key for release pages; a future date is excluded by default
lastmod , date , defaultGit commit time
The page-end “last modified”; not needed by hand when the site enables enableGitInfo
aliases , string array , default
Redirects an old path to this page; for page migration, not for everyday navigation
type , string , defaulttop-level directory name
Decides the template and the shell: docs, book, blog, swagger — see Organizing content
layout , string , default
Picks a layout for one page: landing, releases
cascade , map , default
Pushes the keys below down the whole subtree

Sidebar and navigation

The guide is Organizing content.

icon , Font Awesome class pair , default
Icon in the sidebar, section cards and search results, e.g. fa-solid fa-rocket
toc_hide , boolean , defaultfalse
Absent from the sidebar tree and from the pager sequence
hide_summary , boolean , defaultfalse
Absent from the section index
sidebar_divider , boolean , defaultfalse
The row renders as a sidebar group heading: not a link, and not in the pager sequence
sidebar_expanded , boolean , defaulttrue for blog sections, false otherwise
This section is expanded by default in the sidebar
sidebar_root_for , self / children , default
Makes this section a sidebar tree root; self includes the section index, children covers descendants only. Any other value warns and is ignored
sidebar_root_link_self , boolean , defaulttrue
The root row links to itself; false links to the parent section instead. A non-boolean fails the build
sidebar_root_menu , boolean , defaulttrue
Whether a top-level section appears in the root switcher
toc_root , boolean , defaultfalse
When the sidebar root is the site home, excludes this whole top-level section from the tree and the pager sequence
manual_link , URL , default
The sidebar and section index row points elsewhere
manual_link_relref , content reference , default
The same, resolved with relref; a missing target fails the build
manual_link_title , string , defaulttitle
Hover title for the manual link
manual_link_target , string , default
For example _blank; the theme adds noopener
no_list , boolean , defaultfalse
The section index generates no child list
simple_list , boolean , defaultfalse
The child index renders as a compact bulleted list
section_index , list / cards , defaultsite value (list)
Style of the child index. An invalid value warns and falls back
section_index_columns , integer , default2
Column count in the card style
notoc , boolean , defaultfalse
Hides the right-hand page outline
pager , boolean , defaultdecided by params.ui.pager_types
false turns off previous / next for this page. A non-boolean warns and is ignored
navbar_enabled , boolean , defaultsite value (true)
Whether this page renders the navbar
navbar_autohide , boolean , defaultsite value (false)
The navbar hides itself on pointer devices
page_context_menu , boolean , defaultsite value (true)
The page action menu on the title row (copy Markdown, edit this page, print, …)
page_context_menu.assistant_links , boolean , defaultsite value (false)
The ChatGPT / Claude handover items, written page_context_menu: { assistant_links: false }. A page may only narrow the site policy, never enable it alone

Page shell

Site-level defaults and what they do are in Layouts and page types.

page_width , normal / wide / full , defaultnormal
Width of the content column. An invalid value warns and falls back
reading_width , slim / normal / wide , defaultnormal
Reading measure on Book pages; applies to type: book only
footer_style , fat / slim / none , defaultsite value (fat)
Footer shape. An invalid value warns and falls back
body_class , string , default
A class appended to <body> for the site’s own CSS
reading_time , boolean , defaultsite value
Whether this page shows a reading time; false hides it
sidebar_enabled , boolean , defaulttrue
Whether this page shows the left sidebar; false hides it
scroll_spy , boolean , defaultsite value
Scroll tracking in the outline; true enables it
keyboard_nav , boolean , defaultsite value (true)
Single-key keyboard navigation — see Keyboard navigation. A non-boolean warns and falls back
lastmod_commit , subject / hash / none , defaultsubject
How the commit is shown after “last modified”. An invalid value warns and falls back
sidebar_expand_levels, sidebar_menu_compact, sidebar_menu_foldable, sidebar_item_overflow , as the site parameter , defaultsite value
Sidebar behaviour can be overridden per page too; the values are in Configuration

The guide is Search.

search_keywords , string or string array , default
Extra search terms, including synonyms and other languages
search_boost , positive number , default1.0
Ranking multiplier; the final score is the text match score times this value. A non-numeric, non-finite, zero or negative value warns and falls back to 1.0
search_exclude , boolean , defaultfalse
Keeps the page out of the local index

Output formats

The guides are AI-agent support (.md and llms.txt) and Print.

outputs , string array , defaultsite outputs
Which output formats this page generates; [HTML] stops the .md twin
no_print , boolean , defaultfalse
Excluded from the whole-chapter and whole-book print aggregate

Page end: comments, feedback and provenance

The order is fixed as feedback → provenance → pager → comments; see Writing pages.

comments , boolean , defaultsite params.comments.enable (false)
Whether this page shows the giscus comment section — see Comments
feedback , boolean or map , defaultsite params.ui.feedback (off)
The map form takes enable and reasons. Anything else warns and falls back
annotation , boolean , defaultsite params.ui.annotation (on)
The “last modified / provenance” block at the page end. Only a boolean is accepted; anything else warns and falls back
translation_notice , language code or false , defaultsite params.ui.translation_notice (off)
The language code of the authoritative version, so a translation can say so and link back; write false on a page authored natively in this language

Upstream attribution

When a page is derived from material elsewhere, upstream_link declares the source and the page-end provenance line gives the work, the copyright holder, the licence and a link to the full notice. This family resolves site parameters → the data/upstreams entry named by upstream_source → this page’s front matter, so the most specific declaration wins.

upstream_link is read from front matter only (a cascade counts, site parameters do not) — a site-wide value would make every page claim the same source. Any companion key without upstream_link fails the build.

upstream_link , URL , default
The address of the material this page is derived from. An empty string opts out of an inherited cascade value
upstream_name , string , default
The upstream work, as the attribution names it. Required once upstream_link is set
upstream_copyright , string , default
The copyright notice, retained as upstream wrote it. Required
upstream_license , SPDX identifier , default
Must be found in data/licenses, or the build fails. Required
upstream_notice , site path or URL , default
The page carrying the full notice (licence text, warranty disclaimer, upstream NOTICE, snapshot pin). Required
upstream_ref , string , default
The tag or commit the snapshot pins, shown in parentheses after the work
upstream_source , string , defaultsite parameter
The entry name in data/upstreams, for upstream facts shared by many pages; a missing entry fails the build
upstream_modified , boolean , defaultfalse
Adds a “modified downstream” line; carries a “view history” link when the site has repository information. A non-boolean fails the build

Missing any one of the four required keys (upstream_name, upstream_copyright, upstream_license, upstream_notice) fails the build: a partial attribution is worse than an obvious omission. The theme ships an SPDX table at data/licenses.yaml, and a site adds to or overrides it with a file of the same name.

Image zoom

image_zoom , boolean , defaultsite value (false)
Whether images on this page open full size — see Images. A non-boolean warns and falls back

Blog posts

The guide is Blog posts.

author , string , default
Post byline; inline Markdown is allowed. Ignored on a page that has authors
authors , string array , default
Terms of the authors taxonomy, in byline order — see Authors and bylines. Needs author: authors under taxonomies:
series , string array , default
Terms of the series taxonomy. The strip above the body uses the first one — see Series
series_weight , integer , default
Place in the series. Weighted members come first in ascending order, the rest follow by ascending date
tags , string array , default
Tags — see Taxonomies
categories , string array , default
Categories, likewise
images , string array , default
The first entry becomes the post’s featured image and share card; put it in a section _index.md cascade for a section-wide default, and images: [] means no featured image
featured_image , none / banner / wash , defaultsite value (none)
How this article renders its own featured image. An invalid value warns and falls back
blog_index , list / cards , defaultsite value (list)
Written on a blog root, the list form for that section. An invalid value warns and falls back
share , string array or false , defaultsite params.ui.share (empty)
The page-end share targets, replacing any inherited list; false opts this page out — see Share. An unknown target warns and is dropped
summary , string , default
Fallback excerpt for post rows on tag and category pages; description wins

Book

The guide is Books. A whole book sets type: book through a section cascade.

book_number , string , default
Chapter number, shown before the page title and the sidebar entry
book_status , draft , default
Marks a draft chapter: flagged in the sidebar and contents, and left out of the indexes by default
sidebar_headings , false / true / integer 2–4 , defaultsite value (false)
Expands the h2–h4 branch under the current sidebar entry. Out of range warns and falls back
book_draft_banner , boolean , defaultsite value (false)
Adds a banner at the top of a draft chapter. A non-boolean warns and falls back

Landing

The guide is Home and landing pages. Any page with layout: landing uses the landing shell.

landing , string , default
Data is taken from data/landing/<key>/<language>.yaml
sections , array , default
Section definitions inlined in front matter, taking precedence over landing. Anything but an array fails the build

Release pages

The guide is Releases and downloads. A section with layout: releases ignores weight and sorts by release date and SemVer, newest first.

release , string or map , default
The release facts. The string form is https://github.com/<owner>/<repo>/releases/tag/<tag>; the map form takes product, version, repo, tag, date, prev and checksums, of which version and repo are required, and an unknown key or a wrong type fails the build
release_products , string or string array , default
Restricts the release list to these products. An invalid filter fails the build
release_group_by_product , boolean , defaultfalse
Groups by product; with it on, every selected post must set release.product

3.4 - Blog posts

Setting up a blog section — directory conventions, a post’s front matter, featured images, the year-grouped list page, and RSS.

A blog post’s body is written exactly like a documentation page; the shell is what differs. A post carries a date, an author, tags and a featured image, the list is grouped by year newest first, and the section has an RSS feed. This page covers creating the blog section, a post’s front matter, featured images, list pagination and feeds.

The blog directory

A blog is a section under content/, and type: blog gives it the blog shell. Subdirectories divide it by publisher and audience, with posts sitting flat inside. Do not create year directories: the year grouping is generated by the list page.

this site's content/blog/

  • content/
    • blog/
      • _index.mdtype: blog + cascade
      • _index.zh.md
      • oink/engineering notes and announcements
        • _index.mdcascade: images: [/images/oink.webp]
        • oink-announcement.md
        • oink-announcement.zh.md
      • release/versioned release notes
        • _index.mdcascade: images: [/images/releasenote.webp]
        • 0.4.0.md
        • 0.4.0.zh.md

The section root pushes the type down the whole subtree and sets the behaviour that section shares:

content/blog/_index.md
---
title: Blog
description: OINK engineering notes and release announcements
type: blog
icon: fa-solid fa-blog
sidebar_root_for: self      # the blog has its own sidebar tree
cascade:
  type: blog
  feedback: false           # posts do not ask "was this page helpful?"
  comments: true            # but they do take comments
---

params.ui.blog_section (default blog) names where the blog root is. Rename the directory and either change that parameter or use sidebar_root_for: self as above.

Blog sections are expanded by default in the sidebar and ordered by date, newest first; giving one post a weight pins it to the top.

A post’s front matter

content/blog/release/0.4.0.md
---
title: Oink 0.4.0 — scenario components for a complete release workflow
linkTitle: Oink v0.4.0        # short name in the sidebar and pager
date: 2026-08-14              # publication date; decides ordering and grouping
lastmod: 2026-08-14
description: >-
  Oink 0.4.0 delivers sequential reading and release surfaces, reusable landing
  pages, book publishing with stable references, and a keyboard-first site shell.
author: The OINK maintainers
categories: [Release]
tags: [Oink, Release]
---

Where it differs from a documentation page:

  • date is required. It decides the post’s place in the list, its year group and its RSS timestamp. A date in the future is not built by default; hugo server -F previews it.
  • description is rendered as a standfirst above the body, not only as a search snippet, so write it as a sentence for the reader.
  • author accepts inline Markdown, so [Vonng](https://vonng.com) works. For more than one author, a portrait, or a profile page, use the authors taxonomy below instead; the two do not interfere, and a post keeps rendering author wherever authors is absent.
  • The date display format comes from params.time_format_blog and can be set per language (this site uses Monday, January 02, 2006 in English and 2006年1月2日 in Chinese).

Bilingual posts are stored in pairs, keeping date, author, weight and aliases identical across the two. Titles, descriptions and tags are translated; commit IDs, version numbers, commands and URLs are not.

Each row on a list page or a tag page has a thumbnail on the left, resolved in this order, first match winning:

  1. images in the post’s front matter, first entry;
  2. An image resource in the page bundle whose filename contains featured (it is cropped to a thumbnail, and the resource’s own byline becomes its caption);
  3. An images value inherited from an ancestor section’s cascade, nearest first.

A section-wide default uses Hugo’s native cascade over the whole subtree; this site sets one for each of its two subsections:

content/blog/release/_index.md
cascade:
  images: [/images/releasenote.webp]

To drop the image on one post, write images: [] in its front matter; to drop it for a whole subsection, put images: [] in that level’s cascade. The site-level params.images is unaffected — it feeds the share card only and is never rendered as a list thumbnail.

On the article itself

By default the resolved image appears on list rows and in the social card, and the article itself shows nothing — write the hero by hand and it will disagree with the card sooner or later. params.ui.featured_image renders it from the same resolver instead:

Mode What the article shows
none Nothing. The theme default, so a site that renders nothing today renders exactly the same bytes
banner The image above the title in a fixed 16:9 figure, so a run of articles keeps one rhythm
wash The image behind the article header at a tenth of its opacity, masked to nothing before the text starts — the post takes a colour from its subject without spending any contrast on it
hugo.yml
params:
  ui:
    featured_image: banner

The page key is featured_image, so a cascade on one subsection turns it on for that tree and a single post can opt out. A post with no image renders nothing in either mode, which is why a section can carry the switch for a run of posts that do not all have art. Neither mode adds a script or a bundle member.

content/blog/release/_index.md
cascade:
  featured_image: wash

List pages and pagination

After the body of the section _index.md, the theme appends the post list: grouped by year (“Posted in 2026”), years newest first, each row showing the title, date, subsection, tags, thumbnail and the first 250 characters of the body as a summary.

Pagination uses Hugo’s native paginator, ten posts per page by default, adjusted in hugo.yml:

hugo.yml
pagination:
  pagerSize: 20

The values and the remaining pagination options are in the Hugo documentation.

The card form

params.ui.blog_index: cards renders the same list as a grid of content cards instead of rows: a 16:9 crop of the post’s image above the title, the date and subsection line, and a three-line summary.

hugo.yml
params:
  ui:
    blog_index: cards
    blog_index_columns: 3

The choice is presentational only — year grouping, pagination and manual_link behave identically, and the row output is unchanged to the byte. The column count applies above the xl breakpoint; between md and xl the grid is two columns and below md it is one. Front matter blog_index on a blog root, or its cascade, sets it per section. Term and taxonomy pages keep the row list, and there is no reader-side switch between the two forms.

Card images go through Hugo’s .Fill whenever the resource can be processed, so a grid of posts does not download a full-size original per card.

RSS

Which pages produce a feed is decided by outputs. Adding RSS to section gives every section its own feed:

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

Writing outputs at all replaces Hugo’s defaults wholesale, so RSS has to be written back explicitly. Omitting it turns off the feed for that page kind, and the build does not complain.

This site therefore has /blog/index.xml (the whole blog) and /blog/release/index.xml (release notes only). A section feed recursively includes every subsection’s posts, so subscribing to /blog/ covers everything. An individual post has no .xml of its own.

Each language has its own feed at that language’s route plus index.xml. The item limit is Hugo’s services.rss.limit. On the blog root and its first-level subsection pages, the first action button beside the title row is the RSS link, so a reader need not assemble the address by hand.

To drop feeds site-wide, turn the kind off with disableKinds, which is more thorough than removing RSS from each page kind:

hugo.yml
disableKinds: [RSS]

Components degrade to their static shape in a feed: disclosures are expanded and interactive controls are removed. The four-output rules are the same for blog posts as for documentation.

Categories and tags

tags and categories are Hugo’s taxonomies, and the theme renders them as chips in the post header, a tag cloud in the right column, and a filter menu in the navbar. Enabling them, bilingual term labels, and switching them per content type are covered in Taxonomies.

Release notes

A versioned release announcement is an ordinary post, conventionally under blog/release/, with the version in linkTitle (Oink v0.4.0). For a download page with release cards, asset tables and checksums, see Releases and downloads.

Components in a post

Callouts, tabs, code blocks, images and tables work exactly as on a documentation page; the syntax is in Components. Headings in a post body take explicit English {#id} anchors too.

The four blocks at the end of a post — feedback, last modified, pager, comments — behave as on a documentation page; see Writing pages. A blog usually turns feedback off and keeps comments.

Authors and bylines

Declaring the taxonomy is the entire switch; the theme adds no parameter:

hugo.yml
taxonomies:
  category: categories
  tag: tags
  author: authors

A post then names its authors in order:

authors: [vonng, ada-example]

The article head renders portraits and linked names in exactly that order — the front matter sequence is both the set and the order — a list row renders the names, and the blog feed emits one <dc:creator> per author per item beside the site-level managingEditor. Names are separated by a CSS gap rather than a connector word, because “and” is a per-locale decision and there are 32 locales.

An author’s profile is simply the term page, so there is no data/authors file to disagree with it:

content/authors/vonng/_index.md
---
title: Vonng
description: Maintainer of OINK and Pigsty.
images: [portrait.webp]
---

The long introduction, rendered on the profile page under the name.

The display name is the term page’s link titlelinkTitle when it has one, title otherwise — so a profile can carry a full name and byline a short handle. description is the one-line introduction, the body the long one, and the avatar is whatever the featured-image resolver selects for that page — so images: and a bundled portrait follow the same rules an article’s own image follows. A bilingual profile is an _index.zh.md beside it. A name a post uses but nobody gave a profile page still bylines: the link title, an initial, and a link to its archive.

The 0.4 author: string is untouched wherever authors is absent, and neither form warns about the other.

Series

A series is a reading path through articles that each stand alone. Numbering, cross-references and aggregate output belong to Book; this is the lighter thing. Declaring the taxonomy is again the whole switch:

hugo.yml
taxonomies:
  series: series

An article names the series and may place itself in it:

series: [shell-internals]
series_weight: 20

It then carries a strip above its body naming the series, its position, the next part, and the whole list behind a <details> — no JavaScript, no bundle member. The term page content/series/<name>/_index.md is the introduction, and an _index.zh.md beside it makes the pair bilingual.

Reading order is the theme’s own, because a term page cannot supply one: Hugo’s taxonomy weight reaches neither Page.Weight nor GroupByParam. Weighted members come first in ascending series_weight, the rest follow by ascending date, and Path breaks a tie. The strip and the term page read the same resolver, so they can never disagree about which article is part 2 — which also means a series term page lists oldest-first rather than newest-first, unlike every other term page. That is the feature.

A member of several series shows one strip, for the first term it names. A series of one shows none.

Neither authors nor series appears in the generic taxonomy chip row on an article, because each has a surface of its own. Name one in params.taxonomy.page_header to put it back.

Share

params.ui.share puts a share bar at the top of the page end. It is empty by default, so nothing renders until a site names its targets, in the order it wants them:

hugo.yml
params:
  ui:
    share: [x, bluesky, mastodon, reddit, hackernews, email, copy]

Sixteen targets are available: x, bluesky, mastodon, facebook, linkedin, reddit, hackernews, telegram, whatsapp, line, pinterest, weibo, chatgpt, claude, email, and copy. An unknown name warns and is dropped. Discord is absent on purpose: it publishes no share-intent URL at all, so copy stands in for it rather than the theme guessing at a private scheme.

The page key is share, so a cascade scopes the bar to one tree, a page’s own list replaces the inherited one, and share: false opts a single page out:

content/blog/_index.md
cascade:
  share: [x, bluesky, email, copy]

Only a regular page renders the bar — a list, a term page and the home page have no single thing being shared — and print, Markdown and RSS carry none of it.

What the bar does not do is why it can ship in this theme at all. There is no share count, no platform SDK, no iframe, and no third-party script or stylesheet, which is what those three usually arrive as: one request per page to a company the reader never chose. Every target is a plain <a href> intent link carrying only the page’s own permalink and title, with no campaign parameters, plus one local copy button. Nothing is fetched when the site builds or when the page loads; the only request a share can cause is the navigation the reader starts by clicking. A build with every target enabled passes bin/check-output-security.py with no --third-party allowance.

chatgpt and claude hand that same build-time permalink to an assistant with a prompt asking it to read the page. They are not the “open in ChatGPT” / “open in Claude” entries of the page action menu, which the runtime rewrites at activation time to the live browser URL and which therefore stay behind page_context_menu.assistant_links.

The copy button is the built-in copy_link action, which means the Command Palette carries it on every page of every site whether or not a bar is configured.

Verify

hugo --printPathWarnings --panicOnWarning

It must reach Total in … with no ERROR and no WARN. Then confirm:

  1. The post appears under the right year group at /blog/, with the date in the expected format;
  2. public/blog/index.xml exists, contains the post, and its links are complete absolute addresses;
  3. The thumbnail shows in the list (a missing one means none of the three featured-image sources matched);
  4. Tag chips lead to the corresponding tag page.

3.5 - Books

Turn a directory tree into a book with type: book: chapter numbering, numbered figures and tables, cross-references, generated indexes and whole-book print.

A book is a content tree of type: book: the directory decides chapter order, front matter decides chapter numbers, and figures, tables, equations and examples each carry a hand-written number and a stable anchor. Cross-references resolve in all four outputs, and the book’s root page can generate a whole-book print HTML.

Two prerequisites: the site’s markup.goldmark has attribute lines and passthrough enabled (see Components); and params.ui.shell_types still contains book (the theme default includes it).

A book’s directory

The book root is an ordinary Hugo section, chapters are its subdirectories, and sections are the pages inside a chapter. There is no second chapter list: the sidebar, the pager and the generated contents all read this one tree.

content/handbook/, one book

  • content/handbook/
    • _index.mdbook home: type: book + cascade, holding book-toc and the indexes
    • ch01/
      • _index.mdchapter 1 front page: book_number: 1
      • install.mdsection 1.x
      • bootstrap.md
    • ch02/
      • _index.mdchapter 2: numbered with book_number, optionally marked draft
      • replication.md
      • failover.md
    • appendix.mdan unnumbered appendix, still in the sidebar and the reading order

Chapter numbers are written by hand: book_number displays exactly what you write, and the theme never numbers by directory order. The num on a figure, table, equation or example works the same way — a string the author controls (2-1, 5.3 and A-2 are all valid), not an index computed at render time. Rearranging the tree therefore never shifts a number that has already been printed.

The book home and chapter pages

The book root declares the type, cascades it to descendants, and explicitly requests the print output. That aggregate is expensive to build, so the theme does not turn it on for a consuming site:

content/handbook/_index.md
---
title: The PostgreSQL operations handbook
type: book
book_number: B
cascade:
  type: book
outputs: [HTML, print, markdown]
---

A book that is a section maps to Hugo’s section output kind; home applies only when the book sits at the site root:

hugo.yml
outputs:
  section: [HTML, print, markdown]
params:
  ui:
    sidebar_headings: 3     # project an h2–h3 heading tree under the current entry
    book_draft_banner: true # draft chapters get a localized banner above the body

A chapter page needs only its number and its order:

content/handbook/ch02/_index.md
---
title: Replication and failover
book_number: 2
book_status: draft
weight: 20
---

book_number appears before the page title, in the sidebar and in the generated contents. book_status: draft is a visible editorial label and does not change Hugo’s publication state: a draft chapter builds and publishes as usual.

sidebar_headings accepts false, true (h2 only) or a maximum level from 2 to 4. Give every heading that will be referenced an explicit ID, such as ## Synchronous replication {#sync-replication}: a generated slug is fine for navigation and unfit as a long-lived reference target.

The full key definitions are in Configuration and Page parameters.

Numbering: the native form

Each of the four numbered kinds has a native form: one Markdown block followed immediately by an attribute line. On that line num= is the number, #id is the anchor, and caption= is a plain-text caption.

Figures

An attribute line follows the image block. Omitting #id defaults it to fig-<num>.

Source
![The OINK release notes page](/images/releasenote.webp)
{#book-release-note num="2-1" caption="The release notes page is also the single source of release facts." width=600 height=300}
The OINK release notes page
Figure 2-1 The release notes page is also the single source of release facts.

The native figure form requires the site to set markup.goldmark.parser.wrapStandAloneImageWithinParagraph: false; otherwise the attribute line attaches to the paragraph and is ignored. The alternative text comes from the Markdown image itself and is never replaced by the caption.

Tables

An attribute line follows a pipe table, and the default ID is tbl-<num>.

Source
| Isolation level | Dirty read | Non-repeatable read | Phantom read |
| --- | --- | --- | --- |
| Read Committed | Not possible | Possible | Possible |
| Repeatable Read | Not possible | Not possible | Possible |
| Serializable | Not possible | Not possible | Not possible |
{#tbl-2-1 num="2-1" caption="Anomalies permitted at each PostgreSQL isolation level."}
Isolation level Dirty read Non-repeatable read Phantom read
Read Committed Not possible Possible Possible
Repeatable Read Not possible Not possible Possible
Serializable Not possible Not possible Not possible
Table 2-1 Anomalies permitted at each PostgreSQL isolation level.

Equations

An attribute line follows a $$ block, and the default ID is eq-<num>. The number and caption sit on one non-wrapping line to the right of the formula, so a long caption squeezes the formula column until it becomes a horizontally scrolling region. Keep an equation caption short.

Source
$$
A = \frac{\mathrm{MTBF}}{\mathrm{MTBF} + \mathrm{MTTR}}
$$
{#eq-2-1 num="2-1" caption="Availability from MTBF and MTTR."}
A=MTBFMTBF+MTTR A = \frac{\mathrm{MTBF}}{\mathrm{MTBF} + \mathrm{MTTR}}
Equation 2-1 Availability from MTBF and MTTR.

The native form depends on the site enabling Goldmark passthrough. Without it, use the eq shortcode below, which goes through local server-side KaTeX.

Examples

A code fence with num= and caption= is a numbered example, and the default ID is eg-<num>. An #id written on the fence names the enclosing <figure> — the reference target — rather than the code block itself. The caption is required: writing only num or only caption fails the build. A numbered example renders as one framed unit: the caption is the frame’s header and the body sits inside it, and a body that is exactly one code block sits flush against the frame instead of drawing a second border.

Source
```sql {num="2-1" caption="Daily write volume on the primary." #eg-2-1}
SELECT date_trunc('day', ts) AS day, count(*)
FROM pg_stat_statements_history
GROUP BY 1 ORDER BY 1 DESC LIMIT 7;
```
Example 2-1 Daily write volume on the primary.
SELECT date_trunc('day', ts) AS day, count(*)
FROM pg_stat_statements_history
GROUP BY 1 ORDER BY 1 DESC LIMIT 7;

Numbering: the shortcode form

The four shortcodes fig, tbl, eq and eg render a <figure> identical to the native form, register into the same target table, and sort by source position. Use them only where the native form cannot reach: an image that needs an outbound link, several tables under one number, a site without passthrough, or an example body made of several fences and prose.

fig takes src= (it also accepts inner Markdown content, and the two are mutually exclusive) and additionally supports link, alt, width, height, class, and the migration alias title:

Source
{{< fig num="2-2" src="/images/docsy.webp" alt="The default Docsy shell"
    caption="OINK's upstream: the Docsy content model is still underneath." width="600" height="300" />}}
The default Docsy shell
Figure 2-2 OINK's upstream: the Docsy content model is still underneath.

tbl wraps the label, the table, the caption and the anchor in one semantic figure:

Source
{{< tbl num="2-2" caption="How a numbered component appears in each of the four outputs." >}}
| Output | Label | Anchor |
| --- | --- | --- |
| HTML | Visible | Stable |
| Print | Visible | Stable |
{{< /tbl >}}
Output Label Anchor
HTML Visible Stable
Print Visible Stable
Table 2-2 How a numbered component appears in each of the four outputs.

eq hands its content to local server-side KaTeX, so it does not depend on passthrough:

Source
{{< eq num="2-2" caption="Connection pool saturation." >}}U = \frac{\lambda}{\mu \cdot c}{{< /eq >}}
U=λμcU = \frac{\lambda}{\mu \cdot c}
Equation 2-2 Connection pool saturation.

A bare {{< eq >}} with no parameters is the unnumbered display-maths escape hatch: it registers no target, cannot be reached by xref, and does not appear in the equation index.

eg is a wrapping shortcode whose body renders under the page’s Markdown policy, usually holding one or more fences:

Source
{{< eg num="2-2" caption="Bringing up a new replica with pg_basebackup." >}}
```bash
pg_basebackup -h primary -U replicator -D /pg/data -Fp -Xs -P -R
```
{{< /eg >}}
Example 2-2 Bringing up a new replica with pg_basebackup.
pg_basebackup -h primary -U replicator -D /pg/data -Fp -Xs -P -R

IDs must be unique within a page, and within one kind a number maps to exactly one ID. A duplicate fails the build, and the error names the line that claimed it first.

Footnotes cannot appear in a shortcode body

Hugo renders a shortcode body as its own Goldmark document, and footnotes are page-level. A [^label] inside the body of tbl, eg, fig, card, tab, field or include fails the build, naming the file, the line and the label. With the definition on the page, the reference would print literally as [^label]; with the definition in the body, it would build a second footnote list whose fn:N ids collide with the page’s own. Neither belongs in published output.

A table or code block that needs footnotes uses the native form instead: a table, image or fence carrying {num=… caption=…} keeps its content in the page document, where a footnote numbers, links and backlinks like any other. The rendered figure is the same either way, so this is usually a one-line change. Footnote-shaped text in code — a [^0-9] character class in a listing, or a code span — is left alone.

Cross-references

A target on the same page can be reached with a plain Markdown link: Table 2-1 points at the isolation table above. The cost is that the label and the number are hand-written, so changing a number means finding them yourself.

xref composes the label, the number and the anchor in one place, and works across pages and languages:

Source
See {{< xref fig="2-2" />}} and {{< xref eg="2-1" />}};
with an explicit anchor: {{< xref fig="2-1" anchor="book-release-note" />}}.

See Figure 2-2 and Example 2-1; with an explicit anchor: Figure 2-1.

The rules:

  • At most one kind key (fig, tbl, eq, eg). The kind supplies the localized label (Figure / Table / Equation / Example) and derives the default anchor <kind>-<num>.
  • anchor= overrides the derived anchor, for a target that wrote an explicit #id.
  • page= references another page through Hugo’s page lookup in the current language, so the source never hard-codes a /zh/ prefix.
  • Without a kind, both anchor= and inner link text are required: {{< xref page="../ch01/install" anchor="sync-replication" >}}synchronous replication{{< /xref >}}.
  • A reference may precede its target: nothing reads the registry at render time, so forward references are valid.

A plain cross-page Markdown link is still a site URL inside the whole-book print. A reference that must also jump within the aggregate document is written as an xref.

Indexes: contents and lists of figures

Five index shortcodes walk the same book tree, triggering descendant content and aggregating what it registered. They usually sit on the book home (_index.md) or on a dedicated “list of figures” page.

content/handbook/_index.md
{{< book-toc depth=3 >}}

## List of figures {#lof}
{{< book-figures >}}

## List of tables {#lot}
{{< book-tables >}}

## List of equations {#loe}
{{< book-equations >}}

## List of examples {#lox}
{{< book-examples >}}

These five appear here as source only. They walk down from the navigation root the current page belongs to, so placing one in an ordinary documentation tree would list the whole docs tree as a book. For the real effect, read Write Beautiful Docs and inspect its content/book/_index.md source.

  • book-toc takes a depth of 1 to 3: 1 lists chapters, 2 adds nested sections, 3 also projects each page’s heading tree. drafts=false filters book_status: draft rows out of this generated list only, and does not affect publication.
  • book-figures, book-tables, book-equations and book-examples take no parameters. Each lists one kind, with entries like “Figure 2-1 — caption” linked to the stable ID.
  • In whole-book print, all of these links become in-document fragments.

Sequential reading and drafts

The pager is on by default for the docs, book and blog types, and its order is a pre-order walk of the sidebar tree: a section index first, then its children by weight. Turn a whole type off with params.ui.pager_types, and a single page off with pager: false.

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

Entries hidden with toc_hide, manual_link link-only placeholders and sidebar_divider rows never become pager destinations.

Besides the “draft” label in the sidebar, a draft chapter can carry a banner above its body:

hugo.yml
params:
  ui:
    book_draft_banner: true

The banner appears only on pages that are both type: book and book_status: draft, and its wording comes from the localization key book_draft_notice.

Printing the whole book

Once the book root has the print output, it generates a cover, a local table of contents, the root page’s body and every descendant chapter in visible reading order, all inside one HTML document. Pages with no_print: true, link-only nodes, divider rows and hidden placeholders never become chapters.

Inside the aggregate, the IDs of numbered components are preserved byte for byte. Markdown heading IDs within a page are prefixed with their source page to avoid collisions when several chapters share an anchor such as summary, and the generated heading links are rewritten to match. The output is print-oriented HTML; PDF and EPUB are the site’s own business.

The switches themselves, and per-chapter print, are covered in Print.

Migrating an existing manuscript

An existing manuscript usually expresses figure and table numbering with the site’s own figure shortcode, bold pseudo-captions, and bare links to #fig_*. The theme repository ships a migration script that rewrites those legacy forms into fig, tbl and xref while preserving the public anchors already published. Pin the site to a released OINK version that includes the Book components first, then migrate the content.

Dry run: diff and report only, no files changed
python3 ~/pgsty/oink/bin/migrations/book_figures.py \
  --profile tpme \
  --root /path/to/your-book \
  --report /tmp/book-migrate.json > /tmp/book-migrate.diff

Four profiles cover the legacy conventions of three real manuscripts (DDIA contributes one each for v1 and v2), and each recognizes only the forms actually observed in them:

--profile Legacy form it recognizes
tpme A pseudo-h6 caption beside an image, a caption beside a table, and bare /en/...#fragment links
ddia-v2 The site’s own figure shortcode, classified by number into figure / table / code example
ddia-v1 A bare image with an adjacent bold numbered caption, with the ID derived from the image filename
pg-internal A bold or italic “Figure N” caption in Chinese or English next to an image, and a numbered table caption next to a table
--profile
Required; one of the four values above
--root
Required; the consuming repository’s root
--path
Restricts to a file or directory under --root; repeatable. The default scans the whole content tree
--write
Applies the rewrite. The default is a dry run that writes nothing
--no-diff
Suppresses the diff while keeping the summary and the report
--report
Writes the machine-readable JSON report

The diff goes to standard output, the summary to standard error, and the report carries files_scanned, files_changed, counts, skipped and idempotent. The script rewrites only targets it can determine uniquely: where the number is unclear, the caption is not unique, or the marker form is unrecognized, the text is left as it stands and recorded in skipped for a human. Bold text, inline code and formulas inside a legacy caption degrade to plain text, because a Book caption is plain text by contract.

After reviewing the diff, apply it on a dedicated branch and run a second pass to confirm idempotency:

Apply, then verify idempotency
python3 ~/pgsty/oink/bin/migrations/book_figures.py \
  --profile tpme --root /path/to/your-book --write \
  --report /tmp/book-migrate-written.json

python3 ~/pgsty/oink/bin/migrations/book_figures.py \
  --profile tpme --root /path/to/your-book --no-diff \
  --report /tmp/book-migrate-second.json

The second report should read files_changed: 0, an empty counts and idempotent: true; the script signals idempotency with exit code 0.

The profiles recognize only the legacy forms actually observed in those three manuscripts. Where a manuscript’s conventions fall outside the four, the script does not apply and the rewrite is manual, following Numbering: the native form. The theme repository’s bin/check-book-migrations.py covers all four profiles with a dry-run and an idempotency check.

Verify

  1. The build is warning-free: hugo --printPathWarnings --panicOnWarning. A malformed number, a duplicate ID and a missing caption all fail here.
  2. The page should show a localized label such as “Figure 2-1”, clickable xref links, and anchors that land correctly.
  3. Compare the chapter order across all four places: sidebar, pager, book-toc and whole-book print.
  4. Check the Markdown output: curl -s http://localhost:1313/handbook/ch02/index.md. The shortcode form should degrade to **Figure 2-2.** caption plus the original body, and the native form should keep its source block and attribute line as they are.
  5. Run the anchor check from the theme repository against the build output:
python3 ~/pgsty/oink/bin/check-book.py --site-public public

It verifies that every reference’s target anchor exists, that kind and number agree, that page-local IDs are unique, and that a numbered image has alternative text worthy of its caption.

Book shortcode parameters

num , string , default
Required (except for the bare eq form). Matches [0-9A-Za-z.-]+ and must be quoted
id , string , defaultfig-<num> / tbl-<num> / eq-<num> / eg-<num>
Matches [A-Za-z][A-Za-z0-9_.:-]* and is preserved byte for byte
caption , plain text , defaultempty
Required for eg; optional for fig, tbl and eq. Not Markdown
class , class token , default
Appended to the <figure>; requires num
src , image path , default
fig only. Mutually exclusive with inner content, and follows the shared image resolution order
link alt width height , , default
fig only. Width and height are positive integers
title , plain text , default
fig only. A migration alias for caption, mutually exclusive with it

xref:

fig tbl eq eg , number string , default
At most one. Supplies the localized label and derives the anchor
anchor , ID , defaultderived from kind and number
Required when no kind is given, together with inner link text
page , page reference , defaultcurrent page
Resolved through page lookup in the current language; a missing page fails the build

book-toc:

depth , integer 1–3 , default2
1 chapters / 2 with nested sections / 3 with the heading tree
drafts , boolean , defaulttrue
false filters draft chapters out of the generated list

book-figures, book-tables, book-equations and book-examples take no parameters.

Limits

  • There is no automatic numbering. Chapter, figure and table numbers are all written by hand; changing one is a deliberate edit, not a side effect of a build.
  • The attribute line must touch its block, with no blank line between. An attribute line a tool like Prettier has moved fails silently, and the figure degrades to a plain image.
  • book_kind and book_part are metadata keys the contract acknowledges but the current templates do not render. The ones with a visible effect are book_number and book_status.
  • The index shortcodes trigger descendant content rendering, which noticeably lengthens the build on a very large tree. The same reason is why whole-book print has to be requested explicitly.
  • A footnote reference cannot appear in a shortcode body; the build fails and names the native form to use instead — see Numbering: the shortcode form.
  • The theme stops at print HTML: pagination, font embedding, index compilation and PDF / EPUB packaging are outside the contract.
  • Organizing content — how the tree becomes the sidebar and the reading order
  • Images — captions, sizing, zoom and image processing
  • Tables — table attribute lines and full-width tables
  • Math — KaTeX and passthrough configuration
  • Print — per-chapter and whole-book print

3.6 - Releases and downloads

Record versions, tags, archive links, checksums and install commands as local facts, then let release cards, asset tables, download blocks and index pages derive from that one record.

OINK keeps release facts in two local places: a release_url in a page’s front matter names the GitHub release this page is about, and data/download/<key>.yaml says how to install it. Release cards, asset tables, download blocks and index pages all derive from those two. Nothing contacts GitHub at build time, and nothing claims a tag or an asset already exists.

This page carries demonstration release facts

Its front matter holds a release_url (OINK v0.4.0), and the card, asset table and download block below are really rendered. The checksums and asset filenames are fabricated: the URLs are derived locally from the repository and the tag, the files they point at do not exist in any real release, and the hashes here must not be used to verify anything.

Components and where the facts come from

What you want What renders it Facts come from
A version summary card (tag, date, archives, repo) release-card The page’s release_url
A checksum asset table The checksums fence / release-assets sha*sum lines in the body
A multi-channel download block download data/download/<key>.yaml
A chronological release index layout: releases Each page’s release_url, or its title

The page owns the release facts

One key in the release page’s front matter is the whole record — the exact-tag GitHub release URL:

content/blog/release/0.4.0.md
release_url: https://github.com/pgsty/oink/releases/tag/v0.4.0

The owner, the project, and the tag come out of the URL, and the date is the page’s own date. A value that is not an exact-tag GitHub release URL warns and skips the release block — and fails a --panicOnWarning build. The 0.5 release map (product / version / repo / tag / date / prev / checksums) and its string shorthand are gone; a page still carrying one gets a warning that names release_url.

Put a parameterless shortcode wherever the summary belongs; the call itself accepts no facts:

Source
{{< release-card >}}

The card carries the four links the URL alone can name — the release, both source archives, and the repository — all derived locally. Checksum files belong in the asset table below a note, and comparisons live on GitHub.

The release index page

A section can switch to the release index layout. It lists every regular page of the section, newest first — the page date, with the tag’s version as the tiebreaker inside one day (SemVer precedence, with a deterministic fallback for non-SemVer tags):

content/blog/release/_index.md
---
title: Releases
layout: releases
---

An entry whose release_url parses reads as project tagoink v0.4.0 — over the page’s description; a page without one keeps its own title, so a plain note between releases is a plain entry, not a warning. The 0.5 release_products filter and release_group_by_product grouping are gone; naming either warns.

This site’s Releases currently uses the ordinary blog list. Switch to layout: releases when a strict chronology is wanted.

Checksum assets

The checksums fence is the native form of a checksum table, holding the verbatim output of a sha*sum command:

Source
```checksums
1e2f4c8a9d05b7361f8ac25d0e7b4913a6c8df215047eb9c3a1d6b8250f9e7c4  oink-0.4.0-linux-amd64.tar.gz
7b3d9e0c145a8f26d0b7e93c48156aa2f0d9c7b31e846a5029df1b6c7a3e8250 *oink-0.4.0-darwin-arm64.tar.gz
```
Download asset
FileChecksum
oink-0.4.0-linux-amd64.tar.gz Linuxamd64SHA-256 1e2f4c8a9d05b7361f8ac25d0e7b4913a6c8df215047eb9c3a1d6b8250f9e7c4
oink-0.4.0-darwin-arm64.tar.gz macOSarm64SHA-256 7b3d9e0c145a8f26d0b7e93c48156aa2f0d9c7b31e846a5029df1b6c7a3e8250

Only two line shapes are accepted: <hex><two spaces><filename> and <hex><space>*<filename>. Blank lines and lines starting with # are ignored. The hash length decides the algorithm (MD5 / SHA-1 / SHA-256 / SHA-512), and one block holds one algorithm. A malformed line fails the build with its line number. A filename must be a single path segment. The type, operating system and architecture badges are inferred from the filename; they are decoration, and nothing shows when the inference fails.

The base for asset links: with release_url front matter on the page it is derived as https://github.com/<repo>/releases/download/<tag>/; a page without release facts must write base= explicitly. Having both is an error.

a page with no release front matter
```checksums {base="https://repo.pigsty.io/oink/v0.4.0/" algo="sha256"}
1e2f4c8a9d05b7361f8ac25d0e7b4913a6c8df215047eb9c3a1d6b8250f9e7c4  oink-0.4.0-linux-amd64.tar.gz
```

release-assets is the shortcode form of the same parser and renderer. It adds one thing the fence lacks, src=, so the checksum file itself can be committed as a page resource or a global asset (src and inner content are mutually exclusive); group="auto" groups by platform and architecture:

Source
{{< release-assets group="auto" >}}
5a0c7d1e93b4826f0ad35c9e17b6402d8f1c95ae63d70b28c4e19a5f38207db6  oink-0.4.0-1.el9.x86_64.rpm
c93f16a8d052b7e41ac68d3907b25fe0a41d8c7362b95e0187ac4d63f9520ea8  oink-0.4.0-1.el9.aarch64.rpm
{{< /release-assets >}}

.rpm

Download asset
FileChecksum
oink-0.4.0-1.el9.x86_64.rpm Linuxamd64SHA-256 5a0c7d1e93b4826f0ad35c9e17b6402d8f1c95ae63d70b28c4e19a5f38207db6
oink-0.4.0-1.el9.aarch64.rpm Linuxarm64SHA-256 c93f16a8d052b7e41ac68d3907b25fe0a41d8c7362b95e0187ac4d63f9520ea8

In HTML the hash is shown truncated while the full value stays in the accessible name and in what the copy button copies, and that button comes from a local runtime loaded on demand. With JavaScript disabled it is still a complete linked table. Print expands the full hash without controls, and Markdown and RSS emit a pipe table of full hashes.

Download channel data

How to install belongs to the product rather than to one release, so it lives in data/download/<key>.yaml. This site’s real record is data/download/prd5.yaml:

data/download/prd5.yaml
version: 0.4.0
repo: pgsty/oink
published: true
channels:
  - id: script
    kind: rolling
    title: Install script
    title_zh: 安装脚本
    icon: fa-solid fa-bolt
    note: The rolling channel deliberately contains no version interpolation.
    note_zh: 滚动渠道刻意不插入版本号。
    steps:
      - title: Install
        title_zh: 安装
        code: curl -fsSL https://repo.example.org/oink/install | bash
        lang: bash
  - id: source
    kind: pinned
    title: Source archive
    title_zh: 源码归档
    icon: fa-solid fa-code-branch
    url: https://github.com/pgsty/oink/archive/refs/tags/${tag}.tar.gz
    steps:
      - title: Clone the tag
        title_zh: 克隆标签
        code: git clone --branch ${tag} https://github.com/pgsty/oink.git
        lang: bash
  - id: assets
    kind: pinned
    title: Release assets
    title_zh: 发布资产
    icon: fa-solid fa-box-open
    checksums: |
      aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa  oink-0.4.0.tar.gz

The record has exactly five top-level fields — version, repo, tag, published, channels — and one extra key fails the build. version may be omitted here and supplied by the site’s params.version instead.

version , string , defaultsite params.version
Missing in both places fails the build
repo , owner/name , default
Required once a pinned channel has a link or assets
tag , string , defaultv{version}
URL-safe characters only
published , boolean , defaulttrue
false means the immutable release does not exist yet
channels , array , default
Must be non-empty

Each channel:

id , ^[a-z][a-z0-9-]*$ , default
Unique within the record; used as the anchor
kind , rolling | pinned , default
Decides whether release facts may be interpolated
title , localized string , default
Must resolve to a non-empty value
note , localized string , default
One line of explanation under the channel
icon , Font Awesome class pair , default
For example fa-solid fa-bolt
url , http(s) or a site path , default
Interpolatable on pinned only
steps[] , title / code / lang , defaultlang: text
Code steps go through OINK’s enhanced code renderer
checksums , sha*sum text , default
pinned only; mutually exclusive with checksums_src
checksums_src , asset path , default
Reads the checksum file as a Hugo asset

Two rules:

  • Localization resolves by suffix: <field>_<exact language><field>_<base language><field>. A Chinese site resolves title_zh_cn, then title_zh, then title. camelCase aliases are not accepted.
  • Only a pinned channel’s url and steps[].code interpolate ${version} and ${tag}. A rolling channel refuses interpolation, so a stable install command is never bound to one version. Titles and notes never interpolate.

Rendering the download block

download takes exactly one positional parameter, the data key:

Source
{{< download "prd5" >}}

Install script

The rolling channel deliberately contains no version interpolation.

Install
curl -fsSL https://repo.example.org/oink/install | bash

Source archive

Source archive
Clone the tag
git clone --branch v0.4.0 https://github.com/pgsty/oink.git

Release assets

Download asset
FileChecksum
oink-0.4.0.tar.gz SHA-256 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa

In HTML it renders a row of anchor chips plus one section per channel; code steps reuse the enhanced code block and its on-demand copy runtime, and a checksum channel reuses the asset table above. Print statically expands the same content, Markdown emits the titles, source fences and full hashes, and RSS omits the component.

Before the tag is cut and the assets are uploaded, mark the record unpublished:

data/download/<key>.yaml
published: false

Rolling channels keep working. Pinned channels become an unclickable “pending release” state, omit the pinned commands, and disable asset links and copy controls. Flip the switch once the tag and the assets resolve, rather than writing a guessed link into the prose first.

The same record can also feed a landing page’s download section, with no second version model — see Home and landing pages.

How this relates to blog release notes

The two have different jobs:

  • A release note in the blog (this site keeps them in content/blog/release/) is the narrative: what changed, how to upgrade, what breaks. Its front matter carries release_url, and a release-card can sit at the top. How to write one is in Blog posts.
  • The download data is the operation: which channel, which command, which hash. It is decoupled from the version number, so an upgrade edits one place.

The order for a release: update version in data/download/<key>.yaml → write a new content/blog/release/<version>.md with its release_url → flip published to true once the tag and assets are in place.

Verify

  1. The build is warning-free: hugo --printPathWarnings --panicOnWarning. A malformed hash line, mixed algorithms, a missing base and a misspelled channel field all fail here.
  2. On the page: the card’s tag and date match the repository, and every asset row opens a real download URL.
  3. Check the hashes against the actual artifacts by hand: the component only lays them out and verifies nothing.
  4. Confirm the hashes are complete in non-HTML output:
curl -s http://localhost:1313/docs/write/releases/index.md | grep -c '^| '
  1. Rehearse with published: false first and switch to true only once the tag and assets really exist; test each language and a subpath deployment.

3.7 - API reference pages

Put an OpenAPI specification on the site and render it as a browsable API reference with the bundled Swagger UI or Redoc, without touching a CDN.

An API reference page is one OpenAPI specification plus one shortcode. The Swagger UI and Redoc runtimes ship with the theme (versions 5.32.13 and 2.5.3 respectively, per the repository’s VENDOR.json), load only on a page that uses them, and reach no external service at build time or in the browser.

Three steps: put the specification file under static/, create a page with the shortcode, and change the page type to swagger if it needs the dedicated shell.

Where the specification file goes

The specification goes under static/, is published unchanged at the site root, and both shortcodes then receive a URL the browser can fetch:

where the specification lives

  • static/
    • openapi/
      • docs-demo.yamlpublished as /openapi/docs-demo.yaml
  • content/
    • docs/
      • write/
        • openapi.mdthis page

Do not put the specification beside the page. redoc looks for a file of that name in the content directory and builds a URL from it, but a .yaml in the content directory is a page resource, and Hugo publishes one only when it is referenced or processed. redoc builds a URL without referencing the resource, so the browser gets a 404.

A remote specification (starting https://…) is accepted by both shortcodes, but that is a network dependency, and it exposes the reader’s metadata to that host. Intranet deployments and sites with a CSP should use a same-origin specification.

The examples below use the real /openapi/docs-demo.yaml, a demonstration cluster-management API with no reachable server behind it.

Swagger UI

swagger has one named parameter, src, whose value is a URL from the site root. It passes through the theme’s URL validation, so a subpath deployment resolves correctly:

Source
{{< swagger src="/openapi/docs-demo.yaml" >}}

It renders a container with class="td-swagger-ui" and initializes it in place. The container ID is derived from the page address and the shortcode’s ordinal (td-swagger-<hash>-<n>), so one page can hold several.

This page shows the source without rendering Swagger UI: the markup it generates carries three axe WCAG AA violations (the server dropdown has no accessible name, and the version stamp is a scrollable region without keyboard access), and this site’s accessibility gate requires zero violations per page. The Redoc below is really rendered.

Redoc

redoc takes exactly one positional parameter, the specification path. A second parameter fails the build.

Source
{{< redoc "openapi/docs-demo.yaml" >}}

Path resolution has three branches, in order: anything starting with http is a remote URL; a file of that name found in the content directory yields baseURL + page directory + filename; otherwise it is baseURL + the path as written. So a redoc path must not begin with a slash — /openapi/… would produce a doubled slash such as https://example.com//openapi/…. Unlike swagger, it generates an absolute URL based on baseURL.

The theme pins five attributes — hide-hostname, hide-logo, suppress-warnings, lazy-rendering, native-scrollbars — and hides the Redocly brand mark with CSS. Redoc’s remaining attributes are not exposed to authors; a site that needs them overrides layouts/_shortcodes/redoc.html.

The dedicated page shell

API reference pages tend to be wide and long, which is what the swagger page type is for:

content/api/_index.md
---
title: Cluster management API
type: swagger
page_width: wide
cascade:
  type: swagger
---

swagger is one of the theme’s default shell types (params.ui.shell_types defaults to [docs, book, blog, swagger], and a site that overrides the list needs to keep it). It differs from the docs shell in exactly two ways: an extra td-swagger class on <body> for styling hooks, and no version banner. Sidebar, table of contents, breadcrumbs, pager and page end all behave normally.

Shells and page width are covered fully in Layouts and page types.

Output

Output What appears
HTML The full interactive Swagger UI / Redoc; the runtime loads on demand from local files, with no CDN
Print An empty container only: both interfaces are built by JavaScript in the browser, so print output has no content
Markdown The container <div> / <redoc> and the initialization script as they stand; it does not degrade into an endpoint list
RSS As Markdown

An API reference has content in HTML only. To put endpoint information into print or agent output as well, describe the key endpoints in prose on the same page; body text outside the shortcode survives intact in all four outputs.

Limits

  • Both components derive their container ID from the page address and the shortcode’s ordinal, so several on one page never collide.
  • The two can coexist on one page, but the page becomes long and loads both runtimes. Pick one for a production site.
  • Swagger UI’s markup has axe WCAG AA violations (select-name, scrollable-region-focusable). They come from the upstream distribution and the theme does not rewrite them. A site with a zero-violation accessibility gate excludes such pages, or uses Redoc instead.
  • redoc accepts no attribute parameter: a second positional argument fails the build.
  • A redoc path must not start with /, or the URL gains a doubled slash.
  • The specification must be fetchable by the browser: put it in static/ and confirm the file exists under public/ after a build.
  • There is no mock server: Swagger UI’s “Try it out” makes a real request to whatever servers names, and the address in the sample specification is not reachable.

Verify

  1. The build is warning-free: hugo --printPathWarnings --panicOnWarning.
  2. The specification really was published: ls public/openapi/docs-demo.yaml, or open http://localhost:1313/openapi/docs-demo.yaml.
  3. Endpoints expand on the page and their schemas appear; the browser console shows no 404 and no cross-origin error.
  4. Reload once with the network off: the runtimes are local, and with a same-origin specification the interface should still appear.

4 - Components

Every component available for writing, one page each, examples from the simplest upwards, with the parameter table at the end.

This section answers one question: how do I write this component in Markdown? Every page has the same shape — the shortest example, progressively richer examples, the output matrix, the parameter table, the limits. For syntax at a glance, use the cheatsheet below.

Two forms

A component’s first form is Markdown itself: blockquotes, lists, tables, images, fences — plus a single {…} attribute line right after them. The native form stays readable on GitHub and in any Markdown editor, and the Markdown output keeps the source rather than the rendered HTML.

Whatever the native form cannot express is a shortcode: tabs in running text, parameter tables with block-level descriptions, cards with icons and badges, terminal recordings. Five rules cover them:

  • Every shortcode is written {{</* name */>}}. Only {{%/* steps */%}} uses the % delimiter, because its body is page-level Markdown.
  • Nested names (tab, card, field) are valid only inside their parent.
  • A bad parameter never degrades silently. The build fails, and the error names the file and the line.
  • Public string parameters (captions, labels, titles) are plain text and are not parsed as Markdown. Only bodies are Markdown: tab, card and field bodies, files pulled in by include, and the Book fig / tbl / eg bodies.
  • A component the page never used ships no runtime. The scripts are concatenated from what this page actually used; print, Markdown and RSS output load nothing at all.

Site prerequisites

Components depend on three Goldmark settings. Cloning this site gives you them already configured; copy the snippet when starting from scratch:

hugo.yml
markup:
  goldmark:
    renderer:
      unsafe: true # keep HTML that content emits
    parser:
      attribute:
        block: true # enable {…} attribute lines
      wrapStandAloneImageWithinParagraph: false # standalone images are not wrapped in <p>
  • renderer.unsafe: true — Goldmark drops raw HTML in content by default; with it off, HTML nested inside component bodies disappears.
  • parser.attribute.block: true — the master switch for attribute lines. With it off, {.steps} and {caption="…"} are just a line of text.
  • parser.wrapStandAloneImageWithinParagraph: false — a standalone image is no longer wrapped in <p>, so it can become a captioned figure and an attribute line can follow it.

A few components have their own prerequisites: mathematics needs Goldmark passthrough, PlantUML and Draw.io need a rendering server you run yourself. Each page says so. The complete set of configuration keys is in Configuration.

Cheatsheet

Values in the Form column: native = Markdown syntax plus an attribute line; fence = a fenced block with a language tag; shortcode = {{</* … */>}}. The Runtime column says whether the component ships JavaScript to the page.

Component In one line Shortest form Form Runtime
Callouts Separate prerequisites, warnings and asides from the prose > [!NOTE] native none
Images Captions, sizing, zoom, numbering and build-time processing ![alt](oink.webp) native site switch
Code Blocks Highlighting, titles, copy, folding, linkable lines ```sh fence per page
Tabs One thing, several platforms or languages attribute {tab="Linux"} native + shortcode per page
Tables Plain tables plus full-width, matrix, caption and numbering {.full-width} native none
Fields Parameter lists with type / required / default chips {.fields meta="type default"} native + shortcode none
Steps A procedure with an order {.steps} native + shortcode none
Cards A set of parallel destinations {.cards} native + shortcode none
FileTree Directory structure with an aligned comment column ```filetree fence per page
Math KaTeX inline and display formulas $$ … $$ native per page
Mermaid Flowcharts, sequence diagrams, Gantt charts ```mermaid fence per page
PlantUML UML diagrams; needs a rendering server ```plantuml fence site switch
Markmap A Markdown outline becomes a mind map ```markmap fence site switch
Draw.io Diagrams that stay editable; needs a server ![alt](arch.drawio.svg) native site switch
ECharts Declarative statistical charts ```echarts fence per page
Infographic AntV infographics ```infographic fence per page
Gallery A set of images sharing one zoom dialog ```gallery fence site switch
Badge Inline status markers {{</* badge text="Beta" */>}} shortcode none
Kbd Key names and chords {{</* kbd "Ctrl" "K" */>}} shortcode none
Includes Pull in files, print site parameters, drop build-time notes {{</* include file="parts/x.md" */>}} shortcode none
Asciinema Terminal recordings {{</* asciinema file="images/x.cast" */>}} shortcode per page

Four notes on the Runtime column:

  • A code block loads code-block.js only when a block on the page has a copy or fold control; a file tree loads filetree.js only when the tree has a comment column, which is the runtime that drags the split.
  • Images and galleries share one zoom dialog runtime. It needs ui.image_zoom on for the site and at least one eligible image on the page.
  • Mathematics is rendered to HTML and MathML by KaTeX at build time. The page gains a KaTeX stylesheet and its fonts, and no script.
  • Draw.io loads only on pages whose rendered content contains PNG or SVG candidates, then inspects each distinct image URL once.

Every component has a defined shape in all four outputs — HTML, print, Markdown and RSS. See the Output section on each page.

4.1 - Callouts

Write notes, warnings and collapsible asides — with colour, icon and title — as > [!NOTE] blockquotes, no shortcode involved.

A callout is a GitHub / Obsidian style blockquote: > [!TYPE] on the first line, the body underneath. Use it to lift a prerequisite, a warning or an aside out of the running text; if a sentence in the prose says it, a callout is not needed.

Shortest form

Source
> [!NOTE]
> Hugo Modules need Go on the machine; an offline archive does not.
Note

Hugo Modules need Go on the machine; an offline archive does not.

Without a title the localized type name is used (“Note” on an English site, 「注意」 on a Chinese one). The source renders as a GitHub callout on GitHub and as a plain blockquote in any other Markdown reader — nothing is ever lost.

Ten types

The first five match GitHub; the other five are semantic types OINK adds. Every type has a default icon and accent colour.

Source
> [!TIP]
> `hugo server -D` previews drafts.

> [!IMPORTANT]
> The floor is Hugo Extended 0.160.1; anything older fails the build outright.

> [!WARNING]
> `hugo --cleanDestinationDir` empties `public/`.

> [!CAUTION]
> The first build after deleting `resources/_gen` is much slower.

> [!SUCCESS]
> Build passed with zero warnings — ship it.

> [!DANGER]
> Never commit `go.work`.

> [!QUESTION]
> Should the site have comments? See [enabling comments](/docs/admin/comments/).

> [!EXAMPLE]
> `pgsty.com` is a documentation site built from callouts and tables alone.

> [!QUOTE]
> Documentation is a love letter that you write to your future self.
Tip

hugo server -D previews drafts.

Important

The floor is Hugo Extended 0.160.1; anything older fails the build outright.

Warning

hugo --cleanDestinationDir empties public/.

Caution

The first build after deleting resources/_gen is much slower.

Success

Build passed with zero warnings — ship it.

Danger

Never commit go.work.

Question

Should the site have comments? See enabling comments.

Example

pgsty.com is a documentation site built from callouts and tables alone.

Quote

Documentation is a love letter that you write to your future self.

Type names are case-insensitive.

Custom title

Text after the marker on the same line becomes the title and accepts inline Markdown — code, bold, links.

Source
> [!WARNING] Rewrites `public/`
> Check that `baseURL` points at the production domain before a production
> build, or every absolute link will be wrong.
Rewrites public/

Check that baseURL points at the production domain before a production build, or every absolute link will be wrong.

Body content

The body is page-level Markdown: lists, fenced code, tables, images, nested callouts. Every line starts with >, fences included.

Source
> [!TIP] Three commands to a live preview
>
> 1. Clone: `git clone https://github.com/pgsty/oink.pgsty.com my-docs`
> 2. Enter the directory and preview:
>    ```bash
>    cd my-docs && hugo server
>    ```
> 3. Open <http://localhost:1313/>
>
> | Port | Purpose |
> | --- | --- |
> | 1313 | Hugo development server |
Three commands to a live preview
  1. Clone: git clone https://github.com/pgsty/oink.pgsty.com my-docs
  2. Enter the directory and preview:
    cd my-docs && hugo server
  3. Open http://localhost:1313/
Port Purpose
1313 Hugo development server

Collapsing

A - after the type starts the callout closed, a + starts it open. Both render as a native <details>; no JavaScript is loaded. Use them for full command output, alternatives, background — anything that need not be visible by default.

Source
> [!NOTE]- Why is Go needed?
> Hugo downloads themes through Go's module system (`hugo mod get`). A submodule
> or an offline archive works without Go installed.

> [!TIP]+ Open by default, but the reader can close it
> The closed state is not remembered; a reload returns to the default.
Why is Go needed?

Hugo downloads themes through Go’s module system (hugo mod get). A submodule or an offline archive works without Go installed.

Open by default, but the reader can close it

The closed state is not remembered; a reload returns to the default.

The neutral disclosure, DETAILS

[!DETAILS] is a disclosure without a semantic colour: closed by default, [!DETAILS]+ open. Use it for long output, whole configuration files, anything that has to be foldable.

Source
> [!DETAILS] Full `hugo version` output
> ```text
> hugo v0.164.0+extended+withdeploy darwin/arm64 BuildDate=2026-07-06T16:39:30Z VendorInfo=Homebrew
> ```
Full hugo version output
hugo v0.164.0+extended+withdeploy darwin/arm64 BuildDate=2026-07-06T16:39:30Z VendorInfo=Homebrew

Custom icon

The line right after the blockquote can carry {icon="fa-solid fa-xxx"} — one Font Awesome class pair — replacing the type’s default icon. The attribute line must follow the blockquote immediately, with no blank line between them.

Source
> [!TIP] PostgreSQL 18 is supported
> Pigsty v4 installs PostgreSQL 18 by default.
{icon="fa-solid fa-database"}
PostgreSQL 18 is supported

Pigsty v4 installs PostgreSQL 18 by default.

Nesting

Callouts nest (one more > per level) and can sit inside list items or steps. One level of nesting is plenty.

Source
> [!WARNING] Back up before upgrading
> A theme version bump can change how a page renders.
>
> > [!TIP]- How to back up
> > `git tag pre-upgrade` is enough — rolling back is `git checkout pre-upgrade`.
Back up before upgrading

A theme version bump can change how a page renders.

How to back up

git tag pre-upgrade is enough — rolling back is git checkout pre-upgrade.

Unknown types and common slips

An unknown type name neither fails the build nor loses content: the block renders as an ordinary blockquote with the [!TYPE] marker still visible.

Source
> [!NOTICE] Not a valid type
> The marker stays on the page to tell you so.

[!NOTICE] Not a valid type

The marker stays on the page to tell you so.

Other things that bite:

  • Title merged into the body. In files that pass through Prettier and friends, keep an empty > line under the title line, or the formatter folds the title into the body.
  • Attribute line moved by a formatter. Wrap marker lines such as {icon=…} in <!-- prettier-ignore-start --> / <!-- prettier-ignore-end -->.
  • style, onclick and friends fail the build: the attribute line accepts icon and class only (see the table below).

Output

Output Shape
HTML Static types are <div class="td-callout" role="note">; collapsible types are a native <details> + <summary>
Print All static and expanded; disclosures carry a data-td-callout-collapsible marker
Markdown The source blockquote is preserved, [!TYPE] marker and title included
RSS Same as print — static and expanded

Callouts load no script.

Parameter reference

The marker line > [!TYPE]± Title:

TYPE , enum , default
NOTE TIP IMPORTANT WARNING CAUTION SUCCESS DANGER QUESTION EXAMPLE QUOTE DETAILS; case-insensitive; an unknown value renders as a plain blockquote
± , - / + / none , defaultnone
- collapses closed, + collapses open; bare DETAILS is closed
Title , inline Markdown , defaultthe localized type name
On the same line as the marker

The attribute line {…}, immediately after the blockquote:

icon , Font Awesome class pair , defaultthe type’s icon
For example fa-solid fa-database; DETAILS has no default icon
class , space-separated classes , default
Passed through verbatim for site CSS

style, on* and any other key fail the build.

Limits

  • Colours cannot be customized: the type decides. When you need a new meaning, pick the closest type and write your own title.
  • The collapsed state is not persisted.
  • Callouts work inside {.steps} list items and {{%/* steps */%}} steps (see Steps); every line of the blockquote starts with > and lines up with the list item’s indent.
  • Steps — callouts inside a procedure
  • Tabs — the same note split per platform
  • Writing pages — when to use a callout and when to use prose

4.2 - Images

Plain Markdown image syntax plus one attribute line gives you captions, sizing, zoom, links, numbering and Hugo image processing.

There is one way to write an image: Markdown’s ![alt text](source "title"). An image standing alone as its own paragraph can be followed by a {…} attribute line, making it a captioned figure, a zoom candidate, a numbered figure, or a derivative processed by Hugo. The theme has no image shortcode.

Shortest form

Source
![The OINK documentation shell: sidebar, article and table of contents](oink-shell.webp)
The OINK documentation shell: sidebar, article and table of contents

This image sits in the same directory as the page (a page bundle), so the theme reads its intrinsic size and writes width/height, and the page does not shift while loading; every image is lazy-loaded. Alternative text serves screen readers and search engines and should always be written; an empty alt marks a decorative image, which zoom skips.

Where images come from

Sources resolve in the following order, written the same way in each case:

Placement How it is written Suited to
Beside the page (a bundle: index.md plus the image) ![…](oink-shell.webp) A screenshot only this page uses; it travels with the page and is shared by translations
Global resource assets/images/… ![…](images/logo/oink.webp) Images several pages share, especially ones needing processing (resize / crop)
Static directory static/images/… ![…](/images/hero-light.webp) Large images and downloads that need no processing; supply width/height where the theme cannot measure them
Remote URL ![…](https://example.com/a.png) Rare: nothing is downloaded at build time and nothing can be processed

A relative path is looked up first as a page resource and then as a global resource; failing both, it is emitted as a static path. The theme does not check whether a static path or a remote URL exists. Only an image that asks for processing (command=) fails the build when its resource cannot be found.

Inline versus block

An image inside a line of text is an inline image, rendered as one <img> and unable to carry attributes; an image standing alone as its own paragraph is a block image and can carry an attribute line.

Source
This little one ![shell thumbnail](oink-mini.webp) sits inside a sentence — an inline image.

![shell thumbnail](oink-mini.webp)
{width="100" height="64"}

This little one shell thumbnail sits inside a sentence — an inline image.

shell thumbnail

An inline image displays at its own size (50×32 here). An SVG with no intrinsic size stretches to the container width when inlined, so an SVG belongs as a block image with explicit width/height.

Note

Block images depend on the site setting markup.goldmark.parser.wrapStandAloneImageWithinParagraph: false (this site has it; see Configuration). Without it, Goldmark wraps a standalone image in <p> and the attribute line is treated as prose.

Captions

An attribute line with caption="…" renders the image as a <figure> plus a <figcaption>. A caption is plain text and is not parsed as Markdown.

Source
![Release card: version, publication date and asset buttons](release-note.webp)
{caption="The release card is generated from data/download and the page's release record"}
Release card: version, publication date and asset buttons
The release card is generated from data/download and the page's release record

A Markdown "title" keeps its own meaning (a hover tooltip) and never becomes the caption.

Size

width/height are positive integers overriding the resource’s own dimensions: they give a static or remote image a placeholder box so the page does not shift, or display a large image smaller (the browser scales it; the file is unchanged).

Source
![The OINK home page illustration (light)](/images/hero-light.webp)
{width="450" height="300" caption="A 900×600 illustration from static/images/ shown at half size"}
The OINK home page illustration (light)
A 900×600 illustration from static/images/ shown at half size

Processed images

Page resources and global resources can be processed by Hugo at build time: command and options must both be given, the command is one of Fit, Resize, Fill or Crop, and the options are Hugo’s image processing string. The rendered src is the derivative; with zoom enabled the dialog opens the original.

Source
![shell thumbnail](oink-shell.webp)
{command="Fit" options="300x150" caption="Fit 300x150: scaled to fit inside a 300×150 box"}

![the left half of the shell](oink-shell.webp)
{command="Fill" options="300x150 Left" caption="Fill 300x150 Left: fills the box, cropped from the left"}
shell thumbnail
Fit 300x150: scaled to fit inside a 300×150 box
the left half of the shell
Fill 300x150 Left: fills the box, cropped from the left

Static paths, remote URLs and SVG cannot be processed, and writing command for one fails the build. The options syntax (anchors, quality, format conversion, as in 300x150 webp q80) is in Hugo image processing.

Two forms, for different purposes:

  • No caption, and the image itself is the link: wrap it in a Markdown link, [![alt](src)](href).
  • A captioned figure that is clickable as a whole: add link="…" to the attribute line (which requires caption or num).
Source
[![Go to the highlights page](oink-shell.webp)](/docs/about/features/)

![Release card](release-note.webp)
{caption="Click the image for the releases and downloads guide" link="/docs/write/releases/"}

Go to the highlights page

Release card
Click the image for the releases and downloads guide

A linked image never zooms. Writing link= with no caption fails the build, and the error points at [![…](…)](…) instead.

Numbered figures

Numbered figures are for books and long manuals: add num to the attribute line, with an optional #id. The number is a string the author writes (2-1, 3.4) and the theme never counts automatically; the caption gains a localized “Figure 2-1” prefix, and #id defaults to fig-<num>. Reference it from the prose with an ordinary link [Figure 2-1](#fig-2-1) or the xref shortcode; a whole-book list of figures is in Books.

Source
![Release card](release-note.webp)
{#fig-release num="2-1" caption="The release card: version, date and assets"}

See [Figure 2-1](#fig-release).
Release card
Figure 2-1 The release card: version, date and assets

See Figure 2-1.

A numbered figure can be a processed image at the same time (num plus command), and can carry a link.

Zoom

Image zoom is off by default. Once the site enables it, block images, figures and gallery images that have alt text become clickable buttons that open the full image in a native <dialog> (Esc closes it, focus returns where it was). This page turns it on in its front matter, so every image above is clickable.

hugo.yml
params:
  ui:
    image_zoom: true
One page's front matter: off for this page only
image_zoom: false

Images that never zoom: inline images, decorative images with an empty alt, linked images, and images marked data-no-zoom. The runtime loads only when the page really has a candidate; print, Markdown and RSS have no dialog.

Source: a decorative image does not zoom
![](oink-shell.webp)
{width="150" height="75"}

Light and dark images

The theme has no parameter for swapping an image by colour scheme. Where two images are needed, give each a class and show one per scheme with [data-bs-theme="dark"] in the site’s CSS:

Source
![Sidebar (light)](oink-shell.webp)
{class="only-light"}

![Sidebar (dark)](oink-shell.webp)
{class="only-dark"}
assets/scss/_styles_project.scss
[data-bs-theme="dark"] .only-light,
:not([data-bs-theme="dark"]) .only-dark { display: none; }

class is passed through by the theme untouched, for the site’s CSS to use.

Output

Output What appears
HTML Inline <img>; block <img class="td-image">; with a caption or number, <figure class="td-figure"> plus <figcaption>; a zoom candidate carries data-td-image-zoom
Print As HTML, with the zoom controls removed
Markdown ![alt](src) and the attribute line as they stand
RSS The image src becomes absolute; no zoom

Parameter reference

The attribute line {…} (the line immediately after a block image):

caption , plain text , default
Its presence makes a figure; not parsed as Markdown
#id , identifier , defaultfig-<num> when num is set
[A-Za-z][A-Za-z0-9_.:-]*; the anchor and the Book target ID
num , string , default
[0-9A-Za-z.-]+; registers a Book figure target and prefixes the caption with “Figure N.”
width / height , positive integer , defaultthe resource’s intrinsic size
Overrides the size; static and remote images use it to avoid layout shift
command , enum , default
Fit, Resize, Fill, Crop; must accompany options; page and global resources only
options , string , default
Hugo image processing options such as 600x300, 300x150 Left, 800x webp q80
link , URL , default
Wraps the figure in a link; requires caption or num; a linked image does not zoom
class , class list , default
Passed through for the site’s CSS
data-* / aria-* , string , default
Passed through

style, on*, alt, title, src and any other key on the attribute line fail the build (alt, title and src belong to the Markdown image itself).

Limits

  • A caption holds no Markdown: every public string parameter is plain text, so rich explanation goes in a paragraph below the image.
  • title is not a caption: the c in ![a](b "c") is a hover tooltip.
  • Processing applies to resources only: an image in static/ that needs processing moves to the page bundle or assets/.
  • Remote images are never downloaded at build time.
  • Zoom has no drag, pan or previous / next; a set of related images uses a gallery.
  • Gallery — a set of images sharing one zoom dialog
  • Books — the list of figures and xref cross-references
  • Brand and appearance — where the site logo and favicon go
  • Cards — images on cards

4.3 - Code Blocks

A plain Markdown fence plus one attribute line gives you a filename title, exact copy, line numbers, highlighting, wrapping, folding and linkable lines.

A code block is an ordinary Markdown fence. Highlighting is done at build time by Chroma, which Hugo embeds; there is no highlighter in the browser. Use it for commands, configuration snippets and source. The {…} attributes on the fence’s info line decide the title bar, copy behaviour, line numbers and line anchors. Diagram-style fences (mermaid, echarts, filetree and friends) never take this path — each has its own render hook.

Shortest form

Source
```sql
SELECT datname, numbackends FROM pg_stat_database ORDER BY numbackends DESC;
```
SELECT datname, numbackends FROM pg_stat_database ORDER BY numbackends DESC;

A fence with no attributes still gets the full shell and a copy button. Without a title there is no empty bar: the copy button floats at the top right and appears on hover or when focus enters the block, and is always visible on touch devices. The shell does not display the language; the lexer name goes into data-language for stylesheets and tests.

The language tag is simply Chroma’s lexer name. A diff fence renders a patch with Chroma’s added / removed line styling, no extra component involved:

Source
```diff {title="a change to hugo.yml"}
 params:
   ui:
-    sidebar_menu_compact: true
+    sidebar_menu_compact: false
     sidebar_menu_foldable: true
```
a change to hugo.yml
 params:
   ui:
-    sidebar_menu_compact: true
+    sidebar_menu_compact: false
     sidebar_menu_foldable: true

Filename titles

title gives the block a visible title bar, usually a filename or a path. It also becomes the block’s accessible name.

Source
```yaml {title="hugo.yml"}
markup:
  goldmark:
    parser:
      attribute:
        block: true
    renderer:
      unsafe: true
```
hugo.yml
markup:
  goldmark:
    parser:
      attribute:
        block: true
    renderer:
      unsafe: true

filename is a historical alias of title; writing both fails the build.

Line numbers, start line and highlighting

lineNos takes inline (numbers in the same column as the code) or table (numbers in their own column, selectable on their own and never copied). lineNoStart changes the first displayed number. hl_lines marks lines to emphasize, counted from 1 over the source lines inside the fence, independent of lineNoStart.

Source
```ini {title="postgresql.conf" lineNos="inline" lineNoStart=120 hl_lines="2 4-5"}
shared_buffers = 8GB
max_connections = 200
work_mem = 64MB
wal_level = replica
max_wal_senders = 10
```
postgresql.conf
120shared_buffers = 8GB
121max_connections = 200
122work_mem = 64MB
123wal_level = replica
124max_wal_senders = 10

lineNos="table" puts the numbers in a separate column — in both modes the copy button strips them:

Source
```bash {title="deployment in three commands" lineNos="table"}
./configure -c rich
./install.yml
pig ext install pg_duckdb
```
deployment in three commands
1
2
3
./configure -c rich
./install.yml
pig ext install pg_duckdb

tabWidth decides how many spaces a tab expands to and, like style, is handed straight to Chroma. This site uses class-based Chroma palettes (one for light, one for dark), so style only takes effect when Hugo is switched back to inline style mode.

Wrapping long lines

wrap=true changes display only: the source is unchanged and so is the text you copy. Without it, long lines scroll horizontally.

Source
```text {title="config/artifacts.env" wrap=true}
ARTIFACT_URL=https://repo.pigsty.io/pkg/infra/v3.6.0/infra-pkg-v3.6.0.el9.x86_64.tgz
CHECKSUM=sha256:6d3dce4f7acb18f586469adcb80ab35f3e859f9837786e151cfbc2b3c0f587b2
```
config/artifacts.env
ARTIFACT_URL=https://repo.pigsty.io/pkg/infra/v3.6.0/infra-pkg-v3.6.0.el9.x86_64.tgz
CHECKSUM=sha256:6d3dce4f7acb18f586469adcb80ab35f3e859f9837786e151cfbc2b3c0f587b2

wrap=true cannot coexist with table line numbers: the number column and the code column are two table cells, and wrapping puts them out of step. Writing both fails the build, and the error suggests lineNos="inline" or dropping the wrap.

Folding long code

collapse=N shows the first N lines with a “show all N lines” button at the bottom. The server emits the complete code; folding is a visual clip applied after the browser measures where line N ends. Without JavaScript, in a screen reader, and in print, the code is complete.

Source
```yaml {title="hugo.yml" collapse=8}
baseURL: https://oink.pgsty.com/
title: OINK
defaultContentLanguage: en
languages:
  en:
    languageName: English
    weight: 1
  zh:
    languageName: 简体中文
    weight: 2
params:
  offline_search: true
  ui:
    sidebar_menu_foldable: true
```
hugo.yml
baseURL: https://oink.pgsty.com/
title: OINK
defaultContentLanguage: en
languages:
  en:
    languageName: English
    weight: 1
  zh:
    languageName: 简体中文
    weight: 2
params:
  offline_search: true
  ui:
    sidebar_menu_foldable: true

When the block is no longer than collapse, no button appears. Wrapping and folding work together: folding measures the bottom edge of the Nth source line node, so a wrapped line is never cut in half.

What gets copied

By default the whole source is copied. Terminal sessions — the console and shell-session lexers — copy the commands only: prompted lines survive, the prompts themselves and the output lines are dropped. Copying the block below gives two commands, with no $ and no output.

Source
```console
$ pig ext list duckdb
name       version  category
pg_duckdb  1.0.0    OLAP
$ pig ext install pg_duckdb
INFO installing pg_duckdb
```
$ pig ext list duckdb
name       version  category
pg_duckdb  1.0.0    OLAP
$ pig ext install pg_duckdb
INFO installing pg_duckdb

To copy prompts and output too, write copy="all". Using copy="command" on an ordinary lexer such as bash or sh fails the build, because those cannot tell prompt, command and output apart. For multi-line commands, write the continuation prompt (usually >) on the continuation lines, or they are treated as output and excluded.

When a session-lexer block contains no prompt at all, the copy button reports failure: the icon turns to its error state, an error is logged to the console, and the clipboard is untouched. It never falls back to copying everything.

copy=false removes the copy button from one block — useful for a counter-example nobody should paste:

Source
```yaml {title="counter-example: the attribute line left its block" copy=false}
params:
  ui:
    image_zoom: true   # wrong: image_zoom is a table, not a boolean
```
counter-example: the attribute line left its block
params:
  ui:
    image_zoom: true   # wrong: image_zoom is a table, not a boolean

To turn copying off site-wide use params.ui.code_copy: false, which overrides whatever a block writes in copy (see Configuration). The copy button is icon-only; success and failure swap the icon and announce a localized status. What is copied keeps indentation, blank lines and Unicode, drops line numbers, and ends with exactly one newline.

Turning “see line 3” into a link takes two steps: give the fence an explicit id, then enable anchorLineNos=true. The line numbers become anchor links of the form #<id>-<line>.

Source
```sql {id="ex-explain" title="explain.sql" lineNos="table" anchorLineNos=true}
EXPLAIN (ANALYZE, BUFFERS)
SELECT relname, n_live_tup
FROM pg_stat_user_tables
WHERE n_live_tup > 1000
ORDER BY n_live_tup DESC;
```

Jump to [line 4](#ex-explain-4).
explain.sql
1
2
3
4
5
EXPLAIN (ANALYZE, BUFFERS)
SELECT relname, n_live_tup
FROM pg_stat_user_tables
WHERE n_live_tup > 1000
ORDER BY n_live_tup DESC;

Jump to line 4.

Without an id the theme still generates one that is unique on the page, but it depends on where the fence sits in the page — insert another fence above it and the ID changes. Only an author-written id is a permanent link. IDs must not contain whitespace or control characters, and must not collide with any other viewport, tab, panel, title or line-anchor ID on the page; a collision fails the build.

Numbered examples

In a book or a long manual, number the snippets: num plus caption turns the fence into a Book “example” target that xref can reference and that appears in the book-wide list of examples. The number is written by the author — the theme never counts — and id defaults to eg-<num>.

Source
```sql {num="4-1" caption="Bloat ratio per table" #eg-bloat}
SELECT schemaname, relname, n_dead_tup, n_live_tup
FROM pg_stat_user_tables
WHERE n_dead_tup > n_live_tup * 0.2;
```

See {{< xref eg="4-1" anchor="eg-bloat" >}}.
Example 4-1 Bloat ratio per table
SELECT schemaname, relname, n_dead_tup, n_live_tup
FROM pg_stat_user_tables
WHERE n_dead_tup > n_live_tup * 0.2;

See Example 4-1.

num and caption must appear together; one without the other fails the build. num is mutually exclusive with the tab attribute tab. For numbering and indexing figures, tables and equations, see publishing books.

A set of fences as tabs

Consecutive fences carrying tab are assembled into one tab set in the browser. A group on the first fence makes the set shareable, synchronized and remembered.

Source
```bash {tab="Homebrew" group="oink-install" value="brew"}
brew install hugo
```
```bash {tab="APT" value="apt"}
sudo apt install hugo
```
Homebrew
brew install hugo
APT
sudo apt install hugo

The complete rules — group syntax, URL hash, cross-group synchronization, tabs in running text — are on the Tabs page.

Things that bite

  • Showing a shortcode in the docs: a fence does not stop Hugo from parsing, so a {{< tabs >}} written inside a code block still executes. To display it verbatim, add a comment marker inside each delimiter — {{</* tabs */>}}, and {{%/* steps */%}} for the percent form. Every shortcode shown on this page is written that way.
  • Fences inside fences: four backticks outside, three inside — every “Source” block on this page does it. Add another backtick when the inner block has fences of its own.
  • Attributes go on the info line: a fence’s attributes follow the language on the opening line. Only tables and images take their attributes on the line below. Put them on the next line and you get a visible line of braces.
  • Unknown attributes fail rather than being ignored, and the error lists the allowed names. style, srcdoc and on* are rejected; the data-td-code* prefix plus data-language, data-line-count and data-collapse-lines are reserved by the theme and fail the build too.
  • Fences in list items: indent them to line up with the item’s content (three spaces after 1.), or the fence leaves the list.

Output

Output Shape
HTML A <div class="td-code"> shell around Chroma’s .highlight/.chroma; copy and fold buttons ship hidden and appear once the script confirms it can run
Print Complete code; copy, fold and the fade are removed; long blocks may break across pages; the title bar stays
Markdown The source fence, {…} attributes and all, emitted as written
RSS A static code block with no buttons

A page with no copy or fold control never loads code-block.js; print, Markdown and RSS never load it.

Parameter reference

Inside the {…} after the language on the opening line, OINK’s own attributes:

title , non-empty string , defaultnone
The visible title bar (usually a filename) and the accessible name
filename , non-empty string , defaultnone
Historical alias of title; both together fail the build
copy , all command true false , defaultcommand for session lexers, all otherwise
true is all; command is allowed only on console/shell-session
wrap , boolean , defaultfalse
Visual wrapping, source unchanged; mutually exclusive with table line numbers
collapse , positive integer , defaultnone
Lines shown initially; ignored when the block is shorter
label , non-empty string , defaultderived from the title
Accessible name, not displayed; mutually exclusive with aria-label
id , non-empty token , defaultgenerated
Stable block ID and line-anchor prefix; no whitespace
tab , non-empty string , defaultnone
Tab label, see Tabs; mutually exclusive with num
group , ^[a-z][a-z0-9_-]*$ , defaultnone
On the first fence of a set; enables hash / sync / persistence; requires tab
value , ^[a-z0-9][a-z0-9_-]*$ , defaultnone
Required on every fence of a group, forbidden without one; requires tab
num , [0-9A-Za-z.-]+ , defaultnone
Numbered example (Book eg); must appear with caption
caption , plain text , defaultnone
The numbered example’s caption; must appear with num
class , class list , defaultnone
Appended to the .td-code root element
data-* / aria-* / role , string , defaultnone
Passed through to the root element

title, filename and label already give the block an accessible name and role="group". Any of them together with aria-label, aria-labelledby or role fails the build; those three attributes pass through only when the block has neither a title nor a label.

The same line also takes Chroma options, which the theme hands to Hugo unchanged:

lineNos , false inline table , defaultfalse
Line-number style; table is mutually exclusive with wrap=true
lineNoStart , positive integer , default1
First displayed number; does not affect how hl_lines counts
hl_lines , lines and ranges , defaultnone
For example "2 4-5", counted over the source lines in the fence
anchorLineNos , boolean , defaultfalse
Line numbers become anchor links prefixed with the block’s id
tabWidth , positive integer , defaultHugo’s default
Spaces a tab expands to

Limits

  • No swapping the highlighter: there is no Shiki, no Twoslash, no browser-side highlighting and no runnable playground. For patches use a diff fence — Chroma’s .gi/.gd are the added / removed line styles.
  • copy="command" recognizes session lexers only: on any other language it is a build error, never a silent fallback to copying everything.
  • A generated ID is not a permanent link: write id when you intend to share one.
  • mermaid, math, chem, markmap, plantuml, echarts, infographic, checksums, filetree and gallery are not code blocks: each has its own render hook, no shell around it and no copy button.
  • Tabs — the full rules for assembling adjacent fences
  • Include — pull a real file from the repository in as a code block
  • Publishing books — numbered examples, cross references, the list of examples
  • Print — what long code looks like on paper

4.4 - Tabs

A {tab=} attribute on adjacent fences or tables makes a tab set; add a group and it becomes linkable, synchronized and remembered.

Tabs put equivalent alternatives side by side: package managers, distributions, YAML / TOML / JSON, an environment variable versus a configuration key. Ordered steps and unrelated content do not belong in tabs — the reader sees only one panel at a time.

The native form is a tab attribute on adjacent blocks. Reach for the tabs/tab shortcode only when the panels hold running text: several paragraphs, lists, callouts. Both forms share one runtime, one DOM and the same keyboard behaviour.

Shortest form

Write two fences carrying tab back to back, separated by a blank line only.

Source
```bash {tab="Homebrew"}
brew install hugo
```
```bash {tab="Debian / Ubuntu"}
sudo apt install hugo
```
Homebrew
brew install hugo
Debian / Ubuntu
sudo apt install hugo

The server emits two titled code blocks with no panel hidden; after the page loads, the runtime regroups adjacent blocks of the same kind into a tab set. On GitHub, in print, and with JavaScript off, the reader sees two complete blocks one after the other.

Groups: links, sync and memory

Write group on the first block only and the set gains a public URL hash #<group>-<value>, in-page synchronization and browser persistence. Every block in a group must carry value.

Source
```bash {tab="npm" group="pkgmgr" value="npm"}
npm create hugo-site@latest
```
```bash {tab="pnpm" value="pnpm"}
pnpm create hugo-site
```
```bash {tab="Yarn" value="yarn"}
yarn create hugo-site
```
npm
npm create hugo-site@latest
pnpm
pnpm create hugo-site
Yarn
yarn create hugo-site

value is the machine value (^[a-z0-9][a-z0-9_-]*$), tab is the human label; the two are independent. The pnpm panel above answers to #pkgmgr-pnpm, and visiting this page with that hash selects it.

Groups move together

The set below reuses group="pkgmgr". Switch the package manager above and this one follows; switch it here and the one above follows. The choice is written to localStorage under the key td-tabs:v1:pkgmgr and still applies to same-group tabs on other pages.

Source
```bash {tab="npm" group="pkgmgr" value="npm"}
npm run build
```
```bash {tab="pnpm" value="pnpm"}
pnpm build
```
npm
npm run build
pnpm
pnpm build

This set has no yarn panel. When a value is missing, that set simply stays where it is; a set is never left with nothing selected. The initial selection is decided in this order: URL hash, stored value, the shortcode’s default or the first block, the first tab. Opening the page with a hash switches the set without overwriting a preference the reader already stored.

Tables can be tabs too

The same attributes on a table’s attribute line group adjacent tables into a tab set.

Source
| Parameter | Default |
| --- | --- |
| `shared_buffers` | 25% RAM |
| `max_connections` | 100 |
{tab="PostgreSQL 18" group="pgver" value="pg18"}

| Parameter | Default |
| --- | --- |
| `shared_buffers` | 128MB |
| `max_connections` | 100 |
{tab="PostgreSQL 13" value="pg13"}
PostgreSQL 18
Parameter Default
shared_buffers 25% RAM
max_connections 100
PostgreSQL 13
Parameter Default
shared_buffers 128MB
max_connections 100

Fences and tables are two block kinds and never merge into one set even when adjacent: a tab set is all fences or all tables. To mix them, use the shortcode form below.

A label and a filename together

A fence can carry both tab and title: the label goes in the tab bar, the filename title bar stays inside the panel.

Source
```yaml {tab="YAML" title="hugo.yml" group="conffmt" value="yaml"}
params:
  ui:
    sidebar_menu_foldable: true
```
```toml {tab="TOML" title="hugo.toml" value="toml"}
[params.ui]
sidebar_menu_foldable = true
```
YAML
hugo.yml
params:
  ui:
    sidebar_menu_foldable: true
TOML
hugo.toml
[params.ui]
sidebar_menu_foldable = true

A lone block is just a titled block

A block needs a neighbour of the same kind to become a tab set. On its own it keeps its title rather than becoming a tab bar with one tab.

Source
```ini {tab="on its own"}
listen_addresses = '*'
```
on its own
listen_addresses = '*'

Only blank lines may sit between blocks. Three things break a set: running text in between (a paragraph, a heading or a list all count); an HTML comment in between, of which <!-- prettier-ignore-end --> is the common one; a later block writing its own group, since only the first block of a set may carry it.

Tabs around running text

When a panel holds paragraphs, lists, callouts, or several blocks, use the tabs/tab shortcode. The body is full Markdown.

Source
{{< tabs group="deploy" default="pages" label="Deployment target" >}}
{{< tab label="GitHub Pages" value="pages" >}}
The repository ships `.github/workflows/`; a push to `main` builds and publishes.

> [!NOTE]
> `baseURL` has to be the repository's Pages address.
{{< /tab >}}
{{< tab label="Cloudflare Pages" value="cloudflare" >}}
Connect the repository in the Cloudflare dashboard; the build command is:

```bash
hugo --gc --minify
```
{{< /tab >}}
{{< /tabs >}}

The repository ships .github/workflows/; a push to main builds and publishes.

Note

baseURL has to be the repository’s Pages address.

Connect the repository in the Cloudflare dashboard; the build command is:

hugo --gc --minify

default names the initially selected panel; it must equal a child’s value and it requires group. Without group, value is forbidden and the theme generates tab1, tab2 and so on — such a set switches locally and touches neither the URL nor storage. The shortcode form is stricter than the attribute form: a mistake is reported at build time instead of in the browser.

Output

Output Shape
HTML <div class="td-tabs"> with role="tablist" buttons and panels; every panel is visible until the runtime takes over
Print Consecutive titled static sections, no tab bar
Markdown The fence form keeps the source fence, {tab=} included; the shortcode form emits **Label** plus the body
RSS Same as print — stacked titled sections

Only a page that uses tabs loads tabs.js; print, Markdown and RSS never do.

Parameter reference

Attributes on a fence info line or a table attribute line:

tab , non-empty string , defaultnone
The visible label; on a lone block it is simply that block’s title
group , ^[a-z][a-z0-9_-]*$ , defaultnone
On the first block of a set; enables hash, in-page sync and persistence; requires tab
value , ^[a-z0-9][a-z0-9_-]*$ , defaultnone
Required on every block of a group, forbidden without one; requires tab

The tabs shortcode:

group , ^[a-z][a-z0-9_-]*$ , defaultnone
As above: hash, sync and persistence
default , a child’s value , defaultthe first child
The initially selected panel; requires group
label , plain text , defaultlocalized “Tabs”
Accessible name for the tab bar; not displayed

The tab shortcode:

label , plain text , required
The visible label
value , ^[a-z0-9][a-z0-9_-]*$ , required
Forbidden without a group, where tab1, tab2 … are generated

Behavioural contract: in a group the panel ID is <group>-<value>; when the same group name appears a second time on one page, later sets get a -2, -3 suffix and the deep-link target stays the first set. Ungrouped sets get theme-generated IDs. The storage key is td-tabs:v1:<group>. A click or a key press updates the hash with replaceState and writes storage; arriving with a hash only switches. Left and right arrows (RTL-aware) plus Home/End move and activate, and focus stays on the tab.

Limits

  • Build failures: in the attribute form, value without group, group or value without tab, tab together with the numbering attribute num; in the shortcode form, a duplicate value in one set, a tabs with no tab child, running text between children, or a default that matches no child.
  • Grouping mistakes in the attribute form do not stop the build; they leave a warning in the browser console. A missing value drops group from the whole set, which degrades to a locally switching tab set with no hash, sync or persistence. A duplicate value skips the set entirely and those blocks stay titled blocks.
  • Fences and tables never merge into one set. To mix prose with code, use the shortcode form.
  • Tabs are not a disclosure. To fold away long output use > [!DETAILS] (see Callouts).
  • A group name is shared site-wide: a reader who picks pnpm on page A gets pnpm in the same group on page B. That is the point — and it means group names should mean something, not be tabs1.
  • Code blocks — the rest of the fence attributes (title, copy, line numbers, folding)
  • Tables — the rest of the table attribute line
  • Callouts — for folding rather than juxtaposing
  • Steps — tabs inside a procedure

4.5 - Tables

A plain GFM table plus one attribute line becomes a captioned table, a compatibility matrix, a field list, a numbered table or a tab set; wide tables scroll on their own.

A table is an ordinary GFM pipe table. The theme’s table render hook wraps every one in a horizontally scrollable region, and the {…} attribute line underneath decides which kind of table it is: captioned, a compatibility matrix, a field list, a numbered table, or a tab set. Merged cells, sorting and filtering are out of scope; when you need them, change how the data is presented.

Shortest form

Without an attribute line it is just a table. Alignment still comes from the delimiter row, and header cells are th scope="col".

Source
| Component | Port | Purpose |
| --- | :---: | --- |
| PostgreSQL | 5432 | Database |
| Pgbouncer | 6432 | Connection pool |
| Patroni | 8008 | High-availability orchestration |
Component Port Purpose
PostgreSQL 5432 Database
Pgbouncer 6432 Connection pool
Patroni 8008 High-availability orchestration

Wide tables scroll themselves

A table with too many columns never widens the page; it scrolls inside its own region. That region is focusable: Tab into it and the arrow keys scroll, and its accessible name is the localized “Scrollable table”.

Source
| Cluster | Role | Version | State | Lag | Connections | Size | Backup |
| --- | --- | --- | --- | --- | --- | --- | --- |
| pg-meta | primary | 18.1 | running | — | 42 | 12 GB | 2026-08-17 |
| pg-test | replica | 18.1 | streaming | 12 ms | 8 | 12 GB | 2026-08-17 |
Cluster Role Version State Lag Connections Size Backup
pg-meta primary 18.1 running 42 12 GB 2026-08-17
pg-test replica 18.1 streaming 12 ms 8 12 GB 2026-08-17

Captions

{caption="…"} adds a visible <caption>. It is plain text and it does not number the table.

Source
| Item | Value |
| --- | --- |
| Theme version | v0.6.0 |
| Hugo floor | 0.160.1 Extended |
| Licence | Apache-2.0 |
{caption="Theme facts this site currently builds against"}
Theme facts this site currently builds against
Item Value
Theme version v0.6.0
Hugo floor 0.160.1 Extended
Licence Apache-2.0

Compatibility matrices

{.matrix} is for “row × column = supported or not” tables: the first column becomes a row header (th scope="row"), the header row and the first column stay pinned while scrolling, and the remaining cells are centred unless the delimiter row says otherwise. ✅ and ❌ are characters the author writes; the theme does not interpret them.

Source
| OS / PG | PG18 | PG17 | PG16 | PG15 | PG14 |
| --- | :---: | :---: | :---: | :---: | :---: |
| EL 9 | ✅ | ✅ | ✅ | ✅ | ✅ |
| EL 8 | ✅ | ✅ | ✅ | ✅ | ✅ |
| Debian 13 | ✅ | ✅ | ✅ | ❌ | ❌ |
| Ubuntu 24.04 | ✅ | ✅ | ✅ | ✅ | ❌ |
{.matrix}
OS / PG PG18 PG17 PG16 PG15 PG14
EL 9
EL 8
Debian 13
Ubuntu 24.04

Using the whole canvas

{.full-width} lets a table exceed the reading column and take the full width the article has. It suits tables with many short columns.

Source
| Language | Code | Sidebar | Search | TOC | Print | Status |
| --- | --- | --- | --- | --- | --- | --- |
| 简体中文 | `zh` | ✅ | ✅ | ✅ | ✅ | Reviewed |
| English | `en` | ✅ | ✅ | ✅ | ✅ | Reviewed |
{.full-width}
Language Code Sidebar Search TOC Print Status
简体中文 zh Reviewed
English en Reviewed

Field lists

{.fields} turns a table into a definition list: the first column is the name, the last is the description, and the columns in between are metadata. It is the shape for configuration keys, command flags and API fields; the full syntax is on the Fields page.

Source
| Parameter | Type | Default | Description |
| --- | --- | --- | --- |
| `offline_search` | boolean | `false` | Build the local search index |
| `page_width` | string | `normal` | Width of the reading column |
{.fields meta="type default"}
offline_search , boolean , defaultfalse
Build the local search index
page_width , string , defaultnormal
Width of the reading column

Numbered tables

In a book or a long manual, number the tables: num plus an optional #id and caption. The table is wrapped in a <figure> labelled with a localized “Table N.” and registered as a Book target, so xref can reference it and it appears in the book-wide list of tables. The number is written by the author — the theme never counts — and id defaults to tbl-<num>.

Source
| Isolation level | Dirty read | Non-repeatable read | Phantom read |
| --- | --- | --- | --- |
| Read committed | no | yes | yes |
| Repeatable read | no | no | yes |
| Serializable | no | no | no |
{#tbl-iso num="9-1" caption="Anomalies each PostgreSQL isolation level permits"}

See {{< xref tbl="9-1" anchor="tbl-iso" >}}.
Isolation level Dirty read Non-repeatable read Phantom read
Read committed no yes yes
Repeatable read no no yes
Serializable no no no
Table 9-1 Anomalies each PostgreSQL isolation level permits

See Table 9-1.

Tables as tabs

Adjacent tables carrying {tab="…"} form a tab set under the same rules as adjacent fences: group on the first table enables hash, sync and persistence, and every table after it needs value. The complete rules are on the Tabs page.

Source
| Directory | Contents |
| --- | --- |
| `content/` | Pages |
| `data/` | Landing and release data |
{tab="Content" group="repo-layout" value="content"}

| Directory | Contents |
| --- | --- |
| `assets/` | SCSS and image resources |
| `static/` | Files copied verbatim |
{tab="Assets" value="assets"}
Content
Directory Contents
content/ Pages
data/ Landing and release data
Assets
Directory Contents
assets/ SCSS and image resources
static/ Files copied verbatim

Output

Output Shape
HTML A focusable <div class="td-table-scroll"> around the <table>; matrix and full-width are modifier classes on that wrapper
Print The complete table laid out to the page width; the wrapper stays but is marked td-table-scroll--static and is no longer a focusable viewport
Markdown The source table and its attribute line, emitted as written
RSS The complete static table

Tables load no script.

Parameter reference

The attribute line on the row below the table:

.full-width , marker , defaultnone
Exceed the reading column and use the article canvas
.matrix , marker , defaultnone
First column as row header, header and first column pinned, other cells centred
.fields , marker , defaultnone
Render as a definition list, see Fields
caption , plain text , defaultnone
Visible table caption; on .fields it labels the list
meta , role list , defaultnone
Names the meaning of the middle .fields columns: type required default -; requires .fields
#id , identifier , defaulttbl-<num> when num is set
[A-Za-z][A-Za-z0-9_.:-]*; lands on the <table>, or on the <figure> for a numbered table
num , string , defaultnone
[0-9A-Za-z.-]+; registers a Book table target and prefixes the caption with “Table N.”
tab / group / value , see Tabs , defaultnone
Adjacent tables become a tab set
class , class list , defaultnone
Left on the <table> for site CSS
data-* / aria-* , string , defaultnone
Passed through

style, on* and any other key fail the build.

Limits

  • Mutual exclusions: .fields cannot combine with .matrix, .full-width or num; num and tab are exclusive; group/value require tab; meta requires .fields.
  • The attribute line must touch the table: leave a blank line and it becomes a visible line of braces. Markdown formatters like to move it — wrap it in <!-- prettier-ignore-start --> / <!-- prettier-ignore-end -->.
  • No merged cells, no sorting, no filtering: what a GFM pipe table can express is all there is. Split a complex table with a merged header into two tables, or turn it into a matrix.
  • Block content does not fit in a cell: multi-paragraph descriptions, lists and fences need the fields/field shortcode.
  • .matrix centring is CSS: an explicit alignment in the delimiter row wins.
  • Fields — everything {.fields} can do
  • Tabs — adjacent tables as a tab set
  • Publishing books — numbered tables, cross references, the list of tables
  • Code blocks — where attributes go on the info line instead of the next line

4.6 - Fields

A plain table plus {.fields} documents configuration keys, command flags and API fields — name, type, default and description each in place, readable on a narrow screen, every entry individually linkable.

Fields render “a list of named values with metadata and a description” as a responsive definition list: the name gets its own line, type / required / default sit beside it as small chips, the description starts on the next line, and every entry carries its own anchor. Use it for configuration keys, command flags and API fields. When readers need to compare many rows across the same columns, keep a plain table; when the content is a sequence of actions, use steps.

There are two spellings: a plain table plus {.fields} (the default choice), and the fields/field shortcode, for when a description needs several paragraphs, a list or a code block. Both render the same entries.

Shortest form

A pipe table with at least two columns and {.fields} on the next line. The first column is the name, the last is the description, and every column in between is metadata labelled with its own header text.

Source
| Parameter | Type | Default | Description |
| --- | --- | --- | --- |
| `offline_search` | boolean | `false` | Build the local search index and enable the command palette |
| `offline_search_max_results` | integer | `10` | Maximum number of search results |
| `page_width` | string | `normal` | Reading column width: `narrow` `normal` `wide` |
{.fields}
offline_search , Typeboolean , Defaultfalse
Build the local search index and enable the command palette
offline_search_max_results , Typeinteger , Default10
Maximum number of search results
page_width , Typestring , Defaultnormal
Reading column width: narrow normal wide

Metadata here shows as “Header: value”. The theme infers nothing from the header — Type is only a label. The next section turns those into standard chips. Cells accept inline Markdown (code, emphasis, links) and empty middle cells are omitted.

Semantic columns with meta=

meta says, in order, what each middle column means: type, required, default, or - to keep the header as a plain label. With it, the table form renders the same chips as the shortcode form.

Source
| Parameter | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `baseURL` | string | yes | | Site address, subpath included |
| `title` | string | yes | | Site name, shown in the navbar and the tab |
| `defaultContentLanguage` | string | | `en` | Default language; decides which language unprefixed paths belong to |
{.fields meta="type required default"}
baseURL , string , required
Site address, subpath included
title , string , required
Site name, shown in the navbar and the tab
defaultContentLanguage , string , defaulten
Default language; decides which language unprefixed paths belong to

The rules:

  • meta must name a role for every middle column — exactly the column count minus two. Too many or too few fails the build.
  • A required column is “non-empty means true”: “yes”, “是” or “✔” all read the same, and the rendered chip is the untranslated required. An empty cell shows nothing.
  • type and default cells with no inline markup of their own are wrapped in code formatting, matching the shortcode form.
  • The three semantic chips always display in the order type, required, default, whatever order the columns are in; - columns follow, in column order.

- mixes with semantic roles, which is how you keep one custom label:

Source
| Environment variable | Type | Scope | Description |
| --- | --- | --- | --- |
| `HUGO_MODULE_WORKSPACE` | string | build | Points at `go.work` so the theme resolves from a local checkout |
| `HUGO_ENV` | string | build | Set to `production` to enable minification and fingerprinting |
{.fields meta="type -"}
HUGO_MODULE_WORKSPACE , string , Scopebuild
Points at go.work so the theme resolves from a local checkout
HUGO_ENV , string , Scopebuild
Set to production to enable minification and fingerprinting

Labels and container IDs

caption gives the whole list a visible label, which is also its accessible name; id names the outer container so it can be linked to or styled.

Source
| Parameter | Type | Default | Description |
| --- | --- | --- | --- |
| `enable` | boolean | `false` | Turn image zoom on |
| `selector` | string | `.td-content` | Root selector scanned for candidate images |
{.fields caption="params.ui.image_zoom" id="zoom-params" meta="type default"}

params.ui.image_zoom

enable , boolean , defaultfalse
Turn image zoom on
selector , string , default.td-content
Root selector scanned for candidate images

Every entry is linkable

Each entry gets an anchor of the form field-<name>, and a self-link icon appears beside the name on hover. page_width in the first table above is #field-page_width — a link you can send on its own when answering a question.

Duplicate names on one page get -2, -3 suffixes, the same rule Goldmark uses for duplicate headings. Anchors are generated in HTML only: print and RSS assemble many pages into one document, where in-page anchors would collide.

The shortcode form

When the description needs several paragraphs, a list or a code block, a table cell cannot hold it. Use fields/field:

Source
{{< fields label="Common pig flags" >}}
{{< field name="--config" type="path" required=true >}}
Path to the configuration file. Relative paths resolve against the working
directory.

When `PIG_CONFIG` is also set, the command-line flag wins.
{{< /field >}}
{{< field name="--log-level" type="string" default="info" >}}
Log level, from low to high:

- `debug`: print every remote call
- `info`: the default
- `error`: output only on failure
{{< /field >}}
{{< field name="--dry-run" type="boolean" default=false >}}
Print what would happen and change nothing:

```bash
pig ext install pg_duckdb --dry-run
```
{{< /field >}}
{{< /fields >}}

Common pig flags

--config , path , required

Path to the configuration file. Relative paths resolve against the working directory.

When PIG_CONFIG is also set, the command-line flag wins.

--log-level , string , defaultinfo

Log level, from low to high:

  • debug: print every remote call
  • info: the default
  • error: output only on failure
--dry-run , boolean , defaultfalse

Print what would happen and change nothing:

pig ext install pg_duckdb --dry-run

required=true and default=false are booleans and take no quotes. default accepts any scalar: default=0 and default="" both display faithfully (the empty string shows as ""), and omitting default omits the chip. Every field needs a non-empty body and must be a direct child of fields.

Which form to use

Situation Use
One-sentence descriptions that fit in a table cell table + {.fields}
Descriptions with paragraphs, lists or code blocks the fields/field shortcode
Readers comparing many rows across the same columns a plain table, not a field list
Content that is a sequence of actions Steps

The table form stays a readable table on GitHub, and OINK’s Markdown output keeps it as a table. That is why it is the default.

Output

Output Shape
HTML <div class="td-fields"> around a semantic <dl>; entries carry #field-<name> anchors and self-links
Print The complete definition list, without entry anchors
Markdown The table form keeps the source table; the shortcode form emits a bulleted list of “name — type; required; default: value” plus the indented description
RSS The complete static <dl>, without entry anchors

No script is loaded.

Parameter reference

The table attribute line, on the row below the table:

.fields , marker , defaultnone
Required; renders the table as a field list
meta , role list , defaultnone
Space-separated type required default -; one per middle column; semantic roles cannot repeat
caption , plain text , defaultnone
Visible label and the list’s accessible name
id , identifier , defaultnone
ID of the outer container
class , class list , defaultnone
Passed through for site CSS
data-* / aria-* , string , defaultnone
Passed through

The fields shortcode:

label , non-empty string , required
Visible label; the same thing the table’s caption does
id , identifier , required
Container ID; no whitespace, quotes, <, > or &
class / data-* / aria-* , string , required
The same policy as the table attribute line

The field shortcode:

name , non-empty string , required
The field name
type , non-empty string , required
Type label such as boolean, string[], duration
required , boolean , required
true shows the untranslated required chip; defaults to false
default , scalar , required
String / boolean / integer / float; false, 0 and "" all display

Limits

  • The first column must be non-empty and unique within one table; a duplicate or an empty name fails the build.
  • .fields cannot combine with .matrix, .full-width or num, and meta cannot appear on a table without .fields.
  • Block content does not fit in a table cell: paragraphs, lists and fences need the shortcode form.
  • required and default are untranslated API vocabulary and stay in English in every language. They are contract words, not interface copy.
  • No kind, since, deprecated, location, per-field links or nested structures, and nothing parses TypeScript or an OpenAPI schema at build time.
  • Tables — the rest of the attribute line and the exclusion rules
  • Configuration — the full site parameter table, itself a field list
  • Front matter — the full front matter table
  • Steps — ordered actions do not belong in a field list

4.7 - Steps

An ordered list plus {.steps} becomes a numbered procedure with dots and a connecting rule; switch to the steps shortcode when each step needs a heading in the table of contents.

Steps are an ordered list with numbered dots and a rule running through them: a plain ordered list plus a {.steps} marker line. The dots and the rule are drawn in CSS and no script is loaded. Use it for procedures that have an order. Parallel items with no order belong in a plain list or in cards.

There are two spellings: an ordered list plus {.steps} (the default choice), and the {{% steps %}} shortcode, for when each step needs its own heading and those headings belong in the table of contents.

Shortest form

Write 1. for every item and let Markdown do the counting. Inserting, deleting and reordering steps then needs no renumbering, and the content indent is always three spaces.

Source
1. Install Hugo Extended
1. Clone the documentation site
1. Start the local preview
{.steps}
  1. Install Hugo Extended
  2. Clone the documentation site
  3. Start the local preview

{.steps} must touch the last line of the list; leave a blank line and it turns into a visible line of braces.

What goes in a step

A list item takes any block content: paragraphs, fenced code, callouts, tables, nested lists, images. Indent it to the item’s content column — three spaces.

Source
1. Clone the documentation site; it is itself a complete example of the theme.

   ```bash
   git clone https://github.com/pgsty/oink.pgsty.com my-docs
   cd my-docs
   ```

1. Start the local server.

   ```bash
   hugo server
   ```

   > [!NOTE]
   > The first build fetches the theme through the Go module proxy, which needs
   > Go on the machine.

1. Replace three things and it is your site.

   | Where | Replace with |
   | --- | --- |
   | `title` in `hugo.yml` | your site name |
   | `baseURL` in `hugo.yml` | your domain |
   | `content/` | your content |
{.steps}
  1. Clone the documentation site; it is itself a complete example of the theme.

    git clone https://github.com/pgsty/oink.pgsty.com my-docs
    cd my-docs
  2. Start the local server.

    hugo server
    Note

    The first build fetches the theme through the Go module proxy, which needs Go on the machine.

  3. Replace three things and it is your site.

    Where Replace with
    title in hugo.yml your site name
    baseURL in hugo.yml your domain
    content/ your content

Shortcodes in {{< … >}} form — tabs, cards, badges — work inside a list item too. The {{% … %}} form does not; see Limits.

Splitting one step per platform

When one step differs per platform, write the {tab=} fences side by side inside that list item and they still assemble into a tab set.

Source
1. Install Hugo Extended.

1. Install the dependencies:

   ```bash {tab="EL / RHEL" group="stepdemo" value="rpm"}
   sudo dnf install golang git
   ```
   ```bash {tab="Debian / Ubuntu" value="deb"}
   sudo apt install golang-go git
   ```

1. Run `hugo server` to preview.
{.steps}
  1. Install Hugo Extended.

  2. Install the dependencies:

    EL / RHEL
    sudo dnf install golang git
    Debian / Ubuntu
    sudo apt install golang-go git
  3. Run hugo server to preview.

Continuing the numbering

When prose interrupts a procedure, write the first item of the next group with its real number. Markdown emits start and the numbering continues from there (up to 40).

Source
4. Configure `baseURL` and the deployment workflow.
1. Push to `main` and wait for GitHub Actions to finish.
{.steps}
  1. Configure baseURL and the deployment workflow.
  2. Push to main and wait for GitHub Actions to finish.

Steps with headings

When the procedure is long and each step deserves a heading that can be linked to and collected by the table of contents, use {{% steps %}}: its body is page-level Markdown, every direct child heading is one step, and the body is not indented. The three headings below appear in this page’s table of contents.

Source
{{% steps %}}

### Install the toolchain {#install-toolchain}

You need Hugo Extended ≥ 0.160.1 and Go.

### Run the server {#run-server}

{{< tabs group="oink-os" default="macos" >}}
{{< tab label="macOS" value="macos" >}}
`brew install hugo go`
{{< /tab >}}
{{< tab label="Debian" value="debian" >}}
`sudo apt install hugo golang-go`
{{< /tab >}}
{{< /tabs >}}

### Publish {#publish}

Push to `main`; the workflow the repository ships builds and publishes.

{{% /steps %}}

Install the toolchain

You need Hugo Extended ≥ 0.160.1 and Go.

Run the server

brew install hugo go

sudo apt install hugo golang-go

Publish

Push to main; the workflow the repository ships builds and publishes.

This is the theme’s only {{% … %}} shortcode. The percent form hands its body to Goldmark as page-level Markdown, which is the only way its headings can reach the table of contents and the only way container shortcodes such as tabs, cards and fields can live inside it. The price is that it cannot nest inside a list item or inside another percent container.

Keep the headings of one procedure at one level, and never nest one steps inside another.

Which form to use

Situation Use
A step is a sentence or two plus a command ordered list + {.steps}
Each step needs a heading, a link and a place in the TOC {{% steps %}}
A step must contain a tabs, cards or fields container {{% steps %}}
The procedure itself has to nest inside another list item ordered list + {.steps}

Output

Output Shape
HTML The native form is <ol class="steps"> with numbers and rule drawn in CSS; the shortcode form is <div class="td-steps"> plus headings
Print Numbers and content unchanged, the rule stays
Markdown The source as written: an ordered list plus {.steps}, or headings plus bodies
RSS A static list or titled sections

No script; with JavaScript off nothing changes.

Parameter reference

Neither form takes parameters — only conventions:

{.steps} , Whereline below the ordered list
Required; has no effect on an unordered list
1. , Whereevery item
Let Markdown count; the content indent is always three spaces
4. (first item) , Wherefirst item
Emits <ol start="4"> and continues from 4; supported for 2–40
{{% steps %}} , Wherearound a set of headings
Direct child headings (########) are the steps; the body is not indented

Limits

  • No {{% … %}} inside a list item: the multi-line output of a percent shortcode truncates the list. To put a container in a step, switch the whole procedure to the shortcode form.
  • {{% steps %}} cannot go inside a list item, nor inside another percent container.
  • The marker must touch the list: no blank line between the list and {.steps}. Wrap it in <!-- prettier-ignore-start --> / <!-- prettier-ignore-end --> when a formatter like Prettier is in play.
  • {.steps} applies to ordered lists only: on a - list there are no numbers.
  • Steps do not fold and do not track progress: no “done” state, no expanding or collapsing.
  • Tabs — commands split per platform
  • Callouts — prerequisites and warnings inside a step
  • Code blocks — the commands in a step
  • Cards — “what next” once the procedure is done

4.8 - Cards

A link list plus {.cards} lays out a grid of navigation cards; switch to the shortcode when you need icons, badges or images.

Cards are a set of parallel links: each card is a linked title plus a sentence, and the grid adapts to the container width. They suit section landing pages, “what to read next”, and a handful of parallel entry points. They do not suit running prose (use paragraphs) or a wall of images (use a gallery).

Shortest form

A link list with {.cards} is a card grid. The link is the title; whatever follows is the description.

Source
- [Get started](/docs/start/) — Clone this documentation site, delete what you do not need, replace the site details with your own.
- [Authoring](/docs/write/) — How pages are organized and which front matter keys exist.
- [Customization](/docs/customize/) — Navigation, search, branding, languages.
{.cards}
  • Get started — Clone this documentation site, delete what you do not need, replace the site details with your own.
  • Authoring — How pages are organized and which front matter keys exist.
  • Customization — Navigation, search, branding, languages.

The whole card is the click target, not just the title text. There is no columns parameter: the column count follows the container width and collapses to one on a narrow screen.

Title-only cards

The description is optional. One link per line, {.cards} at the end.

Source
- [Callouts](/docs/components/callout/)
- [Tabs](/docs/components/tabs/)
- [Steps](/docs/components/steps/)
- [Fields](/docs/components/fields/)
{.cards}

Loose lists and longer descriptions

When a sentence is not enough, switch to a loose list: the link is its own paragraph, the description another, with a blank line between items. The title takes its own line and the description sits under it. {.cards} still has to touch the last paragraph — no blank line in between.

Source
- [Front matter](/docs/write/frontmatter/)

  Every page parameter is defined here exactly once: type, default, accepted
  values, and the page that explains it.

- [Configuration](/docs/customize/config/)

  Site parameters grouped by feature, each row linking back to the guide that
  explains it.
{.cards}
  • Front matter

    Every page parameter is defined here exactly once: type, default, accepted values, and the page that explains it.

  • Configuration

    Site parameters grouped by feature, each row linking back to the guide that explains it.

Icons and badges

A link list has no icons, badges, images or multi-paragraph descriptions; those need the cards / card shortcode. icon is exactly one Font Awesome class pair and badge is plain text.

Source
{{< cards >}}
{{< card title="Get started" link="/docs/start/" icon="fa-solid fa-rocket" badge="start here" >}}
Fork the documentation site itself and get a local preview in ten minutes.
{{< /card >}}
{{< card title="Release and download pages" link="/docs/write/releases/" icon="fa-solid fa-box-open" badge="v0.5" >}}
A `release` fact record, an asset table and checksums — all generated locally.
{{< /card >}}
{{< card title="Keyboard navigation" link="/docs/customize/keyboard/" icon="fa-solid fa-keyboard" >}}
Site-wide shortcuts and focus order.
{{< /card >}}
{{< /cards >}}
Get startedstart here

Fork the documentation site itself and get a local preview in ten minutes.

An icon that is not a fa-solid fa-xxx style class pair fails the build rather than being dropped silently.

Markdown bodies

A card body renders as page-level Markdown: inline code, emphasis, links, lists. Parameters such as title and badge are plain text and are not parsed as Markdown.

Source
{{< cards >}}
{{< card title="Hugo Module" icon="fa-brands fa-golang" >}}
`hugo mod get github.com/pgsty/oink`. The recommended way; upgrading is one
version line.
{{< /card >}}
{{< card title="Git submodule" icon="fa-solid fa-code-branch" >}}
No Go installation needed:

- `git submodule add`
- the theme lands in `themes/oink`
{{< /card >}}
{{< /cards >}}
Hugo Module

hugo mod get github.com/pgsty/oink. The recommended way; upgrading is one version line.

Git submodule

No Go installation needed:

  • git submodule add
  • the theme lands in themes/oink

A card without link renders as a bold title and produces no link.

Cards with images

image resolves in the same order as ![alt](src): page resource → global resource in assets/ → static path /images/… → remote URL. Local resources carry their intrinsic size so nothing shifts while loading.

image must be paired with a source of alternative text: image_alt="…" for an informative image, or decorative=true for a purely decorative one. Writing both, or neither, fails the build.

Source
{{< cards >}}
{{< card title="The OINK shell" link="/docs/about/features/" image="/images/oink.webp" image_alt="An OINK documentation page: sidebar, article and table of contents" >}}
Sidebar, article, table of contents — each can be turned off on its own.
{{< /card >}}
{{< card title="Release notes" link="/docs/write/releases/" image="/images/releasenote.webp" decorative=true >}}
A decorative cover: `decorative=true` emits an empty alt and screen readers skip it.
{{< /card >}}
{{< /cards >}}
An OINK documentation page: sidebar, article and table of contents

Sidebar, article, table of contents — each can be turned off on its own.

A decorative cover: decorative=true emits an empty alt and screen readers skip it.

Card images do not take part in image zoom — the whole card is already a link.

Automatic cards on section pages

A section landing page (_index.md) needs no hand-written card list: the theme reads each child page’s title, description and icon and generates the cards. This site turns it on globally in hugo.yml:

hugo.yml
params:
  ui:
    section_index: cards # list | cards

One section can override it in its own front matter, or push the choice down a whole subtree with cascade:

content/docs/customize/_index.md
section_index: list

Automatic and hand-written cards share the td-content-card styling; only the data source differs. Do not hand-write a list of child pages on a section page — it drifts out of step with the sidebar. Hand-write cards only when the set is not this section’s children (external links mixed in, cross-section recommendations). The keys are defined in Configuration.

Which form to use

What you want Which form
A grid of links with one-sentence descriptions {.cards} link list
Icons, badges, images cards / card shortcode
Lists, code or several paragraphs in the description cards / card shortcode
A card with no link cards / card shortcode
This section’s child pages nothing at all — section_index: cards

A link list is still a link list on GitHub; a shortcode is not. Use the native form whenever it is enough.

Output

Output Shape
HTML Native form: <ul class="cards">. Shortcode form: <div class="td-content-cards"> with one <article class="td-content-card"> each. Both are pure CSS grids and load no script
Print The native form stacks; the shortcode form collapses to two columns; in both, a card avoids breaking across pages
Markdown The native form keeps the link list; the shortcode form emits - [Title](link) (badge) — description
RSS The same markup as HTML — a readable list of links without site CSS

Parameter reference

The native form:

{.cards} , list attribute line , default
On the line after an unordered list; unordered lists only
First link in an item , Markdown link , default
The card title and the whole card’s click target
Everything else , Markdown , default
The description: after in a tight list, its own paragraph in a loose one

card parameters (cards itself takes none):

title , plain text , default
Required, non-empty. The card title
link , URL , default
Site path, relative path, http(s):, mailto:; external links get rel="noopener"
icon , Font Awesome class pair , default
For example fa-solid fa-rocket; a malformed value fails the build
badge , plain text , default
A small label beside the title
image , image source , default
Page resource / global resource / static path / remote URL
image_alt , plain text , default
With image, exactly one of this and decorative
decorative , boolean , defaultfalse
true marks a decorative image and emits an empty alt
Body , Markdown , default
The card description

There is no cols, columns, accent, desc or color parameter, and any unknown parameter fails the build.

Limits

  • {.cards} recognizes unordered lists only: on an ordered list it does nothing.
  • {.cards} must touch the list: a blank line in between, or indenting it into a list item, drops the marker silently — the build succeeds and the list stays a list. Check that line first when the output is not a card grid.
  • A card lives only inside cards: alone, or inside another shortcode, it fails the build and the error names the location.
  • The column count is not configurable: the grid adapts to the container. Only automatic section cards take a count, through params.ui.section_index_columns.
  • Cards are not for long text: when a description runs past two lines, use a paragraph or a callout.

4.9 - FileTree

A filetree fence draws an annotated directory structure — aligned comment column, per-entry icons, collapsible directories, a draggable split.

A file tree is a filetree fence whose body is the listing itself: indentation is depth, a trailing / marks a directory, and everything after # is a comment. Use it to explain the part of a directory structure that concerns the reader, one annotation at a time. When the reader has to copy the listing verbatim, use an ordinary code block.

Shortest form

Source
```filetree
- content/
  - _index.md
  - docs/
  - blog/
- hugo.yml
- go.mod
```
  • content/
    • _index.md
    • docs/
    • blog/
  • hugo.yml
  • go.mod

Bullets (-, *, +) may be omitted; the result is the same. An entry with children is a directory. Without children, a trailing / tells the theme it is one.

Adding comments

Everything after the first whitespace-preceded # on a line is a comment, rendered as an aligned right-hand column. Comments are plain text, so Markdown inside them shows literally; for a literal hash write \#.

Source
```filetree
- content/          # every page, both languages in one directory
  - docs/          # the documentation tree you are reading
  - blog/           # release notes and articles
- assets/scss/      # the site's own SCSS, overriding theme variables
- layouts/          # site-level template overrides, the fewer the better
- static/images/    # images that need no build-time processing
- hugo.yml         # site configuration: languages, menus, params.ui
```
  • content/every page, both languages in one directory
    • docs/the documentation tree you are reading
    • blog/release notes and articles
  • assets/scss/the site's own SCSS, overriding theme variables
  • layouts/site-level template overrides, the fewer the better
  • static/images/images that need no build-time processing
  • hugo.ymlsite configuration: languages, menus, params.ui

Where the comment column starts is computed at build time from the widest row, so every # begins at the same column whether or not the source lines up. The comment column takes at most the right half of the panel and at least three tenths. The dashed rule between them is a splitter you can drag, or focus with Tab and move with the arrow keys (Home / End go to the extremes).

Overlong names and comments are truncated with an ellipsis inside their own column, and hovering shows the full text through title. The splitter is the file tree’s only JavaScript, and only a tree with comments loads it.

Source
```filetree {title="truncation in both columns"}
- runbooks/
  - a-deliberately-long-runbook-filename-for-a-failover-drill.md  # an equally overlong comment, kept on one line so it has to be clipped inside the comment column
  - restart.md                                                    # short
```

truncation in both columns

  • runbooks/
    • a-deliberately-long-runbook-filename-for-a-failover-drill.mdan equally overlong comment, kept on one line so it has to be clipped inside the comment column
    • restart.mdshort

Title bars

The fence attribute {title="…"} renders a title bar above the tree; without it there is none.

Source
```filetree {title="the oink.pgsty.com repository root"}
- content/          # pages
- assets/           # resources that take part in the build
- data/             # data for the home page, landings and downloads
- layouts/          # template overrides
- static/           # files copied verbatim
- tests/            # Playwright and node --test
- hugo.yml
- go.mod            # the theme, imported as a Hugo Module
- Makefile          # make d / make b / make c
```

the oink.pgsty.com repository root

  • content/pages
  • assets/resources that take part in the build
  • data/data for the home page, landings and downloads
  • layouts/template overrides
  • static/files copied verbatim
  • tests/Playwright and node --test
  • hugo.yml
  • go.modthe theme, imported as a Hugo Module
  • Makefilemake d / make b / make c

Indentation and depth

Depth comes from indentation. Two spaces, four spaces, or tabs (counted as four columns) all work and need not be consistent within one tree, as long as every level you return to has been opened before. Output from the tree command can be pasted whole, root line and summary line included — the summary is dropped.

Source
```filetree
content/docs
├── about
│   ├── _index.md
│   └── features.md
├── components
│   ├── filetree.md
│   └── image
│       └── index.md
└── _index.md

3 directories, 5 files
```
  • content/docs
    • about
      • _index.md
      • features.md
    • components
      • filetree.md
      • image
        • index.md
    • _index.md

Returning to an indentation level that was never opened fails the build, and the error carries the line number inside the fence.

Folding and explicit types

A directory with children is open by default; {open=false} starts it closed. Directories render as native <details>, so they are keyboard-operable without JavaScript. open is valid on directories only. An entry with no children whose name does not end in / is treated as a file; {type=dir} overrides that, and {type=file} the other way.

Source
```filetree {title="the content directory"}
- content/
  - docs/                # the documentation tree
    - components/         # 22 component pages    {open=false}
      - callout.md
      - filetree.md
      - image/            # page bundle: body + images  {type=dir}
    - customize/          # site-level configuration    {open=false}
      - config.md
  - blog/
    - release.md
```

the content directory

  • content/
    • docs/the documentation tree
      • components/22 component pages
        • callout.md
        • filetree.md
        • image/page bundle: body + images
      • customize/site-level configuration
        • config.md
    • blog/
      • release.md

Icons and tones

Icons are inferred from the name: directories get a folder icon that follows the open state; files are matched first by full filename (LICENSE, Makefile, go.mod, package.json, .gitignore …), then by extension (md yml toml json sh py go js sql css png svg pdf zip …), and otherwise get a generic file icon.

{icon=…} overrides it and takes exactly one Font Awesome class pair. {tone=…} colours the icon, using the same vocabulary as badges: neutral info success warning danger.

Source
```filetree {title="deployment layout: permissions and what matters"}
- /etc/pigsty/                 # 0755 root:root · configuration root        {icon="fa-solid fa-server" tone=info}
  - pigsty.yml                 # 0644 root:root · cluster inventory
  - ca/                        # 0700 root:root · self-signed CA, never commit  {icon="fa-solid fa-lock" tone=danger open=false}
    - ca.key                   # 0600 root:root
- /var/lib/pgsql/18/data/      # 0700 postgres:postgres · data directory    {tone=warning}
  - postgresql.conf            # 0600 postgres:postgres
- /usr/bin/pig                 # 0755 root:root · command-line tool         {icon="fa-solid fa-terminal" tone=success}
```

deployment layout: permissions and what matters

  • /etc/pigsty/0755 root:root · configuration root
    • pigsty.yml0644 root:root · cluster inventory
    • ca/0700 root:root · self-signed CA, never commit
      • ca.key0600 root:root
  • /var/lib/pgsql/18/data/0700 postgres:postgres · data directory
    • postgresql.conf0600 postgres:postgres
  • /usr/bin/pig0755 root:root · command-line tool

tone colours the icon only, never the text. Colour is a supplement; the meaning belongs in the name or the comment.

Write an entry name as [name](link) to make it a link. Site paths, relative paths and http(s): all work, under the same URL validation as every other component.

Source
```filetree {title="this site's component pages"}
- content/docs/
  - [callout.md](/docs/components/callout/)     # callouts
  - [filetree.md](/docs/components/filetree/)   # this page
  - [gallery.md](/docs/components/gallery/)     # galleries
  - image/                                      # page bundle
    - [index.md](/docs/components/image/)       # images
- [hugo.yml](https://github.com/pgsty/oink/blob/main/tests/site/hugo.yaml)   # fixture configuration on GitHub
```

this site's component pages

One tree per platform

A fence carrying tab= (and group= / value=) becomes one panel of a tab set and can sit alongside code fences.

Source
```filetree {tab="Linux" group="platform" value="linux"}
- /etc/pigsty/          # configuration
- /var/lib/pgsql/       # data
- /usr/bin/pig          # executable
```
```filetree {tab="macOS" value="macos"}
- ~/Library/Application Support/pigsty/   # configuration
- /opt/homebrew/bin/pig                   # executable
```
Linux
  • /etc/pigsty/configuration
  • /var/lib/pgsql/data
  • /usr/bin/pigexecutable
macOS
  • ~/Library/Application Support/pigsty/configuration
  • /opt/homebrew/bin/pigexecutable

Output

Output Shape
HTML <div class="td-filetree">, an optional title bar, directories as native <details>; a tree with comments also gets the draggable splitter, its only runtime
Print The same tree, fully expanded, no splitter, comments wrapped instead of truncated
Markdown The filetree fence, emitted as written
RSS The fence source inside a <pre>

Below the sm breakpoint the layout collapses to a single column: comments move under the name, stop being truncated, and the splitter is hidden. A tree without comments is single-column and loads no script at all.

Parameter reference

Fence attributes, after ```filetree:

title , plain text , default
Title bar above the tree; omitted when absent; must not be empty
tab , plain text , default
Makes this tree one panel of a tab set
group / value , string , default
Tab group and sync value; must appear with tab
class , class list , default
Passed through for site CSS

Entry attributes, in the {…} at the end of a line:

icon , Font Awesome class pair , defaultmatched by name / extension
For example fa-solid fa-lock; a malformed value fails the build
tone , enum , defaultneutral
neutral info success warning danger; colours the icon only
open , boolean , defaulttrue
Directories only; false starts it closed
type , enum , defaultinferred
dir or file, overriding the inference

The line syntax itself:

Indentation
Two spaces / four spaces / tabs / the │ ├── └── drawing from tree
- name
The bullet is optional; -, * and + are equivalent
name/
A trailing slash marks a directory; the name renders as written, slash kept
[name](url)
A linked entry
# comment
Everything after the first whitespace-preceded #; \# is a literal hash
N directories, M files
The tree summary line, dropped automatically

Unknown attributes, unknown values, open on a file, a malformed {…}, and returning to an indentation level that was never opened all fail the build with the line number inside the fence.

Limits

  • The filetree fence is the only form: there is no {.filetree} list marker and no shortcode.
  • Names and comments are plain text: **bold** shows literally, so the fence source reads correctly anywhere.
  • Nothing is read from disk: the tree is static content you write or paste, and it does not follow the repository.
  • No search, no multi-select, no copy-the-whole-tree: when the reader has to copy it verbatim, use a code block.
  • The split position is not persisted: after a reload it returns to the width computed at build time.
  • Code blocks — listings meant to be copied verbatim
  • Tabs — one tree per platform, side by side
  • Badgestone uses the same vocabulary
  • Organizing content — how a real content directory is laid out

4.10 - Math

Inline and display mathematics with KaTeX, rendered at build time — the reader downloads no script.

Mathematics is rendered by KaTeX at build time into HTML + MathML. A page with formulas gains one local KaTeX stylesheet and nothing else — no JavaScript, no request to a remote maths service. Inline formulas are \(…\), display formulas are $$…$$ or \[…\], and there are math and chem fences. For TikZ drawings or macro packages KaTeX does not support, use a pre-rendered image.

Shortest form

An inline formula sits inside a sentence, with the surrounding spaces and punctuation outside the delimiters.

Source
The shared buffer hit ratio is \(\mathrm{hit} = \frac{H}{H + R}\), where \(H\) is `blks_hit` and \(R\) is `blks_read`.

The shared buffer hit ratio is hit=HH+R\mathrm{hit} = \frac{H}{H + R}, where HH is blks_hit and RR is blks_read.

Display formulas

A formula in its own paragraph goes between $$, centred and set larger. \[…\] is equivalent.

Source
A B-tree with fan-out \(f\) over \(N\) keys has height:

$$
h = \left\lceil \log_{f} N \right\rceil
$$

A B-tree with fan-out ff over NN keys has height:

h=logfN h = \left\lceil \log_{f} N \right\rceil

A formula too long for one line scrolls horizontally inside the reading column rather than widening the layout; in print it stays static.

The math fence

The math fence is another way to write a display formula, and it does not depend on the site’s passthrough configuration. On GitHub the source is an ordinary code block.

Source
```math
N_{\text{conn}} = \lambda \cdot \bar{t}_{\text{resp}}
```
Nconn=λtˉrespN_{\text{conn}} = \lambda \cdot \bar{t}_{\text{resp}}

That is Little’s law applied to a connection pool: in steady state, the concurrency you need is the arrival rate times the mean response time. A pool is usually far smaller than the number of clients.

Chemistry and units

The chem fence uses KaTeX’s mhchem extension, and its body is written \ce{…}. The same extension typesets physical units.

Source
```chem
\ce{CO2 + H2O <=> H2CO3 <=> H+ + HCO3^-}
```
COX2+HX2OHX2COX3HX++HCOX3X\ce{CO2 + H2O <=> H2CO3 <=> H+ + HCO3^-}

For the syntax see the mhchem manual.

Numbered equations

An attribute line under a display formula makes it a numbered equation. num is a string the author writes (3-1, 5.3) — the theme never counts — and #id defaults to eq-<num>. The number shows to the right of the formula with a localized “Equation” prefix.

Source
$$
\text{WAL}_{\text{day}} \approx \text{TPS} \times \bar{s}_{\text{record}} \times 86400
$$
{#eq-wal num="3-1" caption="Estimating daily WAL volume"}

See [Equation 3-1](#eq-wal): multiply by the retention period for the floor on archive disk size.
WALdayTPS×sˉrecord×86400 \text{WAL}_{\text{day}} \approx \text{TPS} \times \bar{s}_{\text{record}} \times 86400
Equation 3-1 Estimating daily WAL volume

See Equation 3-1: multiply by the retention period for the floor on archive disk size.

caption (plain text) is optional. #id and caption must appear with num — there is no half-numbered equation. A duplicate ID on one page, or one number pointing at two IDs, fails the build.

Cross references

The prose can reference a numbered equation with an ordinary link, as the previous section does. For a cross-page reference, or when the “Equation N” label should be filled in automatically, use xref:

Source
Capacity planning starts from {{< xref eq="3-1" anchor="eq-wal" />}}.

Capacity planning starts from Equation 3-1.

xref may appear before its target; forward references are legal. For a book-wide list of equations and the book-equations index, see publishing books.

The eq shortcode

eq exists for sites that cannot enable passthrough; its body goes to the same KaTeX renderer. Without parameters it is a display formula that registers no number; with num it is equivalent to the attribute-line form above.

Source
{{< eq >}}\sigma_{\text{idx}} = \frac{\text{rows}_{\text{matched}}}{\text{rows}_{\text{total}}}{{< /eq >}}

{{< eq num="3-2" caption="Where a sequential scan and an index scan cost the same" >}}
c_{\text{seq}} \cdot P = c_{\text{rand}} \cdot \sigma \cdot T
{{< /eq >}}
σidx=rowsmatchedrowstotal\sigma_{\text{idx}} = \frac{\text{rows}_{\text{matched}}}{\text{rows}_{\text{total}}}
cseqP=crandσTc_{\text{seq}} \cdot P = c_{\text{rand}} \cdot \sigma \cdot T
Equation 3-2 Where a sequential scan and an index scan cost the same

This site has passthrough on, so day-to-day writing uses $$. eq is for migrated manuscripts and for sites that cannot change hugo.yml.

Site prerequisites

The math and chem fences need no configuration. The $$, \[…\] and \(…\) delimiters depend on Goldmark’s passthrough extension. Hugo does not merge a theme’s markup configuration, so this block has to live in the site’s own configuration file. This site uses:

hugo.yml
markup:
  goldmark:
    parser:
      attribute:
        block: true # numbered equations need the attribute line
    extensions:
      passthrough:
        enable: true
        delimiters:
          block: [['\[', '\]'], ['$$', '$$']]
          inline: [['\(', '\)']]

Every key is defined in Configuration. Delimiters must not collide with the prose: a single $ is deliberately not configured, so a price like “$5” is never read as mathematics.

Output

Output Shape
HTML KaTeX HTML + MathML rendered at build time; this page also loads a local katex.min.css, which pages without formulas never load
Print Same as HTML, static, long formulas do not scroll
Markdown The source as written: $$ blocks with their attribute line, math / chem fences, \(…\); the eq shortcode emits **Equation 3-2.** caption plus a $$ block
RSS The same static text as Markdown

No form loads JavaScript.

Parameter reference

Four spellings:

\(…\) , Placementinline
Governed by the site’s passthrough configuration; takes no attributes
$$…$$ / \[…\] , Placementdisplay
As above; may be followed by an attribute line to become numbered
```math , Placementdisplay fence
Independent of passthrough; takes no attributes
```chem , Placementdisplay fence
As above, with \ce{…} in the body

The attribute line {…} under a display formula:

num , string , default
[0-9A-Za-z.-]+; registers a numbered equation and shows “Equation N” at the right
#id , identifier , defaulteq-<num>
[A-Za-z][A-Za-z0-9_.:-]*; the anchor and cross-reference target
caption , plain text , default
Caption after the number; requires num

The eq shortcode:

num , string , default
As above; without it the formula is an unnumbered display formula
id , identifier , defaulteq-<num>
Requires num
caption , plain text , default
Requires num
class , class list , default
Requires num; passed through for site CSS
Body , TeX , default
Required, non-empty

Broken TeX — an unknown command, unbalanced braces — fails the build, and the error carries KaTeX’s message and the source position.

Limits

  • Delimiters are a site decision: whether $$, \[…\] and \(…\) render depends solely on the passthrough extension in the site’s markup.goldmark. The theme does not read a math: true front matter key, and without the configuration $$ shows literally. The math fence and eq route around it.
  • Only $$ blocks and eq can be numbered: the math fence takes no attribute line, so switch spelling when you need a number.
  • Numbers are hand-written: the theme neither counts nor renumbers, so reordering chapters means editing num.
  • Inline formulas take no attributes: the attribute line applies to display formulas only.
  • caption is plain text: Markdown inside it is not parsed.

4.11 - Mermaid

A mermaid fence turns text into flowcharts, sequence diagrams, Gantt charts, class diagrams and state diagrams — rendered locally, theme-aware, diff-friendly.

A mermaid fence renders text as a flowchart, sequence diagram, Gantt chart, class diagram, ER diagram or state diagram. The diagram exists as source: it goes into Git, it reviews as a diff, and search finds it. Rendering happens in the reader’s browser with the Mermaid copy the theme ships — no external service is contacted. Diagrams that need pixel-level control belong in an SVG, used as an image.

Shortest form

Source
```mermaid
flowchart LR
  content["content/"] --> Hugo
  config["hugo.yml"] --> Hugo
  theme["OINK theme"] --> Hugo
  Hugo --> site["public/"]
```
flowchart LR
  content["content/"] --> Hugo
  config["hugo.yml"] --> Hugo
  theme["OINK theme"] --> Hugo
  Hugo --> site["public/"]

The fence language is mermaid and there is no other switch. Only when the theme sees such a fence does it add the Mermaid runtime to that page, and ten diagrams on one page still load it once.

Sequence diagrams

sequenceDiagram describes messages between participants over time, which suits request paths and load order.

Source
```mermaid
sequenceDiagram
  autonumber
  participant Reader as Reader's browser
  participant CDN as Static hosting
  participant JS as Page script bundle
  Reader->>CDN: GET /docs/components/mermaid/
  CDN-->>Reader: HTML (containing <pre class="mermaid">)
  Reader->>CDN: GET this page's bundle
  CDN-->>Reader: mermaid.min.js
  JS->>JS: render the fence source into SVG
  Note over JS: runtimes the page never used are not downloaded
```
sequenceDiagram
  autonumber
  participant Reader as Reader's browser
  participant CDN as Static hosting
  participant JS as Page script bundle
  Reader->>CDN: GET /docs/components/mermaid/
  CDN-->>Reader: HTML (containing <pre class="mermaid">)
  Reader->>CDN: GET this page's bundle
  CDN-->>Reader: mermaid.min.js
  JS->>JS: render the fence source into SVG
  Note over JS: runtimes the page never used are not downloaded

Gantt charts

gantt draws intervals. Below is the five-year community support window of each PostgreSQL major version, counted from its release date; 1825d is five years.

Source
```mermaid
gantt
  title Five-year community support per PostgreSQL major version
  dateFormat YYYY-MM-DD
  axisFormat %Y
  section PG 15
  released 2022-10-13 :2022-10-13, 1825d
  section PG 16
  released 2023-09-14 :2023-09-14, 1825d
  section PG 17
  released 2024-09-26 :2024-09-26, 1825d
  section PG 18
  released 2025-09-25 :active, 2025-09-25, 1825d
```
gantt
  title Five-year community support per PostgreSQL major version
  dateFormat YYYY-MM-DD
  axisFormat %Y
  section PG 15
  released 2022-10-13 :2022-10-13, 1825d
  section PG 16
  released 2023-09-14 :2023-09-14, 1825d
  section PG 17
  released 2024-09-26 :2024-09-26, 1825d
  section PG 18
  released 2025-09-25 :active, 2025-09-25, 1825d

Class and ER diagrams

classDiagram draws types and relationships, erDiagram entities and cardinality. Both are common ways to explain a data model.

Source
```mermaid
classDiagram
  class Page {
    +string Title
    +string Description
    +int Weight
    +Content()
    +OutputFormats()
  }
  class Resource {
    +string Name
    +string RelPermalink
    +Resize(spec)
  }
  class OutputFormat {
    +string Name
    +string MediaType
  }
  Page "1" --> "0..*" Resource : page bundle resources
  Page "1" --> "1..*" OutputFormat : html / print / markdown / rss
```
classDiagram
  class Page {
    +string Title
    +string Description
    +int Weight
    +Content()
    +OutputFormats()
  }
  class Resource {
    +string Name
    +string RelPermalink
    +Resize(spec)
  }
  class OutputFormat {
    +string Name
    +string MediaType
  }
  Page "1" --> "0..*" Resource : page bundle resources
  Page "1" --> "1..*" OutputFormat : html / print / markdown / rss
Source
```mermaid
erDiagram
  pg_database ||--o{ pg_namespace : "contains schemas"
  pg_namespace ||--o{ pg_class : "contains relations"
  pg_class ||--o{ pg_attribute : "has columns"
  pg_class ||--o{ pg_index : "is indexed by"
  pg_class {
    oid oid PK
    name relname
    char relkind
  }
  pg_attribute {
    oid attrelid FK
    name attname
    smallint attnum
  }
```
erDiagram
  pg_database ||--o{ pg_namespace : "contains schemas"
  pg_namespace ||--o{ pg_class : "contains relations"
  pg_class ||--o{ pg_attribute : "has columns"
  pg_class ||--o{ pg_index : "is indexed by"
  pg_class {
    oid oid PK
    name relname
    char relkind
  }
  pg_attribute {
    oid attrelid FK
    name attname
    smallint attnum
  }

State diagrams

stateDiagram-v2 draws states and the conditions between them. Below are the five states an OINK release passes through. They are not interchangeable, and a green local build is none of them.

Source
```mermaid
stateDiagram-v2
  [*] --> SourceComplete
  SourceComplete --> Validated : theme checks + site suite green
  Validated --> Published : an immutable signed vX.Y.Z tag is pushed
  Published --> Documented : the site's go.mod pins that tag
  Documented --> Deployed : the production build goes live
  Deployed --> [*]
  Published --> SourceComplete : a problem means a new patch version; tags never move
```
stateDiagram-v2
  [*] --> SourceComplete
  SourceComplete --> Validated : theme checks + site suite green
  Validated --> Published : an immutable signed vX.Y.Z tag is pushed
  Published --> Documented : the site's go.mod pins that tag
  Documented --> Deployed : the production build goes live
  Deployed --> [*]
  Published --> SourceComplete : a problem means a new patch version; tags never move

Per-diagram title and configuration

The top of a fence body may carry Mermaid’s own YAML header — this is not Hugo front matter. title gives the diagram a title and config overrides Mermaid configuration for this diagram alone. A diagram that hard-codes config.theme no longer follows the site’s colour scheme.

Source
```mermaid
---
title: Only the runtimes a page used are bundled
config:
  flowchart:
    curve: linear
---
flowchart TD
  Page --> Which{which components?}
  Which -->|Mermaid fence| M[mermaid.min.js]
  Which -->|ECharts fence| E[echarts.min.js]
  Which -->|none| B[base bundle only]
```
---
title: Only the runtimes a page used are bundled
config:
  flowchart:
    curve: linear
---
flowchart TD
  Page --> Which{which components?}
  Which -->|Mermaid fence| M[mermaid.min.js]
  Which -->|ECharts fence| E[echarts.min.js]
  Which -->|none| B[base bundle only]

Light and dark

The theme reads the current colour scheme when the page initializes: in dark mode it uses Mermaid’s dark theme, in light mode the theme the site configured. Mermaid cannot be re-initialized, so switching the colour scheme reloads the whole page and the diagrams come back in the new colours.

For that reason, keep Mermaid diagrams off pages that must preserve input state — a page with a form, for instance.

Site-wide defaults go in hugo.yml with lowercase keys; the theme matches them back to Mermaid’s own casing:

hugo.yml
params:
  mermaid:
    theme: neutral
    flowchart:
      diagrampadding: 6

The full key table is in Configuration; for accepted values see the Mermaid configuration reference.

Inside tabs and steps

A mermaid fence has no tab attribute — adjacent-fence tabs apply to ordinary code fences only. To compare two diagrams side by side, use the tabs shortcode.

Source
{{< tabs >}}
{{< tab label="By data flow" >}}
```mermaid
flowchart LR
  Markdown --> Goldmark --> RenderHooks --> HTML
```
{{< /tab >}}
{{< tab label="By output format" >}}
```mermaid
flowchart LR
  Page --> HTML
  Page --> Print
  Page --> Markdown
  Page --> RSS
```
{{< /tab >}}
{{< /tabs >}}
flowchart LR
  Markdown --> Goldmark --> RenderHooks --> HTML
flowchart LR
  Page --> HTML
  Page --> Print
  Page --> Markdown
  Page --> RSS

Each step inside {{% steps %}} is page-level Markdown and can hold a mermaid fence; see Steps.

Output

Output Shape
HTML <pre class="mermaid"> plus the local Mermaid runtime; the browser draws the SVG
Print Same as HTML: the print view loads the runtime too, so the diagrams are drawn
Markdown The mermaid fence and its source, kept as written
RSS The diagram source inside <pre class="mermaid"> — subscribers see text

Parameter reference

Fence attributes: none. A mermaid fence reads no attribute line; writing {height=…} or {class=…} neither works nor errors. Size follows the diagram itself and the container width.

Site parameters (hugo.yml):

params.mermaid , map , defaultunset
The whole map is passed to Mermaid’s initialize(); write keys in lowercase and the theme matches them back to Mermaid’s casing
params.mermaid.theme , string , defaultMermaid’s default
The light-mode theme; dark mode forces dark

Per-diagram configuration goes in the YAML header at the top of the fence body (title, config). That is Mermaid syntax, not a theme parameter.

Limits

  • Switching colour scheme reloads the page: Mermaid cannot be re-initialized, and the theme chose correct rendering over avoiding the reload.
  • Diagrams cannot be numbered or zoomed: Mermaid emits inline SVG, not an <img>, so {#id num=} numbering and image zoom do not apply. Export to an image when you need a number and use the image numbering.
  • Fence attributes do nothing: control width inside the diagram (flowchart direction, class-diagram layout) or with CSS.
  • Syntax errors show up only in the browser: Hugo does not parse Mermaid, so a broken diagram renders Mermaid’s error box while the build still passes. Check in a browser before publishing.
  • RSS subscribers see the source only: put the conclusion in the prose, not only in the picture.
  • PlantUML — more complete UML, at the price of a rendering server
  • Markmap — outline-shaped hierarchies
  • ECharts — charts with numbers in them
  • Images — hand-drawn SVG, numbering and zoom

4.12 - PlantUML

A plantuml fence writes sequence, class, component, activity and use-case diagrams; rendering requires a PlantUML server you configure yourself.

A plantuml fence holds PlantUML source. The browser compresses and encodes it, appends it to the URL of a PlantUML server, and gets an SVG back. It suits sequence, class, component, activity and use-case diagrams that need the full expressiveness of UML. Rendering depends on that server: the theme ships no default endpoint, and enable: true without svg_image_url fails the build. With no server available, use Mermaid instead.

This page shows source only, not rendered diagrams

PlantUML has to reach a server you run, and this site assumes no endpoint on the reader’s behalf. In the current theme version the plantuml fence also double-escapes <, >, & and ", so source with arrows or quotes comes back from the endpoint as a Syntax Error? image (see Limits). Every snippet below is correct PlantUML in itself.

Diagrams leave the reader’s browser

The encoded diagram source is sent to the endpoint you configure. Never put passwords, internal hostnames or customer names in a PlantUML fence. Internal sites should run their own endpoint, or use a pre-rendered image.

Shortest form

Sequence diagrams are the most common kind: participant declares a participant, -> is a synchronous message, --> a return.

Source
```plantuml
@startuml
actor Reader
participant Browser
participant Endpoint as Server
Reader -> Browser : open the page
Browser -> Server : GET /plantuml/svg/{compressed source}
Server --> Browser : SVG
Browser -> Browser : replace the fence with an img element
@enduml
```

That draws four lanes and four messages: the reader opens the page, the browser requests the endpoint with the encoded source, the endpoint returns SVG, and the runtime swaps the fence for an image.

Class diagrams

class lists members and "1" -- "0..*" gives a relationship its cardinality — the usual way to explain a data model.

Source
```plantuml
@startuml
class Publication {
  + pubname : name
  + puballtables : bool
  + pubinsert / pubupdate / pubdelete : bool
}
class Subscription {
  + subname : name
  + subconninfo : text
  + subslotname : name
}
class ReplicationSlot {
  + slot_name : name
  + plugin : name
  + confirmed_flush_lsn : pg_lsn
}
Publication "1" -- "0..*" Subscription : subscribed by
Subscription "1" -- "1" ReplicationSlot : bound to
@enduml
```

Three boxes with their fields and two annotated connectors: one publication can serve many subscriptions, and every subscription binds one replication slot.

Component diagrams

package groups deployment units, [component] is a box, and --> is the direction of a dependency.

Source
```plantuml
@startuml
package "Monitoring node" {
  [Grafana] as grafana
  [Prometheus] as prom
  [Alertmanager] as alert
}
package "Database node" {
  [node_exporter] as node
  [pg_exporter] as pgexp
  [PostgreSQL] as pg
}
pg --> pgexp : query the statistics views
node --> prom : /metrics
pgexp --> prom : /metrics
prom --> alert : rule fired
grafana --> prom : PromQL
@enduml
```

Two dashed boxes with three components each, and five labelled arrows tracing the collection path.

Activity diagrams

start / stop with if … then … else … endif draws a branching procedure. This kind contains no arrow characters, so it is the one kind that renders correctly in the current version.

Source
```plantuml
@startuml
start
:write content/docs/**/*.md;
:add the translated peer, copying the rendered heading IDs;
if (hugo --panicOnWarning passes?) then (yes)
  :npm test;
else (no)
  :fix using the file and line in the error;
  stop
endif
if (tests green?) then (yes)
  :open the PR;
  stop
else (no)
  :back to editing;
  stop
endif
@enduml
```

One vertical flow line, two diamonds each branching yes / no, four end points.

Use-case diagrams

actor is a stick figure, (use case) an ellipse, and rectangle draws the system boundary — a good fit for a “who is this for” section.

Source
```plantuml
@startuml
left to right direction
actor Reader as reader
actor Author as author
actor Maintainer as maintainer
rectangle "Documentation site" {
  reader --> (full-text search)
  reader --> (switch language)
  reader --> (export the print view)
  author --> (add a page)
  author --> (preview locally)
  maintainer --> (upgrade the theme)
  maintainer --> (publish)
}
@enduml
```

Three figures on the left, one box with seven ellipses on the right, and connectors saying who can do what.

Colours in dark mode

The server knows nothing about the site’s colour scheme, so the SVG comes back on a fixed white ground. skinparam backgroundColor transparent removes it and the diagram sits on the page background. With neutral lines and text it reads in both modes.

Source
```plantuml
@startuml
skinparam backgroundColor transparent
skinparam defaultFontName sans-serif
skinparam ArrowColor #7C7C7C
skinparam ActivityBorderColor #7C7C7C
skinparam ActivityBackgroundColor #B0BEC522
start
:hugo mod get -u github.com/pgsty/oink;
:hugo --gc --minify;
:upload public/;
stop
@enduml
```

PlantUML’s !theme directive (!theme plain, for instance) also works. Themes come from the server, so a self-hosted endpoint has to have them installed.

The rendering server

The fence itself has no switch; whether it renders depends on the site configuration:

hugo.yml
params:
  plantuml:
    enable: true
    svg_image_url: https://plantuml.internal.example/plantuml/svg/
    svg: false
  • enable: true without svg_image_url fails the build with params.plantuml.enable requires an explicit params.plantuml.svg_image_url. The theme does not pick a public service for the site.
  • To self-host, the official image plantuml/plantuml-server works; point svg_image_url at its /svg/ path and keep the trailing slash — the encoded source is appended to it.
  • The endpoint’s CORS policy and the site’s CSP img-src (plus connect-src when svg: true) must both allow it; use an absolute URL on a subpath deployment.

These keys are defined in Configuration.

Output

Output Shape
HTML The source is emitted as <pre><code class="language-plantuml">; once enabled, the runtime replaces it with an <img> (with svg: true, an <svg data-src>)
Print Same as HTML: the print view loads the runtime and requests the endpoint too
Markdown The plantuml fence and its source, kept as written
RSS The fence source only — subscribers see text

When the feature is off, or the runtime has not loaded, what stays on the page is a readable source block, never a broken-image icon.

Parameter reference

Fence attributes: none. A plantuml fence reads no attribute line and does not go through OINK’s code-block shell, so title, copy and the line-number options from Code blocks have no effect here.

Site parameters (hugo.yml):

params.plantuml.enable , bool , defaultfalse
With it off, the fence stays a code block and no runtime loads
params.plantuml.svg_image_url , string , defaultnone
The rendering endpoint; the encoded source is appended to it. Required when enable: true, otherwise the build fails
params.plantuml.svg , bool , defaultfalse
false inserts <img src>; true inserts <svg data-src> and loads an external SVG loader, putting the SVG in the DOM where CSS can reach it

The theme reads those three keys and nothing else.

Limits

  • <, >, & and " are double-escaped: the current theme version escapes the fence content once too often, leaving literal --&gt; and &#34; in the page and returning a Syntax Error? image from the endpoint. Diagrams with arrows (sequence, component, use case, state) therefore do not render today; activity diagrams, which contain none of those characters, do. Until it is fixed, use Mermaid or a pre-rendered image.
  • A server is mandatory: the theme provides no default endpoint and assumes none.
  • Diagram source leaves the browser: keep anything confidential out of a PlantUML fence.
  • No colour-scheme awareness: the server does not know the reader’s mode, so skinparam is the only lever.
  • No numbering, no zoom: the <img> the runtime inserts does not pass through the image render hook, so {#id num=} and image zoom do not apply.
  • Mermaid — no server, follows the colour scheme, the everyday choice
  • Draw.io — the other integration that needs a server of your own
  • Images — pre-rendered SVG: numberable, zoomable, no external dependency
  • Configuration — the full definition of params.plantuml.*

4.13 - Markmap

A markmap fence turns a Markdown outline into an expandable, zoomable mind map — and the source stays a readable outline.

The body of a markmap fence is a plain Markdown outline: headings and lists give the hierarchy, and the browser draws it as a tree you can expand and collapse. It suits showing “what this section covers” at one glance. For flows with direction and conditions, use Mermaid.

Shortest form

Source
```markmap
# OINK
## Local-first
- every runtime ships with the theme
- no CDN involved
## Markdown-native
- components are fences and attribute lines
- usable without writing a shortcode
## Four output states
- HTML
- print
- Markdown
- RSS
```
# OINK
## Local-first
- every runtime ships with the theme
- no CDN involved
## Markdown-native
- components are fences and attribute lines
- usable without writing a shortcode
## Four output states
- HTML
- print
- Markdown
- RSS

The first-level heading is the root; other headings and list items hang under it by indentation. Click the dot on a node to fold or unfold that branch, scroll to zoom, drag to pan. The toolbar at the bottom right offers zoom, fit-to-window and download-as-SVG.

Depth

Deeper levels are set smaller and the canvas lays itself out. Below are the six sections of this theme’s documentation site and their page counts.

Source
```markmap
# OINK documentation
## Introduction (4 pages)
### What it is
### Feature tour
### Showcase
### Licences
## Get started (3 pages)
### Fork this site
### Directory layout
### From scratch
## Authoring (8 pages)
### Organizing content
### Writing pages
### Front matter
### Blog
### Books
### Releases and downloads
### OpenAPI
## Components (22 pages)
### Callouts / tabs / steps / cards
### Images / galleries / tables / fields
### Diagrams: Mermaid / PlantUML / Markmap / ECharts
## Customization (15 pages)
### Branding / navigation / search / languages
### Landing / versions / taxonomies / print
## Operations (7 pages)
### Preview / deploy / upgrade
### Comments / analytics / troubleshooting
```
# OINK documentation
## Introduction (4 pages)
### What it is
### Feature tour
### Showcase
### Licences
## Get started (3 pages)
### Fork this site
### Directory layout
### From scratch
## Authoring (8 pages)
### Organizing content
### Writing pages
### Front matter
### Blog
### Books
### Releases and downloads
### OpenAPI
## Components (22 pages)
### Callouts / tabs / steps / cards
### Images / galleries / tables / fields
### Diagrams: Mermaid / PlantUML / Markmap / ECharts
## Customization (15 pages)
### Branding / navigation / search / languages
### Landing / versions / taxonomies / print
## Operations (7 pages)
### Preview / deploy / upgrade
### Comments / analytics / troubleshooting

Links, code and emphasis

Nodes take inline Markdown: links are clickable, inline code is monospaced, bold and italic behave as usual.

Source
```markmap
# Everyday commands
## Preview
- `hugo server` — open [localhost:1313](http://localhost:1313/)
- `hugo server -D` — **including drafts**
## Build
- `hugo --printPathWarnings --panicOnWarning`
- `hugo --gc --minify` — for publishing
## Theme
- `hugo mod get -u github.com/pgsty/oink`
- [theme repository](https://github.com/pgsty/oink)
- [site source](https://github.com/pgsty/oink.pgsty.com)
```
# Everyday commands
## Preview
- `hugo server` — open [localhost:1313](http://localhost:1313/)
- `hugo server -D` — **including drafts**
## Build
- `hugo --printPathWarnings --panicOnWarning`
- `hugo --gc --minify` — for publishing
## Theme
- `hugo mod get -u github.com/pgsty/oink`
- [theme repository](https://github.com/pgsty/oink)
- [site source](https://github.com/pgsty/oink.pgsty.com)

Mathematics in nodes

The Markmap runtime carries a local KaTeX, so $…$ inside a node renders as a formula.

Source
```markmap
# PostgreSQL metrics worth watching
## Cache hit ratio
- $\frac{blks\_hit}{blks\_hit + blks\_read}$
- below 0.99, look at shared_buffers
## Replication lag
- $lsn_{primary} - lsn_{replica}$
## Transaction throughput
- $TPS = \frac{\Delta xact\_commit}{\Delta t}$
```
# PostgreSQL metrics worth watching
## Cache hit ratio
- $\frac{blks\_hit}{blks\_hit + blks\_read}$
- below 0.99, look at shared_buffers
## Replication lag
- $lsn_{primary} - lsn_{replica}$
## Transaction throughput
- $TPS = \frac{\Delta xact\_commit}{\Delta t}$

Controlling the initial depth

The top of a fence body may carry Markmap’s own YAML header — not Hugo front matter. initialExpandLevel expands only the first few levels and leaves the rest for the reader; colorFreezeLevel says from which level a branch keeps one colour.

Source
```markmap
---
markmap:
  initialExpandLevel: 2
  colorFreezeLevel: 2
---

# Check scripts in the theme repository
## Source-level contracts
### check-i18n.py
### check-taxonomy.py
### check-font-tokens.py
## Output-level checks
### check-output.py
### check-goldens.py
### check-code-blocks.py
### check-content-primitives.py
### check-media-primitives.py
## Browser runtimes
### node --test tests/js/**/*.test.js
```
---
markmap:
  initialExpandLevel: 2
  colorFreezeLevel: 2
---

# Check scripts in the theme repository
## Source-level contracts
### check-i18n.py
### check-taxonomy.py
### check-font-tokens.py
## Output-level checks
### check-output.py
### check-goldens.py
### check-code-blocks.py
### check-content-primitives.py
### check-media-primitives.py
## Browser runtimes
### node --test tests/js/**/*.test.js

Folded into a disclosure

Every map is a fixed 300 pixels tall, so three in a row eat a lot of page. Fold a panoramic one into > [!DETAILS] and let the reader open it. Every line inside the disclosure starts with >, fences included.

Source
> [!DETAILS] What the theme repository looks like
> ```markmap
> # pgsty/oink
> ## layouts/
> - baseof.html and the per-type shells
> - _partials/shell/
> - _markup/ render hooks
> - _shortcodes/
> ## assets/
> - scss/ tokens and component styles
> - js/ browser runtimes
> - third_party/ libraries shipped with the theme
> ## i18n/
> - 32 locale files with identical keys
> ## docs/
> - maintainer contracts
> ```
What the theme repository looks like
# pgsty/oink
## layouts/
- baseof.html and the per-type shells
- _partials/shell/
- _markup/ render hooks
- _shortcodes/
## assets/
- scss/ tokens and component styles
- js/ browser runtimes
- third_party/ libraries shipped with the theme
## i18n/
- 32 locale files with identical keys
## docs/
- maintainer contracts

Output

Output Shape
HTML <pre><code class="language-markmap"> first; the runtime replaces it with <div class="markmap"> and draws the SVG
Print Same as HTML: the print view loads the runtime too
Markdown The markmap fence and its outline, kept as written
RSS The outline source only — a readable outline for subscribers

The outline is the content: wherever JavaScript does not reach, the full hierarchy is still legible.

Parameter reference

Fence attributes: none. A markmap fence reads no attribute line; the height is fixed by the theme at 300px (.markmap > svg) and the width fills the reading column.

Site parameters (hugo.yml):

params.markmap , bool , defaultfalse
With it off, the fence stays a code block and no runtime loads

The key is defined in Configuration. Per-map behaviour goes in the markmap: YAML header at the top of the fence body (initialExpandLevel, colorFreezeLevel, maxWidth …), which is Markmap syntax; the accepted keys are in the Markmap documentation.

Limits

  • The output is an inline SVG fixed at 300px tall: one .markmap > svg rule decides it and the fence cannot change it. When a map has too many levels, use initialExpandLevel or split it in two. Inline SVG also means {#id num=} numbering and image zoom do not apply.
  • No colour-scheme awareness: link colours come from Markmap’s own palette, so check contrast in both modes.
  • Without params.markmap it is only a code block: sites that do not use the component load no runtime.
  • “Download SVG” in the toolbar is a browser action and exports a snapshot of the current expansion state.
  • Avoid <, >, & and " in the outline: the current theme version double-escapes them and nodes show literal &gt; or &#34;. Write links as [text](URL) rather than as autolinks in angle brackets.

4.14 - Draw.io

Put a .drawio.svg that carries an editable copy on the page as an ordinary image; hovering gives the reader a button that opens the Draw.io editor.

The Draw.io integration has neither a fence nor a shortcode — it uses plain Markdown images. Tick “Include a copy of my diagram” when exporting from Draw.io and the SVG or PNG carries an mxfile copy inside it; the theme’s runtime spots that copy and adds an edit button to the image. It suits diagrams readers are meant to take away and change. A diagram that is only there to be looked at is an ordinary image.

Shortest form

The syntax is the plain image syntax. The filename does not matter; .drawio.svg is only a convention.

Source
![The Hugo build pipeline: content goes through Hugo and out as public](pipeline.drawio.svg)
{width="620" height="140"}
The Hugo build pipeline: content goes through Hugo and out as public

An export that carries an mxfile copy is wrapped in a .drawio container. Hover it and a pencil button appears at the bottom right; clicking lays a full-screen iframe over the page and loads the editor the site configured.

How the copy is detected

The runtime looks at one thing: whether the file’s contents contain mxfile. The filename is irrelevant. A hand-drawn SVG written exactly the same way — a block image with the same attribute line — carries no copy, so it gets no button.

Source
![The three columns of the documentation shell: sidebar, article, table of contents](plain-shell.svg)
{width="620" height="140"}
The three columns of the documentation shell: sidebar, article, table of contents

With a caption

Draw.io images go through the ordinary image render hook, so every image attribute still applies. Add caption for a captioned figure; the edit button still appears on the image.

Source
![The Hugo build pipeline](pipeline.drawio.svg)
{caption="Content, configuration and theme templates flow into Hugo and out as public/" width="620" height="140"}
The Hugo build pipeline
Content, configuration and theme templates flow into Hugo and out as public/

As a numbered figure

Add {#id num=…} for a cross-referenceable numbered figure, which xref can reach and which appears in the list of figures like any other.

Source
![The Hugo build pipeline](pipeline.drawio.svg)
{#fig_pipeline num="1-1" caption="From content to a static site" width="620" height="140"}
The Hugo build pipeline
Figure 1-1 From content to a static site

The complete numbering and cross-reference rules are in publishing books.

SVG or PNG

Both are recognized. A Draw.io PNG export can carry the same copy in a text chunk, and the runtime’s test is identical.

Source
![The Hugo build pipeline (PNG export)](pipeline.drawio.png)
{width="620" height="140"}
The Hugo build pipeline (PNG export)

Prefer SVG in documentation: it scales without loss, its text is real text (searchable, readable by screen readers) and its diffs are legible. Use PNG when the diagram is very complex or the target platform cannot take SVG. Only PNG can go through Hugo’s image processing; operations on SVG warn and leave the source unchanged, and strict builds reject the warning.

What the button does

Three things, in order.

Lay an overlay over the page

A full-screen div.drawioframe is inserted holding an iframe whose address is the configured drawio_server plus a fixed query string (embed=1&ui=atlas&proto=json&saveAndEdit=1&noSaveBtn=1).

Hand the diagram to the editor

Once the editor is ready, the runtime sends this image’s contents — the mxfile copy included — into the iframe as a data URL. That step does not go through your server.

Save and write back

Saving in the editor makes it export in the original format, SVG or PNG, and the browser downloads it under the same name. The runtime never writes to the repository: overwrite the file in content/ with what you downloaded and commit it yourself.

The edit button is there so a reader can take the diagram away and change it. It is not online editing of the site.

The editor address

hugo.yml
params:
  drawio:
    enable: true
    drawio_server: https://drawio.internal.example/
  • enable: true without drawio_server warns and disables editing; strict builds fail on that warning. The theme does not pick a public service.
  • When editing has to stay inside the organization, deploy a self-hosted editor and point at it.
  • The public endpoint https://embed.diagrams.net/ works, and the reader’s diagram then travels to a third-party page.

Both keys are defined in Configuration.

Output

Output Shape
HTML A plain <img> or <figure>; once enabled, the runtime wraps an image that carries a copy in <div class="drawio"> and adds the button
Print The image prints as usual; the button is hidden except on hover, so it never reaches paper
Markdown Plain Markdown image syntax
RSS A plain <img> with an absolute URL and no button

The image itself exists in all four states; the edit button is an increment on top.

Parameter reference

There are no fence or shortcode parameters of its own. The image attribute line is the one from Images: caption, width, height, link, #id, num, command, options.

Site parameters (hugo.yml):

params.drawio.enable , bool , defaultfalse
With it off no script loads and an image is just an image
params.drawio.drawio_server , string , defaultnone
The editor address; required when enable: true

Limits

  • The runtime loads only when rendered page content contains .svg or .png candidates. It groups matching images by URL, then reads each URL once to look for mxfile.
  • Forget to tick “Include a copy of my diagram” on export and the image is just an image, with no button.
  • Editing needs the editor and never writes back: offline, the images display fine and the button does nothing; saving is a browser download, and replacing the file and committing it are manual.
  • The button appears on hover only: touch devices have no hover, so readers may not find it. Do not present editability as a headline feature.
  • Colours do not follow the colour scheme: an exported SVG has fixed colours. Set fills to none and use neutral greys for lines and text and it reads in both modes.
  • Images — captions, numbering, sizing and zoom in full
  • PlantUML — the other integration that needs a server
  • Mermaid — diagrams from text with no server at all
  • Configuration — the full definition of params.drawio.*

4.15 - ECharts

Write ECharts options as YAML or JSON in an echarts fence; Hugo validates them at build time and the browser draws a theme-aware chart with the local ECharts.

The body of an echarts fence is an ECharts option object in YAML or JSON — not code. Use it for quantitative charts that need axes, series and a legend. For relationships and flows use Mermaid; for order and hierarchy use Infographic. Hugo parses the options at build time and fails the build if they do not parse; the browser draws with the ECharts copy the theme ships, and only a page that uses it loads the runtime.

Shortest form

A bar chart needs three parts: xAxis, yAxis, series. Below is how many pages each of the six documentation sections has.

Source
```echarts {height="320px"}
tooltip:
  trigger: axis
xAxis:
  type: category
  data: [Introduction, Get started, Authoring, Components, Customization, Operations]
yAxis:
  type: value
  name: pages
series:
  - name: pages
    type: bar
    data: [4, 3, 8, 22, 15, 7]
```
tooltip:
  trigger: axis
xAxis:
  type: category
  data: [Introduction, Get started, Authoring, Components, Customization, Operations]
yAxis:
  type: value
  name: pages
series:
  - name: pages
    type: bar
    data: [4, 3, 8, 22, 15, 7]

Both formats are accepted; YAML needs no quotes or commas and is shorter to write. Broken indentation, or a body that parses to an array instead of a map, fails the build on that line rather than emitting a blank chart.

Multiple line series

series is an array, so another entry is another line, and legend lets the reader hide one. Below are the release years of PostgreSQL major versions and the end-of-support years implied by the community’s five-year policy.

Source
```echarts {height="360px"}
tooltip:
  trigger: axis
legend:
  data: [Released, End of support]
grid:
  left: 56
  right: 24
  top: 48
  bottom: 40
xAxis:
  type: category
  name: major version
  data: ["9.6", "10", "11", "12", "13", "14", "15", "16", "17", "18"]
yAxis:
  type: value
  min: 2015
  max: 2031
  name: year
series:
  - name: Released
    type: line
    smooth: false
    data: [2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025]
  - name: End of support
    type: line
    lineStyle:
      type: dashed
    data: [2021, 2022, 2023, 2024, 2025, 2026, 2027, 2028, 2029, 2030]
```
tooltip:
  trigger: axis
legend:
  data: [Released, End of support]
grid:
  left: 56
  right: 24
  top: 48
  bottom: 40
xAxis:
  type: category
  name: major version
  data: ["9.6", "10", "11", "12", "13", "14", "15", "16", "17", "18"]
yAxis:
  type: value
  min: 2015
  max: 2031
  name: year
series:
  - name: Released
    type: line
    smooth: false
    data: [2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025]
  - name: End of support
    type: line
    lineStyle:
      type: dashed
    data: [2021, 2022, 2023, 2024, 2025, 2026, 2027, 2028, 2029, 2030]

Quote the version numbers: unquoted 10 is a number in YAML and so is 9.6, but as category-axis labels they have to be strings.

Pie and doughnut charts

Give radius two values for a doughnut. Below is how OINK’s 29 shortcodes break down by purpose.

Source
```echarts {height="340px"}
tooltip:
  trigger: item
  formatter: "{b}: {c} ({d}%)"
legend:
  bottom: 0
series:
  - type: pie
    radius: [42%, 70%]
    itemStyle:
      borderRadius: 6
      borderWidth: 2
    label:
      formatter: "{b} {c}"
    data:
      - { value: 14, name: Core components }
      - { value: 10, name: Book numbering and indexes }
      - { value: 3, name: Releases and downloads }
      - { value: 2, name: OpenAPI }
```
tooltip:
  trigger: item
  formatter: "{b}: {c} ({d}%)"
legend:
  bottom: 0
series:
  - type: pie
    radius: [42%, 70%]
    itemStyle:
      borderRadius: 6
      borderWidth: 2
    label:
      formatter: "{b} {c}"
    data:
      - { value: 14, name: Core components }
      - { value: 10, name: Book numbering and indexes }
      - { value: 3, name: Releases and downloads }
      - { value: 2, name: OpenAPI }

{b}, {c} and {d} are ECharts template placeholders — name, value, percentage. Writing them in a string is enough; no function is needed.

Height and full width

height defaults to 400px and accepts px rem em vh vw %. full=true drops the reading-column limit so the chart fills the content area, which suits charts with many points or long labels.

Source
```echarts {height="260px" full=true}
tooltip:
  trigger: axis
grid:
  left: 40
  right: 16
  top: 24
  bottom: 32
xAxis:
  type: category
  data: [i18n, taxonomy, font tokens, content contracts, navigation, runtime, sidebar icons, search, actions, palette, params, reading, release assets, download, landing, book, migrations, keyboard, shell, output, goldens]
yAxis:
  type: value
  name: scripts
series:
  - type: bar
    data: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
```
tooltip:
  trigger: axis
grid:
  left: 40
  right: 16
  top: 24
  bottom: 32
xAxis:
  type: category
  data: [i18n, taxonomy, font tokens, content contracts, navigation, runtime, sidebar icons, search, actions, palette, params, reading, release assets, download, landing, book, migrations, keyboard, shell, output, goldens]
yAxis:
  type: value
  name: scripts
series:
  - type: bar
    data: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]

An invalid height (360, 36pt) fails the build rather than falling back to the default.

Light and dark

Without theme, a chart initializes in the reader’s current colour scheme and redraws in place when that changes — no page reload. It resizes automatically when its container does. Switch this page to dark and the ground and text of every chart above change with it.

A fixed theme pins the colours in both modes:

Source
```echarts {height="240px" theme="dark"}
xAxis:
  type: category
  data: [HTML, Print, Markdown, RSS]
yAxis:
  type: value
series:
  - type: bar
    data: [1, 1, 1, 1]
```
xAxis:
  type: category
  data: [HTML, Print, Markdown, RSS]
yAxis:
  type: value
series:
  - type: bar
    data: [1, 1, 1, 1]

dark is the only theme built into the runtime; any other ECharts theme has to be registered with echarts.registerTheme() before it can be named here. Without a branding requirement, leave theme out and let the chart follow the site.

Callbacks with $fn:

A fence is data and cannot carry JavaScript. When an option needs a function — a tooltip formatter, a data-driven colour — write the string "$fn:name" in the options and register that name on window.OinkEchartsFunctions:

Source
<script>
  window.OinkEchartsFunctions = window.OinkEchartsFunctions || {};
  window.OinkEchartsFunctions.pageShare = function (params) {
    var p = params[0];
    return p.name + ': ' + p.value + ' pages, ' + Math.round((p.value / 59) * 100) + '% of the site';
  };
</script>

```echarts {height="300px"}
tooltip:
  trigger: axis
  formatter: "$fn:pageShare"
xAxis:
  type: category
  data: [Introduction, Get started, Authoring, Components, Customization, Operations]
yAxis:
  type: value
series:
  - type: bar
    data: [4, 3, 8, 22, 15, 7]
```
tooltip:
  trigger: axis
  formatter: "$fn:pageShare"
xAxis:
  type: category
  data: [Introduction, Get started, Authoring, Components, Customization, Operations]
yAxis:
  type: value
series:
  - type: bar
    data: [4, 3, 8, 22, 15, 7]

Hover any bar and the tooltip is the sentence that function builds. An unregistered name resolves to undefined, the chart is drawn as if the option were not set, and neither the build nor the runtime complains. Keep the script next to the fence so they change together.

That script is site code and deserves code review. Formatting a string template ({b}, {c}, {d}) can express does not need a function.

Where the data lives

A fence body is a literal. Hugo does not expand shortcodes, front matter variables or files under data/ inside it — the numbers are written in the fence. The cost is that data cannot be shared; the benefit is that the chart and its data go into Git together and a diff shows which number moved.

Do not draw data that changes often (version matrices, asset lists). Use a table or the data/-driven components on a release page.

Output

Output Shape
HTML A canvas container inside <div class="td-echarts"> plus an application/json options block; the local ECharts draws it
Print No chart; the fence source inside <pre class="td-echarts-source">
Markdown The echarts fence and its option source, kept as written
RSS Same as print — source only

Whatever the chart shows, say it in the prose too: print and RSS have no chart.

Parameter reference

The fence attribute line (```echarts {…}):

height , CSS length , default400px
A non-negative number plus px rem em vh vw %; anything else fails the build
theme , string , defaultunset
Pin an ECharts theme and stop following the site’s colour scheme; only dark is built in
full , bool , defaultfalse
true drops the reading-column limit and fills the content area
class , space-separated classes , default
Passed through to the container for site CSS

style, on* and any other unknown attribute fail the build. The fence body must parse to a YAML/JSON map; failing to parse, or parsing to an array, fails too. The option keys themselves are ECharts’, documented in the official option manual.

There is no site-level parameter: ECharts needs no switch in hugo.yml and loads only where it is used.

Limits

  • No JavaScript in the fence: bridge through $fn: when a function is needed, and remember an unregistered name resolves to undefined with no error.
  • The fence reads no external data: data/, front matter and shortcodes are all out of reach; the numbers live in the fence.
  • Print and RSS carry the source only, so the conclusion belongs in the prose.
  • YAML type coercion: 10, 9.6, on and yes on a category axis become numbers or booleans and need quotes.
  • Colour is not the only distinction: in a multi-series chart vary line style or marker shape too, and check legend contrast in both colour schemes.
  • Infographic — structure and order, not statistics
  • Tables — for few values that must be read exactly
  • Mermaid — relationship and flow diagrams
  • Code blocks — the general rules for fence attribute lines

4.16 - Infographic

An infographic fence picks an AntV template and renders a title plus a list of items as a flow, timeline, funnel, grid or hierarchy.

An infographic fence picks an AntV template and renders “a title plus a list of items” as an infographic. Use it for structure: order, hierarchy, comparison. When you need axes and numeric precision use ECharts; when you need a flow with conditional branches use Mermaid. The fence body is data, and it stays readable text on GitHub.

Shortest form

The first line is infographic <template>, followed by a data block: title is the title and every entry under items needs at least a label.

Source
```infographic
infographic list-row-simple-horizontal-arrow
data
  title Three steps in one documentation change
  items
    - label Write
      desc Start with the source language
    - label Check
      desc Zero build warnings, every example really rendered
    - label Ship
      desc Add the translated peer, open the PR
```
infographic list-row-simple-horizontal-arrow
data
  title Three steps in one documentation change
  items
    - label Write
      desc Start with the source language
    - label Check
      desc Zero build warnings, every example really rendered
    - label Ship
      desc Add the translated peer, open the PR

Indentation decides the structure, two spaces per level. Keep labels short and put the explanation in desc.

Timelines

The sequence-timeline-* family lays the items out on a time axis, with label as the point in time and desc as the event.

Source
```infographic {height="420px"}
infographic sequence-timeline-simple
data
  title The last five PostgreSQL major versions
  items
    - label 2021
      desc 14: another round of parallel query and logical replication work
    - label 2022
      desc 15: the MERGE statement
    - label 2023
      desc 16: logical replication from a standby
    - label 2024
      desc 17: incremental backup and JSON_TABLE
    - label 2025
      desc 18: the asynchronous IO subsystem
```
infographic sequence-timeline-simple
data
  title The last five PostgreSQL major versions
  items
    - label 2021
      desc 14: another round of parallel query and logical replication work
    - label 2022
      desc 15: the MERGE statement
    - label 2023
      desc 16: logical replication from a standby
    - label 2024
      desc 17: incremental backup and JSON_TABLE
    - label 2025
      desc 18: the asynchronous IO subsystem

Funnels

sequence-funnel-simple draws stages that narrow. Below are the theme’s five release states: they are not interchangeable, and only the last one is live.

Source
```infographic {height="420px"}
infographic sequence-funnel-simple
data
  title The five states a theme release passes through
  items
    - label Source complete
      desc The code is written, and that is all
    - label Validated
      desc Theme checks and the site suite are green
    - label Published
      desc An immutable signed tag, resolvable through the Go proxy
    - label Documented
      desc The documentation site pins that tag
    - label Deployed
      desc Production runs this version
```
infographic sequence-funnel-simple
data
  title The five states a theme release passes through
  items
    - label Source complete
      desc The code is written, and that is all
    - label Validated
      desc Theme checks and the site suite are green
    - label Published
      desc An immutable signed tag, resolvable through the Go proxy
    - label Documented
      desc The documentation site pins that tag
    - label Deployed
      desc Production runs this version

Grid cards

When items have no order between them, list-grid-* arranges them in a grid rather than a queue.

Source
```infographic {height="380px"}
infographic list-grid-compact-card
data
  title One page, four outputs
  desc Every content component has to produce something usable in all four
  items
    - label HTML
      desc Interactive, runtimes loaded on demand
    - label Print
      desc Disclosures expanded, zoom and copy removed
    - label Markdown
      desc Plain text, compared byte for byte against goldens
    - label RSS
      desc Static, from the same source as print
```
infographic list-grid-compact-card
data
  title One page, four outputs
  desc Every content component has to produce something usable in all four
  items
    - label HTML
      desc Interactive, runtimes loaded on demand
    - label Print
      desc Disclosures expanded, zoom and copy removed
    - label Markdown
      desc Plain text, compared byte for byte against goldens
    - label RSS
      desc Static, from the same source as print

Items with values

Add value to an item and templates that express proportion — pies, doughnuts, progress — will use it.

Source
```infographic {height="400px"}
infographic chart-pie-donut-plain-text
data
  title How the 29 shortcodes break down
  items
    - label Core components
      value 14
    - label Book numbering and indexes
      value 10
    - label Releases and downloads
      value 3
    - label OpenAPI
      value 2
```
infographic chart-pie-donut-plain-text
data
  title How the 29 shortcodes break down
  items
    - label Core components
      value 14
    - label Book numbering and indexes
      value 10
    - label Releases and downloads
      value 3
    - label OpenAPI
      value 2

Hierarchy and hand-drawn style

Items can nest through children, and hierarchy-mindmap-* draws two levels of structure. A top-level theme block changes the whole look; type takes light, dark or hand-drawn.

Source
```infographic {height="320px"}
infographic hierarchy-mindmap-level-gradient-compact-card
theme
  type hand-drawn
data
  root
    label Theme repository
    children
      - label layouts
        desc templates
        children
          - label _markup
            desc render hooks
          - label _partials
            desc shell and helpers
      - label assets
        desc resources
        children
          - label scss
            desc tokens and component styles
          - label js
            desc browser runtimes
          - label third_party
            desc libraries shipped with the theme
```
infographic hierarchy-mindmap-level-gradient-compact-card
theme
  type hand-drawn
data
  root
    label Theme repository
    children
      - label layouts
        desc templates
        children
          - label _markup
            desc render hooks
          - label _partials
            desc shell and helpers
      - label assets
        desc resources
        children
          - label scss
            desc tokens and component styles
          - label js
            desc browser runtimes
          - label third_party
            desc libraries shipped with the theme

theme belongs to the DSL, not to the fence attributes, and it does not follow the site’s colour scheme: a diagram with type dark stays dark on a light page. Check contrast in both modes.

Picking a template

Template names are structure-variant, and one structure has several visual variants. The common families:

Structure prefix What it expresses Example
list-row-* list-column-* Items in a row or a column list-row-simple-horizontal-arrow
list-grid-* A grid, no order between items list-grid-compact-card list-grid-badge-card
list-pyramid-* sequence-funnel-* Narrowing stages sequence-funnel-simple
sequence-timeline-* sequence-roadmap-vertical-* Timelines and roadmaps sequence-timeline-simple
sequence-steps-* sequence-snake-steps-* Ordered steps sequence-steps-simple
compare-binary-horizontal-* compare-quadrant-* Binary comparison and quadrants compare-binary-horizontal-simple-vs
hierarchy-mindmap-* hierarchy-structure-* Hierarchy, with children hierarchy-mindmap-level-gradient-compact-card
chart-pie-* chart-bar-* chart-column-* Illustrative charts, with value chart-pie-donut-plain-text
relation-network-* relation-dagre-flow Networks and flows, with relations relation-dagre-flow

Choose the smallest form that makes the relationship clear. The full gallery is at AntV Infographic, and the template names match the version shipped with the theme.

Output

Output Shape
HTML A canvas container inside <div class="td-infographic"> plus the DSL; the local AntV runtime draws the SVG
Print No diagram; the DSL source inside <pre class="td-infographic-source">
Markdown The infographic fence and its DSL, kept as written
RSS Same as print — source only

Whatever the diagram says, say it in the prose too: print and RSS carry the DSL and nothing else.

Parameter reference

The fence attribute line (```infographic {…}):

height , auto or a CSS length , defaultauto
A non-negative number plus px rem em vh vw %; anything else fails the build
full , bool , defaultfalse
true drops the reading-column limit
class , space-separated classes , default
Passed through to the container

style, on* and unknown attributes fail the build, and so does an empty DSL body.

The DSL’s top-level keys (AntV’s, not the theme’s):

infographic / template
The template name, on the first line
data
title, desc, items (or sequences, compares, nodes, values, relations, root, depending on the structure), order
theme
type (light / dark / hand-drawn), palette, colorPrimary, stylize
width / height
Canvas size at the DSL level; usually left to the fence’s height
design
Per-part tuning; rarely needed

Each entry under items accepts label, desc, value, icon, children, group and id. The DSL is defined by the AntV Infographic documentation; the version shipped with the theme and its checksum are recorded in the theme’s VENDOR.json.

Limits

  • A wrong template name does not fail the build: Hugo checks the fence attributes only, the DSL is parsed by the browser runtime, and a missing template shows a line of error text in the container. Check the page after changing a template name.
  • No colour-scheme awareness: theme lives in the DSL, so check contrast in both modes.
  • Print and RSS carry the DSL only, so the conclusion belongs in the prose.
  • SVG is not a semantic structure: the order a screen reader gets is not necessarily the visual order. Prefer headings, lists and tables when they can say it.
  • Keep labels short: long text is truncated or squeezed on a narrow screen, so check at phone width after editing.
  • ECharts — when you need axes and exact numbers
  • Steps — when the reader has to follow the procedure
  • Cards — a grid of clickable entry points
  • Mermaid — flows with branches and conditions

4.17 - Gallery

A gallery fence arranges related screenshots in a responsive grid, each with an optional description or link, reusing the page’s image zoom dialog.

A gallery arranges related images in a responsive grid, one image per line inside the fence. It suits several views of one thing: a few screenshots, a few states, a few colour schemes. A single image is an image, and images with no order or comparison between them do not belong in one gallery.

Shortest form

One image per line, written as Markdown’s ![alt](src).

Source
```gallery
![OINK's default documentation shell](/images/oink.webp)
![The classic Docsy layout upstream](/images/docsy.webp)
```

Alternative text is mandatory: it is the item’s title, the only text a screen reader gets, and what decides whether the image can zoom. There is no column parameter — the grid adapts to the container and drops columns on a narrow screen.

Descriptions

Start a description with # after the image and it appears underneath. Descriptions are plain text, so Markdown inside them shows literally; for a literal hash write \#.

Source
```gallery
![The three-column layout of an OINK page](/images/oink.webp) # The default shell: sidebar, article, table of contents
![The classic Docsy documentation layout](/images/docsy.webp) # Docsy upstream — the content model is the same lineage
![A release notes page](/images/releasenote.webp) # Release pages are generated from facts in data/download, offline
```

Descriptions need not be the same length: the grid aligns to the tallest item and a wrapped description does not disturb its neighbours. The image is parsed first, so a # inside the alt text or the path needs no escaping.

{link=…} at the end of a line turns that item into a link. Site paths, relative paths and http(s): all work.

Source
```gallery
![OINK's default documentation shell](/images/oink.webp) # Opens the Images component page {link=/docs/components/image/}
![A release notes page](/images/releasenote.webp) # Opens "Releases and downloads" {link=/docs/write/releases/}
```

A linked item does not zoom, because clicking already means something else. Both kinds can share one gallery: linked items open a page, the rest open the full image.

Where images come from

Sources resolve exactly as for a plain image: page resource (a file next to the page in its bundle) → global resource in assets/ → static path /images/… → remote URL. A local resource carries its intrinsic size, so the page does not shift while loading; a remote image is neither downloaded at build time nor measured.

Source
```gallery
![OINK documentation overview (global resource)](images/content-primitives/oink.webp) # Under assets/images/…, eligible for build-time processing
![The light home page (static path)](/images/hero-light.webp) # Under static/images/…, published as is
```

A missing page or global resource fails the build; static paths and remote URLs are not checked.

Decorative images and zoom

Empty alternative text marks a decorative image: no title, skipped by screen readers, and never a zoom candidate.

Image zoom is a site-level switch and is off by default. This page turns it on in its front matter, so every image above that has alt text and no link opens full size (Esc closes it and focus returns where it was).

this page's front matter
image_zoom: true
Source: one decorative image, one ordinary one
```gallery
![](/images/docsy.webp) # Decorative, never zooms
![The Pigsty release notes page](/images/releasenote.webp) # Has alt text, so it opens
```

A gallery has no zoom runtime of its own; it reuses the one dialog the page shares. With no zoomable image on the page, that runtime is never loaded. The details are in Images · Zoom.

Classes and tabs

class can go on the whole fence (after the language) or on one item (at the end of its line). The theme does not interpret it and passes it through for site CSS. A fence carrying tab= (with group= / value=) becomes one panel of a tab set.

Source
```gallery {tab="OINK" group="shell" value="oink"}
![OINK's default documentation shell](/images/oink.webp) # Sidebar, article, table of contents
```
```gallery {tab="Docsy" value="docsy"}
![The classic Docsy layout upstream](/images/docsy.webp) # The same content-model lineage
```
OINK
Docsy

Output

Output Shape
HTML <ul class="td-gallery"> with one <li> per item; eligible images carry data-td-image-zoom; everything is lazy-loaded
Print The same images stacked, without zoom markers
Markdown The gallery fence, emitted as written
RSS The same static stack as print

Galleries load no JavaScript of their own.

Parameter reference

The line syntax ![alt](src) [# description] [{key=value …}]:

![alt](src) , Requiredyes
Must start the line. alt is the item’s title; empty means decorative
src , Requiredyes
Page resource / global resource / static path / remote URL
# description , Requiredno
Plain text under the image; \# is a literal hash; must not be empty
{link=…} , Requiredno
Makes the item a link, and therefore not zoomable
{class=…} , Requiredno
Adds a site CSS class to that item

Fence attributes:

tab , plain text , default
Makes this gallery one panel of a tab set
group / value , string , default
Tab group and sync value; must appear with tab
class , class list , default
Passed through for site CSS

There is no columns, caption or title attribute. A line that does not start with an image, trailing text outside a #, an empty description, an unknown attribute and a malformed {…} all fail the build with the line number inside the fence.

Limits

  • The fence is the only form: there is no {.gallery} list marker and no shortcode. The cost is that the source does not render as images on GitHub; the benefit is that four-state output and zoom eligibility are guaranteed by the theme.
  • Columns cannot be set and images are not cropped to one aspect ratio: the grid follows the viewport and images keep their own proportions.
  • No slideshow, no carousel, no previous / next: the zoom dialog shows one image at a time.
  • Remote images are not downloaded: there is no network request at build time, so a remote image’s size is unknown until the browser loads it and the layout may shift.
  • Descriptions are not Markdown: put rich text in a paragraph under the gallery.
  • Images — single images, captions, numbering, the zoom switch
  • Cards — a grid of links with images
  • Tabs — one gallery per platform or theme
  • File trees — the same line syntax family

4.18 - Badge

Put a semantic status label next to a feature name, a version or a table cell — five tones, no custom colours.

A badge is an inline status label that sits right after a name: Beta, deprecated, v0.5, needs a server. It suits a status of one or two words. The author picks a semantic tone and the theme picks the colour, with contrast guaranteed in light and dark. When the status needs an explanation, a procedure or a deadline, use prose or a callout.

Shortest form

Source
{{< badge text="Beta" tone="warning" >}}
Beta

text is the only required parameter and must be a non-empty string.

Five tones

These five values, and no custom colours.

Source
{{< badge text="Default" >}}
{{< badge text="Info" tone="info" >}}
{{< badge text="Supported" tone="success" >}}
{{< badge text="Experimental" tone="warning" >}}
{{< badge text="Deprecated" tone="danger" >}}

Default Info Supported Experimental Deprecated

Without tone the badge is neutral. Any other value fails the build, and the error names the source location.

Inside a sentence

A badge is an inline element that follows a name; it never takes its own line.

Source
With `params.ui.image_zoom` {{< badge text="off by default" tone="neutral" >}} enabled,
block images that have alt text open full size. PlantUML {{< badge text="needs a server" tone="warning" >}}
and Draw.io {{< badge text="needs a server" tone="warning" >}} fail the build when no endpoint is
configured, rather than reaching for a public service.

With params.ui.image_zoom off by default enabled, block images that have alt text open full size. PlantUML needs a server and Draw.io needs a server fail the build when no endpoint is configured, rather than reaching for a public service.

Next to a heading

Never put a shortcode in a heading. Hugo builds the table of contents before it expands shortcodes, so the badge renders correctly on the heading while the table of contents is left with an internal Hugo placeholder. Put the status in the first paragraph under the heading instead:

Source
### OpenAPI pages {#openapi-example}

{{< badge text="new in 0.5" tone="success" >}} This section covers…

OpenAPI pages

new in 0.5 The badge sits just under the heading, the table of contents stays clean, and sharing the anchor link does not drag the badge text along.

In table cells

Badges make a comparison table easier to scan than a column of “yes” and “no”.

Source
| Component | Form | Status |
| --- | --- | --- |
| Callouts | `> [!NOTE]` | {{< badge text="stable" tone="success" >}} |
| Galleries | ` ```gallery ` fence | {{< badge text="stable" tone="success" >}} |
| PlantUML | ` ```plantuml ` fence | {{< badge text="needs a server" tone="warning" >}} |
| The `image` shortcode | — | {{< badge text="removed" tone="danger" >}} |
Component Form Status
Callouts > [!NOTE] stable
Galleries ```gallery fence stable
PlantUML ```plantuml fence needs a server
The image shortcode removed

In lists and steps

Source
1. Install Hugo Extended {{< badge text="≥ 0.160.1" tone="info" >}}
1. Clone the documentation site and change `baseURL` in `hugo.yml`
1. `hugo server` to preview {{< badge text="port 1313" tone="neutral" >}}
{.steps}
  1. Install Hugo Extended ≥ 0.160.1
  2. Clone the documentation site and change baseURL in hugo.yml
  3. hugo server to preview port 1313

On cards

A card has its own badge parameter — plain text, fixed to the right of the title — and the card body can hold badge shortcodes.

Source
{{< cards >}}
{{< card title="Hugo Module" icon="fa-brands fa-golang" badge="recommended" >}}
One `hugo mod get` and you are done {{< badge text="needs Go" tone="info" >}}
{{< /card >}}
{{< card title="Offline archive" icon="fa-solid fa-box-archive" >}}
Builds on a machine with no network {{< badge text="manual upgrades" tone="warning" >}}
{{< /card >}}
{{< /cards >}}
Hugo Modulerecommended

One hugo mod get and you are done needs Go

Offline archive

Builds on a machine with no network manual upgrades

With link the badge becomes an <a>: site paths, relative paths, http(s): and mailto: all work.

Source
Current version {{< badge text="v0.5" tone="info" link="/blog/" >}};
for the upgrade steps see {{< badge text="Upgrading" tone="neutral" link="/docs/admin/upgrade/" >}}.

Current version v0.5; for the upgrade steps see Upgrading.

An illegal link — a scheme outside the allowlist — fails the build.

Output

Output Shape
HTML <span class="td-badge td-badge--<tone>">, or <a class="td-badge …"> when linked
Print Same as HTML, a static inline element
Markdown **Beta**, or [**Beta**](/…) when linked
RSS Same as print

No JavaScript. A badge is not a live region, so adding one does not announce anything to a screen reader.

Parameter reference

text , plain text , default
Required, non-empty. What the reader sees
tone , enum , defaultneutral
neutral info success warning danger
link , URL , default
Turns the badge into a link

Named parameters only. There is no icon, class, color, outline or size parameter; an unknown parameter, an empty text, an invalid tone and an illegal link all fail the build.

Limits

  • Colour is not the meaning: tone supplements the text, which has to say it. {{< badge text="🔴" >}} tells a screen reader nothing.
  • No icon parameter: when you need an icon, use cards or a callout.
  • Keep the text short: a badge follows a name without wrapping, so anything longer than a few words belongs in the prose.
  • No more than three in one place: a row of badges drowns out the name it qualifies.
  • Badges exist only as a shortcode — there is no native Markdown form — and in a plain Markdown reader they degrade to bold text.
  • Cardscard has a badge parameter of its own
  • File treestone uses the same vocabulary
  • Keys — the other inline shortcode
  • Callouts — when the status needs explaining

4.19 - Kbd

Write shortcuts with kbd — one shortcode, a list of key names, a semantic key sequence that stays readable in print and in Markdown output.

Keys separate what the reader has to press from the prose. Use it for shortcuts and chords: one positional parameter per key, and the theme draws the caps, adds the separators, and gives screen readers a readable sequence. Command names, flags and text to type are inline code — they are not physical keys.

Shortest form

Source
Press {{< kbd "Ctrl" "K" >}} to open the command palette.

Press Ctrl with K to open the command palette.

Parameters must be quoted, one key per parameter. Fewer than one key, an empty string, or a named parameter all fail the build.

A single key

One parameter is one key, and symbol keys are written as they are.

Source
{{< kbd "Escape" >}} closes a dialog;
{{< kbd "/" >}} jumps to search;
{{< kbd "t" >}} toggles light and dark;
{{< kbd "l" >}} cycles through languages.

Escape closes a dialog; / jumps to search; t toggles light and dark; l cycles through languages.

Chords

Several parameters render in order with + between them. That plus sign is hidden from assistive technology, which hears a localized connector instead.

Source
{{< kbd "⌘" "Shift" "P" >}} and {{< kbd "Ctrl" "Shift" "P" >}} are the same action.
For a literal plus, treat it as a key of its own: {{< kbd "Ctrl" "+" >}} zooms the page in.

with Shift with P and Ctrl with Shift with P are the same action. For a literal plus, treat it as a key of its own: Ctrl with + zooms the page in.

Platform differences

Write the label printed on the reader’s keyboard: on macOS, Ctrl on Windows and Linux. Never merge two platforms into one sequence — a spelling like Ctrl/⌘ cannot be read aloud correctly. Say which platform in the sentence, or split into tabs.

Source
On macOS press {{< kbd "⌘" "K" >}}; on Windows and Linux, {{< kbd "Ctrl" "K" >}}.

On macOS press with K; on Windows and Linux, Ctrl with K.

Shortcut tables

A cheatsheet is where keys most often live. Here are some of the global keys this site honours:

Source
| Key | Action |
| --- | --- |
| {{< kbd "Ctrl" "K" >}} | Open the command palette ({{< kbd "⌘" "K" >}} on macOS) |
| {{< kbd "/" >}} | The palette's full search state |
| {{< kbd "t" >}} | Toggle light and dark |
| {{< kbd "q" >}} / {{< kbd "e" >}} | Previous / next page |
| {{< kbd "w" >}} {{< kbd "s" >}} {{< kbd "a" >}} {{< kbd "d" >}} | Move, collapse and expand in the sidebar tree |
| {{< kbd "Escape" >}} | Leave the sidebar tree for the article |
Key Action
Ctrl with K Open the command palette ( with K on macOS)
/ The palette’s full search state
t Toggle light and dark
q / e Previous / next page
w s a d Move, collapse and expand in the sidebar tree
Escape Leave the sidebar tree for the article

The complete list of site-wide shortcuts is in keyboard navigation.

In steps

Source
1. Press {{< kbd "Ctrl" "K" >}} to open the command palette
1. Type `>` for the command-only state, or type a keyword to search
1. Select with {{< kbd "↑" >}} {{< kbd "↓" >}} and press {{< kbd "Enter" >}} to go
1. {{< kbd "Escape" >}} closes it and focus returns where it was
{.steps}
  1. Press Ctrl with K to open the command palette
  2. Type > for the command-only state, or type a keyword to search
  3. Select with and press Enter to go
  4. Escape closes it and focus returns where it was

Raw <kbd> tags

A raw <kbd> tag in Markdown gets the same styling, and GitHub renders it too. The difference is that the separators and the accessible sequence are then yours to maintain: either spelling works for a single key, but use the shortcode for chords.

Source
Press <kbd>F5</kbd> to reload; in an editor, <kbd>Ctrl</kbd>+<kbd>S</kbd> saves.

Press F5 to reload; in an editor, Ctrl+S saves.

Output

Output Shape
HTML <span class="td-kbd-sequence"> around one <kbd> per key; the visible + is hidden from screen readers, which get a localized connector
Print Same as HTML, static
Markdown Plain text: Ctrl + K, ⌘ + Shift + P
RSS Same as print

Without CSS or JavaScript the instruction is still readable.

Parameter reference

Positional 1..n , string , default
At least one; each must be non-empty and quoted; order is display order

Positional parameters only. There is no separator, label, platform, class or size: Hugo does not allow positional and named parameters in one shortcode call.

Limits

  • One sequence is one set of keys pressed together: press-A-then-B is two kbd calls and a sentence — press Escape, then Enter.
  • No platform detection: the page never swaps Ctrl for based on the visitor’s operating system.
  • No key mapping or recording: menu paths, gestures and gamepads are out of scope.
  • Missing quotes fail the build: Ctrl in {{< kbd Ctrl K >}} is not a string parameter.
  • Do not use it for commands: hugo server is inline code; Ctrl is a key.

4.20 - Includes

Pull an external file in with include, print a site parameter with param, and write a note that reaches no output at all with comment.

Three shortcodes, one job each: include puts another file’s contents into this page, param prints a page or site parameter, and comment discards a passage. They are for fragments reused across pages and constants scattered over many: one set of install steps that appears on three pages is an include, a version number that appears on dozens is a param, and either way you edit one place. Content that appears on one page belongs on that page.

Shortest form

include takes one required parameter, file:

Source
{{< include file="parts/install-oink.md" >}}

The file it pulls in is ordinary Markdown living under assets/:

assets/parts/install-oink.md
Installing OINK into an existing Hugo site takes three commands:

```sh
hugo mod init github.com/you/your-site
hugo mod get github.com/pgsty/oink
hugo server
```

> [!NOTE]
> `hugo mod get` needs Go on the machine; an offline archive or a submodule does not.

The current release is {{< param version >}}.

The result is what you would get by writing it here: the code block has its copy button and the callout is a callout.

Installing OINK into an existing Hugo site takes three commands:

hugo mod init github.com/you/your-site
hugo mod get github.com/pgsty/oink
hugo server
Note

hugo mod get needs Go on the machine; an offline archive or a submodule does not.

The current release is v0.6.0.

The file that gets included is not a page of its own: it is absent from the sidebar, it takes no part in translation pairing, and it has no URL.

Where the file comes from

file resolves in this order, first match wins:

Order Looked up as Written as
1 A page resource — a file in this page’s bundle file="config.yaml"
2 A global resource under assets/ file="snippets/dsn.txt"
3 A file under content/: a leading / is the content root, otherwise relative to the page’s directory file="notes/caveat.md", file="/shared/notice.md"

Missing in all three, the build fails; nothing is emitted as a placeholder. A .. in the path fails the build too: include reads from content/ and assets/ and nowhere else.

A Markdown fragment is read as source, so write the file’s real name on disk. One trap belongs to step 1 alone: Hugo attaches a language-suffixed page resource such as notice.zh.md under its stripped name, so asking a bundle for notice.md hands include already-rendered HTML instead of the source, and <div class="td-code"> turns up in the Markdown output. Under assets/ and content/ the name you write is the file you get. Non-Markdown files (.yaml, .sh, .txt) never have this distinction.

Each language of this page includes its own fragment: English pulls assets/parts/install-oink.md, Chinese pulls assets/parts/install-oink.zh.md. Keeping them under assets/ rather than in the page bundle is what lets both languages fetch the source under the name they write.

Including code files

code=true renders the file as a code block, and lang= sets the highlighting language. Point it at a real file in the repository and the documentation cannot drift from it.

Source
{{< include file="parts/module.yml" code=true lang="yaml" >}}
module:
  imports:
    - path: github.com/pgsty/oink
  hugoVersion:
    extended: true
    min: 0.160.1

Code blocks and fences share one pipeline: highlighting, line numbers and the copy button all work. Fence attributes (title=, collapse, hl_lines=) cannot be passed through; when you need them, write the content as an ordinary code block.

What a fragment can contain

A fragment is page-level Markdown rendered in the current page’s context: callouts, tables, lists, images, steps and shortcodes all work. The last line of the fragment above — “The current release is v0.6.0” — is its {{< param version >}} expanded on this page.

When two pages include one fragment, each renders it separately and each generates its own heading anchors and code-block IDs. They do not collide.

What makes a good fragment

Install commands, connection strings, support matrices, legal notices: content that changes, and that must change everywhere at once. Content that appears on one page belongs on that page.

Printing a site parameter

param prints one parameter: this page’s front matter first, then the site configuration — Hugo’s .Param rule.

Source
This site publishes {{< param version >}}, copyright from {{< param copyright.from_year >}},
and this page's front matter says `pigsty_pg_major: 18`, which reads back as {{< param pigsty_pg_major >}}.

This site publishes v0.6.0, copyright from 2026, and this page’s front matter says pigsty_pg_major: 18, which reads back as 18.

Nested keys join with ., so copyright.from_year reads params.copyright.from_year. A parameter that does not exist, or whose value is a map or a list rather than a scalar, fails the build instead of leaving a gap.

Parameters inside commands, tables and links

param emits escaped plain text, so it can sit in a code fence, a table cell or a link target. A version number in an install command is the obvious case:

Source
```sh
hugo mod get github.com/pgsty/oink@{{< param tdVersion.latest >}}
```

| Item | Value |
| --- | --- |
| Current version | {{< param version >}} |
| Hugo floor | {{< param hugoMinVersion >}} |

[Release notes](https://github.com/pgsty/oink/releases/tag/{{< param tdVersion.latest >}})
hugo mod get github.com/pgsty/[email protected]
Item Value
Current version v0.6.0
Hugo floor 0.160.1

Release notes

Where site parameters are defined and which exist is in Configuration; page parameters are in front matter.

Notes deleted at build time

A comment body appears in none of the four outputs — HTML, print, Markdown, RSS. An HTML comment is different: it stays in the page source and reaches llms.txt.

Source
Since PostgreSQL 18, `pg_stat_io` breaks out WAL statistics.

{{< comment >}}
TODO: after v0.5 ships, bump the version above to 19 and add a pg_stat_io screenshot.
This text reaches no output at all, llms.txt included.
{{< /comment >}}

Verify the dashboards on a test database before upgrading.

Since PostgreSQL 18, pg_stat_io breaks out WAL statistics.

Verify the dashboards on a test database before upgrading.

There is a comment between those two paragraphs, and viewing the page source will not find it.

Output

Output include (Markdown) include code=true param comment
HTML The fragment renders as normal content Highlighted code block + copy button Escaped plain text nothing
Print As HTML As HTML, without the copy button As HTML nothing
Markdown The fragment’s source, as written A source fence The value itself nothing
RSS As HTML As HTML As HTML nothing

In Markdown output a fragment is source rather than HTML, and shortcodes inside it stay as {{< param version >}}. That is consistent with “Markdown output keeps the source”; it is not a missed render. None of the three shortcodes loads a script.

Parameter reference

include (named parameters only):

file , path (required) , default
Resolution order in Where the file comes from; a .., a missing file and an empty value all fail the build
code , boolean , defaultfalse
true renders as a code block; it must be code=true — a quoted code="true" is a string and fails
lang , string , default
Code language; valid only with code=true, and fails on its own

Any other parameter name fails the build, with the file and line in the error.

param (one positional parameter):

parameter name , string (required) , default
Nested keys join with .; page front matter first, then site params; missing or non-scalar (map / list) fails the build

comment takes no parameters. It is used in pairs, and everything between {{< comment >}} and {{< /comment >}} is discarded.

Limits

  • include is not a template: you cannot pass variables to a fragment, include conditionally, or give the included code block fence attributes (title=, collapse). For per-platform variants, write two fragments and use tabs.
  • Fragment languages are yours to maintain: include does no language fallback and takes the exact path you write. Share one fragment across languages — this page’s Chinese translation includes the same English file — or write one per language and point each page at its own.
  • param prints scalars only: structured data — version matrices, download lists — belongs in data/ and is rendered by the matching component.
  • comment is not “unpublish for now”: the content is discarded on every build. To take a whole page down temporarily, use draft: true.
  • Do not use include to build an index page: a page that pulls in ten fragments is a page where the reader wanted ten links.
  • Code blocks — every fence attribute, and the pipeline include code=true reuses
  • Tabs — per-platform or per-language fragments
  • Configuration — the site parameters param can reach
  • Front matter — page parameters, which win over site configuration

4.21 - Asciinema

Put a .cast terminal recording on the page — the text stays selectable text, and the player ships with the theme rather than coming from a CDN.

asciinema renders a .cast recording as a terminal player on the page. It suits command-line walkthroughs: the text in the terminal is still text, it can be selected and copied, and the near-two-minute install excerpt on this page is about 110 KB. Graphical interfaces belong in screenshots or video — this component plays terminal recordings only. The player and its styles ship with the theme, nothing is downloaded at build time, no CDN is contacted at runtime, and only a page that uses it loads the runtime.

Shortest form

file is the only required parameter:

Source
{{< asciinema file="images/install.cast" >}}
images/install.cast

The recording is a single-node Pigsty install on a Debian machine in a 120×36 terminal, trimmed to the first minute and 54 seconds. The file lives at static/images/install.cast on this site, so the path is written from the site root. A file under assets/ is written as a relative path: the theme looks in resources first and falls back to treating the value as a site-root path. Without title, the window title shows the value of file.

Window title and theme

title sets the window title, theme the colours:

Source
{{< asciinema file="images/install.cast" title="Pigsty single-node install" theme="dracula" >}}
Pigsty single-node install

theme defaults to auto: it follows the site’s colour scheme, td-light in light and td-dark in dark, remounting in place when the reader switches. To pin a terminal palette, the values are the player’s own asciinema, dracula, gruvbox-dark, monokai, nord, seti, solarized-dark, solarized-light, tango, plus the theme’s td-light / td-dark. A pinned theme stops following the colour scheme, and solarized-light on a dark site does not have workable contrast. The terminal font needs no setting: the player uses the site’s code font, the one the code blocks use.

Speed, start point and poster

Three parameters control where a long recording starts: speed sets the rate, startAt skips the opening, poster decides the frame shown before playback.

Source
{{< asciinema file="images/install.cast" title="From 60 seconds in, at double speed"
  speed="2" startAt="60" poster="npt:1:30" >}}
From 60 seconds in, at double speed

speed and startAt are numbers (seconds) and poster uses the player’s npt: notation for a point in time, so npt:1:30 is one minute thirty. The player above rests on the frame at 90 seconds and starts playing from 60.

idleTimeLimit compresses silent stretches to at most N seconds. This recording was already compressed while recording (idle_time_limit: 0.5 in the .cast header), so it does not need it. Only files recorded without an idle limit do.

Size and fit

The player scales to the container width by default (fit="width"), and the terminal’s rows and columns come from the .cast header. cols / rows override that:

Source
{{< asciinema file="images/install.cast" title="Only 16 rows tall" rows="16" >}}
Only 16 rows tall

A size smaller than the recording clips it — the one above shows 16 of the 36 rows. cols / rows exist to correct a wrong size in the recording’s header; they are not a layout tool. To make the player shorter, record again in a smaller terminal.

fit takes four values: width (the default, scale to width), height (to height), both (fit both axes) and none (no scaling — a wide terminal overflows).

Looping and preloading

loop replays at the end, and preload fetches the .cast when the page loads so pressing play does not wait:

Source
{{< asciinema file="images/install.cast" title="Looping: the first minute after login"
  startAt="0" speed="3" loop="true" preload="true" >}}
Looping: the first minute after login

autoplay="true" starts playback as the page opens. It is not recommended: a “reduce motion” preference only disables the transitions on the player’s controls, it does not stop autoplay. When you really need it, pair it with loop, keep the clip very short, and put only one on a page.

Inside steps

Put the recording next to the step: the text says what to do, the recording shows what it looks like.

Source
1. Install the dependencies and fetch the installer:

   ```sh
   curl -fsSL https://repo.pigsty.io/get | bash
   ```

2. Run the install; here are the first two minutes:

   {{< asciinema file="images/install.cast" title="pig install" speed="4" >}}

3. Open `http://<node address>:3000` and sign in to Grafana with `admin / pigsty`.
{.steps}
  1. Install the dependencies and fetch the installer:

    curl -fsSL https://repo.pigsty.io/get | bash
  2. Run the install; here are the first two minutes:

    pig install
  3. Open http://<node address>:3000 and sign in to Grafana with admin / pigsty.

A page can hold several players, and the script and styles load once.

Recording a cast file

The theme only plays. Record with asciinemaasciinema rec --idle-time-limit=2 --cols=100 --rows=28 install.cast — and check it locally with asciinema play install.cast.

  • Keep the terminal under 100 columns so it stays readable on a narrow screen, and clear before you start.
  • Clear secrets first: a .cast is plain text and every character in the recording is greppable. Check before committing.
  • Put the file in static/images/ or in the page bundle and commit it. Do not reference a .cast URL on someone else’s site.

Output

Output Shape
HTML A <div class="td-asciinema"> window frame plus the player; the player CSS/JS and the init script load on demand, once per page
Print As HTML — the print output loads the player too; on paper you get whichever frame was showing
Markdown The same container HTML plus a JSON configuration block; the only readable text is the window title
RSS The same static markup; readers do not run scripts, so an empty window frame is all that is left

A recording must never be the only source of information. Write the key commands and the key output beside it in text or a code block: offline readers, whatever consumes llms.txt, and anyone printing the page see only that text.

Parameter reference

file , path (required) , default
Named, or the first positional parameter; looked up as a global resource first, then as a site-root path; a full URL with a scheme is passed through unchanged
title , plain text , defaultthe value of file
The window title
theme , enum , defaultauto
auto follows the site’s colour scheme; or td-light td-dark asciinema dracula gruvbox-dark monokai nord seti solarized-dark solarized-light tango
fit , enum , defaultwidth
width height both none; anything else fails the build
cols / rows , integer , defaultfrom the .cast header
Override the terminal size; smaller than the recording clips it
speed , number , default1
Playback rate
startAt , number (seconds) , default0
Where playback starts
idleTimeLimit , number (seconds) , defaultfrom the .cast header
Longest a silent stretch plays for
poster , string , default
The frame shown before playback, npt:mm:ss
autoplay , "true" / omitted , defaultoff
Play as the page opens; not recommended
loop , "true" / omitted , defaultoff
Replay at the end
preload , "true" / omitted , defaultoff
Fetch the .cast when the page loads
pauseOnMarkers , "true" / omitted , defaultoff
Pause at chapter markers
markers , time:label,time:label , default
Chapter markers; see the limits — the labels do not reach the player today

The boolean-ish parameters compare against the text true: loop="true" and loop=true both enable, anything else disables. fit is validated by the theme and an illegal value errors with the parameter name. Numeric parameters (speed, cols, rows, startAt, idleTimeLimit) fail conversion — and the build — when they are not numbers.

Limits

  • markers labels are lost: the theme flattens the time:label list into a one-dimensional array, and the player accepts only pairs, so the timeline ends up with unlabelled markers. When you need chapters, write a list beside the recording.
  • The player needs JavaScript: with scripts disabled, and in Markdown and RSS output, only an empty window remains — see Output.
  • Recordings are not searchable: the site index covers page text, so a command that only appears in a recording cannot be found.
  • Do not reference a remote .cast: a file with a scheme is passed to the player unchanged, and the page then depends on someone else’s site.
  • Keep each clip short: few people finish a recording longer than five or six minutes. Split a long procedure into several short ones, each with its own text.
  • Code blocks — the key commands and output, copyable
  • Steps — the recording beside the step it belongs to
  • Images — static screenshots: recordings for terminals, screenshots for graphical interfaces
  • Include — when the same commands appear on several pages

5 - 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.

5.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.
  • A few things still stop the build, and they are the ones where carrying on would publish something wrong rather than merely plain. A feature needing an external endpoint — PlantUML, Draw.io, Algolia — errors when the endpoint is missing, because the theme never connects to a public service on your behalf. An incomplete upstream attribution errors, because a partial notice reads exactly like a complete one. params.offline_search_index, the release facts and unresolvable content references do the same.

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

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

Three 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. 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 either 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. Year grouping, pagination and manual_link behave the same in both
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

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.

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.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 fails the build
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 the build fails

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.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

Custom fonts and colours go through the SCSS entry points rather than YAML — 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.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; 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 fails the build
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 fails the build
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 two custom output formats and does not enable them for a site: request what you want under outputs.

hugo.yml
outputs:
  home: [HTML, markdown, LLMS]
  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
print _print/index.html The whole-section print page the theme declares — see Print
RSS index.xml Hugo’s own; attach it to section so every section has a feed

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>.languageDirection , 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 , defaultMonday, January 02, 2006
Blog date format, overridable per language
params.time_format_default , string , defaultJanuary 2, 2006
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)

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"
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, and the currently verified version is 0.164.0. Building against both after a configuration change catches anything that only works on the newer one:

# the floor binary
/path/to/hugo-0.160.1 --printPathWarnings --panicOnWarning
# the currently verified 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.

5.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).

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 fails the build (invalid params.ui.typography). 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 Where it is used
--td-ui-font-family Navigation, controls and interface text
--td-body-font-family Body text and blog posts
--td-heading-font-family Headings in the body
--td-code-font-family Code and terminals
--td-display-font-family Wordmark and display headings
--td-meta-font-family Technical labels and metadata
--td-print-font-family Print body text

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 neither a remote font URL nor arbitrary CSS: font files and styles must both be auditable local inputs.

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 fails the build.

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 fails the build.

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.

5.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: Clone the documentation site, delete what you do not need, make the rest yours.
  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: OINK 0.4.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. It does not accept image, and having both fails the build.

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:[email protected]' }

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, and fails the build rather than rendering an empty page when nothing is found:

  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 fails the build with a message 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.

5.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: Install Hugo, clone this site, deploy in ten minutes
    - 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 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";
  • 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.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, and anything else fails the build.

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 fails the build (data/docs_nav.json does not define any Docs navigation sections), and a page pointing at a page that does not exist fails too (Docs navigation page not found). 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 fails the build. 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, and an invalid value fails the build:

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 fails the build.

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.

5.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 fails the build with 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; exclude_search and excludeSearch fail the build with the new name. 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

5.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, and any other key fails the build:

  • 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.

5.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 fails the build with 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

5.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 interface strings for 32 locales. English, Simplified Chinese (zh and zh-cn) and Traditional Chinese (zh-tw) are reviewed; the rest keep the translations inherited from Docsy, with English fallbacks for the labels OINK added. To change one, 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

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
    languageDirection: 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.

5.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.

5.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.

5.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 fails the build with 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 (validated as a GitHub username, and a duplicate fails the build); 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

5.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
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.

5.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.

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 documentation theme that needs nothing but Hugo Extended…
- [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 documentation theme that needs nothing but Hugo Extended…
  - [Highlights](https://oink.pgsty.com/docs/about/features/index.md): What separates OINK from an ordinary Hugo theme…
  - [Showcase](https://oink.pgsty.com/docs/about/showcase/index.md): Fourteen production sites run on OINK…
- [Get started](https://oink.pgsty.com/docs/start/index.md): Clone the OINK documentation site, preview it locally, replace the site details, and deploy to GitHub Pages.

## 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.

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 and generates llms.txt with layouts/index.llms.txt. A site replaces either 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.

Verify

hugo -d public
ls public/llms.txt public/docs/customize/agents/index.md

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

Then check three 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.

Limits

  • The machine-readable surface the theme produces is exactly two things: a .md per page and llms.txt. There is no nav.json and no other structured index interface; the sitemap is still Hugo’s own sitemap.xml.
  • The LLMS output format is declared as a non-alternative format, so llms.txt never appears in the <head> alternate links and has no page action. It is discovered by its conventional root path.
  • 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.

6 - Operations

Running the site from a laptop to production — local preview, deployment, comments, analytics and SEO, upgrades and troubleshooting.

This section covers what happens after the content is written: previewing locally, building and deploying the output, wiring up comments and analytics, following theme versions, and locating faults. The previous five sections decide how the site looks and what it says; this one decides whether it builds, where it is deployed, and how a problem is diagnosed.

Find it by task

What you want to do Where to go
See a change on your own machine Local preview
Build a deployable public/ Local preview
Deploy to GitHub Pages / Cloudflare / Netlify Deploy
Deploy to a subpath such as example.com/docs/ Deploy
Let readers comment at the bottom of a page Comments
Connect Google Analytics or a self-hosted alternative Analytics and SEO
Get indexed correctly by search engines Analytics and SEO
Upgrade the theme, or migrate from Docsy or 0.4 Upgrade
A build error, no search results, a 404 Troubleshooting

6.1 - Local preview

Preview changes with hugo server, build a deployable public/ with hugo –panicOnWarning, and need neither Node nor a CDN.

Two commands cover the daily work: hugo server previews changes locally, and hugo produces a public/ deployable to any static host. The prerequisite is Hugo Extended (0.160.1 or newer) on the machine, plus Go when the theme comes in as a Hugo Module. The build depends on no Node.js, npm or PostCSS — those serve only this repository’s own regression checks.

The preview server

From the site root (the directory holding hugo.yml):

Terminal
hugo server

Open http://localhost:1313/. Saving a file rebuilds and refreshes the browser, and switching Git branches triggers a rebuild too. The first start is slower: with the theme as a Hugo Module, Hugo has to download the module through Go into its cache, and every start after that reads the cache.

The switches worth knowing

-D / --buildDrafts , defaultoff
Also builds pages with draft: true
-F / --buildFuture , defaultoff
Also builds pages whose date / publishDate is in the future
-E / --buildExpired , defaultoff
Also builds pages whose expiryDate has passed
--disableFastRender , defaultoff
Re-renders the whole site on every change instead of incrementally
-M / --renderToMemory , defaultoff (writes to disk)
Renders in memory only, writing no public/
-N / --navigateToChanged , defaultoff
The browser jumps to whichever page you saved
--bind , default127.0.0.1
The listen address; use 0.0.0.0 to reach it from a LAN or outside a container
-p / --port , default1313
The listen port
--minify , defaultoff
Minifies the preview too, to reproduce production rendering
--printPathWarnings , defaultoff
Warns when two pages write to the same target path

The combination used while developing this site:

Terminal
hugo server -DFE \
  --disableFastRender --renderToMemory --minify \
  --printPathWarnings --logLevel info

-DFE is shorthand for -D -F -E, building drafts, future and expired pages together so a newly created page is visible while writing.

A change that did not take effect

Hugo enables fast render by default, rebuilding only what it judges affected. When editing layouts, configuration, data/, or a file pulled in by include, that judgement can miss, and the page appears unchanged. Three steps:

  1. Restart with --disableFastRender and see whether it comes back.
  2. Hard-refresh the browser (Cmd/Ctrl + Shift + R) to rule out browser cache.
  3. If it still does not, clear the caches and restart.

Reaching it from another device

hugo server listens on 127.0.0.1 only, so no other device can reach it. To preview on a phone or another machine:

Terminal
hugo server --bind 0.0.0.0 --port 1313 --baseURL http://192.168.1.10:1313/

--baseURL must be an address the other device can reach, or the page opens while CSS and the search index — anything using an absolute path — point at localhost.

Production build

Build deployable output with hugo, not hugo server:

Terminal
hugo --gc --minify --printPathWarnings --panicOnWarning

The output goes to public/, which can be deployed independently of the source tree. Each of the four switches does one thing:

--gc
Clears cached resources in resources/_gen that are no longer referenced
--minify
Minifies the HTML, CSS, JS and XML output
--printPathWarnings
Warns when two pages collide on one output path, the commonest silent error on a multilingual site
--panicOnWarning
Fails the build on the first WARNING

--panicOnWarning deserves its own note. Most of OINK’s degradation paths warn rather than error: a missing required giscus key, an unsupported params.comments.type, a configuration key Hugo has deprecated — each prints one WARNING and moves on. CI logs are rarely read line by line, so those reach production. Putting this switch in the build command makes zero warnings the condition for a passing build.

This site’s CI build step (.github/workflows/pages.yml) is hugo --cleanDestinationDir --gc --minify --environment production --printPathWarnings --panicOnWarning, so any warning stops the deployment at the build stage.

baseURL and the build environment

baseURL lives in hugo.yml and can be overridden on the command line:

hugo.yml
baseURL: https://oink.pgsty.com
Terminal
hugo --minify --baseURL "https://example.com/docs/"

When deploying to a subpath, --baseURL must include that path segment; the details are in Deploy.

The build environment is chosen with -e / --environment; hugo defaults to production and hugo server to development. That choice has three visible consequences in OINK:

  • Only production emits <meta name="robots" content="index, follow">; other environments emit noindex, nofollow.
  • Under production robots.txt is Allow: /; elsewhere it is Disallow: /.
  • Only production renders Hugo’s Google Analytics template, and only there are static assets fingerprinted with SRI.

Build preview deployments (PR previews, staging) with a non-production environment, and the output declines indexing and analytics by itself:

Terminal
hugo --minify --environment staging --baseURL "$PREVIEW_URL"

Previewing in a container

A container is not required. Two situations suit one: a team that needs a pinned toolchain version, or one that would rather not install Hugo on every developer machine.

Dockerfile
FROM debian:bookworm-slim

ARG HUGO_VERSION=0.164.0
ARG GO_VERSION=1.26.6
ARG TARGETARCH

RUN apt-get update \
    && apt-get install -y --no-install-recommends ca-certificates curl git \
    && curl -L -o /tmp/hugo.deb \
      "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-${TARGETARCH}.deb" \
    && apt-get install -y /tmp/hugo.deb \
    && curl -L -o /tmp/go.tgz \
      "https://go.dev/dl/go${GO_VERSION}.linux-${TARGETARCH}.tar.gz" \
    && tar -C /usr/local -xzf /tmp/go.tgz \
    && rm -rf /var/lib/apt/lists/* /tmp/hugo.deb /tmp/go.tgz

ENV PATH="/usr/local/go/bin:${PATH}"
WORKDIR /src
EXPOSE 1313
ENTRYPOINT ["hugo"]
CMD ["server", "--bind", "0.0.0.0", "--disableFastRender"]
Terminal
docker build -t oink-hugo .

# preview: mount the site source, and the Go module cache with it
docker run --rm -it -p 1313:1313 \
  -v "$PWD:/src" \
  -v "$HOME/go/pkg/mod:/root/go/pkg/mod" \
  oink-hugo

# production build: override the default server command
docker run --rm --user "$(id -u):$(id -g)" \
  -v "$PWD:/src" \
  oink-hugo --gc --minify

Go is in the image because Hugo needs it to resolve and download the module when the theme comes in as a Hugo Module. A site using a submodule, an offline archive or a plain clone can drop Go, and the image gets much smaller.

Do not let root write public/

A container process is root by default, the generated public/ belongs to root, and the host cannot delete it. In a shared environment, map the user ID with --user "$(id -u):$(id -g)" (the production build command above already does).

The image needs no Node.js, npm or PostCSS, and should have no step fetching remote browser assets. A network-isolated environment needs the base image and those two packages mirrored in advance.

Clearing caches

Hugo’s intermediate output lives in three places; clear them lightest first:

public/ , ContentsThe previous build’s output
A page was deleted but is still live; or let the build clear it with hugo --cleanDestinationDir
resources/_gen/ , ContentsProcessed images and compiled CSS
Image processing parameters, fonts or the accent colour changed and the page still looks old
hugo mod clean , ContentsThe Hugo Module cache
The theme version changed but the old one still resolves; add --all to clear the whole module cache
Terminal
rm -rf public resources/_gen
hugo mod clean          # only the modules this project uses
hugo mod clean --all    # the whole module cache; the next build downloads again

Both public/ and resources/ belong in .gitignore; generated output is never committed.

Working on the theme alongside

This section applies only when changing the theme and the site together. Point the module at a local checkout temporarily with HUGO_MODULE_REPLACEMENTS, leaving go.mod untouched:

Terminal
HUGO_MODULE_REPLACEMENTS='github.com/pgsty/oink -> /absolute/path/to/oink' hugo server

This site’s Makefile wraps those commands and expects the theme checkout at the sibling ../oink:

Makefile targets
make dev     # development server with ../oink substituted
make check   # full regression suite (npm test) with ../oink substituted
make build   # build with the version in go.mod
make serve   # preview server with the production configuration
A replacement is local only

Whether through the environment variable or a Go workspace (go work init plus HUGO_MODULE_WORKSPACE=go.work), CI and production builds read only go.mod; go.work records a developer machine’s paths and is never committed. To judge whether a release tag works, drop the replacement and build once against the version in go.mod.

Verifying an offline build

Acceptance in a network-isolated environment has to cover both the build stage and the browser stage. Six steps:

  1. Start from a verified theme archive and an empty module cache (hugo mod clean --all).
  2. Block outbound HTTP, HTTPS and the Go module proxy.
  3. Run the production build hugo --gc --minify --printPathWarnings --panicOnWarning.
  4. Browse pages in both languages: a documentation page, a blog page, the home page, the 404.
  5. Exercise search, the light/dark toggle, diagrams and content components.
  6. Check subresource origins and confirm there is no unexpected remote host.

The last step uses a script from the theme repository that does not depend on the site’s test framework:

Terminal
python3 bin/check-output-security.py \
  --public public --base-url https://docs.internal.example.com/

The script scans every href / src / srcset / poster and form action in all four outputs, requiring each to be a site-relative path or http / https / mailto / tel, and rejecting inline on* handlers and javascript: URLs. An <iframe>, <script>, <link>, <img>, <video>, <audio>, <embed>, <object> or <source> pointing at another host is an error; where a site genuinely embeds third-party content, --third-party permits it, and a multi-domain language configuration adds first-party hosts with --allow-host.

One pass proves that commit in that environment. Run it again for every theme candidate and after every bundled-dependency update.

Verify

A clean production build should look like this:

Terminal
rm -rf public resources/_gen
hugo --gc --minify --printPathWarnings --panicOnWarning

It passes on Total in … with no ERROR and no WARNING. Then confirm:

  • The log has no npm, PostCSS, Autoprefixer or browser-asset download step. One appearing means upstream Docsy’s process has crept into the configuration.
  • public/ has sitemap.xml and robots.txt, and robots.txt reads Allow: /.
  • On a site with local search, public/ has offline-search-index.<language>.json at its root.
  • Open representative pages with hugo server: one documentation page, one blog page, the home page and the 404, in both languages and both colour schemes.

For a failing build or a wrong result, see Troubleshooting.

6.2 - Deploy

Publish public/ to GitHub Pages, Cloudflare Pages or any static host — matching baseURL, Content Security Policy, the acceptance checklist and rollback.

An OINK site’s output is a plain static directory, deployable anywhere that hosts static files, with no Node runtime, no server-side rendering and no build plugin. The host’s side is three things: run one command with the right Hugo version, publish public/, and keep baseURL matching the final address.

The prerequisite is a warning-free production build locally.

Getting baseURL right

baseURL is the commonest source of failure, and it fails quietly: the page opens, but the search index 404s, page action links point at the wrong place, and some assets do not load.

Deploying at a domain root:

hugo.yml
baseURL: https://oink.pgsty.com

Deploying to a subpath (https://example.com/docs/), the path must be in baseURL:

hugo.yml
baseURL: https://example.com/docs/

It can also be overridden at build time, so one source deploys to several places:

Terminal
hugo --gc --minify --baseURL "https://example.com/docs/"
Do not fix a subpath with canonifyURLs

Hugo’s canonifyURLs defaults to false; keep that default. OINK’s templates and content links all resolve against baseURL: a wrong path means a wrong baseURL, and turning canonifyURLs on rewrites the relative links that were already correct, making the problem harder to locate.

To tell whether it matches, look at the search index request path after a build: the browser should fetch <baseURL>/offline-search-index.en.json, and fetching it from anywhere else means baseURL is wrong.

Choosing a host

With the source on GitHub, one Actions workflow is enough: the build runs in Actions and the output is published through the Pages deployment API, with no gh-pages branch to maintain.

Commit the following file:

.github/workflows/pages.yml
 1name: Deploy Oink site to GitHub Pages
 2
 3on:
 4  push:
 5    branches: [main]
 6  workflow_dispatch:
 7
 8permissions:
 9  contents: read
10  pages: write
11  id-token: write
12
13concurrency:
14  group: pages
15  cancel-in-progress: false
16
17env:
18  GO_VERSION: 1.26.6
19  HUGO_VERSION: 0.164.0
20  # a workspace from a sibling checkout must never take part in a CI build
21  GOWORK: off
22  HUGO_MODULE_WORKSPACE: off
23  HUGO_CACHEDIR: ${{ github.workspace }}/.hugo_cache
24  GOMODCACHE:
25    ${{ github.workspace }}/.hugo_cache/modules/filecache/modules/pkg/mod
26
27jobs:
28  build:
29    name: Build Pages artifact
30    runs-on: ubuntu-latest
31    steps:
32      - name: Checkout
33        uses: actions/checkout@v7
34        with:
35          fetch-depth: 0
36
37      - name: Set up Go
38        uses: actions/setup-go@v6
39        with:
40          go-version: ${{ env.GO_VERSION }}
41
42      - name: Set up Pages
43        id: pages
44        uses: actions/configure-pages@v6
45
46      - name: Install Hugo Extended
47        run: |
48          curl --fail --location --silent --show-error \
49            --output "${RUNNER_TEMP}/hugo.deb" \
50            "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb"
51          sudo dpkg -i "${RUNNER_TEMP}/hugo.deb"
52
53      - name: Download Hugo module
54        run: go mod download github.com/pgsty/oink
55
56      - name: Build site
57        run: |
58          hugo --cleanDestinationDir --gc --minify --environment production \
59            --printPathWarnings --panicOnWarning \
60            --baseURL "${{ steps.pages.outputs.base_url }}/"
61
62      - name: Upload Pages artifact
63        uses: actions/upload-pages-artifact@v5
64        with:
65          path: public
66
67  deploy:
68    name: Deploy to GitHub Pages
69    environment:
70      name: github-pages
71      url: ${{ steps.deployment.outputs.page_url }}
72    runs-on: ubuntu-latest
73    needs: build
74    steps:
75      - name: Deploy
76        id: deployment
77        uses: actions/deploy-pages@v5

That is the workflow this site uses. Several pieces cannot be removed:

  • fetch-depth: 0 — with enableGitInfo on, “last modified” and contributor information need the full Git history, and a shallow clone leaves them empty.
  • setup-go plus go mod download — with the theme as a Hugo Module, Hugo needs Go to resolve it. A site installing the theme as a submodule uses submodules: recursive instead, and one using an offline archive commits themes/oink/; either way both steps go.
  • GOWORK: off and HUGO_MODULE_WORKSPACE: off — keep a local development go.work from taking part in the CI build, so CI verifies the published tag pinned in go.mod.
  • --baseURL "${{ steps.pages.outputs.base_url }}/" — a project site’s URL is https://<OWNER>.github.io/<REPO>/, and configure-pages computes it, so it need not be hard-coded.
  • --panicOnWarning — a warning means no publish.

In the repository, set Settings → Pages → Build and deployment → Source to GitHub Actions, push to main, and watch the first run on the Actions tab.

A custom domain goes in the Custom domain field on that same settings page, with DNS configured as prompted, after which baseURL in hugo.yml becomes that domain. Where the publishing flow needs a CNAME file in the output, put it at static/CNAME and Hugo copies it into public/ unchanged.

Cloudflare Pages builds from a connected GitHub / GitLab repository and creates a preview deployment per review branch. The build happens on the platform side, so no workflow is needed in the repository.

Import the repository under Workers & Pages and choose the production branch:

Build command
hugo --gc --minify --printPathWarnings --panicOnWarning
Build output directory
public
HUGO_VERSION
0.164.0 (or another version the theme has verified)
GO_VERSION
Needed only for the Hugo Module method; pin a version the build image supports
SKIP_DEPENDENCY_INSTALL
1

Four notes:

  1. HUGO_VERSION must be set explicitly, in both the Production and Preview environments. The Cloudflare v3 build image’s default Hugo is older than OINK’s required 0.160.1, and leaving it unpinned changes the toolchain silently when the image updates.
  2. SKIP_DEPENDENCY_INSTALL=1 turns off the generic dependency install step. A consuming OINK site needs no Node.js, and a package.json present only for maintenance tooling should not be installed by the platform.
  3. Where the Hugo site is not at the repository root, set Root directory to the site directory; the output directory resolves against it.
  4. A preview deployment is not a production release. Where a preview needs the generated Pages URL as its base URL, use hugo --gc --minify --baseURL "$CF_PAGES_URL", and rebuild for production with the canonical domain.

Check the first build log: a healthy consuming OINK build is one Hugo command, with no npm, PostCSS or Autoprefixer step and no download of the theme’s own browser assets.

Netlify — build command hugo --gc --minify, publish directory public, environment variable HUGO_VERSION. The same settings can live in the repository:

netlify.toml
[build]
command = "hugo --gc --minify --printPathWarnings --panicOnWarning"
publish = "public"

[build.environment]
HUGO_VERSION = "0.164.0"

With the theme as a submodule, enable recursive submodule checkout; with a Hugo Module, the build environment needs Git and Go. Production and preview should use one Hugo version, unless the preview environment exists to test an upgrade.

Vercel — the same three things: build command hugo --gc --minify, output directory public, environment variable HUGO_VERSION. It likewise needs no npm install.

Any static server (Nginx / Caddy) — lay the contents of public/ down as they are:

/etc/nginx/conf.d/docs.conf
server {
    listen 80;
    server_name docs.example.com;
    root /var/www/oink;
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }

    error_page 404 /404.html;
}

The site is purely static; there is no path to forward to an application server.

Object storage — Hugo has a deploy command; put the target in the configuration:

hugo.yml
deployment:
  targets:
    - name: aws
      URL: 's3://www.your-domain.tld'
      cloudFrontDistributionID: E9RZ8T1EXAMPLEID

Run hugo deploy after a build: it compares the remote with public/, uploads only what changed, and invalidates the CDN cache when given a cloudFrontDistributionID. Without --target it uses the first target, and --dryRun shows what would change first. Two prerequisites: a Hugo binary built withdeploy (visible in hugo version), and cloud credentials supplied through the standard environment variables or configuration file (on AWS, confirm with aws s3 ls first).

Offline packaging — in a network-isolated environment, build on a connected machine and carry the output across as one package:

Terminal
hugo --gc --minify --baseURL "https://docs.internal.example.com/"
tar -czf oink-site-$(date +%Y%m%d).tar.gz -C public .

# on the target machine
tar -xzf oink-site-20260817.tar.gz -C /var/www/oink

Build with the target environment’s baseURL from the start; the absolute links in the output cannot be changed after unpacking.

A host without Go — the Hugo Module method needs Go in the build environment. Where a platform does not provide it, switch to a Git submodule (running git submodule update --init before the build) or an offline archive (committing themes/oink/) — see From scratch and other install methods.

Keeping preview deployments unindexed

Hugo’s -e / --environment selects build-time behaviour and does not change the site’s content, but three things in OINK follow it: only production emits <meta name="robots" content="index, follow">, only it makes robots.txt read Allow: /, and only it renders the Google Analytics template. Do not build PR previews and staging with --environment production:

Terminal
hugo --gc --minify --environment staging --baseURL "$PREVIEW_URL"

The output then carries noindex, nofollow and Disallow: /, and reports nothing to an analytics service.

Content Security Policy

The runtimes, fonts and icons the theme ships are all same-origin assets, so a strict Content Security Policy is workable. The theme provides no general policy: which directives you need depends on what the site enabled.

Five things change the directives needed:

  • Inline HTML and inline scripts written by authors, which are the author’s responsibility under renderer.unsafe: true.
  • ECharts $fn: callbacks: the callback functions are registered by the site on window.OinkEchartsFunctions, and the registering script’s origin belongs in script-src.
  • Analytics scripts: the script the site inserts, and the destination it reports to.
  • Remote API specifications and self-hosted diagram services: these land in connect-src and img-src.
  • giscus: script-src and frame-src must both permit it.

Start from a minimal policy covering only reviewed features and permit things one at a time: keep ECharts options pure data where no callback is needed, review inline scripts written by authors, and add a remote origin only for an integration the site deliberately enabled. Subresource origins in the output can be swept first with the script in Verifying an offline build.

Acceptance checklist

Walk this table after deploying. The first four are build-time; the rest have to be checked on the real URL.

A warning-free build
The build command carries --printPathWarnings --panicOnWarning and the log has Total in …
baseURL is correct
<link rel="canonical"> in the page source points at the real production address, subpath included
Sitemap
<baseURL>/sitemap.xml resolves; a multilingual site has an index pointing at /en/sitemap.xml and /zh/sitemap.xml
robots
<baseURL>/robots.txt reads Allow: / with a Sitemap: line; a preview deployment should read Disallow: /
Search index
The browser can fetch <baseURL>/offline-search-index.<language>.json, and site search returns results
Markdown output
Appending index.md to any page URL returns plain text (where the site enabled markdown under outputs.page)
llms.txt
<baseURL>/llms.txt and <baseURL>/zh/llms.txt resolve (where the site enabled LLMS under outputs.home)
Both languages
Documentation, blog and home pages open in both, and switching language lands on the corresponding page rather than the home page
Appearance and interaction
The light/dark toggle, the print view and representative components (callouts, tabs, code block copy) all work
404
Visiting a path that does not exist shows the site’s own 404 page

The switches for sitemap.xml, robots.txt, .md and llms.txt are in Configuration, and the agent output details are in AI-agent support.

Rollback

Rolling back a static site means republishing the last known-good commit; never edit files by hand in production.

  • GitHub Pages: find the last successful Deploy Oink site to GitHub Pages run in Actions and click Re-run all jobs; or git revert the offending commit and push again.
  • Cloudflare Pages / Netlify / Vercel: pick the last successful deployment from the list and use the platform’s Rollback / Publish deploy to make it production again.
  • A self-hosted static server: keep the previous tar.gz and unpack it over the top. The dated suffix in offline packaging exists for exactly this.

Where the problem is a theme upgrade rather than the content, what rolls back is the version pinned in go.mod — see Upgrade.

  • Local preview — the full production build command, clearing caches and offline verification
  • Troubleshooting — 404s, empty search, platform-specific faults
  • Analytics and SEO — being indexed correctly after launch
  • Upgrade — upgrading the theme version and rolling back
  • ConfigurationbaseURL, outputs and the other site keys

6.3 - Comments

Wire GitHub Discussions into a comment section at the bottom of a page with giscus — on site-wide, off per page, following light and dark.

OINK’s comments run on giscus: each page maps to one GitHub Discussion, readers sign in with a GitHub account to post, and maintainers moderate in GitHub Discussions. The theme provides no comment backend of its own and bundles no provider other than giscus.

The prerequisite is a public GitHub repository; a visitor cannot read a private repository’s Discussions.

This is one of the few features in the theme that makes an outbound request

A page with comments enabled loads a script and an iframe from https://giscus.app, which does not work in a network-isolated environment. It is off by default and loads only when explicitly enabled. Where a site has a privacy policy, this external data boundary belongs in it.

Preparing the GitHub repository

  1. Choose a public repository to hold the comment threads; the site’s source repository works.

  2. In the repository’s Settings → General → Features, tick Discussions.

  3. Install the giscus GitHub App for that repository. Without the App, visitors cannot comment or react.

  4. Choose a Discussion category. giscus recommends the Announcements type: only maintainers and the giscus bot can open a Discussion there, so readers cannot start one by accident.

The repository ID and category ID are public identifiers, not credentials. Never put a personal access token, an OAuth secret or a password in Hugo configuration.

Generating the configuration

Open giscus.app, fill in the repository, mapping and category, and the page generates a <script> block below. Copy four of its attributes into the OINK configuration:

data-repo
repo
data-repo-id
repoId
data-category
category
data-category-id
categoryId

The mapping decides which page corresponds to which Discussion. OINK defaults to pathname, which suits a site with stable published paths and one repository serving several domains or preview environments. Changing mapping or moving a page after comments have accumulated makes giscus look for a different Discussion: the existing comments are not deleted, but the page can no longer find them. Settle the mapping before launch; where a URL really must change, keep a redirect or rename the Discussion at the same time.

Enabling it site-wide

Write the generated identifiers into the site configuration:

hugo.yml
params:
  comments:
    enable: true
    type: giscus
    giscus:
      repo: pgsty/oink.pgsty.com
      repoId: R_kgDOTzFZAg
      category: Announcements
      categoryId: DIC_kwDOTzFZAs4DDCm-
      mapping: pathname
      inputPosition: bottom
      theme: auto
      loading: lazy

That is this site’s live configuration. All four of repo, repoId, category and categoryId are required: if any is missing or only whitespace, Hugo prints one WARNING and skips giscus without failing the build — which is why a production build carries --panicOnWarning. type accepts only giscus today, and any other value likewise warns and skips. The params.comments key names match Hextra’s, so a configuration migrated from Hextra transfers as it stands.

The remaining keys (strict, reactionsEnabled, emitMetadata, term, lang, lightTheme, darkTheme, ariaLabel, errorMessage) all have defaults, defined fully in Configuration. A feature switch takes either a YAML boolean or giscus-style 0 / 1.

Per-page control

comments in front matter overrides the site switch in either direction, and the value nearest the page wins.

To enable comments on selected pages only, turn the site switch off while keeping the full repository configuration, then let chosen pages opt in:

content/blog/2026-roadmap.md
---
title: 2026 roadmap
comments: true
---

To disable them on selected pages, leave the site switch on and let unsuitable pages opt out:

content/about/security.md
---
title: Security policy
comments: false
---

Use a cascade to set a whole section at once. This site writes comments: true in the cascade of content/docs/_index.md, which is why a real giscus section sits at the bottom of this page.

content/docs/_index.md
---
title: OINK Documentation
cascade:
  type: docs
  comments: true
---

Where a site also configures services.disqus.shortname, giscus wins: an active giscus suppresses Disqus, comments: false turns off both, and if a required giscus key is missing it warns, skips, and lets Disqus take over.

Multilingual text

giscus’s interface language follows the current Hugo language automatically: Simplified, Traditional and Hong Kong Traditional Chinese each map to the corresponding giscus locale, and an unsupported language falls back to English. Set lang explicitly only where the automatic choice is wrong.

What does need translating is the two strings on OINK’s side: the comment section’s accessible label and the loading-failure message. They are configured per language and merged with the global repository configuration:

hugo.yml
languages:
  en:
    params:
      comments:
        giscus:
          ariaLabel: Comments
          errorMessage: Comments could not be loaded. Please try again later.
  zh:
    params:
      comments:
        giscus:
          ariaLabel: 评论
          errorMessage: 评论加载失败,请稍后重试。

A language layer only needs the differences; repo / repoId / category / categoryId stay in params.comments.

Following light and dark

With theme: auto, the giscus iframe follows OINK’s light/dark control and the browser’s prefers-color-scheme, so the comment section changes with the rest of the page.

For a closer match to the site’s palette, give lightTheme / darkTheme two giscus themes; each value is a built-in giscus theme name or CSS hosted by the site. This site does the latter:

hugo.yml
params:
  comments:
    giscus:
      theme: auto
      lightTheme: /css/giscus-oink-light.css?v=0.4.0
      darkTheme: /css/giscus-oink-dark.css?v=0.4.0

A fixed theme name in theme stops it following the toggle.

A custom giscus theme has to be readable cross-origin

The giscus iframe loads from giscus.app, so reading a CSS file on your site requires CORS to allow it. This site adds Access-Control-Allow-Origin: '*' under server.headers in hugo.yml for local preview; in production it is the host’s response header configuration.

Privacy and CSP

  • OINK never asks for or stores a reader’s GitHub password or access token; signing in and posting happen entirely on the giscus / GitHub side.
  • The comment initialization script is a same-origin asset shipped with the theme, added only to pages with comments enabled; a page without them has no such script.
  • With loading: lazy, the iframe loads only as the reader scrolls near the comment section.
  • Where a site has a strict Content Security Policy, both script-src and frame-src must permit giscus — merged into the existing policy rather than replacing other directives (the general rules are in Content Security Policy):
CSP fragment
script-src 'self' https://giscus.app;
frame-src 'self' https://giscus.app;

When the external script fails to load or no iframe is created, OINK ends the loading state and shows errorMessage in a live status region rather than leaving the page on “loading”.

Verify

Terminal
hugo --minify --panicOnWarning     # a missing required key fails here
hugo server --disableFastRender

Then confirm each of these:

  1. Open a page that should have comments: giscus appears at the bottom, showing “Sign in with GitHub”, with its interface in the current page’s language.
  2. Toggle OINK’s light/dark control and the comment section follows (with theme: auto).
  3. Open a page with comments: false and confirm there is neither giscus nor any other comment component.
  4. Post a test comment, return to GitHub, and confirm a Discussion appeared in the chosen category and can be managed there.

Before the first comment or reaction creates a Discussion, a browser console message saying the Discussion was not found is expected.

When something is wrong, check in this order: WARNINGs in the build log (the four required keys) → params.comments.enable and type → the page’s comments front matter → whether the repository is public, Discussions are enabled and the giscus App is installed → the browser console and response headers (whether a CSP blocked giscus.app). If existing threads have gone missing, restore the original mapping and page path first.

6.4 - Analytics and SEO

Connect an analytics service (or none), and pair up the canonical, hreflang, social cards, sitemap and robots the theme already generates.

The theme loads no analytics, form or advertising script by default, and makes no outbound request until configured. Connecting one takes explicit configuration, and that external data boundary belongs in the site’s privacy statement. SEO is the opposite: canonical, hreflang, the robots meta, Open Graph and Twitter cards are generated per page by the theme, and what you have to get right is baseURL and each page’s description.

Connecting Google Analytics

Use Hugo’s built-in service configuration with a GA4 measurement ID:

hugo.yml
services:
  googleAnalytics:
    id: G-6JLQEHYFQG

The theme renders that script in the production environment only (a hugo build defaults to production, and hugo server to development). Local previews and preview deployments therefore report nothing, and need no extra switch.

Do not also set the deprecated top-level googleAnalytics key. Where analytics is not wanted, delete the block rather than filling in a fake ID.

This is incompatible with a network-isolated environment

Once configured, page views and events go to Google. A strict same-origin Content Security Policy also has to permit it — see Content Security Policy. This is a site decision, not a theme default.

Connecting another analytics service

Plausible, Umami, Matomo and the like need only a script inserted. The theme provides two injection points; create a file of the same name in the site repository and no theme change is needed:

layouts/_partials/hooks/head-end.html , Insertion pointBefore </head>, ahead of the Google Analytics template
Analytics scripts, cookie consent scripts, meta tags the theme does not provide
layouts/_partials/hooks/body-end.html , Insertion pointLast among the page scripts
Third-party code affecting interaction rather than the first paint
layouts/_partials/hooks/head-end.html
{{ if hugo.IsProduction }}
<script defer data-domain="oink.pgsty.com"
        src="https://plausible.io/js/script.js"></script>
{{ end }}

Do not omit the hugo.IsProduction guard: without it, everyone’s local preview reports into your analytics.

head-end runs before Google Analytics

That is deliberate: a cookie consent script has to run before the analytics script to actually hold it back.

The “was this page helpful?” feedback widget is a separate matter: off by default, making no network request, and configured in Repository links and page info.

Page descriptions

<meta name="description"> takes the first non-empty value of:

  1. The page’s description front matter
  2. The page summary Hugo computes (.Summary)
  3. params.description in the site configuration

Writing one description per page is the only SEO action an author has to take. It serves three purposes at once: the search engine snippet, the card subtitle on a section index, and the result preview in site search.

content/docs/admin/analytics.md (this page)
---
title: Analytics and SEO
description: Connect an analytics service (or none), and pair up the canonical, hreflang, social cards, sitemap and robots the theme already generates.
---

A multilingual site writes one per language; do not copy the English description onto a Chinese page. The site-level default is per language too:

hugo.yml
languages:
  en:
    params:
      description: A Hugo theme for engineering docs
  zh:
    params:
      description: 为工程而设计的 Hugo 文档主题

canonical and hreflang

The theme emits one canonical and a set of hreflang alternates per page, with no configuration:

rendered output (this page)
<link rel="canonical" href="https://oink.pgsty.com/docs/admin/analytics/">
<link rel="alternate" hreflang="en-US" href="https://oink.pgsty.com/docs/admin/analytics/">
<link rel="alternate" hreflang="zh-CN" href="https://oink.pgsty.com/zh/docs/admin/analytics/">

The hreflang codes come from each language’s locale (en-US / zh-CN on this site), and the links from Hugo’s translation relationships. Where a page has no counterpart in the other language, Hugo cannot find a translation and falls back to that language’s home page. That is expected behaviour, and it also tells you whether Hugo recognized the translation pairing.

The canonical is assembled from baseURL. A wrong baseURL points search engines at addresses that do not exist, which is harder to notice than a build failure. Run through the deployment checklist before launching.

Full multilingual configuration is in Languages.

Social cards

The theme calls Hugo’s built-in Open Graph and Twitter card templates, and the title, description, URL, language and site name are all automatic:

rendered output (this page)
<meta property="og:title" content="Analytics and SEO">
<meta property="og:type" content="article">
<meta property="og:url" content="https://oink.pgsty.com/docs/admin/analytics/">
<meta property="og:locale" content="en_US">
<meta property="og:locale:alternate" content="zh_CN">
<meta name="twitter:card" content="summary">

To give a shared link an image, set images in front matter:

any page
---
title: OINK v0.6.0 released
images: [/images/releasenote.webp]
---

For a site-wide fallback, write the same key under params:

hugo.yml
params:
  images: [/images/oink.webp]

With an image, twitter:card changes from summary to summary_large_image and og:image and twitter:image appear. This site sets neither, which is why the rendered output above has no image tags.

Sitemap

Hugo generates it automatically, and a multilingual site gets an index:

the structure under public/
sitemap.xml        ← the index, pointing at the two below
en/sitemap.xml
zh/sitemap.xml

Both the site default and per-page overrides are Hugo’s own:

hugo.yml
sitemap:
  changefreq: monthly
  filename: sitemap.xml
  priority: 0.5
one page
---
title: Release notes
sitemap:
  priority: 0.8
---

changefreq and priority are hints rather than promises, and a search engine may ignore them. What is worth doing before publishing is confirming that drafts, private content and non-canonical copies stayed out of the sitemap, and that each language’s file was generated.

robots.txt and staying unindexed

Hugo generates robots.txt only when the site configuration turns it on:

hugo.yml
enableRobotsTXT: true

The template the theme supplies gives two results by build environment, with no content for you to write:

a production build
User-agent: *
Allow: /

Sitemap: https://oink.pgsty.com/sitemap.xml
a non-production build
User-agent: *
Disallow: /

The robots meta in the page follows the same switch: index, follow in production and outside print output, noindex, nofollow otherwise. Do not build preview deployments with --environment production; a non-production build declines indexing by itself.

The theme has no per-page noindex switch. Where a page should not be indexed, the reliable answer is not to publish it (draft: true, or Hugo’s _build options). To publish it and still keep it out, emit your own tag through the head-end.html hook; the theme already emits one robots meta, and how a search engine reconciles two is its own decision.

Checking indexing

A week or two after launch, confirm in this order that what search engines see matches what you think:

  1. Crawl permission: open <baseURL>/robots.txt and confirm Allow: / rather than Disallow: /.
  2. Page inventory: open <baseURL>/sitemap.xml, follow into a language sitemap, and check the page count.
  3. Indexed count: search site:yourdomain and check the order of magnitude; a page-by-page reconciliation is not needed.
  4. Canonical addresses: results should land on the canonical URL, not a version with a ? parameter or an old domain.
  5. Active submission: add the site in Google Search Console / Bing Webmaster Tools and submit the sitemap.xml address, which is faster than waiting to be crawled.

Search metadata cannot compensate for the content itself: a thin, duplicated or stale page stays that way however well its description is written.

Verify

Terminal
hugo --gc --minify --printPathWarnings --panicOnWarning

Then check these in the output:

Terminal
# the canonical points at the real production address
grep -o '<link rel="canonical"[^>]*>' public/docs/admin/analytics/index.html

# only a production build has index, follow
grep -o '<meta name="robots"[^>]*>' public/docs/admin/analytics/index.html

# robots.txt and the sitemap
cat public/robots.txt
head -5 public/sitemap.xml

# with no analytics connected, the output should have no gtag / analytics request
grep -rl 'googletagmanager\|gtag(' public/ | head

Confirm once more in a browser: open a representative page and look at the network panel — a site with no analytics should make no request to a third-party domain.

  • DeploybaseURL, the checklist, and keeping preview deployments unindexed
  • Repository links and page info — the feedback widget, edit links and last-modified time
  • Languages — language configuration decides hreflang and translation pairing
  • AI-agent support — the .md output and llms.txt written for models
  • Configurationservices, sitemap, enableRobotsTXT and the rest

6.5 - Upgrade

Move to a new theme version, convert 0.4 shortcodes to v5 syntax with the migration toolkit, migrate from Docsy, and roll back when something goes wrong.

Upgrading OINK is changing one pinned module version and confirming the site still builds warning-free. Most content needs no change; where it does — 0.4 shortcodes becoming v5’s native Markdown forms — a dry-run-first migration tool does it, so hundreds of files need not be edited by hand.

An upgrade changes rendered output. Create an upgrade branch before starting, and the cost of backing out is discarding a branch.

Read the release notes first

Every version’s changes, breaking changes and upgrade notes are in its release notes; read the target version’s before upgrading:

The notes say whether content has to change, whether a configuration key was removed, and whether a default behaviour moved. Skipping this step means guessing afterwards why a page looks different.

Upgrading the Hugo Module

A production site pins a release tag or an immutable commit, follows no branch, and does not use @latest:

Terminal
hugo mod get github.com/pgsty/[email protected]   # the tag from the release notes
hugo mod tidy
hugo mod graph | grep github.com/pgsty/oink

The last command must show that tag itself resolving, not a pseudo-version (v0.0.0-2026...-abcdef) or main. The pinned version lands in go.mod and is committed with the code:

go.mod
module github.com/pgsty/oink.pgsty.com

go 1.26.6

require github.com/pgsty/oink v0.6.0
A local module replacement overrides that pin

make dev and make check set HUGO_MODULE_REPLACEMENTS for that command only, using the sibling theme checkout. To judge whether a release tag works, use make build without a replacement; otherwise what is verified is the local copy.

One line for each other install method. Git submodule: fetch the new ref with git submodule update --remote themes/oink and commit the submodule pointer. Offline archive and clone: replace themes/oink/ wholesale with the new version’s unpacked tree, and confirm theme: still matches the directory name. Weighing the three is in From scratch and other install methods.

What to do after upgrading

Terminal
rm -rf public resources/_gen
hugo --gc --minify --printPathWarnings --panicOnWarning --logLevel info

That does three things at once: clears possibly stale caches, rebuilds with the new version, and turns any warning into a failure.

--logLevel info is there to surface Hugo’s deprecation notices. Hugo deprecates in two stages: first a WARN (still usable), then an ERROR in the next version (the build fails). Carrying --panicOnWarning finds them a version early and leaves you the time to fix them.

Once the build passes, look with your own eyes: the home page, a documentation page, a blog page, the 404, both languages, both colour schemes, the print view, and anywhere the site customized something.

The content migration toolkit

A batch of 0.4 shortcodes became native Markdown forms in v5. The theme repository ships a tool for that, depending only on the Python standard library:

Terminal
git clone https://github.com/pgsty/oink
cd oink

# 1. read-only inventory: what several sites would change, exportable as Markdown / JSON
python3 bin/migrations/oink06.py report --sites ~/pgsty/oink.pgsty.com ~/www/ddia --md report.md

# 2. dry run: prints a diff and counts per file, writing nothing
python3 bin/migrations/oink06.py migrate --site ~/pgsty/oink.pgsty.com

# 3. apply: written atomically
python3 bin/migrations/oink06.py migrate --site ~/pgsty/oink.pgsty.com --write

# 4. check for residue: exit code 1 while legacy syntax remains
python3 bin/migrations/oink06.py check --site ~/pgsty/oink.pgsty.com

Four things to remember while using it:

  • A dry run is the default, and only --write touches disk. Dry-run, read the diff, then write.
  • A second run should change nothing. A second --write still reporting changes means a transformation is not converging; stop and look at those files.
  • Text inside fences is untouched, so a documentation site demonstrating the old syntax is not damaged.
  • A construct it cannot express is left as it stands and listed with file:line and a reason, as a manual work list rather than a failure.

To convert one class first, use --only with the keys in the table’s last column:

Terminal
python3 bin/migrations/oink06.py migrate --site ~/www/ddia --only callout,tabs --write

Rebuild afterwards (with --panicOnWarning) and look at the rendered pages: the tool guarantees correct syntax, not that the meaning is what you intended.

The 0.4 → v5 syntax map

{{%/* alert color= title= */%}}, {{%/* details */%}}, {{%/* pageinfo */%}}, hand-written <details><summary> , The v5 form> [!TYPE] Title / > [!DETAILS]-
callout
{{</* tabpane */>}} + {{%/* tab header= */%}}, {{</* code-group */>}} + {{</* code-tab */>}} , The v5 formAdjacent fences with {tab= group= value=}; tabs in running text use {{</* tabs */>}} + {{</* tab */>}}
tabs
{{</* filetree */>}} with filetree/folder and filetree/file , The v5 formThe filetree data fence
filetree
{{</* gallery */>}} with gallery/image , The v5 formThe gallery data fence
gallery
{{</* echarts */>}}, {{</* infographic */>}} , The v5 formData fences of the same name ($fn: is unchanged; a js subfence moves to window.OinkEchartsFunctions)
datafence
doc-cards / doc-card, nav-cards / nav-card, card / cardpane, doc-carousel , The v5 form{{</* cards */>}} + {{</* card */>}}, or a link list with {.cards}
cards
{{</* imgproc */>}}, {{</* image */>}} , The v5 form![alt](src) with the attribute line {command= options= caption=}
image
{{</* readfile file= */>}} , The v5 form{{</* include file= */>}}
include
The fence attribute {filename="x"} , The v5 form{title="x"}
fencetitle
{{</* badge outline= */>}} , The v5 formDrop the outline parameter
badge
{{</* example */>}} + a fence, {{</* book-figures kind="tbl" */>}} , The v5 form{{</* eg */>}}…{{</* /eg */>}}, {{</* book-tables */>}}
eg
{{%/* _param x */%}}, iframe, conditional-text, blocks/*, netlify, a kindless xref , The v5 formReported only; handle by hand
reportonly

What each new form looks like and what parameters it takes is on its page under Components.

Migrating from Docsy

OINK is a hard fork of Docsy: the content model, the td- naming, the Sass variables and most front matter are still there. The core of a migration is deleting the copies of the shared shell in the site and letting the theme’s implementation take over — not rewriting the prose.

  1. Pin the target version. Change go.mod to an OINK release tag, or use a complete versioned archive. During evaluation, an uncommitted go.work can point at a local checkout.

  2. Inventory the overrides. Sort every site-level file under layouts/, assets/ and static/ into four classes: copies of the shared shell (delete after verifying), components OINK already provides (delete or rename mechanically), brand customization (keep, reduced to the smallest hook), and business-specific data and interaction (stays in the site). Delete by reference order, and do not empty layouts/ at once: the home page and download page may still call a partial you are removing.

  3. Move the configuration. title, languages.*, github_repo, github_branch, page_width and params.ui.* all stay in their existing semantic positions; OINK opens no namespace of its own. Search and the logo are just keys to turn on:

    hugo.yml
    params:
      logo: img/product.svg
      offline_search: true

    Docsy’s camelCase search keys have been renamed in OINK: offlineSearch, offlineSearchIndex, offlineSearchMaxResults, offlineSearchOnServe and offlineSearchSummaryLength all become their underscored forms. Rename them deliberately — the migration registry that used to stop the build and name the replacement has been removed, so an old key is now simply a key nobody reads, and search stays off with no message at all.

  4. Fonts and styling compatibility. The Docsy Sass variables in the site’s assets/scss/_variables_project.scss still work as the seed values for the font roles, and need not be deleted to upgrade: $td-fonts-serif, $font-family-sans-serif, $headings-font-family and $font-family-code each feed their role. Docsy’s Google Fonts switches $td-enable-google-fonts, $td-google-font-name and $td-web-font-path are no longer read by the theme; leaving them breaks nothing and does nothing, because OINK ships Inter, Chakra Petch and IBM Plex Mono and neither preset requests anything from Google Fonts. To change fonts, go through the token layer — see Brand and appearance.

  5. Convert the shortcodes. Docsy’s alert, pageinfo, tabpane and card families all have a v5 counterpart; convert them in bulk with the migration toolkit above, one --only class at a time.

  6. Delete one group at a time, building after each. Rehearse on a scratch copy, recording the theme commit, the Hugo version, which files were removed and how many HTML files came out; only after confirming equivalence, repeat it on the production branch.

The “delete after verifying” class in step two is usually these files:

  • layouts/baseof.html and the shared docs / blog baseof*.html;
  • The navbar, footer, sidebar, TOC, search and head CSS partials and their hooks;
  • The old brand documentation shell partials;
  • Copies of the asciinema, echarts, infographic, doc-carousel, details, tab / tabpane, card and param shortcodes;
  • The JavaScript, Lunr copy, carousel code and SCSS that served only those implementations;
  • PostCSS and Autoprefixer steps no site asset needs any more.

Two kinds of problem surface after the deleting.

A site’s own script reports $ is not defined: the theme does not bundle jQuery, which Docsy used to load in every page’s <head>. Nothing in the theme needs it, and a site that still does loads it itself:

layouts/_partials/hooks/head-end.html
<script src="{{ (resources.Get "js/jquery.min.js").RelPermalink }}"></script>

A home page built from Docsy’s blocks/* fails the v5 build with template for shortcode "blocks/cover" not found: the theme has no such shortcode family. Switch to home page sections in data/home/<language>.yaml, or give the page layout: landing — see Home and landing pages.

Upgrading from 0.4

0.4 changed several defaults. If the page gained or lost something after the upgrade, check these first:

  • Sequential paging is on by default. docs, book and blog pages all have previous / next at the page end; documentation follows the sidebar tree and the blog follows time. A page deliberately outside any sequence opts out with pager: false.

  • The navbar shows on every layout. Its compact state is one row of icon navigation, with no second mobile accordion menu, so local scripts and tests that depend on the old mobile menu have to go. A whole section without a navbar uses navbar_enabled: false in a cascade.

  • The footer defaults to fat site-wide. Only fat / slim / none are accepted, and footer data must live in data/footer/<language>.yaml (or data/footer.yaml on a single-language site); a leftover footer key in data/home fails the build with the new location.

  • Single-key navigation is on by default: / opens full search and \ command-only mode. Training material describing the old behaviour needs updating. Page actions have also moved to a split button beside the breadcrumbs.

  • The code block DOM changed. A .td-code wrapper now encloses the original .highlight (both .highlight and .chroma are kept), so a direct child selector such as .td-content > .highlight in site CSS becomes the descendant selector .td-content .highlight.

  • Two ICP footer parameters were removed: footer_icp and footer_icp_url became one string accepting inline Markdown.

    hugo.yml
    params:
      footer_center_info: '[京ICP备00000000号](https://beian.miit.gov.cn/)'
  • Mathematics needs the site to enable passthrough. Hugo does not merge a theme’s markup configuration, so a site using \(…\), \[…\] or $$…$$ must enable the Goldmark passthrough extension in its own hugo.yml — see Math.

The complete configuration for all of these is in Configuration and Layouts and page types.

Verify

An upgrade is not finished at “the build passed”. Look at each surface:

Documentation / Book
Sidebar order, paging, headings, page actions, numbering and cross-references
Blog
Chronological paging, RSS ownership, navbar and footer
Home / landing
Content without JS, the compact menu, print
Release pages
Derived download URLs, checksums, publication state
Components
One page each for the components the site uses most
Accessibility
A keyboard-only pass, focus order, both colour schemes, forced-colors mode
Deployment
Internal links and assets all keep the base path prefix

This site’s full gate is:

Terminal
npm test           # build assertions, Markdown and favicon goldens, translation parity, rendered links
npm run test:browser   # Playwright: accessibility, responsive shell, keyboard navigation, content components, code blocks, scenario components

Another site runs the equivalent build, link, output and browser checks; the details are in Troubleshooting.

A successful local build is not a completed release

The source building, the tag being signed and resolvable through the Go proxy, the site pinning that tag, and production being deployed are four things, each recorded separately. Do not let one green local build stand in for them.

The last step happens in the real environment: deploy a preview, verify the pages and the browser’s network requests on the real URL, merge once reviewed, and smoke-test production afterwards.

Rollback

What rolls back is the version pin, not the working tree:

Terminal
hugo mod get github.com/pgsty/[email protected]   # the last known-good tag
hugo mod tidy
rm -rf public resources/_gen
hugo --gc --minify --panicOnWarning

Three principles:

  • Keep the pre-upgrade module pin, the site commit and the known-good deployment artifact, and restore all three together.
  • Do not roll back only part of it. Putting a few old layout copies back on top of a new theme produces a hybrid harder to diagnose than either complete version.
  • Keep the upgrade branch and its acceptance evidence. A rollback restores production first; it does not throw away the work already done.

Rolling back the deployed output itself (republishing the previous deployment) is in Deploy.

6.6 - Troubleshooting

Symptom → cause → fix for the four fault classes — build, language, search, platform — plus the checks a site can run for itself.

When something goes wrong, run a clean production build first and read from the first error; the ones after it are usually cascades:

Terminal
rm -rf public resources/_gen
hugo --gc --minify --printPathWarnings --panicOnWarning --logLevel info

An npm, PostCSS, Autoprefixer or browser-asset download step in the log means upstream Docsy’s process has crept into the configuration. A consuming OINK build is one Hugo command.

The four tables below are organized as symptom → cause → fix. Find the symptom row; there is no need to read from the top.

Build

Symptom Cause Fix
The build demands a newer Hugo The standard build is installed rather than Extended, or the version is below 0.160.1 hugo version output must contain extended. With several Hugos installed, check PATH and any version pinning before installing another
module "github.com/pgsty/oink" not found The theme did not resolve Hugo Module: check hugo mod graph, go.mod, go.sum, and any stray workspace or replace. Submodule: does CI run git submodule update --init before Hugo. Archive / clone: theme: must match the directory name under themes/
Module download hangs or times out The Go module proxy is unreachable Hugo pulls modules through Go, so GOPROXY applies. In mainland China, export GOPROXY=https://goproxy.cn,direct; in an isolated environment, use an offline archive or commit themes/oink/
{.cards}, {.steps}, {caption=…} appear as literal text The site has not enabled Goldmark block attributes The three settings below must be in the site’s own hugo.yml; Hugo does not merge a theme’s markup configuration
An image with an attribute line is wrapped in <p> and the caption does nothing wrapStandAloneImageWithinParagraph: false is missing As above; add all three together
Inline HTML is escaped into text renderer.unsafe: true is missing As above
\(…\) $$…$$ display literally The site has not enabled Goldmark passthrough See Math; math: true is not the switch
shortcode "tabs" must be closed or self-closed A {{< tabs >}} has no matching {{< /tabs >}} The error carries file:line:column; add the closing marker there
template for shortcode "tabs" not found The body calls a shortcode that does not exist, or quotes shortcode syntax without escaping it Documentation that explains shortcode syntax must escape it: add /* and */ inside the opening and closing markers so Hugo treats it as text rather than a call. A misspelled name is simply corrected
... attributes: unknown attribute "witdh" at ... An attribute-line key is misspelled or not permitted An attribute line accepts that component’s allowed keys plus class, data-* and aria-*; style and on* always fail the build. The allowed keys are in the error’s parentheses
shortcode "field": unsupported parameter "colour" at ... A shortcode parameter name is wrong A component parameter — a shortcode parameter or an attribute-line key — always fails the build and never degrades silently. The error is always “which shortcode → which parameter → which file and line”
invalid params.ui.page_width "widee" (allowed: normal | wide | full) -- using "normal" A configuration or front matter value is not one of the accepted ones Configuration degrades instead of stopping, so one typo does not serve HTTP 500 on every URL under hugo server. The message names the key, the value and the fallback used. Build with --panicOnWarning and it cannot ship
A page setting has no effect and nothing is reported The key was written inside a ui: block in front matter Page keys sit at the top level of the front matter — the site key with ui. dropped. A ui: block there is read by nobody and reported by nobody; see Page parameters
The build passes but production is missing something A WARNING nobody read Add --panicOnWarning to the build command. An invalid configuration value, a missing required giscus key, an unsupported comments.type and Hugo’s deprecation notices are all warnings

The three Goldmark settings:

hugo.yml
markup:
  goldmark:
    parser:
      wrapStandAloneImageWithinParagraph: false
      attribute:
        block: true
    renderer:
      unsafe: true

The two commonest shortcode errors look like this; note the trailing file:line:column:

build output
ERROR error building site: assemble: failed to create page from pageMetaSource /a:
  "…/content/docs/x.md:4:1": failed to extract shortcode:
  shortcode "tabs" must be closed or self-closed

ERROR error building site: assemble: failed to create page from pageMetaSource /a:
  "…/content/docs/x.md:4:5": failed to extract shortcode:
  template for shortcode "tabs" not found

Language

Symptom Cause Fix
A translated page does not appear Four possibilities, in order hugo.yml has languages.zh with a weight; ② the filename is page.zh.md, with zh lowercase; ③ the translation’s front matter has no draft: true and no future date; ④ routing metadata matches the source file
Switching language lands on the home page Hugo found no translation This is by design: with no translation it falls back to the target language’s home page. Landing on the corresponding page requires that translation file to exist
An anchor link opens the page but does not scroll The translated heading text differs, so the generated ID does too Write the English ID explicitly on the translated heading: ## 安装 {#installation}. Where a heading contains a shortcode or inline HTML, do not guess the ID from the text — read the English page’s rendered HTML
Menus / home page sections are untranslated They are not in pages but in configuration and data files Menus are in languages.<lang>.menus, home sections in data/home/<lang>.yaml, interface strings in i18n/<lang>.yaml — see Languages
A Chinese page’s hreflang points at the English home page That page has no English counterpart Add the English page, or accept the fallback: it doubles as a probe for whether Hugo recognized the pairing
Symptom Cause Fix
A search box that never returns results No index was generated With params.offline_search: true, the output root should have offline-search-index.<language>.json, one per language. Its absence means it is not enabled
The index file 404s A wrong baseURL On a subpath deployment, a wrong baseURL is the commonest cause of a 404 index. Look in the browser’s network panel to see where it fetches the index — see Deploy
Search fails under hugo server but works in a build The site turned the preview index off params.offline_search_on_serve defaults to true, so preview matches production; an explicit false skips index generation during preview — remove it or set it back to true
Chinese queries find nothing Usually not a tokenization problem A CJK query uses the theme’s substring fallback. First confirm the Chinese page’s content reached the Chinese index (open offline-search-index.zh.json), then consider tokenization
A new page is not found while old ones are The index is build output Rebuild. Under hugo server, wait for the rebuild after editing
params.search.algolia requires explicit appId, apiKey, and indexName values The three Algolia keys are incomplete All three must be given explicitly; the theme will not use another project’s DocSearch credentials. If Algolia is not wanted, delete the block
The command palette finds no content It and full-text search are two things With the index unavailable the palette still opens, saying so, while page actions and commands work as usual — see Command palette

Platform

Symptom Cause Fix
A 404 or missing styles on GitHub Pages A project site’s URL carries the repository path and baseURL does not Use --baseURL "${{ steps.pages.outputs.base_url }}/" from the workflow rather than hard-coding it. The full workflow is in Deploy
“Last modified” and contributors are empty on GitHub Pages The checkout is shallow Add fetch-depth: 0 to actions/checkout: enableGitInfo needs the full history
A Cloudflare Pages build says Hugo is too old The build image’s default Hugo is older than the theme requires Set HUGO_VERSION in both the Production and Preview environments, and set SKIP_DEPENDENCY_INSTALL=1
The host’s build cannot fetch the theme The build environment has no Go Hugo Modules need Go. Where a platform does not provide it, use a submodule or commit themes/oink/
CI output differs from local go.work took part in the CI build Set GOWORK: off and HUGO_MODULE_WORKSPACE: off in CI so it reads only the version pinned in go.mod
A preview deployment got indexed The preview was built in the production environment too Do not pass --environment production for previews; a non-production build carries noindex and Disallow: / — see Analytics and SEO
macOS reports too many open files Live preview watches more files than the shell limit allows Exclude generated and irrelevant directories from the watch first — usually the real cause — and only then consider ulimit -n
Slow, or missed changes, under WSL Working across a Windows mount point Let Hugo work on paths inside the Linux filesystem; cross-filesystem change notification and permission behaviour break live reload
Bootstrap / Font Awesome / Lunr / Mermaid assets are missing An incomplete distribution Do not paper over it with a CDN URL. Confirm assets/third_party/, assets/js/third_party/, static/webfonts/ and VENDOR.json are all present, and re-fetch the same pinned version if one really is missing

Checks a site can run

Beyond the build itself, a site can run these. The first two work on any OINK site; the rest are this repository’s npm scripts, and another site runs the equivalent.

A zero-warning build , Commandhugo --printPathWarnings --panicOnWarning
Duplicate output paths, invalid parameters, incomplete external integrations
Output trust check , Commandpython3 bin/check-output-security.py --public public --base-url https://oink.pgsty.com/
Every href / src in all four outputs is site-relative or http(s) / mailto / tel; no javascript: URL and no inline on* handler; a cross-site <iframe>, <script> or <img> needs an explicit --third-party
Translation parity , Commandnode scripts/check-doc-translations.mjs --public public
Whether each English page has a Chinese counterpart, and whether the rendered heading IDs line up; misaligned anchors surface here
The full gate , Commandnpm test
Runs the six below in sequence

What each of the six covers:

  • test:base — builds once, then runs the Markdown style, translation parity, rendered Markdown and link checks.
  • test:hugo-build — build assertions: blog metadata, RSS, content components, and a deprecation-free build.
  • test:md-output — byte-level golden comparison of the Markdown and llms.txt output. Changing a component’s Markdown shape fails here.
  • test:alt-site — builds once per alternate configuration in tests/fixtures/*.yml, confirming the combinations still come up.
  • test:favicons — golden comparison of the head output.
  • test:release-pin-contract — whether the version the site advertises matches the one pinned in go.mod.

Browser behaviour is a separate suite: npm run test:browser runs the Playwright accessibility (axe WCAG AA), responsive shell, keyboard navigation, content component, code block and scenario component suites in turn.

check-output-security.py lives in the theme repository

It sits under the theme’s bin/, is a product-level trust check any OINK site can run, and depends on no site test framework. Clone the theme repository and point it at your own public/; the arguments and usage are in Verifying an offline build.

Diagnostic habits

For problems the tables do not cover, dig along these lines:

  • Reproduce with a pinned Hugo Extended version rather than judging in an environment where the version floats.
  • Clear public/ and resources/_gen and rebuild, to rule out stale caches.
  • Compare the development and production configuration layers; many production-only problems are environment differences.
  • Read the first error, not the last.
  • Separate “theme behaviour” from “site override” with a minimal page: isolate the suspect content on its own page and re-enable site overrides in batches until one is implicated.
  • Look at the failing page’s browser console and network panel, especially the paths of any 404 resources.

Getting help

Opening an issue with these saves a round trip: the Hugo version (the full hugo version output), the theme version (hugo mod graph | grep oink), the first complete error, and a minimal page or site that reproduces it.

  • Local preview — clean builds, clearing caches, containers and workspaces
  • DeploybaseURL, the checklist and rollback
  • Upgrade — problems an upgrade introduces, and the migration toolkit
  • Search — index scope, ranking and Algolia
  • Languages — language configuration and the anchor alignment process

7 - Design and development

OINK maintainer contracts, accepted decisions, dated research, and proposals in one canonical bilingual section.
OINK 0.6.0 contract

This section publishes the maintainer contracts released with OINK 0.6.0, with Hugo Extended 0.160.1 as the compatibility floor. The canonical bilingual sources live in this repository under content/docs/design/.

This section is the durable design record for OINK. It complements the task-oriented guides elsewhere on the site: use those guides to build a site, and use this section to understand current invariants, the reasons behind them, the evidence used to evaluate alternatives, and work that is still only a proposal.

Reading this section

Layer Meaning
Contracts Normative behavior that compatible implementations must preserve
Decisions Accepted rationale and boundaries that explain current behavior
Research Dated, non-normative evidence that may need to be refreshed
Proposals Draft PRDs and RFCs; publication here is not proof of implementation

Contract map

Contract Authority
Architecture Build, configuration, diagnostics, featured images, output, security, accessibility, and performance
Components Component API, Book and release primitives, validation, and output degradation
Shell and navigation Navigation, search, blog presentation, actions, taxonomies, and page-end composition
Landing pages Landing data, the 22-section registry, runtime, accessibility, and outputs
Migration boundary Supported 0.4-to-current content and configuration migrations

Design records

Collection Contents
Decisions Accepted diagnostic, configuration, and authoring rationale
Research Goldmark probes and evidence from real OINK consumers
Proposals Active PRDs for knowledge graphs, media convergence, and machine-readable indexes

Create every new OINK PRD or RFC as an English and Chinese page pair under content/docs/design/proposals/. Do not create another repository-local plan/, plans/, or proposal/ tree. Once a proposal is accepted, update the implementation, owning checker, and relevant contract; preserve the stable rationale under Decisions and retire the draft through Git history and the changelog.

Authority and maintenance

This directory owns the maintainer design prose in English and Chinese. The theme repository owns executable facts: hugo.yaml owns published defaults; owning resolvers and checkers define optional shapes; layouts/ and assets/ own rendered behavior; check scripts and tests/goldens/ own validation; and VENDOR.json owns bundled versions, licenses, files, and checksums.

Whenever public behavior changes, update the implementation, its owning checker, and both language versions of the relevant contract in the same delivery. Tests should exercise behavior and output rather than pinning prose.

7.1 - Architecture contract

Repository assembly, configuration, diagnostics, output, performance, security, CSS, accessibility, and release-state boundaries.
OINK 0.6.0 contract

This is the architecture contract released with OINK 0.6.0. This page is the canonical English source; its Chinese peer is maintained beside it in content/docs/design/.

Repository and assembly

The repository root is a Hugo Module and complete theme, not a site or npm workspace. Hugo Extended compiles SCSS and templates. Browser runtimes and third-party assets are committed, so a normal build performs no network fetch. Public bilingual documentation, examples, and browser tests live in the sibling oink.pgsty.com repository; the theme repository keeps only narrow internal regression fixtures under tests/site/ and has no separate public example surface.

Generated public/ and resources/ trees are never source. Vendored runtimes, font families, and Font Awesome glyph definitions are supported distributions, not dead-code candidates; VENDOR.json and bin/check-vendor.py pin their integrity. OINK ships the complete supported Font Awesome distribution because consumer-authored content may use icons that theme templates do not.

Hugo types docs, book, blog, and swagger select the reading shells; params.ui.shell_types may add types. Landing is layout: landing. There is no article type or second blog shell: immersive pages are a blog presentation described in the shell contract.

layouts/_partials/shell/config.html resolves shared shell facts. Layouts must render through content/render.html before scripts.html, because render hooks and shortcodes register capability flags in the Page Store. Override the narrowest partial; superficially similar base templates remain separate where merging would change Hugo lookup precedence.

Configuration and diagnostics

Theme policy lives under params.ui.*; multi-setting integrations such as comments.giscus, plantuml, and drawio stay top-level. Boolean features use bare booleans unless they also have several settings. A page override drops the ui. prefix: params.ui.image_zoom becomes image_zoom, never a front-matter ui map. hugo.yaml declares published defaults; an owning resolver and its checker define any optional configuration shape or range.

Invalid input follows one rule: warn with the value, allowed shape, and safe fallback; then use that fallback or omit the unsafe feature. Ordinary hugo server therefore remains usable, while every publishing gate uses --panicOnWarning. The theme never calls errorf, and check-params.py enforces that boundary. Do not add speculative validation for unreachable states.

There is no generic renamed-key registry. A transition that still needs a migration diagnostic uses a targeted warning in its owning resolver plus a strict negative test; removed keys are never read as a compatibility path.

Network-capable features are explicit and degrade closed. PlantUML requires plantuml.svg_image_url, Draw.io requires drawio.drawio_server, and Algolia requires appId, apiKey, and indexName; incomplete configuration warns and emits no request. Draw.io loads only when rendered content contains PNG or SVG candidates, then inspects each distinct image URL once.

Hugo’s images is the single authored API; params.images is only the site-wide social fallback.

Source Reader thumbnail Social card
Page images, or bundled **featured*, *feature*, {*cover*,*thumbnail*} yes yes
Section cascade.images yes yes
Site params.images no yes

images: [] clears an explicit or cascaded value but does not disable bundled resource discovery. Only the first resolved image is representative. Local processable rasters may be cropped; SVG, static, and remote resources remain valid without Hugo image operations.

featured-image-resolve.html owns source ranking and relative/absolute URLs. A page’s bundled resource outranks an inherited cascade image. List thumbnails, Open Graph/Twitter/schema helpers, author avatars, Pinterest media, and blog presentation all consume that decision.

params.ui.featured_image is blog-only and defaults to none; front matter overrides it per page or cascade. banner renders a figure above a single-page title, wash colors its header, and hero paints the shell backdrop on single pages and section indexes. Missing images and non-HTML output render no image.

Outputs and runtime

Every base template sets Page.Store.tdOutputFormat:

Output Contract
HTML Complete semantic content; local runtime only for used capabilities
Print Expanded content; no shell navigation, search, or zoom runtime; the shared action layer supports explicit print controls
Markdown / LLMS Source-shaped Markdown without td- component markup
RSS Safe static summary or explicit omission

Consumers opt into custom outputs; OINK does not force expensive Book aggregates. HTML gets the shared action and core layers plus a feature bundle keyed by actual page capabilities and language. Print keeps the action layer and only runtimes required by rendered print features. Large third-party UMD files stay separate; unused feature runtimes stay absent.

Performance rules:

  • do not walk .Site.Pages per page when a site-level resource or partialCached result can own the work;
  • render .Content once and read Page Store flags only after it;
  • emit correct markup instead of scanning the DOM to repair it;
  • group browser work by resource URL, not DOM instance;
  • keep ordinary outputs opt-in when their aggregate cost is material;
  • validate reachable author input, not hypothetical internal states.

bin/measure-baseline.py measures build time, output weight, bundle count, and shortcode density. bin/sites/build-all.py builds maintained consumers in isolated snapshots.

Trust, CSS, and accessibility

Authors may enable Goldmark unsafe; configuration and component parameters are not raw HTML. The shared attribute policy consumes an allowlist, validates class tokens, passes data-* and aria-*, and warns while dropping style, srcdoc, on*, reserved, and unknown attributes. URL helpers reject dangerous schemes and protocol-relative URLs where local or explicit absolute URLs are required. Promised remote URLs remain supported but are never fetched at build time.

Theme output uses td- classes, data-td-* attributes, and --td-* custom properties; author markers such as .steps, .cards, and .full-width stay unprefixed. CSS supports RTL, print, forced colors, reduced motion, long tokens, and narrow viewports. Theme-owned decorative icons carry aria-hidden; pages with task lists or raw authored Font Awesome elements alone load the authored accessibility repair.

Font roles are ui, body, heading, code, display, metadata, and print, exposed as --td-*-font-family. params.ui.typography is technical or system; both compile into one stylesheet with no runtime. Legacy Bootstrap/Docsy Sass variables continue to seed these roles.

Release states

Source complete, locally validated, committed, tagged, pushed, pinned by a consumer, deployed, and production-identical are distinct states. A local Hugo build proves only local validation.

7.2 - Component contract

The maintainer contract for OINK authoring primitives, validation, Book and release behavior, and output degradation.
OINK 0.6.0 contract

This is the component contract released with OINK 0.6.0. This page is the canonical English source; its Chinese peer is maintained beside it in content/docs/design/.

Tutorials and exhaustive examples belong in the reader-facing Components section. This page defines the API and behavior that those guides rely on.

Authoring model

Use ordinary Markdown when one block plus attributes can express a component. Use shortcodes for compound bodies or facts Markdown cannot carry. There is no parallel component registry. Native forms require:

markup:
  goldmark:
    renderer: { unsafe: true }
    parser:
      wrapStandAloneImageWithinParagraph: false
      attribute: { block: true }

Only {{%/* steps */%}} uses percent delimiters because its body belongs to the page outline; every other shortcode uses angle delimiters. Compound bodies pass through content/render-block.html with a unique ID scope. Shortcode and component parameter captions, labels, titles, and names are plain text; Markdown belongs in bodies. Landing narrative fields follow their own contract. An icon is one Font Awesome class pair. Components expose safe classes and attributes, not arbitrary color or inline style.

Public API

OINK has 29 shortcodes:

  • core: tabs, tab, steps, cards, card, fields, field, include, kbd, badge, param, comment, contributors, asciinema;
  • Book: fig, tbl, eq, eg, xref, book-toc, book-figures, book-tables, book-equations, book-examples;
  • release: release-card, release-assets, download;
  • OpenAPI: swagger, redoc.
Component Native form Shortcode form HTML runtime
Callout > [!TYPE], fold, {icon=} none none
Tabs adjacent fences/tables with {tab= group= value=} tabs / tab tabs on used pages
Steps ordered list + {.steps} steps none
Cards link list + {.cards} cards / card none
Fields table + {.fields} fields / field none
FileTree filetree data fence none divider only with comments
Gallery gallery data fence none shared Image Zoom when eligible
Image Markdown image + block attributes none Image Zoom when eligible
Table attributes, caption, number, or tabs tbl for compound Book tables tabs when tabbed
Book target image/table/passthrough/fence + {num=} fig, tbl, eq, eg none
Release assets checksums data fence release-assets copy in HTML
Diagram/data mermaid, plantuml, markmap, math, chem, echarts, infographic fences none selected local runtime only

Validation

Invalid author input follows the architecture contract: warn, use the documented safe fallback or omit the component, and let --panicOnWarning make the same diagnostic fatal at publication gates. Named and positional forms are not mixed. Book target IDs match [A-Za-z][A-Za-z0-9_.:-]*; Book numbers match [0-9A-Za-z.-]+; classes are token-validated. Hook and shortcode targets share one page registry, so collisions cannot produce duplicate output IDs.

URLs use content/url.html. Images resolve through page resources, section resources, global assets, then static or explicit remote URLs. Local rasters carry intrinsic dimensions; SVG, static, and remote sources remain valid but cannot use Hugo image operations.

Component behavior

Callouts and tabs

Callout types are note, tip, important, warning, caution, success, danger, question, example, quote, and details; - starts folded and + expanded. Unknown types remain visible as neutral callouts without JS.

Adjacent tabs group only when consecutive and of the same block kind. group enables hash #<group>-<value> and storage td-tabs:v1:<group>; ungrouped tabs use neither. HTML exposes every panel before JS, print expands them, Markdown retains authored source, and RSS receives the rendered text summary. The full form supports arbitrary Markdown; tab.label is required, value is required exactly with a parent group, and an orphan tab warns and renders nothing.

Steps, cards, fields, and tables

Native steps accept ordinary block content. Use the shortcode only when a step must contain a percent-delimited container. Native cards are link lists; the full form adds bodies, badges, icons, and images. Native fields map the first column to the name, the last to the description, and middle columns through meta= or headings; the full form allows block descriptions. card and field are valid only inside their parents.

Field anchors are field-<name> with lowercase punctuation runs collapsed to hyphens, so params.ui.typography becomes field-params-ui-typography. Duplicate anchors receive positional suffixes.

The table hook owns responsive wrapping and captions. .matrix makes the first column row headers; .full-width widens normal or matrix tables. .fields cannot combine with matrix, full-width, numbering, or tabs; numbering and tabs are also mutually exclusive.

The Markdown image hook is the ordinary image API. Inline images stay inline; block images become figures with caption or num. Allowed image attributes are id, num, caption, width, height, link, command, and options plus shared safe attributes. command and options appear together and use Hugo Fit, Resize, Fill, or Crop on processable local resources. A plain linked image uses Markdown syntax; the link attribute therefore requires a caption or number. Linked and decorative images do not load Zoom.

Gallery accepts one Markdown image per line with optional description, link, and class. FileTree accepts indentation, - name, optional /, comments, and validated icon/tone/open/type attributes. Markdown preserves authored source; print renders expanded static figures and trees.

All code highlighting uses Chroma. Common fence attributes include title, copy, wrap, collapse, label, id, line options, tabs, and Book num/caption. Copy returns authored source. ECharts input is declarative JSON/YAML; callbacks use $fn:<name> from window.OinkEchartsFunctions, never embedded script execution.

Book

The book type extends the docs shell and follows the content tree or data/docs_nav.json. book_number, book_part, book_kind, and book_status are presentation metadata; they do not change Hugo publication state.

Numbered kinds are fig, tbl, eq, and eg, with default ID <kind>-<num>. eg needs a caption; eq without num is an unnumbered display formula. xref names exactly one kind plus optional page/anchor, or an anchor with explicit text. A numbered example is one framed body and caption.

Footnotes belong to the page document. Native numbered tables and fences keep them there. A shortcode body is a separate Goldmark document, so footnote references in tbl, eg, fig, card, tab, field, or include warn and remain literal; code-shaped text is ignored by that check.

book-toc follows navigation order at depth 1–3; the four book-* indexes collect one target kind each. Whole-Book print rewrites cross-page links and namespaces ordinary headings and footnotes while preserving explicit target IDs. Consumers opt into that potentially expensive output.

Release and download

Release front matter is one release_url in the form https://github.com/<owner>/<repo>/releases/tag/<tag>; owner, project, and tag come from the URL and date from the page. No remote release state is fetched. The removed release map, release_products, and release_group_by_product warn with their replacement and are not compatibility paths. The section index lists every page, using parsed project tag when available and the page title otherwise.

Checksums accept canonical lines or one source resource, never both; filenames cannot be paths. HTML adds local copy, while static outputs expose full hashes.

Downloads use data/download/<key>.yaml. Channels are rolling or pinned; only pinned URLs and commands interpolate ${version} and ${tag}. Before publication, rolling channels remain usable and pinned channels show pending. Markdown renders the complete channel list; RSS omits the component.

Verification

Shared output rules live in the architecture contract; exceptions are defined with their components above. Markdown and RSS set no browser runtime flags; Print retains only flags required by rendered print features. Source checks cover parameters, hook policy, runtime isolation, and migration; output checks compare HTML, print, Markdown, RSS, and LLMS goldens; browser tests cover interactive surfaces. Migration is documented in the migration contract.

7.3 - Shell and navigation contract

Navigation authorities, immersive blog presentation, search, actions, taxonomies, indexes, and page-end composition.
OINK 0.6.0 contract

This is the shell and navigation contract released with OINK 0.6.0. This page is the canonical English source; its Chinese peer is maintained beside it in content/docs/design/.

Authorities and navigation

Concern Authority
Global navigation Hugo menus.main
Docs / Book sidebar and pager content tree or data/docs_nav.json
Root switcher resolved top-level content roots
Discovery per-language local search index
Page and Palette actions shared action registry

No feature introduces another menu or page tree. One menu child level is interactive; deeper levels warn and flatten beneath linked group headings. External links use target="_blank" rel="noopener noreferrer"; internal links remain language- and subpath-aware.

Navbar desktop and drawer views project one tree, and every dropdown panel is one moderate column of icon-and-title rows — the mega panel and its columns menu parameter are retired, and a configured columns warns while keeping the single column. Menu descriptions are configuration data only. The link tree stays true-centered at every width: text links from lg, icon links below. Between lg and md the end edge keeps search, version, language, theme, and GitHub with no menu button. Below md those utilities move to the footline dock, and Home or explicit Landing pages add one drawer entry beside search that opens the full labelled tree; no other width or surface renders a drawer entry. Language links target the page translation or that language’s home, stay relative when languages share a host/base path, and become absolute only for language-specific baseURLs; hreflang stays absolute. navbar_autohide applies to fine pointers from 768px, never touch or drawer widths, and the hidden bar keeps its slot: the layout reserves the navbar band in both states, a pinned bar occupies exactly that band with its rule inside it, revealing fades the bar in place without covering resting content, and hero pages ignore the policy in favour of their overlay bar. The home page owns the same soft boundary a hero page does: its navbar carries no bottom rule and no scrolled shadow, resolving into a short wash below the bar instead.

Sidebar and pager share root and order. manual_link, build.render: link, dividers, hidden nodes, and placeholders retain their documented semantics. sidebar_icon_policy is all (default), groups, or none; icons are one Font Awesome class pair. Invalid policies follow the shared warning/fallback contract.

Immersive blog presentation

There is no article type or second shell. Immersive reading is four independent keys on the ordinary blog shell, set on a page or section cascade; the section index repeats values it also needs:

featured_image: hero
toc_style: flow
toc_taxonomies: false
sidebar_enabled: false

The blog shell renders no breadcrumb by default—an article reads as a standalone piece—so the recipe needs no key for it. breadcrumb remains an ordinary key a page or cascade may still set either way, on any shell.

hero uses the shared featured image as a decorative full-bleed backdrop on single pages and section indexes. With no image it renders the normal opening; banner and wash remain single-page modes. The navbar overlays a hero on a contrast scrim and scrolls with it.

toc_style is fixed or flow; flow places a wider rail beside the article and pins it only after scrolling. Its resting place aligns with the article’s info line, or its description where a page has no info line. docs-shell.js measures the offset because a title wraps to an unknown number of lines; without JavaScript the rail starts where the article starts. toc_taxonomies: false removes term clouds; a rail with neither TOC nor clouds renders nothing. notoc remains the page-level TOC opt-out. These switches do not change bylines, tags, series, pager order, feeds, or page-end composition, and the rail disappears below the xl breakpoint.

Search, actions, and runtime

params.offline_search opts into a local per-language index. When enabled it also builds under hugo server by default; set offline_search_on_serve: false for large edit loops. HTML search appears on Home, shell pages, and Landing when landing_search is enabled. Other non-shell pages and Print omit the dialog, Lunr, and Palette.

Search metadata is search_keywords, search_boost (default 1), and search_exclude. The index carries URL, title, taxonomies, excerpt, headings, description, body/summary, root, section, type, keywords, boost, breadcrumb, and icon. Fixture budget is 2 MiB raw / 512 KiB gzip. Sites may return extra strings from hooks/search-keywords-extra.html.

Built-in action IDs are copy_markdown, copy_link, open_chatgpt, open_claude, view_markdown, view_history, edit_page, create_child_page, create_issue, create_project_issue, print_section, print, switch_theme, switch_language, switch_version, and open_github. copy_link is Palette-only outside the share bar. Site commands under languages.<lang>.params.ui.command_palette.commands may open a safe URL or invoke a built-in ID, never inject JavaScript.

The Palette has empty, text-search, and > command modes; quick links derive from navigation. It has no history, semantic search, personalization, or remote fallback. Search queries stay in-browser and no default telemetry is sent.

OinkSurfaceCoordinator arbitrates Palette, drawer, root, language, and version menus. Surfaces own focus restoration and Escape. Keyboard navigation ignores editable controls and modals: /, \, f, c open search/commands; j/k move headings; q/e move pages; h changes presentation; l/y, t, and r open language, theme, and root choices. Sidebar WASD/Arrow navigation uses real focus without rewriting Tab order.

The outline derives cursor and visible-heading range from one heading model and the scroller’s computed scroll-padding-top; its SVG line and dot share the same animated values so they cannot drift. No speculative DOM repair pass is allowed.

Share

params.ui.share is empty by default and accepts any ordered subset of 16 targets: x, bluesky, mastodon, facebook, linkedin, reddit, hackernews, telegram, whatsapp, line, pinterest, weibo, chatgpt, claude, email, and copy. A page list replaces its inherited list; share: false opts out. Unknown entries warn and are dropped. Only regular pages render the bar; print, Markdown, and RSS omit it.

Targets are plain intent links carrying the page permalink/title, plus the local copy_link button. Pinterest media comes from the shared featured-image resolver. ChatGPT and Claude receive build-time permalink prompts and are independent of page-menu assistant actions. Discord has no public intent target and is deliberately absent.

The bar loads no platform SDK, iframe, script, stylesheet, counter, or campaign parameter and makes no request until a reader activates a link. It is one accessible labeled glyph row. share/items.html resolves targets and share/bar.html renders them.

Annotation

Page annotation resolves descriptors in annotation-items.html and renders them through page-meta-lastmod.html; either may be overridden narrowly. Lines appear in this order:

Line Condition
Last modified Lastmod is set
Upstream front matter upstream_link is non-empty
Translation configured authoritative language has a translation and this page has authored text

upstream_link is per-page; a cascade counts, and upstream_link: "" opts out. Other upstream facts resolve site params → data/upstreams[upstream_source] → front matter: upstream_name, upstream_copyright, upstream_license, upstream_notice, optional upstream_ref, and upstream_modified. The first four are required with a link. Invalid or incomplete attribution warns and emits no legal notice; unsupported URLs are refused. Publication gates reject the warning with --panicOnWarning.

upstream_modified changes the credit verb and links commit history; it adds no line. The notice page carries full license/warranty text. Translation notice is opt-in through params.ui.translation_notice, cascades as the page key translation_notice, skips generated or bodyless pages, and can be disabled on a natively authored page with translation_notice: false.

Authors and series

A blog article head is title, info line, term badges, byline, then the series strip; the description leads the body below them. The info line (article-info.html) always carries the date; with reading_time on it adds the word count and the minutes. Front matter upstream_link—the same per-page fact the annotation attributes—adds a localized link to the original, gated by the shared URL policy. Term rows are bare badge runs whose taxonomy name lives on the group label, not as a visible prefix. A term badge is a solid brand chip with its label knocked out of the fill, led by the taxonomy’s term glyph. taxonomy-icon.html owns the vocabulary—each taxonomy pairs a whole-taxonomy glyph with a term glyph (folder-open/folder, tags/tag, cubes/cube, users/user-pen, book-bookmark/book for series, generic shapes)—and params.ui.taxonomy_icons overrides a pair with one string for both surfaces or a taxonomy/term map; unusable input warns and keeps the built-in. The right-rail cloud wears the whole-taxonomy glyph on its head alone: cloud chips and the term-archive filter chips stay text plus count, because repeating the glyph beside an announced taxonomy is noise. The byline carries the people alone—portrait, name, and the profile’s one-line bio—with no label and no date. List rows, cards, and term archives share one metadata line of the same shape: date, one localized author-and-section phrase, then word count and minutes behind the same reading_time switch. Under that sentence sits one wrapping badge line with every taxonomy’s terms, taxonomies in alphabetical order, each badge wearing its term glyph; cards leave out authors, whom their sentence already names.

Authors activate only through taxonomies: {author: authors}. The profile term page owns display name, summary, body, and featured-image avatar; an absent profile falls back to link title, initial, and archive. authors-resolve.html preserves front-matter order for article heads, list rows, and one RSS dc:creator per author. Legacy author remains unchanged when authors is absent; when both exist, authors wins without warning. Custom author taxonomy plurals behave as ordinary taxonomies.

Series activate only through taxonomies: {series: series}. Term pages own the introduction; no parameter, data file, cover model, or runtime is added. A page uses series: [name] and optional series_weight. series-pages.html orders weighted members first by weight, then unweighted members by ascending date, with Path tie-breaks; strip and term page share it. The first named series gets one HTML/print strip: a closed disclosure line—series name, part M of N—that expands, and prints expanded, to the reading order, one member per line; singleton series and non-HTML outputs omit it. Numbering, cross-references, and aggregate output remain Book concerns.

The default article taxonomy chips omit reserved authors and series because their dedicated surfaces already carry them. Explicit params.taxonomy.page_header restores either.

Blog indexes and page composition

Blog section indexes use params.ui.blog_index: list (default) and cards are one flat run, newest first, sharing blog_index_size pagination—the metadata line’s dates make year headings redundant; table shows the whole section as date/title/tag rows without pagination. Cards use the shared lead image, localized date/author/section metadata, tags, and a three-line summary. Term and taxonomy pages stay row lists.

params.ui.blog_index_toggle renders all three forms for the current paginator slice and lets readers cycle them. The configured form controls first paint, local storage may override it, and hidden forms load no images. A front-matter value or cascade overrides the site mode per section. A table published without the toggle remains a complete, unpaginated archive.

params.logo is always the brand mark; params.wordmark, or the site title, is the text half hidden at compact widths. Docs, Book, Blog, and Swagger share one shell model. Page-end order is Share, Feedback, Annotation, Pager, Comments. Docs/Book pager follows sidebar preorder; Blog uses weight then reverse date; pager: false opts out. Static outputs omit pager UI.

Every rendered footer style keeps an icon-only utility dock at the end of its bottom line: version, language, theme, then keyboard help. Its menus open upward; the version trigger never exposes the current branch or release label. The fat footer’s collapse chevron follows the dock. Below lg the bottom line gives up its copyright/center/dock columns and stacks them as three centered full-width rows, the dock last. These global controls do not render in the sidebar footer, and footer_style: none removes the whole bottom line.

There is no archive shell, arbitrary-depth flyout, second navigation authority, query upload, or browser compatibility shim for removed config. Feedback emits only docs_feedback through an existing gtag, stores the choice locally, and does not replace Giscus.

Verification

bin/check-navigation-contract.py, bin/check-shell.py, JS tests, output goldens, and the consumer browser suite cover navigation, language/subpath links, blog variants, page-end order, keyboard behavior, accessibility, and responsive layout.

7.4 - Landing contract

The maintainer contract for landing data, the built-in section registry, language resolution, runtime, accessibility, and outputs.
OINK 0.6.0 contract

This is the landing-page contract released with OINK 0.6.0. This page is the canonical English source; its Chinese peer is maintained beside it in content/docs/design/.

Shared rules live in the architecture and component contracts; migration belongs in the migration contract.

Shell and data

Any regular page may declare layout: landing. It renders navbar, full-width canvas, and footer without docs sidebars or TOC rails. The homepage keeps data/home/<lang>.yaml as a compatible authoring path through the same renderer.

A non-home page resolves sections from inline front matter, data/landing/<key>/<lang>.yaml, an exact-language entry in one data/landing/<key>.yaml, then English or unsuffixed local data. Landing never fetches mutable facts; stars, prices, screenshots, and avatars are committed or generated before Hugo runs.

params.ui.landing_search defaults to true and enables the existing local Palette only when offline_search is enabled. params.ui.github_stars and params.ui.alt_site are optional local chrome facts.

Section registry

The registry has exactly 22 built-ins:

  • hero, metrics, capabilities, principles, cards, logo-wall, gallery, testimonials, contributors, faq, markdown, cta;
  • pricing, pricing-compare, command-box, steps, timeline, code-plate, preview, case-study, download, bar-chart.

An entry is a type string or a map with type, key, id, enabled, inline data, or a deliberate local partial. Authors provide unique IDs; OINK normalizes them to anchor-safe values. Unknown types follow the shared warn-and-safe-fallback policy; they never vanish silently, and --panicOnWarning rejects them at publication. landing/ partials own built-ins; removed home/ partial names are not an API.

preview places Markdown source beside RenderString output through the site’s hooks, so its content registers the same runtimes as docs content. The source pane uses Chroma and a file name, default page.md. Markdown output uses a four-backtick markdown fence; RSS omits it. Pane labels are theme i18n.

hero.align is start or center. Center is text-only; combining it with an image warns and falls back to start, preserving the image. download consumes the same data/download/<key>.yaml schema as the shortcode and introduces no second channel, version, publication, or interpolation model.

Language, runtime, and accessibility

Narrative files may be language-specific. Shared fact fields resolve <field>_<exact language> with - normalized to _, then <field>_<primary language>, then the unsuffixed field. camelCase aliases are not accepted. Narrative fields render inline or block Markdown through the site’s hooks; values reused as accessible names are plainified. Section copy is site data; only theme controls use OINK i18n.

Interactive HTML sets hasLanding, which conditionally adds only landing.js. The runtime reuses OinkSurfaceCoordinator and owns reveal, count-up, copy, compact-menu, and theme-image enhancement. Server output remains complete without JavaScript.

Marquee duplication is CSS-only; the duplicate is aria-hidden and inert, and a localized checkbox persists pause without JS. Reduced motion disables motion, forced colors preserves controls, and theme images follow the shared theme event. Navbar mega-menu columns accept 1–4. The compact menu uses real links/buttons, traps no focus, and does not duplicate the desktop tree.

Outputs and compatibility

Output Contract
HTML Full static sections plus progressive enhancement
Print Static grids and content; controls removed
Markdown Headings, prose, lists, tables, and code without theme classes
RSS Landing sections omitted

Non-HTML output sets no Landing flag or runtime. Root-relative links and assets honor deployment subpaths; normal builds download no images.

Removed 0.4 component forms belong to the migration toolkit, not parallel Landing implementations. OINK adds no pricing-period toggle, remote-fact API, hotspot editor, visual builder, or second registry. Existing homepage data and explicit custom section partials remain valid.

7.5 - OINK migration boundary

The supported source, configuration, and validation boundaries for migration from OINK 0.4 through OINK 0.6.0.
OINK 0.6.0 contract

This is the migration contract released with OINK 0.6.0. This page is the canonical English source; its Chinese peer is maintained beside it in content/docs/design/.

This is source and configuration guidance, not a release ledger. Local source, commit, tag, push, consumer pin, deployment, and production parity remain separate states. For the reader-facing upgrade procedure, see Upgrade.

Toolkit scope

bin/migrations/oink06.py only scans and automatically rewrites Markdown files under a site’s content directory, including supported YAML front matter. It does not rewrite Hugo configuration, data files, layouts, assets, modules, or generated output. TOML/JSON front matter and ambiguous Markdown are reported with positions for manual review.

Dry-run is the default and a completed migration is idempotent:

python3 bin/migrations/oink06.py report --sites <dir>... --md report.md --json report.json
python3 bin/migrations/oink06.py migrate --site <dir>
python3 bin/migrations/oink06.py migrate --site <dir> --write
python3 bin/migrations/oink06.py check --site <dir>

Code fences are not rewritten. book_figures.py retains narrow TPME, DDIA v1/v2, and pg-internal profiles; it is not a generic parser.

0.4 content to current forms

Removed form Current form Toolkit key
alert, details, pageinfo, raw disclosure > [!TYPE] callout callout
tabpane, legacy tab, code-group, code-tab adjacent {tab=} blocks or tabs / tab tabs
FileTree shortcodes or {.filetree} list filetree fence filetree
Gallery shortcodes or {.gallery} list gallery fence gallery
ECharts / infographic shortcode same-named data fence datafence
Docsy card families .cards list or cards / card cards
imgproc, image Markdown image + attributes image
readfile include include
fence filename= title= fencetitle
badge outline= remove outline badge
leaf example, book-figures kind= eg, explicit book-* index eg
percent-delimited fields angle-delimited fields / field fieldsdelim
Docsy _param placeholders and card header= highlights Font Awesome / badge / param or callout param_placeholders
unsupported legacy shortcodes manual review with source position reportonly

Configuration and front matter

The following configuration changes are manual; the toolkit may report matching front-matter keys but never edits site configuration.

Old Current
offlineSearch* offline_search*
disable_click2copy_chroma ui.code_copy (inverted)
content_width `reading_width: slim
github_url github_repo
ui.no_left_sidebar ui.sidebar_enabled (inverted)
breadcrumb aliases ui.breadcrumb
ui.scrollSpy ui.scroll_spy (inverted)
ui.showLightDarkModeMenu ui.dark_mode.show_menu
ui.readingtime ui.reading_time
ui.ul_show ui.sidebar_expand_levels
ui.docs_root ui.docs_sidebar_root
ui.pager ui.pager_types
{ enable: bool } annotation/zoom/keyboard/reading maps bare booleans
ui.typography.preset ui.typography
print.disable_toc print.toc (inverted)

Prism, rss_sections, and algolia_docsearch are removed. Chroma is the only highlighter; Algolia configuration is search.algolia. Page overrides drop the ui. prefix. Legacy hide_feedback, hide_readingtime, exclude_search, content_width, camelCase manual links, and nested front-matter ui maps are reported with replacements.

0.5 to 0.6

  • Replace upstream_attribution with upstream_link plus upstream_name, upstream_copyright, upstream_license, and upstream_notice; rename downstream_modified to upstream_modified.
  • Replace the release map with one GitHub release_url; remove release_products and release_group_by_product from release indexes.
  • Blog and default dates now default to ISO 2006-01-02; retain explicit time_format_blog or time_format_default for prose dates.

Removed names warn and take the documented safe fallback or render nothing; ordinary previews continue, while --panicOnWarning rejects them at a strict gate. blog_index_toggle, featured_image: hero, toc_style, and toc_taxonomies are additive opt-ins. They introduce no content type; immersive reading stays on the ordinary blog shell.

Prerequisites and validation

Enable Goldmark unsafe rendering, block attributes, and standalone block images as shown in the component contract. Enable passthrough explicitly for \(...\), \[...\], or $$...$$; Hugo does not merge theme markup config.

Run the smallest source and output checks for the changed contract, both supported Hugo versions, JS tests when runtime changes, and strict root and subpath builds. For maintained sites, inspect representative EN/ZH Docs and Blog routes at desktop and narrow widths, then record pin, deployment, and hosted parity separately.

7.6 - Design decisions

Accepted choices that explain why OINK’s public contracts and implementation have their present shape.
Accepted rationale

A decision explains why OINK chose one compatible design over another. The five contracts above it remain the normative description of current behaviour; implementation and owning checkers remain the executable facts.

OINK used to keep reviews, PRDs, and execution notes in a local plan/ directory. That made useful reasoning hard to discover and allowed abandoned designs to look authoritative. Accepted reasoning now lives here, in the same bilingual, versioned site as the contracts it supports.

Decision map

Decision What it settles
Warnings and safe fallbacks Why ordinary preview survives invalid input while publication remains strict
Configuration model Where configuration belongs, how pages override it, and why OINK has no parallel configuration namespace
Markdown-first authoring Why native Markdown is preferred and Docs, Blog, Book, and Landing extend shared systems

Record format

An accepted decision records context, the choice, consequences, and the proof that makes the choice current. It does not reproduce a parameter reference or a tutorial. Every decision links to its owning contract and verification surface, and its English and Simplified Chinese pages change together.

When a decision changes, update the implementation, checker, affected contract, and decision record in one delivery. Preserve the old answer in Git history and the release changelog instead of leaving two active answers in the navigation tree.

7.6.1 - Warnings and safe fallbacks

Invalid author input warns and degrades safely during preview; –panicOnWarning restores a hard publication gate.
Decision

OINK does not call Hugo’s errorf. Invalid author or site input emits a warning and either uses a documented safe fallback or omits the invalid fragment. Release and deployment builds use --panicOnWarning, so the same warning remains a hard publishing failure.

Context

Hugo builds the whole site as one transaction. An errorf raised while one page is being edited makes every URL served by that rebuild return an error, including unrelated pages and the home page. The server process survives and recovers after the input is fixed, but collaborative preview is unavailable in the meantime.

A warning has a different development cost. The affected value can fall back, the rest of the site remains inspectable, and the author receives a precise message. A publication build still fails because OINK’s CI and integration gates add --panicOnWarning.

Decision

Validation follows four rules:

  1. Name the invalid key and value, the allowed shape, and the fallback.
  2. Include a page position when the value came from page front matter; avoid repeating one site-wide warning for every page.
  3. Never pass an invalid value into a later operation. Validate first, then render from the normalized value.
  4. Where no honest fallback exists, warn and render nothing. Do not invent content, make a network request, or emit an unsafe URL merely to keep going.

The shared enum, boolean, CSS-length, and number shapes live in layouts/_partials/validate.html. Domain resolvers may add narrower checks, but they preserve the same warning/fallback contract.

Safety boundary

Continuing a build never means continuing with unsafe output. A rejected CSS length falls back before it reaches a style attribute. An incomplete remote service configuration omits the component before the browser can make a request. An unsafe action URL is dropped. The protection is the absence of the bad output, not the act of terminating Hugo.

This also separates editing from publication cleanly:

Stage Invalid input
hugo server or an ordinary local build Warn, fall back or omit, keep other pages available
CI, release validation, deployment The same warning becomes a non-zero build under --panicOnWarning

Consequences

  • Every fallback is part of the public contract and must match the default declared by the theme.
  • A change from failure to fallback also changes its tests. A negative test proves ordinary build survival, the warning text, the rendered fallback, and strict-build failure.
  • Checkers must test the rejected output directly. A URL security test, for example, asserts that the unsafe URL is absent instead of treating any build failure as sufficient proof.
  • The source-level parameter checker protects the invariant that theme layouts contain no errorf calls.

Verification

The owning references are the architecture contract, bin/check-params.py, and strict builds of both the theme fixture and this integration site.

7.6.2 - Configuration model

OINK extends Hugo and Docsy-compatible configuration without creating a second namespace or a parallel global resolver.
Decision

OINK keeps Hugo’s native keys and useful Docsy-compatible keys in place, places theme presentation and behaviour under params.ui.*, and exposes a matching top-level front-matter key for a page override. It does not add a params.oink.* tree or a registry that shadows Hugo’s configuration model.

Context

OINK inherits a mature configuration surface and adds shells, content output, and local interaction. Earlier designs attempted to move every theme-owned key under a new namespace and resolve a complete configuration dictionary once per page. That produced a second language beside Hugo’s own keys, complicated section cascades, and made migration larger than the behaviour it was meant to control.

The current model keeps ownership visible instead:

Layer Responsibility Examples
Hugo Site identity, languages, menus, outputs, taxonomies, markup, modules baseURL, languages, outputs
Site facts and integrations Repository, version, author, local search, comments, external services params.github_repo, params.version, params.comments
OINK interface Shell, navigation, presentation, and local interaction params.ui.sidebar_*, params.ui.typography, params.ui.share
Page or section A narrow override of an eligible site default sidebar_enabled, featured_image, share
Data files Structured facts and ordered content that are not switches data/landing, data/download, data/docs_nav.json

Decision

The configuration API follows these rules:

  1. Site facts remain at the established top level. Interface choices belong under params.ui.*.
  2. A page override drops the ui. prefix and otherwise keeps the same name. A section cascade can apply that top-level key to its descendants.
  3. Boolean features use a scalar where that is the complete policy. A map is reserved for features with real subordinate settings; an established map may accept a boolean shorthand.
  4. Names are positive, snake_case, and grouped by function. Closely related settings share a prefix instead of growing another nested resolver.
  5. Theme defaults are declared in the theme’s hugo.yaml. Templates may add a derived default only when one static value would erase a deliberate shell-specific distinction.
  6. Each feature family owns its normalization and validation. A shared helper supplies common shapes, but there is no global compatibility registry that silently rewrites arbitrary old keys.

The complete current key list, types, and defaults live in the configuration reference. This decision records the placement rules; it is not a second parameter catalogue.

Compatibility

Public renames receive a targeted warning from the owning resolver, a migration note, and a negative test. Removed or misspelled keys do not justify a permanent alias layer. Hugo and third-party camelCase keys remain camelCase where changing them would break their native API; OINK-owned additions use snake_case.

Page values resolve through Hugo’s ordinary front-matter and cascade model. OINK does not ask authors to put a nested ui: tree in front matter and does not promise to merge arbitrary nested page maps.

Consequences

  • Adding a public setting requires a declared default or an explicitly derived default, an owning resolver, documentation, and a positive and negative test.
  • Configuration guides link to the one reference table instead of repeating types and defaults.
  • A new data structure is justified by ordered or repeated facts, not merely by a desire to avoid adding a parameter.
  • Invalid scalar values follow the warning and fallback decision.

Verification

bin/check-params.py audits declared defaults, page aliases, warning behaviour, and the no-errorf invariant. The public reference and its Chinese peer are checked in the integration site’s bilingual and rendered-link suites.

7.6.3 - Markdown-first authoring

Native Markdown carries common semantics; shortcodes fill real capability gaps, and content models extend shared shells instead of forking them.
Decision

Prefer a native Markdown form when Goldmark can preserve the intended semantics. Keep a shortcode only when it provides a capability the native form cannot express. Add a content scenario by extending an existing shell and data model, not by creating a parallel rendering system.

Context

OINK serves short manuals, large references, release archives, landing pages, and books. A survey of eleven consumer sites covered more than five thousand Markdown files and exposed both extremes: pages with almost no theme syntax and pages assembled from many nested shortcodes and local layout overrides.

A component API optimized only for the second group becomes a private DSL. An API optimized only for plain Markdown leaves books, rich figures, tab groups, and structured releases to site-local HTML. The useful boundary is capability, not novelty.

Decision

OINK applies the following order:

  1. Native Markdown first. Lists become Steps, Cards, or FileTree markers; tables become Fields or matrices; blockquotes become callouts; fenced code, images, and passthrough blocks carry attributes through render hooks.
  2. Shortcodes for missing capability. A full-form shortcode remains where CommonMark indentation, nested containers, processing options, or cross-page registration cannot express the same result safely.
  3. One semantic implementation. Native and full forms normalize into the same partials and output contract. They are not two components that merely look alike.
  4. One extension line. A new Landing section joins the section registry; a new Blog presentation remains a Blog variant; Book numbering joins the content primitive and navigation systems. OINK does not add a second card, landing, navigation, or article shell for one feature.
  5. Facts stay outside presentation strings. Versions, repositories, dates, and ordered records come from front matter, site parameters, or data files. A shortcode argument is not a second source of truth.

Output contract

An authoring form is complete only when its semantic content has a deliberate result in every enabled output:

Output Requirement
HTML Semantic server-rendered content; JavaScript only enhances it
Print Static, expanded, and free of controls that require interaction
Markdown / LLMS Source-shaped prose, links, lists, tables, and fences; no component HTML
RSS Safe static content or an explicit omission

This requirement prevents an attractive HTML-only component from silently damaging agent output, feeds, or a printable book.

Trust and presentation

Render hooks and shortcodes consume explicit allowlists. Unsafe URL schemes, inline event handlers, and arbitrary style input are dropped. Author-provided classes are accepted only on the documented surfaces where downstream site CSS is part of the established authoring contract. Icons use one Font Awesome class pair; OINK does not invent a second icon-ID language.

Consequences

  • A proposed component must first show why Markdown plus an existing hook is insufficient.
  • Keeping a full-form shortcode requires a named capability and tests for both forms reaching the same normalized output.
  • Shell variants use independent presentation keys so opting into a hero or a flow outline does not change taxonomies, feeds, pager order, or content type.
  • Consumer evidence is dated research, not a permanent excuse to freeze an accidental syntax. The current public surface remains defined by the component contract and shell contract.

Verification

The authoring contract is exercised by theme component, Book, output, and golden checkers, then by this site’s bilingual examples and browser suites. The Goldmark facts behind the native forms are recorded in block-attribute research.

7.7 - Design research

Dated experiments and consumer evidence used to make OINK design decisions, without normative force.
Evidence, not a contract

Research records what was measured, with which inputs and tool versions. Results may explain a decision, but they do not override the current contracts or implementation.

Research belongs in the public Design tree when another maintainer can inspect its method, understand its limits, and repeat the relevant check. Raw agent transcripts, temporary build logs, and local absolute paths do not meet that standard.

Research map

Record Evidence
Goldmark block attributes Render-hook visibility and CommonMark container limits on the supported Hugo floor
Consumer and migration evidence A dated corpus survey plus deterministic Book migration results

Publication rules

A research record states its date, inputs, relevant versions, method, result, and known limits. Volatile counts are labeled as snapshots. External framework comparisons are refreshed from primary sources before publication and distilled into OINK-relevant conclusions rather than copied as a competitor catalogue.

When a result becomes a stable product choice, link it from an accepted decision. When it proposes behaviour that does not exist, move the design question to Proposals.

7.7.1 - Goldmark block-attribute evidence

Reproducible findings for lists, images, tables, passthrough blocks, fences, callouts, and nested containers on Hugo 0.160.1 and 0.164.0.
Verified snapshot

These probes produced byte-identical relevant output on Hugo Extended 0.160.1 and 0.164.0. They explain OINK’s native component forms; the current component contract remains authoritative.

Method

The probe used a minimal Hugo site without OINK templates. Render hooks printed their context fields and .Attributes as visible markers. The site enabled Goldmark block attributes, passthrough delimiters for inline and block math, unsafe rendering for the deliberately inspected raw HTML, and wrapStandAloneImageWithinParagraph: false.

Each source shape was rendered with the compatibility-floor Hugo and the then current Hugo version. Relevant output was compared byte for byte. The findings below record platform behaviour, not visual styling.

Findings

Source shape Hook result Design consequence
Ordered list with paragraphs, fences, callouts, nested lists, and {.steps} The class attaches to the outer <ol> and rich list-item blocks survive A Markdown list is the native Steps form
Heading inside a list item The heading remains inside <li> and enters .TableOfContents Native Steps can carry navigable headings
Nested list with {.filetree} The class attaches to the outer <ul> FileTree needs no wrapper merely to preserve hierarchy
Standalone image plus {#id num= caption= .class} render-image receives IsBlock=true and all attributes A Book figure can have a native image form
Inline image inside a paragraph IsBlock=false; the image receives no block attributes Inline images cannot use the block-figure contract
Block math plus {#id num=} render-passthrough receives block type and attributes A numbered equation can use the native passthrough form
Table plus {.fields #id num= caption=} render-table receives the class and named attributes Field tables, matrix markers, captions, and Book numbering can share one hook
Fenced code plus {#id num= caption=} The code-block hook receives the attributes A numbered example can be the fence itself
Callout plus {icon= tab=} The blockquote hook receives callout metadata and attributes Folding, inline title markup, icon, and tab metadata can coexist
Attribute line separated from its block by a blank line The attribute silently disappears Source checks must reject orphan attribute lines
Adjacent tables with tab= Each table hook receives its own tab label Adjacent-block tabs can extend beyond code fences

Container boundary

Hugo’s % shortcode delimiter renders .Inner as Markdown, but its template must put a blank line before and after that inner Markdown. Without both blank lines, a following list may be treated as literal HTML-block content instead of Markdown.

A multi-line % container inside a CommonMark list item has a harder limit: the generated HTML is not indented as list content, so the list closes before the container and restarts afterwards. This is why OINK keeps a full Steps form for steps that must contain another full container. Ordinary rich blocks, fences, and < shortcodes do not have that limitation.

Nested % shortcodes also receive already rendered inner HTML in the relevant collector shape. A collector that requires the child’s original Markdown uses < delimiters and renders the captured body through the shared scoped block renderer.

Attribute ownership

An available attribute is not automatically a public attribute. Every hook owns a documented allowlist. style and inline on* handlers are rejected; URL-bearing values pass the shared URL policy. A site class is retained only on the surfaces where downstream CSS is an established extension mechanism.

The experiment also showed that gallery images inside list items can be block images while still receiving no knowledge of their parent list’s marker. A runtime may therefore need either a theme-emitted marker or a narrow structural fallback; it cannot assume the image hook sees arbitrary ancestors.

Limits and verification

These results cover Hugo 0.160.1 and 0.164.0 with the stated Goldmark settings. They do not promise identical behaviour for a site that changes those settings or for a later Hugo release. A Hugo-floor change reruns the focused component, Book, table, gallery, and Markdown-output checks before this snapshot is updated.

7.7.2 - Consumer and migration evidence

A dated corpus snapshot that shaped OINK’s shells, authoring primitives, and deterministic Book migration policy.
Dated corpus snapshot

These counts describe the repositories inspected in August 2026. They are evidence for design choices, not live product metrics or compatibility promises.

Corpus

The authoring survey scanned the content/ trees of eleven OINK consumer sites: 5,325 Markdown files, of which 5,293 had YAML front matter. The set included single-language English and Chinese references, bilingual product sites, release archives, custom landing pages, and separate Book consumers.

The survey deliberately measured source Markdown rather than generated HTML. It counted shortcode calls, fenced-code attributes, callouts, table markers, raw HTML, front-matter keys, content types, and site-local layouts. A later Book-focused pass added five long-form consumers.

Findings that changed the design

Evidence Resulting choice
Content ranged from nearly plain Markdown to pages with many nested components Native Markdown is the default form; a full form survives only for a named capability gap
Documentation, Blog, Landing, releases, and books repeatedly reimplemented navigation or cards locally Extend the shared shell, registry, and primitive rather than adding a parallel system
Site-specific table classes were common, while canonical Field-table headings were rare Hook attributes use an allowlist but preserve documented site-class extension points; Fields cannot be inferred from arbitrary two-column tables
Book sites carried private figure, table, equation, example, and cross-reference conventions Numbered primitives and migration profiles need deterministic classification, stable IDs, and rendered-target verification
Sites mixed single-language, peer-file bilingual, and generated-language content Language authority and generation boundaries must be explicit; a migration never treats an untracked generated tree as source
Rich HTML pages still needed print, Markdown, feeds, and agent output Every component declares its output degradation before its interactive HTML is accepted

The evidence also rejected several attractive additions. Documentation sites did not justify a second Landing system; Book sites did not need a new cover component; a serial archive did not justify a new shell type; and remote API collection belonged to site-side CI rather than a Hugo theme that promises local builds.

Block and table evidence

A focused pass over eleven sites plus Book consumers found 11,484 pipe tables. Only eleven already matched the strict Field-table heading vocabulary, while roughly 874 were reference-style tables and about 1,300 were compatibility matrices. The result was explicit .fields and .matrix markers rather than shape guessing.

The same pass found eighteen Steps blocks in the eleven-site corpus. They all used the full form with headings and rich content. Platform probes showed that a native ordered list could carry most of that content, while another full % container inside a list item could not. OINK therefore keeps both forms for a technical capability boundary, not merely for stylistic preference.

Deterministic Book migration

Three dated dry-run profiles tested whether the migration rules could account for every recognized source without inventing semantics:

Profile snapshot Classified result Manual boundary
DDIA v2 106 figures, 3 tables, 22 code examples, and all 304 relevant links accounted for One caption link flattened to visible text; no unaccounted skip
DDIA v1 90 numbered figures and 203 matching references 14 decorative or unnumbered images deliberately left alone
TPME 31 figures, 10 tables, 44 numbered references, and 1,018 generic stable references No skipped recognized item
Private Book profile 119 figures, 5 tables, and 136 numbered references 3 ambiguous images retained for manual review

Each profile was dry-run first, wrote only after its ambiguity boundary was understood, produced zero changes on a second run, built with warnings fatal, and passed rendered kind/number/anchor checks. The public migration toolkit and current profile boundaries are documented in Writing a book and the migration contract.

Limits

These counts should not be copied into product marketing or used as a current site inventory. Repeating the research requires a fresh repository list and a new dated report. Paths, uncommitted content, private repository names, raw agent transcripts, and generated build artifacts are intentionally excluded from this public record.

7.8 - Design proposals and PRDs

The canonical bilingual home for OINK PRDs and designs that are still being evaluated.
Non-normative material

A proposal describes behaviour that may not exist. Current behaviour is defined by the contracts, accepted decisions, implementation, and owning checkers. Never use a proposal as a configuration reference.

This section is the canonical home for OINK product requirement documents, RFC-style designs, and unresolved maintainer proposals. Do not create a local plan/, plans/, proposal/, or parallel design tree in the theme repository or the documentation repository.

Active proposals

Proposal Current boundary
Backlinks and knowledge graph Draft; no graph or backlink implementation exists
Media convergence Draft; records only the remaining cross-surface convergence after the shared content resolver and Zoom marker landed
Bulk agent indexes Draft; per-page Markdown and llms.txt already exist, while bulk text and navigation JSON do not

Where a new PRD goes

Create one English-primary page and its Simplified Chinese peer:

content/docs/design/proposals/<slug>.md
content/docs/design/proposals/<slug>.zh.md

Use explicit, stable English heading IDs in both files. Keep code, keys, paths, versions, and API names unchanged in Chinese. A proposal begins with visible draft status and includes:

  1. status, owner, date, and affected contract surface;
  2. context and evidence;
  3. goals and explicit non-goals;
  4. proposed behaviour and output/accessibility/security boundaries;
  5. compatibility and migration impact;
  6. implementation and owning-checker plan;
  7. acceptance criteria and open decisions;
  8. a decision log for later changes to the proposal itself.

Large experiments may add a dated page under ../research/, but temporary logs and generated artifacts stay outside Hugo content and outside Git.

Lifecycle

draft proposal
    ├── rejected/superseded → remove from the active tree; preserve Git history
    └── accepted
          ├── implementation + owning checker
          ├── affected EN/ZH contract
          ├── accepted Design decision when rationale is durable
          └── changelog, migration, and user docs when their audiences need them

Acceptance does not turn the PRD into a second contract. Move stable behaviour into the owning contract, stable rationale into Decisions, and user steps into the relevant guide. Then retire the proposal from active navigation. A local build, commit, tag, public module, consumer pin, and deployment remain separate completion states.

Review gate

Before implementation, reviewers confirm that the proposal does not duplicate an existing shell, resolver, component family, or data authority. During implementation, a changed design updates this bilingual proposal before code silently diverges. Acceptance requires the narrow theme checker, the real documentation site, rendered EN/ZH, relevant outputs, accessibility, and responsive review.

7.8.1 - Backlinks and knowledge graph

A draft three-stage design for deriving backlinks and local or global graph views from ordinary Hugo links.
Draft PRD — not implemented

OINK currently has no backlink block, local graph, global graph page, or graph output format. Names and configuration in this proposal are not public API until the proposal is accepted and the contracts change.

Premise

Reverse navigation and a view of connected pages are properties of the link graph, not of [[wikilink]] spelling. Hugo already accepts ordinary Markdown links and ref / relref. OINK can derive a graph from content authors already write, without adding a parser, Goldmark extension, or parallel authoring syntax.

The first value is backlinks, not visualization. A static inbound-link list is useful without JavaScript and can degrade into print and Markdown. An interactive graph remains an optional enhancement over that complete list.

Goals and non-goals

Goals:

  • derive one language-local link index per build;
  • show deterministic inbound links on a page;
  • optionally show a bounded local neighbourhood;
  • optionally publish a whole-site view and a machine-readable graph;
  • preserve ordinary preview when an edited link is stale or incomplete.

Non-goals:

  • introducing [[wikilink]] syntax;
  • indexing external, mailto:, same-page anchor, or self links;
  • executing JavaScript to discover links already present in content;
  • turning a visualization into the only way to navigate;
  • promising perfect extraction from arbitrary shortcode parameters or raw HTML.

Delivery stages

Stage Deliverable Runtime Independent value
G1 Language-local link index and backlink list None Reverse navigation in HTML, Print, and Markdown
G2 Local graph around the current page Existing ECharts plus a small local runtime Spatial view with G1 as the accessible fallback
G3 Global graph page and graph data output Same runtime Whole-site exploration and machine-readable edges

Each stage is accepted separately. G1 does not wait for G2, and G2 does not force every page to load graph code.

Extraction contract

The proposed index scans source content once per language and records one edge per source/target pair. It strips fenced code and inline code before extracting ordinary Markdown links and ref / relref; then it resolves only internal pages, removes fragments for page identity, drops self-links, and deduplicates repeated references.

The implementation must test at least:

  • duplicate links collapse to one edge;
  • fenced and inline code produce no edge;
  • external, protocol-relative, mail, same-page anchor, and self links are excluded;
  • ref and relref are included;
  • each language produces an independent graph;
  • an unresolved derived edge warns or is reported by the focused checker without making ordinary hugo server unusable.

Raw source scanning has known omissions. A URL stored in a custom shortcode parameter or raw <a href> may not appear. Those omissions must be documented instead of hidden behind a claim of a complete semantic graph.

G1 renders a short, ordered list near the page end. Order is deterministic: section, then navigation weight, title, and stable path as the final tie-break. The block uses ordinary links and headings, has no disclosure-only content, and is omitted when there are no inbound pages.

Print and Markdown keep the readable list. RSS omits it unless feed-level research demonstrates that backlinks improve an article feed rather than creating noisy site navigation.

Interactive graph boundary

G2 reuses the locally vendored ECharts graph series. The current page is the centre; direct inbound and outbound neighbours form the default depth. A hard node cap prevents unreadable or expensive views. Keyboard focus, text alternatives, reduced motion, forced colours, narrow screens, and print are acceptance requirements, not later polish.

If JavaScript or ECharts is unavailable, G1 remains complete and visible. The runtime is loaded only on pages that render a graph and must join the existing feature-bundle key so unlike pages cannot collide in the asset cache.

Global output

G3 may add a dedicated graph page and an opt-in JSON output. The JSON schema would contain a version, language, nodes, and directed edges with stable URLs; it would not expose local file paths or unpublished pages. The output must be derived from the same index as G1 and G2 so three representations cannot drift.

Compatibility and migration

Ordinary Markdown remains unchanged, so content migration is unnecessary. Configuration names remain undecided until a prototype proves the smallest surface. The default for every interactive or global output is off; a static backlink list may be considered separately because it is local navigation with no network or browser state.

Acceptance criteria

Acceptance requires a focused graph checker, extraction fixtures, HTML/Print/ Markdown goldens, strict-build negative cases, browser accessibility and responsive tests, and a real bilingual-site build. Performance is measured on a representative large site, but a dated prototype timing is not a permanent budget.

Open decisions

  1. Is G1 opt-in, opt-out, or enabled only for selected shell types?
  2. Does the local graph expose one depth or a tightly capped second depth?
  3. Which page metadata, if any, is useful enough to enter graph JSON?
  4. Should unresolved heuristic edges stay silent while a dedicated link checker reports them, or should deduplicated preview warnings be visible?
  5. Is G3 useful enough to justify a new output format before G1 and G2 have production evidence?

7.8.2 - Media convergence

A draft for the remaining convergence between content images, numbered figures, Landing media, and featured-image selection.
Draft PRD — remaining work only

OINK already has a shared content-image resolver, one Zoom marker, processed Markdown images, numbered figures, and safe Landing URL handling. This page proposes only the unresolved convergence; it must not be read as a list of missing current features.

Current baseline

The content image hook, numbered fig, cards, and galleries resolve local page resources, section resources, global assets, static files, and explicit remote URLs through content/image-resolve.html. Raster resources can contribute intrinsic dimensions and processing derivatives. HTML Zoom eligibility is marked with data-td-image-zoom; the build-time detector only checks that theme-emitted marker.

Standalone Markdown images can already combine caption or Book numbering with processing and a link. Numbered image figures share td-figure and td-book-figure semantics. Landing media passes the shared URL trust policy, while featured images intentionally use a ranking resolver because their job is to select a representative image rather than render one explicit source.

Remaining problem

The shared safety boundary is stronger than the shared media model. Landing media still does not obtain the same page-resource metadata and processing result as body images. Featured-image selection and explicit image resolution have separate result shapes. Some compatibility class names remain in markup, and Book’s full fig form cannot express every processing option available to the native image hook.

The design question is therefore no longer “replace seven image entry points.” It is whether the remaining surfaces can share a small result contract without erasing their different semantics.

Goals and non-goals

Goals:

  • define one normalized media-result shape for URL, source URL, dimensions, alternative text, attribution, processability, and external status;
  • let explicit content images, Landing media, and representative images reuse that shape where their source semantics overlap;
  • keep figure markup and Zoom eligibility single-owned;
  • decide whether the full fig form needs processing or whether authors should use the native image form for processed numbered images;
  • retire compatibility markup only after consumer evidence and a release note.

Non-goals:

  • adding a third-party lightbox or remote image service;
  • changing image Zoom from opt-in to site policy by accident;
  • giving galleries a new caption, sequence, or carousel model;
  • merging non-image Book targets such as tables, equations, and examples into an image-only base class;
  • making featured-image ranking identical to explicit image resolution.

Proposed phases

M1 — Result contract

Document the fields returned by the content and representative-image resolvers, then extract the intersection into one internal media-result contract. Keep source ranking in the featured resolver and source resolution in the content resolver. This is an internal refactor with byte-stable output.

M2 — Landing resource metadata

Allow Landing items to resolve eligible local resources through the media contract, gaining intrinsic dimensions and the same URL/security decision. Explicit width and height in Landing data continue to win. Remote and static sources remain valid but cannot pretend to have processable-resource metadata.

M3 — Full figure capability decision

Choose one of two answers:

  1. add processing arguments to the full fig source form and normalize them through the same processing helper; or
  2. keep processing exclusively on native Markdown images and document full fig as the container for arbitrary numbered block content.

No implementation should leave both answers half-supported. Markdown/LLMS must link to the documented source or derivative consistently in both forms.

M4 — Compatibility retirement

Inventory downstream CSS and JavaScript before removing old image-element classes or attributes. If a compatibility name is still used, retain it for a documented release window or migrate the owning site in the same release train.

Safety, output, and accessibility

  • Image URLs keep the shared scheme and remote-host policy.
  • Missing required alternative text warns and renders a decorative fallback only where the current contract permits it.
  • Width and height never claim metadata that an SVG, static file, or remote source did not provide.
  • Linked images are not Zoom targets; the runtime preserves dialog focus, keyboard close, reduced motion, and narrow-screen containment.
  • Print, Markdown, RSS, and LLMS strip interaction markers while retaining the intended image, caption, attribution, number, and link.

Acceptance criteria

Each phase owns byte-level HTML and Markdown evidence, content and Landing resolver tests, URL/security checks, image-processing tests, Book targets, gallery/Zoom browser tests, and real-site EN/ZH narrow-screen review. The proposal is accepted only after the M3 capability choice is explicit.

Open decisions

  1. Is one shared result struct enough, or would a common lower-level URL/resource record keep resolver ownership clearer?
  2. Should Landing consume resource attribution, or only dimensions and URL?
  3. Does full fig processing solve a real consumer need now that native images support numbering, captions, links, and processing together?
  4. Which emitted compatibility names are still used by real consumers?

7.8.3 - Bulk agent indexes

A draft for optional llms-full text bundles and a stable navigation JSON derived from OINK’s existing Markdown output and navigation authority.
Draft PRD — partial premise already exists

OINK already supports per-page Markdown, language-local llms.txt, HTML discovery links, and Copy Markdown. It does not currently publish llms-full.txt or a navigation JSON. Only those remaining outputs are proposed here.

Current baseline

A site opts into Hugo’s Markdown output for pages and sections and the LLMS home output for llms.txt. OINK renders shortcodes into semantic Markdown, keeps the source URL and language-local LLMS index discoverable, and derives Copy Markdown from the same alternative output URL. The theme declares output formats but does not force a site’s outputs selection.

Navigation already has an authority chain: an explicit data/docs_nav.json tree where present, otherwise the content tree and weights. Sidebar, pager, and declared section indexes share that authority. A machine navigation output must derive from it rather than inventing another order.

Goals and non-goals

Goals:

  • optionally assemble a language-local full-text bundle for small sites or one bundle per top-level section for larger sites;
  • optionally publish a versioned navigation JSON for agents and external tools;
  • reuse the same Markdown page renderer, page inclusion policy, and navigation authority as the human site;
  • keep every output opt-in through Hugo’s output configuration;
  • validate links, language isolation, media types, and deterministic order.

Non-goals:

  • replacing per-page Markdown or llms.txt;
  • creating a params.oink.* configuration tree;
  • scraping generated public/ files during the Hugo build;
  • embedding private source paths, draft pages, or cross-language fallbacks;
  • promising that one giant bundle is appropriate for every model context.

Full-text bundle

The proposed llms-full.txt output concatenates the same semantic Markdown used by each page output. Pages are separated by a stable visible delimiter and source URL. A site chooses one of two deployment shapes:

Shape Placement Intended use
Site bundle One language-local file at the language root Small, focused sites
Section bundles One file for each explicitly enabled top-level section Large references and books

Hugo output configuration, not a theme parameter, decides which pages receive the format. The theme may provide a checker that reports a mismatch between an intended shape and the actual outputs, but it does not mutate the site’s output set.

The bundle is assembled inside Hugo from one shared page-rendering partial. It does not read sibling artifacts from public/ or depend on output build order. Size is reported as evidence; an arbitrary warning threshold must not make --panicOnWarning fail an otherwise valid publication.

Navigation JSON

The proposed JSON contains a schema version, language, roots, and recursively ordered nodes. A page node has a stable ID, title, HTML URL, Markdown URL where enabled, kind/type, weight, and children. An explicit external navigation node contains only its label, URL, and external kind.

The output follows the same visibility and ordering rules as the rendered sidebar. It excludes drafts, headless resources, hidden navigation entries, and pages unavailable in the current language. It never serializes a local filename.

The format receives its own JSON Schema and golden fixtures. It is marked notAlternative so Hugo does not advertise it as a page-level alternate.

Discovery and output boundaries

llms.txt may link to enabled bulk bundles and the navigation JSON. HTML head discovery continues to advertise per-page Markdown and the language-local LLMS index; it does not add every bulk artifact to every page.

Shortcodes, Landing sections, Book targets, and interactive components keep their current Markdown degradation. The new outputs do not gain permission to emit component HTML, scripts, comments, feedback controls, or navigation chrome.

Acceptance criteria

  • EN and ZH outputs contain only their own language pages and URLs.
  • Every listed Markdown URL exists; every navigation URL resolves or is an explicit external node.
  • Ordering matches the rendered sidebar and pager for the same root.
  • Rebuilding with the same source produces byte-stable output under pinned Hugo versions and inputs.
  • HTML, Markdown, Print, RSS, and LLMS goldens show no regression when the new formats are disabled.
  • A large-site fixture demonstrates section bundles without generating a file for every nested section.

Open decisions

  1. Are both full-text deployment shapes needed, or is section-only safer?
  2. Should navigation JSON be a home output or a dedicated content page backed by a resource template?
  3. Which node metadata is stable enough for schema version 1?
  4. Should llms.txt list navigation JSON by default when it exists?
  5. What size evidence should the checker report without enforcing an arbitrary model-context limit?