Fork the documentation site itself and get a local preview in ten minutes.
This is the multi-page printable view of this section. .
OINK Documentation
-
1: What is OINK
- 1.1: Highlights
- 1.2: Case Guide
- 1.3: License and acknowledgements
- 2: Quick start
-
3: Authoring
- 3.1: Writing pages
- 3.2: Organizing content
- 3.3: Page parameters
- 3.4: Blog posts
- 3.5: Books
- 3.6: Releases and downloads
- 3.7: API reference pages
- 4: Components
-
5: Customization
- 5.1: Configuration
- 5.2: Brand and appearance
- 5.3: Home and landing pages
- 5.4: Navigation and menus
- 5.5: Layouts and page types
- 5.6: Search
- 5.7: Command palette
- 5.8: Keyboard navigation
- 5.9: Languages
- 5.10: Versions
- 5.11: Taxonomies
- 5.12: Repository links and page info
- 5.13: Print
- 5.14: AI-agent support
-
6: Operations
- 6.1: Local preview
- 6.2: Deploy
- 6.3: Comments
- 6.4: Analytics and SEO
- 6.5: Upgrade
- 6.6: Troubleshooting
-
7: Design and development
- 7.1: Architecture contract
- 7.2: Component contract
- 7.3: Shell and navigation contract
- 7.4: Landing contract
- 7.5: OINK migration boundary
-
7.6: Design decisions
- 7.6.1: Warnings and safe fallbacks
- 7.6.2: Configuration model
- 7.6.3: Markdown-first authoring
- 7.7: Design research
-
7.8: Design proposals and PRDs
- 7.8.1: Backlinks and knowledge graph
- 7.8.2: Media convergence
- 7.8.3: Bulk agent indexes
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
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.

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
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.
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.
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.
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.
Full-text search that stays on the site
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.
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.
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 withxref, indexes generated bybook-tocandbook-figuresand friends, and a printable whole. - Release and download pages:
data/download/*.yamlproduces release cards, asset tables and checksums, with a controlled publication state. - Landing pages:
data/home/<lang>.yamlassembles the home page sections; any page withlayout: landingcan use data underdata/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.
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:
- Press
Cmd/Ctrl + Kon any page and typepostgresto see local search results; press\for command-only mode. - Append
index.mdto the current page address to get this page’s Markdown version. - Open https://oink.pgsty.com/llms.txt, the site inventory written for AI assistants.
Related
- What is OINK — scope, fit and comparisons
- Showcase — how production sites use these features
- Quick start — from clone to deploy
- Configuration — where to look up the parameters named above
1.2 - Case Guide
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.
1.3 - License and acknowledgements
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.
Related
- What is OINK — what the project is and where it came from
- Highlights — what local-first means in practice
- Configuration — which features bring in an external service
- Brand and appearance — changing fonts and icons
2 - Quick start
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.

Walkthrough
-
Install Hugo Extended and Go
Besides Git you need two things. Hugo Extended must be
0.160.1or 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 verifygithub.com/pgsty/oink.macOSLinuxWindowsCheck once afterwards; the output must contain
extended:On other platforms follow the Hugo installation guide and go.dev/dl, taking care to pick the extended build.
-
Clone the documentation site and preview it
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.modpins the theme version, so the clone builds as it is, with no extra install script.NoteThe
Makefilein the repository is only a set of command aliases.make devandmake checkuse a sibling../oinktheme checkout throughHUGO_MODULE_REPLACEMENTS;make buildandmake servealways use the published version pinned ingo.mod. For a new site,hugo serveris enough. -
Replace the site details
Site identity lives entirely in
hugo.yml.baseURLis a YAML anchor — the real address is written onparams.productionURL, and that is the only place to change it:hugo.ymllanguages.en.titleandlanguages.zh.titleoverride the top-leveltitle, 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.ymlWhat to do services.googleAnalytics.idOINK’s analytics ID. Delete it, or replace it with your own when you want analytics params.commentsgiscus pointing at discussions in pgsty/oink.pgsty.com. Delete the block or point it at your repositoryparams.tdVersionparams.versionparams.version_menuparams.versionsOINK’s version menu. Delete params.github_project_repoA link to the theme repository. Delete languages.<lang>.menus.mainNavbar entries pointing at this site’s sections such as /docs/tutorial. Rewrite for your treeReplace the logo and icons. Replace these three files, keeping the filenames — the theme mounts them by name:
static/static/logo.svgis this site’s own brand lockup and no parameter points at it. Delete it, or replace it with a horizontal wordmark and setparams.wordmark.Replace the content.
content/docs/is OINK’s own theme documentation; delete the whole tree and write your first page:content/docs/_index.mdcontent/blog/can keep one post as a template or be deleted entirely (if you delete it, remove theblogentry frommenus.mainas 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.zhblock and every.zh.mdfile, leaving one language:hugo.ymlTo keep both languages or swap in a different pair, see Languages.
-
Deploy
Create an empty repository on GitHub and replace the local history with your own:
The repository ships with
.github/workflows/pages.yml: a push tomainbuilds 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 takesbaseURLfrom GitHub Pages, so publishing to a subpath such asexample.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.Deletingscripts/means editing the workflowThe
Verify advertised and pinned release matchstep inpages.ymlrunsnode scripts/check-release-pin.mjsto check that the version the site advertises matches the one pinned ingo.mod. Oncescripts/is gone, remove that step andSet up Node.jsfrompages.yml.For Cloudflare Pages, Netlify, Nginx and offline packaging, see Deploy: the build command is always
hugo --gc --minify, and onlybaseURLand the environment variables differ.
Verify
Run a production build locally. It is stricter than the development server, and path warnings fail the build:
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 Pagesis 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:
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.
Related
- Repository tour — what each directory is, and the order to delete in
- From scratch and other install methods — starting with
hugo mod init, submodules and offline installation - Local preview — the
hugo serverswitches worth knowing, and previewing drafts - Deploy — configuration per host, and the acceptance checklist
- Troubleshooting — the four common classes of error: build, language, search, platform
2.1 - Repository tour
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 .gitignore — public/ (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 |
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.
-
Drop the scaffolding
None of this takes part in rendering, and removing it affects no page.
After deleting
scripts/you must edit.github/workflows/pages.yml: remove theSet up Node.jsandVerify advertised and pinned release matchsteps, or the deploy fails there. -
Drop the example content
content/docs/is OINK’s own theme documentation andcontent/blog/its engineering blog; neither has anything to do with your product.Edit
menus.mainunder each language inhugo.ymlat the same time: those entries point at paths such as/docs/tutorialand/blog/releasethat no longer exist.content/_index.mdis the home page — keep it and replace the body with yours. -
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.data/home/en.yamlanddata/home/zh.yamldecide which sections the home page has; each entry is explained in Home and landing pages. Deletingdata/home/entirely still builds, and the home page falls back to an ordinary content page. -
Swap the identity
Finally, change the site name,
params.productionURL,params.github_repoand the brand parameters inhugo.ymlto yours, replace the logo and favicon understatic/, and delete the OINK-specific configuration:services.googleAnalytics,params.commentsand theparams.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 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:
Pin to one version:
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:
Once the deleting is done, all of this should hold:
- The build ends with
Total in …and noWARNorERROR - 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 statusshows nopublic/orresources/
Related
- Quick start — the full clone, configure and deploy path
- From scratch and other install methods — building from an empty directory instead of trimming
- Organizing content — how the
content/tree becomes the sidebar - Configuration — every
hugo.ymlkey and its default - Upgrade — upgrading the theme module, and the migration toolkit
2.2 - From scratch and other install methods
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
-
Create the skeleton and fetch the theme
What follows
hugo mod initis your own site’s module path, usually the repository address.hugo mod getwritesgo.modandgo.sum, and both are committed.The newest version number is on GitHub Releases; the
v0.6.0on this page is what this site currently pins. A production site pins a release tag rather than followingmain:@latestis a one-off resolution, not a version policy. -
Writing
hugo.ymlRename the
hugo.yamlthathugo new sitegenerated tohugo.yml(Hugo accepts both; this documentation uses the latter throughout) and replace its contents with the following, which builds as it stands:hugo.ymlWhat each of the five blocks governs:
Block Governs Consequence of omitting it Top level + languagesSite name, domain, languages and navbar menu A wrong baseURLsends every absolute link astray in productionmarkup.goldmarkThe three component prerequisites An attribute line becomes a literal {.steps}in the proseparamsSearch, repository links, shell switches Interactive features stay off; the theme does not decide for the site outputsThe per-page .md,llms.txtand print pagesNo “Copy as Markdown” in the page menu, and no print view moduleReferences 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.
-
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.mdcontent/docs/install.mdWrite 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. -
Preview
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)
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:
CI must initialize the submodule before running Hugo, or themes/oink is an
empty directory:
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.
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/.
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:
Carry the archive and its .sha256 into the isolated environment, verify, then
unpack:
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:
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 |
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:
Use the HUGO_MODULE_REPLACEMENTS environment variable to substitute the local
checkout temporarily, leaving go.mod untouched:
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:
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
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: falsetook effect) git statusshowsgo.modandgo.sum, and nopublic/orresources/
Related
- 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.ymlkey and its default - Writing pages — how to keep writing after the first page
- Upgrade — upgrading the theme module, and migrating from Docsy
3 - Authoring
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:
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
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
- docs/
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.
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:
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 ###:
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/#prerequisitesand/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:
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.
Writing links
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/),  |
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
grepto replace them site-wide after a restructure; - When moving a page, add
aliasesfor the old path and update internal links to the new route — do not let an alias carry navigation indefinitely; - Use
reffor 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  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:
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.
- Install Hugo Extended, 0.160.1 at the oldest:
- Clone the documentation site and preview it:
Tip
Add
-Dto 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:
- 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 arefwhose target is missing all fail here naming the file and the line; the theme never degrades silently. --printPathWarningsreports two pages resolving to the same output path, which turns up most often in multilingual sites or after changingpermalinks.
Then confirm three things in the browser:
- The page is in the sidebar, in the position
weightimplies; - The right-hand outline lists the
##headings you wrote, and clicking one puts an English anchor in the URL; - 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).
Related
- Organizing content — how the directory structure decides the sidebar
- Page parameters — the full front matter table
- Components — each component’s syntax and parameters
- Languages — paired bilingual files and fallback for untranslated pages
- Local preview — the
hugo serverswitches worth knowing
3.2 - Organizing content
_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
- docs/
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.
A section _index.md has one further power: cascade pushes shared settings
down the whole subtree once, instead of repeating them on every page.
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:
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:
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.
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:
Icon density is a site-level policy, so that leaf pages do not all carry icons:
| 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.
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:
Documentation can therefore live at any path, with type assigned by a cascade.
To put a handbook at content/handbook/, the section root reads:
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:
| 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
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:
- The sidebar order matches the
weightvalues you wrote, and a new section appears where expected; - The section index lists every child (a missing one comes from
hide_summaryor a missing_index.md); - Breadcrumbs and the pager follow the same order as the sidebar, because the pager reads the same tree;
- The tree has the same shape after switching language (every
_index.mdneeds a.zh.mdcounterpart).
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.
Related
- Writing pages — how to write a single page
- Page parameters — the full definition of every front matter key used here
- Layouts and page types — site-level shell, sidebar and table-of-contents settings
- Navigation and menus — the navbar menu, breadcrumbs and pager
- Languages — keeping a bilingual tree consistent
3.3 - Page parameters
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:
- The page’s own front matter;
- The nearest
cascade(when several cascade layers set the same key, the one closest to the page wins); - 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.
Inside a cascade the key names are unchanged, just one level deeper:
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, ,- Page heading, browser title, search result title. Required on every page
linkTitle, ,- Short name in the sidebar, breadcrumbs, pager and cards
description, ,- One-sentence summary: section cards, search snippet,
meta description; rendered as a standfirst above the body on blog pages weight, ,- Ordering among siblings; use multiples of 10.
0(unset) sorts after every page that has a weight — see Organizing content draft, ,- A draft never reaches the build output;
hugo server -Dpreviews it — see Writing pages date, ,- Blog date, and the sort key for release pages; a future date is excluded by default
lastmod, ,- The page-end “last modified”; not needed by hand when the site enables
enableGitInfo aliases, ,- Redirects an old path to this page; for page migration, not for everyday navigation
type, ,- Decides the template and the shell:
docs,book,blog,swagger— see Organizing content layout, ,- Picks a layout for one page:
landing,releases cascade, ,- Pushes the keys below down the whole subtree
Sidebar and navigation
The guide is Organizing content.
icon, ,- Icon in the sidebar, section cards and search results, e.g.
fa-solid fa-rocket toc_hide, ,- Absent from the sidebar tree and from the pager sequence
hide_summary, ,- Absent from the section index
sidebar_divider, ,- The row renders as a sidebar group heading: not a link, and not in the pager sequence
sidebar_expanded, ,- This section is expanded by default in the sidebar
sidebar_root_for, ,- Makes this section a sidebar tree root;
selfincludes the section index,childrencovers descendants only. Any other value warns and is ignored sidebar_root_link_self, ,- The root row links to itself;
falselinks to the parent section instead. A non-boolean fails the build sidebar_root_menu, ,- Whether a top-level section appears in the root switcher
toc_root, ,- When the sidebar root is the site home, excludes this whole top-level section from the tree and the pager sequence
manual_link, ,- The sidebar and section index row points elsewhere
manual_link_relref, ,- The same, resolved with
relref; a missing target fails the build manual_link_title, ,- Hover title for the manual link
manual_link_target, ,- For example
_blank; the theme addsnoopener no_list, ,- The section index generates no child list
simple_list, ,- The child index renders as a compact bulleted list
section_index, ,- Style of the child index. An invalid value warns and falls back
section_index_columns, ,- Column count in the card style
notoc, ,- Hides the right-hand page outline
pager, ,falseturns off previous / next for this page. A non-boolean warns and is ignorednavbar_enabled, ,- Whether this page renders the navbar
navbar_autohide, ,- The navbar hides itself on pointer devices
page_context_menu, ,- The page action menu on the title row (copy Markdown, edit this page, print, …)
page_context_menu.assistant_links, ,- 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, ,- Width of the content column. An invalid value warns and falls back
reading_width, ,- Reading measure on Book pages; applies to
type: bookonly footer_style, ,- Footer shape. An invalid value warns and falls back
body_class, ,- A class appended to
<body>for the site’s own CSS reading_time, ,- Whether this page shows a reading time;
falsehides it sidebar_enabled, ,- Whether this page shows the left sidebar;
falsehides it scroll_spy, ,- Scroll tracking in the outline;
trueenables it keyboard_nav, ,- Single-key keyboard navigation — see Keyboard navigation. A non-boolean warns and falls back
lastmod_commit, ,- 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, ,- Sidebar behaviour can be overridden per page too; the values are in Configuration
Search
The guide is Search.
search_keywords, ,- Extra search terms, including synonyms and other languages
search_boost, ,- 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, ,- Keeps the page out of the local index
Output formats
The guides are AI-agent support (.md and
llms.txt) and Print.
outputs, ,- Which output formats this page generates;
[HTML]stops the.mdtwin no_print, ,- 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, ,- Whether this page shows the giscus comment section — see Comments
feedback, ,- The map form takes
enableandreasons. Anything else warns and falls back annotation, ,- The “last modified / provenance” block at the page end. Only a boolean is accepted; anything else warns and falls back
translation_notice, ,- The language code of the authoritative version, so a translation can say so and link back; write
falseon 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, ,- The address of the material this page is derived from. An empty string opts out of an inherited cascade value
upstream_name, ,- The upstream work, as the attribution names it. Required once
upstream_linkis set upstream_copyright, ,- The copyright notice, retained as upstream wrote it. Required
upstream_license, ,- Must be found in
data/licenses, or the build fails. Required upstream_notice, ,- The page carrying the full notice (licence text, warranty disclaimer, upstream NOTICE, snapshot pin). Required
upstream_ref, ,- The tag or commit the snapshot pins, shown in parentheses after the work
upstream_source, ,- The entry name in
data/upstreams, for upstream facts shared by many pages; a missing entry fails the build upstream_modified, ,- 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, ,- 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, ,- Post byline; inline Markdown is allowed. Ignored on a page that has
authors authors, ,- Terms of the
authorstaxonomy, in byline order — see Authors and bylines. Needsauthor: authorsundertaxonomies: series, ,- Terms of the
seriestaxonomy. The strip above the body uses the first one — see Series series_weight, ,- Place in the series. Weighted members come first in ascending order, the rest follow by ascending date
tags, ,- Tags — see Taxonomies
categories, ,- Categories, likewise
images, ,- The first entry becomes the post’s featured image and share card; put it in a section
_index.mdcascade for a section-wide default, andimages: []means no featured image featured_image, ,- How this article renders its own featured image. An invalid value warns and falls back
blog_index, ,- Written on a blog root, the list form for that section. An invalid value warns and falls back
share, ,- The page-end share targets, replacing any inherited list;
falseopts this page out — see Share. An unknown target warns and is dropped summary, ,- Fallback excerpt for post rows on tag and category pages;
descriptionwins
Book
The guide is Books. A whole book sets type: book through
a section cascade.
book_number, ,- Chapter number, shown before the page title and the sidebar entry
book_status, ,- Marks a draft chapter: flagged in the sidebar and contents, and left out of the indexes by default
sidebar_headings, ,- Expands the h2–h4 branch under the current sidebar entry. Out of range warns and falls back
book_draft_banner, ,- 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, ,- Data is taken from
data/landing/<key>/<language>.yaml sections, ,- 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, ,- The release facts. The string form is
https://github.com/<owner>/<repo>/releases/tag/<tag>; the map form takesproduct,version,repo,tag,date,prevandchecksums, of whichversionandrepoare required, and an unknown key or a wrong type fails the build release_products, ,- Restricts the release list to these products. An invalid filter fails the build
release_group_by_product, ,- Groups by product; with it on, every selected post must set
release.product
Related
- Writing pages — the handful of keys every page needs
- Organizing content — what the sidebar and navigation keys actually do
- Configuration — the full table of site parameters in
hugo.yml
3.4 - Blog posts
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
- blog/
The section root pushes the type down the whole subtree and sets the behaviour that section shares:
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
Where it differs from a documentation page:
dateis 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 -Fpreviews it.descriptionis rendered as a standfirst above the body, not only as a search snippet, so write it as a sentence for the reader.authoraccepts inline Markdown, so[Vonng](https://vonng.com)works. For more than one author, a portrait, or a profile page, use theauthorstaxonomy below instead; the two do not interfere, and a post keeps renderingauthorwhereverauthorsis absent.- The date display format comes from
params.time_format_blogand can be set per language (this site usesMonday, January 02, 2006in English and2006年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.
Featured image
Each row on a list page or a tag page has a thumbnail on the left, resolved in this order, first match winning:
imagesin the post’s front matter, first entry;- An image resource in the page bundle whose filename contains
featured(it is cropped to a thumbnail, and the resource’s ownbylinebecomes its caption); - An
imagesvalue inherited from an ancestor section’scascade, 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:
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 |
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.
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:
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.
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:
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:
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:
A post then names its authors in order:
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:
The display name is the term page’s link title — linkTitle 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:
An article names the series and may place itself in it:
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:
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:
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
It must reach Total in … with no ERROR and no WARN. Then confirm:
- The post appears under the right year group at
/blog/, with the date in the expected format; public/blog/index.xmlexists, contains the post, and its links are complete absolute addresses;- The thumbnail shows in the list (a missing one means none of the three featured-image sources matched);
- Tag chips lead to the corresponding tag page.
Related
- Writing pages — how to write the body
- Page parameters — the full definition of
author,imagesand the rest - Organizing content — directories and the sidebar
- Taxonomies — tags and categories
- Releases and downloads — version cards and asset tables
3.5 - Books
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:
A book that is a section maps to Hugo’s section output kind; home applies
only when the book sits at the site root:
A chapter page needs only its number and its order:
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>.

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>.
| 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 |
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.
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.
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:
tbl wraps the label, the table, the caption and the anchor in one semantic
figure:
| Output | Label | Anchor |
|---|---|---|
| HTML | Visible | Stable |
| Visible | Stable |
eq hands its content to local server-side KaTeX, so it does not depend on
passthrough:
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:
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.
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:
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.
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-toctakes adepthof 1 to 3: 1 lists chapters, 2 adds nested sections, 3 also projects each page’s heading tree.drafts=falsefiltersbook_status: draftrows out of this generated list only, and does not affect publication.book-figures,book-tables,book-equationsandbook-examplestake 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.
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:
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.
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:
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
- The build is warning-free:
hugo --printPathWarnings --panicOnWarning. A malformed number, a duplicate ID and a missing caption all fail here. - The page should show a localized label such as “Figure 2-1”, clickable
xreflinks, and anchors that land correctly. - Compare the chapter order across all four places: sidebar, pager,
book-tocand whole-book print. - Check the Markdown output:
curl -s http://localhost:1313/handbook/ch02/index.md. The shortcode form should degrade to**Figure 2-2.** captionplus the original body, and the native form should keep its source block and attribute line as they are. - Run the anchor check from the theme repository against the build output:
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, ,- Required (except for the bare
eqform). Matches[0-9A-Za-z.-]+and must be quoted id, ,- Matches
[A-Za-z][A-Za-z0-9_.:-]*and is preserved byte for byte caption, ,- Required for
eg; optional forfig,tblandeq. Not Markdown class, ,- Appended to the
<figure>; requiresnum src, ,figonly. Mutually exclusive with inner content, and follows the shared image resolution orderlinkaltwidthheight, ,figonly. Width and height are positive integerstitle, ,figonly. A migration alias forcaption, mutually exclusive with it
xref:
figtbleqeg, ,- At most one. Supplies the localized label and derives the anchor
anchor, ,- Required when no kind is given, together with inner link text
page, ,- Resolved through page lookup in the current language; a missing page fails the build
book-toc:
depth, ,- 1 chapters / 2 with nested sections / 3 with the heading tree
drafts, ,falsefilters 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_kindandbook_partare metadata keys the contract acknowledges but the current templates do not render. The ones with a visible effect arebook_numberandbook_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
printhas 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.
Related
- 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
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.
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:
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:
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):
An entry whose release_url parses reads as project tag — oink 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:
| File | Checksum |
|---|---|
| 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.
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:
.rpm
| File | Checksum |
|---|---|
| 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:
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, ,- Missing in both places fails the build
repo, ,- Required once a pinned channel has a link or assets
tag, ,- URL-safe characters only
published, ,falsemeans the immutable release does not exist yetchannels, ,- Must be non-empty
Each channel:
id, ,- Unique within the record; used as the anchor
kind, ,- Decides whether release facts may be interpolated
title, ,- Must resolve to a non-empty value
note, ,- One line of explanation under the channel
icon, ,- For example
fa-solid fa-bolt url, ,- Interpolatable on
pinnedonly steps[], ,- Code steps go through OINK’s enhanced code renderer
checksums, ,pinnedonly; mutually exclusive withchecksums_srcchecksums_src, ,- Reads the checksum file as a Hugo asset
Two rules:
- Localization resolves by suffix:
<field>_<exact language>→<field>_<base language>→<field>. A Chinese site resolvestitle_zh_cn, thentitle_zh, thentitle. camelCase aliases are not accepted. - Only a pinned channel’s
urlandsteps[].codeinterpolate${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:
Install script
The rolling channel deliberately contains no version interpolation.
Source archive
Release assets
| File | Checksum |
|---|---|
| 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:
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 carriesrelease_url, and arelease-cardcan 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
- The build is warning-free:
hugo --printPathWarnings --panicOnWarning. A malformed hash line, mixed algorithms, a missingbaseand a misspelled channel field all fail here. - On the page: the card’s tag and date match the repository, and every asset row opens a real download URL.
- Check the hashes against the actual artifacts by hand: the component only lays them out and verifies nothing.
- Confirm the hashes are complete in non-HTML output:
- Rehearse with
published: falsefirst and switch totrueonly once the tag and assets really exist; test each language and a subpath deployment.
Related
- Blog posts — where release notes live and how they are ordered
- Code Blocks — code rendering and copying inside download steps
- Home and landing pages — the landing
downloadsection - Configuration —
params.versionand the related site parameters - Upgrade — how a consuming site tracks theme versions
3.7 - API reference pages
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
- openapi/
- content/
- docs/
- write/
- openapi.mdthis page
- write/
- docs/
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:
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.
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:
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 |
| 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. redocaccepts no attribute parameter: a second positional argument fails the build.- A
redocpath 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 underpublic/after a build. - There is no mock server: Swagger UI’s “Try it out” makes a real request to whatever
serversnames, and the address in the sample specification is not reachable.
Verify
- The build is warning-free:
hugo --printPathWarnings --panicOnWarning. - The specification really was published:
ls public/openapi/docs-demo.yaml, or openhttp://localhost:1313/openapi/docs-demo.yaml. - Endpoints expand on the page and their schemas appear; the browser console shows no 404 and no cross-origin error.
- Reload once with the network off: the runtimes are local, and with a same-origin specification the interface should still appear.
Related
- Writing pages — page front matter and body basics
- Layouts and page types —
shell_types, page width and the sidebar - AI-agent support — why a component that exists only in HTML needs prose beside it
- Code Blocks — the lighter alternative of request / response examples instead of a whole UI
4 - Components
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,cardandfieldbodies, files pulled in byinclude, and the Bookfig/tbl/egbodies. - 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:
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 |  |
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 |  |
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.jsonly when a block on the page has a copy or fold control; a file tree loadsfiletree.jsonly 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_zoomon 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
> [!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
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.
hugo server -D previews drafts.
The floor is Hugo Extended 0.160.1; anything older fails the build outright.
hugo --cleanDestinationDir empties public/.
The first build after deleting resources/_gen is much slower.
Build passed with zero warnings — ship it.
Never commit go.work.
Should the site have comments? See enabling comments.
pgsty.com is a documentation site built from callouts and tables alone.
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.
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.
- Clone:
git clone https://github.com/pgsty/oink.pgsty.com my-docs - Enter the directory and preview:
- 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.
Hugo downloads themes through Go’s module system (hugo mod get). A submodule
or an offline archive works without Go installed.
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.
hugo version outputCustom 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.
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.
A theme version bump can change how a page renders.
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.
[!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,onclickand friends fail the build: the attribute line acceptsiconandclassonly (see the table below).
Output
| Output | Shape |
|---|---|
| HTML | Static types are <div class="td-callout" role="note">; collapsible types are a native <details> + <summary> |
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, ,NOTETIPIMPORTANTWARNINGCAUTIONSUCCESSDANGERQUESTIONEXAMPLEQUOTEDETAILS; case-insensitive; an unknown value renders as a plain blockquote±, ,-collapses closed,+collapses open; bareDETAILSis closedTitle, ,- On the same line as the marker
The attribute line {…}, immediately after the blockquote:
icon, ,- For example
fa-solid fa-database;DETAILShas no default icon class, ,- 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.
Related
- 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
There is one way to write an image: Markdown’s . 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

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) |
 |
A screenshot only this page uses; it travels with the page and is shared by translations |
Global resource assets/images/… |
 |
Images several pages share, especially ones needing processing (resize / crop) |
Static directory static/images/… |
 |
Large images and downloads that need no processing; supply width/height where the theme cannot measure them |
| Remote URL |  |
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.
This little one
sits inside a sentence — an inline image.

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.
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.

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).

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.


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.
Linked images
Two forms, for different purposes:
- No caption, and the image itself is the link: wrap it in a Markdown link,
[](href). - A captioned figure that is clickable as a whole: add
link="…"to the attribute line (which requirescaptionornum).

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.

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.
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.

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:
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 |
| As HTML, with the zoom controls removed | |
| Markdown |  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, ,- Its presence makes a figure; not parsed as Markdown
#id, ,[A-Za-z][A-Za-z0-9_.:-]*; the anchor and the Book target IDnum, ,[0-9A-Za-z.-]+; registers a Book figure target and prefixes the caption with “Figure N.”width/height, ,- Overrides the size; static and remote images use it to avoid layout shift
command, ,Fit,Resize,Fill,Crop; must accompanyoptions; page and global resources onlyoptions, ,- Hugo image processing options such as
600x300,300x150 Left,800x webp q80 link, ,- Wraps the figure in a link; requires
captionornum; a linked image does not zoom class, ,- Passed through for the site’s CSS
data-*/aria-*, ,- 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.
titleis not a caption: thecinis a hover tooltip.- Processing applies to resources only: an image in
static/that needs processing moves to the page bundle orassets/. - Remote images are never downloaded at build time.
- Zoom has no drag, pan or previous / next; a set of related images uses a gallery.
Related
- Gallery — a set of images sharing one zoom dialog
- Books — the list of figures and
xrefcross-references - Brand and appearance — where the site logo and favicon go
- Cards — images on cards
4.3 - Code Blocks
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
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:
Filename titles
title gives the block a visible title bar, usually a filename or a path. It
also becomes the block’s accessible name.
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.
lineNos="table" puts the numbers in a separate column — in both modes the
copy button strips them:
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.
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.
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.
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:
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.
Line links and stable IDs
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>.
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>.
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.
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,srcdocandon*are rejected; thedata-td-code*prefix plusdata-language,data-line-countanddata-collapse-linesare 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 |
| 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, ,- The visible title bar (usually a filename) and the accessible name
filename, ,- Historical alias of
title; both together fail the build copy, ,trueisall;commandis allowed only onconsole/shell-sessionwrap, ,- Visual wrapping, source unchanged; mutually exclusive with table line numbers
collapse, ,- Lines shown initially; ignored when the block is shorter
label, ,- Accessible name, not displayed; mutually exclusive with
aria-label id, ,- Stable block ID and line-anchor prefix; no whitespace
tab, ,- Tab label, see Tabs; mutually exclusive with
num group, ,- On the first fence of a set; enables hash / sync / persistence; requires
tab value, ,- Required on every fence of a group, forbidden without one; requires
tab num, ,- Numbered example (Book
eg); must appear withcaption caption, ,- The numbered example’s caption; must appear with
num class, ,- Appended to the
.td-coderoot element data-*/aria-*/role, ,- 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, ,- Line-number style;
tableis mutually exclusive withwrap=true lineNoStart, ,- First displayed number; does not affect how
hl_linescounts hl_lines, ,- For example
"2 4-5", counted over the source lines in the fence anchorLineNos, ,- Line numbers become anchor links prefixed with the block’s
id tabWidth, ,- 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
difffence — Chroma’s.gi/.gdare 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
idwhen you intend to share one. mermaid,math,chem,markmap,plantuml,echarts,infographic,checksums,filetreeandgalleryare not code blocks: each has its own render hook, no shell around it and no copy button.
Related
- 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
{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.
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.
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.
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.
| Parameter | Default |
|---|---|
shared_buffers |
25% RAM |
max_connections |
100 |
| 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.
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.
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.
The repository ships .github/workflows/; a push to main builds and publishes.
baseURL has to be the repository’s Pages address.
Connect the repository in the Cloudflare dashboard; the build command is:
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 |
| 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, ,- The visible label; on a lone block it is simply that block’s title
group, ,- On the first block of a set; enables hash, in-page sync and persistence; requires
tab value, ,- Required on every block of a group, forbidden without one; requires
tab
The tabs shortcode:
group, ,- As above: hash, sync and persistence
default, ,- The initially selected panel; requires
group label, ,- Accessible name for the tab bar; not displayed
The tab shortcode:
label, , required- The visible label
value, , 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,
valuewithoutgroup,grouporvaluewithouttab,tabtogether with the numbering attributenum; in the shortcode form, a duplicatevaluein one set, atabswith notabchild, running text between children, or adefaultthat matches no child. - Grouping mistakes in the attribute form do not stop the build; they leave a
warning in the browser console. A missing
valuedropsgroupfrom the whole set, which degrades to a locally switching tab set with no hash, sync or persistence. A duplicatevalueskips 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
groupname 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 meansgroupnames should mean something, not betabs1.
Related
- 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 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".
| 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”.
| 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.
| 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.
| 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.
| Language | Code | Sidebar | Search | TOC | 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.
offline_search, ,- Build the local search index
page_width, ,- 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>.
| Isolation level | Dirty read | Non-repeatable read | Phantom read |
|---|---|---|---|
| Read committed | no | yes | yes |
| Repeatable read | no | no | yes |
| Serializable | no | no | no |
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.
| Directory | Contents |
|---|---|
content/ |
Pages |
data/ |
Landing and release data |
| 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 |
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, ,- Exceed the reading column and use the article canvas
.matrix, ,- First column as row header, header and first column pinned, other cells centred
.fields, ,- Render as a definition list, see Fields
caption, ,- Visible table caption; on
.fieldsit labels the list meta, ,- Names the meaning of the middle
.fieldscolumns:typerequireddefault-; requires.fields #id, ,[A-Za-z][A-Za-z0-9_.:-]*; lands on the<table>, or on the<figure>for a numbered tablenum, ,[0-9A-Za-z.-]+; registers a Book table target and prefixes the caption with “Table N.”tab/group/value, ,- Adjacent tables become a tab set
class, ,- Left on the
<table>for site CSS data-*/aria-*, ,- Passed through
style, on* and any other key fail the build.
Limits
- Mutual exclusions:
.fieldscannot combine with.matrix,.full-widthornum;numandtabare exclusive;group/valuerequiretab;metarequires.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/fieldshortcode. .matrixcentring is CSS: an explicit alignment in the delimiter row wins.
Related
- 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
{.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.
offline_search, ,- Build the local search index and enable the command palette
offline_search_max_results, ,- Maximum number of search results
page_width, ,- Reading column width:
narrownormalwide
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.
baseURL, , required- Site address, subpath included
title, , required- Site name, shown in the navbar and the tab
defaultContentLanguage, ,- Default language; decides which language unprefixed paths belong to
The rules:
metamust name a role for every middle column — exactly the column count minus two. Too many or too few fails the build.- A
requiredcolumn is “non-empty means true”: “yes”, “是” or “✔” all read the same, and the rendered chip is the untranslatedrequired. An empty cell shows nothing. typeanddefaultcells 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:
HUGO_MODULE_WORKSPACE, ,- Points at
go.workso the theme resolves from a local checkout HUGO_ENV, ,- Set to
productionto 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.
params.ui.image_zoom
enable, ,- Turn image zoom on
selector, ,- 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:
Common pig flags
--config, , requiredPath to the configuration file. Relative paths resolve against the working directory.
When
PIG_CONFIGis also set, the command-line flag wins.--log-level, ,Log level, from low to high:
debug: print every remote callinfo: the defaulterror: output only on failure
--dry-run, ,Print what would happen and change nothing:
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 |
| 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, ,- Required; renders the table as a field list
meta, ,- Space-separated
typerequireddefault-; one per middle column; semantic roles cannot repeat caption, ,- Visible label and the list’s accessible name
id, ,- ID of the outer container
class, ,- Passed through for site CSS
data-*/aria-*, ,- Passed through
The fields shortcode:
label, , required- Visible label; the same thing the table’s
captiondoes id, , required- Container ID; no whitespace, quotes,
<,>or& class/data-*/aria-*, , required- The same policy as the table attribute line
The field shortcode:
name, , required- The field name
type, , required- Type label such as
boolean,string[],duration required, , requiredtrueshows the untranslatedrequiredchip; defaults tofalsedefault, , required- String / boolean / integer / float;
false,0and""all display
Limits
- The first column must be non-empty and unique within one table; a duplicate or an empty name fails the build.
.fieldscannot combine with.matrix,.full-widthornum, andmetacannot appear on a table without.fields.- Block content does not fit in a table cell: paragraphs, lists and fences need the shortcode form.
requiredanddefaultare 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.
Related
- 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
{.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.
- Install Hugo Extended
- Clone the documentation site
- 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.
-
Clone the documentation site; it is itself a complete example of the theme.
-
Start the local server.
NoteThe first build fetches the theme through the Go module proxy, which needs Go on the machine.
-
Replace three things and it is your site.
Where Replace with titleinhugo.ymlyour site name baseURLinhugo.ymlyour 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.
-
Install Hugo Extended.
-
Install the dependencies:
EL / RHELDebian / Ubuntu -
Run
hugo serverto 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).
- Configure
baseURLand the deployment workflow. - Push to
mainand 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.
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 |
| 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},- Required; has no effect on an unordered list
1.,- Let Markdown count; the content indent is always three spaces
,4.(first item)- Emits
<ol start="4">and continues from 4; supported for 2–40 {{% steps %}},- 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.
Related
- 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
{.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.
- 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.
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.
-
Every page parameter is defined here exactly once: type, default, accepted values, and the page that explains it.
-
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.
A release fact record, an asset table and checksums — all generated locally.
Site-wide shortcuts and focus order.
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.
hugo mod get github.com/pgsty/oink. The recommended way; upgrading is one
version line.
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 : 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.
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:
One section can override it in its own front matter, or push the choice down a
whole subtree with cascade:
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 |
| 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}, ,- On the line after an unordered list; unordered lists only
First link in an item, ,- The card title and the whole card’s click target
Everything else, ,- The description: after
—in a tight list, its own paragraph in a loose one
card parameters (cards itself takes none):
title, ,- Required, non-empty. The card title
link, ,- Site path, relative path,
http(s):,mailto:; external links getrel="noopener" icon, ,- For example
fa-solid fa-rocket; a malformed value fails the build badge, ,- A small label beside the title
image, ,- Page resource / global resource / static path / remote URL
image_alt, ,- With
image, exactly one of this anddecorative decorative, ,truemarks a decorative image and emits an empty altBody, ,- 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
cardlives only insidecards: 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.
Related
- Fields — also has a native form and a shortcode form
- Galleries — a grid of images
- Badges — inline status labels
- Organizing content — sections, weights and landing pages
- Configuration —
section_indexand friends
4.9 - FileTree
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
- 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 \#.
- 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.
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.
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.
- content/docs
- about
- _index.md
- features.md
- components
- filetree.md
- image
- index.md
- _index.md
- about
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.
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
- components/22 component pages
- blog/
- release.md
- docs/the documentation tree
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.
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.
Linked entries
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.
this site's component pages
- content/docs/
- callout.mdcallouts
- filetree.mdthis page
- gallery.mdgalleries
- image/page bundle
- index.mdimages
- hugo.ymlfixture configuration on GitHub
One tree per platform
A fence carrying tab= (and group= / value=) becomes one panel of a
tab set and can sit alongside code fences.
- /etc/pigsty/configuration
- /var/lib/pgsql/data
- /usr/bin/pigexecutable
- ~/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 |
| 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, ,- Title bar above the tree; omitted when absent; must not be empty
tab, ,- Makes this tree one panel of a tab set
group/value, ,- Tab group and sync value; must appear with
tab class, ,- Passed through for site CSS
Entry attributes, in the {…} at the end of a line:
icon, ,- For example
fa-solid fa-lock; a malformed value fails the build tone, ,neutralinfosuccesswarningdanger; colours the icon onlyopen, ,- Directories only;
falsestarts it closed type, ,dirorfile, overriding the inference
The line syntax itself:
Indentation- Two spaces / four spaces / tabs / the
│ ├── └──drawing fromtree - 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
treesummary 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
filetreefence 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.
Related
- Code blocks — listings meant to be copied verbatim
- Tabs — one tree per platform, side by side
- Badges —
toneuses the same vocabulary - Organizing content — how a real content directory is laid out
4.10 - Math
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.
The shared buffer hit ratio is , where is blks_hit and is blks_read.
Display formulas
A formula in its own paragraph goes between $$, centred and set larger.
\[…\] is equivalent.
A B-tree with fan-out over keys has height:
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.
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.
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.
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:
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.
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:
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 |
| 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:
\(…\),- Governed by the site’s passthrough configuration; takes no attributes
$$…$$/\[…\],- As above; may be followed by an attribute line to become numbered
```math,- Independent of passthrough; takes no attributes
```chem,- As above, with
\ce{…}in the body
The attribute line {…} under a display formula:
num, ,[0-9A-Za-z.-]+; registers a numbered equation and shows “Equation N” at the right#id, ,[A-Za-z][A-Za-z0-9_.:-]*; the anchor and cross-reference targetcaption, ,- Caption after the number; requires
num
The eq shortcode:
num, ,- As above; without it the formula is an unnumbered display formula
id, ,- Requires
num caption, ,- Requires
num class, ,- Requires
num; passed through for site CSS Body, ,- 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’smarkup.goldmark. The theme does not read amath: truefront matter key, and without the configuration$$shows literally. Themathfence andeqroute around it. - Only
$$blocks andeqcan be numbered: themathfence 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.
captionis plain text: Markdown inside it is not parsed.
Related
- Code blocks — fence attributes and numbered examples
- Images — figures use the same
{#id num=}numbering - Publishing books — lists of equations and cross-page references
- Configuration — the
markup.goldmarkkeys
4.11 - Mermaid
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
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.
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.
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.
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
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.
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.
---
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:
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.
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 |
| 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, ,- 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, ,- 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.
Related
4.12 - PlantUML
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.
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.
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.
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.
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.
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.
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.
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.
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:
enable: truewithoutsvg_image_urlfails the build withparams.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-serverworks; pointsvg_image_urlat 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(plusconnect-srcwhensvg: 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>) |
| 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, ,- With it off, the fence stays a code block and no runtime loads
params.plantuml.svg_image_url, ,- The rendering endpoint; the encoded source is appended to it. Required when
enable: true, otherwise the build fails params.plantuml.svg, ,falseinserts<img src>;trueinserts<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-->and"in the page and returning aSyntax 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
skinparamis 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.
Related
- 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
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
# 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.
# 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.
# 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.
# 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.
---
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.
# 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 |
| 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, ,- 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 > svgrule decides it and the fence cannot change it. When a map has too many levels, useinitialExpandLevelor 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.markmapit 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>or". Write links as[text](URL)rather than as autolinks in angle brackets.
Related
- Mermaid — diagrams with direction and conditions
- File trees — more precise for directory structure
- Callouts — everything
[!DETAILS]can do - Configuration —
params.markmap
4.14 - Draw.io
.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.
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.
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.
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.
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.

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
enable: truewithoutdrawio_serverwarns 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 |
| 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, ,- With it off no script loads and an image is just an image
params.drawio.drawio_server, ,- The editor address; required when
enable: true
Limits
- The runtime loads only when rendered page content contains
.svgor.pngcandidates. It groups matching images by URL, then reads each URL once to look formxfile. - 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
noneand use neutral greys for lines and text and it reads in both modes.
Related
- 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
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.
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.
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.
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.
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:
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:
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 |
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, ,- A non-negative number plus
pxrememvhvw%; anything else fails the build theme, ,- Pin an ECharts theme and stop following the site’s colour scheme; only
darkis built in full, ,truedrops the reading-column limit and fills the content areaclass, ,- 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 toundefinedwith 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,onandyeson 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.
Related
- 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
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.
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 PRIndentation 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.
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 subsystemFunnels
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.
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 versionGrid cards
When items have no order between them, list-grid-* arranges them in a grid
rather than a queue.
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 printItems with values
Add value to an item and templates that express proportion — pies, doughnuts,
progress — will use it.
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 2Hierarchy 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.
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 themetheme 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 |
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, ,- A non-negative number plus
pxrememvhvw%; anything else fails the build full, ,truedrops the reading-column limitclass, ,- 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
datatitle,desc,items(orsequences,compares,nodes,values,relations,root, depending on the structure),orderthemetype(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:
themelives 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.
Related
4.17 - Gallery
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 .
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 \#.

The default shell: sidebar, article, table of contents

Docsy upstream — the content model is the same lineage

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.
A link per item
{link=…} at the end of a line turns that item into a link. Site paths,
relative paths and http(s): all work.
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.

Under assets/images/…, eligible for build-time processing

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).

Decorative, never zooms

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.

Sidebar, article, table of contents

The same content-model lineage
Output
| Output | Shape |
|---|---|
| HTML | <ul class="td-gallery"> with one <li> per item; eligible images carry data-td-image-zoom; everything is lazy-loaded |
| 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  [# description] [{key=value …}]:
,- Must start the line.
altis the item’s title; empty means decorative src,- Page resource / global resource / static path / remote URL
# description,- Plain text under the image;
\#is a literal hash; must not be empty {link=…},- Makes the item a link, and therefore not zoomable
{class=…},- Adds a site CSS class to that item
Fence attributes:
tab, ,- Makes this gallery one panel of a tab set
group/value, ,- Tab group and sync value; must appear with
tab class, ,- 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.
Related
- 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
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
text is the only required parameter and must be a non-empty string.
Five tones
These five values, and no custom colours.
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.
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:
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”.
| Component | Form | Status |
|---|---|---|
| Callouts | > [!NOTE] |
stable |
| Galleries | ```gallery fence |
stable |
| PlantUML | ```plantuml fence |
needs a server |
The image shortcode |
— | removed |
In lists and steps
- Install Hugo Extended ≥ 0.160.1
- Clone the documentation site and change
baseURLinhugo.yml hugo serverto 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.
One hugo mod get and you are done needs Go
Builds on a machine with no network manual upgrades
Clickable badges
With link the badge becomes an <a>: site paths, relative paths, http(s):
and mailto: all work.
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 |
| 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, ,- Required, non-empty. What the reader sees
tone, ,neutralinfosuccesswarningdangerlink, ,- 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.
Related
- Cards —
cardhas abadgeparameter of its own - File trees —
toneuses the same vocabulary - Keys — the other inline shortcode
- Callouts — when the status needs explaining
4.19 - Kbd
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
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.
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.
⌘ 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.
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:
| 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
- Press Ctrl with K to open the command palette
- Type
>for the command-only state, or type a keyword to search - Select with ↑ ↓ and press Enter to go
- 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.
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 |
| 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, ,- 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
kbdcalls and a sentence — press Escape, then Enter. - No platform detection: the page never swaps
Ctrlfor⌘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:
Ctrlin{{< kbd Ctrl K >}}is not a string parameter. - Do not use it for commands:
hugo serveris inline code;Ctrlis a key.
Related
- Keyboard navigation — the full shortcut list and its switches
- Command palette — what Ctrl with K opens
- Badges — the other inline shortcode
- Steps — the container for instructions
4.20 - Includes
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:
The file it pulls in is ordinary Markdown living under assets/:
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 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.
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.
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.
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:
| Item | Value |
|---|---|
| Current version | v0.6.0 |
| Hugo floor | 0.160.1 |
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.
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 |
| 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, ,- Resolution order in Where the file comes from; a
.., a missing file and an empty value all fail the build code, ,truerenders as a code block; it must becode=true— a quotedcode="true"is a string and failslang, ,- 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, ,- Nested keys join with
.; page front matter first, then siteparams; 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
includeis 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:
includedoes 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. paramprints scalars only: structured data — version matrices, download lists — belongs indata/and is rendered by the matching component.commentis not “unpublish for now”: the content is discarded on every build. To take a whole page down temporarily, usedraft: true.- Do not use
includeto build an index page: a page that pulls in ten fragments is a page where the reader wanted ten links.
Related
- Code blocks — every fence attribute, and the pipeline
include code=truereuses - Tabs — per-platform or per-language fragments
- Configuration — the site parameters
paramcan reach - Front matter — page parameters, which win over site configuration
4.21 - Asciinema
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:
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:
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.
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:
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:
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.
-
Install the dependencies and fetch the installer:
-
Run the install; here are the first two minutes:
pig install -
Open
http://<node address>:3000and sign in to Grafana withadmin / pigsty.
A page can hold several players, and the script and styles load once.
Recording a cast file
The theme only plays. Record with
asciinema —
asciinema 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
clearbefore you start. - Clear secrets first: a
.castis 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.castURL 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 |
| 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, ,- 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, ,- The window title
theme, ,autofollows the site’s colour scheme; ortd-lighttd-darkasciinemadraculagruvbox-darkmonokainordsetisolarized-darksolarized-lighttangofit, ,widthheightbothnone; anything else fails the buildcols/rows, ,- Override the terminal size; smaller than the recording clips it
speed, ,- Playback rate
startAt, ,- Where playback starts
idleTimeLimit, ,- Longest a silent stretch plays for
poster, ,- The frame shown before playback,
npt:mm:ss autoplay, ,- Play as the page opens; not recommended
loop, ,- Replay at the end
preload, ,- Fetch the
.castwhen the page loads pauseOnMarkers, ,- Pause at chapter markers
markers, ,- 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
markerslabels are lost: the theme flattens thetime:labellist 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: afilewith 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.
Related
- 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
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 | |
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
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:
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, noparams.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: solarizedreportsinvalid params.ui.typography "solarized" (allowed: technical | system) -- using "technical"and the site still builds;footer_style: thin,page_width: hugeandsection_index: gridbehave the same way. One typo therefore degrades one setting instead of serving HTTP 500 on every URL underhugo 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, thereleasefacts 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:
- The page’s own front matter;
cascadein an ancestor section’s_index.md(nearer wins);- 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.
A cascade sets a whole subtree at once:
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:
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,- Site name, shown in the navbar,
<title>and the footer baseURL,- The production domain; include the path segment for a subpath deployment
copyright,- Fallback for the copyright line, rendered as HTML when
params.copyrightis unset enableGitInfo, ,- Required before “last modified” and commit information exist
enableRobotsTXT, ,- Generates
robots.txt enableEmoji, ,- Allows
:smile:shortcodes
Theme parameters:
params.logo, ,- Brand mark; may point at an
assets/resource or astatic/path — see Brand and appearance params.wordmark,- Horizontal wordmark; when set, the navbar uses it instead of “icon + site name”
params.description,- Site description, the meta fallback when a page has no
description params.copyright,- A string renders as Markdown; a map takes
authors,from_yearandto_year(presentmeans this year) params.footer_center_info, ,- Inline Markdown in the centre of the footer; an empty string hides it
params.author,- The RSS author; a map takes
nameandemail
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, ,- Which types use the reading shell with a sidebar — see Layouts and page types
params.ui.docs_section, ,- The documentation section’s root directory name, used for navigation resolution only
params.ui.blog_section, ,- The blog section’s root directory name
params.ui.docs_sidebar_root, ,- With
section, a docs page’s sidebar roots at the documentation section; withhome, at the site home. An invalid value warns and falls back params.ui.quick_links, ,- Top-level menu identifiers listed by the command palette on an empty query — see Command palette
params.ui.sidebar_root_enabled, ,- Allows a subsection to become its own sidebar tree with
sidebar_root_for: self params.ui.sidebar_root_menu, ,- Shows the section switcher above the sidebar; it degrades to a plain link when there is only one entry
params.ui.section_index, ,- Child list style on a section index:
listorcards, overridable per section params.ui.section_index_columns, ,- 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, ,- How an article renders its own featured image:
nonerenders nothing,bannerframes it above the title in a 16:9 figure,washlays it behind the article header at a tenth of its opacity. The image is whichever one the page already shares in its card andog:image, so the two cannot disagree. An article with no image renders nothing in either mode params.ui.blog_index, ,- The blog section’s list page:
listis the row list,cardsa grid of content cards with a 16:9 lead image, the date and section line, and a three-line summary. Year grouping, pagination andmanual_linkbehave the same in both params.ui.blog_index_columns, ,- 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.
Navbar and footer
params.ui.navbar_enabled, ,- Whether the site navbar renders; overridable with a top-level
navbar_enabledon a page — see Navigation and menus params.ui.navbar_autohide, ,- 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, ,fatis a multi-column grid plus the copyright line,slimis the copyright line only,nonerenders nothing. An invalid value warns and falls backparams.ui.dark_mode, ,trueenables both the dark palette and the theme control; for the control alone writedark_mode: { show_menu: true }params.ui.breadcrumb, ,- Breadcrumbs;
falseturns them off. A top-level section already omits a one-level breadcrumb params.ui.page_context_menu.enable, ,- The page action split button beside the title
params.ui.page_context_menu.assistant_links, ,- Shows “Open in ChatGPT / Claude”; clicking sends the full URL off-site
params.ui.page_context_menu.links, ,- Custom external actions;
urlsupports the{url},{title}and{markdown_url}placeholders params.ui.github_stars,- The star count on the navbar GitHub mark; a local constant, never a request
params.ui.alt_site,- A sibling-site link shown in the footer of a single-language site;
labeland an absolutehttp(s)urlare 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, ,- Expands only the current branch and its neighbours
params.ui.sidebar_menu_foldable, ,- Lets the reader expand and collapse sections
params.ui.sidebar_menu_truncate, ,- Maximum entries rendered in one section; the rest are truncated
params.ui.sidebar_cache_limit, ,- Above this page count the site reuses shared navigation markup, and the browser restores the active state
params.ui.sidebar_width_min, ,- Lower bound in pixels for drag-resizing on the desktop
params.ui.sidebar_width_max, ,- Upper bound in pixels for drag-resizing
params.ui.sidebar_item_overflow, ,ellipsistruncates a long title,wrapwraps itparams.ui.sidebar_icon_policy, ,- Icon density:
alleverywhere,groupsonly on the root and nodes with children,nonenowhere. An invalid value warns and falls back toall params.ui.sidebar_expand_levels, ,- Tree levels expanded by default
params.ui.sidebar_headings, ,type: bookonly: expands a heading branch under the current sidebar row; an integer from 2 to 4, andtruemeans 2params.ui.sidebar_enabled, ,- The left sidebar;
falseturns it off, usually per page rather than per site params.ui.taxonomy_icons,- 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, ,- Hugo’s own: the highest heading level collected
markup.tableOfContents.endLevel, ,- Hugo’s own: the lowest heading level collected
params.ui.scroll_spy, ,- Scroll position tracking;
truehighlights 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, ,- Page-end share targets, in the order given, from
xblueskymastodonfacebooklinkedinreddithackernewstelegramwhatsapplinepinterestweibochatgptclaudeemailcopy. 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, ,- 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, ,- The “last modified” and provenance block at the end of the body; the upstream attribution line is driven by the page’s
upstream_linkfamily — see Page parameters params.ui.translation_notice, ,- 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, ,- Shows a reading time under the page title
params.ui.book_draft_banner, ,- Adds a banner at the top of a draft Book page
Search and command palette
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, ,- Generates one local index per language and enables the command palette — see Search
params.offline_search_on_serve, ,- Builds the index under
hugo servertoo, so the preview behaves like production; setfalseon a very large site to speed up local rebuilds params.offline_search_index, ,- Index scope, cumulative:
title,heading,summary,content. An invalid value fails the build params.offline_search_summary_length, ,- Word cut-off for the
summaryscope’s excerpt params.offline_search_max_results, ,- Result cap, bounding both Lunr and the CJK substring fallback
params.ui.landing_search, ,- Whether a
layout: landingpage keeps a search entry point params.ui.command_palette.commands, ,- Custom commands, each with either
urlor a built-inaction— see Command palette params.gcs_engine_id,- A Google Programmable Search engine ID; enabling it brings in an external service
params.search.algolia,- Algolia DocSearch;
appId,apiKeyandindexNamemust 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, ,- Single-key navigation (WASD / arrows walk the tree, j/k jump headings, q/e page, palette and shell switches). With
falsethe runtime never enters the bundle — see Keyboard navigation
Image zoom
params.ui.image_zoom, ,- 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, ,technicaluses the bundled Inter / Chakra Petch / IBM Plex Mono;systemuses the platform stack only and requests no brand font. An invalid value warns and falls backparams.page_width, ,- Overall shell width:
normal,wide,full; overridable per page params.reading_width, ,- 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, ,- The site-level comment switch; a page overrides it with the front matter
comments— see Comments params.comments.type, ,- Only
giscusactually renders today params.comments.giscus.repo,- The GitHub repository hosting the discussions; required
params.comments.giscus.repoId,- The repository ID; required
params.comments.giscus.category,- The discussion category name; required
params.comments.giscus.categoryId,- The discussion category ID; required
params.comments.giscus.mapping, ,- How pages map to discussions
params.comments.giscus.term,- The discussion title or number when
mappingisspecificornumber; the attribute is omitted when unset params.comments.giscus.strict, ,- Strict title matching
params.comments.giscus.reactionsEnabled, ,- Shows reactions on the main post
params.comments.giscus.emitMetadata, ,- Sends discussion metadata to the parent page
params.comments.giscus.inputPosition, ,- Whether the input box sits above or below the list
params.comments.giscus.theme, ,- The giscus theme;
autofollows the site’s light/dark state params.comments.giscus.lightTheme, ,- The giscus theme or custom CSS URL used in light mode
params.comments.giscus.darkTheme, ,- The giscus theme or custom CSS URL used in dark mode
params.comments.giscus.loading, ,- The iframe loading strategy
params.comments.giscus.lang, ,- 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 toen params.comments.giscus.ariaLabel, ,- The
aria-labelon the comment container; the default is English, so a multilingual site writes one per language params.comments.giscus.errorMessage, ,- Text shown when loading fails; the default is English, so a multilingual site writes one per language
params.ui.feedback.enable, ,- The two “was this page helpful?” buttons at the page end; there is no backend, and a structured event is recorded when
gtagis present params.ui.feedback.reasons, ,- 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,- 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, ,- The product repository URL, for “open a project issue” and the navbar GitHub entry
params.github_branch, ,- The branch edit links point at
params.github_subdir,- The content site’s subdirectory inside a monorepo
params.path_base_for_github_subdir,- Source path rewriting; the map form takes
fromandto params.github_url, ,- 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, ,- What follows “last modified”:
subjectthe commit subject,hashthe short hash,nonenothing. An invalid value warns and falls back params.images, ,- The site-level social card: fills
og:imagewhen a page has no image of its own. Metadata only; never rendered as a list thumbnail params.default_featured, ,- Removed; write
params.images, or a sectioncascadecarryingimages. 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, ,- Enables the mind map fence site-wide — see Markmap
params.mermaid,- Configuration passed to
mermaid.initialize(); keys are lowercase, and dark mode overridesthemeautomatically params.plantuml.enable, ,- Enables the PlantUML fence — see PlantUML
params.plantuml.svg_image_url,- The PlantUML service’s SVG endpoint; required when enabled, and its absence fails the build
params.plantuml.svg,- Renders inline SVG instead of an
<img> params.drawio.enable, ,- Enables the edit button on
.drawio.svgimages — see Draw.io params.drawio.drawio_server,- The Draw.io editor address; required when enabled, and its absence fails the build
params.highlight_classes, ,- Emits Chroma classes for highlighting;
falsereturns to Hugo’s inline styles params.ui.code_copy, ,- The copy button on code blocks;
falseremoves it globally, and a fence’s owncopy=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.
| 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, ,- Generates a table of contents at the top of the print page;
falseomits it params.print.section_break_wordcount, ,- 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, ,- The primary language, served without a path prefix
languages.<lang>.label,- The language’s endonym, shown in the language menu
languages.<lang>.locale,- The full locale, used for
<html lang>and SEO languages.<lang>.weight,- Language order, and the cycle order when clicking the language icon
languages.<lang>.title,- The site name in that language
languages.<lang>.languageDirection, ,- Set
rtlfor a right-to-left language
Paired files, anchor alignment and fallback for untranslated pages are in Languages.
Version parameters:
params.version,- The identifier of this site variant, which need not be a Git ref — see Versions
params.version_menu, ,- The version menu’s title
params.version_menu_pagelinks,- On switching version, try the same path on the target site first
params.versions,- Version entries:
version,url,kind;name: '---'is a divider params.archived_version,- Shows the “this is an archived version” banner at the top
params.url_latest_version,- The link to the current version inside that banner
params.time_format_blog, ,- Blog date format, overridable per language
params.time_format_default, ,- All other date formats, overridable per language
Miscellaneous
taxonomies,- Hugo’s own: enables
tag: tags/category: categories— see Taxonomies params.taxonomy.page_header,- Shows only these taxonomies in a post header; unset shows all
services.googleAnalytics.id,- Hugo’s own: the analytics script is injected in production builds only — see Analytics and SEO
module.hugoVersion.min, ,- The Hugo floor the theme declares; anything older fails the build
module.hugoVersion.extended, ,- Hugo Extended is required (SCSS has to be compiled)
Verifying a configuration change
Run a strict build after changing configuration:
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 is declared in the theme’s hugo.yaml and theme.toml, and a site’s
own module.hugoVersion.min should agree with it.
Related
- Brand and appearance — site name, logo, colours, fonts
- Navigation and menus — navbar menu, page actions, footer
- Layouts and page types — shell, sidebar, table of contents
- Page parameters — the full front matter table
- Troubleshooting — locating a build failure
5.2 - Brand and appearance
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:
The top-level title is the fallback, and languages.<lang>.title wins.
Logo and wordmark
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.
params.logois the square mark, shared by the navbar, the sidebar and the footer. Underassets/it goes through Hugo’s resource pipeline (and can be fingerprinted); understatic/it is published as is. Either way the path is relative to theassets/orstatic/root.params.wordmarkis the horizontal wordmark. Once set, the navbar uses it instead of “icon + site name”, falling back toparams.logowhen 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:
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:
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:
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:
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:
Roles inherit by ordinary CSS rules, so changing the font for one kind of content needs no component selectors either:
A monospace stack needs a CJK fallback, or mixed code blocks fail to align:
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
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.
Footer
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 modoverwrites it); - Do not
@importthe theme’s internal partials individually — they are not a public Sass interface and their import order may change; - Do not override
baseof.htmlto 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
- 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.
Related
- Configuration — types and defaults of the brand parameters
- Navigation and menus — navbar menu, page actions and footer data
- Layouts and page types — shell, sidebar and table of contents
- Images — images in the body, light/dark pairs and captions
- Home and landing pages — hero, sections and landing data
5.3 - Home and landing pages
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:
Section data is a file per language:
home page data
- data/
- home/
- en.yamlEnglish home page
- zh.yamlChinese home page
- home/
The lookup order is data/home/<current language>.yaml → data/home/en.yaml →
data/home.yaml for a single-language site.
The file has only two levels: a sections list, and the same-named keys that
list references.
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/).
Hero
The hero is the first screen, and the only section with a large title and an illustration.
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:
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:
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.
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.
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
- pricing/
- landing/
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:
sectionsin the page’s front matter;data/landing/<key>/<exact language>.yaml;- The exact-language entry inside a single
data/landing/<key>.yaml; - The English or language-less record.
Small amounts of data can go in front matter, but landing: and sections: are
mutually exclusive:
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:
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 |
| 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
- The build is warning-free:
hugo --printPathWarnings --panicOnWarning. A misspelled type, a missing data key, andlandingalongsidesectionsall surface here. - Open the home page and any landing page, compare each section against the data file, and look at every language.
- Reload with JavaScript disabled: the content is still there, only without motion.
- Look at both light and dark, confirming
image.lightandimage.darkare each correct. - When deploying to a subpath, confirm internal links and images all carry the prefix.
Related
- Brand and appearance — site name, logo, colours and fonts
- Navigation and menus — navbar, footer and the language menu
- Releases and downloads — the data behind the
downloadsection - Languages — enabling languages and splitting data by language
- Configuration — full definitions of
params.ui.landing_searchand the rest
5.4 - Navigation and menus
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:
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”:
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:
- Every entry is one icon and one title on its own row, in one
moderate-width column. A child’s
params.descriptionis 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.columnsparameter 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:
iconin the target page’s front matter;- The menu entry’s own
params.icon; - A built-in default matched by identifier or section name (
docs,blog,examples,community,about,download,githuband others); fa-solid fa-linkwhen none matched.
An icon is one Font Awesome class pair, with the free faces supplied locally by the theme:
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.
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
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
It can also be turned off for one page or one section:
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.
To let a large subtree become a root of its own (a versioned API reference, a
self-contained handbook), in its _index.md:
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.
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 |
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:
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.
pager_types accepts only docs, book and blog; any other value warns and
is dropped. A page opts out through front matter:
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.
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
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:
- Without
brand.nameandbrand.logoit falls back to the site’s own brand name, logo and wordmark;taglineandsloganrender Markdown. - An internal
urlresolves against the current language root;external: trueopens in a new tab withrel="noopener noreferrer". - The grid has as many columns as the data does.
- A single-language site can use
data/footer.yaml. - With
fatconfigured but no data, it degrades toslimautomatically, 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
After changing navigation, check each of these:
- The build has no
Navbar menu … supports one interactive child levelwarning; 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_repoconfigured).
Related
- Layouts and page types — sidebar tree, outline and shell types
- Configuration — defaults of the navigation parameters
- Command palette — page actions and custom commands
- Repository links and page info — the edit, history and issue links
- Organizing content — how the directory structure decides the sidebar
5.5 - Layouts and page types
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:
| 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:
Section roots are only navigation starting points
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:
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:
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:
The third declares the sidebar root to be the site home, so the sidebar and the pager share one tree:
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:
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.
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:
sidebar_menu_compactexpands only the current branch and its neighbours;falseexpands the whole tree.sidebar_menu_foldablelets the reader expand and collapse sections manually. Blog sections are expanded by default; to collapse one by default, writesidebar_expanded: falsein its_index.md.sidebar_expand_levelsis how many levels are expanded by default.sidebar_menu_truncateis the maximum entries rendered per section, so a thousand-page tree does not inflate the HTML past usability.sidebar_width_min/sidebar_width_maxbound 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_overflowdefaults toellipsis(long titles truncate); a site with many long titles can usewrap.
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:
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.jsoncontaining asectionskey; - The page’s type is
docsorbook; - 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:
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:
| 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:
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:
The theme governs only the tracking behaviour:
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:
list(the default): one title plus description paragraph per child page;cards: a grid of cards reading each child’stitle(orlinkTitle),descriptionandicon.
It can be overridden per section, and an invalid value fails the build:
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
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:
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:
The behaviour is in Navigation and menus and Brand and appearance, and the key definitions in Page parameters.
Verify
- The build prints
Total in …with no ERROR and no WARN; - A newly created
type: docspage has a left sidebar. If not, check whether the cascade reaches that page and whethershell_typescontains the type; - Drag the sidebar divider, reload and confirm the width persists; double-click restores the default;
- Below
mdthe sidebar becomes a closable drawer, and belowxlthe outline moves into the drawer; - A section index has as many cards as the sidebar has child pages;
- A page with
page_width: wideis wider than its neighbours; - With documentation at the site root,
hugo --printPathWarningsreports no duplicate output paths.
Related
- Configuration — defaults for the shell, sidebar and outline parameters
- Organizing content — directory structure,
weightand the sidebar tree - Navigation and menus — navbar, section switcher and pager
- Home and landing pages — writing the data for
layout: landing - Page parameters — the front matter keys used for per-page overrides
5.6 - Search
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
This one key decides whether the index, the Lunr runtime and the search dialog reach a page. Three conditions must hold together:
params.offline_searchis 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 withparams.ui.landing_searchon; - 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:
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.
| 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.
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:
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:
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
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.
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_keywordstherefore 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
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
A landing page for the results is needed too:
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
-
Build, and confirm one index per language was generated:
In a development build the filename is
offline-search-index.zh.json; a production build fingerprints it, as inoffline-search-index.zh.7ab….json. One file per language, and a missing one means that language’s pages never reached an index. -
Look inside the index — the first step in diagnosing “Chinese finds nothing”:
The entry count should be close to the number of Chinese pages, and the
keywordsandboostfields should show what the front matter set. -
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.
-
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”.
Related
- 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 parameters —
search_keywords/search_boost/search_exclude
5.7 - Command palette
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
Quick links are selected from Hugo’s main menu by identifier rather than written out a second time:
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:
That is the one this site uses. There are seven fields, and any other key fails the build:
idis 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.titleis what the palette shows;descriptionis the smaller line beneath it;iconis one Font Awesome class pair.keywordsis an array that takes part in matching without being displayed, for the search terms a reader might type.urlandactionare mutually exclusive and one is required.urlaccepts a fullhttp/httpsaddress, a site path, or an in-page anchor beginning with#; an address with a host opens in a new tab.actionreferences a built-in action ID.
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:
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:
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.
How it relates to full-text search
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
-
After a build, confirm the command manifest reached the page:
Its absence means local search is off, or this page is not in a shell layout.
-
Open the site and press ⌘/Ctrl + K without typing: quick links, page actions, preferences and commands should appear in that order.
-
Type
>: only commands and actions remain. A newly added command should sit after “open the GitHub repository”. -
Repeat step 3 in another language, and confirm the command titles changed while the order did not.
-
A print preview (⌘/Ctrl + P) should show no trace of the palette.
Related
- Search — where the palette’s page results come from
- Keyboard navigation — f, c and the other single keys
- Navigation and menus — the source of quick links and group order
- Repository links and page info — prerequisites for the edit, history and issue actions
- Configuration — full definitions of
ui.command_paletteandui.page_context_menu
5.8 - Keyboard navigation
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.
Search and commands
| 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
contenteditableregion; - 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-colorsand degrades to a system highlight outline. - Reduced motion: with
prefers-reduced-motionon, 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:
For one page (interaction-heavy demonstration pages often need this), or for a whole section by cascade:
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
-
After a build, confirm the cheatsheet button is in the page:
With keyboard navigation off and local search not enabled, the button is not generated at all.
-
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.
-
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.
-
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.
-
Turn on “reduce motion” in the system and press J: it should position instantly with no glide.
Related
- 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
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
That is this site’s configuration. What the four fields do:
labelis the name shown in the language picker, written in that language’s own script:简体中文, notChinese.localeis the standard language tag, and reaches<html lang>, thehreflangalternate links and the Open Graph metadata.weightdecides both language order and the picker’s cycle order, lowest first.paramsis 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: , 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:
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:
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.
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:
Two disciplines:
- 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.
- 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:
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:
<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
-
Build, and confirm both languages’ output and indexes exist:
-
Check
hreflang: each page’s<head>should carry onerel="alternate"per language plus arel="canonical"pointing at itself. -
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.
-
Search the same concept once in each language and confirm both return results.
-
Wire the heading alignment check into CI on a bilingual site, using the script above.
Related
- Search — per-language indexes and CJK queries
- Navigation and menus — per-language menus and where the picker sits
- Home and landing pages —
data/home/<lang>.yaml - Writing pages — how to write explicit heading IDs
- Analytics and SEO — how
hreflangand the sitemap are consumed by search engines
5.10 - Versions
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.
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:
The same list feeds “switch version” in the command palette, so menu and palette never disagree.
The trade-off in page-for-page links
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:
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:
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.versionsis a cross-site list: which versions the menu can reach and where each lives. It describes other sites.params.versionis 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 whendata/download/*.yamlomitsversion(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.
baseURL must include the path segmentOtherwise 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
-
After a build, confirm the version menu reached the page:
With
params.versionsempty or unset, the menu is not generated at all. -
Check whether the current version is marked selected:
None at all means
params.versiondoes not match any entry’sversionfield, orbaseURLdoes not match that entry’surl(mind the trailing slash). -
Visit each link in the menu. With
version_menu_pagelinkson, try it once from a document an older version lacks and confirm the landing is acceptable. -
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.
-
Press ⌘/Ctrl + K to open the command palette; “switch version” should list the same set.
Related
- Navigation and menus — where the version menu sits in the navbar and sidebar
- Command palette — “switch version” in the palette
- Deploy —
baseURL, subpaths and multi-target publishing - Releases and downloads — download data falling back to
params.version - Configuration — full definitions of
version/versions/archived_version
5.11 - Taxonomies
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:
That is this site’s configuration. Three things to note:
- Writing
taxonomies:makes it the complete list, not an addition. To keeptags/categoriesalongside 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:
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 (products → Products). To name one
yourself, write title / linkTitle in content/<plural>/_index.md and
_index.zh.md, and the theme prefers it:
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:
Where a whole section shares one category, write it in the section index’s
cascade rather than repeating it on every page:
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:
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:
| 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/authorsfile. 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_weightcome 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:
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:
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:
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:
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:
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-articleinassets/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.
Related
- Page parameters —
categories/tagsand the other front matter keys - Blog posts — how the blog list works with taxonomies
- Navigation and menus — how to write navbar entries
- Languages — per-language content and menus
- Configuration — full definitions of
params.taxonomy.*andparams.ui.taxonomy_icons
5.12 - Repository links and page info
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.
Four keys wire up every link
Every repository-related entry in the action menu derives from these keys:
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_repopoints 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_repois a second repository, receiving product bugs rather than documentation errors. Do not configure it where readers cannot tell the two apart.github_branchdefaults tomainand names the content branch — not the deployment branch, and not the branch Pages generates.github_subdiris the path inside the repository. Leave it empty when the site source is at the repository root; set it towebsitewhen the source sits in a subdirectory (a repository holding both code andwebsite/, 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/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:
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:
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:
The page end then reads “Last modified August 17, 2026 · …/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. Setfetch-depth: 0in 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_linkplus the four required keysupstream_name,upstream_copyright,upstream_licenseandupstream_notice, and the page end gains an attribution line naming the work, the copyright holder, the licence and a link to the full notice. Addingupstream_modified: trueappends a “modified downstream” line. - Translation notice:
params.ui.translation_noticeholds 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 withtranslation_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:
To enable it for the documentation section only, use a cascade (a blog usually keeps just comments):
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
localStorageper page and language, so a returning reader sees and can change it. - Where the site already has Google Analytics (
gtag), it sends adocs_feedbackevent withresult(solved/not_solved),page_pathandlanguage; choosing a reason sends a second event carryingreasonandrefinement: 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:
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=:
In Markdown and RSS output the wall degrades to a list of
- [@handle](url) — role.
data/contributors.yamlThe 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 apath_base_for_github_subdirexpression 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:
Related
- Page parameters —
annotation/feedback/pager/page_context_menuand the other page switches - Configuration — full definitions of
github_*,ui.lastmod_commitandui.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
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:
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:
- 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-noneand appears on screen only, never on paper. - The section title and summary.
- A whole-section table of contents, numbered
1:,2:,2.1:by level, linking to in-document anchors. - 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:
To drop the table of contents:
It can also be turned off for one section, in the section index’s front matter:
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:
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:
A4paper with18mm 16mm 20mmmargins; 10.5pt body text; the light palette forced.- Fonts switch to the
--td-print-font-familytypography 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:
- 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
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 withno_print: true). - Press
Cmd/Ctrl+Pin 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_wordcountwhere the threshold does not suit.
Related
- Books — numbering, indexes and print for a whole book
- Organizing content — print order is sidebar order
- Brand and appearance — the print font token
- AI-agent support — the other non-HTML output
- Configuration — full definitions of
outputsandparams.print.*
5.14 - AI-agent support
.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:
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:
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.
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:
A multilingual site gets one per language: /llms.txt and
/zh/llms.txt. The content is a generated site index:
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:
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
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:
To keep RSS and drop only Markdown, list the rest:
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.mdorlayouts/docs/list.mdaffects 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
With curl, against production or a local preview:
Then check three things:
- Any page’s HTML
<head>hasrel="alternate" type="text/markdown"; - Clicking the copy button at the right of the breadcrumb row and pasting yields Markdown rather than HTML;
llms.txtcontains no off-site links.
Limits
- The machine-readable surface the theme produces is exactly two things: a
.mdper page andllms.txt. There is nonav.jsonand no other structured index interface; the sitemap is still Hugo’s ownsitemap.xml. - The
LLMSoutput format is declared as a non-alternative format, sollms.txtnever 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
.mdas fence source, not as a diagram.
Related
- Print — the other non-HTML output
- Command palette — the other entry point to the assistant actions
- Page parameters —
outputs/assistant_links/page_context_menu - Navigation and menus —
llms.txt’s site index comes from the main menu - Configuration — full definitions of
outputsandparams.ui.page_context_menu.*
6 - Operations
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
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):
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,- Also builds pages with
draft: true -F/--buildFuture,- Also builds pages whose
date/publishDateis in the future -E/--buildExpired,- Also builds pages whose
expiryDatehas passed --disableFastRender,- Re-renders the whole site on every change instead of incrementally
-M/--renderToMemory,- Renders in memory only, writing no
public/ -N/--navigateToChanged,- The browser jumps to whichever page you saved
--bind,- The listen address; use
0.0.0.0to reach it from a LAN or outside a container -p/--port,- The listen port
--minify,- Minifies the preview too, to reproduce production rendering
--printPathWarnings,- Warns when two pages write to the same target path
The combination used while developing this site:
-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:
- Restart with
--disableFastRenderand see whether it comes back. - Hard-refresh the browser (
Cmd/Ctrl+Shift+R) to rule out browser cache. - 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:
--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:
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/_genthat 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:
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
productionemits<meta name="robots" content="index, follow">; other environments emitnoindex, nofollow. - Under
productionrobots.txtisAllow: /; elsewhere it isDisallow: /. - Only
productionrenders 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:
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.
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.
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/,- A page was deleted but is still live; or let the build clear it with
hugo --cleanDestinationDir resources/_gen/,- Image processing parameters, fonts or the accent colour changed and the page still looks old
hugo mod clean,- The theme version changed but the old one still resolves; add
--allto clear the whole module cache
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:
This site’s Makefile wraps those commands and expects the theme checkout at
the sibling ../oink:
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:
- Start from a verified theme archive and an empty module cache (
hugo mod clean --all). - Block outbound HTTP, HTTPS and the Go module proxy.
- Run the production build
hugo --gc --minify --printPathWarnings --panicOnWarning. - Browse pages in both languages: a documentation page, a blog page, the home page, the 404.
- Exercise search, the light/dark toggle, diagrams and content components.
- 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:
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:
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/hassitemap.xmlandrobots.txt, androbots.txtreadsAllow: /.- On a site with local search,
public/hasoffline-search-index.<language>.jsonat 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.
Related
- Deploy — getting
public/to GitHub Pages, Cloudflare or elsewhere - Troubleshooting — the four common fault classes: build, language, search, platform
- From scratch and other install methods — weighing Hugo Module, submodule and offline archive
- Configuration — every key in
hugo.yml
6.2 - Deploy
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:
Deploying to a subpath (https://example.com/docs/), the path must be in
baseURL:
It can also be overridden at build time, so one source deploys to several places:
canonifyURLsHugo’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:
That is the workflow this site uses. Several pieces cannot be removed:
fetch-depth: 0— withenableGitInfoon, “last modified” and contributor information need the full Git history, and a shallow clone leaves them empty.setup-goplusgo mod download— with the theme as a Hugo Module, Hugo needs Go to resolve it. A site installing the theme as a submodule usessubmodules: recursiveinstead, and one using an offline archive commitsthemes/oink/; either way both steps go.GOWORK: offandHUGO_MODULE_WORKSPACE: off— keep a local developmentgo.workfrom taking part in the CI build, so CI verifies the published tag pinned ingo.mod.--baseURL "${{ steps.pages.outputs.base_url }}/"— a project site’s URL ishttps://<OWNER>.github.io/<REPO>/, andconfigure-pagescomputes 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 commandhugo --gc --minify --printPathWarnings --panicOnWarningBuild output directorypublicHUGO_VERSION0.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_INSTALL1
Four notes:
HUGO_VERSIONmust be set explicitly, in both the Production and Preview environments. The Cloudflare v3 build image’s default Hugo is older than OINK’s required0.160.1, and leaving it unpinned changes the toolchain silently when the image updates.SKIP_DEPENDENCY_INSTALL=1turns off the generic dependency install step. A consuming OINK site needs no Node.js, and apackage.jsonpresent only for maintenance tooling should not be installed by the platform.- Where the Hugo site is not at the repository root, set Root directory to the site directory; the output directory resolves against it.
- 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:
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:
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:
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:
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:
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 onwindow.OinkEchartsFunctions, and the registering script’s origin belongs inscript-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-srcandimg-src. - giscus:
script-srcandframe-srcmust 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 --panicOnWarningand the log hasTotal in … baseURLis correct<link rel="canonical">in the page source points at the real production address, subpath includedSitemap<baseURL>/sitemap.xmlresolves; a multilingual site has an index pointing at/en/sitemap.xmland/zh/sitemap.xmlrobots<baseURL>/robots.txtreadsAllow: /with aSitemap:line; a preview deployment should readDisallow: /Search index- The browser can fetch
<baseURL>/offline-search-index.<language>.json, and site search returns results Markdown output- Appending
index.mdto any page URL returns plain text (where the site enabledmarkdownunderoutputs.page) llms.txt<baseURL>/llms.txtand<baseURL>/zh/llms.txtresolve (where the site enabledLLMSunderoutputs.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 Pagesrun in Actions and click Re-run all jobs; orgit revertthe 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.gzand 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.
Related
- 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
- Configuration —
baseURL,outputsand the other site keys
6.3 - Comments
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.
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
-
Choose a public repository to hold the comment threads; the site’s source repository works.
-
In the repository’s Settings → General → Features, tick Discussions.
-
Install the giscus GitHub App for that repository. Without the App, visitors cannot comment or react.
-
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-reporepodata-repo-idrepoIddata-categorycategorydata-category-idcategoryId
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:
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:
To disable them on selected pages, leave the site switch on and let unsuitable pages opt out:
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.
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:
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:
A fixed theme name in theme stops it following the toggle.
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-srcandframe-srcmust permit giscus — merged into the existing policy rather than replacing other directives (the general rules are in Content Security Policy):
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
Then confirm each of these:
- 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.
- Toggle OINK’s light/dark control and the comment section follows (with
theme: auto). - Open a page with
comments: falseand confirm there is neither giscus nor any other comment component. - 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.
Related
- Repository links and page info — edit this page, open an issue, contributors and the “was this helpful?” widget
- Analytics and SEO — the other capability needing an external service
- Deploy — Content Security Policy and external integrations in preview deployments
- Configuration — every
params.comments.*key - Page parameters —
commentsin front matter
6.4 - Analytics and SEO
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:
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.
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,- Analytics scripts, cookie consent scripts, meta tags the theme does not provide
layouts/_partials/hooks/body-end.html,- Third-party code affecting interaction rather than the first paint
Do not omit the hugo.IsProduction guard: without it, everyone’s local preview
reports into your 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:
- The page’s
descriptionfront matter - The page summary Hugo computes (
.Summary) params.descriptionin 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.
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:
canonical and hreflang
The theme emits one canonical and a set of hreflang alternates per page, with
no configuration:
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:
To give a shared link an image, set images in front matter:
For a site-wide fallback, write the same key under params:
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:
Both the site default and per-page overrides are Hugo’s own:
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:
The template the theme supplies gives two results by build environment, with no content for you to write:
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:
- Crawl permission: open
<baseURL>/robots.txtand confirmAllow: /rather thanDisallow: /. - Page inventory: open
<baseURL>/sitemap.xml, follow into a language sitemap, and check the page count. - Indexed count: search
site:yourdomainand check the order of magnitude; a page-by-page reconciliation is not needed. - Canonical addresses: results should land on the canonical URL, not a version with a
?parameter or an old domain. - Active submission: add the site in Google Search Console / Bing Webmaster Tools and submit the
sitemap.xmladdress, 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
Then check these in the output:
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.
Related
- Deploy —
baseURL, 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
hreflangand translation pairing - AI-agent support — the
.mdoutput andllms.txtwritten for models - Configuration —
services,sitemap,enableRobotsTXTand the rest
6.5 - Upgrade
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 release series in this site’s project blog
- The Releases page on GitHub
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:
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:
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
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:
Four things to remember while using it:
- A dry run is the default, and only
--writetouches disk. Dry-run, read the diff, then write. - A second run should change nothing. A second
--writestill 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:lineand 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:
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>,callout{{</* tabpane */>}}+{{%/* tab header= */%}},{{</* code-group */>}}+{{</* code-tab */>}},tabs{{</* filetree */>}}withfiletree/folderandfiletree/file,filetree{{</* gallery */>}}withgallery/image,gallery{{</* echarts */>}},{{</* infographic */>}},datafencedoc-cards/doc-card,nav-cards/nav-card,card/cardpane,doc-carousel,cards{{</* imgproc */>}},{{</* image */>}},image{{</* readfile file= */>}},includeThe fence attribute,{filename="x"}fencetitle{{</* badge outline= */>}},badge{{</* example */>}}+ a fence,{{</* book-figures kind="tbl" */>}},eg{{%/* _param x */%}},iframe,conditional-text,blocks/*,netlify, a kindlessxref,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.
-
Pin the target version. Change
go.modto an OINK release tag, or use a complete versioned archive. During evaluation, an uncommittedgo.workcan point at a local checkout. -
Inventory the overrides. Sort every site-level file under
layouts/,assets/andstatic/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 emptylayouts/at once: the home page and download page may still call a partial you are removing. -
Move the configuration.
title,languages.*,github_repo,github_branch,page_widthandparams.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.ymlDocsy’s camelCase search keys have been renamed in OINK:
offlineSearch,offlineSearchIndex,offlineSearchMaxResults,offlineSearchOnServeandofflineSearchSummaryLengthall 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. -
Fonts and styling compatibility. The Docsy Sass variables in the site’s
assets/scss/_variables_project.scssstill 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-familyand$font-family-codeeach feed their role. Docsy’s Google Fonts switches$td-enable-google-fonts,$td-google-font-nameand$td-web-font-pathare 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. -
Convert the shortcodes. Docsy’s
alert,pageinfo,tabpaneandcardfamilies all have a v5 counterpart; convert them in bulk with the migration toolkit above, one--onlyclass at a time. -
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.htmland the shared docs / blogbaseof*.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 andparamshortcodes; - 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:
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,bookandblogpages 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 withpager: 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: falsein a cascade. -
The footer defaults to
fatsite-wide. Onlyfat/slim/noneare accepted, and footer data must live indata/footer/<language>.yaml(ordata/footer.yamlon a single-language site); a leftoverfooterkey indata/homefails 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-codewrapper now encloses the original.highlight(both.highlightand.chromaare kept), so a direct child selector such as.td-content > .highlightin site CSS becomes the descendant selector.td-content .highlight. -
Two ICP footer parameters were removed:
footer_icpandfooter_icp_urlbecame one string accepting inline Markdown.hugo.yml -
Mathematics needs the site to enable passthrough. Hugo does not merge a theme’s
markupconfiguration, so a site using\(…\),\[…\]or$$…$$must enable the Goldmark passthrough extension in its ownhugo.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:
Another site runs the equivalent build, link, output and browser checks; the details are in Troubleshooting.
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:
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.
Related
- Deploy — rolling back deployed output
- Troubleshooting — reading a build error after an upgrade
- Local preview — clearing caches and the
go.workworkspace - From scratch and other install methods — weighing the four install methods
- Components — each component’s v5 form
6.6 - Troubleshooting
When something goes wrong, run a clean production build first and read from the first error; the ones after it are usually cascades:
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:
The two commonest shortcode errors look like this; note the trailing
file:line:column:
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 |
Search
| 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,- Duplicate output paths, invalid parameters, incomplete external integrations
Output trust check,- Every
href/srcin all four outputs is site-relative orhttp(s)/mailto/tel; nojavascript:URL and no inlineon*handler; a cross-site<iframe>,<script>or<img>needs an explicit--third-party Translation parity,- Whether each English page has a Chinese counterpart, and whether the rendered heading IDs line up; misaligned anchors surface here
The full gate,- 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 andllms.txtoutput. Changing a component’s Markdown shape fails here.test:alt-site— builds once per alternate configuration intests/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 ingo.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 repositoryIt 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/andresources/_genand 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.
- Theme and documentation issues: https://github.com/pgsty/oink/issues
- Issues with this site’s content: https://github.com/pgsty/oink.pgsty.com/issues
- Upstream Docsy compatibility discussion: https://github.com/google/docsy/discussions
Related
- Local preview — clean builds, clearing caches, containers and workspaces
- Deploy —
baseURL, 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
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
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.
Featured images
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 |
| 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.Pagesper page when a site-level resource orpartialCachedresult can own the work; - render
.Contentonce 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
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:
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.
Images, Gallery, FileTree, and fences
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
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:
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
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 |
| 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
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:
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_attributionwithupstream_linkplusupstream_name,upstream_copyright,upstream_license, andupstream_notice; renamedownstream_modifiedtoupstream_modified. - Replace the
releasemap with one GitHubrelease_url; removerelease_productsandrelease_group_by_productfrom release indexes. - Blog and default dates now default to ISO
2006-01-02; retain explicittime_format_blogortime_format_defaultfor 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
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.
Related
- Design contracts — current normative behaviour
- Research — dated evidence that informs decisions
- Proposals — ideas that have not been accepted
7.6.1 - Warnings and safe fallbacks
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:
- Name the invalid key and value, the allowed shape, and the fallback.
- Include a page position when the value came from page front matter; avoid repeating one site-wide warning for every page.
- Never pass an invalid value into a later operation. Validate first, then render from the normalized value.
- 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
errorfcalls.
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 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:
- Site facts remain at the established top level. Interface choices belong
under
params.ui.*. - A page override drops the
ui.prefix and otherwise keeps the same name. A sectioncascadecan apply that top-level key to its descendants. - 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.
- Names are positive, snake_case, and grouped by function. Closely related settings share a prefix instead of growing another nested resolver.
- 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. - 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
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:
- 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.
- 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.
- One semantic implementation. Native and full forms normalize into the same partials and output contract. They are not two components that merely look alike.
- 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.
- 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 |
| 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
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
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
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
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:
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:
- status, owner, date, and affected contract surface;
- context and evidence;
- goals and explicit non-goals;
- proposed behaviour and output/accessibility/security boundaries;
- compatibility and migration impact;
- implementation and owning-checker plan;
- acceptance criteria and open decisions;
- 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
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
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;
refandrelrefare included;- each language produces an independent graph;
- an unresolved derived edge warns or is reported by the focused checker
without making ordinary
hugo serverunusable.
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.
Backlink output
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
- Is G1 opt-in, opt-out, or enabled only for selected shell types?
- Does the local graph expose one depth or a tightly capped second depth?
- Which page metadata, if any, is useful enough to enter graph JSON?
- Should unresolved heuristic edges stay silent while a dedicated link checker reports them, or should deduplicated preview warnings be visible?
- Is G3 useful enough to justify a new output format before G1 and G2 have production evidence?
7.8.2 - Media convergence
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
figform 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:
- add processing arguments to the full
figsource form and normalize them through the same processing helper; or - keep processing exclusively on native Markdown images and document full
figas 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
- Is one shared result struct enough, or would a common lower-level URL/resource record keep resolver ownership clearer?
- Should Landing consume resource attribution, or only dimensions and URL?
- Does full
figprocessing solve a real consumer need now that native images support numbering, captions, links, and processing together? - Which emitted compatibility names are still used by real consumers?
7.8.3 - Bulk agent indexes
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
- Are both full-text deployment shapes needed, or is section-only safer?
- Should navigation JSON be a home output or a dedicated content page backed by a resource template?
- Which node metadata is stable enough for schema version 1?
- Should
llms.txtlist navigation JSON by default when it exists? - What size evidence should the checker report without enforcing an arbitrary model-context limit?