Skip to content

solo-io/docs-theme-extras

Repository files navigation

docs-theme-extras

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.

Architecture

   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)

CSS layer order

A page rendered against this module loads CSS in this order:

  1. Hextra's compiled bundle (Tailwind + theme defaults)
  2. 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.
  3. 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).
  4. 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).

Brand mechanism

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

Page-context partial

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 via Site.Params.{folder, currentProduct, buildCondition, versions}.
  • url — for single-site repos where the URL itself encodes section and version (e.g., <host>/docs/<section>/<version>/...). Parses Page.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.

Consuming this module

1. Pin the version in your hugo config

# 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 tidy

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

2. Declare your brand

[params.themeExtras]
  brand = "oss"          # or "enterprise"
  pageContextMode = "url"  # or "siteParams"

3. Add a test config at the consumer repo root

# .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 = []

4. Wire CI to check out the harness at the module pin

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,html

Static vs content: run content scanners on content PRs too

The 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 these test.skip (their fixture pages aren't in the consumer's builtRoot), 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-leaks rendering leaks; missing-images <img>/<source> references that resolve to an unpublished file; copy-md-fidelity copy-as-markdown output; hugo-warnings build-log warnings) or the markdown source (curl-quotes, tab-syntax, shortcode-args, include-form, cascade-type — all walk scanRoots). Pass/fail tracks content edits, so gate on content paths AND layout paths (content/**, plus your page/snippet roots such as assets/<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,html

Two viable wirings, both fine:

  • One workflow, both projects (shown above) — simplest. Since every static spec 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's framework-tests.yml uses 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.)

5. Run the harness locally

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-extras

The 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=static

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

Why a consumer may run fewer specs than the module's self-test

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:

1. Fixture-aware specs require /everything/ and /rebased/ pages

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 errors
  • cross-browser.spec.ts — same checks across chromium/firefox/webkit
  • presence.spec.ts — DOM-structure assertions
  • auto-cards.spec.ts<div class="hextra-cards"> child assertions
  • github-shortcode.spec.ts{{< github >}} rendered-body presence
  • versioning.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.

2. [checks] table can disable specs explicitly

[checks]
codeBlockIntegrity = false   # e.g. a consumer with a known backlog of fenced-block fragmentation

All checks default to enabled. Setting false skips that spec entirely.

2a. [crawl].maxFiles caps the browser crawl

[crawl]
maxFiles = 0   # 0 = unlimited; default 50

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

3. buildLog enables hugo-warnings.spec

buildLog = "./.build.log"   # path the harness reads for Hugo warnings

The 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).

4. scanRoots controls source-side lints

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.

What still runs without any of the above

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 text
  • contrast.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.

Local development of this module

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 reports

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

Repo layout

.
├── 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

License

Apache 2.0 — see LICENSE.

About

Extra files to overlay with the Hextra theme.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages