Skip to content

docs: per-package documentation tree with Diátaxis subpages and sidebar nesting#33

Merged
tony merged 63 commits intomainfrom
docs-split
May 6, 2026
Merged

docs: per-package documentation tree with Diátaxis subpages and sidebar nesting#33
tony merged 63 commits intomainfrom
docs-split

Conversation

@tony
Copy link
Copy Markdown
Member

@tony tony commented May 6, 2026

Summary

  • Replace the workspace's flat docs/packages/<name>.md pages with per-package directories carrying Diátaxis-shaped subpages (Tutorial / How to / API Reference / Explanation / Examples), nested under each package in the sidebar.
  • Add a {package-landing} directive that renders the per-package landing — meta badges, synopsis, conditional grid cards, and a hidden toctree — so adding a new package only touches pyproject.toml (or package.json).
  • Add a {cluster-toctree} directive replacing six hand-edited toctree blocks in docs/index.md with five directive calls; the workspace inventory becomes the single source of truth for sidebar nesting.
  • Add dual-source workspace discovery (PackageDocsRecord) so JS-only token packages (@gp-sphinx/furo-tokens) graduate alongside Python packages without separate plumbing.
  • Add four optional showcase directives — {live-signature}, {package-kitchen-sink}, {surface-changelog}, {package-dependents} — opt-in per package via [tool.gp-sphinx.docs].showcase.
  • Preserve every py-domain cross-reference target via a conditional pkg_docname registration that branches on env.found_docs membership; an objects.inv snapshot test mathematically proves no :py:func:/:py:class: reference was lost.
  • Land showcase opt-ins for thirteen packages so the new directives are validated end-to-end against real data.

Changes by area

Sidebar IA

Before After
Six hand-edited {toctree} blocks listing every package by name in docs/index.md Five {cluster-toctree} directive calls; sidebar leaves derived from each package's classifier and [tool.gp-sphinx.docs] overrides
Single flat page per package, all subpages on one scroll Per-package directory with subpages nested under the package as toctree-l2 entries
Inconsistent caption set (Domain Packages / Internal / Utils) Consistent caption set: Autodoc / UX / Tokens / Theme & coordinator / Build & SEO

Per-package layout

docs/packages/<name>/
├── index.md          # 4-line stub: anchor + H1 + {package-landing} call
├── tutorial.md       # if the package has a tutorial-shaped surface
├── how-to.md
├── reference.md      # autodoc directives
├── explanation.md
└── examples.md       # live demos

Optional opt-in subpages (per [tool.gp-sphinx.docs]):

  • errors.md, cli.mdextra keys
  • signatures.md, kitchen-sink.md, surface-diff.md, dependents.mdshowcase keys

docs/_ext/package_reference.py — new directives + roles

