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
23 changes: 13 additions & 10 deletions docs/src/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down
5 changes: 3 additions & 2 deletions docs/src/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion docs/src/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 23 additions & 15 deletions yardang/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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(
Expand Down Expand Up @@ -189,7 +195,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
Expand All @@ -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 ``<html_output_dir>/_static``. Defaults to ``docs/html``.
config_base: Base key in pyproject.toml for configuration.
Expand Down Expand Up @@ -275,7 +283,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
Expand Down Expand Up @@ -672,7 +680,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 = "",
Expand Down Expand Up @@ -704,7 +712,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.
Expand Down
47 changes: 4 additions & 43 deletions yardang/custom.css
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -23,19 +10,8 @@ article table.align-default {
<img src="https://github.com/nbprint/nbprint/raw/main/docs/img/logo-dark.png?raw=true#gh-dark-mode-only" alt="nbprint" width="400"></a>
</a>
*/
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;
Expand All @@ -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;
Expand All @@ -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;
}
}
52 changes: 52 additions & 0 deletions yardang/furo.css
Original file line number Diff line number Diff line change
@@ -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 <body>, 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;
}
}
64 changes: 49 additions & 15 deletions yardang/tests/test_themes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -63,24 +68,39 @@ 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."""
"""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; }"
Expand All @@ -92,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):
Expand Down Expand Up @@ -130,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:
Expand Down