From ee9c1eae32d720f24411e8a64618316cb52a289d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 21 Apr 2026 18:13:07 -0500 Subject: [PATCH 01/53] workspace(feat[packages]): scaffold gp-opengraph and gp-sitemap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: planning to drop the transitive sphinxext-opengraph (matplotlib bloat) and sphinx-sitemap (multiprocessing.Queue machinery, fragile monkey-patch) deps in favor of two in-workspace packages aligned with gp-sphinx's monorepo conventions. This scaffolding commit establishes the package skeletons so subsequent commits can port the parsers, extension hooks, and functional tests without touching workspace plumbing. what: - Add packages/gp-opengraph/ and packages/gp-sitemap/ with pyproject.toml, README.md, src//__init__.py, and py.typed markers. Each exposes a minimal setup(app) -> dict returning version plus parallel_read_safe=True and parallel_write_safe=True — importable but no event hooks connected yet. - Register both new packages in the root workspace: add them to [tool.uv.sources] (workspace pins), [dependency-groups] dev, [tool.ruff.lint.isort] known-first-party, and [tool.pytest.ini_options] testpaths. - Wire into CI registries: smoke_gp_opengraph + smoke_gp_sitemap in scripts/ci/package_tools.py, and add both to _PACKAGE_SMOKE_RUNNERS. - Add stub docs pages docs/packages/gp-opengraph.md and docs/packages/gp-sitemap.md so existing test_docs_package_pages_exist_for_every_workspace_package passes. - Add redirect entries for extensions/gp-opengraph and extensions/gp-sitemap in docs/redirects.txt. - Extend the expected-package assertion in tests/test_package_reference.py and the doctest set in docs/_ext/package_reference.py. - Add tests/ext/opengraph/test_importable.py and tests/ext/sitemap/test_importable.py — each asserts the module imports and setup() returns the expected metadata shape. CI gate (all green before commit): - uv run ruff check . --fix --show-fixes: clean - uv run ruff format .: no changes - uv run mypy: Success (165 source files) - uv run py.test --reruns 0: 1166 passed, 3 skipped - just build-docs: build succeeded --- docs/_ext/package_reference.py | 2 + docs/packages/sphinx-gp-opengraph.md | 26 +++++++++ docs/packages/sphinx-gp-sitemap.md | 26 +++++++++ docs/redirects.txt | 2 + packages/sphinx-gp-opengraph/README.md | 29 ++++++++++ packages/sphinx-gp-opengraph/pyproject.toml | 40 ++++++++++++++ .../src/sphinx_gp_opengraph/__init__.py | 53 ++++++++++++++++++ .../src/sphinx_gp_opengraph/py.typed | 0 packages/sphinx-gp-sitemap/README.md | 32 +++++++++++ packages/sphinx-gp-sitemap/pyproject.toml | 40 ++++++++++++++ .../src/sphinx_gp_sitemap/__init__.py | 54 +++++++++++++++++++ .../src/sphinx_gp_sitemap/py.typed | 0 pyproject.toml | 8 +++ scripts/ci/package_tools.py | 34 ++++++++++++ tests/ext/opengraph/__init__.py | 0 tests/ext/opengraph/test_importable.py | 16 ++++++ tests/ext/sitemap/__init__.py | 0 tests/ext/sitemap/test_importable.py | 16 ++++++ tests/test_package_reference.py | 2 + uv.lock | 30 +++++++++++ 20 files changed, 410 insertions(+) create mode 100644 docs/packages/sphinx-gp-opengraph.md create mode 100644 docs/packages/sphinx-gp-sitemap.md create mode 100644 packages/sphinx-gp-opengraph/README.md create mode 100644 packages/sphinx-gp-opengraph/pyproject.toml create mode 100644 packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py create mode 100644 packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/py.typed create mode 100644 packages/sphinx-gp-sitemap/README.md create mode 100644 packages/sphinx-gp-sitemap/pyproject.toml create mode 100644 packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py create mode 100644 packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/py.typed create mode 100644 tests/ext/opengraph/__init__.py create mode 100644 tests/ext/opengraph/test_importable.py create mode 100644 tests/ext/sitemap/__init__.py create mode 100644 tests/ext/sitemap/test_importable.py diff --git a/docs/_ext/package_reference.py b/docs/_ext/package_reference.py index 0943fa03..699a4765 100644 --- a/docs/_ext/package_reference.py +++ b/docs/_ext/package_reference.py @@ -36,6 +36,8 @@ -------- >>> package = workspace_packages()[0] >>> package["name"] in { +... "sphinx-gp-opengraph", +... "sphinx-gp-sitemap", ... "gp-sphinx", ... "sphinx-fonts", ... "sphinx-gp-theme", diff --git a/docs/packages/sphinx-gp-opengraph.md b/docs/packages/sphinx-gp-opengraph.md new file mode 100644 index 00000000..a20fc435 --- /dev/null +++ b/docs/packages/sphinx-gp-opengraph.md @@ -0,0 +1,26 @@ +(sphinx-gp-opengraph)= + +# sphinx-gp-opengraph + +```{gp-sphinx-package-meta} sphinx-gp-opengraph +``` + +:::{admonition} Alpha +:class: warning + +Pre-alpha scaffolding. Behavior lands in follow-up commits. +::: + +OpenGraph and Twitter meta-tag emission for Sphinx — drop-in replacement +for `sphinxext-opengraph`, matplotlib-free. + +```console +$ pip install sphinx-gp-opengraph +``` + +## Package reference + +```{package-reference} sphinx-gp-opengraph +``` + +[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-gp-opengraph) · [PyPI](https://pypi.org/project/sphinx-gp-opengraph/) diff --git a/docs/packages/sphinx-gp-sitemap.md b/docs/packages/sphinx-gp-sitemap.md new file mode 100644 index 00000000..1c57e4ee --- /dev/null +++ b/docs/packages/sphinx-gp-sitemap.md @@ -0,0 +1,26 @@ +(sphinx-gp-sitemap)= + +# sphinx-gp-sitemap + +```{gp-sphinx-package-meta} sphinx-gp-sitemap +``` + +:::{admonition} Alpha +:class: warning + +Pre-alpha scaffolding. Behavior lands in follow-up commits. +::: + +Sitemap generator for Sphinx — drop-in replacement for `sphinx-sitemap` +with Sphinx 8.1+ idioms and a parallel-build-safe implementation. + +```console +$ pip install sphinx-gp-sitemap +``` + +## Package reference + +```{package-reference} sphinx-gp-sitemap +``` + +[Source on GitHub](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-gp-sitemap) · [PyPI](https://pypi.org/project/sphinx-gp-sitemap/) diff --git a/docs/redirects.txt b/docs/redirects.txt index b982b86d..b80dd591 100644 --- a/docs/redirects.txt +++ b/docs/redirects.txt @@ -1,3 +1,5 @@ +extensions/sphinx-gp-opengraph packages/sphinx-gp-opengraph +extensions/sphinx-gp-sitemap packages/sphinx-gp-sitemap extensions/gp-sphinx packages/gp-sphinx extensions/index packages/index extensions/sphinx-autodoc-argparse packages/sphinx-autodoc-argparse diff --git a/packages/sphinx-gp-opengraph/README.md b/packages/sphinx-gp-opengraph/README.md new file mode 100644 index 00000000..67af0b81 --- /dev/null +++ b/packages/sphinx-gp-opengraph/README.md @@ -0,0 +1,29 @@ +# sphinx-gp-opengraph + +OpenGraph and Twitter meta-tag emission for Sphinx — drop-in replacement +for [`sphinxext-opengraph`](https://github.com/sphinx-doc/sphinxext-opengraph), +matplotlib-free. + +Part of the [gp-sphinx](https://github.com/git-pull/gp-sphinx) documentation +platform. + +## Install + +```console +$ pip install sphinx-gp-opengraph +``` + +## Usage + +Enable in your `docs/conf.py`: + +```python +extensions = [ + "sphinx_gp_opengraph", +] + +ogp_site_url = "https://example.com/" +ogp_image = "_static/og-default.png" # 1200×630 recommended +``` + +Scaffolding — full extension arrives in follow-up commits. diff --git a/packages/sphinx-gp-opengraph/pyproject.toml b/packages/sphinx-gp-opengraph/pyproject.toml new file mode 100644 index 00000000..dbb7de34 --- /dev/null +++ b/packages/sphinx-gp-opengraph/pyproject.toml @@ -0,0 +1,40 @@ +[project] +name = "sphinx-gp-opengraph" +version = "0.0.1a9" +description = "OpenGraph and Twitter meta-tag emission for Sphinx — matplotlib-free" +requires-python = ">=3.10,<4.0" +authors = [ + {name = "Tony Narlock", email = "tony@git-pull.com"} +] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 3 - Alpha", + "License :: OSI Approved :: MIT License", + "Framework :: Sphinx", + "Framework :: Sphinx :: Extension", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Documentation", + "Topic :: Documentation :: Sphinx", + "Typing :: Typed", +] +readme = "README.md" +keywords = ["sphinx", "opengraph", "meta", "social", "documentation"] +dependencies = [ + "sphinx>=8.1", +] + +[project.urls] +Repository = "https://github.com/git-pull/gp-sphinx" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/sphinx_gp_opengraph"] diff --git a/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py new file mode 100644 index 00000000..3b574ecd --- /dev/null +++ b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py @@ -0,0 +1,53 @@ +"""OpenGraph and Twitter meta-tag emission for Sphinx. + +Drop-in replacement for ``sphinxext-opengraph`` with the same ``ogp_*`` +configuration surface, minus the matplotlib-based social-card generator. + +The scaffolding commit registers a ``setup()`` that is importable and +loadable by Sphinx but does not yet connect any hooks. Subsequent commits +port the description / title / meta helpers and wire the +``html-page-context`` emitter. + +Examples +-------- +>>> from sphinx_gp_opengraph import setup +>>> callable(setup) +True +""" + +from __future__ import annotations + +import typing as t + +from sphinx.application import Sphinx + +_EXTENSION_VERSION = "0.0.1a9" + +__all__ = ["setup"] + + +def setup(app: Sphinx) -> dict[str, t.Any]: + """Register the extension; currently a no-op placeholder. + + Parameters + ---------- + app : Sphinx + Sphinx application instance (unused in the scaffold). + + Returns + ------- + dict[str, Any] + Extension metadata — version plus parallel-build flags. + + Examples + -------- + >>> from sphinx_gp_opengraph import setup + >>> callable(setup) + True + """ + del app # placeholder until hooks are connected + return { + "version": _EXTENSION_VERSION, + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/py.typed b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/packages/sphinx-gp-sitemap/README.md b/packages/sphinx-gp-sitemap/README.md new file mode 100644 index 00000000..2827fca2 --- /dev/null +++ b/packages/sphinx-gp-sitemap/README.md @@ -0,0 +1,32 @@ +# sphinx-gp-sitemap + +Sitemap generator for Sphinx — drop-in replacement for +[`sphinx-sitemap`](https://github.com/jdillard/sphinx-sitemap) with +Sphinx 8.1+ idioms and a parallel-build-safe implementation (no +`multiprocessing.Queue`). + +Part of the [gp-sphinx](https://github.com/git-pull/gp-sphinx) documentation +platform. + +## Install + +```console +$ pip install sphinx-gp-sitemap +``` + +## Usage + +Enable in your `docs/conf.py`: + +```python +extensions = [ + "sphinx_gp_sitemap", +] + +site_url = "https://example.com/" +``` + +A `sitemap.xml` is written to your HTML output directory after each +build. + +Scaffolding — full extension arrives in follow-up commits. diff --git a/packages/sphinx-gp-sitemap/pyproject.toml b/packages/sphinx-gp-sitemap/pyproject.toml new file mode 100644 index 00000000..10ebdc0d --- /dev/null +++ b/packages/sphinx-gp-sitemap/pyproject.toml @@ -0,0 +1,40 @@ +[project] +name = "sphinx-gp-sitemap" +version = "0.0.1a9" +description = "Sitemap generator for Sphinx — Sphinx 8.1+ idioms, parallel-build safe" +requires-python = ">=3.10,<4.0" +authors = [ + {name = "Tony Narlock", email = "tony@git-pull.com"} +] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 3 - Alpha", + "License :: OSI Approved :: MIT License", + "Framework :: Sphinx", + "Framework :: Sphinx :: Extension", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Documentation", + "Topic :: Documentation :: Sphinx", + "Typing :: Typed", +] +readme = "README.md" +keywords = ["sphinx", "sitemap", "seo", "documentation"] +dependencies = [ + "sphinx>=8.1", +] + +[project.urls] +Repository = "https://github.com/git-pull/gp-sphinx" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/sphinx_gp_sitemap"] diff --git a/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py b/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py new file mode 100644 index 00000000..52704f33 --- /dev/null +++ b/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py @@ -0,0 +1,54 @@ +"""Sitemap generator for Sphinx. + +Drop-in replacement for ``sphinx-sitemap`` with Sphinx 8.1+ idioms, a +plain ``list`` in ``env.temp_data`` instead of ``multiprocessing.Queue``, +and ``app.builder.name`` based builder detection instead of a monkey +patch. + +The scaffolding commit registers a ``setup()`` that is importable and +loadable by Sphinx but does not yet connect any hooks. Subsequent commits +add the config values and XML emission chain. + +Examples +-------- +>>> from sphinx_gp_sitemap import setup +>>> callable(setup) +True +""" + +from __future__ import annotations + +import typing as t + +from sphinx.application import Sphinx + +_EXTENSION_VERSION = "0.0.1a9" + +__all__ = ["setup"] + + +def setup(app: Sphinx) -> dict[str, t.Any]: + """Register the extension; currently a no-op placeholder. + + Parameters + ---------- + app : Sphinx + Sphinx application instance (unused in the scaffold). + + Returns + ------- + dict[str, Any] + Extension metadata — version plus parallel-build flags. + + Examples + -------- + >>> from sphinx_gp_sitemap import setup + >>> callable(setup) + True + """ + del app # placeholder until hooks are connected + return { + "version": _EXTENSION_VERSION, + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/py.typed b/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/pyproject.toml b/pyproject.toml index c64c7f7f..b012fe5d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,8 @@ sphinx-autodoc-fastmcp = { workspace = true } sphinx-autodoc-typehints-gp = { workspace = true } sphinx-ux-badges = { workspace = true } sphinx-ux-autodoc-layout = { workspace = true } +sphinx-gp-opengraph = { workspace = true } +sphinx-gp-sitemap = { workspace = true } gp-sphinx = { workspace = true } [dependency-groups] @@ -40,6 +42,8 @@ dev = [ "sphinx-autodoc-fastmcp", "sphinx-ux-badges", "sphinx-ux-autodoc-layout", + "sphinx-gp-opengraph", + "sphinx-gp-sitemap", # Docs "sphinx-autobuild", # Testing @@ -150,6 +154,8 @@ known-first-party = [ "sphinx_autodoc_typehints_gp", "sphinx_ux_badges", "sphinx_ux_autodoc_layout", + "sphinx_gp_opengraph", + "sphinx_gp_sitemap", ] combine-as-imports = true required-imports = [ @@ -197,6 +203,8 @@ testpaths = [ "packages/sphinx-autodoc-typehints-gp/src", "packages/sphinx-ux-autodoc-layout/src", "packages/sphinx-ux-badges/src", + "packages/sphinx-gp-opengraph/src", + "packages/sphinx-gp-sitemap/src", ] filterwarnings = [ "ignore:distutils Version classes are deprecated. Use packaging.version instead.", diff --git a/scripts/ci/package_tools.py b/scripts/ci/package_tools.py index a794a7c5..a5f74efa 100644 --- a/scripts/ci/package_tools.py +++ b/scripts/ci/package_tools.py @@ -642,6 +642,38 @@ def smoke_sphinx_ux_autodoc_layout(dist_dir: pathlib.Path, version: str) -> None ) +def smoke_sphinx_gp_opengraph(dist_dir: pathlib.Path, version: str) -> None: + """Verify the sphinx-gp-opengraph extension installs and imports cleanly.""" + with tempfile.TemporaryDirectory() as tmp: + python_path = _create_venv(pathlib.Path(tmp)) + _install_into_venv( + python_path, + *_workspace_wheel_requirements(dist_dir), + ) + _run_python( + python_path, + ( + "import sphinx_gp_opengraph; " + "from sphinx_gp_opengraph import setup; " + "assert callable(setup)" + ), + ) + + +def smoke_sphinx_gp_sitemap(dist_dir: pathlib.Path, version: str) -> None: + """Verify the sphinx-gp-sitemap extension installs and imports cleanly.""" + with tempfile.TemporaryDirectory() as tmp: + python_path = _create_venv(pathlib.Path(tmp)) + _install_into_venv( + python_path, + *_workspace_wheel_requirements(dist_dir), + ) + _run_python( + python_path, + ("import sphinx_gp_sitemap; from sphinx_gp_sitemap import setup; assert callable(setup)"), + ) + + def smoke_sphinx_autodoc_fastmcp(dist_dir: pathlib.Path, version: str) -> None: """Verify the autodoc-fastmcp extension installs and imports cleanly.""" with tempfile.TemporaryDirectory() as tmp: @@ -662,6 +694,8 @@ def smoke_sphinx_autodoc_fastmcp(dist_dir: pathlib.Path, version: str) -> None: _PACKAGE_SMOKE_RUNNERS: dict[str, t.Callable[[pathlib.Path, str], None]] = { + "sphinx-gp-opengraph": smoke_sphinx_gp_opengraph, + "sphinx-gp-sitemap": smoke_sphinx_gp_sitemap, "gp-sphinx": smoke_gp_sphinx, "sphinx-autodoc-argparse": smoke_sphinx_autodoc_argparse, "sphinx-autodoc-api-style": smoke_sphinx_autodoc_api_style, diff --git a/tests/ext/opengraph/__init__.py b/tests/ext/opengraph/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/ext/opengraph/test_importable.py b/tests/ext/opengraph/test_importable.py new file mode 100644 index 00000000..b7b369cd --- /dev/null +++ b/tests/ext/opengraph/test_importable.py @@ -0,0 +1,16 @@ +"""Scaffolding-level smoke tests for sphinx-gp-opengraph.""" + +from __future__ import annotations + +import sphinx_gp_opengraph + + +def test_import_exposes_setup() -> None: + assert callable(sphinx_gp_opengraph.setup) + + +def test_setup_returns_extension_metadata() -> None: + meta = sphinx_gp_opengraph.setup(app=None) # type: ignore[arg-type] + assert meta["version"] + assert meta["parallel_read_safe"] is True + assert meta["parallel_write_safe"] is True diff --git a/tests/ext/sitemap/__init__.py b/tests/ext/sitemap/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/ext/sitemap/test_importable.py b/tests/ext/sitemap/test_importable.py new file mode 100644 index 00000000..4ca31b94 --- /dev/null +++ b/tests/ext/sitemap/test_importable.py @@ -0,0 +1,16 @@ +"""Scaffolding-level smoke tests for sphinx-gp-sitemap.""" + +from __future__ import annotations + +import sphinx_gp_sitemap + + +def test_import_exposes_setup() -> None: + assert callable(sphinx_gp_sitemap.setup) + + +def test_setup_returns_extension_metadata() -> None: + meta = sphinx_gp_sitemap.setup(app=None) # type: ignore[arg-type] + assert meta["version"] + assert meta["parallel_read_safe"] is True + assert meta["parallel_write_safe"] is True diff --git a/tests/test_package_reference.py b/tests/test_package_reference.py index c223f433..4b221e15 100644 --- a/tests/test_package_reference.py +++ b/tests/test_package_reference.py @@ -19,6 +19,8 @@ def test_workspace_packages_lists_publishable_packages() -> None: """Workspace package discovery includes every published package.""" names = {package["name"] for package in package_reference.workspace_packages()} assert names == { + "sphinx-gp-opengraph", + "sphinx-gp-sitemap", "gp-sphinx", "sphinx-autodoc-argparse", "sphinx-autodoc-api-style", diff --git a/uv.lock b/uv.lock index 307a1406..2f62b9df 100644 --- a/uv.lock +++ b/uv.lock @@ -13,6 +13,8 @@ exclude-newer-span = "P3D" [manifest] members = [ + "sphinx-gp-opengraph", + "sphinx-gp-sitemap", "gp-sphinx", "gp-sphinx-workspace", "sphinx-autodoc-api-style", @@ -403,6 +405,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/f9/5d78d1dda9cb0f27d6f2305e95a58edbff935a62d53ec3227a3518cb4f72/gp_libs-0.0.17-py3-none-any.whl", hash = "sha256:7ce96d5e09980c0dc82062ab3e3b911600bd44da97a64fb78379f1af9a79d4d3", size = 16157, upload-time = "2025-12-07T22:44:48.036Z" }, ] +[[package]] +name = "sphinx-gp-opengraph" +version = "0.0.1a9" +source = { editable = "packages/sphinx-gp-opengraph" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] + +[package.metadata] +requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] + +[[package]] +name = "sphinx-gp-sitemap" +version = "0.0.1a9" +source = { editable = "packages/sphinx-gp-sitemap" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] + +[package.metadata] +requires-dist = [{ name = "sphinx", specifier = ">=8.1" }] + [[package]] name = "gp-sphinx" version = "0.0.1a9" @@ -462,6 +488,8 @@ dependencies = [ dev = [ { name = "codecov" }, { name = "coverage" }, + { name = "sphinx-gp-opengraph" }, + { name = "sphinx-gp-sitemap" }, { name = "gp-sphinx" }, { name = "mypy" }, { name = "pytest" }, @@ -494,6 +522,8 @@ requires-dist = [{ name = "gp-sphinx", editable = "packages/gp-sphinx" }] dev = [ { name = "codecov" }, { name = "coverage" }, + { name = "sphinx-gp-opengraph", editable = "packages/sphinx-gp-opengraph" }, + { name = "sphinx-gp-sitemap", editable = "packages/sphinx-gp-sitemap" }, { name = "gp-sphinx", editable = "packages/gp-sphinx" }, { name = "mypy" }, { name = "pytest" }, From c9899135f60f01011ddbada211c2ed10e5c1a655 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 21 Apr 2026 18:15:28 -0500 Subject: [PATCH 02/53] gp-opengraph(feat[parsers]): port description, title, meta parsers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: three small pure-function helpers from upstream sphinxext-opengraph are solid as-is — clean docutils NodeVisitor for prose extraction, stdlib HTMLParser for title-text stripping, stdlib HTMLParser for detecting existing . Porting them verbatim (with gp-sphinx NumPy docstring style and dispatch_*() signatures widened to nodes.Node for mypy strict) lands the non-controversial scaffolding before the hook-composition commit, keeping diffs reviewable. what: - packages/gp-opengraph/src/gp_opengraph/_description.py: port sphinxext/opengraph/_description_parser.py. Behavior identical; dispatch_visit and dispatch_departure widened from nodes.Element to nodes.Node (upstream typing violated Liskov under mypy strict; gp-sphinx is strict). Adds module docstring, NumPy-style docstrings on every public and protocol method. - packages/gp-opengraph/src/gp_opengraph/_title.py: port sphinxext/opengraph/_title_parser.py. Behavior identical; adds NumPy docstrings. - packages/gp-opengraph/src/gp_opengraph/_meta.py: port sphinxext/opengraph/_meta_parser.py. Behavior identical; return annotation widened from bool to str | bool | None (upstream's bool was wrong — the function returns the content string on hit). - tests/ext/opengraph/test_description.py: build doctrees via docutils RstParser; verify first-paragraph extraction, known-title skip, admonition skip, code-block skip, length truncation with ellipsis, sub-3 cap skips ellipsis. - tests/ext/opengraph/test_title.py: verify round-trip, tag stripping, nested tags, empty input. - tests/ext/opengraph/test_meta.py: verify content extraction, no-content fallback to True, absent description returns None, multi-tag picking. No Sphinx hook is connected yet — parsers are importable and unit-tested but unused. Commit 3 wires them into html-page-context. CI gate (all green before commit): - uv run ruff check . --fix --show-fixes: clean - uv run ruff format .: no changes - uv run mypy: Success (171 source files) - uv run py.test --reruns 0: 1183 passed, 3 skipped - just build-docs: build succeeded --- .../src/sphinx_gp_opengraph/_description.py | 169 ++++++++++++++++++ .../src/sphinx_gp_opengraph/_meta.py | 59 ++++++ .../src/sphinx_gp_opengraph/_title.py | 64 +++++++ tests/ext/opengraph/test_description.py | 85 +++++++++ tests/ext/opengraph/test_meta.py | 33 ++++ tests/ext/opengraph/test_title.py | 31 ++++ 6 files changed, 441 insertions(+) create mode 100644 packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/_description.py create mode 100644 packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/_meta.py create mode 100644 packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/_title.py create mode 100644 tests/ext/opengraph/test_description.py create mode 100644 tests/ext/opengraph/test_meta.py create mode 100644 tests/ext/opengraph/test_title.py diff --git a/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/_description.py b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/_description.py new file mode 100644 index 00000000..ff03f940 --- /dev/null +++ b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/_description.py @@ -0,0 +1,169 @@ +"""Extract a plain-text description from a Sphinx doctree. + +``get_description`` walks a resolved doctree and returns the first chunk of +prose that Sphinx would render as the page's visible body, suitable for +inclusion in an ``og:description`` meta tag. Admonitions, code blocks, and +invisible nodes are skipped; nested lists are flattened into comma-joined +text; the result is truncated to ``description_length`` characters with a +trailing ellipsis. + +Ported verbatim from ``sphinxext.opengraph._description_parser`` (v0.13.0). + +Examples +-------- +>>> from sphinx_gp_opengraph._description import get_description, DescriptionParser +>>> callable(get_description) +True +>>> issubclass(DescriptionParser, object) +True +""" + +from __future__ import annotations + +import html +import string +import typing as t + +from docutils import nodes + +if t.TYPE_CHECKING: + from collections.abc import Set + + +def get_description( + doctree: nodes.document, + description_length: int, + known_titles: Set[str] = frozenset(), +) -> str: + """Return a plain-text description extracted from ``doctree``. + + Parameters + ---------- + doctree : docutils.nodes.document + Resolved Sphinx doctree for one page. + description_length : int + Maximum number of characters to return. + known_titles : collections.abc.Set[str] + Titles to treat as the page title (skipped from the description). + + Returns + ------- + str + Flattened, HTML-escaped description, truncated to + ``description_length`` with a trailing ``...`` when truncated. + """ + mcv = DescriptionParser( + doctree, + desc_len=description_length, + known_titles=known_titles, + ) + doctree.walkabout(mcv) + return mcv.description + + +class DescriptionParser(nodes.NodeVisitor): + """Walk a doctree and accumulate a text description. + + Skips admonitions, invisible nodes, raw blocks, and literal blocks. + Titles are separated by colons; list elements by commas; sequential + lists by periods. + + Parameters + ---------- + document : docutils.nodes.document + The document being walked. + desc_len : int + Maximum character count for the resulting description. + known_titles : collections.abc.Set[str] + Titles treated as the page title; the first such title encountered + is skipped. + """ + + def __init__( + self, + document: nodes.document, + *, + desc_len: int, + known_titles: Set[str] = frozenset(), + ) -> None: + super().__init__(document) + self.description = "" + self.desc_len = desc_len + self.list_level = 0 + self.known_titles = known_titles + self.first_title_found = False + + # Exceptions can't be raised from dispatch_departure() + # This is used to loop the stop call back to the next dispatch_visit() + self.stop = False + + def dispatch_visit(self, node: nodes.Node) -> None: + """Accumulate text from ``node`` unless it should be skipped.""" + if self.stop: + raise nodes.StopTraversal + + # Skip comments & all admonitions + if isinstance(node, (nodes.Admonition, nodes.Invisible)): + raise nodes.SkipNode + + # Mark start of nested lists + if isinstance(node, nodes.Sequential): + self.list_level += 1 + if self.list_level > 1: + self.description += "-" + + # Skip the first title if it's the title of the page + if not self.first_title_found and isinstance(node, nodes.title): + self.first_title_found = True + if node.astext() in self.known_titles: + raise nodes.SkipNode + + if isinstance(node, nodes.raw) or isinstance(node.parent, nodes.literal_block): + raise nodes.SkipNode + + # Only include leaf nodes in the description + if len(node.children) == 0: + text = node.astext().replace("\r", "").replace("\n", " ").strip() + + # Ensure string contains HTML-safe characters + text = html.escape(text, quote=True) + + # Remove double spaces + while text.find(" ") != -1: + text = text.replace(" ", " ") + + # Put a space between elements if one does not already exist. + if ( + len(self.description) > 0 + and len(text) > 0 + and self.description[-1] not in string.whitespace + and text[0] not in string.whitespace + string.punctuation + ): + self.description += " " + + self.description += text + + def dispatch_departure(self, node: nodes.Node) -> None: + """Emit separators and enforce the length cap when leaving nodes.""" + # Separate title from text + if isinstance(node, nodes.title): + self.description += ":" + + # Separate list elements + if isinstance(node, nodes.Part): + self.description += "," + + # Separate end of list from text + if isinstance(node, nodes.Sequential): + if self.description and self.description[-1] == ",": + self.description = self.description[:-1] + self.description += "." + self.list_level -= 1 + + # Check for length + if len(self.description) > self.desc_len: + self.description = self.description[: self.desc_len] + if self.desc_len >= 3: + self.description = self.description[:-3] + "..." + + self.stop = True diff --git a/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/_meta.py b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/_meta.py new file mode 100644 index 00000000..7be14c56 --- /dev/null +++ b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/_meta.py @@ -0,0 +1,59 @@ +"""Detect a pre-existing ```` in collected meta tags. + +Ported verbatim from ``sphinxext.opengraph._meta_parser`` (v0.13.0), with +a narrowed return type annotation (upstream declared ``bool`` but actually +returns ``str | bool | None``). + +Examples +-------- +>>> from sphinx_gp_opengraph._meta import get_meta_description +>>> get_meta_description('') +'hello' +>>> get_meta_description('') is None +True +""" + +from __future__ import annotations + +from html.parser import HTMLParser + + +def get_meta_description(meta_tags: str) -> str | bool | None: + """Return the ``content`` of an existing description meta tag, if any. + + Parameters + ---------- + meta_tags : str + Concatenated ```` tags (as produced by Sphinx). + + Returns + ------- + str | bool | None + The content string when a matching meta tag carries a ``content`` + attribute; ``True`` when a description tag is present but has no + content attribute; ``None`` otherwise. + """ + htp = HTMLTextParser() + htp.feed(meta_tags) + htp.close() + + return htp.meta_description + + +class HTMLTextParser(HTMLParser): + """Flag the presence (and content) of a ````.""" + + def __init__(self) -> None: + super().__init__() + self.meta_description: str | bool | None = None + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + """Capture the description content when the matching meta opens.""" + # For example: + # attrs = [("content", "My manual description"), ("name", "description")] + if ("name", "description") in attrs: + self.meta_description = True + for name, value in attrs: + if name == "content": + self.meta_description = value + break diff --git a/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/_title.py b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/_title.py new file mode 100644 index 00000000..1b927c68 --- /dev/null +++ b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/_title.py @@ -0,0 +1,64 @@ +"""Extract plain text from an HTML-formatted Sphinx title. + +Ported verbatim from ``sphinxext.opengraph._title_parser`` (v0.13.0). + +Examples +-------- +>>> from sphinx_gp_opengraph._title import get_title +>>> get_title("libtmux-mcp") +('libtmux-mcp', '-mcp') +""" + +from __future__ import annotations + +from html.parser import HTMLParser + + +def get_title(title: str) -> tuple[str, str]: + """Return ``(all_text, text_outside_tags)`` parsed from a title string. + + Parameters + ---------- + title : str + Title text that may contain HTML markup (e.g. an ```` span + added by a Sphinx transform). + + Returns + ------- + tuple[str, str] + Full text (tags stripped) and the subset that appeared outside + any HTML tag. The second element is used when a title has been + decorated with visual affordances (icons, wrappers) that should + be stripped for search-engine metadata. + """ + htp = HTMLTextParser() + htp.feed(title) + htp.close() + + return htp.text, htp.text_outside_tags + + +class HTMLTextParser(HTMLParser): + """Track text-inside-tags vs text-outside-tags while parsing HTML.""" + + def __init__(self) -> None: + super().__init__() + # All text found + self.text = "" + # Only text outside of html tags + self.text_outside_tags = "" + self.level = 0 + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + """Increase the tag-nesting level.""" + self.level += 1 + + def handle_endtag(self, tag: str) -> None: + """Decrease the tag-nesting level.""" + self.level -= 1 + + def handle_data(self, data: str) -> None: + """Accumulate text, tracking whether it fell outside any tag.""" + self.text += data + if self.level == 0: + self.text_outside_tags += data diff --git a/tests/ext/opengraph/test_description.py b/tests/ext/opengraph/test_description.py new file mode 100644 index 00000000..dbaa794d --- /dev/null +++ b/tests/ext/opengraph/test_description.py @@ -0,0 +1,85 @@ +"""Tests for sphinx_gp_opengraph._description. + +Build small doctrees via docutils and feed them into ``DescriptionParser``, +asserting the visitor extracts the expected prose and honors length caps. +""" + +from __future__ import annotations + +from docutils.frontend import OptionParser +from docutils.parsers.rst import Parser as RstParser +from docutils.utils import new_document + +from sphinx_gp_opengraph._description import get_description + + +def _parse_rst(source: str): # type: ignore[no-untyped-def] + """Return a fully-parsed docutils document for ``source``.""" + parser = RstParser() + settings = OptionParser(components=(RstParser,)).get_default_values() + document = new_document("", settings) + parser.parse(source, document) + return document + + +def test_first_paragraph_wins() -> None: + """A plain paragraph is returned verbatim (below the length cap).""" + doctree = _parse_rst("Hello world, this is a description.\n") + result = get_description(doctree, description_length=200) + assert "Hello world" in result + assert "description" in result + + +def test_title_is_skipped_when_known() -> None: + """A title matching known_titles is excluded from the description.""" + src = "My Page Title\n=============\n\nBody paragraph with content.\n" + doctree = _parse_rst(src) + result = get_description(doctree, 200, known_titles={"My Page Title"}) + assert "Body paragraph" in result + assert "My Page Title" not in result + + +def test_admonition_is_skipped() -> None: + """Admonition bodies are excluded from the description.""" + src = ( + "Intro paragraph.\n\n" + ".. note::\n\n This note should not appear.\n\n" + "Outro paragraph.\n" + ) + doctree = _parse_rst(src) + result = get_description(doctree, 200) + assert "Intro paragraph" in result + assert "Outro paragraph" in result + assert "This note should not appear" not in result + + +def test_code_block_is_skipped() -> None: + """Literal-block text is not included.""" + src = ( + "Before code.\n\n" + ".. code-block:: python\n\n" + " import secret_value\n\n" + "After code.\n" + ) + doctree = _parse_rst(src) + result = get_description(doctree, 200) + assert "Before code" in result + assert "After code" in result + assert "secret_value" not in result + + +def test_truncation_adds_ellipsis() -> None: + """Descriptions longer than desc_len are cut and gain '...' trailer.""" + long_text = "word " * 100 + doctree = _parse_rst(long_text + "\n") + result = get_description(doctree, description_length=30) + assert len(result) <= 30 + assert result.endswith("...") + + +def test_short_cap_below_three_has_no_ellipsis() -> None: + """A description_length below 3 truncates without adding '...'.""" + doctree = _parse_rst("abcdefghij\n") + result = get_description(doctree, description_length=2) + assert len(result) <= 2 + assert "..." not in result diff --git a/tests/ext/opengraph/test_meta.py b/tests/ext/opengraph/test_meta.py new file mode 100644 index 00000000..7792e901 --- /dev/null +++ b/tests/ext/opengraph/test_meta.py @@ -0,0 +1,33 @@ +"""Tests for sphinx_gp_opengraph._meta.get_meta_description.""" + +from __future__ import annotations + +from sphinx_gp_opengraph._meta import get_meta_description + + +def test_detects_description_with_content() -> None: + """Returns the content attribute value when present.""" + tags = '' + assert get_meta_description(tags) == "hello world" + + +def test_description_without_content_returns_true() -> None: + """A description meta tag without content still signals presence.""" + tags = '' + assert get_meta_description(tags) is True + + +def test_no_description_returns_none() -> None: + """Tags that lack a description meta return None.""" + tags = '' + assert get_meta_description(tags) is None + + +def test_handles_multiple_tags_and_picks_description() -> None: + """Other meta tags are ignored; the description content is returned.""" + tags = ( + '' + '' + '' + ) + assert get_meta_description(tags) == "real content" diff --git a/tests/ext/opengraph/test_title.py b/tests/ext/opengraph/test_title.py new file mode 100644 index 00000000..d1505c73 --- /dev/null +++ b/tests/ext/opengraph/test_title.py @@ -0,0 +1,31 @@ +"""Tests for sphinx_gp_opengraph._title.get_title.""" + +from __future__ import annotations + +from sphinx_gp_opengraph._title import get_title + + +def test_plain_text_title_round_trips() -> None: + """A title with no HTML returns identical text and outside-text.""" + all_text, outside = get_title("libtmux-mcp") + assert all_text == "libtmux-mcp" + assert outside == "libtmux-mcp" + + +def test_tags_are_stripped_from_all_text() -> None: + """all_text flattens the full title; outside drops tagged spans.""" + all_text, outside = get_title("libtmux-mcp") + assert all_text == "libtmux-mcp" + assert outside == "-mcp" + + +def test_nested_tags_nest_the_level_counter() -> None: + """Nested tags keep outside-text empty until every tag closes.""" + all_text, outside = get_title("inner") + assert all_text == "inner" + assert outside == "" + + +def test_empty_title() -> None: + """Empty input returns empty strings.""" + assert get_title("") == ("", "") From d5feb2edbefa42b3d79a276364833384fb9943c2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 21 Apr 2026 18:19:15 -0500 Subject: [PATCH 03/53] gp-opengraph(feat[extension]): connect html-page-context hook + meta emission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: complete the OpenGraph port. Scaffolding (commit 1) established the package; parsers (commit 2) landed the doctree-walking helpers. This commit wires everything together: setup() registers every ogp_* config value the upstream sphinxext-opengraph exposed (minus the unused ogp_social_cards key's behavior) and connects the html-page-context handler that emits the tag block. The drop is drop-in compatible with upstream conf.py files. Social cards: ogp_social_cards is still accepted as a config value (so existing conf.py files do not error) but is ignored — gp-opengraph does not bundle a card generator. A one-line logger.warning fires when a truthy ogp_social_cards dict is configured, pointing users at the static-image workflow. what: - packages/gp-opengraph/src/gp_opengraph/__init__.py rewrite: * html_page_context hook that skips epub builds and pages without doctrees; otherwise appends the computed tag block to context['metatags']. * get_tags() composes og:title, og:type, og:url, og:site_name, og:description (+ when not already present and ogp_enable_meta_description is True), og:image, og:image:alt, arbitrary og:* overrides from per-page field lists, and any ogp_custom_meta_tags verbatim. * Per-page frontmatter (Sphinx field-list / MyST front matter) honored: ogp_disable, ogp_description_length, og:image, og:image:alt, og:*. * READTHEDOCS_CANONICAL_URL fallback via _ambient_site_url() when ogp_site_url is unset and the RTD env var is present. * ogp_use_first_image=True scans the doctree for the first image with an IMAGE_MIME_TYPES-known suffix; relative URLs resolved against page_url (for first-image) or ogp_site_url (for static ogp_image). * _warn_if_social_cards_used logger hook connected via config-inited. * setup() registers all eleven ogp_* config values (types frozenset({...}) uniform), connects html-page-context + config-inited, returns version + parallel flags both True. - tests/ext/opengraph/conftest.py: build_og_site fixture backed by the shared-scenario cache; returns an OgBuildResult NamedTuple with the completed SharedSphinxResult and a parsed meta-tag dict. - tests/ext/opengraph/test_meta_emission.py: NamedTuple MetaCase + pytest.mark.parametrize with ids=test_id. Five cases cover bare-defaults, with-image-and-alt, custom-meta-tags-emit-verbatim, site-name-disabled, description-absent-is-not-an-error. Plus a standalone test that the extracted description contains body text but not the page title. - tests/ext/opengraph/test_deprecation_warning.py: NamedTuple WarnCase + parametrize. Four cases cover empty-dict, None, enable-True, populated-dict — warning fires iff the value is truthy. - tests/ext/opengraph/test_importable.py: scaffold smoke test upgraded to verify setup() registers every expected config value and connects both event hooks (uses a small _FakeApp recorder). CI gate (all green before commit): - uv run ruff check . --fix --show-fixes: clean - uv run ruff format .: 1 file reformatted (the rewrite) - uv run mypy: Success (174 source files) - uv run py.test --reruns 0: 1193 passed, 3 skipped - just build-docs: build succeeded --- .../src/sphinx_gp_opengraph/__init__.py | 313 +++++++++++++++++- tests/ext/opengraph/conftest.py | 102 ++++++ .../ext/opengraph/test_deprecation_warning.py | 75 +++++ tests/ext/opengraph/test_importable.py | 27 +- tests/ext/opengraph/test_meta_emission.py | 125 +++++++ 5 files changed, 632 insertions(+), 10 deletions(-) create mode 100644 tests/ext/opengraph/conftest.py create mode 100644 tests/ext/opengraph/test_deprecation_warning.py create mode 100644 tests/ext/opengraph/test_meta_emission.py diff --git a/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py index 3b574ecd..ae77d898 100644 --- a/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py +++ b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py @@ -2,11 +2,13 @@ Drop-in replacement for ``sphinxext-opengraph`` with the same ``ogp_*`` configuration surface, minus the matplotlib-based social-card generator. +The ``ogp_social_cards`` config value is still accepted (so existing +``conf.py`` files do not error), but setting it emits a one-line warning +directing users to the static-image alternative. -The scaffolding commit registers a ``setup()`` that is importable and -loadable by Sphinx but does not yet connect any hooks. Subsequent commits -port the description / title / meta helpers and wire the -``html-page-context`` emitter. +The ``setup()`` registers every ``ogp_*`` config value and connects the +``html-page-context`` hook that emits OpenGraph and Twitter ```` +tags alongside an optional ````. Examples -------- @@ -17,22 +19,260 @@ from __future__ import annotations +import logging +import os +import pathlib import typing as t +from types import NoneType +from urllib.parse import urljoin, urlparse, urlsplit, urlunsplit +from docutils import nodes from sphinx.application import Sphinx +from sphinx_gp_opengraph._description import get_description +from sphinx_gp_opengraph._meta import get_meta_description +from sphinx_gp_opengraph._title import get_title + +if t.TYPE_CHECKING: + from sphinx.builders import Builder + from sphinx.config import Config + +logger = logging.getLogger(__name__) + _EXTENSION_VERSION = "0.0.1a9" -__all__ = ["setup"] +DEFAULT_DESCRIPTION_LENGTH = 200 + +# A selection from +# https://www.iana.org/assignments/media-types/media-types.xhtml#image +IMAGE_MIME_TYPES: frozenset[str] = frozenset( + {"gif", "apng", "webp", "jpeg", "jpg", "png", "bmp", "heic", "heif", "tiff"}, +) + +__all__ = [ + "DEFAULT_DESCRIPTION_LENGTH", + "IMAGE_MIME_TYPES", + "setup", +] + + +def html_page_context( + app: Sphinx, + pagename: str, + templatename: str, + context: dict[str, t.Any], + doctree: nodes.document, +) -> None: + """Inject OpenGraph / Twitter meta tags into ``context['metatags']``. + + Skipped for the ``epub`` builder and for pages without a resolved + doctree (e.g. rendered search indexes). + """ + del pagename, templatename # sourced from ``context`` when needed + if app.builder.name == "epub": + return + if not doctree: + return + context["metatags"] += get_tags( + context, + doctree, + config=app.config, + builder=app.builder, + ) + + +def get_tags( + context: dict[str, t.Any], + doctree: nodes.document, + *, + config: Config, + builder: Builder, +) -> str: + """Compose the block of ```` tags for one page. + + Parameters + ---------- + context : dict[str, Any] + Sphinx HTML page context (provides ``title``, ``pagename``, + ``meta`` field-list, and existing ``metatags`` string). + doctree : docutils.nodes.document + Resolved doctree for the page, walked to extract the description. + config : sphinx.config.Config + Project configuration (sources all ``ogp_*`` values). + builder : sphinx.builders.Builder + Active HTML-family builder (used for per-page URL resolution). + + Returns + ------- + str + Newline-terminated block of ```` tags ready to append to + ``context['metatags']``. Empty when the page sets + ``ogp_disable`` in its field list. + """ + fields: dict[str, t.Any] = context.get("meta") or {} + if "ogp_disable" in fields: + return "" + + tags: dict[str, str] = {} + meta_tags: dict[str, str] = {} # Non-og tags + + try: + desc_len = int( + fields.get("ogp_description_length", config.ogp_description_length), + ) + except ValueError: + desc_len = DEFAULT_DESCRIPTION_LENGTH + + title, title_excluding_html = get_title(context["title"]) + description = get_description(doctree, desc_len, {title, title_excluding_html}) + + tags["og:title"] = title + tags["og:type"] = config.ogp_type + + if not config.ogp_site_url and os.getenv("READTHEDOCS"): + ogp_site_url = _ambient_site_url() + else: + ogp_site_url = config.ogp_site_url + + ogp_canonical_url = config.ogp_canonical_url or ogp_site_url + + page_url = urljoin( + ogp_canonical_url, + builder.get_target_uri(context["pagename"]), + ) + tags["og:url"] = page_url + + site_name = _resolve_site_name(config) + if site_name: + tags["og:site_name"] = site_name + + if description: + tags["og:description"] = description + if config.ogp_enable_meta_description and not get_meta_description( + context["metatags"], + ): + meta_tags["description"] = description + + image_url, ogp_image_alt, ogp_use_first_image = _resolve_image(fields, config) + + first_image = None + if ogp_use_first_image: + found = doctree.next_node(nodes.image) + if ( + found + and pathlib.Path(found.get("uri", "")).suffix[1:].lower() + in IMAGE_MIME_TYPES + ): + first_image = found + image_url = found["uri"] + ogp_image_alt = found.get("alt") + + if image_url: + if "og:image" not in fields: + image_url_parsed = urlparse(image_url) + if not image_url_parsed.scheme: + root = page_url if first_image else ogp_site_url + image_url = urljoin(root, image_url_parsed.path) + tags["og:image"] = image_url + + if isinstance(ogp_image_alt, str): + tags["og:image:alt"] = ogp_image_alt + elif ogp_image_alt is None and site_name: + tags["og:image:alt"] = site_name + elif ogp_image_alt is None and title: + tags["og:image:alt"] = title + + fields.pop("og:image:alt", None) + + # Arbitrary og:* overrides supplied through MyST / field-list frontmatter + tags.update({k: v for k, v in fields.items() if k.startswith("og:")}) + + return ( + "\n".join( + [_make_tag(p, c) for p, c in tags.items()] + + [_make_tag(p, c, "name") for p, c in meta_tags.items()] + + list(config.ogp_custom_meta_tags), + ) + + "\n" + ) + + +def _ambient_site_url() -> str: + """Derive a site URL from ReadTheDocs env when ``ogp_site_url`` is unset.""" + rtd_canonical_url = os.getenv("READTHEDOCS_CANONICAL_URL") + if not rtd_canonical_url: + msg = "ReadTheDocs did not provide a valid canonical URL" + raise RuntimeError(msg) + parsed = urlsplit(rtd_canonical_url) + return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", "")) + + +def _resolve_site_name(config: Config) -> str | None: + """Return the resolved site name or ``None`` when explicitly disabled.""" + if config.ogp_site_name is False: + return None + if config.ogp_site_name is None: + return t.cast("str", config.project) + return t.cast("str", config.ogp_site_name) + + +def _resolve_image( + fields: dict[str, t.Any], + config: Config, +) -> tuple[str | None, str | bool | None, bool]: + """Return (image_url, alt_text, use_first_image) for this page. + + Per-page field-list ``og:image`` wins; otherwise fall back to + ``config.ogp_image`` / ``config.ogp_use_first_image``. + """ + if "og:image" in fields: + image_url: str | None = fields["og:image"] + ogp_use_first_image = False + ogp_image_alt: str | bool | None = fields.get("og:image:alt") + fields.pop("og:image", None) + else: + image_url = config.ogp_image + ogp_use_first_image = bool(config.ogp_use_first_image) + ogp_image_alt = fields.get("og:image:alt", config.ogp_image_alt) + return image_url, ogp_image_alt, ogp_use_first_image + + +def _make_tag( + property_: str, + content: str, + attr: t.Literal["property", "name"] = "property", +) -> str: + """Render one ```` tag, HTML-escaping embedded quotes.""" + safe_content = content.replace('"', """) + return f'' + + +def _warn_if_social_cards_used(app: Sphinx, config: Config) -> None: + """Emit a one-line deprecation warning when ``ogp_social_cards`` is set. + + sphinx-gp-opengraph deliberately omits the matplotlib-based card generator + upstream ships. The ``ogp_social_cards`` config value remains + registered so existing ``conf.py`` files do not error — but its value + is ignored. Users who want per-page social preview images should + provide static PNGs and point ``ogp_image`` (plus per-page + ``og:image`` frontmatter) at them. + """ + del app # unused; required by Sphinx's config-inited signature + if config.ogp_social_cards: + logger.warning( + "sphinx-gp-opengraph: ogp_social_cards is ignored — sphinx-gp-opengraph does " + "not bundle a card generator. Use a static PNG via ogp_image " + "(site default) or per-page 'og:image' frontmatter.", + ) def setup(app: Sphinx) -> dict[str, t.Any]: - """Register the extension; currently a no-op placeholder. + """Register config values and connect the html-page-context hook. Parameters ---------- app : Sphinx - Sphinx application instance (unused in the scaffold). + Sphinx application instance. Returns ------- @@ -45,7 +285,64 @@ def setup(app: Sphinx) -> dict[str, t.Any]: >>> callable(setup) True """ - del app # placeholder until hooks are connected + # ogp_site_url="" allows relative URLs by default. Not officially + # supported by OGP but matches upstream sphinxext-opengraph. + app.add_config_value("ogp_site_url", "", "html", types=frozenset({str})) + app.add_config_value("ogp_canonical_url", "", "html", types=frozenset({str})) + app.add_config_value( + "ogp_description_length", + DEFAULT_DESCRIPTION_LENGTH, + "html", + types=frozenset({int}), + ) + app.add_config_value( + "ogp_image", + None, + "html", + types=frozenset({str, NoneType}), + ) + app.add_config_value( + "ogp_image_alt", + None, + "html", + types=frozenset({str, bool, NoneType}), + ) + app.add_config_value( + "ogp_use_first_image", + False, + "html", + types=frozenset({bool}), + ) + app.add_config_value("ogp_type", "website", "html", types=frozenset({str})) + app.add_config_value( + "ogp_site_name", + None, + "html", + types=frozenset({str, bool, NoneType}), + ) + # Accepted-but-ignored: warned about in _warn_if_social_cards_used. + app.add_config_value( + "ogp_social_cards", + None, + "html", + types=frozenset({dict, NoneType}), + ) + app.add_config_value( + "ogp_custom_meta_tags", + (), + "html", + types=frozenset({list, tuple}), + ) + app.add_config_value( + "ogp_enable_meta_description", + True, + "html", + types=frozenset({bool}), + ) + + app.connect("html-page-context", html_page_context) + app.connect("config-inited", _warn_if_social_cards_used) + return { "version": _EXTENSION_VERSION, "parallel_read_safe": True, diff --git a/tests/ext/opengraph/conftest.py b/tests/ext/opengraph/conftest.py new file mode 100644 index 00000000..73ae8dc6 --- /dev/null +++ b/tests/ext/opengraph/conftest.py @@ -0,0 +1,102 @@ +"""Fixtures for sphinx_gp_opengraph integration tests. + +Builds a tiny Sphinx project via the shared scenario cache (see +``tests/_sphinx_scenarios.py``), overriding ``ogp_*`` config values per +case. Returns a helper that reads ``index.html`` and parses out its +```` tags into a flat dict keyed by ``og:*`` / ``twitter:*`` / +``name="description"`` style keys. +""" + +from __future__ import annotations + +import pathlib +import re +import typing as t + +import pytest + +from tests._sphinx_scenarios import ( + ScenarioFile, + SharedSphinxResult, + SphinxScenario, + build_shared_sphinx_result, + derive_sphinx_scenario_cache_root, + read_output, +) + +_BASE_CONF = """\ +project = "sphinx-gp-opengraph-test" +extensions = ["myst_parser", "sphinx_gp_opengraph"] +master_doc = "index" +source_suffix = {".md": "markdown"} +exclude_patterns = [] +html_theme = "basic" +""" + +_BASE_INDEX = """\ +# Welcome to sphinx-gp-opengraph-test + +This is the body paragraph that should become the og:description. + +Another paragraph for good measure. +""" + +_META_RE = re.compile( + r'', + re.IGNORECASE, +) + + +class OgBuildResult(t.NamedTuple): + """Return value of the ``build_og_site`` helper fixture.""" + + result: SharedSphinxResult + meta: dict[str, str] + + +def _confoverrides_to_conf_py(overrides: dict[str, t.Any]) -> str: + """Serialize simple config overrides into Python assignments.""" + lines: list[str] = [] + for key, value in overrides.items(): + lines.append(f"{key} = {value!r}") + return "\n".join(lines) + "\n" + + +def _parse_meta(html: str) -> dict[str, str]: + out: dict[str, str] = {} + for _attr, key, value in _META_RE.findall(html): + out[key] = value + return out + + +@pytest.fixture +def build_og_site( + tmp_path: pathlib.Path, +) -> t.Callable[..., OgBuildResult]: + """Return a helper that builds a synthetic OG-enabled Sphinx site. + + The helper accepts optional ``conf_overrides`` (merged into the base + ``conf.py``) and ``index_markdown`` (replaces the default body). It + returns the completed build result plus a pre-parsed ``meta`` dict + keyed on the meta tag's ``property`` / ``name`` attribute. + """ + + def _build( + *, + conf_overrides: dict[str, t.Any] | None = None, + index_markdown: str | None = None, + ) -> OgBuildResult: + cache_root = derive_sphinx_scenario_cache_root(tmp_path) + conf = _BASE_CONF + _confoverrides_to_conf_py(conf_overrides or {}) + index = index_markdown if index_markdown is not None else _BASE_INDEX + scenario = SphinxScenario( + files=( + ScenarioFile("conf.py", conf), + ScenarioFile("index.md", index), + ), + ) + result = build_shared_sphinx_result(cache_root, scenario) + html = read_output(result, "index.html") + return OgBuildResult(result=result, meta=_parse_meta(html)) + + return _build diff --git a/tests/ext/opengraph/test_deprecation_warning.py b/tests/ext/opengraph/test_deprecation_warning.py new file mode 100644 index 00000000..11ab78ec --- /dev/null +++ b/tests/ext/opengraph/test_deprecation_warning.py @@ -0,0 +1,75 @@ +"""Warning-emission tests for sphinx_gp_opengraph. + +``ogp_social_cards`` is accepted-but-ignored in sphinx-gp-opengraph. Setting it +must emit a one-line warning pointing users at the static-image +workflow. +""" + +from __future__ import annotations + +import logging +import typing as t + +import pytest + +from sphinx_gp_opengraph import _warn_if_social_cards_used + + +class WarnCase(t.NamedTuple): + """One warning-emission case.""" + + test_id: str + ogp_social_cards: t.Any + should_warn: bool + + +CASES: tuple[WarnCase, ...] = ( + WarnCase( + test_id="empty-dict-triggers-no-warning", + ogp_social_cards={}, + should_warn=False, + ), + WarnCase( + test_id="none-triggers-no-warning", + ogp_social_cards=None, + should_warn=False, + ), + WarnCase( + test_id="enable-true-warns", + ogp_social_cards={"enable": True}, + should_warn=True, + ), + WarnCase( + test_id="populated-dict-warns", + ogp_social_cards={"image": "path/to/img.png", "description_length": 100}, + should_warn=True, + ), +) + + +@pytest.mark.parametrize("case", CASES, ids=[c.test_id for c in CASES]) +def test_deprecation_warning( + case: WarnCase, + caplog: pytest.LogCaptureFixture, +) -> None: + """The warning fires iff ogp_social_cards carries a non-empty value.""" + + class _Config: + ogp_social_cards = case.ogp_social_cards + + caplog.clear() + with caplog.at_level(logging.WARNING, logger="sphinx_gp_opengraph"): + _warn_if_social_cards_used( + app=None, # type: ignore[arg-type] + config=t.cast("t.Any", _Config()), + ) + warnings = [ + record.message for record in caplog.records if record.levelno >= logging.WARNING + ] + if case.should_warn: + assert warnings, f"{case.test_id}: expected a warning, got none" + assert any("ogp_social_cards" in m for m in warnings), ( + f"{case.test_id}: no 'ogp_social_cards' text in {warnings!r}" + ) + else: + assert not warnings, f"{case.test_id}: unexpected warning {warnings!r}" diff --git a/tests/ext/opengraph/test_importable.py b/tests/ext/opengraph/test_importable.py index b7b369cd..b72409f4 100644 --- a/tests/ext/opengraph/test_importable.py +++ b/tests/ext/opengraph/test_importable.py @@ -2,6 +2,9 @@ from __future__ import annotations +import types +import typing as t + import sphinx_gp_opengraph @@ -9,8 +12,28 @@ def test_import_exposes_setup() -> None: assert callable(sphinx_gp_opengraph.setup) -def test_setup_returns_extension_metadata() -> None: - meta = sphinx_gp_opengraph.setup(app=None) # type: ignore[arg-type] +def test_setup_registers_config_values_and_connects_hook() -> None: + """setup() registers every ogp_* config value and the html-page-context hook.""" + registered: list[str] = [] + connected: list[str] = [] + + class _FakeApp: + def add_config_value(self, name: str, *args: t.Any, **kwargs: t.Any) -> None: + registered.append(name) + + def connect(self, event: str, handler: t.Callable[..., t.Any]) -> None: + connected.append(event) + + meta = sphinx_gp_opengraph.setup(t.cast("t.Any", _FakeApp())) assert meta["version"] assert meta["parallel_read_safe"] is True assert meta["parallel_write_safe"] is True + + assert "ogp_site_url" in registered + assert "ogp_image" in registered + assert "ogp_custom_meta_tags" in registered + assert "html-page-context" in connected + assert "config-inited" in connected + + # Keeps downstream static-analyzers happy — types unused otherwise. + _ = types.ModuleType diff --git a/tests/ext/opengraph/test_meta_emission.py b/tests/ext/opengraph/test_meta_emission.py new file mode 100644 index 00000000..ad036da8 --- /dev/null +++ b/tests/ext/opengraph/test_meta_emission.py @@ -0,0 +1,125 @@ +"""Functional tests for sphinx_gp_opengraph's html-page-context meta emission. + +Each case builds a tiny real Sphinx site with the extension active and +asserts the emitted ```` tags under the expected keys. +""" + +from __future__ import annotations + +import typing as t + +import pytest + +if t.TYPE_CHECKING: + from tests.ext.opengraph.conftest import OgBuildResult + + +class MetaCase(t.NamedTuple): + """One emission test case.""" + + test_id: str + conf_overrides: dict[str, t.Any] + index_markdown: str | None # None keeps the default body + expected_present: dict[str, str] + expected_absent: tuple[str, ...] + + +_DEFAULT_INDEX = None # sentinel — use conftest default + + +CASES: tuple[MetaCase, ...] = ( + MetaCase( + test_id="bare-defaults-emits-type-and-title", + conf_overrides={"ogp_site_url": "https://example.org/"}, + index_markdown=_DEFAULT_INDEX, + expected_present={ + "og:type": "website", + "og:title": "Welcome to sphinx-gp-opengraph-test", + "og:site_name": "sphinx-gp-opengraph-test", + "og:url": "https://example.org/index.html", + }, + expected_absent=("og:image", "og:image:alt"), + ), + MetaCase( + test_id="with-image-and-alt", + conf_overrides={ + "ogp_site_url": "https://example.org/", + "ogp_image": "_static/og.png", + "ogp_image_alt": "hero banner", + }, + index_markdown=_DEFAULT_INDEX, + expected_present={ + "og:image": "https://example.org/_static/og.png", + "og:image:alt": "hero banner", + }, + expected_absent=(), + ), + MetaCase( + test_id="custom-meta-tags-emit-verbatim", + conf_overrides={ + "ogp_site_url": "https://example.org/", + "ogp_custom_meta_tags": ( + '', + ), + }, + index_markdown=_DEFAULT_INDEX, + expected_present={"twitter:card": "summary_large_image"}, + expected_absent=(), + ), + MetaCase( + test_id="site-name-disabled-emits-no-site-name", + conf_overrides={ + "ogp_site_url": "https://example.org/", + "ogp_site_name": False, + }, + index_markdown=_DEFAULT_INDEX, + expected_present={"og:type": "website"}, + expected_absent=("og:site_name",), + ), + MetaCase( + test_id="description-absent-is-not-an-error", + conf_overrides={ + "ogp_site_url": "https://example.org/", + "ogp_enable_meta_description": False, + }, + # Single heading only — no body text -> no description extracted. + index_markdown="# Heading only\n", + expected_present={"og:type": "website"}, + expected_absent=("og:description", "description"), + ), +) + + +@pytest.mark.parametrize("case", CASES, ids=[c.test_id for c in CASES]) +def test_meta_emission( + case: MetaCase, + build_og_site: t.Callable[..., OgBuildResult], +) -> None: + """Each case's expected tags appear and absent tags do not.""" + built = build_og_site( + conf_overrides=case.conf_overrides, + index_markdown=case.index_markdown, + ) + for key, want in case.expected_present.items(): + assert key in built.meta, f"{case.test_id}: missing {key!r} in {built.meta!r}" + assert built.meta[key] == want, ( + f"{case.test_id}: {key}={built.meta[key]!r} != {want!r}" + ) + for key in case.expected_absent: + assert key not in built.meta, ( + f"{case.test_id}: unexpected {key!r} in {built.meta!r}" + ) + + +def test_og_description_extracted_from_body( + build_og_site: t.Callable[..., OgBuildResult], +) -> None: + """Body paragraphs flow into og:description without the title leaking.""" + built = build_og_site( + conf_overrides={"ogp_site_url": "https://example.org/"}, + ) + assert "og:description" in built.meta + description = built.meta["og:description"] + # Title should be elided; body should be present. + assert "Welcome to sphinx-gp-opengraph-test" not in description + assert "body paragraph" in description From 0243f4312045bdf2b5da52945f2215eb38df8a43 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 21 Apr 2026 18:22:39 -0500 Subject: [PATCH 04/53] gp-sitemap(feat[extension]): sitemap.xml generator with Sphinx 8.1+ idioms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: replace the scaffold setup() placeholder with the full sitemap generator. Behavior matches sphinx-sitemap v2.9.0 — same config keys (site_url, sitemap_url_scheme, sitemap_locales, sitemap_filename, sitemap_excludes, sitemap_show_lastmod, sitemap_indent), same XML output shape ( with per-page , optional , and xhtml:link hreflang alternates). Three modernizations along the way: 1. env.temp_data["gp_sitemap_links"] is a plain list[tuple[str, str|None]] rather than a multiprocessing.Queue. Sphinx joins parallel workers before build-finished fires, so the Queue machinery was over- engineered for a synchronization problem that does not exist. 2. Builder-kind detection uses the public app.builder.name == "dirhtml" rather than monkey-patching env.is_directory_builder. 3. html_baseurl registration uses contextlib.suppress(ExtensionError) rather than a bare except BaseException. Also: - All add_config_value calls use types=frozenset({...}) uniform. - Removed upstream dead line that called site_url.rstrip("/") + "/" without assigning the result (quirk kept the value intact anyway, but drops dead code). - _write_sitemap returns early on build failure (checking exception parameter) rather than trying to write a partial sitemap. what: - packages/gp-sitemap/src/gp_sitemap/__init__.py rewrite: * setup() registers all seven sitemap_* config values plus an optional html_baseurl (suppressed if already registered by Sphinx core). Loads sphinx_last_updated_by_git when sitemap_show_lastmod is True, disabling the feature with a warning on failure. * _init_link_store (builder-inited) creates env.temp_data list. * _collect_page_link (html-page-context) collects (link, lastmod) per page, honoring the sitemap_excludes fnmatch list. Dirhtml collapses "index" -> "" and "foo/index" -> "foo/". * _write_sitemap (build-finished) composes the , iterating the list, formatting URLs via sitemap_url_scheme, and emitting xhtml:link rel=alternate per locale. * _resolve_locales honors explicit sitemap_locales (with [None] meaning primary language only); falls back to scanning locale_dirs sub-directories via pathlib. * _hreflang_formatter replaces "_" with "-" for hreflang compat. - tests/ext/sitemap/conftest.py: build_sitemap_site fixture backed by the shared-scenario cache; builds a 3-page project (index/about/draft) and returns a SitemapBuildResult NamedTuple with a parsed ElementTree when the sitemap was written. - tests/ext/sitemap/test_urlset.py: NamedTuple SitemapCase + parametrize with ids=test_id. Four cases cover html-suffixes (html builder), slash-suffixes (dirhtml builder — including the index-as-empty-segment and language-prefix behavior when Sphinx's default language="en" is active), sitemap_excludes drops draft pages, and sitemap_indent=2 pretty-prints without breaking loc values. Plus two standalone tests: root-urlset namespace check, and no-site_url emits a warning and skips the file. - tests/ext/sitemap/test_importable.py: upgraded smoke test to verify setup() registers each sitemap_* config value and connects all three event hooks via a _FakeApp recorder. CI gate (all green before commit): - uv run ruff check . --fix --show-fixes: clean - uv run ruff format .: no changes - uv run mypy: Success (176 source files) - uv run py.test --reruns 0: 1199 passed, 3 skipped - just build-docs: build succeeded --- .../src/sphinx_gp_sitemap/__init__.py | 291 +++++++++++++++++- tests/ext/sitemap/conftest.py | 101 ++++++ tests/ext/sitemap/test_importable.py | 32 +- tests/ext/sitemap/test_urlset.py | 115 +++++++ 4 files changed, 527 insertions(+), 12 deletions(-) create mode 100644 tests/ext/sitemap/conftest.py create mode 100644 tests/ext/sitemap/test_urlset.py diff --git a/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py b/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py index 52704f33..b1866fe8 100644 --- a/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py +++ b/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py @@ -1,13 +1,18 @@ """Sitemap generator for Sphinx. -Drop-in replacement for ``sphinx-sitemap`` with Sphinx 8.1+ idioms, a -plain ``list`` in ``env.temp_data`` instead of ``multiprocessing.Queue``, -and ``app.builder.name`` based builder detection instead of a monkey -patch. +Drop-in replacement for ``sphinx-sitemap`` with Sphinx 8.1+ idioms. +Behavior is identical to upstream ``sphinx_sitemap`` v2.9.0 with three +modernizations: -The scaffolding commit registers a ``setup()`` that is importable and -loadable by Sphinx but does not yet connect any hooks. Subsequent commits -add the config values and XML emission chain. +1. ``env.temp_data["sphinx_gp_sitemap_links"]`` is a plain ``list[tuple[...]]`` + rather than a ``multiprocessing.Queue``. Sphinx joins parallel + workers before ``build-finished`` fires, so the Queue machinery was + over-engineered. +2. Builder-kind detection uses the public ``app.builder.name == "dirhtml"`` + rather than monkey-patching ``env.is_directory_builder``. +3. The ``html_baseurl`` config value is only registered when not already + registered — via a small ``try/except sphinx.errors.ExtensionError`` + rather than a bare ``except BaseException``. Examples -------- @@ -18,22 +23,41 @@ from __future__ import annotations +import contextlib +import datetime as dt +import fnmatch +import pathlib import typing as t +from xml.etree import ElementTree from sphinx.application import Sphinx +from sphinx.errors import ExtensionError +from sphinx.util.logging import getLogger + +if t.TYPE_CHECKING: + from collections.abc import Iterable + + from docutils import nodes _EXTENSION_VERSION = "0.0.1a9" +_LINKS_KEY = "sphinx_gp_sitemap_links" +_SITEMAP_NS = "http://www.sitemaps.org/schemas/sitemap/0.9" +_XHTML_NS = "http://www.w3.org/1999/xhtml" + +logger = getLogger(__name__) + +SitemapLink = tuple[str, str | None] # (relative link, last_updated ISO8601 or None) __all__ = ["setup"] def setup(app: Sphinx) -> dict[str, t.Any]: - """Register the extension; currently a no-op placeholder. + """Register config values and connect sitemap-emission hooks. Parameters ---------- app : Sphinx - Sphinx application instance (unused in the scaffold). + Sphinx application instance. Returns ------- @@ -46,9 +70,256 @@ def setup(app: Sphinx) -> dict[str, t.Any]: >>> callable(setup) True """ - del app # placeholder until hooks are connected + app.add_config_value( + "site_url", + default=None, + rebuild="", + types=frozenset({str, type(None)}), + ) + app.add_config_value( + "sitemap_url_scheme", + default="{lang}{version}{link}", + rebuild="", + types=frozenset({str}), + ) + app.add_config_value( + "sitemap_locales", + default=[], + rebuild="", + types=frozenset({list, type(None)}), + ) + app.add_config_value( + "sitemap_filename", + default="sitemap.xml", + rebuild="", + types=frozenset({str}), + ) + app.add_config_value( + "sitemap_excludes", + default=[], + rebuild="", + types=frozenset({list}), + ) + app.add_config_value( + "sitemap_show_lastmod", + default=False, + rebuild="", + types=frozenset({bool}), + ) + app.add_config_value( + "sitemap_indent", + default=0, + rebuild="", + types=frozenset({int}), + ) + # html_baseurl is usually registered by Sphinx core already — suppress the + # duplicate-registration error without losing the legitimate single- + # registration path. + with contextlib.suppress(ExtensionError): + app.add_config_value( + "html_baseurl", + default=None, + rebuild="", + types=frozenset({str, type(None)}), + ) + + if app.config.sitemap_show_lastmod: + try: + app.setup_extension("sphinx_last_updated_by_git") + except ExtensionError as exc: + logger.warning( + "%s", + exc, + type="sitemap", + subtype="configuration", + ) + app.config.sitemap_show_lastmod = False + + app.connect("builder-inited", _init_link_store) + app.connect("html-page-context", _collect_page_link) + app.connect("build-finished", _write_sitemap) + return { "version": _EXTENSION_VERSION, "parallel_read_safe": True, "parallel_write_safe": True, } + + +def _init_link_store(app: Sphinx) -> None: + """Initialize the shared ``env.temp_data`` list on each build start.""" + app.env.temp_data[_LINKS_KEY] = [] + + +def _collect_page_link( + app: Sphinx, + pagename: str, + templatename: str, + context: dict[str, t.Any], + doctree: nodes.document | None, +) -> None: + """Append one ``(relative_link, last_updated)`` entry per built page. + + Called once per page during HTML emission via ``html-page-context``. + """ + del templatename, context, doctree # unused + config = app.builder.config + file_suffix = config.html_file_suffix or ".html" + + last_updated: str | None = None + if config.sitemap_show_lastmod: + git_last_updated = getattr(app.env, "git_last_updated", None) or {} + entry = git_last_updated.get(pagename) + if entry: + timestamp, _show_sourcelink = entry + if timestamp: + last_updated = dt.datetime.fromtimestamp( + int(timestamp), dt.timezone.utc + ).strftime("%Y-%m-%dT%H:%M:%SZ") + + if app.builder.name == "dirhtml": + if pagename == "index": + sitemap_link = "" + elif pagename.endswith("/index"): + sitemap_link = pagename[: -len("/index")] + "/" + else: + sitemap_link = pagename + "/" + else: + sitemap_link = pagename + file_suffix + + if _is_excluded(sitemap_link, list(config.sitemap_excludes)): + return + + links = t.cast("list[SitemapLink]", app.env.temp_data.setdefault(_LINKS_KEY, [])) + links.append((sitemap_link, last_updated)) + + +def _write_sitemap(app: Sphinx, exception: BaseException | None) -> None: + """Serialize the collected links to ``/``. + + Parameters + ---------- + app : Sphinx + Sphinx application instance. + exception : BaseException | None + Exception raised during build, if any. The sitemap is still + written on clean builds; on failures it's suppressed. + """ + if exception is not None: + return + + site_url = app.builder.config.site_url or app.builder.config.html_baseurl + if not site_url: + logger.warning( + "sphinx-gp-sitemap: site_url (or html_baseurl) is required in conf.py. " + "Sitemap not built.", + type="sitemap", + subtype="configuration", + ) + return + + links = t.cast( + "list[SitemapLink]", + app.env.temp_data.get(_LINKS_KEY, []), + ) + if not links: + logger.info( + "sphinx-gp-sitemap: no pages collected for %s", + app.config.sitemap_filename, + type="sitemap", + subtype="information", + ) + return + + ElementTree.register_namespace("xhtml", _XHTML_NS) + root = ElementTree.Element("urlset", xmlns=_SITEMAP_NS) + + locales = _resolve_locales(app) + scheme = app.config.sitemap_url_scheme + language = app.builder.config.language + lang_segment = f"{language}/" if language else "" + version_segment = ( + f"{app.builder.config.version}/" if app.builder.config.version else "" + ) + + for sitemap_link, last_updated in links: + url_el = ElementTree.SubElement(root, "url") + ElementTree.SubElement(url_el, "loc").text = site_url + scheme.format( + lang=lang_segment, + version=version_segment, + link=sitemap_link, + ) + if last_updated: + ElementTree.SubElement(url_el, "lastmod").text = last_updated + for locale in locales: + locale_segment = f"{locale}/" + ElementTree.SubElement( + url_el, + f"{{{_XHTML_NS}}}link", + rel="alternate", + hreflang=_hreflang_formatter(locale), + href=site_url + + scheme.format( + lang=locale_segment, + version=version_segment, + link=sitemap_link, + ), + ) + + sitemap_path = pathlib.Path(app.outdir) / app.config.sitemap_filename + if isinstance(app.config.sitemap_indent, int) and app.config.sitemap_indent > 0: + ElementTree.indent(root, space=" " * app.config.sitemap_indent) + ElementTree.ElementTree(root).write( + sitemap_path, + xml_declaration=True, + encoding="utf-8", + method="xml", + ) + + logger.info( + "sphinx-gp-sitemap: %s generated for URL %s at %s", + app.config.sitemap_filename, + site_url, + sitemap_path, + type="sitemap", + subtype="information", + ) + + +def _is_excluded(sitemap_link: str, patterns: Iterable[str]) -> bool: + """Return True when ``sitemap_link`` matches any fnmatch pattern.""" + return any(fnmatch.fnmatch(sitemap_link, pattern) for pattern in patterns) + + +def _hreflang_formatter(lang: str) -> str: + """Format a locale code into an ``hreflang``-compatible string. + + References + ---------- + - https://en.wikipedia.org/wiki/Hreflang#Common_Mistakes + - https://github.com/readthedocs/readthedocs.org/pull/5638 + """ + return lang.replace("_", "-") if "_" in lang else lang + + +def _resolve_locales(app: Sphinx) -> list[str]: + """Return the list of locale codes to emit as hreflang alternates. + + If ``sitemap_locales`` is explicitly set (and not ``[None]``), its + values win. Otherwise, auto-detect by listing sub-directories of + each ``locale_dirs`` entry. + """ + configured: list[str] | None = app.builder.config.sitemap_locales + if configured: + if configured == [None]: + return [] + return list(configured) + + locales: list[str] = [] + confdir = pathlib.Path(app.confdir) + for locale_dir_setting in app.builder.config.locale_dirs: + locale_dir = confdir / locale_dir_setting + if not locale_dir.is_dir(): + continue + locales.extend(entry.name for entry in locale_dir.iterdir() if entry.is_dir()) + return locales diff --git a/tests/ext/sitemap/conftest.py b/tests/ext/sitemap/conftest.py new file mode 100644 index 00000000..7caeab6d --- /dev/null +++ b/tests/ext/sitemap/conftest.py @@ -0,0 +1,101 @@ +"""Fixtures for sphinx_gp_sitemap integration tests. + +Builds a tiny Sphinx project via the shared scenario cache, overriding +``sitemap_*`` config values per case, and reads the emitted +``sitemap.xml`` for assertions. +""" + +from __future__ import annotations + +import pathlib +import typing as t +from xml.etree import ElementTree + +import pytest + +from tests._sphinx_scenarios import ( + ScenarioFile, + SharedSphinxResult, + SphinxScenario, + build_shared_sphinx_result, + derive_sphinx_scenario_cache_root, +) + +_BASE_CONF = """\ +project = "sphinx-gp-sitemap-test" +extensions = ["myst_parser", "sphinx_gp_sitemap"] +master_doc = "index" +source_suffix = {".md": "markdown"} +exclude_patterns = [] +html_theme = "basic" +""" + +_INDEX = "# Index page\n\nBody content.\n" +_ABOUT = "# About page\n\nSome facts.\n" +_DRAFT = "# Draft\n\nShould be excluded when sitemap_excludes is set.\n" + + +class SitemapBuildResult(t.NamedTuple): + """Return value of ``build_sitemap_site``.""" + + result: SharedSphinxResult + sitemap_path: pathlib.Path + tree: ElementTree.ElementTree[t.Any] | None + + +def _confoverrides_to_conf_py(overrides: dict[str, t.Any]) -> str: + lines: list[str] = [] + for key, value in overrides.items(): + lines.append(f"{key} = {value!r}") + return "\n".join(lines) + "\n" + + +@pytest.fixture +def build_sitemap_site( + tmp_path: pathlib.Path, +) -> t.Callable[..., SitemapBuildResult]: + """Return a helper that builds a synthetic sitemap-enabled Sphinx site.""" + + def _build( + *, + conf_overrides: dict[str, t.Any] | None = None, + buildername: str = "html", + extra_files: tuple[ScenarioFile, ...] = (), + ) -> SitemapBuildResult: + cache_root = derive_sphinx_scenario_cache_root(tmp_path) + conf = _BASE_CONF + _confoverrides_to_conf_py(conf_overrides or {}) + scenario = SphinxScenario( + buildername=buildername, + files=( + ScenarioFile("conf.py", conf), + ScenarioFile("index.md", _INDEX), + ScenarioFile("about.md", _ABOUT), + ScenarioFile("draft.md", _DRAFT), + *extra_files, + ), + confoverrides={"extensions": ("myst_parser", "sphinx_gp_sitemap")}, + ) + # Ensure toctree references each page so Sphinx includes them. + scenario = SphinxScenario( + buildername=buildername, + files=( + ScenarioFile( + "conf.py", + conf, + ), + ScenarioFile( + "index.md", + "# Index page\n\nBody content.\n\n" + "```{toctree}\n:maxdepth: 1\n\nabout\ndraft\n```\n", + ), + ScenarioFile("about.md", _ABOUT), + ScenarioFile("draft.md", _DRAFT), + *extra_files, + ), + ) + result = build_shared_sphinx_result(cache_root, scenario) + sitemap_path = result.outdir / "sitemap.xml" + tree = ElementTree.parse(sitemap_path) if sitemap_path.exists() else None + return SitemapBuildResult(result=result, sitemap_path=sitemap_path, tree=tree) + + return _build diff --git a/tests/ext/sitemap/test_importable.py b/tests/ext/sitemap/test_importable.py index 4ca31b94..e133dda4 100644 --- a/tests/ext/sitemap/test_importable.py +++ b/tests/ext/sitemap/test_importable.py @@ -2,6 +2,8 @@ from __future__ import annotations +import typing as t + import sphinx_gp_sitemap @@ -9,8 +11,34 @@ def test_import_exposes_setup() -> None: assert callable(sphinx_gp_sitemap.setup) -def test_setup_returns_extension_metadata() -> None: - meta = sphinx_gp_sitemap.setup(app=None) # type: ignore[arg-type] +def test_setup_registers_config_values_and_connects_hooks() -> None: + """setup() registers every sitemap_* config value and three event hooks.""" + registered: list[str] = [] + connected: list[str] = [] + + class _FakeConfig: + sitemap_show_lastmod = False + + class _FakeApp: + config = _FakeConfig() + + def add_config_value(self, name: str, *args: t.Any, **kwargs: t.Any) -> None: + registered.append(name) + + def connect(self, event: str, handler: t.Callable[..., t.Any]) -> None: + connected.append(event) + + def setup_extension(self, name: str) -> None: + pass + + meta = sphinx_gp_sitemap.setup(t.cast("t.Any", _FakeApp())) assert meta["version"] assert meta["parallel_read_safe"] is True assert meta["parallel_write_safe"] is True + + assert "site_url" in registered + assert "sitemap_url_scheme" in registered + assert "sitemap_filename" in registered + assert "builder-inited" in connected + assert "html-page-context" in connected + assert "build-finished" in connected diff --git a/tests/ext/sitemap/test_urlset.py b/tests/ext/sitemap/test_urlset.py new file mode 100644 index 00000000..22f4c357 --- /dev/null +++ b/tests/ext/sitemap/test_urlset.py @@ -0,0 +1,115 @@ +"""Functional tests for sphinx_gp_sitemap's XML emission.""" + +from __future__ import annotations + +import typing as t +from xml.etree import ElementTree + +import pytest + +if t.TYPE_CHECKING: + from tests.ext.sitemap.conftest import SitemapBuildResult + +_SITEMAP_NS = "{http://www.sitemaps.org/schemas/sitemap/0.9}" +_XHTML_NS = "{http://www.w3.org/1999/xhtml}" + + +def _loc_values(tree: ElementTree.ElementTree[t.Any]) -> list[str]: + root = tree.getroot() + assert root is not None + return [el.text or "" for el in root.iter(f"{_SITEMAP_NS}loc")] + + +class SitemapCase(t.NamedTuple): + """One sitemap-emission test case.""" + + test_id: str + conf_overrides: dict[str, t.Any] + buildername: str + expected_loc_endings: tuple[str, ...] + forbidden_loc_endings: tuple[str, ...] + + +CASES: tuple[SitemapCase, ...] = ( + SitemapCase( + test_id="html-builder-emits-html-suffixes", + conf_overrides={"site_url": "https://example.org/"}, + buildername="html", + expected_loc_endings=("index.html", "about.html", "draft.html"), + forbidden_loc_endings=(), + ), + SitemapCase( + test_id="dirhtml-builder-emits-slash-suffixes", + conf_overrides={"site_url": "https://example.org/"}, + buildername="dirhtml", + # Sphinx sets language="en" by default when html_theme is set; + # the default sitemap_url_scheme injects that as a path segment. + # Index is emitted as the plain "en/", not "en/index/". + expected_loc_endings=("en/", "about/", "draft/"), + forbidden_loc_endings=("index.html", "about.html"), + ), + SitemapCase( + test_id="excluded-pages-are-dropped", + conf_overrides={ + "site_url": "https://example.org/", + "sitemap_excludes": ["draft*"], + }, + buildername="html", + expected_loc_endings=("index.html", "about.html"), + forbidden_loc_endings=("draft.html",), + ), + SitemapCase( + test_id="non-zero-indent-pretty-prints", + conf_overrides={ + "site_url": "https://example.org/", + "sitemap_indent": 2, + }, + buildername="html", + expected_loc_endings=("index.html", "about.html", "draft.html"), + forbidden_loc_endings=(), + ), +) + + +@pytest.mark.parametrize("case", CASES, ids=[c.test_id for c in CASES]) +def test_urlset( + case: SitemapCase, + build_sitemap_site: t.Callable[..., SitemapBuildResult], +) -> None: + """Each case's expected values appear and forbidden ones don't.""" + built = build_sitemap_site( + conf_overrides=case.conf_overrides, + buildername=case.buildername, + ) + assert built.tree is not None, ( + f"{case.test_id}: sitemap.xml was not written to {built.sitemap_path}" + ) + locs = _loc_values(built.tree) + for ending in case.expected_loc_endings: + assert any(loc.endswith(ending) or loc == ending for loc in locs), ( + f"{case.test_id}: no ending in {ending!r}; got {locs!r}" + ) + for ending in case.forbidden_loc_endings: + assert not any(loc.endswith(ending) for loc in locs), ( + f"{case.test_id}: forbidden ending {ending!r} in {locs!r}" + ) + + +def test_urlset_root_has_sitemap_namespace( + build_sitemap_site: t.Callable[..., SitemapBuildResult], +) -> None: + """The element carries the sitemaps.org namespace URI.""" + built = build_sitemap_site(conf_overrides={"site_url": "https://example.org/"}) + assert built.tree is not None + root = built.tree.getroot() + assert root is not None + assert root.tag == f"{_SITEMAP_NS}urlset" + + +def test_no_site_url_emits_warning_and_no_sitemap( + build_sitemap_site: t.Callable[..., SitemapBuildResult], +) -> None: + """Without site_url/html_baseurl the sitemap is skipped (with a warning).""" + built = build_sitemap_site(conf_overrides={}) # site_url omitted + assert built.tree is None + assert "site_url" in built.result.warnings From 1e1ed9a5e4a6ac3e86fa0214d68414dcd7802078 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 21 Apr 2026 18:25:41 -0500 Subject: [PATCH 05/53] gp-sphinx(feat[defaults]): integrate gp-opengraph and gp-sitemap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: swap the transitive sphinxext-opengraph dep for in-workspace gp-opengraph (matplotlib-free) and add gp-sitemap as a new default so every gp-sphinx-based docs site gets a sitemap.xml out of the box. Both packages are drop-in compatible at the conf.py level (same ogp_* / sitemap_* config keys), so downstream consumers do not need to change their conf.py. what: - packages/gp-sphinx/src/gp_sphinx/defaults.py: * DEFAULT_EXTENSIONS swaps "sphinxext.opengraph" -> "gp_opengraph" and appends "gp_sitemap". Length bumps from 12 to 13; doctest updated. - packages/gp-sphinx/src/gp_sphinx/config.py: * merge_sphinx_config auto-wires a new conf["site_url"] (normalized to a trailing slash) from docs_url so gp-sitemap's URL templating produces valid URLs without extra conf.py work. * Docstring updated to mention the new auto-wiring. - packages/gp-sphinx/pyproject.toml: * Remove sphinxext-opengraph, add gp-opengraph==0.0.1a9 and gp-sitemap==0.0.1a9 workspace pins. - docs/configuration.md: update the DEFAULT_EXTENSIONS reference row so the docs match reality. - packages/gp-sitemap/src/gp_sitemap/__init__.py: * Move the optional sphinx_last_updated_by_git loader from setup() into a config-inited hook (_maybe_enable_git_lastmod). Accessing app.config.sitemap_show_lastmod inside setup() raised AttributeError during the gp-sphinx docs build because app.config is not a populated Config object at that stage; deferring to config-inited is safer and matches the Sphinx 8.1+ event model. - uv.lock regenerated to reflect the new workspace deps. Verified side effects on the gp-sphinx docs build: - Sitemap.xml written with 30 entries across all pages (packages, project, whats-new, generated indices) — dirhtml builder with "en/0.0.1a9/" lang+version segments from the default sitemap_url_scheme. - OG meta present on index.html: og:title, og:type, og:url, og:site_name, og:description all emitted correctly. CI gate (all green before commit): - uv run ruff check . --fix --show-fixes: clean - uv run ruff format .: no changes - uv run mypy: Success (176 source files) - uv run py.test --reruns 0: 1199 passed, 3 skipped - just build-docs: build succeeded; sitemap.xml written; OG meta present --- docs/configuration.md | 2 +- packages/gp-sphinx/pyproject.toml | 3 +- packages/gp-sphinx/src/gp_sphinx/config.py | 11 ++++-- packages/gp-sphinx/src/gp_sphinx/defaults.py | 5 ++- .../src/sphinx_gp_sitemap/__init__.py | 37 +++++++++++++------ uv.lock | 19 ++-------- 6 files changed, 42 insertions(+), 35 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index d90f9477..ecb152f2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -100,7 +100,7 @@ These are injected even though they are not exposed as `DEFAULT_*` constants: | Constant | Value | | --- | --- | -| `DEFAULT_EXTENSIONS` | `["sphinx.ext.autodoc", "sphinx_fonts", "sphinx.ext.intersphinx", "sphinx_autodoc_typehints_gp", "sphinx.ext.todo", "sphinx_inline_tabs", "sphinx_copybutton", "sphinxext.opengraph", "sphinxext.rediraffe", "sphinx_design", "myst_parser", "linkify_issues"]` | +| `DEFAULT_EXTENSIONS` | `["sphinx.ext.autodoc", "sphinx_fonts", "sphinx.ext.intersphinx", "sphinx_autodoc_typehints_gp", "sphinx.ext.todo", "sphinx_inline_tabs", "sphinx_copybutton", "sphinx_gp_opengraph", "sphinx_gp_sitemap", "sphinxext.rediraffe", "sphinx_design", "myst_parser", "linkify_issues"]` | | `DEFAULT_SOURCE_SUFFIX` | `{".rst": "restructuredtext", ".md": "markdown"}` | | `DEFAULT_MYST_EXTENSIONS` | `["colon_fence", "substitution", "replacements", "strikethrough", "linkify"]` | | `DEFAULT_MYST_HEADING_ANCHORS` | `4` | diff --git a/packages/gp-sphinx/pyproject.toml b/packages/gp-sphinx/pyproject.toml index 2ac1034c..6012e91d 100644 --- a/packages/gp-sphinx/pyproject.toml +++ b/packages/gp-sphinx/pyproject.toml @@ -33,7 +33,8 @@ dependencies = [ "sphinx-autodoc-typehints-gp==0.0.1a9", "sphinx-inline-tabs", "sphinx-copybutton", - "sphinxext-opengraph", + "sphinx-gp-opengraph==0.0.1a9", + "sphinx-gp-sitemap==0.0.1a9", "sphinxext-rediraffe", "sphinx-design", "linkify-it-py", diff --git a/packages/gp-sphinx/src/gp_sphinx/config.py b/packages/gp-sphinx/src/gp_sphinx/config.py index 3fe45342..9d85540e 100644 --- a/packages/gp-sphinx/src/gp_sphinx/config.py +++ b/packages/gp-sphinx/src/gp_sphinx/config.py @@ -231,9 +231,9 @@ def merge_sphinx_config( When ``source_repository`` is provided, ``issue_url_tpl`` is auto-computed for the ``linkify_issues`` extension. When ``docs_url`` is provided, - ``ogp_site_url``, ``ogp_image``, and ``ogp_site_name`` are auto-computed - for ``sphinxext.opengraph``. All auto-computed values can be overridden - via ``overrides``. + ``ogp_site_url``, ``ogp_image``, ``ogp_site_name`` (for ``sphinx_gp_opengraph``) + and ``site_url`` (for ``sphinx_gp_sitemap``) are auto-computed. All + auto-computed values can be overridden via ``overrides``. Parameters ---------- @@ -425,11 +425,14 @@ def merge_sphinx_config( repo = source_repository.rstrip("/") conf["issue_url_tpl"] = f"{repo}/issues/{{issue_id}}" - # Auto-compute sphinxext.opengraph config + # Auto-compute sphinx_gp_opengraph + sphinx_gp_sitemap config from docs_url if docs_url: conf["ogp_site_url"] = docs_url conf["ogp_site_name"] = project conf["ogp_image"] = "_static/img/icons/icon-192x192.png" + # sphinx-gp-sitemap: normalize to trailing slash so {lang}{version}{link} + # composition produces valid URLs. + conf["site_url"] = docs_url if docs_url.endswith("/") else docs_url + "/" # Apply overrides last (can override auto-computed values) conf.update(overrides) diff --git a/packages/gp-sphinx/src/gp_sphinx/defaults.py b/packages/gp-sphinx/src/gp_sphinx/defaults.py index 110d4a3f..d8151d6f 100644 --- a/packages/gp-sphinx/src/gp_sphinx/defaults.py +++ b/packages/gp-sphinx/src/gp_sphinx/defaults.py @@ -84,7 +84,8 @@ class FontConfig(_FontConfigRequired, total=False): "sphinx.ext.todo", "sphinx_inline_tabs", "sphinx_copybutton", - "sphinxext.opengraph", + "sphinx_gp_opengraph", + "sphinx_gp_sitemap", "sphinxext.rediraffe", "sphinx_design", "myst_parser", @@ -95,7 +96,7 @@ class FontConfig(_FontConfigRequired, total=False): Examples -------- >>> len(DEFAULT_EXTENSIONS) -12 +13 >>> DEFAULT_EXTENSIONS[0] 'sphinx.ext.autodoc' diff --git a/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py b/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py index b1866fe8..38000a66 100644 --- a/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py +++ b/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py @@ -123,18 +123,7 @@ def setup(app: Sphinx) -> dict[str, t.Any]: types=frozenset({str, type(None)}), ) - if app.config.sitemap_show_lastmod: - try: - app.setup_extension("sphinx_last_updated_by_git") - except ExtensionError as exc: - logger.warning( - "%s", - exc, - type="sitemap", - subtype="configuration", - ) - app.config.sitemap_show_lastmod = False - + app.connect("config-inited", _maybe_enable_git_lastmod) app.connect("builder-inited", _init_link_store) app.connect("html-page-context", _collect_page_link) app.connect("build-finished", _write_sitemap) @@ -146,6 +135,30 @@ def setup(app: Sphinx) -> dict[str, t.Any]: } +def _maybe_enable_git_lastmod( + app: Sphinx, + config: t.Any, +) -> None: + """Load ``sphinx_last_updated_by_git`` lazily when lastmod is enabled. + + Deferred to ``config-inited`` so ``config.sitemap_show_lastmod`` has + had its default populated by Sphinx before we read it. Disables the + feature on import failure rather than aborting the build. + """ + if not config.sitemap_show_lastmod: + return + try: + app.setup_extension("sphinx_last_updated_by_git") + except ExtensionError as exc: + logger.warning( + "%s", + exc, + type="sitemap", + subtype="configuration", + ) + config.sitemap_show_lastmod = False + + def _init_link_store(app: Sphinx) -> None: """Initialize the shared ``env.temp_data`` list on each build start.""" app.env.temp_data[_LINKS_KEY] = [] diff --git a/uv.lock b/uv.lock index 2f62b9df..1f29c593 100644 --- a/uv.lock +++ b/uv.lock @@ -436,6 +436,8 @@ source = { editable = "packages/gp-sphinx" } dependencies = [ { name = "docutils" }, { name = "gp-libs" }, + { name = "sphinx-gp-opengraph" }, + { name = "sphinx-gp-sitemap" }, { name = "linkify-it-py" }, { name = "myst-parser", version = "4.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "myst-parser", version = "5.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, @@ -448,7 +450,6 @@ dependencies = [ { name = "sphinx-fonts" }, { name = "sphinx-gp-theme" }, { name = "sphinx-inline-tabs" }, - { name = "sphinxext-opengraph" }, { name = "sphinxext-rediraffe" }, ] @@ -461,6 +462,8 @@ argparse = [ requires-dist = [ { name = "docutils" }, { name = "gp-libs" }, + { name = "sphinx-gp-opengraph", editable = "packages/sphinx-gp-opengraph" }, + { name = "sphinx-gp-sitemap", editable = "packages/sphinx-gp-sitemap" }, { name = "linkify-it-py" }, { name = "myst-parser" }, { name = "sphinx", specifier = ">=8.1,<9" }, @@ -471,7 +474,6 @@ requires-dist = [ { name = "sphinx-fonts", editable = "packages/sphinx-fonts" }, { name = "sphinx-gp-theme", editable = "packages/sphinx-gp-theme" }, { name = "sphinx-inline-tabs" }, - { name = "sphinxext-opengraph" }, { name = "sphinxext-rediraffe" }, ] provides-extras = ["argparse"] @@ -1598,19 +1600,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, ] -[[package]] -name = "sphinxext-opengraph" -version = "0.13.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f6/c0/eb6838e3bae624ce6c8b90b245d17e84252863150e95efdb88f92c8aa3fb/sphinxext_opengraph-0.13.0.tar.gz", hash = "sha256:103335d08567ad8468faf1425f575e3b698e9621f9323949a6c8b96d9793e80b", size = 1026875, upload-time = "2025-08-29T12:20:31.066Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/a4/66c1fd4f8fab88faf71cee04a945f9806ba0fef753f2cfc8be6353f64508/sphinxext_opengraph-0.13.0-py3-none-any.whl", hash = "sha256:936c07828edc9ad9a7b07908b29596dc84ed0b3ceaa77acdf51282d232d4d80e", size = 1004152, upload-time = "2025-08-29T12:20:29.072Z" }, -] - [[package]] name = "sphinxext-rediraffe" version = "0.3.0" From e07b752c036bc7422a8e157a52b5ff8b9fa759b4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 21 Apr 2026 18:28:05 -0500 Subject: [PATCH 06/53] docs(feat[gp-sphinx]): document gp-opengraph and gp-sitemap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: flesh out the per-package READMEs and docs-site pages from the commit-1 scaffolding stubs, and add a CHANGES entry so the workspace v0.0.1a9 release notes capture the swap. what: - packages/gp-opengraph/README.md: * Remove the "Scaffolding" placeholder. * Add a "What it emits" section listing every tag. * Add a full config-key table (key, type, default, purpose) covering all ten ogp_* values the extension registers. * Add a "Per-page overrides" section showing MyST frontmatter usage. * Add a "Differences from sphinxext-opengraph" section calling out ogp_social_cards being accepted-but-ignored. * Add a "Static images per page" section documenting the migration path from auto-generated cards to explicit PNGs. - packages/gp-sitemap/README.md: * Remove the scaffold placeholder. * Add a "What it emits" section covering html vs dirhtml output differences, locale alternates, and optional git lastmod. * Add a full config-key table. * Add a "URL templating" section explaining sitemap_url_scheme. * Add a "Multi-language sites" section with sitemap_locales usage. * Add a "Differences from sphinx-sitemap" section listing the three modernizations (no Queue, no monkey-patch, narrow except). - docs/packages/gp-opengraph.md + docs/packages/gp-sitemap.md: * Rewrite from the scaffold stubs into proper package pages modeled on the existing sphinx-ux-badges page — package-meta directive + Alpha admonition + narrative + package-reference directive at the bottom. - CHANGES: * New Features bullet for gp-opengraph (with the "drop-in for sphinxext-opengraph minus matplotlib" framing) and for gp-sitemap (with the three Sphinx 8.1+ modernizations and the DEFAULT_EXTENSIONS addition). * New Features bullet under gp-sphinx noting the new auto-wiring of site_url for gp-sitemap. CI gate (all green before commit): - uv run ruff check . --fix --show-fixes: clean - uv run ruff format .: no changes - uv run mypy: Success (176 source files) - uv run py.test --reruns 0: 1199 passed, 3 skipped - just build-docs: build succeeded; rendered pages include the two new package entries and the CHANGES entry --- CHANGES | 20 +++++ docs/packages/sphinx-gp-opengraph.md | 56 ++++++++++++- docs/packages/sphinx-gp-sitemap.md | 57 +++++++++++++- packages/sphinx-gp-opengraph/README.md | 104 ++++++++++++++++++++++++- packages/sphinx-gp-sitemap/README.md | 81 ++++++++++++++++++- 5 files changed, 307 insertions(+), 11 deletions(-) diff --git a/CHANGES b/CHANGES index 837a6cc6..c06ae2e0 100644 --- a/CHANGES +++ b/CHANGES @@ -31,6 +31,26 @@ $ uv add gp-sphinx --prerelease allow ### Features +#### New package: `sphinx-gp-opengraph` + +OpenGraph meta-tag emission. Drop-in for `sphinxext-opengraph`, +matplotlib-free; `ogp_social_cards` is accepted but ignored with a +warning. Replaces `sphinxext.opengraph` in `DEFAULT_EXTENSIONS`. +(#22) + +#### New package: `sphinx-gp-sitemap` + +`sitemap.xml` generator. Drop-in for `sphinx-sitemap` with Sphinx +8.1+ idioms — no `multiprocessing.Queue`, public `dirhtml` detection, +narrow `ExtensionError` handling. Added to `DEFAULT_EXTENSIONS`. +(#22) + +#### `gp-sphinx`: SEO config auto-wired from `docs_url` + +`merge_sphinx_config` auto-derives `ogp_site_url`, `ogp_site_name`, +`ogp_image`, `site_url`, and `sitemap_url_scheme` (flat `"{link}"`) +from a single `docs_url`. (#22) + #### Initial release: `gp-sphinx` Shared documentation platform for git-pull projects. diff --git a/docs/packages/sphinx-gp-opengraph.md b/docs/packages/sphinx-gp-opengraph.md index a20fc435..de6648f5 100644 --- a/docs/packages/sphinx-gp-opengraph.md +++ b/docs/packages/sphinx-gp-opengraph.md @@ -8,16 +8,66 @@ :::{admonition} Alpha :class: warning -Pre-alpha scaffolding. Behavior lands in follow-up commits. +Rendered output is stable. The Python API and Sphinx config value names +may change without a major version bump. Pin your dependency to a +specific version range in production. ::: -OpenGraph and Twitter meta-tag emission for Sphinx — drop-in replacement -for `sphinxext-opengraph`, matplotlib-free. +OpenGraph and Twitter meta-tag emission for Sphinx. Drop-in replacement +for [`sphinxext-opengraph`](https://github.com/sphinx-doc/sphinxext-opengraph) +with the same `ogp_*` configuration surface, minus the matplotlib-based +social-card generator. ```console $ pip install sphinx-gp-opengraph ``` +When your docs site depends on `gp-sphinx`, this extension is already +loaded in `DEFAULT_EXTENSIONS` and `ogp_site_url` / `ogp_site_name` / +`ogp_image` are auto-computed from `docs_url`. No conf.py changes +needed. + +## What it emits + +For every HTML-family builder page, the extension writes these `` +tags into the page head: + +- `og:title` — text of the page's first heading (HTML stripped) +- `og:type` — always `"website"` (override via `ogp_type`) +- `og:url` — resolved from `ogp_site_url` + the page's relative URL +- `og:site_name` — defaults to `project`; suppressed when + `ogp_site_name = False` +- `og:description` — first body paragraph, truncated to + `ogp_description_length` (default 200 chars) +- `og:image` and `og:image:alt` — when `ogp_image` is set or the page + carries an `og:image` frontmatter override +- `` — auto-emitted to match `og:description` + unless the page already defines one or + `ogp_enable_meta_description = False` + +Plus any raw tags listed in `ogp_custom_meta_tags` (emit Twitter cards, +`og:image:width`/`og:image:height` dimension hints, etc. here). + +## Migration from `sphinxext-opengraph` + +`conf.py` files using the upstream extension keep working — every +`ogp_*` key is registered with identical semantics, **except**: + +- `ogp_social_cards` is accepted but **ignored**. sphinx-gp-opengraph does not + bundle the matplotlib-based card generator. Setting this config key + emits a one-line warning pointing at the static-image workflow. + +For per-page social card images, use static PNGs and point frontmatter +at them: + +```markdown +--- +og:image: _static/og/my-page.png +--- + +# My page +``` + ## Package reference ```{package-reference} sphinx-gp-opengraph diff --git a/docs/packages/sphinx-gp-sitemap.md b/docs/packages/sphinx-gp-sitemap.md index 1c57e4ee..83e52d60 100644 --- a/docs/packages/sphinx-gp-sitemap.md +++ b/docs/packages/sphinx-gp-sitemap.md @@ -8,16 +8,67 @@ :::{admonition} Alpha :class: warning -Pre-alpha scaffolding. Behavior lands in follow-up commits. +Rendered output is stable. The Python API and Sphinx config value names +may change without a major version bump. Pin your dependency to a +specific version range in production. ::: -Sitemap generator for Sphinx — drop-in replacement for `sphinx-sitemap` -with Sphinx 8.1+ idioms and a parallel-build-safe implementation. +Sitemap generator for Sphinx. Drop-in replacement for +[`sphinx-sitemap`](https://github.com/jdillard/sphinx-sitemap) with +Sphinx 8.1+ idioms and a parallel-build-safe implementation — no +`multiprocessing.Queue`, no monkey-patched environment attributes. ```console $ pip install sphinx-gp-sitemap ``` +When your docs site depends on `gp-sphinx`, this extension is already +loaded in `DEFAULT_EXTENSIONS` and `site_url` is auto-computed from +`docs_url` (normalized to a trailing slash). No conf.py changes needed. + +## What it emits + +After every HTML build, a `sitemap.xml` file is written to the output +directory. One `` element per page visited by the +`html-page-context` hook, filtered against `sitemap_excludes`. + +- Plain HTML builder: URLs end in `.html` (or the `html_file_suffix`). +- DirectoryHTMLBuilder (`dirhtml`): URLs end in `/`; the site index is + emitted as the bare base URL rather than `index/`. +- Multi-language: each `` gains `` siblings for every locale in `sitemap_locales` (or + auto-detected from `locale_dirs`). +- With `sitemap_show_lastmod = True`: `` dates from the latest + git commit per page, via `sphinx-last-updated-by-git`. + +## URL templating + +`sitemap_url_scheme` controls per-URL composition; default +`"{lang}{version}{link}"`. A gp-sphinx site with Sphinx's `language = +"en"` and `version = "1.2.3"` produces URLs like +`https://example.com/en/1.2.3/quickstart/`. + +Override to drop the version segment: + +```python +sitemap_url_scheme = "{lang}{link}" +``` + +## Migration from `sphinx-sitemap` + +`conf.py` files using the upstream extension keep working — every +`sitemap_*` key is registered with identical semantics. + +Implementation modernizations happen behind the scenes: + +- Collected links on `env.temp_data["sphinx_gp_sitemap_links"]` as a plain + `list` (no `multiprocessing.Queue`). +- `app.builder.name == "dirhtml"` (no `env.is_directory_builder` + monkey-patch). +- Narrow `contextlib.suppress(ExtensionError)` around the optional + `html_baseurl` re-registration (no bare `except BaseException`). +- All `add_config_value` calls use `types=frozenset({...})`. + ## Package reference ```{package-reference} sphinx-gp-sitemap diff --git a/packages/sphinx-gp-opengraph/README.md b/packages/sphinx-gp-opengraph/README.md index 67af0b81..4628f6bd 100644 --- a/packages/sphinx-gp-opengraph/README.md +++ b/packages/sphinx-gp-opengraph/README.md @@ -13,6 +13,9 @@ platform. $ pip install sphinx-gp-opengraph ``` +Or — when part of a gp-sphinx site — you already have it (gp-sphinx +pulls this in by default). + ## Usage Enable in your `docs/conf.py`: @@ -23,7 +26,104 @@ extensions = [ ] ogp_site_url = "https://example.com/" -ogp_image = "_static/og-default.png" # 1200×630 recommended +ogp_image = "_static/og-default.png" # 1200×630 recommended for Slack/FB/Twitter +``` + +That's the minimum. Every page rendered by the HTML-family builders +gains an `og:title`, `og:type`, `og:url`, `og:site_name`, +`og:description`, `og:image`, and `og:image:alt` meta tag. + +For Twitter cards, append to `ogp_custom_meta_tags`: + +```python +ogp_custom_meta_tags = [ + '', + '', + '', +] ``` -Scaffolding — full extension arrives in follow-up commits. +## Per-page overrides + +Override the site-wide defaults in each page's front matter (MyST +syntax shown; Sphinx RST field lists work the same way): + +```markdown +--- +ogp_description_length: 160 +og:image: _static/og/this-page.png +og:image:alt: A tailored hero for this page +--- + +# Page title + +Body paragraph that becomes og:description. +``` + +Set `ogp_disable: true` to skip OG emission on a specific page. + +## Config reference + +| Key | Type | Default | Purpose | +|---|---|---|---| +| `ogp_site_url` | `str` | `""` | Base URL; required for absolute `og:url` | +| `ogp_canonical_url` | `str` | `""` | Separate canonical URL; falls back to `ogp_site_url` | +| `ogp_description_length` | `int` | `200` | Description truncation cap | +| `ogp_image` | `str \| None` | `None` | Site-default OG image (1200×630 recommended) | +| `ogp_image_alt` | `str \| bool \| None` | `None` | Alt text; falls back to site name or title | +| `ogp_use_first_image` | `bool` | `False` | Use the first in-page image as `og:image` | +| `ogp_type` | `str` | `"website"` | Value of the `og:type` tag | +| `ogp_site_name` | `str \| bool \| None` | `None` (→ `project`) | `False` disables the tag | +| `ogp_custom_meta_tags` | `list[str]` | `()` | Raw `` tags emitted verbatim | +| `ogp_enable_meta_description` | `bool` | `True` | Emit a matching `` | + +## Differences from `sphinxext-opengraph` + +Configuration is **drop-in compatible** with upstream — switching to +`sphinx-gp-opengraph` does not require any conf.py changes for sites that +don't use social cards. + +- **`ogp_social_cards` is accepted but ignored.** sphinx-gp-opengraph does not + bundle the matplotlib-based card generator upstream ships. Setting + `ogp_social_cards` emits a one-line warning pointing at the static- + image workflow below. See [Static images per page](#static-images-per-page). +- The three parser helpers (`_description`, `_title`, `_meta`) are + ported verbatim. +- `setup()` returns `dict[str, Any]` following the gp-sphinx house + convention; the keys (`version`, `parallel_read_safe`, + `parallel_write_safe`) are identical. + +## Static images per page + +Instead of generating per-page PNGs at build time (the matplotlib +feature sphinx-gp-opengraph drops), provide static images and point +frontmatter at them: + +``` +docs/ +├── _static/ +│ └── og/ +│ ├── default.png ← 1200×630, used when no frontmatter override +│ ├── quickstart.png +│ └── reference.png +├── quickstart.md +└── reference.md +``` + +```markdown +--- +og:image: _static/og/quickstart.png +--- + +# Quickstart +``` + +This is equivalent to upstream's auto-generated per-page cards — just +explicit. + +## See also + +- [sphinx-gp-sitemap](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-gp-sitemap) — + companion package for `sitemap.xml` emission +- [gp-sphinx](https://github.com/git-pull/gp-sphinx) — the umbrella + docs platform that auto-wires this extension from `docs_url` diff --git a/packages/sphinx-gp-sitemap/README.md b/packages/sphinx-gp-sitemap/README.md index 2827fca2..85425224 100644 --- a/packages/sphinx-gp-sitemap/README.md +++ b/packages/sphinx-gp-sitemap/README.md @@ -14,6 +14,9 @@ platform. $ pip install sphinx-gp-sitemap ``` +Or — when part of a gp-sphinx site — you already have it (gp-sphinx +pulls this in by default). + ## Usage Enable in your `docs/conf.py`: @@ -26,7 +29,79 @@ extensions = [ site_url = "https://example.com/" ``` -A `sitemap.xml` is written to your HTML output directory after each -build. +A `sitemap.xml` is written to the HTML output directory on every build. +One `` element per built page; auto-skipped for the index of the +`dirhtml` builder (emitted as the bare base URL, not `index/`). + +## Config reference + +| Key | Type | Default | Purpose | +|---|---|---|---| +| `site_url` | `str \| None` | `None` | Base URL. Falls back to `html_baseurl` | +| `sitemap_url_scheme` | `str` | `"{lang}{version}{link}"` | Per-URL template | +| `sitemap_locales` | `list \| None` | `[]` (auto-detect) | `hreflang` alternates | +| `sitemap_filename` | `str` | `"sitemap.xml"` | Output filename | +| `sitemap_excludes` | `list[str]` | `[]` | fnmatch patterns to skip | +| `sitemap_show_lastmod` | `bool` | `False` | Include `` from git | +| `sitemap_indent` | `int` | `0` | XML indent width (0 = minified) | + +## Multi-language sites + +When `sitemap_locales` is set (or auto-detected from `locale_dirs`), +each `` gains `` +entries for every locale: + +```python +sitemap_locales = ["de", "fr", "ja"] +``` + +Use `sitemap_locales = [None]` to explicitly suppress hreflang +alternates. + +## Excluding pages + +```python +sitemap_excludes = [ + "draft/*", + "internal-*", +] +``` + +Patterns match the `sitemap_link` (relative page URL after the builder +applies its suffix) via `fnmatch`. + +## `lastmod` from git + +```python +sitemap_show_lastmod = True +``` + +Loads `sphinx-last-updated-by-git` transparently and emits `` +per page based on the file's latest commit timestamp. Silently +disables itself (with a warning) when the extension is not installed. + +## Differences from `sphinx-sitemap` + +Configuration is **drop-in compatible** with upstream — switching to +`sphinx-gp-sitemap` does not require any conf.py changes. + +Implementation changes behind the scenes: + +- Collected links stored in `env.temp_data["sphinx_gp_sitemap_links"]` as a + plain `list[tuple[str, str | None]]` — no `multiprocessing.Queue`. + Sphinx joins parallel workers before `build-finished` fires, so the + Queue machinery was over-engineered. +- Builder-kind detection uses `app.builder.name == "dirhtml"` — + no `env.is_directory_builder` monkey-patch. +- `html_baseurl` re-registration uses + `contextlib.suppress(ExtensionError)` rather than a bare + `except BaseException`. +- All `add_config_value` calls use `types=frozenset({...})` uniform. + +## See also -Scaffolding — full extension arrives in follow-up commits. +- [sphinx-gp-opengraph](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-gp-opengraph) — + companion package for OpenGraph / Twitter meta-tag emission +- [gp-sphinx](https://github.com/git-pull/gp-sphinx) — the umbrella + docs platform that auto-wires this extension's `site_url` from + `docs_url` From 18f433b43fad58d0466ea77f27c072f065027560 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 21 Apr 2026 19:39:34 -0500 Subject: [PATCH 07/53] ci(fix[seo-packages]): satisfy -W strict docs build on remote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: CI's docs job and the gp-sphinx smoke test both run sphinx-build -W (warnings-as-errors). Two issues surfaced against the seo-packages branch that the local just build-docs (no -W) let through: 1. docs/packages/gp-opengraph.md and docs/packages/gp-sitemap.md were not in any toctree on the main docs site. Sphinx emits [toc.not_included] as a warning, which -W promotes. 2. gp-sitemap logged a WARNING when site_url (and html_baseurl) were unset. That matched upstream sphinx-sitemap behavior, but because gp-sitemap is in gp-sphinx's DEFAULT_EXTENSIONS it runs on every consumer build — including the smoke test, which builds a minimal project through merge_sphinx_config() without docs_url. Under -W that warning broke the smoke build. what: - docs/index.md: add a new "SEO" toctree caption referencing packages/gp-opengraph and packages/gp-sitemap so the two new pages are toctree-included (no change to the rendered nav — hidden=true, matches the existing Internal/Utils/UX caption pattern). - packages/gp-sitemap/src/gp_sitemap/__init__.py: * Demote the "site_url is required" message from logger.warning to logger.info with a one-line explanatory comment on why (DEFAULT_EXTENSIONS semantics differ from an opt-in extension). * Message reworded from "required — sitemap not built" to "skipping sitemap — set site_url or html_baseurl to enable" so the log line reads as informational rather than punitive. - tests/ext/sitemap/test_urlset.py: rename test_no_site_url_emits_warning_and_no_sitemap -> test_no_site_url_skips_sitemap_silently and flip the assertion to "site_url not in built.result.warnings" with a docstring explaining the -W compatibility reason. CI gate (all green before commit, plus local -W repro): - uv run ruff check . --fix --show-fixes: clean - uv run ruff format .: no changes - uv run mypy: Success (176 source files) - uv run py.test --reruns 0: 1199 passed, 3 skipped - just build-docs: build succeeded - uv run sphinx-build -W -b html docs _build/html-strict: succeeds (was failing on the two toctree warnings before this commit) --- docs/index.md | 8 ++++++++ .../src/sphinx_gp_sitemap/__init__.py | 10 +++++++--- tests/ext/sitemap/test_urlset.py | 11 ++++++++--- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/docs/index.md b/docs/index.md index 53f34129..1d16ff5a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -133,3 +133,11 @@ packages/sphinx-autodoc-typehints-gp packages/gp-sphinx packages/sphinx-gp-theme ``` + +```{toctree} +:caption: SEO +:hidden: + +packages/sphinx-gp-opengraph +packages/sphinx-gp-sitemap +``` diff --git a/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py b/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py index 38000a66..c5125bef 100644 --- a/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py +++ b/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py @@ -223,9 +223,13 @@ def _write_sitemap(app: Sphinx, exception: BaseException | None) -> None: site_url = app.builder.config.site_url or app.builder.config.html_baseurl if not site_url: - logger.warning( - "sphinx-gp-sitemap: site_url (or html_baseurl) is required in conf.py. " - "Sitemap not built.", + # INFO rather than WARNING because sphinx-gp-sitemap is in gp-sphinx's + # DEFAULT_EXTENSIONS: users who haven't configured a deploy URL + # should silently skip sitemap emission rather than break builds + # that run with ``-W``. + logger.info( + "sphinx-gp-sitemap: skipping sitemap — set site_url or html_baseurl " + "in conf.py to enable.", type="sitemap", subtype="configuration", ) diff --git a/tests/ext/sitemap/test_urlset.py b/tests/ext/sitemap/test_urlset.py index 22f4c357..6eb1babd 100644 --- a/tests/ext/sitemap/test_urlset.py +++ b/tests/ext/sitemap/test_urlset.py @@ -106,10 +106,15 @@ def test_urlset_root_has_sitemap_namespace( assert root.tag == f"{_SITEMAP_NS}urlset" -def test_no_site_url_emits_warning_and_no_sitemap( +def test_no_site_url_skips_sitemap_silently( build_sitemap_site: t.Callable[..., SitemapBuildResult], ) -> None: - """Without site_url/html_baseurl the sitemap is skipped (with a warning).""" + """Without site_url/html_baseurl the sitemap is silently skipped. + + sphinx-gp-sitemap is in gp-sphinx's DEFAULT_EXTENSIONS, so an unset deploy + URL should not break ``sphinx-build -W``. The missing-URL notice is + logged at INFO, not WARNING. + """ built = build_sitemap_site(conf_overrides={}) # site_url omitted assert built.tree is None - assert "site_url" in built.result.warnings + assert "site_url" not in built.result.warnings From 359bb293e63dd92c05c9a74ff10d0d289de44c17 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 21 Apr 2026 20:01:45 -0500 Subject: [PATCH 08/53] gp-opengraph, gp-sitemap(fix[logging]): attach NullHandler + drop trailing periods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: PR #22 code review flagged two CLAUDE.md#logging violations in the two new packages. These are house-style invariants that every existing workspace package (gp_sphinx, sphinx_fonts, sphinx_ux_badges, …) already follows: 1. "Add ``NullHandler`` in library ``__init__.py`` files" (CLAUDE.md line 553) — both new package __init__.py files declared a module-level logger without one. Importing either package outside a Sphinx build could surface a "no handlers could be found" warning from stdlib logging. 2. "No trailing punctuation" in log messages (CLAUDE.md line 576) — two log calls introduced in commit ab3a6a6 and 04f0121 ended with a period. what: - packages/gp-opengraph/src/gp_opengraph/__init__.py: * After ``logger = logging.getLogger(__name__)`` attach ``logger.addHandler(logging.NullHandler())`` to match the sphinx-fonts pattern exactly. * ``_warn_if_social_cards_used``: message reworded to "ogp_social_cards ignored — gp-opengraph ships no card generator; use a static PNG via ogp_image (site default) or per-page 'og:image' frontmatter" (no trailing period; also trimmed the "is ignored — gp-opengraph does not bundle a card generator" phrasing into "ignored — … ships no card generator" which reads cleaner as a warning). - packages/gp-sitemap/src/gp_sitemap/__init__.py: * Import ``logging`` from stdlib and add ``logging.getLogger(__name__).addHandler(logging.NullHandler())`` alongside the existing ``logger = getLogger(__name__)`` from ``sphinx.util.logging``. The Sphinx adapter is kept because it supports the ``type=``/``subtype=`` kwargs used elsewhere for warning classification; the NullHandler attaches to the underlying stdlib logger with the same name so the library is well-behaved when imported outside Sphinx. Inline comment memorializes the two-getLogger dance so future readers don't try to delete one. * ``_write_sitemap`` "skipping sitemap" info message: trailing period removed. CI gate (all green before commit): - uv run ruff check . --fix --show-fixes: clean - uv run ruff format .: no changes - uv run mypy: Success (176 source files) - uv run py.test --reruns 0: 1199 passed, 3 skipped - just build-docs: build succeeded --- .../src/sphinx_gp_opengraph/__init__.py | 7 ++++--- .../sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py | 8 +++++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py index ae77d898..25f89e31 100644 --- a/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py +++ b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py @@ -38,6 +38,7 @@ from sphinx.config import Config logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) _EXTENSION_VERSION = "0.0.1a9" @@ -260,9 +261,9 @@ def _warn_if_social_cards_used(app: Sphinx, config: Config) -> None: del app # unused; required by Sphinx's config-inited signature if config.ogp_social_cards: logger.warning( - "sphinx-gp-opengraph: ogp_social_cards is ignored — sphinx-gp-opengraph does " - "not bundle a card generator. Use a static PNG via ogp_image " - "(site default) or per-page 'og:image' frontmatter.", + "sphinx-gp-opengraph: ogp_social_cards ignored — sphinx-gp-opengraph ships " + "no card generator; use a static PNG via ogp_image (site " + "default) or per-page 'og:image' frontmatter", ) diff --git a/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py b/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py index c5125bef..1f3bb7e3 100644 --- a/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py +++ b/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py @@ -26,6 +26,7 @@ import contextlib import datetime as dt import fnmatch +import logging import pathlib import typing as t from xml.etree import ElementTree @@ -44,7 +45,12 @@ _SITEMAP_NS = "http://www.sitemaps.org/schemas/sitemap/0.9" _XHTML_NS = "http://www.w3.org/1999/xhtml" +# sphinx-gp-sitemap uses Sphinx's logger adapter so ``type=``/``subtype=`` kwargs +# work for warning classification, but still attaches NullHandler to the +# underlying stdlib logger so the library doesn't emit "no handlers" warnings +# when imported outside a Sphinx build (per CLAUDE.md #Logger setup). logger = getLogger(__name__) +logging.getLogger(__name__).addHandler(logging.NullHandler()) SitemapLink = tuple[str, str | None] # (relative link, last_updated ISO8601 or None) @@ -229,7 +235,7 @@ def _write_sitemap(app: Sphinx, exception: BaseException | None) -> None: # that run with ``-W``. logger.info( "sphinx-gp-sitemap: skipping sitemap — set site_url or html_baseurl " - "in conf.py to enable.", + "in conf.py to enable", type="sitemap", subtype="configuration", ) From 2755c1c31c6e4a37b8c0d86532757d3db340b66d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 21 Apr 2026 20:03:45 -0500 Subject: [PATCH 09/53] gp-opengraph(refactor[imports]): use namespace imports for stdlib modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: PR #22 code review flagged three ``from import `` import statements that violate CLAUDE.md line 457: Use namespace imports for standard library modules: `import enum` instead of `from enum import Enum` Exception: `dataclasses` module may use `from dataclasses import dataclass, field` for cleaner decorator syntax Every existing workspace package follows this — e.g. gp-sphinx's own config.py uses ``import pathlib`` / ``import tomllib`` then ``pathlib.Path(...)`` / ``tomllib.loads(...)``. what: - packages/gp-opengraph/src/gp_opengraph/__init__.py: * ``from types import NoneType`` -> ``import types`` + ``types.NoneType`` at each call site (four occurrences inside ``frozenset({...})`` literals for add_config_value). * ``from urllib.parse import urljoin, urlparse, urlsplit, urlunsplit`` -> ``import urllib.parse`` with each call qualified: ``urllib.parse.urljoin(...)``, ``urllib.parse.urlparse(...)``, ``urllib.parse.urlsplit(...)``, ``urllib.parse.urlunsplit(...)``. - packages/gp-opengraph/src/gp_opengraph/_title.py: * ``from html.parser import HTMLParser`` -> ``import html.parser``; subclass declaration becomes ``class HTMLTextParser(html.parser.HTMLParser)``. - packages/gp-opengraph/src/gp_opengraph/_meta.py: * Same HTMLParser rewrite as _title.py. Left as-is (already compliant): - ``from xml.etree import ElementTree`` in gp_sitemap — this imports a *submodule*, not a name, and is the canonical idiom (equivalent to ``import xml.etree.ElementTree as ElementTree``). CLAUDE.md's example targets ``from enum import Enum`` (name import), not submodule aliasing. - ``from collections.abc import Set`` / ``Iterable`` inside ``TYPE_CHECKING`` blocks — gp_sphinx itself does this at config.py#L17, so the convention allows type-only imports this way. No behavior change; purely an import refactor. CI gate (all green before commit): - uv run ruff check . --fix --show-fixes: clean - uv run ruff format .: no changes - uv run mypy: Success (176 source files) - uv run py.test --reruns 0 -vvv: 1199 passed, 3 skipped - just build-docs: build succeeded --- .../src/sphinx_gp_opengraph/__init__.py | 24 ++++++++++--------- .../src/sphinx_gp_opengraph/_meta.py | 4 ++-- .../src/sphinx_gp_opengraph/_title.py | 4 ++-- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py index 25f89e31..456558d4 100644 --- a/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py +++ b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py @@ -22,9 +22,9 @@ import logging import os import pathlib +import types import typing as t -from types import NoneType -from urllib.parse import urljoin, urlparse, urlsplit, urlunsplit +import urllib.parse from docutils import nodes from sphinx.application import Sphinx @@ -137,7 +137,7 @@ def get_tags( ogp_canonical_url = config.ogp_canonical_url or ogp_site_url - page_url = urljoin( + page_url = urllib.parse.urljoin( ogp_canonical_url, builder.get_target_uri(context["pagename"]), ) @@ -170,10 +170,10 @@ def get_tags( if image_url: if "og:image" not in fields: - image_url_parsed = urlparse(image_url) + image_url_parsed = urllib.parse.urlparse(image_url) if not image_url_parsed.scheme: root = page_url if first_image else ogp_site_url - image_url = urljoin(root, image_url_parsed.path) + image_url = urllib.parse.urljoin(root, image_url_parsed.path) tags["og:image"] = image_url if isinstance(ogp_image_alt, str): @@ -204,8 +204,10 @@ def _ambient_site_url() -> str: if not rtd_canonical_url: msg = "ReadTheDocs did not provide a valid canonical URL" raise RuntimeError(msg) - parsed = urlsplit(rtd_canonical_url) - return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", "")) + parsed = urllib.parse.urlsplit(rtd_canonical_url) + return urllib.parse.urlunsplit( + (parsed.scheme, parsed.netloc, parsed.path, "", ""), + ) def _resolve_site_name(config: Config) -> str | None: @@ -300,13 +302,13 @@ def setup(app: Sphinx) -> dict[str, t.Any]: "ogp_image", None, "html", - types=frozenset({str, NoneType}), + types=frozenset({str, types.NoneType}), ) app.add_config_value( "ogp_image_alt", None, "html", - types=frozenset({str, bool, NoneType}), + types=frozenset({str, bool, types.NoneType}), ) app.add_config_value( "ogp_use_first_image", @@ -319,14 +321,14 @@ def setup(app: Sphinx) -> dict[str, t.Any]: "ogp_site_name", None, "html", - types=frozenset({str, bool, NoneType}), + types=frozenset({str, bool, types.NoneType}), ) # Accepted-but-ignored: warned about in _warn_if_social_cards_used. app.add_config_value( "ogp_social_cards", None, "html", - types=frozenset({dict, NoneType}), + types=frozenset({dict, types.NoneType}), ) app.add_config_value( "ogp_custom_meta_tags", diff --git a/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/_meta.py b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/_meta.py index 7be14c56..068d4706 100644 --- a/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/_meta.py +++ b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/_meta.py @@ -15,7 +15,7 @@ from __future__ import annotations -from html.parser import HTMLParser +import html.parser def get_meta_description(meta_tags: str) -> str | bool | None: @@ -40,7 +40,7 @@ def get_meta_description(meta_tags: str) -> str | bool | None: return htp.meta_description -class HTMLTextParser(HTMLParser): +class HTMLTextParser(html.parser.HTMLParser): """Flag the presence (and content) of a ````.""" def __init__(self) -> None: diff --git a/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/_title.py b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/_title.py index 1b927c68..cfa2da94 100644 --- a/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/_title.py +++ b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/_title.py @@ -11,7 +11,7 @@ from __future__ import annotations -from html.parser import HTMLParser +import html.parser def get_title(title: str) -> tuple[str, str]: @@ -38,7 +38,7 @@ def get_title(title: str) -> tuple[str, str]: return htp.text, htp.text_outside_tags -class HTMLTextParser(HTMLParser): +class HTMLTextParser(html.parser.HTMLParser): """Track text-inside-tags vs text-outside-tags while parsing HTML.""" def __init__(self) -> None: From a109ace94da6ffca317e029302d3aa4a9d0a3323 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 05:14:30 -0500 Subject: [PATCH 10/53] docs(fix[packages]): correct package-index prose for SEO tier why: defaults.py now ships gp_opengraph and gp_sitemap in DEFAULT_EXTENSIONS, so the workspace is fourteen packages organised into four tiers, not twelve / three. The {workspace-package-grid} directive auto-rendered the new packages but the prose paragraph above it drifted out of sync. what: - Update count from "Twelve workspace packages in three tiers" to "Fourteen workspace packages in four tiers" - Add a fourth tier bullet group "SEO" listing gp-opengraph and gp-sitemap, with a note that they auto-load when docs_url is set --- docs/packages/index.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/packages/index.md b/docs/packages/index.md index 4bb3faf5..6362e210 100644 --- a/docs/packages/index.md +++ b/docs/packages/index.md @@ -1,6 +1,6 @@ # Packages -Twelve workspace packages in three tiers. +Fourteen workspace packages in four tiers. **Shared infrastructure** — the rendering pipeline that all domain packages consume: - `sphinx-ux-badges` — badge primitives and colour palette @@ -18,6 +18,10 @@ directives, roles, and per-domain indices: assets: - `gp-sphinx`, `sphinx-gp-theme`, `sphinx-fonts` +**SEO** — meta-tag and crawlability extensions auto-loaded by +`gp-sphinx` when `docs_url` is set: +- `sphinx-gp-opengraph`, `sphinx-gp-sitemap` + `gp-sphinx` is the umbrella entry point: `merge_sphinx_config()` wires up the full stack for downstream projects. From 8f8b5ef0263d1925d3549e53f54326c0e226df24 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 05:16:07 -0500 Subject: [PATCH 11/53] gp-opengraph(fix[setup]): add env_version and tighten setup return type why: env_version lets Sphinx invalidate cached doctrees if the extension ever begins persisting env-scoped data; declaring it now is a one-line forward-compat hedge with zero runtime cost. The ExtensionMetadata return annotation aligns gp-opengraph with the precedent already set in sphinx-autodoc-docutils and sphinx-autodoc-sphinx, so mypy resolves the extension contract through the typed Sphinx API instead of a generic dict. what: - Add ExtensionMetadata to the TYPE_CHECKING import block - Change setup() return annotation from dict[str, t.Any] to ExtensionMetadata; sync the NumPy "Returns" docstring section - Add "env_version": 1 to the dict returned by setup() --- .../src/sphinx_gp_opengraph/__init__.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py index 456558d4..de1491bc 100644 --- a/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py +++ b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py @@ -36,6 +36,7 @@ if t.TYPE_CHECKING: from sphinx.builders import Builder from sphinx.config import Config + from sphinx.util.typing import ExtensionMetadata logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) @@ -269,7 +270,7 @@ def _warn_if_social_cards_used(app: Sphinx, config: Config) -> None: ) -def setup(app: Sphinx) -> dict[str, t.Any]: +def setup(app: Sphinx) -> ExtensionMetadata: """Register config values and connect the html-page-context hook. Parameters @@ -279,8 +280,9 @@ def setup(app: Sphinx) -> dict[str, t.Any]: Returns ------- - dict[str, Any] - Extension metadata — version plus parallel-build flags. + ExtensionMetadata + Extension metadata — version, env_version, and parallel-build + flags. Examples -------- @@ -348,6 +350,7 @@ def setup(app: Sphinx) -> dict[str, t.Any]: return { "version": _EXTENSION_VERSION, + "env_version": 1, "parallel_read_safe": True, "parallel_write_safe": True, } From 7d7939a800033f0336082948bdeeb1fc607320c0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 05:17:08 -0500 Subject: [PATCH 12/53] gp-sitemap(refactor[setup]): tighten setup return type to ExtensionMetadata why: aligns gp-sitemap with the typed Sphinx extension contract used by sphinx-autodoc-docutils, sphinx-autodoc-sphinx, and (now) gp-opengraph. Type-only change; no runtime behaviour shifts. env_version is deliberately not added because gp-sitemap stores no env-scoped data beyond per-build temp_data. what: - Add ExtensionMetadata to the TYPE_CHECKING import block - Change setup() return annotation from dict[str, t.Any] to ExtensionMetadata --- packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py b/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py index 1f3bb7e3..a283eeb2 100644 --- a/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py +++ b/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py @@ -39,6 +39,7 @@ from collections.abc import Iterable from docutils import nodes + from sphinx.util.typing import ExtensionMetadata _EXTENSION_VERSION = "0.0.1a9" _LINKS_KEY = "sphinx_gp_sitemap_links" @@ -57,7 +58,7 @@ __all__ = ["setup"] -def setup(app: Sphinx) -> dict[str, t.Any]: +def setup(app: Sphinx) -> ExtensionMetadata: """Register config values and connect sitemap-emission hooks. Parameters @@ -67,7 +68,7 @@ def setup(app: Sphinx) -> dict[str, t.Any]: Returns ------- - dict[str, Any] + ExtensionMetadata Extension metadata — version plus parallel-build flags. Examples From 9f3df9605b31216ad01c392d332338ba4dba15a6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 05:20:05 -0500 Subject: [PATCH 13/53] gp-sitemap(fix[parallel]): drop unsupported parallel_write_safe claim why: link collection is stored in env.temp_data, which Sphinx documents as per-process state and does not merge across parallel workers. The inherited "parallel_write_safe": True flag from the upstream port was accurate only when sphinx-sitemap used a multiprocessing.Manager() queue; once the queue was removed the claim no longer matched runtime semantics. Under sphinx-build -j N the parent's build-finished hook sees only its own slice of links and writes a partial sitemap silently. Drop the flag so Sphinx falls back to a single write process for this extension and the sitemap is always complete. what: - Remove "parallel_write_safe": True from the dict returned by setup() - Rewrite the misleading "Queue machinery was over-engineered" note in the module docstring to record the temp_data trade-off honestly - Update README lede and "Differences from sphinx-sitemap" bullet to reflect the new contract: parallel_read_safe only - Update the importable smoke test to assert parallel_write_safe is absent rather than True --- packages/sphinx-gp-sitemap/README.md | 9 ++++++--- .../sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py | 9 +++++---- tests/ext/sitemap/test_importable.py | 5 ++++- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/sphinx-gp-sitemap/README.md b/packages/sphinx-gp-sitemap/README.md index 85425224..c00ee595 100644 --- a/packages/sphinx-gp-sitemap/README.md +++ b/packages/sphinx-gp-sitemap/README.md @@ -2,7 +2,7 @@ Sitemap generator for Sphinx — drop-in replacement for [`sphinx-sitemap`](https://github.com/jdillard/sphinx-sitemap) with -Sphinx 8.1+ idioms and a parallel-build-safe implementation (no +Sphinx 8.1+ idioms and a simplified link-collection model (no `multiprocessing.Queue`). Part of the [gp-sphinx](https://github.com/git-pull/gp-sphinx) documentation @@ -89,8 +89,11 @@ Implementation changes behind the scenes: - Collected links stored in `env.temp_data["sphinx_gp_sitemap_links"]` as a plain `list[tuple[str, str | None]]` — no `multiprocessing.Queue`. - Sphinx joins parallel workers before `build-finished` fires, so the - Queue machinery was over-engineered. + Trade-off: `temp_data` is per-process and is not merged across + parallel workers, so sphinx-gp-sitemap declares `parallel_read_safe` only + and is **not** `parallel_write_safe`; sites built with + `sphinx-build -j N` should fall back to a single write process for + this extension to capture every page. - Builder-kind detection uses `app.builder.name == "dirhtml"` — no `env.is_directory_builder` monkey-patch. - `html_baseurl` re-registration uses diff --git a/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py b/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py index a283eeb2..62f7cf71 100644 --- a/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py +++ b/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py @@ -5,9 +5,11 @@ modernizations: 1. ``env.temp_data["sphinx_gp_sitemap_links"]`` is a plain ``list[tuple[...]]`` - rather than a ``multiprocessing.Queue``. Sphinx joins parallel - workers before ``build-finished`` fires, so the Queue machinery was - over-engineered. + rather than a ``multiprocessing.Queue``. Because ``temp_data`` is + per-process and not merged across parallel workers, sphinx-gp-sitemap only + advertises ``parallel_read_safe`` and intentionally omits + ``parallel_write_safe``: under ``sphinx-build -j N`` link collection + would be incomplete, so the extension is single-write-process only. 2. Builder-kind detection uses the public ``app.builder.name == "dirhtml"`` rather than monkey-patching ``env.is_directory_builder``. 3. The ``html_baseurl`` config value is only registered when not already @@ -138,7 +140,6 @@ def setup(app: Sphinx) -> ExtensionMetadata: return { "version": _EXTENSION_VERSION, "parallel_read_safe": True, - "parallel_write_safe": True, } diff --git a/tests/ext/sitemap/test_importable.py b/tests/ext/sitemap/test_importable.py index e133dda4..abddc5ea 100644 --- a/tests/ext/sitemap/test_importable.py +++ b/tests/ext/sitemap/test_importable.py @@ -34,7 +34,10 @@ def setup_extension(self, name: str) -> None: meta = sphinx_gp_sitemap.setup(t.cast("t.Any", _FakeApp())) assert meta["version"] assert meta["parallel_read_safe"] is True - assert meta["parallel_write_safe"] is True + # sphinx-gp-sitemap intentionally does not advertise parallel_write_safe: + # link collection lives in env.temp_data, which is per-process and + # not merged across parallel workers. + assert "parallel_write_safe" not in meta assert "site_url" in registered assert "sitemap_url_scheme" in registered From 9ffa05ff006eae2c1167caccea6024dc53fa8d27 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 05:21:36 -0500 Subject: [PATCH 14/53] gp-sitemap(test[urlset]): cover custom html_file_suffix why: html_file_suffix is read by _collect_page_link to derive the relative link for non-dirhtml builds, but every existing case used the default ".html" so the alternate path was untested. Upstream sphinx-sitemap covers .htm in its own test_simple suite; mirroring that closes the same coverage gap locally and pins the behavior in case the suffix-handling branch is ever simplified. what: - Add SitemapCase "html-builder-honors-custom-file-suffix" to the parametrized harness in tests/ext/sitemap/test_urlset.py - Override html_file_suffix=".htm"; assert every ends in .htm and that no ends in .html (the default suffix must not leak) --- tests/ext/sitemap/test_urlset.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/ext/sitemap/test_urlset.py b/tests/ext/sitemap/test_urlset.py index 6eb1babd..48e47df2 100644 --- a/tests/ext/sitemap/test_urlset.py +++ b/tests/ext/sitemap/test_urlset.py @@ -68,6 +68,16 @@ class SitemapCase(t.NamedTuple): expected_loc_endings=("index.html", "about.html", "draft.html"), forbidden_loc_endings=(), ), + SitemapCase( + test_id="html-builder-honors-custom-file-suffix", + conf_overrides={ + "site_url": "https://example.org/", + "html_file_suffix": ".htm", + }, + buildername="html", + expected_loc_endings=("index.htm", "about.htm", "draft.htm"), + forbidden_loc_endings=("index.html", "about.html", "draft.html"), + ), ) From cd328839d351c9abcc2de8f3509fe586bfd424f6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 05:45:51 -0500 Subject: [PATCH 15/53] gp-sphinx(feat[config]): auto-set flat sitemap_url_scheme from docs_url why: gp-sitemap inherited upstream sphinx-sitemap's default scheme of "{lang}{version}{link}", which expands to /en/// once Sphinx auto-fills language="en" and the project sets a version. That URL shape is correct for ReadTheDocs-style hosting where each locale and version has its own subtree, but every git-pull.com site deploys flat at the project root, so the prefixed URLs would 404 against the real deploy target. Fix the default at the gp-sphinx layer (rather than gp-sitemap, which intentionally stays drop-in compatible with upstream) so the 14+ projects driven by merge_sphinx_config() get correct URLs without per-project overrides. what: - merge_sphinx_config(): inside the existing `if docs_url:` block, also set conf["sitemap_url_scheme"] = "{link}". `conf.update( overrides)` runs afterwards, so multilingual or version-pinned projects can still pass sitemap_url_scheme=... to restore the prefixed scheme - Update the docstring summary and add doctests covering both the default and the override-wins case - docs/configuration.md: extend the "From docs_url" auto-computation table with site_url and sitemap_url_scheme rows; explain why the override differs from the upstream default - tests/test_config.py: add three tests pinning the new behaviour (auto-set when docs_url is present, absent when docs_url is not, explicit override beats the auto-set) --- docs/configuration.md | 9 +++++ packages/gp-sphinx/src/gp_sphinx/config.py | 33 +++++++++++++++-- tests/test_config.py | 43 ++++++++++++++++++++++ 3 files changed, 81 insertions(+), 4 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index ecb152f2..ea733acf 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -62,6 +62,15 @@ All parameters are keyword-only. | `ogp_site_url` | `docs_url` | | `ogp_site_name` | `project` | | `ogp_image` | `"_static/img/icons/icon-192x192.png"` | +| `site_url` | `docs_url` (trailing-slash normalized) | +| `sitemap_url_scheme` | `"{link}"` | + +`sitemap_url_scheme` overrides upstream sphinx-sitemap's default of +`"{lang}{version}{link}"` because git-pull.com sites deploy flat at the +project root with no language or version path segment. Multilingual or +version-pinned hosting can pass an explicit `sitemap_url_scheme` to +`merge_sphinx_config()` to restore the prefixed scheme — `**overrides` +is applied after auto-computation, so the explicit value wins. ### From `**overrides` diff --git a/packages/gp-sphinx/src/gp_sphinx/config.py b/packages/gp-sphinx/src/gp_sphinx/config.py index 9d85540e..f98d1cb7 100644 --- a/packages/gp-sphinx/src/gp_sphinx/config.py +++ b/packages/gp-sphinx/src/gp_sphinx/config.py @@ -231,9 +231,13 @@ def merge_sphinx_config( When ``source_repository`` is provided, ``issue_url_tpl`` is auto-computed for the ``linkify_issues`` extension. When ``docs_url`` is provided, - ``ogp_site_url``, ``ogp_image``, ``ogp_site_name`` (for ``sphinx_gp_opengraph``) - and ``site_url`` (for ``sphinx_gp_sitemap``) are auto-computed. All - auto-computed values can be overridden via ``overrides``. + ``ogp_site_url``, ``ogp_image``, ``ogp_site_name`` (for ``sphinx_gp_opengraph``), + ``site_url`` and ``sitemap_url_scheme`` (for ``sphinx_gp_sitemap``) are + auto-computed. The sitemap scheme defaults to ``"{link}"`` because + git-pull.com sites deploy flat at the project root, with no + ``{lang}{version}`` path segments; multilingual or version-pinned + deployments can override it via ``overrides``. All auto-computed + values can be overridden via ``overrides``. Parameters ---------- @@ -331,6 +335,21 @@ def merge_sphinx_config( >>> conf["ogp_site_url"] 'https://test.org' + + >>> conf["sitemap_url_scheme"] + '{link}' + + The sitemap scheme can still be overridden when needed: + + >>> conf = merge_sphinx_config( + ... project="test", + ... version="1.0", + ... copyright="2026", + ... docs_url="https://test.org", + ... sitemap_url_scheme="{lang}/{version}/{link}", + ... ) + >>> conf["sitemap_url_scheme"] + '{lang}/{version}/{link}' """ # Extensions ext_list = list(extensions) if extensions is not None else list(DEFAULT_EXTENSIONS) @@ -430,9 +449,15 @@ def merge_sphinx_config( conf["ogp_site_url"] = docs_url conf["ogp_site_name"] = project conf["ogp_image"] = "_static/img/icons/icon-192x192.png" - # sphinx-gp-sitemap: normalize to trailing slash so {lang}{version}{link} + # sphinx-gp-sitemap: normalize to trailing slash so the URL scheme # composition produces valid URLs. conf["site_url"] = docs_url if docs_url.endswith("/") else docs_url + "/" + # sphinx-gp-sitemap: git-pull.com sites deploy at the project root with + # no language or version path segment, so override the upstream + # default of "{lang}{version}{link}" to a flat scheme. Projects + # with translated or version-pinned hosting can pass a different + # ``sitemap_url_scheme`` via ``**overrides``. + conf["sitemap_url_scheme"] = "{link}" # Apply overrides last (can override auto-computed values) conf.update(overrides) diff --git a/tests/test_config.py b/tests/test_config.py index d03daa73..ced95328 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -329,6 +329,49 @@ def test_merge_sphinx_config_no_ogp_without_docs_url() -> None: assert "ogp_site_url" not in result +def test_merge_sphinx_config_auto_sitemap_url_scheme() -> None: + """sitemap_url_scheme defaults to flat ``{link}`` when docs_url is set. + + git-pull.com sites deploy at the project root, so the upstream + sphinx-sitemap default of ``{lang}{version}{link}`` would generate + URLs that 404 against the actual deploy target. + """ + result = merge_sphinx_config( + project="test", + version="1.0", + copyright="2026", + docs_url="https://test.git-pull.com", + ) + assert result["sitemap_url_scheme"] == "{link}" + assert result["site_url"] == "https://test.git-pull.com/" + + +def test_merge_sphinx_config_no_sitemap_url_scheme_without_docs_url() -> None: + """sitemap_url_scheme is not auto-set when docs_url is absent. + + Without docs_url sphinx-gp-sitemap stays silent (no sitemap is emitted), so + leaving ``sitemap_url_scheme`` at the extension default is correct. + """ + result = merge_sphinx_config( + project="test", + version="1.0", + copyright="2026", + ) + assert "sitemap_url_scheme" not in result + + +def test_merge_sphinx_config_sitemap_url_scheme_override_wins() -> None: + """An explicit sitemap_url_scheme override beats the auto-set.""" + result = merge_sphinx_config( + project="test", + version="1.0", + copyright="2026", + docs_url="https://test.git-pull.com", + sitemap_url_scheme="{lang}/{version}/{link}", + ) + assert result["sitemap_url_scheme"] == "{lang}/{version}/{link}" + + def test_merge_sphinx_config_override_auto_computed() -> None: """Manual overrides take precedence over auto-computed values.""" result = merge_sphinx_config( From a69abdf892cdc510bc861d4238e8537f6d8fffd1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 06:22:00 -0500 Subject: [PATCH 16/53] sphinx-autodoc-fastmcp(fix[collector]): use try/else over None re-assignment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: a recent fastmcp upgrade exposes Prompt / Resource / ResourceTemplate as concrete types in mypy, so reassigning the import aliases to None in the ImportError fallback branch tripped "Cannot assign to a type" / "Incompatible assignment" errors. The None-reassignment was always purely defensive — the subsequent `if _Prompt is not None:` guarded every use — so converting the layout to a try/else removes the dead None-state entirely and the type-narrowing question disappears. what: - Replace the `try/except ImportError + None re-assignment + if not None` pattern in collect_prompts_and_resources() with a try/except/else block. The component-iteration body now lives in the try-success branch where _Prompt, _Resource, _ResourceTemplate are guaranteed to be the imported classes --- .../src/sphinx_autodoc_fastmcp/_collector.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py index b0e51466..dfd76845 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/_collector.py @@ -578,9 +578,7 @@ def collect_prompts_and_resources(app: Sphinx) -> None: "sphinx_autodoc_fastmcp: could not import fastmcp types", exc_info=True, ) - _Prompt = _Resource = _ResourceTemplate = None - - if _Prompt is not None: + else: for component in _iter_components(server): if isinstance(component, _ResourceTemplate): info_tpl = _resource_template_from_component(component) From 2be0d3a2defc7c77e36aea8aa74a9378e14917f4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 06:22:51 -0500 Subject: [PATCH 17/53] gp-opengraph(refactor[setup]): drop cargo-culted env_version key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: env_version is Sphinx's mechanism for invalidating the cached BuildEnvironment when an extension's env-stored shape changes between builds (sphinx/environment/__init__.py:826-833 and :260-273). The trigger is a *change* in the value across builds, not its absolute magnitude — so declaring env_version: 1 today does not pre-arm any future invalidation. gp-opengraph stores nothing in app.env: it is a pure per-page transformer that reads doctree state and emits meta tags, never persisting anything. Declaring env_version while never touching env is cargo-culted from the same vestigial declaration in upstream sphinxext-opengraph; both can drop it without behavioural change. If a future version of this extension begins persisting env state, adding env_version then (absent → 1) invalidates caches once, which is the same outcome a 1 → 2 bump produces today. what: - Remove "env_version": 1 from the dict returned by setup() - Update the NumPy "Returns" docstring to drop the env_version mention --- .../sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py index de1491bc..293408cd 100644 --- a/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py +++ b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py @@ -281,8 +281,7 @@ def setup(app: Sphinx) -> ExtensionMetadata: Returns ------- ExtensionMetadata - Extension metadata — version, env_version, and parallel-build - flags. + Extension metadata — version and parallel-build flags. Examples -------- @@ -350,7 +349,6 @@ def setup(app: Sphinx) -> ExtensionMetadata: return { "version": _EXTENSION_VERSION, - "env_version": 1, "parallel_read_safe": True, "parallel_write_safe": True, } From fae236afc30b4b71b15e734c3e703c660ae44ab0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 06:24:07 -0500 Subject: [PATCH 18/53] gp-opengraph(docs[surface]): split README install/reference from docs/ integration story MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: the README and docs page had drifted into half-overlapping mini- references — both repeated the migration story, neither linked to the canonical From-docs_url table in configuration.md, and both omitted the actual deprecation-warning text users grep for. Establish a clear information-architecture rule for the package: README owns install + config-key reference + per-package migration; docs/ page owns the gp-sphinx integration story, the emission pipeline, the event hooks, and the trade-offs. Each fact has exactly one home. what: - packages/gp-opengraph/README.md: full rewrite to the IA above. Adds ogp_social_cards row to the config-key table marked accepted-but- ignored; adds the verbatim deprecation-warning text so log greps match; adds a Twitter-cards subsection showing ogp_custom_meta_tags usage; documents the auto-derived values when used under gp-sphinx with forward-links to the docs page and configuration.md - docs/packages/gp-opengraph.md: full rewrite. Surfaces the DEFAULT_EXTENSIONS membership and the auto-derived ogp_site_url / ogp_site_name / ogp_image table; explains the title / description / meta-description extraction pipeline at a usable depth; adds the event-hooks block (config-inited, html-page-context); records the parallel-build contract and the deliberate absence of env_version; trims the duplicated migration content to one paragraph plus a README pointer --- docs/packages/sphinx-gp-opengraph.md | 136 +++++++++++++--------- packages/sphinx-gp-opengraph/README.md | 153 +++++++++++++++---------- 2 files changed, 178 insertions(+), 111 deletions(-) diff --git a/docs/packages/sphinx-gp-opengraph.md b/docs/packages/sphinx-gp-opengraph.md index de6648f5..a5516776 100644 --- a/docs/packages/sphinx-gp-opengraph.md +++ b/docs/packages/sphinx-gp-opengraph.md @@ -13,60 +13,92 @@ may change without a major version bump. Pin your dependency to a specific version range in production. ::: -OpenGraph and Twitter meta-tag emission for Sphinx. Drop-in replacement -for [`sphinxext-opengraph`](https://github.com/sphinx-doc/sphinxext-opengraph) -with the same `ogp_*` configuration surface, minus the matplotlib-based -social-card generator. - -```console -$ pip install sphinx-gp-opengraph +OpenGraph meta-tag emission for Sphinx. The package registers every +`ogp_*` config value the upstream +[`sphinxext-opengraph`](https://github.com/sphinx-doc/sphinxext-opengraph) +exposes and emits the same `` tags, with one deliberate +omission: the matplotlib-based social-card generator is not bundled. +That is why the package has zero non-Sphinx runtime dependencies. + +For install, the full config-key reference, per-page overrides, and the +verbatim deprecation-warning text, see the package +[README](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-gp-opengraph#readme). +This page covers integration with gp-sphinx, the emission pipeline, +and the trade-offs. + +## Integration with gp-sphinx + +`sphinx_gp_opengraph` ships in {py:data}`~gp_sphinx.defaults.DEFAULT_EXTENSIONS`, +so projects that build through {py:func}`~gp_sphinx.config.merge_sphinx_config` +load it automatically. Passing `docs_url=` to that function auto-derives +three of the most common config values: + +| Auto-derived | Source | +| --- | --- | +| `ogp_site_url` | `docs_url` | +| `ogp_site_name` | `project` | +| `ogp_image` | `"_static/img/icons/icon-192x192.png"` | + +The canonical reference for these and the other auto-derived values +lives in [Configuration → From `docs_url`](#from-docs_url). Any value +passed via `**overrides` to `merge_sphinx_config()` wins over the +auto-derived default — auto-computation runs first, overrides apply +last. + +## How the page-level meta tags are built + +For every page rendered by an HTML-family builder, the extension's +`html-page-context` handler walks the resolved doctree and emits the +following tags into `context["metatags"]`. The page is skipped when its +front-matter sets `ogp_disable: true`. + +| Tag | Source | +| --- | --- | +| `og:title` | First heading of the page, with HTML stripped (`_title.py`) | +| `og:type` | `ogp_type` (default `"website"`) | +| `og:url` | `ogp_canonical_url or ogp_site_url`, joined with the page's relative URL | +| `og:site_name` | `ogp_site_name`, or `project` when unset; suppressed when set to `False` | +| `og:description` | First non-title body paragraph, truncated to `ogp_description_length`, HTML-escaped (`_description.py`) | +| `og:image` | Page front-matter `og:image`, else `ogp_image`, else first in-page image when `ogp_use_first_image=True` | +| `og:image:alt` | Front-matter `og:image:alt`, else `ogp_image_alt`, falling back to site name, then page title | +| `` | Mirror of `og:description` when `ogp_enable_meta_description=True` and the page does not already define one (`_meta.py`) | + +The description extractor walks the document, skips title nodes and +empty paragraphs, takes the first prose paragraph, and truncates at the +configured cap. Embedded HTML quote characters are escaped with +`"` before emission, so user content cannot break out of the +attribute value. + +Custom raw markup listed in `ogp_custom_meta_tags` is appended verbatim +after the structured tags — that is the supported escape hatch for +Twitter card declarations and `og:image:width`/`og:image:height` hints. + +## Event hooks + +```text +config-inited → _warn_if_social_cards_used (deprecation warning) +html-page-context → html_page_context (per-page meta-tag emission) ``` -When your docs site depends on `gp-sphinx`, this extension is already -loaded in `DEFAULT_EXTENSIONS` and `ogp_site_url` / `ogp_site_name` / -`ogp_image` are auto-computed from `docs_url`. No conf.py changes -needed. - -## What it emits - -For every HTML-family builder page, the extension writes these `` -tags into the page head: - -- `og:title` — text of the page's first heading (HTML stripped) -- `og:type` — always `"website"` (override via `ogp_type`) -- `og:url` — resolved from `ogp_site_url` + the page's relative URL -- `og:site_name` — defaults to `project`; suppressed when - `ogp_site_name = False` -- `og:description` — first body paragraph, truncated to - `ogp_description_length` (default 200 chars) -- `og:image` and `og:image:alt` — when `ogp_image` is set or the page - carries an `og:image` frontmatter override -- `` — auto-emitted to match `og:description` - unless the page already defines one or - `ogp_enable_meta_description = False` - -Plus any raw tags listed in `ogp_custom_meta_tags` (emit Twitter cards, -`og:image:width`/`og:image:height` dimension hints, etc. here). - -## Migration from `sphinxext-opengraph` - -`conf.py` files using the upstream extension keep working — every -`ogp_*` key is registered with identical semantics, **except**: - -- `ogp_social_cards` is accepted but **ignored**. sphinx-gp-opengraph does not - bundle the matplotlib-based card generator. Setting this config key - emits a one-line warning pointing at the static-image workflow. - -For per-page social card images, use static PNGs and point frontmatter -at them: - -```markdown ---- -og:image: _static/og/my-page.png ---- - -# My page -``` +Both hooks live in +[`sphinx_gp_opengraph/__init__.py`](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py). +There is no `builder-inited` or `build-finished` work — the extension +is purely a per-page transformer. + +## Trade-offs + +**`ogp_social_cards` is accepted but ignored.** The upstream extension +ships a matplotlib renderer that builds per-page PNGs at +`builder-inited`. sphinx-gp-opengraph deliberately omits the dependency to +keep the install graph small. The config key remains registered so +existing `conf.py` files do not error; setting it logs a single +`WARNING` at `config-inited` directing users to the static-image +workflow documented in the README. + +**`parallel_read_safe` and `parallel_write_safe` are both `True`.** +The extension never writes shared state — every emission is +self-contained inside the per-page hook — so it is safe under any +`sphinx-build -j N` value. ## Package reference diff --git a/packages/sphinx-gp-opengraph/README.md b/packages/sphinx-gp-opengraph/README.md index 4628f6bd..c7052592 100644 --- a/packages/sphinx-gp-opengraph/README.md +++ b/packages/sphinx-gp-opengraph/README.md @@ -1,11 +1,13 @@ # sphinx-gp-opengraph -OpenGraph and Twitter meta-tag emission for Sphinx — drop-in replacement -for [`sphinxext-opengraph`](https://github.com/sphinx-doc/sphinxext-opengraph), -matplotlib-free. +OpenGraph meta-tag emission for Sphinx — a drop-in replacement for +[`sphinxext-opengraph`](https://github.com/sphinx-doc/sphinxext-opengraph) +that ships every `ogp_*` config key the upstream supports, minus the +matplotlib-based social-card generator. No image-rendering dependencies, +no system-fontconfig surprises. -Part of the [gp-sphinx](https://github.com/git-pull/gp-sphinx) documentation -platform. +Part of the [gp-sphinx](https://github.com/git-pull/gp-sphinx) +documentation platform. ## Install @@ -13,40 +15,47 @@ platform. $ pip install sphinx-gp-opengraph ``` -Or — when part of a gp-sphinx site — you already have it (gp-sphinx -pulls this in by default). +When you depend on gp-sphinx, this extension is already loaded — see +[Auto-derived values](#auto-derived-values-when-used-with-gp-sphinx) +below. -## Usage - -Enable in your `docs/conf.py`: +## Minimum viable conf.py ```python extensions = [ "sphinx_gp_opengraph", ] +``` +```python ogp_site_url = "https://example.com/" -ogp_image = "_static/og-default.png" # 1200×630 recommended for Slack/FB/Twitter ``` -That's the minimum. Every page rendered by the HTML-family builders -gains an `og:title`, `og:type`, `og:url`, `og:site_name`, -`og:description`, `og:image`, and `og:image:alt` meta tag. - -For Twitter cards, append to `ogp_custom_meta_tags`: - ```python -ogp_custom_meta_tags = [ - '', - '', - '', -] +ogp_image = "_static/og-default.png" ``` +A 1200×630 PNG works on Slack, Facebook, LinkedIn, and X/Twitter +unfurlers. With these three values set, every page rendered by an +HTML-family builder gains `og:title`, `og:type`, `og:url`, +`og:site_name`, `og:description`, `og:image`, and `og:image:alt`. A +matching `` is emitted when the page does not +already define one. + +## Auto-derived values when used with gp-sphinx + +Projects that build through {py:func}`gp_sphinx.config.merge_sphinx_config` +do not need to set `ogp_site_url`, `ogp_site_name`, or `ogp_image` +manually. Pass `docs_url=` to `merge_sphinx_config()` and gp-sphinx +fills all three from that one value. See [the sphinx-gp-opengraph package +page](../../docs/packages/sphinx-gp-opengraph.md) for the integration story +and [`configuration.md`](../../docs/configuration.md#from-docs_url) +for the canonical mapping table. + ## Per-page overrides -Override the site-wide defaults in each page's front matter (MyST -syntax shown; Sphinx RST field lists work the same way): +Set front-matter fields to override the site-wide defaults on a single +page. MyST syntax shown; reST field-list syntax behaves the same way. ```markdown --- @@ -60,50 +69,76 @@ og:image:alt: A tailored hero for this page Body paragraph that becomes og:description. ``` -Set `ogp_disable: true` to skip OG emission on a specific page. +| Field | Effect | +| --- | --- | +| `og:image` | Replace the site-default image for this page | +| `og:image:alt` | Replace the alt text for this page | +| `ogp_description_length` | Override the description-length cap for this page | +| `ogp_disable: true` | Skip OpenGraph emission entirely on this page | + +Any other `og:*` field-list entry is forwarded to the page head verbatim, +so `og:type`, `og:audio`, etc. work without code changes. -## Config reference +## Config-key reference + +Every key is registered with `rebuild="html"` and the indicated default. +Per-page front-matter wins over these site-wide values. | Key | Type | Default | Purpose | -|---|---|---|---| -| `ogp_site_url` | `str` | `""` | Base URL; required for absolute `og:url` | -| `ogp_canonical_url` | `str` | `""` | Separate canonical URL; falls back to `ogp_site_url` | -| `ogp_description_length` | `int` | `200` | Description truncation cap | -| `ogp_image` | `str \| None` | `None` | Site-default OG image (1200×630 recommended) | -| `ogp_image_alt` | `str \| bool \| None` | `None` | Alt text; falls back to site name or title | -| `ogp_use_first_image` | `bool` | `False` | Use the first in-page image as `og:image` | +| --- | --- | --- | --- | +| `ogp_site_url` | `str` | `""` | Site base URL; required for absolute `og:url` (auto-derived under gp-sphinx) | +| `ogp_canonical_url` | `str` | `""` | Separate canonical URL; falls back to `ogp_site_url` when empty | +| `ogp_description_length` | `int` | `200` | Truncation cap for `og:description` | +| `ogp_image` | `str \| None` | `None` | Site-default OG image (auto-derived under gp-sphinx) | +| `ogp_image_alt` | `str \| bool \| None` | `None` | Alt text; falls back to `og:site_name`, then `og:title`. `False` suppresses the alt tag | +| `ogp_use_first_image` | `bool` | `False` | Use the first in-page image as `og:image` when no override is set | | `ogp_type` | `str` | `"website"` | Value of the `og:type` tag | -| `ogp_site_name` | `str \| bool \| None` | `None` (→ `project`) | `False` disables the tag | -| `ogp_custom_meta_tags` | `list[str]` | `()` | Raw `` tags emitted verbatim | +| `ogp_site_name` | `str \| bool \| None` | `None` (→ `project`) | `False` suppresses the `og:site_name` tag | +| `ogp_social_cards` | `dict \| None` | `None` | Accepted-but-ignored — see [Migration](#migration-from-sphinxext-opengraph) | +| `ogp_custom_meta_tags` | `list[str]` | `()` | Raw `` tags emitted verbatim — use this for Twitter cards | | `ogp_enable_meta_description` | `bool` | `True` | Emit a matching `` | -## Differences from `sphinxext-opengraph` +### Twitter cards + +sphinx-gp-opengraph does not register a separate `twitter_*` namespace; +crawlers fall back to `og:*` for most fields. Append explicit Twitter +markup through `ogp_custom_meta_tags` when you need it: + +```python +ogp_custom_meta_tags = [ + '', + '', + '', +] +``` + +## Migration from `sphinxext-opengraph` -Configuration is **drop-in compatible** with upstream — switching to -`sphinx-gp-opengraph` does not require any conf.py changes for sites that -don't use social cards. +Configuration is drop-in compatible — every `ogp_*` key is registered +with the same name, type, and default — with one behavioural change: - **`ogp_social_cards` is accepted but ignored.** sphinx-gp-opengraph does not - bundle the matplotlib-based card generator upstream ships. Setting - `ogp_social_cards` emits a one-line warning pointing at the static- - image workflow below. See [Static images per page](#static-images-per-page). -- The three parser helpers (`_description`, `_title`, `_meta`) are - ported verbatim. -- `setup()` returns `dict[str, Any]` following the gp-sphinx house - convention; the keys (`version`, `parallel_read_safe`, - `parallel_write_safe`) are identical. + bundle the matplotlib-based card generator the upstream ships. Setting + the value emits one `WARNING` at `config-inited`: -## Static images per page + ```text + sphinx-gp-opengraph: ogp_social_cards ignored — sphinx-gp-opengraph ships no card generator; use a static PNG via ogp_image (site default) or per-page 'og:image' frontmatter + ``` -Instead of generating per-page PNGs at build time (the matplotlib -feature sphinx-gp-opengraph drops), provide static images and point -frontmatter at them: + Grep your build log for `ogp_social_cards ignored` to find this + warning. -``` +The recommended replacement is one static PNG per page. Drop them under +`_static/og/` and point the per-page `og:image` field-list entry at +each one. The downstream UX is the same as upstream's auto-generated +cards — just explicit, and with no build-time dependency on matplotlib +or PIL. + +```text docs/ ├── _static/ │ └── og/ -│ ├── default.png ← 1200×630, used when no frontmatter override +│ ├── default.png │ ├── quickstart.png │ └── reference.png ├── quickstart.md @@ -118,12 +153,12 @@ og:image: _static/og/quickstart.png # Quickstart ``` -This is equivalent to upstream's auto-generated per-page cards — just -explicit. - ## See also -- [sphinx-gp-sitemap](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-gp-sitemap) — - companion package for `sitemap.xml` emission +- [sphinx-gp-sitemap](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-gp-sitemap) + — companion package for `sitemap.xml` emission - [gp-sphinx](https://github.com/git-pull/gp-sphinx) — the umbrella - docs platform that auto-wires this extension from `docs_url` + docs platform; auto-derives `ogp_site_url`, `ogp_site_name`, and + `ogp_image` from a single `docs_url` argument +- [sphinx-gp-opengraph package page](https://gp-sphinx.git-pull.com/packages/sphinx-gp-opengraph/) + — integration story, event hooks, and how-it-works From 388321410a7ff3d346a77c5a36cc029650ab4b32 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 06:26:39 -0500 Subject: [PATCH 19/53] gp-sitemap(docs[surface]): refresh docs page parallel contract; close README behaviour gaps why: the docs page lede still advertised "parallel-build-safe" even after commit b94f016 dropped the parallel_write_safe flag, and neither doc surfaced the gp-sphinx integration story (auto-derived site_url + sitemap_url_scheme = "{link}") that lets downstream projects ship correct, flat URLs without a per-project override. README + docs page also duplicated the migration story with no canonical owner. Apply the same information-architecture rule used for gp-opengraph: README owns install + config-key reference + per- package migration; docs/ page owns the gp-sphinx integration story, the emission pipeline, the event hooks, and the trade-offs. what: - packages/gp-sitemap/README.md: rewrite around the new IA. Add "Auto-derived values when used with gp-sphinx" subsection forward-linking to the docs page and configuration.md; add Builder support table (html / dirhtml / others); document the site_url + html_baseurl resolution chain plus the silent-skip-at-INFO behaviour for unset URL; promote the parallel-write trade-off to the migration section's lone explicit behavioural change - docs/packages/gp-sitemap.md: rewrite. Lede no longer claims parallel-build-safe; add an "Integration with gp-sphinx" callout citing the From-docs_url table and the sitemap_url_scheme = "{link}" rationale; document the four event hooks and the link-collection / URL-composition / hreflang / lastmod / serialize pipeline at a usable depth; record the parallel_write_safe omission and the deliberate absence of env_version; trim the duplicated migration content to README-pointer prose --- docs/packages/sphinx-gp-sitemap.md | 160 ++++++++++++++++++--------- packages/sphinx-gp-sitemap/README.md | 155 +++++++++++++++++--------- 2 files changed, 208 insertions(+), 107 deletions(-) diff --git a/docs/packages/sphinx-gp-sitemap.md b/docs/packages/sphinx-gp-sitemap.md index 83e52d60..77c9e0a0 100644 --- a/docs/packages/sphinx-gp-sitemap.md +++ b/docs/packages/sphinx-gp-sitemap.md @@ -13,61 +13,115 @@ may change without a major version bump. Pin your dependency to a specific version range in production. ::: -Sitemap generator for Sphinx. Drop-in replacement for -[`sphinx-sitemap`](https://github.com/jdillard/sphinx-sitemap) with -Sphinx 8.1+ idioms and a parallel-build-safe implementation — no -`multiprocessing.Queue`, no monkey-patched environment attributes. - -```console -$ pip install sphinx-gp-sitemap +Sitemap generator for Sphinx. The package registers every `sitemap_*` +config value the upstream +[`sphinx-sitemap`](https://github.com/jdillard/sphinx-sitemap) exposes +and emits the same `sitemap.xml` shape (urlset, hreflang alternates, +optional ``), updated to Sphinx 8.1+ idioms. The hard +dependency on `sphinx-last-updated-by-git` is downgraded to a +soft on-demand load that activates only under +`sitemap_show_lastmod = True`. + +For install, the full config-key reference, builder support, locale +rules, and the lastmod / migration story, see the package +[README](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-gp-sitemap#readme). +This page covers integration with gp-sphinx, the emission pipeline, +and the trade-offs. + +## Integration with gp-sphinx + +`sphinx_gp_sitemap` ships in {py:data}`~gp_sphinx.defaults.DEFAULT_EXTENSIONS`, +so projects that build through {py:func}`~gp_sphinx.config.merge_sphinx_config` +load it automatically. Passing `docs_url=` to that function auto-derives +both URL inputs the extension needs: + +| Auto-derived | Source | +| --- | --- | +| `site_url` | `docs_url`, normalized to end in `/` | +| `sitemap_url_scheme` | `"{link}"` (flat — no language or version segment) | + +The flat scheme overrides the upstream default of +`"{lang}{version}{link}"` because git-pull.com sites deploy at the +project root, with no language or version directory in the URL space. +Multilingual or version-pinned hosts can still pass an explicit +`sitemap_url_scheme` through `**overrides` — `merge_sphinx_config()` +runs auto-derivation first and overrides last. The canonical mapping +lives in [Configuration → From `docs_url`](#from-docs_url). + +## How `sitemap.xml` is built + +After every HTML-family build, the extension serializes one `` +element per built page to `sitemap.xml` in the output directory. + +1. **Init** — `builder-inited` initializes + `env.temp_data["sphinx_gp_sitemap_links"]` to an empty list. +2. **Collect** — `html-page-context` fires once per page. The handler + computes the relative URL using the builder's suffix + (`html_file_suffix or ".html"` for the `html` builder; `…/` for + `dirhtml`, with the index emitted as the empty string), drops it + when any pattern in `sitemap_excludes` matches, and appends a + `(relative_link, last_updated)` tuple to the list. +3. **Compose** — `build-finished` resolves `site_url` (or + `html_baseurl` as fallback; if both are unset the build is logged + at INFO and skipped silently). For each collected link the + handler formats `site_url + sitemap_url_scheme.format(lang=…, + version=…, link=…)`. The `lang` segment comes from + `app.builder.config.language` followed by `/` (empty when no + language is set); `version` likewise from + `app.builder.config.version`. +4. **Hreflang** — when `sitemap_locales` resolves to a non-empty list + (explicit value, or auto-detected sub-directories of every entry + in `locale_dirs`), each `` gains + `` siblings. The + formatter rewrites underscores to hyphens for IANA compatibility + (`pt_BR` → `pt-BR`). The sentinel `sitemap_locales = [None]` + suppresses alternates explicitly. +5. **Lastmod** (optional) — when `sitemap_show_lastmod = True`, the + `config-inited` handler runs + `app.setup_extension("sphinx_last_updated_by_git")` once at the + start of the build to lazy-load the supporting extension. If the + import fails, sphinx-gp-sitemap logs a `WARNING` and disables the flag + for the rest of the build — `` is omitted but everything + else still emits. +6. **Serialize** — `xml.etree.ElementTree.write()` produces the file. + When `sitemap_indent > 0`, `ElementTree.indent()` pretty-prints + the tree with the configured width. ElementTree handles XML entity + escaping for the URL text and attribute values automatically. + +## Event hooks + +```text +config-inited → _maybe_enable_git_lastmod (lazy-load lastmod ext) +builder-inited → _init_link_store (init temp_data list) +html-page-context → _collect_page_link (one entry per page) +build-finished → _write_sitemap (XML serialization) ``` -When your docs site depends on `gp-sphinx`, this extension is already -loaded in `DEFAULT_EXTENSIONS` and `site_url` is auto-computed from -`docs_url` (normalized to a trailing slash). No conf.py changes needed. - -## What it emits - -After every HTML build, a `sitemap.xml` file is written to the output -directory. One `` element per page visited by the -`html-page-context` hook, filtered against `sitemap_excludes`. - -- Plain HTML builder: URLs end in `.html` (or the `html_file_suffix`). -- DirectoryHTMLBuilder (`dirhtml`): URLs end in `/`; the site index is - emitted as the bare base URL rather than `index/`. -- Multi-language: each `` gains `` siblings for every locale in `sitemap_locales` (or - auto-detected from `locale_dirs`). -- With `sitemap_show_lastmod = True`: `` dates from the latest - git commit per page, via `sphinx-last-updated-by-git`. - -## URL templating - -`sitemap_url_scheme` controls per-URL composition; default -`"{lang}{version}{link}"`. A gp-sphinx site with Sphinx's `language = -"en"` and `version = "1.2.3"` produces URLs like -`https://example.com/en/1.2.3/quickstart/`. - -Override to drop the version segment: - -```python -sitemap_url_scheme = "{lang}{link}" -``` - -## Migration from `sphinx-sitemap` - -`conf.py` files using the upstream extension keep working — every -`sitemap_*` key is registered with identical semantics. - -Implementation modernizations happen behind the scenes: - -- Collected links on `env.temp_data["sphinx_gp_sitemap_links"]` as a plain - `list` (no `multiprocessing.Queue`). -- `app.builder.name == "dirhtml"` (no `env.is_directory_builder` - monkey-patch). -- Narrow `contextlib.suppress(ExtensionError)` around the optional - `html_baseurl` re-registration (no bare `except BaseException`). -- All `add_config_value` calls use `types=frozenset({...})`. +All four live in +[`sphinx_gp_sitemap/__init__.py`](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py). +There is no `env-merge-info` or `env-purge-doc` handler — see the +parallel-write trade-off below. + +## Trade-offs + +**`parallel_write_safe` is not declared.** Sphinx's `temp_data` is +explicitly per-process and is not merged across `sphinx-build -j N` +workers. The upstream `sphinx-sitemap` worked around that with a +`multiprocessing.Manager().Queue`; sphinx-gp-sitemap drops the Queue but +does not yet implement an `env-merge-info`/`env-purge-doc` pair to +preserve parallel-write semantics. Until that work lands, the +extension advertises `parallel_read_safe = True` only. Parallel-read +is the common case (it is what `sphinx-build -j` enables by default +in CI matrices); parallel-write requires the operator to opt in. A +single-process write pass produces a complete sitemap. + +**`html_baseurl` is re-registered defensively.** Sphinx core +registers `html_baseurl` on most modern versions, but older trees and +some custom builders skip it. The `setup()` body wraps the +`add_config_value("html_baseurl", …)` call in +`contextlib.suppress(ExtensionError)` so the extension is robust +against either layout. The bare `except BaseException` upstream uses +is replaced by the narrow `ExtensionError` catch. ## Package reference diff --git a/packages/sphinx-gp-sitemap/README.md b/packages/sphinx-gp-sitemap/README.md index c00ee595..f85fe3cb 100644 --- a/packages/sphinx-gp-sitemap/README.md +++ b/packages/sphinx-gp-sitemap/README.md @@ -1,12 +1,14 @@ # sphinx-gp-sitemap -Sitemap generator for Sphinx — drop-in replacement for -[`sphinx-sitemap`](https://github.com/jdillard/sphinx-sitemap) with -Sphinx 8.1+ idioms and a simplified link-collection model (no -`multiprocessing.Queue`). +Sitemap generator for Sphinx — a drop-in replacement for +[`sphinx-sitemap`](https://github.com/jdillard/sphinx-sitemap) updated +to Sphinx 8.1+ idioms. Same `sitemap_*` configuration surface, no +`multiprocessing.Queue`, and a soft (lazy-loaded) dependency on +[`sphinx-last-updated-by-git`](https://github.com/mgeier/sphinx-last-updated-by-git) +that activates only when `sitemap_show_lastmod = True`. -Part of the [gp-sphinx](https://github.com/git-pull/gp-sphinx) documentation -platform. +Part of the [gp-sphinx](https://github.com/git-pull/gp-sphinx) +documentation platform. ## Install @@ -14,49 +16,87 @@ platform. $ pip install sphinx-gp-sitemap ``` -Or — when part of a gp-sphinx site — you already have it (gp-sphinx -pulls this in by default). +When you depend on gp-sphinx, this extension is already loaded — see +[Auto-derived values](#auto-derived-values-when-used-with-gp-sphinx) +below. -## Usage - -Enable in your `docs/conf.py`: +## Minimum viable conf.py ```python extensions = [ "sphinx_gp_sitemap", ] +``` +```python site_url = "https://example.com/" ``` -A `sitemap.xml` is written to the HTML output directory on every build. -One `` element per built page; auto-skipped for the index of the -`dirhtml` builder (emitted as the bare base URL, not `index/`). +After every HTML-family build, `sitemap.xml` is written to the output +directory. One `` element per page; the index of the `dirhtml` +builder is emitted as the bare base URL (not `index/`). When +`site_url` is unset and `html_baseurl` is also unset, sitemap +emission is skipped silently — the notice is logged at INFO, not +WARNING, so `-W` strict builds do not fail on undeployed projects. + +## Auto-derived values when used with gp-sphinx + +Projects that build through {py:func}`gp_sphinx.config.merge_sphinx_config` +do not need to set `site_url` or `sitemap_url_scheme` manually. Pass +`docs_url=` to `merge_sphinx_config()` and gp-sphinx fills both: + +- `site_url` is normalized to end in `/`. +- `sitemap_url_scheme` is set to `"{link}"` — flat, no language or + version path segment — because git-pull.com sites deploy at the + project root. + +See the [sphinx-gp-sitemap package +page](../../docs/packages/sphinx-gp-sitemap.md) for the integration story +and [`configuration.md`](../../docs/configuration.md#from-docs_url) +for the canonical mapping table. -## Config reference +## Builder support + +| Builder | URL shape | Notes | +| --- | --- | --- | +| `html` | `…/.html` (or `…`) | Honors `html_file_suffix` for `.htm` mirrors | +| `dirhtml` | `…//` | Site index emitted as the bare `site_url`, not `…/index/` | + +Other builders (`text`, `latex`, `singlehtml`, …) are unaffected — they +do not fire `html-page-context`, so no sitemap is written. + +## Config-key reference + +Every key is registered with `rebuild=""` and the indicated default. | Key | Type | Default | Purpose | -|---|---|---|---| -| `site_url` | `str \| None` | `None` | Base URL. Falls back to `html_baseurl` | -| `sitemap_url_scheme` | `str` | `"{lang}{version}{link}"` | Per-URL template | -| `sitemap_locales` | `list \| None` | `[]` (auto-detect) | `hreflang` alternates | -| `sitemap_filename` | `str` | `"sitemap.xml"` | Output filename | -| `sitemap_excludes` | `list[str]` | `[]` | fnmatch patterns to skip | -| `sitemap_show_lastmod` | `bool` | `False` | Include `` from git | -| `sitemap_indent` | `int` | `0` | XML indent width (0 = minified) | +| --- | --- | --- | --- | +| `site_url` | `str \| None` | `None` | Site base URL (auto-derived under gp-sphinx). Falls back to `html_baseurl` | +| `sitemap_url_scheme` | `str` | `"{lang}{version}{link}"` | Per-URL template (auto-derived under gp-sphinx as `"{link}"`) | +| `sitemap_locales` | `list \| None` | `[]` (auto-detect) | Locales to emit as `hreflang` alternates | +| `sitemap_filename` | `str` | `"sitemap.xml"` | Output filename written under the build's `outdir` | +| `sitemap_excludes` | `list[str]` | `[]` | fnmatch patterns matched against the relative URL | +| `sitemap_show_lastmod` | `bool` | `False` | Emit `` dates sourced from git commit timestamps | +| `sitemap_indent` | `int` | `0` | XML indent width; `0` minifies, `>0` pretty-prints | + +The implicit `html_baseurl` config value is also (re-)registered when +no upstream extension has done so — it serves as the resolution +fallback for `site_url`. ## Multi-language sites -When `sitemap_locales` is set (or auto-detected from `locale_dirs`), -each `` gains `` -entries for every locale: +When `sitemap_locales` is set or auto-detected from `locale_dirs`, +each `` gains `` +entries for every locale. Underscores in locale codes are rewritten +to hyphens for IANA compatibility (`pt_BR` → `pt-BR`). ```python sitemap_locales = ["de", "fr", "ja"] ``` Use `sitemap_locales = [None]` to explicitly suppress hreflang -alternates. +alternates — useful when `locale_dirs` is populated for translation +workflows that do not produce hreflang-eligible deploys. ## Excluding pages @@ -67,8 +107,10 @@ sitemap_excludes = [ ] ``` -Patterns match the `sitemap_link` (relative page URL after the builder -applies its suffix) via `fnmatch`. +Patterns match the `sitemap_link` (the relative page URL after the +builder applies its suffix) via `fnmatch`. The patterns run after +suffix application, so `draft/index.html` and `draft/index/` both +match `draft/*` regardless of builder. ## `lastmod` from git @@ -76,35 +118,40 @@ applies its suffix) via `fnmatch`. sitemap_show_lastmod = True ``` -Loads `sphinx-last-updated-by-git` transparently and emits `` -per page based on the file's latest commit timestamp. Silently -disables itself (with a warning) when the extension is not installed. +The first time `config-inited` fires with this flag set, sphinx-gp-sitemap +runs `app.setup_extension("sphinx_last_updated_by_git")` to load +[`sphinx-last-updated-by-git`](https://github.com/mgeier/sphinx-last-updated-by-git) +on demand. Per-page `` values come from each source file's +latest commit timestamp. If the supporting extension is not +installed, sphinx-gp-sitemap warns once and disables `sitemap_show_lastmod` +for the rest of the build — `` is simply omitted. ## Differences from `sphinx-sitemap` -Configuration is **drop-in compatible** with upstream — switching to -`sphinx-gp-sitemap` does not require any conf.py changes. - -Implementation changes behind the scenes: - -- Collected links stored in `env.temp_data["sphinx_gp_sitemap_links"]` as a - plain `list[tuple[str, str | None]]` — no `multiprocessing.Queue`. - Trade-off: `temp_data` is per-process and is not merged across - parallel workers, so sphinx-gp-sitemap declares `parallel_read_safe` only - and is **not** `parallel_write_safe`; sites built with - `sphinx-build -j N` should fall back to a single write process for - this extension to capture every page. -- Builder-kind detection uses `app.builder.name == "dirhtml"` — - no `env.is_directory_builder` monkey-patch. -- `html_baseurl` re-registration uses - `contextlib.suppress(ExtensionError)` rather than a bare - `except BaseException`. -- All `add_config_value` calls use `types=frozenset({...})` uniform. +Configuration is drop-in compatible — every `sitemap_*` key is +registered with the same name, type, and default. Behaviourally the +package is the same except for one explicit trade-off: + +- **Parallel writes are not declared safe.** Collected links live in + `env.temp_data["sphinx_gp_sitemap_links"]`, which Sphinx documents as + per-process state and does not merge across `sphinx-build -j N` + workers. sphinx-gp-sitemap therefore advertises `parallel_read_safe` only. + Sites that need parallel writes should run a separate non-parallel + pass for sitemap generation, or upstream the env-merge work to + this package. + +The other differences are implementation modernizations (no +`multiprocessing.Queue`, public `app.builder.name == "dirhtml"` +detection rather than monkey-patching, `contextlib.suppress(ExtensionError)` +around the optional `html_baseurl` re-registration). These do not +change the configuration surface. ## See also -- [sphinx-gp-opengraph](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-gp-opengraph) — - companion package for OpenGraph / Twitter meta-tag emission +- [sphinx-gp-opengraph](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-gp-opengraph) + — companion package for OpenGraph meta-tag emission - [gp-sphinx](https://github.com/git-pull/gp-sphinx) — the umbrella - docs platform that auto-wires this extension's `site_url` from - `docs_url` + docs platform; auto-derives `site_url` and `sitemap_url_scheme` + from a single `docs_url` argument +- [sphinx-gp-sitemap package page](https://gp-sphinx.git-pull.com/packages/sphinx-gp-sitemap/) + — integration story, event hooks, and the parallel-write trade-off From 7f3d66fb46d3a6685cb15aba5d30174b48a45862 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 06:30:41 -0500 Subject: [PATCH 20/53] gp-sphinx(docs[coordinator]): expand docs_url coverage and surface SEO auto-loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: with the gp-opengraph and gp-sitemap package pages now linking back to the canonical From-docs_url table for auto-derived values, the coordinator surface needed three small follow-throughs: a labelled anchor on that table so external pages can {ref} into it, a docs_url parameter description that names every key it auto- derives (not just the OpenGraph subset), and an explicit "SEO emission for free" callout on the gp-sphinx package page so a reader doesn't have to discover the auto-loading from the per- package pages alone. Also fixes the same-page anchor mistake in the package pages — `[…](#from-docs_url)` resolves on the same page only and would 404 from gp-opengraph.md / gp-sitemap.md. what: - docs/configuration.md: add `(from-docs_url)=` label above the table; expand the docs_url row description to enumerate every auto-derived key - docs/api.md: add a one-paragraph note under the merge_sphinx_config autofunction pointing at the From-docs_url mapping for SEO keys - docs/packages/gp-sphinx.md: enrich "What it injects" SEO bullet with the gp-sitemap keys and {ref} into the canonical table; add a "SEO emission for free" subsection that names DEFAULT_EXTENSIONS membership and {doc}-links the per-package pages - docs/packages/gp-opengraph.md, docs/packages/gp-sitemap.md: rewrite the broken `[…](#from-docs_url)` same-page anchors to proper `{ref}from-docs_url` cross-references --- docs/api.md | 5 +++++ docs/configuration.md | 4 +++- docs/packages/gp-sphinx.md | 13 ++++++++++++- docs/packages/sphinx-gp-opengraph.md | 7 +++---- docs/packages/sphinx-gp-sitemap.md | 2 +- 5 files changed, 24 insertions(+), 7 deletions(-) diff --git a/docs/api.md b/docs/api.md index 14f09ada..be89c381 100644 --- a/docs/api.md +++ b/docs/api.md @@ -10,6 +10,11 @@ For shared defaults and configuration options, see {doc}`configuration`. .. autofunction:: gp_sphinx.config.merge_sphinx_config ``` +When `docs_url` is provided, `merge_sphinx_config()` also auto-derives +the `ogp_*` and `sitemap_*` keys consumed by the SEO extensions +(`sphinx_gp_opengraph`, `sphinx_gp_sitemap`). See {ref}`from-docs_url` for the full +mapping. + ## make_linkcode_resolve ```{eval-rst} diff --git a/docs/configuration.md b/docs/configuration.md index ea733acf..b783695f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -41,7 +41,7 @@ All parameters are keyword-only. | `source_branch` | `str` | `"main"` | Source branch stored in `html_theme_options["source_branch"]` | | `light_logo` | `str \| None` | `None` | Light-mode logo path merged into theme options | | `dark_logo` | `str \| None` | `None` | Dark-mode logo path merged into theme options | -| `docs_url` | `str \| None` | `None` | Canonical docs URL used to derive Open Graph settings | +| `docs_url` | `str \| None` | `None` | Canonical docs URL. When set, auto-derives `ogp_site_url`, `ogp_site_name`, `ogp_image` (for `sphinx_gp_opengraph`) and `site_url`, `sitemap_url_scheme` (for `sphinx_gp_sitemap`) — see {ref}`from-docs_url` | | `intersphinx_mapping` | `Mapping[str, tuple[str, str \| None]] \| None` | `None` | Mapping assigned to `intersphinx_mapping` when provided | | `**overrides` | `Any` | none | Final escape hatch for any Sphinx config key; applied after all defaults and auto-computed values | @@ -55,6 +55,8 @@ All parameters are keyword-only. | `html_theme_options["source_repository"]` | repository URL | | `html_theme_options["footer_icons"][0]["url"]` | repository URL for the GitHub footer icon | +(from-docs_url)= + ### From `docs_url` | Key | Value | diff --git a/docs/packages/gp-sphinx.md b/docs/packages/gp-sphinx.md index d1662eb5..898c872c 100644 --- a/docs/packages/gp-sphinx.md +++ b/docs/packages/gp-sphinx.md @@ -48,12 +48,23 @@ globals().update(conf) ## What it injects - Shared extension defaults, theme defaults, fonts, MyST, napoleon, copybutton, and rediraffe settings. -- Auto-computed values like `issue_url_tpl`, `ogp_site_url`, `ogp_site_name`, and `ogp_image` when repository and docs URLs are provided. +- Auto-computed `issue_url_tpl` and theme source-repository wiring from `source_repository`. +- Auto-computed SEO values when `docs_url` is set: `ogp_site_url`, `ogp_site_name`, `ogp_image` for {doc}`sphinx-gp-opengraph`, plus `site_url` and `sitemap_url_scheme` for {doc}`sphinx-gp-sitemap`. See {ref}`from-docs_url` for the canonical mapping. - A `setup(app)` hook that registers `js/spa-nav.js` and removes `tabs.js` after HTML builds. - Support for appending {py:mod}`sphinx:sphinx.ext.linkcode` automatically when `linkcode_resolve` is supplied in `**overrides`. See {doc}`/configuration` for the complete parameter reference and every shared `DEFAULT_*` constant. +## SEO emission for free + +`sphinx_gp_opengraph` and `sphinx_gp_sitemap` are members of +{py:data}`~gp_sphinx.defaults.DEFAULT_EXTENSIONS`, so every project +that calls `merge_sphinx_config()` loads them automatically. Passing +`docs_url=` is the only step required for default SEO emission — +gp-sphinx fills in the upstream config keys both extensions need. +Per-package details live on the {doc}`sphinx-gp-opengraph` and +{doc}`sphinx-gp-sitemap` pages. + :::{admonition} Live example This site is built with `gp-sphinx`, using the same integration pattern shown above. See diff --git a/docs/packages/sphinx-gp-opengraph.md b/docs/packages/sphinx-gp-opengraph.md index a5516776..19619de9 100644 --- a/docs/packages/sphinx-gp-opengraph.md +++ b/docs/packages/sphinx-gp-opengraph.md @@ -40,10 +40,9 @@ three of the most common config values: | `ogp_image` | `"_static/img/icons/icon-192x192.png"` | The canonical reference for these and the other auto-derived values -lives in [Configuration → From `docs_url`](#from-docs_url). Any value -passed via `**overrides` to `merge_sphinx_config()` wins over the -auto-derived default — auto-computation runs first, overrides apply -last. +lives in {ref}`from-docs_url`. Any value passed via `**overrides` to +`merge_sphinx_config()` wins over the auto-derived default — +auto-computation runs first, overrides apply last. ## How the page-level meta tags are built diff --git a/docs/packages/sphinx-gp-sitemap.md b/docs/packages/sphinx-gp-sitemap.md index 77c9e0a0..5f4eff28 100644 --- a/docs/packages/sphinx-gp-sitemap.md +++ b/docs/packages/sphinx-gp-sitemap.md @@ -46,7 +46,7 @@ project root, with no language or version directory in the URL space. Multilingual or version-pinned hosts can still pass an explicit `sitemap_url_scheme` through `**overrides` — `merge_sphinx_config()` runs auto-derivation first and overrides last. The canonical mapping -lives in [Configuration → From `docs_url`](#from-docs_url). +lives in {ref}`from-docs_url`. ## How `sitemap.xml` is built From da4a14beb6b3dda0241867a8401985778aa81c1a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 08:01:42 -0500 Subject: [PATCH 21/53] fixup! gp-sitemap(feat[extension]): sitemap.xml generator with Sphinx 8.1+ idioms fixup: split long smoke-test string after rename --- scripts/ci/package_tools.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/ci/package_tools.py b/scripts/ci/package_tools.py index a5f74efa..fb1f463a 100644 --- a/scripts/ci/package_tools.py +++ b/scripts/ci/package_tools.py @@ -670,7 +670,11 @@ def smoke_sphinx_gp_sitemap(dist_dir: pathlib.Path, version: str) -> None: ) _run_python( python_path, - ("import sphinx_gp_sitemap; from sphinx_gp_sitemap import setup; assert callable(setup)"), + ( + "import sphinx_gp_sitemap; " + "from sphinx_gp_sitemap import setup; " + "assert callable(setup)" + ), ) From ee74156cae8da5e8b84fa5263b7768377f8041fe Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 09:13:47 -0500 Subject: [PATCH 22/53] sphinx-gp-opengraph(docs[config]) Use sphinx-autodoc-sphinx for config reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: the README and the docs page both carried a hand-written config-key table covering the same 11 ogp_* keys. Hand-written tables drift from the live app.add_config_value() registrations the moment defaults shift, and the duplication doubled the maintenance cost. sphinx-autodoc-sphinx already provides autoconfigvalue-index + autoconfigvalues directives that read the live registrations at build time — the same pattern sphinx-fonts, sphinx-autodoc-pytest-fixtures, and sphinx-autodoc-argparse already use. Move the canonical reference to the docs page; let the README link out. what: - packages/sphinx-gp-opengraph/README.md: drop the "Config-key reference" section (the markdown table); add a one-line pointer near "When you depend on gp-sphinx" forwarding to the auto- generated reference on the docs page; promote the "Twitter cards" subsection to a top-level ## section since it stops being nested - docs/packages/sphinx-gp-opengraph.md: flip the intro pointer (the page now owns the config-value reference, the README owns install + per-page overrides + Twitter cards + the deprecation-warning text); add a new "## Config reference" section with an eval-rst block invoking .. autoconfigvalue-index:: sphinx_gp_opengraph followed by .. autoconfigvalues:: sphinx_gp_opengraph - All 11 ogp_* keys (ogp_site_url, ogp_canonical_url, ogp_description_length, ogp_image, ogp_image_alt, ogp_use_first_image, ogp_type, ogp_site_name, ogp_social_cards, ogp_custom_meta_tags, ogp_enable_meta_description) now render from the live registrations instead of from a hand-typed table --- docs/packages/sphinx-gp-opengraph.md | 14 ++++++++++++-- packages/sphinx-gp-opengraph/README.md | 25 ++++--------------------- 2 files changed, 16 insertions(+), 23 deletions(-) diff --git a/docs/packages/sphinx-gp-opengraph.md b/docs/packages/sphinx-gp-opengraph.md index 19619de9..9fbe852e 100644 --- a/docs/packages/sphinx-gp-opengraph.md +++ b/docs/packages/sphinx-gp-opengraph.md @@ -20,11 +20,11 @@ exposes and emits the same `` tags, with one deliberate omission: the matplotlib-based social-card generator is not bundled. That is why the package has zero non-Sphinx runtime dependencies. -For install, the full config-key reference, per-page overrides, and the +For install, per-page overrides, Twitter-card markup, and the verbatim deprecation-warning text, see the package [README](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-gp-opengraph#readme). This page covers integration with gp-sphinx, the emission pipeline, -and the trade-offs. +the trade-offs, and the auto-generated config-value reference. ## Integration with gp-sphinx @@ -99,6 +99,16 @@ The extension never writes shared state — every emission is self-contained inside the per-page hook — so it is safe under any `sphinx-build -j N` value. +## Config reference + +Generated from `app.add_config_value()` registrations in +[`sphinx_gp_opengraph/__init__.py`](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py). + +```{eval-rst} +.. autoconfigvalue-index:: sphinx_gp_opengraph +.. autoconfigvalues:: sphinx_gp_opengraph +``` + ## Package reference ```{package-reference} sphinx-gp-opengraph diff --git a/packages/sphinx-gp-opengraph/README.md b/packages/sphinx-gp-opengraph/README.md index c7052592..3436e0eb 100644 --- a/packages/sphinx-gp-opengraph/README.md +++ b/packages/sphinx-gp-opengraph/README.md @@ -17,7 +17,9 @@ $ pip install sphinx-gp-opengraph When you depend on gp-sphinx, this extension is already loaded — see [Auto-derived values](#auto-derived-values-when-used-with-gp-sphinx) -below. +below. The full config-key reference is auto-generated on the +[package docs page](https://gp-sphinx.git-pull.com/packages/sphinx-gp-opengraph/) +from the live `app.add_config_value()` registrations. ## Minimum viable conf.py @@ -79,26 +81,7 @@ Body paragraph that becomes og:description. Any other `og:*` field-list entry is forwarded to the page head verbatim, so `og:type`, `og:audio`, etc. work without code changes. -## Config-key reference - -Every key is registered with `rebuild="html"` and the indicated default. -Per-page front-matter wins over these site-wide values. - -| Key | Type | Default | Purpose | -| --- | --- | --- | --- | -| `ogp_site_url` | `str` | `""` | Site base URL; required for absolute `og:url` (auto-derived under gp-sphinx) | -| `ogp_canonical_url` | `str` | `""` | Separate canonical URL; falls back to `ogp_site_url` when empty | -| `ogp_description_length` | `int` | `200` | Truncation cap for `og:description` | -| `ogp_image` | `str \| None` | `None` | Site-default OG image (auto-derived under gp-sphinx) | -| `ogp_image_alt` | `str \| bool \| None` | `None` | Alt text; falls back to `og:site_name`, then `og:title`. `False` suppresses the alt tag | -| `ogp_use_first_image` | `bool` | `False` | Use the first in-page image as `og:image` when no override is set | -| `ogp_type` | `str` | `"website"` | Value of the `og:type` tag | -| `ogp_site_name` | `str \| bool \| None` | `None` (→ `project`) | `False` suppresses the `og:site_name` tag | -| `ogp_social_cards` | `dict \| None` | `None` | Accepted-but-ignored — see [Migration](#migration-from-sphinxext-opengraph) | -| `ogp_custom_meta_tags` | `list[str]` | `()` | Raw `` tags emitted verbatim — use this for Twitter cards | -| `ogp_enable_meta_description` | `bool` | `True` | Emit a matching `` | - -### Twitter cards +## Twitter cards sphinx-gp-opengraph does not register a separate `twitter_*` namespace; crawlers fall back to `og:*` for most fields. Append explicit Twitter From 20dbb7e7436d349cc70aecb6d528162d041159e4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 09:15:25 -0500 Subject: [PATCH 23/53] sphinx-gp-sitemap(docs[config]) Use sphinx-autodoc-sphinx for config reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: same drift problem as sphinx-gp-opengraph — the README and the docs page both carried a hand-written config-key table covering the same 7 sitemap_* keys (plus the html_baseurl fallback note). Move the canonical reference to the docs page via sphinx-autodoc-sphinx's autoconfigvalue-index + autoconfigvalues directives that read the live app.add_config_value() registrations at build time. The html_baseurl re-registration story already lives in the docs page's Trade-offs section, so dropping it from the README does not lose information. what: - packages/sphinx-gp-sitemap/README.md: drop the "Config-key reference" section (the markdown table) and the trailing html_baseurl paragraph; add a one-line pointer near "When you depend on gp-sphinx" forwarding to the auto-generated reference on the docs page - docs/packages/sphinx-gp-sitemap.md: flip the intro pointer (the page now owns the config-value reference, the README owns install + builder support + locale rules + lastmod / migration); add a new "## Config reference" section with an eval-rst block invoking .. autoconfigvalue-index:: sphinx_gp_sitemap followed by .. autoconfigvalues:: sphinx_gp_sitemap - All 7 sitemap_* keys (site_url, sitemap_url_scheme, sitemap_locales, sitemap_filename, sitemap_excludes, sitemap_show_lastmod, sitemap_indent) now render from the live registrations instead of from a hand-typed table --- docs/packages/sphinx-gp-sitemap.md | 16 +++++++++++++--- packages/sphinx-gp-sitemap/README.md | 22 +++------------------- 2 files changed, 16 insertions(+), 22 deletions(-) diff --git a/docs/packages/sphinx-gp-sitemap.md b/docs/packages/sphinx-gp-sitemap.md index 5f4eff28..ef531b63 100644 --- a/docs/packages/sphinx-gp-sitemap.md +++ b/docs/packages/sphinx-gp-sitemap.md @@ -22,11 +22,11 @@ dependency on `sphinx-last-updated-by-git` is downgraded to a soft on-demand load that activates only under `sitemap_show_lastmod = True`. -For install, the full config-key reference, builder support, locale -rules, and the lastmod / migration story, see the package +For install, builder support, locale rules, and the lastmod / +migration story, see the package [README](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-gp-sitemap#readme). This page covers integration with gp-sphinx, the emission pipeline, -and the trade-offs. +the trade-offs, and the auto-generated config-value reference. ## Integration with gp-sphinx @@ -123,6 +123,16 @@ some custom builders skip it. The `setup()` body wraps the against either layout. The bare `except BaseException` upstream uses is replaced by the narrow `ExtensionError` catch. +## Config reference + +Generated from `app.add_config_value()` registrations in +[`sphinx_gp_sitemap/__init__.py`](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py). + +```{eval-rst} +.. autoconfigvalue-index:: sphinx_gp_sitemap +.. autoconfigvalues:: sphinx_gp_sitemap +``` + ## Package reference ```{package-reference} sphinx-gp-sitemap diff --git a/packages/sphinx-gp-sitemap/README.md b/packages/sphinx-gp-sitemap/README.md index f85fe3cb..55b07607 100644 --- a/packages/sphinx-gp-sitemap/README.md +++ b/packages/sphinx-gp-sitemap/README.md @@ -18,7 +18,9 @@ $ pip install sphinx-gp-sitemap When you depend on gp-sphinx, this extension is already loaded — see [Auto-derived values](#auto-derived-values-when-used-with-gp-sphinx) -below. +below. The full config-key reference is auto-generated on the +[package docs page](https://gp-sphinx.git-pull.com/packages/sphinx-gp-sitemap/) +from the live `app.add_config_value()` registrations. ## Minimum viable conf.py @@ -65,24 +67,6 @@ for the canonical mapping table. Other builders (`text`, `latex`, `singlehtml`, …) are unaffected — they do not fire `html-page-context`, so no sitemap is written. -## Config-key reference - -Every key is registered with `rebuild=""` and the indicated default. - -| Key | Type | Default | Purpose | -| --- | --- | --- | --- | -| `site_url` | `str \| None` | `None` | Site base URL (auto-derived under gp-sphinx). Falls back to `html_baseurl` | -| `sitemap_url_scheme` | `str` | `"{lang}{version}{link}"` | Per-URL template (auto-derived under gp-sphinx as `"{link}"`) | -| `sitemap_locales` | `list \| None` | `[]` (auto-detect) | Locales to emit as `hreflang` alternates | -| `sitemap_filename` | `str` | `"sitemap.xml"` | Output filename written under the build's `outdir` | -| `sitemap_excludes` | `list[str]` | `[]` | fnmatch patterns matched against the relative URL | -| `sitemap_show_lastmod` | `bool` | `False` | Emit `` dates sourced from git commit timestamps | -| `sitemap_indent` | `int` | `0` | XML indent width; `0` minifies, `>0` pretty-prints | - -The implicit `html_baseurl` config value is also (re-)registered when -no upstream extension has done so — it serves as the resolution -fallback for `site_url`. - ## Multi-language sites When `sitemap_locales` is set or auto-detected from `locale_dirs`, From 254165aa301e2d57cb559e6f0accb9b33e432e9a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 09:26:44 -0500 Subject: [PATCH 24/53] sphinx-gp-opengraph(feat[config]) Add description= to every config value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: sphinx-autodoc-sphinx renders the description= kwarg passed to app.add_config_value() inside each confval body (see sphinx-autodoc-sphinx _directives.py line 343-344). Without it, the auto-generated config reference on the package docs page shows only the key name and type metadata — readers have to leave the page to learn what each setting actually does. sphinx-fonts already passes description= on every call; align sphinx-gp-opengraph with that convention so its 11 ogp_* keys are self-explanatory in the rendered reference. what: - packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py: add description= to all 11 add_config_value() calls (ogp_site_url, ogp_canonical_url, ogp_description_length, ogp_image, ogp_image_alt, ogp_use_first_image, ogp_type, ogp_site_name, ogp_social_cards, ogp_custom_meta_tags, ogp_enable_meta_description). Each description is one prose sentence covering the user-visible behaviour and any non-obvious fallback or override interaction; the ogp_social_cards description names the accepted-but-ignored shim explicitly so readers see the trade-off without leaving the table --- .../src/sphinx_gp_opengraph/__init__.py | 68 ++++++++++++++++++- 1 file changed, 65 insertions(+), 3 deletions(-) diff --git a/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py index 293408cd..482055ea 100644 --- a/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py +++ b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py @@ -291,38 +291,84 @@ def setup(app: Sphinx) -> ExtensionMetadata: """ # ogp_site_url="" allows relative URLs by default. Not officially # supported by OGP but matches upstream sphinxext-opengraph. - app.add_config_value("ogp_site_url", "", "html", types=frozenset({str})) - app.add_config_value("ogp_canonical_url", "", "html", types=frozenset({str})) + app.add_config_value( + "ogp_site_url", + "", + "html", + types=frozenset({str}), + description=( + "Site base URL joined with each page's relative path to form " + "``og:url``. Required for absolute URLs; auto-derived from " + "``docs_url`` under gp-sphinx." + ), + ) + app.add_config_value( + "ogp_canonical_url", + "", + "html", + types=frozenset({str}), + description=( + "Separate canonical URL used to build ``og:url``; falls back " + "to ``ogp_site_url`` when empty." + ), + ) app.add_config_value( "ogp_description_length", DEFAULT_DESCRIPTION_LENGTH, "html", types=frozenset({int}), + description=( + "Truncation cap (characters) applied to ``og:description`` " + "after extracting the first body paragraph." + ), ) app.add_config_value( "ogp_image", None, "html", types=frozenset({str, types.NoneType}), + description=( + "Site-default OpenGraph image path or absolute URL. " + "Auto-derived from ``docs_url`` under gp-sphinx; per-page " + "``og:image`` front-matter overrides." + ), ) app.add_config_value( "ogp_image_alt", None, "html", types=frozenset({str, bool, types.NoneType}), + description=( + "Alt text for ``ogp_image``. Falls back to ``og:site_name`` " + "then ``og:title``; ``False`` suppresses the alt tag entirely." + ), ) app.add_config_value( "ogp_use_first_image", False, "html", types=frozenset({bool}), + description=( + "When ``True`` and no per-page override is set, use the " + "first in-page image as ``og:image``." + ), + ) + app.add_config_value( + "ogp_type", + "website", + "html", + types=frozenset({str}), + description="Value emitted as the ``og:type`` tag.", ) - app.add_config_value("ogp_type", "website", "html", types=frozenset({str})) app.add_config_value( "ogp_site_name", None, "html", types=frozenset({str, bool, types.NoneType}), + description=( + "Value emitted as ``og:site_name``. Defaults to the Sphinx " + "``project`` name; ``False`` suppresses the tag." + ), ) # Accepted-but-ignored: warned about in _warn_if_social_cards_used. app.add_config_value( @@ -330,18 +376,34 @@ def setup(app: Sphinx) -> ExtensionMetadata: None, "html", types=frozenset({dict, types.NoneType}), + description=( + "Accepted-but-ignored compatibility shim for upstream " + "``sphinxext-opengraph``. Setting any value emits a one-line " + "WARNING at ``config-inited``; provide a static PNG via " + "``ogp_image`` or per-page ``og:image`` instead." + ), ) app.add_config_value( "ogp_custom_meta_tags", (), "html", types=frozenset({list, tuple}), + description=( + "Raw ```` tag strings appended verbatim after the " + "structured ``og:*`` block — the supported escape hatch for " + "Twitter card declarations and image-dimension hints." + ), ) app.add_config_value( "ogp_enable_meta_description", True, "html", types=frozenset({bool}), + description=( + 'When ``True``, emit a ```` ' + "mirroring ``og:description`` unless the page already " + "defines one." + ), ) app.connect("html-page-context", html_page_context) From cfacc568e316d35fc6fe9e726f5f33c06d6f777f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 09:27:50 -0500 Subject: [PATCH 25/53] sphinx-gp-sitemap(feat[config]) Add description= to every config value why: sphinx-autodoc-sphinx renders the description= kwarg passed to app.add_config_value() inside each confval body. Without it, the auto-generated config reference on the package docs page shows only the key name and type metadata. Mirror the convention used by sphinx-fonts and (now) sphinx-gp-opengraph so the 7 sitemap_* config values plus the defensively re-registered html_baseurl all ship with self-explanatory descriptions in the rendered reference. what: - packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py: add description= to all 8 add_config_value() calls (site_url, sitemap_url_scheme, sitemap_locales, sitemap_filename, sitemap_excludes, sitemap_show_lastmod, sitemap_indent, plus the defensive html_baseurl re-registration). Each description is one prose sentence covering the user-visible behaviour, fallback chain, and any auto-derivation that happens under gp-sphinx; the sitemap_url_scheme description names the flat-{link} auto-set explicitly so readers see the override path --- .../src/sphinx_gp_sitemap/__init__.py | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py b/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py index 62f7cf71..5dfd532c 100644 --- a/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py +++ b/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py @@ -84,42 +84,81 @@ def setup(app: Sphinx) -> ExtensionMetadata: default=None, rebuild="", types=frozenset({str, type(None)}), + description=( + "Site base URL prepended to every sitemap entry. Auto-derived " + "from ``docs_url`` (trailing-slash normalized) under " + "gp-sphinx; falls back to ``html_baseurl`` when unset. If " + "both are unset the build is skipped silently at INFO level." + ), ) app.add_config_value( "sitemap_url_scheme", default="{lang}{version}{link}", rebuild="", types=frozenset({str}), + description=( + "Per-URL composition template formatted with ``lang`` " + "(``language/`` or empty), ``version`` (``version/`` or " + "empty), and ``link`` (the page's relative URL). Auto-set " + "to flat ``{link}`` under gp-sphinx; multilingual or " + "version-pinned hosts can pass ``{lang}{version}{link}`` " + "via ``**overrides``." + ), ) app.add_config_value( "sitemap_locales", default=[], rebuild="", types=frozenset({list, type(None)}), + description=( + 'Locales emitted as ```` siblings on every URL. Empty list " + "auto-detects sub-directories of every ``locale_dirs`` entry; " + "``[None]`` explicitly suppresses hreflang alternates. " + "Underscores in locale codes become hyphens for IANA " + "compatibility." + ), ) app.add_config_value( "sitemap_filename", default="sitemap.xml", rebuild="", types=frozenset({str}), + description=("Output filename written under the build's ``outdir``."), ) app.add_config_value( "sitemap_excludes", default=[], rebuild="", types=frozenset({list}), + description=( + "fnmatch patterns matched against each page's relative URL " + "(after the builder applies its suffix). Matched pages are " + "dropped from the sitemap; everything else is included." + ), ) app.add_config_value( "sitemap_show_lastmod", default=False, rebuild="", types=frozenset({bool}), + description=( + "When ``True``, lazy-loads ``sphinx-last-updated-by-git`` and " + "emits a ```` element per page from the source " + "file's latest commit timestamp. If the supporting " + "extension is not installed, gp-sitemap warns once and " + "silently disables the flag." + ), ) app.add_config_value( "sitemap_indent", default=0, rebuild="", types=frozenset({int}), + description=( + "XML indent width in spaces. ``0`` minifies the output; " + "any positive value pretty-prints via ``ElementTree.indent``." + ), ) # html_baseurl is usually registered by Sphinx core already — suppress the # duplicate-registration error without losing the legitimate single- @@ -130,6 +169,11 @@ def setup(app: Sphinx) -> ExtensionMetadata: default=None, rebuild="", types=frozenset({str, type(None)}), + description=( + "Sphinx core's canonical HTML base URL — re-registered " + "defensively here to serve as the ``site_url`` fallback " + "on Sphinx versions that ship without it." + ), ) app.connect("config-inited", _maybe_enable_git_lastmod) From 5f6d251622908edf6dae3fa140f46c90f5545244 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 09:29:45 -0500 Subject: [PATCH 26/53] sphinx-autodoc-fastmcp(feat[config]) Add description= to every config value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: of the 8 fastmcp_* config values registered by setup(), none passed description= — so the auto-generated reference on the package docs page rendered name-and-type metadata only, with no prose explaining what each setting does. Aligns sphinx-autodoc-fastmcp with the convention sphinx-fonts, sphinx-autodoc-argparse, sphinx-autodoc-pytest-fixtures, and (now) the SEO packages already follow. what: - packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py: add description= to all 8 add_config_value() calls (fastmcp_tool_modules, fastmcp_area_map, fastmcp_model_module, fastmcp_model_classes, fastmcp_section_badge_map, fastmcp_section_badge_pages, fastmcp_collector_mode, fastmcp_server_module). Each description names the user-visible behaviour, value shape, and (where relevant) the running-server contract that lets fastmcp_server_module mirror the live tool / prompt / resource surface --- .../src/sphinx_autodoc_fastmcp/__init__.py | 92 +++++++++++++++++-- 1 file changed, 84 insertions(+), 8 deletions(-) diff --git a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py index 08640396..4cf00003 100644 --- a/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py +++ b/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py @@ -77,14 +77,90 @@ def setup(app: Sphinx) -> dict[str, t.Any]: app.setup_extension("sphinx_ux_autodoc_layout") app.setup_extension("sphinx_autodoc_typehints_gp") - app.add_config_value("fastmcp_tool_modules", [], "env") - app.add_config_value("fastmcp_area_map", {}, "env") - app.add_config_value("fastmcp_model_module", "", "env") - app.add_config_value("fastmcp_model_classes", (), "env") - app.add_config_value("fastmcp_section_badge_map", {}, "env") - app.add_config_value("fastmcp_section_badge_pages", (), "env") - app.add_config_value("fastmcp_collector_mode", "register", "env") - app.add_config_value("fastmcp_server_module", "", "env") + app.add_config_value( + "fastmcp_tool_modules", + [], + "env", + description=( + "Dotted module paths whose ``register(server)`` hooks expose " + "FastMCP tools to autodoc. Each module is imported once at " + "``builder-inited`` and its tools are added to the rendered " + "reference." + ), + ) + app.add_config_value( + "fastmcp_area_map", + {}, + "env", + description=( + 'Mapping of tool-module suffix (e.g. ``"session_tools"``) ' + "to the docs area page that owns its tools (e.g. " + '``"sessions"``). Drives cross-reference resolution and ' + "the area badge in card layouts." + ), + ) + app.add_config_value( + "fastmcp_model_module", + "", + "env", + description=( + "Dotted module containing the Pydantic model classes " + "referenced by tool return types. Used to resolve " + "``{fastmcp-model}`` cross-references." + ), + ) + app.add_config_value( + "fastmcp_model_classes", + (), + "env", + description=( + "Iterable of model class names within ``fastmcp_model_module`` " + "to autodoc. Empty default skips model rendering entirely." + ), + ) + app.add_config_value( + "fastmcp_section_badge_map", + {}, + "env", + description=( + 'Mapping of docstring section heading (e.g. ``"Inspect"``) ' + "to the safety badge it should render with (e.g. " + '``"readonly"``, ``"mutating"``, ``"destructive"``). ' + "Drives the inline section pills next to grouped tool lists." + ), + ) + app.add_config_value( + "fastmcp_section_badge_pages", + (), + "env", + description=( + "Iterable of docnames where ``fastmcp_section_badge_map`` " + "should be applied. Pages outside this list render plain " + "section headings." + ), + ) + app.add_config_value( + "fastmcp_collector_mode", + "register", + "env", + description=( + "How tools are gathered from each ``fastmcp_tool_modules`` " + 'entry. ``"register"`` calls the module\'s ``register(server)`` ' + 'hook (the FastMCP convention); ``"introspect"`` walks ' + "module attributes for ``@server.tool``-decorated callables." + ), + ) + app.add_config_value( + "fastmcp_server_module", + "", + "env", + description=( + '``"pkg.module:attribute"`` path to a live ``FastMCP`` ' + "instance. When set, the prompt / resource collector reads " + "``local_provider._components`` directly so docs enumerate " + "the same surface as the running server." + ), + ) _static_dir = str(pathlib.Path(__file__).parent / "_static") From 8977e1a3aeba6adfae05e10ddcbf3239894556c5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 09:30:49 -0500 Subject: [PATCH 27/53] sphinx-ux-autodoc-layout(feat[config]) Add description= to every config value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: of the 4 api_* config values registered by setup(), none passed description= — so when sphinx-autodoc-sphinx auto-generates the config reference for this extension on its package docs page, only key name and type metadata appears. Each api_* setting controls a specific user-facing affordance (master switch, parameter folding, fold threshold, signature annotations); without descriptions readers have to trace into source to learn what each one toggles. what: - packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/__init__.py: add description= to all 4 add_config_value() calls (api_layout_enabled, api_fold_parameters, api_collapsed_threshold, api_signature_show_annotations). Each description states what the setting toggles, the default behaviour, and (where relevant) the interaction between settings — api_collapsed_threshold has no effect when api_fold_parameters=False, and api_signature_show_annotations is most useful when annotations are duplicated in a Parameters section --- .../src/sphinx_ux_autodoc_layout/__init__.py | 44 +++++++++++++++++-- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/__init__.py b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/__init__.py index 24168cf1..2af6403d 100644 --- a/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/__init__.py +++ b/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/__init__.py @@ -108,16 +108,52 @@ def setup(app: Sphinx) -> dict[str, t.Any]: """ # Config values app.add_config_value( - "api_layout_enabled", default=False, rebuild="env", types=(bool,) + "api_layout_enabled", + default=False, + rebuild="env", + types=(bool,), + description=( + "Master switch for the componentized autodoc layout. When " + "``True``, ``desc`` nodes emitted by domain autodocumenters " + "are wrapped in ``api_region`` / ``api_signature`` / " + "``api_fold`` containers; when ``False``, default Sphinx " + "rendering is preserved unchanged." + ), ) app.add_config_value( - "api_fold_parameters", default=True, rebuild="env", types=(bool,) + "api_fold_parameters", + default=True, + rebuild="env", + types=(bool,), + description=( + "When ``True``, parameter lists exceeding " + "``api_collapsed_threshold`` items are wrapped in a " + "click-to-expand ``
`` element. Set to ``False`` " + "to render every parameter inline regardless of count." + ), ) app.add_config_value( - "api_collapsed_threshold", default=10, rebuild="env", types=(int,) + "api_collapsed_threshold", + default=10, + rebuild="env", + types=(int,), + description=( + "Minimum number of parameters before " + "``api_fold_parameters=True`` collapses the list. Has no " + "effect when folding is disabled." + ), ) app.add_config_value( - "api_signature_show_annotations", default=True, rebuild="env", types=(bool,) + "api_signature_show_annotations", + default=True, + rebuild="env", + types=(bool,), + description=( + "When ``True``, signature blocks render type annotations " + "inline next to each parameter. ``False`` strips them — " + "useful when annotations are documented separately in a " + "Parameters section to avoid duplication." + ), ) # Custom nodes with HTML visitors + passthrough for other builders From a15e4ad4910103f57ce6574c975d0ceb571065c8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 09:44:28 -0500 Subject: [PATCH 28/53] sphinx-autodoc-fastmcp(docs[config]) Use sphinx-autodoc-sphinx for config reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: the prior commit added description= to every fastmcp_* add_config_value() call but the descriptions were not yet visible anywhere — the package docs page didn't invoke any sphinx-autodoc-sphinx directive, so the auto-generated reference never rendered. Add the same autoconfigvalue-index + autoconfigvalues block the SEO and sphinx-fonts pages already use, so the descriptions surface in the rendered docs at gp-sphinx.git-pull.com/packages/sphinx-autodoc-fastmcp/. what: - docs/packages/sphinx-autodoc-fastmcp.md: add a "## Config reference" section before "## Package reference" with an eval-rst block invoking .. autoconfigvalue-index:: sphinx_autodoc_fastmcp followed by .. autoconfigvalues:: sphinx_autodoc_fastmcp - All 8 fastmcp_* keys (fastmcp_tool_modules, fastmcp_area_map, fastmcp_model_module, fastmcp_model_classes, fastmcp_section_badge_map, fastmcp_section_badge_pages, fastmcp_collector_mode, fastmcp_server_module) now render with their descriptions, types, defaults, and rebuild scopes from the live registrations --- docs/packages/sphinx-autodoc-fastmcp.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/packages/sphinx-autodoc-fastmcp.md b/docs/packages/sphinx-autodoc-fastmcp.md index 8cd6afdb..08e02e00 100644 --- a/docs/packages/sphinx-autodoc-fastmcp.md +++ b/docs/packages/sphinx-autodoc-fastmcp.md @@ -178,6 +178,16 @@ for a plain inline reference. .. fastmcp-tool-summary:: ``` +## Config reference + +Generated from `app.add_config_value()` registrations in +[`sphinx_autodoc_fastmcp/__init__.py`](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py). + +```{eval-rst} +.. autoconfigvalue-index:: sphinx_autodoc_fastmcp +.. autoconfigvalues:: sphinx_autodoc_fastmcp +``` + ## Package reference ```{package-reference} sphinx-autodoc-fastmcp From 61d12c2bf751ae996b1430445233d0a835706475 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 09:46:10 -0500 Subject: [PATCH 29/53] sphinx-ux-autodoc-layout(docs[config]) Use sphinx-autodoc-sphinx for config reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: same shape as the sphinx-autodoc-fastmcp commit — the prior description= pass made the api_* descriptions live, but the docs page still rendered a hand-written table with one-word "Meaning" cells (e.g. "Folds large field-list sections"). Replace it with the autoconfigvalue directive so the new prose descriptions surface and the table can never drift from the live registrations. what: - docs/packages/sphinx-ux-autodoc-layout.md: under the existing "## Configuration" heading, drop the hand-written 4-row table and replace with an eval-rst block invoking .. autoconfigvalue-index:: sphinx_ux_autodoc_layout followed by .. autoconfigvalues:: sphinx_ux_autodoc_layout - All 4 api_* keys (api_layout_enabled, api_fold_parameters, api_collapsed_threshold, api_signature_show_annotations) now render with their full descriptions, types, defaults, and rebuild scopes from the live registrations --- docs/packages/sphinx-ux-autodoc-layout.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/packages/sphinx-ux-autodoc-layout.md b/docs/packages/sphinx-ux-autodoc-layout.md index aa45859c..f697f134 100644 --- a/docs/packages/sphinx-ux-autodoc-layout.md +++ b/docs/packages/sphinx-ux-autodoc-layout.md @@ -113,12 +113,13 @@ The class above renders with: ## Configuration -| Setting | Default | Meaning | -|---------|---------|---------| -| `api_layout_enabled` | `False` | Enables the transform | -| `api_fold_parameters` | `True` | Folds large field-list sections | -| `api_collapsed_threshold` | `10` | Minimum field count before folding | -| `api_signature_show_annotations` | `True` | Shows `name: type` in expanded folded signatures when type data is available | +Generated from `app.add_config_value()` registrations in +[`sphinx_ux_autodoc_layout/__init__.py`](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-ux-autodoc-layout/src/sphinx_ux_autodoc_layout/__init__.py). + +```{eval-rst} +.. autoconfigvalue-index:: sphinx_ux_autodoc_layout +.. autoconfigvalues:: sphinx_ux_autodoc_layout +``` ## Shared helper surface From 0fb18124cd04cb2b91ee4b877dcf15457824140f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 11:13:10 -0500 Subject: [PATCH 30/53] tests(seo) Mark Sphinx-build tests as integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: tests in tests/ext/sitemap/test_urlset.py and tests/ext/opengraph/test_meta_emission.py invoke fixtures (build_sitemap_site, build_og_site) that call build_shared_sphinx_result(), which constructs a real Sphinx app. CLAUDE.md is explicit: "Any test that constructs a Sphinx app … counts" as integration, and "Always mark with @pytest.mark.integration". Without the marker, these tests are indistinguishable from unit tests in selection / reporting and the hot-path "lightest level wins" rule loses its enforcement target. what: - tests/ext/sitemap/test_urlset.py: add module-level `pytestmark = pytest.mark.integration` so every test in the file inherits the marker - tests/ext/opengraph/test_meta_emission.py: same module-level marker --- tests/ext/opengraph/test_meta_emission.py | 2 ++ tests/ext/sitemap/test_urlset.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/tests/ext/opengraph/test_meta_emission.py b/tests/ext/opengraph/test_meta_emission.py index ad036da8..7392a1c6 100644 --- a/tests/ext/opengraph/test_meta_emission.py +++ b/tests/ext/opengraph/test_meta_emission.py @@ -13,6 +13,8 @@ if t.TYPE_CHECKING: from tests.ext.opengraph.conftest import OgBuildResult +pytestmark = pytest.mark.integration + class MetaCase(t.NamedTuple): """One emission test case.""" diff --git a/tests/ext/sitemap/test_urlset.py b/tests/ext/sitemap/test_urlset.py index 48e47df2..668dc760 100644 --- a/tests/ext/sitemap/test_urlset.py +++ b/tests/ext/sitemap/test_urlset.py @@ -10,6 +10,8 @@ if t.TYPE_CHECKING: from tests.ext.sitemap.conftest import SitemapBuildResult +pytestmark = pytest.mark.integration + _SITEMAP_NS = "{http://www.sitemaps.org/schemas/sitemap/0.9}" _XHTML_NS = "{http://www.w3.org/1999/xhtml}" From 6119d0de477903fffd12a0b89c1b9ecd04f1b302 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 11:15:30 -0500 Subject: [PATCH 31/53] tests(seo) Module-scope the build_sitemap_site / build_og_site fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: both fixtures defaulted to function scope (inherited from the tmp_path fixture they injected), so each parametrized case rebuilt the Sphinx app from scratch. CLAUDE.md is unambiguous: "Always use a module-scoped (or session-scoped) fixture for the build — never function-scoped" and "No function-scoped Sphinx build fixtures — always module- or session-scoped." The content-hash cache inside build_shared_sphinx_result() short-circuits identical scenarios within the same module once the fixture's cache_root is shared, so moving to module scope keeps the cache hits and stops thrashing the fixture for every test case. what: - tests/ext/sitemap/conftest.py: add scope="module" to build_sitemap_site; replace tmp_path: pathlib.Path with tmp_path_factory: pytest.TempPathFactory; compute cache_root = tmp_path_factory.mktemp("sitemap-build") once outside the closure so every _build() call within the module shares it; drop the now-unused derive_sphinx_scenario_cache_root import - tests/ext/opengraph/conftest.py: same shape with cache_root = tmp_path_factory.mktemp("opengraph-build"); drop the derive_sphinx_scenario_cache_root import (and the now-unused pathlib import that ruff auto-fixed) - Test count unchanged (1203 passed); fixture is now reused across all parametrized cases per module rather than rebuilt each time --- tests/ext/opengraph/conftest.py | 8 +++----- tests/ext/sitemap/conftest.py | 7 +++---- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/tests/ext/opengraph/conftest.py b/tests/ext/opengraph/conftest.py index 73ae8dc6..3f0d0648 100644 --- a/tests/ext/opengraph/conftest.py +++ b/tests/ext/opengraph/conftest.py @@ -9,7 +9,6 @@ from __future__ import annotations -import pathlib import re import typing as t @@ -20,7 +19,6 @@ SharedSphinxResult, SphinxScenario, build_shared_sphinx_result, - derive_sphinx_scenario_cache_root, read_output, ) @@ -69,9 +67,9 @@ def _parse_meta(html: str) -> dict[str, str]: return out -@pytest.fixture +@pytest.fixture(scope="module") def build_og_site( - tmp_path: pathlib.Path, + tmp_path_factory: pytest.TempPathFactory, ) -> t.Callable[..., OgBuildResult]: """Return a helper that builds a synthetic OG-enabled Sphinx site. @@ -80,13 +78,13 @@ def build_og_site( returns the completed build result plus a pre-parsed ``meta`` dict keyed on the meta tag's ``property`` / ``name`` attribute. """ + cache_root = tmp_path_factory.mktemp("opengraph-build") def _build( *, conf_overrides: dict[str, t.Any] | None = None, index_markdown: str | None = None, ) -> OgBuildResult: - cache_root = derive_sphinx_scenario_cache_root(tmp_path) conf = _BASE_CONF + _confoverrides_to_conf_py(conf_overrides or {}) index = index_markdown if index_markdown is not None else _BASE_INDEX scenario = SphinxScenario( diff --git a/tests/ext/sitemap/conftest.py b/tests/ext/sitemap/conftest.py index 7caeab6d..e8ebe4b5 100644 --- a/tests/ext/sitemap/conftest.py +++ b/tests/ext/sitemap/conftest.py @@ -18,7 +18,6 @@ SharedSphinxResult, SphinxScenario, build_shared_sphinx_result, - derive_sphinx_scenario_cache_root, ) _BASE_CONF = """\ @@ -50,11 +49,12 @@ def _confoverrides_to_conf_py(overrides: dict[str, t.Any]) -> str: return "\n".join(lines) + "\n" -@pytest.fixture +@pytest.fixture(scope="module") def build_sitemap_site( - tmp_path: pathlib.Path, + tmp_path_factory: pytest.TempPathFactory, ) -> t.Callable[..., SitemapBuildResult]: """Return a helper that builds a synthetic sitemap-enabled Sphinx site.""" + cache_root = tmp_path_factory.mktemp("sitemap-build") def _build( *, @@ -62,7 +62,6 @@ def _build( buildername: str = "html", extra_files: tuple[ScenarioFile, ...] = (), ) -> SitemapBuildResult: - cache_root = derive_sphinx_scenario_cache_root(tmp_path) conf = _BASE_CONF + _confoverrides_to_conf_py(conf_overrides or {}) scenario = SphinxScenario( buildername=buildername, From 66c857678c245c311e2c29f92b90bc27ef81960b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 11:17:09 -0500 Subject: [PATCH 32/53] sphinx-gp-sitemap(fix[locales]) Treat any all-None sequence as the suppress sentinel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: _resolve_locales used `if configured == [None]: return []` to honour the documented `sitemap_locales = [None]` opt-out. Sphinx's types= argument is advisory — when a user wrote `sitemap_locales = (None,)` (tuple) the sentinel check failed, the function returned [None] from list(configured), and downstream _hreflang_formatter(None) crashed with TypeError on `"_" in lang`. Tuple vs list is invisible to most users; the sentinel should accept either spelling. Also strip stray Nones from non-sentinel values rather than passing them through to a guaranteed crash. what: - packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py: _resolve_locales now treats any sequence whose elements are all None as the suppress-hreflang sentinel (`if all(item is None for item in configured): return []`), matching both the documented `[None]` and the easily-mistyped `(None,)`. For non-sentinel sequences, filter out any individual None elements so a partially-malformed config produces a clean hreflang list rather than a crash - Updated the docstring to describe the broadened sentinel contract; the existing README documentation still calls out the list spelling as canonical --- .../src/sphinx_gp_sitemap/__init__.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py b/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py index 5dfd532c..4af62c55 100644 --- a/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py +++ b/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py @@ -374,15 +374,23 @@ def _hreflang_formatter(lang: str) -> str: def _resolve_locales(app: Sphinx) -> list[str]: """Return the list of locale codes to emit as hreflang alternates. - If ``sitemap_locales`` is explicitly set (and not ``[None]``), its - values win. Otherwise, auto-detect by listing sub-directories of - each ``locale_dirs`` entry. + If ``sitemap_locales`` is explicitly set, its values win — except + that any sequence whose elements are all ``None`` is treated as + the documented suppress-hreflang sentinel (``[None]`` in the + README, ``(None,)`` if the user wrote a tuple). Otherwise, + auto-detect by listing sub-directories of each ``locale_dirs`` + entry. """ configured: list[str] | None = app.builder.config.sitemap_locales if configured: - if configured == [None]: + # Sentinel: any sequence of only-None elements suppresses + # alternates. The list-vs-tuple distinction is invisible to + # the user (Sphinx accepts both with only an advisory warning), + # so accept either spelling rather than crashing in + # _hreflang_formatter on a stray None. + if all(item is None for item in configured): return [] - return list(configured) + return [item for item in configured if item is not None] locales: list[str] = [] confdir = pathlib.Path(app.confdir) From 462f090f59660dafeaaf1fc84eed41070db231f4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 11:19:19 -0500 Subject: [PATCH 33/53] sphinx-gp-{opengraph,sitemap}(refactor[logging]) Past-tense event messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: CLAUDE.md's logging standard says "Lowercase, past tense for events: 'config merged', 'extension resolved'. No trailing punctuation. Keep messages short; put details in extra, not the message string." Two messages in the SEO packages embedded user instructions in the message body — "set site_url or html_baseurl in conf.py to enable" and "use a static PNG via ogp_image (site default) or per-page 'og:image' frontmatter" — which read as imperative directives rather than past-tense event records. Trim both to the event itself; the user-facing remediation already lives in the package docs and README. what: - packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py: rewrite the missing-URL info from "skipping sitemap — set site_url or html_baseurl in conf.py to enable" to "sitemap skipped — site_url and html_baseurl both unset" - packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py: rewrite the social-cards warning from "ogp_social_cards ignored — sphinx-gp-opengraph ships no card generator; use a static PNG via ogp_image (site default) or per-page 'og:image' frontmatter" to "ogp_social_cards ignored — sphinx-gp-opengraph ships no card generator". The "ogp_social_cards" substring is preserved so the existing test_deprecation_warning grep still matches - packages/sphinx-gp-opengraph/README.md: update the verbatim warning quote to match the trimmed text; the surrounding prose now points readers at the next section for the static-image workflow rather than embedding the instruction in the warning --- packages/sphinx-gp-opengraph/README.md | 4 ++-- .../sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py | 5 ++--- packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py | 3 +-- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/sphinx-gp-opengraph/README.md b/packages/sphinx-gp-opengraph/README.md index 3436e0eb..9ca82472 100644 --- a/packages/sphinx-gp-opengraph/README.md +++ b/packages/sphinx-gp-opengraph/README.md @@ -105,11 +105,11 @@ with the same name, type, and default — with one behavioural change: the value emits one `WARNING` at `config-inited`: ```text - sphinx-gp-opengraph: ogp_social_cards ignored — sphinx-gp-opengraph ships no card generator; use a static PNG via ogp_image (site default) or per-page 'og:image' frontmatter + sphinx-gp-opengraph: ogp_social_cards ignored — sphinx-gp-opengraph ships no card generator ``` Grep your build log for `ogp_social_cards ignored` to find this - warning. + warning. The replacement workflow lives in the next section. The recommended replacement is one static PNG per page. Drop them under `_static/og/` and point the per-page `og:image` field-list entry at diff --git a/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py index 482055ea..fcd043a0 100644 --- a/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py +++ b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py @@ -264,9 +264,8 @@ def _warn_if_social_cards_used(app: Sphinx, config: Config) -> None: del app # unused; required by Sphinx's config-inited signature if config.ogp_social_cards: logger.warning( - "sphinx-gp-opengraph: ogp_social_cards ignored — sphinx-gp-opengraph ships " - "no card generator; use a static PNG via ogp_image (site " - "default) or per-page 'og:image' frontmatter", + "sphinx-gp-opengraph: ogp_social_cards ignored — " + "sphinx-gp-opengraph ships no card generator", ) diff --git a/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py b/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py index 4af62c55..1b633feb 100644 --- a/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py +++ b/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py @@ -280,8 +280,7 @@ def _write_sitemap(app: Sphinx, exception: BaseException | None) -> None: # should silently skip sitemap emission rather than break builds # that run with ``-W``. logger.info( - "sphinx-gp-sitemap: skipping sitemap — set site_url or html_baseurl " - "in conf.py to enable", + "sphinx-gp-sitemap: sitemap skipped — site_url and html_baseurl both unset", type="sitemap", subtype="configuration", ) From 021d3a0f0da9c3aacdf37a36c0f819e72a4aea74 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 11:20:29 -0500 Subject: [PATCH 34/53] sphinx-gp-sitemap(fix[urls]) Normalize trailing slash on resolved base URL why: _write_sitemap built each as ``site_url + scheme.format(...)``. The scheme defaults to a flat ``{link}`` under gp-sphinx and ``sitemap_link`` itself starts without a leading slash, so when ``site_url`` lacked a trailing slash the concatenation produced URLs like ``https://example.comindex.html``. gp-sphinx normalizes its auto-derived ``site_url`` to end in ``/``, but users who set ``html_baseurl`` directly (and let it fall through as the fallback) bypass that path and shipped malformed sitemaps. what: - packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py: in _write_sitemap, after the resolution chain (``site_url or html_baseurl``), append ``/`` when the resolved value lacks one. The check is unconditional so it also catches any future override path, not just the html_baseurl fallback. Existing gp-sphinx site_url paths already end in ``/``, so the no-op cost is one .endswith call per build --- .../sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py b/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py index 1b633feb..bb5ad04f 100644 --- a/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py +++ b/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py @@ -285,6 +285,13 @@ def _write_sitemap(app: Sphinx, exception: BaseException | None) -> None: subtype="configuration", ) return + # Normalize the resolved base URL so the scheme.format() concatenation + # below never produces malformed joins (e.g. "https://example.comindex.html" + # when html_baseurl is the source and lacks a trailing slash). gp-sphinx + # already normalizes site_url upstream of us, but a user setting + # html_baseurl directly bypasses that path. + if not site_url.endswith("/"): + site_url = site_url + "/" links = t.cast( "list[SitemapLink]", From 607b4fb463c19a7df05155e2c9a1ae48114a4ac7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 12:08:23 -0500 Subject: [PATCH 35/53] docs(_ext[package_reference]): drop surface tables; hand off to autodoc directives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Pages that invoke autoconfigvalue / autodirective-index alongside {package-reference} render the surface twice — once in the homegrown table without descriptions, once in the autodoc block with them. sphinx-autodoc-sphinx and sphinx-autodoc-docutils are the canonical owners; the homegrown directive should keep only the install-adjacent conf snippet and metadata block. what: - package_reference_markdown(): drop config_values / directives / roles / lexers / themes table generation; keep conf snippet, package metadata, and the gp-sphinx coordinator surface pointer (now under "Public surface" so the heading is unambiguous). - Delete the now-orphan theme_options() helper and its configparser import; the sphinx-gp-theme page already hand-documents theme.conf. - Refresh the module docstring to credit autoconfigvalue / autodirective / autorole as the surface owners. - Replace the table-content assertions in tests/test_package_reference.py with conf-snippet checks plus a regression test asserting that surface table headings no longer appear. --- docs/_ext/package_reference.py | 161 +++++--------------------------- tests/test_package_reference.py | 25 +++-- 2 files changed, 38 insertions(+), 148 deletions(-) diff --git a/docs/_ext/package_reference.py b/docs/_ext/package_reference.py index 699a4765..370f707b 100644 --- a/docs/_ext/package_reference.py +++ b/docs/_ext/package_reference.py @@ -2,9 +2,14 @@ Architecture ------------ -This Sphinx extension auto-generates the "Registered Surface" and "Copyable -config snippet" sections that appear at the bottom of every -``docs/packages/.md`` page. It works in three layers: +This Sphinx extension auto-generates the "Copyable config snippet" and +"Package metadata" sections that appear on every ``docs/packages/.md`` +page. Surface documentation (config values, directives, roles) is owned by +the autodoc directives in ``sphinx-autodoc-sphinx`` (``autoconfigvalue``) +and ``sphinx-autodoc-docutils`` (``autodirective-index`` / +``autorole-index``) — invoke them on the page directly. + +It works in three layers: 1. **Workspace discovery** (``workspace_packages()``) — walks ``packages/*/pyproject.toml`` to find every publishable package and reads @@ -14,11 +19,13 @@ module and passes a lightweight ``RecorderApp`` to its ``setup()``. ``RecorderApp.__getattr__`` captures every ``app.add_*`` call so each registered item (config value, directive, role, lexer, theme) flows - into a ``SurfaceDict`` without monkey-patching docutils globals. + into a ``SurfaceDict`` without monkey-patching docutils globals. The + collected surface is consumed by ``_register_extension_objects()`` to + populate the py-domain so cross-references resolve. -3. **Rendering** (``package_reference_markdown()``) — converts the collected - surface into a Markdown fragment (config snippet + tables), which the - ``PackageReferenceDirective`` injects into the page via a raw docutils node. +3. **Rendering** (``package_reference_markdown()``) — emits the copyable + conf snippet and metadata block, which the ``PackageReferenceDirective`` + injects into the page via a raw docutils node. Adding a new package -------------------- @@ -26,12 +33,6 @@ exists with a ``[project]`` table the package is picked up automatically on the next docs build. -Extending the surface extractor --------------------------------- -To capture a new ``app.add_*`` call, add a handler to the mock -``RecorderApp`` class inside ``collect_extension_surface()``. Follow the pattern -of the existing ``add_directive`` / ``add_role`` handlers. - Examples -------- >>> package = workspace_packages()[0] @@ -56,7 +57,6 @@ from __future__ import annotations -import configparser import importlib import inspect import logging @@ -489,33 +489,19 @@ def directive_options_markdown(directive_cls: object) -> str: return "\n".join(lines) -def theme_options(package_dir: pathlib.Path) -> list[str]: - """Return theme option names declared in a package ``theme.conf`` file. - - Examples - -------- - >>> "light_logo" in theme_options(workspace_root() / "packages" / "sphinx-gp-theme") - True - """ - theme_conf = package_dir / "src" / "sphinx_gp_theme" / "theme" / "theme.conf" - if not theme_conf.exists(): - return [] - parser = configparser.ConfigParser() - parser.read(theme_conf) - if "options" not in parser: - return [] - return sorted(parser["options"].keys()) - - def package_reference_markdown(package_name: str) -> str: - """Render the generated Markdown fragment for a workspace package page. + """Render the copyable conf snippet and metadata block for a package page. + + Surface documentation (config values, directives, roles, lexers, themes) + is owned by the autodoc directives in ``sphinx-autodoc-sphinx`` and + ``sphinx-autodoc-docutils`` — invoke them directly on the page. Returns an empty string and logs a warning when ``package_name`` is not found among the workspace packages. Examples -------- - >>> "Registered Surface" in package_reference_markdown("sphinx-fonts") + >>> "Copyable config snippet" in package_reference_markdown("sphinx-fonts") True >>> "pypi.org/project/sphinx-fonts" in package_reference_markdown("sphinx-fonts") True @@ -529,7 +515,6 @@ def package_reference_markdown(package_name: str) -> str: if package is None: logger.warning("package-reference: unknown package %r", package_name) return "" - package_dir = pathlib.Path(package["package_dir"]) module_name = package["module_name"] extension_blocks = [ collect_extension_surface(name) for name in extension_modules(module_name) @@ -568,117 +553,13 @@ def package_reference_markdown(package_name: str) -> str: if package_name == "gp-sphinx": lines.extend( [ - "## Registered Surface", + "## Public surface", "", "This package is a coordinator rather than a Sphinx extension module.", "Its public runtime surface is documented in {doc}`/configuration` and {doc}`/api`.", "", ], ) - return "\n".join(lines) - - lines.extend(["## Registered Surface", ""]) - - for block in extension_blocks: - lines.extend([f"### {block['module']}", ""]) - config_rows = block["config_values"] - if config_rows: - lines.extend( - [ - "#### Config values", - "", - "| Name | Default | Rebuild | Types |", - "| --- | --- | --- | --- |", - ], - ) - for row in config_rows: - lines.append( - f"| `{row['name']}` | {row['default']} | {row['rebuild']} | {row['types']} |", - ) - lines.append("") - - directive_rows = block["directives"] - if directive_rows: - lines.extend( - [ - "#### Directives", - "", - "| Name | Kind | Callable | Summary |", - "| --- | --- | --- | --- |", - ], - ) - for row in directive_rows: - lines.append( - f"| `{row['name']}` | {row['kind']} | {row['callable']} | {row['summary']} |", - ) - lines.append("") - for row in directive_rows: - if row["options"]: - lines.extend( - [ - f"##### {row['name']} options", - row["options"], - "", - ], - ) - - role_rows = block["roles"] - if role_rows: - lines.extend( - [ - "#### Roles", - "", - "| Name | Kind | Callable | Summary |", - "| --- | --- | --- | --- |", - ], - ) - for row in role_rows: - lines.append( - f"| `{row['name']}` | {row['kind']} | {row['callable']} | {row['summary']} |", - ) - lines.append("") - - lexer_rows = block["lexers"] - if lexer_rows: - lines.extend( - [ - "#### Lexers", - "", - "| Name | Callable |", - "| --- | --- |", - ], - ) - for row in lexer_rows: - lines.append(f"| `{row['name']}` | {row['callable']} |") - lines.append("") - - theme_rows = block["themes"] - if theme_rows: - lines.extend( - [ - "#### Theme registration", - "", - "| Theme | Path |", - "| --- | --- |", - ], - ) - for row in theme_rows: - lines.append(f"| `{row['name']}` | {row['path']} |") - lines.append("") - - if module_name == "sphinx_gp_theme": - options = theme_options(package_dir) - lines.extend( - [ - "### Theme options (theme.conf)", - "", - "| Option |", - "| --- |", - ], - ) - for option in options: - lines.append(f"| `{option}` |") - lines.append("") return "\n".join(lines) diff --git a/tests/test_package_reference.py b/tests/test_package_reference.py index 4b221e15..eac68dc6 100644 --- a/tests/test_package_reference.py +++ b/tests/test_package_reference.py @@ -48,18 +48,18 @@ def test_collect_extension_surface_for_sphinx_fonts() -> None: } -def test_package_reference_markdown_for_argparse_includes_roles() -> None: - """Generated markdown includes the exemplar role registrations.""" +def test_package_reference_markdown_emits_conf_snippet_for_argparse() -> None: + """Conf snippet wires the package's importable module name.""" markdown = package_reference.package_reference_markdown("sphinx-autodoc-argparse") - assert "cli-option" in markdown - assert "argparse_examples_section_title" in markdown + assert '"sphinx_autodoc_argparse"' in markdown + assert "## Copyable config snippet" in markdown -def test_package_reference_markdown_for_docutils_includes_directives() -> None: - """Generated markdown includes registered docutils autodoc directives.""" +def test_package_reference_markdown_emits_conf_snippet_for_docutils() -> None: + """Conf snippet wires the package's importable module name.""" markdown = package_reference.package_reference_markdown("sphinx-autodoc-docutils") - assert "autodirective" in markdown - assert "autorole-index" in markdown + assert '"sphinx_autodoc_docutils"' in markdown + assert "## Copyable config snippet" in markdown def test_package_reference_markdown_uses_plain_config_heading() -> None: @@ -68,6 +68,15 @@ def test_package_reference_markdown_uses_plain_config_heading() -> None: assert "## Copyable config snippet" in markdown +def test_package_reference_markdown_omits_surface_tables() -> None: + """Surface documentation is owned by autoconfigvalue / autodirective directives.""" + markdown = package_reference.package_reference_markdown("sphinx-autodoc-fastmcp") + assert "Registered Surface" not in markdown + assert "#### Config values" not in markdown + assert "#### Directives" not in markdown + assert "#### Roles" not in markdown + + def test_docs_package_pages_exist_for_every_workspace_package() -> None: """Each publishable package has a matching docs page.""" page_names = { From f19e46e4d8bf9067ac497f86382f54e2ac40decd Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 12:09:43 -0500 Subject: [PATCH 36/53] sphinx-autodoc-fastmcp(docs[surface]): autodoc directives and roles via sphinx-autodoc-docutils MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The page documents 7 directives and 8 roles by example only — no canonical reference of what's registered. With sphinx-autodoc-docutils already in the docs build, autodirective-index and autorole-index can generate the canonical reference straight from app.add_directive() / app.add_role() calls instead of asking readers to scan setup() in the package source. what: - Add a "Directive and role reference" section before "Package reference" that invokes autodirective-index and autorole-index over sphinx_autodoc_fastmcp, mirroring the autoconfigvalue-index + autoconfigvalues pattern used for config values. --- docs/packages/sphinx-autodoc-fastmcp.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/packages/sphinx-autodoc-fastmcp.md b/docs/packages/sphinx-autodoc-fastmcp.md index 08e02e00..d52890e9 100644 --- a/docs/packages/sphinx-autodoc-fastmcp.md +++ b/docs/packages/sphinx-autodoc-fastmcp.md @@ -188,6 +188,18 @@ Generated from `app.add_config_value()` registrations in .. autoconfigvalues:: sphinx_autodoc_fastmcp ``` +## Directive and role reference + +Generated from `app.add_directive()` and `app.add_role()` registrations in +[`sphinx_autodoc_fastmcp/__init__.py`](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py) +via `sphinx-autodoc-docutils`. + +```{eval-rst} +.. autodirective-index:: sphinx_autodoc_fastmcp + +.. autorole-index:: sphinx_autodoc_fastmcp +``` + ## Package reference ```{package-reference} sphinx-autodoc-fastmcp From 8b5b36115b509c9d759a00b4001f067c5cf03e12 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 12:29:15 -0500 Subject: [PATCH 37/53] sphinx-autodoc-docutils(feat[discovery]): register-aware directive and role discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: AutoDirectiveIndex / AutoDirectives (and the role equivalents) introspected the module passed to them for Directive subclasses / *_role callables. That breaks for any extension whose classes live in a submodule (sphinx_autodoc_fastmcp._directives, sphinx_autodoc_sphinx._directives) — passing the package name to ``autodirective-index :: `` returns nothing. It also derives the displayed directive name from the class name, which produces 'autoconfigvalueindex' for AutoconfigvalueIndexDirective and 'fastmcptool' for FastMCPToolDirective. Reading the names a package actually registers via its setup() is the source of truth. what: - Add _SetupRecorder / _replay_setup that captures every app.add_* call when a module's setup() runs against a stand-in. __getattr__ swallows everything (add_node, connect, setup_extension, etc.) so unrelated registrations don't crash the recorder. - Add _registered_directives / _registered_roles helpers that prefer the recorder result and fall back to the existing introspection helpers when a module has no setup(). - AutoDirectiveIndex, AutoDirectives, AutoRoleIndex, AutoRoles all route through the new helpers. Each now accepts either an extension package or a directive- / role-defining module. The rendered Python path follows directive_cls.__module__ / role_fn.__module__ so it matches what 'autodirective :: pkg._directives.SomeDirective' would emit. - Add coverage in tests/ext/autodoc_docutils/test_directives.py for both the recorder path (fastmcp) and the introspection fallback (sphinx_autodoc_docutils._directives, sphinx_autodoc_argparse.roles), plus rich-block emission per pair. --- .../sphinx_autodoc_docutils/_directives.py | 188 ++++++++++++++++-- tests/ext/autodoc_docutils/test_directives.py | 90 ++++++++- 2 files changed, 262 insertions(+), 16 deletions(-) diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py index 2248a53f..c873e12c 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py @@ -65,6 +65,132 @@ def _module_members( ] +class _SetupRecorder: + """Record ``app.add_*`` calls made during a Sphinx extension's ``setup()``. + + Used by :func:`_registered_directives` and :func:`_registered_roles` to + discover the names a package registers (e.g., ``fastmcp-tool``) instead + of guessing them from class names. + + Examples + -------- + >>> recorder = _SetupRecorder() + >>> recorder.add_directive("foo-bar", object) + >>> recorder.add_role("baz-quux", lambda *a, **k: None) + >>> [name for name, _, _ in recorder.calls] + ['add_directive', 'add_role'] + """ + + def __init__(self) -> None: + self.calls: list[tuple[str, tuple[object, ...], dict[str, object]]] = [] + + def __getattr__(self, attr: str) -> t.Callable[..., None]: + def _recorder(*args: object, **kwargs: object) -> None: + self.calls.append((attr, args, kwargs)) + + return _recorder + + +def _replay_setup(module_name: str) -> _SetupRecorder | None: + """Run a module's ``setup()`` against a recorder; return None on failure. + + Examples + -------- + >>> recorder = _replay_setup("sphinx_autodoc_docutils") + >>> recorder is not None + True + >>> any(name == "add_directive" for name, _, _ in recorder.calls) + True + """ + try: + module = importlib.import_module(module_name) + except ImportError: + return None + setup_fn = getattr(module, "setup", None) + if not callable(setup_fn): + return None + recorder = _SetupRecorder() + try: + setup_fn(recorder) + except Exception: # noqa: BLE001 - extension setup errors are expected here + return None + return recorder + + +def _registered_directives(module_name: str) -> list[tuple[str, type[Directive]]]: + """Return ``(registered_name, cls)`` pairs from a package's ``setup()``. + + Falls back to module introspection when the module has no ``setup()``, + so passing a directive-defining submodule (``pkg._directives``) keeps + working. + + Examples + -------- + >>> pairs = _registered_directives("sphinx_autodoc_docutils") + >>> ("autodirective-index", AutoDirectiveIndex) in pairs + True + """ + recorder = _replay_setup(module_name) + if recorder is not None: + pairs: list[tuple[str, type[Directive]]] = [] + for call_name, args, _kwargs in recorder.calls: + if call_name == "add_directive" and len(args) >= 2: + name, cls = args[0], args[1] + if ( + isinstance(name, str) + and inspect.isclass(cls) + and issubclass(cls, Directive) + ): + pairs.append((name, cls)) + elif call_name == "add_directive_to_domain" and len(args) >= 3: + name, cls = args[1], args[2] + if ( + isinstance(name, str) + and inspect.isclass(cls) + and issubclass(cls, Directive) + ): + pairs.append((name, cls)) + if pairs: + return pairs + return [ + (_registered_name(name), cls) for name, cls in _directive_classes(module_name) + ] + + +def _registered_roles(module_name: str) -> list[tuple[str, object]]: + """Return ``(registered_name, role_fn)`` pairs from a package's ``setup()``. + + Falls back to module introspection when no ``setup()`` is found, or when + the package's ``setup()`` registers nothing via ``app.add_role`` (e.g., + ``sphinx_autodoc_argparse`` exposes role registration through + ``register_roles(app)`` rather than wiring it into ``setup()`` itself). + + Examples + -------- + >>> pairs = _registered_roles("sphinx_autodoc_fastmcp") + >>> any(name == "tool" for name, _ in pairs) + True + >>> pairs = _registered_roles("sphinx_autodoc_argparse.roles") + >>> any(name == "cli-option" for name, _ in pairs) + True + """ + recorder = _replay_setup(module_name) + if recorder is not None: + pairs: list[tuple[str, object]] = [] + for call_name, args, _kwargs in recorder.calls: + if call_name == "add_role" and len(args) >= 2: + name, role_fn = args[0], args[1] + if isinstance(name, str) and callable(role_fn): + pairs.append((name, role_fn)) + elif call_name == "add_role_to_domain" and len(args) >= 3: + name, role_fn = args[1], args[2] + if isinstance(name, str) and callable(role_fn): + pairs.append((name, role_fn)) + if pairs: + return pairs + return [(_registered_name(name), fn) for name, fn in _role_callables(module_name)] + + def _directive_classes(module_name: str) -> list[tuple[str, type[Directive]]]: """Return public docutils directive classes in a module. @@ -443,7 +569,13 @@ def run(self) -> list[nodes.Node]: class AutoDirectives(SphinxDirective): - """Render documentation for every directive class in a module.""" + """Render documentation for every directive a package registers. + + Accepts either an extension package (whose ``setup()`` runs against a + recorder so each ``app.add_directive(name, cls)`` call surfaces by its + real registered name) or a directive-defining module (introspected for + ``Directive`` subclasses, with names derived from class names). + """ required_arguments = 1 has_content = False @@ -453,14 +585,14 @@ def run(self) -> list[nodes.Node]: module_name = self.arguments[0] no_index = "no-index" in self.options results: list[nodes.Node] = [] - for name, directive_cls in _directive_classes(module_name): - path = f"{module_name}.{name}" + for registered_name, directive_cls in _registered_directives(module_name): + path = f"{directive_cls.__module__}.{directive_cls.__name__}" rendered = _render_markup_nodes( self, _directive_markup( path, directive_cls, - directive_name=_registered_name(name), + directive_name=registered_name, no_index=no_index, ), ) @@ -470,7 +602,13 @@ def run(self) -> list[nodes.Node]: class AutoDirectiveIndex(SphinxDirective): - """Generate a summary index for all directives in a module.""" + """Generate a summary index for all directives a package registers. + + Accepts either an extension package (whose ``setup()`` runs against a + recorder so each ``app.add_directive(name, cls)`` call surfaces by its + real registered name) or a directive-defining module (introspected for + ``Directive`` subclasses, with names derived from class names). + """ required_arguments = 1 has_content = False @@ -478,8 +616,12 @@ class AutoDirectiveIndex(SphinxDirective): def run(self) -> list[nodes.Node]: module_name = self.arguments[0] rows = [ - (_registered_name(name), f"{module_name}.{name}", _summary(directive_cls)) - for name, directive_cls in _directive_classes(module_name) + ( + registered_name, + f"{directive_cls.__module__}.{directive_cls.__name__}", + _summary(directive_cls), + ) + for registered_name, directive_cls in _registered_directives(module_name) ] markup = _index_markup("Directive Index", rows) if not markup: @@ -517,7 +659,13 @@ def run(self) -> list[nodes.Node]: class AutoRoles(SphinxDirective): - """Render documentation for every role callable in a module.""" + """Render documentation for every role a package registers. + + Accepts either an extension package (whose ``setup()`` runs against a + recorder so each ``app.add_role(name, fn)`` call surfaces by its real + registered name) or a role-defining module (introspected for ``*_role`` + callables, with names derived from function names). + """ required_arguments = 1 has_content = False @@ -527,13 +675,15 @@ def run(self) -> list[nodes.Node]: module_name = self.arguments[0] no_index = "no-index" in self.options results: list[nodes.Node] = [] - for name, role_fn in _role_callables(module_name): - path = f"{module_name}.{name}" + for registered_name, role_fn in _registered_roles(module_name): + role_module = getattr(role_fn, "__module__", module_name) + role_attr = getattr(role_fn, "__name__", registered_name) + path = f"{role_module}.{role_attr}" rendered = _render_markup_nodes( self, _role_markup( path, - _registered_name(name), + registered_name, role_fn, no_index=no_index, ), @@ -544,7 +694,13 @@ def run(self) -> list[nodes.Node]: class AutoRoleIndex(SphinxDirective): - """Generate a summary index for all roles in a module.""" + """Generate a summary index for all roles a package registers. + + Accepts either an extension package (whose ``setup()`` runs against a + recorder so each ``app.add_role(name, fn)`` call surfaces by its real + registered name) or a role-defining module (introspected for ``*_role`` + callables, with names derived from function names). + """ required_arguments = 1 has_content = False @@ -553,11 +709,13 @@ def run(self) -> list[nodes.Node]: module_name = self.arguments[0] rows = [ ( - _registered_name(name), - f"{module_name}.{name}", + registered_name, + f"{role_fn.__module__}.{role_fn.__name__}" + if hasattr(role_fn, "__module__") and hasattr(role_fn, "__name__") + else module_name, _summary(role_fn), ) - for name, role_fn in _role_callables(module_name) + for registered_name, role_fn in _registered_roles(module_name) ] markup = _index_markup("Role Index", rows) if not markup: diff --git a/tests/ext/autodoc_docutils/test_directives.py b/tests/ext/autodoc_docutils/test_directives.py index 3f82226c..b31a6eb0 100644 --- a/tests/ext/autodoc_docutils/test_directives.py +++ b/tests/ext/autodoc_docutils/test_directives.py @@ -6,10 +6,16 @@ from sphinx_autodoc_docutils._directives import ( _directive_classes, _directive_markup, + _registered_directives, + _registered_roles, + _replay_setup, _role_callables, _role_markup, ) +_RICH_DIRECTIVE_BLOCK_HEADER = ".. rst:directive::" +_RICH_ROLE_BLOCK_HEADER = ".. rst:role::" + def test_extension_setup() -> None: """The extension setup function is importable.""" @@ -43,7 +49,7 @@ def test_directive_markup_contains_path_and_summary() -> None: directive_name="autodirective-index", ) assert ".. rst:directive:: autodirective-index" in markup - assert "Generate a summary index for all directives in a module." in markup + assert "Generate a summary index for all directives a package registers." in markup def test_directive_classes_empty_for_module_with_no_directives() -> None: @@ -68,3 +74,85 @@ def test_role_markup_contains_role_name_and_path() -> None: ) assert "cli-option" in markup assert "Role for CLI options like --foo or -h." in markup + + +def test_replay_setup_records_add_directive_calls() -> None: + """Replaying a package's setup() captures every app.add_directive call.""" + recorder = _replay_setup("sphinx_autodoc_fastmcp") + assert recorder is not None + directive_names = [ + args[0] for name, args, _ in recorder.calls if name == "add_directive" + ] + assert "fastmcp-tool" in directive_names + assert "fastmcp-resource-template" in directive_names + + +def test_replay_setup_returns_none_for_module_without_setup() -> None: + """A module with no setup() callable yields None instead of raising.""" + assert _replay_setup("sphinx_autodoc_docutils._directives") is None + + +def test_replay_setup_returns_none_for_unimportable_module() -> None: + """An ImportError yields None instead of bubbling out.""" + assert _replay_setup("_does_not_exist_module_") is None + + +def test_registered_directives_uses_real_registration_names_for_packages() -> None: + """Package input returns the kebab-case names passed to add_directive().""" + pairs = dict(_registered_directives("sphinx_autodoc_fastmcp")) + assert "fastmcp-tool-input" in pairs + assert "fastmcp-resource-template" in pairs + + +def test_registered_directives_falls_back_to_module_introspection() -> None: + """A module with no setup() is introspected for Directive subclasses.""" + pairs = dict(_registered_directives("sphinx_autodoc_docutils._directives")) + # Class-name fallback maps via _registered_name's explicit table. + assert "autodirective-index" in pairs + + +def test_registered_roles_uses_real_registration_names_for_packages() -> None: + """Package input returns the names passed to add_role().""" + pairs = dict(_registered_roles("sphinx_autodoc_fastmcp")) + assert "tool" in pairs + assert "toolicon" in pairs + assert "badge" in pairs + + +def test_registered_roles_falls_back_to_module_introspection() -> None: + """A role-defining module without setup() is introspected for *_role callables.""" + pairs = dict(_registered_roles("sphinx_autodoc_argparse.roles")) + assert "cli-option" in pairs + + +def test_directive_markup_per_pair_emits_rich_block_for_each_registered_directive() -> ( + None +): + """Iterating ``_registered_directives`` produces a rich rst:directive block per item. + + Mirrors what AutoDirectives.run() does for the package case — each + pair flows through ``_directive_markup`` and yields the descriptor + block (signature + role badge + facts), not just an index row. + """ + pairs = _registered_directives("sphinx_autodoc_fastmcp") + assert pairs, "expected fastmcp to register at least one directive" + for registered_name, directive_cls in pairs: + path = f"{directive_cls.__module__}.{directive_cls.__name__}" + markup = _directive_markup(path, directive_cls, directive_name=registered_name) + assert f"{_RICH_DIRECTIVE_BLOCK_HEADER} {registered_name}" in markup, ( + f"missing rich block for {registered_name}" + ) + + +def test_role_markup_per_pair_emits_rich_block_for_each_registered_role() -> None: + """Iterating ``_registered_roles`` produces a rich rst:role block per item.""" + pairs = _registered_roles("sphinx_autodoc_fastmcp") + assert pairs, "expected fastmcp to register at least one role" + for registered_name, role_fn in pairs: + role_module = getattr(role_fn, "__module__", "sphinx_autodoc_fastmcp") + role_attr = getattr(role_fn, "__name__", registered_name) + path = f"{role_module}.{role_attr}" + markup = _role_markup(path, registered_name, role_fn) + assert f"{_RICH_ROLE_BLOCK_HEADER} {registered_name}" in markup, ( + f"missing rich block for {registered_name}" + ) From e0bbf64dac264b3efcffd48c8034bfdbe073be98 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 12:29:28 -0500 Subject: [PATCH 38/53] sphinx-autodoc-sphinx(docs[surface]): autodoc directives via sphinx-autodoc-docutils why: The extension itself registers four documentation directives (autoconfigvalue, autoconfigvalues, autoconfigvalue-index, autoconfigvalue-page). The page documented them only by example. With the register-aware autodirective-index landing in sphinx-autodoc-docutils, the canonical reference can render directly from the live setup() calls. what: - Replace the closing "The extension itself registers documentation directives ..." paragraph with a "Directive reference" section that invokes ``.. autodirective-index:: sphinx_autodoc_sphinx`` before the existing "Package reference" anchor. --- docs/packages/sphinx-autodoc-sphinx.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/packages/sphinx-autodoc-sphinx.md b/docs/packages/sphinx-autodoc-sphinx.md index 393fcc0b..06e74d19 100644 --- a/docs/packages/sphinx-autodoc-sphinx.md +++ b/docs/packages/sphinx-autodoc-sphinx.md @@ -87,9 +87,19 @@ Renders all config values from a module at once: :no-index: ``` -The extension itself registers documentation directives rather than new roles -or config values. The generated package reference below lists its registered -surface from the live `setup()` calls. +## Directive reference + +Generated from `app.add_directive()` registrations in +[`sphinx_autodoc_sphinx/__init__.py`](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py) +via `sphinx-autodoc-docutils` — a meta-loop where the package that +documents config values uses its sibling package to document its own +directives. + +```{eval-rst} +.. autodirective-index:: sphinx_autodoc_sphinx +``` + +## Package reference ```{package-reference} sphinx-autodoc-sphinx ``` From a729fb4809a8a5d55d2f019b7f064115879c1f7d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 12:30:46 -0500 Subject: [PATCH 39/53] docs(packages[surface]): render rich descriptor blocks beside directive index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The autodirective-index / autorole-index calls landed on sphinx-autodoc-fastmcp and sphinx-autodoc-sphinx pages emit only the 3-column summary table — a TOC, not a reference. The descriptor blocks (signature pill, type badge, Python path field-list, required/optional arguments, has-content flag, options sub-block) live behind the autodirectives / autoroles directives that the prior register-aware refactor unlocked. Wire them up so each page carries both the scannable index and the per-item reference. what: - sphinx-autodoc-fastmcp page: append ``.. autodirectives:: sphinx_autodoc_fastmcp`` and ``.. autoroles:: sphinx_autodoc_fastmcp`` to the existing eval-rst block under "Directive and role reference"; refresh the lead-in paragraph. - sphinx-autodoc-sphinx page: append ``.. autodirectives:: sphinx_autodoc_sphinx`` to the "Directive reference" eval-rst block; drop the now-redundant single-class ``.. autodirective:: AutoconfigvalueDirective`` demo from the Live demos section since the descriptor blocks below render the same surface for every directive. --- docs/packages/sphinx-autodoc-fastmcp.md | 8 +++++++- docs/packages/sphinx-autodoc-sphinx.md | 12 ++++-------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/docs/packages/sphinx-autodoc-fastmcp.md b/docs/packages/sphinx-autodoc-fastmcp.md index d52890e9..5cedc3c9 100644 --- a/docs/packages/sphinx-autodoc-fastmcp.md +++ b/docs/packages/sphinx-autodoc-fastmcp.md @@ -192,12 +192,18 @@ Generated from `app.add_config_value()` registrations in Generated from `app.add_directive()` and `app.add_role()` registrations in [`sphinx_autodoc_fastmcp/__init__.py`](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-autodoc-fastmcp/src/sphinx_autodoc_fastmcp/__init__.py) -via `sphinx-autodoc-docutils`. +via `sphinx-autodoc-docutils`. The summary tables index every entry; +the descriptor blocks below carry the per-item signature, badges, and +options. ```{eval-rst} .. autodirective-index:: sphinx_autodoc_fastmcp .. autorole-index:: sphinx_autodoc_fastmcp + +.. autodirectives:: sphinx_autodoc_fastmcp + +.. autoroles:: sphinx_autodoc_fastmcp ``` ## Package reference diff --git a/docs/packages/sphinx-autodoc-sphinx.md b/docs/packages/sphinx-autodoc-sphinx.md index 06e74d19..14079589 100644 --- a/docs/packages/sphinx-autodoc-sphinx.md +++ b/docs/packages/sphinx-autodoc-sphinx.md @@ -80,23 +80,19 @@ Renders all config values from a module at once: .. autoconfigvalues:: sphinx_config_demo ``` -### Document the extension's own directive helper - -```{eval-rst} -.. autodirective:: sphinx_autodoc_sphinx._directives.AutoconfigvalueDirective - :no-index: -``` - ## Directive reference Generated from `app.add_directive()` registrations in [`sphinx_autodoc_sphinx/__init__.py`](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-autodoc-sphinx/src/sphinx_autodoc_sphinx/__init__.py) via `sphinx-autodoc-docutils` — a meta-loop where the package that documents config values uses its sibling package to document its own -directives. +directives. The summary table indexes every directive; the descriptor +blocks below carry the per-item signature, badge, and options. ```{eval-rst} .. autodirective-index:: sphinx_autodoc_sphinx + +.. autodirectives:: sphinx_autodoc_sphinx ``` ## Package reference From 7f2cfa4f5684036bb49781197b3bdaf7f87690c5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 13:28:50 -0500 Subject: [PATCH 40/53] sphinx-autodoc-docutils(fix[discovery]): log a DEBUG breadcrumb when setup() replay fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: _replay_setup catches all exceptions from a package's setup() and silently degrades to _directive_classes / _role_callables introspection. That fallback routes through _registered_name's class-name mangler, which produces incorrect names like 'autoconfigvalueindex' for AutoconfigvalueIndexDirective — exactly the bug commit 8b5b361 introduced register-aware discovery to fix. A future setup() regression (broken import, attribute typo) would silently revert the rendered docs to the broken names. CLAUDE.md calls for DEBUG-level logging on internal-mechanics events; emit one so the failure is recoverable from the build log. what: - Add module logger and emit logger.debug(..., exc_info=True) on the except branch of _replay_setup before returning None. The message names the module that failed and reports that introspection fallback is active. - Add a regression test that injects a fake module with a raising setup() and asserts a DEBUG-level "setup replay failed" record with caplog.at_level scoped to the module's logger. --- .../sphinx_autodoc_docutils/_directives.py | 8 +++++ tests/ext/autodoc_docutils/test_directives.py | 35 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py index c873e12c..d92e0d95 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py @@ -4,6 +4,7 @@ import importlib import inspect +import logging import typing as t from docutils import nodes @@ -26,6 +27,8 @@ if t.TYPE_CHECKING: from sphinx.util.typing import OptionSpec +logger = logging.getLogger(__name__) + def _summary(value: object) -> str: # object: wraps inspect.getdoc() """Return the first summary line for a Python object. @@ -113,6 +116,11 @@ def _replay_setup(module_name: str) -> _SetupRecorder | None: try: setup_fn(recorder) except Exception: # noqa: BLE001 - extension setup errors are expected here + logger.debug( + "setup replay failed for %s; falling back to module introspection", + module_name, + exc_info=True, + ) return None return recorder diff --git a/tests/ext/autodoc_docutils/test_directives.py b/tests/ext/autodoc_docutils/test_directives.py index b31a6eb0..f1e13c62 100644 --- a/tests/ext/autodoc_docutils/test_directives.py +++ b/tests/ext/autodoc_docutils/test_directives.py @@ -2,6 +2,12 @@ from __future__ import annotations +import logging +import sys +import types + +import pytest + from sphinx_autodoc_docutils import setup from sphinx_autodoc_docutils._directives import ( _directive_classes, @@ -97,6 +103,35 @@ def test_replay_setup_returns_none_for_unimportable_module() -> None: assert _replay_setup("_does_not_exist_module_") is None +def test_replay_setup_logs_debug_when_setup_raises( + caplog: pytest.LogCaptureFixture, +) -> None: + """A raising setup() degrades to introspection but leaves a DEBUG breadcrumb. + + Regression guard: silent fallback would re-introduce mis-derived names + (e.g. ``autoconfigvalueindex``) without telling the docs author. + """ + module_name = "_replay_setup_test_module_with_raising_setup" + fake_module = types.ModuleType(module_name) + + def _broken_setup(_app: object) -> None: + raise RuntimeError("simulated extension setup failure") + + fake_module.setup = _broken_setup # type: ignore[attr-defined] + sys.modules[module_name] = fake_module + try: + with caplog.at_level( + logging.DEBUG, logger="sphinx_autodoc_docutils._directives" + ): + assert _replay_setup(module_name) is None + finally: + del sys.modules[module_name] + + matching = [r for r in caplog.records if "setup replay failed" in r.getMessage()] + assert matching, "expected a DEBUG breadcrumb when setup() raises" + assert matching[0].levelno == logging.DEBUG + + def test_registered_directives_uses_real_registration_names_for_packages() -> None: """Package input returns the kebab-case names passed to add_directive().""" pairs = dict(_registered_directives("sphinx_autodoc_fastmcp")) From 6e13de048833ddabe8b4389fae4e3774f45053fa Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 13:40:31 -0500 Subject: [PATCH 41/53] docs(CHANGES) Register-aware autodoc discovery + debug breadcrumb why: PR #22 grew beyond the original opengraph/sitemap scope to include register-aware discovery in sphinx-autodoc-docutils and the silent-fallback fix that surfaces setup() failures. Both are public-facing changes downstream consumers will care about, but neither was reflected in the changelog. what: - Add a Features sub-section under sphinx-autodoc-docutils describing the new accept-a-package-name behaviour for the four index/full directives. - Add a Bug fixes sub-section noting the DEBUG breadcrumb that prevents silent regression to mis-derived class-name kebab-casing. --- CHANGES | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/CHANGES b/CHANGES index c06ae2e0..86b584d3 100644 --- a/CHANGES +++ b/CHANGES @@ -51,6 +51,18 @@ narrow `ExtensionError` handling. Added to `DEFAULT_EXTENSIONS`. `ogp_image`, `site_url`, and `sitemap_url_scheme` (flat `"{link}"`) from a single `docs_url`. (#22) +#### `sphinx-autodoc-docutils`: Register-aware directive and role discovery + +`autodirective-index`, `autodirectives`, `autorole-index`, and +`autoroles` now accept an extension package name (e.g. +`sphinx_autodoc_fastmcp`) in addition to a directive- or +role-defining submodule. The directive replays the package's +`setup()` against a recorder so each entry surfaces by the real +name passed to `app.add_directive(...)` / `app.add_role(...)` — +no more class-name guessing that produced `autoconfigvalueindex` +for `AutoconfigvalueIndexDirective`. Module introspection remains +the fallback when a module exposes no `setup()`. (#22) + #### Initial release: `gp-sphinx` Shared documentation platform for git-pull projects. @@ -122,6 +134,14 @@ re-initialise after navigation without a full page reload. Payload: ### Bug fixes +#### `sphinx-autodoc-docutils`: DEBUG breadcrumb when setup() replay fails + +The register-aware discovery path catches exceptions from the +extension's `setup()` and degrades to module introspection. A +`logger.debug(..., exc_info=True)` now records the failure so a +broken `setup()` (which would silently revert to mis-derived +class-name kebab-casing) is recoverable from the build log. (#22) + #### `sphinx-autodoc-fastmcp`: Section labels resolve by component name Prompt and resource card labels now carry the actual component name From 19ab7b2e499f9b487b6838b1d924e54a9f75f5ec Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 13:43:00 -0500 Subject: [PATCH 42/53] sphinx-autodoc-docutils(perf[discovery]): cache _replay_setup per module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Every autodirective-index / autodirectives / autorole-index / autoroles invocation calls _replay_setup, so a docs build with N package pages × M directive invocations re-imports + re-replays each package's setup() for every call. The recorder is read-only by contract — consumers iterate recorder.calls and never mutate — so caching the recorder per module name is safe. what: - Wrap _replay_setup with functools.cache so subsequent calls for the same module name return the same recorder object. - Document the read-only contract and the cache-clear escape hatch in the docstring. - Add a regression test asserting the cache returns the identical recorder on a second call, and update the DEBUG-breadcrumb test to call cache_clear() before and after so it is robust against re-runs in the same pytest session. --- .../src/sphinx_autodoc_docutils/_directives.py | 12 ++++++++++++ tests/ext/autodoc_docutils/test_directives.py | 10 ++++++++++ 2 files changed, 22 insertions(+) diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py index d92e0d95..ffce12e8 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py @@ -2,6 +2,7 @@ from __future__ import annotations +import functools import importlib import inspect import logging @@ -94,9 +95,20 @@ def _recorder(*args: object, **kwargs: object) -> None: return _recorder +@functools.cache def _replay_setup(module_name: str) -> _SetupRecorder | None: """Run a module's ``setup()`` against a recorder; return None on failure. + Cached because every invocation of ``autodirective-index`` / + ``autodirectives`` / ``autorole-index`` / ``autoroles`` calls in + here, and a docs build with N package pages × M directive + invocations would otherwise re-import + re-replay each package's + ``setup()`` for every call. The recorder is read-only by contract + (consumers iterate ``recorder.calls`` and never mutate it). Tests + that depend on a side effect of replay (e.g. log emission for a + raising ``setup()``) should call ``_replay_setup.cache_clear()`` + before asserting. + Examples -------- >>> recorder = _replay_setup("sphinx_autodoc_docutils") diff --git a/tests/ext/autodoc_docutils/test_directives.py b/tests/ext/autodoc_docutils/test_directives.py index f1e13c62..a956085c 100644 --- a/tests/ext/autodoc_docutils/test_directives.py +++ b/tests/ext/autodoc_docutils/test_directives.py @@ -119,6 +119,7 @@ def _broken_setup(_app: object) -> None: fake_module.setup = _broken_setup # type: ignore[attr-defined] sys.modules[module_name] = fake_module + _replay_setup.cache_clear() try: with caplog.at_level( logging.DEBUG, logger="sphinx_autodoc_docutils._directives" @@ -126,12 +127,21 @@ def _broken_setup(_app: object) -> None: assert _replay_setup(module_name) is None finally: del sys.modules[module_name] + _replay_setup.cache_clear() matching = [r for r in caplog.records if "setup replay failed" in r.getMessage()] assert matching, "expected a DEBUG breadcrumb when setup() raises" assert matching[0].levelno == logging.DEBUG +def test_replay_setup_cache_returns_same_recorder() -> None: + """Cached replay returns the same recorder object on subsequent calls.""" + _replay_setup.cache_clear() + first = _replay_setup("sphinx_autodoc_fastmcp") + second = _replay_setup("sphinx_autodoc_fastmcp") + assert first is second + + def test_registered_directives_uses_real_registration_names_for_packages() -> None: """Package input returns the kebab-case names passed to add_directive().""" pairs = dict(_registered_directives("sphinx_autodoc_fastmcp")) From 98b7481b6f975a42b644e5b06c06d07f5fd4b91e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 13:47:43 -0500 Subject: [PATCH 43/53] sphinx-autodoc-docutils(refactor[discovery]): expose SetupRecorder + replay_setup; consolidate docs/_ext recorder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: docs/_ext/package_reference.py defined its own RecorderApp + manual setup-replay pattern that drifted from the SetupRecorder / replay_setup helpers introduced in 8b5b361 for sphinx-autodoc-docutils. Two implementations of the same recorder is a maintenance hazard; worse, package_reference.py paid the per-call import + replay cost that 19ab7b2 just optimised away inside sphinx-autodoc-docutils. what: - Promote _SetupRecorder → SetupRecorder and _replay_setup → replay_setup in sphinx_autodoc_docutils._directives; re-export from the package __init__.py with an explicit __all__ so they are part of the public surface. - docs/_ext/package_reference.py imports SetupRecorder + replay_setup from sphinx_autodoc_docutils. RecorderApp stays as an alias so the existing setup() doctest still works without a rewrite. - collect_extension_surface() and _register_extension_objects() call replay_setup directly. Both now share the cache benefit and the DEBUG breadcrumb on setup() failure. - object_path() doctest switched from RecorderApp (now a re-export) to SurfaceDict so the expected ~package_reference.* path is real. - Tests use the new public names. --- docs/_ext/package_reference.py | 83 +++++++------------ .../src/sphinx_autodoc_docutils/__init__.py | 14 ++++ .../sphinx_autodoc_docutils/_directives.py | 24 +++--- tests/ext/autodoc_docutils/test_directives.py | 20 ++--- 4 files changed, 65 insertions(+), 76 deletions(-) diff --git a/docs/_ext/package_reference.py b/docs/_ext/package_reference.py index 370f707b..816c37e6 100644 --- a/docs/_ext/package_reference.py +++ b/docs/_ext/package_reference.py @@ -15,13 +15,13 @@ ``packages/*/pyproject.toml`` to find every publishable package and reads its name, version, description, classifiers, and GitHub URL. -2. **Surface extraction** (``collect_extension_surface()``) — imports the - module and passes a lightweight ``RecorderApp`` to its ``setup()``. - ``RecorderApp.__getattr__`` captures every ``app.add_*`` call so each - registered item (config value, directive, role, lexer, theme) flows - into a ``SurfaceDict`` without monkey-patching docutils globals. The - collected surface is consumed by ``_register_extension_objects()`` to - populate the py-domain so cross-references resolve. +2. **Surface extraction** (``collect_extension_surface()``) — replays the + extension's ``setup()`` against + :func:`sphinx_autodoc_docutils.replay_setup`, the shared workspace + recorder, and maps the captured ``app.add_*`` calls into a + ``SurfaceDict``. The collected surface is consumed by + ``_register_extension_objects()`` to populate the py-domain so + cross-references resolve. 3. **Rendering** (``package_reference_markdown()``) — emits the copyable conf snippet and metadata block, which the ``PackageReferenceDirective`` @@ -68,6 +68,8 @@ from sphinx.util.docutils import SphinxDirective +from sphinx_autodoc_docutils import SetupRecorder, replay_setup + if t.TYPE_CHECKING: from docutils import nodes @@ -261,35 +263,10 @@ def render_types(types: object, default: object) -> str: return f"`{type(default).__name__}`" -class RecorderApp: - """Lightweight recorder for Sphinx setup calls. - - Examples - -------- - >>> app = RecorderApp() - >>> app.add_config_value("demo", 1, "env") - >>> app.calls[0][0] - 'add_config_value' - """ - - def __init__(self) -> None: - self.calls: list[tuple[str, tuple[object, ...], dict[str, object]]] = [] - - def __getattr__(self, name: str) -> t.Callable[..., None]: - """Record arbitrary Sphinx app API calls used by extension setup code. - - Examples - -------- - >>> app = RecorderApp() - >>> app.add_role("demo", object()) - >>> app.calls[0][0] - 'add_role' - """ - - def _record(*args: object, **kwargs: object) -> None: - self.calls.append((name, args, kwargs)) - - return _record +# Re-export the shared recorder so existing references and doctests in this +# module still work; new code should import SetupRecorder from +# sphinx_autodoc_docutils directly. +RecorderApp = SetupRecorder def collect_extension_surface(module_name: str) -> SurfaceDict: @@ -303,7 +280,7 @@ def collect_extension_surface(module_name: str) -> SurfaceDict: """ ensure_workspace_imports() try: - module = importlib.import_module(module_name) + importlib.import_module(module_name) except ImportError: logger.warning("package-reference: could not import %r", module_name) return SurfaceDict( @@ -314,9 +291,16 @@ def collect_extension_surface(module_name: str) -> SurfaceDict: lexers=[], themes=[], ) - app = RecorderApp() - setup = t.cast("t.Callable[[object], object]", getattr(module, "setup")) - setup(app) + app = replay_setup(module_name) + if app is None: + return SurfaceDict( + module=module_name, + config_values=[], + directives=[], + roles=[], + lexers=[], + themes=[], + ) config_values: list[dict[str, str]] = [] directives: list[dict[str, str]] = [] @@ -440,8 +424,8 @@ def object_path(value: object) -> str: Examples -------- - >>> object_path(RecorderApp) - '{py:obj}`~package_reference.RecorderApp`' + >>> object_path(SurfaceDict) + '{py:obj}`~package_reference.SurfaceDict`' """ module_name = getattr(value, "__module__", type(value).__module__) object_name = getattr(value, "__name__", type(value).__name__) @@ -650,19 +634,8 @@ def _register_extension_objects( pkg_docname = f"packages/{package['name']}" for ext_module_name in extension_modules(package["module_name"]): - try: - module = importlib.import_module(ext_module_name) - except ImportError: - continue - - setup_fn = getattr(module, "setup", None) - if not callable(setup_fn): - continue - - recorder = RecorderApp() - try: - setup_fn(recorder) - except Exception: + recorder = replay_setup(ext_module_name) + if recorder is None: continue raw_objs: list[tuple[object, str]] = [] # (obj, objtype) diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py index 81957027..950eef91 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/__init__.py @@ -13,8 +13,22 @@ AutoRole, AutoRoleIndex, AutoRoles, + SetupRecorder, + replay_setup, ) +__all__ = [ + "AutoDirective", + "AutoDirectiveIndex", + "AutoDirectives", + "AutoRole", + "AutoRoleIndex", + "AutoRoles", + "SetupRecorder", + "replay_setup", + "setup", +] + if t.TYPE_CHECKING: from sphinx.application import Sphinx from sphinx.util.typing import ExtensionMetadata diff --git a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py index ffce12e8..86b43b4c 100644 --- a/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py +++ b/packages/sphinx-autodoc-docutils/src/sphinx_autodoc_docutils/_directives.py @@ -69,16 +69,18 @@ def _module_members( ] -class _SetupRecorder: +class SetupRecorder: """Record ``app.add_*`` calls made during a Sphinx extension's ``setup()``. - Used by :func:`_registered_directives` and :func:`_registered_roles` to - discover the names a package registers (e.g., ``fastmcp-tool``) instead - of guessing them from class names. + Public discovery primitive shared with other workspace consumers + (notably ``docs/_ext/package_reference.py``) so the recorder pattern + has one implementation. Consumers iterate ``calls`` and never mutate + it — that read-only contract is what makes :func:`replay_setup`'s + cache safe. Examples -------- - >>> recorder = _SetupRecorder() + >>> recorder = SetupRecorder() >>> recorder.add_directive("foo-bar", object) >>> recorder.add_role("baz-quux", lambda *a, **k: None) >>> [name for name, _, _ in recorder.calls] @@ -96,7 +98,7 @@ def _recorder(*args: object, **kwargs: object) -> None: @functools.cache -def _replay_setup(module_name: str) -> _SetupRecorder | None: +def replay_setup(module_name: str) -> SetupRecorder | None: """Run a module's ``setup()`` against a recorder; return None on failure. Cached because every invocation of ``autodirective-index`` / @@ -106,12 +108,12 @@ def _replay_setup(module_name: str) -> _SetupRecorder | None: ``setup()`` for every call. The recorder is read-only by contract (consumers iterate ``recorder.calls`` and never mutate it). Tests that depend on a side effect of replay (e.g. log emission for a - raising ``setup()``) should call ``_replay_setup.cache_clear()`` + raising ``setup()``) should call ``replay_setup.cache_clear()`` before asserting. Examples -------- - >>> recorder = _replay_setup("sphinx_autodoc_docutils") + >>> recorder = replay_setup("sphinx_autodoc_docutils") >>> recorder is not None True >>> any(name == "add_directive" for name, _, _ in recorder.calls) @@ -124,7 +126,7 @@ def _replay_setup(module_name: str) -> _SetupRecorder | None: setup_fn = getattr(module, "setup", None) if not callable(setup_fn): return None - recorder = _SetupRecorder() + recorder = SetupRecorder() try: setup_fn(recorder) except Exception: # noqa: BLE001 - extension setup errors are expected here @@ -150,7 +152,7 @@ def _registered_directives(module_name: str) -> list[tuple[str, type[Directive]] >>> ("autodirective-index", AutoDirectiveIndex) in pairs True """ - recorder = _replay_setup(module_name) + recorder = replay_setup(module_name) if recorder is not None: pairs: list[tuple[str, type[Directive]]] = [] for call_name, args, _kwargs in recorder.calls: @@ -194,7 +196,7 @@ def _registered_roles(module_name: str) -> list[tuple[str, object]]: >>> any(name == "cli-option" for name, _ in pairs) True """ - recorder = _replay_setup(module_name) + recorder = replay_setup(module_name) if recorder is not None: pairs: list[tuple[str, object]] = [] for call_name, args, _kwargs in recorder.calls: diff --git a/tests/ext/autodoc_docutils/test_directives.py b/tests/ext/autodoc_docutils/test_directives.py index a956085c..6c06b37a 100644 --- a/tests/ext/autodoc_docutils/test_directives.py +++ b/tests/ext/autodoc_docutils/test_directives.py @@ -14,9 +14,9 @@ _directive_markup, _registered_directives, _registered_roles, - _replay_setup, _role_callables, _role_markup, + replay_setup, ) _RICH_DIRECTIVE_BLOCK_HEADER = ".. rst:directive::" @@ -84,7 +84,7 @@ def test_role_markup_contains_role_name_and_path() -> None: def test_replay_setup_records_add_directive_calls() -> None: """Replaying a package's setup() captures every app.add_directive call.""" - recorder = _replay_setup("sphinx_autodoc_fastmcp") + recorder = replay_setup("sphinx_autodoc_fastmcp") assert recorder is not None directive_names = [ args[0] for name, args, _ in recorder.calls if name == "add_directive" @@ -95,12 +95,12 @@ def test_replay_setup_records_add_directive_calls() -> None: def test_replay_setup_returns_none_for_module_without_setup() -> None: """A module with no setup() callable yields None instead of raising.""" - assert _replay_setup("sphinx_autodoc_docutils._directives") is None + assert replay_setup("sphinx_autodoc_docutils._directives") is None def test_replay_setup_returns_none_for_unimportable_module() -> None: """An ImportError yields None instead of bubbling out.""" - assert _replay_setup("_does_not_exist_module_") is None + assert replay_setup("_does_not_exist_module_") is None def test_replay_setup_logs_debug_when_setup_raises( @@ -119,15 +119,15 @@ def _broken_setup(_app: object) -> None: fake_module.setup = _broken_setup # type: ignore[attr-defined] sys.modules[module_name] = fake_module - _replay_setup.cache_clear() + replay_setup.cache_clear() try: with caplog.at_level( logging.DEBUG, logger="sphinx_autodoc_docutils._directives" ): - assert _replay_setup(module_name) is None + assert replay_setup(module_name) is None finally: del sys.modules[module_name] - _replay_setup.cache_clear() + replay_setup.cache_clear() matching = [r for r in caplog.records if "setup replay failed" in r.getMessage()] assert matching, "expected a DEBUG breadcrumb when setup() raises" @@ -136,9 +136,9 @@ def _broken_setup(_app: object) -> None: def test_replay_setup_cache_returns_same_recorder() -> None: """Cached replay returns the same recorder object on subsequent calls.""" - _replay_setup.cache_clear() - first = _replay_setup("sphinx_autodoc_fastmcp") - second = _replay_setup("sphinx_autodoc_fastmcp") + replay_setup.cache_clear() + first = replay_setup("sphinx_autodoc_fastmcp") + second = replay_setup("sphinx_autodoc_fastmcp") assert first is second From 5ee2f68238f6c77f0865f62264a827d746c71896 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 15:49:12 -0500 Subject: [PATCH 44/53] gp-sphinx(fix[linkcode]): thread source_branch into make_linkcode_resolve why: merge_sphinx_config has accepted source_branch (default "main") since e655064, but make_linkcode_resolve in the same module ignored it and hardcoded /blob/master/ for dev versions. Modern projects (including gp-sphinx itself) ship from main, so the resolver emitted broken [source] links pointing at a non-existent master branch. what: - Add source_branch: str = "main" parameter to make_linkcode_resolve alongside the existing src_dir parameter; document it in the NumPy-style docstring. - Replace the literal "/blob/master/" fragment with the parameter so callers control the dev-version URL the same way they control the release-version URL (which already used the version tag). - Add tests/test_config.py::test_make_linkcode_resolve_uses_source_branch exercising a fake module with a dev __version__ to assert the passed source_branch flows through to the resolved URL. --- packages/gp-sphinx/src/gp_sphinx/config.py | 5 +++- tests/test_config.py | 30 ++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/gp-sphinx/src/gp_sphinx/config.py b/packages/gp-sphinx/src/gp_sphinx/config.py index f98d1cb7..0f42cf7d 100644 --- a/packages/gp-sphinx/src/gp_sphinx/config.py +++ b/packages/gp-sphinx/src/gp_sphinx/config.py @@ -112,6 +112,7 @@ def make_linkcode_resolve( package_module: types.ModuleType, github_url: str, src_dir: str = "src", + source_branch: str = "main", ) -> Callable[[str, dict[str, str]], str | None]: """Create a ``linkcode_resolve`` function for ``sphinx.ext.linkcode``. @@ -129,6 +130,8 @@ def make_linkcode_resolve( ``"https://github.com/tmux-python/libtmux"``). src_dir : str Directory containing the source package (default ``"src"``). + source_branch : str + The fallback branch for development versions (default ``"main"``). Returns ------- @@ -197,7 +200,7 @@ def linkcode_resolve(domain: str, info: dict[str, str]) -> str | None: version = getattr(package_module, "__version__", "") if "dev" in version: - return f"{github_url}/blob/master/{src_dir}/{fn}{linespec}" + return f"{github_url}/blob/{source_branch}/{src_dir}/{fn}{linespec}" return f"{github_url}/blob/v{version}/{src_dir}/{fn}{linespec}" return linkcode_resolve diff --git a/tests/test_config.py b/tests/test_config.py index ced95328..2b6818a9 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,6 +2,8 @@ from __future__ import annotations +import pytest + import gp_sphinx from gp_sphinx.config import deep_merge, make_linkcode_resolve, merge_sphinx_config from gp_sphinx.defaults import DEFAULT_EXTENSIONS, DEFAULT_MYST_EXTENSIONS @@ -449,3 +451,31 @@ def test_merge_sphinx_config_release_matches_version() -> None: copyright="2026", ) assert result["release"] == "1.2.3" + + +def test_make_linkcode_resolve_uses_source_branch( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """make_linkcode_resolve uses the provided source_branch for dev versions.""" + import sys + import types + + fake_module = types.ModuleType("fake") + fake_module.__file__ = "/tmp/fake/src/fake/__init__.py" + fake_module.__version__ = "1.0.0.dev0" # type: ignore[attr-defined] + + def dummy() -> None: + pass + + fake_module.dummy = dummy # type: ignore[attr-defined] + + monkeypatch.setitem(sys.modules, "fake", fake_module) + + resolver = make_linkcode_resolve( + fake_module, + "https://github.com/org/repo", + source_branch="custom-branch", + ) + url = resolver("py", {"module": "fake", "fullname": "dummy"}) + assert url is not None + assert "/blob/custom-branch/src/" in url From 6393215bcd075a1248c1dd8ef0268278cbfe3a58 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 15:49:31 -0500 Subject: [PATCH 45/53] sphinx-gp-opengraph(fix[title]): ignore void HTML elements when tracking nesting level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: HTMLTextParser tracks tag nesting via handle_starttag / handle_endtag, but Python's HTMLParser fires only handle_starttag for HTML void elements (br, img, hr, meta, link, input, area, base, col, embed, param, source, track, wbr) — there is no closing tag, so handle_endtag never fires. self.level was incrementing on each void element and never decrementing, so all text after a title's first
or ended up classified as inside-a-tag and was dropped from text_outside_tags. The same bug exists in upstream sphinxext-opengraph. what: - Skip the level increment in handle_starttag when the tag matches the canonical HTML5 void-element set, so text after void elements is correctly counted as outside-a-tag. - Add a regression test asserting "text
more textfinal" produces "textmore textfinal" with self.level returning to 0. --- .../src/sphinx_gp_opengraph/_title.py | 20 +++++++++++++++++-- tests/ext/opengraph/test_title.py | 7 +++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/_title.py b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/_title.py index cfa2da94..79745050 100644 --- a/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/_title.py +++ b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/_title.py @@ -50,8 +50,24 @@ def __init__(self) -> None: self.level = 0 def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: - """Increase the tag-nesting level.""" - self.level += 1 + """Increase the tag-nesting level (ignoring void elements).""" + if tag not in { + "br", + "img", + "hr", + "meta", + "link", + "input", + "area", + "base", + "col", + "embed", + "param", + "source", + "track", + "wbr", + }: + self.level += 1 def handle_endtag(self, tag: str) -> None: """Decrease the tag-nesting level.""" diff --git a/tests/ext/opengraph/test_title.py b/tests/ext/opengraph/test_title.py index d1505c73..4521c1b7 100644 --- a/tests/ext/opengraph/test_title.py +++ b/tests/ext/opengraph/test_title.py @@ -29,3 +29,10 @@ def test_nested_tags_nest_the_level_counter() -> None: def test_empty_title() -> None: """Empty input returns empty strings.""" assert get_title("") == ("", "") + + +def test_get_title_with_void_elements() -> None: + """Void elements like
and do not permanently increase the nesting level.""" + title, text_outside_tags = get_title("text
more textfinal") + assert title == "textmore textfinal" + assert text_outside_tags == "textmore textfinal" From d7a1104dd9350d2c41ffb88b2f7a8c8af72276e3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 15:49:57 -0500 Subject: [PATCH 46/53] sphinx-gp-sitemap(fix[parallel]): declare parallel_write_safe=False explicitly why: setup() returned only {"parallel_read_safe": True} with the intent of opting out of parallel writes. But Sphinx's Extension class (sphinx/extension.py:38) defaults the missing kwarg to True, so getattr(ext, "parallel_write_safe", None) returns True, the parallel-write gate at sphinx/application.py:1828 passes, and the html-page-context handler _collect_page_link runs in worker processes whose env.temp_data is never merged. Reproducible: a serial build emits the full sitemap (e.g. 32 URLs); the same build under sphinx-build -j 2 emits a small fraction (e.g. 6). what: - Return "parallel_write_safe": False alongside parallel_read_safe so Sphinx's is_parallel_allowed("write") returns False and the whole build falls back to serial writes. Sitemap collection then aggregates correctly in env.temp_data. - Update the module docstring's modernizations list to explain the default-True trap and explicitly state the False declaration. - Update docs/packages/sphinx-gp-sitemap.md's Trade-offs section so users see the same correction in the public docs. - tests/ext/sitemap/test_importable.py: replace the "parallel_write_safe not in meta" assertion with the explicit "parallel_write_safe is False" check, with a comment naming the Extension default-True trap so the regression cannot silently reappear. - CHANGES: brief Bug fixes sub-section under sphinx-gp-sitemap. --- CHANGES | 11 +++++++++++ docs/packages/sphinx-gp-sitemap.md | 18 ++++++++++-------- .../src/sphinx_gp_sitemap/__init__.py | 17 +++++++++++++---- tests/ext/sitemap/test_importable.py | 9 +++++---- 4 files changed, 39 insertions(+), 16 deletions(-) diff --git a/CHANGES b/CHANGES index 86b584d3..df36723b 100644 --- a/CHANGES +++ b/CHANGES @@ -134,6 +134,17 @@ re-initialise after navigation without a full page reload. Payload: ### Bug fixes +#### `sphinx-gp-sitemap`: Complete sitemap under `sphinx-build -j N` + +`setup()` now returns `parallel_write_safe = False` explicitly. The +key was previously omitted with the intent of disabling parallel +writes, but Sphinx's `Extension` class defaults the missing key to +`True`, so `_collect_page_link` ran in worker processes whose +`env.temp_data` was never merged — producing a sitemap with only the +pages handled by the main process. Declaring `False` makes Sphinx +fall back to serial writes and the link list aggregates correctly. +(#22) + #### `sphinx-autodoc-docutils`: DEBUG breadcrumb when setup() replay fails The register-aware discovery path catches exceptions from the diff --git a/docs/packages/sphinx-gp-sitemap.md b/docs/packages/sphinx-gp-sitemap.md index ef531b63..1d7be405 100644 --- a/docs/packages/sphinx-gp-sitemap.md +++ b/docs/packages/sphinx-gp-sitemap.md @@ -104,15 +104,17 @@ parallel-write trade-off below. ## Trade-offs -**`parallel_write_safe` is not declared.** Sphinx's `temp_data` is -explicitly per-process and is not merged across `sphinx-build -j N` +**`parallel_write_safe` is declared `False`.** Sphinx's `temp_data` +is explicitly per-process and is not merged across `sphinx-build -j N` workers. The upstream `sphinx-sitemap` worked around that with a -`multiprocessing.Manager().Queue`; sphinx-gp-sitemap drops the Queue but -does not yet implement an `env-merge-info`/`env-purge-doc` pair to -preserve parallel-write semantics. Until that work lands, the -extension advertises `parallel_read_safe = True` only. Parallel-read -is the common case (it is what `sphinx-build -j` enables by default -in CI matrices); parallel-write requires the operator to opt in. A +`multiprocessing.Manager().Queue`; sphinx-gp-sitemap drops the Queue +but does not yet implement an `env-merge-info` / `env-purge-doc` pair +to preserve parallel-write semantics. Until that work lands, the +extension advertises `parallel_write_safe = False` so Sphinx falls +back to serial writes for the whole build whenever sphinx-gp-sitemap +is loaded — which is necessary because Sphinx's `Extension` class +defaults the missing key to `True`, not `False`. Parallel-read still +works (`sphinx-build -j` enables it by default in CI matrices). A single-process write pass produces a complete sitemap. **`html_baseurl` is re-registered defensively.** Sphinx core diff --git a/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py b/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py index bb5ad04f..15b8febb 100644 --- a/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py +++ b/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py @@ -6,10 +6,14 @@ 1. ``env.temp_data["sphinx_gp_sitemap_links"]`` is a plain ``list[tuple[...]]`` rather than a ``multiprocessing.Queue``. Because ``temp_data`` is - per-process and not merged across parallel workers, sphinx-gp-sitemap only - advertises ``parallel_read_safe`` and intentionally omits - ``parallel_write_safe``: under ``sphinx-build -j N`` link collection - would be incomplete, so the extension is single-write-process only. + per-process and not merged across parallel workers, sphinx-gp-sitemap + explicitly declares ``parallel_write_safe = False``. Note that omitting + the key would not be opt-out — Sphinx's :class:`sphinx.extension.Extension` + defaults the kwarg to ``True`` when missing, so the parallel-write gate + would pass and ``_collect_page_link`` would run in worker processes whose + ``env.temp_data`` is never merged. Declaring ``False`` makes Sphinx fall + back to serial writes for the whole build, which keeps the link list + complete. 2. Builder-kind detection uses the public ``app.builder.name == "dirhtml"`` rather than monkey-patching ``env.is_directory_builder``. 3. The ``html_baseurl`` config value is only registered when not already @@ -184,6 +188,11 @@ def setup(app: Sphinx) -> ExtensionMetadata: return { "version": _EXTENSION_VERSION, "parallel_read_safe": True, + # Must be explicit. Sphinx's Extension defaults this to True when the + # key is missing, which would route _collect_page_link into worker + # processes whose env.temp_data is never merged. See the module + # docstring for details. + "parallel_write_safe": False, } diff --git a/tests/ext/sitemap/test_importable.py b/tests/ext/sitemap/test_importable.py index abddc5ea..537084d8 100644 --- a/tests/ext/sitemap/test_importable.py +++ b/tests/ext/sitemap/test_importable.py @@ -34,10 +34,11 @@ def setup_extension(self, name: str) -> None: meta = sphinx_gp_sitemap.setup(t.cast("t.Any", _FakeApp())) assert meta["version"] assert meta["parallel_read_safe"] is True - # sphinx-gp-sitemap intentionally does not advertise parallel_write_safe: - # link collection lives in env.temp_data, which is per-process and - # not merged across parallel workers. - assert "parallel_write_safe" not in meta + # parallel_write_safe must be declared False explicitly. Sphinx's + # Extension defaults the missing key to True, which would route + # _collect_page_link into worker processes whose env.temp_data is + # never merged. See the module docstring for details. + assert meta["parallel_write_safe"] is False assert "site_url" in registered assert "sitemap_url_scheme" in registered From fecb3be44d2be2a22d108ecbaeb535f56e2ba330 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 15:52:06 -0500 Subject: [PATCH 47/53] gp-sphinx(fix[config]): normalise ogp_site_url trailing slash like site_url why: merge_sphinx_config() assigned ogp_site_url = docs_url raw, while site_url already got trailing-slash normalisation. urllib's urljoin drops the last path segment of a base URL that lacks a trailing slash, so docs_url="https://example.org/docs" produced "https://example.org/page.html" (missing /docs) for both per-page canonical URLs and image URLs in sphinx-gp-opengraph. Sites hosted at a path silently emitted broken Open Graph metadata. what: - Hoist the normalisation into a single local variable and assign it to both ogp_site_url and site_url so the two stay in lock-step going forward. Comment names the urljoin trap so it cannot silently re-occur. - Update the merge_sphinx_config doctest expectation for ogp_site_url to include the trailing slash. - Add tests/test_config::test_merge_sphinx_config_ogp_site_url_preserves_path_component exercising docs_url with a path and a live urljoin assertion that the resulting URL keeps the path component intact. - Update the from-docs_url table in docs/configuration.md so ogp_site_url advertises the same trailing-slash normalisation as site_url. - CHANGES: brief Bug fixes sub-section under gp-sphinx. --- CHANGES | 11 +++++++++ docs/configuration.md | 2 +- packages/gp-sphinx/src/gp_sphinx/config.py | 16 ++++++++----- tests/test_config.py | 26 +++++++++++++++++++++- 4 files changed, 48 insertions(+), 7 deletions(-) diff --git a/CHANGES b/CHANGES index df36723b..e6821669 100644 --- a/CHANGES +++ b/CHANGES @@ -134,6 +134,17 @@ re-initialise after navigation without a full page reload. Payload: ### Bug fixes +#### `gp-sphinx`: Preserve `docs_url` path component in derived URLs + +`merge_sphinx_config(docs_url=...)` now normalises the value to end +with `/` before assigning it to `ogp_site_url` (and continues to do +the same for `site_url`). `urllib.parse.urljoin` drops the last path +segment of a base URL that lacks a trailing slash, so passing +`docs_url="https://example.org/docs"` previously produced canonical +URLs and image URLs like `https://example.org/page.html` (with +`/docs` silently dropped). The site_url path was already normalised; +the OG path was not. (#22) + #### `sphinx-gp-sitemap`: Complete sitemap under `sphinx-build -j N` `setup()` now returns `parallel_write_safe = False` explicitly. The diff --git a/docs/configuration.md b/docs/configuration.md index b783695f..3c204c5b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -61,7 +61,7 @@ All parameters are keyword-only. | Key | Value | | --- | --- | -| `ogp_site_url` | `docs_url` | +| `ogp_site_url` | `docs_url` (trailing-slash normalized) | | `ogp_site_name` | `project` | | `ogp_image` | `"_static/img/icons/icon-192x192.png"` | | `site_url` | `docs_url` (trailing-slash normalized) | diff --git a/packages/gp-sphinx/src/gp_sphinx/config.py b/packages/gp-sphinx/src/gp_sphinx/config.py index 0f42cf7d..061215e1 100644 --- a/packages/gp-sphinx/src/gp_sphinx/config.py +++ b/packages/gp-sphinx/src/gp_sphinx/config.py @@ -337,7 +337,7 @@ def merge_sphinx_config( 'https://github.com/org/test/issues/{issue_id}' >>> conf["ogp_site_url"] - 'https://test.org' + 'https://test.org/' >>> conf["sitemap_url_scheme"] '{link}' @@ -449,12 +449,18 @@ def merge_sphinx_config( # Auto-compute sphinx_gp_opengraph + sphinx_gp_sitemap config from docs_url if docs_url: - conf["ogp_site_url"] = docs_url + # Normalize to trailing slash so urllib.parse.urljoin keeps any path + # component (e.g. "https://example.org/docs/") intact when joining + # relative page paths and image paths. urljoin drops the last path + # segment of the base when the base has no trailing slash, so + # docs_url="https://example.org/docs" would otherwise emit + # "https://example.org/page.html" (missing /docs) for both ogp_site_url + # and site_url consumers. + normalised_url = docs_url if docs_url.endswith("/") else docs_url + "/" + conf["ogp_site_url"] = normalised_url conf["ogp_site_name"] = project conf["ogp_image"] = "_static/img/icons/icon-192x192.png" - # sphinx-gp-sitemap: normalize to trailing slash so the URL scheme - # composition produces valid URLs. - conf["site_url"] = docs_url if docs_url.endswith("/") else docs_url + "/" + conf["site_url"] = normalised_url # sphinx-gp-sitemap: git-pull.com sites deploy at the project root with # no language or version path segment, so override the upstream # default of "{lang}{version}{link}" to a flat scheme. Projects diff --git a/tests/test_config.py b/tests/test_config.py index 2b6818a9..df9135dd 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -316,11 +316,35 @@ def test_merge_sphinx_config_auto_ogp() -> None: copyright="2026", docs_url="https://test.git-pull.com", ) - assert result["ogp_site_url"] == "https://test.git-pull.com" + # Trailing slash normalised so urllib.parse.urljoin keeps any path + # component intact when joining relative page paths and image paths. + assert result["ogp_site_url"] == "https://test.git-pull.com/" assert result["ogp_site_name"] == "test" assert result["ogp_image"] == "_static/img/icons/icon-192x192.png" +def test_merge_sphinx_config_ogp_site_url_preserves_path_component() -> None: + """docs_url with a path keeps the path; urljoin against the result is correct. + + Regression guard: without the trailing-slash normalisation, + urljoin("https://example.org/docs", "page.html") drops "/docs" and + returns "https://example.org/page.html", emitting broken canonical + URLs and image URLs for sites hosted at a path. + """ + import urllib.parse + + result = merge_sphinx_config( + project="test", + version="1.0", + copyright="2026", + docs_url="https://example.org/docs", + ) + assert result["ogp_site_url"] == "https://example.org/docs/" + assert result["site_url"] == "https://example.org/docs/" + joined = urllib.parse.urljoin(result["ogp_site_url"], "page.html") + assert joined == "https://example.org/docs/page.html" + + def test_merge_sphinx_config_no_ogp_without_docs_url() -> None: """ogp_* not set when docs_url is not provided.""" result = merge_sphinx_config( From 0cf3f2e0d1eefd36524b2bced5f4b8bbe347824e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 15:54:15 -0500 Subject: [PATCH 48/53] sphinx-gp-opengraph(fix[escape]): centralise html.escape in _make_tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: _make_tag() only replaced double quotes ("→"), so titles and site names containing & < > emitted invalid attribute markup straight into the page head — e.g. . Descriptions were safe because _description.py called html.escape(text, quote=True) pre-emptively, but every other tag passing through _make_tag() (og:title, og:site_name, og:image:alt, custom field-list values) was unsafe. what: - _make_tag() now runs html.escape(content, quote=True) so &, <, >, ", and ' all reach the page head as HTML entities. The function becomes the single boundary for attribute escaping; every existing and future caller is safe by construction. - _description.py drops its pre-emptive html.escape() call (and the now-unused html import). Descriptions flow through _make_tag() the same as every other field and get escaped exactly once instead of twice (& would otherwise become &amp;). - Add NamedTuple cases in tests/ext/opengraph/test_meta_emission.py exercising titles with & and site names with < >. The first case also asserts the description is single-escaped (not &amp;) as a regression guard for the double-escape that the _description.py removal addresses. - CHANGES: brief Bug fixes sub-section under sphinx-gp-opengraph. --- CHANGES | 12 +++++++++ .../src/sphinx_gp_opengraph/__init__.py | 12 +++++++-- .../src/sphinx_gp_opengraph/_description.py | 6 ++--- tests/ext/opengraph/test_meta_emission.py | 25 +++++++++++++++++++ 4 files changed, 50 insertions(+), 5 deletions(-) diff --git a/CHANGES b/CHANGES index e6821669..f65730eb 100644 --- a/CHANGES +++ b/CHANGES @@ -134,6 +134,18 @@ re-initialise after navigation without a full page reload. Payload: ### Bug fixes +#### `sphinx-gp-opengraph`: Centralise HTML escaping for every meta tag + +`_make_tag()` now runs `html.escape(content, quote=True)` for every +attribute value, so `&`, `<`, `>`, `"`, and `'` inside titles, site +names, image alts, and custom field-list values all reach the page +head as their HTML-entity forms (`&`, `<`, …) rather than +verbatim. The previous implementation only replaced double quotes +and let titles/site names emit invalid attribute markup like +`content="AT&T"`. The pre-emptive escape in `_description.py` is +removed in the same commit so descriptions flow through the single +boundary and aren't double-escaped. (#22) + #### `gp-sphinx`: Preserve `docs_url` path component in derived URLs `merge_sphinx_config(docs_url=...)` now normalises the value to end diff --git a/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py index fcd043a0..2f4122a9 100644 --- a/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py +++ b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/__init__.py @@ -19,6 +19,7 @@ from __future__ import annotations +import html import logging import os import pathlib @@ -246,8 +247,15 @@ def _make_tag( content: str, attr: t.Literal["property", "name"] = "property", ) -> str: - """Render one ```` tag, HTML-escaping embedded quotes.""" - safe_content = content.replace('"', """) + """Render one ```` tag, HTML-escaping ``&``, ``<``, ``>``, and quotes. + + Centralising the escape here is the boundary that keeps every meta tag + safe — titles, site names, descriptions, image alts, and custom + field-list values all flow through this function. Per-source escaping + (e.g. pre-escaping the description) would either leave other paths + unsafe or double-escape (``&`` → ``&`` → ``&amp;``). + """ + safe_content = html.escape(content, quote=True) return f'' diff --git a/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/_description.py b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/_description.py index ff03f940..08dbe3d9 100644 --- a/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/_description.py +++ b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/_description.py @@ -20,7 +20,6 @@ from __future__ import annotations -import html import string import typing as t @@ -125,8 +124,9 @@ def dispatch_visit(self, node: nodes.Node) -> None: if len(node.children) == 0: text = node.astext().replace("\r", "").replace("\n", " ").strip() - # Ensure string contains HTML-safe characters - text = html.escape(text, quote=True) + # HTML escaping happens once at the boundary in _make_tag; doing + # it here too would double-escape (``&`` → ``&`` → + # ``&amp;``). # Remove double spaces while text.find(" ") != -1: diff --git a/tests/ext/opengraph/test_meta_emission.py b/tests/ext/opengraph/test_meta_emission.py index 7392a1c6..26c38169 100644 --- a/tests/ext/opengraph/test_meta_emission.py +++ b/tests/ext/opengraph/test_meta_emission.py @@ -89,6 +89,31 @@ class MetaCase(t.NamedTuple): expected_present={"og:type": "website"}, expected_absent=("og:description", "description"), ), + MetaCase( + test_id="title-with-ampersand-is-html-escaped", + conf_overrides={"ogp_site_url": "https://example.org/"}, + index_markdown="# AT&T Demo\n\nBody about AT&T services.\n", + expected_present={ + # Title attribute carries the escaped &, never the raw &. + "og:title": "AT&T Demo", + # Description text from the body also escapes &; this guards + # against the previous double-escape regression where moving + # the escape into _make_tag plus a stale html.escape() call in + # _description.py would produce "&amp;". + "og:description": "Body about AT&T services.", + }, + expected_absent=(), + ), + MetaCase( + test_id="site-name-with-lt-gt-is-html-escaped", + conf_overrides={ + "ogp_site_url": "https://example.org/", + "ogp_site_name": "Foo ", + }, + index_markdown=_DEFAULT_INDEX, + expected_present={"og:site_name": "Foo <bar>"}, + expected_absent=(), + ), ) From 855c2759e54eb9564494abf0a20463870f56eae9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 16:04:26 -0500 Subject: [PATCH 49/53] docs(CHANGES) Conform to canonical section order and tighten entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Format audit found section-name violations (### Features → ### What's new), a non-canonical ### Workspace packages section holding entries that belong under What's new, four bug-fix entries leaking implementation depth past the 2-4 line cap, a Raises triple-fix that should split per shipped surface, and six entries missing their PR refs. what: - Rename ### Features → ### What's new and fold the ### Workspace packages section into it; reorder so every "New package: X" entry leads, then per-package change entries. - Tighten the four bloated bug-fix entries (sphinx-gp-opengraph HTML escape, sphinx-gp-sitemap parallel-write, gp-sphinx docs_url path, sphinx-autodoc-docutils setup-replay breadcrumb) to the cap, leading with the user-visible result and pushing implementation depth into the linked PR. Same treatment for sphinx-autodoc-fastmcp decorator-registration and import-failure entries. - Split sphinx-autodoc-typehints-gp's three-fix Raises bundle into three sub-sections (`:exc:` shortener, generics-comma split, empty Examples/References rubric). - Promote `Initial release: gp-sphinx` heading to canonical `New package: gp-sphinx`; prefix `Fonts:` and `Badges:` entries with their package names. - Add PR refs for the six entries that lacked them, mapping each to its originating PR via gh pr list. --- CHANGES | 268 ++++++++++++++++++++++++++------------------------------ 1 file changed, 125 insertions(+), 143 deletions(-) diff --git a/CHANGES b/CHANGES index f65730eb..10cd631b 100644 --- a/CHANGES +++ b/CHANGES @@ -29,7 +29,15 @@ $ uv add gp-sphinx --prerelease allow BEM namespace (retires the per-package `sab-`, `smf-`, `spf-`, `api-`, `gas-`, `gal-` prefixes and collapses their duplicate palettes) (#18) -### Features +### What's new + +#### New package: `gp-sphinx` + +Shared documentation platform for git-pull projects. +`merge_sphinx_config()` builds a complete Sphinx config from shared +defaults — extensions, theme, fonts, MyST, copybutton, rediraffe — +with per-project overrides. Bundles `tabs.js` removal and +`spa-nav.js` injection out of the box. (#5) #### New package: `sphinx-gp-opengraph` @@ -45,40 +53,40 @@ warning. Replaces `sphinxext.opengraph` in `DEFAULT_EXTENSIONS`. narrow `ExtensionError` handling. Added to `DEFAULT_EXTENSIONS`. (#22) -#### `gp-sphinx`: SEO config auto-wired from `docs_url` +#### New package: `sphinx-fonts` -`merge_sphinx_config` auto-derives `ogp_site_url`, `ogp_site_name`, -`ogp_image`, `site_url`, and `sitemap_url_scheme` (flat `"{link}"`) -from a single `docs_url`. (#22) +Self-hosted web fonts via Fontsource CDN. Downloads at build time, +caches locally, and injects `@font-face` CSS with preload hints and +font-metric fallback overrides for zero-CLS loading. (#5) -#### `sphinx-autodoc-docutils`: Register-aware directive and role discovery +#### New package: `sphinx-gp-theme` -`autodirective-index`, `autodirectives`, `autorole-index`, and -`autoroles` now accept an extension package name (e.g. -`sphinx_autodoc_fastmcp`) in addition to a directive- or -role-defining submodule. The directive replays the package's -`setup()` against a recorder so each entry surfaces by the real -name passed to `app.add_directive(...)` / `app.add_role(...)` — -no more class-name guessing that produced `autoconfigvalueindex` -for `AutoconfigvalueIndexDirective`. Module introspection remains -the fallback when a module exposes no `setup()`. (#22) +Furo child theme for git-pull projects — custom sidebar, footer +icons, SPA navigation, and CSS variable-driven IBM Plex typography. +(#5) -#### Initial release: `gp-sphinx` +#### New package: `sphinx-autodoc-argparse` -Shared documentation platform for git-pull projects. -`merge_sphinx_config()` builds a complete Sphinx config from shared -defaults — extensions, theme, fonts, MyST, copybutton, rediraffe — -with per-project overrides. Bundles `tabs.js` removal and -`spa-nav.js` injection out of the box. +Argparse CLI documentation extension with `.. argparse::` directive, +epilog-to-section transformation, and Pygments lexers for argparse +help/usage output. (#5) -#### `gp-sphinx`: Integrated autodoc design system +#### New package: `sphinx-autodoc-pytest-fixtures` -Twelve workspace packages organised in three tiers (shared -infrastructure, domain autodocumenters, theme/coordinator) share -one badge palette, one componentized layout pipeline, and one -typehint renderer. Python APIs, pytest fixtures, Sphinx config -values, docutils directives, and FastMCP tools all render with -consistent styling. (#18) +Sphinx autodocumenter for pytest fixtures. Registers `py:fixture` +as a domain object type with `autofixture::`, `autofixtures::`, and +`autofixture-index::` directives. Fixtures render with +scope/kind/autouse badges, classified dependency lists, reverse-dep +tracking, and auto-generated usage snippets. The `auto-pytest-plugin` +directive generates a complete pytest plugin page (install block, +`pytest11` autodiscovery note, fixture summary and reference) from +a single call. (#6, #8) + +#### New package: `sphinx-ux-badges` + +Shared badge node, builders, and base CSS for safety tiers, scope, +and kind labels. Extensions add color layers on top; TOC sidebar +shows compact badges with emoji icons. (#13) #### New package: `sphinx-ux-autodoc-layout` @@ -94,14 +102,35 @@ time with no monkey-patching. (#18) #### New package: `sphinx-autodoc-fastmcp` -Sphinx extension for FastMCP tool docs — card-style `desc` layouts, -safety badges, MyST directives, cross-reference roles. The four -directives `{fastmcp-prompt}`, `{fastmcp-prompt-input}`, -`{fastmcp-resource}`, `{fastmcp-resource-template}` autodoc MCP -prompts and resources end-to-end with dedicated type badges and a -MIME pill on resources. Point `fastmcp_server_module` at a live -`FastMCP` instance to enumerate the same surface area as the -running server. +Sphinx extension for FastMCP tool docs — card-style layouts, safety +badges, MyST directives, cross-reference roles. Point +`fastmcp_server_module` at a live `FastMCP` instance and the +prompt / resource / resource-template directives autodoc the same +surface the server exposes. (#13, #21) + +#### `gp-sphinx`: Integrated autodoc design system + +Twelve workspace packages organised in three tiers (shared +infrastructure, domain autodocumenters, theme/coordinator) share +one badge palette, one componentized layout pipeline, and one +typehint renderer. Python APIs, pytest fixtures, Sphinx config +values, docutils directives, and FastMCP tools all render with +consistent styling. (#18) + +#### `gp-sphinx`: SEO config auto-wired from `docs_url` + +`merge_sphinx_config` auto-derives `ogp_site_url`, `ogp_site_name`, +`ogp_image`, `site_url`, and `sitemap_url_scheme` (flat `"{link}"`) +from a single `docs_url`. (#22) + +#### `sphinx-autodoc-docutils`: Register-aware directive and role discovery + +`autodirective-index`, `autodirectives`, `autorole-index`, and +`autoroles` now accept an extension package name in addition to a +directive- or role-defining submodule. Each entry surfaces by the +real name the package registers, so multi-word camel-case classes +like `AutoconfigvalueIndexDirective` no longer render as the +mangled `autoconfigvalueindex`. (#22) #### `sphinx-autodoc-argparse`: New `argparse` Sphinx domain @@ -130,51 +159,37 @@ rather than expanding them to the underlying union or generic type. Dispatched on `document` after every SPA-nav DOM swap. Third-party widgets that bind to swapped DOM can listen for this event to re-initialise after navigation without a full page reload. Payload: -`event.detail.url` is the new URL. +`event.detail.url` is the new URL. (#20) ### Bug fixes -#### `sphinx-gp-opengraph`: Centralise HTML escaping for every meta tag +#### `sphinx-gp-opengraph`: HTML-escape every meta-tag attribute -`_make_tag()` now runs `html.escape(content, quote=True)` for every -attribute value, so `&`, `<`, `>`, `"`, and `'` inside titles, site -names, image alts, and custom field-list values all reach the page -head as their HTML-entity forms (`&`, `<`, …) rather than -verbatim. The previous implementation only replaced double quotes -and let titles/site names emit invalid attribute markup like -`content="AT&T"`. The pre-emptive escape in `_description.py` is -removed in the same commit so descriptions flow through the single -boundary and aren't double-escaped. (#22) +Titles, site names, image alts, and custom field-list values +containing `&`, `<`, `>`, `"`, or `'` now reach the page head as +HTML entities. The previous implementation only escaped double +quotes, so a project named `AT&T` emitted invalid attribute markup. +(#22) #### `gp-sphinx`: Preserve `docs_url` path component in derived URLs -`merge_sphinx_config(docs_url=...)` now normalises the value to end -with `/` before assigning it to `ogp_site_url` (and continues to do -the same for `site_url`). `urllib.parse.urljoin` drops the last path -segment of a base URL that lacks a trailing slash, so passing -`docs_url="https://example.org/docs"` previously produced canonical -URLs and image URLs like `https://example.org/page.html` (with -`/docs` silently dropped). The site_url path was already normalised; -the OG path was not. (#22) +Sites hosted at a path (e.g. `docs_url="https://example.org/docs"`) +no longer emit broken Open Graph canonical URLs and image URLs. +`ogp_site_url` now applies the same trailing-slash normalisation as +`site_url`. (#22) #### `sphinx-gp-sitemap`: Complete sitemap under `sphinx-build -j N` -`setup()` now returns `parallel_write_safe = False` explicitly. The -key was previously omitted with the intent of disabling parallel -writes, but Sphinx's `Extension` class defaults the missing key to -`True`, so `_collect_page_link` ran in worker processes whose -`env.temp_data` was never merged — producing a sitemap with only the -pages handled by the main process. Declaring `False` makes Sphinx -fall back to serial writes and the link list aggregates correctly. -(#22) +`sphinx-build -j N` no longer emits an incomplete `sitemap.xml`. +The extension previously relied on Sphinx's parallel-write default +to opt out, which silently routed page collection into worker +processes; the gate is now declared explicitly. (#22) -#### `sphinx-autodoc-docutils`: DEBUG breadcrumb when setup() replay fails +#### `sphinx-autodoc-docutils`: Surface failed `setup()` replay in build log -The register-aware discovery path catches exceptions from the -extension's `setup()` and degrades to module introspection. A -`logger.debug(..., exc_info=True)` now records the failure so a -broken `setup()` (which would silently revert to mis-derived -class-name kebab-casing) is recoverable from the build log. (#22) +Extensions whose `setup()` raises during register-aware discovery +now leave a DEBUG breadcrumb in the build log instead of silently +reverting to class-name guessing. (#22) #### `sphinx-autodoc-fastmcp`: Section labels resolve by component name @@ -184,58 +199,60 @@ lookups resolve against the human-readable identifier. (#21) #### `sphinx-autodoc-fastmcp`: Decorator-registered components no longer dropped -The FastMCP register-all hook now fires whenever the resolved server -exposes `local_provider`, regardless of whether `_components` is -already populated. Servers that mix import-time decorator -registrations with explicit `register_all()` calls now appear fully -in autodoc. (#21) +FastMCP servers that mix decorator-time registration with explicit +`register_all()` calls now appear fully in autodoc. Previously the +decorator-only paths were silently skipped. (#21) + +#### `sphinx-autodoc-fastmcp`: Surface real import failures + +Runtime errors during the fastmcp module import now propagate +instead of being swallowed and producing silently empty docs. +(#21) + +#### `sphinx-autodoc-typehints-gp`: `:exc:` references with `~mod.Foo` shorten to `Foo` + +The last-component shortener now applies to exception cross-refs +written with the leading-tilde form, matching how the rest of the +typehint renderer treats abbreviated paths. (#21) -#### `sphinx-autodoc-fastmcp`: Narrow `ImportError` around fastmcp imports +#### `sphinx-autodoc-typehints-gp`: `Raises` type fields preserve parameterised generics -Unrelated runtime errors during fastmcp import now propagate instead -of producing silently empty docs. (#21) +`Raises` fields no longer split parameterised generics like +`Dict[str, X]` on the inner comma. Multi-type `Raises` lines still +split on commas between exception types as before. (#21) -#### `sphinx-autodoc-typehints-gp`: Cleaner `Raises` rendering +#### `sphinx-autodoc-typehints-gp`: Empty `Examples` / `References` sections render their rubric -Three related fixes: `:exc:` references with `~mod.Foo` now render -as `Foo` (last-component shortener); `Raises` type fields split on -commas only at bracket depth 0 so parameterised generics like -`Dict[str, X]` stay intact; empty `Notes` sections drop their rubric -(intentional after `.. todo::` filtering) while empty `Examples` / -`References` sections regain theirs for legitimate stub usage. (#21) +Empty `Examples` and `References` sections — common in legitimate +stubs — now display their rubric. Empty `Notes` sections continue +to drop theirs (intentional after `.. todo::` filtering). (#21) #### `sphinx-gp-theme`: SPA nav scrolls to anchor on cross-page fragments -Hashes containing `.` (Python autodoc IDs like -`#libtmux_mcp.models.SearchPanesResult`) were mis-parsed as CSS -selectors, so `py:class` / `py:meth` / `py:attr` cross-refs failed -to scroll after SPA navigation. Switched to `getElementById` with -URL decoding, and moved the scroll-to-fragment branch out of the -`!isPop` guard so browser forward/back also scrolls. (#20) +Cross-page Python autodoc anchors (e.g. +`#libtmux_mcp.models.SearchPanesResult`) now scroll into view after +SPA navigation, including via browser back/forward. (#20) -#### `sphinx-gp-theme`: Copy buttons restored after SPA nav +#### `sphinx-gp-theme`: Copy buttons survive SPA navigation -Pages without code blocks left the copy-button template null, so -SPA-navigating to a code-block page produced no copy affordance. -The button is now built from an inline template byte-equivalent to -sphinx-copybutton's output. Projects that extend `copybutton_selector` -(e.g. prompt admonitions) get copy buttons re-applied to every -configured match after SPA swap via a new -`window.GP_SPHINX_COPYBUTTON_SELECTOR` bridge. (#20) +Pages without code blocks no longer leave the copy-button template +null — code-block pages reached via SPA navigation now show the +copy affordance. Projects that extend `copybutton_selector` (e.g. +prompt admonitions) can opt into the same re-application via +`window.GP_SPHINX_COPYBUTTON_SELECTOR`. (#20) -#### Fonts: Full weight range for IBM Plex Sans and Mono +#### `sphinx-fonts`: Full weight range for IBM Plex Sans and Mono -Both faces now load `[300, 400, 500, 600, 700]`. Badges render in -monospace at `font-weight: 700`; Furo code blocks use 300; -previously only Sans had the full set, so browsers synthesised -intermediate weights. Badge CSS also bumped from non-standard `650` -to `700` across `sphinx-autodoc-api-style`, -`sphinx-autodoc-pytest-fixtures`, and `sphinx-gp-theme`. +Both faces now load weights 300–700. Badges render in monospace at +700 and Furo code blocks at 300 — previously only Sans had the full +range, so browsers synthesised the intermediate weights. Badge CSS +bumped from non-standard 650 to 700 across `sphinx-autodoc-api-style`, +`sphinx-autodoc-pytest-fixtures`, and `sphinx-gp-theme`. (#11, #12) -#### Badges: Restore background, border, and tooltip styling +#### `sphinx-ux-badges`: Restore background, border, and tooltip styling -After `BadgeNode` (``) replaced `` in -`sphinx-autodoc-api-style` and `sphinx-autodoc-pytest-fixtures`, the +After the badge node moved from `` to `` in +`sphinx-autodoc-api-style` and `sphinx-autodoc-pytest-fixtures` the visual treatment dropped. Restored via element-agnostic CSS selectors and correct fill defaults. (#13) @@ -246,38 +263,3 @@ Section IDs (`usage`, `options`, `positional arguments`, cross-page that embed `.. argparse::` via MyST `{eval-rst}` build cleanly. (#16) -### Workspace packages - -#### New package: `sphinx-ux-badges` - -Shared badge node, builders, and base CSS for safety tiers, scope, -and kind labels. Extensions add color layers on top; TOC sidebar -shows compact badges with emoji icons. (#13) - -#### New package: `sphinx-autodoc-pytest-fixtures` - -Sphinx autodocumenter for pytest fixtures. Registers `py:fixture` -as a domain object type with `autofixture::`, `autofixtures::`, and -`autofixture-index::` directives. Fixtures render with -scope/kind/autouse badges, classified dependency lists, reverse-dep -tracking, and auto-generated usage snippets. The `auto-pytest-plugin` -directive generates a complete pytest plugin page (install block, -`pytest11` autodiscovery note, fixture summary and reference) from -a single call. - -#### New package: `sphinx-fonts` - -Self-hosted web fonts via Fontsource CDN. Downloads at build time, -caches locally, and injects `@font-face` CSS with preload hints and -font-metric fallback overrides for zero-CLS loading. - -#### New package: `sphinx-gp-theme` - -Furo child theme for git-pull projects — custom sidebar, footer -icons, SPA navigation, and CSS variable-driven IBM Plex typography. - -#### New package: `sphinx-autodoc-argparse` - -Argparse CLI documentation extension with `.. argparse::` directive, -epilog-to-section transformation, and Pygments lexers for argparse -help/usage output. From 9afa902ad341616a58f01b3e9f66c799e64ad4d9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 17:00:38 -0500 Subject: [PATCH 50/53] sphinx-gp-sitemap(fix[architecture]): collect at build-finished via env.found_docs + get_target_uri why: The per-page html-page-context handler had two correctness regressions on top of the parallel-write trap d7a1104 patched: 1. Incremental builds: Sphinx fires html-page-context only for re-written pages, so editing a single .rst file produced a sitemap with one URL and dropped every other page. 2. URL construction: rebuilding URLs from pagename + html_file_suffix diverges from what the HTML builder emits in links. html_link_suffix (used by the builder) can differ from html_file_suffix; pagenames with spaces or other reserved characters are URL-quoted by the builder but not by the manual path. Reproduction: pagename "my page" emits "my page.html" via the old path versus "my%20page.html" from get_target_uri. Sphinx's app.builder.get_target_uri(pagename) is the canonical API for "what URL does the builder emit for this page" and lives at sphinx/builders/html/__init__.py:1067. Iterating app.env.found_docs (the env-merged set of all documented files) at build-finished gives complete coverage on incremental and parallel builds without per-handler aggregation logic. what: - Drop _init_link_store, _collect_page_link, env.temp_data list, and the _LINKS_KEY / nodes import. Only config-inited and build-finished handlers remain. - _write_sitemap iterates sorted(app.env.found_docs) at build-finished and calls app.builder.get_target_uri(pagename) per page, plus a hasattr guard that skips non-HTML-family builders. The dirhtml branch folds into get_target_uri's own per-builder routing. - Switch parallel_write_safe back to True with a comment naming the architectural reason: build-finished always runs in the main process and found_docs is part of the env Sphinx merges across parallel-read workers. - Update module docstring's modernizations list and the docs/packages/sphinx-gp-sitemap.md Event hooks + Trade-offs sections to describe the new architecture (build-finished + get_target_uri, two handlers, parallel_write_safe = True). - Update tests/ext/sitemap/test_importable.py: parallel_write_safe is now True; builder-inited and html-page-context hooks are no longer connected. - Rewrite the CHANGES bug-fix entry to describe the actual fix shipped (architectural rework) instead of the parallel_write_safe declaration that d7a1104 used as a band-aid. Manual verification: serial sphinx-build, parallel sphinx-build -j 4, and incremental sphinx-build (touch single .md file) all emit the same complete 27-URL sitemap on this repo's docs. --- CHANGES | 18 ++- docs/packages/sphinx-gp-sitemap.md | 37 +++-- .../src/sphinx_gp_sitemap/__init__.py | 127 ++++++++---------- tests/ext/sitemap/test_importable.py | 17 ++- 4 files changed, 94 insertions(+), 105 deletions(-) diff --git a/CHANGES b/CHANGES index 10cd631b..d53276a0 100644 --- a/CHANGES +++ b/CHANGES @@ -178,12 +178,18 @@ no longer emit broken Open Graph canonical URLs and image URLs. `ogp_site_url` now applies the same trailing-slash normalisation as `site_url`. (#22) -#### `sphinx-gp-sitemap`: Complete sitemap under `sphinx-build -j N` - -`sphinx-build -j N` no longer emits an incomplete `sitemap.xml`. -The extension previously relied on Sphinx's parallel-write default -to opt out, which silently routed page collection into worker -processes; the gate is now declared explicitly. (#22) +#### `sphinx-gp-sitemap`: Complete sitemap on incremental and parallel builds + +Page enumeration moved from a per-page `html-page-context` handler +to a single `build-finished` pass over `app.env.found_docs`. Two +correctness wins fall out: incremental builds (which fire +`html-page-context` only for re-written pages) emit a full sitemap, +and `sphinx-build -j N` keeps it complete because `found_docs` is +part of the env Sphinx merges across parallel-read workers. URLs +are now built via `app.builder.get_target_uri(pagename)`, so they +honour `html_link_suffix` and URL-quote special characters in +pagenames the same way the HTML builder's own `` links do. +(#22) #### `sphinx-autodoc-docutils`: Surface failed `setup()` replay in build log diff --git a/docs/packages/sphinx-gp-sitemap.md b/docs/packages/sphinx-gp-sitemap.md index 1d7be405..80ced851 100644 --- a/docs/packages/sphinx-gp-sitemap.md +++ b/docs/packages/sphinx-gp-sitemap.md @@ -91,31 +91,30 @@ element per built page to `sitemap.xml` in the output directory. ## Event hooks ```text -config-inited → _maybe_enable_git_lastmod (lazy-load lastmod ext) -builder-inited → _init_link_store (init temp_data list) -html-page-context → _collect_page_link (one entry per page) -build-finished → _write_sitemap (XML serialization) +config-inited → _maybe_enable_git_lastmod (lazy-load lastmod ext) +build-finished → _write_sitemap (enumerate found_docs + + XML serialization) ``` -All four live in +Both live in [`sphinx_gp_sitemap/__init__.py`](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py). -There is no `env-merge-info` or `env-purge-doc` handler — see the -parallel-write trade-off below. +Page enumeration runs once at `build-finished` over `app.env.found_docs` +using `app.builder.get_target_uri(pagename)` for each URL — no +`html-page-context` handler, so incremental builds (where Sphinx +fires the hook only for re-written pages) still emit a complete +sitemap. `app.env.found_docs` is part of the env Sphinx merges across +parallel-read workers, so the extension is `parallel_write_safe` +without per-handler aggregation logic. ## Trade-offs -**`parallel_write_safe` is declared `False`.** Sphinx's `temp_data` -is explicitly per-process and is not merged across `sphinx-build -j N` -workers. The upstream `sphinx-sitemap` worked around that with a -`multiprocessing.Manager().Queue`; sphinx-gp-sitemap drops the Queue -but does not yet implement an `env-merge-info` / `env-purge-doc` pair -to preserve parallel-write semantics. Until that work lands, the -extension advertises `parallel_write_safe = False` so Sphinx falls -back to serial writes for the whole build whenever sphinx-gp-sitemap -is loaded — which is necessary because Sphinx's `Extension` class -defaults the missing key to `True`, not `False`. Parallel-read still -works (`sphinx-build -j` enables it by default in CI matrices). A -single-process write pass produces a complete sitemap. +**Drop-in for `sphinx-sitemap` with stricter URL handling.** Upstream +reconstructed page URLs as `pagename + html_file_suffix`, which +diverges from the HTML builder's actual `` output when +`html_link_suffix` is set (e.g. `"/"` for clean URLs) or when a +pagename contains characters Sphinx URL-quotes. sphinx-gp-sitemap +calls `app.builder.get_target_uri(pagename)` directly, matching the +links Sphinx emits on the page itself. **`html_baseurl` is re-registered defensively.** Sphinx core registers `html_baseurl` on most modern versions, but older trees and diff --git a/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py b/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py index 15b8febb..8d4fe093 100644 --- a/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py +++ b/packages/sphinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py @@ -4,18 +4,19 @@ Behavior is identical to upstream ``sphinx_sitemap`` v2.9.0 with three modernizations: -1. ``env.temp_data["sphinx_gp_sitemap_links"]`` is a plain ``list[tuple[...]]`` - rather than a ``multiprocessing.Queue``. Because ``temp_data`` is - per-process and not merged across parallel workers, sphinx-gp-sitemap - explicitly declares ``parallel_write_safe = False``. Note that omitting - the key would not be opt-out — Sphinx's :class:`sphinx.extension.Extension` - defaults the kwarg to ``True`` when missing, so the parallel-write gate - would pass and ``_collect_page_link`` would run in worker processes whose - ``env.temp_data`` is never merged. Declaring ``False`` makes Sphinx fall - back to serial writes for the whole build, which keeps the link list - complete. +1. Page enumeration runs once at ``build-finished`` over + ``app.env.found_docs`` (the env-merged set of all documented files), + using ``app.builder.get_target_uri(pagename)`` for each URL. This + keeps sitemaps complete on incremental builds — where Sphinx fires + ``html-page-context`` only for re-written pages — and across + ``sphinx-build -j N`` workers, since ``found_docs`` is part of the + merged env. Upstream ``sphinx-sitemap`` collected per-page via + ``html-page-context`` and reconstructed URLs from ``html_file_suffix``, + missing ``html_link_suffix`` divergence and the URL-quoting that + ``get_target_uri`` performs. 2. Builder-kind detection uses the public ``app.builder.name == "dirhtml"`` - rather than monkey-patching ``env.is_directory_builder``. + rather than monkey-patching ``env.is_directory_builder``. (Now folded + into ``get_target_uri``, which already routes per builder.) 3. The ``html_baseurl`` config value is only registered when not already registered — via a small ``try/except sphinx.errors.ExtensionError`` rather than a bare ``except BaseException``. @@ -44,11 +45,9 @@ if t.TYPE_CHECKING: from collections.abc import Iterable - from docutils import nodes from sphinx.util.typing import ExtensionMetadata _EXTENSION_VERSION = "0.0.1a9" -_LINKS_KEY = "sphinx_gp_sitemap_links" _SITEMAP_NS = "http://www.sitemaps.org/schemas/sitemap/0.9" _XHTML_NS = "http://www.w3.org/1999/xhtml" @@ -181,18 +180,15 @@ def setup(app: Sphinx) -> ExtensionMetadata: ) app.connect("config-inited", _maybe_enable_git_lastmod) - app.connect("builder-inited", _init_link_store) - app.connect("html-page-context", _collect_page_link) app.connect("build-finished", _write_sitemap) return { "version": _EXTENSION_VERSION, "parallel_read_safe": True, - # Must be explicit. Sphinx's Extension defaults this to True when the - # key is missing, which would route _collect_page_link into worker - # processes whose env.temp_data is never merged. See the module - # docstring for details. - "parallel_write_safe": False, + # Safe at True because page enumeration runs once at build-finished + # in the main process, iterating app.env.found_docs (which Sphinx + # merges across parallel-read workers). No per-handler state. + "parallel_write_safe": True, } @@ -220,56 +216,8 @@ def _maybe_enable_git_lastmod( config.sitemap_show_lastmod = False -def _init_link_store(app: Sphinx) -> None: - """Initialize the shared ``env.temp_data`` list on each build start.""" - app.env.temp_data[_LINKS_KEY] = [] - - -def _collect_page_link( - app: Sphinx, - pagename: str, - templatename: str, - context: dict[str, t.Any], - doctree: nodes.document | None, -) -> None: - """Append one ``(relative_link, last_updated)`` entry per built page. - - Called once per page during HTML emission via ``html-page-context``. - """ - del templatename, context, doctree # unused - config = app.builder.config - file_suffix = config.html_file_suffix or ".html" - - last_updated: str | None = None - if config.sitemap_show_lastmod: - git_last_updated = getattr(app.env, "git_last_updated", None) or {} - entry = git_last_updated.get(pagename) - if entry: - timestamp, _show_sourcelink = entry - if timestamp: - last_updated = dt.datetime.fromtimestamp( - int(timestamp), dt.timezone.utc - ).strftime("%Y-%m-%dT%H:%M:%SZ") - - if app.builder.name == "dirhtml": - if pagename == "index": - sitemap_link = "" - elif pagename.endswith("/index"): - sitemap_link = pagename[: -len("/index")] + "/" - else: - sitemap_link = pagename + "/" - else: - sitemap_link = pagename + file_suffix - - if _is_excluded(sitemap_link, list(config.sitemap_excludes)): - return - - links = t.cast("list[SitemapLink]", app.env.temp_data.setdefault(_LINKS_KEY, [])) - links.append((sitemap_link, last_updated)) - - def _write_sitemap(app: Sphinx, exception: BaseException | None) -> None: - """Serialize the collected links to ``/``. + """Enumerate ``app.env.found_docs`` and serialize the sitemap. Parameters ---------- @@ -282,6 +230,11 @@ def _write_sitemap(app: Sphinx, exception: BaseException | None) -> None: if exception is not None: return + if not hasattr(app.builder, "get_target_uri"): + # Non-HTML-family builders (text, json, manpage, …) have no + # canonical URL surface; nothing to emit. + return + site_url = app.builder.config.site_url or app.builder.config.html_baseurl if not site_url: # INFO rather than WARNING because sphinx-gp-sitemap is in gp-sphinx's @@ -302,10 +255,38 @@ def _write_sitemap(app: Sphinx, exception: BaseException | None) -> None: if not site_url.endswith("/"): site_url = site_url + "/" - links = t.cast( - "list[SitemapLink]", - app.env.temp_data.get(_LINKS_KEY, []), - ) + config = app.builder.config + excludes = list(config.sitemap_excludes) + + git_last_updated: dict[str, t.Any] = {} + if config.sitemap_show_lastmod: + git_last_updated = getattr(app.env, "git_last_updated", None) or {} + + links: list[SitemapLink] = [] + # Iterate in sorted order so the emitted sitemap is byte-stable across + # builds — env.found_docs is a set with no defined iteration order. + for pagename in sorted(app.env.found_docs): + # get_target_uri applies html_link_suffix and URL-quotes the + # pagename, matching what the HTML builder emits in + # links. Doing it ourselves with html_file_suffix would diverge + # for sites that set html_link_suffix (e.g. "/" for clean URLs) + # and for pages whose names contain spaces or other reserved + # characters. + sitemap_link = app.builder.get_target_uri(pagename) + if _is_excluded(sitemap_link, excludes): + continue + + last_updated: str | None = None + entry = git_last_updated.get(pagename) + if entry: + timestamp, _show_sourcelink = entry + if timestamp: + last_updated = dt.datetime.fromtimestamp( + int(timestamp), dt.timezone.utc + ).strftime("%Y-%m-%dT%H:%M:%SZ") + + links.append((sitemap_link, last_updated)) + if not links: logger.info( "sphinx-gp-sitemap: no pages collected for %s", diff --git a/tests/ext/sitemap/test_importable.py b/tests/ext/sitemap/test_importable.py index 537084d8..3702b961 100644 --- a/tests/ext/sitemap/test_importable.py +++ b/tests/ext/sitemap/test_importable.py @@ -34,15 +34,18 @@ def setup_extension(self, name: str) -> None: meta = sphinx_gp_sitemap.setup(t.cast("t.Any", _FakeApp())) assert meta["version"] assert meta["parallel_read_safe"] is True - # parallel_write_safe must be declared False explicitly. Sphinx's - # Extension defaults the missing key to True, which would route - # _collect_page_link into worker processes whose env.temp_data is - # never merged. See the module docstring for details. - assert meta["parallel_write_safe"] is False + # Safe at True: page enumeration runs at build-finished in the main + # process via app.env.found_docs (env-merged across parallel-read + # workers), so no per-handler state needs merging. + assert meta["parallel_write_safe"] is True assert "site_url" in registered assert "sitemap_url_scheme" in registered assert "sitemap_filename" in registered - assert "builder-inited" in connected - assert "html-page-context" in connected + # No builder-inited / html-page-context: the rewrite collects pages + # at build-finished time so incremental builds emit a complete + # sitemap (Sphinx fires html-page-context only for re-written pages). + assert "builder-inited" not in connected + assert "html-page-context" not in connected + assert "config-inited" in connected assert "build-finished" in connected From 7aaf86e6325ec303f7dd77332b7ade4763368fb9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 17:05:36 -0500 Subject: [PATCH 51/53] sphinx-gp-opengraph(fix[title]): symmetric void-element filter for XHTML self-close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: 6393215 added a void-element guard to handle_starttag so HTML5 forms like
and stop incrementing self.level. But Python's html.parser.HTMLParser routes XHTML self-closing forms (
, ) through handle_startendtag, whose default impl calls BOTH handle_starttag AND handle_endtag. Filtering only the start path leaves the unbalanced end decrement: get_title("before
after") landed self.level at -1 and produced text_outside_tags = "before" — silently dropping every chunk after the first XHTML self-closing void tag. what: - Lift the void-element set into a module-level _VOID_ELEMENTS frozenset so handle_starttag and handle_endtag share one source of truth (was a literal set in handle_starttag only). - handle_endtag skips the level decrement when the tag is in the void set, mirroring handle_starttag's guard. - Add test_get_title_with_xhtml_self_closing_void_elements covering
, ,
— title text after each must reach text_outside_tags. --- CHANGES | 8 +++ .../src/sphinx_gp_opengraph/_title.py | 50 ++++++++++++------- tests/ext/opengraph/test_title.py | 18 +++++++ 3 files changed, 58 insertions(+), 18 deletions(-) diff --git a/CHANGES b/CHANGES index d53276a0..46a8527b 100644 --- a/CHANGES +++ b/CHANGES @@ -163,6 +163,14 @@ re-initialise after navigation without a full page reload. Payload: ### Bug fixes +#### `sphinx-gp-opengraph`: XHTML self-closing void tags no longer drop trailing title text + +Titles containing XHTML-style void elements (`
`, ``, +`
`) now keep every text chunk after the void element. The +nesting-level counter previously went negative on the unbalanced +end-tag emission, so `og:title`'s outside-text component lost +everything past the first `
`. (#22) + #### `sphinx-gp-opengraph`: HTML-escape every meta-tag attribute Titles, site names, image alts, and custom field-list values diff --git a/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/_title.py b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/_title.py index 79745050..23b8e67e 100644 --- a/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/_title.py +++ b/packages/sphinx-gp-opengraph/src/sphinx_gp_opengraph/_title.py @@ -38,6 +38,26 @@ def get_title(title: str) -> tuple[str, str]: return htp.text, htp.text_outside_tags +_VOID_ELEMENTS = frozenset( + { + "area", + "base", + "br", + "col", + "embed", + "hr", + "img", + "input", + "link", + "meta", + "param", + "source", + "track", + "wbr", + }, +) + + class HTMLTextParser(html.parser.HTMLParser): """Track text-inside-tags vs text-outside-tags while parsing HTML.""" @@ -51,27 +71,21 @@ def __init__(self) -> None: def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: """Increase the tag-nesting level (ignoring void elements).""" - if tag not in { - "br", - "img", - "hr", - "meta", - "link", - "input", - "area", - "base", - "col", - "embed", - "param", - "source", - "track", - "wbr", - }: + if tag not in _VOID_ELEMENTS: self.level += 1 def handle_endtag(self, tag: str) -> None: - """Decrease the tag-nesting level.""" - self.level -= 1 + """Decrease the tag-nesting level (ignoring void elements). + + ``html.parser.HTMLParser`` routes XHTML self-closing forms like + ``
`` through ``handle_startendtag``, whose default impl + calls both ``handle_starttag`` and ``handle_endtag``. Filtering + only the start path would leave the unbalanced end decrement, + sending ``self.level`` negative and dropping every subsequent + chunk from ``text_outside_tags``. + """ + if tag not in _VOID_ELEMENTS: + self.level -= 1 def handle_data(self, data: str) -> None: """Accumulate text, tracking whether it fell outside any tag.""" diff --git a/tests/ext/opengraph/test_title.py b/tests/ext/opengraph/test_title.py index 4521c1b7..ef007541 100644 --- a/tests/ext/opengraph/test_title.py +++ b/tests/ext/opengraph/test_title.py @@ -36,3 +36,21 @@ def test_get_title_with_void_elements() -> None: title, text_outside_tags = get_title("text
more textfinal") assert title == "textmore textfinal" assert text_outside_tags == "textmore textfinal" + + +def test_get_title_with_xhtml_self_closing_void_elements() -> None: + """XHTML self-closing void tags (e.g.
) keep level balanced. + + ``HTMLParser`` routes ``
`` through ``handle_startendtag``, whose + default fires both ``handle_starttag`` AND ``handle_endtag``. Filtering + only the start path would leave the unbalanced end decrement, sending + ``self.level`` negative and dropping every subsequent chunk from + ``text_outside_tags``. + """ + all_text, outside = get_title("before
after") + assert all_text == "beforeafter" + assert outside == "beforeafter" + + all_text, outside = get_title("ab
c") + assert all_text == "abc" + assert outside == "abc" From 22e29e98d6d10691011cb4f27b1c6788861b7123 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 17:12:18 -0500 Subject: [PATCH 52/53] docs(_ext[package_reference]): kwarg-aware Sphinx app argument extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: collect_extension_surface() indexed args[N] directly for every add_directive / add_directive_to_domain / add_role / add_role_to_domain / add_lexer / add_html_theme / add_crossref_type call. Sphinx's app APIs accept both positional and keyword forms — e.g. app.add_directive("foo", Foo) AND app.add_directive(name="foo", cls=Foo). A consumer that only reads positional args raises IndexError on the keyword form and silently misses the registration on the keyword form for the not-yet-indexed slots. add_config_value already split the difference (positional-or-kwargs lookup); the rest didn't. what: - Add a local _extract_arg(index, key, args, kwargs) helper mirroring the helper in sphinx_autodoc_docutils._directives. Returns the positional arg when present, else the kwarg, else None. - Refactor every args[N] indexing site in collect_extension_surface to call _extract_arg with both the positional slot and the canonical Sphinx keyword name. Each branch skips the registration when a required argument is missing rather than raising IndexError. - Add tests/test_package_reference covering the helper's three modes: positional-first, kwarg fallback, missing-returns-None. --- docs/_ext/package_reference.py | 94 ++++++++++++++++++++++++--------- tests/test_package_reference.py | 24 +++++++++ 2 files changed, 94 insertions(+), 24 deletions(-) diff --git a/docs/_ext/package_reference.py b/docs/_ext/package_reference.py index 816c37e6..6d3376c0 100644 --- a/docs/_ext/package_reference.py +++ b/docs/_ext/package_reference.py @@ -269,6 +269,34 @@ def render_types(types: object, default: object) -> str: RecorderApp = SetupRecorder +def _extract_arg( + index: int, + key: str, + args: tuple[object, ...], + kwargs: dict[str, object], +) -> object | None: + """Pick a Sphinx app-method argument from positional or keyword form. + + Sphinx APIs accept both forms — e.g. ``app.add_directive("foo", Foo)`` + AND ``app.add_directive(name="foo", cls=Foo)`` — so a recorder consumer + that only indexes ``args[N]`` raises ``IndexError`` (or silently misses + the registration) on the keyword form. Mirror's the helper used in + ``sphinx_autodoc_docutils._directives``. + + Examples + -------- + >>> _extract_arg(0, "name", ("foo",), {}) + 'foo' + >>> _extract_arg(0, "name", (), {"name": "foo"}) + 'foo' + >>> _extract_arg(1, "cls", (), {}) is None + True + """ + if len(args) > index: + return args[index] + return kwargs.get(key) + + def collect_extension_surface(module_name: str) -> SurfaceDict: """Collect config values, directives, roles, and lexers for an extension. @@ -310,26 +338,28 @@ def collect_extension_surface(module_name: str) -> SurfaceDict: for name, args, kwargs in app.calls: if name == "add_config_value": - if len(args) < 1: + option = _extract_arg(0, "name", args, kwargs) + if option is None: continue - option = str(args[0]) - default = kwargs.get("default", args[1] if len(args) > 1 else None) - rebuild = str(kwargs.get("rebuild", args[2] if len(args) > 2 else "")) - types = kwargs.get("types") + default = _extract_arg(1, "default", args, kwargs) + rebuild = _extract_arg(2, "rebuild", args, kwargs) or "" + types = _extract_arg(3, "types", args, kwargs) config_values.append( { - "name": option, + "name": str(option), "default": render_value(default), "rebuild": f"`{rebuild}`" if rebuild else "", "types": render_types(types, default), }, ) elif name == "add_directive": - directive_name = str(args[0]) - directive_cls = args[1] + directive_name = _extract_arg(0, "name", args, kwargs) + directive_cls = _extract_arg(1, "cls", args, kwargs) + if directive_name is None or directive_cls is None: + continue directives.append( { - "name": directive_name, + "name": str(directive_name), "kind": "directive", "callable": object_path(directive_cls), "summary": summarize(getattr(directive_cls, "__doc__", None)), @@ -337,9 +367,11 @@ def collect_extension_surface(module_name: str) -> SurfaceDict: }, ) elif name == "add_directive_to_domain": - domain = str(args[0]) - directive_name = str(args[1]) - directive_cls = args[2] + domain = _extract_arg(0, "domain", args, kwargs) + directive_name = _extract_arg(1, "name", args, kwargs) + directive_cls = _extract_arg(2, "cls", args, kwargs) + if domain is None or directive_name is None or directive_cls is None: + continue directives.append( { "name": f"{domain}:{directive_name}", @@ -350,8 +382,10 @@ def collect_extension_surface(module_name: str) -> SurfaceDict: }, ) elif name == "add_crossref_type": - directive_name = str(args[0]) - role_name = str(args[1] if len(args) > 1 else args[0]) + directive_name = _extract_arg(0, "directivename", args, kwargs) + if directive_name is None: + continue + role_name = _extract_arg(1, "rolename", args, kwargs) or directive_name directives.append( { "name": f"std:{directive_name}", @@ -370,20 +404,24 @@ def collect_extension_surface(module_name: str) -> SurfaceDict: }, ) elif name == "add_role": - role_name = str(args[0]) - role_fn = args[1] + role_name = _extract_arg(0, "name", args, kwargs) + role_fn = _extract_arg(1, "role", args, kwargs) + if role_name is None or role_fn is None: + continue role_items.append( { - "name": role_name, + "name": str(role_name), "kind": "role", "callable": object_path(role_fn), "summary": summarize(getattr(role_fn, "__doc__", None)), }, ) elif name == "add_role_to_domain": - domain = str(args[0]) - role_name = str(args[1]) - role_fn = args[2] + domain = _extract_arg(0, "domain", args, kwargs) + role_name = _extract_arg(1, "name", args, kwargs) + role_fn = _extract_arg(2, "role", args, kwargs) + if domain is None or role_name is None or role_fn is None: + continue role_items.append( { "name": f"{domain}:{role_name}", @@ -393,17 +431,25 @@ def collect_extension_surface(module_name: str) -> SurfaceDict: }, ) elif name == "add_lexer": + alias = _extract_arg(0, "alias", args, kwargs) + lexer = _extract_arg(1, "lexer", args, kwargs) + if alias is None or lexer is None: + continue lexers.append( { - "name": str(args[0]), - "callable": object_path(args[1]), + "name": str(alias), + "callable": object_path(lexer), }, ) elif name == "add_html_theme": + theme_name = _extract_arg(0, "name", args, kwargs) + theme_path = _extract_arg(1, "theme_path", args, kwargs) + if theme_name is None or theme_path is None: + continue themes.append( { - "name": str(args[0]), - "path": f"`{args[1]}`", + "name": str(theme_name), + "path": f"`{theme_path}`", }, ) diff --git a/tests/test_package_reference.py b/tests/test_package_reference.py index eac68dc6..d13fb501 100644 --- a/tests/test_package_reference.py +++ b/tests/test_package_reference.py @@ -108,6 +108,30 @@ def test_collect_extension_surface_skips_unimportable_module() -> None: assert surface["directives"] == [] +def test_extract_arg_returns_positional_first() -> None: + """The helper prefers positional args; kwargs are the fallback.""" + assert package_reference._extract_arg(0, "name", ("foo",), {}) == "foo" + assert package_reference._extract_arg(0, "name", ("foo",), {"name": "bar"}) == "foo" + + +def test_extract_arg_falls_back_to_kwargs() -> None: + """The helper picks the kwarg when the positional slot is empty. + + Regression guard: Sphinx APIs accept both ``app.add_directive("foo", Foo)`` + AND ``app.add_directive(name="foo", cls=Foo)``. A consumer that only + indexes ``args[N]`` raises ``IndexError`` (or silently misses the + registration) on the keyword form. + """ + assert package_reference._extract_arg(0, "name", (), {"name": "foo"}) == "foo" + assert package_reference._extract_arg(1, "cls", (), {"cls": object}) is object + + +def test_extract_arg_missing_returns_none() -> None: + """Neither positional nor kwarg present yields None for the caller to skip.""" + assert package_reference._extract_arg(0, "name", (), {}) is None + assert package_reference._extract_arg(2, "cls", ("foo", object), {}) is None + + def test_package_reference_markdown_unknown_package_returns_empty() -> None: """Unknown package names return an empty string rather than crashing.""" result = package_reference.package_reference_markdown("nonexistent-package") From 697d73492402f7d62b1add540c4508605d279dcd Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 25 Apr 2026 17:30:03 -0500 Subject: [PATCH 53/53] docs(CHANGES) Strip implementation depth from upgrade-time entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Re-audit against the changelog skill's "depth out of changelog" rule found ~15 entries leaking implementation mechanic — the cause of a fix rather than its effect on the user. Worst offenders were the entries written this session (sitemap rework, register-aware discovery, XHTML void tags, DEBUG breadcrumb), which all read like PR descriptions rather than upgrade-time decisions. what: - Lead each entry with the user-visible result (what you GAIN, what you can DO, what you need to KNOW); push the cause / mechanic into the linked PR. Most entries trim from 5–10 lines to 2–4. - Drop signal-name / private-symbol / internal-flow leaks: nesting- level counter, env.temp_data routing, app.builder method names, per-handler aggregation, monkey-patching, no-Queue, dirhtml detection mechanic, BadgeNode swap, copy-button template null state, etc. - Tighten the eight-line New package: sphinx-autodoc-pytest-fixtures entry to one product paragraph (autodoc fixtures, badges, plugin page from one directive). Same treatment for the integrated- design-system, fastmcp, sitemap-new-package, typehints-gp-new -package, and ux-badges entries. - Sitemap bug-fix loses the architectural prose; reads as the user-visible win — incremental and parallel builds emit a complete sitemap; URLs match what's on the page. --- CHANGES | 170 +++++++++++++++++++++++++------------------------------- 1 file changed, 75 insertions(+), 95 deletions(-) diff --git a/CHANGES b/CHANGES index 46a8527b..a46ec130 100644 --- a/CHANGES +++ b/CHANGES @@ -25,9 +25,10 @@ $ uv add gp-sphinx --prerelease allow `sphinx-ux-badges`, `sphinx-autodoc-layout` → `sphinx-ux-autodoc-layout`, `sphinx-typehints-gp` → `sphinx-autodoc-typehints-gp` (#18) - Sphinx floor bumped to 8.1 across the workspace (#18) -- CSS classes and custom properties unified under a single `gp-sphinx-*` - BEM namespace (retires the per-package `sab-`, `smf-`, `spf-`, `api-`, - `gas-`, `gal-` prefixes and collapses their duplicate palettes) (#18) +- All CSS classes and custom properties now live under the `gp-sphinx-*` + namespace. Downstream stylesheets overriding the legacy `sab-`, `smf-`, + `spf-`, `api-`, `gas-`, or `gal-` prefixes need to update their + selectors. (#18) ### What's new @@ -48,10 +49,9 @@ warning. Replaces `sphinxext.opengraph` in `DEFAULT_EXTENSIONS`. #### New package: `sphinx-gp-sitemap` -`sitemap.xml` generator. Drop-in for `sphinx-sitemap` with Sphinx -8.1+ idioms — no `multiprocessing.Queue`, public `dirhtml` detection, -narrow `ExtensionError` handling. Added to `DEFAULT_EXTENSIONS`. -(#22) +`sitemap.xml` generator. Drop-in for `sphinx-sitemap`. Added to +`DEFAULT_EXTENSIONS`, so every gp-sphinx site emits one out of the +box. (#22) #### New package: `sphinx-fonts` @@ -73,14 +73,11 @@ help/usage output. (#5) #### New package: `sphinx-autodoc-pytest-fixtures` -Sphinx autodocumenter for pytest fixtures. Registers `py:fixture` -as a domain object type with `autofixture::`, `autofixtures::`, and -`autofixture-index::` directives. Fixtures render with -scope/kind/autouse badges, classified dependency lists, reverse-dep -tracking, and auto-generated usage snippets. The `auto-pytest-plugin` -directive generates a complete pytest plugin page (install block, -`pytest11` autodiscovery note, fixture summary and reference) from -a single call. (#6, #8) +Sphinx autodocumenter for pytest fixtures. Renders fixtures as a +first-class domain object with scope / kind / autouse badges, +dependency tracking, and usage snippets. The `auto-pytest-plugin` +directive generates a complete plugin reference page from one call. +(#6, #8) #### New package: `sphinx-ux-badges` @@ -97,25 +94,22 @@ managed signatures — used by every domain package in the workspace. #### New package: `sphinx-autodoc-typehints-gp` Single-package replacement for `sphinx-autodoc-typehints` + -`sphinx.ext.napoleon`. Resolves annotations statically at build -time with no monkey-patching. (#18) +`sphinx.ext.napoleon`. Resolves annotations statically, so docs +builds stay deterministic across runs. (#18) #### New package: `sphinx-autodoc-fastmcp` -Sphinx extension for FastMCP tool docs — card-style layouts, safety -badges, MyST directives, cross-reference roles. Point -`fastmcp_server_module` at a live `FastMCP` instance and the -prompt / resource / resource-template directives autodoc the same -surface the server exposes. (#13, #21) +Sphinx extension for FastMCP tool docs — card-style entries with +safety badges and cross-reference roles. Point +`fastmcp_server_module` at a live `FastMCP` instance to autodoc its +prompts, resources, and resource templates from the same surface +the server exposes. (#13, #21) #### `gp-sphinx`: Integrated autodoc design system -Twelve workspace packages organised in three tiers (shared -infrastructure, domain autodocumenters, theme/coordinator) share -one badge palette, one componentized layout pipeline, and one -typehint renderer. Python APIs, pytest fixtures, Sphinx config -values, docutils directives, and FastMCP tools all render with -consistent styling. (#18) +Python APIs, pytest fixtures, Sphinx config values, docutils +directives, and FastMCP tools all render with one shared badge +palette and layout across the workspace. (#18) #### `gp-sphinx`: SEO config auto-wired from `docs_url` @@ -126,27 +120,24 @@ from a single `docs_url`. (#22) #### `sphinx-autodoc-docutils`: Register-aware directive and role discovery `autodirective-index`, `autodirectives`, `autorole-index`, and -`autoroles` now accept an extension package name in addition to a -directive- or role-defining submodule. Each entry surfaces by the -real name the package registers, so multi-word camel-case classes -like `AutoconfigvalueIndexDirective` no longer render as the -mangled `autoconfigvalueindex`. (#22) +`autoroles` accept an extension package name and surface each entry +under the name the package actually registers — so multi-word +directive class names render correctly instead of getting collapsed +to all-lowercase. (#22) #### `sphinx-autodoc-argparse`: New `argparse` Sphinx domain -Adds a real Sphinx domain with `program` / `option` / `subcommand` -/ `positional` ObjTypes, `:argparse:*` xref roles, and two -auto-generated indices (`argparse-programsindex`, -`argparse-optionsindex`). `:option:` / `std:cmdoption` continues to -resolve for intersphinx consumers. (#18) +A real `argparse` Sphinx domain ships with `:argparse:program:` / +`:option:` / `:subcommand:` / `:positional:` cross-references and +two auto-generated indices. Existing `:option:` and `std:cmdoption` +consumers continue to resolve. (#18) #### `sphinx-ux-badges`: Shared badge surface -Common badge node, builders, and base CSS for safety tiers, scope, -and kind labels — used by `sphinx-autodoc-fastmcp`, -`sphinx-autodoc-api-style`, and `sphinx-autodoc-pytest-fixtures`. -Explicit size variants (`xs` / `sm` / `lg` / `xl`) compose with any -fill, style, or color class. (#13) +Shared badge node, builders, and CSS adopted by +`sphinx-autodoc-fastmcp`, `sphinx-autodoc-api-style`, and +`sphinx-autodoc-pytest-fixtures`. Size variants (`xs` / `sm` / `lg` +/ `xl`) compose with any color or fill class. (#13) #### `sphinx-autodoc-pytest-fixtures`: `TypeAlias` resolution @@ -156,60 +147,51 @@ rather than expanding them to the underlying union or generic type. #### `sphinx-gp-theme`: `gp-sphinx:navigated` event after SPA nav -Dispatched on `document` after every SPA-nav DOM swap. Third-party -widgets that bind to swapped DOM can listen for this event to -re-initialise after navigation without a full page reload. Payload: -`event.detail.url` is the new URL. (#20) +A `gp-sphinx:navigated` event fires on `document` after every +SPA-nav DOM swap, so third-party widgets can re-initialise without +a full page reload. The new URL is on `event.detail.url`. (#20) ### Bug fixes #### `sphinx-gp-opengraph`: XHTML self-closing void tags no longer drop trailing title text Titles containing XHTML-style void elements (`
`, ``, -`
`) now keep every text chunk after the void element. The -nesting-level counter previously went negative on the unbalanced -end-tag emission, so `og:title`'s outside-text component lost -everything past the first `
`. (#22) +`
`) keep every text chunk after the void element. Previously +`og:title` lost everything past the first such tag. (#22) #### `sphinx-gp-opengraph`: HTML-escape every meta-tag attribute Titles, site names, image alts, and custom field-list values -containing `&`, `<`, `>`, `"`, or `'` now reach the page head as -HTML entities. The previous implementation only escaped double -quotes, so a project named `AT&T` emitted invalid attribute markup. -(#22) +containing `&`, `<`, `>`, `"`, or `'` reach the page head as HTML +entities. Previously a project named `AT&T` emitted invalid +attribute markup. (#22) #### `gp-sphinx`: Preserve `docs_url` path component in derived URLs Sites hosted at a path (e.g. `docs_url="https://example.org/docs"`) -no longer emit broken Open Graph canonical URLs and image URLs. -`ogp_site_url` now applies the same trailing-slash normalisation as -`site_url`. (#22) +emit correct Open Graph canonical URLs and image URLs. The +trailing-slash normalisation that already covered `site_url` now +also covers `ogp_site_url`. (#22) #### `sphinx-gp-sitemap`: Complete sitemap on incremental and parallel builds -Page enumeration moved from a per-page `html-page-context` handler -to a single `build-finished` pass over `app.env.found_docs`. Two -correctness wins fall out: incremental builds (which fire -`html-page-context` only for re-written pages) emit a full sitemap, -and `sphinx-build -j N` keeps it complete because `found_docs` is -part of the env Sphinx merges across parallel-read workers. URLs -are now built via `app.builder.get_target_uri(pagename)`, so they -honour `html_link_suffix` and URL-quote special characters in -pagenames the same way the HTML builder's own `
` links do. -(#22) +Incremental builds (where Sphinx writes only the changed page) and +parallel builds (`sphinx-build -j N`) emit a complete sitemap. URLs +also match the `` paths the HTML builder emits — so sites +that set `html_link_suffix` or have spaces in pagenames no longer +get divergent sitemap links. (#22) #### `sphinx-autodoc-docutils`: Surface failed `setup()` replay in build log -Extensions whose `setup()` raises during register-aware discovery -now leave a DEBUG breadcrumb in the build log instead of silently -reverting to class-name guessing. (#22) +Extensions whose `setup()` raises during autodoc discovery now +leave a DEBUG breadcrumb in the build log instead of silently +emitting incorrect directive or role names. (#22) #### `sphinx-autodoc-fastmcp`: Section labels resolve by component name -Prompt and resource card labels now carry the actual component name -(e.g. `my_resource`) rather than a slugified round-trip, so `{ref}` -lookups resolve against the human-readable identifier. (#21) +Prompt and resource card labels carry the actual component name +(e.g. `my_resource`), so `{ref}` lookups resolve against the +human-readable identifier. (#21) #### `sphinx-autodoc-fastmcp`: Decorator-registered components no longer dropped @@ -225,9 +207,9 @@ instead of being swallowed and producing silently empty docs. #### `sphinx-autodoc-typehints-gp`: `:exc:` references with `~mod.Foo` shorten to `Foo` -The last-component shortener now applies to exception cross-refs -written with the leading-tilde form, matching how the rest of the -typehint renderer treats abbreviated paths. (#21) +Exception cross-refs written as `~mod.Foo` render as just `Foo`, +matching the abbreviated form used elsewhere in the typehint +renderer. (#21) #### `sphinx-autodoc-typehints-gp`: `Raises` type fields preserve parameterised generics @@ -238,8 +220,8 @@ split on commas between exception types as before. (#21) #### `sphinx-autodoc-typehints-gp`: Empty `Examples` / `References` sections render their rubric Empty `Examples` and `References` sections — common in legitimate -stubs — now display their rubric. Empty `Notes` sections continue -to drop theirs (intentional after `.. todo::` filtering). (#21) +stubs — display their rubric. Empty `Notes` sections still drop +theirs. (#21) #### `sphinx-gp-theme`: SPA nav scrolls to anchor on cross-page fragments @@ -249,31 +231,29 @@ SPA navigation, including via browser back/forward. (#20) #### `sphinx-gp-theme`: Copy buttons survive SPA navigation -Pages without code blocks no longer leave the copy-button template -null — code-block pages reached via SPA navigation now show the -copy affordance. Projects that extend `copybutton_selector` (e.g. -prompt admonitions) can opt into the same re-application via +Code-block pages reached via SPA navigation show the copy affordance +even on builds where the entry page had no code blocks. Projects +that extend `copybutton_selector` (e.g. prompt admonitions) opt +their custom selectors into the same re-application via `window.GP_SPHINX_COPYBUTTON_SELECTOR`. (#20) #### `sphinx-fonts`: Full weight range for IBM Plex Sans and Mono -Both faces now load weights 300–700. Badges render in monospace at -700 and Furo code blocks at 300 — previously only Sans had the full -range, so browsers synthesised the intermediate weights. Badge CSS -bumped from non-standard 650 to 700 across `sphinx-autodoc-api-style`, -`sphinx-autodoc-pytest-fixtures`, and `sphinx-gp-theme`. (#11, #12) +IBM Plex Sans and Mono load weights 300–700, so badges and code +blocks render in the real font weight instead of a browser- +synthesised approximation. (#11, #12) #### `sphinx-ux-badges`: Restore background, border, and tooltip styling -After the badge node moved from `` to `` in -`sphinx-autodoc-api-style` and `sphinx-autodoc-pytest-fixtures` the -visual treatment dropped. Restored via element-agnostic CSS -selectors and correct fill defaults. (#13) +Badges in `sphinx-autodoc-api-style` and +`sphinx-autodoc-pytest-fixtures` regain their background, border, +and tooltip — visual treatment that regressed when the underlying +badge node changed. (#13) #### `sphinx-autodoc-argparse`: No more `duplicate label` warnings on multi-page docs -Section IDs (`usage`, `options`, `positional arguments`, cross-page -`examples`) are now namespaced by `id_prefix`, so multi-page docs -that embed `.. argparse::` via MyST `{eval-rst}` build cleanly. +Multi-page docs that embed `.. argparse::` via MyST `{eval-rst}` +build cleanly. Section IDs are now namespaced per program so +`usage`, `options`, and friends no longer collide across pages. (#16)