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'