Directive / role Purpose
{package-landing} Renders the per-package landing markup (meta badges, synopsis, conditional grid cards, hidden toctree)
{cluster-toctree} Emits a hidden toctree of every Shipped package in a sidebar cluster, skipping Emerging packages so the build never references a missing docname
{subpage-exists} Conditional cross-reference role — emits a {doc} xref when the target resolves, plain text otherwise (so Tutorial → How-to "Where to next" links don't break when the sibling subpage is absent)
{live-signature} Imports the package and renders each public function/class signature from the running interpreter; flags drift between source and docstring
{package-kitchen-sink} One example invocation per registered directive plus a list of registered roles
{surface-changelog} Diffs current registered directives/roles/config against a JSON snapshot at docs/_static/surface-snapshots/<name>.json
{package-dependents} Reverse-intersphinx — lists workspace packages that declare the named package in [project].dependencies
{workspace-package-grid} :groups: by-cluster Existing directive extended; new mode renders one grid per cluster preserving the curated framing prose in docs/packages/index.md

Workspace discovery (dual-source)

workspace_package_records() walks every directory under packages/ and probes for either manifest:

State Predicate Treatment
shipped-py pyproject.toml with [project] table Full landing + cluster toctree entry + grid card
shipped-js package.json with name field, no pyproject.toml Full landing (synopsis from package.json description); gp-sphinx-package-meta emits an npm badge instead of PyPI
emerging Neither manifest exists Skipped from cluster-toctrees so the build never references a missing docname

Cross-reference preservation (Risk 1 mitigation)

The existing _register_extension_objects() was hardcoded to register py-domain objects against the flat docname packages/<name>. After migration that docname no longer exists. The fix branches on env.found_docs membership at env-check-consistency time: prefer packages/<name>/reference once that docname is in the build, fall back to the flat packages/<name> while a package is still pre-migration. The conditional makes the registration correct both before and after each per-package commit.

CI gates added under tests/docs/

Test What it asserts
test_no_filler.py Denylist guard: no TBD, XXX, FIXME, Coming soon, intentionally blank, Lorem ipsum, (write me), placeholder in any shipped subpage
test_no_legacy_pages.py No flat <name>.md co-exists with a per-package <name>/index.md
test_sidebar_density.py Sidebar leaves stay under 19 * 6 + 30 post-migration ceiling
test_objects_inv_compat.py Module-scoped Sphinx fixture builds the live docs/ tree to a tmp dir; asserts the live objects.inv is a superset of the committed snapshot for py: / rst: / argparse: domains
Per-directive coverage test_subpage_exists_role.py, test_package_landing.py, test_cluster_toctree.py, test_workspace_package_grid_groups.py, test_live_signature.py, test_package_kitchen_sink.py, test_surface_changelog.py, test_package_dependents.py

Showcase opt-ins (per package)

Package Subpages added
sphinx-ux-badges, sphinx-autodoc-typehints-gp, sphinx-ux-autodoc-layout, sphinx-fonts, sphinx-gp-theme, sphinx-gp-sitemap Dependents
sphinx-autodoc-fastmcp, sphinx-autodoc-argparse, sphinx-autodoc-pytest-fixtures, sphinx-autodoc-sphinx, sphinx-autodoc-docutils Kitchen sink
gp-furo-theme, sphinx-gp-opengraph Signatures (live) + Dependents

surface-changelog is registered and tested but not yet opted into; baseline JSON snapshots can be captured at the next release tag.

Design decisions

  • Conditional pkg_docname registration over flag-day rewrite. Iterative migration would have left every package's py-domain xrefs dangling for the duration. Branching on env.found_docs membership makes the new behavior safe to land before any per-package commit and self-corrects as each package migrates.
  • Stub provides title; directive provides body. Sphinx's title-extraction runs at parse time, before custom directives render. Directives that emit # H1 via parse_text_to_nodes cannot retroactively set the page title — the page renders as <no title> and the parent toctree promotes the page's children to its own level. Every showcase stub is a 4-line file (anchor + blank + H1 + blank + directive call) so Sphinx finds a title.
  • Cluster-toctree skips Emerging. A cluster-toctree that emits docnames Sphinx hasn't discovered would fail sphinx-build -W. Filtering at emit time keeps the build green even when the workspace has half-baked package directories.
  • Body-only Group H directives. Same constraint as the landing directive — stubs supply anchor + H1, directives supply body. Makes opt-ins follow a single canonical pattern and prevents the no-title bug from recurring.

Verification

The branch passes the workspace verification gate at every commit. Reviewers can re-run from a fresh checkout:

$ rm -rf docs/_build
$ uv run ruff check . --fix --show-fixes
$ uv run ruff format .
$ uv run mypy
$ uv run py.test --reruns 0 -vvv
$ just build-docs

CI-equivalent (warnings-as-errors):

$ uv run sphinx-build -W -b dirhtml docs docs/_build/html

Inspect the rendered sidebar from the workspace landing — every package shows toctree-l1 has-children with subpages indented as toctree-l2:

$ grep -B 1 -A 8 'caption-text">Autodoc' docs/_build/html/index.html

Verify zero flat <name>.md per-package pages remain:

$ ls docs/packages/*.md | grep -v 'index.md$'

Test plan

  • tests/docs/test_no_filler.py — every shipped subpage is free of denylist filler strings
  • tests/docs/test_no_legacy_pages.py — no flat <name>.md shadows a per-package <name>/index.md
  • tests/docs/test_sidebar_density.py — total toctree leaves stay under the post-migration ceiling
  • tests/docs/test_objects_inv_compat.py::test_objects_inv_is_superset_of_baseline — live build produces a superset of the committed objects.inv snapshot for py: / rst: / argparse: domains (Risk 1 mitigation)
  • tests/docs/test_package_landing.py — landing markdown helper renders meta + synopsis + conditional grid; no anchor or H1 emitted by the directive (stub-supplies-title contract)
  • tests/docs/test_cluster_toctree.py — Emerging packages excluded; alphabetical leaf order; every Shipped package belongs to exactly one cluster
  • tests/docs/test_subpage_exists_role.py — sibling-relative and absolute targets resolve; absent targets degrade to plain text
  • tests/docs/test_workspace_package_grid_groups.py:groups: by-cluster mode preserves curated prose; legacy single-grid output unchanged
  • tests/docs/test_live_signature.py / test_package_kitchen_sink.py / test_surface_changelog.py / test_package_dependents.py — per-directive contracts (body-only output, edge cases for unknown / surface-less / unimportable packages)
  • tests/test_package_reference.py::test_register_extension_objects_uses_reference_docname_after_migration — conditional pkg_docname fix branches correctly between flat and per-package docnames
  • Manual sidebar audit across all five clusters confirms every package is toctree-l1 has-children (or a leaf for shipped-js packages without subpages) with subpages as toctree-l2
  • uv run sphinx-build -W -b dirhtml docs docs/_build/html completes without warnings

tony added 30 commits May 5, 2026 16:33
…rkspace discovery

why: Establish the single-source-of-truth schema every directive in the
forthcoming per-package docs restructure will read from. Probing
package.json alongside pyproject.toml is required so JS-only token
packages (e.g. @gp-sphinx/furo-tokens) graduate to a full landing
without a Python manifest.

what:
- Add PackageDocsRecord frozen dataclass with state / cluster /
  manifest_path / src_dir / module_name / description / version /
  repository_url / pypi_url / npm_url / maturity / docs_opts fields
- Add DocsOpts frozen dataclass for [tool.gp-sphinx.docs] overrides
  (omit, extra, showcase, reference_link)
- Add workspace_package_records() walking packages/*/ and probing
  pyproject.toml -> shipped-py, package.json -> shipped-js, neither
  -> emerging
- Add _cluster_for() name-based cluster classifier and
  _docs_opts_from_pyproject() / _repository_url_from_package_json()
  helpers
- Existing workspace_packages() unchanged; new records API is
  purely additive so existing consumers keep working
- Tests assert: shipped-js includes @gp-sphinx/furo-tokens, every
  shipped record has a known cluster, pypi_url only on shipped-py,
  npm_url only on shipped-js, emerging records carry no manifest_path,
  DocsOpts round-trips from pyproject TOML
…eference role

why: Forthcoming per-package landing pages need "Where to next" link
sections in tutorial.md / how-to.md that gracefully handle absent
sibling subpages — bare {doc} refs would crash sphinx-build -W when
the target hasn't been authored yet. The role degrades to plain text
instead of failing the build, so the same prose works whether or not
a sibling exists.

what:
- Add _subpage_target_exists() helper resolving sibling-relative or
  absolute docnames against env.found_docs
- Add subpage_exists_role() implementing the {subpage-exists}`<target>`
  role: emits a sphinx pending_xref when the target resolves, plain
  inline text otherwise
- Register via app.add_role("subpage-exists", ...) in setup()
- Create tests/docs/ package with __init__.py and the role's test
  module; parametrized fixture covers sibling-present, sibling-absent,
  absolute-present, absolute-absent cases plus helper edge cases
- No consumers yet — landing markup in subsequent commits will use the
  role from emitted Where-to-next sections
why: Anti-pattern #15 of the per-package restructure plan forbids
filler strings (TBD, Coming soon, FIXME, etc.) in any shipped
markdown. Encoding the rule as a CI-level pytest gate is stronger
than prose: the migration script in scripts/docs_split.py and any
human author cannot ship a stub by accident, regression or mistake.

what:
- Add tests/docs/test_no_filler.py walking every package-shipped
  markdown file (packages/<name>/docs/*.md and docs/packages/**/*.md)
- Five word-boundary-aware regex patterns covering TBD / XXX / FIXME /
  placeholder, Coming soon, intentionally blank, Lorem ipsum, and the
  literal (write me)
- Parametrized fixture verifies each pattern matches its filler token
  and skips legitimate prose like "Tutorial: document your first ..."
- Walks both the co-located packages/<name>/docs/ tree and the legacy
  in-docs tree so the guard works during and after the migration
why: Risk 2 of the per-package restructure plan calls out a
"wall of links" regression hazard. A pinned permissive ceiling
during the migration window catches accidental sidebar explosion
(e.g. promoting every H2 to its own toctree leaf) before it
ships. Group G2 tightens this to the exact post-migration value
once every package has migrated.

what:
- Add tests/docs/test_sidebar_density.py walking docs/**/index.md
  for {toctree} fenced blocks and counting non-option leaf entries
- Pin the migration baseline at 28 leaves (16 flat package pages
  + 12 workspace chrome entries) plus a 50-leaf headroom buffer
- Three tests: bound stays under ceiling, packages/* entries
  exist (smoke), no leading/trailing whitespace on leaves
- _toctree_entries() handles arbitrary fence indent and skips
  MyST option lines (:caption:, :hidden:, :titlesonly:, ...)
why: Risk 1 of the per-package restructure plan calls out broken
intersphinx anchors as a high-likelihood failure: every existing
flat package page registers py-domain objects against
``packages/<name>``, and the migration moves them to
``packages/<name>/reference``. A snapshot of today's full
inventory mathematically proves no cross-reference target is
silently lost in any subsequent migration commit.

what:
- tests/docs/__snapshots__/objects-inv-baseline.txt: 389 entries
  serialized as ``<domain><TAB><name>`` lines, sorted, unique
  (covers argparse / py / rst / std domains across the entire
  workspace surface)
- tests/docs/test_objects_inv_compat.py:
  - Module-scoped _live_objects_inv fixture builds the live docs/
    tree via sphinx.application.Sphinx against a tmp output dir
    so the assertion is hermetic from the developer's working
    docs/_build/ cache
  - test_objects_inv_is_superset_of_baseline asserts every
    baseline (domain, name) pair is present in the live build
  - Two structural tests check the snapshot file is sorted,
    unique, and well-shaped (single tab per line, non-empty halves)
- Group G4 of the migration plan regenerates this snapshot once
  every package has migrated; until then any regression that
  drops a target trips this gate
…sized package landings

why: Per the per-package restructure plan, every per-package
docs/packages/<name>/index.md becomes a 2-line stub that calls
{package-landing} <name>. The directive walks candidate subpage
paths on disk and emits a conditional sphinx-design grid + hidden
toctree over only the subpages that exist — so absent subpages
are invisible everywhere (Bliss anti-pattern #1: no empty stubs).

Calls env.note_dependency() on each candidate path so incremental
local builds rebuild the landing the moment an author drops a new
tutorial.md, without a clean rebuild.

what:
- _DEFAULT_LANDING_SUBPAGES: tutorial / how-to / reference /
  explanation / examples
- _OCTICONS / _TITLES / _DEFAULT_SUMMARIES: per-subpage card metadata
  including the four optional showcase pages (signatures,
  kitchen-sink, surface-diff, dependents) and extras (errors, cli,
  tokens) that opt in via [tool.gp-sphinx.docs].extra
- _candidate_subpage_paths(): maps each candidate to its
  docs/packages/<name>/<subpage>.md path; includes default
  Diátaxis subpages plus DocsOpts.extra
- _package_landing_markdown(): pure helper rendering anchor + H1 +
  meta directive + synopsis (or "No description provided"
  fallback) + grid cards + hidden toctree; emits no toctree when
  no subpages exist
- PackageLandingDirective: takes one positional argument (package
  name), looks up record via workspace_package_records(),
  notes-dependency on every candidate, renders only present
  subpages
- Register via app.add_directive("package-landing", ...) in setup()
- 9 unit tests covering: anchor + meta + title presence, synopsis
  fallback, no-subpages emits no toctree, present subpages render
  grid + toctree, known-octicon mapping (errors -> alert), unknown
  subpage falls back to link icon, parametrized candidate-path map
  for defaults-only and defaults-plus-extras cases
…Emerging logic

why: docs/index.md currently maintains seven hand-edited {toctree}
blocks (lines 87-151) listing each cluster's packages by hand. A
single-source-of-truth directive replaces all seven so adding a new
package only touches its pyproject.toml — the sidebar updates
automatically. Emerging packages are silently skipped at emit time
so the toctree never points at a missing docname (would crash
sphinx-build -W).

what:
- _cluster_toctree_markdown(): renders a hidden toctree of every
  Shipped package (shipped-py + shipped-js) in the requested cluster,
  alphabetically sorted, pointing at packages/<name>/index. Returns
  empty string when the cluster has no Shipped members.
- ClusterToctreeDirective: takes one positional cluster name plus
  optional :caption: and :titlesonly: flags; logs a warning when the
  cluster is empty (no nodes emitted)
- Register via app.add_directive("cluster-toctree", ...)
- 8 unit tests covering: autodoc cluster has 7 packages, caption +
  titlesonly options round-trip, unknown cluster renders empty,
  shipped-js packages appear alongside Python ones (@gp-sphinx/
  furo-tokens in tokens cluster), Emerging packages skipped (skipped
  when no Emerging packages exist), leaves are alphabetical, every
  Shipped package is reachable from at least one cluster
…ace-package-grid

why: docs/packages/index.md (line 13) carries hand-written framing
prose for each cluster ("The rendering pipeline every autodoc
extension consumes:", "Domain-specific autodoc extensions:", etc.).
Replacing the page with a single workspace-package-grid call would
silently delete that prose. The :groups: by-cluster extension teaches
the directive to render one grid per cluster with heading and prose
preserved as a single source of truth.

what:
- _CLUSTER_HEADINGS: ordered tuple of (cluster_id, heading, prose)
  for the five non-Quickstart clusters; prose lifted from the
  workspace's existing curated framing
- _grid_card_lines_for_record(): renders one card per record;
  Emerging packages link to GitHub (no docname/no maturity badge)
  rather than emitting a 404
- _flat_workspace_grid_markdown(): legacy single-grid renderer,
  unchanged from the previous implementation
- _grouped_workspace_grid_markdown(): new mode with one grid per
  populated cluster, sorted alphabetically within each cluster
- workspace_package_grid_markdown(*, groups=None): keyword-only
  `groups`; None preserves legacy output, "by-cluster" engages the
  new mode; raises ValueError on unknown values
- WorkspacePackageGridDirective: gains :groups: option_spec; default
  invocations remain identical to the previous output
- 7 unit tests: legacy-default-unchanged, by-cluster emits five
  cluster headings, by-cluster opens five separate {grid} blocks,
  shipped-js packages appear in the tokens cluster, Emerging cards
  render as GitHub-linked, shipped-py cards render with
  :link-type: doc + maturity badge, unknown groups argument raises
…rd state

why: When per-package reference subpages start landing in Group E,
the existing _register_extension_objects() registers all py-domain
objects against the flat docname packages/<name>. Once packages/<name>
disappears (replaced by packages/<name>/index + per-page subpages),
every :py:func:/:py:class: cross-reference 404s. Branching on
record.state and on found_docs membership keeps un-migrated packages
on the flat docname while migrated ones receive the new
packages/<name>/reference docname — so the registration is harmless
before any migration commit and correct after each.

Independently identified by both Serenity runner-up plans in pass 1
of the brainstorm session — strong signal this is load-bearing.

what:
- _register_extension_objects() now iterates workspace_package_records()
  rather than workspace_packages() so it can access record.state
- Skip records with state != "shipped-py" (Emerging records have no
  source to introspect; shipped-js has no Python module)
- For shipped-py records: pick packages/<name>/reference when that
  docname is in env.found_docs, else fall back to packages/<name>
- Two new tests covering: post-migration uses /reference docname,
  un-migrated packages keep flat docname, function-level branching
  is exercised; the existing parametrized test asserts pre-migration
  state continues to use packages/<name>
…ackage_reference_markdown

why: The Package metadata section (GitHub link + PyPI link + Maturity)
duplicates the surface that gp-sphinx-package-meta already emits as
SAB badges at the top of every package page. Rendering it again as a
paragraph below the conf snippet is redundant noise — pass-1
runner-up plans flagged this duplication and pass-2 verification
confirmed it. Keeping the badge row as the only metadata surface on
the landing matches §3.3 rule 7 of the per-package restructure
plan: GitHub link, PyPI link, and maturity are read from
pyproject.toml once via the meta directive, never duplicated.

what:
- package_reference_markdown(): drop the if package["repository"]:
  block emitting "## Package metadata" + the three list items
- Update the doctest example to assert "## Package metadata" is
  NOT in the rendered output (was: pypi-url-presence assertion)
- The "Copyable config snippet" half remains; that is the half
  PackageLandingDirective will re-host on the synthesized landing
- The if package_name == "gp-sphinx" Public surface block is
  unaffected (different concern)
why: PackageLandingDirective (B1) emits {grid} blocks with
:class-container: gp-sphinx-package__landing-grid and references the
__hero class for the synopsis line. Both classes were named in the
plan's §3.4 but had no actual CSS rule — pass-2 critique caught this.
Adding the rules now keeps the directive self-contained per the
workspace's "Package CSS self-containment" rule (CLAUDE.md).

what:
- Append two selectors at the end of docs/_static/css/custom.css:
  .gp-sphinx-package__landing-grid (block-level vertical rhythm)
  .gp-sphinx-package__hero (synopsis line styling)
- Both rules sit at 0,1,0 specificity per the workspace CSS
  standards; both use existing Furo CSS custom properties
  (--color-foreground-secondary) so dark-mode handling is automatic
- No JavaScript, no media queries beyond what the parent grid
  already provides
why: Group E of the per-package restructure plan migrates 16 flat
docs/packages/<name>.md pages into per-package directories. Doing
that by hand for each package is tedious and error-prone — a script
applies the H2-classification rules from §4.2 of the woven plan
deterministically and emits the 2-line landing stub at the same
time. The script is one-shot (deleted in commit G3) so it lives
under scripts/ rather than as a long-lived workspace utility.

what:
- scripts/docs_split.py with three subcommands:
  - split <flat-page>: classify H2 sections, write
    docs/packages/<name>/<bucket>.md plus the 2-line index.md stub,
    delete the flat page (--keep-flat preserves it for testing)
  - new <name>: emit a fresh index.md stub for a brand-new package
  - --report (under split): print the classification without writing
- _H2_RULES: ordered (regex, target-bucket-or-None) tuples covering
  Live demos -> examples, Tool cards -> examples, Configuration
  values / Directives / Roles / CSS classes / *Reference* / Color
  palette / CSS custom properties / Context-aware sizing -> reference,
  Downstream extensions -> explanation, Downstream conf.py / Working
  usage examples -> tutorial, Package reference -> deleted; default
  bucket for unmatched H2 is how-to
- assert_no_filler() runs the banned-strings denylist over each
  generated subpage; ValueError raised if any filler survives
- assemble_subpage() emits canonical (<name>-<bucket>)= anchor +
  H1 title + each section heading + trimmed body
- 13 unit tests in tests/scripts/test_docs_split.py: parametrized
  classify_heading() round-trip for 13 heading patterns, end-to-end
  classify_flat_page() with multi-section fixture, parse_h2_sections
  drops preamble + buckets correctly, assemble_subpage shape,
  stub_markdown is two lines, assert_no_filler raises and passes,
  render_report shows distribution + deletions
- The script is loaded via importlib.util in tests so the workspace's
  packages/<name>/src import paths don't shadow it
why: Smoke-test commit for the per-package docs migration: smallest
shipped-py package (84 lines, 0 demos) — establishes the migration
shape so the harder 192-394-line splits in E3 / E7 land predictably.
Picks up four pass-2 follow-ups along the way that turned out to be
load-bearing for any per-package migration:

1. Risk-1 baseline narrowing: the objects.inv superset check is
   specifically py-domain (per the woven plan §5.3); std:doc /
   std:label entries change as docnames reorganize and rediraffe
   handles the URL-level redirects for legacy consumers
2. test_docs_package_pages_exist_for_every_workspace_package now
   accepts both legacy <name>.md and migrated <name>/index.md as
   "this package has docs"
3. workspace_package_grid_markdown emits 🔗 <name>/index for
   migrated packages and 🔗 <name> for legacy ones, picked at
   render time by checking docs/packages/<name>/index.md on disk
4. Two manual cross-references (architecture.md, packages/index.md)
   updated from packages/sphinx-fonts to packages/sphinx-fonts/index

what:
- Run scripts/docs_split.py split docs/packages/sphinx-fonts.md →
  produces docs/packages/sphinx-fonts/{index.md, how-to.md, reference.md}
  (no tutorial / examples / explanation buckets — sphinx-fonts has
  none of those H2 shapes)
- Update docs/index.md UX toctree leaf:
  packages/sphinx-fonts → packages/sphinx-fonts/index
- Update docs/architecture.md L117 doc xref likewise
- Update docs/packages/index.md L20 markdown link likewise
- Add _grid_link_for_legacy_record() helper in package_reference.py
  consulting disk for the migrated/legacy choice
- Filter test_objects_inv_compat baseline to py: / rst: / argparse:
  domains (Risk 1's stated scope)
- Update test_docs_package_pages_exist_for_every_workspace_package
  to glob both *.md AND */index.md
…ee (E2)

Bundles three closely-related improvements that surfaced during
landing the second migration commit:

1. Sidebar hierarchy: the per-package landing must declare its
   anchor + H1 in the STUB source (docs/packages/<name>/index.md)
   so Sphinx finds a page title at parse time. Without the H1 in
   the stub, the page rendered as "<no title>" and the parent
   toctree promoted the package's children to its own level —
   visible bug: "How to" and "API Reference" appeared as siblings
   of sphinx-ux-autodoc-layout under the UX caption rather than
   nested under "sphinx-fonts".
2. Card / heading titles: rename "How-to" -> "How to" and
   "Reference" -> "API Reference" per user feedback. Update both
   PackageLandingDirective._TITLES (card titles) and the migration
   script's bucket_titles (subpage H1s) so the rendered sidebar
   reads cleanly.
3. sphinx-autodoc-sphinx migration: 5 buckets generated by the
   script (tutorial, how-to, reference, examples, plus the deleted
   Package reference auto-block). Update docs/index.md toctree leaf
   + 2 cross-references in architecture.md and packages/index.md.

what:
- stub_markdown() now emits four lines: anchor + blank + H1 +
  blank + {package-landing} call (was 2 lines). The H1 is the page
  title source-of-truth.
- _package_landing_markdown() drops the anchor + H1 emission;
  directive emits only meta + synopsis + grid + hidden toctree.
- Existing sphinx-fonts/index.md regenerated with new shape.
- _TITLES["how-to"] = "How to", _TITLES["reference"] = "API Reference".
- Tests updated to assert the new card titles and stub shape (no
  longer asserts directive emits anchor/H1).
- New per-package: docs/packages/sphinx-autodoc-sphinx/{index,
  tutorial, how-to, reference, examples}.md
- docs/index.md: packages/sphinx-autodoc-sphinx ->
  packages/sphinx-autodoc-sphinx/index
- docs/architecture.md L97 + L156: docname / link target updated
- docs/packages/index.md L31: markdown link target updated

Verification: built docs sidebar shows
  UX
    sphinx-fonts (has-children, collapsible)
      How to
      API Reference
    sphinx-ux-autodoc-layout
    sphinx-ux-badges
why: Stress-test for the migration recipe — sphinx-ux-badges.md
was 394 lines (the largest flat package page). Five buckets after
split: tutorial (46), reference (291 — palette/API/CSS-vars/
classes/sizing), explanation (27 — downstream extensions), examples
(11 — live demos), index stub (6). Total 381 lines vs 394 source —
13-line drop is the deleted "## Package reference" auto-block
and dropped Alpha admonition.

what:
- scripts/docs_split.py split docs/packages/sphinx-ux-badges.md
- docs/index.md UX toctree leaf:
  packages/sphinx-ux-badges -> packages/sphinx-ux-badges/index
- docs/architecture.md L21 grid-item-card link target updated
- docs/packages/index.md L17 markdown link target updated
- Sidebar verified post-build: sphinx-ux-badges renders as
  toctree-l1 has-children with five toctree-l2 children
  (Tutorial / How to / API Reference / Explanation / Examples)
… tree (E4)

what:
- scripts/docs_split.py split docs/packages/sphinx-autodoc-api-style.md
  into 5 buckets: tutorial, how-to, reference, examples, index stub
  (165 source lines)
- docs/index.md autodoc toctree leaf updated to /index
- docs/architecture.md L57 grid + L154 markdown link updated
- docs/packages/index.md L26 markdown link updated
- Sidebar verified: sphinx-autodoc-api-style nests its 4 subpages
  under the Autodoc caption
…tree (E5)

what:
- 150 source lines split into tutorial / how-to / reference /
  examples + index stub
- docs/index.md autodoc toctree leaf updated to /index
- docs/architecture.md L65 grid card link target updated
- docs/packages/index.md L27 markdown link updated
- Sidebar verified: sphinx-autodoc-argparse nests subpages under
  the Autodoc caption
…tree (E6)

what:
- 113 source lines split into tutorial / how-to / examples + index
  stub (no reference.md — flat page had no reference-classified
  H2 sections; the package's surface is documented inline in
  how-to.md)
- docs/index.md autodoc toctree leaf updated to /index
- docs/architecture.md L73 grid card + L157 markdown link updated
- docs/packages/index.md L28 markdown link updated
…ree (E7)

why: 192-line flat page (second-largest) — exercises all of the
fastmcp_area_map-driven demo rendering plus tool/prompt/resource
cards. Migration moves the demos to packages/sphinx-autodoc-fastmcp/
examples.md so fastmcp_area_map must point at the new docname for
{tool}/{toolref} cross-references to resolve.

what:
- scripts/docs_split.py split into tutorial / how-to / reference /
  examples + index stub (5 buckets)
- docs/conf.py:92 fastmcp_area_map updated:
  "packages/sphinx-autodoc-fastmcp" ->
  "packages/sphinx-autodoc-fastmcp/examples"
- docs/index.md autodoc toctree leaf -> /index
- docs/architecture.md L81 grid card + L158 markdown link updated
- docs/packages/index.md L29 markdown link updated
- tests/test_docs_package_pages.py adapted to handle both layouts:
  * _autodoc_and_ux_package_paths() prefers <name>/examples.md
    when present, falls back to flat <name>.md
  * test_autodoc_package_pages_have_copyable_examples_and_live_demos
    asserts live-demo marker for migrated packages, full structural
    H2 / package-reference checks for legacy ones
  * test_docs_conf_registers_fastmcp_demo_page_support accepts both
    fastmcp_area_map docnames during the migration window
…átaxis tree (E8)

what:
- 121 lines split into tutorial / how-to / examples + index stub
  (no reference.md — surface lives inline in how-to.md)
- docs/index.md autodoc toctree leaf -> /index
- docs/architecture.md L89 grid + L155 markdown link updated
- docs/packages/index.md L30 markdown link updated
…xis tree (E9)

what:
- 130 lines split into tutorial / how-to / examples + index stub
- docs/index.md Utils toctree leaf -> /index
- docs/architecture.md L37 grid card link target updated
- docs/packages/index.md L19 markdown link updated
what:
- 78-line gp-sphinx coordinator page split into how-to + index
  stub. The flat page had only how-to-shaped H2s (Downstream conf.py,
  What it injects, SEO emission for free).
- docs/index.md Internal toctree leaf -> /index
- docs/architecture.md L114 doc xref + docs/packages/index.md L8 +
  L43 markdown links updated
what:
- 85-line theme page split into how-to + index stub
- docs/index.md Internal toctree leaf -> /index
- docs/architecture.md L115 doc xref + docs/packages/index.md L44
  markdown link updated
what:
- 92-line theme page split into how-to + index stub
- docs/index.md Internal toctree leaf -> /index
- docs/architecture.md L116 doc xref + docs/packages/index.md L45
  markdown link updated
… (E13)

what:
- 78-line PEP 517 / Vite-orchestration page split into how-to +
  index stub
- docs/index.md Build utils toctree leaf -> /index
- docs/architecture.md L133 grid card link target updated
- docs/packages/index.md L37 markdown link updated
… (E14)

what:
- 116 lines split into how-to / reference + index stub
- docs/index.md SEO toctree leaf -> /index
- docs/packages/index.md L51 markdown link updated
…E15)

