From 03af5aa85b7277e8f13aa641010d92b319d20d61 Mon Sep 17 00:00:00 2001 From: Tim Paine <3105306+timkpaine@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:48:58 -0400 Subject: [PATCH] feat(themes): add bundled theme defaults and `yardang preview` Ship per-theme defaults and a preview workflow so the docs can be compared across multiple Sphinx themes. - pyproject: add `themes` extra (shibuya, sphinxawesome-theme) - build.py: add BUNDLED_THEMES and _resolve_custom_asset for per-theme CSS/JS defaults; add html_output_dir so the HTML output dir can be redirected - cli.py: add `yardang preview` to render the site once per theme; `build` gains an --output option - bundled shibuya.css / sphinxawesome_theme.css defaults - docs: document the themes extra, custom-css/js precedence, and previewing - ci: build with the working copy, render per-theme previews, publish to gh-pages - tests: cover bundled themes and preview behaviour --- .github/workflows/docs.yaml | 25 ++++++- docs/src/configuration.md | 55 +++++++++++++-- docs/src/installation.md | 7 ++ docs/src/overview.md | 6 +- pyproject.toml | 7 ++ yardang/__init__.py | 3 +- yardang/build.py | 98 +++++++++++++++------------ yardang/cli.py | 47 ++++++++++++- yardang/shibuya.css | 14 ++++ yardang/sphinxawesome_theme.css | 33 +++++++++ yardang/tests/test_themes.py | 114 ++++++++++++++++++++++++++++++++ 11 files changed, 356 insertions(+), 53 deletions(-) create mode 100644 yardang/shibuya.css create mode 100644 yardang/sphinxawesome_theme.css create mode 100644 yardang/tests/test_themes.py diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index e1429e0..d1a7fdb 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -12,6 +12,13 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.11' + cache: pip + cache-dependency-path: pyproject.toml + - name: Install Rust uses: dtolnay/rust-toolchain@stable @@ -26,12 +33,26 @@ jobs: - name: Install doxygen run: sudo apt-get install -y doxygen + # Build with the working copy (not the released yardang) so that the docs, + # and the per-theme previews below, exercise the version in this repo. - name: Install self run: pip install -e .[develop] - - uses: actions-ext/yardang@main + - name: Build docs + run: yardang build + + # Render the full site once per bundled theme into docs/html/_previews// + # so each theme is browsable live at a suburl of the published site. + - name: Build theme previews + run: yardang preview + + - name: Publish to GitHub Pages + uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 # v4.1.0 with: - token: ${{ secrets.GITHUB_TOKEN }} + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_branch: gh-pages + publish_dir: docs/html + force_orphan: true - name: Build Wiki run: yardang wiki --output-dir docs/wiki diff --git a/docs/src/configuration.md b/docs/src/configuration.md index 44b5f00..67fa268 100644 --- a/docs/src/configuration.md +++ b/docs/src/configuration.md @@ -54,24 +54,69 @@ authors = "your project authors" version = "0.1.0" ``` -## `theme` +## [`theme`](https://www.sphinx-doc.org/en/master/usage/configuration.html#confval-html_theme) -Defaults to `furo`. +The Sphinx HTML theme to build with. Defaults to `furo`. ```toml [tool.yardang] theme = "furo" ``` -## [`theme`](https://www.sphinx-doc.org/en/master/usage/configuration.html#confval-html_theme) +`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) +- [`sphinxawesome_theme`](https://sphinxawesome.xyz/) +- [`shibuya`](https://shibuya.lepture.com/) -Defaults to `furo`. +`furo` is 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. + +## `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. ```toml [tool.yardang] -theme = "furo" +custom-css = "docs/_static/my.css" +``` + +## Previewing themes + +Build the docs once per theme to compare them side-by-side: + +```bash +yardang preview ``` +This renders the documentation into `docs/html/_previews//` for each +bundled theme (`furo`, `sphinxawesome_theme`, `shibuya`). Themes whose package is +not installed are skipped. Restrict the set with `--themes`: + +```bash +yardang preview --themes furo --themes shibuya +``` + +Install the optional themes with: + +```bash +pip install yardang[themes] +``` + +When this runs in CI before the GitHub Pages deploy (as in `yardang`'s own +[`docs.yaml`](https://github.com/python-project-templates/yardang/blob/main/.github/workflows/docs.yaml)), +each theme is browsable live at a suburl of the published site: + +- [`/_previews/furo/`](https://yardang.python-templates.dev/_previews/furo/) +- [`/_previews/sphinxawesome_theme/`](https://yardang.python-templates.dev/_previews/sphinxawesome_theme/) +- [`/_previews/shibuya/`](https://yardang.python-templates.dev/_previews/shibuya/) + ## `root` The root page to use, defaults to `README.md`. diff --git a/docs/src/installation.md b/docs/src/installation.md index 7fa0741..68b45ca 100644 --- a/docs/src/installation.md +++ b/docs/src/installation.md @@ -8,6 +8,13 @@ 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: + +```bash +pip install yardang[themes] +``` + ## Conda You can install from `conda-forge` via `conda` (or `mamba`, etc): diff --git a/docs/src/overview.md b/docs/src/overview.md index 180fd1d..4e97bbf 100644 --- a/docs/src/overview.md +++ b/docs/src/overview.md @@ -14,7 +14,11 @@ 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) +- [Furo Theme](https://github.com/pradyunsg/furo) (the default), plus bundled defaults for [`sphinxawesome_theme`](https://sphinxawesome.xyz/) and [`shibuya`](https://shibuya.lepture.com/) + +`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 +live previews. ## Usage diff --git a/pyproject.toml b/pyproject.toml index c995554..bb38438 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,10 @@ wiki = [ "sphinx-markdown-builder>=0.6.9", ] +themes = [ + "shibuya", + "sphinxawesome-theme", +] develop = [ "build", "bump-my-version", @@ -80,6 +84,9 @@ develop = [ "sphinx-rust", "sphinx-js>=5.0.0", "sphinx-markdown-builder>=0.6.9", + # Themes + "shibuya", + "sphinxawesome-theme", ] [project.scripts] diff --git a/yardang/__init__.py b/yardang/__init__.py index b5b573d..81bb441 100644 --- a/yardang/__init__.py +++ b/yardang/__init__.py @@ -1,9 +1,10 @@ __version__ = "0.6.0" -from .build import generate_docs_configuration, generate_wiki_configuration, run_doxygen_if_needed +from .build import BUNDLED_THEMES, generate_docs_configuration, generate_wiki_configuration, run_doxygen_if_needed from .wiki import process_wiki_output __all__ = ( + "BUNDLED_THEMES", "generate_docs_configuration", "generate_wiki_configuration", "run_doxygen_if_needed", diff --git a/yardang/build.py b/yardang/build.py index 9f1e90e..9d1b9cf 100644 --- a/yardang/build.py +++ b/yardang/build.py @@ -4,13 +4,47 @@ from contextlib import contextmanager from pathlib import Path from tempfile import TemporaryDirectory -from typing import Callable, Dict, List, Optional +from typing import Callable, Dict, List, Optional, Union from jinja2 import Environment, FileSystemLoader from .utils import get_config, get_config_flex -__all__ = ("generate_docs_configuration", "run_doxygen_if_needed", "generate_wiki_configuration") +__all__ = ("generate_docs_configuration", "run_doxygen_if_needed", "generate_wiki_configuration", "BUNDLED_THEMES") + +# Themes for which yardang ships per-theme defaults (a bundled ``{theme}.css`` and/or +# an optional dependency). Used as the default set for ``yardang preview``. +BUNDLED_THEMES = ("furo", "sphinxawesome_theme", "shibuya") + + +def _resolve_custom_asset(value: Optional[Union[str, Path]], theme: Optional[str], extension: str, *, assets_dir: Path) -> Optional[str]: + """Resolve custom CSS/JS content for the docs build. + + Resolution precedence: + + 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. + """ + if value: + try: + candidate = Path(value) + if candidate.is_file(): + return candidate.read_text() + looks_like_path = str(value).endswith(f".{extension}") + except OSError: + looks_like_path = False + if not looks_like_path: + return str(value) + names = [f"{theme}.{extension}"] if theme else [] + names.append(f"custom.{extension}") + for name in names: + asset = assets_dir / name + if asset.is_file(): + return asset.read_text() + return None def run_doxygen_if_needed( @@ -117,6 +151,7 @@ def generate_docs_configuration( autoapi_ignore: Optional[List] = None, custom_css: Optional[Path] = None, custom_js: Optional[Path] = None, + html_output_dir: Optional[str] = None, config_base: Optional[str] = None, previous_versions: Optional[bool] = False, adjust_arguments: Callable = None, @@ -148,8 +183,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 to custom CSS file. Defaults to bundled custom.css. - custom_js: Path to custom JavaScript file. Defaults to bundled custom.js. + 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``. + 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. Defaults to ``"tool.yardang"``. previous_versions: Whether to generate previous versions documentation. @@ -163,7 +202,6 @@ def generate_docs_configuration( or the current directory if conf.py already exists. Raises: - FileNotFoundError: If custom_css or custom_js paths don't exist. toml.TomlDecodeError: If pyproject.toml is malformed. Example: @@ -242,38 +280,11 @@ def customize(args): use_autoapi = use_autoapi if use_autoapi is not None else get_config_flex(section="use-autoapi", base=config_base) autoapi_ignore = autoapi_ignore if autoapi_ignore is not None else get_config_flex(section="autoapi-ignore", base=config_base) - custom_css = ( - custom_css - if custom_css is not None - else Path(get_config_flex(section="custom-css", base=config_base) or (Path(__file__).parent / "custom.css")) - ) - custom_js = ( - custom_js - if custom_js is not None - else Path(get_config_flex(section="custom-js", base=config_base) or (Path(__file__).parent / "custom.js")) - ) - - # if custom_css and custom_js are strings and they exist as paths, read them as Paths - # otherwise, assume the content is directly provided - if isinstance(custom_css, str): - custom_css_path = Path(custom_css) - # if the path is too long, it will throw - try: - if custom_css_path.exists(): - custom_css = custom_css_path.read_text() - except OSError: - pass - else: - custom_css = custom_css.read_text() - if isinstance(custom_js, str): - custom_js_path = Path(custom_js) - try: - if custom_js_path.exists(): - custom_js = custom_js_path.read_text() - except OSError: - pass - else: - custom_js = custom_js.read_text() + custom_css = custom_css if custom_css is not None else get_config_flex(section="custom-css", base=config_base) + custom_js = custom_js if custom_js is not None else get_config_flex(section="custom-js", base=config_base) + assets_dir = Path(__file__).parent + custom_css = _resolve_custom_asset(custom_css, theme, "css", assets_dir=assets_dir) + custom_js = _resolve_custom_asset(custom_js, theme, "js", assets_dir=assets_dir) source_dir = os.path.curdir @@ -559,11 +570,14 @@ def customize(args): template_file = Path(td) / "conf.py" template_file.write_text(template) - # write custom css and customjs - Path("docs/html/_static/styles").mkdir(parents=True, exist_ok=True) - Path("docs/html/_static/styles/custom.css").write_text(custom_css) - Path("docs/html/_static/js").mkdir(parents=True, exist_ok=True) - Path("docs/html/_static/js/custom.js").write_text(custom_js) + # write custom css and customjs into the html output's static dir + html_output = Path(html_output_dir) if html_output_dir is not None else Path("docs/html") + styles_dir = html_output / "_static" / "styles" + styles_dir.mkdir(parents=True, exist_ok=True) + (styles_dir / "custom.css").write_text(custom_css or "") + js_dir = html_output / "_static" / "js" + js_dir.mkdir(parents=True, exist_ok=True) + (js_dir / "custom.js").write_text(custom_js or "") # append docs-specific ignores to gitignore if Path(".gitignore").exists(): diff --git a/yardang/cli.py b/yardang/cli.py index c956175..5a7f02b 100644 --- a/yardang/cli.py +++ b/yardang/cli.py @@ -1,3 +1,4 @@ +from importlib.util import find_spec from pathlib import Path from subprocess import Popen from sys import executable, stderr, stdout @@ -6,7 +7,7 @@ from typer import Exit, Typer -from .build import generate_docs_configuration, generate_wiki_configuration +from .build import BUNDLED_THEMES, generate_docs_configuration, generate_wiki_configuration from .utils import get_config from .wiki import process_wiki_output @@ -31,6 +32,7 @@ def build( use_autoapi: Optional[bool] = None, custom_css: Optional[Path] = None, custom_js: Optional[Path] = None, + output: str = "docs/html", config_base: Optional[str] = "tool.yardang", previous_versions: Optional[bool] = False, ): @@ -50,6 +52,7 @@ def build( use_autoapi=use_autoapi, custom_css=custom_css, custom_js=custom_js, + html_output_dir=output, config_base=config_base, previous_versions=previous_versions, ) as file: @@ -58,7 +61,7 @@ def build( "-m", "sphinx", ".", - "docs/html", + output, "-c", file, ] @@ -200,10 +203,50 @@ def wiki( print(" 3. Commit and push to publish") +def preview( + *, + themes: Optional[List[str]] = None, + output: str = "docs/html/_previews", + quiet: bool = False, + debug: bool = False, + pdb: bool = False, +): + """Build the documentation once per theme for side-by-side comparison. + + For each theme in ``themes`` (defaulting to the themes yardang bundles + defaults for), the docs are rendered into ``/``. Themes whose + Sphinx package is not installed are skipped with a warning. + """ + themes = themes or list(BUNDLED_THEMES) + built = [] + failed = [] + for theme in themes: + if find_spec(theme) is None: + print(f"Skipping theme '{theme}': install it to include it in previews", file=stderr) + continue + theme_output = str(Path(output) / theme) + if not quiet: + print(f"Building preview for theme '{theme}' -> {theme_output}") + try: + build(theme=theme, output=theme_output, quiet=quiet, debug=debug, pdb=pdb) + except Exit as exc: + failed.append(theme) + print(f"Failed to build preview for theme '{theme}' (exit {exc.exit_code})", file=stderr) + continue + built.append((theme, theme_output)) + if built: + print("\nTheme previews generated:") + for theme, theme_output in built: + print(f" {theme}: {theme_output}/index.html") + if failed: + raise Exit(1) + + def main(): app = Typer() app.command("build")(build) app.command("debug")(debug) + app.command("preview")(preview) app.command("wiki")(wiki) app() diff --git a/yardang/shibuya.css b/yardang/shibuya.css new file mode 100644 index 0000000..7b6fcb1 --- /dev/null +++ b/yardang/shibuya.css @@ -0,0 +1,14 @@ +/* Inline references and the images they wrap */ +a.reference { + display: inline; +} + +a.reference > img { + display: inline; + max-width: unset; +} + +/* Shibuya ships its own copy button; hide the sphinx-copybutton duplicate */ +.copybtn { + display: none; +} diff --git a/yardang/sphinxawesome_theme.css b/yardang/sphinxawesome_theme.css new file mode 100644 index 0000000..0e8b7c5 --- /dev/null +++ b/yardang/sphinxawesome_theme.css @@ -0,0 +1,33 @@ +a.reference { + display: inline; +} + + +a.reference > img { + display: inline; + max-width: unset; +} + + +/* Lighten code block backgrounds in dark mode */ +.dark pre, +.dark .highlight, +.dark .highlight pre, +.dark div[class^="highlight"] { + background-color: #1e2433 !important; +} + +.dark pre code, +.dark .highlight code { + background-color: transparent !important; +} + +/* Lighter background for inline code in dark mode */ +.dark code:not(pre code) { + background-color: rgba(255, 255, 255, 0.08) !important; +} + +/* Hide duplicate copybtn */ +.copybtn { + display: none; +} diff --git a/yardang/tests/test_themes.py b/yardang/tests/test_themes.py new file mode 100644 index 0000000..490f02f --- /dev/null +++ b/yardang/tests/test_themes.py @@ -0,0 +1,114 @@ +import os +from pathlib import Path +from unittest.mock import patch + +import yardang.build as build_module +from yardang.build import BUNDLED_THEMES, _resolve_custom_asset, generate_docs_configuration +from yardang.cli import preview + +ASSETS_DIR = Path(build_module.__file__).parent + +MINIMAL_PYPROJECT = """ +[project] +name = "test-project" +version = "1.0.0" + +[tool.yardang] +title = "Test Project" +root = "README.md" +use-autoapi = false +""" + + +class TestResolveCustomAsset: + """Theme-aware 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_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_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_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_user_content_takes_precedence(self): + content = "body { color: red; }" + assert _resolve_custom_asset(content, "shibuya", "css", assets_dir=ASSETS_DIR) == content + + def test_reads_existing_file(self, tmp_path): + asset = tmp_path / "mine.css" + asset.write_text("h1 { color: blue; }") + 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() + 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): + assert _resolve_custom_asset(None, "furo", "css", assets_dir=tmp_path) is None + + +class TestGenerateDocsThemeAssets: + """Theme assets flow through ``generate_docs_configuration`` into the output.""" + + def _setup_project(self, tmp_path): + (tmp_path / "pyproject.toml").write_text(MINIMAL_PYPROJECT) + (tmp_path / "README.md").write_text("# Test Project\n") + + def test_html_output_dir_places_custom_assets(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(theme="furo", html_output_dir=str(out)): + pass + finally: + os.chdir(original_cwd) + assert (out / "_static" / "styles" / "custom.css").is_file() + assert (out / "_static" / "js" / "custom.js").is_file() + + def test_theme_specific_css_is_written(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(theme="shibuya", html_output_dir=str(out)): + pass + finally: + os.chdir(original_cwd) + written = (out / "_static" / "styles" / "custom.css").read_text() + assert written == (ASSETS_DIR / "shibuya.css").read_text() + + +class TestPreview: + """The ``yardang preview`` command builds the docs once per theme.""" + + def test_builds_each_installed_theme(self): + with patch("yardang.cli.build") as mock_build, patch("yardang.cli.find_spec", return_value=object()): + preview(themes=["furo", "shibuya"], output="out", quiet=True) + calls = mock_build.call_args_list + assert [call.kwargs["theme"] for call in calls] == ["furo", "shibuya"] + assert [call.kwargs["output"] for call in calls] == [str(Path("out") / "furo"), str(Path("out") / "shibuya")] + + def test_uses_bundled_themes_by_default(self): + with patch("yardang.cli.build") as mock_build, patch("yardang.cli.find_spec", return_value=object()): + preview(quiet=True) + assert [call.kwargs["theme"] for call in mock_build.call_args_list] == list(BUNDLED_THEMES) + + def test_skips_uninstalled_theme(self): + def fake_find_spec(name): + return object() if name == "furo" else None + + with patch("yardang.cli.build") as mock_build, patch("yardang.cli.find_spec", side_effect=fake_find_spec): + preview(themes=["furo", "shibuya"], quiet=True) + assert [call.kwargs["theme"] for call in mock_build.call_args_list] == ["furo"]