Back to docs

Bora Docs

English · v1.0.0

18 June 2026

Getting Started

Installation

Add the Bora theme to a new or existing Hugo project.

Requirements

  • Hugo v0.141.0 or later (standard build, extended is not required)
  • Dart Sass on the build machine (the theme compiles SCSS with the dartsass transpiler)

Tip

Install Dart Sass once per machine/CI image: download a release from sass-lang.com/dart-sass and put sass on the PATH. Node.js is not required. All JavaScript is pre-bundled in the theme.

If your site is not already a Hugo module, initialise it first:

bash
hugo mod init gitlab.com/your-org/your-docs-site

Add the theme as a dependency in hugo.toml:

hugo.tomltoml
[module]
  [[module.imports]]
    path = "gitlab.com/natitec_public/bora"

Download the module:

bash
hugo mod get gitlab.com/natitec_public/bora

Option 2: Git submodule

bash
git submodule add https://gitlab.com/natitec_public/bora.git themes/bora
hugo.tomltoml
theme = "bora"

Required site files

The theme reads two files from your site root (next to hugo.toml):

file purpose
site.json titles, logo, themes, menus, footer. The theme’s configuration (reference)
static/versions.json minimal version manifest for local development. In production the release pipeline generates it from git tags (versioning)

Your hugo.toml also needs a few output-format blocks. The Hugo configuration page has the complete copy-paste setup.

Important

Without site.json the build stops with a clear error. The fastest start is copying the exampleSite/ directory from the theme repository, which is the site you are reading right now.

Continue with the Quickstart.

Quickstart

From empty site to running documentation in five steps.

Every snippet below is copy-paste ready. The exampleSite/ in the theme repository is this exact setup, fully assembled.

1. Configure Hugo

Copy the output-format blocks from Hugo configuration into your hugo.toml and set your baseURL. Deploying under a sub-path (GitHub/GitLab Pages) only requires changing baseURL.

2. Create site.json

Minimal version (full key reference here):

site.jsonjson
{
  "title": "My Product Docs",
  "defaultLanguage": "en",
  "themes": [
    { "id": "light", "label": "Light", "dark": false, "default": true },
    { "id": "dark",  "label": "Dark",  "dark": true,  "default": true }
  ]
}

3. Create the content tree

You always write under one fixed directory called the working-docs token, with a language directory below it: docs/<token>/<language>/…. Versions are created later from git tags (versioning).

The token is yours to choose. This site uses __vcurrent__, the snippets below use __mydocs__. Pick a string that will never occur naturally in your content, because releasing literally string-replaces it in links. If it contains capitals, set disablePathToLower = true in hugo.toml.

Every level gets an _index.md:

text
content/
└── docs/
    ├── _index.md                  ← title: "Documentation"
    └── __mydocs__/                    ← your token
        ├── _index.md              ← title: "__mydocs__" (the literal token)
        └── en/
            ├── _index.md          ← title: "English"
            └── getting-started/
                ├── _index.md      ← title + description + weight
                └── hello.md
getting-started/_index.mdyaml
---
title: "Getting Started"
description: "Install and run the product."
weight: 1
---

weight orders sections and pages in the sidebar. description shows on section cards, in search results and in link previews.

4. Add the version manifest (for local development)

In production this file is generated by the release pipeline from your git tags. Commit a minimal one so the pickers work while writing locally:

static/versions.jsonjson
{
  "versions": [
    {
      "id": "__mydocs__", "label": "__mydocs__", "path": "/docs/__mydocs__/", "latest": true,
      "languages": [
        { "code": "en", "label": "EN", "path": "/docs/__mydocs__/en/", "default": true }
      ]
    }
  ]
}

It drives the version/language pickers, search scoping and the outdated-version banner.

5. Run

bash
hugo server

Note

site.json and static/versions.json are not watched by the dev server. Restart hugo server after editing them.

You now have: a themed homepage at /, your docs under docs/__mydocs__/en/, working search (Ctrl/⌘ K), light/dark switching and a styled 404. Releasing versions from tags is covered in versioning. Next up: writing pages.

Configuration

site.json

Complete reference for every site.json key, the theme's configuration file.

site.json lives in your site root, next to hugo.toml. It is validated at build time: a missing file or an empty themes list fails the build with a clear message. Everything else is optional with sensible defaults.

Note

The dev server does not watch site.json. Restart hugo server after changes.

This site’s own site.json uses every key below.

Required

json
{
  "title": "My Product Docs",
  "themes": [
    { "id": "light", "label": "Light", "dark": false, "default": true },
    { "id": "dark",  "label": "Dark",  "dark": true,  "default": true }
  ]
}
key meaning
title site name used as the topbar fallback logo, <title>, and social cards
themes available color themes. Each id maps to static/docs/css/theme-<id>.css. default: true marks the eager-loaded default of each family (light/dark). See Themes

Branding & navigation

key example meaning
logo "/docs/media/svg/logo.svg" topbar/homepage logo. Falls back to title text if the file doesn’t exist
favicon "/docs/media/svg/favicon.svg" favicon path
defaultLanguage "en" UI language for pages without a path language (homepage, 404) and the x-default hreflang
menu.links [{ "label": "Support", "url": "mailto:…", "external": true }, { "type": "divider" }] links in the settings drawer
footer { "copyright": "© :YEAR: Acme", "social": [{ "name": "GitHub", "icon": "github", "url": "…" }], "repoUrl": "…" } footer content. :YEAR: becomes the current year. Icons: github, linkedin

Sub-path deployments (GitHub/GitLab Pages)

Write internal URL values (menu.links, landing.ctas) without a leading slash, e.g. "changelog/" rather than "/changelog/". Hugo resolves slash-less values against your baseURL, so the same site.json works at the domain root and under https://host/project/. The paths inside versions.json are navigated to verbatim, so on a sub-path deployment they must include the sub-path. The release pipeline’s generated manifest handles that automatically.

SEO & social

key example meaning
social.cover "/docs/media/banner.png" default Open Graph/Twitter banner (per-page override: cover front matter)
themeColor { "light": "#fff8f6", "dark": "#1a110f" } browser-UI tint per color scheme

Page features

key example meaning
editUrl "https://gitlab.com/acme/docs/-/edit/main/content" base for “Edit this page” links (page path is appended). Omit to hide.
feedback.url "https://feedback.acme.com/api" enables the “Was this page helpful?” widget. See Page extras for the payload and CORS requirements
announcement { "id": "rel-2", "text": "**v2 is out** - [notes](/changelog/)", "dismissible": true } dismissible bar above the topbar. Markdown text. A new id re-shows it to everyone