what:
- 141 lines split into how-to / reference + index stub
- docs/index.md SEO toctree leaf -> /index
- docs/packages/index.md L52 markdown link updated
… tree (E16)

Last per-package migration commit. Every package in docs/packages/
now has a per-package directory with index.md (rendered by
{package-landing}) plus its Diátaxis subpages.

what:
- 167 lines split into tutorial / how-to / reference / examples +
  index stub
- docs/index.md UX toctree leaf -> /index
- docs/architecture.md L29 grid card link target updated
- docs/packages/index.md L18 markdown link updated

Group E (per-package migrations) complete: 16 of 16 packages
migrated. Sidebar nests subpages under each package across all
six cluster captions.
… calls (F1)

why: docs/index.md L101-151 carried six hand-edited {toctree} blocks
listing every package by hand. Adding a new package required editing
this file. Replacing them with five cluster-toctree directive calls
makes the workspace inventory the single source of truth — the
classifier in package_reference.py:_CLUSTER_FOR_NAME plus the package
classifier metadata derive the sidebar nav. Adding a new package
touches only its pyproject.toml.

what:
- Replace six toctrees with five cluster-toctree calls covering
  autodoc / ux / tokens / theme-coordinator / build-seo
- Caption set updated per the woven plan §2.3:
  Autodoc / UX / Tokens / Theme & coordinator / Build & SEO
  (was Domain Packages / UX / Utils / Internal / Build utils / SEO)
