Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
56f6213
feat(_ext[package_reference]): add PackageDocsRecord + dual-source wo…
tony May 5, 2026
291685c
feat(_ext[package_reference]): add subpage-exists conditional cross-r…
tony May 5, 2026
e443856
test(docs): add banned-strings denylist guard for shipped subpages
tony May 5, 2026
c907692
test(docs): add sidebar-density permissive upper-bound guard
tony May 5, 2026
9baa883
test(docs): snapshot pre-migration objects.inv as superset baseline
tony May 5, 2026
1804faf
feat(_ext[package_reference]): add PackageLandingDirective for synthe…
tony May 5, 2026
0abb327
feat(_ext[package_reference]): add ClusterToctreeDirective with skip-…
tony May 5, 2026
f2c2f8c
feat(_ext[package_reference]): add :groups: by-cluster mode to worksp…
tony May 5, 2026
9c9edbc
fix(_ext[package_reference]): branch pkg_docname registration by reco…
tony May 5, 2026
4a9317f
refactor(_ext[package_reference]): drop Package metadata block from p…
tony May 5, 2026
56f24ee
style(docs): add gp-sphinx-package__landing-grid + __hero CSS classes
tony May 5, 2026
e044263
feat(scripts): add docs_split.py one-shot migration helper
tony May 5, 2026
f5cc455
migrate(sphinx-fonts): split flat docs page into Diátaxis tree (E1)
tony May 5, 2026
5b488f8
migrate(sphinx-autodoc-sphinx): split flat docs page into Diátaxis tr…
tony May 5, 2026
b1a1e8c
migrate(sphinx-ux-badges): split flat docs page into Diátaxis tree (E3)
tony May 5, 2026
541e29d
migrate(sphinx-autodoc-api-style): split flat docs page into Diátaxis…
tony May 5, 2026
17ec287
migrate(sphinx-autodoc-argparse): split flat docs page into Diátaxis …
tony May 5, 2026
cb64adf
migrate(sphinx-autodoc-docutils): split flat docs page into Diátaxis …
tony May 5, 2026
9daccb1
migrate(sphinx-autodoc-fastmcp): split flat docs page into Diátaxis t…
tony May 5, 2026
43b8d9b
migrate(sphinx-autodoc-pytest-fixtures): split flat docs page into Di…
tony May 5, 2026
6e4d094
migrate(sphinx-autodoc-typehints-gp): split flat docs page into Diáta…
tony May 5, 2026
6be753b
migrate(gp-sphinx): split flat docs page into Diátaxis tree (E10)
tony May 5, 2026
67ef83a
migrate(sphinx-gp-theme): split flat docs page into Diátaxis tree (E11)
tony May 5, 2026
aa86126
migrate(gp-furo-theme): split flat docs page into Diátaxis tree (E12)
tony May 5, 2026
49fefa9
migrate(sphinx-vite-builder): split flat docs page into Diátaxis tree…
tony May 5, 2026
92dd6a7
migrate(sphinx-gp-opengraph): split flat docs page into Diátaxis tree…
tony May 5, 2026
d091f2d
migrate(sphinx-gp-sitemap): split flat docs page into Diátaxis tree (…
tony May 5, 2026
a92f422
migrate(sphinx-ux-autodoc-layout): split flat docs page into Diátaxis…
tony May 5, 2026
b708ce0
refactor(docs): replace six hand-edited toctrees with cluster-toctree…
tony May 5, 2026
d1f4cfc
test(docs): add stale-legacy-page CI gate (G1)
tony May 5, 2026
5ddbc73
test(docs): tighten sidebar-density bound to post-migration value (G2)
tony May 5, 2026
8a4d7ad
chore(scripts): delete one-shot docs_split.py (G3)
tony May 5, 2026
c7728ba
test(docs): refresh objects.inv snapshot to post-migration superset (G4)
tony May 5, 2026
a9b1058
feat(_ext[package_reference]): add live-signature showcase directive …
tony May 5, 2026
9cef760
feat(_ext[package_reference]): add package-kitchen-sink showcase dire…
tony May 5, 2026
54ce0fc
feat(_ext[package_reference]): add surface-changelog showcase directi…
tony May 5, 2026
bc518ca
feat(_ext[package_reference]): add package-dependents reverse-intersp…
tony May 5, 2026
d4f69ed
fix(docs): land JS-only landing + cross-ref repairs so sphinx-build -…
tony May 5, 2026
166a24a
docs(CHANGES): add entry for the per-package docs restructure
tony May 5, 2026
c1ef0ec
refactor(_ext[showcase]): make Group H directives body-only + extend …
tony May 5, 2026
5305e01
opt-in(sphinx-ux-badges): add dependents showcase subpage
tony May 5, 2026
e37adbf
opt-in(sphinx-autodoc-fastmcp): add kitchen-sink showcase subpage
tony May 5, 2026
caec66f
opt-in(sphinx-autodoc-argparse): add kitchen-sink showcase subpage
tony May 5, 2026
ed06090
opt-in(sphinx-autodoc-pytest-fixtures): add kitchen-sink showcase sub…
tony May 5, 2026
bba7ba9
opt-in(sphinx-autodoc-sphinx): add kitchen-sink showcase subpage
tony May 5, 2026
6a5eef7
opt-in(sphinx-autodoc-docutils): add kitchen-sink showcase subpage
tony May 5, 2026
204aa36
opt-in(sphinx-autodoc-typehints-gp): add dependents showcase subpage
tony May 5, 2026
3c60ec6
opt-in(sphinx-ux-autodoc-layout): add dependents showcase subpage
tony May 5, 2026
2aea139
opt-in(sphinx-fonts): add dependents showcase subpage
tony May 5, 2026
40c9540
opt-in(sphinx-gp-theme): add dependents showcase subpage
tony May 5, 2026
c2aab36
opt-in(gp-furo-theme): add signatures + dependents showcase subpages
tony May 6, 2026
3cd9690
opt-in(sphinx-gp-opengraph): add signatures + dependents showcase sub…
tony May 6, 2026
d90fd36
opt-in(sphinx-gp-sitemap): add dependents showcase subpage
tony May 6, 2026
9e3550a
fix(docs[css]): give the synopsis blockquote breathing room above the…
tony May 6, 2026
81e9dff
fix(gp-sphinx[myst-lexer]): tokenize :::{...} colon-fences for syntax…
tony May 6, 2026
61ed103
fix(_ext[kitchen-sink]): use rst lang tag so directive invocations hi…
tony May 6, 2026
aa811c6
feat(docs[inline-highlight]): tokenize :role: + $ shell + .. directiv…
tony May 6, 2026
d3e8766
fix(inline_highlight[imports]): Switch to namespace import for stdlib…
tony May 6, 2026
897b16b
docs(package_reference[role]): Add doctest for `subpage_exists_role`
tony May 6, 2026
1fd9bc8
fix(package_reference[npm-url]): Stop mangling scoped package URLs
tony May 6, 2026
58b8fde
test(objects_inv[mark]): Mark live-build test as integration
tony May 6, 2026
63019a3
docs(CHANGES) MystLexer colon-fence highlighting fix
tony May 6, 2026
ec7466c
docs(CHANGES) Trim per-package docs entry and reshape under Documenta…
tony May 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,35 @@ $ uv add gp-sphinx --prerelease allow

<!-- To maintainers and contributors: Please add notes for the forthcoming version below -->

### Bug fixes

#### `gp-sphinx`: MystLexer highlights `:::{directive}` colon-fences

Embedded MyST snippets that use the colon-fence directive syntax now
tokenize correctly when rendered through `pygments` (e.g. inside
`.. code-block:: myst`). Previously the fence and body fell through
to plain text. (#33)

### Documentation

#### Per-package documentation tree

Workspace packages now ship per-package directories with Diátaxis
subpages (`tutorial`, `how-to`, `reference`, `explanation`,
`examples`); the sidebar nests subpages under their package. New
`{package-landing}` and `{cluster-toctree}` directives derive
landings and sidebar leaves from a `PackageDocsRecord` schema that
reads both `pyproject.toml` and `package.json` manifests, so JS-only
packages like `@gp-sphinx/furo-tokens` sit alongside the Python
ones. Legacy `/packages/<name>.html` URLs continue to resolve. (#33)

#### Showcase opt-in directives

`{live-signature}`, `{package-kitchen-sink}`, `{surface-changelog}`,
and `{package-dependents}` opt in via
`[tool.gp-sphinx.docs].showcase` for richer per-package landings.
(#33)

## gp-sphinx 0.0.1a16 (2026-05-03)

### What's new
Expand Down
240 changes: 240 additions & 0 deletions docs/_ext/inline_highlight.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
"""Inline syntax highlighting for ``nodes.literal`` (single-backtick code).

Sphinx + MyST send single-backtick inline code to docutils as
:class:`docutils.nodes.literal`, which renders as
``<code class="docutils literal notranslate"><span class="pre">…</span></code>``
with no Pygments invocation. Block-level fences (``rst``/``myst``)
go through Pygments; inline literals don't. This is upstream-default
Sphinx behavior.

This transform restores parity for the four inline content patterns
the workspace's reference + kitchen-sink pages rely on:

- **Bare RST roles** like ``:argparse:program:`` / ``:tool:`` —
Pygments' :class:`RstLexer` does not tokenize bare role names
(without a backtick body), so we emit a single ``Name.Attribute``
span explicitly.
- **RST roles with content** like ``:tool:`list_sessions``` —
:class:`RstLexer` tokenizes these as ``Name.Attribute`` +
``Name.Variable``.
- **Shell sessions** like ``$ uv run pytest`` — :class:`BashSessionLexer`
tokenizes the ``$`` prompt as ``Generic.Prompt``.
- **Inline RST directive references** like ``.. autodirective::`` —
:class:`RstLexer` tokenizes ``..`` / directive name / ``::``.

Dimensional invariants
----------------------
The transform PRESERVES the outer ``<code class="docutils literal
notranslate">`` wrapper that Sphinx emits for ``nodes.literal``.
Furo's inline-literal styling targets ``code.literal, .sig-inline``
in ``furo.css`` (background, border-radius, font-size, padding) — by
keeping ``<code>`` as the outer element, the existing box styling
applies unchanged. Pygments token spans inside add foreground color
only (no padding/margin), so line height, line wrapping, and span
width are identical to pre-transform.

Inspired by :class:`django_docutils.lib.transforms.code.CodeTransform`,
which establishes the precedent of post-parse pattern dispatch over
``nodes.literal``.
"""

from __future__ import annotations

import html
import io
import re
import typing as t

from docutils import nodes
from docutils.transforms import Transform
from pygments.formatters.html import HtmlFormatter
from pygments.lexers.markup import RstLexer
from pygments.lexers.shell import BashSessionLexer
from pygments.token import Token

if t.TYPE_CHECKING:
from pygments.lexer import Lexer
from sphinx.application import Sphinx


# Pattern detectors run in order; first match wins. Anchored at start
# AND end of string (the literal's full text) so prose containing a
# stray ``:foo:`` substring elsewhere in a sentence cannot trigger
# false positives — only inline literals whose ENTIRE content matches
# one of these shapes get highlighted.

# Bare RST role / option / directive-attribute name like ``:tool:``,
# ``:argparse:program:``, ``:package:``. Word characters and hyphens
# only between the colons.
_BARE_RST_ROLE_RE = re.compile(r"^:[\w-]+(?::[\w-]+)*:$")

# RST role with a backtick body: ``:tool:`list_sessions```,
# ``:argparse:program:`myapp```. Body content is permissive (any non-
# backtick run) since RstLexer handles whatever's inside.
_RST_ROLE_WITH_CONTENT_RE = re.compile(r"^:[\w-]+(?::[\w-]+)*:`[^`]+`$")

# Shell session: starts with ``$ ``. Single line — inline literals
# can't span multiple lines, but we replace any embedded ``\n`` with a
# space defensively (django-docutils precedent).
_SHELL_SESSION_RE = re.compile(r"^\$ ")

# Inline RST directive reference: ``.. <name>::`` optionally followed
# by an argument. The two leading dots + the name + the closing ``::``
# are the recognizable shape.
_INLINE_RST_DIRECTIVE_RE = re.compile(r"^\.\.\s+[\w-]+::")


class _InlineFormatter(HtmlFormatter[str]):
r"""Pygments HTML formatter tuned for inline output.

With ``nowrap=True`` the formatter emits raw ``<span>`` tokens with
no surrounding ``<pre>`` or ``<div>``. This override additionally
strips the trailing ``(Token.Text, '\n')`` that every Pygments
lexer appends at end-of-stream — that newline would render as a
phantom whitespace span tight against the closing ``</code>`` tag
in inline contexts. Pattern lifted from
:class:`django_docutils.lib.transforms.code.InlineHtmlFormatter`.
"""

def format_unencoded(
self,
tokensource: t.Iterable[tuple[t.Any, str]],
outfile: t.Any,
) -> None:
"""Trim the trailing newline token before delegating to ``HtmlFormatter``."""
tokens = list(tokensource)
if tokens and tokens[-1] == (Token.Text, "\n"):
tokens = tokens[:-1]
super().format_unencoded(iter(tokens), outfile)


_FORMATTER = _InlineFormatter(nowrap=True)


def _highlight(text: str, lexer: Lexer) -> str:
"""Return inner pygments span markup for ``text`` tokenized by ``lexer``."""
out = io.StringIO()
_FORMATTER.format(lexer.get_tokens(text), out)
return out.getvalue().rstrip("\n")


def _detect_lexer(text: str) -> Lexer | None:
"""Return the pygments lexer that should tokenize ``text``, or ``None``.

Returns ``None`` when no pattern matches — caller leaves the
``nodes.literal`` unchanged in that case.

Examples
--------
>>> _detect_lexer(":tool:`list_sessions`").__class__.__name__
'RstLexer'
>>> _detect_lexer("$ uv run pytest").__class__.__name__
'BashSessionLexer'
>>> _detect_lexer(".. autodirective::").__class__.__name__
'RstLexer'
>>> _detect_lexer("just plain prose") is None
True
"""
if _SHELL_SESSION_RE.match(text):
return BashSessionLexer()
if _RST_ROLE_WITH_CONTENT_RE.match(text):
return RstLexer()
if _INLINE_RST_DIRECTIVE_RE.match(text):
return RstLexer()
return None


def _bare_rst_role_html(text: str) -> str:
"""Wrap a bare RST role token in a ``Name.Attribute`` (``na``) span.

:class:`RstLexer` does not recognize bare ``:role:`` patterns
(without a backtick body) — it tokenizes them as plain ``Token.Text``.
Emit the ``Name.Attribute`` span explicitly so the ``Role`` columns
in package reference tables get the same colored treatment as the
matching ``Example`` columns.

Examples
--------
>>> _bare_rst_role_html(":tool:")
'<span class="na">:tool:</span>'
>>> _bare_rst_role_html(":argparse:program:")
'<span class="na">:argparse:program:</span>'
"""
return f'<span class="na">{html.escape(text)}</span>'


def _inline_html_for(text: str) -> str | None:
"""Return the inner HTML for ``text`` (token spans), or ``None``.

``None`` signals "no pattern matched, leave the literal alone."
"""
if _BARE_RST_ROLE_RE.match(text):
return _bare_rst_role_html(text)
lexer = _detect_lexer(text)
if lexer is None:
return None
return _highlight(text, lexer)


class InlineHighlightTransform(Transform):
"""Apply Pygments highlighting to ``nodes.literal`` matching known shapes.

Walks every ``nodes.literal`` in the resolved doctree, dispatches by
content pattern (see module docstring), and replaces matched nodes
with ``nodes.raw`` containing ``<code class="docutils literal
notranslate highlight">…spans…</code>``. The outer ``<code>`` element
is preserved so Furo's existing inline-literal box styling applies
unchanged — pygments tokens add color only.

Priority 120 follows the
:class:`django_docutils.lib.transforms.code.CodeTransform` precedent
— runs after docutils' default transforms but before the writer.
"""

default_priority = 120

def apply(self, **kwargs: t.Any) -> None:
"""Walk literals, replace matched ones with token-span raw HTML."""
for node in list(self.document.findall(nodes.literal)):
text = node.astext()
if not text:
continue
# Inline literals can't span lines, but defensive normalize
# — docutils sometimes leaks newlines from soft-wrapped
# source. Match the django-docutils CodeTransform behavior.
normalized = text.strip().replace("\n", " ")

inner = _inline_html_for(normalized)
if inner is None:
continue

wrapped = (
f'<code class="docutils literal notranslate highlight">{inner}</code>'
)
replacement = nodes.raw("", wrapped, format="html")
if node.parent is not None:
node.replace_self(replacement)


def setup(app: Sphinx) -> dict[str, object]:
"""Register :class:`InlineHighlightTransform` with Sphinx.

Examples
--------
>>> class _FakeApp:
... transforms: t.ClassVar[list[type]] = []
... def add_transform(self, cls: type) -> None:
... self.transforms.append(cls)
>>> app = _FakeApp()
>>> meta = setup(app) # type: ignore[arg-type]
>>> InlineHighlightTransform in app.transforms
True
>>> meta["parallel_read_safe"]
True
"""
app.add_transform(InlineHighlightTransform)
return {
"version": "0.1",
"parallel_read_safe": True,
"parallel_write_safe": True,
}
Loading