Homepage

json
"landing": {
  "tagline": "Versioned, multilingual product documentation.",
  "ctas": [
    { "label": "Get started", "url": "/docs/v1_0_0/en/getting-started/", "primary": true },
    { "label": "Changelog",   "url": "/changelog/" }
  ],
  "features": [
    { "title": "Instant search", "text": "Client-side, no server required." }
  ]
}

All three sub-keys are optional. Without the block the homepage renders the title, description and a “Get started” button.

hugo.toml

The complete Hugo configuration the theme needs, copy-paste ready.

This is the full hugo.toml for a Bora site. Copy it, change baseURL and the module path style to your setup, and you are done:

hugo.tomltoml
baseURL = "https://docs.example.com/"
languageCode = "en-us"
title = "My Docs"

disableKinds = ["taxonomy", "term"]   # no tags/categories in a docs site

[markup.highlight]
  noClasses = false
  style     = "github"

# ── Output formats used by the theme ────────────────────────────────


[outputFormats.SearchIndex]       # section-chunked search index
  mediaType      = "application/json"
  baseName       = "searchindex"
  isPlainText    = true
  notAlternative = true

[outputFormats.LLMS]              # llms.txt for AI assistants
  mediaType      = "text/plain"
  baseName       = "llms"
  isPlainText    = true
  notAlternative = true

[mediaTypes."text/markdown"]
  suffixes = ["md"]

[outputFormats.Markdown]          # raw markdown per page (linked from llms.txt)
  mediaType      = "text/markdown"
  baseName       = "index"
  isPlainText    = true
  notAlternative = true

[outputs]
  home = ["HTML", "SearchIndex", "LLMS"]
  page = ["HTML", "Markdown"]

[module]
  [[module.imports]]
    path = "gitlab.com/natitec_public/bora"

What each piece does

Setting Why
markup.highlight.noClasses = false Hugo emits CSS classes so theme-syntax.css can color code per theme
outputFormats.SearchIndex + home output emits /searchindex.json. Without it, search shows “Search is unavailable”

Themes

How color themes work, and how to add your own using Material Theme Builder.

The example site ships four themes: Light and Dark (the defaults, a cool blue that matches the Bora logo), plus Green Light and Green Dark. Switch the active family with the toggle in the topbar, and pick any specific theme from the settings drawer. Your choice persists across visits and carries from the homepage into the docs.

How it works

  • Each theme is one CSS file at static/docs/css/theme-<id>.css. It holds the Material color variables, scoped under [data-theme="<id>"].
  • site.json registers each theme. Switching themes only changes the data-theme attribute on the <html> element, and the matching file supplies the colors.
  • Only the default theme in each family loads up front (one light, one dark). Other variants load the first time someone picks them. A returning visitor’s saved choice is applied before the first paint, so there is no flash and nothing extra is downloaded.
site.jsonjson
"themes": [
  { "id": "light",       "label": "Light",      "dark": false, "default": true  },
  { "id": "dark",        "label": "Dark",       "dark": true,  "default": true  },
  { "id": "light-green", "label": "Green Light", "dark": false, "default": false },
  { "id": "dark-green",  "label": "Green Dark",  "dark": true,  "default": false }
]

The dark field groups a theme into the light or dark family. The topbar toggle switches between the two families’ defaults. The settings drawer lists every registered theme.

Important

A dark theme’s id must start with dark. The dark code-block palette and the dark treatments of UI elements (admonitions, badges, version banners) apply to any [data-theme^="dark"], so name dark themes like dark, dark-ocean or dark-green. Light themes have no prefix requirement. The dark: true flag and the dark id prefix do two different jobs: the flag groups the theme for the toggle, and the prefix switches the code and component styling to their dark form.

Adding your own theme

  1. Generate the palette with Material Theme Builder. Pick your brand color and export as Web (CSS). The export gives you a light block and a dark block, each wrapped in a class such as .light or .dark.

  2. Re-scope each block to the attribute the theme reads. Replace the exported class selector with [data-theme="<id>"], and give the dark block an id that starts with dark. The variables themselves do not change.

    static/docs/css/theme-ocean.csscss
    [data-theme="ocean"] {
      --md-sys-color-primary: rgb(0 101 142);
      --md-sys-color-on-primary: rgb(255 255 255);
      /* the rest of the exported variables, unchanged */
    }
  3. Save each block as its own file at static/docs/css/theme-<id>.css, for example theme-ocean.css and theme-dark-ocean.css. Your site’s static/ folder merges with the theme’s, so you do not need to fork anything.

  4. Register the themes in site.json:

    site.jsonjson
    { "id": "ocean",      "label": "Ocean",      "dark": false, "default": false },
    { "id": "dark-ocean", "label": "Ocean Dark", "dark": true,  "default": false }

Each theme then appears in the settings drawer and loads the first time it is picked. To make one the family default, set "default": true on it and remove that flag from the theme it replaces.

Tip

High-contrast and brand variants work the same way. Material Theme Builder can export them all at once. Each one is a single file plus a single line in the registry.

  • themeColor in site.json tints the browser UI per scheme (reference).
  • Code block colors come from theme-syntax.css (Chroma classes). Override it from your site’s static/ the same way.
  • Warning accents (admonitions, banners) default to amber. Override with --nti-color-warning inside your theme file.

Languages

Add a documentation language: content tree, UI strings, changelog.

Languages are plain directories. The theme does not use Hugo’s multilingual mode. Adding German takes three steps:

1. Content tree

text
content/docs/__mydocs__/   ← your working-docs token
├── en/ …
└── de/                ← new
    ├── _index.md      ← title: "Deutsch"
    └── …

From the next tag onward, the release pipeline detects the new directory and lists it in the generated manifest automatically.

2. Register in the local-dev manifest

So the pickers also show it while writing locally, add it to the minimal static/versions.json:

static/versions.jsonjson
"languages": [
  { "code": "en", "label": "EN", "path": "/docs/__mydocs__/en/", "default": true },
  { "code": "de", "label": "DE", "path": "/docs/__mydocs__/de/" }
]

The language picker, search scope filter and hreflang links pick it up automatically. Pages link their translations only where the same page actually exists.

3. Translate the UI strings