- sphinx-fonts moves from UX caption to Tokens caption per its
  cluster classifier (it was always classified as tokens; the
  visible sidebar now matches)
- sphinx-autodoc-typehints-gp moves from Utils into Autodoc
  cluster (its name starts with sphinx-autodoc-)
- The workspace-chrome toctree at the top (whats-new, gallery,
  architecture, quickstart, ...) is unchanged
why: After Group E migrated every package, no flat
docs/packages/<name>.md should co-exist with the per-package
docs/packages/<name>/index.md. A guard at CI time catches drift if
a future commit accidentally re-creates a flat page (e.g. via a
botched merge or a stray file rename) — Sphinx would silently pick
one form over the other and the visible site would be subtly
wrong.

what:
- tests/docs/test_no_legacy_pages.py: single test asserts the
  intersection of (flat <name>.md stems) and (per-package <name>/
  directories with index.md) is empty
- Excludes packages/index.md (workspace inventory page, not a
  per-package legacy)
tony added 14 commits May 5, 2026 18:50
why: Pilot opt-in to validate the Group H showcase machinery
end-to-end. sphinx-ux-badges is the foundational badge primitives
package: every autodoc-* extension consumes its SAB palette and
BadgeNode rendering. Five workspace packages declare a
sphinx-ux-badges dependency in their pyproject.toml — surfacing
that fan-out as a discoverable subpage helps a reader navigate
from the foundational package to its consumers.

