Skip to content

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

Return to the regular view of this page.

Operations

Running the site from a laptop to production — local preview, deployment, comments, analytics and SEO, upgrades and troubleshooting.

This section covers what happens after the content is written: previewing locally, building and deploying the output, wiring up comments and analytics, following theme versions, and locating faults. The previous five sections decide how the site looks and what it says; this one decides whether it builds, where it is deployed, and how a problem is diagnosed.

Find it by task

What you want to do Where to go
See a change on your own machine Local preview
Build a deployable public/ Local preview
Deploy to GitHub Pages / Cloudflare / Netlify Deploy
Deploy to a subpath such as example.com/docs/ Deploy
Let readers comment at the bottom of a page Comments
Connect Google Analytics or a self-hosted alternative Analytics and SEO
Get indexed correctly by search engines Analytics and SEO
Upgrade the theme, or migrate from Docsy or 0.4 Upgrade
A build error, no search results, a 404 Troubleshooting

1 - Local preview

Preview changes with hugo server, build a deployable public/ with hugo –panicOnWarning, and need neither Node nor a CDN.

Two commands cover the daily work: hugo server previews changes locally, and hugo produces a public/ deployable to any static host. The prerequisite is Hugo Extended (0.160.1 or newer) on the machine, plus Go when the theme comes in as a Hugo Module. The build depends on no Node.js, npm or PostCSS — those serve only this repository’s own regression checks.

The preview server

From the site root (the directory holding hugo.yml):

Terminal
hugo server

Open http://localhost:1313/. Saving a file rebuilds and refreshes the browser, and switching Git branches triggers a rebuild too. The first start is slower: with the theme as a Hugo Module, Hugo has to download the module through Go into its cache, and every start after that reads the cache.

The switches worth knowing

-D / --buildDrafts , defaultoff
Also builds pages with draft: true
-F / --buildFuture , defaultoff
Also builds pages whose date / publishDate is in the future
-E / --buildExpired , defaultoff
Also builds pages whose expiryDate has passed
--disableFastRender , defaultoff
Re-renders the whole site on every change instead of incrementally
-M / --renderToMemory , defaultoff (writes to disk)
Renders in memory only, writing no public/
-N / --navigateToChanged , defaultoff
The browser jumps to whichever page you saved
--bind , default127.0.0.1
The listen address; use 0.0.0.0 to reach it from a LAN or outside a container
-p / --port , default1313
The listen port
--minify , defaultoff
Minifies the preview too, to reproduce production rendering
--printPathWarnings , defaultoff
Warns when two pages write to the same target path

The combination used while developing this site:

Terminal
hugo server -DFE \
  --disableFastRender --renderToMemory --minify \
  --printPathWarnings --logLevel info

-DFE is shorthand for -D -F -E, building drafts, future and expired pages together so a newly created page is visible while writing.

A change that did not take effect

Hugo enables fast render by default, rebuilding only what it judges affected. When editing layouts, configuration, data/, or a file pulled in by include, that judgement can miss, and the page appears unchanged. Three steps:

  1. Restart with --disableFastRender and see whether it comes back.
  2. Hard-refresh the browser (Cmd/Ctrl + Shift + R) to rule out browser cache.
  3. If it still does not, clear the caches and restart.

Reaching it from another device

hugo server listens on 127.0.0.1 only, so no other device can reach it. To preview on a phone or another machine:

Terminal
hugo server --bind 0.0.0.0 --port 1313 --baseURL http://192.168.1.10:1313/

--baseURL must be an address the other device can reach, or the page opens while CSS and the search index — anything using an absolute path — point at localhost.

Production build

Build deployable output with hugo, not hugo server:

Terminal
hugo --gc --minify --printPathWarnings --panicOnWarning

The output goes to public/, which can be deployed independently of the source tree. Each of the four switches does one thing:

--gc
Clears cached resources in resources/_gen that are no longer referenced
--minify
Minifies the HTML, CSS, JS and XML output
--printPathWarnings
Warns when two pages collide on one output path, the commonest silent error on a multilingual site
--panicOnWarning
Fails the build on the first WARNING

--panicOnWarning deserves its own note. Most of OINK’s degradation paths warn rather than error: a missing required giscus key, an unsupported params.comments.type, a configuration key Hugo has deprecated — each prints one WARNING and moves on. CI logs are rarely read line by line, so those reach production. Putting this switch in the build command makes zero warnings the condition for a passing build.

This site’s CI build step (.github/workflows/pages.yml) is hugo --cleanDestinationDir --gc --minify --environment production --printPathWarnings --panicOnWarning, so any warning stops the deployment at the build stage.

baseURL and the build environment

baseURL lives in hugo.yml and can be overridden on the command line:

hugo.yml
baseURL: https://oink.pgsty.com
Terminal
hugo --minify --baseURL "https://example.com/docs/"

When deploying to a subpath, --baseURL must include that path segment; the details are in Deploy.

The build environment is chosen with -e / --environment; hugo defaults to production and hugo server to development. That choice has three visible consequences in OINK:

  • Only production emits <meta name="robots" content="index, follow">; other environments emit noindex, nofollow.
  • Under production robots.txt is Allow: /; elsewhere it is Disallow: /.
  • Only production renders Hugo’s Google Analytics template, and only there are static assets fingerprinted with SRI.

Build preview deployments (PR previews, staging) with a non-production environment, and the output declines indexing and analytics by itself:

Terminal
hugo --minify --environment staging --baseURL "$PREVIEW_URL"

Previewing in a container

A container is not required. Two situations suit one: a team that needs a pinned toolchain version, or one that would rather not install Hugo on every developer machine.

Dockerfile
FROM debian:bookworm-slim

ARG HUGO_VERSION=0.164.0
ARG GO_VERSION=1.26.6
ARG TARGETARCH