The chrome (“Search…”, “On this page”, badge labels, error messages) reads from data/i18n/<lang>.toml. Copy the English file from the theme (data/i18n/en.toml) into your site as data/i18n/de.toml and translate the values:

data/i18n/de.tomltoml
search_placeholder = "Dokumentation durchsuchen…"
back_to_top        = "Nach oben"
on_this_page       = "Auf dieser Seite"
# … keep every key. Missing keys fall back to English.

The <lang> filename must match the language directory name. Site data files override the theme’s, so you can also tweak English wording the same way.

Note

The page’s language comes from its path (docs/<version>/<lang>/…), so the right strings apply per page automatically, even when versions mix languages.

Changelog languages

The changelog is also per-language: add content/changelog/de/ with the same _index.md pattern as English.

Features

Every Bora feature is documented here. Each page explains what it does and includes working examples you can copy.

Writing content

Shortcodes

Theme features

Writing Pages

Front matter reference and how headings drive navigation, search and deep links.

Every page in Bora is a Markdown file. Hugo converts it to HTML and the theme wires it into the sidebar, table of contents and search index automatically.

Front matter

Every page needs at least a title. Everything else is optional:

yaml
---
title: "Page Title"
description: "One-line summary for cards, search results and link previews."
weight: 10
cover: "/docs/media/special-banner.png"
draft: false
---
Field Description
title Page heading, sidebar label and search result title.
description Card subtitle on section pages, search snippet, <meta name="description"> and social card text.
weight Sidebar and prev/next ordering. Lower values appear first.
cover Per-page Open Graph banner, overriding social.cover from site.json.
draft Drafts build only with hugo server -D and get a badge on section cards.

Headings

Use ## (h2) and ### (h3) to structure pages. The theme turns them into:

  • the “On this page” column on wide screens, with the current section highlighted while scrolling
  • search results that deep-link to the exact section, shown as “Page > Heading”
  • anchor links: hover any heading and click the link icon to copy the URL

#### and deeper headings render normally but stay out of the table of contents and search chunks. That is a signal to keep page structure flat.

Section index pages

A directory needs an _index.md to appear in the sidebar and get a card grid on wide screens. The minimal version:

yaml
---
title: "Section Title"
description: "What this section covers."
weight: 10
---

You can add body content below the front matter and it appears above the card grid.

Everything you can put on a page

  • Admonitions: note, tip, important, warning and caution callouts
  • Tabs: synced multi-variant content blocks
  • Code blocks: titles, highlighted lines, line numbers and Mermaid
  • Images and links: figures, lightbox, remote images
  • Shortcodes: diagrams, PDF embeds, OpenAPI, steps, cards and more

Standard Markdown works too: tables, blockquotes, task lists and \``mermaid` diagrams all render out of the box.

Admonitions

Callout boxes with GitHub-compatible syntax: note, tip, important, warning, caution.

Admonitions use GitHub’s alert syntax, so the same Markdown renders as callouts here and on GitHub and GitLab:

markdown
> [!NOTE]
> Useful background information.

The five types

Note

Background information the reader should notice in passing.

Tip

A best practice or shortcut.

Important

Must-read information for the task to succeed.

Warning

Something that can go wrong.

Caution

Destructive consequences ahead.

Custom titles

Add the title on the marker line:

markdown
> [!TIP] Name your versions consistently
> The label line replaces the default "Tip".

Name your versions consistently

The label line replaces the default “Tip”. Icon and colors stay.

Rich content inside

Any Markdown works inside the body: multiple paragraphs, lists, links and code blocks.

markdown
> [!WARNING]
> First paragraph with **bold** and `inline code`.
>
> ```bash
> echo "code blocks too"
> ```
>
> - and lists
> - work fine

Warning

First paragraph with bold and inline code.

bash
echo "code blocks too"
  • and lists
  • work fine

Plain blockquotes (no [!…] marker) keep their own separate quote styling:

A regular quote, unaffected by admonition styles.

Notes

  • Default labels translate per language via data/i18n. See languages.
  • All five types map to distinct icon and color combinations that follow the active theme.

Code Blocks

Syntax highlighting, filename titles, highlighted lines, line numbers, wrap toggle and Mermaid diagrams.

Highlighting happens at build time via Hugo’s Chroma engine, so no JavaScript is needed for colors. The palette follows the active theme. Every block gets a copy button and a line-wrap toggle in its header bar automatically.

Language badge

markdown
```go
package main

func main() {
    println("hello")
}
```
go
package main

func main() {
    println("hello")
}

Filename title

markdown
```go {title="main.go"}
package main // the bar shows main.go next to the language badge
```
main.gogo
package main // the bar shows main.go next to the language badge

Highlighted lines

markdown
```yaml {title="site.json themes" hl_lines=[2,3]}
themes:
  - id: light      # these two lines
    default: true  # are highlighted
  - id: dark
```
site.json themesyaml
themes:
  - id: light      # these two lines
    default: true  # are highlighted
  - id: dark

Line numbers

markdown
```bash {linenos=true}
hugo mod get gitlab.com/natitec_public/bora
hugo server
```
bash
1
2
hugo mod get gitlab.com/natitec_public/bora
hugo server

Wrap toggle

Click the wrap button (left of copy) on this block. The long line soft-wraps instead of scrolling:

text
This deliberately long single line would normally force a horizontal scrollbar inside the code block unless the wrap toggle is switched on, in which case it wraps within the block borders.

Mermaid diagrams

A ```mermaid block renders as a diagram. The renderer loads only on pages that use it and re-renders when the theme switches:

flowchart LR
  A[Markdown] --> B(Chroma)
  B --> C{Theme CSS}
  C -->|light| D[GitHub palette]
  C -->|dark| E[Dark palette]

Combining options

All options combine freely on one block:

markdown
```yaml {title="example.yaml" hl_lines=[2] linenos=true}
key: value
highlighted: true
another: value
```
example.yamlyaml
1
2
3
key: value
highlighted: true
another: value

Images and Links

Figures with captions, click-to-zoom lightbox, remote image fetching and link validation.

Plain images and figures

Standard Markdown. Add a title to get a centered figure with a caption:

markdown
![Alt text](/docs/media/diagram.png)
![Alt text](/docs/media/diagram.png "This title becomes the caption")

Local SVGs are inlined at build time and follow the theme colors. Raster images get width and height attributes at build time so the page does not shift while loading.

