Skip to content

Commit 3b0bdc1

Browse files
authored
Fix two broken links that -W cannot see (#81)
Neither defect is a resolved reference, so a warnings-as-errors build reported nothing while both shipped 404s. `sphinx-gp-llms` advertised a Markdown twin in the page footer for every page, while twins were written only for real source documents, so `genindex`, `py-modindex` and `search` each linked a file that was never created. One predicate now owns the three conditions a twin requires -- the page is in `found_docs`, it does not match `llms_excludes`, and its source exists -- and both the writer and the page-context handler call it, so the link and the writer cannot drift apart again. `gp-sphinx` deletes `_static/tabs.js` after each HTML build, working around a `sphinx-inline-tabs` script that conflicts with SPA navigation, but nothing dropped the `<script>` tag the extension registers; every page then requested an asset the build had just removed. The tag is now dropped alongside the file. Tab switching is unaffected, being driven by CSS, and cross-group sync remains the standing cost of the workaround rather than a regression here. Smartquotes mangling literal JSON in an argument description was reported alongside these and is deliberately unchanged: descriptions are already parsed as inline markup, so a literal reaches docutils intact, and only generated boilerplate arrives unprotected.
2 parents 9b6c63c + b8e66c9 commit 3b0bdc1

6 files changed

Lines changed: 204 additions & 8 deletions

File tree

CHANGES

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,24 @@ $ uv add gp-sphinx --prerelease allow
2020

2121
### Fixes
2222

23+
#### `sphinx-gp-llms`: A Markdown twin link only where a twin exists
24+
25+
The page footer advertised a `.md` sibling on every page, while twins were
26+
written only for real source documents, so `genindex`, `py-modindex` and
27+
`search` each linked a file that was never created. The link is rendered from
28+
the template rather than resolved as a reference, so nothing reported it, not
29+
even under `-W`. The footer now offers the link only for a page that has a
30+
twin -- covering an excluded page and a page whose source is absent, as well
31+
as a generated one. (#81)
32+
33+
#### `gp-sphinx`: Pages no longer request the `tabs.js` the build removes
34+
35+
`sphinx-inline-tabs` ships a `tabs.js` that conflicts with SPA navigation, and
36+
gp-sphinx deletes it after each HTML build. Nothing dropped the `<script>` tag
37+
the extension registers, so every page requested an asset that was not there.
38+
The tag is now dropped alongside the file. Tab switching is unaffected, being
39+
driven by CSS. (#81)
40+
2341
#### `sphinx-autodoc-fastmcp`: An axis cannot claim a component's id namespace
2442

2543
`fastmcp-tool-summary` anchors its sections on `fastmcp-<axis>-<term>`,

docs/configuration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ browser helpers, autodoc filter, and lexer aliases into the Sphinx app:
9292
| `app.add_js_file("js/spa-nav.js", loading_method="defer")` | Registers the bundled SPA navigation script from `sphinx-gp-theme` |
9393
| `app.connect("html-page-context", _inject_copybutton_bridge)` | Adds copybutton prompt settings to the page context so copied examples stay prompt-aware after SPA navigation |
9494
| `app.connect("html-page-context", _inject_fowt_prevention)` | Injects the early theme script that prevents a flash of the wrong theme before Furo initializes |
95+
| `app.connect("html-page-context", _drop_tabs_js_reference)` | Drops the `_static/tabs.js` `<script>` tag, so pages do not request the file `remove_tabs_js` deletes |
9596
| `app.connect("build-finished", remove_tabs_js)` | Removes `_static/tabs.js` after HTML builds as a `sphinx-inline-tabs` workaround |
9697
| `app.connect("autodoc-skip-member", skip_machinery_members, priority=900)` | Hides the {py:data}`gp_sphinx.config.MACHINERY_MEMBERS` names that {py:class}`abc.ABCMeta`, {py:class}`typing.Protocol`, and {py:class}`typing.NamedTuple` write into a class |
9798
| `app.add_lexer("myst", MystLexer)` | Registers the MyST lexer alias used by Markdown examples |

packages/gp-sphinx/src/gp_sphinx/config.py

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import logging
3333
import os.path
3434
import pathlib
35+
import posixpath
3536
import sys
3637
import typing as t
3738

@@ -628,6 +629,10 @@ def merge_sphinx_config(
628629
return conf
629630

630631

632+
#: Asset ``sphinx-inline-tabs`` ships that conflicts with SPA navigation.
633+
_TABS_JS_NAME = "tabs.js"
634+
635+
631636
def remove_tabs_js(app: Sphinx, exc: Exception | None) -> None:
632637
"""Remove ``tabs.js`` from ``_static`` after build.
633638
@@ -642,11 +647,76 @@ def remove_tabs_js(app: Sphinx, exc: Exception | None) -> None:
642647
Build exception, if any.
643648
"""
644649
if app.builder.format == "html" and not exc:
645-
tabs_js = pathlib.Path(app.builder.outdir) / "_static" / "tabs.js"
650+
tabs_js = pathlib.Path(app.builder.outdir) / "_static" / _TABS_JS_NAME
646651
with contextlib.suppress(FileNotFoundError):
647652
tabs_js.unlink()
648653

649654

655+
def _is_tabs_js(asset: object) -> bool:
656+
"""Return whether *asset* is the ``tabs.js`` :func:`remove_tabs_js` deletes.
657+
658+
Sphinx models a script as ``_JavaScript``, whose ``__str__`` renders the
659+
whole ``<script>`` tag rather than the path, so the filename must be read
660+
from the attribute. Compare the base name: ``design-tabs.js``, which
661+
``sphinx-design`` ships and which is copied normally, ends with the same
662+
text.
663+
664+
Parameters
665+
----------
666+
asset : object
667+
Entry from the page context's ``script_files``.
668+
669+
Returns
670+
-------
671+
bool
672+
``True`` when the entry refers to ``tabs.js``.
673+
674+
Examples
675+
--------
676+
>>> _is_tabs_js("_static/tabs.js")
677+
True
678+
>>> _is_tabs_js("_static/design-tabs.js")
679+
False
680+
"""
681+
filename = getattr(asset, "filename", asset)
682+
return posixpath.basename(str(filename)) == _TABS_JS_NAME
683+
684+
685+
def _drop_tabs_js_reference(
686+
app: Sphinx,
687+
pagename: str,
688+
templatename: str,
689+
context: dict[str, t.Any],
690+
doctree: object,
691+
) -> None:
692+
"""Stop pages referencing the ``tabs.js`` that :func:`remove_tabs_js` deletes.
693+
694+
``sphinx-inline-tabs`` registers ``tabs.js`` in its ``setup()``, so every
695+
page renders a ``<script>`` tag for it. Deleting the built file without
696+
dropping the tag leaves every page requesting an asset that is not there.
697+
Sphinx supports rewriting ``script_files`` from this event.
698+
699+
Parameters
700+
----------
701+
app : Sphinx
702+
The Sphinx application object.
703+
pagename : str
704+
Name of the page being rendered.
705+
templatename : str
706+
Template about to be rendered.
707+
context : dict
708+
Jinja2 context for the page.
709+
doctree : object
710+
Resolved doctree, or a falsy value for a generated page.
711+
"""
712+
del app, pagename, templatename, doctree
713+
714+
script_files = context.get("script_files")
715+
if not script_files:
716+
return
717+
context["script_files"] = [js for js in script_files if not _is_tabs_js(js)]
718+
719+
650720
def _inject_copybutton_bridge(
651721
app: Sphinx,
652722
pagename: str,
@@ -875,6 +945,7 @@ def setup(app: Sphinx) -> None:
875945
app.add_js_file("js/spa-nav.js", loading_method="defer")
876946
app.connect("html-page-context", _inject_copybutton_bridge)
877947
app.connect("html-page-context", _inject_fowt_prevention)
948+
app.connect("html-page-context", _drop_tabs_js_reference)
878949
app.connect("build-finished", remove_tabs_js)
879950
app.connect(
880951
"autodoc-skip-member",

packages/sphinx-gp-llms/src/sphinx_gp_llms/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,10 @@ def _inject_llms_context(
209209
return
210210

211211
if app.config.llms_generate_md_twins:
212-
context["llms_md_url"] = pagename + ".md"
212+
from sphinx_gp_llms._md_twins import has_md_twin
213+
214+
if has_md_twin(app, pagename):
215+
context["llms_md_url"] = pagename + ".md"
213216
if app.config.llms_generate_txt:
214217
context["llms_txt_url"] = app.config.llms_txt_filename
215218
if app.config.llms_generate_full:

packages/sphinx-gp-llms/src/sphinx_gp_llms/_md_twins.py

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,41 @@
2727
logger = getLogger(__name__)
2828

2929

30+
def has_md_twin(app: Sphinx, docname: str) -> bool:
31+
"""Return whether :func:`write_md_twins` writes a twin for *docname*.
32+
33+
The footer link and the twin writer must agree: a page linked to a
34+
``.md`` sibling that was never written is a 404 the build cannot see,
35+
because the link is rendered from the template rather than resolved as
36+
a reference.
37+
38+
Parameters
39+
----------
40+
app : Sphinx
41+
Sphinx application instance.
42+
docname : str
43+
Document name, as passed to ``html-page-context``.
44+
45+
Returns
46+
-------
47+
bool
48+
``True`` when a twin exists for *docname*.
49+
50+
Examples
51+
--------
52+
>>> from sphinx_gp_llms._md_twins import has_md_twin
53+
>>> callable(has_md_twin)
54+
True
55+
"""
56+
if docname not in app.env.found_docs:
57+
return False
58+
if _is_excluded(
59+
app.builder.get_target_uri(docname), list(app.config.llms_excludes)
60+
):
61+
return False
62+
return pathlib.Path(app.env.doc2path(docname)).exists()
63+
64+
3065
def write_md_twins(app: Sphinx) -> None:
3166
"""Copy source files as ``.md`` siblings in the build output directory.
3267
@@ -41,19 +76,14 @@ def write_md_twins(app: Sphinx) -> None:
4176
>>> callable(write_md_twins)
4277
True
4378
"""
44-
excludes: list[str] = list(app.config.llms_excludes)
4579
outdir = pathlib.Path(app.outdir)
4680
count = 0
4781

4882
for docname in sorted(app.env.found_docs):
49-
uri = app.builder.get_target_uri(docname)
50-
if _is_excluded(uri, excludes):
83+
if not has_md_twin(app, docname):
5184
continue
5285

5386
source_path = pathlib.Path(app.env.doc2path(docname))
54-
if not source_path.exists():
55-
continue
56-
5787
target = outdir / (docname + ".md")
5888
target.parent.mkdir(parents=True, exist_ok=True)
5989
shutil.copy2(source_path, target)

tests/ext/llms/test_md_twins.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,76 @@ def test_md_twin_content_matches_source(
5050
encoding="utf-8",
5151
)
5252
assert "Get started with the project quickly." in md_content
53+
54+
55+
_GENERATED_PAGES = ["genindex", "py-modindex", "search"]
56+
57+
58+
@pytest.mark.parametrize("pagename", _GENERATED_PAGES)
59+
def test_generated_pages_advertise_no_md_twin(
60+
pagename: str,
61+
llms_build: LlmsBuildResult,
62+
) -> None:
63+
"""A page Sphinx generates has no source, so it must not link a twin."""
64+
from sphinx_gp_llms._md_twins import has_md_twin
65+
66+
app = llms_build.result.app
67+
assert not has_md_twin(app, pagename)
68+
assert not (llms_build.result.outdir / f"{pagename}.md").exists()
69+
70+
71+
def test_the_twin_link_and_the_twin_writer_agree(
72+
llms_build: LlmsBuildResult,
73+
) -> None:
74+
"""Every page advertising a twin has one, and vice versa.
75+
76+
The footer link is rendered from the template rather than resolved as a
77+
reference, so Sphinx never reports a twin link that points at nothing --
78+
not even under ``-W``. This invariant is what catches it instead.
79+
"""
80+
from sphinx_gp_llms._md_twins import has_md_twin
81+
82+
app = llms_build.result.app
83+
outdir = llms_build.result.outdir
84+
candidates = sorted(app.env.found_docs) + _GENERATED_PAGES
85+
86+
advertised = {name for name in candidates if has_md_twin(app, name)}
87+
written = {name for name in candidates if (outdir / f"{name}.md").exists()}
88+
89+
assert advertised == written
90+
91+
92+
class TwinContextCase(t.NamedTuple):
93+
"""Whether a pagename should be offered a Markdown twin link."""
94+
95+
test_id: str
96+
pagename: str
97+
expects_link: bool
98+
99+
100+
_CONTEXT_CASES: list[TwinContextCase] = [
101+
TwinContextCase(test_id="real-page", pagename="index", expects_link=True),
102+
TwinContextCase(test_id="genindex", pagename="genindex", expects_link=False),
103+
TwinContextCase(test_id="modindex", pagename="py-modindex", expects_link=False),
104+
TwinContextCase(test_id="search", pagename="search", expects_link=False),
105+
]
106+
107+
108+
@pytest.mark.parametrize(
109+
list(TwinContextCase._fields),
110+
_CONTEXT_CASES,
111+
ids=[c.test_id for c in _CONTEXT_CASES],
112+
)
113+
def test_only_a_page_with_a_twin_is_offered_the_link(
114+
test_id: str,
115+
pagename: str,
116+
expects_link: bool,
117+
llms_build: LlmsBuildResult,
118+
) -> None:
119+
"""The footer variable is set only when the twin was written."""
120+
from sphinx_gp_llms import _inject_llms_context
121+
122+
context: dict[str, t.Any] = {}
123+
_inject_llms_context(llms_build.result.app, pagename, "page.html", context, None)
124+
125+
assert ("llms_md_url" in context) is expects_link

0 commit comments

Comments
 (0)