Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <target>` form (semicolons
Expand Down
73 changes: 57 additions & 16 deletions docs/badges_buttons.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<span class="fa-solid fa-rocket">`.
By default each role emits exactly the classes it is named after: the
`fa-solid` role applied to `rocket` produces
`<span class="fa-solid fa-rocket">`, while the `fas` role produces
`<span class="fas fa-rocket">`.

#### 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

Expand Down Expand Up @@ -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.

Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions sphinx_design/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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={
Expand Down
41 changes: 35 additions & 6 deletions sphinx_design/icons.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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.
Expand All @@ -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], []
Expand Down Expand Up @@ -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:
Expand Down
146 changes: 145 additions & 1 deletion tests/test_fontawesome.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``.
Expand Down Expand Up @@ -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'<span class="{role} fa-{icon}"' in html
# the doctree carries exactly the same (verbatim) classes
by_icon = _fa_by_icon(builder.get_doctree("index"))
for role, icon in ALL_FA_ROLES.items():
assert by_icon[icon] == [role, f"fa-{icon}"]


def test_version_invalid_falls_back(sphinx_builder: Callable[..., SphinxBuilder]):
"""An invalid ``sd_fontawesome_version`` warns and falls back to as-named."""
builder = _build_all_role_spellings(
sphinx_builder, "rst", {"sd_fontawesome_version": "7"}
)
builder.build(assert_pass=False)
assert "sd_fontawesome_version: " in builder.warnings
assert "must be one of" in builder.warnings
by_icon = _fa_by_icon(builder.get_doctree("index"))
for role, icon in ALL_FA_ROLES.items():
assert by_icon[icon] == [role, f"fa-{icon}"]


@pytest.mark.parametrize("version", ["as-named", "4", "5", "6"])
def test_version_latex_unaffected(version, sphinx_builder):
"""``sd_fontawesome_version`` does not change LaTeX output — for ANY value.

The semantic style travels on the node (``icon_style``), so the
``fontawesome5`` LaTeX rendering is identical regardless of the HTML
class scheme — including ``"4"``, whose HTML classes collapse all styles
to ``fa``.
"""
builder = sphinx_builder(
buildername="latex",
conf_kwargs={
"extensions": ["sphinx_design"],
"sd_fontawesome_latex": "fontawesome5",
"sd_fontawesome_version": version,
},
)
builder.src_path.joinpath("index.rst").write_text(
"Title\n=====\n\nIcons :fas:`rocket`, :fab:`github`, :far:`bell`.\n",
encoding="utf8",
)
builder.build(assert_pass=False)
tex = _latex_tex(builder)
assert "\\usepackage{fontawesome5}" in tex
assert "\\faIcon{rocket}" in tex
assert "\\faIcon{github}" in tex
assert "\\faIcon[regular]{bell}" in tex
assert "not included in LaTeX output" not in builder.warnings
3 changes: 3 additions & 0 deletions tests/test_misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,7 @@ def test_button_i18n_translated(sphinx_builder):
"custom_directives": (["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"),
"fontawesome_latex": ("bad-mode", "must be a bool or one of"),
"tabs_storage_prefix": (123, "must be of type"),
}
Expand Down Expand Up @@ -617,6 +618,7 @@ def test_config_toml_round_trip():
toml_str = """\
fontawesome_source = "cdn"
fontawesome_cdn_url = "https://example.com/fa.css"
fontawesome_version = "6"
fontawesome_latex = true
tabs_storage_prefix = "sphinx-design-tab-id-"

Expand All @@ -635,6 +637,7 @@ def test_config_toml_round_trip():
config = SdConfig(**data)
assert config.fontawesome_source == "cdn"
assert config.fontawesome_cdn_url == "https://example.com/fa.css"
assert config.fontawesome_version == "6"
assert config.fontawesome_latex is True
assert config.tabs_storage_prefix == "sphinx-design-tab-id-"
assert config.fontawesome_latex_mode == "fontawesome"
Expand Down
2 changes: 1 addition & 1 deletion tests/test_snippets/snippet_post_icon-fontawesome.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@
Heading
<paragraph>
An icon
<fontawesome classes="fas fa-spinner sd-bg-primary sd-bg-text-primary" icon="spinner">
<fontawesome classes="fas fa-spinner sd-bg-primary sd-bg-text-primary" icon="spinner" icon_style="solid">
, some more text.
2 changes: 1 addition & 1 deletion tests/test_snippets/snippet_pre_icon-fontawesome.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@
Heading
<paragraph>
An icon
<fontawesome classes="fas fa-spinner sd-bg-primary sd-bg-text-primary" icon="spinner">
<fontawesome classes="fas fa-spinner sd-bg-primary sd-bg-text-primary" icon="spinner" icon_style="solid">
, some more text.
Loading