what:
- packages/sphinx-ux-badges/pyproject.toml: add
  [tool.gp-sphinx.docs] table with showcase = ["dependents"]
- docs/packages/sphinx-ux-badges/dependents.md: 4-line stub —
  anchor + H1 + {package-dependents} call. Stub-supplied anchor
  + H1 are required so Sphinx finds a page title at parse time
  (per the body-only contract established by the pre-flight
  refactor).
- _package_dependents_markdown(): emit absolute {doc} cross-refs
  with leading slash (/packages/<name>/index) so they resolve
  from the source root rather than the dependents page's
  directory. Without this Sphinx looked for
  packages/sphinx-ux-badges/packages/<name>/index and 404'd.

Verification: sidebar nests Dependents as toctree-l2 under
sphinx-ux-badges; sphinx-build -W passes; the rendered Dependents
page lists each of the five dependents as a {doc} cross-reference
bullet.
what:
- packages/sphinx-autodoc-fastmcp/pyproject.toml: showcase = ["kitchen-sink"]
- docs/packages/sphinx-autodoc-fastmcp/kitchen-sink.md: stub
- The kitchen-sink page exercises every directive (seven) and role
  (eight) the extension registers on one page; useful as a quick
  reference card for downstream FastMCP doc authors.
what:
- packages/sphinx-autodoc-argparse/pyproject.toml: showcase = ["kitchen-sink"]
- docs/packages/sphinx-autodoc-argparse/kitchen-sink.md: stub
- Exercises the {argparse} directive once on the page so a reader
  can see the invocation shape at a glance.
