"""A lightweight book theme based on the pydata sphinx theme."""
from pathlib import Path
import json
import os
import hashlib
from functools import lru_cache
import subprocess
from datetime import datetime, timedelta, timezone
import yaml
from docutils import nodes
from sphinx.util import logging
from bs4 import BeautifulSoup as bs
from sphinx.util.fileutil import copy_asset
from sphinx.util.osutil import ensuredir
from .launch import add_hub_urls
__version__ = "0.22.0"
"""quantecon-book-theme version"""
SPHINX_LOGGER = logging.getLogger(__name__)
MESSAGE_CATALOG_NAME = "booktheme"
def get_html_theme_path():
"""Return list of HTML theme paths."""
parent = Path(__file__).parent.resolve()
theme_path = parent / "theme" / "quantecon_book_theme"
return theme_path
def find_url_relative_to_root(pagename, relative_page, path_docs_source):
"""Given the current page (pagename), a relative page to it (relative_page),
and a path to the docs source, return the path to `relative_page`, but now relative
to the docs source (since this is what keys in Sphinx tend to use).
"""
# In this case, the relative_page is the same as the pagename
if relative_page == "":
relative_page = Path(Path(pagename).name)
# Convert everything to paths for use later
path_rel = Path(relative_page).with_suffix("")
path_parent = Path(pagename) # pagename is relative to docs root
source_dir = Path(path_docs_source)
# This should be the path to `relative_page`, relative to `pagename`
path_rel_from_page_dir = source_dir.joinpath(
path_parent.parent.joinpath(path_rel.parent)
)
path_from_page_dir = path_rel_from_page_dir.resolve()
page_rel_root = path_from_page_dir.relative_to(source_dir).joinpath(path_rel.name)
return page_rel_root
def add_plugins_list(app):
# copying plugins
if "plugins_list" in app.config.html_theme_options:
outdir = app.outdir / "plugins"
ensuredir(outdir)
for i, asset in enumerate(app.config.html_theme_options["plugins_list"]):
assetname = Path(asset).name
copy_asset(app.confdir + "/" + asset, outdir)
app.config.html_theme_options["plugins_list"][i] = "plugins/" + assetname
def get_git_last_modified(source_file, source_dir):
"""Get the last modified date for a source file from git.
Args:
source_file: The source file path relative to source_dir
source_dir: The Sphinx source directory
Returns:
datetime object or None if git is not available
"""
try:
# Get the full path to the source file
file_path = Path(source_dir) / source_file
# Check if git is available and we're in a git repo
result = subprocess.run(
["git", "rev-parse", "--git-dir"],
cwd=source_dir,
capture_output=True,
text=True,
timeout=5,
)
if result.returncode != 0:
return None
# Get the last commit date for this file
result = subprocess.run(
["git", "log", "-1", "--format=%ct", "--follow", "--", str(file_path)],
cwd=source_dir,
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0 and result.stdout.strip():
timestamp = int(result.stdout.strip())
return datetime.fromtimestamp(timestamp, tz=timezone.utc)
except (
subprocess.TimeoutExpired,
subprocess.SubprocessError,
ValueError,
FileNotFoundError,
):
pass
return None
def get_git_changelog(source_file, source_dir, max_entries=10):
"""Get the changelog for a source file from git.
Args:
source_file: The source file path relative to source_dir
source_dir: The Sphinx source directory
max_entries: Maximum number of changelog entries to return
Returns:
List of dicts with keys: hash, author, date, message, relative_time
Empty list if git is not available
"""
try:
# Get the full path to the source file
file_path = Path(source_dir) / source_file
# Check if git is available and we're in a git repo
result = subprocess.run(
["git", "rev-parse", "--git-dir"],
cwd=source_dir,
capture_output=True,
text=True,
timeout=5,
)
if result.returncode != 0:
return []
# Get the changelog with format: hash|author|timestamp|subject
result = subprocess.run(
[
"git",
"log",
f"-{max_entries}",
"--format=%h|%an|%ct|%s",
"--follow",
"--",
str(file_path),
],
cwd=source_dir,
capture_output=True,
text=True,
timeout=5,
)
if result.returncode != 0 or not result.stdout.strip():
return []
changelog = []
for line in result.stdout.strip().split("\n"):
if not line:
continue
parts = line.split("|", 3)
if len(parts) == 4:
commit_hash, author, timestamp, message = parts
commit_time = datetime.fromtimestamp(int(timestamp), tz=timezone.utc)
relative_time = get_relative_time(commit_time)
changelog.append(
{
"hash": commit_hash,
"author": author,
"date": commit_time,
"message": message,
"relative_time": relative_time,
}
)
return changelog
except (
subprocess.TimeoutExpired,
subprocess.SubprocessError,
ValueError,
FileNotFoundError,
):
pass
return []
def get_relative_time(past_date):
"""Convert a datetime to relative time string (e.g., '3 months ago')."""
now = datetime.now(timezone.utc)
# Ensure past_date is timezone-aware for comparison
if past_date.tzinfo is None:
past_date = past_date.replace(tzinfo=timezone.utc)
diff = now - past_date
seconds = diff.total_seconds()
if seconds < 60:
return "just now"
elif seconds < 3600:
minutes = int(seconds / 60)
return f"{minutes} minute{'s' if minutes != 1 else ''} ago"
elif seconds < 86400:
hours = int(seconds / 3600)
return f"{hours} hour{'s' if hours != 1 else ''} ago"
elif seconds < 604800:
days = int(seconds / 86400)
return f"{days} day{'s' if days != 1 else ''} ago"
elif seconds < 2592000:
weeks = int(seconds / 604800)
return f"{weeks} week{'s' if weeks != 1 else ''} ago"
elif seconds < 31536000:
months = int(seconds / 2592000)
return f"{months} month{'s' if months != 1 else ''} ago"
else:
years = int(seconds / 31536000)
return f"{years} year{'s' if years != 1 else ''} ago"
def _parse_iso_date(value):
"""Parse a ``YYYY-MM-DD`` string into a ``date``, or return ``None``."""
if not value:
return None
try:
return datetime.strptime(value.strip(), "%Y-%m-%d").date()
except (ValueError, TypeError):
return None
def _build_announcements(config_theme):
"""Build the list of announcements to render in the page banner.
Currently this is a single site-wide announcement (from the ``announcement``
option), but it returns a *list* so per-page announcements can be appended
additively in future without changing the template or JavaScript (tracked in
GitHub issue #403). Each entry is a dict with:
- ``html``: the (trusted) HTML message
- ``id``: a short content hash, used to key dismissal in localStorage so an
edited message re-appears for everyone who dismissed the old one
- ``expires_iso``: the ISO expiry date (or ``""``), checked client-side so the
banner disappears for visitors on the date even without a rebuild
An announcement whose expiry has already passed at build time is omitted
entirely; the client-side check handles expiry that falls between builds.
"""
announcements = []
message = (config_theme.get("announcement") or "").strip()
if message:
expires = (config_theme.get("announcement_expires") or "").strip()
expires_date = _parse_iso_date(expires)
# Build-time skip is only an optimization: drop a clearly-stale notice so
# it isn't shipped in the HTML at all. Keep it conservative — the expiry
# day ends at different UTC instants across timezones, and the client
# side hides the banner precisely per-reader, so we only skip once the
# date is past for every real-world timezone (a one-day UTC grace
# covers the full UTC-12..UTC+14 range).
today_utc = datetime.now(timezone.utc).date()
if expires_date is not None and today_utc > expires_date + timedelta(days=1):
return announcements
announcement_id = hashlib.sha1(message.encode("utf-8")).hexdigest()[:12]
announcements.append(
{
"html": message,
"id": announcement_id,
"expires_iso": expires if expires_date is not None else "",
}
)
return announcements
def _process_languages(config_theme):
"""Validate and normalize language switcher configuration.
Returns a tuple of (languages_list, current_language_string).
Only returns languages when there are 2+ valid entries.
"""
languages = config_theme.get("languages", [])
current_language = config_theme.get("current_language", "")
if isinstance(languages, list) and len(languages) > 1:
valid_languages = []
for lang in languages:
if isinstance(lang, dict) and all(
k in lang for k in ("code", "name", "url")
):
normalized = dict(lang)
normalized["url"] = normalized["url"].rstrip("/")
valid_languages.append(normalized)
if len(valid_languages) > 1:
return valid_languages, current_language
return [], ""
def _normalise_people(value):
"""Coerce an authors/translators value into a list of ``{name, url}`` dicts.
Accepts the documented list-of-dicts form, and tolerates a list of plain
strings, a single dict, or a single string. Anything else -- including
``None`` and the empty string that ``theme.conf`` uses for its default --
becomes an empty list. Entries without a usable name are dropped. Name and
url strings are passed through untouched so that projects already setting
``authors`` keep identical output.
"""
if isinstance(value, (str, dict)):
value = [value]
if not isinstance(value, (list, tuple)):
return []
people = []
for item in value:
if isinstance(item, dict):
name = item.get("name", "")
url = item.get("url", "") or ""
elif isinstance(item, str):
name, url = item, ""
else:
continue
name = name if isinstance(name, str) else str(name)
url = url if isinstance(url, str) else str(url)
if name.strip():
people.append({"name": name, "url": url})
return people
def _is_fence(line, width=3):
"""True for a front matter fence: a run of at least ``width`` dashes."""
return len(line) >= width and line.count("-") == len(line)
def _md_front_matter(path):
"""Return the leading YAML front matter of a markdown source, or ``{}``.
Sources are opened as ``utf-8-sig`` to match Sphinx's own
``source_encoding`` default, so that a byte-order mark does not hide front
matter the parser goes on to read. The fence is a run of three or more
dashes closed by a run at least as long, which is what MyST accepts.
"""
try:
with open(path, encoding="utf-8-sig") as handle:
opening = handle.readline().strip()
if not _is_fence(opening):
return {}
lines = []
for line in handle:
if _is_fence(line.strip(), width=len(opening)):
break
lines.append(line)
else:
# Unterminated block: not front matter as far as MyST is concerned.
return {}
except (OSError, UnicodeDecodeError):
return {}
try:
data = yaml.safe_load("".join(lines))
except yaml.YAMLError:
return {}
return data if isinstance(data, dict) else {}
def _nb_front_matter(path):
"""Return the notebook-level metadata of an ``.ipynb`` source, or ``{}``."""
try:
with open(path, encoding="utf-8-sig") as handle:
notebook = json.load(handle)
except (OSError, UnicodeDecodeError, ValueError):
return {}
if not isinstance(notebook, dict):
return {}
metadata = notebook.get("metadata")
return metadata if isinstance(metadata, dict) else {}
@lru_cache(maxsize=None)
def _read_front_matter(path, mtime_ns, size):
"""Front matter for one source file, cached on its path and mtime/size.
The returned mapping is shared between callers, so treat it as read-only.
"""
if path.endswith(".ipynb"):
return _nb_front_matter(path)
if path.endswith((".md", ".myst", ".markdown")):
return _md_front_matter(path)
return {}
def _page_front_matter(app, pagename):
"""Front matter for ``pagename``, or ``{}`` when there is none to read.
The source file is read directly rather than going through
``app.env.metadata``, because docutils treats ``authors`` as a
bibliographic field: MyST's serialised value comes back through that path
smart-quoted and split on commas, and in a *different* shape again once
``language`` is set to a locale that does not list ``authors`` among its
bibliographic fields. Reading the source keeps one shape in every edition.
Returns ``{}`` for generated pages such as ``genindex`` and ``search``, and
for source formats (notably reStructuredText) whose field lists cannot
express a list of mappings.
"""
try:
path = str(app.env.doc2path(pagename))
stat = os.stat(path)
except (OSError, KeyError, AttributeError):
return {}
return _read_front_matter(path, stat.st_mtime_ns, stat.st_size)
def _front_matter_people(value):
"""Interpret one front matter authors/translators value.
Returns the people to render, or ``None`` when the value is not something
this theme should act on.
Front matter is stricter than ``html_theme_options`` on purpose. ``authors``
is shared ground -- docutils treats it as a bibliographic field and nbformat
defines it in the notebook schema -- so a page may already carry one written
for something else entirely, in a shape this theme cannot read. Only the
documented list-of-mappings form counts as an override, and only an
explicitly empty value suppresses the block; anything else is left alone so
the project-level credit still renders.
"""
if value is None or (isinstance(value, (list, tuple, str)) and not value):
return []
if not isinstance(value, (list, tuple)):
return None
if not all(isinstance(item, dict) for item in value):
return None
people = _normalise_people(value)
return people if len(people) == len(value) else None
def _resolve_people(front_matter, config_theme, key):
"""Resolve the authors or translators of one page.
Returns ``(people, suppressed)``. Page front matter replaces the
project-level value outright rather than merging with it, and an absent key
inherits the project value. ``suppressed`` is True when a page set the key
explicitly to nobody, which leaves the block off that page rather than
falling back to the project-wide credit.
"""
if key in front_matter:
people = _front_matter_people(front_matter[key])
if people is not None:
return people, not people
return _normalise_people(config_theme.get(key)), False
def _resolve_label(front_matter, context, key):
"""Resolve an attribution label, letting page front matter override it.
The fallback is whatever the theme configuration already placed in the
context, so ``theme.conf`` stays the only place a default is written down.
"""
value = front_matter[key] if key in front_matter else context.get("theme_" + key)
return "" if value is None else str(value)
[docs]
def add_to_context(app, pagename, templatename, context, doctree):
"""Functions and variable additions to context."""
config_theme = app.config.html_theme_options
def sbt_generate_toctree_html(
level=1,
include_item_names=False,
with_home_page=False,
):
# Config stuff
if isinstance(with_home_page, str):
with_home_page = with_home_page.lower() == "true"
# Grab the raw toctree object and structure it so we can manipulate it
toctree = context["generate_toctree_html"](
startdepth=level - 1,
maxdepth=level + 1,
kind="sidebar",
collapse=False,
titles_only=True,
includehidden=True,
)
# toctree = bs(toc_sphinx, "html.parser")
# pair "current" with "active" since that's what we use w/ bootstrap
for li in toctree("li", {"class": "current"}):
li["class"].append("active")
# Add the master_doc page as the first item if specified
if with_home_page:
master_title = master_doctree.traverse(nodes.title)[0].astext()
if len(master_title) == 0:
raise ValueError(f"Landing page missing a title: {master_doc}")
li_class = "toctree-l1"
if context["pagename"] == master_doc:
li_class += " current"
# Insert it into our toctree
ul_home = bs(
f"""
<ul class="nav bd-sidenav">
<li class="{li_class}">
<a href="{master_url}" class="reference internal">{master_title}</a>
</li>
</ul>""",
"html.parser",
)
toctree.insert(0, ul_home("ul")[0])
# Add an icon for external links
for a_ext in toctree("a", attrs={"class": ["external"]}):
a_ext.append(
toctree.new_tag("i", attrs={"class": ["fas", "fa-external-link-alt"]})
)
# Add bootstrap classes for first `ul` items
for ul in toctree("ul", recursive=False):
ul.attrs["class"] = ul.attrs.get("class", []) + ["nav", "sidenav_l1"]
return toctree.prettify()
def generate_toc_html():
"""Return the within-page TOC links in HTML."""
if not context.get("toc"):
return ""
soup = bs(context["toc"], "html.parser")
# Add toc-hN classes
def add_header_level_recursive(ul, level):
for li in ul("li", recursive=False):
li["class"] = li.get("class", []) + [f"toc-h{level}"]
ul = li.find("ul", recursive=False)
if ul:
add_header_level_recursive(ul, level + 1)
add_header_level_recursive(soup.find("ul"), 1)
# Add in CSS classes for bootstrap
for ul in soup("ul"):
ul["class"] = ul.get("class", []) + ["nav", "section-nav", "flex-column"]
for li in soup("li"):
li["class"] = li.get("class", []) + ["nav-item", "toc-entry"]
if li.find("a"):
a = li.find("a")
a["class"] = a.get("class", []) + ["nav-link"]
# Keep only the sub-sections of the title (so no title is shown)
title = soup.find("a", attrs={"href": "#"})
if title:
title = title.parent
# Only show if children of the title item exist
if title.select("ul li"):
out = title.find("ul").prettify()
else:
out = ""
else:
out = ""
return out
def get_github_src_folder(app):
if "github_repo" in context:
github_repo = context["github_repo"]
if github_repo in str(app.srcdir):
index = str(app.srcdir).rfind(github_repo)
branch = config_theme.get("nb_branch", "")
if branch == "":
branch = "main"
folder = str(app.srcdir)[index + len(github_repo) :]
return "/blob/" + branch + folder
return ""
# Pull metadata about the master doc
master_doc = app.config["master_doc"]
master_doctree = app.env.get_doctree(master_doc)
master_url = context["pathto"](master_doc)
context["master_url"] = master_url
context["sbt_generate_toctree_html"] = sbt_generate_toctree_html
context["generate_toc_html"] = generate_toc_html
# check if book pdf folder is present
if os.path.isdir(app.outdir / "_pdf"):
if "pdf_book_name" not in context:
context["pdf_book_name"] = app.config.latex_documents[0][1].replace(
".tex", ""
)
context["pdf_book_path"] = "/_pdf/" + context["pdf_book_name"] + ".pdf"
# check if notebook folder is present
if os.path.isdir(app.outdir / "_notebooks"):
if "download_nb_path" in app.config.html_theme_options:
context["notebook_path"] = (
app.config.html_theme_options["download_nb_path"]
+ "/_notebooks/"
+ context["pagename"]
+ ".ipynb"
)
else:
context["notebook_path"] = "/_notebooks/" + context["pagename"] + ".ipynb"
# Update the page title because HTML makes it into the page title occasionally
if pagename in app.env.titles:
title = app.env.titles[pagename]
context["pagetitle"] = title.astext()
# Add a shortened page text to the context using the sections text
if not len(context["theme_description"]) > 0 and doctree:
description = ""
for section in doctree.traverse(nodes.section):
description += section.astext().replace("\n", " ")
description = description[:160]
context["theme_description"] = description
# Add the author if it exists
if app.config.author != "unknown":
context["author"] = app.config.author
# Absolute URLs for logo if `html_baseurl` is given
# pageurl will already be set by Sphinx if so
if app.config.html_baseurl and app.config.html_logo:
context["logourl"] = "/".join(
(app.config.html_baseurl.rstrip("/"), context["logo_url"])
)
# Check mathjax version and set it in a variable
if app.config["mathjax_path"] and "@3" in app.config["mathjax_path"]:
context["mathjax_version"] = 3
else:
context["mathjax_version"] = 2
# Add HTML context variables that the pydata theme uses that we configure elsewhere
# For some reason the source_suffix sometimes isn't there even when doctree is
if doctree and context.get("page_source_suffix"):
repo_url = config_theme.get("repository_url", "")
# Only add the edit button if `repository_url` is given
if repo_url:
branch = config_theme.get("repository_branch")
if not branch:
# Explicitly check in case branch is ""
branch = "main"
relpath = config_theme.get("path_to_docs", "")
org, repo = repo_url.strip("/").split("/")[-2:]
context.update(
{
"github_user": org,
"github_repo": repo,
"github_version": branch,
"doc_path": relpath,
}
)
else:
# Disable using the button so we don't get errors
context["theme_use_edit_page_button"] = False
# default value is book.tex
if "pdf_book_name" not in context:
context["pdf_book_name"] = app.config.latex_documents[0][1].replace(".tex", "")
context["github_sourcefolder"] = get_github_src_folder(app)
# Add git information (last modified date and changelog)
if doctree and hasattr(app.env, "doc2path"):
source_file = app.env.doc2path(pagename, base=False)
source_dir = app.srcdir
# Get last modified date
last_modified = get_git_last_modified(source_file, source_dir)
if last_modified:
# Get date format from theme options, default to "%b %d, %Y"
date_format = config_theme.get("last_modified_date_format", "%b %d, %Y")
context["last_modified_date"] = last_modified.strftime(date_format)
context["last_modified_iso"] = last_modified.isoformat()
else:
context["last_modified_date"] = None
# Get changelog entries
max_changelog_entries = config_theme.get("changelog_max_entries", 10)
changelog = get_git_changelog(source_file, source_dir, max_changelog_entries)
context["changelog_entries"] = changelog
context["has_git_info"] = last_modified is not None and len(changelog) > 0
# Add repository URL and source file for GitHub links
repo_url = config_theme.get("repository_url", "")
if repo_url:
context["theme_repository_url"] = repo_url.rstrip("/")
# Construct full path including path_to_docs
path_to_docs = config_theme.get("path_to_docs", "")
if path_to_docs:
full_source_path = f"{path_to_docs}/{source_file}".replace("//", "/")
else:
full_source_path = source_file
context["theme_source_file"] = full_source_path
else:
context["theme_repository_url"] = None
context["theme_source_file"] = None
else:
context["last_modified_date"] = None
context["changelog_entries"] = []
context["has_git_info"] = False
context["theme_repository_url"] = None
context["theme_source_file"] = None
# Process language switcher configuration
context["theme_languages"], context["theme_current_language"] = _process_languages(
config_theme
)
# Build the announcement banner list (currently site-wide only; the list
# shape leaves room for additive per-page announcements later).
context["announcements"] = _build_announcements(config_theme)
# Authors and translators, either of which a page may override in its own
# front matter
front_matter = _page_front_matter(app, pagename)
context["theme_authors"], context["authors_suppressed"] = _resolve_people(
front_matter, config_theme, "authors"
)
context["theme_translators"], _ = _resolve_people(
front_matter, config_theme, "translators"
)
context["theme_authors_label"] = _resolve_label(
front_matter, context, "authors_label"
)
context["theme_translators_label"] = _resolve_label(
front_matter, context, "translators_label"
)
# Make sure the context values are bool
blns = [
"theme_use_edit_page_button",
"theme_use_repository_button",
"theme_use_issues_button",
"theme_enable_rtl",
]
for key in blns:
if key in context:
context[key] = _string_or_bool(context[key])
@lru_cache(maxsize=None)
def _gen_hash(path: str) -> str:
return hashlib.sha1(path.read_bytes()).hexdigest()
def hash_assets_for_files(assets: list, theme_static: Path, context):
"""Generate a hash for assets, and append to its entry in context.
assets: a list of assets to hash, each path should be relative to
the theme's static folder.
theme_static: a path to the theme's static folder.
context: the Sphinx context object where asset links are stored. These are:
`css_files` and `script_files` keys.
"""
for asset in assets:
# CSS assets are stored in css_files, JS assets in script_files
asset_type = "css_files" if asset.endswith(".css") else "script_files"
if asset_type in context:
# Define paths to the original asset file, and its linked file in Sphinx
asset_sphinx_link = f"_static/{asset}"
asset_source_path = theme_static / asset
if not asset_source_path.exists():
SPHINX_LOGGER.warning(
f"Asset {asset_source_path} does not exist, not linking."
)
# Find this asset in context, and update it to include the digest
# Use .filename attribute to avoid deprecation warnings in Sphinx 9+
for i, css_or_js in enumerate(context[asset_type]):
filename = getattr(css_or_js, "filename", None)
# Skip if filename attribute doesn't exist
if filename is None:
continue
if filename == asset_sphinx_link:
hash = _gen_hash(asset_source_path)
context[asset_type][i] = asset_sphinx_link + "?digest=" + hash
break
def hash_html_assets(app, pagename, templatename, context, doctree):
"""Add ?digest={hash} to assets in order to bust cache when changes are made.
The source files are in `static` while the built HTML is in `_static`.
"""
assets = ["scripts/quantecon-book-theme.js"]
# Only append the book theme CSS if it's explicitly this theme. Sub-themes
# will define their own CSS file, so if a sub-theme is used, this code is
# run but the book theme CSS file won't be linked in Sphinx.
if app.config.html_theme == "quantecon_book_theme":
assets.append("styles/quantecon-book-theme.css")
hash_assets_for_files(assets, get_html_theme_path() / "static", context)
def add_pygments_style_class(app, pagename, templatename, context, doctree):
"""Add CSS class to root element if QuantEcon theme code style is disabled.
When qetheme_code_style is False, adds 'use-pygments-style' class which
disables the custom QuantEcon code token styles and allows Pygments
built-in styles (configured via pygments_style) to be used.
"""
config_theme = app.config.html_theme_options
qetheme_code_style = config_theme.get("qetheme_code_style", True)
# Convert string "false"/"true" to boolean if needed
if isinstance(qetheme_code_style, str):
qetheme_code_style = qetheme_code_style.lower() != "false"
# Set a context variable that can be used in templates
context["use_pygments_style"] = not qetheme_code_style
inline_literal_box = config_theme.get("inline_literal_box", False)
if isinstance(inline_literal_box, str):
inline_literal_box = inline_literal_box.lower() == "true"
context["inline_literal_box"] = inline_literal_box
def setup_pygments_css(app):
"""Ensure Pygments CSS is included when using Pygments styles.
This runs during builder-inited, after config is fully loaded.
We generate our own unscoped pygments CSS file instead of using
Sphinx's scoped version.
"""
from pygments.formatters import HtmlFormatter
# Access html_theme_options from app.config (it's a dict)
config_theme = getattr(app.config, "html_theme_options", {})
qetheme_code_style = config_theme.get("qetheme_code_style", True)
# Convert string "false"/"true" to boolean if needed
if isinstance(qetheme_code_style, str):
qetheme_code_style = qetheme_code_style.lower() != "false"
# When using Pygments styles, generate and include unscoped CSS
if not qetheme_code_style:
# Get the Pygments style name from config (default to 'default')
pygments_style = getattr(app.config, "pygments_style", None) or "default"
# Generate CSS without data-theme scoping
formatter = HtmlFormatter(style=pygments_style)
css_content = formatter.get_style_defs(".highlight")
# Write CSS file to _static directory with a different name
# This ensures it won't be overwritten by Sphinx or pydata-sphinx-theme
static_dir = Path(app.outdir) / "_static"
static_dir.mkdir(parents=True, exist_ok=True)
pygments_css_path = static_dir / "pygments-quantecon.css"
pygments_css_path.write_text(css_content)
# Add the CSS file to the page (instead of the default pygments.css)
app.add_css_file("pygments-quantecon.css")
def _string_or_bool(var):
if isinstance(var, str):
return var.lower() == "true"
elif isinstance(var, bool):
return var
else:
return var is None
# Announcement banner styles. "bar" is a thin full-width strip; "callout" is
# the original boxed in-column notice.
_VALID_ANNOUNCEMENT_STYLES = ["bar", "callout"]
_DEFAULT_ANNOUNCEMENT_STYLE = "bar"
def validate_announcement(app):
"""Validate the announcement options once, at build start.
Fails open: an unparseable ``announcement_expires`` is dropped (cleared) so a
typo can never silently hide an active announcement, and an unknown
``announcement_style`` falls back to the default. Warnings are logged so the
misconfiguration surfaces during the build.
"""
theme_options = app.config.html_theme_options
expires = theme_options.get("announcement_expires", "")
if expires and _parse_iso_date(expires) is None:
SPHINX_LOGGER.warning(
"Invalid announcement_expires %r. Expected ISO date YYYY-MM-DD. "
"Ignoring expiry; the announcement will not auto-expire.",
expires,
)
theme_options["announcement_expires"] = ""
style = str(theme_options.get("announcement_style", "") or "").strip().lower()
if not style:
style = _DEFAULT_ANNOUNCEMENT_STYLE
elif style not in _VALID_ANNOUNCEMENT_STYLES:
SPHINX_LOGGER.warning(
"Unknown announcement_style %r. Valid styles: %s. Falling back to %r.",
style,
", ".join(_VALID_ANNOUNCEMENT_STYLES),
_DEFAULT_ANNOUNCEMENT_STYLE,
)
style = _DEFAULT_ANNOUNCEMENT_STYLE
theme_options["announcement_style"] = style
# Built-in text color schemes
_VALID_COLOR_SCHEMES = ["seoul256", "gruvbox", "none"]
def validate_color_scheme(app):
"""Validate the color_scheme theme option.
Ensures the selected scheme is a known built-in scheme name. Invalid values
fall back to the default 'seoul256' scheme with a warning.
Also checks for a custom_color_scheme.css in the project's _static
directories and automatically includes it if found.
"""
theme_options = app.config.html_theme_options
scheme = theme_options.get("color_scheme", "seoul256").strip().lower()
if scheme not in _VALID_COLOR_SCHEMES:
SPHINX_LOGGER.warning(
"Unknown color_scheme %r. Valid schemes: %s. Falling back to 'seoul256'.",
scheme,
", ".join(_VALID_COLOR_SCHEMES),
)
theme_options["color_scheme"] = "seoul256"
else:
theme_options["color_scheme"] = scheme
# Auto-detect custom_color_scheme.css in _static directories
static_paths = getattr(app.config, "html_static_path", [])
confdir = Path(app.confdir) if app.confdir else None
for static_path in static_paths:
if confdir:
full_path = confdir / static_path / "custom_color_scheme.css"
if full_path.is_file():
app.add_css_file("custom_color_scheme.css")
SPHINX_LOGGER.info(
"Loading custom text color scheme from %s", full_path
)
break
def setup(app):
# Configuration for Juypter Book
app.setup_extension("sphinx_book_theme")
app.add_js_file("scripts/quantecon-book-theme.js")
app.add_js_file("scripts/jquery.js")
app.add_js_file("scripts/_sphinx_javascript_frameworks_compat.js")
app.connect("html-page-context", add_hub_urls)
app.connect("builder-inited", add_plugins_list)
app.connect("builder-inited", validate_announcement)
app.connect("builder-inited", validate_color_scheme)
app.connect("builder-inited", setup_pygments_css)
app.connect("html-page-context", hash_html_assets)
app.connect("html-page-context", add_pygments_style_class)
app.add_html_theme("quantecon_book_theme", get_html_theme_path())
app.connect("html-page-context", add_to_context)
return {
"parallel_read_safe": True,
"parallel_write_safe": True,
}