Every content image is click-to-zoom. Press Esc or click anywhere to close. Try it:

A sample image that opens in the lightbox
Click the image to zoom. This caption comes from the title attribute.

Images wrapped in a link keep their link behavior and are excluded from the lightbox.

Remote images

Remote URLs are downloaded at build time and served from your site. A dead URL never breaks the build. The theme logs a warning and falls back to a plain <img> the browser loads at view time. Severity is configurable:

hugo.tomltoml
[params.render_hooks.image]
errorLevel = "warning"   # ignore | warning (default) | error

Tip

Set error in CI if you want broken remote images to fail the pipeline.

Internal links are validated at build time:

hugo.tomltoml
[params.render_hooks.link]
errorLevel = "warning"   # ignore | warning | error

Set error to make broken internal links fail the CI pipeline.

External links open in a new tab and get a small external icon automatically: Hugo documentation.

Same-page anchors work as expected: back to top. The target heading flashes on arrival so readers see where they landed.

Badge

Inline pill labels for annotating features, status and version information.

The badge shortcode renders a small inline pill label. Use it in prose, headings and table cells to annotate feature status, API stability, version requirements and similar metadata. All parameters are named.

Basic usage

text
Released in {{< badge text="v0.8.0" >}} and later.

Released in v0.8.0 and later.

Variants

The variant parameter controls the color. All variants derive their color from the theme palette and adapt automatically in light and dark mode.

Variant Use for
default General labels, version numbers
tip New features, additions
important Key information, notable changes
warning Experimental, subject to change
danger Deprecated, breaking, removed
neutral Status labels, categories
text
{{< badge text="Default" >}}
{{< badge text="New" variant="tip" >}}
{{< badge text="Important" variant="important" >}}
{{< badge text="Experimental" variant="warning" >}}
{{< badge text="Deprecated" variant="danger" >}}
{{< badge text="Stable" variant="neutral" >}}

Default New Important Experimental Deprecated Stable

Add href to make the badge a clickable link:

text
{{< badge text="Migration guide" variant="danger" href="/docs/migration/" >}}
Migration guide

In headings and tables

Badges work inline in Markdown headings and table cells:

markdown
## Feature name {{< badge text="Beta" variant="warning" >}}

| API | Status |
|-----|--------|
| `renderMathInElement` | {{< badge text="Stable" variant="neutral" >}} |
| `fileTree` | {{< badge text="New" variant="tip" >}} |

Parameters

Parameter Default Description
text required Label text.
variant "default" Color variant. See the table above.
href (none) URL. Makes the badge a link.

Cards

Responsive grid of linked or static cards for section overviews and feature grids.

The cards shortcode renders a responsive grid of cards. Each inner card shortcode defines one card with a title, optional body text, optional icon and an optional link that makes the whole card clickable.

Basic usage

text
{{< cards >}}
{{< card title="Getting started" >}}
Install and configure Bora in under five minutes.
{{< /card >}}
{{< card title="Shortcodes" >}}
Embed diagrams, math, file trees, and more.
{{< /card >}}
{{< card title="Theming" >}}
Switch between light and dark, or add your own palette.
{{< /card >}}
{{< /cards >}}
Getting started

Install and configure Bora in under five minutes.

Shortcodes

Embed diagrams, math, file trees, and more.

Theming

Switch between light and dark, or add your own palette.

With icons

Pass any emoji or Unicode character as icon:

text
{{< cards >}}
{{< card title="Getting started" icon="🚀" href="/docs/v1_0_0/en/getting-started/" >}}
Up and running in minutes.
{{< /card >}}
{{< card title="Features" icon="⚙️" href="/docs/v1_0_0/en/features/" >}}
Diagrams, math, file trees, steps, and more.
{{< /card >}}
{{< card title="Search" icon="🔍" >}}
Client-side full-text search, no server required.
{{< /card >}}
{{< /cards >}}

Linked cards

Add href to make the entire card surface a click target. Linked cards show a primary-color border and shadow on hover:

text
{{< cards >}}
{{< card title="Changelog" href="/changelog/" >}}{{< /card >}}
{{< card title="Support" href="mailto:support@example.com" >}}{{< /card >}}
{{< /cards >}}

card parameters

Parameter Type Default Description
First positional / title string required Card heading.
icon string (none) Emoji or character shown above the title.
href string (none) URL. Makes the card a link.
Inner content markdown (none) Body text rendered below the title.

Notes

  • The grid uses auto-fill with a 220 px minimum column width, so it adapts from one column on narrow screens to three or four on wide ones.
  • Cards without href are <div> elements. Cards with href are <a> elements with full keyboard and screen-reader semantics.
  • Inner content is rendered as Markdown, so you can include inline code, links and emphasis.

Columns

Place two or three blocks of content side by side in a responsive grid.

The columns shortcode arranges content in a horizontal grid. Separate each column with <---> on its own line. On narrow screens the columns stack vertically automatically.

Two columns

text
{{< columns >}}
**Option A**

Works well for small datasets with simple queries.

<--->

**Option B**

Better for high-volume writes and concurrent connections.
{{< /columns >}}

Option A

Works well for small datasets with simple queries.

Option B

Better for high-volume writes and concurrent connections.

Three columns

text
{{< columns >}}
Install the CLI:

```shell
npm install -g mybundle
```

<--->

Configure your project:

```shell
mybundle init
```

<--->

Run the build:

```shell
mybundle build
```
{{< /columns >}}

Install the CLI:

shell
npm install -g mybundle

Configure your project:

shell
mybundle init

Run the build:

shell
mybundle build

Custom ratio

Use ratio to control relative widths. The value maps directly to CSS fr units, so "2:1" gives the left column twice the space of the right.

text
{{< columns ratio="2:1" >}}
A wider left column for the main explanation or code sample.

<--->

A narrower right column for a note, tip, or screenshot.
{{< /columns >}}

A wider left column for the main explanation or code sample.

A narrower right column for a note, tip, or screenshot.

Parameters

Parameter Default Description
ratio (none) Column width ratio. Use colon-separated integers such as "2:1" or "1:2:1".

Notes

  • Any number of columns is supported, though two or three work best on most screen widths.
  • Each column renders full Markdown including code blocks, lists, badges and other shortcodes.
  • Columns collapse to a single vertical stack on screens narrower than 768 px.

Details

Collapsible sections using the native HTML details element. No JavaScript required.

