Skip to content

Add sphinx-gp-opengraph + sphinx-gp-sitemap; register-aware autodoc-docutils discovery#22

Merged
tony merged 53 commits intomainfrom
seo-packages
Apr 25, 2026
Merged

Add sphinx-gp-opengraph + sphinx-gp-sitemap; register-aware autodoc-docutils discovery#22
tony merged 53 commits intomainfrom
seo-packages

Conversation

@tony
Copy link
Copy Markdown
Member

@tony tony commented Apr 21, 2026

Summary

  • Add sphinx-gp-opengraph: workspace package — drop-in replacement for sphinxext-opengraph minus the matplotlib-based social-card generator. ogp_social_cards is accepted-but-ignored with a one-line warning pointing at the static-image workflow.
  • Add sphinx-gp-sitemap: workspace package — drop-in replacement for sphinx-sitemap with Sphinx 8.1+ idioms.
  • Integrate both into gp-sphinx: DEFAULT_EXTENSIONS swaps "sphinxext.opengraph""sphinx_gp_opengraph" and adds "sphinx_gp_sitemap". merge_sphinx_config(docs_url=...) auto-derives ogp_site_url, ogp_site_name, ogp_image, site_url, and sitemap_url_scheme (flat "{link}") from one parameter.
  • Register-aware autodoc discovery in sphinx-autodoc-docutils: autodirective-index / autodirectives / autorole-index / autoroles accept an extension package name and surface each entry under the name the package actually registers, instead of guessing from class names.

Motivation

gp-sphinx used to pull sphinxext-opengraph as a transitive dep, which drags matplotlib (~60 MB) for a per-page social-card generator most git-pull projects don't use. It also left gp-sphinx without a sitemap.xml generator — a standard SEO affordance for documentation sites.

Bringing both in-house as workspace packages drops the matplotlib dependency from gp-sphinx's baseline while keeping every ogp_* config key working, adds sitemap.xml emission as a default so every gp-sphinx site is more discoverable, modernises both extensions to Sphinx 8.1+ idioms, and aligns them with gp-sphinx's house conventions (NumPy docstrings, from __future__ import annotations, src/<module>/ layout, py.typed, hatchling builds, workspace-pinned 0.0.1a9).

Drop-in compatibility

Every ogp_* key the upstream sphinxext-opengraph registered remains, with identical semantics — except ogp_social_cards, which is accepted but ignored (with a one-line warning). Every sitemap_* key the upstream sphinx-sitemap registered remains, with identical XML output shape. Downstream consumers should not need any conf.py changes.

What shipped

CHANGES is the canonical record. The branch has Breaking changes (Sphinx 8.1 floor; CSS gp-sphinx-* namespace migration; five package renames including sphinx-argparse-neosphinx-autodoc-argparse), What's new (the two new packages plus the integrated autodoc design system entries), and Bug fixes (the multi-round audit findings).

Verification

uv run ruff check . --fix --show-fixes  # clean
uv run ruff format .                    # no changes
uv run mypy                             # Success: no issues found in 176 source files
uv run pytest --reruns 0 -q             # 1226 passed, 3 skipped
just build-docs                         # build succeeded

Manual sitemap verification on this repo's docs:

  • Serial sphinx-build, parallel sphinx-build -j 4, and incremental sphinx-build (after touching one source file) all emit the same complete 27-URL sitemap.xml. The earlier per-page html-page-context collection silently dropped pages on incremental and parallel builds; the build-finished + app.env.found_docs + app.builder.get_target_uri() path fixes both.
  • docs/_build/html/index.html carries the expected OG meta — og:title, og:type, og:url, og:site_name, og:description all well-formed, with HTML entities for & / < / > / " / '.

uv tree | grep -E "sphinxext-opengraph|sphinx-sitemap" returns no output — the upstream packages are gone from the workspace.

Pre-merge decisions to sign off

