Skip to content

Commit d841b39

Browse files
authored
docs: Refactor with typesetting and SPA-like transitions (#643)
why: Improve documentation UX across the project ecosystem with faster loads, zero layout shift, and smooth navigation — delivering a polished reading experience on par with modern documentation sites. what: Self-hosted fonts with build-time caching - Download IBM Plex Sans/Mono from Fontsource CDN at docs build time - Cache to ~/.cache/sphinx-fonts/ with CI caching via GitHub Actions - Generate inline font-face declarations with font-display: block - Preload critical font weights to eliminate Flash of Unstyled Text Layout shift prevention (CLS → 0) - Font fallback metrics (size-adjust, ascent/descent/line-gap overrides) prevent text reflow when web fonts load - Badge/image placeholders with fixed dimensions prevent content jumping - Sidebar logo gets explicit width/height with decoding="async" SPA-like page navigation - Vanilla JS (~236 lines, zero dependencies) intercepts internal links - Swaps only .article-container, .sidebar-tree, and .toc-drawer regions - Preserves sidebar scroll position, theme state, and copy buttons - Hover prefetch (65ms delay) for near-instant perceived navigation - History API integration for native back/forward behavior - View Transitions API crossfade (150ms) with graceful degradation Typography and readability - Heading hierarchy: weight 500 (not bold), sized h1→h6 with spacing - Body: line-height 1.6, optimizeLegibility, kerning, common ligatures - Code: optimizeSpeed, no ligatures, no kerning — clean monospace grid - TOC: 87.5% font size, 1.4 line-height, min-width 18em for long entries - Content area: max-width 46em for optimal reading line length Testing and type safety - 21 test functions (545 lines) covering downloads, caching, error handling, Sphinx builder integration, and template context injection - mypy strict mode compatible with targeted overrides for local extension Ported from tmuxp#1021 and tmuxp#1022.
1 parent 372bfb1 commit d841b39

File tree

14 files changed

+1296
-11
lines changed

14 files changed

+1296
-11
lines changed

.github/workflows/docs.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,15 @@ jobs:
6363
python -V
6464
uv run python -V
6565
66+
- name: Cache sphinx fonts
67+
if: env.PUBLISH == 'true'
68+
uses: actions/cache@v5
69+
with:
70+
path: ~/.cache/sphinx-fonts
71+
key: sphinx-fonts-${{ hashFiles('docs/conf.py') }}
72+
restore-keys: |
73+
sphinx-fonts-
74+
6675
- name: Build documentation
6776
if: env.PUBLISH == 'true'
6877
run: |

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,11 @@ target/
7777
# Monkeytype
7878
monkeytype.sqlite3
7979

80+
81+
# Generated by sphinx_fonts extension (downloaded at build time)
82+
docs/_static/fonts/
83+
docs/_static/css/fonts.css
84+
8085
# Claude code
8186
**/CLAUDE.local.md
8287
**/CLAUDE.*.md

docs/_ext/sphinx_fonts.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
"""Sphinx extension for self-hosted fonts via Fontsource CDN.
2+
3+
Downloads font files at build time, caches them locally, and passes
4+
structured font data to the template context for inline @font-face CSS.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import logging
10+
import pathlib
11+
import shutil
12+
import typing as t
13+
import urllib.error
14+
import urllib.request
15+
16+
if t.TYPE_CHECKING:
17+
from sphinx.application import Sphinx
18+
19+
logger = logging.getLogger(__name__)
20+
21+
CDN_TEMPLATE = (
22+
"https://cdn.jsdelivr.net/npm/{package}@{version}"
23+
"/files/{font_id}-{subset}-{weight}-{style}.woff2"
24+
)
25+
26+
27+
class SetupDict(t.TypedDict):
28+
"""Return type for Sphinx extension setup()."""
29+
30+
version: str
31+
parallel_read_safe: bool
32+
parallel_write_safe: bool
33+
34+
35+
def _cache_dir() -> pathlib.Path:
36+
return pathlib.Path.home() / ".cache" / "sphinx-fonts"
37+
38+
39+
def _cdn_url(
40+
package: str,
41+
version: str,
42+
font_id: str,
43+
subset: str,
44+
weight: int,
45+
style: str,
46+
) -> str:
47+
return CDN_TEMPLATE.format(
48+
package=package,
49+
version=version,
50+
font_id=font_id,
51+
subset=subset,
52+
weight=weight,
53+
style=style,
54+
)
55+
56+
57+
def _download_font(url: str, dest: pathlib.Path) -> bool:
58+
if dest.exists():
59+
logger.debug("font cached: %s", dest.name)
60+
return True
61+
dest.parent.mkdir(parents=True, exist_ok=True)
62+
try:
63+
urllib.request.urlretrieve(url, dest)
64+
logger.info("downloaded font: %s", dest.name)
65+
except (urllib.error.URLError, OSError):
66+
if dest.exists():
67+
dest.unlink()
68+
logger.warning("failed to download font: %s", url)
69+
return False
70+
return True
71+
72+
73+
def _on_builder_inited(app: Sphinx) -> None:
74+
if app.builder.format != "html":
75+
return
76+
77+
fonts: list[dict[str, t.Any]] = app.config.sphinx_fonts
78+
variables: dict[str, str] = app.config.sphinx_font_css_variables
79+
if not fonts:
80+
return
81+
82+
cache = _cache_dir()
83+
static_dir = pathlib.Path(app.outdir) / "_static"
84+
fonts_dir = static_dir / "fonts"
85+
fonts_dir.mkdir(parents=True, exist_ok=True)
86+
87+
font_faces: list[dict[str, str]] = []
88+
for font in fonts:
89+
font_id = font["package"].split("/")[-1]
90+
version = font["version"]
91+
package = font["package"]
92+
subset = font.get("subset", "latin")
93+
for weight in font["weights"]:
94+
for style in font["styles"]:
95+
filename = f"{font_id}-{subset}-{weight}-{style}.woff2"
96+
cached = cache / filename
97+
url = _cdn_url(package, version, font_id, subset, weight, style)
98+
if _download_font(url, cached):
99+
shutil.copy2(cached, fonts_dir / filename)
100+
font_faces.append(
101+
{
102+
"family": font["family"],
103+
"style": style,
104+
"weight": str(weight),
105+
"filename": filename,
106+
}
107+
)
108+
109+
preload_hrefs: list[str] = []
110+
preload_specs: list[tuple[str, int, str]] = app.config.sphinx_font_preload
111+
for family_name, weight, style in preload_specs:
112+
for font in fonts:
113+
if font["family"] == family_name:
114+
font_id = font["package"].split("/")[-1]
115+
subset = font.get("subset", "latin")
116+
filename = f"{font_id}-{subset}-{weight}-{style}.woff2"
117+
preload_hrefs.append(filename)
118+
break
119+
120+
fallbacks: list[dict[str, str]] = app.config.sphinx_font_fallbacks
121+
122+
app._font_preload_hrefs = preload_hrefs # type: ignore[attr-defined]
123+
app._font_faces = font_faces # type: ignore[attr-defined]
124+
app._font_fallbacks = fallbacks # type: ignore[attr-defined]
125+
app._font_css_variables = variables # type: ignore[attr-defined]
126+
127+
128+
def _on_html_page_context(
129+
app: Sphinx,
130+
pagename: str,
131+
templatename: str,
132+
context: dict[str, t.Any],
133+
doctree: t.Any,
134+
) -> None:
135+
context["font_preload_hrefs"] = getattr(app, "_font_preload_hrefs", [])
136+
context["font_faces"] = getattr(app, "_font_faces", [])
137+
context["font_fallbacks"] = getattr(app, "_font_fallbacks", [])
138+
context["font_css_variables"] = getattr(app, "_font_css_variables", {})
139+
140+
141+
def setup(app: Sphinx) -> SetupDict:
142+
"""Register config values, events, and return extension metadata."""
143+
app.add_config_value("sphinx_fonts", [], "html")
144+
app.add_config_value("sphinx_font_fallbacks", [], "html")
145+
app.add_config_value("sphinx_font_css_variables", {}, "html")
146+
app.add_config_value("sphinx_font_preload", [], "html")
147+
app.connect("builder-inited", _on_builder_inited)
148+
app.connect("html-page-context", _on_html_page_context)
149+
return {
150+
"version": "1.0",
151+
"parallel_read_safe": True,
152+
"parallel_write_safe": True,
153+
}

0 commit comments

Comments
 (0)