This is the multi-page printable view of this section. .
Authoring
- 1: Writing pages
- 2: Organizing content
- 3: Page parameters
- 4: Blog posts
- 5: Books
- 6: Releases and downloads
- 7: API reference pages
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 |
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
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 - 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
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
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
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
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