Source code for quantecon_book_theme

"""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, }