'
diff --git a/docs/_ext/package_reference.py b/docs/_ext/package_reference.py
index 5d8b06b0..024b5988 100644
--- a/docs/_ext/package_reference.py
+++ b/docs/_ext/package_reference.py
@@ -202,6 +202,7 @@ class PackageDocsRecord:
"sphinx-ux-badges": "ux",
"sphinx-ux-autodoc-layout": "ux",
"sphinx-gp-mermaid": "ux",
+ "sphinx-gp-highlighting": "highlighting",
"sphinx-vite-builder": "build-seo",
"sphinx-gp-opengraph": "build-seo",
"sphinx-gp-sitemap": "build-seo",
@@ -945,6 +946,11 @@ def maturity_badge(maturity: str) -> str:
"Badge primitives, layout presenters, and other shared "
"rendering helpers consumed by the autodoc family.",
),
+ (
+ "highlighting",
+ "Highlighting",
+ "Pygments lexers and inline literal helpers for code-heavy docs.",
+ ),
(
"build-seo",
"Build & SEO",
diff --git a/docs/conf.py b/docs/conf.py
index dd7e58ca..2ef0c7d8 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -59,6 +59,7 @@
str(project_root / "packages" / "sphinx-autodoc-typehints-gp" / "src"),
)
sys.path.insert(0, str(project_root / "packages" / "sphinx-gp-mermaid" / "src"))
+sys.path.insert(0, str(project_root / "packages" / "sphinx-gp-highlighting" / "src"))
sys.path.insert(0, str(cwd / "_ext")) # docs demo modules
import gp_sphinx # noqa: E402
@@ -102,6 +103,7 @@
"sphinx_autodoc_argparse.exemplar",
"sphinx_ux_autodoc_layout",
"sphinx_gp_mermaid",
+ "sphinx_gp_highlighting",
"mermaid_examples",
],
fastmcp_tool_modules=["fastmcp_demo_tools"],
diff --git a/docs/index.md b/docs/index.md
index 67cbb6fd..e2601086 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -115,6 +115,10 @@ GitHub
:caption: Theme & coordinator
```
+```{cluster-toctree} highlighting
+:caption: Highlighting
+```
+
```{cluster-toctree} build-seo
:caption: Build & SEO
```
diff --git a/docs/packages/index.md b/docs/packages/index.md
index 09f9171f..56bdf0b3 100644
--- a/docs/packages/index.md
+++ b/docs/packages/index.md
@@ -20,6 +20,12 @@ The rendering pipeline every autodoc extension consumes, plus shared page primit
- [`sphinx-fonts`](sphinx-fonts/index.md) — IBM Plex font preloading
- [`sphinx-gp-mermaid`](sphinx-gp-mermaid/index.md) — build-time mermaid diagrams as dual-theme inline SVG
+## Code highlighting
+
+Pygments lexers and inline literal helpers for code-heavy documentation:
+
+- [`sphinx-gp-highlighting`](sphinx-gp-highlighting/index.md) — directory-tree code blocks and inline command/path highlighting
+
## Autodoc extensions
Domain-specific [autodoc extensions](https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html) — each adds directives that generate documentation from a particular source-construct family (Python APIs, argparse parsers, pytest fixtures, etc.):
diff --git a/docs/packages/sphinx-gp-highlighting/examples.md b/docs/packages/sphinx-gp-highlighting/examples.md
new file mode 100644
index 00000000..1f0bd443
--- /dev/null
+++ b/docs/packages/sphinx-gp-highlighting/examples.md
@@ -0,0 +1,56 @@
+(sphinx-gp-highlighting-examples)=
+
+# Examples
+
+## Directory trees
+
+Use `tree` for static project layouts. It highlights connector glyphs,
+directory names, filenames, and comments without pretending the snippet
+is a shell transcript.
+
+```tree
+python_module
+├── tmuxp_plugin_my_plugin_module
+│ ├── __init__.py
+│ └── plugin.py
+└── pyproject.toml # Python project configuration file
+```
+
+The longer aliases render the same way:
+
+```directory-tree
+docs/
+├── conf.py
+├── index.md
+└── packages/
+```
+
+```dir-tree
+src/
+└── package_name/
+ └── __init__.py
+```
+
+## Inline literals
+
+Explicit roles let prose declare intent while keeping the familiar
+inline-code shape:
+
+Run {cmd}`tmuxp freeze my-session`, then edit
+{path}`~/.config/tmuxp/config.yaml`.
+
+Store plugin modules under {dir}`./plugins/`.
+
+## Safe automatic highlighting
+
+Safe automatic highlighting is opt-in and command allow-list based:
+
+```python
+gp_highlighting_inline_literals = "safe"
+gp_highlighting_inline_commands = ["tmuxp", "agentgrep"]
+```
+
+With that configuration, ordinary backtick literals such as
+`tmuxp freeze my-session`, `agentgrep search mermaid`, and
+`~/.config/tmuxp/` can receive the same package-owned inline classes,
+while names such as `module_name` stay unchanged.
diff --git a/docs/packages/sphinx-gp-highlighting/how-to.md b/docs/packages/sphinx-gp-highlighting/how-to.md
new file mode 100644
index 00000000..0d27bb99
--- /dev/null
+++ b/docs/packages/sphinx-gp-highlighting/how-to.md
@@ -0,0 +1,52 @@
+(sphinx-gp-highlighting-how-to)=
+
+# How to
+
+## Highlight a directory tree
+
+Use the `tree` lexer for static directory layouts. It highlights connector glyphs, directory names, filenames, and trailing comments without treating the snippet as a shell transcript:
+
+````markdown
+```tree
+python_module
+├── tmuxp_plugin_my_plugin_module
+│ ├── __init__.py
+│ └── plugin.py
+└── pyproject.toml # Python project configuration file
+```
+````
+
+The aliases `directory-tree` and `dir-tree` are equivalent.
+
+## Mark up inline commands and paths
+
+Use explicit roles when the prose knows what the literal is:
+
+```markdown
+Run {cmd}`tmuxp freeze my-session`, then edit {path}`~/.config/tmuxp/config.yaml`.
+Store plugin modules under {dir}`./plugins/`.
+```
+
+The `{cmd}` role uses Sphinx's inline Pygments path with Bash highlighting. The `{path}` and `{dir}` roles keep ordinary inline-code styling and add package-owned classes for path-specific CSS.
+
+## Enable safe automatic inline highlighting
+
+Automatic highlighting of ordinary backtick literals is opt-in:
+
+```python
+gp_highlighting_inline_literals = "safe"
+gp_highlighting_inline_commands = ["tmuxp", "agentgrep", "unihan-etl"]
+```
+
+Safe mode only catches shell prompts, configured command names with arguments, and clear filesystem paths or directories. Literals such as `module_name` and `PackageClass` stay unchanged.
+
+## Use MyST inline language attributes
+
+MyST's `attrs_inline` extension can still request a lexer directly:
+
+```markdown
+Inline Python code `value = 1`{l=python}
+```
+
+`sphinx-gp-highlighting` leaves literals that already carry a language alone, so explicit MyST language attributes and the package's safe auto-detection can coexist.
+
diff --git a/docs/packages/sphinx-gp-highlighting/index.md b/docs/packages/sphinx-gp-highlighting/index.md
new file mode 100644
index 00000000..f3f9dd60
--- /dev/null
+++ b/docs/packages/sphinx-gp-highlighting/index.md
@@ -0,0 +1,6 @@
+(sphinx-gp-highlighting)=
+
+# sphinx-gp-highlighting
+
+```{package-landing} sphinx-gp-highlighting
+```
diff --git a/docs/packages/sphinx-gp-highlighting/reference.md b/docs/packages/sphinx-gp-highlighting/reference.md
new file mode 100644
index 00000000..fff0d218
--- /dev/null
+++ b/docs/packages/sphinx-gp-highlighting/reference.md
@@ -0,0 +1,44 @@
+(sphinx-gp-highlighting-reference)=
+
+# Reference
+
+[`sphinx_gp_highlighting/__init__.py`](https://github.com/git-pull/gp-sphinx/tree/main/packages/sphinx-gp-highlighting/src/sphinx_gp_highlighting/__init__.py).
+
+## Registered surface
+
+```{package-reference} sphinx-gp-highlighting
+```
+
+## Lexers
+
+`sphinx-gp-highlighting` registers these Pygments aliases:
+
+| Alias | Purpose |
+| --- | --- |
+| `tree` | Preferred directory-tree fence name |
+| `directory-tree` | Explicit directory-tree alias |
+| `dir-tree` | Short directory-tree alias |
+
+## Config values
+
+```{eval-rst}
+.. autoconfigvalues:: sphinx_gp_highlighting
+```
+
+## Roles
+
+```{eval-rst}
+.. autoroles:: sphinx_gp_highlighting
+```
+
+## API
+
+```{eval-rst}
+.. autofunction:: sphinx_gp_highlighting.setup
+
+.. autoclass:: sphinx_gp_highlighting.lexers.DirectoryTreeLexer
+
+.. autofunction:: sphinx_gp_highlighting.inline.classify_inline_literal
+
+.. autofunction:: sphinx_gp_highlighting.inline.build_highlighted_literal
+```
diff --git a/docs/packages/sphinx-gp-mermaid/how-to.md b/docs/packages/sphinx-gp-mermaid/how-to.md
index b6067498..e10f09b6 100644
--- a/docs/packages/sphinx-gp-mermaid/how-to.md
+++ b/docs/packages/sphinx-gp-mermaid/how-to.md
@@ -9,16 +9,44 @@ Write a diagram in a MyST fence — it renders to inline SVG at build time, so i
```markdown
:::{mermaid}
:caption: How a request flows.
+:alt: browser request through the CDN to the origin
+:name: request-flow
+:responsive: fit
flowchart LR
browser --> cdn --> origin
:::
```
-`:caption:`, `:alt:`, and `:name:` are the directive's options; `:name:` gives the figure a cross-reference target. When you build through {py:func}`~gp_sphinx.config.merge_sphinx_config` with the extension in `extra_extensions`, plain {rst:dir}`mermaid` fences route to the same directive automatically (it sets `myst_fence_as_directive = ["mermaid"]`).
+`:caption:`, `:alt:`, `:name:`, `:class:`, and `:responsive:` are the directive's options; `:name:` gives the figure a cross-reference target. When you build through {py:func}`~gp_sphinx.config.merge_sphinx_config` with the extension in `extra_extensions`, plain {rst:dir}`mermaid` fences route to the same directive automatically (it sets `myst_fence_as_directive = ["mermaid"]`).
See {ref}`sphinx-gp-mermaid-reference` for the full option and config list.
+## Choose the responsive policy
+
+Every rendered diagram sits in a scrollable figure. The `:responsive:` option decides what the SVG itself does inside that figure:
+
+- `fit` is the default. It keeps the SVG's build-time aspect ratio but lets the browser scale the diagram down to the content column.
+- `preserve` keeps the SVG at its intrinsic Mermaid-rendered width. If the diagram is wider than the column, the figure scrolls horizontally.
+
+Use `fit` for diagrams that remain readable when scaled: small flows, state diagrams, and short sequence diagrams. Use `preserve` for diagrams where scale-down would make labels unreadable, such as dense architecture maps or compatibility matrices.
+
+```markdown
+:::{mermaid}
+:caption: Dense architecture map.
+:alt: subsystem ownership and data flow
+:name: architecture-map
+:responsive: preserve
+
+flowchart LR
+ api --> planner --> executor --> sinks
+ planner --> cache
+ executor --> audit
+:::
+```
+
+`fit` and `preserve` do not rewrite the Mermaid graph. If a wide `flowchart LR` turns into a hard-to-read strip on phones, split it into smaller diagrams or use a top-to-bottom layout. Reach for `preserve` when the wide view is the useful artifact.
+
## Install the renderer
Rendering shells out to [`mmdc`](https://github.com/mermaid-js/mermaid-cli), so the docs toolchain needs Node. Install it as a dev dependency:
@@ -33,6 +61,6 @@ In a container, set `PUPPETEER_EXECUTABLE_PATH` to your Chrome (the generated pu
## How rendering behaves
-Each diagram renders twice — a light and a dark SVG — and CSS on `body[data-theme]` shows the matching one, so the theme toggle works with no JavaScript and no layout shift. Rendered SVGs are cached by content hash under `/_mermaid_cache`, which lives outside `_build` and survives `rm -rf docs/_build`, so a clean rebuild re-renders nothing unchanged.
+Each diagram renders twice — a light and a dark SVG — and CSS on `body[data-theme]` shows the matching one, so the theme toggle works with no JavaScript and no layout shift. The HTML figure carries `gp-sphinx-mermaid--fit` or `gp-sphinx-mermaid--preserve`, plus `data-mermaid-responsive`, `data-mermaid-width`, and `data-mermaid-height` attributes for downstream styling or audits. Rendered SVGs are cached by content hash under `/_mermaid_cache`, which lives outside `_build` and survives `rm -rf docs/_build`, so a clean rebuild re-renders nothing unchanged.
If `mmdc` is missing or fails, the build still succeeds: the diagram degrades to its escaped source text with a single warning, and non-HTML builders emit a `[diagram: ]` stand-in.
diff --git a/docs/redirects.txt b/docs/redirects.txt
index a65d799e..f8bc1234 100644
--- a/docs/redirects.txt
+++ b/docs/redirects.txt
@@ -2,6 +2,7 @@ extensions/sphinx-gp-opengraph packages/sphinx-gp-opengraph
extensions/sphinx-gp-sitemap packages/sphinx-gp-sitemap
extensions/sphinx-gp-llms packages/sphinx-gp-llms
extensions/sphinx-gp-mermaid packages/sphinx-gp-mermaid
+extensions/sphinx-gp-highlighting packages/sphinx-gp-highlighting
extensions/gp-sphinx packages/gp-sphinx
extensions/index packages/index
extensions/sphinx-autodoc-argparse packages/sphinx-autodoc-argparse
diff --git a/packages/sphinx-gp-highlighting/README.md b/packages/sphinx-gp-highlighting/README.md
new file mode 100644
index 00000000..9da83ca8
--- /dev/null
+++ b/packages/sphinx-gp-highlighting/README.md
@@ -0,0 +1,30 @@
+# sphinx-gp-highlighting
+
+Reusable Sphinx highlighting helpers for git-pull documentation. The package
+ships Pygments lexers for documentation-oriented blocks and Sphinx inline
+literal helpers for commands, paths, and directories.
+
+## Install
+
+```console
+$ pip install sphinx-gp-highlighting
+```
+
+## Usage
+
+Enable the extension in `conf.py`:
+
+```python
+extensions = ["sphinx_gp_highlighting"]
+```
+
+Use the `tree` lexer for directory layouts:
+
+````markdown
+```tree
+python_module
+├── package
+│ └── __init__.py
+└── pyproject.toml
+```
+````
diff --git a/packages/sphinx-gp-highlighting/pyproject.toml b/packages/sphinx-gp-highlighting/pyproject.toml
new file mode 100644
index 00000000..99d85c38
--- /dev/null
+++ b/packages/sphinx-gp-highlighting/pyproject.toml
@@ -0,0 +1,49 @@
+[project]
+name = "sphinx-gp-highlighting"
+version = "0.0.1a32"
+description = "Reusable Pygments lexers and inline highlighting for Sphinx"
+requires-python = ">=3.10,<4.0"
+authors = [
+ {name = "Tony Narlock", email = "tony@git-pull.com"}
+]
+license = { text = "MIT" }
+classifiers = [
+ "Development Status :: 3 - Alpha",
+ "License :: OSI Approved :: MIT License",
+ "Framework :: Sphinx",
+ "Framework :: Sphinx :: Extension",
+ "Intended Audience :: Developers",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+ "Programming Language :: Python :: 3.14",
+ "Topic :: Documentation",
+ "Topic :: Documentation :: Sphinx",
+ "Topic :: Software Development :: Documentation",
+ "Typing :: Typed",
+]
+readme = "README.md"
+keywords = ["sphinx", "pygments", "highlighting", "documentation"]
+dependencies = [
+ "docutils",
+ "pygments",
+ "sphinx>=8.1",
+]
+
+[project.entry-points."pygments.lexers"]
+"directory-tree" = "sphinx_gp_highlighting.lexers:DirectoryTreeLexer"
+
+[project.urls]
+Repository = "https://github.com/git-pull/gp-sphinx"
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/sphinx_gp_highlighting"]
+
+[tool.gp-sphinx.docs]
+showcase = ["examples"]
diff --git a/packages/sphinx-gp-highlighting/src/sphinx_gp_highlighting/__init__.py b/packages/sphinx-gp-highlighting/src/sphinx_gp_highlighting/__init__.py
new file mode 100644
index 00000000..dfc93285
--- /dev/null
+++ b/packages/sphinx-gp-highlighting/src/sphinx_gp_highlighting/__init__.py
@@ -0,0 +1,100 @@
+"""Reusable Sphinx highlighting helpers.
+
+Registers documentation-oriented Pygments lexers and inline literal
+helpers for Sphinx projects.
+
+Examples
+--------
+>>> from sphinx_gp_highlighting import setup
+>>> callable(setup)
+True
+"""
+
+from __future__ import annotations
+
+import logging
+import pathlib
+import typing as t
+
+from sphinx.util.logging import getLogger
+
+from sphinx_gp_highlighting.inline import (
+ HighlightingInlineTransform,
+ cmd_role,
+ dir_role,
+ path_role,
+)
+from sphinx_gp_highlighting.lexers import DirectoryTreeLexer
+
+if t.TYPE_CHECKING:
+ from sphinx.application import Sphinx
+ from sphinx.util.typing import ExtensionMetadata
+
+_EXTENSION_VERSION = "0.0.1a32"
+_TREE_ALIASES: tuple[str, ...] = ("tree", "directory-tree", "dir-tree")
+
+logger = getLogger(__name__)
+logging.getLogger(__name__).addHandler(logging.NullHandler())
+
+__all__ = ["DirectoryTreeLexer", "HighlightingInlineTransform", "setup"]
+
+
+def setup(app: Sphinx) -> ExtensionMetadata:
+ """Register the package lexers with Sphinx.
+
+ Parameters
+ ----------
+ app : Sphinx
+ Sphinx application instance.
+
+ Returns
+ -------
+ ExtensionMetadata
+ Extension metadata with version and parallel-build flags.
+
+ Examples
+ --------
+ >>> from sphinx_gp_highlighting import setup
+ >>> callable(setup)
+ True
+ """
+ for alias in _TREE_ALIASES:
+ app.add_lexer(alias, DirectoryTreeLexer)
+ app.add_config_value(
+ "gp_highlighting_inline_literals",
+ default="off",
+ rebuild="env",
+ types=frozenset({str}),
+ description=(
+ "Inline literal auto-highlighting mode. Use ``'safe'`` to "
+ "highlight configured commands and clear path/directory literals."
+ ),
+ )
+ app.add_config_value(
+ "gp_highlighting_inline_commands",
+ default=[],
+ rebuild="env",
+ types=frozenset({list, tuple}),
+ description=(
+ "Command names eligible for safe inline literal highlighting "
+ "when ``gp_highlighting_inline_literals`` is ``'safe'``."
+ ),
+ )
+ app.add_role("cmd", cmd_role)
+ app.add_role("path", path_role)
+ app.add_role("dir", dir_role)
+ app.add_transform(HighlightingInlineTransform)
+
+ static_dir = str(pathlib.Path(__file__).parent / "_static")
+
+ def _add_static_path(app: Sphinx) -> None:
+ if static_dir not in app.config.html_static_path:
+ app.config.html_static_path.append(static_dir)
+
+ app.connect("builder-inited", _add_static_path)
+ app.add_css_file("css/sphinx_gp_highlighting.css")
+ return {
+ "version": _EXTENSION_VERSION,
+ "parallel_read_safe": True,
+ "parallel_write_safe": True,
+ }
diff --git a/packages/sphinx-gp-highlighting/src/sphinx_gp_highlighting/_static/css/sphinx_gp_highlighting.css b/packages/sphinx-gp-highlighting/src/sphinx_gp_highlighting/_static/css/sphinx_gp_highlighting.css
new file mode 100644
index 00000000..e230d5af
--- /dev/null
+++ b/packages/sphinx-gp-highlighting/src/sphinx_gp_highlighting/_static/css/sphinx_gp_highlighting.css
@@ -0,0 +1,17 @@
+.gp-sphinx-highlighting-inline {
+ white-space: nowrap;
+}
+
+.gp-sphinx-highlighting-inline--kind-command,
+.gp-sphinx-highlighting-inline--kind-shell-session {
+ color: var(--color-code-foreground, inherit);
+}
+
+.gp-sphinx-highlighting-inline--kind-path,
+.gp-sphinx-highlighting-inline--kind-dir {
+ color: var(--color-api-name, inherit);
+}
+
+.gp-sphinx-highlighting-inline--kind-dir::after {
+ color: var(--color-foreground-muted, currentColor);
+}
diff --git a/packages/sphinx-gp-highlighting/src/sphinx_gp_highlighting/inline.py b/packages/sphinx-gp-highlighting/src/sphinx_gp_highlighting/inline.py
new file mode 100644
index 00000000..b47895e4
--- /dev/null
+++ b/packages/sphinx-gp-highlighting/src/sphinx_gp_highlighting/inline.py
@@ -0,0 +1,350 @@
+"""Inline literal roles and transforms for Sphinx highlighting."""
+
+from __future__ import annotations
+
+import re
+import typing as t
+
+from docutils import nodes
+from docutils.transforms import Transform
+
+if t.TYPE_CHECKING:
+ from docutils.parsers.rst.states import Inliner
+
+InlineLiteralKind: t.TypeAlias = t.Literal["command", "shell-session", "path", "dir"]
+
+_BASE_CLASS = "gp-sphinx-highlighting-inline"
+_COMMAND_LANGUAGE = "bash"
+_SHELL_SESSION_LANGUAGE = "console"
+_SHELL_SESSION_RE = re.compile(r"^\$ \S")
+_HOME_OR_DOT_PATH_RE = re.compile(r"^(?:~|\.{1,2}/)[^\s`]*$")
+_WINDOWS_PATH_RE = re.compile(r"^[A-Za-z]:[\\/][^\s`]+$")
+_RELATIVE_DIR_RE = re.compile(r"^(?:[\w.-]+/)+$")
+_RELATIVE_FILE_RE = re.compile(r"^(?:[\w.-]+/)+[\w.-]+\.[A-Za-z0-9][\w.-]*$")
+_ABSOLUTE_PATH_ROOTS = frozenset(
+ {
+ "Applications",
+ "Library",
+ "Users",
+ "bin",
+ "dev",
+ "etc",
+ "home",
+ "lib",
+ "lib64",
+ "mnt",
+ "opt",
+ "proc",
+ "root",
+ "run",
+ "sbin",
+ "srv",
+ "sys",
+ "tmp",
+ "usr",
+ "var",
+ "Volumes",
+ }
+)
+
+
+def classify_inline_literal(
+ text: str,
+ *,
+ commands: t.Iterable[str] = (),
+) -> InlineLiteralKind | None:
+ """Classify a literal that is safe to highlight automatically.
+
+ Parameters
+ ----------
+ text : str
+ Literal text.
+ commands : iterable of str
+ Command names eligible for bare command highlighting.
+
+ Returns
+ -------
+ InlineLiteralKind | None
+ The safe literal kind, or ``None`` when the text should remain
+ an ordinary inline literal.
+
+ Examples
+ --------
+ >>> classify_inline_literal("tmuxp freeze my-session", commands=["tmuxp"])
+ 'command'
+ >>> classify_inline_literal("~/.config/tmuxp/")
+ 'dir'
+ >>> classify_inline_literal("module_name") is None
+ True
+ """
+ candidate = text.strip()
+ if not candidate or "\n" in candidate:
+ return None
+ if _SHELL_SESSION_RE.match(candidate):
+ return "shell-session"
+ first_word = candidate.split(maxsplit=1)[0]
+ if first_word in set(commands) and " " in candidate:
+ return "command"
+ if _is_path_like(candidate):
+ return "dir" if candidate.endswith("/") else "path"
+ return None
+
+
+def build_highlighted_literal(text: str, kind: InlineLiteralKind) -> nodes.literal:
+ """Build a literal node with package highlighting classes.
+
+ Parameters
+ ----------
+ text : str
+ Literal text.
+ kind : InlineLiteralKind
+ Highlighting kind.
+
+ Returns
+ -------
+ nodes.literal
+ Literal node ready for the Sphinx HTML writer.
+
+ Examples
+ --------
+ >>> node = build_highlighted_literal("tmuxp freeze my-session", "command")
+ >>> node["language"]
+ 'bash'
+ >>> "gp-sphinx-highlighting-inline--kind-command" in node["classes"]
+ True
+ >>> "language" in build_highlighted_literal("~/.config/tmuxp/", "dir")
+ False
+ """
+ node = nodes.literal(
+ text,
+ text,
+ classes=[_BASE_CLASS, f"{_BASE_CLASS}--kind-{kind}"],
+ )
+ if kind in {"command", "shell-session"}:
+ node["classes"].extend(["code", "highlight"])
+ node["language"] = (
+ _SHELL_SESSION_LANGUAGE if kind == "shell-session" else _COMMAND_LANGUAGE
+ )
+ return node
+
+
+def cmd_role(
+ name: str,
+ rawtext: str,
+ text: str,
+ lineno: int,
+ inliner: Inliner | None,
+ options: dict[str, t.Any] | None = None,
+ content: list[str] | None = None,
+) -> tuple[list[nodes.Node], list[nodes.system_message]]:
+ """Role for inline shell commands.
+
+ Parameters
+ ----------
+ name : str
+ Role name.
+ rawtext : str
+ Full role markup.
+ text : str
+ Role content.
+ lineno : int
+ Source line number.
+ inliner : Inliner | None
+ Docutils inliner.
+ options : dict | None
+ Role options.
+ content : list | None
+ Role content lines.
+
+ Returns
+ -------
+ tuple[list[nodes.Node], list[nodes.system_message]]
+ Role output nodes and messages.
+
+ Examples
+ --------
+ >>> nodes_, messages = cmd_role(
+ ... "cmd", "{cmd}`tmuxp freeze x`", "tmuxp freeze x", 1, None
+ ... )
+ >>> messages
+ []
+ >>> nodes_[0]["language"]
+ 'bash'
+ """
+ return [build_highlighted_literal(text, "command")], []
+
+
+def path_role(
+ name: str,
+ rawtext: str,
+ text: str,
+ lineno: int,
+ inliner: Inliner | None,
+ options: dict[str, t.Any] | None = None,
+ content: list[str] | None = None,
+) -> tuple[list[nodes.Node], list[nodes.system_message]]:
+ """Role for inline filesystem paths.
+
+ Parameters
+ ----------
+ name : str
+ Role name.
+ rawtext : str
+ Full role markup.
+ text : str
+ Role content.
+ lineno : int
+ Source line number.
+ inliner : Inliner | None
+ Docutils inliner.
+ options : dict | None
+ Role options.
+ content : list | None
+ Role content lines.
+
+ Returns
+ -------
+ tuple[list[nodes.Node], list[nodes.system_message]]
+ Role output nodes and messages.
+
+ Examples
+ --------
+ >>> nodes_, messages = path_role("path", "{path}`~/x.py`", "~/x.py", 1, None)
+ >>> messages
+ []
+ >>> "gp-sphinx-highlighting-inline--kind-path" in nodes_[0]["classes"]
+ True
+ """
+ return [build_highlighted_literal(text, "path")], []
+
+
+def dir_role(
+ name: str,
+ rawtext: str,
+ text: str,
+ lineno: int,
+ inliner: Inliner | None,
+ options: dict[str, t.Any] | None = None,
+ content: list[str] | None = None,
+) -> tuple[list[nodes.Node], list[nodes.system_message]]:
+ """Role for inline filesystem directories.
+
+ Parameters
+ ----------
+ name : str
+ Role name.
+ rawtext : str
+ Full role markup.
+ text : str
+ Role content.
+ lineno : int
+ Source line number.
+ inliner : Inliner | None
+ Docutils inliner.
+ options : dict | None
+ Role options.
+ content : list | None
+ Role content lines.
+
+ Returns
+ -------
+ tuple[list[nodes.Node], list[nodes.system_message]]
+ Role output nodes and messages.
+
+ Examples
+ --------
+ >>> nodes_, messages = dir_role("dir", "{dir}`~/x/`", "~/x/", 1, None)
+ >>> messages
+ []
+ >>> "gp-sphinx-highlighting-inline--kind-dir" in nodes_[0]["classes"]
+ True
+ """
+ return [build_highlighted_literal(text, "dir")], []
+
+
+class HighlightingInlineTransform(Transform):
+ """Highlight safe inline literals when configured."""
+
+ default_priority = 120
+
+ def apply(self) -> None:
+ """Rewrite safe inline literals to highlighted literal nodes."""
+ env = getattr(self.document.settings, "env", None)
+ if env is None:
+ return
+ config = env.app.config
+ if getattr(config, "gp_highlighting_inline_literals", "off") != "safe":
+ return
+ commands = tuple(getattr(config, "gp_highlighting_inline_commands", ()))
+ for literal in list(self.document.findall(nodes.literal)):
+ if _should_skip_literal(literal):
+ continue
+ kind = classify_inline_literal(literal.astext(), commands=commands)
+ if kind is None:
+ continue
+ replacement = build_highlighted_literal(literal.astext(), kind)
+ replacement.source = literal.source
+ replacement.line = literal.line
+ literal.replace_self(replacement)
+
+
+def _is_path_like(text: str) -> bool:
+ """Return whether ``text`` looks like a filesystem path.
+
+ Examples
+ --------
+ >>> _is_path_like("~/.config/tmuxp/")
+ True
+ >>> _is_path_like("/mnt/c/Users/example")
+ True
+ >>> _is_path_like("application/json")
+ False
+ """
+ return bool(
+ _HOME_OR_DOT_PATH_RE.match(text)
+ or _WINDOWS_PATH_RE.match(text)
+ or _is_absolute_filesystem_path(text)
+ or _is_relative_filesystem_path(text)
+ )
+
+
+def _is_absolute_filesystem_path(text: str) -> bool:
+ """Return whether ``text`` starts with a common absolute path root.
+
+ Examples
+ --------
+ >>> _is_absolute_filesystem_path("/proc/version")
+ True
+ >>> _is_absolute_filesystem_path("/api/pull")
+ False
+ """
+ if not text.startswith("/"):
+ return False
+ first_component = text.removeprefix("/").split("/", maxsplit=1)[0]
+ return first_component in _ABSOLUTE_PATH_ROOTS
+
+
+def _is_relative_filesystem_path(text: str) -> bool:
+ """Return whether ``text`` is a conservative bare relative path.
+
+ Examples
+ --------
+ >>> _is_relative_filesystem_path("docs/conf.py")
+ True
+ >>> _is_relative_filesystem_path("resources/read")
+ False
+ """
+ if text.startswith(("/", "~", "./", "../")):
+ return False
+ first_component = text.split("/", maxsplit=1)[0]
+ if "." in first_component and not first_component.startswith("."):
+ return False
+ return bool(_RELATIVE_DIR_RE.match(text) or _RELATIVE_FILE_RE.match(text))
+
+
+def _should_skip_literal(node: nodes.literal) -> bool:
+ """Return whether a literal should not be rewritten."""
+ classes: set[str] = set(node.get("classes", ()))
+ if _BASE_CLASS in classes:
+ return True
+ return bool("language" in node or "code" in classes)
diff --git a/packages/sphinx-gp-highlighting/src/sphinx_gp_highlighting/lexers.py b/packages/sphinx-gp-highlighting/src/sphinx_gp_highlighting/lexers.py
new file mode 100644
index 00000000..2759257e
--- /dev/null
+++ b/packages/sphinx-gp-highlighting/src/sphinx_gp_highlighting/lexers.py
@@ -0,0 +1,82 @@
+"""Pygments lexers for documentation-focused code blocks."""
+
+from __future__ import annotations
+
+import re
+import typing as t
+
+from pygments.lexer import RegexLexer
+from pygments.token import Comment, Name, Punctuation, Text, Whitespace
+
+
+class DirectoryTreeLexer(RegexLexer):
+ r"""Lexer for ``tree(1)``-style directory listings.
+
+ The lexer targets documentation snippets that show project layouts
+ with box-drawing connectors. It deliberately uses standard Pygments
+ token types so existing Sphinx styles can colour the output without
+ package-specific token CSS.
+
+ Examples
+ --------
+ >>> from pygments.token import Token
+ >>> lexer = DirectoryTreeLexer()
+ >>> source = '''\
+ ... root
+ ... └── pyproject.toml # config
+ ... '''
+ >>> tokens = list(lexer.get_tokens(source))
+ >>> (Token.Punctuation, '└──') in tokens
+ True
+ >>> (Token.Name, 'pyproject.toml') in tokens
+ True
+ >>> (Token.Comment.Single, '# config') in tokens
+ True
+ """
+
+ name = "Directory tree"
+ aliases = ["tree", "directory-tree", "dir-tree"] # noqa: RUF012
+ filenames: t.ClassVar[list[str]] = []
+ mimetypes = ["text/x-directory-tree"] # noqa: RUF012
+
+ tokens = { # noqa: RUF012
+ "root": [
+ (r"\n", Whitespace),
+ (r"[ \t]+", Whitespace),
+ (r"(?:├──|└──|│)", Punctuation),
+ (r"#.*$", Comment.Single),
+ (r"[^/\s#]+/", Name.Namespace),
+ (r"[^/\s#.]+\.[^/\s#]+", Name),
+ (r"[^/\s#]+", Name.Namespace),
+ (r".", Text),
+ ],
+ }
+
+ @staticmethod
+ def analyse_text(text: str) -> float:
+ r"""Return a confidence score for tree-like text.
+
+ Parameters
+ ----------
+ text : str
+ Source text to inspect.
+
+ Returns
+ -------
+ float
+ ``0.7`` when box-drawing tree connectors are present,
+ otherwise ``0.0``.
+
+ Examples
+ --------
+ >>> DirectoryTreeLexer.analyse_text('''\
+ ... root
+ ... └── pyproject.toml
+ ... ''')
+ 0.7
+ >>> DirectoryTreeLexer.analyse_text("plain text")
+ 0.0
+ """
+ if re.search(r"(?m)^[│ \t]*(?:├──|└──)\s+\S", text):
+ return 0.7
+ return 0.0
diff --git a/packages/sphinx-gp-highlighting/src/sphinx_gp_highlighting/py.typed b/packages/sphinx-gp-highlighting/src/sphinx_gp_highlighting/py.typed
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/packages/sphinx-gp-highlighting/src/sphinx_gp_highlighting/py.typed
@@ -0,0 +1 @@
+
diff --git a/packages/sphinx-gp-mermaid/README.md b/packages/sphinx-gp-mermaid/README.md
index d33eb09f..0cba7b15 100644
--- a/packages/sphinx-gp-mermaid/README.md
+++ b/packages/sphinx-gp-mermaid/README.md
@@ -37,12 +37,19 @@ Author diagrams as MyST fences:
````markdown
:::{mermaid}
:caption: How it flows.
+:alt: a request flowing through two steps
+:name: request-flow
+:responsive: fit
flowchart LR
a --> b
:::
````
+`fit` is the default responsive policy: the rendered SVG scales down to the
+content column. Use `:responsive: preserve` when a dense diagram should keep
+its intrinsic width and scroll horizontally instead of shrinking labels.
+
## Renderer toolchain
Rendering shells out to `mmdc`, resolved from the `mermaid_cmd` config value,
diff --git a/packages/sphinx-gp-mermaid/src/sphinx_gp_mermaid/__init__.py b/packages/sphinx-gp-mermaid/src/sphinx_gp_mermaid/__init__.py
index 9576cb48..c0520db3 100644
--- a/packages/sphinx-gp-mermaid/src/sphinx_gp_mermaid/__init__.py
+++ b/packages/sphinx-gp-mermaid/src/sphinx_gp_mermaid/__init__.py
@@ -19,6 +19,7 @@
:::{mermaid}
:caption: How it flows.
+ :responsive: fit
flowchart LR
a --> b
@@ -85,6 +86,13 @@
_THEME_LIGHT = "light"
_THEME_DARK = "dark"
+#: Author-facing responsive policies. ``fit`` scales the SVG down to the
+#: available column; ``preserve`` keeps its intrinsic width and lets the figure
+#: scroll horizontally.
+_RESPONSIVE_FIT = "fit"
+_RESPONSIVE_PRESERVE = "preserve"
+_RESPONSIVE_POLICIES = (_RESPONSIVE_FIT, _RESPONSIVE_PRESERVE)
+
#: System font stack matching gp-furo's body font (gp-furo-tokens --font-stack).
_FONT_STACK = (
"-apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, sans-serif"
@@ -254,6 +262,22 @@ def _svg_element_id(digest: str, theme: str, *, occurrence: int = 0) -> str:
return f"mermaid-{digest[:12]}{suffix}-{theme}"
+def _svg_dimensions(svg: str) -> tuple[str, str] | None:
+ """Return root SVG ``viewBox`` width and height, if available.
+
+ >>> _svg_dimensions('')
+ ('120', '40')
+ >>> _svg_dimensions('')
+ ('148', '194')
+ >>> _svg_dimensions("") is None
+ True
+ """
+ match = _VIEWBOX_RE.search(svg)
+ if match is None:
+ return None
+ return match.group(1), match.group(2)
+
+
def _normalize_svg(svg: str, *, svg_id: str) -> str:
"""Make an ``mmdc`` SVG safe to inline: unique id, explicit size, no max-width.
@@ -267,8 +291,8 @@ def _normalize_svg(svg: str, *, svg_id: str) -> str:
2. The root ``width="100%"`` (mermaid emits no ``height``) is replaced with
explicit ``width``/``height`` taken from the ``viewBox``, so the browser
reserves the diagram's box in the first layout pass — before any script.
- 3. The inline ``max-width: NNpx`` is stripped; paired with the stylesheet's
- ``max-width: 100%; height: auto`` the diagram scales without reflow.
+ 3. The inline ``max-width: NNpx`` is stripped so the stylesheet, not
+ mermaid's inline style, controls the figure's responsive policy.
>>> svg = (
... '