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 ordered by date 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. Year directories are unnecessary: the list orders posts by their
date.
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:
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 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 matches
featuredorfeature, thencoverorthumbnail(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 clear an inherited image on one post, write images: [] in its front matter;
for a whole subsection, put it in that level’s cascade. This does not suppress
an image supplied by the page bundle. To hide the image on the article itself,
use featured_image: none; list thumbnails and share cards are separate uses.
The site-level params.images 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. Set
params.ui.featured_image to use the same image on the article itself:
| Mode | What the article shows |
|---|---|
none |
No article image; the theme default |
banner |
The image above the title in a fixed 16:9 figure, so a run of articles keeps one rhythm |
wash |
A faint image behind the article header, fading before the body |
hero |
An immersive full-bleed image header |
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
no image in any mode, so a section can enable it even when only some posts have
art. These modes need no additional script.
List pages and pagination
After the body of the section _index.md, the theme appends the post list:
ordered by date, newest first, with no year groups. Each row shows the
title, date, subsection, tags, thumbnail and the first 250 characters of the
body as a summary.
List and card views show 12 posts per page by default. Set the theme’s
blog_index_size in hugo.yml to change it:
The same key in a blog section’s front matter overrides the site value. The
theme passes this size to Hugo’s paginator explicitly, so pagination.pagerSize
does not control these lists.
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.
List and card views share date ordering, pagination and manual_link behavior.
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 the view per section. Term and taxonomy pages keep the
row list.
A standalone blog_index: table with blog_index_toggle: false lists the
entire section without pagination. Set blog_index_toggle: true to let readers
switch among list, cards and table:
With switching enabled, all three views show the same current page of posts,
using blog_index_size. Only the standalone table with
blog_index_toggle: false shows the entire section. Hidden views do not load
their images.
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 that order; list rows
show the names. The blog feed includes each author alongside the site-level
managingEditor.
An author’s profile is the taxonomy term page; no data/authors file is needed:
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.
Members with series_weight come first in ascending order; the rest follow
from oldest to newest, with the content path breaking ties. The series strip
and term page use this same reading order. For implementation details, see
Authors and series.
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.
Share targets are links carrying the page’s permalink and title, plus a local copy button. The bar loads no third-party scripts or stylesheets; a target is contacted only when the reader clicks its link. See the Share contract for the implementation rules.
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 in the right date order 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