From 5a02aa25b3888272f8b5e5931dc221f22a0d15c0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 06:51:11 -0500 Subject: [PATCH 01/22] docs(style[toc,body]): refine right-panel TOC and body typography MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Furo's default TOC title (10px) is nearly invisible and smaller than its own items (12px), inverting typographic hierarchy. Body line-height (1.5) is tighter than WCAG-recommended range. what: - Bump TOC item size 75% → 81.25% (12→13px) via --toc-font-size - Bump TOC title size 62.5% → 87.5% (10→14px) via --toc-title-font-size - Increase .toc-tree line-height 1.3 → 1.4 for wrapped entries - Increase article line-height 1.5 → 1.6 for paragraph readability - Enable text-rendering: optimizeLegibility on body --- docs/_static/css/custom.css | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index b420cee94..3bd309812 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -18,3 +18,33 @@ .sidebar-tree .active { font-weight: bold; } + +/* ── 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: items 75% → 81.25% (12→13px), + title 62.5% → 87.5% (10→14px) */ +:root { + --toc-font-size: var(--font-size--small--2); + --toc-title-font-size: var(--font-size--small); +} + +/* More generous line-height for wrapped TOC entries */ +.toc-tree { + line-height: 1.4; +} + +/* ── Body typography refinements ──────────────────────────── + * Improve paragraph readability with wider line-height and + * sharper text rendering. Furo already sets font-smoothing. + * ────────────────────────────────────────────────────────── */ +body { + text-rendering: optimizeLegibility; +} + +article { + line-height: 1.6; +} From 61a6c70ed46188cf139123c431e4f322bcfb9bdf Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 06:51:11 -0500 Subject: [PATCH 02/22] docs(style[toc,content]): flexible TOC width with inner-panel padding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Furo hardcodes .toc-drawer to 15em; long TOC entries overflow and code blocks in the content area are cramped. what: - Override .toc-drawer min-width to 18em with flex-shrink: 0 - Move padding to .toc-sticky inner panel (1.5em right) - Set .content to flex: 1 1 46em with max-width: 46em - Override right offset at ≤82em breakpoint for collapse --- docs/_static/css/custom.css | 38 +++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index 3bd309812..6b2286589 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -37,6 +37,44 @@ 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. From c094d6b4fc8b2cdc71667a3fc5b81459db1ea044 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 06:51:11 -0500 Subject: [PATCH 03/22] docs(style[toc]): increase TOC font size from 81.25% to 87.5% why: 81.25% (13px) is still noticeably smaller than the 14px body text; at 87.5% (14px) the TOC matches body size and reads comfortably beside it. what: - Change --toc-font-size from --font-size--small--2 to --font-size--small - Move variables from :root to body for specificity with Furo --- docs/_static/css/custom.css | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index 6b2286589..6587788a7 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -25,11 +25,10 @@ * Uses Furo CSS variable overrides where possible. * ────────────────────────────────────────────────────────── */ -/* TOC font sizes: items 75% → 81.25% (12→13px), - title 62.5% → 87.5% (10→14px) */ -:root { - --toc-font-size: var(--font-size--small--2); - --toc-title-font-size: var(--font-size--small); +/* TOC font sizes: override Furo defaults (75% → 87.5%) */ +body { + --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 */ From 588e0b1241a5183ff04e92349e299b34b168609b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 06:51:11 -0500 Subject: [PATCH 04/22] =?UTF-8?q?docs(style[headings]):=20refine=20heading?= =?UTF-8?q?=20hierarchy=20=E2=80=94=20scale,=20spacing,=20eyebrow=20labels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Furo headings are large and bold, crowding the page and flattening visual hierarchy. Biome-inspired medium-weight scale uses size and spacing — not boldness — to convey structure. what: - Set all article headings to font-weight: 500 - Scale: h1 1.8em, h2 1.6em, h3 1.15em, h4-h6 eyebrow treatment - Add uppercase + letter-spacing + muted color for h4-h6 - Add changelog heading extras for #history section - Revert TOC variables from body back to :root --- docs/_static/css/custom.css | 82 ++++++++++++++++++++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index 6587788a7..dd9c6058e 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -19,6 +19,86 @@ 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). @@ -26,7 +106,7 @@ * ────────────────────────────────────────────────────────── */ /* TOC font sizes: override Furo defaults (75% → 87.5%) */ -body { +:root { --toc-font-size: var(--font-size--small); /* 87.5% = 14px */ --toc-title-font-size: var(--font-size--small); /* 87.5% = 14px */ } From 8c60a1f45ae8ebf09ef016b262709d1d1249f0cf Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 06:51:12 -0500 Subject: [PATCH 05/22] docs(fonts): self-host IBM Plex via Fontsource CDN why: Standardize on IBM Plex Sans / Mono across projects without committing ~227KB of binary font files to the repo. what: - Add sphinx_fonts extension that downloads fonts at build time, caches in ~/.cache/sphinx-fonts/, and generates @font-face CSS - Configure IBM Plex Sans (400/500/600/700) and IBM Plex Mono (400) with CSS variable overrides for Furo theme - Add actions/cache step in docs workflow for font cache persistence - Gitignore generated font assets in docs/_static/ --- .github/workflows/docs.yml | 9 +++ .gitignore | 4 + docs/_ext/sphinx_fonts.py | 146 +++++++++++++++++++++++++++++++++++++ docs/conf.py | 27 +++++++ 4 files changed, 186 insertions(+) create mode 100644 docs/_ext/sphinx_fonts.py 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..2f467433f --- /dev/null +++ b/docs/_ext/sphinx_fonts.py @@ -0,0 +1,146 @@ +"""Sphinx extension for self-hosted fonts via Fontsource CDN. + +Downloads font files at build time, caches them locally, and generates +CSS with @font-face declarations and CSS variable overrides. +""" + +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): + 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 _generate_css( + fonts: list[dict[str, t.Any]], + variables: dict[str, str], +) -> str: + lines: list[str] = [] + for font in fonts: + family = font["family"] + font_id = font["package"].split("/")[-1] + subset = font.get("subset", "latin") + for weight in font["weights"]: + for style in font["styles"]: + filename = f"{font_id}-{subset}-{weight}-{style}.woff2" + lines.append("@font-face {") + lines.append(f' font-family: "{family}";') + lines.append(f" font-style: {style};") + lines.append(f" font-weight: {weight};") + lines.append(" font-display: swap;") + lines.append(f' src: url("../fonts/{filename}") format("woff2");') + lines.append("}") + lines.append("") + + if variables: + lines.append(":root {") + for var, value in variables.items(): + lines.append(f" {var}: {value};") + lines.append("}") + lines.append("") + + return "\n".join(lines) + + +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" + css_dir = static_dir / "css" + fonts_dir.mkdir(parents=True, exist_ok=True) + css_dir.mkdir(parents=True, exist_ok=True) + + 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) + + css_content = _generate_css(fonts, variables) + (css_dir / "fonts.css").write_text(css_content, encoding="utf-8") + logger.info("generated fonts.css with %d font families", len(fonts)) + + app.add_css_file("css/fonts.css") + + +def setup(app: Sphinx) -> SetupDict: + app.add_config_value("sphinx_fonts", [], "html") + app.add_config_value("sphinx_font_css_variables", {}, "html") + app.connect("builder-inited", _on_builder_inited) + return { + "version": "1.0", + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/docs/conf.py b/docs/conf.py index dd2eee839..25550b3bf 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,32 @@ 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_css_variables = { + "--font-stack": '"IBM Plex Sans", -apple-system, BlinkMacSystemFont, sans-serif', + "--font-stack--monospace": '"IBM Plex Mono", 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), From b9b1645d5f718eabb31e839f04ef444a6013c61f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 06:51:12 -0500 Subject: [PATCH 06/22] =?UTF-8?q?docs(fonts[css]):=20fix=20variable=20spec?= =?UTF-8?q?ificity=20=E2=80=94=20use=20body=20instead=20of=20:root?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Furo sets --font-stack on body, so :root declarations lose in specificity. Using body selector matches Furo's own pattern. what: - Change CSS variable container from :root to body in _generate_css --- docs/_ext/sphinx_fonts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_ext/sphinx_fonts.py b/docs/_ext/sphinx_fonts.py index 2f467433f..0abad4363 100644 --- a/docs/_ext/sphinx_fonts.py +++ b/docs/_ext/sphinx_fonts.py @@ -90,7 +90,7 @@ def _generate_css( lines.append("") if variables: - lines.append(":root {") + lines.append("body {") for var, value in variables.items(): lines.append(f" {var}: {value};") lines.append("}") From 1ab377fff941bedff086b576a8a57432ba84dbaf Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 06:51:12 -0500 Subject: [PATCH 07/22] docs(fonts[preload]): add for critical font weights MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The browser doesn't discover font URLs until it parses fonts.css, which itself must wait for the HTML to load. Preload hints in tell the browser to start downloading fonts immediately. what: - Add sphinx_font_preload config option to sphinx_fonts extension - Emit tags in page.html template's extrahead block - Preload 3 critical weights: Sans 400/700, Mono 400 - Rename layout.html → page.html (Furo extends !page.html) --- docs/_ext/sphinx_fonts.py | 24 ++++++++++++++++++++++ docs/_templates/{layout.html => page.html} | 5 ++++- docs/conf.py | 6 ++++++ 3 files changed, 34 insertions(+), 1 deletion(-) rename docs/_templates/{layout.html => page.html} (91%) diff --git a/docs/_ext/sphinx_fonts.py b/docs/_ext/sphinx_fonts.py index 0abad4363..d7e026117 100644 --- a/docs/_ext/sphinx_fonts.py +++ b/docs/_ext/sphinx_fonts.py @@ -132,13 +132,37 @@ def _on_builder_inited(app: Sphinx) -> None: (css_dir / "fonts.css").write_text(css_content, encoding="utf-8") logger.info("generated fonts.css with %d font families", len(fonts)) + 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 + app._font_preload_hrefs = preload_hrefs # type: ignore[attr-defined] + app.add_css_file("css/fonts.css") +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", []) + + def setup(app: Sphinx) -> SetupDict: app.add_config_value("sphinx_fonts", [], "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, diff --git a/docs/_templates/layout.html b/docs/_templates/page.html similarity index 91% rename from docs/_templates/layout.html rename to docs/_templates/page.html index c57a16e5e..c89ac2233 100644 --- a/docs/_templates/layout.html +++ b/docs/_templates/page.html @@ -1,6 +1,9 @@ -{% extends "!layout.html" %} +{% extends "!page.html" %} {%- block extrahead %} {{ super() }} + {%- for href in font_preload_hrefs|default([]) %} + + {%- endfor %} {%- if theme_show_meta_manifest_tag == true %} {% endif -%} diff --git a/docs/conf.py b/docs/conf.py index 25550b3bf..b5411e70a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -173,6 +173,12 @@ }, ] +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_css_variables = { "--font-stack": '"IBM Plex Sans", -apple-system, BlinkMacSystemFont, sans-serif', "--font-stack--monospace": '"IBM Plex Mono", SFMono-Regular, Menlo, Consolas, monospace', From 5e2187d3a3ca0c1e217ab31a6cd056d279166e34 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 06:51:12 -0500 Subject: [PATCH 08/22] docs(fonts[css]): add kerning, ligatures, and code rendering overrides why: IBM Plex Sans benefits from OpenType features that browsers disable by default; monospace blocks need opposite treatment. what: - Add font-kerning, font-variant-ligatures, letter-spacing to body - Add optimizeSpeed and disable ligatures for pre/code/kbd/samp --- docs/_static/css/custom.css | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index dd9c6058e..a02647e4c 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -157,9 +157,31 @@ article h6 { /* ── 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 { From 4a6ff51583cde0f973c0b6074bb7de3730982b1b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 06:51:12 -0500 Subject: [PATCH 09/22] docs(images[cls]): prevent layout shift and add non-blocking loading why: Every on the docs site lacked dimension hints, causing Cumulative Layout Shift (CLS) on page load. what: - Add content-visibility: auto on all img for off-screen decode skip - Add height: auto !important for lazy-loaded images with aspect-ratio - Add CSS height: 20px for shields.io / badge / codecov badges - Add sidebar/brand.html with width/height/decoding on logo --- docs/_static/css/custom.css | 28 ++++++++++++++++++++++++++++ docs/_templates/sidebar/brand.html | 18 ++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 docs/_templates/sidebar/brand.html diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index a02647e4c..f1ebfa5bb 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -187,3 +187,31 @@ samp { 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; +} 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 @@ + From 50e6fd7bb8fe1730bcf61bccd28611a90a22db1d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 06:51:12 -0500 Subject: [PATCH 10/22] docs(nav[spa]): add SPA-like navigation to avoid full page reloads why: Every page navigation re-downloads and re-parses CSS/JS/fonts and re-renders the entire layout. Only article content, TOC, and active sidebar link actually change between pages. what: - Create docs/_static/js/spa-nav.js (~170 lines, vanilla JS, no deps) - Intercept internal link clicks and swap three DOM regions - Preserve sidebar scroll, theme state, all CSS/JS/fonts - Register in conf.py setup() with loading_method="defer" --- docs/_static/js/spa-nav.js | 228 +++++++++++++++++++++++++++++++++++++ docs/conf.py | 1 + 2 files changed, 229 insertions(+) create mode 100644 docs/_static/js/spa-nav.js diff --git a/docs/_static/js/spa-nav.js b/docs/_static/js/spa-nav.js new file mode 100644 index 000000000..fa7fd2ed6 --- /dev/null +++ b/docs/_static/js/spa-nav.js @@ -0,0 +1,228 @@ +/** + * 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"); + + 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(); + } 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/conf.py b/docs/conf.py index b5411e70a..294f7b3bd 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -270,6 +270,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 From cae61f9bf34b8029bab977d9ba71f5b2ddb721ea Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 06:51:12 -0500 Subject: [PATCH 11/22] docs(fonts[fallback]): add fallback font metrics to eliminate FOUT reflow why: When web fonts load, text reflowed because system fallbacks have different metrics. Capsize-derived overrides make fallback fonts match IBM Plex dimensions exactly. what: - Add sphinx_font_fallbacks config with size-adjust/ascent/descent overrides for Arial (sans) and Courier New (mono) - Update font stacks to include fallback font families --- docs/conf.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 294f7b3bd..136ce89e8 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -179,9 +179,28 @@ ("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", -apple-system, BlinkMacSystemFont, sans-serif', - "--font-stack--monospace": '"IBM Plex Mono", SFMono-Regular, Menlo, Consolas, monospace', + "--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)", } From 12b7cec8f3818bfeab9b91763585ee2327656879 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 06:51:12 -0500 Subject: [PATCH 12/22] docs(images[badges]): add placeholder sizing for external badge images why: Badges from shields.io/codecov render at 0x0 until loaded, causing visible layout shift in the header area. what: - Add min-width: 60px, border-radius, and background placeholder to badge selectors for stable pre-load dimensions --- docs/_static/css/custom.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index f1ebfa5bb..9aa213ca6 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -214,4 +214,7 @@ img[src*="badge.svg"], img[src*="codecov.io"] { height: 20px; width: auto; + min-width: 60px; + border-radius: 3px; + background: var(--color-background-secondary); } From 975fc11232d7b37b65559665b2e94cf5ca02533a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 06:51:12 -0500 Subject: [PATCH 13/22] docs(sidebar[projects]): prevent active link flash with visibility gate why: All links render visibly then JS replaces the hostname-matching link with a bold span, causing a visible reflow. what: - Remove misleading class="current" from all project links - Hide #sidebar-projects until JS resolves active state (.ready) - Use textContent instead of innerHTML for safer DOM manipulation --- docs/_static/css/custom.css | 4 ++++ docs/_templates/sidebar/projects.html | 21 +++++++++++---------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index 9aa213ca6..b075fce58 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -15,6 +15,10 @@ margin-right: calc(var(--sidebar-item-spacing-horizontal) / 2.5); } +#sidebar-projects:not(.ready) { + visibility: hidden; +} + .sidebar-tree .active { font-weight: bold; } 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

From b47afb5985b9c4ae770d22680e1922cd7017bbad Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 06:51:12 -0500 Subject: [PATCH 14/22] docs(nav[spa]): wrap DOM swap in View Transitions API for smooth crossfade why: SPA navigation instantly replaces DOM content, causing a jarring visual jump between pages instead of a smooth transition. what: - Wrap swap+reinit in document.startViewTransition() when available - Add 150ms crossfade animation via ::view-transition pseudo-elements - Progressive enhancement: unsupported browsers get instant swap --- docs/_static/css/custom.css | 10 ++++++++++ docs/_static/js/spa-nav.js | 32 ++++++++++++++++++++------------ 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index b075fce58..8b04d2b33 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -198,6 +198,16 @@ article { * content-visibility and badge-specific height to prevent CLS. * ────────────────────────────────────────────────────────── */ + +/* ── 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; +} + img { content-visibility: auto; } diff --git a/docs/_static/js/spa-nav.js b/docs/_static/js/spa-nav.js index fa7fd2ed6..e00e521ab 100644 --- a/docs/_static/js/spa-nav.js +++ b/docs/_static/js/spa-nav.js @@ -160,21 +160,29 @@ if (!doc.querySelector(".article-container")) throw new Error("no article"); - swap(doc); + 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); + } + } - if (!isPop) history.pushState({ spa: true }, "", url); + reinit(); + }; - 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); - } + if (document.startViewTransition) { + document.startViewTransition(applySwap); + } else { + applySwap(); } - - reinit(); } catch (err) { if (err.name === "AbortError") return; window.location.href = url; From cc24577fc2f208c8e82e331cf575e76946312daf Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 06:51:12 -0500 Subject: [PATCH 15/22] docs(css[structure]): move view transitions section after image rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: View transitions are not image-related — grouping them with image CLS rules is misleading. Moving to end of file keeps related sections together and matches the logical reading order. what: - Move view transitions CSS block after badge placeholder rules --- docs/_static/css/custom.css | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index 8b04d2b33..903277352 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -199,15 +199,6 @@ article { * ────────────────────────────────────────────────────────── */ -/* ── 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; -} - img { content-visibility: auto; } @@ -232,3 +223,12 @@ img[src*="codecov.io"] { 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; +} From 40b07afcfc232b17153526e5f7ada881bffe1d03 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 06:51:12 -0500 Subject: [PATCH 16/22] docs(fonts[loading]): switch to font-display block with inline CSS why: font-display swap causes visible text reflow (FOUT). Matching the tony.nl/cv approach: block rendering until preloaded fonts arrive, and inline the @font-face CSS to eliminate the extra fonts.css request. what: - Change font-display from swap to block - Move @font-face CSS from external fonts.css to inline + {%- endif %} {%- if theme_show_meta_manifest_tag == true %} {% endif -%} From 8d76aa3ea7e75320427852916c894098e607bcf3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 07:00:41 -0500 Subject: [PATCH 17/22] docs(fonts[lint]): add docstrings to sphinx_fonts extension why: ruff D101/D103 rules flag missing docstrings on SetupDict and setup(), causing CI lint failures in repos that lint docs/_ext/. what: - Add docstring to SetupDict TypedDict class - Add docstring to setup() function --- docs/_ext/sphinx_fonts.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/_ext/sphinx_fonts.py b/docs/_ext/sphinx_fonts.py index 750a8e2e9..e8d2a692a 100644 --- a/docs/_ext/sphinx_fonts.py +++ b/docs/_ext/sphinx_fonts.py @@ -25,6 +25,8 @@ class SetupDict(t.TypedDict): + """Return type for Sphinx extension setup().""" + version: str parallel_read_safe: bool parallel_write_safe: bool @@ -137,6 +139,7 @@ def _on_html_page_context( 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") From 5d9043ecf3b39abe620edd6e47c0ed4d335f9955 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 10:00:45 -0500 Subject: [PATCH 18/22] test(docs[sphinx_fonts]): add tests for sphinx_fonts extension why: codecov drops because docs/_ext/sphinx_fonts.py is measured for coverage but has zero tests across all repos. what: - Add test_sphinx_fonts.py with 21 tests covering all functions - Test pure functions, I/O with monkeypatch, Sphinx events with SimpleNamespace - Cover all branches: cached/success/URLError/OSError, html/non-html, empty/with fonts --- tests/docs/_ext/test_sphinx_fonts.py | 511 +++++++++++++++++++++++++++ 1 file changed, 511 insertions(+) create mode 100644 tests/docs/_ext/test_sphinx_fonts.py diff --git a/tests/docs/_ext/test_sphinx_fonts.py b/tests/docs/_ext/test_sphinx_fonts.py new file mode 100644 index 000000000..b3eeeff60 --- /dev/null +++ b/tests/docs/_ext/test_sphinx_fonts.py @@ -0,0 +1,511 @@ +"""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" + + def fake_urlretrieve(url: str, filename: t.Any) -> t.NoReturn: + raise urllib.error.URLError("network error") + + 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" + + def fake_urlretrieve(url: str, filename: t.Any) -> t.NoReturn: + raise OSError("disk full") + + 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) + + +# --- _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) # type: ignore[arg-type] + 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) # type: ignore[arg-type] + 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) # type: ignore[arg-type] + + 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 still builds font_faces entry on download failure.""" + monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache") + + def fake_urlretrieve(url: str, filename: t.Any) -> t.NoReturn: + raise urllib.error.URLError("offline") + + 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) # type: ignore[arg-type] + + assert len(app._font_faces) == 1 + assert app._font_faces[0]["family"] == "Inter" + + +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) # type: ignore[arg-type] + + 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) # type: ignore[arg-type] + + 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) # type: ignore[arg-type] + + 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) # type: ignore[arg-type] + + 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 # type: ignore[arg-type] + ) + + 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 # type: ignore[arg-type] + ) + + 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: + """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) # type: ignore[arg-type] + + assert result == { + "version": "1.0", + "parallel_read_safe": True, + "parallel_write_safe": True, + } + + +def test_setup_config_values() -> None: + """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) # type: ignore[arg-type] + + 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: + """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) # type: ignore[arg-type] + + 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 From 7c13c8a4a5d48ed900049798de0c374055e99739 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 10:07:00 -0500 Subject: [PATCH 19/22] test(docs[sphinx_fonts]): fix ruff lint errors in test_sphinx_fonts why: CI ruff check fails on EM101 (string literal in exception), TRY003 (long exception message), and D403 (uncapitalized docstring). what: - Extract exception messages to variables for EM101/TRY003 - Capitalize docstrings starting with "setup" for D403 --- tests/docs/_ext/test_sphinx_fonts.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/tests/docs/_ext/test_sphinx_fonts.py b/tests/docs/_ext/test_sphinx_fonts.py index b3eeeff60..a76bf1487 100644 --- a/tests/docs/_ext/test_sphinx_fonts.py +++ b/tests/docs/_ext/test_sphinx_fonts.py @@ -145,8 +145,10 @@ def test_download_font_url_error( """_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("network error") + raise urllib.error.URLError(msg) monkeypatch.setattr("sphinx_fonts.urllib.request.urlretrieve", fake_urlretrieve) @@ -166,8 +168,10 @@ def test_download_font_os_error( """_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("disk full") + raise OSError(msg) monkeypatch.setattr("sphinx_fonts.urllib.request.urlretrieve", fake_urlretrieve) @@ -261,8 +265,10 @@ def test_on_builder_inited_download_failure( """_on_builder_inited still builds 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("offline") + raise urllib.error.URLError(msg) monkeypatch.setattr("sphinx_fonts.urllib.request.urlretrieve", fake_urlretrieve) @@ -446,7 +452,7 @@ def test_on_html_page_context_without_attrs() -> None: def test_setup_return_value() -> None: - """setup returns correct metadata dict.""" + """Verify setup() returns correct metadata dict.""" config_values: list[tuple[str, t.Any, str]] = [] connections: list[tuple[str, t.Any]] = [] @@ -467,7 +473,7 @@ def test_setup_return_value() -> None: def test_setup_config_values() -> None: - """setup registers all expected config values.""" + """Verify setup() registers all expected config values.""" config_values: list[tuple[str, t.Any, str]] = [] connections: list[tuple[str, t.Any]] = [] @@ -489,7 +495,7 @@ def test_setup_config_values() -> None: def test_setup_event_connections() -> None: - """setup connects to builder-inited and html-page-context events.""" + """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]] = [] From 8d283fa60675e04bb89e376b32f6393775fa48e2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 10:08:36 -0500 Subject: [PATCH 20/22] test(docs[sphinx_fonts]): apply ruff format to test_sphinx_fonts why: CI ruff format check fails on multi-line function call formatting. what: - Apply ruff format to test_sphinx_fonts.py --- tests/docs/_ext/test_sphinx_fonts.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/docs/_ext/test_sphinx_fonts.py b/tests/docs/_ext/test_sphinx_fonts.py index a76bf1487..c650940b8 100644 --- a/tests/docs/_ext/test_sphinx_fonts.py +++ b/tests/docs/_ext/test_sphinx_fonts.py @@ -424,7 +424,11 @@ def test_on_html_page_context_with_attrs() -> None: context: dict[str, t.Any] = {} sphinx_fonts._on_html_page_context( - app, "index", "page.html", context, None # type: ignore[arg-type] + app, + "index", + "page.html", + context, + None, # type: ignore[arg-type] ) assert context["font_preload_hrefs"] == ["font-400.woff2"] @@ -439,7 +443,11 @@ def test_on_html_page_context_without_attrs() -> None: context: dict[str, t.Any] = {} sphinx_fonts._on_html_page_context( - app, "index", "page.html", context, None # type: ignore[arg-type] + app, + "index", + "page.html", + context, + None, # type: ignore[arg-type] ) assert context["font_preload_hrefs"] == [] From 4222d6b8e2b6e765a2b69de5cc54612670ec6cc3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 10:12:22 -0500 Subject: [PATCH 21/22] test(docs[sphinx_fonts]): remove type: ignore comments for mypy compat why: CI mypy fails with unused-ignore because sphinx_fonts is imported as an untyped module via sys.path, making arg-type suppression unnecessary. what: - Remove all type: ignore[arg-type] comments from test_sphinx_fonts.py --- tests/docs/_ext/test_sphinx_fonts.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/tests/docs/_ext/test_sphinx_fonts.py b/tests/docs/_ext/test_sphinx_fonts.py index c650940b8..ce87faab5 100644 --- a/tests/docs/_ext/test_sphinx_fonts.py +++ b/tests/docs/_ext/test_sphinx_fonts.py @@ -213,14 +213,14 @@ def _make_app( 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) # type: ignore[arg-type] + 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) # type: ignore[arg-type] + sphinx_fonts._on_builder_inited(app) assert not hasattr(app, "_font_faces") @@ -247,7 +247,7 @@ def test_on_builder_inited_with_fonts( for weight in [400, 700]: (cache / f"open-sans-latin-{weight}-normal.woff2").write_bytes(b"data") - sphinx_fonts._on_builder_inited(app) # type: ignore[arg-type] + sphinx_fonts._on_builder_inited(app) assert len(app._font_faces) == 2 assert app._font_faces[0]["family"] == "Open Sans" @@ -283,7 +283,7 @@ def fake_urlretrieve(url: str, filename: t.Any) -> t.NoReturn: ] app = _make_app(tmp_path, fonts=fonts) - sphinx_fonts._on_builder_inited(app) # type: ignore[arg-type] + sphinx_fonts._on_builder_inited(app) assert len(app._font_faces) == 1 assert app._font_faces[0]["family"] == "Inter" @@ -312,7 +312,7 @@ def test_on_builder_inited_explicit_subset( cache.mkdir(parents=True) (cache / "noto-sans-latin-ext-400-normal.woff2").write_bytes(b"data") - sphinx_fonts._on_builder_inited(app) # type: ignore[arg-type] + sphinx_fonts._on_builder_inited(app) assert app._font_faces[0]["filename"] == "noto-sans-latin-ext-400-normal.woff2" @@ -340,7 +340,7 @@ def test_on_builder_inited_preload_match( cache.mkdir(parents=True) (cache / "open-sans-latin-400-normal.woff2").write_bytes(b"data") - sphinx_fonts._on_builder_inited(app) # type: ignore[arg-type] + sphinx_fonts._on_builder_inited(app) assert app._font_preload_hrefs == ["open-sans-latin-400-normal.woff2"] @@ -368,7 +368,7 @@ def test_on_builder_inited_preload_no_match( cache.mkdir(parents=True) (cache / "open-sans-latin-400-normal.woff2").write_bytes(b"data") - sphinx_fonts._on_builder_inited(app) # type: ignore[arg-type] + sphinx_fonts._on_builder_inited(app) assert app._font_preload_hrefs == [] @@ -397,7 +397,7 @@ def test_on_builder_inited_fallbacks_and_variables( cache.mkdir(parents=True) (cache / "inter-latin-400-normal.woff2").write_bytes(b"data") - sphinx_fonts._on_builder_inited(app) # type: ignore[arg-type] + sphinx_fonts._on_builder_inited(app) assert app._font_fallbacks == fallbacks assert app._font_css_variables == variables @@ -428,7 +428,7 @@ def test_on_html_page_context_with_attrs() -> None: "index", "page.html", context, - None, # type: ignore[arg-type] + None, ) assert context["font_preload_hrefs"] == ["font-400.woff2"] @@ -447,7 +447,7 @@ def test_on_html_page_context_without_attrs() -> None: "index", "page.html", context, - None, # type: ignore[arg-type] + None, ) assert context["font_preload_hrefs"] == [] @@ -471,7 +471,7 @@ def test_setup_return_value() -> None: connect=lambda event, handler: connections.append((event, handler)), ) - result = sphinx_fonts.setup(app) # type: ignore[arg-type] + result = sphinx_fonts.setup(app) assert result == { "version": "1.0", @@ -492,7 +492,7 @@ def test_setup_config_values() -> None: connect=lambda event, handler: connections.append((event, handler)), ) - sphinx_fonts.setup(app) # type: ignore[arg-type] + sphinx_fonts.setup(app) config_names = [c[0] for c in config_values] assert "sphinx_fonts" in config_names @@ -514,7 +514,7 @@ def test_setup_event_connections() -> None: connect=lambda event, handler: connections.append((event, handler)), ) - sphinx_fonts.setup(app) # type: ignore[arg-type] + sphinx_fonts.setup(app) event_names = [c[0] for c in connections] assert "builder-inited" in event_names From 5cab5461195c2ffbf9a7cd9ead4256bedbb75ac2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 11:32:00 -0500 Subject: [PATCH 22/22] pyproject(mypy): add sphinx_fonts to ignore_missing_imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: sphinx_fonts is a local docs/_ext extension, not an installed package — mypy cannot resolve it at analysis time. what: - Add sphinx_fonts to [[tool.mypy.overrides]] ignore_missing_imports --- pyproject.toml | 1 + tests/docs/_ext/test_sphinx_fonts.py | 26 +++++++++++++++++++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) 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 index ce87faab5..22f546a2e 100644 --- a/tests/docs/_ext/test_sphinx_fonts.py +++ b/tests/docs/_ext/test_sphinx_fonts.py @@ -183,6 +183,27 @@ def fake_urlretrieve(url: str, filename: t.Any) -> t.NoReturn: 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 --- @@ -262,7 +283,7 @@ def test_on_builder_inited_download_failure( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """_on_builder_inited still builds font_faces entry on download failure.""" + """_on_builder_inited skips font_faces entry on download failure.""" monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache") msg = "offline" @@ -285,8 +306,7 @@ def fake_urlretrieve(url: str, filename: t.Any) -> t.NoReturn: sphinx_fonts._on_builder_inited(app) - assert len(app._font_faces) == 1 - assert app._font_faces[0]["family"] == "Inter" + assert len(app._font_faces) == 0 def test_on_builder_inited_explicit_subset(