diff --git a/CHANGES.rst b/CHANGES.rst index 5ebeeb3559..8257611caa 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -17,6 +17,10 @@ Deprecated Features added -------------- +* Add :meth:`~sphinx.application.Sphinx.add_static_dir` for copying static + assets from extensions to the build output. + Patch by Jared Dillard + Bugs fixed ---------- diff --git a/doc/extdev/appapi.rst b/doc/extdev/appapi.rst index c7b17e796a..1f44769f5a 100644 --- a/doc/extdev/appapi.rst +++ b/doc/extdev/appapi.rst @@ -71,6 +71,8 @@ package. .. automethod:: Sphinx.add_css_file +.. automethod:: Sphinx.add_static_dir + .. automethod:: Sphinx.add_latex_package .. automethod:: Sphinx.add_lexer diff --git a/doc/usage/configuration.rst b/doc/usage/configuration.rst index 70de2ca3c1..6bb45d4184 100644 --- a/doc/usage/configuration.rst +++ b/doc/usage/configuration.rst @@ -1739,6 +1739,11 @@ and also make use of these options. An alternative approach is to use :confval:`html_extra_path`, which allows copying dotfiles under the directories. + .. seealso:: + + :meth:`~sphinx.application.Sphinx.add_static_dir`, for use in extensions + to copy static files to the output directory. + .. versionchanged:: 0.4 The paths in :confval:`html_static_path` can now contain subdirectories. diff --git a/sphinx/application.py b/sphinx/application.py index bac6fd41fd..5349c36ad4 100644 --- a/sphinx/application.py +++ b/sphinx/application.py @@ -10,6 +10,7 @@ import pickle import sys from collections import deque from io import StringIO +from pathlib import Path from typing import TYPE_CHECKING, overload from docutils.parsers.rst import roles @@ -37,7 +38,6 @@ from sphinx.util.tags import Tags if TYPE_CHECKING: import os from collections.abc import Callable, Collection, Iterable, Sequence, Set - from pathlib import Path from typing import IO, Any, Final, Literal from docutils import nodes @@ -1484,6 +1484,9 @@ class Sphinx: A JavaScript file can be added to the specific HTML page when an extension calls this method on :event:`html-page-context` event. + .. seealso:: + :meth:`add_static_dir` for copying static files to the output directory + .. versionadded:: 0.5 .. versionchanged:: 1.8 @@ -1549,6 +1552,9 @@ class Sphinx: A CSS file can be added to the specific HTML page when an extension calls this method on :event:`html-page-context` event. + .. seealso:: + :meth:`add_static_dir` for copying static files to the output directory + .. versionadded:: 1.0 .. versionchanged:: 1.6 @@ -1572,6 +1578,42 @@ class Sphinx: filename, priority=priority, **kwargs ) + def add_static_dir(self, path: str | os.PathLike[str]) -> None: + """Register a static directory to include in HTML output. + + The given directory's contents will be copied to the ``_static`` + directory during an HTML build. Files from extension static directories + are copied after theme static files and before any directories from + the user-configured ``html_static_path`` setting. + + Sphinx has built-in support for ``static/`` directories in themes; + theme developers should only use this method to register further + directories to be copied. + + :param path: The path to a directory containing static files. + This is typically relative to the extension's package + directory. + + Example:: + + from pathlib import Path + + def setup(app): + # All files in this directory are copied to _static/, + # preserving the subdirectory structure + app.add_static_dir(Path(__file__).parent / 'static') + + # Add JavaScript and CSS files to HTML pages, + # the paths are relative to _static/ + app.add_js_file('js/my_extension.js') + app.add_css_file('css/my_extension.css') + + .. versionadded:: 9.1 + """ + path = Path(path) + logger.debug("[app] adding static_dir: '%s'", path) + self.registry.add_static_dir(path) + def add_latex_package( self, packagename: str, options: str | None = None, after_hyperref: bool = False ) -> None: diff --git a/sphinx/builders/html/__init__.py b/sphinx/builders/html/__init__.py index 37becdeb92..153cfa141c 100644 --- a/sphinx/builders/html/__init__.py +++ b/sphinx/builders/html/__init__.py @@ -865,6 +865,15 @@ class StandaloneHTMLBuilder(Builder): force=True, ) + def copy_static_dirs(self) -> None: + """Copy static files registered by extensions.""" + for static_dir in self._registry.static_dirs: + if static_dir.is_dir(): + shutil.copytree(static_dir, self._static_dir, dirs_exist_ok=True) + else: + msg = __("extension static directory '%s' does not exist") + logger.warning(msg, static_dir) + def copy_html_static_files(self, context: dict[str, Any]) -> None: def onerror(filename: str, error: Exception) -> None: logger.warning( @@ -916,6 +925,7 @@ class StandaloneHTMLBuilder(Builder): self.copy_translation_js() self.copy_stemmer_js() self.copy_theme_static_files(context) + self.copy_static_dirs() self.copy_html_static_files(context) self.copy_html_logo() self.copy_html_favicon() diff --git a/sphinx/registry.py b/sphinx/registry.py index df0f7dc32d..da729240b3 100644 --- a/sphinx/registry.py +++ b/sphinx/registry.py @@ -22,6 +22,7 @@ from sphinx.util.logging import prefixed_warnings if TYPE_CHECKING: import os from collections.abc import Callable, Iterator, Mapping, Sequence + from pathlib import Path from typing import Any from docutils import nodes @@ -125,6 +126,9 @@ class SphinxComponentRegistry: #: js_files; list of JS paths or URLs self.js_files: list[tuple[str | None, dict[str, Any]]] = [] + #: static directories registered by extensions + self.static_dirs: list[Path] = [] + #: LaTeX packages; list of package names and its options self.latex_packages: list[tuple[str, str | None]] = [] @@ -466,6 +470,11 @@ class SphinxComponentRegistry: logger.debug('[app] adding js_file: %r, %r', filename, attributes) self.js_files.append((filename, attributes)) + def add_static_dir(self, path: Path) -> None: + """Register a static directory for extensions.""" + logger.debug("[app] adding static_dir: '%s'", path) + self.static_dirs.append(path) + def has_latex_package(self, name: str) -> bool: packages = self.latex_packages + self.latex_packages_after_hyperref return bool([x for x in packages if x[0] == name]) diff --git a/sphinx/util/fileutil.py b/sphinx/util/fileutil.py index 95fd9c59f1..41a075786f 100644 --- a/sphinx/util/fileutil.py +++ b/sphinx/util/fileutil.py @@ -115,6 +115,9 @@ def copy_asset( Use ``copy_asset_file`` instead to copy a single file. + Use ``app.add_static_dir()`` instead to copy static files to + the output directory. + :param source: The path to source file or directory :param destination: The path to destination directory :param excluded: The matcher to determine the given path should be copied or not diff --git a/tests/roots/test-ext_static_dir/conf.py b/tests/roots/test-ext_static_dir/conf.py new file mode 100644 index 0000000000..9fd5e4c6ce --- /dev/null +++ b/tests/roots/test-ext_static_dir/conf.py @@ -0,0 +1,7 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path.cwd().resolve())) + +project = 'Test Extension Static Dir' +extensions = ['staticdirext'] diff --git a/tests/roots/test-ext_static_dir/index.rst b/tests/roots/test-ext_static_dir/index.rst new file mode 100644 index 0000000000..ad33d69c39 --- /dev/null +++ b/tests/roots/test-ext_static_dir/index.rst @@ -0,0 +1,4 @@ +Test +==== + +Content for testing extension static directories. diff --git a/tests/roots/test-ext_static_dir/staticdirext/__init__.py b/tests/roots/test-ext_static_dir/staticdirext/__init__.py new file mode 100644 index 0000000000..88f879e42b --- /dev/null +++ b/tests/roots/test-ext_static_dir/staticdirext/__init__.py @@ -0,0 +1,16 @@ +"""Test extension that registers a static directory.""" + +from pathlib import Path + +HERE = Path(__file__).resolve().parent + + +def setup(app): + app.add_static_dir(HERE / 'static') + app.add_js_file('js/myext.js') + app.add_css_file('css/myext.css') + return { + 'version': '1.0', + 'parallel_read_safe': True, + 'parallel_write_safe': True, + } diff --git a/tests/roots/test-ext_static_dir/staticdirext/static/css/myext.css b/tests/roots/test-ext_static_dir/staticdirext/static/css/myext.css new file mode 100644 index 0000000000..dd2713f3ce --- /dev/null +++ b/tests/roots/test-ext_static_dir/staticdirext/static/css/myext.css @@ -0,0 +1,2 @@ +/* Extension CSS file */ +.myext { color: blue; } diff --git a/tests/roots/test-ext_static_dir/staticdirext/static/js/myext.js b/tests/roots/test-ext_static_dir/staticdirext/static/js/myext.js new file mode 100644 index 0000000000..8216160e2f --- /dev/null +++ b/tests/roots/test-ext_static_dir/staticdirext/static/js/myext.js @@ -0,0 +1,2 @@ +// Extension JavaScript file +console.log('myext loaded'); diff --git a/tests/test_builders/test_build_html_assets.py b/tests/test_builders/test_build_html_assets.py index 9059b42322..c730a15546 100644 --- a/tests/test_builders/test_build_html_assets.py +++ b/tests/test_builders/test_build_html_assets.py @@ -180,3 +180,26 @@ def test_javscript_loading_method(app: SphinxTestApp) -> None: assert '' in content assert '' in content assert '' in content + + +@pytest.mark.sphinx('html', testroot='ext_static_dir') +def test_extension_static_dir(app: SphinxTestApp) -> None: + """Test that extension static directories are copied.""" + app.build(force_all=True) + + # Verify extension static files were copied to _static + assert (app.outdir / '_static' / 'js' / 'myext.js').is_file() + assert (app.outdir / '_static' / 'css' / 'myext.css').is_file() + + # Verify JS and CSS are included in the HTML output + content = (app.outdir / 'index.html').read_text(encoding='utf8') + assert '_static/js/myext.js' in content + assert '_static/css/myext.css' in content + + +@pytest.mark.sphinx('html', testroot='ext_static_dir') +def test_extension_static_dir_registry(app: SphinxTestApp) -> None: + """Test that extension static directories are registered in the registry.""" + # Verify the static dir is registered + assert len(app.registry.static_dirs) == 1 + assert app.registry.static_dirs[0].name == 'static'