From 7232356a295eb7b439f39171f7b7ca2abf456b92 Mon Sep 17 00:00:00 2001 From: Tobias Kaestner Date: Tue, 13 Oct 2026 23:03:41 +0200 Subject: [PATCH 1/3] fix: docrefs: link the Doxygen documents of a PDF by the base URL In a PDF, the links of the needs to other documents use the base URL, but the Doxygen links did not: testmodule (Source, See also), symbol_needs (Declared in), testcoverage (the place of a body) and the tag-file routing got the relative HTML path in every build. A PDF has no sibling tree, so ../../dox-api/main_8c_source.html was dead in it. docrefs now gives these the URL by which this output links a peer (link_urls): the relative path in HTML, the base_url-rooted URL otherwise, as _peer_url already does for the needs. The extensions put the page's ../ prefix only in front of a relative URL (doxygen_parser.page_url). Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tobias Kaestner --- scripts/docrefs.py | 35 +++++++++++-------- sphinx/_extensions/_tests/test_adequacy.py | 9 +++++ sphinx/_extensions/symbol_needs.py | 4 +-- sphinx/_extensions/test_coverage.py | 4 +-- sphinx/_extensions/test_module.py | 12 +++++-- sphinx/_scripts/_tests/test_doxygen_parser.py | 13 +++++++ sphinx/_scripts/doxygen_parser.py | 9 +++++ 7 files changed, 65 insertions(+), 21 deletions(-) diff --git a/scripts/docrefs.py b/scripts/docrefs.py index dd72900..1045e42 100644 --- a/scripts/docrefs.py +++ b/scripts/docrefs.py @@ -862,6 +862,12 @@ def _peer_url(doc_id, meta): return rel_urls[doc_id] return base_url + meta.get("path", f"html/{doc_id}") + # The URL by which this document's output links into a locally built + # peer: ``rel_urls`` in HTML, the ``base_url``-rooted URL in a PDF, which + # has no sibling tree to be relative to. The extensions put the page's + # ``../`` prefix only in front of a relative one. + link_urls = {doc_id: _peer_url(doc_id, documents[doc_id]) for doc_id in rel_urls} + # Nav links, grouped by the registry's `groups:`. sphinx/doxygen -> the # peer's index page (relative ones are relative to this doc's html root; # layout.html prefixes them with the page's own content_root); external -> @@ -992,10 +998,10 @@ def _nav_href(doc_id, meta): testmodule = { "xml_dir": xml_dir, - "doxygen_url": rel_urls.get(doxygen_source, "") if doxygen_source else "", - "api_url": rel_urls.get(api_reference, "") if api_reference else "", + "doxygen_url": link_urls.get(doxygen_source, "") if doxygen_source else "", + "api_url": link_urls.get(api_reference, "") if api_reference else "", "needs_json": needs_json, - "tag_urls": tag_urls(documents, deploy, rel_urls), + "tag_urls": tag_urls(documents, deploy, link_urls), "coverage_source_docs": list(testmodule_block.get("coverage_source_docs") or []), } @@ -1005,13 +1011,13 @@ def _nav_href(doc_id, meta): source = symbol_block["doxygen_source"] symbol_needs = { "xml_dir": str(deploy / "xml" / source), - "doxygen_url": rel_urls.get(source, ""), + "doxygen_url": link_urls.get(source, ""), } doxygen_docs = { - doc_id: {"xml_dir": str(deploy / "xml" / doc_id), "html_url": rel_urls[doc_id]} + doc_id: {"xml_dir": str(deploy / "xml" / doc_id), "html_url": link_urls[doc_id]} for doc_id, meta in documents.items() - if meta.get("kind") == "doxygen" and doc_id in rel_urls + if meta.get("kind") == "doxygen" and doc_id in link_urls } return Refs( @@ -1029,22 +1035,23 @@ def _nav_href(doc_id, meta): ) -def tag_urls(documents, deploy, rel_urls): +def tag_urls(documents, deploy, link_urls): """``{tag file path: HTML directory URL}`` for every tag file `tagfiles` can name. Doxygen marks a reference it resolved through a tag file with ``external=""``, the path exactly as ``TAGFILES`` gave it. This map sends such a reference to the document whose tag file it is: - for a ``kind: doxygen`` peer its HTML directory relative to this - document's root (``rel_urls``), for a ``kind: doxygen-external`` peer its - absolute remote directory, and for a needs tag its Sphinx root. + for a ``kind: doxygen`` peer its HTML directory (``link_urls``: relative + to this document's root in HTML, absolute in a PDF), for a + ``kind: doxygen-external`` peer its absolute remote directory, and for a + needs tag its Sphinx root. """ urls = {} for doc_id, meta in documents.items(): kind = meta.get("kind") path = meta.get("path", f"html/{doc_id}") - if kind == "doxygen" and doc_id in rel_urls: - urls[(deploy / path / DOXYGEN_TAGFILE).as_posix()] = rel_urls[doc_id] + if kind == "doxygen" and doc_id in link_urls: + urls[(deploy / path / DOXYGEN_TAGFILE).as_posix()] = link_urls[doc_id] elif kind == "doxygen-external": # Without its trailing slash: the reader appends "/". base_dir_url = _intersphinx_target_dir(meta["remote-url"]).rstrip("/") @@ -1052,9 +1059,9 @@ def tag_urls(documents, deploy, rel_urls): elif ( kind in (None, "sphinx") and meta.get("doxygen_tag") is not None - and doc_id in rel_urls + and doc_id in link_urls ): - urls[(deploy / path / NEEDS_TAGFILE).as_posix()] = rel_urls[doc_id] + urls[(deploy / path / NEEDS_TAGFILE).as_posix()] = link_urls[doc_id] return urls diff --git a/sphinx/_extensions/_tests/test_adequacy.py b/sphinx/_extensions/_tests/test_adequacy.py index 334c735..151d6f4 100644 --- a/sphinx/_extensions/_tests/test_adequacy.py +++ b/sphinx/_extensions/_tests/test_adequacy.py @@ -736,6 +736,15 @@ def test_registry_gives_the_coverage_source_docs(tmp_path, monkeypatch): refs = docrefs.load("report", _registry(tmp_path)) assert refs.testmodule["coverage_source_docs"] == ["dox-dd", "dox-api"] assert set(refs.doxygen_docs) == {"dox-dd", "dox-api"} + assert refs.doxygen_docs["dox-dd"]["html_url"] == "../dox-dd" + + +def test_a_pdf_links_the_doxygen_documents_by_the_base_url(tmp_path, monkeypatch): + # A PDF has no sibling tree: a relative ../dox-dd would be dead in it. + monkeypatch.setenv("OUTPUT_DIR", str(tmp_path / "deploy" / "pdf" / "report")) + monkeypatch.setenv("ZDOCS_DOC_BASE_URL", "https://docs.example.org/v1") + refs = docrefs.load("report", _registry(tmp_path)) + assert refs.doxygen_docs["dox-dd"]["html_url"] == "https://docs.example.org/v1/html/dox-dd" def test_manifest_names_the_coverage_source_docs(tmp_path): diff --git a/sphinx/_extensions/symbol_needs.py b/sphinx/_extensions/symbol_needs.py index 14f6575..de83215 100644 --- a/sphinx/_extensions/symbol_needs.py +++ b/sphinx/_extensions/symbol_needs.py @@ -21,7 +21,7 @@ from docutils import nodes from docutils.parsers.rst import Directive from docutils.statemachine import ViewList -from doxygen_parser import SourcePages, load_group_index, parse_symbol +from doxygen_parser import SourcePages, load_group_index, page_url, parse_symbol from input_tracking import _note_input from needs_fields import depends_field from rst_builders import build_symbol_need_rst @@ -134,7 +134,7 @@ def run(self): page_prefix = "../" * (len(Path(env.docname).parts) - 1) doxygen_url = config.symbolneeds_doxygen_url - html_dir = page_prefix + doxygen_url if doxygen_url else "" + html_dir = page_url(page_prefix, doxygen_url) try: rst = symbol_needs_rst( xml_dir, html_dir, group, _need_names_from_config(config), diff --git a/sphinx/_extensions/test_coverage.py b/sphinx/_extensions/test_coverage.py index ccf1812..795c3c0 100644 --- a/sphinx/_extensions/test_coverage.py +++ b/sphinx/_extensions/test_coverage.py @@ -27,7 +27,7 @@ from docutils import nodes from docutils.parsers.rst import Directive, directives from docutils.statemachine import ViewList -from doxygen_parser import SourcePages +from doxygen_parser import SourcePages, page_url from input_tracking import _note_input from needs_fields import field_type from rst_builders import _need_name @@ -365,7 +365,7 @@ def _body_url(self, env): continue if doc_id not in cache: cache[doc_id] = SourcePages(doc["xml_dir"]) - listings.append((cache[doc_id], page_prefix + doc["html_url"])) + listings.append((cache[doc_id], page_url(page_prefix, doc["html_url"]))) def body_url(path, line): for pages, html_dir in listings: diff --git a/sphinx/_extensions/test_module.py b/sphinx/_extensions/test_module.py index 59f0414..c7ee0f2 100644 --- a/sphinx/_extensions/test_module.py +++ b/sphinx/_extensions/test_module.py @@ -10,7 +10,13 @@ from docutils import nodes from docutils.parsers.rst import Directive, directives from docutils.statemachine import ViewList -from doxygen_parser import SourcePages, detail_rst_lines, load_group_index, parse_memberdef +from doxygen_parser import ( + SourcePages, + detail_rst_lines, + load_group_index, + page_url, + parse_memberdef, +) from input_tracking import ( # noqa: F401 (the other hooks are re-exported for tests) _merge_inputs, _note_input, @@ -586,8 +592,8 @@ def run(self): page_depth = len(Path(env.docname).parts) - 1 page_prefix = "../" * page_depth - testspec_html_dir = page_prefix + app.config.testspec_doxygen_url - api_html_dir = page_prefix + app.config.api_doxygen_url + testspec_html_dir = page_url(page_prefix, app.config.testspec_doxygen_url) + api_html_dir = page_url(page_prefix, app.config.api_doxygen_url) tag_dirs = _tag_dirs(getattr(app.config, "testmodule_tag_urls", {}), page_prefix) # testmodule_root is supplied by the engine (zdocs_conf.py, defaulting # to ZDOCS_PROJECT_BASE) — no ZEPHYR_BASE fallback: that was a diff --git a/sphinx/_scripts/_tests/test_doxygen_parser.py b/sphinx/_scripts/_tests/test_doxygen_parser.py index d6110b9..f0e43c2 100644 --- a/sphinx/_scripts/_tests/test_doxygen_parser.py +++ b/sphinx/_scripts/_tests/test_doxygen_parser.py @@ -664,6 +664,19 @@ def test_source_pages_take_the_file_compound_of_the_path(tmp_path): ) +@pytest.mark.parametrize( + "url, seen", + [ + ("../dox-api", "../../../dox-api"), + ("https://docs.example.org/html/dox-api", "https://docs.example.org/html/dox-api"), + ("/html/dox-api", "/html/dox-api"), + ("", ""), + ], +) +def test_page_url_prefixes_only_a_relative_url(url, seen): + assert dp.page_url("../../", url) == seen + + def test_source_pages_report_the_files_they_read(tmp_path): xml = _run(tmp_path, files={"a_8c": "src/a.c", "b_8c": "src/b.c"}) pages = dp.SourcePages(xml) diff --git a/sphinx/_scripts/doxygen_parser.py b/sphinx/_scripts/doxygen_parser.py index 8175d5f..b63cd2a 100644 --- a/sphinx/_scripts/doxygen_parser.py +++ b/sphinx/_scripts/doxygen_parser.py @@ -692,6 +692,15 @@ def member_source(memberdef, html_dir, source_pages=None): return f"{Path(fpath).name} (line {line})", url +def page_url(page_prefix: str, url: str) -> str: + """``url`` as seen from a page ``page_prefix`` (``../`` per level) below + the document root: a relative URL gets the prefix, an absolute one (a + peer's site in a PDF build, ``https://...``) stays as it is.""" + if not url or "://" in url or url.startswith("/"): + return url + return page_prefix + url + + #: Doxygen writes no listing for a documentation file. _DOC_FILES = (".dox", ".md", ".markdown", ".txt", ".doc") #: The files that ``VERBATIM_HEADERS`` gives a listing. From 7d13ba25e07766f880d6f312c7cae96046f6021a Mon Sep 17 00:00:00 2001 From: Tobias Kaestner Date: Tue, 13 Oct 2026 23:03:41 +0200 Subject: [PATCH 2/3] fix: needs_config_state: read the imported needs again after a new base URL The pickled environment keeps each imported need with its URL. The digest of the imports covered the content of each needs.json, not the settings of the source. So after a new ZDOCS_DOC_BASE_URL, an incremental PDF build still linked the needs of other documents to the old base URL. A fresh doctree cache gave the right links. The digest now covers the settings of each source (base_url and the others) too. A change of them starts from a fresh environment, as a change of the content does. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tobias Kaestner --- .../_extensions/_tests/test_needs_config_state.py | 13 +++++++++++++ sphinx/_extensions/needs_config_state.py | 11 ++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/sphinx/_extensions/_tests/test_needs_config_state.py b/sphinx/_extensions/_tests/test_needs_config_state.py index c6a3ad0..2cce296 100644 --- a/sphinx/_extensions/_tests/test_needs_config_state.py +++ b/sphinx/_extensions/_tests/test_needs_config_state.py @@ -280,6 +280,19 @@ def test_imported_digest_follows_the_imported_needs(tmp_path): assert ncs.imported_needs_digest(tmp_path, sources) != first +def test_imported_digest_follows_the_base_url(tmp_path): + # The environment keeps each imported need with its URL: a PDF built + # after a new ZDOCS_DOC_BASE_URL linked the old one. + peer = tmp_path / "needs.json" + _needs_json(peer, {"SPEC_001": {"id": "SPEC_001"}}) + def digest(base_url): + return ncs.imported_needs_digest(tmp_path, [{"json_path": str(peer), "base_url": base_url}]) + + local = digest("http://localhost:8000/html/spec") + site = digest("https://docs.example.org/html/spec") + assert local != site + + def test_imported_digest_reads_the_configured_version(tmp_path): peer = tmp_path / "needs.json" data = { diff --git a/sphinx/_extensions/needs_config_state.py b/sphinx/_extensions/needs_config_state.py index 964ea79..9bd109e 100644 --- a/sphinx/_extensions/needs_config_state.py +++ b/sphinx/_extensions/needs_config_state.py @@ -104,6 +104,10 @@ def imported_needs_digest(confdir, external_needs): Only ``json_path`` sources count. A relative path is resolved against ``confdir``, as sphinx-needs resolves it. A ``json_url`` source is not read. + + The settings of each source (``base_url`` and the others) count too: the + pickled environment keeps each imported need with its URL. Without them, + a new ``ZDOCS_DOC_BASE_URL`` left the links of a PDF on the old one. """ sha = hashlib.sha256() count = 0 @@ -112,7 +116,12 @@ def imported_needs_digest(confdir, external_needs): if not json_path: continue path = Path(confdir, json_path) - sha.update(f"{path}\0{_imported_content(path, source.get('version'))}\0".encode()) + settings = json.dumps( + {k: v for k, v in source.items() if k != "json_path"}, sort_keys=True, default=str + ) + sha.update( + f"{path}\0{settings}\0{_imported_content(path, source.get('version'))}\0".encode() + ) count += 1 return sha.hexdigest() if count else "" From 7e7ad96051c39e1c60a2884ee5cdf1aa5eb4159a Mon Sep 17 00:00:00 2001 From: Tobias Kaestner Date: Tue, 13 Oct 2026 23:03:41 +0200 Subject: [PATCH 3/3] feat: sphinx: no dead external link in a PDF Two kinds of link from the HTML world are dead in a PDF: a link to a local server (a development base_url such as http://localhost:8000/) and a relative HTML path, which has no target next to the PDF. A PDF is mailed and filed, so such a link stays dead for good. The new pdf_links extension, loaded for every document, writes each of them as its text in a LaTeX build, and warns once per document with the count and the first URL. Set ZDOCS_DOC_BASE_URL to the published site to keep the links to other documents. Links inside the PDF and links to another host stay. It does not use the "internal" flag of a reference: sphinx-needs sets it on a needtable link to a need of another document too. Checked on the safety-toolbox PDFs: with the development base_url, 0 links to localhost and 0 relative links are left; with https://tiacsys.github.io/safety-toolbox/ all links to other documents and to Doxygen go to the site, with no warning. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Tobias Kaestner --- doc/api/python/extensions.rst | 9 ++ doc/manual/howto/publish-a-deploy-tree.rst | 17 ++++ .../_tests/roots/test-pdflinks/conf.py | 9 ++ .../_tests/roots/test-pdflinks/index.rst | 12 +++ sphinx/_extensions/_tests/test_pdf_links.py | 78 +++++++++++++++++ sphinx/_extensions/pdf_links.py | 84 +++++++++++++++++++ sphinx/zdocs_conf.py | 3 + 7 files changed, 212 insertions(+) create mode 100644 sphinx/_extensions/_tests/roots/test-pdflinks/conf.py create mode 100644 sphinx/_extensions/_tests/roots/test-pdflinks/index.rst create mode 100644 sphinx/_extensions/_tests/test_pdf_links.py create mode 100644 sphinx/_extensions/pdf_links.py diff --git a/doc/api/python/extensions.rst b/doc/api/python/extensions.rst index ee6bfb8..7ff23e8 100644 --- a/doc/api/python/extensions.rst +++ b/doc/api/python/extensions.rst @@ -91,6 +91,15 @@ need cards. .. automodule:: need_cards :members: +``pdf_links`` +--------------- + +Writes a link that is dead in a PDF (a local server, a relative HTML path) as +its text. + +.. automodule:: pdf_links + :members: + ``xref_builder`` ------------------ diff --git a/doc/manual/howto/publish-a-deploy-tree.rst b/doc/manual/howto/publish-a-deploy-tree.rst index a5487f6..bec33f1 100644 --- a/doc/manual/howto/publish-a-deploy-tree.rst +++ b/doc/manual/howto/publish-a-deploy-tree.rst @@ -41,3 +41,20 @@ file wherever it is filed or mailed, so a PDF built against a development ``base_url`` points at a host that may not exist — permanently, in a document that could already be signed off. Build (or rebuild) with the production ``base_url`` before generating a PDF meant for distribution or signature. + +The links of a PDF to other documents use the ``base_url``: the links of the +needs, and the links to Doxygen pages (Source, See also, Declared in). Two +kinds of link would be dead in a PDF: a link to a local server +(``localhost`` or a loopback address, such as a development ``base_url``) +and a relative HTML path, which has no target next to the PDF. The +``pdf_links`` extension writes each of them as its text, without the link, +and warns once per document:: + + WARNING: pdf_links: 72 link(s) written as text, without the link: they go + to a local server or to an HTML path next to the PDF (the first: + http://localhost/html/...). Set ZDOCS_DOC_BASE_URL to the published + site to keep the links to other documents. [zdocs.pdf_links] + +So a PDF of a development build has no dead links, only fewer links. A new +``ZDOCS_DOC_BASE_URL`` takes effect on the next incremental build: the +imported needs are read again with the new URL. diff --git a/sphinx/_extensions/_tests/roots/test-pdflinks/conf.py b/sphinx/_extensions/_tests/roots/test-pdflinks/conf.py new file mode 100644 index 0000000..f35e100 --- /dev/null +++ b/sphinx/_extensions/_tests/roots/test-pdflinks/conf.py @@ -0,0 +1,9 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) # _extensions/ + +extensions = ["pdf_links"] +master_doc = "index" +exclude_patterns = ["_build"] +latex_documents = [("index", "pdflinks.tex", "PDF links", "zdocs", "howto")] diff --git a/sphinx/_extensions/_tests/roots/test-pdflinks/index.rst b/sphinx/_extensions/_tests/roots/test-pdflinks/index.rst new file mode 100644 index 0000000..b7a1f78 --- /dev/null +++ b/sphinx/_extensions/_tests/roots/test-pdflinks/index.rst @@ -0,0 +1,12 @@ +PDF links +========= + +.. _target: + +A local server: `peer `_. + +A relative page: `listing <../../dox-api/main_8c_source.html#l00061>`_. + +A published site: `site `_. + +Inside the document: :ref:`the target `. diff --git a/sphinx/_extensions/_tests/test_pdf_links.py b/sphinx/_extensions/_tests/test_pdf_links.py new file mode 100644 index 0000000..4b32ea2 --- /dev/null +++ b/sphinx/_extensions/_tests/test_pdf_links.py @@ -0,0 +1,78 @@ +# Copyright (c) 2026 inovex GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +"""pdf_links: no link to a local server or a relative HTML path in a PDF.""" + +from pathlib import Path + +import pytest +from pdf_links import dead_in_pdf + +_ROOT = str(Path(__file__).parent / "roots" / "test-pdflinks") + + +@pytest.mark.parametrize( + "uri, dead", + [ + ("http://localhost:8000/html/peer/index.html#REQ-1", True), + ("http://127.0.0.1:8001/x.html", True), + ("http://127.0.0.2/x.html", True), + ("http://[::1]:8000/x.html", True), + ("http://0.0.0.0:8000/x.html", True), + ("https://localhost.example.org/x.html", False), + ("https://192.0.2.1/x.html", False), + ("file:///tmp/x.html", True), + ("../../dox-api/main_8c_source.html#l00061", True), + ("https://example.org/html/peer/index.html#REQ-1", False), + ("mailto:someone@example.org", False), + ("#REQ-1", False), + ("%index#target", False), + ("", False), + ], +) +def test_dead_in_pdf(uri, dead): + assert dead_in_pdf(uri) is dead + + +@pytest.mark.sphinx("latex", srcdir=_ROOT) +def test_a_pdf_has_no_dead_link(app): + app.build() + tex = (Path(app.outdir) / "pdflinks.tex").read_text() + assert "localhost" not in tex and "main\\_8c\\_source" not in tex + assert "peer" in tex and "listing" in tex # the text stays + assert "\\sphinxhref{https://example.org/html/peer/index.html\\#REQ-1}{site}" in tex + assert "\\hyperref[\\detokenize{index:target}]" in tex + warnings = app._warning.getvalue() + assert "pdf_links: 2 link(s) written as text" in warnings + assert "http://localhost:8000/html/peer/index.html#REQ-1" in warnings + + +def test_a_link_marked_internal_with_an_external_target_is_text_too(): + # sphinx-needs marks a needtable link "internal" by the row's need. + from types import SimpleNamespace + + from docutils import nodes + from docutils.utils import new_document + from pdf_links import _unlink + + doc = new_document("index") + para = nodes.paragraph() + para += nodes.reference( + "", "TC_1", internal=True, refuri="http://localhost:8000/html/spec/index.html#TC_1" + ) + para += nodes.reference("", "here", internal=True, refuri="%index#target") + doc += para + _unlink(SimpleNamespace(builder=SimpleNamespace(format="latex")), doc, "index") + refs = list(doc.findall(nodes.reference)) + assert [r["refuri"] for r in refs] == ["%index#target"] + assert para.astext() == "TC_1here" + + +@pytest.mark.sphinx("html", srcdir=_ROOT) +def test_html_keeps_every_link(app): + app.build() + html = (Path(app.outdir) / "index.html").read_text() + assert 'href="http://localhost:8000/html/peer/index.html#REQ-1"' in html + assert 'href="../../dox-api/main_8c_source.html#l00061"' in html + assert "pdf_links" not in app._warning.getvalue() diff --git a/sphinx/_extensions/pdf_links.py b/sphinx/_extensions/pdf_links.py new file mode 100644 index 0000000..097e26a --- /dev/null +++ b/sphinx/_extensions/pdf_links.py @@ -0,0 +1,84 @@ +# Copyright (c) 2026 inovex GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +"""Sphinx extension: no dead external link in a PDF. + +A PDF has no sibling tree of HTML pages. Two kinds of link from the HTML +world are dead in it: + +- a relative link, such as ``../../dox-api/main_8c_source.html``: it + resolves against the folder of the PDF, where no such page is; +- a link to a local server (``localhost`` or a loopback address): the + registry's ``base_url`` of a development build. + +In a LaTeX build, this extension writes such a link as its text, without the +link, and warns once per document. Set ``ZDOCS_DOC_BASE_URL`` to the +published site to keep the links to other documents. Links inside the PDF and +links to another host stay as they are. + +It runs on ``doctree-resolved``, after sphinx-needs has written the links of +the needs (doctree-resolved at the default priority 500). +""" + +import ipaddress +from urllib.parse import urlsplit + +from docutils import nodes + +from sphinx.util import logging + +logger = logging.getLogger(__name__) + + +def local_host(host): + """Whether ``host`` is this machine: ``localhost``, a loopback address or + the unspecified address.""" + if host == "localhost": + return True + try: + address = ipaddress.ip_address(host or "") + except ValueError: + return False + return address.is_loopback or address.is_unspecified + + +def dead_in_pdf(uri): + """Whether ``uri`` (a ``refuri``) has no target from a PDF.""" + if not uri or uri.startswith(("#", "%")): # inside the document + return False + parts = urlsplit(uri) + if parts.scheme in ("http", "https"): + return local_host(parts.hostname) + if parts.scheme == "file": + return True + return not parts.scheme # a relative path; mailto: and the like stay + + +def _unlink(app, doctree, docname): + if app.builder.format != "latex": + return + # Not by the "internal" flag: sphinx-needs sets it on a needtable link to + # a need of another document too. In LaTeX, a link inside the document + # starts with "%" or "#", which dead_in_pdf keeps. + dead = [ + ref for ref in doctree.findall(nodes.reference) + if not ref.get("refid") and dead_in_pdf(ref.get("refuri")) + ] + for ref in dead: + ref.replace_self(nodes.inline(ref.rawsource, "", *ref.children, classes=ref["classes"])) + if dead: + logger.warning( + f"pdf_links: {len(dead)} link(s) written as text, without the link: they " + f"go to a local server or to an HTML path next to the PDF (the first: " + f"{dead[0].get('refuri')}). Set ZDOCS_DOC_BASE_URL to the published site " + f"to keep the links to other documents.", + location=docname, + type="zdocs", + subtype="pdf_links", + ) + + +def setup(app): + app.connect("doctree-resolved", _unlink, priority=900) + return {"version": "1.0", "parallel_read_safe": True, "parallel_write_safe": True} diff --git a/sphinx/zdocs_conf.py b/sphinx/zdocs_conf.py index 13b8485..3f1a34f 100644 --- a/sphinx/zdocs_conf.py +++ b/sphinx/zdocs_conf.py @@ -139,6 +139,9 @@ def configure( # type, from the needs configuration (one small CSS file), and a class # for the status of each need. "need_cards", + # In a PDF, a link to a local server or a relative HTML path (which + # has no target next to the PDF) is written as text, with a warning. + "pdf_links", # The `doc_control` directive: the controlled-document header (owner, # classification, approval dates, version). Registers `signature_section` # and `releaselevel` as config values, so it must be loaded even by