From ad90f6c921c5d97107824477d03ac3721da6eac4 Mon Sep 17 00:00:00 2001 From: Tim Paine <3105306+timkpaine@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:40:10 -0400 Subject: [PATCH 1/2] Default to the fuma theme Use fuma (sphinx-fuma) when no theme is configured, in both the docs and wiki configuration generators. furo stays a core dependency and bundled theme so its preview still builds. Signed-off-by: Tim Paine <3105306+timkpaine@users.noreply.github.com> --- docs/src/configuration.md | 12 ++++++------ docs/src/installation.md | 5 +++-- docs/src/overview.md | 2 +- yardang/build.py | 8 ++++---- yardang/tests/test_themes.py | 17 +++++++++++++++++ 5 files changed, 31 insertions(+), 13 deletions(-) diff --git a/docs/src/configuration.md b/docs/src/configuration.md index 5ccf666f..7d08ab63 100644 --- a/docs/src/configuration.md +++ b/docs/src/configuration.md @@ -57,30 +57,30 @@ version = "0.1.0" ## [`theme`](https://www.sphinx-doc.org/en/master/usage/configuration.html#confval-html_theme) -The Sphinx HTML theme to build with. Defaults to `furo`. +The Sphinx HTML theme to build with. Defaults to `fuma`. ```toml [tool.yardang] -theme = "furo" +theme = "fuma" ``` `yardang` ships per-theme defaults (sensible CSS tweaks, and an optional dependency) for the following themes: -- [`furo`](https://github.com/pradyunsg/furo) (the default, always installed) +- [`fuma`](https://github.com/python-project-templates/sphinx-fuma) (the default, always installed) +- [`furo`](https://github.com/pradyunsg/furo) (always installed) - [`sphinxawesome_theme`](https://sphinxawesome.xyz/) - [`shibuya`](https://shibuya.lepture.com/) -- [`fuma`](https://github.com/python-project-templates/sphinx-fuma) - [`klink`](https://github.com/pmorissette/klink) -`furo` is always available; install the rest with `pip install yardang[themes]`. +`fuma` and `furo` are always available; install the rest with `pip install yardang[themes]`. Any other installed Sphinx theme works too — you just won't get the bundled defaults. See [Previewing themes](#previewing-themes) below to compare them live. ### The `fuma` theme [`sphinx-fuma`](https://github.com/python-project-templates/sphinx-fuma) is a -separate distribution, installed by `pip install yardang[themes]`. It is a +separate distribution, installed with `yardang`. It is a three-column layout — page tree on the left, content in the middle, page headings on the right — with a `/` or `Cmd-K` search dialog, light/dark modes that work without JavaScript, and a scroll-following heading indicator that diff --git a/docs/src/installation.md b/docs/src/installation.md index 68b45caa..e16f99d0 100644 --- a/docs/src/installation.md +++ b/docs/src/installation.md @@ -8,8 +8,9 @@ You can install from PyPI via `pip`: pip install yardang ``` -The `furo` theme is included by default. To pull in the other bundled themes -(`sphinxawesome_theme`, `shibuya`), install the `themes` extra: +The `fuma` theme is the default and the `furo` theme is included as well. To pull +in the other bundled themes (`sphinxawesome_theme`, `shibuya`), install the +`themes` extra: ```bash pip install yardang[themes] diff --git a/docs/src/overview.md b/docs/src/overview.md index c069ada7..097f52b1 100644 --- a/docs/src/overview.md +++ b/docs/src/overview.md @@ -14,7 +14,7 @@ Out of the box, it comes with support for several popular Sphinx frameworks: - [Myst Markdown](https://jupyterbook.org/en/stable/content/myst.html) - [Sphinx AutoAPI](https://sphinx-autoapi.readthedocs.io/en/latest/) - [Autodoc Pydantic](https://autodoc-pydantic.readthedocs.io/en/stable/users/examples.html) -- [Furo Theme](https://github.com/pradyunsg/furo) (the default), plus bundled defaults for [`sphinx-fuma`](https://github.com/python-project-templates/sphinx-fuma), [`sphinxawesome_theme`](https://sphinxawesome.xyz/), [`shibuya`](https://shibuya.lepture.com/), and [`klink`](https://github.com/pmorissette/klink). +- [Fuma Theme](https://github.com/python-project-templates/sphinx-fuma) (the default), plus bundled defaults for [Furo](https://github.com/pradyunsg/furo), [`sphinxawesome_theme`](https://sphinxawesome.xyz/), [`shibuya`](https://shibuya.lepture.com/), and [`klink`](https://github.com/pmorissette/klink). `yardang preview` builds the docs once per theme so you can compare them side-by-side. See [Previewing themes](configuration.md#previewing-themes) for the diff --git a/yardang/build.py b/yardang/build.py index 43f348f6..a12be375 100644 --- a/yardang/build.py +++ b/yardang/build.py @@ -189,7 +189,7 @@ def generate_docs_configuration( author: Author name. Falls back to first entry in ``[project].authors``. copyright: Copyright string. Falls back to author name. version: Version string. Falls back to ``[project].version``. - theme: Sphinx theme name. Defaults to ``"furo"``. + theme: Sphinx theme name. Defaults to ``"fuma"``. docs_root: Base URL for hosted documentation. Used for canonical URLs. root: Path to README or index file to use as documentation root. source_dir: Existing Sphinx source directory. When set, root names an @@ -275,7 +275,7 @@ def customize(args): if isinstance(author, dict): author = author["name"] copyright = copyright if copyright is not None else author - theme = theme if theme is not None else get_config(section="theme", base=config_base) or "furo" + theme = theme if theme is not None else get_config(section="theme", base=config_base) or "fuma" version = version if version is not None else get_config(section="version", base="project") docs_root = ( docs_root @@ -672,7 +672,7 @@ def generate_wiki_configuration( author: str | None = "", copyright: str | None = "", version: str | None = "", - theme: str | None = "furo", + theme: str | None = "fuma", docs_root: str | None = "", root: str | None = "", cname: str | None = "", @@ -704,7 +704,7 @@ def generate_wiki_configuration( author: Author name. Falls back to first entry in ``[project].authors``. copyright: Copyright string. Falls back to author name. version: Version string. Falls back to ``[project].version``. - theme: Sphinx theme name. Defaults to ``"furo"``. + theme: Sphinx theme name. Defaults to ``"fuma"``. docs_root: Base URL for hosted documentation. Used for canonical URLs. root: Path to README or index file to use as documentation root. cname: Custom domain name for GitHub Pages CNAME file. diff --git a/yardang/tests/test_themes.py b/yardang/tests/test_themes.py index 9347276a..abc058ba 100644 --- a/yardang/tests/test_themes.py +++ b/yardang/tests/test_themes.py @@ -63,6 +63,23 @@ def test_fuma_is_registered_as_an_extension(self, tmp_path): assert "sphinx_fuma" in self._conf(tmp_path, theme="fuma") +class TestDefaultTheme: + """An unconfigured build uses the bundled default theme.""" + + def test_unconfigured_theme_is_fuma(self, tmp_path): + (tmp_path / "pyproject.toml").write_text(MINIMAL_PYPROJECT) + (tmp_path / "README.md").write_text("# Test Project\n") + original_cwd = os.getcwd() + try: + os.chdir(tmp_path) + with generate_docs_configuration(html_output_dir=str(tmp_path / "site")) as config_dir: + conf = Path(config_dir, "conf.py").read_text() + finally: + os.chdir(original_cwd) + assert 'html_theme = "fuma"' in conf + assert "sphinx_fuma" in conf + + class TestResolveCustomAsset: """Theme-aware resolution of bundled and user-supplied CSS/JS.""" From f942c03f0dd57b1cea1669a4b20bbd2ef04d06c7 Mon Sep 17 00:00:00 2001 From: Tim Paine <3105306+timkpaine@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:45:42 -0400 Subject: [PATCH 2/2] Layer per-theme CSS on top of generic defaults Resolution used to replace the bundled generic custom.css with {theme}.css, so fuma builds lost the theme-agnostic rules and klink had to inherit furo's attribute-driven ones. Write the generic file first and append {theme}.css when the theme ships one, and move furo's layout and data-theme rules into furo.css. Signed-off-by: Tim Paine <3105306+timkpaine@users.noreply.github.com> --- docs/src/configuration.md | 11 +++++--- yardang/build.py | 30 +++++++++++++-------- yardang/custom.css | 47 +++----------------------------- yardang/furo.css | 52 ++++++++++++++++++++++++++++++++++++ yardang/tests/test_themes.py | 47 +++++++++++++++++++++----------- 5 files changed, 114 insertions(+), 73 deletions(-) create mode 100644 yardang/furo.css diff --git a/docs/src/configuration.md b/docs/src/configuration.md index 7d08ab63..343676c1 100644 --- a/docs/src/configuration.md +++ b/docs/src/configuration.md @@ -264,10 +264,13 @@ pass through `[tool.yardang.sphinx]` as normal. ## `custom-css` / `custom-js` Inject a custom stylesheet or script. The value may be a path or raw content. -When unset, `yardang` looks for a bundled per-theme asset named `{theme}.css` / -`{theme}.js`, then falls back to the generic `custom.css` / `custom.js`. This lets -each theme ship sensible defaults — for example, `sphinxawesome_theme` and -`shibuya` hide the duplicate copy button. +When unset, `yardang` ships sensible defaults: the generic `custom.css` / +`custom.js` applies to every theme, and a bundled per-theme `{theme}.css` / +`{theme}.js` is appended when that theme has one, so theme rules win. This lets +each theme add its own tweaks — for example, `sphinxawesome_theme` and `shibuya` +hide the duplicate copy button, and `furo` gets the wide-content layout and the +`data-theme` rules that theme uses for light/dark. An explicit value replaces +both. ```toml [tool.yardang] diff --git a/yardang/build.py b/yardang/build.py index a12be375..b50293b2 100644 --- a/yardang/build.py +++ b/yardang/build.py @@ -32,9 +32,11 @@ def _resolve_custom_asset(value: str | Path | None, theme: str | None, extension 1. An explicit ``value`` (an existing file path is read; a path-like value that does not exist is ignored; anything else is treated as raw content). - 2. A bundled theme-specific asset named ``{theme}.{extension}``. - 3. A bundled generic ``custom.{extension}``. - 4. ``None`` when nothing matches. + It replaces the bundled content rather than adding to it. + 2. The bundled generic ``custom.{extension}``, followed by the bundled + ``{theme}.{extension}`` when the theme ships one, so per-theme rules win + over the generic defaults. + 3. ``None`` when nothing matches. """ if value: try: @@ -46,13 +48,17 @@ def _resolve_custom_asset(value: str | Path | None, theme: str | None, extension looks_like_path = False if not looks_like_path: return str(value) - names = [f"{theme}.{extension}"] if theme else [] - names.append(f"custom.{extension}") + names = [f"custom.{extension}"] + if theme: + names.append(f"{theme}.{extension}") + parts = [] for name in names: asset = assets_dir / name if asset.is_file(): - return asset.read_text() - return None + parts.append(asset.read_text()) + if not parts: + return None + return "\n".join(part.rstrip("\n") for part in parts) + "\n" def run_doxygen_if_needed( @@ -198,10 +204,12 @@ def generate_docs_configuration( pages: List of page paths to include in the toctree. use_autoapi: Whether to use sphinx-autoapi for Python API docs. Defaults to ``None`` (auto-detect). - custom_css: Path or raw content for custom CSS. When unset, falls back to a - bundled per-theme ``{theme}.css`` then the generic ``custom.css``. - custom_js: Path or raw content for custom JS. When unset, falls back to a - bundled per-theme ``{theme}.js`` then the generic ``custom.js``. + custom_css: Path or raw content for custom CSS. When unset, the bundled + generic ``custom.css`` is used, then the bundled ``{theme}.css`` when + the theme ships one. An explicit value replaces both. + custom_js: Path or raw content for custom JS. When unset, the bundled + generic ``custom.js`` is used, then the bundled ``{theme}.js`` when + the theme ships one. An explicit value replaces both. html_output_dir: Directory where Sphinx writes HTML output. Custom CSS/JS are placed under ``/_static``. Defaults to ``docs/html``. config_base: Base key in pyproject.toml for configuration. diff --git a/yardang/custom.css b/yardang/custom.css index fa97cc5b..0b262be6 100644 --- a/yardang/custom.css +++ b/yardang/custom.css @@ -1,18 +1,5 @@ -/* Wide main page */ -.content { - flex: 3; -} -aside.sidebar-drawer { - width: unset; -} -aside.toc-drawer { - flex: 1; -} - -/* Left-align tables */ -article table.align-default { - margin-left: 0; -} +/* Generic defaults applied for every theme. Theme-specific overrides live in a + bundled `{theme}.css` and are appended after this file. */ /* Handle logo switching for light/dark themes */ /* Put in code like: @@ -23,19 +10,8 @@ article table.align-default { nbprint */ -body[data-theme="light"] .logo-light { - display: inline; -} -body[data-theme="light"] .logo-dark { - display: none; -} -body[data-theme="dark"] .logo-light { - display: none; -} -body[data-theme="dark"] .logo-dark { - display: inline; -} - +/* Themes that toggle the color scheme with their own attribute add matching + rules on top of this file. */ @media (prefers-color-scheme: dark) { .logo-light { display: none; @@ -50,18 +26,6 @@ body[data-theme="dark"] .logo-dark { /* Handle mermaid diagrams in dark mode */ -body[data-theme="light"] .mermaid-container { - background-color: #fafafa; -} -body[data-theme="light"] pre.mermaid svg path { - stroke: black !important; -} -body[data-theme="dark"] .mermaid-container { - background-color: #222; -} -body[data-theme="dark"] pre.mermaid svg path { - stroke: white !important; -} @media (prefers-color-scheme: dark) { .mermaid-container { background-color: #222; @@ -74,7 +38,4 @@ body[data-theme="dark"] pre.mermaid svg path { .mermaid-container { background-color: #fafafa; } - body[data-theme="dark"] pre.mermaid svg path { - stroke: black !important; - } } diff --git a/yardang/furo.css b/yardang/furo.css new file mode 100644 index 00000000..6c11647c --- /dev/null +++ b/yardang/furo.css @@ -0,0 +1,52 @@ +/* Per-theme overrides layered on top of the generic custom.css defaults. Furo + drives light/dark through a `data-theme` attribute on , so the rules + that read that attribute belong here rather than in the generic file. */ + +/* Wide main page */ +.content { + flex: 3; +} +aside.sidebar-drawer { + width: unset; +} +aside.toc-drawer { + flex: 1; +} + +/* Left-align tables */ +article table.align-default { + margin-left: 0; +} + +/* Logo switching driven by furo's data-theme attribute */ +body[data-theme="light"] .logo-light { + display: inline; +} +body[data-theme="light"] .logo-dark { + display: none; +} +body[data-theme="dark"] .logo-light { + display: none; +} +body[data-theme="dark"] .logo-dark { + display: inline; +} + +/* Mermaid diagrams in dark mode */ +body[data-theme="light"] .mermaid-container { + background-color: #fafafa; +} +body[data-theme="light"] pre.mermaid svg path { + stroke: black !important; +} +body[data-theme="dark"] .mermaid-container { + background-color: #222; +} +body[data-theme="dark"] pre.mermaid svg path { + stroke: white !important; +} +@media (prefers-color-scheme: light) { + body[data-theme="dark"] pre.mermaid svg path { + stroke: black !important; + } +} diff --git a/yardang/tests/test_themes.py b/yardang/tests/test_themes.py index abc058ba..18641bf9 100644 --- a/yardang/tests/test_themes.py +++ b/yardang/tests/test_themes.py @@ -22,6 +22,11 @@ """ +def _bundled(*names: str) -> str: + """Expected content for a layered resolution over the named bundled assets.""" + return "\n".join((ASSETS_DIR / name).read_text().rstrip("\n") for name in names) + "\n" + + class TestThemeModule: """Theme names map to the package that provides them.""" @@ -81,23 +86,21 @@ def test_unconfigured_theme_is_fuma(self, tmp_path): class TestResolveCustomAsset: - """Theme-aware resolution of bundled and user-supplied CSS/JS.""" + """Layered resolution of bundled and user-supplied CSS/JS.""" - def test_prefers_theme_specific_file(self): - expected = (ASSETS_DIR / "sphinxawesome_theme.css").read_text() - assert _resolve_custom_asset(None, "sphinxawesome_theme", "css", assets_dir=ASSETS_DIR) == expected + def test_generic_defaults_apply_to_every_theme(self): + assert _resolve_custom_asset(None, "klink", "css", assets_dir=ASSETS_DIR) == _bundled("custom.css") - def test_shibuya_theme_specific_file(self): - expected = (ASSETS_DIR / "shibuya.css").read_text() - assert _resolve_custom_asset(None, "shibuya", "css", assets_dir=ASSETS_DIR) == expected + def test_theme_rules_are_appended_after_generic_defaults(self): + resolved = _resolve_custom_asset(None, "shibuya", "css", assets_dir=ASSETS_DIR) + assert resolved == _bundled("custom.css", "shibuya.css") + assert resolved.index("prefers-color-scheme") < resolved.index("button.copybtn") - def test_falls_back_to_custom_css(self): - expected = (ASSETS_DIR / "custom.css").read_text() - assert _resolve_custom_asset(None, "furo", "css", assets_dir=ASSETS_DIR) == expected + def test_furo_rules_are_appended_after_generic_defaults(self): + assert _resolve_custom_asset(None, "furo", "css", assets_dir=ASSETS_DIR) == _bundled("custom.css", "furo.css") - def test_falls_back_to_custom_js(self): - expected = (ASSETS_DIR / "custom.js").read_text() - assert _resolve_custom_asset(None, "furo", "js", assets_dir=ASSETS_DIR) == expected + def test_generic_js_is_used_when_theme_ships_no_js(self): + assert _resolve_custom_asset(None, "furo", "js", assets_dir=ASSETS_DIR) == _bundled("custom.js") def test_user_content_takes_precedence(self): content = "body { color: red; }" @@ -109,7 +112,7 @@ def test_reads_existing_file(self, tmp_path): assert _resolve_custom_asset(asset, "furo", "css", assets_dir=ASSETS_DIR) == "h1 { color: blue; }" def test_ignores_missing_path(self): - expected = (ASSETS_DIR / "shibuya.css").read_text() + expected = _bundled("custom.css", "shibuya.css") assert _resolve_custom_asset("does/not/exist/custom.css", "shibuya", "css", assets_dir=ASSETS_DIR) == expected def test_returns_none_when_nothing_matches(self, tmp_path): @@ -147,7 +150,21 @@ def test_theme_specific_css_is_written(self, tmp_path): finally: os.chdir(original_cwd) written = (out / "_static" / "styles" / "custom.css").read_text() - assert written == (ASSETS_DIR / "shibuya.css").read_text() + assert written == _bundled("custom.css", "shibuya.css") + + def test_default_theme_keeps_generic_defaults(self, tmp_path): + self._setup_project(tmp_path) + out = tmp_path / "site" + original_cwd = os.getcwd() + try: + os.chdir(tmp_path) + with generate_docs_configuration(html_output_dir=str(out)): + pass + finally: + os.chdir(original_cwd) + written = (out / "_static" / "styles" / "custom.css").read_text() + assert written == _bundled("custom.css", "fuma.css") + assert "mermaid" in written class TestPreview: