From 26d9aba9b30cff5a16fe557668880392c61adba4 Mon Sep 17 00:00:00 2001 From: Chris Sewell Date: Tue, 14 Jul 2026 16:04:33 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20FontAwesome=20v6=20roles,=20CDN=20c?= =?UTF-8?q?onfig,=20fontawesome5=20LaTeX=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add v6 roles fa-solid/fa-brands/fa-regular (legacy fa/fas/fab/far kept) - add sd_fontawesome_source ("none"|"cdn") and sd_fontawesome_cdn_url config - sd_fontawesome_latex now accepts "none"/"fontawesome"/"fontawesome5" (bool True/False still work identically); "fontawesome5" emits \faIcon{...} - throttle the LaTeX / unsupported-builder icon warnings to once per build Closes #174, #242. --- CHANGELOG.md | 8 ++ docs/badges_buttons.md | 116 ++++++++++++++---- docs/conf.py | 13 +- sphinx_design/config.py | 102 +++++++++++++++- sphinx_design/icons.py | 52 +++++++- tests/test_fontawesome.py | 246 ++++++++++++++++++++++++++++++++++++++ tests/test_misc.py | 17 ++- 7 files changed, 514 insertions(+), 40 deletions(-) create mode 100644 tests/test_fontawesome.py 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",