The details shortcode wraps content in a native HTML <details>/<summary> element. No JavaScript is needed. The browser handles expand and collapse.

Usage

Pass the summary title as the first argument:

text
{{< details "What is Bora?" >}}
Bora is a Hugo theme for technical documentation.
{{< /details >}}
What is Bora?

Bora is a Hugo theme for technical documentation.

Open by default

Add open=true to render the section expanded on load:

text
{{< details title="Already open" open=true >}}
This section is expanded by default.
{{< /details >}}
Already open

This section is expanded by default.

Markdown inside

The inner content is rendered as full Markdown, so you can include headings, lists, code blocks and other shortcodes:

text
{{< details "Configuration options" >}}
| Option | Default | Description |
|--------|---------|-------------|
| `baseURL` | (none) | The root URL of your site. |
| `title` | (none) | Site title shown in the browser tab. |
{{< /details >}}
Configuration options
Option Default Description
baseURL (none) The root URL of your site.
title (none) Site title shown in the browser tab.

Parameters

Parameter Type Default Description
First positional / title string "Details" Text shown in the summary bar.
open bool false Start the section expanded. When used, pass title as a named parameter too.

draw.io

Embed and render interactive draw.io diagrams with zoom and pan.

The drawio shortcode embeds a draw.io diagram directly in the page. Readers can zoom, pan and navigate layers without leaving the documentation. The draw.io viewer script loads only on pages that contain at least one diagram.

File location

Where you place the .drawio file determines how it is loaded.

Path style Where the file lives How it is loaded
diagram.drawio (no leading /) Next to the page’s index.md as a page bundle resource Read at build time, embedded inline, works on private sites
/path/to/diagram.drawio (leading /) In assets/ at that path Read at build time, embedded inline, works on private sites
Fallback (file not found in bundle or assets) In static/ at that path Fetched at runtime, public sites only

Page bundle resources sit in the same directory as the page’s index.md. For a page at content/docs/en/guides/setup/index.md, a file at content/docs/en/guides/setup/diagram.drawio is referenced simply as diagram.drawio.

Build-time embedding means the diagram XML is written directly into the HTML. No HTTP request is made when the reader opens the page, so the embed works even on private GitLab Pages or any other auth-gated host.

Usage

text
{{< drawio "diagram.drawio" >}}

Or with an absolute path from assets/:

text
{{< drawio "/docs/files/architecture.drawio" >}}

Live example

The demo file lives at exampleSite/assets/docs/files/example.drawio in the theme repository and is embedded inline at build time.

text
{{< drawio "/docs/files/example.drawio" >}}

Use the toolbar to zoom in, zoom out, and fit the diagram to the container. You can also pan by clicking and dragging.

Parameters

Parameter Description
1st positional Path to the .drawio file. Relative paths resolve as page bundle resources. Absolute paths (leading /) resolve from assets/.

Notes

  • The viewer script (drawio-viewer-static.min.js) is bundled with the theme and works fully offline.
  • Diagrams load after page JavaScript runs, so a brief loading state is normal.
  • The viewer is read-only. To edit diagrams, use the draw.io desktop app or diagrams.net.
  • Interactive embeds are replaced with a static note in the printable handbook.

File tree

Styled directory tree for showing project or repository structure.

The filetree shortcode renders a styled directory tree. Write the tree using standard box-drawing characters. Folder names (ending with /) are highlighted in the primary color and tree connector characters are rendered in a muted tone.

Usage

text
{{< filetree >}}
content/
├── docs/
│   ├── _index.md
│   └── getting-started.md
├── blog/
│   └── first-post.md
└── _index.md
{{< /filetree >}}
content/├── docs/│   ├── _index.md│   └── getting-started.md├── blog/│   └── first-post.md└── _index.md

Generating the tree text

Use the tree command to generate the input on Linux and macOS:

shell
tree -F --noreport content/

On Windows (PowerShell):

powershell
tree /F content

Copy the output directly into the shortcode body.

Common box-drawing characters

Character Unicode Meaning
├── U+251C U+2500 U+2500 Branch (more items follow)
└── U+2514 U+2500 U+2500 Last branch
U+2502 Vertical connector

Most terminals and editors insert these automatically when using tree.

Notes

  • Lines ending with / are treated as folders and styled with the primary color.
  • All other non-blank lines are treated as files.
  • Horizontal scrolling is enabled so long paths do not break the layout.

Math

Render LaTeX math expressions in your docs using KaTeX.

Bora renders LaTeX math via KaTeX (v0.17.0). Both display blocks and inline expressions are supported. KaTeX loads only on pages that use it, so pages without math carry no extra weight.

Display math

Use a fenced code block with the math language identifier. KaTeX renders it as a centered block equation:

markdown
```math
\int_0^\infty e^{-x^2} dx = \frac{\sqrt{\pi}}{2}
```
$$\int_0^\infty e^{-x^2} dx = \frac{\sqrt{\pi}}{2}$$

Another example:

$$\sum_{n=1}^{\infty} \frac{1}{n^2} = \frac{\pi^2}{6}$$

Maxwell’s equations in differential form:

$$\nabla \cdot \mathbf{E} = \frac{\rho}{\varepsilon_0}, \quad \nabla \cdot \mathbf{B} = 0, \quad \nabla \times \mathbf{E} = -\frac{\partial \mathbf{B}}{\partial t}, \quad \nabla \times \mathbf{B} = \mu_0 \mathbf{J} + \mu_0 \varepsilon_0 \frac{\partial \mathbf{E}}{\partial t}$$

Inline math

For inline math, add math: true to the page front matter and enable the Goldmark passthrough extension in hugo.toml. Then write $...$ for inline expressions and $$...$$ for display expressions directly in prose.

hugo.tomltoml
[markup.goldmark.extensions.passthrough]
  enable = true
  [markup.goldmark.extensions.passthrough.delimiters]
    block  = [["$$", "$$"], ["\\[", "\\]"]]
    inline = [["$",  "$" ], ["\\(", "\\)"]]

With passthrough enabled and math: true in front matter, you can write:

markdown
The quadratic formula is $x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$.

Which renders as: The quadratic formula is $x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$.

Tip

Use math: true in front matter only on pages that contain inline $...$ math. Pages that only use ```math ``` blocks do not need it. The render hook sets the flag automatically.

How KaTeX loads

