pydata-hugo-theme is a Hugo Module port of the PyData Sphinx Theme — a generic, site-agnostic theme for any Hugo project, with no branding or content baked in.

This site is itself built with the theme (see the sidebar), so everything you see here — the navbar, sidebar, search, dark mode switcher, footer — is the theme working out of the box with no site-level template overrides.

Where to start

Credit

This is an unofficial, community-maintained port. It is not affiliated with, endorsed by, or maintained by the PyData team. See Credit / Attribution in the repository README for the full notice, and LICENSE for licensing.

Accessibility

General, framework-agnostic recommendations (adapted from upstream’s guidance, which applies just as well here):

  • Color contrast — if you override --pst-* color variables (see Styling ), check contrast ratios against WCAG AA, especially for both light and dark mode.
  • Keyboard navigation — the search dialog, theme switcher, and sidebar toggle are all keyboard-operable out of the box; if you add custom partials into any slot, keep them keyboard-accessible too.
  • Logo alt text — always set logo.alt_text when using an image logo (see Branding ) rather than relying on the fallback.
  • Sitemaps — Hugo generates a sitemap.xml automatically for every build; no theme configuration or extension (unlike Sphinx’s sphinx_sitemap) is needed.

Announcements

announcement shows a dismissible-looking banner above the navbar. Two modes, chosen automatically by the value:

[params]
  # Static: rendered as-is (raw HTML allowed)
  announcement = "See what's new in <a href=\"/blog/\">the changelog</a>."

  # Remote: any value starting with "http" is treated as a URL to fetch
  # the banner's HTML content from at runtime instead
  announcement = 'https://example.com/announcement.html'

Leave it unset (the default) to disable the banner entirely.

Asset pipeline

The FontAwesome and Bootstrap JS mounted via [[module.mounts]] (see Hugo Modules ) are loaded through Hugo Pipes , not referenced as plain static <script src> tags:

{{- with resources.Get "vendor/fontawesome/all.min.js" }}
  {{- if hugo.IsDevelopment }}
    <script src="{{ .RelPermalink }}" defer></script>
  {{- else }}
    {{- with . | fingerprint }}
      <script src="{{ .RelPermalink }}" integrity="{{ .Data.Integrity }}" crossorigin="anonymous" defer></script>
    {{- end }}
  {{- end }}
{{- end }}

In development builds, the file is served as-is for fast rebuilds. In production builds (hugo --minify, or whenever hugo.IsDevelopment is false), it’s fingerprinted — Hugo computes a content hash, appends it to the URL for cache-busting, and adds a Subresource Integrity (integrity) attribute the browser verifies before executing the script. Sphinx has no equivalent — static files there are referenced as-is, with no build-time hashing or integrity verification.

Back to top

[params]
  back_to_top_button = true

Shows a floating button that scrolls back to the top of the page once you’ve scrolled down. Set to false to remove it.

Branding

[params.logo]
  text        = 'My Site'
  link        = '/'
  # image_light = 'images/logo-light.png'
  # image_dark  = 'images/logo-dark.png'
  alt_text    = 'My Site — Home'
  • With no image_light set, text renders as a plain text brand link (falls back to site.Title if text is unset).
  • With image_light set, the image is used instead; image_dark swaps in for dark mode (falls back to image_light if image_dark is unset). Text still renders alongside the image if text is explicitly set.
  • alt_text sets the image’s alt attribute (falls back to "<site title> - Home").

This documentation site intentionally only sets logo.text and ships no sample logo image at all — this is an unofficial, unaffiliated port, so it deliberately avoids using (or even hotlinking) the original PyData Sphinx Theme’s branding.

Content-Security-Policy

head.html emits a <meta http-equiv="Content-Security-Policy"> tag on every page, with script-src already covering the CDNs the theme’s own search feature needs (cdnjs for FontAwesome, jsdelivr for Fuse.js). If your site embeds a third-party iframe (a calendar widget, a video embed, etc.), extend frame-src via:

[params]
  csp_frame_src = ['https://calendar.google.com']

'self' is always included automatically — you only need to list additional origins. Upstream Sphinx docs don’t ship a CSP meta tag at all; this is engineering specific to this port.

Feature parity with upstream

Where this port diverges from the upstream PyData Sphinx Theme user guide — either because a feature has no meaningful Hugo equivalent, or because it’s declared but not yet implemented in this port’s templates.

