docs: per-package documentation tree with Diátaxis subpages and sidebar nesting#33
Merged
docs: per-package documentation tree with Diátaxis subpages and sidebar nesting#33
Conversation
…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)
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 Report❌ Patch coverage is 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. 🚀 New features to boost your workflow:
|
… 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.
Member
Author
Code reviewFound 4 issues.
gp-sphinx/docs/_ext/inline_highlight.py Lines 44 to 48 in aa811c6
gp-sphinx/docs/_ext/package_reference.py Lines 1209 to 1227 in aa811c6
gp-sphinx/docs/_ext/package_reference.py Lines 394 to 412 in aa811c6
gp-sphinx/tests/docs/test_objects_inv_compat.py Lines 88 to 113 in aa811c6 Generated with Claude Code |
… `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`.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
docs/packages/<name>.mdpages with per-package directories carrying Diátaxis-shaped subpages (Tutorial / How to / API Reference / Explanation / Examples), nested under each package in the sidebar.{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 touchespyproject.toml(orpackage.json).{cluster-toctree}directive replacing six hand-edited toctree blocks indocs/index.mdwith five directive calls; the workspace inventory becomes the single source of truth for sidebar nesting.PackageDocsRecord) so JS-only token packages (@gp-sphinx/furo-tokens) graduate alongside Python packages without separate plumbing.{live-signature},{package-kitchen-sink},{surface-changelog},{package-dependents}— opt-in per package via[tool.gp-sphinx.docs].showcase.pkg_docnameregistration that branches onenv.found_docsmembership; anobjects.invsnapshot test mathematically proves no:py:func:/:py:class:reference was lost.Changes by area
Sidebar IA
{toctree}blocks listing every package by name indocs/index.md{cluster-toctree}directive calls; sidebar leaves derived from each package's classifier and[tool.gp-sphinx.docs]overridestoctree-l2entriesDomain Packages/Internal/Utils)Autodoc/UX/Tokens/Theme & coordinator/Build & SEOPer-package layout
Optional opt-in subpages (per
[tool.gp-sphinx.docs]):errors.md,cli.md—extrakeyssignatures.md,kitchen-sink.md,surface-diff.md,dependents.md—showcasekeysdocs/_ext/package_reference.py— new directives + roles{package-landing}{cluster-toctree}{subpage-exists}{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}{package-kitchen-sink}{surface-changelog}docs/_static/surface-snapshots/<name>.json{package-dependents}[project].dependencies{workspace-package-grid} :groups: by-clusterdocs/packages/index.mdWorkspace discovery (dual-source)
workspace_package_records()walks every directory underpackages/and probes for either manifest:shipped-pypyproject.tomlwith[project]tableshipped-jspackage.jsonwithnamefield, nopyproject.tomlpackage.jsondescription);gp-sphinx-package-metaemits annpmbadge instead ofPyPIemergingCross-reference preservation (Risk 1 mitigation)
The existing
_register_extension_objects()was hardcoded to register py-domain objects against the flat docnamepackages/<name>. After migration that docname no longer exists. The fix branches onenv.found_docsmembership atenv-check-consistencytime: preferpackages/<name>/referenceonce that docname is in the build, fall back to the flatpackages/<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_no_filler.pyTBD,XXX,FIXME,Coming soon,intentionally blank,Lorem ipsum,(write me),placeholderin any shipped subpagetest_no_legacy_pages.py<name>.mdco-exists with a per-package<name>/index.mdtest_sidebar_density.py19 * 6 + 30post-migration ceilingtest_objects_inv_compat.pydocs/tree to a tmp dir; asserts the liveobjects.invis a superset of the committed snapshot forpy:/rst:/argparse:domainstest_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.pyShowcase opt-ins (per package)
sphinx-ux-badges,sphinx-autodoc-typehints-gp,sphinx-ux-autodoc-layout,sphinx-fonts,sphinx-gp-theme,sphinx-gp-sitemapsphinx-autodoc-fastmcp,sphinx-autodoc-argparse,sphinx-autodoc-pytest-fixtures,sphinx-autodoc-sphinx,sphinx-autodoc-docutilsgp-furo-theme,sphinx-gp-opengraphsurface-changelogis registered and tested but not yet opted into; baseline JSON snapshots can be captured at the next release tag.Design decisions
pkg_docnameregistration over flag-day rewrite. Iterative migration would have left every package's py-domain xrefs dangling for the duration. Branching onenv.found_docsmembership makes the new behavior safe to land before any per-package commit and self-corrects as each package migrates.# H1viaparse_text_to_nodescannot 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.sphinx-build -W. Filtering at emit time keeps the build green even when the workspace has half-baked package directories.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-docsCI-equivalent (warnings-as-errors):
$ uv run sphinx-build -W -b dirhtml docs docs/_build/htmlInspect the rendered sidebar from the workspace landing — every package shows
toctree-l1 has-childrenwith subpages indented astoctree-l2:$ grep -B 1 -A 8 'caption-text">Autodoc' docs/_build/html/index.htmlVerify zero flat
<name>.mdper-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 stringstests/docs/test_no_legacy_pages.py— no flat<name>.mdshadows a per-package<name>/index.mdtests/docs/test_sidebar_density.py— total toctree leaves stay under the post-migration ceilingtests/docs/test_objects_inv_compat.py::test_objects_inv_is_superset_of_baseline— live build produces a superset of the committedobjects.invsnapshot forpy:/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 clustertests/docs/test_subpage_exists_role.py— sibling-relative and absolute targets resolve; absent targets degrade to plain texttests/docs/test_workspace_package_grid_groups.py—:groups: by-clustermode preserves curated prose; legacy single-grid output unchangedtests/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— conditionalpkg_docnamefix branches correctly between flat and per-package docnamestoctree-l1 has-children(or a leaf for shipped-js packages without subpages) with subpages astoctree-l2uv run sphinx-build -W -b dirhtml docs docs/_build/htmlcompletes without warnings