KaTeX (JS, CSS and fonts) is loaded only on pages where math is detected. The render hook for ```math ``` blocks sets the flag automatically. For inline math, the math: true front matter triggers the load. Neither the JS nor the CSS appears on pages without math.

OpenAPI

Full API reference rendered from an OpenAPI or Swagger spec, offline and theme-aware.

The openapi shortcode renders an OpenAPI 3.x (or Swagger 2) spec as a browsable API reference using a vendored Redoc bundle. There is no CDN dependency, so it works in air-gapped deployments. The bundle loads only on pages that use the shortcode and the reference re-renders when the theme switches.

File location

Where you place the spec file determines how it is loaded.

Path style Where the file lives How it is loaded
api.yaml (no leading /) Next to the page’s index.md as a page bundle resource Parsed at build time, embedded inline, works on private sites
/path/to/api.yaml (leading /) In assets/ at that path Parsed at build time, embedded inline, works on private sites
Fallback (file not found in bundle or assets) In static/ at that path Fetched at runtime, public sites only

Build-time embedding parses the YAML or JSON spec and writes it directly into the HTML as JSON. No HTTP request is made when the reader opens the page, so the embed works on private GitLab Pages and any other auth-gated host.

Usage

text
{{< openapi "api.yaml" >}}

Or with an absolute path from assets/:

text
{{< openapi "/docs/specs/api.yaml" >}}

Live demo

The spec file for this demo lives at exampleSite/assets/docs/specs/sample-api.yaml in the theme repository and is embedded inline at build time.

text
{{< openapi "/docs/specs/sample-api.yaml" >}}

Parameters

Parameter Description
1st positional Path to the .yaml or .json spec file. Relative paths resolve as page bundle resources. Absolute paths (leading /) resolve from assets/.

Notes

  • The Redoc bundle is vendored inside the theme. No internet connection is needed at build time or runtime.
  • The reference respects the active color theme: primary color, text and sidebar colors all follow the theme palette.
  • Interactive embeds are replaced with a static note in the printable handbook.

PDF embed

Inline PDF viewer with a fullscreen expand button.

The pdf shortcode embeds a PDF document inline on the page using PDFObject. Readers can scroll through the document without leaving the page. An expand button opens the viewer fullscreen. Press Esc to return to the page.

The PDFObject script loads only on pages that contain a PDF embed.

File location

Where you place the .pdf file determines how it is loaded.

Path style Where the file lives How it is loaded
report.pdf (no leading /) Next to the page’s index.md as a page bundle resource Read at build time, embedded as a data URI, works on private sites
/path/to/report.pdf (leading /) In assets/ at that path Read at build time, embedded as a data URI, works on private sites
Fallback (file not found in bundle or assets) In static/ at that path Fetched at runtime, public sites only

Build-time embedding converts the PDF to a base64 data URI and writes it directly into the HTML. No HTTP request is made when the reader opens the page, so the embed works on private GitLab Pages and any other auth-gated host. Keep individual PDF files under a few megabytes to avoid large HTML pages.

Usage

text
{{< pdf "report.pdf" >}}

Or with an absolute path from assets/:

text
{{< pdf "/docs/files/report.pdf" >}}

Live example

The demo file lives at exampleSite/assets/docs/files/sample.pdf in the theme repository and is embedded as a data URI at build time.

text
{{< pdf "/docs/files/sample.pdf" >}}

Scroll through the pages inline, or use the expand button to open the viewer fullscreen and press Esc to return.

Parameters

Parameter Description
1st positional Path to the PDF file. Relative paths resolve as page bundle resources. Absolute paths (leading /) resolve from assets/.

Notes

  • The embed height defaults to 800 px and can be resized by dragging the bottom edge.
  • If the browser does not support inline PDF embedding, PDFObject shows a link to open the file directly.
  • Looking for the printable handbook and PDF export? See Print and handbook.

Steps

Numbered procedural steps with a visual connecting rail.

The steps shortcode wraps a Markdown ordered list and applies a visual treatment: numbered circles connected by a vertical rail. Use it for installation guides, tutorials and any sequential process where order matters.

Usage

Write a standard Markdown ordered list inside the shortcode:

text
{{< steps >}}
1. Install Hugo (v0.141 or later).
2. Clone your documentation repository.
3. Run `hugo server` to start the local preview.
{{< /steps >}}
  1. Install Hugo (v0.141 or later).
  2. Clone your documentation repository.
  3. Run hugo server to start the local preview.

Rich content per step

Each list item renders as full Markdown, so steps can contain paragraphs, code blocks, callouts and other shortcodes:

text
{{< steps >}}
1. **Create the config file.**

   Copy the starter config to your project root:

   ```toml
   baseURL = "https://docs.example.com/"
   theme   = "bora"
   ```

2. **Add your content.**

   Place Markdown files under `content/docs/`.

3. **Deploy.**

   Push to your hosting provider. Bora generates a fully static site.
{{< /steps >}}
  1. Create the config file.

    Copy the starter config to your project root:

    toml
    baseURL = "https://docs.example.com/"
    theme   = "bora"
  2. Add your content.

    Place Markdown files under content/docs/.

  3. Deploy.

    Push to your hosting provider. Bora generates a fully static site.

Parameters

This shortcode takes no parameters. It styles the first ordered list inside its body.

Notes

  • The shortcode targets the outermost <ol> inside the block. Nested lists are unaffected.
  • Unordered lists (- or *) do not receive the step treatment.

Tabs

Tabbed content with synced groups and a persisted choice, ideal for OS or language variants.

Tabs show alternative versions of the same content side by side, so readers only see what applies to them. Common uses: OS-specific install commands, language-specific code samples and before/after comparisons.

Usage

text
{{< tabs "os" >}}
{{< tab "Linux" >}} …content… {{< /tab >}}
{{< tab "macOS" >}} …content… {{< /tab >}}
{{< /tabs >}}

The optional argument ("os") is a sync group: every block with the same group switches together site-wide, and the choice is remembered across visits.

Synced group

Switch a tab below and watch the second block flip with it:

bash
sudo apt install hugo

Verify with hugo version. Panels hold any Markdown, not just code.

Independent tabs

Omit the group argument for a standalone block that neither syncs nor persists:

text
{{< tabs >}}
{{< tab "JSON" >}}
```json
{ "title": "My Docs" }
```
{{< /tab >}}
{{< tab "TOML" >}}
```toml
title = "My Docs"
```
{{< /tab >}}
{{< /tabs >}}
json
{ "title": "My Docs" }

Notes

  • Tabs are keyboard-accessible: focus a tab, then use //Home/End.
  • All panels are server-rendered, so their content is searchable and visible without JavaScript. All panels expand when printing.
  • The behavior script loads only on pages that use the shortcode.

Video and iframe

Embed YouTube videos, Vimeo videos, self-hosted video files and general iframes.

Bora provides four shortcodes for embedding video and external content.

YouTube (yvideo)

Embeds a YouTube video using the privacy-enhanced youtube-nocookie.com domain. Pass a full URL or a bare video ID:

text
{{< yvideo "https://www.youtube.com/watch?v=dQw4w9WgXcQ" >}}
{{< yvideo "https://youtu.be/dQw4w9WgXcQ" >}}
{{< yvideo "dQw4w9WgXcQ" >}}
{{< yvideo "dQw4w9WgXcQ" title="Never Gonna Give You Up" >}}
Parameter Description
1st positional YouTube URL or bare video ID.
title Accessible iframe title (default: "YouTube video").

Vimeo (vvideo)

Embeds a Vimeo video. Pass a full URL or a bare numeric video ID:

text
{{< vvideo "https://vimeo.com/123456789" >}}
{{< vvideo "123456789" >}}
{{< vvideo "123456789" title="Product demo" >}}
Parameter Description
1st positional Vimeo URL or bare video ID.
title Accessible iframe title (default: "Vimeo video").

Self-hosted video (video)

Renders an HTML5 <video> player for .mp4, .webm or .ogg files served from static/. Relative paths resolve from the current page:

text
{{< video "/media/demo.mp4" >}}
{{< video src="/media/demo.mp4" poster="/media/thumb.jpg" >}}
Parameter Description
1st positional / src Path to the video file.
poster Optional thumbnail image shown before playback.

General iframe (iframe)

Embeds any URL in an iframe with an expand-to-fullscreen button. Press Esc to exit fullscreen.

text
{{< iframe "https://example.com" >}}
{{< iframe url="https://codepen.io/pen/embed/..." title="Live demo" height="500" >}}
Parameter Default Description
1st positional / url required URL to embed.
title "Embedded content" Accessible iframe title.
height 500 Initial height in pixels.

Note

The iframe is sandboxed (allow-scripts allow-same-origin allow-forms allow-popups). Sites that set X-Frame-Options: DENY will not load inside an iframe regardless of this shortcode.

Search

Client-side full-text search scoped to the current version and language.

Try it

Press Ctrl/⌘ K (or click Search… in the topbar) and type manifest. Results deep-link to the exact section, shown as “Page > Heading”.

How it works

At build time the theme emits one searchindex.json for the whole site, chunked per heading. Each h2/h3 section is its own entry with its anchor. Nothing search-related loads with the page. On the first open of the modal the browser fetches the index and the search library in parallel. Pages stay light, and the index is built once per visit. Everything is client-side: queries never leave the browser, no search server exists, and it works in intranet and air-gapped deployments.

Scoping

Results are scoped to the current version and language by default. The modal’s filters switch scope, and the All button searches everything, including the changelog.

Keyboard

Keys Action
Ctrl/⌘ K Open or close
Type Live results
Enter Open the first result
Move through results
Esc Close

Press ? anywhere for the full shortcut overview.

The 404 page searches too

A missing URL offers a Search the docs button prefilled with the broken link’s slug. Readers landing on dead links from old bookmarks find the moved page in one keystroke.

Important

Search requires the SearchIndex output format in your hugo.toml. See Configuration. Without it the modal reports “Search is unavailable”.

Navigation

Sidebar, on-this-page column, breadcrumbs, prev/next and keyboard shortcuts.

The left sidebar lists the current version and language’s sections as collapsible groups. Clicking a group header expands or collapses it without navigating. Ordering follows weight. Sub-sections nest: a directory inside a section renders as an indented nested group, recursively.

The sidebar state (open/closed) persists. The group containing the current page auto-opens, the active page is highlighted, and on mobile the panel closes after choosing a page.

On this page

Wide screens (1280 px and wider) get a sticky right column built from the page’s h2/h3 headings, with the current section highlighted while scrolling. Clicking an entry jumps to the heading and flashes it so the reader sees where they landed. That is also why this page has several headings.

Every article shows its trail (home > version > language > section > page) with the version label rendered as dots (v0.0.1). The same trail is emitted as structured data for search engines. See SEO and AI readability.

Previous / Next

Article pages link their neighbors in sidebar order, strictly within the current version and language. The pager never jumps across versions or languages.

Keyboard and accessibility

  • A Skip to content link is the first Tab stop on every page.
  • Dropdowns (version, language, theme) follow the listbox pattern: arrows, Home/End, Enter, Esc.
  • Heading anchors are keyboard-reachable. The active sidebar link carries aria-current="page".
  • Press ? for the shortcuts dialog.

Back to top

A floating button appears after scrolling. One click returns smoothly to the top.

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.

Page extras

Announcement bar, edit links, feedback widget, last-updated date and the changelog.

Everything on this page is opt-in via site.json. Nothing renders until you configure it. This demo site has all of them enabled, so you can see each one live.

Announcement bar

The bar above the topbar. Markdown text with an optional dismiss button. The dismissal remembers the announcement’s id, so a new id re-shows the bar to everyone:

site.jsonjson
"announcement": {
  "id": "release-2026-06",
  "text": "**v2 is out**: [release notes](/changelog/)",
  "dismissible": true
}

Outdated-version banner

Automatic. No configuration is needed beyond the version manifest. Readers on a non-latest version see a banner linking to the same page in the latest version.

Edit this page and last updated

At the bottom of every article:

site.jsonjson
"editUrl": "https://gitlab.com/acme/docs/-/edit/main/content"

The page’s content path is appended automatically. “Last updated” appears when dates exist. Set enableGitInfo = true in hugo.toml to pull dates from git history.

Feedback widget

“Was this page helpful?” with Yes/No at the end of each article (look below). Votes POST as JSON to your endpoint:

site.jsonjson
"feedback": { "url": "https://feedback.acme.com/api/docs" }
payloadjson
{ "href": "/docs/v1_0_0/en/install/", "helpful": true, "version": "v1_0_0", "lang": "en", "ts": "2026-06-12T10:00:00Z" }

No personal data is collected. The endpoint must respond 2xx and allow CORS from the docs origin. A short Cloudflare Worker or Netlify function forwarding to a Slack webhook or spreadsheet is the typical receiver.

Caution

This demo site posts to httpbin.org (a public echo service) purely so the buttons work. Replace or remove feedback.url before shipping.

Printable handbook

The book icon in the settings drawer opens a handbook: the entire current version and language assembled into one page. It starts with a cover, then every section and page in sidebar order, each chapter starting on a fresh sheet. Open it and print to PDF (Ctrl/⌘ P) to get a single coherent manual.

It is a Hugo output format, enabled on each language root’s _index.md:

docs/<token>/<lang>/_index.mdyaml
---
title: "English"
outputs: ["HTML", "Handbook"]
---

That produces one handbook per version and language at docs/<version>/<lang>/handbook.html. It reuses the print stylesheet, always prints with the light theme (ink-friendly and readable) and needs no JavaScript.

Because it is plain HTML, you can also feed it to any HTML-to-PDF tool in your CI pipeline to produce a real, page-numbered PDF. See Print and handbook.

Note

Interactive embeds (Mermaid, draw.io, OpenAPI, PDF) render via JavaScript, so in the static handbook they show a short “view online” note instead. All text, code, images, admonitions, tables and tabs are included in full.

Changelog

A multilingual release-notes section at /changelog/, with one page per release showing the date, colored badges and full Markdown. Create:

text
content/changelog/
├── _index.md            ← title, type: docs, layout: changelog-list, cascade type
└── en/
    ├── _index.md        ← layout: changelog-list, cascade layout: changelog
    └── v1_0_0.md        ← title, date, badges, body
changelog/en/v1_0_0.mdyaml
---
title: "v1.0.0"
date: 2026-06-01
description: "First stable release."
badges: [added, fixed, breaking]
---

Badge vocabulary (translated and theme-colored): added, changed, fixed, deprecated, removed, breaking, security, performance, docs. Each language gets an RSS feed automatically. Copy the _index.md files from this site’s exampleSite/content/changelog/. The front matter wiring matters.

Print and handbook

One-click printable handbook plus PDF export via any HTML-to-PDF tool in CI.

The printable handbook is a plain, self-contained HTML page at a predictable URL:

text
docs/<version>/<lang>/handbook.html

No app chrome, no JavaScript. Just the cover, a linked table of contents and every page in order, styled for print. That makes it ideal input for any HTML-to-PDF tool run in your CI. The theme ships no PDF toolchain of its own, so you keep full control over the renderer and its output.

Why generate the PDF yourself

The handbook’s in-browser print uses the browser’s print dialog, which cannot compute page numbers. That is why its table of contents has no page numbers. A dedicated HTML-to-PDF engine can: it lays the document out into real pages and back-fills numbers and leader dots. If you want a true page-numbered PDF, render the handbook with one of the tools below.

With WeasyPrint (no browser)

WeasyPrint renders HTML and CSS to PDF with its own engine. No headless browser is required, so it is light in CI (Python plus a few system libraries).

bash
# after hugo has built the site into ./public
weasyprint public/docs/v1_0_0/en/handbook.html handbook-v1.0.0-en.pdf

With Paged.js CLI (headless Chrome)

pagedjs-cli drives headless Chromium, so the PDF matches Chrome exactly. Heavier (Node plus Chromium) but pixel-faithful.

bash
npx pagedjs-cli public/docs/v1_0_0/en/handbook.html -o handbook-v1.0.0-en.pdf

Prince, wkhtmltopdf or your own headless-Chrome script work the same way: point them at handbook.html.

Page numbers and a leader-dot TOC

To get the classic Chapter …………… 12 table of contents, add a small print stylesheet that your engine understands (WeasyPrint and Paged.js both support CSS Generated Content for Paged Media):

pdf.csscss
/* number the pages */
@page { @bottom-center { content: counter(page); } }

/* fill the handbook TOC leaders with the target page number */
.handbook-toc__link::after {
  content: leader('.') target-counter(attr(href), page);
}

Pass it to the tool (e.g. weasyprint -s pdf.css …). Because the handbook’s TOC links already point at each page’s anchor, target-counter resolves the real page numbers for you.

In a CI job

A minimal GitLab CI example that produces a downloadable PDF alongside the site:

.gitlab-ci.ymlyaml
pdf:
  stage: deploy
  image: python:3.12-slim
  rules: [{ if: $CI_COMMIT_TAG }]
  script:
    - pip install weasyprint
    - sh ci/scripts/build-docs.sh ci/config.json "$PAGES_PATH"
    - hugo --source exampleSite --environment production --baseURL "${CI_PAGES_URL}/"
    - weasyprint exampleSite/public/docs/<version>/en/handbook.html public/handbook.pdf
  artifacts: { paths: [public] }

The result is a real, page-numbered PDF, generated by the engine you chose, with no PDF dependency baked into the theme.

Tip

Looking for the shortcode that embeds a PDF viewer on a page? See PDF embed.

SEO and AI readability

Open Graph, hreflang, structured data, llms.txt and per-page markdown outputs.

Per-page head, automatically

Every page emits without configuration:

  • canonical URL (self-referencing, safe with separately-deployed versions)
  • Open Graph and Twitter card tags: title, description, type and locale
  • hreflang alternates linking the same page across the version’s languages (only where the page exists), with x-default on the default language
  • BreadcrumbList JSON-LD matching the visible breadcrumbs
  • noindex automatically on non-production builds

Social card image

site.jsonjson
"social": { "cover": "/docs/media/banner.png" }

Per-page override via cover: front matter. With an image set, shares render as large cards. Without one, they appear as text cards. Must be a direct image URL, sized approximately 1200x630.

Browser tint

site.jsonjson
"themeColor": { "light": "#fff8f6", "dark": "#1a110f" }

llms.txt and Markdown outputs

For AI assistants and “read as Markdown” use cases, the build emits:

  • /llms.txt: a curated index (llmstxt.org) of the latest version’s default language, linking to the per-page Markdown files
  • per-page index.md: the raw Markdown of every article, next to its HTML (this page: index.md)

Both come from the LLMS and Markdown output formats in hugo.toml. Remove those lines to disable them.

Internal links and anchors are validated while building:

hugo.tomltoml
[params.render_hooks.link]
errorLevel = "warning"   # ignore | warning | error

Set error in CI to make broken internal links fail the pipeline. The same pattern exists for remote images. See Images and links.