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/site-checks.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.165.0
ARG GO_VERSION=1.27.0
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
Local overrides can mask the published version

CI and production builds can still inherit replacements or workspaces. Do not commit a workspace containing developer-machine paths. To verify a published tag, unset HUGO_MODULE_REPLACEMENTS and set both GOWORK=off and HUGO_MODULE_WORKSPACE=off. Also inspect persistent replacements in go.mod and Hugo configuration, and any _vendor/ copy; confirm the exact version in hugo mod graph under the same environment before building.

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.

For the last step, use the output checker from a theme checkout matching your pinned release. It needs Python 3, not this documentation site’s test framework. Replace both absolute paths below and use the same base URL as the build:

Terminal
python3 /path/to/oink/bin/check-output-security.py \
  --public /path/to/my-site/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: /.
  • With local search enabled, each language has an index in public/: production filenames are offline-search-index.<language>.<hash>.json, while development omits the hash. Open search and confirm the actual data-td-index-src URL returns 200.
  • 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.

With local search enabled, open search and inspect its index request in the browser’s Network panel. It must use the correct language and deployment subpath and return 200. The page’s data-td-index-src attribute supplies the actual URL: production filenames are offline-search-index.<language>.<hash>.json; development filenames have no hash. Do not test a guessed filename.

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. OINK Starter already includes the file below; copy it only when assembling a site manually.

.github/workflows/github-pages.yaml
 1name: Deploy 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: github-pages
15  cancel-in-progress: false
16
17env:
18  HUGO_VERSION: 0.165.0
19  # a workspace from a sibling checkout must never take part in a CI build
20  GOWORK: off
21  HUGO_MODULE_WORKSPACE: off
22  HUGO_CACHEDIR: ${{ github.workspace }}/.hugo_cache
23
24jobs:
25  build:
26    name: Build Pages artifact
27    runs-on: ubuntu-latest
28    steps:
29      - name: Check out source
30        uses: actions/checkout@v7
31        with:
32          fetch-depth: 0
33
34      - name: Set up Go
35        uses: actions/setup-go@v7
36        with:
37          go-version-file: go.mod
38          cache-dependency-path: go.sum
39
40      - name: Configure GitHub Pages
41        id: pages
42        uses: actions/configure-pages@v6
43
44      - name: Install Hugo Extended
45        run: |
46          curl --fail --location --silent --show-error \
47            --output "${RUNNER_TEMP}/hugo.deb" \
48            "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb"
49          sudo dpkg -i "${RUNNER_TEMP}/hugo.deb"
50
51      - name: Download OINK
52        run: go mod download github.com/pgsty/oink
53
54      - name: Build
55        run: |
56          hugo --cleanDestinationDir --gc --minify --environment production \
57            --printPathWarnings --panicOnWarning \
58            --baseURL "${{ steps.pages.outputs.base_url }}/"
59
60      - name: Upload Pages artifact
61        uses: actions/upload-pages-artifact@v5
62        with:
63          path: public
64
65  deploy:
66    name: Deploy
67    environment:
68      name: github-pages
69      url: ${{ steps.deployment.outputs.page_url }}
70    runs-on: ubuntu-latest
71    needs: build
72    steps:
73      - name: Publish
74        id: deployment
75        uses: actions/deploy-pages@v5

That is the workflow shipped by OINK Starter. 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.yaml 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.

OINK Starter ships .github/workflows/cloudflare-pages.yaml, a Direct Upload workflow. The strict build stays in GitHub Actions and Wrangler uploads the same public/ artifact to a Cloudflare Pages project.

  1. Create a Direct Upload Pages project. By default its name matches the repository; override it with repository variable CLOUDFLARE_PROJECT_NAME.
  2. Add repository secrets CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN. The token needs Account → Cloudflare Pages → Edit.
  3. Run Deploy to Cloudflare Pages manually once. Set repository variable CLOUDFLARE_PAGES_ENABLED=true to deploy every push to main.
  4. The canonical URL defaults to https://<project>.pages.dev/. Set CLOUDFLARE_SITE_URL when a custom domain becomes production.

