diff --git a/mkdocs/commands/build.py b/mkdocs/commands/build.py index f111bcdc05..751923c648 100644 --- a/mkdocs/commands/build.py +++ b/mkdocs/commands/build.py @@ -246,7 +246,7 @@ def _build_page( config._current_page = None -def build(config: MkDocsConfig, *, serve_url: str | None = None, dirty: bool = False) -> None: +def build(config: MkDocsConfig, *, serve_url: str | None = None, dirty: bool = False) -> Files: """Perform a full site build.""" logger = logging.getLogger('mkdocs') @@ -322,7 +322,12 @@ def build(config: MkDocsConfig, *, serve_url: str | None = None, dirty: bool = F # with lower precedence get written first so that files with higher precedence can overwrite them. log.debug("Copying static assets.") - files.copy_static_files(dirty=dirty, inclusion=inclusion) + for file in files: + if not file.is_documentation_page() and inclusion(file.inclusion): + if serve_url and file.is_copyless_static_file: + log.debug(f"Skip copying static file: '{file.src_uri}'") + continue + file.copy_file(dirty) for template in config.theme.static_templates: _build_theme_template(template, env, files, config, nav) @@ -351,6 +356,7 @@ def build(config: MkDocsConfig, *, serve_url: str | None = None, dirty: bool = F raise Abort(f'Aborted with {msg} in strict mode!') log.info(f'Documentation built in {time.monotonic() - start:.2f} seconds') + return files except Exception as e: # Run `build_error` plugin events. diff --git a/mkdocs/commands/serve.py b/mkdocs/commands/serve.py index 889d80668e..d9dd956f38 100644 --- a/mkdocs/commands/serve.py +++ b/mkdocs/commands/serve.py @@ -1,15 +1,16 @@ from __future__ import annotations import logging +import os.path import shutil import tempfile -from os.path import isdir, isfile, join -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal from urllib.parse import urlsplit from mkdocs.commands.build import build from mkdocs.config import load_config from mkdocs.livereload import LiveReloadServer, _serve_url +from mkdocs.structure.files import Files if TYPE_CHECKING: from mkdocs.config.defaults import MkDocsConfig @@ -35,8 +36,6 @@ def serve( whenever a file is edited. """ # Create a temporary build directory, and set some options to serve it - # PY2 returns a byte string by default. The Unicode prefix ensures a Unicode - # string is returned. And it makes MkDocs temp dirs easier to identify. site_dir = tempfile.mkdtemp(prefix='mkdocs_') def get_config(): @@ -58,22 +57,42 @@ def get_config(): mount_path = urlsplit(config.site_url or '/').path config.site_url = serve_url = _serve_url(host, port, mount_path) + files: Files = Files(()) + def builder(config: MkDocsConfig | None = None): log.info("Building documentation...") if config is None: config = get_config() config.site_url = serve_url - build(config, serve_url=None if is_clean else serve_url, dirty=is_dirty) + nonlocal files + files = build(config, serve_url=None if is_clean else serve_url, dirty=is_dirty) + + def file_hook(path: str) -> str | None: + f = files.get_file_from_path(path) + if f is not None and f.is_copyless_static_file: + return f.abs_src_path + return None + + def get_file(path: str) -> str | None: + if new_path := file_hook(path): + return os.path.join(site_dir, new_path) + if try_path := os.path.join(site_dir, path): + return try_path + return None server = LiveReloadServer( - builder=builder, host=host, port=port, root=site_dir, mount_path=mount_path + builder=builder, + host=host, + port=port, + root=site_dir, + file_hook=file_hook, + mount_path=mount_path, ) def error_handler(code) -> bytes | None: if code in (404, 500): - error_page = join(site_dir, f'{code}.html') - if isfile(error_page): + if error_page := get_file(f'{code}.html'): with open(error_page, 'rb') as f: return f.read() return None @@ -108,5 +127,5 @@ def error_handler(code) -> bytes | None: server.shutdown() finally: config.plugins.on_shutdown() - if isdir(site_dir): + if os.path.isdir(site_dir): shutil.rmtree(site_dir) diff --git a/mkdocs/livereload/__init__.py b/mkdocs/livereload/__init__.py index 70d44ba259..9e70af1cdb 100644 --- a/mkdocs/livereload/__init__.py +++ b/mkdocs/livereload/__init__.py @@ -101,6 +101,8 @@ def __init__( host: str, port: int, root: str, + *, + file_hook: Callable[[str], str | None] = lambda path: None, mount_path: str = "/", polling_interval: float = 0.5, shutdown_delay: float = 0.25, @@ -112,6 +114,7 @@ def __init__( except Exception: pass self.root = os.path.abspath(root) + self.file_hook = file_hook self.mount_path = _normalize_mount_path(mount_path) self.url = _serve_url(host, port, mount_path) self.build_delay = 0.1 @@ -289,7 +292,6 @@ def condition(): rel_file_path += "index.html" # Prevent directory traversal - normalize the path. rel_file_path = posixpath.normpath("/" + rel_file_path).lstrip("/") - file_path = os.path.join(self.root, rel_file_path) elif path == "/": start_response("302 Found", [("Location", urllib.parse.quote(self.mount_path))]) return [] @@ -298,13 +300,20 @@ def condition(): # Wait until the ongoing rebuild (if any) finishes, so we're not serving a half-built site. with self._epoch_cond: - self._epoch_cond.wait_for(lambda: self._visible_epoch == self._wanted_epoch) + file_path = self.file_hook(rel_file_path) + if file_path is None: + file_path = os.path.join(self.root, rel_file_path) + + self._epoch_cond.wait_for(lambda: self._visible_epoch == self._wanted_epoch) epoch = self._visible_epoch try: file: BinaryIO = open(file_path, "rb") except OSError: - if not path.endswith("/") and os.path.isfile(os.path.join(file_path, "index.html")): + if not path.endswith("/") and ( + self.file_hook(rel_file_path) is not None + or os.path.isfile(os.path.join(file_path, "index.html")) + ): start_response("302 Found", [("Location", urllib.parse.quote(path) + "/")]) return [] return None # Not found diff --git a/mkdocs/structure/files.py b/mkdocs/structure/files.py index 6bc4bfb189..a98f0deb89 100644 --- a/mkdocs/structure/files.py +++ b/mkdocs/structure/files.py @@ -116,7 +116,7 @@ def copy_static_files( *, inclusion: Callable[[InclusionLevel], bool] = InclusionLevel.is_included, ) -> None: - """Copy static files from source to destination.""" + """Soft-deprecated, do not use.""" for file in self: if not file.is_documentation_page() and inclusion(file.inclusion): file.copy_file(dirty) @@ -464,6 +464,10 @@ def content_string(self, value: str): self._content = value self.abs_src_path = None + @utils.weak_property + def is_copyless_static_file(self) -> bool: + return self.abs_src_path is not None and self.dest_uri == self.src_uri + def copy_file(self, dirty: bool = False) -> None: """Copy source file to destination, ensuring parent directories exist.""" if dirty and not self.is_modified():