RUN apt-get update \
    && apt-get install -y --no-install-recommends ca-certificates curl git \
    && curl -L -o /tmp/hugo.deb \
      "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-${TARGETARCH}.deb" \
    && apt-get install -y /tmp/hugo.deb \
    && curl -L -o /tmp/go.tgz \
      "https://go.dev/dl/go${GO_VERSION}.linux-${TARGETARCH}.tar.gz" \
    && tar -C /usr/local -xzf /tmp/go.tgz \
    && rm -rf /var/lib/apt/lists/* /tmp/hugo.deb /tmp/go.tgz

ENV PATH="/usr/local/go/bin:${PATH}"
WORKDIR /src
EXPOSE 1313
ENTRYPOINT ["hugo"]
CMD ["server", "--bind", "0.0.0.0", "--disableFastRender"]
Terminal
docker build -t oink-hugo .

# preview: mount the site source, and the Go module cache with it
docker run --rm -it -p 1313:1313 \
  -v "$PWD:/src" \
  -v "$HOME/go/pkg/mod:/root/go/pkg/mod" \
  oink-hugo

# production build: override the default server command
docker run --rm --user "$(id -u):$(id -g)" \
  -v "$PWD:/src" \
  oink-hugo --gc --minify

Go is in the image because Hugo needs it to resolve and download the module when the theme comes in as a Hugo Module. A site using a submodule, an offline archive or a plain clone can drop Go, and the image gets much smaller.

Do not let root write public/

A container process is root by default, the generated public/ belongs to root, and the host cannot delete it. In a shared environment, map the user ID with --user "$(id -u):$(id -g)" (the production build command above already does).

The image needs no Node.js, npm or PostCSS, and should have no step fetching remote browser assets. A network-isolated environment needs the base image and those two packages mirrored in advance.

Clearing caches

Hugo’s intermediate output lives in three places; clear them lightest first:

public/ , ContentsThe previous build’s output
A page was deleted but is still live; or let the build clear it with hugo --cleanDestinationDir
resources/_gen/ , ContentsProcessed images and compiled CSS
Image processing parameters, fonts or the accent colour changed and the page still looks old
hugo mod clean , ContentsThe Hugo Module cache
The theme version changed but the old one still resolves; add --all to clear the whole module cache
Terminal
rm -rf public resources/_gen
hugo mod clean          # only the modules this project uses
hugo mod clean --all    # the whole module cache; the next build downloads again

Both public/ and resources/ belong in .gitignore; generated output is never committed.

Working on the theme alongside

This section applies only when changing the theme and the site together. Point the module at a local checkout temporarily with HUGO_MODULE_REPLACEMENTS, leaving go.mod untouched:

Terminal
HUGO_MODULE_REPLACEMENTS='github.com/pgsty/oink -> /absolute/path/to/oink' hugo server

This site’s Makefile wraps those commands and expects the theme checkout at the sibling ../oink:

Makefile targets
make dev     # development server with ../oink substituted
make check   # full regression suite (npm test) with ../oink substituted
make build   # build with the version in go.mod
make serve   # preview server with the production configuration
A replacement is local only

Whether through the environment variable or a Go workspace (go work init plus HUGO_MODULE_WORKSPACE=go.work), CI and production builds read only go.mod; go.work records a developer machine’s paths and is never committed. To judge whether a release tag works, drop the replacement and build once against the version in go.mod.

Verifying an offline build

Acceptance in a network-isolated environment has to cover both the build stage and the browser stage. Six steps:

  1. Start from a verified theme archive and an empty module cache (hugo mod clean --all).
  2. Block outbound HTTP, HTTPS and the Go module proxy.
  3. Run the production build hugo --gc --minify --printPathWarnings --panicOnWarning.
  4. Browse pages in both languages: a documentation page, a blog page, the home page, the 404.
  5. Exercise search, the light/dark toggle, diagrams and content components.
  6. Check subresource origins and confirm there is no unexpected remote host.

The last step uses a script from the theme repository that does not depend on the site’s test framework:

Terminal
python3 bin/check-output-security.py \
  --public public --base-url https://docs.internal.example.com/

The script scans every href / src / srcset / poster and form action in all four outputs, requiring each to be a site-relative path or http / https / mailto / tel, and rejecting inline on* handlers and javascript: URLs. An <iframe>, <script>, <link>, <img>, <video>, <audio>, <embed>, <object> or <source> pointing at another host is an error; where a site genuinely embeds third-party content, --third-party permits it, and a multi-domain language configuration adds first-party hosts with --allow-host.

One pass proves that commit in that environment. Run it again for every theme candidate and after every bundled-dependency update.

Verify

A clean production build should look like this:

Terminal
rm -rf public resources/_gen
hugo --gc --minify --printPathWarnings --panicOnWarning

It passes on Total in … with no ERROR and no WARNING. Then confirm:

  • The log has no npm, PostCSS, Autoprefixer or browser-asset download step. One appearing means upstream Docsy’s process has crept into the configuration.
  • public/ has sitemap.xml and robots.txt, and robots.txt reads Allow: /.
  • On a site with local search, public/ has offline-search-index.<language>.json at its root.
  • Open representative pages with hugo server: one documentation page, one blog page, the home page and the 404, in both languages and both colour schemes.

For a failing build or a wrong result, see Troubleshooting.

2 - Deploy

Publish public/ to GitHub Pages, Cloudflare Pages or any static host — matching baseURL, Content Security Policy, the acceptance checklist and rollback.

An OINK site’s output is a plain static directory, deployable anywhere that hosts static files, with no Node runtime, no server-side rendering and no build plugin. The host’s side is three things: run one command with the right Hugo version, publish public/, and keep baseURL matching the final address.

The prerequisite is a warning-free production build locally.

Getting baseURL right

baseURL is the commonest source of failure, and it fails quietly: the page opens, but the search index 404s, page action links point at the wrong place, and some assets do not load.

Deploying at a domain root:

hugo.yml
baseURL: https://oink.pgsty.com

Deploying to a subpath (https://example.com/docs/), the path must be in baseURL:

hugo.yml
baseURL: https://example.com/docs/

It can also be overridden at build time, so one source deploys to several places:

Terminal
hugo --gc --minify --baseURL "https://example.com/docs/"
Do not fix a subpath with canonifyURLs

Hugo’s canonifyURLs defaults to false; keep that default. OINK’s templates and content links all resolve against baseURL: a wrong path means a wrong baseURL, and turning canonifyURLs on rewrites the relative links that were already correct, making the problem harder to locate.

To tell whether it matches, look at the search index request path after a build: the browser should fetch <baseURL>/offline-search-index.en.json, and fetching it from anywhere else means baseURL is wrong.

Choosing a host

With the source on GitHub, one Actions workflow is enough: the build runs in Actions and the output is published through the Pages deployment API, with no gh-pages branch to maintain.

Commit the following file:

.github/workflows/pages.yml
 1name: Deploy Oink site to GitHub Pages
 2
 3on:
 4  push:
 5    branches: [main]
 6  workflow_dispatch:
 7
 8permissions:
 9  contents: read
10  pages: write
11  id-token: write
12
13concurrency:
14  group: pages
15  cancel-in-progress: false
16
17env:
18  GO_VERSION: 1.26.6
19  HUGO_VERSION: 0.164.0
20  # a workspace from a sibling checkout must never take part in a CI build
21  GOWORK: off
22  HUGO_MODULE_WORKSPACE: off
23  HUGO_CACHEDIR: ${{ github.workspace }}/.hugo_cache
24  GOMODCACHE:
25    ${{ github.workspace }}/.hugo_cache/modules/filecache/modules/pkg/mod
26
27jobs:
28  build:
29    name: Build Pages artifact
30    runs-on: ubuntu-latest
31    steps:
32      - name: Checkout
33        uses: actions/checkout@v7
34        with:
35          fetch-depth: 0
36
37      - name: Set up Go
38        uses: actions/setup-go@v6
39        with:
40          go-version: ${{ env.GO_VERSION }}
41
42      - name: Set up Pages
43        id: pages
44        uses: actions/configure-pages@v6
45
46      - name: Install Hugo Extended
47        run: |
48          curl --fail --location --silent --show-error \
49            --output "${RUNNER_TEMP}/hugo.deb" \
50            "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb"
51          sudo dpkg -i "${RUNNER_TEMP}/hugo.deb"
52
53      - name: Download Hugo module
54        run: go mod download github.com/pgsty/oink
55
56      - name: Build site
57        run: |
58          hugo --cleanDestinationDir --gc --minify --environment production \
59            --printPathWarnings --panicOnWarning \
60            --baseURL "${{ steps.pages.outputs.base_url }}/"
61
62      - name: Upload Pages artifact
63        uses: actions/upload-pages-artifact@v5
64        with:
65          path: public
66
67  deploy:
68    name: Deploy to GitHub Pages
69    environment:
70      name: github-pages
71      url: ${{ steps.deployment.outputs.page_url }}
72    runs-on: ubuntu-latest
73    needs: build
74    steps:
75      - name: Deploy
76        id: deployment
77        uses: actions/deploy-pages@v5

That is the workflow this site uses. Several pieces cannot be removed:

  • fetch-depth: 0 — with enableGitInfo on, “last modified” and contributor information need the full Git history, and a shallow clone leaves them empty.
  • setup-go plus go mod download — with the theme as a Hugo Module, Hugo needs Go to resolve it. A site installing the theme as a submodule uses submodules: recursive instead, and one using an offline archive commits themes/oink/; either way both steps go.
  • GOWORK: off and HUGO_MODULE_WORKSPACE: off — keep a local development go.work from taking part in the CI build, so CI verifies the published tag pinned in go.mod.
  • --baseURL "${{ steps.pages.outputs.base_url }}/" — a project site’s URL is https://<OWNER>.github.io/<REPO>/, and configure-pages computes it, so it need not be hard-coded.
  • --panicOnWarning — a warning means no publish.

In the repository, set Settings → Pages → Build and deployment → Source to GitHub Actions, push to main, and watch the first run on the Actions tab.

A custom domain goes in the Custom domain field on that same settings page, with DNS configured as prompted, after which baseURL in hugo.yml becomes that domain. Where the publishing flow needs a CNAME file in the output, put it at static/CNAME and Hugo copies it into public/ unchanged.

Cloudflare Pages builds from a connected GitHub / GitLab repository and creates a preview deployment per review branch. The build happens on the platform side, so no workflow is needed in the repository.

Import the repository under Workers & Pages and choose the production branch:

Build command
hugo --gc --minify --printPathWarnings --panicOnWarning
Build output directory
public
HUGO_VERSION
0.164.0 (or another version the theme has verified)
GO_VERSION
Needed only for the Hugo Module method; pin a version the build image supports
SKIP_DEPENDENCY_INSTALL
1

Four notes:

  1. HUGO_VERSION must be set explicitly, in both the Production and Preview environments. The Cloudflare v3 build image’s default Hugo is older than OINK’s required 0.160.1, and leaving it unpinned changes the toolchain silently when the image updates.
  2. SKIP_DEPENDENCY_INSTALL=1 turns off the generic dependency install step. A consuming OINK site needs no Node.js, and a package.json present only for maintenance tooling should not be installed by the platform.
  3. Where the Hugo site is not at the repository root, set Root directory to the site directory; the output directory resolves against it.
  4. A preview deployment is not a production release. Where a preview needs the generated Pages URL as its base URL, use hugo --gc --minify --baseURL "$CF_PAGES_URL", and rebuild for production with the canonical domain.

Check the first build log: a healthy consuming OINK build is one Hugo command, with no npm, PostCSS or Autoprefixer step and no download of the theme’s own browser assets.

Netlify — build command hugo --gc --minify, publish directory public, environment variable HUGO_VERSION. The same settings can live in the repository:

netlify.toml
[build]
command = "hugo --gc --minify --printPathWarnings --panicOnWarning"
publish = "public"

[build.environment]
HUGO_VERSION = "0.164.0"

With the theme as a submodule, enable recursive submodule checkout; with a Hugo Module, the build environment needs Git and Go. Production and preview should use one Hugo version, unless the preview environment exists to test an upgrade.

Vercel — the same three things: build command hugo --gc --minify, output directory public, environment variable HUGO_VERSION. It likewise needs no npm install.

Any static server (Nginx / Caddy) — lay the contents of public/ down as they are:

/etc/nginx/conf.d/docs.conf
server {
    listen 80;
    server_name docs.example.com;
    root /var/www/oink;
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }

    error_page 404 /404.html;
}

The site is purely static; there is no path to forward to an application server.

Object storage — Hugo has a deploy command; put the target in the configuration:

hugo.yml
deployment:
  targets:
    - name: aws
      URL: 's3://www.your-domain.tld'
      cloudFrontDistributionID: E9RZ8T1EXAMPLEID

Run hugo deploy after a build: it compares the remote with public/, uploads only what changed, and invalidates the CDN cache when given a cloudFrontDistributionID. Without --target it uses the first target, and --dryRun shows what would change first. Two prerequisites: a Hugo binary built withdeploy (visible in hugo version), and cloud credentials supplied through the standard environment variables or configuration file (on AWS, confirm with aws s3 ls first).

Offline packaging — in a network-isolated environment, build on a connected machine and carry the output across as one package:

Terminal
hugo --gc --minify --baseURL "https://docs.internal.example.com/"
tar -czf oink-site-$(date +%Y%m%d).tar.gz -C public .

# on the target machine
tar -xzf oink-site-20260817.tar.gz -C /var/www/oink

Build with the target environment’s baseURL from the start; the absolute links in the output cannot be changed after unpacking.

A host without Go — the Hugo Module method needs Go in the build environment. Where a platform does not provide it, switch to a Git submodule (running git submodule update --init before the build) or an offline archive (committing themes/oink/) — see From scratch and other install methods.

Keeping preview deployments unindexed

Hugo’s -e / --environment selects build-time behaviour and does not change the site’s content, but three things in OINK follow it: only production emits <meta name="robots" content="index, follow">, only it makes robots.txt read Allow: /, and only it renders the Google Analytics template. Do not build PR previews and staging with --environment production:

Terminal
hugo --gc --minify --environment staging --baseURL "$PREVIEW_URL"

The output then carries noindex, nofollow and Disallow: /, and reports nothing to an analytics service.

Content Security Policy

The runtimes, fonts and icons the theme ships are all same-origin assets, so a strict Content Security Policy is workable. The theme provides no general policy: which directives you need depends on what the site enabled.

Five things change the directives needed:

  • Inline HTML and inline scripts written by authors, which are the author’s responsibility under renderer.unsafe: true.
  • ECharts $fn: callbacks: the callback functions are registered by the site on window.OinkEchartsFunctions, and the registering script’s origin belongs in script-src.
  • Analytics scripts: the script the site inserts, and the destination it reports to.
  • Remote API specifications and self-hosted diagram services: these land in connect-src and img-src.
  • giscus: script-src and frame-src must both permit it.

Start from a minimal policy covering only reviewed features and permit things one at a time: keep ECharts options pure data where no callback is needed, review inline scripts written by authors, and add a remote origin only for an integration the site deliberately enabled. Subresource origins in the output can be swept first with the script in Verifying an offline build.

Acceptance checklist

Walk this table after deploying. The first four are build-time; the rest have to be checked on the real URL.

A warning-free build
The build command carries --printPathWarnings --panicOnWarning and the log has Total in …
baseURL is correct
<link rel="canonical"> in the page source points at the real production address, subpath included
Sitemap
<baseURL>/sitemap.xml resolves; a multilingual site has an index pointing at /en/sitemap.xml and /zh/sitemap.xml
robots
<baseURL>/robots.txt reads Allow: / with a Sitemap: line; a preview deployment should read Disallow: /
Search index
The browser can fetch <baseURL>/offline-search-index.<language>.json, and site search returns results
Markdown output
Appending index.md to any page URL returns plain text (where the site enabled markdown under outputs.page)
llms.txt
<baseURL>/llms.txt and <baseURL>/zh/llms.txt resolve (where the site enabled LLMS under outputs.home)
Both languages
Documentation, blog and home pages open in both, and switching language lands on the corresponding page rather than the home page
Appearance and interaction
The light/dark toggle, the print view and representative components (callouts, tabs, code block copy) all work
404
Visiting a path that does not exist shows the site’s own 404 page

The switches for sitemap.xml, robots.txt, .md and llms.txt are in Configuration, and the agent output details are in AI-agent support.

Rollback

Rolling back a static site means republishing the last known-good commit; never edit files by hand in production.

  • GitHub Pages: find the last successful Deploy Oink site to GitHub Pages run in Actions and click Re-run all jobs; or git revert the offending commit and push again.
  • Cloudflare Pages / Netlify / Vercel: pick the last successful deployment from the list and use the platform’s Rollback / Publish deploy to make it production again.
  • A self-hosted static server: keep the previous tar.gz and unpack it over the top. The dated suffix in offline packaging exists for exactly this.

Where the problem is a theme upgrade rather than the content, what rolls back is the version pinned in go.mod — see Upgrade.

  • Local preview — the full production build command, clearing caches and offline verification
  • Troubleshooting — 404s, empty search, platform-specific faults
  • Analytics and SEO — being indexed correctly after launch
  • Upgrade — upgrading the theme version and rolling back
  • ConfigurationbaseURL, outputs and the other site keys

3 - Comments

Wire GitHub Discussions into a comment section at the bottom of a page with giscus — on site-wide, off per page, following light and dark.

OINK’s comments run on giscus: each page maps to one GitHub Discussion, readers sign in with a GitHub account to post, and maintainers moderate in GitHub Discussions. The theme provides no comment backend of its own and bundles no provider other than giscus.

The prerequisite is a public GitHub repository; a visitor cannot read a private repository’s Discussions.

This is one of the few features in the theme that makes an outbound request

A page with comments enabled loads a script and an iframe from https://giscus.app, which does not work in a network-isolated environment. It is off by default and loads only when explicitly enabled. Where a site has a privacy policy, this external data boundary belongs in it.

Preparing the GitHub repository

  1. Choose a public repository to hold the comment threads; the site’s source repository works.

  2. In the repository’s Settings → General → Features, tick Discussions.

  3. Install the giscus GitHub App for that repository. Without the App, visitors cannot comment or react.

  4. Choose a Discussion category. giscus recommends the Announcements type: only maintainers and the giscus bot can open a Discussion there, so readers cannot start one by accident.

The repository ID and category ID are public identifiers, not credentials. Never put a personal access token, an OAuth secret or a password in Hugo configuration.

Generating the configuration

Open giscus.app, fill in the repository, mapping and category, and the page generates a <script> block below. Copy four of its attributes into the OINK configuration:

data-repo
repo
data-repo-id
repoId
data-category
category
data-category-id
categoryId

The mapping decides which page corresponds to which Discussion. OINK defaults to pathname, which suits a site with stable published paths and one repository serving several domains or preview environments. Changing mapping or moving a page after comments have accumulated makes giscus look for a different Discussion: the existing comments are not deleted, but the page can no longer find them. Settle the mapping before launch; where a URL really must change, keep a redirect or rename the Discussion at the same time.

Enabling it site-wide

Write the generated identifiers into the site configuration:

hugo.yml
params:
  comments:
    enable: true
    type: giscus
    giscus:
      repo: pgsty/oink.pgsty.com
      repoId: R_kgDOTzFZAg
      category: Announcements
      categoryId: DIC_kwDOTzFZAs4DDCm-
      mapping: pathname
      inputPosition: bottom
      theme: auto
      loading: lazy

That is this site’s live configuration. All four of repo, repoId, category and categoryId are required: if any is missing or only whitespace, Hugo prints one WARNING and skips giscus without failing the build — which is why a production build carries --panicOnWarning. type accepts only giscus today, and any other value likewise warns and skips. The params.comments key names match Hextra’s, so a configuration migrated from Hextra transfers as it stands.

The remaining keys (strict, reactionsEnabled, emitMetadata, term, lang, lightTheme, darkTheme, ariaLabel, errorMessage) all have defaults, defined fully in Configuration. A feature switch takes either a YAML boolean or giscus-style 0 / 1.

Per-page control

comments in front matter overrides the site switch in either direction, and the value nearest the page wins.

To enable comments on selected pages only, turn the site switch off while keeping the full repository configuration, then let chosen pages opt in:

content/blog/2026-roadmap.md
---
title: 2026 roadmap
comments: true
---

To disable them on selected pages, leave the site switch on and let unsuitable pages opt out:

content/about/security.md
---
title: Security policy
comments: false
---

Use a cascade to set a whole section at once. This site writes comments: true in the cascade of content/docs/_index.md, which is why a real giscus section sits at the bottom of this page.

content/docs/_index.md
---
title: OINK Documentation
cascade:
  type: docs
  comments: true
---

Where a site also configures services.disqus.shortname, giscus wins: an active giscus suppresses Disqus, comments: false turns off both, and if a required giscus key is missing it warns, skips, and lets Disqus take over.

Multilingual text

giscus’s interface language follows the current Hugo language automatically: Simplified, Traditional and Hong Kong Traditional Chinese each map to the corresponding giscus locale, and an unsupported language falls back to English. Set lang explicitly only where the automatic choice is wrong.

What does need translating is the two strings on OINK’s side: the comment section’s accessible label and the loading-failure message. They are configured per language and merged with the global repository configuration:

hugo.yml
languages:
  en:
    params:
      comments:
        giscus:
          ariaLabel: Comments
          errorMessage: Comments could not be loaded. Please try again later.
  zh:
    params:
      comments:
        giscus:
          ariaLabel: 评论
          errorMessage: 评论加载失败,请稍后重试。

A language layer only needs the differences; repo / repoId / category / categoryId stay in params.comments.

Following light and dark

With theme: auto, the giscus iframe follows OINK’s light/dark control and the browser’s prefers-color-scheme, so the comment section changes with the rest of the page.

For a closer match to the site’s palette, give lightTheme / darkTheme two giscus themes; each value is a built-in giscus theme name or CSS hosted by the site. This site does the latter:

hugo.yml
params:
  comments:
    giscus:
      theme: auto
      lightTheme: /css/giscus-oink-light.css?v=0.4.0
      darkTheme: /css/giscus-oink-dark.css?v=0.4.0

A fixed theme name in theme stops it following the toggle.

A custom giscus theme has to be readable cross-origin

The giscus iframe loads from giscus.app, so reading a CSS file on your site requires CORS to allow it. This site adds Access-Control-Allow-Origin: '*' under server.headers in hugo.yml for local preview; in production it is the host’s response header configuration.

Privacy and CSP

  • OINK never asks for or stores a reader’s GitHub password or access token; signing in and posting happen entirely on the giscus / GitHub side.
  • The comment initialization script is a same-origin asset shipped with the theme, added only to pages with comments enabled; a page without them has no such script.
  • With loading: lazy, the iframe loads only as the reader scrolls near the comment section.
  • Where a site has a strict Content Security Policy, both script-src and frame-src must permit giscus — merged into the existing policy rather than replacing other directives (the general rules are in Content Security Policy):
CSP fragment
script-src 'self' https://giscus.app;
frame-src 'self' https://giscus.app;

When the external script fails to load or no iframe is created, OINK ends the loading state and shows errorMessage in a live status region rather than leaving the page on “loading”.

Verify

Terminal
hugo --minify --panicOnWarning     # a missing required key fails here
hugo server --disableFastRender

Then confirm each of these:

  1. Open a page that should have comments: giscus appears at the bottom, showing “Sign in with GitHub”, with its interface in the current page’s language.
  2. Toggle OINK’s light/dark control and the comment section follows (with theme: auto).
  3. Open a page with comments: false and confirm there is neither giscus nor any other comment component.
  4. Post a test comment, return to GitHub, and confirm a Discussion appeared in the chosen category and can be managed there.

Before the first comment or reaction creates a Discussion, a browser console message saying the Discussion was not found is expected.

When something is wrong, check in this order: WARNINGs in the build log (the four required keys) → params.comments.enable and type → the page’s comments front matter → whether the repository is public, Discussions are enabled and the giscus App is installed → the browser console and response headers (whether a CSP blocked giscus.app). If existing threads have gone missing, restore the original mapping and page path first.

4 - Analytics and SEO

Connect an analytics service (or none), and pair up the canonical, hreflang, social cards, sitemap and robots the theme already generates.

The theme loads no analytics, form or advertising script by default, and makes no outbound request until configured. Connecting one takes explicit configuration, and that external data boundary belongs in the site’s privacy statement. SEO is the opposite: canonical, hreflang, the robots meta, Open Graph and Twitter cards are generated per page by the theme, and what you have to get right is baseURL and each page’s description.

Connecting Google Analytics

Use Hugo’s built-in service configuration with a GA4 measurement ID:

hugo.yml
services:
  googleAnalytics:
    id: G-6JLQEHYFQG

The theme renders that script in the production environment only (a hugo build defaults to production, and hugo server to development). Local previews and preview deployments therefore report nothing, and need no extra switch.

Do not also set the deprecated top-level googleAnalytics key. Where analytics is not wanted, delete the block rather than filling in a fake ID.

This is incompatible with a network-isolated environment

Once configured, page views and events go to Google. A strict same-origin Content Security Policy also has to permit it — see Content Security Policy. This is a site decision, not a theme default.

Connecting another analytics service

Plausible, Umami, Matomo and the like need only a script inserted. The theme provides two injection points; create a file of the same name in the site repository and no theme change is needed:

layouts/_partials/hooks/head-end.html , Insertion pointBefore </head>, ahead of the Google Analytics template
Analytics scripts, cookie consent scripts, meta tags the theme does not provide
layouts/_partials/hooks/body-end.html , Insertion pointLast among the page scripts
Third-party code affecting interaction rather than the first paint
layouts/_partials/hooks/head-end.html
{{ if hugo.IsProduction }}
<script defer data-domain="oink.pgsty.com"
        src="https://plausible.io/js/script.js"></script>
{{ end }}

Do not omit the hugo.IsProduction guard: without it, everyone’s local preview reports into your analytics.

head-end runs before Google Analytics

That is deliberate: a cookie consent script has to run before the analytics script to actually hold it back.

The “was this page helpful?” feedback widget is a separate matter: off by default, making no network request, and configured in Repository links and page info.

Page descriptions

<meta name="description"> takes the first non-empty value of:

  1. The page’s description front matter
  2. The page summary Hugo computes (.Summary)
  3. params.description in the site configuration

Writing one description per page is the only SEO action an author has to take. It serves three purposes at once: the search engine snippet, the card subtitle on a section index, and the result preview in site search.

content/docs/admin/analytics.md (this page)
---
title: Analytics and SEO
description: Connect an analytics service (or none), and pair up the canonical, hreflang, social cards, sitemap and robots the theme already generates.
---

A multilingual site writes one per language; do not copy the English description onto a Chinese page. The site-level default is per language too:

hugo.yml
languages:
  en:
    params:
      description: A Hugo theme for engineering docs
  zh:
    params:
      description: 为工程而设计的 Hugo 文档主题

canonical and hreflang

The theme emits one canonical and a set of hreflang alternates per page, with no configuration:

rendered output (this page)
<link rel="canonical" href="https://oink.pgsty.com/docs/admin/analytics/">
<link rel="alternate" hreflang="en-US" href="https://oink.pgsty.com/docs/admin/analytics/">
<link rel="alternate" hreflang="zh-CN" href="https://oink.pgsty.com/zh/docs/admin/analytics/">

The hreflang codes come from each language’s locale (en-US / zh-CN on this site), and the links from Hugo’s translation relationships. Where a page has no counterpart in the other language, Hugo cannot find a translation and falls back to that language’s home page. That is expected behaviour, and it also tells you whether Hugo recognized the translation pairing.

The canonical is assembled from baseURL. A wrong baseURL points search engines at addresses that do not exist, which is harder to notice than a build failure. Run through the deployment checklist before launching.

Full multilingual configuration is in Languages.

Social cards

The theme calls Hugo’s built-in Open Graph and Twitter card templates, and the title, description, URL, language and site name are all automatic:

rendered output (this page)
<meta property="og:title" content="Analytics and SEO">
<meta property="og:type" content="article">
<meta property="og:url" content="https://oink.pgsty.com/docs/admin/analytics/">
<meta property="og:locale" content="en_US">
<meta property="og:locale:alternate" content="zh_CN">
<meta name="twitter:card" content="summary">

To give a shared link an image, set images in front matter:

any page
---
title: OINK v0.6.0 released
images: [/images/releasenote.webp]
---

For a site-wide fallback, write the same key under params:

hugo.yml
params:
  images: [/images/oink.webp]

With an image, twitter:card changes from summary to summary_large_image and og:image and twitter:image appear. This site sets neither, which is why the rendered output above has no image tags.

Sitemap

Hugo generates it automatically, and a multilingual site gets an index:

the structure under public/
sitemap.xml        ← the index, pointing at the two below
en/sitemap.xml
zh/sitemap.xml

Both the site default and per-page overrides are Hugo’s own:

hugo.yml
sitemap:
  changefreq: monthly
  filename: sitemap.xml
  priority: 0.5
one page
---
title: Release notes
sitemap:
  priority: 0.8
---

changefreq and priority are hints rather than promises, and a search engine may ignore them. What is worth doing before publishing is confirming that drafts, private content and non-canonical copies stayed out of the sitemap, and that each language’s file was generated.

robots.txt and staying unindexed

Hugo generates robots.txt only when the site configuration turns it on:

hugo.yml
enableRobotsTXT: true

The template the theme supplies gives two results by build environment, with no content for you to write:

a production build
User-agent: *
Allow: /

Sitemap: https://oink.pgsty.com/sitemap.xml
a non-production build
User-agent: *
Disallow: /

The robots meta in the page follows the same switch: index, follow in production and outside print output, noindex, nofollow otherwise. Do not build preview deployments with --environment production; a non-production build declines indexing by itself.

The theme has no per-page noindex switch. Where a page should not be indexed, the reliable answer is not to publish it (draft: true, or Hugo’s _build options). To publish it and still keep it out, emit your own tag through the head-end.html hook; the theme already emits one robots meta, and how a search engine reconciles two is its own decision.

Checking indexing

A week or two after launch, confirm in this order that what search engines see matches what you think:

  1. Crawl permission: open <baseURL>/robots.txt and confirm Allow: / rather than Disallow: /.
  2. Page inventory: open <baseURL>/sitemap.xml, follow into a language sitemap, and check the page count.
  3. Indexed count: search site:yourdomain and check the order of magnitude; a page-by-page reconciliation is not needed.
  4. Canonical addresses: results should land on the canonical URL, not a version with a ? parameter or an old domain.
  5. Active submission: add the site in Google Search Console / Bing Webmaster Tools and submit the sitemap.xml address, which is faster than waiting to be crawled.

Search metadata cannot compensate for the content itself: a thin, duplicated or stale page stays that way however well its description is written.

Verify

Terminal
hugo --gc --minify --printPathWarnings --panicOnWarning

Then check these in the output:

Terminal
# the canonical points at the real production address
grep -o '<link rel="canonical"[^>]*>' public/docs/admin/analytics/index.html

# only a production build has index, follow
grep -o '<meta name="robots"[^>]*>' public/docs/admin/analytics/index.html

# robots.txt and the sitemap
cat public/robots.txt
head -5 public/sitemap.xml

# with no analytics connected, the output should have no gtag / analytics request
grep -rl 'googletagmanager\|gtag(' public/ | head

Confirm once more in a browser: open a representative page and look at the network panel — a site with no analytics should make no request to a third-party domain.

  • DeploybaseURL, the checklist, and keeping preview deployments unindexed
  • Repository links and page info — the feedback widget, edit links and last-modified time
  • Languages — language configuration decides hreflang and translation pairing
  • AI-agent support — the .md output and llms.txt written for models
  • Configurationservices, sitemap, enableRobotsTXT and the rest

5 - Upgrade

Move to a new theme version, convert 0.4 shortcodes to v5 syntax with the migration toolkit, migrate from Docsy, and roll back when something goes wrong.

Upgrading OINK is changing one pinned module version and confirming the site still builds warning-free. Most content needs no change; where it does — 0.4 shortcodes becoming v5’s native Markdown forms — a dry-run-first migration tool does it, so hundreds of files need not be edited by hand.

An upgrade changes rendered output. Create an upgrade branch before starting, and the cost of backing out is discarding a branch.

Read the release notes first

Every version’s changes, breaking changes and upgrade notes are in its release notes; read the target version’s before upgrading:

The notes say whether content has to change, whether a configuration key was removed, and whether a default behaviour moved. Skipping this step means guessing afterwards why a page looks different.

Upgrading the Hugo Module

A production site pins a release tag or an immutable commit, follows no branch, and does not use @latest:

Terminal
hugo mod get github.com/pgsty/[email protected]   # the tag from the release notes
hugo mod tidy
hugo mod graph | grep github.com/pgsty/oink

The last command must show that tag itself resolving, not a pseudo-version (v0.0.0-2026...-abcdef) or main. The pinned version lands in go.mod and is committed with the code:

go.mod
module github.com/pgsty/oink.pgsty.com

go 1.26.6

require github.com/pgsty/oink v0.6.0
A local module replacement overrides that pin

make dev and make check set HUGO_MODULE_REPLACEMENTS for that command only, using the sibling theme checkout. To judge whether a release tag works, use make build without a replacement; otherwise what is verified is the local copy.

One line for each other install method. Git submodule: fetch the new ref with git submodule update --remote themes/oink and commit the submodule pointer. Offline archive and clone: replace themes/oink/ wholesale with the new version’s unpacked tree, and confirm theme: still matches the directory name. Weighing the three is in From scratch and other install methods.

What to do after upgrading

Terminal
rm -rf public resources/_gen
hugo --gc --minify --printPathWarnings --panicOnWarning --logLevel info

That does three things at once: clears possibly stale caches, rebuilds with the new version, and turns any warning into a failure.

--logLevel info is there to surface Hugo’s deprecation notices. Hugo deprecates in two stages: first a WARN (still usable), then an ERROR in the next version (the build fails). Carrying --panicOnWarning finds them a version early and leaves you the time to fix them.

Once the build passes, look with your own eyes: the home page, a documentation page, a blog page, the 404, both languages, both colour schemes, the print view, and anywhere the site customized something.

The content migration toolkit

A batch of 0.4 shortcodes became native Markdown forms in v5. The theme repository ships a tool for that, depending only on the Python standard library:

Terminal
git clone https://github.com/pgsty/oink
cd oink

# 1. read-only inventory: what several sites would change, exportable as Markdown / JSON
python3 bin/migrations/oink06.py report --sites ~/pgsty/oink.pgsty.com ~/www/ddia --md report.md

# 2. dry run: prints a diff and counts per file, writing nothing
python3 bin/migrations/oink06.py migrate --site ~/pgsty/oink.pgsty.com

# 3. apply: written atomically
python3 bin/migrations/oink06.py migrate --site ~/pgsty/oink.pgsty.com --write

# 4. check for residue: exit code 1 while legacy syntax remains
python3 bin/migrations/oink06.py check --site ~/pgsty/oink.pgsty.com

Four things to remember while using it:

  • A dry run is the default, and only --write touches disk. Dry-run, read the diff, then write.
  • A second run should change nothing. A second --write still reporting changes means a transformation is not converging; stop and look at those files.
  • Text inside fences is untouched, so a documentation site demonstrating the old syntax is not damaged.
  • A construct it cannot express is left as it stands and listed with file:line and a reason, as a manual work list rather than a failure.

To convert one class first, use --only with the keys in the table’s last column:

Terminal
python3 bin/migrations/oink06.py migrate --site ~/www/ddia --only callout,tabs --write

Rebuild afterwards (with --panicOnWarning) and look at the rendered pages: the tool guarantees correct syntax, not that the meaning is what you intended.

The 0.4 → v5 syntax map

{{%/* alert color= title= */%}}, {{%/* details */%}}, {{%/* pageinfo */%}}, hand-written <details><summary> , The v5 form> [!TYPE] Title / > [!DETAILS]-
callout
{{</* tabpane */>}} + {{%/* tab header= */%}}, {{</* code-group */>}} + {{</* code-tab */>}} , The v5 formAdjacent fences with {tab= group= value=}; tabs in running text use {{</* tabs */>}} + {{</* tab */>}}
tabs
{{</* filetree */>}} with filetree/folder and filetree/file , The v5 formThe filetree data fence
filetree
{{</* gallery */>}} with gallery/image , The v5 formThe gallery data fence
gallery
{{</* echarts */>}}, {{</* infographic */>}} , The v5 formData fences of the same name ($fn: is unchanged; a js subfence moves to window.OinkEchartsFunctions)
datafence
doc-cards / doc-card, nav-cards / nav-card, card / cardpane, doc-carousel , The v5 form{{</* cards */>}} + {{</* card */>}}, or a link list with {.cards}
cards
{{</* imgproc */>}}, {{</* image */>}} , The v5 form![alt](src) with the attribute line {command= options= caption=}
image
{{</* readfile file= */>}} , The v5 form{{</* include file= */>}}
include
The fence attribute {filename="x"} , The v5 form{title="x"}
fencetitle
{{</* badge outline= */>}} , The v5 formDrop the outline parameter
badge
{{</* example */>}} + a fence, {{</* book-figures kind="tbl" */>}} , The v5 form{{</* eg */>}}…{{</* /eg */>}}, {{</* book-tables */>}}
eg
{{%/* _param x */%}}, iframe, conditional-text, blocks/*, netlify, a kindless xref , The v5 formReported only; handle by hand
reportonly

What each new form looks like and what parameters it takes is on its page under Components.

Migrating from Docsy

OINK is a hard fork of Docsy: the content model, the td- naming, the Sass variables and most front matter are still there. The core of a migration is deleting the copies of the shared shell in the site and letting the theme’s implementation take over — not rewriting the prose.

  1. Pin the target version. Change go.mod to an OINK release tag, or use a complete versioned archive. During evaluation, an uncommitted go.work can point at a local checkout.

  2. Inventory the overrides. Sort every site-level file under layouts/, assets/ and static/ into four classes: copies of the shared shell (delete after verifying), components OINK already provides (delete or rename mechanically), brand customization (keep, reduced to the smallest hook), and business-specific data and interaction (stays in the site). Delete by reference order, and do not empty layouts/ at once: the home page and download page may still call a partial you are removing.

  3. Move the configuration. title, languages.*, github_repo, github_branch, page_width and params.ui.* all stay in their existing semantic positions; OINK opens no namespace of its own. Search and the logo are just keys to turn on:

    hugo.yml
    params:
      logo: img/product.svg
      offline_search: true

    Docsy’s camelCase search keys have been renamed in OINK: offlineSearch, offlineSearchIndex, offlineSearchMaxResults, offlineSearchOnServe and offlineSearchSummaryLength all become their underscored forms. Rename them deliberately — the migration registry that used to stop the build and name the replacement has been removed, so an old key is now simply a key nobody reads, and search stays off with no message at all.

  4. Fonts and styling compatibility. The Docsy Sass variables in the site’s assets/scss/_variables_project.scss still work as the seed values for the font roles, and need not be deleted to upgrade: $td-fonts-serif, $font-family-sans-serif, $headings-font-family and $font-family-code each feed their role. Docsy’s Google Fonts switches $td-enable-google-fonts, $td-google-font-name and $td-web-font-path are no longer read by the theme; leaving them breaks nothing and does nothing, because OINK ships Inter, Chakra Petch and IBM Plex Mono and neither preset requests anything from Google Fonts. To change fonts, go through the token layer — see Brand and appearance.

  5. Convert the shortcodes. Docsy’s alert, pageinfo, tabpane and card families all have a v5 counterpart; convert them in bulk with the migration toolkit above, one --only class at a time.

  6. Delete one group at a time, building after each. Rehearse on a scratch copy, recording the theme commit, the Hugo version, which files were removed and how many HTML files came out; only after confirming equivalence, repeat it on the production branch.

The “delete after verifying” class in step two is usually these files:

  • layouts/baseof.html and the shared docs / blog baseof*.html;
  • The navbar, footer, sidebar, TOC, search and head CSS partials and their hooks;
  • The old brand documentation shell partials;
  • Copies of the asciinema, echarts, infographic, doc-carousel, details, tab / tabpane, card and param shortcodes;
  • The JavaScript, Lunr copy, carousel code and SCSS that served only those implementations;
  • PostCSS and Autoprefixer steps no site asset needs any more.

Two kinds of problem surface after the deleting.

A site’s own script reports $ is not defined: the theme does not bundle jQuery, which Docsy used to load in every page’s <head>. Nothing in the theme needs it, and a site that still does loads it itself:

layouts/_partials/hooks/head-end.html
<script src="{{ (resources.Get "js/jquery.min.js").RelPermalink }}"></script>

A home page built from Docsy’s blocks/* fails the v5 build with template for shortcode "blocks/cover" not found: the theme has no such shortcode family. Switch to home page sections in data/home/<language>.yaml, or give the page layout: landing — see Home and landing pages.

Upgrading from 0.4

0.4 changed several defaults. If the page gained or lost something after the upgrade, check these first:

  • Sequential paging is on by default. docs, book and blog pages all have previous / next at the page end; documentation follows the sidebar tree and the blog follows time. A page deliberately outside any sequence opts out with pager: false.

  • The navbar shows on every layout. Its compact state is one row of icon navigation, with no second mobile accordion menu, so local scripts and tests that depend on the old mobile menu have to go. A whole section without a navbar uses navbar_enabled: false in a cascade.

  • The footer defaults to fat site-wide. Only fat / slim / none are accepted, and footer data must live in data/footer/<language>.yaml (or data/footer.yaml on a single-language site); a leftover footer key in data/home fails the build with the new location.

  • Single-key navigation is on by default: / opens full search and \ command-only mode. Training material describing the old behaviour needs updating. Page actions have also moved to a split button beside the breadcrumbs.

  • The code block DOM changed. A .td-code wrapper now encloses the original .highlight (both .highlight and .chroma are kept), so a direct child selector such as .td-content > .highlight in site CSS becomes the descendant selector .td-content .highlight.

  • Two ICP footer parameters were removed: footer_icp and footer_icp_url became one string accepting inline Markdown.

    hugo.yml
    params:
      footer_center_info: '[京ICP备00000000号](https://beian.miit.gov.cn/)'
  • Mathematics needs the site to enable passthrough. Hugo does not merge a theme’s markup configuration, so a site using \(…\), \[…\] or $$…$$ must enable the Goldmark passthrough extension in its own hugo.yml — see Math.

The complete configuration for all of these is in Configuration and Layouts and page types.

Verify

An upgrade is not finished at “the build passed”. Look at each surface:

Documentation / Book
Sidebar order, paging, headings, page actions, numbering and cross-references
Blog
Chronological paging, RSS ownership, navbar and footer
Home / landing
Content without JS, the compact menu, print
Release pages
Derived download URLs, checksums, publication state
Components
One page each for the components the site uses most
Accessibility
A keyboard-only pass, focus order, both colour schemes, forced-colors mode
Deployment
Internal links and assets all keep the base path prefix

This site’s full gate is:

Terminal
npm test           # build assertions, Markdown and favicon goldens, translation parity, rendered links
npm run test:browser   # Playwright: accessibility, responsive shell, keyboard navigation, content components, code blocks, scenario components

Another site runs the equivalent build, link, output and browser checks; the details are in Troubleshooting.

A successful local build is not a completed release

The source building, the tag being signed and resolvable through the Go proxy, the site pinning that tag, and production being deployed are four things, each recorded separately. Do not let one green local build stand in for them.

The last step happens in the real environment: deploy a preview, verify the pages and the browser’s network requests on the real URL, merge once reviewed, and smoke-test production afterwards.

Rollback

What rolls back is the version pin, not the working tree:

Terminal
hugo mod get github.com/pgsty/[email protected]   # the last known-good tag
hugo mod tidy
rm -rf public resources/_gen
hugo --gc --minify --panicOnWarning

Three principles:

  • Keep the pre-upgrade module pin, the site commit and the known-good deployment artifact, and restore all three together.
  • Do not roll back only part of it. Putting a few old layout copies back on top of a new theme produces a hybrid harder to diagnose than either complete version.
  • Keep the upgrade branch and its acceptance evidence. A rollback restores production first; it does not throw away the work already done.

Rolling back the deployed output itself (republishing the previous deployment) is in Deploy.

6 - Troubleshooting

Symptom → cause → fix for the four fault classes — build, language, search, platform — plus the checks a site can run for itself.

When something goes wrong, run a clean production build first and read from the first error; the ones after it are usually cascades:

Terminal
rm -rf public resources/_gen
hugo --gc --minify --printPathWarnings --panicOnWarning --logLevel info

An npm, PostCSS, Autoprefixer or browser-asset download step in the log means upstream Docsy’s process has crept into the configuration. A consuming OINK build is one Hugo command.

The four tables below are organized as symptom → cause → fix. Find the symptom row; there is no need to read from the top.

Build

Symptom Cause Fix
The build demands a newer Hugo The standard build is installed rather than Extended, or the version is below 0.160.1 hugo version output must contain extended. With several Hugos installed, check PATH and any version pinning before installing another
module "github.com/pgsty/oink" not found The theme did not resolve Hugo Module: check hugo mod graph, go.mod, go.sum, and any stray workspace or replace. Submodule: does CI run git submodule update --init before Hugo. Archive / clone: theme: must match the directory name under themes/
Module download hangs or times out The Go module proxy is unreachable Hugo pulls modules through Go, so GOPROXY applies. In mainland China, export GOPROXY=https://goproxy.cn,direct; in an isolated environment, use an offline archive or commit themes/oink/
{.cards}, {.steps}, {caption=…} appear as literal text The site has not enabled Goldmark block attributes The three settings below must be in the site’s own hugo.yml; Hugo does not merge a theme’s markup configuration
An image with an attribute line is wrapped in <p> and the caption does nothing wrapStandAloneImageWithinParagraph: false is missing As above; add all three together
Inline HTML is escaped into text renderer.unsafe: true is missing As above
\(…\) $$…$$ display literally The site has not enabled Goldmark passthrough See Math; math: true is not the switch
shortcode "tabs" must be closed or self-closed A {{< tabs >}} has no matching {{< /tabs >}} The error carries file:line:column; add the closing marker there
template for shortcode "tabs" not found The body calls a shortcode that does not exist, or quotes shortcode syntax without escaping it Documentation that explains shortcode syntax must escape it: add /* and */ inside the opening and closing markers so Hugo treats it as text rather than a call. A misspelled name is simply corrected
... attributes: unknown attribute "witdh" at ... An attribute-line key is misspelled or not permitted An attribute line accepts that component’s allowed keys plus class, data-* and aria-*; style and on* always fail the build. The allowed keys are in the error’s parentheses
shortcode "field": unsupported parameter "colour" at ... A shortcode parameter name is wrong A component parameter — a shortcode parameter or an attribute-line key — always fails the build and never degrades silently. The error is always “which shortcode → which parameter → which file and line”
invalid params.ui.page_width "widee" (allowed: normal | wide | full) -- using "normal" A configuration or front matter value is not one of the accepted ones Configuration degrades instead of stopping, so one typo does not serve HTTP 500 on every URL under hugo server. The message names the key, the value and the fallback used. Build with --panicOnWarning and it cannot ship
A page setting has no effect and nothing is reported The key was written inside a ui: block in front matter Page keys sit at the top level of the front matter — the site key with ui. dropped. A ui: block there is read by nobody and reported by nobody; see Page parameters
The build passes but production is missing something A WARNING nobody read Add --panicOnWarning to the build command. An invalid configuration value, a missing required giscus key, an unsupported comments.type and Hugo’s deprecation notices are all warnings

The three Goldmark settings:

hugo.yml
markup:
  goldmark:
    parser:
      wrapStandAloneImageWithinParagraph: false
      attribute:
        block: true
    renderer:
      unsafe: true

The two commonest shortcode errors look like this; note the trailing file:line:column:

build output
ERROR error building site: assemble: failed to create page from pageMetaSource /a:
  "…/content/docs/x.md:4:1": failed to extract shortcode:
  shortcode "tabs" must be closed or self-closed

ERROR error building site: assemble: failed to create page from pageMetaSource /a:
  "…/content/docs/x.md:4:5": failed to extract shortcode:
  template for shortcode "tabs" not found

Language

Symptom Cause Fix
A translated page does not appear Four possibilities, in order hugo.yml has languages.zh with a weight; ② the filename is page.zh.md, with zh lowercase; ③ the translation’s front matter has no draft: true and no future date; ④ routing metadata matches the source file
Switching language lands on the home page Hugo found no translation This is by design: with no translation it falls back to the target language’s home page. Landing on the corresponding page requires that translation file to exist
An anchor link opens the page but does not scroll The translated heading text differs, so the generated ID does too Write the English ID explicitly on the translated heading: ## 安装 {#installation}. Where a heading contains a shortcode or inline HTML, do not guess the ID from the text — read the English page’s rendered HTML
Menus / home page sections are untranslated They are not in pages but in configuration and data files Menus are in languages.<lang>.menus, home sections in data/home/<lang>.yaml, interface strings in i18n/<lang>.yaml — see Languages
A Chinese page’s hreflang points at the English home page That page has no English counterpart Add the English page, or accept the fallback: it doubles as a probe for whether Hugo recognized the pairing
Symptom Cause Fix
A search box that never returns results No index was generated With params.offline_search: true, the output root should have offline-search-index.<language>.json, one per language. Its absence means it is not enabled
The index file 404s A wrong baseURL On a subpath deployment, a wrong baseURL is the commonest cause of a 404 index. Look in the browser’s network panel to see where it fetches the index — see Deploy
Search fails under hugo server but works in a build The site turned the preview index off params.offline_search_on_serve defaults to true, so preview matches production; an explicit false skips index generation during preview — remove it or set it back to true
Chinese queries find nothing Usually not a tokenization problem A CJK query uses the theme’s substring fallback. First confirm the Chinese page’s content reached the Chinese index (open offline-search-index.zh.json), then consider tokenization
A new page is not found while old ones are The index is build output Rebuild. Under hugo server, wait for the rebuild after editing
params.search.algolia requires explicit appId, apiKey, and indexName values The three Algolia keys are incomplete All three must be given explicitly; the theme will not use another project’s DocSearch credentials. If Algolia is not wanted, delete the block
The command palette finds no content It and full-text search are two things With the index unavailable the palette still opens, saying so, while page actions and commands work as usual — see Command palette

Platform

Symptom Cause Fix
A 404 or missing styles on GitHub Pages A project site’s URL carries the repository path and baseURL does not Use --baseURL "${{ steps.pages.outputs.base_url }}/" from the workflow rather than hard-coding it. The full workflow is in Deploy
“Last modified” and contributors are empty on GitHub Pages The checkout is shallow Add fetch-depth: 0 to actions/checkout: enableGitInfo needs the full history
A Cloudflare Pages build says Hugo is too old The build image’s default Hugo is older than the theme requires Set HUGO_VERSION in both the Production and Preview environments, and set SKIP_DEPENDENCY_INSTALL=1
The host’s build cannot fetch the theme The build environment has no Go Hugo Modules need Go. Where a platform does not provide it, use a submodule or commit themes/oink/
CI output differs from local go.work took part in the CI build Set GOWORK: off and HUGO_MODULE_WORKSPACE: off in CI so it reads only the version pinned in go.mod
A preview deployment got indexed The preview was built in the production environment too Do not pass --environment production for previews; a non-production build carries noindex and Disallow: / — see Analytics and SEO
macOS reports too many open files Live preview watches more files than the shell limit allows Exclude generated and irrelevant directories from the watch first — usually the real cause — and only then consider ulimit -n
Slow, or missed changes, under WSL Working across a Windows mount point Let Hugo work on paths inside the Linux filesystem; cross-filesystem change notification and permission behaviour break live reload
Bootstrap / Font Awesome / Lunr / Mermaid assets are missing An incomplete distribution Do not paper over it with a CDN URL. Confirm assets/third_party/, assets/js/third_party/, static/webfonts/ and VENDOR.json are all present, and re-fetch the same pinned version if one really is missing

