diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 02c783ee8..213094d52 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -63,6 +63,15 @@ jobs: python -V uv run python -V + - name: Cache sphinx fonts + if: env.PUBLISH == 'true' + uses: actions/cache@v5 + with: + path: ~/.cache/sphinx-fonts + key: sphinx-fonts-${{ hashFiles('docs/conf.py') }} + restore-keys: | + sphinx-fonts- + - name: Build documentation if: env.PUBLISH == 'true' run: | diff --git a/.gitignore b/.gitignore index d0f22f7ea..62a3ca750 100644 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,7 @@ pip-wheel-metadata/ monkeytype.sqlite3 **/.claude/settings.local.json + +# Generated by sphinx_fonts extension (downloaded at build time) +docs/_static/fonts/ +docs/_static/css/fonts.css diff --git a/docs/_ext/sphinx_fonts.py b/docs/_ext/sphinx_fonts.py new file mode 100644 index 000000000..e8d2a692a --- /dev/null +++ b/docs/_ext/sphinx_fonts.py @@ -0,0 +1,153 @@ +"""Sphinx extension for self-hosted fonts via Fontsource CDN. + +Downloads font files at build time, caches them locally, and passes +structured font data to the template context for inline @font-face CSS. +""" + +from __future__ import annotations + +import logging +import pathlib +import shutil +import typing as t +import urllib.error +import urllib.request + +if t.TYPE_CHECKING: + from sphinx.application import Sphinx + +logger = logging.getLogger(__name__) + +CDN_TEMPLATE = ( + "https://cdn.jsdelivr.net/npm/{package}@{version}" + "/files/{font_id}-{subset}-{weight}-{style}.woff2" +) + + +class SetupDict(t.TypedDict): + """Return type for Sphinx extension setup().""" + + version: str + parallel_read_safe: bool + parallel_write_safe: bool + + +def _cache_dir() -> pathlib.Path: + return pathlib.Path.home() / ".cache" / "sphinx-fonts" + + +def _cdn_url( + package: str, + version: str, + font_id: str, + subset: str, + weight: int, + style: str, +) -> str: + return CDN_TEMPLATE.format( + package=package, + version=version, + font_id=font_id, + subset=subset, + weight=weight, + style=style, + ) + + +def _download_font(url: str, dest: pathlib.Path) -> bool: + if dest.exists(): + logger.debug("font cached: %s", dest.name) + return True + dest.parent.mkdir(parents=True, exist_ok=True) + try: + urllib.request.urlretrieve(url, dest) + logger.info("downloaded font: %s", dest.name) + except (urllib.error.URLError, OSError): + if dest.exists(): + dest.unlink() + logger.warning("failed to download font: %s", url) + return False + return True + + +def _on_builder_inited(app: Sphinx) -> None: + if app.builder.format != "html": + return + + fonts: list[dict[str, t.Any]] = app.config.sphinx_fonts + variables: dict[str, str] = app.config.sphinx_font_css_variables + if not fonts: + return + + cache = _cache_dir() + static_dir = pathlib.Path(app.outdir) / "_static" + fonts_dir = static_dir / "fonts" + fonts_dir.mkdir(parents=True, exist_ok=True) + + font_faces: list[dict[str, str]] = [] + for font in fonts: + font_id = font["package"].split("/")[-1] + version = font["version"] + package = font["package"] + subset = font.get("subset", "latin") + for weight in font["weights"]: + for style in font["styles"]: + filename = f"{font_id}-{subset}-{weight}-{style}.woff2" + cached = cache / filename + url = _cdn_url(package, version, font_id, subset, weight, style) + if _download_font(url, cached): + shutil.copy2(cached, fonts_dir / filename) + font_faces.append( + { + "family": font["family"], + "style": style, + "weight": str(weight), + "filename": filename, + } + ) + + preload_hrefs: list[str] = [] + preload_specs: list[tuple[str, int, str]] = app.config.sphinx_font_preload + for family_name, weight, style in preload_specs: + for font in fonts: + if font["family"] == family_name: + font_id = font["package"].split("/")[-1] + subset = font.get("subset", "latin") + filename = f"{font_id}-{subset}-{weight}-{style}.woff2" + preload_hrefs.append(filename) + break + + fallbacks: list[dict[str, str]] = app.config.sphinx_font_fallbacks + + app._font_preload_hrefs = preload_hrefs # type: ignore[attr-defined] + app._font_faces = font_faces # type: ignore[attr-defined] + app._font_fallbacks = fallbacks # type: ignore[attr-defined] + app._font_css_variables = variables # type: ignore[attr-defined] + + +def _on_html_page_context( + app: Sphinx, + pagename: str, + templatename: str, + context: dict[str, t.Any], + doctree: t.Any, +) -> None: + context["font_preload_hrefs"] = getattr(app, "_font_preload_hrefs", []) + context["font_faces"] = getattr(app, "_font_faces", []) + context["font_fallbacks"] = getattr(app, "_font_fallbacks", []) + context["font_css_variables"] = getattr(app, "_font_css_variables", {}) + + +def setup(app: Sphinx) -> SetupDict: + """Register config values, events, and return extension metadata.""" + app.add_config_value("sphinx_fonts", [], "html") + app.add_config_value("sphinx_font_fallbacks", [], "html") + app.add_config_value("sphinx_font_css_variables", {}, "html") + app.add_config_value("sphinx_font_preload", [], "html") + app.connect("builder-inited", _on_builder_inited) + app.connect("html-page-context", _on_html_page_context) + return { + "version": "1.0", + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index b420cee94..903277352 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -15,6 +15,220 @@ margin-right: calc(var(--sidebar-item-spacing-horizontal) / 2.5); } +#sidebar-projects:not(.ready) { + visibility: hidden; +} + .sidebar-tree .active { font-weight: bold; } + + +/* ── Global heading refinements ───────────────────────────── + * Biome-inspired scale: medium weight (500) throughout — size + * and spacing carry hierarchy, not boldness. H4-H6 add eyebrow + * treatment (uppercase, muted). `article` prefix overrides + * Furo's bare h1-h6 selectors. + * ────────────────────────────────────────────────────────── */ +article h1 { + font-size: 1.8em; + font-weight: 500; + margin-top: 1.5rem; + margin-bottom: 0.75rem; +} + +article h2 { + font-size: 1.6em; + font-weight: 500; + margin-top: 2.5rem; + margin-bottom: 0.5rem; +} + +article h3 { + font-size: 1.15em; + font-weight: 500; + margin-top: 1.5rem; + margin-bottom: 0.375rem; +} + +article h4 { + font-size: 0.85em; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-foreground-secondary); + margin-top: 1rem; + margin-bottom: 0.25rem; +} + +article h5 { + font-size: 0.8em; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-foreground-secondary); +} + +article h6 { + font-size: 0.75em; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-foreground-secondary); +} + +/* ── Changelog heading extras ─────────────────────────────── + * Vertical spacing separates consecutive version entries. + * Category headings (h3) are muted. Item headings (h4) are + * subtle. Targets #history section from CHANGES markdown. + * ────────────────────────────────────────────────────────── */ + +/* Spacing between consecutive version entries */ +#history > section + section { + margin-top: 2.5rem; +} + +/* Category headings — muted secondary color */ +#history h3 { + color: var(--color-foreground-secondary); + margin-top: 1.25rem; +} + +/* Item headings — subtle, same size as body */ +#history h4 { + font-size: 1em; + margin-top: 1rem; + text-transform: none; + letter-spacing: normal; + color: inherit; +} + +/* ── Right-panel TOC refinements ──────────────────────────── + * Adjust Furo's table-of-contents proportions for better + * readability. Inspired by Starlight defaults (Biome docs). + * Uses Furo CSS variable overrides where possible. + * ────────────────────────────────────────────────────────── */ + +/* TOC font sizes: override Furo defaults (75% → 87.5%) */ +:root { + --toc-font-size: var(--font-size--small); /* 87.5% = 14px */ + --toc-title-font-size: var(--font-size--small); /* 87.5% = 14px */ +} + +/* More generous line-height for wrapped TOC entries */ +.toc-tree { + line-height: 1.4; +} + +/* ── Flexible right-panel TOC (inner-panel padding) ───────── + * Furo hardcodes .toc-drawer to width: 15em (SASS, compiled). + * min-width: 18em overrides it; long TOC entries wrap inside + * the box instead of blowing past the viewport. + * + * Padding lives on .toc-sticky (the inner panel), not on + * .toc-drawer (the outer aside). This matches Biome/Starlight + * where the aside defines dimensions and an inner wrapper + * (.right-sidebar-panel) controls content insets. The + * scrollbar sits naturally between content and viewport edge. + * + * Content area gets flex: 1 to absorb extra space on wide + * screens. At ≤82em Furo collapses the TOC to position: fixed; + * override right offset so the drawer fully hides off-screen. + * ────────────────────────────────────────────────────────── */ +.toc-drawer { + min-width: 18em; + flex-shrink: 0; + padding-right: 0; +} + +.toc-sticky { + padding-right: 1.5em; +} + +.content { + width: auto; + max-width: 46em; + flex: 1 1 46em; + padding: 0 2em; +} + +@media (max-width: 82em) { + .toc-drawer { + right: -18em; + } +} + +/* ── Body typography refinements ──────────────────────────── + * Improve paragraph readability with wider line-height and + * sharper text rendering. Furo already sets font-smoothing. + * + * IBM Plex tracks slightly wide at default spacing; -0.01em + * tightens it to feel more natural (matches tony.sh/tony.nl). + * Kerning + ligatures polish AV/To pairs and fi/fl combos. + * ────────────────────────────────────────────────────────── */ +body { + text-rendering: optimizeLegibility; + font-kerning: normal; + font-variant-ligatures: common-ligatures; + letter-spacing: -0.01em; +} + +/* ── Code block text rendering ──────────────────────────── + * Monospace needs fixed-width columns: disable kerning, + * ligatures, and letter-spacing that body sets for prose. + * optimizeSpeed skips heuristics that can shift the grid. + * ────────────────────────────────────────────────────────── */ +pre, +code, +kbd, +samp { + text-rendering: optimizeSpeed; + font-kerning: none; + font-variant-ligatures: none; + letter-spacing: normal; +} + +article { + line-height: 1.6; +} + +/* ── Image layout shift prevention ──────────────────────── + * Reserve space for images before they load. Furo already + * sets max-width: 100%; height: auto on img. We add + * content-visibility and badge-specific height to prevent CLS. + * ────────────────────────────────────────────────────────── */ + + +img { + content-visibility: auto; +} + +/* Docutils emits :width:/:height: as inline CSS (style="width: Xpx; + * height: Ypx;") rather than HTML attributes. When Furo's + * max-width: 100% constrains width below the declared value, + * the fixed height causes distortion. height: auto + aspect-ratio + * lets the browser compute the correct height from the intrinsic + * ratio once loaded; before load, aspect-ratio reserves space + * at the intended proportion — preventing both CLS and distortion. */ +article img[loading="lazy"] { + height: auto !important; +} + +img[src*="shields.io"], +img[src*="badge.svg"], +img[src*="codecov.io"] { + height: 20px; + width: auto; + min-width: 60px; + border-radius: 3px; + background: var(--color-background-secondary); +} + +/* ── View Transitions (SPA navigation) ──────────────────── + * Crossfade between pages during SPA navigation. + * Browsers without View Transitions API get instant swap. + * ────────────────────────────────────────────────────────── */ +::view-transition-old(root), +::view-transition-new(root) { + animation-duration: 150ms; +} diff --git a/docs/_static/js/spa-nav.js b/docs/_static/js/spa-nav.js new file mode 100644 index 000000000..e00e521ab --- /dev/null +++ b/docs/_static/js/spa-nav.js @@ -0,0 +1,236 @@ +/** + * SPA-like navigation for Sphinx/Furo docs. + * + * Intercepts internal link clicks and swaps only the content that changes + * (article, sidebar nav tree, TOC drawer), preserving sidebar scroll + * position, theme state, and avoiding full-page reloads. + * + * Progressive enhancement: no-op when fetch/DOMParser/pushState unavailable. + */ +(function () { + "use strict"; + + if (!window.fetch || !window.DOMParser || !window.history?.pushState) return; + + // --- Theme toggle (replicates Furo's cycleThemeOnce) --- + + function cycleTheme() { + var current = localStorage.getItem("theme") || "auto"; + var prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + var next; + if (current === "auto") next = prefersDark ? "light" : "dark"; + else if (current === "dark") next = prefersDark ? "auto" : "light"; + else next = prefersDark ? "dark" : "auto"; + document.body.dataset.theme = next; + localStorage.setItem("theme", next); + } + + // --- Copy button injection --- + + var copyBtnTemplate = null; + + function captureCopyIcon() { + var btn = document.querySelector(".copybtn"); + if (btn) copyBtnTemplate = btn.cloneNode(true); + } + + function addCopyButtons() { + if (!copyBtnTemplate) captureCopyIcon(); + if (!copyBtnTemplate) return; + var cells = document.querySelectorAll("div.highlight pre"); + cells.forEach(function (cell, i) { + cell.id = "codecell" + i; + var next = cell.nextElementSibling; + if (next && next.classList.contains("copybtn")) { + next.setAttribute("data-clipboard-target", "#codecell" + i); + } else { + var btn = copyBtnTemplate.cloneNode(true); + btn.setAttribute("data-clipboard-target", "#codecell" + i); + cell.insertAdjacentElement("afterend", btn); + } + }); + } + + // --- Minimal scrollspy --- + + var scrollCleanup = null; + + function initScrollSpy() { + if (scrollCleanup) scrollCleanup(); + scrollCleanup = null; + + var links = document.querySelectorAll(".toc-tree a"); + if (!links.length) return; + + var entries = []; + links.forEach(function (a) { + var id = (a.getAttribute("href") || "").split("#")[1]; + var el = id && document.getElementById(id); + var li = a.closest("li"); + if (el && li) entries.push({ el: el, li: li }); + }); + if (!entries.length) return; + + function update() { + var offset = + parseFloat(getComputedStyle(document.documentElement).fontSize) * 4; + var active = null; + for (var i = entries.length - 1; i >= 0; i--) { + if (entries[i].el.getBoundingClientRect().top <= offset) { + active = entries[i]; + break; + } + } + entries.forEach(function (e) { + e.li.classList.remove("scroll-current"); + }); + if (active) active.li.classList.add("scroll-current"); + } + + window.addEventListener("scroll", update, { passive: true }); + update(); + scrollCleanup = function () { + window.removeEventListener("scroll", update); + }; + } + + // --- Link interception --- + + function shouldIntercept(link, e) { + if (e.defaultPrevented || e.button !== 0) return false; + if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return false; + if (link.origin !== location.origin) return false; + if (link.target && link.target !== "_self") return false; + if (link.hasAttribute("download")) return false; + + var path = link.pathname; + if (!path.endsWith(".html") && !path.endsWith("/")) return false; + + var base = path.split("/").pop() || ""; + if ( + base === "search.html" || + base === "genindex.html" || + base === "py-modindex.html" + ) + return false; + + if (link.closest("#sidebar-projects")) return false; + if (link.pathname === location.pathname && link.hash) return false; + + return true; + } + + // --- DOM swap --- + + function swap(doc) { + [".article-container", ".sidebar-tree", ".toc-drawer"].forEach( + function (sel) { + var fresh = doc.querySelector(sel); + var stale = document.querySelector(sel); + if (fresh && stale) stale.replaceWith(fresh); + }, + ); + var title = doc.querySelector("title"); + if (title) document.title = title.textContent || ""; + } + + function reinit() { + addCopyButtons(); + initScrollSpy(); + var btn = document.querySelector(".content-icon-container .theme-toggle"); + if (btn) btn.addEventListener("click", cycleTheme); + } + + // --- Navigation --- + + var currentCtrl = null; + + async function navigate(url, isPop) { + if (currentCtrl) currentCtrl.abort(); + var ctrl = new AbortController(); + currentCtrl = ctrl; + + try { + var resp = await fetch(url, { signal: ctrl.signal }); + if (!resp.ok) throw new Error(resp.status); + + var html = await resp.text(); + var doc = new DOMParser().parseFromString(html, "text/html"); + + if (!doc.querySelector(".article-container")) + throw new Error("no article"); + + var applySwap = function () { + swap(doc); + + if (!isPop) history.pushState({ spa: true }, "", url); + + if (!isPop) { + var hash = new URL(url, location.href).hash; + if (hash) { + var el = document.querySelector(hash); + if (el) el.scrollIntoView(); + } else { + window.scrollTo(0, 0); + } + } + + reinit(); + }; + + if (document.startViewTransition) { + document.startViewTransition(applySwap); + } else { + applySwap(); + } + } catch (err) { + if (err.name === "AbortError") return; + window.location.href = url; + } finally { + if (currentCtrl === ctrl) currentCtrl = null; + } + } + + // --- Events --- + + document.addEventListener("click", function (e) { + var link = e.target.closest("a[href]"); + if (link && shouldIntercept(link, e)) { + e.preventDefault(); + navigate(link.href, false); + } + }); + + history.replaceState({ spa: true }, ""); + + window.addEventListener("popstate", function (e) { + if (e.state && e.state.spa) navigate(location.href, true); + }); + + // --- Hover prefetch --- + + var prefetchTimer = null; + + document.addEventListener("mouseover", function (e) { + var link = e.target.closest("a[href]"); + if (!link || link.origin !== location.origin) return; + if (!link.pathname.endsWith(".html") && !link.pathname.endsWith("/")) + return; + + clearTimeout(prefetchTimer); + prefetchTimer = setTimeout(function () { + fetch(link.href, { priority: "low" }).catch(function () {}); + }, 65); + }); + + document.addEventListener("mouseout", function (e) { + if (e.target.closest("a[href]")) clearTimeout(prefetchTimer); + }); + + // --- Init --- + + // Copy buttons are injected by copybutton.js on DOMContentLoaded. + // This defer script runs before DOMContentLoaded, so our handler + // fires after copybutton's handler (registration order preserved). + document.addEventListener("DOMContentLoaded", captureCopyIcon); +})(); diff --git a/docs/_templates/layout.html b/docs/_templates/page.html similarity index 68% rename from docs/_templates/layout.html rename to docs/_templates/page.html index c57a16e5e..53625a252 100644 --- a/docs/_templates/layout.html +++ b/docs/_templates/page.html @@ -1,6 +1,37 @@ -{% extends "!layout.html" %} +{% extends "!page.html" %} {%- block extrahead %} {{ super() }} + {%- for href in font_preload_hrefs|default([]) %} + + {%- endfor %} + {%- if font_faces is defined and font_faces %} + + {%- endif %} {%- if theme_show_meta_manifest_tag == true %} {% endif -%} diff --git a/docs/_templates/sidebar/brand.html b/docs/_templates/sidebar/brand.html new file mode 100644 index 000000000..7fe241c00 --- /dev/null +++ b/docs/_templates/sidebar/brand.html @@ -0,0 +1,18 @@ + + {%- block brand_content %} + {%- if logo_url %} + + {%- endif %} + {%- if theme_light_logo and theme_dark_logo %} + + {%- endif %} + {% if not theme_sidebar_hide_name %} + + {%- endif %} + {% endblock brand_content %} + diff --git a/docs/_templates/sidebar/projects.html b/docs/_templates/sidebar/projects.html index 97420c1ad..0c182a2b3 100644 --- a/docs/_templates/sidebar/projects.html +++ b/docs/_templates/sidebar/projects.html @@ -7,24 +7,24 @@
vcs-python - vcspull + vcspull (libvcs), g
tmux-python - tmuxp + tmuxp (libtmux)
cihai - unihan-etl + unihan-etl (db) - cihai + cihai (cli)
@@ -32,38 +32,39 @@django - django-slugify-processor + django-slugify-processor - django-docutils + django-docutils
docs + tests - gp-libs + gp-libs
web - social-embed + social-embed
diff --git a/docs/conf.py b/docs/conf.py index dd2eee839..136ce89e8 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -29,6 +29,7 @@ extensions = [ "sphinx.ext.autodoc", + "sphinx_fonts", "sphinx.ext.intersphinx", "sphinx_autodoc_typehints", "sphinx.ext.todo", @@ -152,6 +153,57 @@ ogp_image = "_static/img/icons/icon-192x192.png" ogp_site_name = about["__title__"] +# sphinx_fonts — self-hosted IBM Plex via Fontsource CDN +sphinx_fonts = [ + { + "family": "IBM Plex Sans", + "package": "@fontsource/ibm-plex-sans", + "version": "5.2.8", + "weights": [400, 500, 600, 700], + "styles": ["normal", "italic"], + "subset": "latin", + }, + { + "family": "IBM Plex Mono", + "package": "@fontsource/ibm-plex-mono", + "version": "5.2.7", + "weights": [400], + "styles": ["normal", "italic"], + "subset": "latin", + }, +] + +sphinx_font_preload = [ + ("IBM Plex Sans", 400, "normal"), # body text + ("IBM Plex Sans", 700, "normal"), # headings + ("IBM Plex Mono", 400, "normal"), # code blocks +] + +sphinx_font_fallbacks = [ + { + "family": "IBM Plex Sans Fallback", + "src": 'local("Arial"), local("Helvetica Neue"), local("Helvetica")', + "size_adjust": "110.6%", + "ascent_override": "92.7%", + "descent_override": "24.9%", + "line_gap_override": "0%", + }, + { + "family": "IBM Plex Mono Fallback", + "src": 'local("Courier New"), local("Courier")', + "size_adjust": "100%", + "ascent_override": "102.5%", + "descent_override": "27.5%", + "line_gap_override": "0%", + }, +] + +sphinx_font_css_variables = { + "--font-stack": '"IBM Plex Sans", "IBM Plex Sans Fallback", -apple-system, BlinkMacSystemFont, sans-serif', + "--font-stack--monospace": '"IBM Plex Mono", "IBM Plex Mono Fallback", SFMono-Regular, Menlo, Consolas, monospace', + "--font-stack--headings": "var(--font-stack)", +} + intersphinx_mapping = { "py": ("https://docs.python.org/", None), "libvcs": ("https://libvcs.git-pull.com/", None), @@ -237,6 +289,7 @@ def remove_tabs_js(app: Sphinx, exc: Exception) -> None: def setup(app: Sphinx) -> None: """Sphinx setup hook.""" + app.add_js_file("js/spa-nav.js", loading_method="defer") app.connect("build-finished", remove_tabs_js) # Register vcspull-specific lexers diff --git a/pyproject.toml b/pyproject.toml index 500bfc914..1cb676961 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -161,6 +161,7 @@ module = [ "shtab", "sphinx_argparse_neo", "sphinx_argparse_neo.*", + "sphinx_fonts", "cli_usage_lexer", "argparse_lexer", "argparse_roles", diff --git a/tests/docs/_ext/test_sphinx_fonts.py b/tests/docs/_ext/test_sphinx_fonts.py new file mode 100644 index 000000000..22f546a2e --- /dev/null +++ b/tests/docs/_ext/test_sphinx_fonts.py @@ -0,0 +1,545 @@ +"""Tests for sphinx_fonts Sphinx extension.""" + +from __future__ import annotations + +import logging +import pathlib +import types +import typing as t +import urllib.error + +import pytest +import sphinx_fonts + +# --- _cache_dir tests --- + + +def test_cache_dir_returns_home_cache_path() -> None: + """_cache_dir returns ~/.cache/sphinx-fonts.""" + result = sphinx_fonts._cache_dir() + assert result == pathlib.Path.home() / ".cache" / "sphinx-fonts" + + +# --- _cdn_url tests --- + + +class CdnUrlFixture(t.NamedTuple): + """Test fixture for CDN URL generation.""" + + test_id: str + package: str + version: str + font_id: str + subset: str + weight: int + style: str + expected_url: str + + +CDN_URL_FIXTURES: list[CdnUrlFixture] = [ + CdnUrlFixture( + test_id="normal_weight", + package="@fontsource/open-sans", + version="5.2.5", + font_id="open-sans", + subset="latin", + weight=400, + style="normal", + expected_url=( + "https://cdn.jsdelivr.net/npm/@fontsource/open-sans@5.2.5" + "/files/open-sans-latin-400-normal.woff2" + ), + ), + CdnUrlFixture( + test_id="bold_italic", + package="@fontsource/roboto", + version="5.0.0", + font_id="roboto", + subset="latin-ext", + weight=700, + style="italic", + expected_url=( + "https://cdn.jsdelivr.net/npm/@fontsource/roboto@5.0.0" + "/files/roboto-latin-ext-700-italic.woff2" + ), + ), +] + + +@pytest.mark.parametrize( + list(CdnUrlFixture._fields), + CDN_URL_FIXTURES, + ids=[f.test_id for f in CDN_URL_FIXTURES], +) +def test_cdn_url( + test_id: str, + package: str, + version: str, + font_id: str, + subset: str, + weight: int, + style: str, + expected_url: str, +) -> None: + """_cdn_url formats the CDN URL template correctly.""" + result = sphinx_fonts._cdn_url(package, version, font_id, subset, weight, style) + assert result == expected_url + + +def test_cdn_url_matches_template() -> None: + """_cdn_url produces URLs matching CDN_TEMPLATE structure.""" + url = sphinx_fonts._cdn_url( + "@fontsource/inter", "5.1.0", "inter", "latin", 400, "normal" + ) + assert url.startswith("https://cdn.jsdelivr.net/npm/") + assert "@fontsource/inter@5.1.0" in url + assert url.endswith(".woff2") + + +# --- _download_font tests --- + + +def test_download_font_cached( + tmp_path: pathlib.Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """_download_font returns True and logs debug when file exists.""" + dest = tmp_path / "font.woff2" + dest.write_bytes(b"cached-data") + + with caplog.at_level(logging.DEBUG, logger="sphinx_fonts"): + result = sphinx_fonts._download_font("https://example.com/font.woff2", dest) + + assert result is True + debug_records = [r for r in caplog.records if r.levelno == logging.DEBUG] + assert any("cached" in r.message for r in debug_records) + + +def test_download_font_success( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """_download_font downloads and returns True on success.""" + dest = tmp_path / "subdir" / "font.woff2" + + def fake_urlretrieve(url: str, filename: t.Any) -> tuple[str, t.Any]: + pathlib.Path(filename).write_bytes(b"font-data") + return (str(filename), None) + + monkeypatch.setattr("sphinx_fonts.urllib.request.urlretrieve", fake_urlretrieve) + + with caplog.at_level(logging.INFO, logger="sphinx_fonts"): + result = sphinx_fonts._download_font("https://example.com/font.woff2", dest) + + assert result is True + info_records = [r for r in caplog.records if r.levelno == logging.INFO] + assert any("downloaded" in r.message for r in info_records) + + +def test_download_font_url_error( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """_download_font returns False and warns on URLError.""" + dest = tmp_path / "font.woff2" + + msg = "network error" + + def fake_urlretrieve(url: str, filename: t.Any) -> t.NoReturn: + raise urllib.error.URLError(msg) + + monkeypatch.setattr("sphinx_fonts.urllib.request.urlretrieve", fake_urlretrieve) + + with caplog.at_level(logging.WARNING, logger="sphinx_fonts"): + result = sphinx_fonts._download_font("https://example.com/font.woff2", dest) + + assert result is False + warning_records = [r for r in caplog.records if r.levelno == logging.WARNING] + assert any("failed" in r.message for r in warning_records) + + +def test_download_font_os_error( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """_download_font returns False and warns on OSError.""" + dest = tmp_path / "font.woff2" + + msg = "disk full" + + def fake_urlretrieve(url: str, filename: t.Any) -> t.NoReturn: + raise OSError(msg) + + monkeypatch.setattr("sphinx_fonts.urllib.request.urlretrieve", fake_urlretrieve) + + with caplog.at_level(logging.WARNING, logger="sphinx_fonts"): + result = sphinx_fonts._download_font("https://example.com/font.woff2", dest) + + assert result is False + warning_records = [r for r in caplog.records if r.levelno == logging.WARNING] + assert any("failed" in r.message for r in warning_records) + + +def test_download_font_partial_file_cleanup( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """_download_font removes partial file on failure.""" + dest = tmp_path / "cache" / "partial.woff2" + + msg = "disk full" + + def fake_urlretrieve(url: str, filename: t.Any) -> t.NoReturn: + pathlib.Path(filename).write_bytes(b"partial") + raise OSError(msg) + + monkeypatch.setattr("sphinx_fonts.urllib.request.urlretrieve", fake_urlretrieve) + + result = sphinx_fonts._download_font("https://example.com/font.woff2", dest) + + assert result is False + assert not dest.exists() + + +# --- _on_builder_inited tests --- + + +def _make_app( + tmp_path: pathlib.Path, + *, + builder_format: str = "html", + fonts: list[dict[str, t.Any]] | None = None, + preload: list[tuple[str, int, str]] | None = None, + fallbacks: list[dict[str, str]] | None = None, + variables: dict[str, str] | None = None, +) -> types.SimpleNamespace: + """Create a fake Sphinx app namespace for testing.""" + config = types.SimpleNamespace( + sphinx_fonts=fonts if fonts is not None else [], + sphinx_font_preload=preload if preload is not None else [], + sphinx_font_fallbacks=fallbacks if fallbacks is not None else [], + sphinx_font_css_variables=variables if variables is not None else {}, + ) + builder = types.SimpleNamespace(format=builder_format) + return types.SimpleNamespace( + builder=builder, + config=config, + outdir=str(tmp_path / "output"), + ) + + +def test_on_builder_inited_non_html(tmp_path: pathlib.Path) -> None: + """_on_builder_inited returns early for non-HTML builders.""" + app = _make_app(tmp_path, builder_format="latex") + sphinx_fonts._on_builder_inited(app) + assert not hasattr(app, "_font_faces") + + +def test_on_builder_inited_empty_fonts(tmp_path: pathlib.Path) -> None: + """_on_builder_inited returns early when no fonts configured.""" + app = _make_app(tmp_path, fonts=[]) + sphinx_fonts._on_builder_inited(app) + assert not hasattr(app, "_font_faces") + + +def test_on_builder_inited_with_fonts( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """_on_builder_inited processes fonts and stores results on app.""" + monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache") + + fonts = [ + { + "package": "@fontsource/open-sans", + "version": "5.2.5", + "family": "Open Sans", + "weights": [400, 700], + "styles": ["normal"], + }, + ] + app = _make_app(tmp_path, fonts=fonts) + + cache = tmp_path / "cache" + cache.mkdir(parents=True) + for weight in [400, 700]: + (cache / f"open-sans-latin-{weight}-normal.woff2").write_bytes(b"data") + + sphinx_fonts._on_builder_inited(app) + + assert len(app._font_faces) == 2 + assert app._font_faces[0]["family"] == "Open Sans" + assert app._font_faces[0]["weight"] == "400" + assert app._font_faces[1]["weight"] == "700" + assert app._font_preload_hrefs == [] + assert app._font_fallbacks == [] + assert app._font_css_variables == {} + + +def test_on_builder_inited_download_failure( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """_on_builder_inited skips font_faces entry on download failure.""" + monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache") + + msg = "offline" + + def fake_urlretrieve(url: str, filename: t.Any) -> t.NoReturn: + raise urllib.error.URLError(msg) + + monkeypatch.setattr("sphinx_fonts.urllib.request.urlretrieve", fake_urlretrieve) + + fonts = [ + { + "package": "@fontsource/inter", + "version": "5.0.0", + "family": "Inter", + "weights": [400], + "styles": ["normal"], + }, + ] + app = _make_app(tmp_path, fonts=fonts) + + sphinx_fonts._on_builder_inited(app) + + assert len(app._font_faces) == 0 + + +def test_on_builder_inited_explicit_subset( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """_on_builder_inited respects explicit subset in font config.""" + monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache") + + fonts = [ + { + "package": "@fontsource/noto-sans", + "version": "5.0.0", + "family": "Noto Sans", + "subset": "latin-ext", + "weights": [400], + "styles": ["normal"], + }, + ] + app = _make_app(tmp_path, fonts=fonts) + + cache = tmp_path / "cache" + cache.mkdir(parents=True) + (cache / "noto-sans-latin-ext-400-normal.woff2").write_bytes(b"data") + + sphinx_fonts._on_builder_inited(app) + + assert app._font_faces[0]["filename"] == "noto-sans-latin-ext-400-normal.woff2" + + +def test_on_builder_inited_preload_match( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """_on_builder_inited builds preload_hrefs for matching preload specs.""" + monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache") + + fonts = [ + { + "package": "@fontsource/open-sans", + "version": "5.2.5", + "family": "Open Sans", + "weights": [400], + "styles": ["normal"], + }, + ] + preload = [("Open Sans", 400, "normal")] + app = _make_app(tmp_path, fonts=fonts, preload=preload) + + cache = tmp_path / "cache" + cache.mkdir(parents=True) + (cache / "open-sans-latin-400-normal.woff2").write_bytes(b"data") + + sphinx_fonts._on_builder_inited(app) + + assert app._font_preload_hrefs == ["open-sans-latin-400-normal.woff2"] + + +def test_on_builder_inited_preload_no_match( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """_on_builder_inited produces empty preload when family doesn't match.""" + monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache") + + fonts = [ + { + "package": "@fontsource/open-sans", + "version": "5.2.5", + "family": "Open Sans", + "weights": [400], + "styles": ["normal"], + }, + ] + preload = [("Nonexistent Font", 400, "normal")] + app = _make_app(tmp_path, fonts=fonts, preload=preload) + + cache = tmp_path / "cache" + cache.mkdir(parents=True) + (cache / "open-sans-latin-400-normal.woff2").write_bytes(b"data") + + sphinx_fonts._on_builder_inited(app) + + assert app._font_preload_hrefs == [] + + +def test_on_builder_inited_fallbacks_and_variables( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """_on_builder_inited stores fallbacks and CSS variables on app.""" + monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache") + + fonts = [ + { + "package": "@fontsource/inter", + "version": "5.0.0", + "family": "Inter", + "weights": [400], + "styles": ["normal"], + }, + ] + fallbacks = [{"family": "system-ui", "style": "normal", "weight": "400"}] + variables = {"--font-body": "Inter, system-ui"} + app = _make_app(tmp_path, fonts=fonts, fallbacks=fallbacks, variables=variables) + + cache = tmp_path / "cache" + cache.mkdir(parents=True) + (cache / "inter-latin-400-normal.woff2").write_bytes(b"data") + + sphinx_fonts._on_builder_inited(app) + + assert app._font_fallbacks == fallbacks + assert app._font_css_variables == variables + + +# --- _on_html_page_context tests --- + + +def test_on_html_page_context_with_attrs() -> None: + """_on_html_page_context injects font data from app attributes.""" + app = types.SimpleNamespace( + _font_preload_hrefs=["font-400.woff2"], + _font_faces=[ + { + "family": "Inter", + "weight": "400", + "style": "normal", + "filename": "font-400.woff2", + }, + ], + _font_fallbacks=[{"family": "system-ui"}], + _font_css_variables={"--font-body": "Inter"}, + ) + context: dict[str, t.Any] = {} + + sphinx_fonts._on_html_page_context( + app, + "index", + "page.html", + context, + None, + ) + + assert context["font_preload_hrefs"] == ["font-400.woff2"] + assert context["font_faces"] == app._font_faces + assert context["font_fallbacks"] == [{"family": "system-ui"}] + assert context["font_css_variables"] == {"--font-body": "Inter"} + + +def test_on_html_page_context_without_attrs() -> None: + """_on_html_page_context uses defaults when app attrs are missing.""" + app = types.SimpleNamespace() + context: dict[str, t.Any] = {} + + sphinx_fonts._on_html_page_context( + app, + "index", + "page.html", + context, + None, + ) + + assert context["font_preload_hrefs"] == [] + assert context["font_faces"] == [] + assert context["font_fallbacks"] == [] + assert context["font_css_variables"] == {} + + +# --- setup tests --- + + +def test_setup_return_value() -> None: + """Verify setup() returns correct metadata dict.""" + config_values: list[tuple[str, t.Any, str]] = [] + connections: list[tuple[str, t.Any]] = [] + + app = types.SimpleNamespace( + add_config_value=lambda name, default, rebuild: config_values.append( + (name, default, rebuild) + ), + connect=lambda event, handler: connections.append((event, handler)), + ) + + result = sphinx_fonts.setup(app) + + assert result == { + "version": "1.0", + "parallel_read_safe": True, + "parallel_write_safe": True, + } + + +def test_setup_config_values() -> None: + """Verify setup() registers all expected config values.""" + config_values: list[tuple[str, t.Any, str]] = [] + connections: list[tuple[str, t.Any]] = [] + + app = types.SimpleNamespace( + add_config_value=lambda name, default, rebuild: config_values.append( + (name, default, rebuild) + ), + connect=lambda event, handler: connections.append((event, handler)), + ) + + sphinx_fonts.setup(app) + + config_names = [c[0] for c in config_values] + assert "sphinx_fonts" in config_names + assert "sphinx_font_fallbacks" in config_names + assert "sphinx_font_css_variables" in config_names + assert "sphinx_font_preload" in config_names + assert all(c[2] == "html" for c in config_values) + + +def test_setup_event_connections() -> None: + """Verify setup() connects to builder-inited and html-page-context events.""" + config_values: list[tuple[str, t.Any, str]] = [] + connections: list[tuple[str, t.Any]] = [] + + app = types.SimpleNamespace( + add_config_value=lambda name, default, rebuild: config_values.append( + (name, default, rebuild) + ), + connect=lambda event, handler: connections.append((event, handler)), + ) + + sphinx_fonts.setup(app) + + event_names = [c[0] for c in connections] + assert "builder-inited" in event_names + assert "html-page-context" in event_names + + handlers = {c[0]: c[1] for c in connections} + assert handlers["builder-inited"] is sphinx_fonts._on_builder_inited + assert handlers["html-page-context"] is sphinx_fonts._on_html_page_context