Each of these is intentional, none is a blocker, all are hard-to-undo:

  1. PyPI name claims become permanent. gp-sphinx's pyproject.toml hard-pins sphinx-gp-opengraph==0.0.1a9 and sphinx-gp-sitemap==0.0.1a9, so those two need to land on PyPI before the first gp-sphinx release tag (or pip install gp-sphinx fails resolution). Names cannot be reclaimed once published.
  2. New public-API surface. sphinx-autodoc-docutils.__all__ now exports SetupRecorder and replay_setup. Once a release ships these names, downstream consumers can import them — renaming becomes a breaking change.
  3. DEFAULT_EXTENSIONS change is automatic for every consumer. Every gp-sphinx project's next install gets the sphinxext.opengraphsphinx_gp_opengraph swap and the sphinx_gp_sitemap addition. Projects that set ogp_social_cards get one WARNING per build; projects without site_url or html_baseurl see an INFO-level "sitemap skipped" line.
  4. Merge strategy. 53 commits with rewrites (an earlier git filter-repo rename pass). Squash-merge collapses cleanly into one main commit; merge-commit preserves the history. Either works — pick consciously.

Non-goals

  • Social card generation — dropped. Revisitable as an optional companion sphinx-gp-opengraph-cards package using Pillow if demand appears.
  • RSS / Atom feeds, JSON-LD structured data, robots.txt generation — plausible follow-up sphinx-gp-* packages, not in scope.
  • Config-key rename — staying drop-in; no gp_og_* namespace.
  • Upstream backport of the sitemap / opengraph fixes — separate concern, not a blocker.

Test plan

  • Every commit passes the five-command verification recipe
  • sphinx-gp-opengraph parser units (description / title / meta) + functional tests across bare-defaults, image+alt, custom-meta-tags, site-name-disabled, description-absent, HTML-escape, XHTML void self-close, deprecation-warning
  • sphinx-gp-sitemap units + functional tests across html / dirhtml / excludes / non-zero-indent / no-site-url-skip
  • sphinx-autodoc-docutils units for register-aware discovery, kwarg form, debug breadcrumb on setup() failure
  • Full workspace test suite: 1226 passed, 3 skipped
  • mypy + ruff (check + format) clean
  • Real gp-sphinx docs build emits sitemap.xml and OG meta on serial, -j 4, and incremental sphinx-build invocations

@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented Apr 21, 2026

Codecov Report

❌ Patch coverage is 90.20979% with 84 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.20%. Comparing base (4223958) to head (697d734).

Files with missing lines Patch % Lines
...x-gp-opengraph/src/sphinx_gp_opengraph/__init__.py 80.16% 24 Missing ⚠️
...phinx-gp-sitemap/src/sphinx_gp_sitemap/__init__.py 79.43% 22 Missing ⚠️
docs/_ext/package_reference.py 76.59% 11 Missing ⚠️
...-opengraph/src/sphinx_gp_opengraph/_description.py 79.24% 11 Missing ⚠️
scripts/ci/package_tools.py 20.00% 8 Missing ⚠️
...ocutils/src/sphinx_autodoc_docutils/_directives.py 90.62% 6 Missing ⚠️
tests/ext/sitemap/test_importable.py 96.42% 1 Missing ⚠️
tests/test_config.py 96.96% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #22      +/-   ##
==========================================
+ Coverage   89.89%   90.20%   +0.30%     
==========================================
  Files         148      163      +15     
  Lines       13134    13876     +742     
==========================================
+ Hits        11807    12517     +710     
- Misses       1327     1359      +32     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@tony
Copy link
Copy Markdown
Member Author

tony commented Apr 22, 2026

Code review

Found 2 issues:

  1. Both new library __init__.py files declare a module-level logger without attaching logging.NullHandler() — every existing workspace package (gp_sphinx, sphinx_fonts, sphinx_ux_badges, …) attaches one (CLAUDE.md says "Add NullHandler in library __init__.py files")

logger = logging.getLogger(__name__)
_EXTENSION_VERSION = "0.0.1a9"

logger = getLogger(__name__)
SitemapLink = tuple[str, str | None] # (relative link, last_updated ISO8601 or None)

  1. Two log messages end with a period (CLAUDE.md says "No trailing punctuation" under message style)

del app # unused; required by Sphinx's config-inited signature
if config.ogp_social_cards:
logger.warning(
"gp-opengraph: ogp_social_cards is ignored — gp-opengraph does "
"not bundle a card generator. Use a static PNG via ogp_image "
"(site default) or per-page 'og:image' frontmatter.",
)

# should silently skip sitemap emission rather than break builds
# that run with ``-W``.
logger.info(
"gp-sitemap: skipping sitemap — set site_url or html_baseurl "
"in conf.py to enable.",
type="sitemap",
subtype="configuration",
)

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

tony added a commit that referenced this pull request Apr 22, 2026
…iling periods

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
tony added a commit that referenced this pull request Apr 22, 2026
…ules

why: PR #22 code review flagged three ``from <stdlib> import <name>``
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
tony added a commit that referenced this pull request Apr 25, 2026
…iling periods

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
tony added a commit that referenced this pull request Apr 25, 2026
…ules

why: PR #22 code review flagged three ``from <stdlib> import <name>``
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
@tony tony force-pushed the seo-packages branch 3 times, most recently from 35d7c64 to b5138cd Compare April 25, 2026 11:41
tony added a commit that referenced this pull request Apr 25, 2026
…ubsections

