From 6a5f30c197da9f3afe71a8fd44baa8796db77787 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 3bf24f5ef..9c4cd8a5c 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -35,3 +35,33 @@ h4 { .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 b85cb5ac2064aa64d35dbf39c455aa708a4d0a30 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 9c4cd8a5c..2b2f9cc65 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -54,6 +54,44 @@ h4 { 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 a4126041bdc117dab28babf7c63c121a0fabcdd1 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 2b2f9cc65..18c37cb14 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -42,11 +42,10 @@ h4 { * 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 693a880567305bf2d9a40cb3f0c88787bd1a64ca 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 | 99 ++++++++++++++++++++++++++++++------- 1 file changed, 81 insertions(+), 18 deletions(-) diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index 18c37cb14..dd9c6058e 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -1,20 +1,3 @@ -h2 { - margin-bottom: 1.25rem; - margin-top: 1.25rem; - scroll-margin-top: 0.5rem; -} - -h3 { - margin-bottom: 1.25rem; - margin-top: 1.25rem; - scroll-margin-top: 0.5rem; -} - -h4 { - margin-bottom: 1.25rem; - scroll-margin-top: 0.5rem; -} - .sidebar-tree p.indented-block { padding: var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal) 0 var(--sidebar-item-spacing-horizontal); @@ -36,6 +19,86 @@ h4 { 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). @@ -43,7 +106,7 @@ h4 { * ────────────────────────────────────────────────────────── */ /* 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 9c45202a8eb3b300587956a1ca00ca56ac0936ab 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 12b235ef0..d43d61141 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -56,6 +56,15 @@ jobs: if: env.PUBLISH == 'true' uses: extractions/setup-just@v3 + - 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 29869d033..e881c088b 100644 --- a/.gitignore +++ b/.gitignore @@ -91,3 +91,7 @@ monkeytype.sqlite3 *repopack* **/.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 068927954..b305c0820 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -31,6 +31,7 @@ extensions = [ "sphinx.ext.napoleon", # Should go first "sphinx.ext.autodoc", + "sphinx_fonts", "sphinx_autodoc_typehints", "sphinx.ext.intersphinx", "sphinx.ext.todo", @@ -147,6 +148,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/3", None), "pip": ("https://pip.pypa.io/en/latest/", None), From 98d2e0fa7f8a226c21edbd20a0b2927e0553afec 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 2c560c4a320f05293b80457f678dadeefab7c427 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 19a94fe47..920df885c 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 b305c0820..f494ea71a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -168,6 +168,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 b9e8b6f9349814ea65a713966d216d588e16b02c 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 f83b0cb012216e9f7548135177aac3897b559cb5 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 b3a04819e1267960969e5d738d743a7b8b09581e 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 f494ea71a..9075c0938 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -281,4 +281,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 ccab0596237389fcf59a97300f765c679978c306 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 9075c0938..a36aec224 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -174,9 +174,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 36ead8f4cf7fd3761a5d32a686e6fe0599cbf99b 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 c67cb9953728df2ac7f27fe1ac4016c0304b2e02 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 006a28103396d4040dd4f58db7a5914e68047e7e 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 7fede09237fcf039aa078bb4a0c0c891873763a0 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 0fa6520deb42df063419d28bea067c817c0180f9 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 f128bf277367918fe625ebf5c1f53d8e6e335410 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 07:00:39 -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 09b5b2ffb6a1ed2786292f2f1bbf9236c9c2733a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 10:00:57 -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 - Add test infrastructure (conftest, __init__) for docs/_ext tests - Test pure functions, I/O with monkeypatch, Sphinx events with SimpleNamespace - Cover all branches: cached/success/URLError/OSError, html/non-html, empty/with fonts --- docs/_ext/conftest.py | 10 + tests/docs/__init__.py | 3 + tests/docs/_ext/__init__.py | 3 + tests/docs/_ext/conftest.py | 10 + tests/docs/_ext/test_sphinx_fonts.py | 511 +++++++++++++++++++++++++++ 5 files changed, 537 insertions(+) create mode 100644 docs/_ext/conftest.py create mode 100644 tests/docs/__init__.py create mode 100644 tests/docs/_ext/__init__.py create mode 100644 tests/docs/_ext/conftest.py create mode 100644 tests/docs/_ext/test_sphinx_fonts.py diff --git a/docs/_ext/conftest.py b/docs/_ext/conftest.py new file mode 100644 index 000000000..267629452 --- /dev/null +++ b/docs/_ext/conftest.py @@ -0,0 +1,10 @@ +"""Pytest configuration for docs/_ext doctests.""" + +from __future__ import annotations + +import pathlib +import sys + +_ext_dir = pathlib.Path(__file__).parent +if str(_ext_dir) not in sys.path: + sys.path.insert(0, str(_ext_dir)) diff --git a/tests/docs/__init__.py b/tests/docs/__init__.py new file mode 100644 index 000000000..b6723bfd0 --- /dev/null +++ b/tests/docs/__init__.py @@ -0,0 +1,3 @@ +"""Tests for documentation extensions.""" + +from __future__ import annotations diff --git a/tests/docs/_ext/__init__.py b/tests/docs/_ext/__init__.py new file mode 100644 index 000000000..56548488e --- /dev/null +++ b/tests/docs/_ext/__init__.py @@ -0,0 +1,3 @@ +"""Tests for docs/_ext Sphinx extensions.""" + +from __future__ import annotations diff --git a/tests/docs/_ext/conftest.py b/tests/docs/_ext/conftest.py new file mode 100644 index 000000000..e7547fa8a --- /dev/null +++ b/tests/docs/_ext/conftest.py @@ -0,0 +1,10 @@ +"""Fixtures and configuration for docs extension tests.""" + +from __future__ import annotations + +import pathlib +import sys + +docs_ext_path = pathlib.Path(__file__).parent.parent.parent.parent / "docs" / "_ext" +if str(docs_ext_path) not in sys.path: + sys.path.insert(0, str(docs_ext_path)) 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 5d37f93dc55345c823a7acd82ec6030a429f205a 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 2e5f1ad862cd2d233573eb810b7dda6748a51507 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 14 Mar 2026 10:08:37 -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 2f458a7f64df8581380eb66f6988edfc5e672b71 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]): fix mypy errors in test_sphinx_fonts why: CI mypy fails with unused-ignore (sphinx_fonts is untyped) and duplicate module (docs/_ext/conftest.py conflicts with root conftest.py). what: - Remove all type: ignore[arg-type] comments from test_sphinx_fonts.py - Remove docs/_ext/conftest.py (not needed, sphinx_fonts has no doctests) --- docs/_ext/conftest.py | 10 ---------- tests/docs/_ext/test_sphinx_fonts.py | 26 +++++++++++++------------- 2 files changed, 13 insertions(+), 23 deletions(-) delete mode 100644 docs/_ext/conftest.py diff --git a/docs/_ext/conftest.py b/docs/_ext/conftest.py deleted file mode 100644 index 267629452..000000000 --- a/docs/_ext/conftest.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Pytest configuration for docs/_ext doctests.""" - -from __future__ import annotations - -import pathlib -import sys - -_ext_dir = pathlib.Path(__file__).parent -if str(_ext_dir) not in sys.path: - sys.path.insert(0, str(_ext_dir)) 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 8def3253f1e2426196894456a8b72700717d858f 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 - Add targeted arg-type disable for test_sphinx_fonts (SimpleNamespace stubs) --- pyproject.toml | 8 ++++++++ tests/docs/_ext/test_sphinx_fonts.py | 26 +++++++++++++++++++++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7e68bc986..67e69467f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -135,6 +135,14 @@ files = [ "tests", ] +[[tool.mypy.overrides]] +module = ["sphinx_fonts"] +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = ["tests.docs._ext.test_sphinx_fonts"] +disable_error_code = ["arg-type"] + [tool.coverage.run] branch = true parallel = true 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(