html builder: Append CRC32 checksum to asset URIs (#11415)

This commit is contained in:
Adam Turner 2023-05-11 05:19:31 +01:00 committed by GitHub
parent 706f5f9cc8
commit ae206694e6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 86 additions and 34 deletions

View File

@ -22,6 +22,9 @@ Deprecated
Features added Features added
-------------- --------------
* #11415: Add a checksum to JavaScript and CSS asset URIs included within
generated HTML, using the CRC32 algorithm.
Bugs fixed Bugs fixed
---------- ----------

View File

@ -560,6 +560,9 @@ class Builder:
with progress_message(__('preparing documents')): with progress_message(__('preparing documents')):
self.prepare_writing(docnames) self.prepare_writing(docnames)
with progress_message(__('copying assets')):
self.copy_assets()
if self.parallel_ok: if self.parallel_ok:
# number of subprocesses is parallel-1 because the main process # number of subprocesses is parallel-1 because the main process
# is busy loading doctrees and doing write_doc_serialized() # is busy loading doctrees and doing write_doc_serialized()
@ -620,6 +623,10 @@ class Builder:
"""A place where you can add logic before :meth:`write_doc` is run""" """A place where you can add logic before :meth:`write_doc` is run"""
raise NotImplementedError raise NotImplementedError
def copy_assets(self) -> None:
"""Where assets (images, static files, etc) are copied before writing"""
pass
def write_doc(self, docname: str, doctree: nodes.document) -> None: def write_doc(self, docname: str, doctree: nodes.document) -> None:
"""Where you actually write something to the filesystem.""" """Where you actually write something to the filesystem."""
raise NotImplementedError raise NotImplementedError

View File

@ -8,6 +8,7 @@ import posixpath
import re import re
import sys import sys
import warnings import warnings
import zlib
from datetime import datetime from datetime import datetime
from os import path from os import path
from typing import IO, Any, Iterable, Iterator, List, Tuple, Type from typing import IO, Any, Iterable, Iterator, List, Tuple, Type
@ -649,6 +650,12 @@ class StandaloneHTMLBuilder(Builder):
'page_source_suffix': source_suffix, 'page_source_suffix': source_suffix,
} }
def copy_assets(self) -> None:
self.finish_tasks.add_task(self.copy_download_files)
self.finish_tasks.add_task(self.copy_static_files)
self.finish_tasks.add_task(self.copy_extra_files)
self.finish_tasks.join()
def write_doc(self, docname: str, doctree: nodes.document) -> None: def write_doc(self, docname: str, doctree: nodes.document) -> None:
destination = StringOutput(encoding='utf-8') destination = StringOutput(encoding='utf-8')
doctree.settings = self.docsettings doctree.settings = self.docsettings
@ -678,9 +685,6 @@ class StandaloneHTMLBuilder(Builder):
self.finish_tasks.add_task(self.gen_pages_from_extensions) self.finish_tasks.add_task(self.gen_pages_from_extensions)
self.finish_tasks.add_task(self.gen_additional_pages) self.finish_tasks.add_task(self.gen_additional_pages)
self.finish_tasks.add_task(self.copy_image_files) self.finish_tasks.add_task(self.copy_image_files)
self.finish_tasks.add_task(self.copy_download_files)
self.finish_tasks.add_task(self.copy_static_files)
self.finish_tasks.add_task(self.copy_extra_files)
self.finish_tasks.add_task(self.write_buildinfo) self.finish_tasks.add_task(self.write_buildinfo)
# dump the search index # dump the search index
@ -1193,8 +1197,11 @@ def setup_css_tag_helper(app: Sphinx, pagename: str, templatename: str,
value = css.attributes[key] value = css.attributes[key]
if value is not None: if value is not None:
attrs.append(f'{key}="{html.escape(value, True)}"') attrs.append(f'{key}="{html.escape(value, True)}"')
attrs.append('href="%s"' % pathto(css.filename, resource=True)) uri = pathto(css.filename, resource=True)
return '<link %s />' % ' '.join(attrs) if checksum := _file_checksum(app.outdir, css.filename):
uri += f'?v={checksum}'
attrs.append(f'href="{uri}"')
return f'<link {" ".join(attrs)} />'
context['css_tag'] = css_tag context['css_tag'] = css_tag
@ -1217,14 +1224,17 @@ def setup_js_tag_helper(app: Sphinx, pagename: str, templatename: str,
if key == 'body': if key == 'body':
body = value body = value
elif key == 'data_url_root': elif key == 'data_url_root':
attrs.append('data-url_root="%s"' % pathto('', resource=True)) attrs.append(f'data-url_root="{pathto("", resource=True)}"')
else: else:
attrs.append(f'{key}="{html.escape(value, True)}"') attrs.append(f'{key}="{html.escape(value, True)}"')
if js.filename: if js.filename:
attrs.append('src="%s"' % pathto(js.filename, resource=True)) uri = pathto(js.filename, resource=True)
if checksum := _file_checksum(app.outdir, js.filename):
uri += f'?v={checksum}'
attrs.append(f'src="{uri}"')
else: else:
# str value (old styled) # str value (old styled)
attrs.append('src="%s"' % pathto(js, resource=True)) attrs.append(f'src="{pathto(js, resource=True)}"')
if attrs: if attrs:
return f'<script {" ".join(attrs)}>{body}</script>' return f'<script {" ".join(attrs)}>{body}</script>'
@ -1234,6 +1244,21 @@ def setup_js_tag_helper(app: Sphinx, pagename: str, templatename: str,
context['js_tag'] = js_tag context['js_tag'] = js_tag
def _file_checksum(outdir: str, filename: str) -> str:
# Don't generate checksums for HTTP URIs
if '://' in filename:
return ''
try:
# Ensure universal newline mode is used to avoid checksum differences
with open(path.join(outdir, filename), encoding='utf-8') as f:
content = f.read().encode(encoding='utf-8')
except FileNotFoundError:
return ''
if not content:
return ''
return f'{zlib.crc32(content):08x}'
def setup_resource_paths(app: Sphinx, pagename: str, templatename: str, def setup_resource_paths(app: Sphinx, pagename: str, templatename: str,
context: dict, doctree: Node) -> None: context: dict, doctree: Node) -> None:
"""Set up relative resource paths.""" """Set up relative resource paths."""

View File

@ -254,6 +254,12 @@ class LaTeXBuilder(Builder):
f.write('% Its contents depend on pygments_style configuration variable.\n\n') f.write('% Its contents depend on pygments_style configuration variable.\n\n')
f.write(highlighter.get_stylesheet()) f.write(highlighter.get_stylesheet())
def copy_assets(self) -> None:
self.copy_support_files()
if self.config.latex_additional_files:
self.copy_latex_additional_files()
def write(self, *ignored: Any) -> None: def write(self, *ignored: Any) -> None:
docwriter = LaTeXWriter(self) docwriter = LaTeXWriter(self)
with warnings.catch_warnings(): with warnings.catch_warnings():
@ -267,6 +273,7 @@ class LaTeXBuilder(Builder):
self.init_document_data() self.init_document_data()
self.write_stylesheet() self.write_stylesheet()
self.copy_assets()
for entry in self.document_data: for entry in self.document_data:
docname, targetname, title, author, themename = entry[:5] docname, targetname, title, author, themename = entry[:5]
@ -371,10 +378,6 @@ class LaTeXBuilder(Builder):
def finish(self) -> None: def finish(self) -> None:
self.copy_image_files() self.copy_image_files()
self.write_message_catalog() self.write_message_catalog()
self.copy_support_files()
if self.config.latex_additional_files:
self.copy_latex_additional_files()
@progress_message(__('copying TeX support files')) @progress_message(__('copying TeX support files'))
def copy_support_files(self) -> None: def copy_support_files(self) -> None:

View File

@ -85,6 +85,7 @@ class TexinfoBuilder(Builder):
def write(self, *ignored: Any) -> None: def write(self, *ignored: Any) -> None:
self.init_document_data() self.init_document_data()
self.copy_assets()
for entry in self.document_data: for entry in self.document_data:
docname, targetname, title, author = entry[:4] docname, targetname, title, author = entry[:4]
targetname += '.texi' targetname += '.texi'
@ -168,7 +169,7 @@ class TexinfoBuilder(Builder):
pendingnode.replace_self(newnodes) pendingnode.replace_self(newnodes)
return largetree return largetree
def finish(self) -> None: def copy_assets(self) -> None:
self.copy_support_files() self.copy_support_files()
def copy_image_files(self, targetname: str) -> None: def copy_image_files(self, targetname: str) -> None:

View File

@ -8,7 +8,7 @@ import re
import subprocess import subprocess
from os import path from os import path
from subprocess import CalledProcessError from subprocess import CalledProcessError
from typing import Any from typing import TYPE_CHECKING, Any
from docutils import nodes from docutils import nodes
from docutils.nodes import Node from docutils.nodes import Node
@ -20,7 +20,6 @@ from sphinx.errors import SphinxError
from sphinx.locale import _, __ from sphinx.locale import _, __
from sphinx.util import logging, sha1 from sphinx.util import logging, sha1
from sphinx.util.docutils import SphinxDirective, SphinxTranslator from sphinx.util.docutils import SphinxDirective, SphinxTranslator
from sphinx.util.fileutil import copy_asset
from sphinx.util.i18n import search_image_for_language from sphinx.util.i18n import search_image_for_language
from sphinx.util.nodes import set_source_info from sphinx.util.nodes import set_source_info
from sphinx.util.osutil import ensuredir from sphinx.util.osutil import ensuredir
@ -31,6 +30,9 @@ from sphinx.writers.manpage import ManualPageTranslator
from sphinx.writers.texinfo import TexinfoTranslator from sphinx.writers.texinfo import TexinfoTranslator
from sphinx.writers.text import TextTranslator from sphinx.writers.text import TextTranslator
if TYPE_CHECKING:
from sphinx.config import Config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -391,11 +393,9 @@ def man_visit_graphviz(self: ManualPageTranslator, node: graphviz) -> None:
raise nodes.SkipNode raise nodes.SkipNode
def on_build_finished(app: Sphinx, exc: Exception) -> None: def on_config_inited(_app: Sphinx, config: Config) -> None:
if exc is None and app.builder.format == 'html': css_path = path.join(sphinx.package_dir, 'templates', 'graphviz', 'graphviz.css')
src = path.join(sphinx.package_dir, 'templates', 'graphviz', 'graphviz.css') config.html_static_path.append(css_path)
dst = path.join(app.outdir, '_static')
copy_asset(src, dst)
def setup(app: Sphinx) -> dict[str, Any]: def setup(app: Sphinx) -> dict[str, Any]:
@ -412,5 +412,5 @@ def setup(app: Sphinx) -> dict[str, Any]:
app.add_config_value('graphviz_dot_args', [], 'html') app.add_config_value('graphviz_dot_args', [], 'html')
app.add_config_value('graphviz_output_format', 'png', 'html') app.add_config_value('graphviz_output_format', 'png', 'html')
app.add_css_file('graphviz.css') app.add_css_file('graphviz.css')
app.connect('build-finished', on_build_finished) app.connect('config-inited', on_config_inited)
return {'version': sphinx.__display_version__, 'parallel_read_safe': True} return {'version': sphinx.__display_version__, 'parallel_read_safe': True}

View File

@ -1186,19 +1186,32 @@ def test_assets_order(app):
content = (app.outdir / 'index.html').read_text(encoding='utf8') content = (app.outdir / 'index.html').read_text(encoding='utf8')
# css_files # css_files
expected = ['_static/early.css', '_static/pygments.css', '_static/alabaster.css', expected = [
'https://example.com/custom.css', '_static/normal.css', '_static/late.css', '_static/early.css',
'_static/css/style.css', '_static/lazy.css'] '_static/pygments.css?v=b3523f8e',
pattern = '.*'.join('href="%s"' % f for f in expected) '_static/alabaster.css?v=039e1c02',
assert re.search(pattern, content, re.S) 'https://example.com/custom.css',
'_static/normal.css',
'_static/late.css',
'_static/css/style.css',
'_static/lazy.css',
]
pattern = '.*'.join(f'href="{re.escape(f)}"' for f in expected)
assert re.search(pattern, content, re.DOTALL), content
# js_files # js_files
expected = ['_static/early.js', expected = [
'_static/doctools.js', '_static/sphinx_highlight.js', '_static/early.js',
'https://example.com/script.js', '_static/normal.js', '_static/doctools.js?v=888ff710',
'_static/late.js', '_static/js/custom.js', '_static/lazy.js'] '_static/sphinx_highlight.js?v=4825356b',
pattern = '.*'.join('src="%s"' % f for f in expected) 'https://example.com/script.js',
assert re.search(pattern, content, re.S) '_static/normal.js',
'_static/late.js',
'_static/js/custom.js',
'_static/lazy.js',
]
pattern = '.*'.join(f'src="{re.escape(f)}"' for f in expected)
assert re.search(pattern, content, re.DOTALL), content
@pytest.mark.sphinx('html', testroot='html_assets') @pytest.mark.sphinx('html', testroot='html_assets')

View File

@ -99,10 +99,10 @@ def test_dark_style(app, status, warning):
assert (app.outdir / '_static' / 'pygments_dark.css').exists() assert (app.outdir / '_static' / 'pygments_dark.css').exists()
result = (app.outdir / 'index.html').read_text(encoding='utf8') result = (app.outdir / 'index.html').read_text(encoding='utf8')
assert '<link rel="stylesheet" type="text/css" href="_static/pygments.css" />' in result assert '<link rel="stylesheet" type="text/css" href="_static/pygments.css?v=b76e3c8a" />' in result
assert ('<link id="pygments_dark_css" media="(prefers-color-scheme: dark)" ' assert ('<link id="pygments_dark_css" media="(prefers-color-scheme: dark)" '
'rel="stylesheet" type="text/css" ' 'rel="stylesheet" type="text/css" '
'href="_static/pygments_dark.css" />') in result 'href="_static/pygments_dark.css?v=e15ddae3" />') in result
@pytest.mark.sphinx(testroot='theming') @pytest.mark.sphinx(testroot='theming')