mirror of
https://github.com/mkdocs/mkdocs.git
synced 2026-03-27 09:58:31 +07:00
Implement cache busting: append a suffix to CSS and JS URLs
This commit is contained in:
@@ -497,6 +497,50 @@ Only the plain string variant detects the `.mjs` extension and adds `type="modul
|
||||
|
||||
NOTE: `*.js` and `*.css` files, just like any other type of file, are always copied from `docs_dir` into the site's deployed copy, regardless if they're linked to the pages via the above configs or not.
|
||||
|
||||
### hash_rename_assets
|
||||
|
||||
NEW: **New in version 1.5.**
|
||||
|
||||
Set patterns of files to rename (on the fly, upon copying to the built site) by inserting a hash of the content. This is done for purposes of *cache busting*.
|
||||
|
||||
It is recommended to use this setting whenever possible.
|
||||
|
||||
The patterns follow the [.gitignore pattern format](https://git-scm.com/docs/gitignore#_pattern_format). But in this case these are positive matches, not "ignore" matches.
|
||||
|
||||
For example:
|
||||
|
||||
```yaml
|
||||
extra_javascript:
|
||||
- js/foo.js
|
||||
- vendored/jquery-1.2.3.js
|
||||
hash_rename_assets: |
|
||||
*.css
|
||||
*.js
|
||||
!/vendored/*.js
|
||||
```
|
||||
|
||||
Then the matched file is copied with a modified name and references to it are modified accordingly, e.g.:
|
||||
|
||||
```html
|
||||
<script src="../js/foo.e3b0c442.js">
|
||||
```
|
||||
|
||||
but one doesn't need to remember to update this hash, you can just keep modifying `foo.js` normally and referring to it normally.
|
||||
|
||||
Note how in this example we chose not to hash the file that already has its own version. But we could rename it, too.
|
||||
|
||||
### hash_append_assets
|
||||
|
||||
NEW: **New in version 1.5.**
|
||||
|
||||
Same as [hash_rename_assets](#hash_rename_assets) but the file doesn't get renamed, instead whenever it is referred to, a URL parameter is appended to it.
|
||||
|
||||
E.g. a script might get linked to as:
|
||||
|
||||
```html
|
||||
<script src="../js/foo.js?h=e3b0c442">
|
||||
```
|
||||
|
||||
### extra_templates
|
||||
|
||||
Set a list of templates in your `docs_dir` to be built by MkDocs. To see more
|
||||
|
||||
@@ -42,9 +42,10 @@ def get_context(
|
||||
base_url = utils.get_relative_url('.', page.url)
|
||||
|
||||
extra_javascript = [
|
||||
utils.normalize_url(str(script), page, base_url) for script in config.extra_javascript
|
||||
utils.normalize_url(str(script), page, base_url, files)
|
||||
for script in config.extra_javascript
|
||||
]
|
||||
extra_css = [utils.normalize_url(path, page, base_url) for path in config.extra_css]
|
||||
extra_css = [utils.normalize_url(path, page, base_url, files) for path in config.extra_css]
|
||||
|
||||
return templates.TemplateContext(
|
||||
nav=nav,
|
||||
@@ -56,6 +57,7 @@ def get_context(
|
||||
build_date_utc=utils.get_build_datetime(),
|
||||
config=config,
|
||||
page=page,
|
||||
_files=files,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1164,6 +1164,6 @@ class PathSpec(BaseConfigOption[pathspec.gitignore.GitIgnoreSpec]):
|
||||
if not isinstance(value, str):
|
||||
raise ValidationError(f'Expected a multiline string, but a {type(value)} was given.')
|
||||
try:
|
||||
return pathspec.gitignore.GitIgnoreSpec.from_lines(lines=value.splitlines())
|
||||
return pathspec.gitignore.GitIgnoreSpec.from_lines(value.splitlines())
|
||||
except ValueError as e:
|
||||
raise ValidationError(str(e))
|
||||
|
||||
@@ -96,6 +96,11 @@ class MkDocsConfig(base.Config):
|
||||
"""Specify which css or javascript files from the docs directory should be
|
||||
additionally included in the site."""
|
||||
|
||||
hash_rename_assets = c.Optional(c.PathSpec())
|
||||
hash_append_assets = c.Optional(c.PathSpec())
|
||||
"""Specify which css or javascript files from the docs directory should be
|
||||
renamed to contain a hash suffix, for cache busting."""
|
||||
|
||||
extra_templates = c.Type(list, default=[])
|
||||
"""Similar to the above, but each template (HTML or XML) will be build with
|
||||
Jinja2 and the global context."""
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import fnmatch
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import posixpath
|
||||
@@ -21,6 +22,7 @@ from mkdocs import utils
|
||||
if TYPE_CHECKING:
|
||||
from mkdocs.config.defaults import MkDocsConfig
|
||||
from mkdocs.structure.pages import Page
|
||||
from mkdocs.theme import Theme
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -52,6 +54,15 @@ class InclusionLevel(enum.Enum):
|
||||
return self.value <= self.NOT_IN_NAV.value
|
||||
|
||||
|
||||
class AssetVersioning(enum.Enum):
|
||||
NONE = 'NONE'
|
||||
"""The asset file is copied as is."""
|
||||
HASH_RENAME = 'HASH_RENAME'
|
||||
"""The file (such as 'main.js') gets renamed to e.g. 'main.e3b0c442.js'."""
|
||||
HASH_SUFFIX = 'HASH_SUFFIX'
|
||||
"""The file (such as 'main.js') always gets a hash appended when linking to it, e.g. 'main.js?h=e3b0c442'."""
|
||||
|
||||
|
||||
class Files:
|
||||
"""A collection of [File][mkdocs.structure.files.File] objects."""
|
||||
|
||||
@@ -164,7 +175,15 @@ class Files:
|
||||
for dir in config.theme.dirs:
|
||||
# Find the first theme dir which contains path
|
||||
if os.path.isfile(os.path.join(dir, path)):
|
||||
self.append(File(path, dir, config.site_dir, config.use_directory_urls))
|
||||
self.append(
|
||||
File(
|
||||
path,
|
||||
dir,
|
||||
config.site_dir,
|
||||
config.use_directory_urls,
|
||||
asset_versioning=_asset_versioning(path, config.theme),
|
||||
)
|
||||
)
|
||||
break
|
||||
|
||||
|
||||
@@ -233,15 +252,17 @@ class File:
|
||||
*,
|
||||
dest_uri: str | None = None,
|
||||
inclusion: InclusionLevel = InclusionLevel.UNDEFINED,
|
||||
asset_versioning: AssetVersioning = AssetVersioning.NONE,
|
||||
) -> None:
|
||||
self.page = None
|
||||
self.src_path = path
|
||||
self.name = self._get_stem()
|
||||
self.abs_src_path = os.path.normpath(os.path.join(src_dir, self.src_uri))
|
||||
self.asset_versioning = asset_versioning
|
||||
if dest_uri is None:
|
||||
dest_uri = self._get_dest_path(use_directory_urls)
|
||||
self.dest_uri = dest_uri
|
||||
self.url = self._get_url(use_directory_urls)
|
||||
self.abs_src_path = os.path.normpath(os.path.join(src_dir, self.src_uri))
|
||||
self.abs_dest_path = os.path.normpath(os.path.join(dest_dir, self.dest_uri))
|
||||
self.inclusion = inclusion
|
||||
|
||||
@@ -250,7 +271,7 @@ class File:
|
||||
isinstance(other, self.__class__)
|
||||
and self.src_uri == other.src_uri
|
||||
and self.abs_src_path == other.abs_src_path
|
||||
and self.url == other.url
|
||||
and self.dest_uri == other.dest_uri
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
@@ -276,15 +297,31 @@ class File:
|
||||
else:
|
||||
# foo.md => foo/index.html
|
||||
return posixpath.join(parent, self.name, 'index.html')
|
||||
|
||||
if self.asset_versioning is AssetVersioning.HASH_RENAME:
|
||||
try:
|
||||
suf = _hash_suffix(self.abs_src_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
else:
|
||||
name, ext = posixpath.splitext(self.src_uri)
|
||||
return f'{name}.{suf}{ext}'
|
||||
|
||||
return self.src_uri
|
||||
|
||||
def _get_url(self, use_directory_urls: bool) -> str:
|
||||
"""Return url based in destination path."""
|
||||
"""Return url based on destination path."""
|
||||
url = self.dest_uri
|
||||
dirname, filename = posixpath.split(url)
|
||||
if use_directory_urls and filename == 'index.html':
|
||||
url = (dirname or '.') + '/'
|
||||
return urlquote(url)
|
||||
if use_directory_urls:
|
||||
if url == 'index.html' or url.endswith('/index.html'):
|
||||
url = (posixpath.dirname(url) or '.') + '/'
|
||||
url = urlquote(url)
|
||||
if self.asset_versioning is AssetVersioning.HASH_SUFFIX:
|
||||
try:
|
||||
url += '?h=' + _hash_suffix(self.abs_src_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return url
|
||||
|
||||
def url_relative_to(self, other: File | str) -> str:
|
||||
"""Return url for file relative to other file."""
|
||||
@@ -327,6 +364,30 @@ class File:
|
||||
return self.src_uri.endswith('.css')
|
||||
|
||||
|
||||
def _asset_versioning(
|
||||
src_uri: str,
|
||||
config: MkDocsConfig | Theme,
|
||||
) -> AssetVersioning:
|
||||
hash_rename_assets = getattr(config, 'hash_rename_assets', None)
|
||||
hash_append_assets = getattr(config, 'hash_append_assets', None)
|
||||
if hash_rename_assets and hash_rename_assets.match_file(src_uri):
|
||||
return AssetVersioning.HASH_RENAME
|
||||
elif hash_append_assets and hash_append_assets.match_file(src_uri):
|
||||
return AssetVersioning.HASH_SUFFIX
|
||||
return AssetVersioning.NONE
|
||||
|
||||
|
||||
def _hash_suffix(abs_src_path: str) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with open(abs_src_path, 'rb') as f:
|
||||
while True:
|
||||
data = f.read(65536)
|
||||
if not data:
|
||||
break
|
||||
digest.update(data)
|
||||
return digest.hexdigest()[:8]
|
||||
|
||||
|
||||
_default_exclude = pathspec.gitignore.GitIgnoreSpec.from_lines(['.*', '/templates/'])
|
||||
|
||||
|
||||
@@ -346,22 +407,24 @@ def _set_exclusions(files: Iterable[File], config: MkDocsConfig | Mapping[str, A
|
||||
file.inclusion = InclusionLevel.INCLUDED
|
||||
|
||||
|
||||
def get_files(config: MkDocsConfig | Mapping[str, Any]) -> Files:
|
||||
def get_files(config: MkDocsConfig) -> Files:
|
||||
"""Walk the `docs_dir` and return a Files collection."""
|
||||
files: list[File] = []
|
||||
conflicting_files: list[tuple[File, File]] = []
|
||||
for source_dir, dirnames, filenames in os.walk(config['docs_dir'], followlinks=True):
|
||||
relative_dir = os.path.relpath(source_dir, config['docs_dir'])
|
||||
relative_dir = PurePath(os.path.relpath(source_dir, config['docs_dir'])).as_posix()
|
||||
dirnames.sort()
|
||||
filenames.sort(key=_file_sort_key)
|
||||
|
||||
files_by_dest: dict[str, File] = {}
|
||||
for filename in filenames:
|
||||
src_uri = posixpath.join(relative_dir, filename)
|
||||
file = File(
|
||||
os.path.join(relative_dir, filename),
|
||||
src_uri,
|
||||
config['docs_dir'],
|
||||
config['site_dir'],
|
||||
config['use_directory_urls'],
|
||||
asset_versioning=_asset_versioning(src_uri, config),
|
||||
)
|
||||
# Skip README.md if an index file also exists in dir (part 1)
|
||||
prev_file = files_by_dest.setdefault(file.dest_uri, file)
|
||||
|
||||
@@ -220,7 +220,7 @@ class BuildTests(PathAssertionMixin, unittest.TestCase):
|
||||
|
||||
def test_context_extra_css_js_no_page(self):
|
||||
cfg = load_config(extra_css=['style.css'], extra_javascript=['script.js'])
|
||||
context = build.get_context(mock.Mock(), mock.Mock(), cfg, base_url='..')
|
||||
context = build.get_context(mock.Mock(), Files([]), cfg, base_url='..')
|
||||
self.assertEqual(context['extra_css'], ['../style.css'])
|
||||
self.assertEqual(context['extra_javascript'], ['../script.js'])
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import os
|
||||
from typing import Any, Collection, MutableMapping
|
||||
|
||||
import jinja2
|
||||
import pathspec.gitignore
|
||||
|
||||
from mkdocs import localization, utils
|
||||
from mkdocs.config.base import ValidationError
|
||||
@@ -66,6 +67,15 @@ class Theme(MutableMapping[str, Any]):
|
||||
locale if locale is not None else _vars['locale']
|
||||
)
|
||||
|
||||
if 'hash_rename_assets' in _vars:
|
||||
self.hash_rename_assets = pathspec.gitignore.GitIgnoreSpec.from_lines(
|
||||
_vars.pop('hash_rename_assets').splitlines()
|
||||
)
|
||||
if 'hash_append_assets' in _vars:
|
||||
self.hash_append_assets = pathspec.gitignore.GitIgnoreSpec.from_lines(
|
||||
_vars.pop('hash_append_assets').splitlines()
|
||||
)
|
||||
|
||||
name: str | None
|
||||
|
||||
@property
|
||||
@@ -80,13 +90,16 @@ class Theme(MutableMapping[str, Any]):
|
||||
|
||||
static_templates: set[str]
|
||||
|
||||
hash_rename_assets: pathspec.gitignore.GitIgnoreSpec | None = None
|
||||
hash_append_assets: pathspec.gitignore.GitIgnoreSpec | None = None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "{}(name={!r}, dirs={!r}, static_templates={!r}, {})".format(
|
||||
self.__class__.__name__,
|
||||
self.name,
|
||||
self.dirs,
|
||||
self.static_templates,
|
||||
', '.join(f'{k}={v!r}' for k, v in self.items()),
|
||||
return (
|
||||
f'{self.__class__.__name__}('
|
||||
f'name={self.name!r}, dirs={self.dirs!r}, static_templates={self.static_templates!r}, '
|
||||
f'hash_rename_assets={self.hash_rename_assets!r}, hash_append_assets={self.hash_append_assets!r}, '
|
||||
+ ', '.join(f'{k}={v!r}' for k, v in self.items())
|
||||
+ ')'
|
||||
)
|
||||
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
static_templates:
|
||||
- 404.html
|
||||
|
||||
hash_rename_assets: |
|
||||
*.js
|
||||
*.css
|
||||
!*-*
|
||||
!*worker*
|
||||
|
||||
locale: en
|
||||
|
||||
include_search_page: false
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
static_templates:
|
||||
- 404.html
|
||||
|
||||
hash_rename_assets: |
|
||||
theme*.js
|
||||
theme*.css
|
||||
|
||||
locale: en
|
||||
|
||||
include_search_page: true
|
||||
|
||||
@@ -32,6 +32,7 @@ from yaml_env_tag import construct_env_tag
|
||||
from mkdocs import exceptions
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mkdocs.structure.files import Files
|
||||
from mkdocs.structure.pages import Page
|
||||
|
||||
T = TypeVar('T')
|
||||
@@ -281,11 +282,17 @@ def get_relative_url(url: str, other: str) -> str:
|
||||
return relurl + '/' if url.endswith('/') else relurl
|
||||
|
||||
|
||||
def normalize_url(path: str, page: Page | None = None, base: str = '') -> str:
|
||||
def normalize_url(
|
||||
path: str, page: Page | None = None, base: str = '', files: Files | None = None
|
||||
) -> str:
|
||||
"""Return a URL relative to the given page or using the base."""
|
||||
path, relative_level = _get_norm_url(path)
|
||||
if relative_level == -1:
|
||||
return path
|
||||
if files is not None:
|
||||
file = files.get_file_from_path(path)
|
||||
if file is not None:
|
||||
path = file.url
|
||||
if page is not None:
|
||||
result = get_relative_url(path, page.url)
|
||||
if relative_level > 0:
|
||||
|
||||
@@ -23,7 +23,7 @@ from mkdocs.utils import normalize_url
|
||||
if TYPE_CHECKING:
|
||||
from mkdocs.config.config_options import ExtraScriptValue
|
||||
from mkdocs.config.defaults import MkDocsConfig
|
||||
from mkdocs.structure.files import File
|
||||
from mkdocs.structure.files import File, Files
|
||||
from mkdocs.structure.nav import Navigation
|
||||
from mkdocs.structure.pages import Page
|
||||
|
||||
@@ -38,12 +38,15 @@ class TemplateContext(TypedDict):
|
||||
build_date_utc: datetime.datetime
|
||||
config: MkDocsConfig
|
||||
page: Page | None
|
||||
_files: Files
|
||||
|
||||
|
||||
@contextfilter
|
||||
def url_filter(context: TemplateContext, value: str) -> str:
|
||||
"""A Template filter to normalize URLs."""
|
||||
return normalize_url(str(value), page=context['page'], base=context['base_url'])
|
||||
return normalize_url(
|
||||
value, page=context['page'], base=context['base_url'], files=context.get('_files')
|
||||
)
|
||||
|
||||
|
||||
@contextfilter
|
||||
|
||||
Reference in New Issue
Block a user