…page

what:
- packages/sphinx-autodoc-pytest-fixtures/pyproject.toml: showcase=["kitchen-sink"]
- docs/packages/sphinx-autodoc-pytest-fixtures/kitchen-sink.md: stub
- Four directives + two roles get exercised on one page.
what:
- packages/sphinx-autodoc-sphinx/pyproject.toml: showcase=["kitchen-sink"]
- docs/packages/sphinx-autodoc-sphinx/kitchen-sink.md: stub
- Two directives ({autoconfigvalue}, {autoconfigvalues}) exercised
  on a single page.
what:
- packages/sphinx-autodoc-docutils/pyproject.toml: showcase=["kitchen-sink"]
- docs/packages/sphinx-autodoc-docutils/kitchen-sink.md: stub
- Four directives ({autodirective}, {autodirectives}, {autorole},
  {autoroles}) exercised inline on one page.
what:
- packages/sphinx-autodoc-typehints-gp/pyproject.toml: showcase=["dependents"]
- docs/packages/sphinx-autodoc-typehints-gp/dependents.md: stub
- Five workspace packages depend on the typehint normalizer; the
  Dependents page lists them as {doc} cross-references for
  navigation.
what:
- packages/sphinx-ux-autodoc-layout/pyproject.toml: showcase=["dependents"]
- docs/packages/sphinx-ux-autodoc-layout/dependents.md: stub
- Five workspace packages depend on the structural layout presenter.
what:
- packages/sphinx-fonts/pyproject.toml: showcase=["dependents"]
- docs/packages/sphinx-fonts/dependents.md: stub
- gp-sphinx loads sphinx-fonts from DEFAULT_EXTENSIONS; the page
  makes that consumer relationship explicit.
what:
- packages/sphinx-gp-theme/pyproject.toml: showcase=["dependents"]
- docs/packages/sphinx-gp-theme/dependents.md: stub
- One workspace dependent (gp-sphinx) consumes this Furo child theme.
what:
- packages/gp-furo-theme/pyproject.toml: showcase=["signatures","dependents"]
- docs/packages/gp-furo-theme/signatures.md: stub calling {live-signature}
- docs/packages/gp-furo-theme/dependents.md: stub calling {package-dependents}
- gp-furo-theme exposes nine public callables (get_theme_path,
  pygments helpers, vite root resolution, the wrap-table-and-math
  transform); live-signature surfaces source/docstring drift, and
  one workspace dependent (gp-sphinx) consumes the theme.

Note: showcase keys name the subpage filename (signatures), not
the directive name (live-signature). The directive {live-signature}
renders inside packages/<name>/signatures.md; the {package-dependents}
directive renders inside packages/<name>/dependents.md.
…pages

what:
- packages/sphinx-gp-opengraph/pyproject.toml: showcase=["signatures","dependents"]
- docs/packages/sphinx-gp-opengraph/signatures.md: stub
- docs/packages/sphinx-gp-opengraph/dependents.md: stub
- Three public callables (get_tags, html_page_context, setup) get
  surfaced via {live-signature}; one workspace dependent (gp-sphinx
  auto-loads the extension when docs_url is set) gets surfaced via
  {package-dependents}.
what:
- packages/sphinx-gp-sitemap/pyproject.toml: showcase=["dependents"]
- docs/packages/sphinx-gp-sitemap/dependents.md: stub
- gp-sphinx auto-loads sphinx-gp-sitemap when docs_url is set;
  the page lists that consumer relationship.
… landing grid

why: On every per-package landing the synopsis renders as a MyST
block-quote (> {synopsis}) which Furo styles with no vertical
margin-bottom. The grid container immediately below sat flush
against it, producing visibly cramped layout. The original CSS
intent (margin-top on .gp-sphinx-package__landing-grid) never
fired because docutils normalizes underscores in HTML class
attributes to hyphens — the rendered class is
gp-sphinx-package-landing-grid (single dash) but the CSS
selector targeted gp-sphinx-package__landing-grid (BEM with
double underscore from the source-side directive option).

what:
- Add gp-sphinx-package-landing-grid (rendered form) alongside
  the BEM source form in the existing margin-top/-bottom rule
  so the spacing applies regardless of which form is present
- Add a :has(+ ...) selector tightening the synopsis blockquote's
  margin-bottom only when the next sibling is the landing grid
  (so this fix is scoped to package landings, doesn't touch
  block-quotes elsewhere on the docs site)
