-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy path__init__.py
More file actions
659 lines (548 loc) · 23.1 KB
/
Copy path__init__.py
File metadata and controls
659 lines (548 loc) · 23.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
"""gp-furo-theme — Tailwind-v4-driven port of the Furo Sphinx theme.
Ported from furo 2025.12.19 (b788b8a), MIT (Pradyun Gedam). See LICENSE-FURO at the
package root. Theme name registers as ``gp-furo`` (vs upstream's ``furo``);
the ``__version__`` follows the gp-sphinx workspace lock-step. Jinja
context variables retain their upstream ``furo_*`` names so the ported
templates render byte-equivalently.
Examples
--------
>>> theme_path = get_theme_path()
>>> theme_path.is_dir()
True
>>> (theme_path / "theme.conf").is_file()
True
"""
from __future__ import annotations
import hashlib
import logging
import os
import pathlib
import shutil
import sys
import typing as t
from functools import cache, lru_cache
import sphinx.application
from docutils import nodes
from pygments.formatters import HtmlFormatter
from pygments.style import Style
from pygments.token import Text
from sphinx.builders.dirhtml import DirectoryHTMLBuilder
from sphinx.builders.html import StandaloneHTMLBuilder
from sphinx.environment.adapters.toctree import TocTree
from sphinx.errors import ConfigError
from sphinx.highlighting import PygmentsBridge
from sphinx.transforms.post_transforms import SphinxPostTransform
from .navigation import get_navigation_tree
__version__ = "0.0.1a15"
THEME_NAME = "gp-furo"
THEME_PATH = (pathlib.Path(__file__).parent / "theme" / THEME_NAME).resolve()
logger = logging.getLogger(__name__)
logger.addHandler(logging.NullHandler())
# Vite-built theme assets the rendered HTML references. Both must be on
# disk under ``THEME_PATH/static/`` for sphinx-build to copy them into the
# output's ``_static/`` tree. Missing assets ship the docs unstyled — the
# failure mode that took down https://gp-sphinx.git-pull.com/ on the
# v0.0.1a15 attempt and https://libtmux.git-pull.com/ on the downstream
# install of that broken wheel.
_REQUIRED_VITE_ASSETS: tuple[str, ...] = (
"scripts/furo.js",
"styles/furo-tw.css",
)
def _missing_vite_assets() -> list[pathlib.Path]:
"""Return absolute paths of any required vite assets not on disk."""
static_root = THEME_PATH / "static"
return [
static_root / asset
for asset in _REQUIRED_VITE_ASSETS
if not (static_root / asset).is_file()
]
def _gp_sphinx_vite_owns_lifecycle(app: sphinx.application.Sphinx) -> bool:
"""Detect whether ``gp-sphinx-vite`` is actively managing assets.
When the orchestration extension is registered AND mode resolves to
``dev`` (sphinx-autobuild), it spawns ``pnpm exec vite build --watch``
from its own ``builder-inited`` handler; assets land asynchronously.
Hard-failing during the first ``builder-inited`` would defeat the
autobuild UX. ``prod`` mode is a no-op (extension intentionally idle),
so the assertion still applies there.
"""
if "gp_sphinx_vite" not in app.config.extensions:
return False
try:
from gp_sphinx_vite.config import Mode, detect_mode
except ImportError: # pragma: no cover - defensive; declared dep
return False
cfg_value = getattr(app.config, "gp_sphinx_vite_mode", "auto")
return (
detect_mode(
config_value=str(cfg_value),
argv=sys.argv,
env=os.environ,
)
is Mode.DEV
)
def _format_missing_assets_hint(missing: list[pathlib.Path], *, version: str) -> str:
"""Build the ConfigError message for missing vite assets.
The hint adapts to the runtime context so the action is copy-pasteable:
workspace contributors get a ``pnpm install`` / ``vite build`` recipe;
wheel-install consumers learn that the upstream wheel is broken and
where to file the issue.
"""
web_root = get_vite_root()
pnpm_present = shutil.which("pnpm") is not None
bullets = [f" - {p}" for p in missing]
lines = [
"gp-furo-theme: required theme assets are missing on disk:",
*bullets,
"",
]
if web_root is None:
# Wheel install: the source ``web/`` tree is not present, so the
# only fix is upstream — either the published wheel was built
# without its assets (gp-sphinx <= 0.0.1a15 bug) or the install
# is corrupted. Don't surface contributor-only commands (e.g.
# ``pnpm exec vite build``) since there's no ``web/`` to run them
# against — the user can't act on those locally.
lines.extend(
[
f"Running from a wheel install of gp-furo-theme=={version}.",
"The wheel was published without its built theme assets — an",
"upstream packaging bug.",
"",
"Workarounds while waiting for a fixed release:",
" 1. Pin to an earlier working release of gp-sphinx (the",
" pre-Furo-port a14 line shipped vendored Furo CSS).",
" 2. Install gp-furo-theme from a git checkout or sdist, and",
" populate the package's static/ directory by hand.",
"",
"Track the fix at https://github.com/git-pull/gp-sphinx/issues",
]
)
else:
# Workspace checkout: actionable recipe for the contributor.
lines.append(
"Running from a workspace checkout. Rebuild the assets with:",
)
lines.append("")
if not pnpm_present:
lines.extend(
[
" # pnpm is not on PATH. Install it via one of:",
" corepack enable # Node 16.10+ ships corepack",
" curl -fsSL https://get.pnpm.io/install.sh | sh -",
" # See https://pnpm.io/installation",
"",
]
)
if not (web_root / "node_modules").is_dir():
lines.append(
f" cd {web_root} && pnpm install --frozen-lockfile",
)
lines.append(f" cd {web_root} && pnpm exec vite build")
lines.extend(
[
"",
"Or, for live-rebuild during authoring, run sphinx-autobuild",
"with gp-sphinx-vite enabled:",
" extensions = ['gp_sphinx_vite'] # in conf.py",
" uv run sphinx-autobuild docs _build/html",
"",
"gp-sphinx-vite auto-installs node_modules/ and spawns",
"``pnpm exec vite build --watch`` for you.",
]
)
return "\n".join(lines)
# GLOBAL STATE — populated by ``_builder_inited`` and consumed by
# ``_html_page_context`` + ``_overwrite_pygments_css``. Values are Pygments
# style *classes* (subclasses of ``Style``), not instances; that is how
# ``PygmentsBridge.formatter_args["style"]`` stores them, even though
# upstream Furo's typing on this dict is loose.
_KNOWN_STYLES_IN_USE: dict[str, type[Style] | None] = {
"light": None,
"dark": None,
}
class WrapTableAndMathInAContainerTransform(SphinxPostTransform):
"""A Sphinx post-transform that wraps `table` and `div.math` in a container `div`.
This makes it possible to handle these overflowing the content-width, which is
necessary in a responsive theme.
"""
formats = ("html",)
default_priority = 500
def run(self, **kwargs: t.Any) -> None:
"""Perform the post-transform on `self.document`."""
get_nodes = (
self.document.findall # docutils 0.18+
if hasattr(self.document, "findall")
else self.document.traverse # docutils <= 0.17.x
)
for table_node in list(get_nodes(nodes.table)):
new_node = nodes.container(classes=["table-wrapper"])
new_node.update_all_atts(table_node)
table_node.parent.replace(table_node, new_node)
new_node.append(table_node)
for math_node in list(get_nodes(nodes.math_block)):
new_node = nodes.container(classes=["math-wrapper"])
new_node.update_all_atts(math_node)
math_node.parent.replace(math_node, new_node)
new_node.append(math_node)
def has_not_enough_items_to_show_toc(
builder: StandaloneHTMLBuilder, docname: str
) -> bool:
"""Check if the toc has one or fewer items."""
assert builder.env
toctree = TocTree(builder.env).get_toc_for(docname, builder)
try:
self_toctree = toctree[0][1] # type: ignore[index]
except IndexError:
val = True
else:
# There's only the page's own toctree(s) in there.
val = all(entry.tagname == "toctree" for entry in self_toctree)
return val
def get_pygments_style_colors(
style: type[Style], *, fallbacks: dict[str, str]
) -> dict[str, str]:
"""Get background/foreground colors for given pygments style."""
background = style.background_color
text_colors = style.style_for_token(Text)
foreground = text_colors["color"]
if not background:
background = fallbacks["background"]
foreground = fallbacks["foreground"] if not foreground else f"#{foreground}"
return {"background": background, "foreground": foreground}
@lru_cache(maxsize=2)
def get_colors_for_codeblocks(
highlighter: PygmentsBridge, *, fg: str, bg: str
) -> dict[str, str]:
"""Get background/foreground colors for given pygments style."""
return get_pygments_style_colors(
highlighter.formatter_args["style"],
fallbacks={
"foreground": fg,
"background": bg,
},
)
def _compute_navigation_tree(context: dict[str, t.Any]) -> str:
# The navigation tree, generated from the sphinx-provided ToC tree.
if "toctree" in context:
toctree = context["toctree"]
toctree_html = toctree(
collapse=False,
titles_only=True,
maxdepth=-1,
includehidden=True,
)
else:
toctree_html = ""
return get_navigation_tree(toctree_html)
def _compute_hide_toc(
context: dict[str, t.Any],
*,
builder: StandaloneHTMLBuilder,
docname: str,
) -> bool:
# Should the table of contents be hidden?
file_meta = context.get("meta") or {}
if "hide-toc" in file_meta:
return True
if "toc" not in context:
return True
if not context["toc"]:
return True
return has_not_enough_items_to_show_toc(builder, docname)
@cache
def _asset_hash(path: str) -> str:
"""Append a `?digest=` to an url based on the file content."""
full_path = THEME_PATH / "static" / path
digest = hashlib.sha1(full_path.read_bytes()).hexdigest()
return f"_static/{path}?digest={digest}"
def _add_asset_hashes(static: list[str], add_digest_to: list[str]) -> None:
if sphinx.version_info >= (7, 1):
# https://github.com/sphinx-doc/sphinx/pull/11415 added the relevant
# functionality to Sphinx, so we don't need to do anything.
return
for asset in add_digest_to:
try:
index = static.index("_static/" + asset)
except ValueError as exc:
msg = (
"gp-furo-theme is trying to add a digest to an asset that is "
f"not in the static files: {asset}. Please check conf.py for "
"overrides of theme-provided assets such as `html_style`."
)
raise ConfigError(msg) from exc
# Make this idempotent
if "?digest=" in static[index].filename: # type: ignore[attr-defined]
continue
static[index].filename = _asset_hash(asset) # type: ignore[attr-defined]
def _fix_canonical_url(
app: sphinx.application.Sphinx, pagename: str, context: dict[str, t.Any]
) -> None:
"""Fix the canonical URL when using the dirhtml builder.
Sphinx builds a canonical URL if ``html_baseurl`` config is set. However,
it builds a URL ending with ".html" when using the dirhtml builder, which is
incorrect. Detect this and generate the correct URL for each page.
"""
if (
not app.config.html_baseurl
or not isinstance(app.builder, DirectoryHTMLBuilder)
or not context["pageurl"]
or not context["pageurl"].endswith(".html")
):
return
target = app.builder.get_target_uri(pagename)
context["pageurl"] = app.config.html_baseurl + target
def _html_page_context(
app: sphinx.application.Sphinx,
pagename: str,
templatename: str,
context: dict[str, t.Any],
doctree: t.Any,
) -> None:
if "css_files" in context:
# Sphinx 7.1+ handles cache-bust hashing natively, so this is a
# no-op call (see _add_asset_hashes early-return at line 181).
# Kept for the < 7.1 compatibility branch; the list reflects
# what we actually ship — only styles/furo-tw.css since the
# SCSS pipeline was dropped in step 9.14 of the 2026-04-30 pivot.
_add_asset_hashes(
context["css_files"],
["styles/furo-tw.css"],
)
if "scripts" in context:
_add_asset_hashes(
context["scripts"],
["scripts/furo.js"],
)
_fix_canonical_url(app, pagename, context)
# Basic constants — preserve upstream Furo's ``furo_*`` keys so the
# ported Jinja templates render byte-equivalently.
context["furo_version"] = __version__
# Values computed from page-level context.
context["furo_navigation_tree"] = _compute_navigation_tree(context)
context["furo_hide_toc"] = _compute_hide_toc(
context,
builder=t.cast("StandaloneHTMLBuilder", app.builder),
docname=pagename,
)
assert _KNOWN_STYLES_IN_USE["light"]
assert _KNOWN_STYLES_IN_USE["dark"]
# Inject information about styles
context["furo_pygments"] = {
"light": get_pygments_style_colors(
_KNOWN_STYLES_IN_USE["light"],
fallbacks={"foreground": "black", "background": "white"},
),
"dark": get_pygments_style_colors(
_KNOWN_STYLES_IN_USE["dark"],
fallbacks={"foreground": "white", "background": "black"},
),
}
def _builder_inited(app: sphinx.application.Sphinx) -> None:
if "gp_furo_theme" in app.config.extensions:
# Loading us as both an extension and a theme would re-run setup() and
# double-register hooks. Match upstream Furo's ConfigError on the
# equivalent misconfiguration.
msg = (
"Did you list 'gp_furo_theme' in the `extensions` in conf.py? "
"If so, please remove it. gp-furo-theme does not work with "
"non-HTML builders and specifying it as an `html_theme` is "
"sufficient."
)
raise ConfigError(msg)
looks_like_html_builder = isinstance(app.builder, StandaloneHTMLBuilder) or (
app.builder.name in {"html", "dirhtml"}
)
if not looks_like_html_builder:
msg = (
"gp-furo-theme is being used as an extension in a non-HTML build. "
"This should not happen."
)
raise ConfigError(msg)
# Hard-fail when the vite-built theme assets aren't on disk. Without
# this check sphinx-build silently skipped missing static files (no
# ``-W`` warning fires for stylesheets declared in ``theme.conf`` that
# aren't on disk), the deployed HTML referenced 404'd assets, and the
# site rendered unstyled. We fail loudly with an actionable hint
# instead. Skipped under ``gp-sphinx-vite``'s dev mode, which spawns
# vite-watch from its own ``builder-inited`` handler — the assets land
# asynchronously and would race a strict assertion here.
if not _gp_sphinx_vite_owns_lifecycle(app):
missing = _missing_vite_assets()
if missing:
raise ConfigError(_format_missing_assets_hint(missing, version=__version__))
# Our JS file needs to be loaded as soon as possible.
app.add_js_file("scripts/furo.js", priority=200)
# NOTE: pre-pivot we also added "styles/furo-extensions.css" via
# add_css_file (priority=600) for sphinx-design / inline-tabs /
# copybutton / readthedocs styles. Step 9.9 bundled all of those
# into the main entry (web/src/styles/components/extensions.css ->
# imported by index.css -> compiles into furo-tw.css), so the
# secondary stylesheet is no longer needed.
builder = app.builder
assert (
builder.highlighter is not None # type: ignore[attr-defined]
), "there should be a default style known to Sphinx"
assert (
builder.dark_highlighter is None # type: ignore[attr-defined]
), "this shouldn't be a dark style known to Sphinx"
update_known_styles_state(app)
def _update_default(key: str, *, new_default: t.Any) -> None:
try:
conf_py_settings = app.config._raw_config
except AttributeError:
pass # Sphinx's config has changed.
else:
if key not in conf_py_settings:
app.config._raw_config.setdefault(key, new_default)
# Change the default permalinks icon
_update_default("html_permalinks_icon", new_default="#")
def update_known_styles_state(app: sphinx.application.Sphinx) -> None:
"""Update a global store of known styles of this application."""
global _KNOWN_STYLES_IN_USE
_KNOWN_STYLES_IN_USE = {
"light": _get_light_style(app),
"dark": _get_dark_style(app),
}
def _get_light_style(app: sphinx.application.Sphinx) -> type[Style]:
return t.cast(
"type[Style]",
app.builder.highlighter.formatter_args["style"], # type: ignore[attr-defined]
)
def _get_dark_style(app: sphinx.application.Sphinx) -> type[Style]:
dark_style = app.config.pygments_dark_style
return t.cast(
"type[Style]",
PygmentsBridge("html", dark_style).formatter_args["style"],
)
def _get_styles(formatter: HtmlFormatter[str], *, prefix: str) -> t.Iterator[str]:
"""Get styles out of a formatter, where everything has the correct prefix."""
for line in formatter.get_linenos_style_defs(): # type: ignore[no-untyped-call]
yield f"{prefix} {line}"
yield from formatter.get_background_style_defs(prefix) # type: ignore[no-untyped-call]
yield from formatter.get_token_style_defs(prefix) # type: ignore[no-untyped-call]
def get_pygments_stylesheet() -> str:
"""Generate the theme-specific pygments.css.
There is no way to tell Sphinx how the theme handles dark mode at this time,
so we generate a stylesheet that supports both light and dark via
``body[data-theme]`` and ``prefers-color-scheme``.
"""
light_style = _KNOWN_STYLES_IN_USE["light"]
dark_style = _KNOWN_STYLES_IN_USE["dark"]
assert light_style is not None, "_builder_inited has not run"
assert dark_style is not None, "_builder_inited has not run"
light_formatter = PygmentsBridge.html_formatter(style=light_style)
dark_formatter = PygmentsBridge.html_formatter(style=dark_style)
lines: list[str] = []
lines.extend(_get_styles(light_formatter, prefix=".highlight"))
lines.append("@media not print {")
dark_prefix = 'body[data-theme="dark"] .highlight'
lines.extend(_get_styles(dark_formatter, prefix=dark_prefix))
not_light_prefix = 'body:not([data-theme="light"]) .highlight'
lines.append("@media (prefers-color-scheme: dark) {")
lines.extend(_get_styles(dark_formatter, prefix=not_light_prefix))
lines.append("}")
lines.append("}")
return "\n".join(lines)
# Yup, we overwrite the default pygments.css file, because it can't possibly respect
# the needs of this theme.
def _overwrite_pygments_css(
app: sphinx.application.Sphinx,
exception: Exception | None,
) -> None:
if exception is not None:
return
assert app.builder
pygments_css = pathlib.Path(app.builder.outdir) / "_static" / "pygments.css"
pygments_css.write_text(get_pygments_stylesheet(), encoding="utf-8")
def get_vite_root() -> pathlib.Path | None:
"""Locate the ``web/`` directory containing ``package.json`` + ``vite.config.ts``.
Returns the path when running from a workspace checkout (where the
Vite source files live alongside the Python package), or ``None``
when running from an installed wheel (the wheel ships pre-built
static assets but not the SCSS/TS sources).
Intended for use by ``gp-sphinx-vite`` consumers — set
``gp_sphinx_vite_root = gp_furo_theme.get_vite_root()`` in
``conf.py`` (or wire it through :func:`gp_sphinx.config.merge_sphinx_config`)
so the orchestration finds the right ``cwd`` to spawn ``vite`` in.
Returns
-------
pathlib.Path | None
Absolute path to the ``web/`` directory, or ``None`` if it is
not present (typical for installed wheels).
Examples
--------
>>> root = get_vite_root()
>>> root is None or root.is_dir()
True
"""
candidate = pathlib.Path(__file__).resolve().parents[2] / "web"
return candidate if candidate.is_dir() else None
def get_theme_path() -> pathlib.Path:
"""Return the absolute path to the bundled ``gp-furo`` theme directory.
Returns
-------
pathlib.Path
Directory containing ``theme.conf`` and the ported Jinja templates +
Vite-built assets.
Examples
--------
>>> theme_path = get_theme_path()
>>> (theme_path / "theme.conf").exists()
True
"""
return THEME_PATH
def setup(app: sphinx.application.Sphinx) -> dict[str, bool | str]:
"""Register the ``gp-furo`` theme with Sphinx.
Parameters
----------
app : Sphinx
The Sphinx application object.
Returns
-------
dict[str, bool | str]
Extension metadata: ``parallel_read_safe`` and
``parallel_write_safe`` are both ``True`` (matching upstream Furo's
guarantees), and ``version`` reports the package version.
Examples
--------
>>> class FakeApp:
... def __init__(self) -> None:
... self.themes: list[tuple[str, pathlib.Path]] = []
... self.post_transforms: list[type] = []
... self.events: list[str] = []
... self.config_values: list[str] = []
... def require_sphinx(self, version: str) -> None:
... pass
... def add_config_value(self, name: str, **kwargs: object) -> None:
... self.config_values.append(name)
... def add_html_theme(self, name: str, theme_path: pathlib.Path) -> None:
... self.themes.append((name, theme_path))
... def add_post_transform(self, transform: type) -> None:
... self.post_transforms.append(transform)
... def connect(self, event: str, callback: object) -> None:
... self.events.append(event)
>>> fake = FakeApp()
>>> metadata = setup(fake) # type: ignore[arg-type]
>>> fake.themes[0][0]
'gp-furo'
>>> "pygments_dark_style" in fake.config_values
True
>>> sorted(fake.events)
['build-finished', 'builder-inited', 'html-page-context']
>>> metadata["parallel_read_safe"]
True
"""
app.require_sphinx("8.1")
app.add_config_value(
"pygments_dark_style", default="native", rebuild="env", types=[str]
)
app.add_html_theme(THEME_NAME, str(THEME_PATH))
app.add_post_transform(WrapTableAndMathInAContainerTransform)
app.connect("html-page-context", _html_page_context)
app.connect("builder-inited", _builder_inited)
app.connect("build-finished", _overwrite_pygments_css)
return {
"parallel_read_safe": True,
"parallel_write_safe": True,
"version": __version__,
}