Skip to content

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

Return to the regular view of this page.

Authoring

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

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

What a documentation page is made of

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

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

## Prerequisites {#prerequisites}

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

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

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

Content types and where they are covered

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

1 - Writing pages

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

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

Creating a page

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

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

the two page shapes inside content/

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

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

Important

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

The front matter you need

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

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

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

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

Heading levels and stable anchors

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

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

Source
## Prerequisites {#prerequisites}

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

There are two reasons:

  • Cross-language alignment. Hugo derives an ID from the heading text, so a Chinese heading yields a Chinese ID: /docs/install/#prerequisites and /zh/docs/install/#前提条件 point at the same semantic place through two different anchors, which no translation audit can compare. Give the translated heading the English page’s ID and both sides share one fragment.
  • Link stability. Heading text changes as wording is revised, and a public link should not break with it. An explicit ID is a public route once published; when a rename is needed, leave an empty anchor for the old ID:
Source: leaving a target behind for the old anchor
## Getting started <a id="get-started"></a> {#quickstart}

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

Three forms, for different purposes:

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

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

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

Plain Markdown links are not checked for existence. So:

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

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

Where images go

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

Drafts and publishing

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

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

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

OINK’s Markdown extensions at a glance

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

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

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

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

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

    Add -D to preview drafts as well.

What appears at the end of a page

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

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

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

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

Verify

After writing a page, run a strict build:

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

Then confirm three things in the browser:

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

2 - Organizing content

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

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

Directories are the sidebar

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

the first two levels of content/docs/

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

Every directory needs an _index.md

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

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

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

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

Ordering: use multiples of 10 for weight

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

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

Single file or page bundle

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

List or cards on a section index

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

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

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

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

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

Tip

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

Sidebar icons

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

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

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

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

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

Expanding and folding

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

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

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

Hiding from the sidebar

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

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

The shell follows type, not the path

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

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

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

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

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

Multiple sidebar roots

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

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

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

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

Verify

hugo --printPathWarnings --panicOnWarning

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

Then confirm each of these in the browser:

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

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

3 - Page parameters

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

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

How to read the tables

Precedence, highest first:

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

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

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

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

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

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

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

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

Basics

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

Sidebar and navigation

The guide is Organizing content.

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

Page shell

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

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

The guide is Search.

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

Output formats

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

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

Page end: comments, feedback and provenance

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

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

Upstream attribution

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

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

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

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

Image zoom

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

Blog posts

The guide is Blog posts.

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

Book

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

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

Landing

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

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

Release pages

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

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

4 - Blog posts

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

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

The blog directory

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

this site's content/blog/

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

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

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

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

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

A post’s front matter

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

Where it differs from a documentation page:

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

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

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

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

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

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

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

On the article itself

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

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

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

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

List pages and pagination

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

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

hugo.yml
pagination:
  pagerSize: 20

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

The card form

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

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

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

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

RSS

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

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

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

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

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

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

hugo.yml
disableKinds: [RSS]

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

Categories and tags

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

Release notes

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

Components in a post

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

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

Authors and bylines

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

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

A post then names its authors in order:

authors: [vonng, ada-example]

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

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

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

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

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

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

Series

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

hugo.yml
taxonomies:
  series: series

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

series: [shell-internals]
series_weight: 20

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

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

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

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

Share

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

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

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

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

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

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

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

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

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

Verify

hugo --printPathWarnings --panicOnWarning

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

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

5 - Books

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

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

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

A book’s directory

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

content/handbook/, one book

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

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

The book home and chapter pages

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

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

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

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

A chapter page needs only its number and its order:

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

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

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

The full key definitions are in Configuration and Page parameters.

Numbering: the native form

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

Figures

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

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

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

Tables

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

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

Equations

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

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

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

Examples

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

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

Numbering: the shortcode form

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

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

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

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

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

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

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

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

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

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

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

Footnotes cannot appear in a shortcode body

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

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

Cross-references

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

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

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

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

The rules:

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

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

Indexes: contents and lists of figures

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

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

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

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

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

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

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

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

Sequential reading and drafts

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

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

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

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

hugo.yml
params:
  ui:
    book_draft_banner: true

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

Printing the whole book

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

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

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

Migrating an existing manuscript

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

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

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

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

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

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

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

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

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

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

Verify

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

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

Book shortcode parameters

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

xref:

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

book-toc:

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

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

Limits

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

6 - Releases and downloads

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

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

This page carries demonstration release facts

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

Components and where the facts come from

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

The page owns the release facts

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

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

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

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

Source
{{< release-card >}}

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

The release index page

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

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

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

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

Checksum assets

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

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

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

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

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

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

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

.rpm

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

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

Download channel data

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

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

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

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

Each channel:

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

Two rules:

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

Rendering the download block

download takes exactly one positional parameter, the data key:

Source
{{< download "prd5" >}}

Install script

The rolling channel deliberately contains no version interpolation.

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

Source archive

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

Release assets

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

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

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

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

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

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

How this relates to blog release notes

The two have different jobs:

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

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

Verify

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

7 - API reference pages

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

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

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

Where the specification file goes

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

where the specification lives

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

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

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

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

Swagger UI

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

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

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

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

Redoc

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

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

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

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

The dedicated page shell

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

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

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

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

Output

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

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

Limits

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

Verify

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