Checks a site can run

Beyond the build itself, a site can run these. The first two work on any OINK site; the rest are this repository’s npm scripts, and another site runs the equivalent.

A zero-warning build , Commandhugo --printPathWarnings --panicOnWarning
Duplicate output paths, invalid parameters, incomplete external integrations
Output trust check , Commandpython3 bin/check-output-security.py --public public --base-url https://oink.pgsty.com/
Every href / src in all four outputs is site-relative or http(s) / mailto / tel; no javascript: URL and no inline on* handler; a cross-site <iframe>, <script> or <img> needs an explicit --third-party
Translation parity , Commandnode scripts/check-doc-translations.mjs --public public
Whether each English page has a Chinese counterpart, and whether the rendered heading IDs line up; misaligned anchors surface here
The full gate , Commandnpm test
Runs the six below in sequence

What each of the six covers:

  • test:base — builds once, then runs the Markdown style, translation parity, rendered Markdown and link checks.
  • test:hugo-build — build assertions: blog metadata, RSS, content components, and a deprecation-free build.
  • test:md-output — byte-level golden comparison of the Markdown and llms.txt output. Changing a component’s Markdown shape fails here.
  • test:alt-site — builds once per alternate configuration in tests/fixtures/*.yml, confirming the combinations still come up.
  • test:favicons — golden comparison of the head output.
  • test:release-pin-contract — whether the version the site advertises matches the one pinned in go.mod.

Browser behaviour is a separate suite: npm run test:browser runs the Playwright accessibility (axe WCAG AA), responsive shell, keyboard navigation, content component, code block and scenario component suites in turn.

check-output-security.py lives in the theme repository

It sits under the theme’s bin/, is a product-level trust check any OINK site can run, and depends on no site test framework. Clone the theme repository and point it at your own public/; the arguments and usage are in Verifying an offline build.

Diagnostic habits

For problems the tables do not cover, dig along these lines:

  • Reproduce with a pinned Hugo Extended version rather than judging in an environment where the version floats.
  • Clear public/ and resources/_gen and rebuild, to rule out stale caches.
  • Compare the development and production configuration layers; many production-only problems are environment differences.
  • Read the first error, not the last.
  • Separate “theme behaviour” from “site override” with a minimal page: isolate the suspect content on its own page and re-enable site overrides in batches until one is implicated.
  • Look at the failing page’s browser console and network panel, especially the paths of any 404 resources.

Getting help

Opening an issue with these saves a round trip: the Hugo version (the full hugo version output), the theme version (hugo mod graph | grep oink), the first complete error, and a minimal page or site that reproduces it.

  • Local preview — clean builds, clearing caches, containers and workspaces
  • DeploybaseURL, the checklist and rollback
  • Upgrade — problems an upgrade introduces, and the migration toolkit
  • Search — index scope, ranking and Algolia
  • Languages — language configuration and the anchor alignment process