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
- Get started → Install — add the module to your site and get it building.
- Get started → Layout — understand the header/sidebar/footer slot system before diving into individual options.
- Feature parity with upstream — what the original Sphinx theme has that this port doesn’t (yet).
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_textwhen using an image logo (see Branding ) rather than relying on the fallback. - Sitemaps — Hugo generates a
sitemap.xmlautomatically for every build; no theme configuration or extension (unlike Sphinx’ssphinx_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 = trueShows a floating button that scrolls back to the top of the page once you’ve scrolled down. Set to false to remove it.
Branding
Logo
[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_lightset,textrenders as a plain text brand link (falls back tosite.Titleiftextis unset). - With
image_lightset, the image is used instead;image_darkswaps in for dark mode (falls back toimage_lightifimage_darkis unset). Text still renders alongside the image iftextis explicitly set. alt_textsets the image’saltattribute (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 feature | Status here | Why |
|---|---|---|
Version switcher dropdown (switcher, check_switcher) | Not implemented | No switcher/version-JSON mechanism exists in this port’s params or templates. |
Version warning banner (show_version_warning_banner) | Not implemented | Depends on the version switcher above. |
Google Analytics / Plausible injection (analytics) | Not implemented | No 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 applicable | Sphinx-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 implemented | No equivalent partial exists yet in this port. |
Sphinx indices (genindex/modindex/py-modindex) | Not applicable | These are Sphinx/Python-doc-specific autogenerated indices with no Hugo concept to map to. |
| ABlog / sphinx-design / sphinx-togglebutton integration | Not applicable | These 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) | Implemented | Ported natively as a codeblock render hook — every fenced code block gets a copy button automatically, no shortcode needed. |
| MathJax / math roles | Not applicable | No math rendering integration in this port. |
Pygments light/dark syntax style (pygments_light_style/pygments_dark_style) | Not implemented | Hugo’s Chroma highlighter is configured at the site level ([markup.highlight]), not through a theme param — see Styling
. |
show_nav_level | Declared, not implemented | Present 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_depth | Declared, not implemented | Same as above. |
collapse_navigation | Declared, not implemented | Same as above. |
icon_links_label | Declared, not implemented | No template currently renders this label. |
| ReadTheDocs-specific integration (flyout menu, ethical ads sidebar) | Not applicable | Specific to the ReadTheDocs hosting platform. |
sphinx-remove-toctrees / stub-page pruning | Not applicable | Toctree-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
External link behavior
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 installThat’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
| Param | Default | Effect |
|---|---|---|
show_title | true | Auto-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 bynavbar-nav.html), with automatic overflow into a “More” dropdown pastheader_links_before_dropdownentries (see Header links ).site.Menus.sidebar— the left sidebar nav tree (rendered bysidebar-nav.html, see Sidebar navigation ), falling back tosite.Menus.mainif 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).