From 682fbb6bc7148554a8c5a7103f06c785dc5cc3ad Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 06:51:11 -0500 Subject: [PATCH 01/21] 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 b420cee..3bd3098 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 c8a638063d06c4126cde630e8ef728d3d104a414 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 06:51:11 -0500 Subject: [PATCH 02/21] 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 3bd3098..6b22865 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 57728a15ff140f49f156abc20b2aaf6325ef1532 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 06:51:11 -0500 Subject: [PATCH 03/21] 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 6b22865..6587788 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 14efa69831ead9caabfcbb2b8adbe1652c99b5a8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 06:51:11 -0500 Subject: [PATCH 04/21] =?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 6587788..dd9c605 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 56236fc5f624553ee628c43e1163f4b57dca47a2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 06:51:12 -0500 Subject: [PATCH 05/21] 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 | 5 ++ docs/_ext/sphinx_fonts.py | 146 +++++++++++++++++++++++++++++++++++++ docs/conf.py | 27 +++++++ 4 files changed, 187 insertions(+) create mode 100644 docs/_ext/sphinx_fonts.py diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 8cce27e..237b8f9 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -60,6 +60,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 0621c10..c3eb56a 100644 --- a/.gitignore +++ b/.gitignore @@ -82,6 +82,11 @@ pip-wheel-metadata/ # Monkeytype monkeytype.sqlite3 + +# Generated by sphinx_fonts extension (downloaded at build time) +docs/_static/fonts/ +docs/_static/css/fonts.css + # Claude code **/CLAUDE.local.md **/CLAUDE.*.md diff --git a/docs/_ext/sphinx_fonts.py b/docs/_ext/sphinx_fonts.py new file mode 100644 index 0000000..2f46743 --- /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 28743f4..76bbcf8 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -30,6 +30,7 @@ extensions = [ "sphinx.ext.autodoc", + "sphinx_fonts", "sphinx.ext.intersphinx", "sphinx_autodoc_typehints", "sphinx.ext.todo", @@ -126,6 +127,32 @@ rediraffe_redirects = "redirects.txt" rediraffe_branch = "master~1" +# 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": ("http://libvcs.git-pull.com/", None), From 33a720b823c2b0a74bd7c4b35710254446407b42 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 06:51:12 -0500 Subject: [PATCH 06/21] =?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 2f46743..0abad43 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 3f74fb5c12cde1dfefc34593b2fcca349061d83e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 06:51:12 -0500 Subject: [PATCH 07/21] 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 0abad43..d7e0261 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 bbfae70..55001a3 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 76bbcf8..d6c7b5b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -147,6 +147,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 7ff9ca9ddde1b7214dbd6464b0f3d39aeb9328ef Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 06:51:12 -0500 Subject: [PATCH 08/21] 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 dd9c605..a02647e 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 039d2ab8ea8995981b7e6a463de4d8d52cf1d3ac Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 06:51:12 -0500 Subject: [PATCH 09/21] 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 a02647e..f1ebfa5 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 0000000..7fe241c --- /dev/null +++ b/docs/_templates/sidebar/brand.html @@ -0,0 +1,18 @@ + From 00f55b9d6e5335b4e2f42fdf22baf15f9539f2b5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 06:51:12 -0500 Subject: [PATCH 10/21] 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 0000000..fa7fd2e --- /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 d6c7b5b..344aafa 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -246,4 +246,5 @@ def remove_tabs_js(app: Sphinx, exc: Exception) -> None: def setup(app: Sphinx) -> None: """Configure Sphinx app hooks.""" + app.add_js_file("js/spa-nav.js", loading_method="defer") app.connect("build-finished", remove_tabs_js) From 190230a8d17592f027892b6db401b91f6fbee535 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 06:51:12 -0500 Subject: [PATCH 11/21] 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 344aafa..fd76955 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -153,9 +153,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 318b57509dd91f720a40ac6d6842bb54eb748640 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 06:51:12 -0500 Subject: [PATCH 12/21] 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 f1ebfa5..9aa213c 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 d87e93761479921446c3469e0285328f198e0199 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 06:51:12 -0500 Subject: [PATCH 13/21] 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 9aa213c..b075fce 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 97420c1..0c182a2 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 10470f6a8ec468f64975830c7b5943a5ddd26ea5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 06:51:12 -0500 Subject: [PATCH 14/21] 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 b075fce..8b04d2b 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 fa7fd2e..e00e521 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 bbeceae033290ba28ac081f27e90ef882c71d1eb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 06:51:12 -0500 Subject: [PATCH 15/21] 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 8b04d2b..9032773 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 4f441331f2eea9e4a4efb219e7a994c2150de71c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 06:51:12 -0500 Subject: [PATCH 16/21] 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 d3a59107a9f3fb177096cd8c7c6f802a42331755 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 07:00:42 -0500 Subject: [PATCH 17/21] 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 750a8e2..e8d2a69 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 53345b23b95639d9c30cad2e59fdeede1452beb7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 10:00:45 -0500 Subject: [PATCH 18/21] 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 0000000..b3eeeff --- /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 42ae9ad64b20747144bcb36b5c1be8b10b3ec463 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 10:07:00 -0500 Subject: [PATCH 19/21] 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 b3eeeff..a76bf14 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 28a9659f7a8283e0c07483fcab5788cb5bfb10c4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 10:08:36 -0500 Subject: [PATCH 20/21] 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 a76bf14..c650940 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 10d3369a213c1e91b6483c0d73e0b310788176cb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 10:12:22 -0500 Subject: [PATCH 21/21] 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 | 52 +++++++++++++++++++--------- 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/tests/docs/_ext/test_sphinx_fonts.py b/tests/docs/_ext/test_sphinx_fonts.py index c650940..22f546a 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 --- @@ -213,14 +234,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 +268,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" @@ -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" @@ -283,10 +304,9 @@ 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" + assert len(app._font_faces) == 0 def test_on_builder_inited_explicit_subset( @@ -312,7 +332,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 +360,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 +388,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 +417,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 +448,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 +467,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 +491,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 +512,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 +534,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