From 3352b201ccf6a92ebcddcdf6b90ed572b09d7ebe Mon Sep 17 00:00:00 2001 From: Anmol Date: Sun, 30 Aug 2026 21:02:45 +0530 Subject: [PATCH] Add ``singlehtml_embed_assets`` to embed assets into the single HTML file When enabled, the singlehtml builder rewrites the generated page after the build so that local stylesheets, scripts, images, and icons are embedded directly into the HTML file, producing a self-contained single file that can be shared without its accompanying asset files. Local stylesheets become inline ' + if 'icon' in rel or 'apple-touch-icon' in rel: + return _replace_attribute_value(tag, 'href', _data_uri(path)) + return tag + + page = _LINK_TAG_RE.sub(replace_link, page) + + def replace_script(match: re.Match[str]) -> str: + tag = match[0] + attrs = _tag_attributes(tag) + src = attrs.get('src', '') + path = _local_asset_path(src, page_dir, outdir) + if path is None: + return tag + try: + script = path.read_text(encoding='utf-8') + except (OSError, UnicodeDecodeError): + return tag + # prevent the script content from terminating the element early; + # within JavaScript string literals this is an escaped forward slash + script = _SCRIPT_CLOSE_RE.sub(r'<\\/\1', script) + remaining = _ATTR_RE.sub( + lambda m: '' if m['name'].lower() == 'src' else m[0], + match['attrs'], + ).strip() + remaining_attrs = f' {remaining}' if remaining else '' + return f'{script}' + + page = _SCRIPT_TAG_RE.sub(replace_script, page) + + def replace_img(match: re.Match[str]) -> str: + tag = match[0] + src = _tag_attributes(tag).get('src', '') + path = _local_asset_path(src, page_dir, outdir) + if path is None: + return tag + return _replace_attribute_value(tag, 'src', _data_uri(path)) + + page = _IMG_TAG_RE.sub(replace_img, page) + + page_path.write_text(page, encoding='utf-8') + + +def _embed_assets(app: Sphinx, exc: Exception | None) -> None: + """Embed local assets into the page written by the singlehtml builder.""" + builder = app.builder + if exc is not None or not isinstance(builder, SingleFileHTMLBuilder): + return + if not app.config.singlehtml_embed_assets: + return + with progress_message(__('embedding assets')): + page_path = builder.get_output_path(builder.config.root_doc) + _embed_assets_in_page(page_path, Path(builder.outdir)) + + def setup(app: Sphinx) -> ExtensionMetadata: app.setup_extension('sphinx.builders.html') @@ -215,6 +400,15 @@ def setup(app: Sphinx) -> ExtensionMetadata: 'html', types=frozenset({dict}), ) + app.add_config_value( + 'singlehtml_embed_assets', + False, + 'html', + types=frozenset({bool}), + ) + # run late (priority 999) so that assets copied by other extensions' + # ``build-finished`` handlers are embedded as well + app.connect('build-finished', _embed_assets, priority=999) return { 'version': 'builtin', diff --git a/tests/roots/test-singlehtml-embed/_static/background.png b/tests/roots/test-singlehtml-embed/_static/background.png new file mode 100644 index 0000000000000000000000000000000000000000..613754cfaf74a7a2d86984231479d5671731f18a GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9xZY8HA{D|jgU}|SSG{)78&qol`;+0KfSU_W%F@ literal 0 HcmV?d00001 diff --git a/tests/roots/test-singlehtml-embed/_static/imported.css b/tests/roots/test-singlehtml-embed/_static/imported.css new file mode 100644 index 00000000000..e13e3f97acd --- /dev/null +++ b/tests/roots/test-singlehtml-embed/_static/imported.css @@ -0,0 +1,3 @@ +p.user-imported { + color: #123456; +} diff --git a/tests/roots/test-singlehtml-embed/_static/user.css b/tests/roots/test-singlehtml-embed/_static/user.css new file mode 100644 index 00000000000..d07d2d15085 --- /dev/null +++ b/tests/roots/test-singlehtml-embed/_static/user.css @@ -0,0 +1,5 @@ +@import "imported.css"; + +div.user-background { + background-image: url(background.png); +} diff --git a/tests/roots/test-singlehtml-embed/_static/user.js b/tests/roots/test-singlehtml-embed/_static/user.js new file mode 100644 index 00000000000..61a7e766188 --- /dev/null +++ b/tests/roots/test-singlehtml-embed/_static/user.js @@ -0,0 +1 @@ +const userEmbeddedScript = ''; diff --git a/tests/roots/test-singlehtml-embed/conf.py b/tests/roots/test-singlehtml-embed/conf.py new file mode 100644 index 00000000000..8042e9f4a17 --- /dev/null +++ b/tests/roots/test-singlehtml-embed/conf.py @@ -0,0 +1,12 @@ +html_static_path = ['_static'] +html_css_files = [ + 'user.css', + ( + 'https://example.com/external.css', + {'media': 'print', 'priority': 400}, + ), +] +html_js_files = [ + 'user.js', + 'https://example.com/external.js', +] diff --git a/tests/roots/test-singlehtml-embed/img.png b/tests/roots/test-singlehtml-embed/img.png new file mode 100644 index 0000000000000000000000000000000000000000..613754cfaf74a7a2d86984231479d5671731f18a GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9xZY8HA{D|jgU}|SSG{)78&qol`;+0KfSU_W%F@ literal 0 HcmV?d00001 diff --git a/tests/roots/test-singlehtml-embed/index.rst b/tests/roots/test-singlehtml-embed/index.rst new file mode 100644 index 00000000000..4d57f3b0d71 --- /dev/null +++ b/tests/roots/test-singlehtml-embed/index.rst @@ -0,0 +1,4 @@ +test-singlehtml-embed +===================== + +.. image:: img.png diff --git a/tests/test_builders/test_build_singlehtml.py b/tests/test_builders/test_build_singlehtml.py new file mode 100644 index 00000000000..ffafd98f1b4 --- /dev/null +++ b/tests/test_builders/test_build_singlehtml.py @@ -0,0 +1,57 @@ +"""Test the SingleFileHTMLBuilder.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from sphinx.testing.util import SphinxTestApp + + +@pytest.mark.sphinx('singlehtml', testroot='singlehtml-embed') +def test_singlehtml_embed_assets_disabled_by_default(app: SphinxTestApp) -> None: + app.build(force_all=True) + content = (app.outdir / 'index.html').read_text(encoding='utf8') + + # assets are referenced, not embedded + assert 'href="_static/user.css' in content + assert 'src="_static/user.js' in content + assert 'src="_images/img.png"' in content + assert 'data:image/png;base64,' not in content + + +@pytest.mark.sphinx( + 'singlehtml', + testroot='singlehtml-embed', + confoverrides={'singlehtml_embed_assets': True}, +) +def test_singlehtml_embed_assets(app: SphinxTestApp) -> None: + app.build(force_all=True) + content = (app.outdir / 'index.html').read_text(encoding='utf8') + + # no references to local stylesheets or scripts remain + assert 'href="_static/' not in content + assert 'src="_static/' not in content + assert 'src="_images/' not in content + + # local stylesheets are inlined, including @import-ed stylesheets + # and url() references within them + assert 'p.user-imported' in content + assert 'background-image: url(data:image/png;base64,' in content + + # local scripts are inlined, with '' escaped so that the + # script content does not terminate the element early + assert "const userEmbeddedScript = '<\\/script>';" in content + + # images in the document body are embedded as data URIs + assert 'src="data:image/png;base64,' in content + + # external assets are left untouched + assert 'href="https://example.com/external.css"' in content + assert 'src="https://example.com/external.js"' in content + + # asset files are still written to the output directory + assert (app.outdir / '_static' / 'user.css').is_file() + assert (app.outdir / '_images' / 'img.png').is_file()