Examples
EN

Versioned Docs

Hwaro can publish several versions of the same documentation (v1, v2, …) from one site, with a version switcher, per-version navigation and SEO that points search engines at the current release. Versioning is directory-based and modeled on multilingual support: a page belongs to the version whose content directory contains it.

Configuration

[versions]
latest_at_root = true     # latest version renders at its section's natural URL
noindex_old = true        # older versions: <meta name="robots" content="noindex"> + canonical to the latest counterpart
search = "latest"         # "latest" | "all" — which versions enter search.json AND sitemap.xml
feeds = "latest"          # "latest" | "all" — which versions feed RSS/Atom
taxonomies = "latest"     # "latest" | "all" — which versions taxonomy term pages collect from

[[versions.list]]
name = "v2"               # URL segment, must be URL-safe (letters, digits, - _ . ~)
label = "2.x (latest)"    # switcher label (defaults to name)
path = "docs/v2"          # content directory relative to content/ (defaults to name)
latest = true

[[versions.list]]
name = "v1"
label = "1.x"
path = "docs/v1"

TOML cannot make one key both a table and an array of tables, so the switches live in [versions] and the entries in [[versions.list]]. If you only need the defaults, a bare array works too:

[[versions]]
name = "v2"
path = "docs/v2"
latest = true

[[versions]]
name = "v1"
path = "docs/v1"

Validation (all of these fail the build with HWARO_E_CONFIG):

hwaro doctor warns (version-path-missing) when a version points at a content directory that does not exist.

Content Structure

Each version is a normal content tree under its own directory. Files with the same path relative to the version root are treated as the same page in different versions. That is how the switcher finds counterparts.

content/
└── docs/
    ├── v2/
    │   ├── _index.md
    │   ├── install.md
    │   └── plugins.md      # only in v2
    └── v1/
        ├── _index.md
        ├── install.md
        └── legacy.md       # only in v1

URL Mapping

The version directory is swapped for the directory the version publishes under. With latest_at_root = true (the default) the latest version takes the parent's natural URL and older versions get a /<name>/ segment:

Source URL
content/docs/v2/_index.md /docs/
content/docs/v2/install.md /docs/install/
content/docs/v1/_index.md /docs/v1/
content/docs/v1/install.md /docs/v1/install/

With latest_at_root = false every version keeps its segment (/docs/v2/install/, /docs/v1/install/) and /docs/ becomes a redirect stub to the latest version's root, unless you author your own content/docs/_index.md, which then keeps that URL.

The URL segment is the version name, not the directory basename: name = "2.x" with path = "docs/v2" publishes at /docs/2.x/… when it is not at root. Version directories may also sit at the top level (content/v2/…), in which case the latest version is the site root.

Notes:

Multilingual

Languages and versions combine: the language prefix comes first, the version after. content/docs/v1/install.ko.md renders at /ko/docs/v1/install/, and foo.ko.md files inside a version directory behave exactly as they do elsewhere (translations, hreflang, per-language menus).

Template Variables

page.version

nil for unversioned pages (so {% if page.version %} is the guard), otherwise:

Property Type Description
.name String Version name ("v2")
.label String Display label ("2.x (latest)")
.latest Bool Is this the latest version
.url String Root URL of the version in the page's language (/docs/, /ko/docs/v1/)

One entry per configured version, in config order. These are the switcher rows. Empty for unversioned pages.

Property Type Description
.name String Version name
.label String Display label
.latest Bool Is the latest version
.url String The same page in that version when it exists, else that version's root
.exists Bool Whether the counterpart page exists (falseurl is the version root)
.current Bool Whether this row is the page's own version

Counterparts are matched by path relative to the version root, in the same language: docs/v1/install.mddocs/v2/install.md, docs/v1/install.ko.mddocs/v2/install.ko.md. A render = false counterpart does not count as existing.

versions (global)

Available on every page of a versioned site. It is a list ({% for v in versions %}) of {name, label, latest, url} entries whose url is each version's root in the current page's language, plus:

Property Description
versions.latest The latest version entry
versions.all The same list as a plain array
versions.size Number of versions

Like page.url, every URL above is site-relative, so prefix it with {{ base_url }} (or {{ base_path }}) in links so subpath deployments work.

Version Switcher Example

{% if page.version %}
<details class="version-switch">
  <summary aria-label="Switch documentation version">
    {{ page.version.label }}
    {% if not page.version.latest %}<span class="badge">old</span>{% endif %}
  </summary>
  <ul>
    {% for v in page.version_links %}
    <li>
      <a href="{{ base_url }}{{ v.url }}"
         {% if v.current %}class="current" aria-current="page"{% endif %}
         {% if not v.exists %}title="This page does not exist in {{ v.label }} — opens the {{ v.label }} start page"{% endif %}>
        {{ v.label }}{% if v.latest %} (latest){% endif %}
      </a>
    </li>
    {% endfor %}
  </ul>
</details>

{% if not page.version.latest %}
<div class="version-banner">
  You are reading the {{ page.version.label }} documentation.
  {% for v in page.version_links %}{% if v.latest %}
  <a href="{{ base_url }}{{ v.url }}">{% if v.exists %}Read this page in {{ v.label }}{% else %}Go to the {{ v.label }} docs{% endif %}</a>
  {% endif %}{% endfor %}
</div>
{% endif %}
{% endif %}

A site-wide entry point that does not depend on the current page:

<a href="{{ base_url }}{{ versions.latest.url }}">Docs ({{ versions.latest.label }})</a>
<select onchange="location.href=this.value">
  {% for v in versions %}
  <option value="{{ base_url }}{{ v.url }}">{{ v.label }}</option>
  {% endfor %}
</select>

Scoping Rules

Every version is its own tree; nothing leaks across the boundary:

Surface Behavior
page.lower / page.higher The reading order is built per {language, version}. The last v1 page has no "next"; it never jumps into v2.
page.ancestors (breadcrumbs) Stop at the version root. An unversioned docs/_index.md is not an ancestor of docs/v1/….
get_section(), section.pages, section.subsections A version root is not a subsection or page of its unversioned parent; listings inside a version only see that version.
Menus (get_menu) Config [[menus.*]] entries appear everywhere. Front-matter menus = […] registrations from a versioned page appear only in that version's menus; unversioned pages additionally see the latest version's registrations.
Related posts Never cross a version boundary.
Taxonomies Term pages collect from unversioned content plus the latest version (taxonomies = "latest", the default). Set taxonomies = "all" to list every version.

Series are not version-aware: a series shared by a v1 and a v2 page groups them together.

SEO & Discovery

Canonical and noindex

Pages of an older version emit a canonical link to their latest counterpart when it exists (self-canonical otherwise) and, with noindex_old = true (default), a <meta name="robots" content="noindex"> right after it. Both come out of {{ canonical_tag }}, so templates that already print it need no change; seo.canonical_url follows the same rule and seo.noindex exposes the flag.

<link rel="canonical" href="https://example.com/docs/install/">
<meta name="robots" content="noindex">

Latest-version pages self-canonicalize as usual. Paginated listings keep self-canonicalizing (page/2/ of an old section is not page/2/ of the new one). hreflang_tags are unaffected, since they link translations of the same version.

Discovery surfaces

Surface Switch Default
search.json [versions] search latest only
sitemap.xml [versions] search (same switch) latest only
RSS / Atom (main, section, per-language) [versions] feeds latest only
Taxonomy term pages [versions] taxonomies latest only
llms.txt / llms-full.txt always latest only

Unversioned pages always pass. With search = "all" every record in search.json carries a version field (the version name) so a client can filter results to the version being read:

const current = document.documentElement.dataset.version; // e.g. from data-version="{{ page.version.name }}"
const hits = results.filter((r) => !r.version || r.version === current);

Build Cache, Serve and Doctor

Not Included

See Also