Upstream featureStatus hereWhy
Version switcher dropdown (switcher, check_switcher)Not implementedNo switcher/version-JSON mechanism exists in this port’s params or templates.
Version warning banner (show_version_warning_banner)Not implementedDepends on the version switcher above.
Google Analytics / Plausible injection (analytics)Not implementedNo analytics param or script-injection partial exists; add your own via a site-level content_footer_items/footer_end partial if needed.
i18n message catalogs (gettext/pybabel)Not applicableSphinx-specific tooling. Hugo has its own multilingual/i18n system , unrelated to this theme’s params.
“Edit this page” / “view source” buttons (use_edit_page_button, html_context)Not implementedNo equivalent partial exists yet in this port.
Sphinx indices (genindex/modindex/py-modindex)Not applicableThese are Sphinx/Python-doc-specific autogenerated indices with no Hugo concept to map to.
ABlog / sphinx-design / sphinx-togglebutton integrationNot applicableThese are Sphinx extensions. Hugo handles equivalent needs (blogging, callouts) through its own content/shortcode mechanisms, outside this theme’s scope.
sphinx-copybutton (copy-to-clipboard on code blocks)ImplementedPorted natively as a codeblock render hook — every fenced code block gets a copy button automatically, no shortcode needed.
MathJax / math rolesNot applicableNo math rendering integration in this port.
Pygments light/dark syntax style (pygments_light_style/pygments_dark_style)Not implementedHugo’s Chroma highlighter is configured at the site level ([markup.highlight]), not through a theme param — see Styling .
show_nav_levelDeclared, not implementedPresent in hugo.toml defaults (carried over from Sphinx’s theme.conf), but no template logic reads it — the sidebar tree always renders fully expanded.
navigation_depthDeclared, not implementedSame as above.
collapse_navigationDeclared, not implementedSame as above.
icon_links_labelDeclared, not implementedNo template currently renders this label.
ReadTheDocs-specific integration (flyout menu, ethical ads sidebar)Not applicableSpecific to the ReadTheDocs hosting platform.
sphinx-remove-toctrees / stub-page pruningNot applicableToctree-driven sidebar generation is a Sphinx concept; this port’s sidebar is menu-driven (see Menus ).

If you need any of the “not implemented” rows above, contributions are welcome — see the module’s README.md for how to work on this repo locally.

Fonts

FontAwesome and Bootstrap’s JS are pulled in as build-time npm dependencies and mounted into the asset pipeline via [[module.mounts]] in this module’s own hugo.toml — see Asset pipeline for how the mounting/fingerprinting works.

Overriding the bundled FontAwesome version

The pinned version lives in this module’s own package.json. A consuming site can override it without forking the theme by mounting its own copy at the same target path — Hugo resolves mounts from the site’s own node_modules first:

Header links

Every content-authored Markdown link and most theme-rendered links pass through link-target-attrs.html, which decides whether a link opens in a new tab based on external_links:

[params.external_links]
  new_tab    = true
  exceptions = ['https://docs.example.com/']

A URL is treated as internal (no new tab) if it’s not http(s), points back at site.BaseURL, or matches a prefix in exceptions. This is applied automatically to every Markdown-authored link via a render hook — you don’t need to add target="_blank" by hand anywhere in your content.

Hugo Modules

This is the mechanism the theme itself is distributed through — Sphinx has no equivalent concept (a Sphinx theme is just a Python package with a theme.conf, not a versioned, mountable dependency graph).

Explicit mounts

Adding any [[module.mounts]] entry disables Hugo’s implicit default mounts, so this module’s hugo.toml re-declares every standard component directory explicitly (content, static, layouts, data, assets, i18n, archetypes), plus two npm-sourced mounts that pull prebuilt JS straight out of node_modules into the asset pipeline:

Install

Add the module to your site’s hugo.toml:

[module]
  [[module.imports]]
    path = "github.com/saltstack/pydata-hugo-theme"

Then fetch it and its build-time npm dependencies (FontAwesome’s JS+SVG icon kit, Bootstrap’s JS bundle, mounted via [[module.mounts]]):

hugo mod get github.com/saltstack/pydata-hugo-theme
npm install

That’s it — hugo server/hugo build will now resolve the theme.

Offline / reproducible builds

By default Hugo resolves the module (and its npm mounts) fresh at build time, which needs Go and network access. If you’d rather commit a fully offline, reproducible copy — no Go toolchain, no npm, no live fetch during CI — run:

Layout

The theme renders a page as a set of named “slots” — each a list of partial names, resolved via site.Params.* — rather than a single fixed template. Override just the slot lists you care about; everything else falls back to the theme’s defaults.

Page title

ParamDefaultEffect
show_titletrueAuto-renders <h1>{{ .Title }}</h1> before the page content

