# Blog posts

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

---

LLMS index: [llms.txt](/llms.txt)

---

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 {#layout}
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.

```filetree {title="this site's content/blog/"}
- content/
  - blog/
    - _index.md                      # type: blog + cascade
    - _index.zh.md
    - oink/                          # engineering notes and announcements
      - _index.md                    # cascade: images: [/images/oink.webp]
      - oink-announcement.md
      - oink-announcement.zh.md
    - release/                       # versioned release notes
      - _index.md                    # cascade: 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:

```yaml {title="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 {#front-matter}

```yaml {title="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.

## Featured image {#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:

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:

```yaml {title="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 {#featured-image-article}

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 |

```yaml {title="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.

```yaml {title="content/blog/release/_index.md"}
cascade:
  featured_image: wash
```

## List pages and pagination {#list}

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`:

```yaml {title="hugo.yml"}
pagination:
  pagerSize: 20
```

The values and the remaining pagination options are in the
[Hugo documentation](https://gohugo.io/configuration/pagination/).

### The card form {#list-cards}

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

```yaml {title="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 {#rss}

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

```yaml {title="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:

```yaml {title="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 {#taxonomy}

`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](/docs/customize/taxonomy/).

## Release notes {#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](/docs/write/releases/).

## Components in a post {#components}

Callouts, tabs, code blocks, images and tables work exactly as on a
documentation page; the syntax is in [Components](/docs/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](/docs/write/pages/#page-end). A blog usually turns feedback off
and keeps comments.

## Authors and bylines {#authors}

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

```yaml {title="hugo.yml"}
taxonomies:
  category: categories
  tag: tags
  author: authors
```

A post then names its authors in order:

```yaml
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:

```markdown {title="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 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 {#series}

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

```yaml {title="hugo.yml"}
taxonomies:
  series: series
```

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

```yaml
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 {#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:

```yaml {title="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:

```yaml {title="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 {#verify}

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

## Related {#related}

- [Writing pages](/docs/write/pages/) — how to write the body
- [Page parameters](/docs/write/frontmatter/) — the full definition of `author`, `images` and the rest
- [Organizing content](/docs/write/organize/) — directories and the sidebar
- [Taxonomies](/docs/customize/taxonomy/) — tags and categories
- [Releases and downloads](/docs/write/releases/) — version cards and asset tables
