Skip to content

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.