diff --git a/CHANGELOG.md b/CHANGELOG.md
index f5b813a..17656ed 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,14 @@
## Unreleased
+- ✨ NEW: FontAwesome v6 roles (`fa-solid`/`fa-brands`/`fa-regular`) and
+ declarative CSS loading via the new `sd_fontawesome_source` /
+ `sd_fontawesome_cdn_url` options (the supported path for FontAwesome Pro
+ kits) ({pr}`285`, {issue}`174`)
+- 👌 IMPROVE: `sd_fontawesome_latex` now also accepts `"fontawesome5"` (adding
+ `fontawesome5.sty` and emitting `\faIcon{...}`), resolving package clashes
+ with themes that load `fontawesome5`; `True`/`False` keep working unchanged
+ ({pr}`285`, {issue}`242`)
- 🐛 FIX: Synced tabs stay in sync when activated by click-and-drag or keyboard,
by syncing on the radio input's `change` event rather than a label click
({pr}`284`, {issue}`46`)
diff --git a/docs/badges_buttons.md b/docs/badges_buttons.md
index d3cbf22..bd4ff01 100644
--- a/docs/badges_buttons.md
+++ b/docs/badges_buttons.md
@@ -240,48 +240,120 @@ 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.
-If the theme you are using does not already include the FontAwesome CSS, it should be loaded in your configuration from a [font-awesome CDN](https://cdnjs.com/libraries/font-awesome), with the [html_css_files](https://www.sphinx-doc.org/en/master/usage/configuration.html#confval-html_css_files) option, e.g.:
+FontAwesome icons are added via the FontAwesome CSS classes, with the
+`fa-solid`, `fa-brands` and `fa-regular` roles (the FontAwesome v6 style names):
+
+- 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 ``.
+
+#### Loading the FontAwesome CSS
+
+sphinx-design does **not** bundle the FontAwesome CSS.
+By default (`sd_fontawesome_source = "none"`) you, or your theme, are
+responsible for making it available (many themes already include it).
+
+To have sphinx-design load it for you from a
+[FontAwesome CDN](https://cdnjs.com/libraries/font-awesome),
+so you no longer have to hand-edit
+[`html_css_files`](https://www.sphinx-doc.org/en/master/usage/configuration.html#confval-html_css_files),
+set:
```python
-html_css_files = [
- "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.1.1/css/all.min.css"
-]
+sd_fontawesome_source = "cdn"
```
-Use either `fa` (deprecated in font-awesome v5), `fas`, `fab` or `far` for the role name.
-Note that not all regular style icons are free, `far` role only works with free ones.
+This adds `sd_fontawesome_cdn_url` (a FontAwesome v6 `all.min.css` by default)
+to the HTML output. Override `sd_fontawesome_cdn_url` to pin a different
+version or a self-hosted copy.
````{warning}
-Since the FontAwesome icons are fetched directly from their distributed CSS, specifying a height/size to the `fa*` roles is not supported.
+Since the FontAwesome icons are fetched directly from their distributed CSS, specifying a height/size to the icon roles is not supported.
However, you can always add your custom CSS class that controls the `font-size` property.
-If a height/size is supplied to a `fa*` role, then it will be interpreted as a CSS class.
-There can only be a maximum of 1 `;` in the `fa*` roles' arguments
+If a height/size is supplied to an icon role, then it will be interpreted as a CSS class.
+There can only be a maximum of 1 `;` in the roles' arguments
````
````{tab-set-code}
```markdown
-- An icon {fas}`spinner;sd-text-primary`, some more text.
-- An icon {fab}`github`, some more text.
-- An icon {fab}`gitkraken;sd-text-success fa-xl`, some more text.
-- An icon {fas}`skull;sd-text-danger`, some more text.
+- An icon {fa-solid}`spinner;sd-text-primary`, some more text.
+- An icon {fa-brands}`github`, some more text.
+- An icon {fa-brands}`gitkraken;sd-text-success fa-xl`, some more text.
+- An icon {fa-solid}`skull;sd-text-danger`, some more text.
```
```rst
-- An icon :fas:`spinner;sd-text-primary`, some more text.
-- An icon :fab:`github`, some more text.
-- An icon :fab:`gitkraken;sd-text-success fa-xl`, some more text.
-- An icon :fas:`skull;sd-text-danger`, some more text.
+- An icon :fa-solid:`spinner;sd-text-primary`, some more text.
+- An icon :fa-brands:`github`, some more text.
+- An icon :fa-brands:`gitkraken;sd-text-success fa-xl`, some more text.
+- An icon :fa-solid:`skull;sd-text-danger`, some more text.
```
````
+- An icon {fa-solid}`spinner;sd-text-primary`, some more text.
+- An icon {fa-brands}`github`, some more text.
+- An icon {fa-brands}`gitkraken;sd-text-success fa-xl`, some more text.
+- An icon {fa-solid}`skull;sd-text-danger`, some more text.
+
+#### Using FontAwesome Pro kits
+
+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.
+
+#### 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-...`):
+
- An icon {fas}`spinner;sd-text-primary`, some more text.
- An icon {fab}`github`, some more text.
-- An icon {fab}`gitkraken;sd-text-success fa-xl`, some more text.
-- An icon {fas}`skull;sd-text-danger`, 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.
+Note that not all regular style icons are free; `far`/`fa-regular` only work
+with the free ones.
-By default, icons will only be output in HTML formats. But if you want FontAwesome icons to be output on LaTeX, using the [fontawesome package](https://ctan.org/pkg/fontawesome), you can add to your configuration:
+#### FontAwesome in LaTeX output
+
+By default, icons are only rendered for HTML builders.
+To also render them in LaTeX output, set `sd_fontawesome_latex` to the LaTeX
+package you want to use:
+
+| `sd_fontawesome_latex` | LaTeX package | Rendering |
+| ---------------------- | ------------- | --------- |
+| `False` / `"none"` (default) | – | icons skipped (one warning per build) |
+| `True` / `"fontawesome"` | [`fontawesome`](https://ctan.org/pkg/fontawesome) | `\faicon{name}` |
+| `"fontawesome5"` | [`fontawesome5`](https://ctan.org/pkg/fontawesome5) | `\faIcon{name}` (see below) |
```python
-sd_fontawesome_latex = True
+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}`).
+
+If your theme (or another extension) already loads the `fontawesome5` package,
+set `sd_fontawesome_latex = "fontawesome5"` so both agree, avoiding the LaTeX
+error that comes from mixing the `fontawesome` and `fontawesome5` packages.
+
+```{note}
+The LaTeX packages predate FontAwesome 6 and resolve icons by their **v5**
+(or v4, for `fontawesome`) names. Icons that were renamed in v6 (for example
+`arrow-up-right-from-square`, formerly `external-link-alt`) render in HTML
+but will raise an "icon not found" error when building PDF output — use the
+older name if you need LaTeX support for such an icon.
```
diff --git a/docs/conf.py b/docs/conf.py
index 248c6f3..35fcc14 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -41,9 +41,9 @@
html_favicon = "_static/logo_square.svg"
if html_theme not in ("sphinx_book_theme", "pydata_sphinx_theme"):
- html_css_files = [
- "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.1.1/css/all.min.css"
- ]
+ # let sphinx-design load FontAwesome from the CDN, rather than
+ # hand-editing html_css_files (the sphinx_book/pydata themes bundle it)
+ sd_fontawesome_source = "cdn"
if html_theme == "alabaster":
html_logo = ""
html_theme_options = {
@@ -67,10 +67,8 @@
"home_page_in_toc": False,
}
if html_theme == "furo":
- html_css_files = [
- "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/fontawesome.min.css",
- "furo.css",
- ]
+ # FontAwesome is loaded via sd_fontawesome_source="cdn" (set above)
+ html_css_files = ["furo.css"]
html_theme_options = {
"sidebar_hide_name": True,
}
@@ -81,6 +79,7 @@
if html_theme == "sphinx_immaterial":
extensions.append("sphinx_immaterial")
html_css_files = ["sphinx_immaterial.css"]
+ sd_fontawesome_source = "none" # immaterial provides its own icon fonts
html_theme_options = {
"icon": {
"repo": "fontawesome/brands/github",
diff --git a/sphinx_design/config.py b/sphinx_design/config.py
index 03df979..f6b6d26 100644
--- a/sphinx_design/config.py
+++ b/sphinx_design/config.py
@@ -27,6 +27,8 @@
from sphinx.util.logging import getLogger
if TYPE_CHECKING:
+ from collections.abc import Sequence
+
from sphinx.application import Sphinx
from sphinx.config import Config
from sphinx.environment import BuildEnvironment
@@ -98,6 +100,58 @@ def _validator(
return _validator
+def one_of(allowed: Sequence[Any]) -> ValidatorType:
+ """Create a validator that raises a ``ValueError``
+ if the value is not one of the allowed values.
+
+ :param allowed: The allowed values.
+ """
+ allowed = tuple(allowed)
+
+ def _validator(
+ inst: Any, field: dc.Field[Any], value: Any, suffix: str = ""
+ ) -> None:
+ if value not in allowed:
+ raise ValueError(
+ f"'{field.name}{suffix}' must be one of {allowed!r} (got {value!r})."
+ )
+
+ return _validator
+
+
+FONTAWESOME_LATEX_MODES = ("none", "fontawesome", "fontawesome5")
+"""Allowed string values for the ``fontawesome_latex`` configuration."""
+
+
+def validate_fontawesome_latex(
+ inst: Any, field: dc.Field[Any], value: Any, suffix: str = ""
+) -> None:
+ """Validate the ``fontawesome_latex`` value.
+
+ Accepts a ``bool`` (``True``/``False`` are kept for backwards
+ compatibility, and normalized by :attr:`SdConfig.fontawesome_latex_mode`),
+ or one of :data:`FONTAWESOME_LATEX_MODES`.
+
+ :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 isinstance(value, bool):
+ return
+ if not isinstance(value, str):
+ raise TypeError(
+ f"'{field.name}{suffix}' must be a bool or str "
+ f"(got {value!r} that is a {value.__class__!r})."
+ )
+ if value not in FONTAWESOME_LATEX_MODES:
+ raise ValueError(
+ f"'{field.name}{suffix}' must be a bool or one of "
+ f"{FONTAWESOME_LATEX_MODES!r} (got {value!r})."
+ )
+
+
def validate_custom_directive(field: dc.Field[Any], name: Any, data: Any) -> None:
"""Validate the shape of a single custom directive (name -> data) entry.
@@ -165,11 +219,29 @@ class SdConfig:
"doc_type": "dict[str, dict]",
},
)
- fontawesome_latex: bool = dc.field(
+ fontawesome_source: str = dc.field(
+ default="none",
+ metadata={
+ "validator": one_of(("none", "cdn")),
+ "help": "How sphinx-design loads the FontAwesome CSS: "
+ '"none" (default, provided by you/your theme) or "cdn"',
+ },
+ )
+ fontawesome_cdn_url: str = dc.field(
+ default="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.1.1/css/all.min.css",
+ metadata={
+ "validator": instance_of(str),
+ "help": 'FontAwesome CSS URL to add when sd_fontawesome_source="cdn"',
+ },
+ )
+ fontawesome_latex: bool | str = dc.field(
default=False,
metadata={
- "validator": instance_of(bool),
- "help": "Render fontawesome icons in LaTeX output",
+ "validator": validate_fontawesome_latex,
+ "types": (bool, str),
+ "help": "Render fontawesome icons in LaTeX output: "
+ 'False/"none", True/"fontawesome", or "fontawesome5"',
+ "doc_type": "bool | str",
},
)
tabs_storage_prefix: str = dc.field(
@@ -186,6 +258,23 @@ class SdConfig:
def __post_init__(self) -> None:
validate_fields(self)
+ @property
+ def fontawesome_latex_mode(self) -> str:
+ """The normalized LaTeX fontawesome mode.
+
+ Maps the backwards-compatible boolean values to their string
+ equivalents (``True`` -> ``"fontawesome"``, ``False`` -> ``"none"``),
+ so downstream code only has to handle :data:`FONTAWESOME_LATEX_MODES`.
+
+ :return: One of :data:`FONTAWESOME_LATEX_MODES`.
+ """
+ value = self.fontawesome_latex
+ if value is True:
+ return "fontawesome"
+ if value is False:
+ return "none"
+ return value
+
@classmethod
def from_sphinx(cls, config: Config) -> SdConfig:
"""Create a validated instance from the flat ``sd_`` prefixed
@@ -217,7 +306,12 @@ def setup_sd_config(app: Sphinx) -> None:
:param app: The Sphinx application object.
"""
for field in dc.fields(SdConfig):
- app.add_config_value(f"sd_{field.name}", _field_default(field), "env")
+ app.add_config_value(
+ f"sd_{field.name}",
+ _field_default(field),
+ "env",
+ types=field.metadata.get("types", ()),
+ )
# low priority, so that the values are validated
# before any other `config-inited` listener reads them
app.connect("config-inited", _validate_config_values, priority=400)
diff --git a/sphinx_design/icons.py b/sphinx_design/icons.py
index a9101c3..52c32c7 100644
--- a/sphinx_design/icons.py
+++ b/sphinx_design/icons.py
@@ -21,12 +21,18 @@
def setup_icons(app: Sphinx) -> None:
app.add_role("octicon", OcticonRole())
app.add_directive("_all-octicon", AllOcticons)
+ # legacy v4/v5 class scheme (kept for backwards compatibility);
+ # note: fa is deprecated in v5, fas is the default and fab is the other free option
for style in ["fa", "fas", "fab", "far"]:
- # note: fa is deprecated in v5, fas is the default and fab is the other free option
+ app.add_role(style, FontawesomeRole(style))
+ # v6 canonical class scheme (required by FA6+ setups without compatibility
+ # aliases, e.g. Pro kits; the concise fas/fab/far roles remain supported)
+ for style in ["fa-solid", "fa-brands", "fa-regular"]:
app.add_role(style, FontawesomeRole(style))
for style in ["regular", "outlined", "round", "sharp", "twotone"]:
app.add_role("material-" + style, MaterialRole(style))
app.connect("config-inited", add_fontawesome_pkg)
+ app.connect("builder-inited", add_fontawesome_css)
app.add_node(
sd_icon,
html=(visit_sd_icon_html, None),
@@ -213,6 +219,20 @@ 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 = {
+ "fa": "solid",
+ "fas": "solid",
+ "fab": "brands",
+ "far": "regular",
+ "fa-solid": "solid",
+ "fa-brands": "brands",
+ "fa-regular": "regular",
+}
+
+
class FontawesomeRole(SphinxRole):
"""Role to display a Fontawesome icon.
@@ -242,14 +262,36 @@ def depart_fontawesome_html(self, node):
self.body.append("")
+def add_fontawesome_css(app: Sphinx) -> None:
+ """Add the FontAwesome CDN CSS to HTML builds, if so configured."""
+ if app.builder.format != "html":
+ return
+ config = get_sd_config(app.env)
+ if config.fontawesome_source == "cdn":
+ app.add_css_file(config.fontawesome_cdn_url)
+
+
def add_fontawesome_pkg(app, config):
- if SdConfig.from_sphinx(config).fontawesome_latex:
+ """Load the LaTeX fontawesome package matching ``sd_fontawesome_latex``."""
+ mode = SdConfig.from_sphinx(config).fontawesome_latex_mode
+ if mode == "fontawesome":
app.add_latex_package("fontawesome")
+ elif mode == "fontawesome5":
+ app.add_latex_package("fontawesome5")
def visit_fontawesome_latex(self, node):
- """Add latex fonteawesome icon, if configured, else warn."""
- if get_sd_config(self.builder.env).fontawesome_latex:
+ """Add latex fontawesome icon, if configured, else warn."""
+ mode = get_sd_config(self.builder.env).fontawesome_latex_mode
+ 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")
+ if style == "regular":
+ self.body.append(f"\\faIcon[regular]{{{node['icon']}}}")
+ else:
+ self.body.append(f"\\faIcon{{{node['icon']}}}")
+ elif mode == "fontawesome":
self.body.append(f"\\faicon{{{node['icon']}}}")
else:
logger.warning(
@@ -258,6 +300,7 @@ def visit_fontawesome_latex(self, node):
location=node,
type=WARNING_TYPE,
subtype="fa-build",
+ once=True,
)
raise nodes.SkipNode
@@ -270,6 +313,7 @@ def visit_fontawesome_warning(self, node: nodes.Element) -> None:
location=node,
type=WARNING_TYPE,
subtype="fa-build",
+ once=True,
)
raise nodes.SkipNode
diff --git a/tests/test_fontawesome.py b/tests/test_fontawesome.py
new file mode 100644
index 0000000..be884b9
--- /dev/null
+++ b/tests/test_fontawesome.py
@@ -0,0 +1,246 @@
+"""Tests for the FontAwesome icon roles, CDN config and LaTeX package support.
+
+Covers:
+
+- the v6 role names (``fa-solid``/``fa-brands``/``fa-regular``) emit exactly the
+ canonical v6 classes, and the legacy ``fa``/``fas``/``fab``/``far`` roles are
+ unchanged (backwards compatibility, #174);
+- ``sd_fontawesome_source`` adds (or omits) the FontAwesome CDN CSS in HTML;
+- ``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.
+
+Written to also run under ``py311-no-myst``: the core assertions use
+reStructuredText, and MyST variants are guarded by ``MYST_PARAM``.
+"""
+
+from collections.abc import Callable
+
+import pytest
+
+from sphinx_design.icons import fontawesome
+
+from .conftest import SphinxBuilder
+
+try:
+ import myst_parser # noqa: F401
+
+ MYST_INSTALLED = True
+except ImportError:
+ MYST_INSTALLED = False
+
+MYST_PARAM = pytest.param(
+ "myst",
+ marks=pytest.mark.skipif(not MYST_INSTALLED, reason="myst-parser not installed"),
+)
+
+#: The default v6 CDN URL added by ``sd_fontawesome_source = "cdn"``.
+DEFAULT_CDN_URL = (
+ "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.1.1/css/all.min.css"
+)
+
+
+def _fa_by_icon(doctree) -> dict[str, list[str]]:
+ """Map each fontawesome node's icon name to its list of CSS classes."""
+ return {
+ node["icon"]: list(node["classes"]) for node in doctree.findall(fontawesome)
+ }
+
+
+def _build_icons(sphinx_builder, fmt, roles):
+ """Build a one-page project exercising ``roles`` (a list of ``(name, icon)``).
+
+ ``name`` is the role name, ``icon`` the icon argument.
+ """
+ if fmt == "rst":
+ builder = sphinx_builder(conf_kwargs={"extensions": ["sphinx_design"]})
+ body = ", ".join(f":{name}:`{icon}`" for name, icon in roles)
+ builder.src_path.joinpath("index.rst").write_text(
+ f"Title\n=====\n\nIcons {body}.\n", encoding="utf8"
+ )
+ else:
+ builder = sphinx_builder()
+ body = ", ".join(f"{{{name}}}`{icon}`" for name, icon in roles)
+ builder.src_path.joinpath("index.md").write_text(
+ f"# Title\n\nIcons {body}.\n", encoding="utf8"
+ )
+ builder.build() # asserts no warnings (HTML always renders FA)
+ return builder
+
+
+@pytest.mark.parametrize("fmt", ["rst", MYST_PARAM])
+def test_v6_role_classes(fmt, sphinx_builder: Callable[..., SphinxBuilder]):
+ """The v6 roles emit exactly the classes their name implies, no legacy alias."""
+ builder = _build_icons(
+ sphinx_builder,
+ fmt,
+ [("fa-solid", "rocket"), ("fa-brands", "github"), ("fa-regular", "bell")],
+ )
+ by_icon = _fa_by_icon(builder.get_doctree("index"))
+ assert by_icon["rocket"] == ["fa-solid", "fa-rocket"]
+ assert by_icon["github"] == ["fa-brands", "fa-github"]
+ assert by_icon["bell"] == ["fa-regular", "fa-bell"]
+
+
+@pytest.mark.parametrize("fmt", ["rst", MYST_PARAM])
+def test_v6_role_extra_classes(fmt, sphinx_builder: Callable[..., SphinxBuilder]):
+ """Extra classes after a semicolon are appended to the v6 role classes."""
+ if fmt == "rst":
+ builder = sphinx_builder(conf_kwargs={"extensions": ["sphinx_design"]})
+ builder.src_path.joinpath("index.rst").write_text(
+ "Title\n=====\n\n:fa-solid:`rocket;sd-text-primary fa-xl`\n",
+ encoding="utf8",
+ )
+ else:
+ builder = sphinx_builder()
+ builder.src_path.joinpath("index.md").write_text(
+ "# Title\n\n{fa-solid}`rocket;sd-text-primary fa-xl`\n", encoding="utf8"
+ )
+ builder.build()
+ by_icon = _fa_by_icon(builder.get_doctree("index"))
+ assert by_icon["rocket"] == ["fa-solid", "fa-rocket", "sd-text-primary", "fa-xl"]
+
+
+@pytest.mark.parametrize("fmt", ["rst", MYST_PARAM])
+def test_legacy_role_classes_unchanged(
+ fmt, sphinx_builder: Callable[..., SphinxBuilder]
+):
+ """The legacy roles keep emitting the v4/v5 class scheme."""
+ builder = _build_icons(
+ sphinx_builder,
+ fmt,
+ [("fa", "star"), ("fas", "spinner"), ("fab", "github"), ("far", "bell")],
+ )
+ by_icon = _fa_by_icon(builder.get_doctree("index"))
+ assert by_icon["star"] == ["fa", "fa-star"]
+ assert by_icon["spinner"] == ["fas", "fa-spinner"]
+ assert by_icon["github"] == ["fab", "fa-github"]
+ assert by_icon["bell"] == ["far", "fa-bell"]
+
+
+def test_fontawesome_source_cdn_adds_link(sphinx_builder):
+ """``sd_fontawesome_source="cdn"`` adds the CDN CSS to the HTML head once."""
+ builder = sphinx_builder(
+ conf_kwargs={
+ "extensions": ["sphinx_design"],
+ "sd_fontawesome_source": "cdn",
+ }
+ )
+ builder.src_path.joinpath("index.rst").write_text(
+ "Title\n=====\n\n:fa-solid:`rocket`\n", encoding="utf8"
+ )
+ builder.build()
+ html = (builder.out_path / "index.html").read_text(encoding="utf8")
+ assert html.count(DEFAULT_CDN_URL) == 1
+
+
+def test_fontawesome_source_none_omits_link(sphinx_builder):
+ """The default ``sd_fontawesome_source="none"`` adds no FontAwesome CSS."""
+ builder = sphinx_builder(conf_kwargs={"extensions": ["sphinx_design"]})
+ builder.src_path.joinpath("index.rst").write_text(
+ "Title\n=====\n\n:fa-solid:`rocket`\n", encoding="utf8"
+ )
+ builder.build()
+ html = (builder.out_path / "index.html").read_text(encoding="utf8")
+ assert DEFAULT_CDN_URL not in html
+ assert "font-awesome" not in html
+
+
+def test_fontawesome_source_cdn_custom_url(sphinx_builder):
+ """``sd_fontawesome_cdn_url`` overrides the CSS URL that is added."""
+ url = "https://example.com/my-fontawesome.css"
+ builder = sphinx_builder(
+ conf_kwargs={
+ "extensions": ["sphinx_design"],
+ "sd_fontawesome_source": "cdn",
+ "sd_fontawesome_cdn_url": url,
+ }
+ )
+ builder.src_path.joinpath("index.rst").write_text(
+ "Title\n=====\n\n:fa-solid:`rocket`\n", encoding="utf8"
+ )
+ builder.build()
+ html = (builder.out_path / "index.html").read_text(encoding="utf8")
+ assert html.count(url) == 1
+ assert DEFAULT_CDN_URL not in html
+
+
+LATEX_SRC = (
+ "Title\n=====\n\nIcons :fas:`rocket`, :fab:`github`, :far:`bell`, :fa:`star`.\n"
+)
+
+
+def _latex_tex(builder) -> str:
+ """Return the built ``.tex`` output."""
+ return next(builder.out_path.glob("*.tex")).read_text(encoding="utf8")
+
+
+def test_latex_fontawesome5(sphinx_builder):
+ """``"fontawesome5"`` loads that package and maps styles per its manual."""
+ builder = sphinx_builder(
+ buildername="latex",
+ conf_kwargs={
+ "extensions": ["sphinx_design"],
+ "sd_fontawesome_latex": "fontawesome5",
+ },
+ )
+ builder.src_path.joinpath("index.rst").write_text(
+ "Title\n=====\n\n"
+ "Icons :fa-solid:`rocket`, :fa-brands:`github`, :fa-regular:`bell`.\n",
+ encoding="utf8",
+ )
+ builder.build(assert_pass=False)
+ tex = _latex_tex(builder)
+ assert "\\usepackage{fontawesome5}" in tex
+ assert "\\faIcon{rocket}" in tex # solid -> default style
+ assert "\\faIcon{github}" in tex # brands -> resolved by name
+ assert "\\faIcon[regular]{bell}" in tex # regular -> optional style arg
+ # the fontawesome5 mode never emits the legacy lowercase macro
+ assert "\\faicon{" not in tex
+ assert "not included in LaTeX output" not in builder.warnings
+
+
+@pytest.mark.parametrize("value", [True, "fontawesome"])
+def test_latex_legacy_fontawesome_parity(value, sphinx_builder):
+ """``True`` and ``"fontawesome"`` give the legacy, byte-identical output.
+
+ (Compared against a captured ``main`` build: ``\\usepackage{fontawesome}``
+ and ``\\faicon{}`` for every icon, regardless of style.)
+ """
+ builder = sphinx_builder(
+ buildername="latex",
+ conf_kwargs={
+ "extensions": ["sphinx_design"],
+ "sd_fontawesome_latex": value,
+ },
+ )
+ builder.src_path.joinpath("index.rst").write_text(LATEX_SRC, encoding="utf8")
+ builder.build(assert_pass=False)
+ tex = _latex_tex(builder)
+ assert "\\usepackage{fontawesome}" in tex
+ assert "\\usepackage{fontawesome5}" not in tex
+ for icon in ("rocket", "github", "bell", "star"):
+ assert f"\\faicon{{{icon}}}" in tex
+ # legacy uses the lowercase macro, never the fontawesome5 \faIcon
+ assert "\\faIcon{" not in tex
+ assert "not included in LaTeX output" not in builder.warnings
+
+
+@pytest.mark.parametrize("value", [False, "none"])
+def test_latex_disabled_warns_once(value, sphinx_builder):
+ """With icons disabled in LaTeX, the warning fires exactly once per build."""
+ builder = sphinx_builder(
+ buildername="latex",
+ conf_kwargs={
+ "extensions": ["sphinx_design"],
+ "sd_fontawesome_latex": value,
+ },
+ )
+ builder.src_path.joinpath("index.rst").write_text(LATEX_SRC, encoding="utf8")
+ builder.build(assert_pass=False)
+ # four icons in the source, but the throttled warning appears only once
+ assert builder.warnings.count("not included in LaTeX output") == 1
+ tex = _latex_tex(builder)
+ assert "\\faicon{" not in tex
+ assert "\\faIcon{" not in tex
diff --git a/tests/test_misc.py b/tests/test_misc.py
index 19dec9b..74e7bf8 100644
--- a/tests/test_misc.py
+++ b/tests/test_misc.py
@@ -506,7 +506,9 @@ def test_button_i18n_translated(sphinx_builder):
INVALID_CONFIG_VALUES = {
"custom_directives": (["not", "a", "dict"], "must be a dictionary"),
- "fontawesome_latex": ("not-a-bool", "must be of type"),
+ "fontawesome_source": ("invalid", "must be one of"),
+ "fontawesome_cdn_url": (123, "must be of type"),
+ "fontawesome_latex": ("bad-mode", "must be a bool or one of"),
"tabs_storage_prefix": (123, "must be of type"),
}
"""An invalidly typed value (and expected warning) for every ``SdConfig`` field."""
@@ -596,8 +598,12 @@ def test_config_warnings_suppressible(sphinx_builder):
def test_config_strict_validation():
"""Directly instantiating ``SdConfig`` with invalid values should raise."""
- with pytest.raises(TypeError, match="'fontawesome_latex' must be of type"):
- SdConfig(fontawesome_latex="not-a-bool")
+ with pytest.raises(
+ ValueError, match="'fontawesome_latex' must be a bool or one of"
+ ):
+ SdConfig(fontawesome_latex="bad-mode")
+ with pytest.raises(TypeError, match="'fontawesome_latex' must be a bool or str"):
+ SdConfig(fontawesome_latex=123)
with pytest.raises(TypeError, match="'custom_directives' must be a dictionary"):
SdConfig(custom_directives="not-a-dict")
with pytest.raises(ValueError, match="must have an 'inherit' key"):
@@ -609,6 +615,8 @@ def test_config_toml_round_trip():
a TOML document containing every field should load and validate.
"""
toml_str = """\
+ fontawesome_source = "cdn"
+ fontawesome_cdn_url = "https://example.com/fa.css"
fontawesome_latex = true
tabs_storage_prefix = "sphinx-design-tab-id-"
@@ -625,8 +633,11 @@ def test_config_toml_round_trip():
"the TOML sample should contain every SdConfig field"
)
config = SdConfig(**data)
+ assert config.fontawesome_source == "cdn"
+ assert config.fontawesome_cdn_url == "https://example.com/fa.css"
assert config.fontawesome_latex is True
assert config.tabs_storage_prefix == "sphinx-design-tab-id-"
+ assert config.fontawesome_latex_mode == "fontawesome"
assert config.custom_directives == {
"dropdown-syntax": {
"inherit": "dropdown",