The workflow pins Hugo Extended 0.165.0, reads Go from go.mod, disables local module workspaces, and builds with --panicOnWarning before upload. It is the recommended reproducible path for Starter users.

Cloudflare Git integration remains valid as a separate mode: configure build command hugo --gc --minify --printPathWarnings --panicOnWarning, output directory public, Hugo 0.165.0, and Go 1.27. Use Git integration or the Direct Upload workflow for one project, not both. A preview deployment is still not production proof; rebuild with its own URL and keep it unindexed.

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.165.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) — serve an unchanged copy of public/. The example below uses a current symlink to a release directory; create it with the offline packaging steps below. Set the host’s document root to that link when configuring Nginx or Caddy:

/etc/nginx/conf.d/docs.conf
server {
    listen 80;
    server_name docs.example.com;
    root /var/www/oink/current;
    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. Choose your own archive path and a new release name for every artifact. On the Linux host, these commands require write access to /var/www/oink and GNU mv; current must be absent or a symlink, and current.next must not already exist. Configure Nginx or Caddy to serve current, as above; this is a host setting, not a Hugo option.

Terminal
hugo --cleanDestinationDir --gc --minify --panicOnWarning --baseURL "https://docs.internal.example.com/" &&
  tar -czf oink-site-20260929-01.tar.gz -C public .

Only after the build and packaging succeed, transfer this archive to the Linux host and run the following there. Use a new release name for every artifact:

Linux host
release_dir=/var/www/oink/releases/20260929-01
mkdir -p /var/www/oink/releases
mkdir "$release_dir" &&
  tar -xzf oink-site-20260929-01.tar.gz -C "$release_dir" &&
  test -f "$release_dir/index.html" &&
  ln -s "$release_dir" /var/www/oink/current.next &&
  mv -Tf /var/www/oink/current.next /var/www/oink/current

The command chain switches current only after a new directory is created, extraction succeeds and index.html exists. Check the deployed pages afterward and retain the previous release directory for rollback. Never unpack a release over an existing release directory.

Build with the target environment’s baseURL from the start; changing it requires a new build.

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 shipped runtimes, fonts and icons are same-origin assets, but script-src 'self'; style-src 'self' alone does not cover an ordinary OINK page. The theme emits inline theme initialization and shell prepaint scripts, inline styles for the initial canvas, theme colors and font roles, and style attributes in some components. Markmap adds inline configuration and styles. The theme provides neither a general policy nor automatic CSP hashes or nonce injection; the deployment owns a policy derived from its actual built output.

Additional features 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.

Hash each permitted inline script/style block from the final deployed bytes, or have the hosting layer inject a fresh nonce into both the response policy and the corresponding tags. A nonce on a style tag does not authorize style attributes; review those separately under style-src-attr. Recheck hashes when content, configuration, minification, or the theme changes. Begin with Content-Security-Policy-Report-Only and exercise light/dark startup, shell state, menus, and every enabled component before enforcing it. An origin scan alone cannot establish CSP compatibility.

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
With local search enabled, the URL from data-td-index-src returns 200; production filenames contain a hash, 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
The primary and every enabled language root publish llms.txt where the site enabled LLMS under outputs.home
Enabled languages
Documentation, blog and home pages open in each, 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 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: point current back to the previous release directory from offline packaging. Do not overlay the old archive on the new files: paths added by the newer release would remain live.

On the Linux host, replace the example path with the retained known-good release. The same symlink and GNU mv prerequisites apply:

Terminal
previous_release=/var/www/oink/releases/20260928-01
test -d "$previous_release" &&
  ln -s "$previous_release" /var/www/oink/current.next &&
  mv -Tf /var/www/oink/current.next /var/www/oink/current

Check a representative old page at the public URL and confirm that a path added only by the rejected release is no longer served.

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
  • Configuration — baseURL, 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

Copy all four repository/category fields from the configuration generated for your own repository; replace every placeholder below before enabling comments:

hugo.yml
params:
  comments:
    enable: true
    type: giscus
    giscus:
      repo: YOUR_OWNER/YOUR_REPO
      repoId: YOUR_REPO_ID
      category: YOUR_CATEGORY
      categoryId: YOUR_CATEGORY_ID
      mapping: pathname
      inputPosition: bottom
      theme: auto
      loading: lazy

All four of repo, repoId, category and categoryId must describe the same repository and its selected Discussion category. They 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. Replace G-YOUR_MEASUREMENT_ID with your own GA4 measurement ID before enabling this block:

hugo.yml
services:
  googleAnalytics:
    id: G-YOUR_MEASUREMENT_ID

The theme renders that script only in the production environment. A normal hugo server uses development and does not report, but a hugo build defaults to production even on a preview host. Select a non-production environment for PR and staging deployments, with PREVIEW_URL set to their actual address:

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

See preview deployment settings.

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

For Plausible, replace your-site.example with the domain registered in your own account before adding this hook:

layouts/_partials/hooks/head-end.html
{{ if hugo.IsProduction }}
<script defer data-domain="your-site.example"
        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 per page and hreflang alternates for actual translations, 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. In the 1.2.0 implementation, a missing translation is omitted from both hreflang and og:locale:alternate. The visible language switcher may still lead to that language’s home page; that navigation fallback is not a translation.

Each paginated Blog index has its own canonical URL. From page 2 onward, language alternates are omitted because pagination does not establish matching translated pages. OINK 1.2.0 includes these corrections; 1.1.0 retains the earlier behavior.

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

Run from your site’s root. Replace PAGE with a generated page on your site, including any language prefix:

Terminal
hugo --gc --minify --printPathWarnings --panicOnWarning
PAGE=public/docs/getting-started/index.html
test -f "$PAGE"

# Canonical should use the real production address; production robots allow indexing.
grep -o '<link[^>]*canonical[^>]*>' "$PAGE"
grep -o '<meta[^>]*robots[^>]*>' "$PAGE"
cat public/robots.txt
head -5 public/sitemap.xml

# No match is expected when Google Analytics is not configured.
grep -o '<script[^>]*googletagmanager[^>]*>' "$PAGE"

In the browser’s Network panel, check that any configured analytics request uses your own measurement ID or registered domain. Repeat on a deployment built with --environment staging: it should make no analytics request. If no analytics is configured, there should be no analytics request in either environment; other explicitly enabled integrations may still use their own remote services.

  • Deploy — baseURL, 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
  • Configuration — services, sitemap, enableRobotsTXT and the rest

5 - Upgrade

Pin a published theme version, adopt the 1.2.0 changes, migrate legacy content or a Docsy site, and roll back safely.

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 the current 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 published release tag or a deliberately selected immutable commit, follows no branch, and does not use @latest. The example below upgrades to the published v1.2.0 tag. For a later release, verify its publication and module resolution before selecting that tag:

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

When selecting a tag, confirm that the module graph shows that exact version. A deliberately selected immutable commit is normally recorded as a Go pseudo-version; that is valid if it resolves to the intended commit, but it is not evidence of a named release. Commit the resulting go.mod and go.sum. For the published tag in this example, go.mod contains:

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

go 1.27.0

require github.com/pgsty/oink v1.2.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, remove that environment replacement and disable both GOWORK and HUGO_MODULE_WORKSPACE. Inspect persistent replacements and _vendor/ too; make build by itself does not prove which theme was resolved. See the preview guide.

For a Git submodule, check that it has no local edits, fetch the tags, and check out the exact published version rather than following its remote branch:

Terminal
git -C themes/oink fetch origin --tags
git -C themes/oink checkout --detach v1.2.0
git add themes/oink

Commit the updated submodule pointer after validation. For an offline archive or clone, replace themes/oink/ with the selected version’s complete tree and confirm that theme: still matches the directory name. The install-method tradeoffs are in From scratch and other install methods.

What to do after upgrading

Terminal
rm -rf public resources/_gen
env -u HUGO_MODULE_REPLACEMENTS GOWORK=off HUGO_MODULE_WORKSPACE=off \
  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 includes informational diagnostics, while --panicOnWarning treats warnings as failures. Review the deprecation messages emitted by the pinned Hugo version before upgrading it; the severity and removal schedule depend on the deprecated feature.

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.

Upgrading from 1.0 to 1.1

OINK 1.1.0 upgrade checklist

This checklist covers the published v1.1.0 release. Each consumer still needs to update its dependency pin, rebuild and deploy; theme publication does not upgrade an existing site automatically.

No source migration is required from 1.0.0. Hugo Extended 0.160.1 remains the floor; CI uses the pinned 0.165.0 toolchain. The module’s Go 1.27.0 directive is unchanged from 1.0.0. On Hugo 0.160.x, a non-default generic zh language alongside the regional Chinese catalogs needs locale: zh-CN.

Review the affected surfaces before choosing the new pin:

Languages
All 32 interface catalogs have the same native-message schema. Check the site’s language labels, plural counts, and RTL direction; authored translations remain the site’s responsibility.
Taxonomies
Root pages become term-card directories with a taxonomy switcher. Review any taxonomy-template or CSS overrides, author portraits, and localized breadcrumbs.
Sidebars
Cached trees preserve effective page settings and remain usable without JavaScript. Exercise collapse, hover restore, mobile drawer, and keyboard focus; hidden content must leave the focus order.
Groups
sidebar_divider: true retains a section’s children. Add build.render: never only when that group’s own outputs are intentionally omitted; verify child navigation, breadcrumbs, paging, Print, and Book contents.
Root menus
Explicit sidebar_root_menu: false now applies to self-root sections too. The current linkable root remains a location marker.
Custom scripts
Feature-detect OinkSidebar and OinkCommandPalette.registerSearchTail if an integration must also support 1.0.0. Restore branch state through the API rather than changing classes or ARIA attributes directly.
Copying articles
With image zoom enabled, copy an image and its caption as plain text and rich HTML. Preview instructions must not enter the copied article; zoom and its keyboard controls must still work.
Print and Redoc
Check page and Book aggregate Print, heading and tab links, and local Redoc specifications under the real deployment prefix. Local specification paths are rooted under static/.

params.ui.image_zoom and params.offline_search remain off by default. The new search hook does not enable a remote provider or add query telemetry. params.ui.scroll_spy and page-level scroll_spy remain accepted no-ops in 1.x; removing the obsolete patch does not disable normal outline tracking.

Update or remove affected site-level copies of theme code after comparing them with the new implementation. A copied old image-zoom script or sidebar partial will otherwise continue to hide the upstream fix.

To test local theme changes with the documentation site, use its sibling theme checkout without committing a filesystem replacement:

Terminal — from oink.pgsty.com
make check
make browser
make dev

These commands validate the local checkout. For release acceptance, pin the published version, build without a module replacement, then validate the deployed pages. The authoring and API details live in content groups, the sidebar contract, search actions, and image zoom.

Upgrading from 1.1 to 1.2

Default appearance change

Paper is the default in OINK 1.2.0. Set params.ui.preset: slate before adopting this change if the site must retain its existing appearance. preset_menu: true enables reader choice; its default remains false. Ink and Terminal require an explicit preset or menu list. Their buttons do not carry experiment badges; the configuration opt-in remains unchanged. Review custom dark brand selectors as described in Brand.

OINK 1.2.0 is available as a published tag. Apart from the default appearance change above, no content migration is required. Hugo Extended 0.160.1 remains the compatibility floor. Update the module, then verify the site:

  • Check the chosen preset, light/dark icons, keyboard and mobile menus, saved preferences, and custom font/accent overrides. Sun means light; moon means dark. Switching styles must not change the saved light/dark preference.
  • Recheck explicit navigation, hidden subtrees, page links, Blog pagination canonicals, and SEO alternates for pages without translations.
  • Check CJK keyword-only search summaries and outline links with literal percent signs. Compare source-derived Edit, History, and Create child links on Windows or mounted content; mappings must yield repository-relative paths.
  • Check Landing content with JavaScript disabled or blocked, preserved metric formatting, dialogs and keyboard shortcuts, copy fallback, Draw.io controls, and numbered equations at narrow widths.
  • Run a warning-fatal build for diagram endpoints and resource alt metadata; malformed values now produce a warning and use a safe fallback. To disable a PlantUML or Draw.io endpoint intentionally, use false or an empty string.
  • Use the revised PDF and migration tools when testing publication or content conversion. Review PDF remote-resource opt-ins and migration diffs, including code examples nested in lists. The consumer-upgrade helper ships with 1.2.0 for inventory, module updates and exact-version validation.

The Architecture, Components, Shell, and Migration contracts describe the published 1.2.0 behavior.

The content migration toolkit

A batch of 0.4 shortcodes became the current native Markdown forms. 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 → current syntax map

{{%/* alert color= title= */%}}, {{%/* details */%}}, {{%/* pageinfo */%}}, hand-written <details><summary> , The current form> [!TYPE] Title / > [!DETAILS]-
callout
{{</* tabpane */>}} + {{%/* tab header= */%}}, {{</* code-group */>}} + {{</* code-tab */>}} , The current formAdjacent fences with {tab= group= value=}; tabs in running text use {{</* tabs */>}} + {{</* tab */>}}
tabs
{{</* filetree */>}} with filetree/folder and filetree/file , The current formThe filetree data fence
filetree
{{</* gallery */>}} with gallery/image , The current formThe gallery data fence
gallery
{{</* echarts */>}}, {{</* infographic */>}} , The current 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 current form{{</* cards */>}} + {{</* card */>}}, or a link list with {.cards}
cards
{{</* imgproc */>}}, {{</* image */>}} , The current form![alt](src) with the attribute line {command= options= caption=}
image
{{</* readfile file= */>}} , The current form{{</* include file= */>}}
include
The fence attribute {filename="x"} , The current form{title="x"}
fencetitle
{{</* badge outline= */>}} , The current formDrop the outline parameter
badge
{{</* example */>}} + a fence, {{</* book-figures kind="tbl" */>}} , The current form{{</* eg */>}}…{{</* /eg */>}}, {{</* book-tables */>}}
eg
{{%/* _param x */%}}, iframe, conditional-text, blocks/*, netlify, a kindless xref , The current 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 current 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 an OINK 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 warns with the new location, and strict publishing rejects it.

  • 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
make check     # local sibling theme: build, outputs, translations, and rendered links
make browser   # local sibling theme: accessibility, responsive and interactive behavior
make build     # published theme pinned in go.mod, without a local replacement

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

A validated source commit, a published tag that resolves through the module proxy, a consumer pin with its checksum, and a verified production deployment are separate states. One green local build does not prove the others.

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]   # example: the site's 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 The warning names the allowed keys and ignores the bad attribute; style and on* are likewise dropped. --panicOnWarning turns it into a publishing failure
shortcode "field": unsupported parameter "colour" at ... A shortcode parameter name is wrong The warning names the shortcode, parameter, file, and line, then ignores the unsupported parameter or component. Ordinary preview remains usable; strict publishing fails
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 page’s hreflang points at another language’s home page The published 1.1.0 behavior or a copied older SEO partial can reuse the language-switcher fallback The 1.2.0 development implementation omits missing translations from SEO alternates. Check the resolved theme version and template overrides; add the corresponding page when a translation is intended. The visible language switcher’s home-page fallback remains valid
Symptom Cause Fix
A search box that never returns results No index was generated Check params.offline_search and open search. Inspect the page’s data-td-index-src URL in Network: production uses offline-search-index.<language>.<hash>.json, development omits the hash. If absent, also check offline_search_on_serve during hugo server
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 the Chinese page’s actual data-td-index-src URL), 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

Run the build in your site’s root. The output checker is a separate script from a theme checkout matching your pinned release: replace /path/to/oink, /path/to/my-site/public, and the base URL with your values. The remaining commands are examples from this documentation repository’s test harness, not commands supplied to every OINK consumer.

A zero-warning build , Commandhugo --printPathWarnings --panicOnWarning
Duplicate output paths, invalid parameters, incomplete external integrations
Output trust check , Commandpython3 /path/to/oink/bin/check-output-security.py --public /path/to/my-site/public --base-url https://my-site.example/
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. Keep the theme-tool path and your site’s output path distinct; the complete example is 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
  • Deploy — baseURL, 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