why: the existing CHANGES Features bullets for gp-opengraph and
gp-sitemap had grown into multi-paragraph mini-READMEs, and they
predated both the package rename to the sphinx-gp-* prefix and the
flat sitemap_url_scheme = "{link}" auto-set. Replace them with three
brief #### subsections that describe the shipped surface in two
sentences each, name the renamed packages, and carry the (#22) PR
ref.

what:
- CHANGES: replace the three SEO Features bullets with three ####
  subsections — "New package: sphinx-gp-opengraph", "New package:
  sphinx-gp-sitemap", and "gp-sphinx: SEO config auto-wired from
  docs_url" — each ~3 lines, with (#22) PR refs
tony added a commit that referenced this pull request Apr 25, 2026
…iling periods

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
tony added a commit that referenced this pull request Apr 25, 2026
…ules

why: PR #22 code review flagged three ``from <stdlib> import <name>``
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
tony added a commit that referenced this pull request Apr 25, 2026
…ubsections

why: after the filter-repo sweep collapsed the gp-* → sphinx-gp-*
rename across the entire branch history, the Features section had
three multi-paragraph bullets describing the new packages plus the
gp-sphinx auto-wiring. Replace them with three brief #### subsections
that describe the shipped surface in two sentences each, name the
renamed packages, and carry the (#22) PR ref.

what:
- CHANGES: replace the three SEO Features bullets with three ####
  subsections — "New package: sphinx-gp-opengraph", "New package:
  sphinx-gp-sitemap", and "gp-sphinx: SEO config auto-wired from
  docs_url" — each ~3 lines, with (#22) PR refs
- uv.lock: refresh certifi (incidental)
tony added a commit that referenced this pull request Apr 25, 2026
…ubsections

why: after the filter-repo sweep collapsed the gp-* → sphinx-gp-*
rename across the entire branch history, the Features section had
three multi-paragraph bullets describing the new packages plus the
gp-sphinx auto-wiring. Replace them with three brief #### subsections
that describe the shipped surface in two sentences each, name the
renamed packages, and carry the (#22) PR ref.

what:
- CHANGES: replace the three SEO Features bullets with three ####
  subsections — "New package: sphinx-gp-opengraph", "New package:
  sphinx-gp-sitemap", and "gp-sphinx: SEO config auto-wired from
  docs_url" — each ~3 lines, with (#22) PR refs
tony added a commit that referenced this pull request Apr 25, 2026
…iling periods

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
tony added a commit that referenced this pull request Apr 25, 2026
…ules

why: PR #22 code review flagged three ``from <stdlib> import <name>``
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
tony added 11 commits April 25, 2026 08:39
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/<module>/__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
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 <meta name="description">. 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
…emission

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 (+ <meta name="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
…dioms

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 (<urlset> with per-page <url>, optional <lastmod>, 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 <urlset>, 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
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 <url> 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
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
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)
…iling periods

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
…ules

why: PR #22 code review flagged three ``from <stdlib> import <name>``
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
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
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()
…config reference

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
@tony
Copy link
Copy Markdown
Member Author

tony commented Apr 25, 2026

Code review

Found 3 issues:

  1. New Sphinx-build tests are missing @pytest.mark.integration (CLAUDE.md says "Any test that constructs a Sphinx app. build_shared_sphinx_result / build_isolated_sphinx_result with any builder — including buildername=\"dummy\" — counts." and "Always mark with @pytest.mark.integration")

@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 <loc> 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}"
)

@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],

  1. Sphinx-build fixtures default to function scope — every parametrized test rebuilds (CLAUDE.md says "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")

@pytest.fixture
def build_sitemap_site(
tmp_path: pathlib.Path,
) -> t.Callable[..., SitemapBuildResult]:
"""Return a helper that builds a synthetic sitemap-enabled Sphinx site."""

@pytest.fixture
def build_og_site(
tmp_path: pathlib.Path,
) -> t.Callable[..., OgBuildResult]:
"""Return a helper that builds a synthetic OG-enabled Sphinx site.

  1. _resolve_locales only treats the literal list [None] as the suppress-hreflang sentinel; a tuple (None,) (which Sphinx accepts with only a warning, since types= is advisory) bypasses the check, returns [None] from list(configured), and crashes downstream in _hreflang_formatter on "_" in lang against None (bug due to if configured == [None]: return [] not handling tuple equivalents)

if configured:
if configured == [None]:
return []
return list(configured)

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

tony added 12 commits April 25, 2026 11:13
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
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
…ppress sentinel

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
…sages

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
…e URL

why: _write_sitemap built each <loc> 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
…oc directives

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.
…ia sphinx-autodoc-docutils

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.
…d role discovery

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 :: <pkg>`` 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.
…utodoc-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.
…ve index

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.
…setup() replay fails

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.
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.
@tony tony changed the title Add gp-opengraph and gp-sitemap workspace packages Add sphinx-gp-opengraph + sphinx-gp-sitemap; register-aware autodoc-docutils discovery Apr 25, 2026
tony added 12 commits April 25, 2026 13:43
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.
…replay_setup; consolidate docs/_ext recorder

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.
…olve

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.
…ing nesting level

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 <br> or <img> 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<br>more text<img>final"
  produces "textmore textfinal" with self.level returning to 0.
…xplicitly

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.
…te_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.
why: _make_tag() only replaced double quotes ("→&quot;), so titles
and site names containing & < > emitted invalid attribute markup
straight into the page head — e.g. <meta property="og:title"
content="AT&T" />. 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;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;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.
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.
…nv.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 <a href> 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.
…TML self-close

why: 6393215 added a void-element guard to handle_starttag so HTML5
forms like <br> and <img> stop incrementing self.level. But Python's
html.parser.HTMLParser routes XHTML self-closing forms (<br/>, <img/>)
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<br/>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
  <br/>, <img/>, <hr/> — title text after each must reach
  text_outside_tags.
…tion

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.
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 <abbr>→<span> 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.
@tony tony merged commit de3c52e into main Apr 25, 2026
40 checks passed
@tony tony deleted the seo-packages branch April 25, 2026 22:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants