Skip to content

Commit ae20669

Browse files
authored
html builder: Append CRC32 checksum to asset URIs (#11415)
1 parent 706f5f9 commit ae20669

File tree

8 files changed

+86
-34
lines changed

8 files changed

+86
-34
lines changed

CHANGES

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ Deprecated
2222
Features added
2323
--------------
2424

25+
* #11415: Add a checksum to JavaScript and CSS asset URIs included within
26+
generated HTML, using the CRC32 algorithm.
27+
2528
Bugs fixed
2629
----------
2730

sphinx/builders/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,9 @@ def write(
560560
with progress_message(__('preparing documents')):
561561
self.prepare_writing(docnames)
562562

563+
with progress_message(__('copying assets')):
564+
self.copy_assets()
565+
563566
if self.parallel_ok:
564567
# number of subprocesses is parallel-1 because the main process
565568
# is busy loading doctrees and doing write_doc_serialized()
@@ -620,6 +623,10 @@ def prepare_writing(self, docnames: set[str]) -> None:
620623
"""A place where you can add logic before :meth:`write_doc` is run"""
621624
raise NotImplementedError
622625

626+
def copy_assets(self) -> None:
627+
"""Where assets (images, static files, etc) are copied before writing"""
628+
pass
629+
623630
def write_doc(self, docname: str, doctree: nodes.document) -> None:
624631
"""Where you actually write something to the filesystem."""
625632
raise NotImplementedError

sphinx/builders/html/__init__.py

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import re
99
import sys
1010
import warnings
11+
import zlib
1112
from datetime import datetime
1213
from os import path
1314
from typing import IO, Any, Iterable, Iterator, List, Tuple, Type
@@ -649,6 +650,12 @@ def get_doc_context(self, docname: str, body: str, metatags: str) -> dict[str, A
649650
'page_source_suffix': source_suffix,
650651
}
651652

653+
def copy_assets(self) -> None:
654+
self.finish_tasks.add_task(self.copy_download_files)
655+
self.finish_tasks.add_task(self.copy_static_files)
656+
self.finish_tasks.add_task(self.copy_extra_files)
657+
self.finish_tasks.join()
658+
652659
def write_doc(self, docname: str, doctree: nodes.document) -> None:
653660
destination = StringOutput(encoding='utf-8')
654661
doctree.settings = self.docsettings
@@ -678,9 +685,6 @@ def finish(self) -> None:
678685
self.finish_tasks.add_task(self.gen_pages_from_extensions)
679686
self.finish_tasks.add_task(self.gen_additional_pages)
680687
self.finish_tasks.add_task(self.copy_image_files)
681-
self.finish_tasks.add_task(self.copy_download_files)
682-
self.finish_tasks.add_task(self.copy_static_files)
683-
self.finish_tasks.add_task(self.copy_extra_files)
684688
self.finish_tasks.add_task(self.write_buildinfo)
685689

686690
# dump the search index
@@ -1193,8 +1197,11 @@ def css_tag(css: Stylesheet) -> str:
11931197
value = css.attributes[key]
11941198
if value is not None:
11951199
attrs.append(f'{key}="{html.escape(value, True)}"')
1196-
attrs.append('href="%s"' % pathto(css.filename, resource=True))
1197-
return '<link %s />' % ' '.join(attrs)
1200+
uri = pathto(css.filename, resource=True)
1201+
if checksum := _file_checksum(app.outdir, css.filename):
1202+
uri += f'?v={checksum}'
1203+
attrs.append(f'href="{uri}"')
1204+
return f'<link {" ".join(attrs)} />'
11981205

11991206
context['css_tag'] = css_tag
12001207

@@ -1217,14 +1224,17 @@ def js_tag(js: JavaScript) -> str:
12171224
if key == 'body':
12181225
body = value
12191226
elif key == 'data_url_root':
1220-
attrs.append('data-url_root="%s"' % pathto('', resource=True))
1227+
attrs.append(f'data-url_root="{pathto("", resource=True)}"')
12211228
else:
12221229
attrs.append(f'{key}="{html.escape(value, True)}"')
12231230
if js.filename:
1224-
attrs.append('src="%s"' % pathto(js.filename, resource=True))
1231+
uri = pathto(js.filename, resource=True)
1232+
if checksum := _file_checksum(app.outdir, js.filename):
1233+
uri += f'?v={checksum}'
1234+
attrs.append(f'src="{uri}"')
12251235
else:
12261236
# str value (old styled)
1227-
attrs.append('src="%s"' % pathto(js, resource=True))
1237+
attrs.append(f'src="{pathto(js, resource=True)}"')
12281238

12291239
if attrs:
12301240
return f'<script {" ".join(attrs)}>{body}</script>'
@@ -1234,6 +1244,21 @@ def js_tag(js: JavaScript) -> str:
12341244
context['js_tag'] = js_tag
12351245

12361246

1247+
def _file_checksum(outdir: str, filename: str) -> str:
1248+
# Don't generate checksums for HTTP URIs
1249+
if '://' in filename:
1250+
return ''
1251+
try:
1252+
# Ensure universal newline mode is used to avoid checksum differences
1253+
with open(path.join(outdir, filename), encoding='utf-8') as f:
1254+
content = f.read().encode(encoding='utf-8')
1255+
except FileNotFoundError:
1256+
return ''
1257+
if not content:
1258+
return ''
1259+
return f'{zlib.crc32(content):08x}'
1260+
1261+
12371262
def setup_resource_paths(app: Sphinx, pagename: str, templatename: str,
12381263
context: dict, doctree: Node) -> None:
12391264
"""Set up relative resource paths."""

sphinx/builders/latex/__init__.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,12 @@ def write_stylesheet(self) -> None:
254254
f.write('% Its contents depend on pygments_style configuration variable.\n\n')
255255
f.write(highlighter.get_stylesheet())
256256

257+
def copy_assets(self) -> None:
258+
self.copy_support_files()
259+
260+
if self.config.latex_additional_files:
261+
self.copy_latex_additional_files()
262+
257263
def write(self, *ignored: Any) -> None:
258264
docwriter = LaTeXWriter(self)
259265
with warnings.catch_warnings():
@@ -267,6 +273,7 @@ def write(self, *ignored: Any) -> None:
267273

268274
self.init_document_data()
269275
self.write_stylesheet()
276+
self.copy_assets()
270277

271278
for entry in self.document_data:
272279
docname, targetname, title, author, themename = entry[:5]
@@ -371,10 +378,6 @@ def assemble_doctree(
371378
def finish(self) -> None:
372379
self.copy_image_files()
373380
self.write_message_catalog()
374-
self.copy_support_files()
375-
376-
if self.config.latex_additional_files:
377-
self.copy_latex_additional_files()
378381

379382
@progress_message(__('copying TeX support files'))
380383
def copy_support_files(self) -> None:

sphinx/builders/texinfo.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ def init_document_data(self) -> None:
8585

8686
def write(self, *ignored: Any) -> None:
8787
self.init_document_data()
88+
self.copy_assets()
8889
for entry in self.document_data:
8990
docname, targetname, title, author = entry[:4]
9091
targetname += '.texi'
@@ -168,7 +169,7 @@ def assemble_doctree(
168169
pendingnode.replace_self(newnodes)
169170
return largetree
170171

171-
def finish(self) -> None:
172+
def copy_assets(self) -> None:
172173
self.copy_support_files()
173174

174175
def copy_image_files(self, targetname: str) -> None:

sphinx/ext/graphviz.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import subprocess
99
from os import path
1010
from subprocess import CalledProcessError
11-
from typing import Any
11+
from typing import TYPE_CHECKING, Any
1212

1313
from docutils import nodes
1414
from docutils.nodes import Node
@@ -20,7 +20,6 @@
2020
from sphinx.locale import _, __
2121
from sphinx.util import logging, sha1
2222
from sphinx.util.docutils import SphinxDirective, SphinxTranslator
23-
from sphinx.util.fileutil import copy_asset
2423
from sphinx.util.i18n import search_image_for_language
2524
from sphinx.util.nodes import set_source_info
2625
from sphinx.util.osutil import ensuredir
@@ -31,6 +30,9 @@
3130
from sphinx.writers.texinfo import TexinfoTranslator
3231
from sphinx.writers.text import TextTranslator
3332

33+
if TYPE_CHECKING:
34+
from sphinx.config import Config
35+
3436
logger = logging.getLogger(__name__)
3537

3638

@@ -391,11 +393,9 @@ def man_visit_graphviz(self: ManualPageTranslator, node: graphviz) -> None:
391393
raise nodes.SkipNode
392394

393395

394-
def on_build_finished(app: Sphinx, exc: Exception) -> None:
395-
if exc is None and app.builder.format == 'html':
396-
src = path.join(sphinx.package_dir, 'templates', 'graphviz', 'graphviz.css')
397-
dst = path.join(app.outdir, '_static')
398-
copy_asset(src, dst)
396+
def on_config_inited(_app: Sphinx, config: Config) -> None:
397+
css_path = path.join(sphinx.package_dir, 'templates', 'graphviz', 'graphviz.css')
398+
config.html_static_path.append(css_path)
399399

400400

401401
def setup(app: Sphinx) -> dict[str, Any]:
@@ -412,5 +412,5 @@ def setup(app: Sphinx) -> dict[str, Any]:
412412
app.add_config_value('graphviz_dot_args', [], 'html')
413413
app.add_config_value('graphviz_output_format', 'png', 'html')
414414
app.add_css_file('graphviz.css')
415-
app.connect('build-finished', on_build_finished)
415+
app.connect('config-inited', on_config_inited)
416416
return {'version': sphinx.__display_version__, 'parallel_read_safe': True}

tests/test_build_html.py

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1186,19 +1186,32 @@ def test_assets_order(app):
11861186
content = (app.outdir / 'index.html').read_text(encoding='utf8')
11871187

11881188
# css_files
1189-
expected = ['_static/early.css', '_static/pygments.css', '_static/alabaster.css',
1190-
'https://example.com/custom.css', '_static/normal.css', '_static/late.css',
1191-
'_static/css/style.css', '_static/lazy.css']
1192-
pattern = '.*'.join('href="%s"' % f for f in expected)
1193-
assert re.search(pattern, content, re.S)
1189+
expected = [
1190+
'_static/early.css',
1191+
'_static/pygments.css?v=b3523f8e',
1192+
'_static/alabaster.css?v=039e1c02',
1193+
'https://example.com/custom.css',
1194+
'_static/normal.css',
1195+
'_static/late.css',
1196+
'_static/css/style.css',
1197+
'_static/lazy.css',
1198+
]
1199+
pattern = '.*'.join(f'href="{re.escape(f)}"' for f in expected)
1200+
assert re.search(pattern, content, re.DOTALL), content
11941201

11951202
# js_files
1196-
expected = ['_static/early.js',
1197-
'_static/doctools.js', '_static/sphinx_highlight.js',
1198-
'https://example.com/script.js', '_static/normal.js',
1199-
'_static/late.js', '_static/js/custom.js', '_static/lazy.js']
1200-
pattern = '.*'.join('src="%s"' % f for f in expected)
1201-
assert re.search(pattern, content, re.S)
1203+
expected = [
1204+
'_static/early.js',
1205+
'_static/doctools.js?v=888ff710',
1206+
'_static/sphinx_highlight.js?v=4825356b',
1207+
'https://example.com/script.js',
1208+
'_static/normal.js',
1209+
'_static/late.js',
1210+
'_static/js/custom.js',
1211+
'_static/lazy.js',
1212+
]
1213+
pattern = '.*'.join(f'src="{re.escape(f)}"' for f in expected)
1214+
assert re.search(pattern, content, re.DOTALL), content
12021215

12031216

12041217
@pytest.mark.sphinx('html', testroot='html_assets')

tests/test_theming.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,10 @@ def test_dark_style(app, status, warning):
9999
assert (app.outdir / '_static' / 'pygments_dark.css').exists()
100100

101101
result = (app.outdir / 'index.html').read_text(encoding='utf8')
102-
assert '<link rel="stylesheet" type="text/css" href="_static/pygments.css" />' in result
102+
assert '<link rel="stylesheet" type="text/css" href="_static/pygments.css?v=b76e3c8a" />' in result
103103
assert ('<link id="pygments_dark_css" media="(prefers-color-scheme: dark)" '
104104
'rel="stylesheet" type="text/css" '
105-
'href="_static/pygments_dark.css" />') in result
105+
'href="_static/pygments_dark.css?v=e15ddae3" />') in result
106106

107107

108108
@pytest.mark.sphinx(testroot='theming')

0 commit comments

Comments
 (0)