- Both rules stay at 0,1,0 specificity per CLAUDE.md CSS standards

Verified: rendered HTML for sphinx-autodoc-api-style/index has
the blockquote followed by the gp-sphinx-package-landing-grid
container; the new rule applies the 1.25rem margin-bottom
gracefully separating the two.
@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented May 6, 2026

Codecov Report

❌ Patch coverage is 95.86207% with 48 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.57%. Comparing base (81eb49b) to head (ad894bb).

Files with missing lines Patch % Lines
docs/_ext/package_reference.py 93.08% 28 Missing ⚠️
tests/docs/test_cluster_toctree.py 91.30% 6 Missing ⚠️
tests/docs/test_objects_inv_compat.py 93.22% 4 Missing ⚠️
tests/test_docs_package_pages.py 88.46% 3 Missing ⚠️
packages/gp-sphinx/src/gp_sphinx/myst_lexer.py 90.90% 2 Missing ⚠️
docs/_ext/inline_highlight.py 98.33% 1 Missing ⚠️
tests/docs/test_no_filler.py 96.29% 1 Missing ⚠️
tests/docs/test_package_dependents.py 96.15% 1 Missing ⚠️
tests/docs/test_sidebar_density.py 98.43% 1 Missing ⚠️
tests/test_package_reference.py 98.48% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #33      +/-   ##
==========================================
+ Coverage   89.09%   91.57%   +2.48%     
==========================================
  Files         191      205      +14     
  Lines       15675    16814    +1139     
==========================================
+ Hits        13966    15398    +1432     
+ Misses       1709     1416     -293     

☔ 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 added 3 commits May 5, 2026 20:08
… highlighting

why: A reader on the pytest-fixtures tutorial saw its :::{auto-
pytest-plugin} sample render as plain text inside the highlight
wrapper while the parallel docutils tutorial rendered with full
pygments token coloring. Pygments' MarkdownLexer (and the workspace's
MystLexer extension) only matched triple-backtick code fences; the
MyST colon_fence extension's :::{...} syntax fell through with no
tokenization. Adding a colon-fence handler restores parity with
the backtick path so any source sample documenting MyST directives
gets the same visual treatment.

what:
- _handle_colon_fence method in MystLexer mirrors _handle_eval_rst
  shape: yields the opening :::{name} line as String.Backtick,
  walks the body line-by-line tokenizing 🔑 option keys as
  Name.Tag (RST option-list convention) with values as Text, and
  yields the closing ::: line as String.Backtick. Body content
  outside option lines is plain Text — directive-specific inner
  highlighting is out of scope.
- New regex rule prepended to MystLexer.tokens["root"] ahead of the
  inherited MarkdownLexer rules so the colon-fence handler runs
  before fall-through to plain Text. Matches exactly three colons;
  four-or-more variants are not in the workspace tree today (easy
  to add later via a backreference).
- New ColonFenceFixture parametrized tests assert opening, info-
  string, closing, option-key, and hyphenated-directive cases. A
  regression-guard test pins the user's exact failing snippet.
  An additional test asserts inline ":::not-a-fence" prose stays
  Text (the rule anchors ^::: at line start).
- Pygments token imports extended with Name and Text alongside
  the existing String + Whitespace.

Verified: curl on
http://localhost:3124/packages/sphinx-autodoc-pytest-fixtures/tutorial/
now produces <span class="sb">:::{auto-pytest-plugin} ...</span>
and <span class="nt">📦</span> inside the highlight wrapper
(was a single empty <span></span> followed by raw text).
sphinx-build -W passes. All 1456 tests pass.
…ghlight

why: A reader on /packages/sphinx-autodoc-argparse/kitchen-sink/ and
/packages/sphinx-autodoc-docutils/kitchen-sink/ saw the .. directive::
example blocks render as plain unstyled text. The
package-kitchen-sink directive emitted ```text fences, where text is
pygments' alias for "no highlighting" — the rendered HTML carried
<div class="highlight-text"> with no token spans inside, producing
the same visual flatness as the colon-fence bug fixed in the prior
commit but for a different reason.

what:
- _kitchen_sink_markdown(): swap ```text -> ```rst for the per-
  directive example fences. Pygments' RstLexer (alias rst) tokenizes
  .. directive:: invocations with .. as Punctuation, the directive
  name as Operator.Word, and :: as Punctuation — matching the visual
  treatment the parallel package tutorials get via {eval-rst} fences.
- test_kitchen_sink_markdown_lists_each_directive_with_example_block:
  assert the rendered markdown carries ```rst (positive) and NOT
  ```text (negative regression guard so the bug can't return).

Verified: curl on the two affected pages now produces
<span class="p">..</span><span class="ow">argparse</span><span
class="p">::</span> inside the highlight wrapper instead of plain
text. sphinx-build -W passes. All 1456 tests pass.
…e:: inline literals

why: A reader on /packages/sphinx-autodoc-argparse/reference/ saw
the Role + Example table columns ship as plain unstyled <code>
elements; the same on /packages/sphinx-autodoc-fastmcp/kitchen-sink/
for the Roles bullet list. Sphinx + MyST send single-backtick inline
code to docutils as nodes.literal which renders as
<code class="docutils literal notranslate"><span class="pre">…</span></code>
without going through Pygments — that's upstream-default Sphinx
behaviour. Block-level fences highlight; inline doesn't.

The django-docutils precedent (CodeTransform in
django_docutils/lib/transforms/code.py) shows how to fix this: walk
nodes.literal post-parse, dispatch by content pattern, replace with
raw HTML carrying Pygments token spans. The user explicitly asked
for that approach with one constraint — preserve text dimensions
(no warping of line height or wrapping).

what:
- docs/_ext/inline_highlight.py: new Sphinx extension. Defines
  InlineHighlightTransform (Transform, priority 120 per the
  django-docutils precedent) walking every nodes.literal in the
  resolved doctree, dispatching by content pattern:
  * Bare RST role pattern (^:[\w-]+(?::[\w-]+)*:$) — RstLexer
    doesn't tokenize bare role names, so emit a single Name.Attribute
    span explicitly via _bare_rst_role_html()
  * RST role-with-content (^:[\w-]+(?::[\w-]+)*:`[^`]+`$) — RstLexer
    handles natively (Name.Attribute + Name.Variable)
  * Shell session (^\$ ) — BashSessionLexer
  * Inline RST directive (^\.\.\s+[\w-]+::) — RstLexer
  All patterns anchor start AND end of the literal's full text so
  prose containing stray :foo: substrings can't trigger false
  positives.
- _InlineFormatter (HtmlFormatter[str], nowrap=True) drops the
  trailing (Token.Text, '\n') token a Pygments lexer always appends
  — without this every inline span would emit a phantom whitespace
  span at end, breaking tightness against subsequent prose.
- The transform replaces nodes.literal with nodes.raw containing
  <code class="docutils literal notranslate highlight">…token-spans…</code>
  — preserving the OUTER <code> wrapper so Furo's
  code.literal CSS (background, border-radius, font-size, padding)
  applies unchanged. Pygments tokens add foreground color only,
  no margin/padding, so text dimensions are byte-identical to
  pre-transform — no line warping.
- docs/conf.py: prepend inline_highlight to extra_extensions so the
  transform runs on every page in the workspace docs build.
- tests/docs/test_inline_highlight.py: parametrized fixture covers
  the four matched patterns plus two negative cases (plain prose,
  module-name-like content). Three integration tests assert the
  transform preserves the <code> outer wrapper, skips unmatched
  literals, and skips empty literals. Two unit tests exercise the
  formatter trailing-newline strip and the bare-role HTML escape.

Verified: curl on /packages/sphinx-autodoc-argparse/reference/ now
emits <code class="docutils literal notranslate highlight"><span
class="na">:argparse:program:</span></code> for the Role column;
plain identifiers like argparse / --verbose stay unchanged as
<span class="pre">. sphinx-build -W passes. All 1472 tests pass.
@tony
Copy link
Copy Markdown
Member Author

tony commented May 6, 2026

Code review

Found 4 issues.

  1. from html import escape violates the stdlib namespace-import rule (CLAUDE.md says "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. This rule applies to Python standard library only").

import re
import typing as t
from html import escape
from docutils import nodes

  1. subpage_exists_role lacks a doctest (CLAUDE.md says "All functions and methods MUST have working doctests"). Sibling role functions in sphinx-autodoc-argparse (e.g. cli_option_role) demonstrate the inliner=None pattern is practical here.

def subpage_exists_role(
name: str,
rawtext: str,
text: str,
lineno: int,
inliner: t.Any,
options: dict[str, t.Any] | None = None,
content: list[str] | None = None,
) -> tuple[list[t.Any], list[t.Any]]:
"""Implement the ``{subpage-exists}`<target>`` MyST role.
Renders a Sphinx ``:doc:`` cross-reference when ``<target>`` resolves
to an existing docname (sibling-relative or absolute); otherwise
emits plain text so the build does not fail. Used in tutorial /
how-to "Where to next" sections so prose never refers to absent
subpages.
"""
from docutils import nodes as docutils_nodes

  1. The npm URL builder strips @ and percent-encodes / for scoped packages, producing https://www.npmjs.com/package/gp-sphinx%2ffuro-tokens instead of https://www.npmjs.com/package/@gp-sphinx/furo-tokens. The npm web UI 404s on the encoded slug; the test only asserts record.npm_url is not None so the bad value passes.

if package_json_path.is_file():
manifest = json.loads(package_json_path.read_text(encoding="utf-8"))
name = str(manifest.get("name", pkg_dir.name))
npm_slug = name.lstrip("@").replace("/", "%2f") if name else pkg_dir.name
return PackageDocsRecord(
name=name,
state="shipped-js",
cluster=_cluster_for(name),
package_dir=pkg_dir,
manifest_path=package_json_path,
src_dir=src_dir if src_dir.is_dir() else None,
module_name="",
description=str(manifest.get("description", "")),
version=str(manifest.get("version", "")),
repository_url=_repository_url_from_package_json(manifest),
pypi_url=None,
npm_url=f"https://www.npmjs.com/package/{npm_slug}",
maturity="Unknown",
)

  1. test_objects_inv_is_superset_of_baseline uses the _live_objects_inv fixture which instantiates sphinx.application.Sphinx and calls app.build(), but the test function lacks @pytest.mark.integration (CLAUDE.md says "Always mark with @pytest.mark.integration" and "Any test that constructs a Sphinx app ... counts" as integration).

status = io.StringIO()
warning = io.StringIO()
app = Sphinx(
srcdir=str(src_dir),
confdir=str(src_dir),
outdir=str(html_dir),
doctreedir=str(doctree_dir),
buildername="dirhtml",
status=status,
warning=warning,
freshenv=True,
)
app.build()
inv_path = html_dir / "objects.inv"
if not inv_path.is_file():
pytest.fail(f"sphinx build produced no objects.inv at {inv_path}")
return inv_path
def test_objects_inv_is_superset_of_baseline(
_live_objects_inv: pathlib.Path,
) -> None:
"""No baseline cross-reference target is missing from the live build."""
live_keys = _flatten_inventory(_live_objects_inv)
baseline = _baseline_keys()
missing = baseline - live_keys

Generated with Claude Code

tony added 6 commits May 5, 2026 21:13
… `html`

why: Stdlib member imports (`from X import Y`) violate AGENTS.md's
namespace-import-stdlib rule. `html` is not the `dataclasses` exception.

what:
- Replace `from html import escape` with `import html`
- Update the lone call site to `html.escape(text)`
why: Every public function needs a working doctest. Sphinx role
functions can use a `types.SimpleNamespace` chain for the inliner so
the test stays pure-Python with no Sphinx app required.

what:
- Add an Examples block exercising both branches: target found
  (returns `pending_xref` with `reftype="doc"`) and target missing
  (returns plain `inline` text).
- Mirror the inliner mocking style established in
  `sphinx-autodoc-argparse` role doctests, extended to satisfy this
  role's `inliner.document.settings.env` access pattern.
why: The npm web UI serves scoped packages at literal
``/package/@scope/name``. Stripping the leading ``@`` and
percent-encoding the ``/`` produced
``https://www.npmjs.com/package/gp-sphinx%2ffuro-tokens`` for
``@gp-sphinx/furo-tokens`` — a 404. The pre-existing assertion only
checked ``is not None``, so the broken URL passed CI.

what:
- Pass the manifest ``name`` straight through into the npm URL.
- Tighten the test to assert the exact URL form (literal scope and
  slash) so a future regression that re-encodes either character
  fails the suite instead of shipping a 404 link.
why: ``test_objects_inv_is_superset_of_baseline`` resolves
``_live_objects_inv``, which instantiates ``sphinx.application.Sphinx``
and runs ``app.build()`` — that is the canonical integration shape and
needs the ``integration`` marker so collection filters (e.g.,
``-m 'not integration'``) honor the test's cost.

what:
- Decorate ``test_objects_inv_is_superset_of_baseline`` with
  ``@pytest.mark.integration``, matching the convention already in use
  across ``test_gp_furo_theme``, ``test_pygments_style``, and the
  sphinx-vite-builder integration suites.
Note the user-visible portion of the recent MystLexer change so
upgrade readers can find it; the per-package docs migration is
already covered above.
…tion

The previous entry read like a feature spec. CHANGES is a
"should I care?" filter — keep it brief and put docs work under
Documentation, not What's new. CI gates and migration notes
collapse into one sentence inside the main entry; the rest belongs
in the PR.
tony added a commit that referenced this pull request May 6, 2026
Trigger the docs workflow on pushes to `docs-split` so the
per-package Diátaxis tree (PR #33) can be reviewed on
gp-sphinx.git-pull.com before merge. Drop or revert this commit
before merging — it must not land on `main`.
@tony tony merged commit 6736f2a into main May 6, 2026
52 checks passed
@tony tony deleted the docs-split branch May 6, 2026 02:33
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