Skip to content

Commit

Permalink
Implement cache busting: append a suffix to CSS and JS URLs
Browse files Browse the repository at this point in the history
  • Loading branch information
oprypin committed Nov 27, 2022
1 parent 32424d6 commit d460eaf
Show file tree
Hide file tree
Showing 6 changed files with 138 additions and 37 deletions.
23 changes: 10 additions & 13 deletions mkdocs/commands/build.py
Expand Up @@ -4,7 +4,7 @@
import logging
import os
import time
from typing import Any, Dict, Optional, Sequence, Set, Union
from typing import Any, Dict, Optional, Set
from urllib.parse import urlsplit

import jinja2
Expand All @@ -14,7 +14,7 @@
from mkdocs import utils
from mkdocs.config.defaults import MkDocsConfig
from mkdocs.exceptions import Abort, BuildError
from mkdocs.structure.files import File, Files, get_files
from mkdocs.structure.files import Files, get_files
from mkdocs.structure.nav import Navigation, get_navigation
from mkdocs.structure.pages import Page

Expand All @@ -37,7 +37,7 @@ def __call__(self, record: logging.LogRecord) -> bool:

def get_context(
nav: Navigation,
files: Union[Sequence[File], Files],
files: Files,
config: MkDocsConfig,
page: Optional[Page] = None,
base_url: str = '',
Expand All @@ -48,16 +48,13 @@ def get_context(
if page is not None:
base_url = utils.get_relative_url('.', page.url)

extra_javascript = utils.create_media_urls(config.extra_javascript, page, base_url)

extra_css = utils.create_media_urls(config.extra_css, page, base_url)

if isinstance(files, Files):
files = files.documentation_pages()
extra_javascript = utils.create_media_urls(config.extra_javascript, page, base_url, files)
extra_css = utils.create_media_urls(config.extra_css, page, base_url, files)

return {
'nav': nav,
'pages': files,
'_files': files,
'pages': files.documentation_pages(),
'base_url': base_url,
'extra_css': extra_css,
'extra_javascript': extra_javascript,
Expand Down Expand Up @@ -196,7 +193,7 @@ def _populate_page(page: Page, config: MkDocsConfig, files: Files, dirty: bool =
def _build_page(
page: Page,
config: MkDocsConfig,
doc_files: Sequence[File],
files: Files,
nav: Navigation,
env: jinja2.Environment,
dirty: bool = False,
Expand All @@ -214,7 +211,7 @@ def _build_page(
# Activate page. Signals to theme that this is the current page.
page.active = True

context = get_context(nav, doc_files, config, page)
context = get_context(nav, files, config, page)

# Allow 'template:' override in md source files.
if 'template' in page.meta:
Expand Down Expand Up @@ -326,7 +323,7 @@ def build(config: MkDocsConfig, live_server: bool = False, dirty: bool = False)
doc_files = files.documentation_pages()
for file in doc_files:
assert file.page is not None
_build_page(file.page, config, doc_files, nav, env, dirty)
_build_page(file.page, config, files, nav, env, dirty)

# Run `post_build` plugin events.
config.plugins.run_event('post_build', config=config)
Expand Down
16 changes: 16 additions & 0 deletions mkdocs/config/config_options.py
@@ -1,5 +1,6 @@
from __future__ import annotations

import enum
import functools
import ipaddress
import os
Expand All @@ -23,6 +24,7 @@
Tuple,
TypeVar,
Union,
cast,
overload,
)
from urllib.parse import quote as urlquote
Expand All @@ -42,6 +44,7 @@

T = TypeVar('T')
SomeConfig = TypeVar('SomeConfig', bound=Config)
SomeEnum = TypeVar('SomeEnum', bound=enum.Enum)


class SubConfig(Generic[SomeConfig], BaseConfigOption[SomeConfig]):
Expand Down Expand Up @@ -284,6 +287,19 @@ def run_validation(self, value: object) -> T:
return value # type: ignore


class EnumChoice(Generic[SomeEnum], Choice[SomeEnum]):
def __init__(self, enum_type: t.Type[SomeEnum], default: t.Optional[SomeEnum] = None) -> None:
self.enum_type = enum_type
super().__init__(
[it.value for it in enum_type],
default=default.value if default is not None else None,
)

def run_validation(self, value: object) -> SomeEnum:
string_value = Choice.run_validation(cast(Choice, self), value)
return self.enum_type[string_value]


class Deprecated(BaseConfigOption):
"""
Deprecated Config Option
Expand Down
4 changes: 4 additions & 0 deletions mkdocs/config/defaults.py
Expand Up @@ -2,6 +2,7 @@

from mkdocs.config import base
from mkdocs.config import config_options as c
from mkdocs.structure.files import AssetVersioning


def get_schema() -> base.PlainConfigSchema:
Expand Down Expand Up @@ -88,6 +89,9 @@ class MkDocsConfig(base.Config):
"""Specify which css or javascript files from the docs directory should be
additionally included in the site."""

asset_versioning = c.EnumChoice(AssetVersioning, default=AssetVersioning.hash_rename)
asset_patterns = c.ListOfItems(c.Type(str), default=['*.css', '*.js'])

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."""
Expand Down
110 changes: 91 additions & 19 deletions mkdocs/structure/files.py
@@ -1,23 +1,15 @@
from __future__ import annotations

import enum
import fnmatch
import functools
import hashlib
import logging
import os
import posixpath
import shutil
from pathlib import PurePath
from typing import (
TYPE_CHECKING,
Any,
Dict,
Iterable,
Iterator,
List,
Mapping,
Optional,
Sequence,
Union,
)
from typing import TYPE_CHECKING, Dict, Iterable, Iterator, List, Optional, Sequence
from urllib.parse import quote as urlquote

import jinja2.environment
Expand All @@ -28,11 +20,19 @@
if TYPE_CHECKING:
from mkdocs.config.defaults import MkDocsConfig
from mkdocs.structure.pages import Page
else:
from mkdocs.config.base import Config as MkDocsConfig


log = logging.getLogger(__name__)


class AssetVersioning(enum.Enum):
none = 'none'
hash_rename = 'hash_rename'
hash_suffix = 'hash_suffix'


class Files:
"""A collection of [File][mkdocs.structure.files.File] objects."""

Expand Down Expand Up @@ -130,7 +130,14 @@ def filter(name):
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.from_config(
config,
path,
src_dir=dir,
asset_versioning=AssetVersioning.hash_rename,
)
)
break


Expand Down Expand Up @@ -199,12 +206,51 @@ def dest_path(self, value):

page: Optional[Page]

def __init__(self, path: str, src_dir: str, dest_dir: str, use_directory_urls: bool) -> None:
def __init__(
self,
path: str,
src_dir: str,
dest_dir: str,
use_directory_urls: bool,
asset_versioning: AssetVersioning = AssetVersioning.none,
):
self.page = None
self.src_path = path
self.src_dir = src_dir
self.dest_dir = dest_dir
self.use_directory_urls = use_directory_urls
self.asset_versioning = asset_versioning

@classmethod
def from_config(
cls,
config: MkDocsConfig,
path: str,
*,
src_dir: Optional[str] = None,
dest_dir: Optional[str] = None,
use_directory_urls: Optional[bool] = None,
asset_versioning: Optional[AssetVersioning] = None,
) -> File:
if src_dir is None:
src_dir = config.docs_dir
if dest_dir is None:
dest_dir = config.site_dir
if use_directory_urls is None:
use_directory_urls = config.use_directory_urls
if asset_versioning is None:
asset_versioning = (
config.asset_versioning
if any(fnmatch.fnmatch(path, pat) for pat in config.asset_patterns)
else AssetVersioning.none
)
return cls(
path=path,
src_dir=src_dir,
dest_dir=dest_dir,
use_directory_urls=use_directory_urls,
asset_versioning=asset_versioning,
)

def __eq__(self, other) -> bool:
return (
Expand Down Expand Up @@ -237,6 +283,16 @@ def _get_dest_path(self, use_directory_urls: bool) -> str:
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:
Expand All @@ -245,7 +301,13 @@ def _get_url(self, use_directory_urls: bool) -> str:
dirname, filename = posixpath.split(url)
if use_directory_urls and filename == 'index.html':
url = (dirname or '.') + '/'
return urlquote(url)
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:
"""Return url for file relative to other file."""
Expand Down Expand Up @@ -288,7 +350,19 @@ def is_css(self) -> bool:
return self.src_uri.endswith('.css')


def get_files(config: Union[MkDocsConfig, Mapping[str, Any]]) -> Files:
@functools.lru_cache(maxsize=None)
def _hash_suffix(abs_src_path):
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]


def get_files(config: MkDocsConfig) -> Files:
"""Walk the `docs_dir` and return a Files collection."""
files = []
exclude = ['.*', '/templates']
Expand All @@ -314,9 +388,7 @@ def get_files(config: Union[MkDocsConfig, Mapping[str, Any]]) -> Files:
f"Both index.md and README.md found. Skipping README.md from {source_dir}"
)
continue
files.append(
File(path, config['docs_dir'], config['site_dir'], config['use_directory_urls'])
)
files.append(File.from_config(config, path))

return Files(files)

Expand Down
18 changes: 14 additions & 4 deletions mkdocs/utils/__init__.py
Expand Up @@ -45,6 +45,7 @@
from mkdocs import exceptions

if TYPE_CHECKING:
from mkdocs.structure.files import Files
from mkdocs.structure.pages import Page

T = TypeVar('T')
Expand Down Expand Up @@ -292,11 +293,17 @@ def get_relative_url(url: str, other: str) -> str:
return relurl + '/' if url.endswith('/') else relurl


def normalize_url(path: str, page: Optional[Page] = None, base: str = '') -> str:
def normalize_url(
path: str, page: Optional[Page] = None, base: str = '', files: Optional[Files] = None
) -> str:
"""Return a URL relative to the given page or using the base."""
path, is_abs = _get_norm_url(path)
if is_abs:
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:
return get_relative_url(path, page.url)
return posixpath.join(base, path)
Expand All @@ -320,12 +327,15 @@ def _get_norm_url(path: str) -> Tuple[str, bool]:


def create_media_urls(
path_list: List[str], page: Optional[Page] = None, base: str = ''
) -> List[str]:
path_list: List[str],
page: Optional[Page] = None,
base: str = '',
files: Optional[Files] = None,
):
"""
Return a list of URLs relative to the given page or using the base.
"""
return [normalize_url(path, page, base) for path in path_list]
return [normalize_url(path, page, base, files) for path in path_list]


def path_to_url(path):
Expand Down
4 changes: 3 additions & 1 deletion mkdocs/utils/filters.py
Expand Up @@ -11,4 +11,6 @@
@contextfilter
def url_filter(context, value: str) -> str:
"""A Template filter to normalize URLs."""
return normalize_url(value, page=context['page'], base=context['base_url'])
return normalize_url(
value, page=context['page'], base=context['base_url'], files=context['_files']
)

0 comments on commit d460eaf

Please sign in to comment.