Resolved page frontmatter → site.Params → hardcoded default, same as other overridable params. To omit the auto-rendered heading on a single page (e.g. because the Markdown body already starts with its own # heading), set show_title: false in that page’s frontmatter. To change the site-wide default, set show_title = false under [params] in hugo.toml.

Light / dark mode

[params]
  default_mode = 'auto'  # 'light' | 'dark' | 'auto'

auto follows the visitor’s OS/browser color-scheme preference. The mode is read/written to localStorage client-side, so a visitor’s explicit choice persists across pages and future visits.

The theme switcher

theme-switcher.html renders a dropdown (light/dark/system) — it’s in navbar_end by default. Remove it from your navbar slots to hide the control entirely (visitors then always get whatever default_mode resolves to):

Markdown render hooks

layouts/_default/_markup/render-link.html is a Hugo render hook — it intercepts every [text](url) link Hugo renders from your Markdown content, site-wide, and applies the same external_links new-tab/exceptions rules (see Header links ) that the theme’s own hand-written partials use.

The practical effect: you never need to write target="_blank" rel="noopener" by hand in a blog post or page — every content-authored link gets consistent external-link behavior automatically, driven by one site-wide config block.

Menus

Sphinx derives navigation from the doc tree (toctree directives) — there’s no separately configured menu structure. This port instead uses two independent Hugo menus:

  • site.Menus.main — top navbar links (rendered by navbar-nav.html), with automatic overflow into a “More” dropdown past header_links_before_dropdown entries (see Header links ).
  • site.Menus.sidebar — the left sidebar nav tree (rendered by sidebar-nav.html, see Sidebar navigation ), falling back to site.Menus.main if you don’t define one.

Because they’re independent, your top nav and sidebar nav don’t have to mirror each other — a common pattern (used by this docs site itself) is a short main menu (just “Docs” + “GitHub”) alongside a much deeper sidebar menu for in-page navigation.

Page table of contents

The right sidebar renders secondary_sidebar_items (default ['page-toc'], which renders .TableOfContents from your Markdown headings via page-toc.html).

[params]
  secondary_sidebar_items = ['page-toc']

Hiding it on a single page

Set show_toc: false in a page’s frontmatter to hide the entire right sidebar for that page (it isn’t just the TOC — the whole secondary_sidebar_items slot is skipped):

---
title: A wide page with no right sidebar
show_toc: false
---

This is a Hugo-idiomatic per-page override — see Per-page overrides for how the fallback chain (page → site → hardcoded default) works generally.

Per-page overrides

Several slot/behavior params can be overridden on a single page via frontmatter, resolved through a page → site → hardcoded-default fallback chain (.Params.x | default (site.Params.x | default fallback)):

---
title: A page with no right sidebar
show_toc: false
---

show_toc (see Page table of contents ) overrides secondary_sidebar_items for just that page, without touching the site-wide default. show_prev_next works the same way, overriding the site-wide show_prev_next param (default true) to hide (or show) the prev/next footer links on a single page — useful for standalone pages like an event calendar or an RSS feed list, where “previous/next page in section” links don’t make sense:

Search

Search

Search is powered by Fuse.js against a build-time JSON index (layouts/index.json), fetched client-side — not a Sphinx-style server-generated searchindex.js.

Required consumer-site setup: the index is only built when the home page’s JSON output format is enabled. Add this to your site’s hugo.toml (most starter configs don’t enable it by default):

[outputs]
  home    = ['HTML', 'RSS', 'JSON']
  section = ['HTML', 'RSS']
  page    = ['HTML']

Without this, index.json is never generated and the search box silently returns nothing — there’s no visible error, just no results.

Sidebar navigation

The left sidebar nav tree is built from site.Menus.sidebar — falling back to site.Menus.main if no sidebar menu is defined — rendered by sidebar-nav.html. This site’s own sidebar (see the left of this page) is built exactly this way; its hugo.toml defines [[menus.sidebar]] entries with identifier/parent/weight to create the grouped, collapsible tree you’re looking at.

A top-level entry with an empty url = '' and an identifier renders as an expandable group header; entries that set parent = "<identifier>" nest underneath it.

Static assets

If you’re coming from Sphinx: this theme’s extra_css param (see Styling ) is the direct equivalent of html_css_files. There’s no direct equivalent of html_js_files/app.add_js_file as a theme param — for custom JS, use Hugo’s own asset pipeline (Hugo Pipes ) at the site level: drop a script in your site’s assets/, process/fingerprint it with resources.Get/fingerprint in a site-level partial override, and add that partial to whichever slot you want it in (e.g. content_footer_items, footer_end).

Styling

CSS custom properties

The vendored static/styles/pydata-sphinx-theme.css exposes the same --pst-* CSS custom properties as the upstream theme (colors, fonts, spacing, sidebar widths, etc.). Override them in your own stylesheet:

:root {
  --pst-color-primary: #123456;
}

Adding your own stylesheet

[params]
  extra_css = ['/css/custom.css']

extra_css is this theme’s equivalent of Sphinx’s html_css_files — a list of stylesheet paths or full URLs, injected after the theme’s own CSS (so your rules can override theme defaults without !important).