diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f31abd..f1b05b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased +- ✨ NEW: `sd_custom_roles` defines reusable badge roles in `conf.py`, + inheriting a built-in badge role (`bdg`, `bdg-`, `bdg-*-line`, + `bdg-link-*`, `bdg-ref-*`) and optionally baking in a default `tooltip` — the + recommended way to declare repeated status badges (`stable`/`beta`/...) once, + instead of retyping the `` ; tooltip `` suffix at every use site (a + per-instance suffix still overrides the baked default). A companion to + `sd_custom_directives`, fully TOML-representable; the roles are reconciled on + each build, so config edits are picked up and a removed role is unregistered, + and a name clashing with a docutils built-in or another extension's role is + skipped with a warning ({pr}`295`, {issue}`81`). - ♻️ IMPROVE: Replace the Sass/Node build with a dependency-free Python CSS generator (`tools/generate_css.py` driven by `style/design.toml` and hand-authored `style/*.css`); `package.json` is gone. The compiled diff --git a/docs/badges_buttons.md b/docs/badges_buttons.md index 9564dda..f752ac5 100644 --- a/docs/badges_buttons.md +++ b/docs/badges_buttons.md @@ -53,11 +53,83 @@ The syntax is the same as for the `ref` role. ```` ````` +(sd-custom-badge-roles)= + +### Custom badge roles + +When the **same** badge recurs throughout your documentation — status labels +such as *stable*, *beta* or *deprecated* whose colour and tooltip never change — +define it **once** as a custom role via the `sd_custom_roles` configuration +option, rather than retyping the colour and tooltip at every use site. This is +the recommended pattern for such repeated, semantic badges: + +```python +sd_custom_roles = { + "bdg-stable": { + "inherit": "bdg-success", + "tooltip": "A released, supported version", + }, + "bdg-beta": {"inherit": "bdg-warning", "tooltip": "Interface may change"}, + "bdg-deprecated": {"inherit": "bdg-danger", "tooltip": "Scheduled for removal"}, +} +``` + +Each key is the new role name to register; the value is a dictionary with: + +- `inherit` (required): the built-in badge role to inherit from — any of the + `bdg`, `bdg-`, `bdg--line`, `bdg-link-*` or `bdg-ref-*` roles. +- `tooltip` (optional): a default tooltip, applied as the HTML `title` whenever + the role is used *without* a per-instance `; tooltip` suffix. For a + `bdg-link-*`/`bdg-ref-*` role the baked tooltip applies even to the bare + form (`` {bdg-link-*}`` ``), where the suffix grammar deliberately + refuses to split, and it overrides a reference's automatic title. + +Give custom roles a distinguishing prefix (the `bdg-` convention used here is a +good choice): a name that collides with a docutils built-in (`code`, `math`, +`strong`, …) or another extension's role is skipped, so `bdg-`-prefixed names +keep you clear of every such clash. + +The new role then behaves exactly like the role it inherits (same rendering, +links and references), with its tooltip baked in: + +{bdg-stable}`v2.1` {bdg-beta}`v3.0rc1` {bdg-deprecated}`v1.0` + +````{tab-set-code} +```markdown +{bdg-stable}`v2.1` {bdg-beta}`v3.0rc1` {bdg-deprecated}`v1.0` +``` +```rst +:bdg-stable:`v2.1` :bdg-beta:`v3.0rc1` :bdg-deprecated:`v1.0` +``` +```` + +A baked `tooltip` is only the *default*: a per-instance `; tooltip` suffix on an +individual badge **overrides** it, so a config-defined role covers the common +case while the suffix stays available for the occasional one-off: + +{bdg-stable}`v2.1 ; Long-term support release` + +````{tab-set-code} +```markdown +{bdg-stable}`v2.1 ; Long-term support release` +``` +```rst +:bdg-stable:`v2.1 ; Long-term support release` +``` +```` + +Custom roles are the badge analogue of +{ref}`custom directives `. Inheritance is limited to the +badge family listed above; a role whose `inherit` names an unknown badge role, +or whose own name clashes with an existing role, is skipped with a warning. +Editing an entry (or removing it) is picked up on the next build. + ### Badge tooltips -Any badge can be given a tooltip (shown on hover, via the HTML `title` -attribute) by appending a `; tooltip` suffix to its text. -This works for every badge family: +For a **one-off** tooltip (shown on hover, via the HTML `title` attribute) — +rather than a repeated one better served by a +{ref}`custom badge role ` — append a `; tooltip` suffix +to any badge's text. This works for every badge family: {bdg-primary}`stable ; A released, supported version` {bdg-link-info}`docs ; Opens the documentation` diff --git a/docs/conf.py b/docs/conf.py index e715cf4..8ec8dcf 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -41,6 +41,14 @@ }, } } +sd_custom_roles = { + "bdg-stable": { + "inherit": "bdg-success", + "tooltip": "A released, supported version", + }, + "bdg-beta": {"inherit": "bdg-warning", "tooltip": "Interface may change"}, + "bdg-deprecated": {"inherit": "bdg-danger", "tooltip": "Scheduled for removal"}, +} extlinks = { "pr": ("https://github.com/executablebooks/sphinx-design/pull/%s", "PR #%s"), diff --git a/docs/get_started.md b/docs/get_started.md index 66a9665..dff0686 100644 --- a/docs/get_started.md +++ b/docs/get_started.md @@ -60,6 +60,8 @@ sd_hide_title: true ::: :::: +(sd-custom-directives)= + ### Creating custom directives :::{versionadded} 0.6.0 @@ -86,6 +88,10 @@ The key is the new directive name to add, and the value is a dictionary with the - `argument`: The default argument (optional, only for directives that take a single argument) - `options`: A dictionary of default options for the directive (optional) +The same idea is available for roles: `sd_custom_roles` defines reusable badge +roles (e.g. status badges), inheriting a built-in badge role and optionally +baking in a tooltip. See {ref}`Custom badge roles `. + ## Supported browsers sphinx-design targets [**Baseline** Widely Available](https://web.dev/baseline) diff --git a/sphinx_design/badges_buttons.py b/sphinx_design/badges_buttons.py index ceb2e67..7f85200 100644 --- a/sphinx_design/badges_buttons.py +++ b/sphinx_design/badges_buttons.py @@ -1,19 +1,25 @@ -from collections.abc import Sequence +from collections.abc import Iterator, Sequence import re from typing import Any from docutils import nodes from docutils.parsers.rst import directives +from docutils.parsers.rst import roles as rst_roles from docutils.parsers.rst.states import Inliner from docutils.utils import unescape from sphinx import addnodes from sphinx.application import Sphinx +from sphinx.config import Config from sphinx.transforms.post_transforms import SphinxPostTransform from sphinx.util import ws_re from sphinx.util.docutils import ReferenceRole, SphinxRole +from sphinx.util.logging import getLogger +from sphinx_design.config import WARNING_TYPE, SdConfig from sphinx_design.shared import SEMANTIC_COLORS, SdDirective, make_choice, text_align +LOGGER = getLogger(__name__) + ROLE_NAME_BADGE_PREFIX = "bdg" ROLE_NAME_LINK_PREFIX = "bdg-link" ROLE_NAME_REF_PREFIX = "bdg-ref" @@ -25,28 +31,51 @@ # in particular for rounded-pill class etc -def setup_badges_and_buttons(app: Sphinx) -> None: - """Setup the badge components.""" - app.add_role(ROLE_NAME_BADGE_PREFIX, BadgeRole()) - app.add_role(ROLE_NAME_LINK_PREFIX, LinkBadgeRole()) - app.add_role(ROLE_NAME_REF_PREFIX, XRefBadgeRole()) +#: A single built-in badge role, as ``(name, role_class, color, outline)``. +BadgeRoleSpec = tuple[str, type["_TooltipRoleMixin"], "str | None", bool] + + +def _iter_badge_roles() -> Iterator[BadgeRoleSpec]: + """Yield the ``(name, role_class, color, outline)`` of every built-in badge role. + + This is the single source of truth for the badge role family, used both to + register the roles (:func:`setup_badges_and_buttons`) and to resolve an + ``sd_custom_roles`` ``inherit`` target (:func:`setup_custom_roles`). + + .. note:: + + The custom-role ``inherit`` mechanism is deliberately limited to this + badge family in v1. Extending it to the icon roles (``icons.py``) or the + button *roles* would mean yielding those here too, and giving their role + constructors the same ``tooltip`` keyword the badge roles now accept. + """ + yield ROLE_NAME_BADGE_PREFIX, BadgeRole, None, False + yield ROLE_NAME_LINK_PREFIX, LinkBadgeRole, None, False + yield ROLE_NAME_REF_PREFIX, XRefBadgeRole, None, False for color in SEMANTIC_COLORS: - app.add_role("-".join((ROLE_NAME_BADGE_PREFIX, color)), BadgeRole(color)) - app.add_role( - "-".join((ROLE_NAME_BADGE_PREFIX, color, "line")), - BadgeRole(color, outline=True), - ) - app.add_role("-".join((ROLE_NAME_LINK_PREFIX, color)), LinkBadgeRole(color)) - app.add_role( + yield "-".join((ROLE_NAME_BADGE_PREFIX, color)), BadgeRole, color, False + yield "-".join((ROLE_NAME_BADGE_PREFIX, color, "line")), BadgeRole, color, True + yield "-".join((ROLE_NAME_LINK_PREFIX, color)), LinkBadgeRole, color, False + yield ( "-".join((ROLE_NAME_LINK_PREFIX, color, "line")), - LinkBadgeRole(color, outline=True), + LinkBadgeRole, + color, + True, ) - app.add_role("-".join((ROLE_NAME_REF_PREFIX, color)), XRefBadgeRole(color)) - app.add_role( + yield "-".join((ROLE_NAME_REF_PREFIX, color)), XRefBadgeRole, color, False + yield ( "-".join((ROLE_NAME_REF_PREFIX, color, "line")), - XRefBadgeRole(color, outline=True), + XRefBadgeRole, + color, + True, ) + +def setup_badges_and_buttons(app: Sphinx) -> None: + """Setup the badge components.""" + for name, role_cls, color, outline in _iter_badge_roles(): + app.add_role(name, role_cls(color, outline=outline)) + app.add_node( sd_badge, html=(visit_sd_badge_html, depart_sd_badge_html), @@ -64,6 +93,91 @@ def setup_badges_and_buttons(app: Sphinx) -> None: app.add_post_transform(BadgeRefTooltipGraft) +#: Marker attribute set on the role instances this extension registers from +#: ``sd_custom_roles``. docutils' role registry (``roles._roles``) is +#: process-global and shared across Sphinx apps, so carrying "ownership" on the +#: stored role object itself lets every ``config-inited`` reconcile *our* roles +#: (refresh or unregister) without a side table that could drift from the +#: registry - e.g. across the many apps a test session builds. +_SD_CUSTOM_ROLE_ATTR = "_sd_custom_role" + + +def _role_name_taken(name: str) -> bool: + """Return whether ``name`` already resolves to a role. + + docutils looks roles up case-insensitively, so the lower-cased name is + compared against both the locally registered roles (``roles._roles``, the + registry Sphinx's own ``add_role`` consults) *and* docutils' canonical + built-in roles (``roles._role_registry``: ``code``/``math``/``strong``/ + ``emphasis``/``literal``/... ). Both are long-stable private attributes; + docutils exposes no public query for either. + """ + key = name.lower() + return key in rst_roles._roles or key in rst_roles._role_registry + + +def setup_custom_roles(app: Sphinx, config: Config) -> None: + """Register (and reconcile) the custom roles declared in ``sd_custom_roles``. + + Parallel to :func:`sphinx_design.shared.setup_custom_directives`: each entry + inherits a built-in sphinx-design *badge* role (v1 scope; see + :func:`_iter_badge_roles`), optionally baking in a default ``tooltip`` used + whenever no per-instance ``; suffix`` overrides it. + + The entry shapes have already been validated on ``config-inited`` + (see ``sphinx_design.config``); here we additionally check ``inherit`` + against the known badge roles. Because it runs on every ``config-inited`` + against a process-global registry, the reconciliation policy is: + + * a name we registered before is **refreshed** (re-registered with override, + picking up tooltip/inherit edits on an in-process rebuild); + * a name we registered before but now absent from the config is + **unregistered**, so it never leaks into a later build or sibling app; + * a name clashing with a *foreign* role (another extension's, or a docutils + built-in such as ``code``/``strong``) is skipped with a warning. + """ + + def _warn(msg: str) -> None: + LOGGER.warning(f"sd_custom_roles: {msg}", type=WARNING_TYPE, subtype="config") + + specs = { + name: (role_cls, color, outline) + for name, role_cls, color, outline in _iter_badge_roles() + } + # extension point: to also inherit icon/button roles, yield them from + # ``_iter_badge_roles`` (and give those constructors a ``tooltip`` keyword). + + # decide the roles to (re)register: a known ``inherit``, and a name that is + # either already ours (refresh) or free (a foreign clash is skipped) + desired: dict[str, tuple[str, dict[str, Any]]] = {} # lower name -> (name, data) + for name, data in SdConfig.from_sphinx(config).custom_roles.items(): + inherit = data["inherit"] + if inherit not in specs: + _warn(f"'{name}.inherit' is an unknown badge role: {inherit}") + continue + existing = rst_roles._roles.get(name.lower()) + if not getattr(existing, _SD_CUSTOM_ROLE_ATTR, False) and _role_name_taken( + name + ): + _warn(f"'{name}' clashes with an existing role, skipping") + continue + desired[name.lower()] = (name, data) + + # unregister any of *our* previously-registered roles dropped from the config + # (the registry is process-global, so this is what stops a stale role + # resolving in a later in-process build or a sibling app) + for key, role in list(rst_roles._roles.items()): + if getattr(role, _SD_CUSTOM_ROLE_ATTR, False) and key not in desired: + del rst_roles._roles[key] + + # (re)register, refreshing tooltip/inherit changes via ``override=True`` + for name, data in desired.values(): + role_cls, color, outline = specs[data["inherit"]] + role = role_cls(color, outline=outline, tooltip=data.get("tooltip")) + setattr(role, _SD_CUSTOM_ROLE_ATTR, True) + app.add_role(name, role, override=True) + + def create_bdg_classes(color: str | None, outline: bool) -> list[str]: """Create the badge classes.""" classes = [ @@ -202,6 +316,10 @@ class _TooltipRoleMixin(SphinxRole): The parsed tooltip (or ``None``) is stored on :attr:`tooltip`, and the remaining text - with the suffix removed - is forwarded to the base role, so that ``ReferenceRole``'s title/target parsing still applies to it. + + The badge roles share this constructor (``color``/``outline``, plus an + optional baked ``tooltip``), so that they - and any config-defined roles + inheriting them (see :func:`setup_custom_roles`) - are built uniformly. """ #: the tooltip parsed from the current role invocation, if any @@ -212,6 +330,28 @@ class _TooltipRoleMixin(SphinxRole): #: form is a target in which ``;`` is a legal character) tooltip_requires_explicit_target: bool = False + def __init__( + self, + color: str | None = None, + *, + outline: bool = False, + tooltip: str | None = None, + ) -> None: + """Construct a badge role. + + :param color: the semantic colour, or ``None`` for the plain badge. + :param outline: whether to use the outline (``-line``) variant. + :param tooltip: a baked-in default tooltip, used when no per-instance + ``; suffix`` overrides it (set by config-defined roles). An explicit + suffix always wins. ``None`` for the built-in roles, keeping their + output byte-identical. + """ + super().__init__() + self.color = color + self.outline = outline + #: the baked default tooltip (see the ``tooltip`` parameter) + self.default_tooltip = tooltip + def __call__( # noqa: PLR0913 self, name: str, @@ -249,17 +389,16 @@ def __call__( # noqa: PLR0913 # resolve raw (MyST-style) ``\;`` escapes in the forwarded text; a # no-op for rST, whose escapes are NUL-encoded and resolved downstream text = _RAW_ESCAPED_SEMICOLON_RE.sub(";", text) + if self.tooltip is None: + # no per-instance suffix: fall back to any baked default (``None`` + # for the built-in roles). A suffix, when present, always wins. + self.tooltip = self.default_tooltip return super().__call__(name, rawtext, text, lineno, inliner, options, content) class BadgeRole(_TooltipRoleMixin): """Role to display a badge.""" - def __init__(self, color: str | None = None, *, outline: bool = False) -> None: - super().__init__() - self.color = color - self.outline = outline - def run(self) -> tuple[list[nodes.Node], list[nodes.system_message]]: """Run the role.""" node = sd_badge( @@ -279,11 +418,6 @@ class LinkBadgeRole(_TooltipRoleMixin, ReferenceRole): # ``;`` is legal in URLs: tooltips only after ``text `` tooltip_requires_explicit_target = True - def __init__(self, color: str | None = None, *, outline: bool = False) -> None: - super().__init__() - self.color = color - self.outline = outline - def run(self) -> tuple[list[nodes.Node], list[nodes.system_message]]: """Run the role.""" node = nodes.reference( @@ -306,11 +440,6 @@ class XRefBadgeRole(_TooltipRoleMixin, ReferenceRole): # ``;`` is legal in reference targets: tooltips only after ``text `` tooltip_requires_explicit_target = True - def __init__(self, color: str | None = None, *, outline: bool = False) -> None: - super().__init__() - self.color = color - self.outline = outline - def run(self) -> tuple[list[nodes.Node], list[nodes.system_message]]: """Run the role.""" options = { diff --git a/sphinx_design/config.py b/sphinx_design/config.py index 7522104..fee151d 100644 --- a/sphinx_design/config.py +++ b/sphinx_design/config.py @@ -22,6 +22,7 @@ from __future__ import annotations import dataclasses as dc +import re from typing import TYPE_CHECKING, Any, Protocol from sphinx.util.logging import getLogger @@ -203,6 +204,70 @@ def validate_custom_directives( validate_custom_directive(field, name, data) +#: The keys a single ``sd_custom_roles`` entry may declare. +CUSTOM_ROLE_KEYS = ("inherit", "tooltip") + +#: A syntactically usable role name: non-empty and free of whitespace (which +#: docutils' inline-role parser can never match). Unicode letters, digits, +#: hyphens, colons and mixed case are all accepted (docutils looks roles up +#: case-insensitively). +_ROLE_NAME_RE = re.compile(r"\A\S+\Z") + + +def validate_custom_role(field: dc.Field[Any], name: Any, data: Any) -> None: + """Validate the shape of a single custom role (name -> data) entry. + + Note, whether ``data["inherit"]`` refers to a known sphinx-design badge + role can only be checked at registration time + (see ``sphinx_design.badges_buttons.setup_custom_roles``). + + :param field: The dataclass field the entry belongs to. + :param name: The name of the new role. + :param data: The role data, expected shape + ``{inherit: str, tooltip: str}`` (``tooltip`` optional). + :raises TypeError | ValueError: If the entry is invalid. + """ + if not isinstance(name, str): + raise TypeError(f"key must be a string: {name!r}") + if not _ROLE_NAME_RE.match(name): + raise ValueError( + f"role name {name!r} is invalid: it must be non-empty and contain " + "no whitespace (docutils could never parse such a role)" + ) + if not isinstance(data, dict): + raise TypeError(f"{name!r} value must be a dictionary") + if "inherit" not in data: + raise ValueError(f"{name!r} value must have an 'inherit' key") + if not isinstance(data["inherit"], str): + raise TypeError(f"'{name}.inherit' value must be a string") + if "tooltip" in data and not isinstance(data["tooltip"], str): + raise TypeError(f"'{name}.tooltip' value must be a string") + # unlike custom directives (whose option set is directive-specific), a role + # entry has a small, fixed key set, so unknown keys are rejected to catch typos + unknown = [key for key in data if key not in CUSTOM_ROLE_KEYS] + if unknown: + raise ValueError( + f"'{name}' has unknown keys {unknown!r} (allowed: {list(CUSTOM_ROLE_KEYS)!r})" + ) + + +def validate_custom_roles( + inst: Any, field: dc.Field[Any], value: Any, suffix: str = "" +) -> None: + """Validate the custom roles mapping, raising on the first invalid entry. + + :param inst: The dataclass instance (or None if not yet created). + :param field: The dataclass field. + :param value: The value to validate. + :param suffix: Suffix to append to the field name in error messages. + :raises TypeError | ValueError: If the value is invalid. + """ + if not isinstance(value, dict): + raise TypeError(f"'{field.name}{suffix}' must be a dictionary (got {value!r})") + for name, data in value.items(): + validate_custom_role(field, name, data) + + @dc.dataclass class SdConfig: """Global configuration for sphinx-design (all values TOML-compatible). @@ -219,6 +284,15 @@ class SdConfig: "doc_type": "dict[str, dict]", }, ) + custom_roles: dict[str, Any] = dc.field( + default_factory=dict, + metadata={ + "validator": validate_custom_roles, + "entry_validator": validate_custom_role, + "help": "Custom roles, inheriting from sphinx-design badge ones", + "doc_type": "dict[str, dict]", + }, + ) fontawesome_source: str = dc.field( default="none", metadata={ diff --git a/sphinx_design/extension.py b/sphinx_design/extension.py index 370ddc7..9d9fce0 100644 --- a/sphinx_design/extension.py +++ b/sphinx_design/extension.py @@ -8,7 +8,7 @@ from sphinx.transforms import SphinxTransform from .article_info import setup_article_info -from .badges_buttons import setup_badges_and_buttons +from .badges_buttons import setup_badges_and_buttons, setup_custom_roles from .cards import setup_cards from .config import get_sd_config, setup_sd_config from .dropdown import setup_dropdown @@ -56,6 +56,7 @@ def setup_extension(app: Sphinx) -> None: app.connect( "config-inited", partial(setup_custom_directives, directive_map=directive_map) ) + app.connect("config-inited", setup_custom_roles) @contextmanager diff --git a/tests/test_custom_roles.py b/tests/test_custom_roles.py new file mode 100644 index 0000000..a0d757c --- /dev/null +++ b/tests/test_custom_roles.py @@ -0,0 +1,455 @@ +"""Tests for config-defined custom badge roles (``sd_custom_roles``). + +Each ``sd_custom_roles`` entry registers a new role that inherits a built-in +sphinx-design *badge* role (the v1 scope: ``bdg``/``bdg-``/``-line`` and +the ``bdg-link-*``/``bdg-ref-*`` families), optionally baking in a default +``tooltip``. The custom role renders identically to the role it inherits, and: + +* a baked ``tooltip`` becomes the default HTML ``title`` (via the same + mechanisms as the per-instance ``; tooltip`` suffix - a ``sd_badge`` node for + plain badges, ``reftitle`` for link/ref badges), +* a per-instance ``; tooltip`` suffix **overrides** the baked value, +* a custom role *without* a tooltip is byte-identical to the inherited role, +* an unknown ``inherit`` or a clash with an existing role warns and is skipped. + +Registration is reconciled on every ``config-inited`` against docutils' +process-global role registry, so this module also covers: a clash with a +docutils canonical role (``code``/``strong``/...) is warned and skipped; an +in-process rebuild refreshes an edited tooltip; and a role dropped from the +config is unregistered (rather than leaking into a later build or sibling app). + +Written to also run under ``py311-no-myst``: core assertions use +reStructuredText, and the MyST variants are guarded by ``MYST_PARAM``. +""" + +import io + +from docutils import nodes +import pytest +from sphinx.application import Sphinx +from sphinx.util.docutils import docutils_namespace + +from sphinx_design.badges_buttons import sd_badge + +try: + import myst_parser # noqa: F401 + + MYST_INSTALLED = True +except ImportError: + MYST_INSTALLED = False + +# guard MyST variants so the ``py311-no-myst`` environment still passes +MYST_PARAM = pytest.param( + "myst", + marks=pytest.mark.skipif(not MYST_INSTALLED, reason="myst-parser not installed"), +) + +# the custom roles shared by the rendering tests below +CUSTOM_ROLES = { + "bdg-stable": { + "inherit": "bdg-success", + "tooltip": "A released, supported version", + }, + "bdg-plain": {"inherit": "bdg-info"}, + "bdg-docs": {"inherit": "bdg-link-info", "tooltip": "Opens the documentation"}, + "bdg-jump": {"inherit": "bdg-ref-primary", "tooltip": "Jump to the target"}, +} + + +def _build(sphinx_builder, fmt, content, *, custom_roles=None, assert_pass=True): + """Build a single-document project from a ``{"rst": ..., "myst": ...}`` map.""" + custom_roles = CUSTOM_ROLES if custom_roles is None else custom_roles + if fmt == "rst": + builder = sphinx_builder( + conf_kwargs={ + "extensions": ["sphinx_design"], + "sd_custom_roles": custom_roles, + } + ) + builder.src_path.joinpath("index.rst").write_text( + content["rst"], encoding="utf8" + ) + else: + builder = sphinx_builder( + conf_kwargs={ + "extensions": ["myst_parser", "sphinx_design"], + "myst_enable_extensions": ["colon_fence"], + "sd_custom_roles": custom_roles, + } + ) + builder.src_path.joinpath("index.md").write_text( + content["myst"], encoding="utf8" + ) + builder.build(assert_pass=assert_pass) + return builder + + +# --------------------------------------------------------------------------- +# plain badge inheritance: baked tooltip + suffix precedence +# --------------------------------------------------------------------------- + +PLAIN = { + "rst": """ +Heading +======= + +Baked :bdg-stable:`v1` here. + +Override :bdg-stable:`v1 ; Custom override` there. +""", + "myst": """ +# Heading + +Baked {bdg-stable}`v1` here. + +Override {bdg-stable}`v1 ; Custom override` there. +""", +} + + +@pytest.mark.parametrize("fmt", ["rst", MYST_PARAM]) +def test_baked_tooltip_and_suffix_override(fmt, sphinx_builder): + """A baked tooltip is the default ``title``; a ``; suffix`` overrides it.""" + builder = _build(sphinx_builder, fmt, PLAIN) + html = (builder.out_path / "index.html").read_text(encoding="utf8") + # inherits bdg-success's classes, and bakes in the default tooltip + assert ( + 'v1' in html + ) + # the per-instance suffix overrides the baked default + assert ( + 'v1' in html + ) + # the baked default never appears alongside the override + assert 'title="A released, supported version">v1' in html + + doctree = builder.get_doctree("index") + badges = list(doctree.findall(sd_badge)) + assert len(badges) == 2 + assert badges[0]["tooltip"] == "A released, supported version" + assert badges[1]["tooltip"] == "Custom override" + + +# --------------------------------------------------------------------------- +# byte-identity: a no-tooltip custom role == the inherited role +# --------------------------------------------------------------------------- + +IDENTICAL = { + "rst": """ +Heading +======= + +Custom :bdg-plain:`hello` and inherited :bdg-info:`hello`. +""", + "myst": """ +# Heading + +Custom {bdg-plain}`hello` and inherited {bdg-info}`hello`. +""", +} + + +@pytest.mark.parametrize("fmt", ["rst", MYST_PARAM]) +def test_no_tooltip_custom_role_byte_identical(fmt, sphinx_builder): + """A tooltip-less custom role renders byte-identically to its inherited role.""" + builder = _build(sphinx_builder, fmt, IDENTICAL) + html = (builder.out_path / "index.html").read_text(encoding="utf8") + span = 'hello' + # the custom and the inherited badge produce the exact same span (no title) + assert html.count(span) == 2 + + # ... and the doctree nodes are equivalent: neither carries a tooltip + doctree = builder.get_doctree("index") + badges = list(doctree.findall(sd_badge)) + assert len(badges) == 2 + for badge in badges: + assert "tooltip" not in badge + assert badge["classes"] == [ + "sd-sphinx-override", + "sd-badge", + "sd-bg-info", + "sd-bg-text-info", + ] + + +# --------------------------------------------------------------------------- +# link badge inheritance (reference reftitle) +# --------------------------------------------------------------------------- + +LINK = { + "rst": """ +Heading +======= + +Baked :bdg-docs:`docs `. + +Override :bdg-docs:`docs ; Other tip`. +""", + "myst": """ +# Heading + +Baked {bdg-docs}`docs `. + +Override {bdg-docs}`docs ; Other tip`. +""", +} + + +@pytest.mark.parametrize("fmt", ["rst", MYST_PARAM]) +def test_link_badge_custom_role(fmt, sphinx_builder): + """A custom link badge bakes its tooltip into ``reftitle``; a suffix wins.""" + builder = _build(sphinx_builder, fmt, LINK) + doctree = builder.get_doctree("index") + refs = [r for r in doctree.findall(nodes.reference) if "sd-badge" in r["classes"]] + assert len(refs) == 2 + assert refs[0]["refuri"] == "https://example.com" + assert refs[0]["reftitle"] == "Opens the documentation" + assert refs[1]["reftitle"] == "Other tip" + # inherits the bdg-link-info colour classes + assert "sd-bg-info" in refs[0]["classes"] + + +LINK_BARE = { + "rst": """ +Heading +======= + +Bare :bdg-docs:`https://example.com/a;b`. +""", + "myst": """ +# Heading + +Bare {bdg-docs}`https://example.com/a;b`. +""", +} + + +@pytest.mark.parametrize("fmt", ["rst", MYST_PARAM]) +def test_link_badge_baked_tooltip_bare_form(fmt, sphinx_builder): + """A baked tooltip applies even to a bare-form link badge. + + The ``; tooltip`` *suffix* is only recognised after the explicit + ``text `` form for link/ref badges (``;`` is legal in URLs), so a + bare target is never split. A *baked* tooltip is not a suffix, so it still + applies here - reaching a use where the per-instance grammar could not. + """ + builder = _build(sphinx_builder, fmt, LINK_BARE) + doctree = builder.get_doctree("index") + refs = [r for r in doctree.findall(nodes.reference) if "sd-badge" in r["classes"]] + assert len(refs) == 1 + # the bare target is untouched (the whole text is the URL, ``;`` and all) + assert refs[0]["refuri"] == "https://example.com/a;b" + # yet the baked tooltip is applied + assert refs[0]["reftitle"] == "Opens the documentation" + + +# --------------------------------------------------------------------------- +# cross-reference badge inheritance (reftitle grafted post-resolution) +# --------------------------------------------------------------------------- + +REF = { + "rst": """ +Heading +======= + +.. _my-target: + +My Target Section +----------------- + +Baked :bdg-jump:`Target `. + +Override :bdg-jump:`Target ; Other tip`. +""", + "myst": """ +# Heading + +(my-target)= + +## My Target Section + +Baked {bdg-jump}`Target `. + +Override {bdg-jump}`Target ; Other tip`. +""", +} + + +@pytest.mark.parametrize("fmt", ["rst", MYST_PARAM]) +def test_ref_badge_custom_role(fmt, sphinx_builder): + """A custom ref badge bakes its tooltip, applied to the resolved reference.""" + builder = _build(sphinx_builder, fmt, REF) + html = (builder.out_path / "index.html").read_text(encoding="utf8") + assert html.count('href="#my-target"') == 2 + + doctree = builder.get_doctree("index", post_transforms=True) + refs = [r for r in doctree.findall(nodes.reference) if "sd-badge" in r["classes"]] + assert len(refs) == 2 + titles = {r.get("reftitle") for r in refs} + assert titles == {"Jump to the target", "Other tip"} + # inherits the bdg-ref-primary colour classes + assert all("sd-bg-primary" in r["classes"] for r in refs) + + +# --------------------------------------------------------------------------- +# registration edge cases: clash + unknown inherit warn and skip +# --------------------------------------------------------------------------- + +CLASH = { + "rst": """ +Heading +======= + +A :bdg-primary:`p` badge. +""", + "myst": """ +# Heading + +A {bdg-primary}`p` badge. +""", +} + + +@pytest.mark.parametrize("fmt", ["rst", MYST_PARAM]) +def test_clash_with_existing_role_warns_and_skips(fmt, sphinx_builder): + """A custom role named like an existing role warns and leaves it untouched.""" + builder = _build( + sphinx_builder, + fmt, + CLASH, + custom_roles={"bdg-primary": {"inherit": "bdg-danger", "tooltip": "nope"}}, + assert_pass=False, + ) + assert ( + "sd_custom_roles: 'bdg-primary' clashes with an existing role, skipping" + in builder.warnings + ) + html = (builder.out_path / "index.html").read_text(encoding="utf8") + # the built-in bdg-primary is retained (primary classes, no baked tooltip) + assert ( + '' + "p" in html + ) + assert 'title="nope"' not in html + + +CONTENT = { + "rst": "Heading\n=======\n\ncontent\n", + "myst": "# Heading\n\ncontent\n", +} + + +@pytest.mark.parametrize("fmt", ["rst", MYST_PARAM]) +def test_unknown_inherit_warns_and_skips(fmt, sphinx_builder): + """A custom role inheriting an unknown badge role warns and is not registered.""" + builder = _build( + sphinx_builder, + fmt, + CONTENT, + custom_roles={"bdg-foo": {"inherit": "not-a-badge"}}, + assert_pass=False, + ) + assert ( + "sd_custom_roles: 'bdg-foo.inherit' is an unknown badge role: not-a-badge" + in builder.warnings + ) + + +CANONICAL = { + "rst": """ +Heading +======= + +Bold **b**, role :strong:`s`, and code :code:`x = 1`. +""", + "myst": """ +# Heading + +Bold **b**, role {strong}`s`, and code {code}`x = 1`. +""", +} + + +@pytest.mark.parametrize("fmt", ["rst", MYST_PARAM]) +def test_canonical_role_clash_warns_and_skips(fmt, sphinx_builder): + """A custom role named after a docutils canonical role is skipped, not a hijack. + + ``strong``/``code``/... live in docutils' canonical registry (not the local + one Sphinx's ``add_role`` checks), so they must be recognised as clashes - + otherwise a custom role would silently override ``**bold**`` / ``:code:``. + """ + builder = _build( + sphinx_builder, + fmt, + CANONICAL, + custom_roles={ + "strong": {"inherit": "bdg-danger", "tooltip": "HIJACK"}, + "code": {"inherit": "bdg-info", "tooltip": "HIJACK"}, + }, + assert_pass=False, + ) + assert "'strong' clashes with an existing role, skipping" in builder.warnings + assert "'code' clashes with an existing role, skipping" in builder.warnings + html = (builder.out_path / "index.html").read_text(encoding="utf8") + # neither canonical role was overridden: no badge, no baked tooltip + assert "HIJACK" not in html + assert "sd-badge" not in html + # ``**b**`` and ``:strong:`s``` still render as real ```` + assert html.count("") == 2 + + +def _raw_build(tmp_path, sub, custom_roles): + """Build a tiny rST project with a raw ``Sphinx`` app (no fixture isolation). + + Returns the rendered ``index.html``. Used to exercise the process-global + docutils role registry that the fixtures otherwise reset between apps. + """ + src = tmp_path / sub + src.mkdir() + (src / "conf.py").write_text( + "extensions = ['sphinx_design']\n" + f"sd_custom_roles = {custom_roles!r}\n", + encoding="utf8", + ) + (src / "index.rst").write_text("H\n=\n\nUse :bdg-stable:`v1`.\n", encoding="utf8") + app = Sphinx( + str(src), + str(src), + str(src / "_out"), + str(src / "_dt"), + "html", + warningiserror=False, + status=None, + warning=io.StringIO(), + ) + app.build() + return (src / "_out" / "index.html").read_text(encoding="utf8") + + +def test_reconcile_refresh_and_unregister_in_process(tmp_path): + """In one process (shared role registry): edits refresh, drops unregister. + + The pytest fixtures reset docutils' global role state between apps, which + would mask the reconciliation; building several raw apps inside a single + ``docutils_namespace`` exercises the real shared registry (and restores it + on exit, so other tests are unaffected). + """ + stable = {"inherit": "bdg-success"} + with docutils_namespace(): + html1 = _raw_build( + tmp_path, "a", {"bdg-stable": {**stable, "tooltip": "FIRST"}} + ) + assert 'title="FIRST"' in html1 + + # an edited tooltip is picked up on the in-process rebuild (no stale value) + html2 = _raw_build( + tmp_path, "b", {"bdg-stable": {**stable, "tooltip": "SECOND"}} + ) + assert 'title="SECOND"' in html2 + assert 'title="FIRST"' not in html2 + + # dropping the role unregisters it: it no longer resolves as a badge + html3 = _raw_build(tmp_path, "c", {}) + assert "sd-badge" not in html3 + assert "problematic" in html3 # the now-unknown role errors instead diff --git a/tests/test_misc.py b/tests/test_misc.py index dc651e5..9566cb9 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -506,6 +506,7 @@ def test_button_i18n_translated(sphinx_builder): INVALID_CONFIG_VALUES = { "custom_directives": (["not", "a", "dict"], "must be a dictionary"), + "custom_roles": (["not", "a", "dict"], "must be a dictionary"), "fontawesome_source": ("invalid", "must be one of"), "fontawesome_cdn_url": (123, "must be of type"), "fontawesome_version": ("7", "must be one of"), @@ -579,6 +580,42 @@ def test_config_custom_directives_unknown_inherit(sphinx_builder): assert "'foo.inherit' is an unknown directive key: unknown" in builder.warnings +def test_config_custom_roles_invalid_entry(sphinx_builder): + """An invalid ``sd_custom_roles`` entry should emit a ``design.config`` + warning and be discarded, without affecting valid entries. + """ + builder = sphinx_builder( + conf_kwargs={ + "extensions": ["sphinx_design"], + "sd_custom_roles": { + "bdg-stable": {"inherit": "bdg-success", "tooltip": "Stable"}, + "bdg-bad": {"tooltip": "no inherit"}, + "bdg-typo": {"inherit": "bdg-info", "tootlip": "misspelt key"}, + "bad name": {"inherit": "bdg-info"}, + }, + } + ) + _write_index_rst(builder) + builder.build(assert_pass=False) + assert "'bdg-bad' value must have an 'inherit' key" in builder.warnings + assert "'bdg-typo' has unknown keys" in builder.warnings + assert "role name 'bad name' is invalid" in builder.warnings + assert list(get_sd_config(builder.app.env).custom_roles) == ["bdg-stable"] + + +def test_config_custom_roles_unknown_inherit(sphinx_builder): + """An unknown ``inherit`` badge role should emit a ``design.config`` warning.""" + builder = sphinx_builder( + conf_kwargs={ + "extensions": ["sphinx_design"], + "sd_custom_roles": {"bdg-foo": {"inherit": "not-a-badge"}}, + } + ) + _write_index_rst(builder) + builder.build(assert_pass=False) + assert "'bdg-foo.inherit' is an unknown badge role: not-a-badge" in builder.warnings + + def test_config_warnings_suppressible(sphinx_builder): """Config warnings are emitted with the ``design.config`` type/subtype, so they can be suppressed via ``suppress_warnings``. @@ -609,6 +646,14 @@ def test_config_strict_validation(): SdConfig(custom_directives="not-a-dict") with pytest.raises(ValueError, match="must have an 'inherit' key"): SdConfig(custom_directives={"foo": {}}) + with pytest.raises(TypeError, match="'custom_roles' must be a dictionary"): + SdConfig(custom_roles="not-a-dict") + with pytest.raises(ValueError, match="must have an 'inherit' key"): + SdConfig(custom_roles={"bdg-foo": {}}) + with pytest.raises(ValueError, match="has unknown keys"): + SdConfig(custom_roles={"bdg-foo": {"inherit": "bdg", "bad": 1}}) + with pytest.raises(ValueError, match="role name 'bad name' is invalid"): + SdConfig(custom_roles={"bad name": {"inherit": "bdg"}}) def test_config_toml_round_trip(): @@ -629,6 +674,10 @@ def test_config_toml_round_trip(): [custom_directives.dropdown-syntax.options] color = "primary" icon = "code" + + [custom_roles.bdg-stable] + inherit = "bdg-success" + tooltip = "A released, supported version" """ data = tomllib.loads(toml_str) assert set(data) == {f.name for f in dc.fields(SdConfig)}, ( @@ -648,3 +697,9 @@ def test_config_toml_round_trip(): "options": {"color": "primary", "icon": "code"}, } } + assert config.custom_roles == { + "bdg-stable": { + "inherit": "bdg-success", + "tooltip": "A released, supported version", + } + }