This is a demo announcement, dismissible and configured in site.json.

Versioning

Tag-based versioning with an automatic pipeline, version pickers and outdated banners.

The model

You only ever write one docs tree, living under a directory named after your working-docs token (the snippets below use __mydocs__, this site uses __vcurrent__):

text
content/docs/
├── _index.md
└── __mydocs__/
    ├── _index.md       ← title: "__mydocs__" (the literal token)
    └── en/ …           ← you write here, always

Releasing means pushing a git tag (v1.0.0). Nothing is copied in the repository. Git remembers what the token directory looked like at every tag.

At build time, the release pipeline assembles the full site:

text
tag v1.0.0 ──┐
tag v1.1.0 ──┼─→  content/docs/v1_0_0/…    ← extracted from each tag,
tag v2.0.0 ──┘                 v1_1_0/…       the token dir renamed to the tag,
                               v2_0_0/…       token links and title replaced
             └─→  static/versions.json     ← generated: newest tag = latest

One Hugo build then renders everything. Pickers, search scopes, hreflang and the outdated-version banner work across the assembled versions automatically.

Choosing the token

Releasing is a literal string replacement, so the token must be a string that will never occur naturally in your content. Avoid real words like “current”. A sentinel like __mydocs__ or __vnext__ works well. Two hard rules:

  • It is configured in exactly one place: the last directory of docsPath in ci/config.json. The theme itself never knows it.
  • Capitals in the token require disablePathToLower = true in hugo.toml, otherwise Hugo lowercases the URLs while your links keep the capitals.

The pipeline replaces the token in three places:

  1. Link paths in every Markdown file: /docs/__mydocs__/… becomes /docs/v1_2_0/…
  2. The token directory’s own _index.md: every bare __mydocs__ becomes the tag name (v1.2.0). That is why its title should be the literal token, so each released version’s card and breadcrumb show its tag.
  3. site.json: links into the token dir (landing CTAs, menu entries) are pointed at the latest version, since the token dir itself is gone after assembly.

No other file’s prose is ever touched.

The pipeline

The theme ships the script (ci/scripts/build-docs.sh), driven by ci/config.json:

ci/config.jsonjson
{
  "docsPath": "content/docs/__mydocs__",
  "fromTag": "",
  "untilTag": "",
  "skipTags": [],
  "includeCurrent": false
}
Key Meaning
docsPath Where your working docs live. The last directory is your token.
fromTag / untilTag Tag range to build. Empty means from the first tag up to the latest.
skipTags Yanked or broken releases to exclude.
includeCurrent Also include the working tree as a preview version, named after the token.

CI usage:

.gitlab-ci.ymlyaml
pages:
  rules: [{ if: $CI_COMMIT_TAG }]
  variables: { GIT_DEPTH: 0 }
  script:
    - sh ci/scripts/build-docs.sh ci/config.json "$PAGES_PATH"
    - hugo --environment production --baseURL "${CI_PAGES_URL}/"

With no tags the script leaves the working tree untouched. Running hugo server while writing simply shows the token directory.

Writing rules this enables

  • Internal links always use the fixed prefix docs/__mydocs__/<lang>/…. The pipeline rewrites it per version.
  • Version names come from tags (v1.2.0 becomes directory v1_2_0, displayed as “v1.2.0”). You never name version directories yourself.
  • Fixing an old release: branch from its tag, fix, tag again (v1.0.1). Retire the bad tag via skipTags.

The version manifest

/versions.json always lives beside the site. The theme fetches it host-relative. It drives the pickers, search scoping and the outdated banner, and is generated by the pipeline. The minimal copy committed under static/ only serves local development.

The outdated-version banner

Readers of any non-latest version get a banner linking to the same page in the latest version. To preview it locally: hand-edit static/versions.json, add a fake newer version with "latest": true, restart hugo server.

Failure behavior

If versions.json cannot be fetched, the pickers hide, a toast informs the reader and everything else keeps working. Search falls back to unscoped results.