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
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ repos:
entry: npm run css
require_serial: true
pass_filenames: false
# args: [--style=compressed, --no-source-map, style/index.scss, sphinx_design/compiled/style.min.css]
# args: [--style=compressed, --no-source-map, style/index.scss, sphinx_design/static/sphinx-design.min.css]

- id: tsc
name: tsc (jsdoc)
Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ The extension uses SASS for styling:

1. SASS source files are in `style/`
2. Compiled using `npm run css` (requires Node.js)
3. Output goes to `sphinx_design/compiled/style.min.css`
3. Output goes to `sphinx_design/static/sphinx-design.min.css`
4. CSS is automatically copied to build output during Sphinx builds

## Key Files
Expand Down Expand Up @@ -339,7 +339,7 @@ The extension uses SASS for styling:

1. Edit SASS files in `style/`
2. Run `npm run css` to compile (or `pre-commit run --all css`)
3. Compiled output goes to `sphinx_design/compiled/style.min.css`
3. Compiled output goes to `sphinx_design/static/sphinx-design.min.css`
4. Test with different themes to ensure compatibility

## Reference Documentation
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

## Unreleased

- ♻️ IMPROVE: Static assets (CSS/JS) are now served via Sphinx's standard
`html_static_path` mechanism, rather than being written directly into the
build output; non-HTML builds no longer gain a spurious
`_sphinx_design_static` directory ({pr}`276`, {issue}`200`, {issue}`235`).
A stale `_sphinx_design_static` directory left in an existing HTML build
directory by previous versions is unused and can safely be deleted.
- ⬆️ UPGRADE: Sphinx `>=7.2` is now required ({pr}`276`)
- 🗑️ REMOVE: The private `sphinx_design._compat.findall` helper has been
removed (docutils `Element.findall` is guaranteed by the Sphinx floor);
any code importing it should call `node.findall(...)` directly ({pr}`276`)
- 🐛 FIX: buttons are no longer destroyed by gettext translation:
translated `button-link`/`button-ref` keep their styling and links
(gettext now targets only the button text), thanks to {user}`sneakers-the-rat`
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"version": "0.0.1",
"description": "Scripts for compiling the sphinx-design assets",
"scripts": {
"css": "sass --style=compressed --no-source-map style/index.scss sphinx_design/compiled/style.min.css"
"css": "sass --style=compressed --no-source-map style/index.scss sphinx_design/static/sphinx-design.min.css"
},
"dependencies": {
"sass": "^1.35.2"
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ classifiers = [
]
keywords = ["sphinx", "extension", "material design", "web components"]
requires-python = ">=3.11"
dependencies = ["sphinx>=7,<10"]
dependencies = ["sphinx>=7.2,<10"]

[project.urls]
Homepage = "https://github.com/executablebooks/sphinx-design"
Expand Down
10 changes: 0 additions & 10 deletions sphinx_design/_compat.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,7 @@
"""Helpers for cross compatibility across dependency versions."""

from collections.abc import Callable, Iterable
from importlib import resources

from docutils.nodes import Element


def findall(node: Element) -> Callable[..., Iterable[Element]]:
"""Iterate through"""
# findall replaces traverse in docutils v0.18
# note a difference is that findall is an iterator
return getattr(node, "findall", node.traverse)


def read_text(module: resources.Package, filename: str) -> str:
return resources.files(module).joinpath(filename).read_text()
5 changes: 2 additions & 3 deletions sphinx_design/cards.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
from sphinx.util.docutils import SphinxDirective
from sphinx.util.logging import getLogger

from ._compat import findall
from .shared import (
WARNING_TYPE,
PassthroughTextElement,
Expand Down Expand Up @@ -247,9 +246,9 @@ def _create_component(
@staticmethod
def add_card_child_classes(node):
"""Add classes to specific child nodes."""
for para in findall(node)(nodes.paragraph):
for para in node.findall(nodes.paragraph):
para["classes"] = [*para.get("classes", []), "sd-card-text"]
# for title in findall(node)(nodes.title):
# for title in node.findall(nodes.title):
# title["classes"] = ([] if "classes" not in title else title["classes"]) + [
# "sd-card-title"
# ]
Expand Down
5 changes: 2 additions & 3 deletions sphinx_design/dropdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
margin_option,
)

from ._compat import findall
from .icons import get_octicon, list_octicons


Expand Down Expand Up @@ -153,7 +152,7 @@ class DropdownHtmlTransform(SphinxPostTransform):
def run(self, **kwargs: Any) -> None:
"""Run the transform"""
document: nodes.document = self.document
for node in findall(document)(lambda node: is_component(node, "dropdown")):
for node in document.findall(lambda node: is_component(node, "dropdown")):
# TODO option to not have card css (but requires more formatting)
use_card = True

Expand Down Expand Up @@ -230,7 +229,7 @@ def run(self, **kwargs: Any) -> None:
children=body_children,
)
if use_card:
for para in findall(body_node)(nodes.paragraph):
for para in body_node.findall(nodes.paragraph):
para["classes"] = ([] if "classes" in para else para["classes"]) + [
"sd-card-text"
]
Expand Down
60 changes: 12 additions & 48 deletions sphinx_design/extension.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,12 @@
from contextlib import contextmanager
from functools import partial
import hashlib
from pathlib import Path

from docutils import nodes
from docutils.parsers.rst import directives
from sphinx import version_info as sphinx_version
from sphinx.application import Sphinx
from sphinx.environment import BuildEnvironment
from sphinx.transforms import SphinxTransform

from . import compiled as static_module
from ._compat import findall, read_text
from .article_info import setup_article_info
from .badges_buttons import setup_badges_and_buttons
from .cards import setup_cards
Expand All @@ -27,12 +22,13 @@
)
from .tabs import setup_tabs

STATIC_DIR = Path(__file__).parent / "static"


def setup_extension(app: Sphinx) -> None:
"""Set up the sphinx extension."""
setup_sd_config(app)
app.connect("builder-inited", update_css_js)
app.connect("env-updated", update_css_links)
app.connect("builder-inited", add_static_assets)
# we override container html visitors, to stop the default behaviour
# of adding the `container` class to all nodes.container
app.add_node(
Expand Down Expand Up @@ -77,45 +73,13 @@ def _add_directive(name, directive, **kwargs):
app.add_directive = add_directive # type: ignore[method-assign]


def update_css_js(app: Sphinx):
"""Copy the CSS to the build directory."""
# reset changed identifier
app.env.sphinx_design_css_changed = False # type: ignore[attr-defined]
# setup up new static path in output dir
static_path = (Path(app.outdir) / "_sphinx_design_static").absolute()
static_existed = static_path.exists()
static_path.mkdir(exist_ok=True)
app.config.html_static_path.append(str(static_path))
# Copy JS to the build directory.
js_path = static_path / "design-tabs.js"
app.add_js_file(js_path.name)
if not js_path.exists():
content = read_text(static_module, "sd_tabs.js")
js_path.write_text(content)
# Read the css content and hash it
content = read_text(static_module, "style.min.css")
# Write the css file
if sphinx_version < (7, 1):
hash = hashlib.md5(content.encode("utf8"), usedforsecurity=False).hexdigest()
css_path = static_path / f"sphinx-design.{hash}.min.css"
else:
# since sphinx 7.1 a checksum is added to the css file URL, so there is no need to do it here
# https://github.com/sphinx-doc/sphinx/pull/11415
css_path = static_path / "sphinx-design.min.css"
app.add_css_file(css_path.name)
if css_path.exists():
def add_static_assets(app: Sphinx) -> None:
"""Register the extension's static assets (HTML-format builders only)."""
if app.builder.format != "html":
return
if static_existed:
app.env.sphinx_design_css_changed = True # type: ignore[attr-defined]
for path in static_path.glob("*.css"):
path.unlink()
css_path.write_text(content, encoding="utf8")


def update_css_links(app: Sphinx, env: BuildEnvironment):
"""If CSS has changed, all files must be re-written, to include the correct stylesheets."""
if env.sphinx_design_css_changed: # type: ignore[attr-defined]
return list(env.all_docs.keys())
app.config.html_static_path.append(str(STATIC_DIR))
app.add_css_file("sphinx-design.min.css")
app.add_js_file("design-tabs.js")


def visit_container(self, node: nodes.Node):
Expand Down Expand Up @@ -175,15 +139,15 @@ class AddFirstTitleCss(SphinxTransform):

def apply(self):
hide = False
for docinfo in findall(self.document)(nodes.docinfo):
for name in findall(docinfo)(nodes.field_name):
for docinfo in self.document.findall(nodes.docinfo):
for name in docinfo.findall(nodes.field_name):
if name.astext() == "sd_hide_title":
hide = True
break
break
if not hide:
return
for section in findall(self.document)(nodes.section):
for section in self.document.findall(nodes.section):
if isinstance(section.children[0], nodes.title):
if "classes" in section.children[0]:
section.children[0]["classes"].append("sd-d-none")
Expand Down
File renamed without changes.
3 changes: 1 addition & 2 deletions sphinx_design/tabs.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
from sphinx.transforms.post_transforms import SphinxPostTransform
from sphinx.util.logging import getLogger

from ._compat import findall
from .shared import (
WARNING_TYPE,
SdDirective,
Expand Down Expand Up @@ -246,7 +245,7 @@ def run(self, **kwargs: Any) -> None:
tab_item_id_num = 0

for tab_set_id_num, tab_set in enumerate(
findall(self.document)(lambda node: is_component(node, "tab-set"))
self.document.findall(lambda node: is_component(node, "tab-set"))
):
tab_set_identity = tab_set_id_base + str(tab_set_id_num)
children = []
Expand Down
13 changes: 2 additions & 11 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,10 @@
from docutils import __version_info__ as docutils_version_info
from docutils import nodes
import pytest
from sphinx import version_info
from sphinx.testing.util import SphinxTestApp

from sphinx_design._compat import findall

pytest_plugins = "sphinx.testing.fixtures"

if version_info >= (7, 2):
# see https://github.com/sphinx-doc/sphinx/pull/11526
from pathlib import Path as sphinx_path # noqa: N813
else:
from sphinx.testing.path import path as sphinx_path # type: ignore[no-redef]


class SphinxBuilder:
def __init__(self, app: SphinxTestApp, src_path: Path):
Expand Down Expand Up @@ -54,7 +45,7 @@ def get_doctree(
if post_transforms:
self.app.env.apply_post_transforms(doctree, docname)
# make source path consistent for test comparisons
for node in findall(doctree)(include_self=True):
for node in doctree.findall(include_self=True):
if not (hasattr(node, "get") and node.get("source")):
continue
node["source"] = Path(node["source"]).relative_to(self.src_path).as_posix()
Expand Down Expand Up @@ -86,7 +77,7 @@ def _create_project(
)
src_path.joinpath("conf.py").write_text(content, encoding="utf8")
app = make_app(
srcdir=sphinx_path(os.path.abspath(str(src_path))), # noqa: PTH100
srcdir=Path(os.path.abspath(str(src_path))), # noqa: PTH100
buildername=buildername,
)
return SphinxBuilder(app, src_path)
Expand Down
71 changes: 71 additions & 0 deletions tests/test_assets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""Tests for how the extension registers and emits its static assets (CSS/JS).

These tests are intentionally written with plain reStructuredText and only the
``sphinx_design`` extension enabled, so that they also run in the
``py311-no-myst`` environment.
"""

from pathlib import Path

# Keep the config myst-free so these tests run without myst-parser installed.
SD_CONF = {"extensions": ["sphinx_design"]}

INDEX_RST = """
Test Document
=============

Some content.
"""


def _write_index(src_path: Path) -> None:
src_path.joinpath("index.rst").write_text(INDEX_RST, encoding="utf8")


def test_latex_no_static_dir(sphinx_builder):
"""A latex build must not gain a spurious ``_sphinx_design_static`` directory.

Regression test for #200 (non-HTML builders getting the static dir) and
#235 (``mkdir`` crashing when the outdir does not yet exist).
"""
builder = sphinx_builder(buildername="latex", conf_kwargs=SD_CONF)
_write_index(builder.src_path)
builder.build()
out_path = builder.out_path
# the old hand-rolled copying wrote this directory for *every* builder
assert list(out_path.rglob("_sphinx_design_static")) == []
# and the CSS should never leak into a non-HTML build
assert list(out_path.rglob("sphinx-design.min.css")) == []


def test_html_static_assets(sphinx_builder):
"""An HTML build copies the assets into ``_static`` and links them with a checksum."""
builder = sphinx_builder(buildername="html", conf_kwargs=SD_CONF)
_write_index(builder.src_path)
builder.build()
out_path = builder.out_path
assert out_path.joinpath("_static", "sphinx-design.min.css").exists()
assert out_path.joinpath("_static", "design-tabs.js").exists()
index_html = out_path.joinpath("index.html").read_text(encoding="utf8")
# Sphinx >=7.1 appends a native ``?v=<checksum>`` cache-busting suffix
assert "sphinx-design.min.css?v=" in index_html
assert "design-tabs.js?v=" in index_html


def test_epub_static_assets(sphinx_builder):
"""An epub build still receives the assets (preserving pre-existing behaviour).

``Epub3Builder.format == "html"``, so it keeps getting the assets exactly as
before. A minimal epub project emits unrelated EPUB3 metadata warnings (empty
``epub_copyright`` / ``version``), so we do not assert a warning-free build;
we only check that output was produced and the assets are present.
"""
builder = sphinx_builder(buildername="epub", conf_kwargs=SD_CONF)
_write_index(builder.src_path)
builder.build(assert_pass=False)
out_path = builder.out_path
# the build produced epub output
assert list(out_path.rglob("*.epub"))
# and the assets were copied into the epub tree
assert list(out_path.rglob("sphinx-design.min.css"))
assert list(out_path.rglob("design-tabs.js"))
Loading
Loading