diff --git a/.github/workflows/sync.yaml b/.github/workflows/sync.yaml index 6aee0aa..3812b14 100644 --- a/.github/workflows/sync.yaml +++ b/.github/workflows/sync.yaml @@ -6,7 +6,7 @@ on: workflow_dispatch: pull_request: paths: - - src/scverse_doc/theme/scverse/static/_tokens.css + - src/scverse_doc/theme/scverse/static/styles/_tokens.css - src/scverse_doc/theme/scverse/static/scverse-fa.svg - scripts/** diff --git a/.gitignore b/.gitignore index da69f80..12729b1 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ __pycache__/ # Distribution / packaging /dist/ +# Built by hatch_build.py +*.min.css* # Tests and coverage /data/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 45ab2ba..14159ab 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -12,6 +12,8 @@ repos: - id: biome-format exclude: ^\.cruft\.json$ # inconsistent indentation with cruft - file never to be modified manually. groups: [format] + - id: biome-lint + args: [--error-on-warnings] - repo: https://github.com/tox-dev/pyproject-fmt rev: v2.30.3 hooks: @@ -21,6 +23,7 @@ repos: rev: v1.46.6 hooks: - id: djlint-reformat + groups: [format] - id: djlint - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.17.1 diff --git a/biome.jsonc b/biome.jsonc index a130bbe..d7adb9e 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -7,7 +7,7 @@ "**", "!src/scverse_doc/registry.json", "!src/scverse_doc/_accents.json", - "!src/scverse_doc/theme/scverse/static/_tokens.css", + "!src/scverse_doc/theme/scverse/static/styles/_tokens.css", ], }, "formatter": { "useEditorconfig": true }, diff --git a/docs/contributing.md b/docs/contributing.md index 44c3b03..77b6a02 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -341,8 +341,9 @@ sphinx-build -M html . _build -W ### Changing the theme -The theme lives in `src/scverse_doc/theme/scverse`: brand tokens in `static/_tokens.css`, +The theme lives in `src/scverse_doc/theme/scverse`: brand tokens in `static/styles/_tokens.css`, component styling in `static/styles/scverse.css`, and the sidebar, header and footer parts in `components/`. +On package build or docs build, `scverse.css` is bundled into a minified `scverse.min.css` with its imports inlined. After a change, build the docs and check {doc}`kitchen-sink` – it renders every construct the theme styles – in both colour schemes, using the theme switcher in the page header. diff --git a/hatch_build.py b/hatch_build.py new file mode 100644 index 0000000..5b97fcd --- /dev/null +++ b/hatch_build.py @@ -0,0 +1,22 @@ +# SPDX-License-Identifier: MPL-2.0 +"""Build the theme’s ``styles/*.min.css``, see ``scverse_doc.theme._bundle``.""" + +from __future__ import annotations + +import runpy +from pathlib import Path +from typing import Any + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface + + +class CSSBundleHook(BuildHookInterface): # type: ignore[type-arg] + """Bundle the theme stylesheets.""" + + def initialize(self, version: str, build_data: dict[str, Any]) -> None: # noqa: D102 + # By path, as the package isn’t installed yet. + mod = runpy.run_path(str(Path(self.root) / "src/scverse_doc/theme/_bundle.py")) + if (exe := mod["find_lightningcss"]()) is None: + msg = "lightningcss binary not found" + raise RuntimeError(msg) + mod["bundle"](exe) diff --git a/pyproject.toml b/pyproject.toml index fb84c60..f7fe185 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [build-system] build-backend = "hatchling.build" -requires = [ "hatch-fancy-pypi-readme", "hatch-vcs", "hatchling" ] +requires = [ "hatch-fancy-pypi-readme", "hatch-vcs", "hatchling", "lightningcss-cli" ] [project] name = "scverse-doc" @@ -50,16 +50,18 @@ dev = [ "twine>=4.0.2", ] test = [ - "coloraide", # to check the accent lightness bounds + "coloraide", # to check the accent lightness bounds "coverage>=7.10", - "defusedxml", # for sphinx.testing’s HTML assertions + "defusedxml", # for sphinx.testing’s HTML assertions + "lightningcss-cli", # to rebuild the theme’s stylesheet bundles "pytest", - "pytest-cov", # For VS Code’s coverage functionality - "wenmode", # for scripts/check_changelog.py + "pytest-cov", # For VS Code’s coverage functionality + "wenmode", # for scripts/check_changelog.py ] doc = [ "ipykernel", "ipython", + "lightningcss-cli", # to rebuild the theme’s stylesheet bundles "scverse-misc[sphinx]>=0.1.2", "sphinx-autodoc-typehints", "sphinxcontrib-bibtex>=1", @@ -72,6 +74,10 @@ metadata.hooks.fancy-pypi-readme.content-type = "text/markdown" metadata.hooks.fancy-pypi-readme.fragments = [ { path = "README.md", start-after = "\n" }, ] +build.targets.wheel.exclude = [ "/src/scverse_doc/theme/scverse/static/styles/" ] +build.targets.wheel.artifacts = [ "/src/scverse_doc/theme/scverse/static/styles/*.min.css*" ] +# Builds `styles/*.min.css`, which replace the stylesheet sources in wheels, see `hatch_build.py`. +build.targets.wheel.hooks.custom = {} build.targets.sdist.exclude = [ "/docs", "/tests" ] envs.default.installer = "uv" envs.default.dependency-groups = [ "dev" ] @@ -85,7 +91,7 @@ envs.hatch-check-code.scripts.lint-check = "echo 'try `hatch check code --fix`'; envs.hatch-check-code.scripts.lint-fix = "prek run -a --no-group=format --no-group=typecheck" envs.hatch-check-fmt.dependencies = [ "prek" ] envs.hatch-check-fmt.scripts.format-check = "echo 'try `hatch check fmt --fix`'; false" -envs.hatch-check-fmt.scripts.format-fix = "prek run -a --group=format --no-group=typecheck" +envs.hatch-check-fmt.scripts.format-fix = "prek run -a --group=format" envs.hatch-check-types.features = [ "full" ] envs.hatch-check-types.scripts.check = [ "mypy" ] envs.hatch-check-types.dependency-groups = [ "typecheck", "doc", "test" ] diff --git a/scripts/sync_brand_tokens.py b/scripts/sync_brand_tokens.py index b7ab50e..6f80cad 100755 --- a/scripts/sync_brand_tokens.py +++ b/scripts/sync_brand_tokens.py @@ -35,7 +35,7 @@ HERE = Path(__file__).parent STATIC = HERE.parent / "src" / "scverse_doc" / "theme" / "scverse" / "static" -TARGET = STATIC / "_tokens.css" +TARGET = STATIC / "styles" / "_tokens.css" #: Website file -> package file, copied verbatim. ASSETS = { diff --git a/src/scverse_doc/theme/__init__.py b/src/scverse_doc/theme/__init__.py index 76b177d..728998e 100644 --- a/src/scverse_doc/theme/__init__.py +++ b/src/scverse_doc/theme/__init__.py @@ -17,6 +17,7 @@ from sphinx.util.typing import ExtensionMetadata from ..registry import DEFAULT_ACCENT, _build_cache, packages +from ._bundle import bundle, find_lightningcss if TYPE_CHECKING: from sphinx.application import Sphinx @@ -102,6 +103,9 @@ def _configure(app: Sphinx) -> None: return _add_chrome(config) + # In source trees, rebuild the stylesheet bundles so edits show without reinstalling. + if exe := find_lightningcss(): + bundle(exe) css = _ACCENT_CSS.format(accent=_accent(config)) if secondary := _secondary_accent(config): diff --git a/src/scverse_doc/theme/_bundle.py b/src/scverse_doc/theme/_bundle.py new file mode 100644 index 0000000..1a6f7cf --- /dev/null +++ b/src/scverse_doc/theme/_bundle.py @@ -0,0 +1,33 @@ +# SPDX-License-Identifier: MPL-2.0 +"""Bundle the theme stylesheets into minified ``*.min.css``, saving browsers a request per `@import`. + +Stdlib only: ``hatch_build.py`` runs this before the package is installed. +""" + +from __future__ import annotations + +import shutil +import subprocess +import sysconfig +from pathlib import Path + +STYLES = Path(__file__).parent / "scverse" / "static" / "styles" + + +def find_lightningcss() -> str | None: + """Find the binary `lightningcss-cli` installs into the env, which needn’t be on $PATH.""" + return shutil.which("lightningcss", path=sysconfig.get_path("scripts")) + + +def bundle(exe: str) -> None: + """Bundle each ``styles/*.css`` into ``*.min.css`` next to it; ``_*.css`` partials are only imported. + + Each bundle gets a ``*.min.css.map`` source map, which embeds the sources. + Wheels ship no sources, so this is a no-op outside of source trees. + """ + for src in STYLES.glob("[!_]*.css"): + if not src.name.endswith(".min.css"): + # No --targets, so modern syntax like light-dark() passes through untouched. + # Relative paths in STYLES, as the CLI writes them into the source map and its URL as given. + args = ["--bundle", "--minify", "--sourcemap", src.name, "-o", src.with_suffix(".min.css").name] + subprocess.run([exe, *args], cwd=STYLES, check=True) diff --git a/src/scverse_doc/theme/scverse/components/toggle-secondary-sidebar.html b/src/scverse_doc/theme/scverse/components/toggle-secondary-sidebar.html index a9b8480..2bf174b 100644 --- a/src/scverse_doc/theme/scverse/components/toggle-secondary-sidebar.html +++ b/src/scverse_doc/theme/scverse/components/toggle-secondary-sidebar.html @@ -1,7 +1,7 @@ {#- Opens the secondary sidebar drawer on narrow screens; pydata only renders its own in the (empty) navbar. Unconditional: pydata decides whether a page has a secondary sidebar only after it has dropped - the header templates that render empty. scverse.css hides the button on pages without one. + the header templates that render empty. styles/_pydata.css hides the button on pages without one. -#}