A Hugo theme module that overlays Hextra
with shared shortcodes, partials, and CSS used across Solo's documentation
sites — plus a bundled HTML test harness that any consumer repo can
re-run against its own built public/.
Two faces, one repo:
- Hugo theme module — consumers import this via
go.mod. Hextra comes along as a transitive dependency. - Playwright HTML-only harness — consumers point it at their built output via
make test CONFIG=path/to/.docs-test.toml.
Tip
Authoring or maintaining content? See USAGE.md for the shortcodes and render behavior this module adds on top of, or changes from, stock Hugo and Hextra (with links to the upstream docs), plus which Hextra layout files this module shadows and how to maintain them.
github.com/imfing/hextra
│
│ hugo module import
▼
docs-theme-extras
│ │ │
│ │ └── tests/ Playwright HTML-only harness (17 specs)
│ │ helpers/ config loader, crawl, target, shortcodes
│ │
│ └── layouts/ shortcodes, partials, _markup hooks,
│ │ default+docs/ layouts
│ └── partials/utils/page-context.html ← dual-mode (url|siteParams)
│
└── assets/css/
├── docs-theme-extras.css always loaded; component baseline
├── brand-oss.css loaded when brand=oss
├── brand-enterprise.css loaded when brand=enterprise
└── custom.css per-repo slot (consumer overrides last)
A page rendered against this module loads CSS in this order:
- Hextra's compiled bundle (Tailwind + theme defaults)
docs-theme-extras.css— component-level styling for.version-dropdown,.copy-md-btn,.section-card, breadcrumb, sidebar, TOC, etc. Uses CSS custom properties (--theme-primary,--theme-primary-hover,--theme-primary-tint) with neutral defaults.brand-{oss,enterprise}.css— overrides the theme vars and adds brand-specific font-family rules. Ships in this module; consumers opt in via a single config flag (see below).- The consumer's own
assets/css/custom.css— per-repo overrides (Hextra concatenates this into its main bundle, so it loads earlier in HTML order; rules with higher specificity or later cascade order still win on conflicts).
Each consumer declares one of two brand variants (or leaves it unset):
# Enterprise consumer
[params.themeExtras]
brand = "enterprise"
# OSS consumer
[params.themeExtras]
brand = "oss"
# A new consumer with no brand layer
# (omit themeExtras.brand entirely)The module's head-end.html partial reads the flag and conditionally
links the matching brand-*.css file. Brand swap is one config change;
the module's component CSS is unchanged.
| OSS | Enterprise | |
|---|---|---|
| Primary | hsl(212, 100%, 45%) |
#158bc2 |
| Body / heading font | Open Sans | Apple system stack |
| Heading colors | (inherits theme) | #253e58 light / #fff dark |
| Link colors | inherits --theme-primary |
#158bc2 / #106a94 |
Some shortcodes need to know the page's section / version / build
condition (e.g., conditional-text, version, link-hextra). Two
URL conventions exist across consumers:
siteParams— for multi-product hubs that mount each product at<host>/<product>/<version>/...and surface that mapping viaSite.Params.{folder, currentProduct, buildCondition, versions}.url— for single-site repos where the URL itself encodes section and version (e.g.,<host>/docs/<section>/<version>/...). ParsesPage.RelPermalink.
Each consumer picks one in their hugo config:
[params]
pageContextMode = "siteParams" # or "url"; default "url"Shortcodes that need page context call partial "utils/page-context" .
and read .section, .version, .condition, .prefix from the
returned dict. Each branch handles one convention.
# hugo.yaml (or hugo.toml)
module:
imports:
- path: github.com/solo-io/docs-theme-extras# During active development, track main:
hugo mod get github.com/solo-io/docs-theme-extras@main
hugo mod tidyThe module is still iterating quickly, so consumers track main for now.
Hugo's module system rewrites @main into a pseudo-version + commit SHA
in go.mod (e.g., v0.0.0-20260508153012-abc1234def56), so the pin is
still reproducible — bumping it is hugo mod get …@main again, which
shows up as a SHA change in the PR diff.
Once the module stabilizes, switch to explicit semver tags (@v0.1.0,
etc.) and treat @latest / floating branch refs as unsupported.
[params.themeExtras]
brand = "oss" # or "enterprise"
pageContextMode = "url" # or "siteParams"# .docs-test.toml
version = "1"
name = "my-docs-site"
brand = "oss" # or "enterprise"; matches params.themeExtras.brand
builtRoot = "./public"
baseURL = "/docs"
buildLog = "./build.log"
[[pages]]
url = "/docs/quickstart/"
[versioning]
versionFromPath = "^/docs/(?<version>v\\d+|main)/"
versions = ["v1", "v2", "main"]
[checks]
codeBlockIntegrity = false
[allowlists]
hugoWarnings = []The harness lives here, in tests/. Each consumer's CI checks out
solo-io/docs-theme-extras at the SHA pinned in its own go.mod (the
pseudo-version that hugo mod get produced) so layouts and tests stay
in lockstep — bumping the module pin is one PR that updates both.
The minimum-viable workflow for a single-site consumer:
# .github/workflows/framework-tests.yml
name: Framework tests
on: [pull_request, workflow_dispatch]
jobs:
framework-test-static:
runs-on: ubuntu-latest
continue-on-error: true # soft signal for the first ~week
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with: { go-version: 'stable', cache: false }
- uses: peaceiris/actions-hugo@v3
with: { hugo-version: '0.160.1', extended: true }
- name: Resolve docs-theme-extras SHA from go.mod
id: theme-sha
run: |
sha=$(grep "docs-theme-extras" go.mod | grep -oE '[0-9a-f]{12}' | head -1)
echo "sha=$sha" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v6
with:
repository: solo-io/docs-theme-extras
ref: ${{ steps.theme-sha.outputs.sha }}
path: docs-theme-extras
- uses: actions/setup-node@v6
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: 'docs-theme-extras/package-lock.json'
- name: Build site
run: hugo --gc --minify
- name: Install harness deps
working-directory: docs-theme-extras
run: npm ci
- name: Run static specs
working-directory: docs-theme-extras
env:
DOCS_TEST_CONFIG: ${{ github.workspace }}/.docs-test.toml
run: npx playwright test --project=static --reporter=list,htmlThe harness splits into two file-scan projects, categorized by what each spec reads:
--project=static— every spec renders the theme's bundled fixture and asserts theme behavior (versioning, cards, sidebar, callouts, shortcode edge cases). Against a consumer's own build thesetest.skip(their fixture pages aren't in the consumer'sbuiltRoot), so they carry signal only when layouts change. Gate on layout paths (layouts/**,static/**,assets/css/**,assets/js/**,go.mod,hugo.yaml).--project=content— every spec reads the consumer's own content: the built HTML tree (markdown-leaksrendering leaks;missing-images<img>/<source>references that resolve to an unpublished file;copy-md-fidelitycopy-as-markdown output;hugo-warningsbuild-log warnings) or the markdown source (curl-quotes,tab-syntax,shortcode-args,include-form,cascade-type— all walkscanRoots). Pass/fail tracks content edits, so gate on content paths AND layout paths (content/**, plus your page/snippet roots such asassets/<product>-docs/**, plus the layout paths above) — content edits and layout edits both change what renders.
The categorization is by input, not by name: a spec that scans consumer
content belongs in content even if it feels "static." Because every static
spec skips against a consumer build, running static on a content PR is pure
no-op — so a content PR only needs --project=content, and only a layout PR
needs both.
The common trap this split fixes: a workflow gated only on layouts/**
never fires on a content-only PR, so the leak scan never runs on the exact
PRs that introduce content rendering breaks. The fix is just the trigger —
make sure content runs on content paths. The simplest wiring is a single
workflow gated on content paths + layout paths that builds once and runs
both projects in one step:
run: npx playwright test --project=static --project=content --reporter=list,htmlTwo viable wirings, both fine:
- One workflow, both projects (shown above) — simplest. Since every
staticspec skips against a consumer build, running it on a content PR is an instant no-op, and both projects share the one Hugo build (the slow part), so the waste is negligible. agw-oss'sframework-tests.ymluses this. - Two path-filtered workflows — a content workflow (
--project=content, content + layout paths) and a layout workflow (--project=static, layout paths only). Slightly more config, but a content PR then runs only the specs that can actually fail on it. Prefer this if you want the CI summary to show only relevant checks per PR.
Multi-product hub repos (one site, many product subpaths) run the same
content project per product via a matrix, setting CONTENT_DIR=<product>
so each job scans only that product's subtree of builtRoot. They add extra
jobs for --project=browser, plus per-product artifact downloads in place of
the inline hugo build step. (CONTENT_DIR replaced the former dedicated
"smoke" project + SMOKE_PRODUCT env — the built-HTML checks now live in
content, scoped by directory.)
The local-invocation pattern depends on the consumer repo's setup. Two working examples today:
Pattern A — consumer ships Makefile targets that drive the harness
from a sibling clone. Recommended for repos where multiple developers
will run tests regularly. Targets are prefixed framework-test-* so they
don't collide with the test-* namespace some consumers reserve for doc
tests (code blocks executed against a cluster):
# One-time: clone docs-theme-extras as a sibling
git clone https://github.com/solo-io/docs-theme-extras ../docs-theme-extras
cd <consumer-repo>
make framework-test-install # npm + Playwright browsers in the sibling
# Build the test fixture / site, then run a project
make framework-test # all projects (static, browser, cross-browser)
make framework-test-static # fastest loop — ~2s after Hugo build
make framework-test-browser # chromium only
make framework-test-cross-browser # chromium + firefox + webkit
make framework-test-content CONTENT_DIR=<product> # scope content scan to one product subtree (hubs)
# Override the sibling location if needed
make framework-test FRAMEWORK_EXTRAS_DIR=/abs/path/to/docs-theme-extrasThe sibling-clone pattern, the FRAMEWORK_EXTRAS_DIR override variable,
and the per-target signatures are conventions a Makefile-shipping consumer
opts into. See Makefile examples in the consumer repos for the full template.
Pattern B — consumer invokes the harness directly. Works for any consumer without Makefile scaffolding; useful as a starter or for ad-hoc runs:
# One-time: clone docs-theme-extras as a sibling and install
git clone https://github.com/solo-io/docs-theme-extras ../docs-theme-extras
cd ../docs-theme-extras && npm ci && npx playwright install --with-deps chromium
# Build the consumer site, then run the harness against it
cd <consumer-repo> && hugo --gc --minify
cd ../docs-theme-extras && \
DOCS_TEST_CONFIG=$(pwd)/../<consumer-repo>/.docs-test.toml \
npx playwright test --project=staticEither pattern resolves builtRoot from the consumer's .docs-test.toml,
so any consumer can run the same harness against its own public/ once
the config is in place.
A consumer that imports the module will see strictly fewer Playwright tests
than make test-oss / make test-enterprise runs against the bundled
fixture. This is by design, not a bug — different specs need different
inputs, and a real product site can't provide all of them.
Four mechanisms determine which specs run for a given .docs-test.toml:
These specs were written against the bundled fixture, which contains a
/everything/ page (calls every shortcode the framework cares about) and
a /rebased/ page (alternate-render path). Each spec greps target.pages
for a URL ending in those segments; if neither match, the whole spec is
test.skip'd. Affected specs:
browser.spec.ts— tabs, mermaid SVG, copy-md script, theme toggle, console errorscross-browser.spec.ts— same checks across chromium/firefox/webkitpresence.spec.ts— DOM-structure assertionsauto-cards.spec.ts—<div class="hextra-cards">child assertionsgithub-shortcode.spec.ts—{{< github >}}rendered-body presenceversioning.spec.ts— needs/everything/to exist under each declared version
To opt in, declare [[pages]] entries in your .docs-test.toml whose URLs
end in /everything/ and /rebased/. Real product docs rarely have pages
that call every shortcode in one place, so most consumers leave these
specs off and rely on the module's own self-test for fixture-dependent
checks.
[checks]
codeBlockIntegrity = false # e.g. a consumer with a known backlog of fenced-block fragmentationAll checks default to enabled. Setting false skips that spec entirely.
[crawl]
maxFiles = 0 # 0 = unlimited; default 50Only the browser crawl (console-errors.spec.ts, the browser-crawl
project) is capped — opening pages in Chromium is expensive, so it samples 50
by default. Set maxFiles = 0 to open every built page. The cheap file-read
scans (content project) always walk every page regardless.
For the consumer's own build the static project already crawls
everything, so the default cap is the right choice.
buildLog = "./.build.log" # path the harness reads for Hugo warningsThe Hugo-warnings spec parses the build log for unallowlisted warnings.
If buildLog isn't set, the spec skips. The consumer's make/CI must
arrange for Hugo to write the log at that path (hugo > .build.log 2>&1).
scanRoots = ["./content/docs"]Source-tree lints (currently curl-quotes.spec.ts) walk every *.md
under each listed root. An empty or omitted list means the lint skips —
useful when a corpus has too many pre-existing violations to ratchet
in one go.
Specs that operate on the whole built tree by crawling builtRoot work
for every consumer regardless of [[pages]]:
static.spec.ts— shortcode delimiter leaks, raw markdown bleed, copy-as-md script presence, image alt textcontrast.spec.ts/viewport.spec.ts(mostly) — sampled across crawled pages
So the floor for any consumer is structural HTML quality. Fixture-driven interactive checks are bonus coverage that only the module's self-test (or a consumer that ships a synthetic fixture page) can provide.
make install # npm dependencies
# Local dev preview, brand-conditional
make server-oss # http://localhost:1313/ (OSS brand)
make server-enterprise # http://localhost:1313/ (enterprise brand)
# Static brand builds (production-shaped baseURL=/test)
make build-oss # → public-oss/test/
make build-enterprise # → public-enterprise/test/
# Self-test against the bundled fixture
make test-oss # build-oss + harness
make test-enterprise # build-enterprise + harness
make test-all # both — CI default
# Generic harness against any pre-built site
make test CONFIG=/path/to/consumer-repo/.docs-test.toml
make clean # wipe build outputs and test reportsThe dev server uses baseURL = "/" (via hugo-{oss,enterprise}-local.toml)
because Hugo's dev server gets confused by path-only baseURLs. The static
builds use baseURL = "/test" to match the URL shape consumer repos
emit in production.
If you switch brands and the dev preview still looks like the previous
brand, the make targets auto-clear Hugo's resources/ cache and pass
--ignoreCache. You may also need to hard-reload the browser
(Cmd+Shift+R) — Hugo re-emits CSS at the same URL paths so a soft
reload reuses the cached version.
.
├── go.mod Hugo module declaration
├── theme.toml Hextra-style theme metadata
├── package.json Playwright + serve + smol-toml
├── playwright.config.ts Reads DOCS_TEST_CONFIG TOML
├── Makefile Build + test targets
├── README.md
├── USAGE.md Authoring + maintaining reference: shortcode
│ and render-hook diffs vs Hugo/Hextra, plus
│ the Hextra files this module shadows
├── LICENSE Apache-2.0
├── MIGRATION_AUDIT.md Phase-0 audit (kept for reference)
│
├── hugo-oss.toml Static build, brand=oss
├── hugo-oss-local.toml Dev server, brand=oss
├── hugo-enterprise.toml Static build, brand=enterprise
├── hugo-enterprise-local.toml Dev server, brand=enterprise
│
├── layouts/ Module's overlay on top of Hextra
│ ├── _markup/ render-link, render-table hooks
│ ├── default/list.html Auto-card section index
│ ├── docs/{single,list}.html Doc page templates
│ ├── partials/ Navbar, sidebar, breadcrumb, copy-md, ...
│ │ └── utils/page-context.html Dual-mode partial (url|siteParams)
│ └── shortcodes/ 21 shortcodes (alert, callout, version, ...)
│
├── assets/ Top-level CSS/JS shared by all builds
│ ├── css/{docs-theme-extras,brand-oss,brand-enterprise,custom}.css
│ └── js/{flexsearch.js,core/toc-scroll.js}
│
├── fixture/ Bundled fixture content + assets
│ ├── content/en/test/{v1,v2,main}/{everything,rebased,_index}.md
│ ├── assets/conrefs/test/ Master conref + snippets
│ ├── assets/test/openapi/
│ ├── static/ Static files (images, logos, openapi)
│ └── .docs-test-{oss,enterprise}.toml Harness config per brand
│
├── tests/ Playwright specs
│ ├── *.spec.ts specs (content, static, browser, ...)
│ └── helpers/ config, target, crawl, shortcodes, ...
│
├── static/test/readfile-sample.txt Top-level path for Hugo's readFile
│ (filesystem-path, not module-mount)
│
└── .github/workflows/self-test.yml CI: runs make test-all on PRs
Apache 2.0 — see LICENSE.