From 8f6796e44d13405513a3cdddd589d945087f700b Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Wed, 15 Jul 2026 11:09:37 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20sd=5Ffontawesome=5Fversion=20?= =?UTF-8?q?for=20version-agnostic=20icon=20roles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The role name now acts as a pure style selector (solid/brands/regular): set sd_fontawesome_version = "4" | "5" | "6" and every role spelling emits that version's class scheme, so upgrading FontAwesome is a one-line conf.py change. The default "as-named" emits the role name verbatim, byte-identical to previous releases. LaTeX rendering is unaffected. --- CHANGELOG.md | 5 + docs/badges_buttons.md | 73 +++++++-- sphinx_design/config.py | 8 + sphinx_design/icons.py | 41 ++++- tests/test_fontawesome.py | 146 +++++++++++++++++- tests/test_misc.py | 3 + .../snippet_post_icon-fontawesome.xml | 2 +- .../snippet_pre_icon-fontawesome.xml | 2 +- 8 files changed, 255 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4db2da0..46d3c1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- ✨ NEW: `sd_fontawesome_version` makes icon role names version-agnostic: + any spelling (`fas`, `fa-solid`, ...) can emit the FontAwesome `"4"`, `"5"` + or `"6"` class scheme, so upgrading FontAwesome is a one-line `conf.py` + change; the default `"as-named"` keeps emitting the role name verbatim + ({pr}`288`, {issue}`174`) - ✨ NEW: Badge roles accept a trailing `` ; tooltip `` suffix, rendered as a native HTML `title` tooltip on all `bdg-*` families; for the link/ref badges the suffix applies only after the explicit `text ` form (semicolons diff --git a/docs/badges_buttons.md b/docs/badges_buttons.md index 3a2c410..b222e8c 100644 --- a/docs/badges_buttons.md +++ b/docs/badges_buttons.md @@ -283,15 +283,51 @@ Not all icons are available for each flavor, but most are. Instead of displaying ### FontAwesome Icons -FontAwesome icons are added via the FontAwesome CSS classes, with the -`fa-solid`, `fa-brands` and `fa-regular` roles (the FontAwesome v6 style names): +FontAwesome icons are added via the FontAwesome CSS classes. +The role name selects the icon *style* — solid (`fa-solid`/`fas`/`fa`), +brands (`fa-brands`/`fab`) or regular (`fa-regular`/`far`) — and you can pick +any spelling: - A solid icon {fa-solid}`rocket;sd-text-primary`, some more text. - A brand icon {fa-brands}`github`, some more text. - A regular icon {fa-regular}`bell;sd-text-warning`, some more text. -Each role emits exactly the classes it is named after: the `fa-solid` role -applied to `rocket` produces ``. +By default each role emits exactly the classes it is named after: the +`fa-solid` role applied to `rocket` produces +``, while the `fas` role produces +``. + +#### Matching your FontAwesome version + +Set `sd_fontawesome_version` to the major version of the FontAwesome CSS you +load, and every role spelling is translated to that version's class scheme — +so the role names are version-agnostic: write whichever spelling you prefer, +and upgrading (or downgrading) FontAwesome is a one-line `conf.py` change: + +```python +sd_fontawesome_version = "6" +``` + +| Roles | Style | `"4"` | `"5"` | `"6"` | +| ----- | ----- | ----- | ----- | ----- | +| `fa`, `fas`, `fa-solid` | solid | `fa` | `fas` | `fa-solid` | +| `fab`, `fa-brands` | brands | `fa` | `fab` | `fa-brands` | +| `far`, `fa-regular` | regular | `fa` | `far` | `fa-regular` | + +The default, `"as-named"`, emits the role name verbatim as the leading class +(the behaviour shown above, and of previous sphinx-design versions). + +```{note} +The bare `fa` role maps to *solid* under `"5"`/`"6"` (FontAwesome 4's single +style became solid in v5). Conversely, `"4"` collapses all style distinctions +to `fa` in the HTML classes, since FontAwesome 4 had no style prefixes — +LaTeX output is unaffected (it always uses the role's own style). + +Only the leading *style* class is translated — icon **names** that FontAwesome +renamed between versions (e.g. v4 `external-link` vs v6 +`arrow-up-right-from-square`) are emitted as written, just like in the LaTeX +note below. +``` #### Loading the FontAwesome CSS @@ -345,26 +381,29 @@ There can only be a maximum of 1 `;` in the roles' arguments If you use a [FontAwesome Pro kit](https://fontawesome.com/kits), keep `sd_fontawesome_source = "none"` (do **not** also load the free CDN, whose -own font-face would fight your kit), load the kit as usual, and use the v6 -role names above (`fa-solid`/`fa-brands`/`fa-regular`), which emit exactly the -classes a Pro kit expects. +own font-face would fight your kit), load the kit as usual, and either use the +v6 role names (`fa-solid`/`fa-brands`/`fa-regular`) directly, or set +`sd_fontawesome_version = "6"`, which makes every spelling — including the +concise `fas`/`fab`/`far` — emit exactly the classes a Pro kit expects. #### Concise role names The `fas`, `fab` and `far` roles (and `fa`, which FontAwesome itself -deprecated in v5) are equally supported, with no plans to remove them. Each -role name is emitted verbatim as the leading CSS class, so these produce the -v4/v5 class scheme (`fas fa-...`): +deprecated in v5) are equally supported, with no plans to remove them. By +default (`sd_fontawesome_version = "as-named"`) each role name is emitted +verbatim as the leading CSS class, so these produce the v4/v5 class scheme +(`fas fa-...`): - An icon {fas}`spinner;sd-text-primary`, some more text. - An icon {fab}`github`, some more text. - An icon {far}`bell`, some more text. -Pick whichever spelling matches the FontAwesome CSS you load: the free CDN -builds define both class schemes, so the concise names work fine there. The -`fa-solid`/`fa-brands`/`fa-regular` names are only *required* for FontAwesome -v6+ setups that drop the compatibility aliases — most notably Pro kits — and -have the side benefit of matching what fontawesome.com shows for each icon. +The free CDN builds define both class schemes, so the concise names work fine +there as-is. And combined with `sd_fontawesome_version`, the concise names are +future-proof for *any* setup: keep writing `fas`/`fab`/`far` and set the +version knob to match the CSS you load — no source churn when FontAwesome (or +your theme's bundled copy) moves on. The `fa-solid`/`fa-brands`/`fa-regular` +spellings remain handy for matching what fontawesome.com shows for each icon. Note that not all regular style icons are free; `far`/`fa-regular` only work with the free ones. @@ -387,7 +426,9 @@ sd_fontawesome_latex = "fontawesome5" With `"fontawesome5"`, the icon style is mapped to that package's conventions: brand icons resolve by name (`\faIcon{github}`), regular-style icons use the optional style argument (`\faIcon[regular]{name}`), and solid icons use the -default (`\faIcon{name}`). +default (`\faIcon{name}`). Note that `sd_fontawesome_version` only selects the +*HTML* class scheme; LaTeX rendering is driven by the role's style and +`sd_fontawesome_latex` alone. If your theme (or another extension) already loads the `fontawesome5` package, set `sd_fontawesome_latex = "fontawesome5"` so both agree, avoiding the LaTeX diff --git a/sphinx_design/config.py b/sphinx_design/config.py index f6b6d26..7522104 100644 --- a/sphinx_design/config.py +++ b/sphinx_design/config.py @@ -234,6 +234,14 @@ class SdConfig: "help": 'FontAwesome CSS URL to add when sd_fontawesome_source="cdn"', }, ) + fontawesome_version: str = dc.field( + default="as-named", + metadata={ + "validator": one_of(("as-named", "4", "5", "6")), + "help": "FontAwesome class scheme emitted by the icon roles: " + '"as-named" (default, role name emitted verbatim), "4", "5" or "6"', + }, + ) fontawesome_latex: bool | str = dc.field( default=False, metadata={ diff --git a/sphinx_design/icons.py b/sphinx_design/icons.py index 52c32c7..2b7ea2b 100644 --- a/sphinx_design/icons.py +++ b/sphinx_design/icons.py @@ -219,10 +219,13 @@ class fontawesome(nodes.Element, nodes.General): # noqa: N801 """Node for rendering fontawesome icon.""" -#: Map a fontawesome role name (the node's leading CSS class) to the semantic -#: style used for the ``fontawesome5`` LaTeX package. ``fa`` (v4) and ``fas`` -#: are solid, ``fab`` brands, ``far`` regular; the v6 role names are explicit. -FA_LATEX_STYLES = { +#: Map a fontawesome role name (also the node's leading CSS class, under the +#: default ``as-named`` scheme) to the semantic icon style. ``fa`` (v4) and +#: ``fas`` are solid, ``fab`` brands, ``far`` regular; the v6 role names are +#: explicit. Used both to translate role names between FontAwesome class +#: schemes (``sd_fontawesome_version``) and for the ``fontawesome5`` LaTeX +#: package's style conventions. +FA_ROLE_STYLES = { "fa": "solid", "fas": "solid", "fab": "brands", @@ -232,6 +235,16 @@ class fontawesome(nodes.Element, nodes.General): # noqa: N801 "fa-regular": "regular", } +#: Map a semantic icon style to the leading CSS class emitted for each +#: FontAwesome version (``sd_fontawesome_version``). v4 has no style prefixes +#: (all distinctions collapse to ``fa``), v5 uses ``fas``/``fab``/``far``, +#: v6 uses ``fa-solid``/``fa-brands``/``fa-regular``. +FA_VERSION_CLASSES = { + "solid": {"4": "fa", "5": "fas", "6": "fa-solid"}, + "brands": {"4": "fa", "5": "fab", "6": "fa-brands"}, + "regular": {"4": "fa", "5": "far", "6": "fa-regular"}, +} + class FontawesomeRole(SphinxRole): """Role to display a Fontawesome icon. @@ -247,8 +260,20 @@ def run(self) -> tuple[list[nodes.Node], list[nodes.system_message]]: """Run the role.""" icon, classes = self.text.split(";", 1) if ";" in self.text else [self.text, ""] icon = icon.strip() + # the role name selects the icon style; the emitted leading class is + # the role name itself ("as-named", backward-compatible default), or + # its translation into the configured FontAwesome version's scheme + version = get_sd_config(self.env).fontawesome_version + if version == "as-named": + leading_class = self.style + else: + leading_class = FA_VERSION_CLASSES[FA_ROLE_STYLES[self.style]][version] node = fontawesome( - icon=icon, classes=[self.style, f"fa-{icon}", *classes.split()] + icon=icon, + # the semantic style travels on the node, so non-HTML renderers + # (LaTeX) stay independent of the configured HTML class scheme + icon_style=FA_ROLE_STYLES[self.style], + classes=[leading_class, f"fa-{icon}", *classes.split()], ) self.set_source_info(node) return [node], [] @@ -286,7 +311,11 @@ def visit_fontawesome_latex(self, node): if mode == "fontawesome5": # the fontawesome5 package resolves brand icons by name, and takes the # style as an optional argument (default solid); see its manual - style = FA_LATEX_STYLES.get(node["classes"][0], "solid") + # prefer the semantic style stored on the node; fall back to deriving + # it from the leading class (doctrees pickled before it existed) + style = node.get("icon_style") or FA_ROLE_STYLES.get( + node["classes"][0], "solid" + ) if style == "regular": self.body.append(f"\\faIcon[regular]{{{node['icon']}}}") else: diff --git a/tests/test_fontawesome.py b/tests/test_fontawesome.py index be884b9..171a920 100644 --- a/tests/test_fontawesome.py +++ b/tests/test_fontawesome.py @@ -9,7 +9,11 @@ - ``sd_fontawesome_latex`` selects the LaTeX package, including the new ``"fontawesome5"`` mode (``\\faIcon``) and byte-compatible legacy ``True`` behaviour (``fontawesome`` package, ``\\faicon``) (#242); -- the "icons not in LaTeX output" warning is throttled to once per build. +- the "icons not in LaTeX output" warning is throttled to once per build; +- ``sd_fontawesome_version`` translates every role spelling to the configured + FontAwesome class scheme (``"4"``/``"5"``/``"6"``), with the default + ``"as-named"`` emitting the role name verbatim (parity with previous + releases), and without affecting LaTeX output. Written to also run under ``py311-no-myst``: the core assertions use reStructuredText, and MyST variants are guarded by ``MYST_PARAM``. @@ -244,3 +248,143 @@ def test_latex_disabled_warns_once(value, sphinx_builder): tex = _latex_tex(builder) assert "\\faicon{" not in tex assert "\\faIcon{" not in tex + + +#: One icon per role spelling (distinct names, so doctree lookups don't clash). +ALL_FA_ROLES = { + "fa": "star", + "fas": "rocket", + "fab": "github", + "far": "bell", + "fa-solid": "fire", + "fa-brands": "gitkraken", + "fa-regular": "clock", +} + +#: Expected leading class per role spelling, for every version scheme. +VERSION_LEADING_CLASS = { + "as-named": {role: role for role in ALL_FA_ROLES}, + "4": dict.fromkeys(ALL_FA_ROLES, "fa"), + "5": { + "fa": "fas", + "fas": "fas", + "fa-solid": "fas", + "fab": "fab", + "fa-brands": "fab", + "far": "far", + "fa-regular": "far", + }, + "6": { + "fa": "fa-solid", + "fas": "fa-solid", + "fa-solid": "fa-solid", + "fab": "fa-brands", + "fa-brands": "fa-brands", + "far": "fa-regular", + "fa-regular": "fa-regular", + }, +} + + +def _build_all_role_spellings(sphinx_builder, fmt, conf_extra=None): + """Build a one-page project using every fontawesome role spelling.""" + conf = {"extensions": ["sphinx_design"], **(conf_extra or {})} + if fmt == "rst": + builder = sphinx_builder(conf_kwargs=conf) + body = ", ".join(f":{name}:`{icon}`" for name, icon in ALL_FA_ROLES.items()) + builder.src_path.joinpath("index.rst").write_text( + f"Title\n=====\n\nIcons {body}.\n", encoding="utf8" + ) + else: + conf = { + "extensions": ["myst_parser", "sphinx_design"], + "myst_enable_extensions": ["colon_fence"], + **(conf_extra or {}), + } + builder = sphinx_builder(conf_kwargs=conf) + body = ", ".join(f"{{{name}}}`{icon}`" for name, icon in ALL_FA_ROLES.items()) + builder.src_path.joinpath("index.md").write_text( + f"# Title\n\nIcons {body}.\n", encoding="utf8" + ) + return builder + + +@pytest.mark.parametrize("version", ["as-named", "4", "5", "6"]) +@pytest.mark.parametrize("fmt", ["rst", MYST_PARAM]) +def test_version_class_mapping( + fmt, version, sphinx_builder: Callable[..., SphinxBuilder] +): + """Each role spelling emits the exact class list for the configured version.""" + builder = _build_all_role_spellings( + sphinx_builder, fmt, {"sd_fontawesome_version": version} + ) + builder.build() + by_icon = _fa_by_icon(builder.get_doctree("index")) + for role, icon in ALL_FA_ROLES.items(): + expected = [VERSION_LEADING_CLASS[version][role], f"fa-{icon}"] + assert by_icon[icon] == expected, f"role {role!r} with version {version!r}" + + +@pytest.mark.parametrize("explicit", [False, True], ids=["default", "explicit"]) +def test_version_as_named_parity( + explicit, sphinx_builder: Callable[..., SphinxBuilder] +): + """The default (and explicit) ``"as-named"`` emits the role name verbatim. + + The asserted spans are byte-identical to those produced by sphinx-design + before ``sd_fontawesome_version`` existed (verified against a build from + ``main``), for every one of the seven role spellings. + """ + conf = {"sd_fontawesome_version": "as-named"} if explicit else None + builder = _build_all_role_spellings(sphinx_builder, "rst", conf) + builder.build() + html = (builder.out_path / "index.html").read_text(encoding="utf8") + for role, icon in ALL_FA_ROLES.items(): + assert f' An icon - + , some more text. diff --git a/tests/test_snippets/snippet_pre_icon-fontawesome.xml b/tests/test_snippets/snippet_pre_icon-fontawesome.xml index 6e82aaf..d18b7b0 100644 --- a/tests/test_snippets/snippet_pre_icon-fontawesome.xml +++ b/tests/test_snippets/snippet_pre_icon-fontawesome.xml @@ -4,5 +4,5 @@ Heading An icon - + , some more text.