Repository: mkdocs/mkdocs
Pull request #3451 (2023-10-30 00:51:25)

Rework Files to support generated files
* Closes #3344
* Closes #3345


Patch:
diff --git a/mkdocs/commands/build.py b/mkdocs/commands/build.py
index 3a09a014..f111bcdc 100644
--- a/mkdocs/commands/build.py
+++ b/mkdocs/commands/build.py
@@ -131,8 +131,7 @@ def _build_extra_template(template_name: str, files: Files, config: MkDocsConfig
         return
 
     try:
-        with open(file.abs_src_path, encoding='utf-8', errors='strict') as f:
-            template = jinja2.Template(f.read())
+        template = jinja2.Template(file.content_string)
     except Exception as e:
         log.warning(f"Error reading template '{template_name}': {e}")
         return
@@ -292,7 +291,7 @@ def build(config: MkDocsConfig, *, serve_url: str | None = None, dirty: bool = F
         # Run `files` plugin events.
         files = config.plugins.on_files(files, config=config)
         # If plugins have added files but haven't set their inclusion level, calculate it again.
-        set_exclusions(files._files, config)
+        set_exclusions(files, config)
 
         nav = get_navigation(files, config)
 
diff --git a/mkdocs/plugins.py b/mkdocs/plugins.py
index aaf3b203..1992cbe6 100644
--- a/mkdocs/plugins.py
+++ b/mkdocs/plugins.py
@@ -323,6 +323,8 @@ class BasePlugin(Generic[SomeConfig]):
 
     def on_page_read_source(self, /, *, page: Page, config: MkDocsConfig) -> str | None:
         """
+        Deprecated: Since MkDocs 1.6, instead set `content_bytes`/`content_string` of a `File`.
+
         The `on_page_read_source` event can replace the default mechanism to read
         the contents of a page's source from the filesystem.
 
@@ -493,6 +495,8 @@ class PluginCollection(dict, MutableMapping[str, BasePlugin]):
     by calling `run_event`.
     """
 
+    _current_plugin: str | None
+
     def __init__(self, *args, **kwargs) -> None:
         super().__init__(*args, **kwargs)
         self.events: dict[str, list[Callable]] = {k: [] for k in EVENTS}
@@ -551,9 +555,9 @@ class PluginCollection(dict, MutableMapping[str, BasePlugin]):
         """
         pass_item = item is not None
         for method in self.events[name]:
+            self._current_plugin = self._event_origins.get(method, '<unknown>')
             if log.getEffectiveLevel() <= logging.DEBUG:
-                plugin_name = self._event_origins.get(method, '<unknown>')
-                log.debug(f"Running `{name}` event from plugin '{plugin_name}'")
+                log.debug(f"Running `{name}` event from plugin '{self._current_plugin}'")
             if pass_item:
                 result = method(item, **kwargs)
             else:
@@ -561,6 +565,7 @@ class PluginCollection(dict, MutableMapping[str, BasePlugin]):
             # keep item if method returned `None`
             if result is not None:
                 item = result
+        self._current_plugin = None
         return item
 
     def on_startup(self, *, command: Literal['build', 'gh-deploy', 'serve'], dirty: bool) -> None:
diff --git a/mkdocs/structure/files.py b/mkdocs/structure/files.py
index 9dfdf588..6bc4bfb1 100644
--- a/mkdocs/structure/files.py
+++ b/mkdocs/structure/files.py
@@ -7,8 +7,9 @@ import os
 import posixpath
 import shutil
 import warnings
-from pathlib import PurePath
-from typing import TYPE_CHECKING, Callable, Iterable, Iterator, Sequence
+from functools import cached_property
+from pathlib import PurePath, PurePosixPath
+from typing import TYPE_CHECKING, Callable, Iterable, Iterator, Mapping, Sequence, overload
 from urllib.parse import quote as urlquote
 
 import pathspec
@@ -61,50 +62,53 @@ class InclusionLevel(enum.Enum):
 class Files:
     """A collection of [File][mkdocs.structure.files.File] objects."""
 
-    def __init__(self, files: list[File]) -> None:
-        self._files = files
-        self._src_uris: dict[str, File] | None = None
+    def __init__(self, files: Iterable[File]) -> None:
+        self._src_uris = {f.src_uri: f for f in files}
 
     def __iter__(self) -> Iterator[File]:
         """Iterate over the files within."""
-        return iter(self._files)
+        return iter(self._src_uris.values())
 
     def __len__(self) -> int:
         """The number of files within."""
-        return len(self._files)
+        return len(self._src_uris)
 
     def __contains__(self, path: str) -> bool:
-        """Whether the file with this `src_uri` is in the collection."""
-        return PurePath(path).as_posix() in self.src_uris
+        """Soft-deprecated, prefer `get_file_from_path(path) is not None`."""
+        return PurePath(path).as_posix() in self._src_uris
 
     @property
     def src_paths(self) -> dict[str, File]:
         """Soft-deprecated, prefer `src_uris`."""
-        return {file.src_path: file for file in self._files}
+        return {file.src_path: file for file in self}
 
     @property
-    def src_uris(self) -> dict[str, File]:
+    def src_uris(self) -> Mapping[str, File]:
         """
         A mapping containing every file, with the keys being their
         [`src_uri`][mkdocs.structure.files.File.src_uri].
         """
-        if self._src_uris is None:
-            self._src_uris = {file.src_uri: file for file in self._files}
         return self._src_uris
 
     def get_file_from_path(self, path: str) -> File | None:
         """Return a File instance with File.src_uri equal to path."""
-        return self.src_uris.get(PurePath(path).as_posix())
+        return self._src_uris.get(PurePath(path).as_posix())
 
     def append(self, file: File) -> None:
-        """Append file to Files collection."""
-        self._src_uris = None
-        self._files.append(file)
+        """Add file to the Files collection."""
+        if file.src_uri in self._src_uris:
+            warnings.warn(
+                "To replace an existing file, call `remove` before `append`.", DeprecationWarning
+            )
+            del self._src_uris[file.src_uri]
+        self._src_uris[file.src_uri] = file
 
     def remove(self, file: File) -> None:
         """Remove file from Files collection."""
-        self._src_uris = None
-        self._files.remove(file)
+        try:
+            del self._src_uris[file.src_uri]
+        except KeyError:
+            raise ValueError(f'{file.src_uri!r} not in collection')
 
     def copy_static_files(
         self,
@@ -156,58 +160,99 @@ class Files:
 
         for path in env.list_templates(filter_func=filter):
             # Theme files do not override docs_dir files
-            path = PurePath(path).as_posix()
-            if path not in self.src_uris:
+            if self.get_file_from_path(path) is None:
                 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))
                         break
 
+    @property
+    def _files(self) -> Iterable[File]:
+        warnings.warn("Do not access Files._files.", DeprecationWarning)
+        return self
+
+    @_files.setter
+    def _files(self, value: Iterable[File]):
+        warnings.warn("Do not access Files._files.", DeprecationWarning)
+        self._src_uris = {f.src_uri: f for f in value}
+
 
 class File:
     """
     A MkDocs File object.
 
-    Points to the source and destination locations of a file.
+    It represents how the contents of one file should be populated in the destination site.
+
+    A file always has its `abs_dest_path` (obtained by joining `dest_dir` and `dest_path`),
+    where the `dest_dir` is understood to be the *site* directory.
+
+    `content_bytes`/`content_string` (new in MkDocs 1.6) can always be used to obtain the file's
+    content. But it may be backed by one of the two sources:
+
+    *   A physical source file at `abs_src_path` (by default obtained by joining `src_dir` and
+        `src_uri`). `src_dir` is understood to be the *docs* directory.
+
+        Then `content_bytes`/`content_string` will read the file at `abs_src_path`.
+
+        `src_dir` *should* be populated for real files and should be `None` for generated files.
 
-    The `path` argument must be a path that exists relative to `src_dir`.
+    *   Since MkDocs 1.6 a file may alternatively be stored in memory - `content_string`/`content_bytes`.
 
-    The `src_dir` and `dest_dir` must be absolute paths on the local file system.
+        Then `src_dir` and `abs_src_path` will remain `None`. `content_bytes`/`content_string` need
+        to be written to, or populated through the `content` argument in the constructor.
 
-    The `use_directory_urls` argument controls how destination paths are generated. If `False`, a Markdown file is
-    mapped to an HTML file of the same name (the file extension is changed to `.html`). If True, a Markdown file is
-    mapped to an HTML index file (`index.html`) nested in a directory using the "name" of the file in `path`. The
-    `use_directory_urls` argument has no effect on non-Markdown files.
+        But `src_uri` is still populated for such files as well! The virtual file pretends as if it
+        originated from that path in the `docs` directory, and other values are derived.
 
-    File objects have the following properties, which are Unicode strings:
+    For static files the file is just copied to the destination, and `dest_uri` equals `src_uri`.
+
+    For Markdown files (determined by the file extension in `src_uri`) the destination content
+    will be the rendered content, and `dest_uri` will have the `.html` extension and some
+    additional transformations to the path, based on `use_directory_urls`.
     """
 
     src_uri: str
     """The pure path (always '/'-separated) of the source file relative to the source directory."""
 
-    abs_src_path: str
-    """The absolute concrete path of the source file. Will use backslashes on Windows."""
+    use_directory_urls: bool
+    """Whether directory URLs ('foo/') should be used or not ('foo.html').
 
-    dest_uri: str
-    """The pure path (always '/'-separated) of the destination file relative to the destination directory."""
+    If `False`, a Markdown file is mapped to an HTML file of the same name (the file extension is
+    changed to `.html`). If True, a Markdown file is mapped to an HTML index file (`index.html`)
+    nested in a directory using the "name" of the file in `path`. Non-Markdown files retain their
+    original path.
+    """
 
-    abs_dest_path: str
-    """The absolute concrete path of the destination file. Will use backslashes on Windows."""
+    src_dir: str | None
+    """The OS path of the top-level directory that the source file originates from.
 
-    url: str
-    """The URI of the destination file relative to the destination directory as a string."""
+    Assumed to be the *docs_dir*; not populated for generated files."""
+
+    dest_dir: str
+    """The OS path of the destination directory (top-level site_dir) that the file should be copied to."""
 
     inclusion: InclusionLevel = InclusionLevel.UNDEFINED
     """Whether the file will be excluded from the built site."""
 
+    generated_by: str | None = None
+    """If not None, indicates that a plugin generated this file on the fly.
+
+    The value is the plugin's entrypoint name and can be used to find the plugin by key in the PluginCollection."""
+
+    _content: str | bytes | None = None
+    """If set, the file's content will be read from here.
+
+    This logic is handled by `content_bytes`/`content_string`, which should be used instead of
+    accessing this attribute."""
+
     @property
     def src_path(self) -> str:
         """Same as `src_uri` (and synchronized with it) but will use backslashes on Windows. Discouraged."""
         return os.path.normpath(self.src_uri)
 
     @src_path.setter
-    def src_path(self, value):
+    def src_path(self, value: str):
         self.src_uri = PurePath(value).as_posix()
 
     @property
@@ -216,48 +261,116 @@ class File:
         return os.path.normpath(self.dest_uri)
 
     @dest_path.setter
-    def dest_path(self, value):
+    def dest_path(self, value: str):
         self.dest_uri = PurePath(value).as_posix()
 
-    page: Page | None
+    page: Page | None = None
+
+    @overload
+    @classmethod
+    def generated(
+        cls,
+        config: MkDocsConfig,
+        src_uri: str,
+        *,
+        content: str | bytes,
+        inclusion: InclusionLevel = InclusionLevel.UNDEFINED,
+    ) -> File:
+        """
+        Create a virtual file backed by in-memory content.
+
+        It will pretend to be a file in the docs dir at `src_uri`.
+        """
+
+    @overload
+    @classmethod
+    def generated(
+        cls,
+        config: MkDocsConfig,
+        src_uri: str,
+        *,
+        abs_src_path: str,
+        inclusion: InclusionLevel = InclusionLevel.UNDEFINED,
+    ) -> File:
+        """
+        Create a virtual file backed by a physical temporary file at `abs_src_path`.
+
+        It will pretend to be a file in the docs dir at `src_uri`.
+        """
+
+    @classmethod
+    def generated(
+        cls,
+        config: MkDocsConfig,
+        src_uri: str,
+        *,
+        content: str | bytes | None = None,
+        abs_src_path: str | None = None,
+        inclusion: InclusionLevel = InclusionLevel.UNDEFINED,
+    ) -> File:
+        """
+        Create a virtual file, backed either by in-memory `content` or by a file at `abs_src_path`.
+
+        It will pretend to be a file in the docs dir at `src_uri`.
+        """
+        if (content is None) == (abs_src_path is None):
+            raise TypeError("File must have exactly one of 'content' or 'abs_src_path'")
+        f = cls(
+            src_uri,
+            src_dir=None,
+            dest_dir=config.site_dir,
+            use_directory_urls=config.use_directory_urls,
+            inclusion=inclusion,
+        )
+        f.generated_by = config.plugins._current_plugin or '<unknown>'
+        f.abs_src_path = abs_src_path
+        f._content = content
+        return f
 
     def __init__(
         self,
         path: str,
-        src_dir: str,
+        src_dir: str | None,
         dest_dir: str,
         use_directory_urls: bool,
         *,
         dest_uri: str | None = None,
         inclusion: InclusionLevel = InclusionLevel.UNDEFINED,
     ) -> None:
-        self.page = None
         self.src_path = path
-        self.name = self._get_stem()
-        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.src_dir = src_dir
+        self.dest_dir = dest_dir
+        self.use_directory_urls = use_directory_urls
+        if dest_uri is not None:
+            self.dest_uri = dest_uri
         self.inclusion = inclusion
 
     def __repr__(self):
         return (
-            f"File(src_uri='{self.src_uri}', dest_uri='{self.dest_uri}',"
-            f" name='{self.name}', url='{self.url}')"
+            f"{type(self).__name__}({self.src_uri!r}, src_dir={self.src_dir!r}, "
+            f"dest_dir={self.dest_dir!r}, use_directory_urls={self.use_directory_urls!r}, "
+            f"dest_uri={self.dest_uri!r}, inclusion={self.inclusion})"
         )
 
+    @utils.weak_property
+    def edit_uri(self) -> str | None:
+        return self.src_uri if self.generated_by is None else None
+
     def _get_stem(self) -> str:
-        """Return the name of the file without its extension."""
+        """Soft-deprecated, do not use."""
         filename = posixpath.basename(self.src_uri)
         stem, ext = posixpath.splitext(filename)
         return 'index' if stem == 'README' else stem
 
-    def _get_dest_path(self, use_directory_urls: bool) -> str:
-        """Return destination path based on source path."""
+    name = cached_property(_get_stem)
+    """Return the name of the file without its extension."""
+
+    def _get_dest_path(self, use_directory_urls: bool | None = None) -> str:
+        """Soft-deprecated, do not use."""
         if self.is_documentation_page():
             parent, filename = posixpath.split(self.src_uri)
+            if use_directory_urls is None:
+                use_directory_urls = self.use_directory_urls
             if not use_directory_urls or self.name == 'index':
                 # index.md or README.md => index.html
                 # foo.md => foo.html
@@ -267,30 +380,116 @@ class File:
                 return posixpath.join(parent, self.name, 'index.html')
         return self.src_uri
 
-    def _get_url(self, use_directory_urls: bool) -> str:
-        """Return url based in destination path."""
+    dest_uri = cached_property(_get_dest_path)
+    """The pure path (always '/'-separated) of the destination file relative to the destination directory."""
+
+    def _get_url(self, use_directory_urls: bool | None = None) -> str:
+        """Soft-deprecated, do not use."""
         url = self.dest_uri
         dirname, filename = posixpath.split(url)
+        if use_directory_urls is None:
+            use_directory_urls = self.use_directory_urls
         if use_directory_urls and filename == 'index.html':
             url = (dirname or '.') + '/'
         return urlquote(url)
 
+    url = cached_property(_get_url)
+    """The URI of the destination file relative to the destination directory as a string."""
+
+    @cached_property
+    def abs_src_path(self) -> str | None:
+        """
+        The absolute concrete path of the source file. Will use backslashes on Windows.
+
+        Note: do not use this path to read the file, prefer `content_bytes`/`content_string`.
+        """
+        if self.src_dir is None:
+            return None
+        return os.path.normpath(os.path.join(self.src_dir, self.src_uri))
+
+    @cached_property
+    def abs_dest_path(self) -> str:
+        """The absolute concrete path of the destination file. Will use backslashes on Windows."""
+        return os.path.normpath(os.path.join(self.dest_dir, self.dest_uri))
+
     def url_relative_to(self, other: File | str) -> str:
         """Return url for file relative to other file."""
         return utils.get_relative_url(self.url, other.url if isinstance(other, File) else other)
 
+    @property
+    def content_bytes(self) -> bytes:
+        """
+        Get the content of this file as a bytestring.
+
+        May raise if backed by a real file (`abs_src_path`) if it cannot be read.
+
+        If used as a setter, it defines the content of the file, and `abs_src_path` becomes unset.
+        """
+        content = self._content
+        if content is None:
+            assert self.abs_src_path is not None
+            with open(self.abs_src_path, 'rb') as f:
+                return f.read()
+        if not isinstance(content, bytes):
+            content = content.encode()
+        return content
+
+    @content_bytes.setter
+    def content_bytes(self, value: bytes):
+        assert isinstance(value, bytes)
+        self._content = value
+        self.abs_src_path = None
+
+    @property
+    def content_string(self) -> str:
+        """
+        Get the content of this file as a string. Assumes UTF-8 encoding, may raise.
+
+        May also raise if backed by a real file (`abs_src_path`) if it cannot be read.
+
+        If used as a setter, it defines the content of the file, and `abs_src_path` becomes unset.
+        """
+        content = self._content
+        if content is None:
+            assert self.abs_src_path is not None
+            with open(self.abs_src_path, encoding='utf-8-sig', errors='strict') as f:
+                return f.read()
+        if not isinstance(content, str):
+            content = content.decode('utf-8-sig', errors='strict')
+        return content
+
+    @content_string.setter
+    def content_string(self, value: str):
+        assert isinstance(value, str)
+        self._content = value
+        self.abs_src_path = None
+
     def copy_file(self, dirty: bool = False) -> None:
         """Copy source file to destination, ensuring parent directories exist."""
         if dirty and not self.is_modified():
             log.debug(f"Skip copying unmodified file: '{self.src_uri}'")
-        else:
-            log.debug(f"Copying media file: '{self.src_uri}'")
+            return
+        log.debug(f"Copying media file: '{self.src_uri}'")
+        output_path = self.abs_dest_path
+        os.makedirs(os.path.dirname(output_path), exist_ok=True)
+        content = self._content
+        if content is None:
+            assert self.abs_src_path is not None
             try:
-                utils.copy_file(self.abs_src_path, self.abs_dest_path)
+                utils.copy_file(self.abs_src_path, output_path)
             except shutil.SameFileError:
                 pass  # Let plugins write directly into site_dir.
+        elif isinstance(content, str):
+            with open(output_path, 'w', encoding='utf-8') as output_file:
+                output_file.write(content)
+        else:
+            with open(output_path, 'wb') as output_file:
+                output_file.write(content)
 
     def is_modified(self) -> bool:
+        if self._content is not None:
+            return True
+        assert self.abs_src_path is not None
         if os.path.isfile(self.abs_dest_path):
             return os.path.getmtime(self.abs_dest_path) < os.path.getmtime(self.abs_src_path)
         return True
@@ -383,12 +582,25 @@ def get_files(config: MkDocsConfig) -> Files:
     return Files(files)
 
 
+def file_sort_key(f: File, /):
+    """
+    Replicates the sort order how `get_files` produces it - index first, directories last.
+
+    To sort a list of `File`, pass as the `key` argument to `sort`.
+    """
+    parts = PurePosixPath(f.src_uri).parts
+    if not parts:
+        return ()
+    return (parts[:-1], f.name != "index", parts[-1])
+
+
 def _file_sort_key(f: str):
-    """Always sort `index` or `README` as first filename in list."""
+    """Always sort `index` or `README` as first filename in list. This works only on basenames of files."""
     return (os.path.splitext(f)[0] not in ('index', 'README'), f)
 
 
 def _sort_files(filenames: Iterable[str]) -> list[str]:
+    """Soft-deprecated, do not use."""
     return sorted(filenames, key=_file_sort_key)
 
 
diff --git a/mkdocs/structure/nav.py b/mkdocs/structure/nav.py
index 2c39fa20..21815fe6 100644
--- a/mkdocs/structure/nav.py
+++ b/mkdocs/structure/nav.py
@@ -6,6 +6,7 @@ from urllib.parse import urlsplit
 
 from mkdocs.exceptions import BuildError
 from mkdocs.structure import StructureItem
+from mkdocs.structure.files import file_sort_key
 from mkdocs.structure.pages import Page, _AbsoluteLinksValidationValue
 from mkdocs.utils import nest_paths
 
@@ -129,9 +130,10 @@ class Link(StructureItem):
 def get_navigation(files: Files, config: MkDocsConfig) -> Navigation:
     """Build site navigation from config and files."""
     documentation_pages = files.documentation_pages()
-    nav_config = config['nav'] or nest_paths(
-        f.src_uri for f in documentation_pages if f.inclusion.is_in_nav()
-    )
+    nav_config = config['nav']
+    if nav_config is None:
+        documentation_pages = sorted(documentation_pages, key=file_sort_key)
+        nav_config = nest_paths(f.src_uri for f in documentation_pages if f.inclusion.is_in_nav())
     items = _data_to_navigation(nav_config, files, config)
     if not isinstance(items, list):
         items = [items]
diff --git a/mkdocs/structure/pages.py b/mkdocs/structure/pages.py
index 55a0ed3d..b7e949cb 100644
--- a/mkdocs/structure/pages.py
+++ b/mkdocs/structure/pages.py
@@ -173,38 +173,42 @@ class Page(StructureItem):
         edit_uri: str | None = None,
         edit_uri_template: str | None = None,
     ) -> None:
-        if edit_uri or edit_uri_template:
-            src_uri = self.file.src_uri
-            if edit_uri_template:
-                noext = posixpath.splitext(src_uri)[0]
-                edit_uri = edit_uri_template.format(path=src_uri, path_noext=noext)
-            else:
-                assert edit_uri is not None and edit_uri.endswith('/')
-                edit_uri += src_uri
-            if repo_url:
-                # Ensure urljoin behavior is correct
-                if not edit_uri.startswith(('?', '#')) and not repo_url.endswith('/'):
-                    repo_url += '/'
-            else:
-                try:
-                    parsed_url = urlsplit(edit_uri)
-                    if not parsed_url.scheme or not parsed_url.netloc:
-                        log.warning(
-                            f"edit_uri: {edit_uri!r} is not a valid URL, it should include the http:// (scheme)"
-                        )
-                except ValueError as e:
-                    log.warning(f"edit_uri: {edit_uri!r} is not a valid URL: {e}")
-
-            self.edit_url = urljoin(repo_url or '', edit_uri)
-        else:
+        if not edit_uri_template and not edit_uri:
             self.edit_url = None
+            return
+        src_uri = self.file.edit_uri
+        if src_uri is None:
+            self.edit_url = None
+            return
+
+        if edit_uri_template:
+            noext = posixpath.splitext(src_uri)[0]
+            file_edit_uri = edit_uri_template.format(path=src_uri, path_noext=noext)
+        else:
+            assert edit_uri is not None and edit_uri.endswith('/')
+            file_edit_uri = edit_uri + src_uri
+
+        if repo_url:
+            # Ensure urljoin behavior is correct
+            if not file_edit_uri.startswith(('?', '#')) and not repo_url.endswith('/'):
+                repo_url += '/'
+        else:
+            try:
+                parsed_url = urlsplit(file_edit_uri)
+                if not parsed_url.scheme or not parsed_url.netloc:
+                    log.warning(
+                        f"edit_uri: {file_edit_uri!r} is not a valid URL, it should include the http:// (scheme)"
+                    )
+            except ValueError as e:
+                log.warning(f"edit_uri: {file_edit_uri!r} is not a valid URL: {e}")
+
+        self.edit_url = urljoin(repo_url or '', file_edit_uri)
 
     def read_source(self, config: MkDocsConfig) -> None:
         source = config.plugins.on_page_read_source(page=self, config=config)
         if source is None:
             try:
-                with open(self.file.abs_src_path, encoding='utf-8-sig', errors='strict') as f:
-                    source = f.read()
+                source = self.file.content_string
             except OSError:
                 log.error(f'File not found: {self.file.src_path}')
                 raise


Test patch:
diff --git a/mkdocs/tests/build_tests.py b/mkdocs/tests/build_tests.py
index 9e9d6c0a..2ad93532 100644
--- a/mkdocs/tests/build_tests.py
+++ b/mkdocs/tests/build_tests.py
@@ -274,14 +274,14 @@ class BuildTests(PathAssertionMixin, unittest.TestCase):
     # Test build._build_extra_template
 
     @tempdir()
-    @mock.patch('mkdocs.commands.build.open', mock.mock_open(read_data='template content'))
+    @mock.patch('mkdocs.structure.files.open', mock.mock_open(read_data='template content'))
     def test_build_extra_template(self, site_dir):
         cfg = load_config(site_dir=site_dir)
         fs = [File('foo.html', cfg.docs_dir, cfg.site_dir, cfg.use_directory_urls)]
         files = Files(fs)
         build._build_extra_template('foo.html', files, cfg, mock.Mock())
 
-    @mock.patch('mkdocs.commands.build.open', mock.mock_open(read_data='template content'))
+    @mock.patch('mkdocs.structure.files.open', mock.mock_open(read_data='template content'))
     def test_skip_missing_extra_template(self):
         cfg = load_config()
         fs = [File('foo.html', cfg.docs_dir, cfg.site_dir, cfg.use_directory_urls)]
@@ -293,7 +293,7 @@ class BuildTests(PathAssertionMixin, unittest.TestCase):
             "WARNING:mkdocs.commands.build:Template skipped: 'missing.html' not found in docs_dir.",
         )
 
-    @mock.patch('mkdocs.commands.build.open', mock.Mock(side_effect=OSError('Error message.')))
+    @mock.patch('mkdocs.structure.files.open', mock.Mock(side_effect=OSError('Error message.')))
     def test_skip_ioerror_extra_template(self):
         cfg = load_config()
         fs = [File('foo.html', cfg.docs_dir, cfg.site_dir, cfg.use_directory_urls)]
@@ -305,7 +305,7 @@ class BuildTests(PathAssertionMixin, unittest.TestCase):
             "WARNING:mkdocs.commands.build:Error reading template 'foo.html': Error message.",
         )
 
-    @mock.patch('mkdocs.commands.build.open', mock.mock_open(read_data=''))
+    @mock.patch('mkdocs.structure.files.open', mock.mock_open(read_data=''))
     def test_skip_extra_template_empty_output(self):
         cfg = load_config()
         fs = [File('foo.html', cfg.docs_dir, cfg.site_dir, cfg.use_directory_urls)]
@@ -350,7 +350,7 @@ class BuildTests(PathAssertionMixin, unittest.TestCase):
         self.assertEqual(page.content, None)
 
     @tempdir(files={'index.md': 'new page content'})
-    @mock.patch('mkdocs.structure.pages.open', side_effect=OSError('Error message.'))
+    @mock.patch('mkdocs.structure.files.open', side_effect=OSError('Error message.'))
     def test_populate_page_read_error(self, docs_dir, mock_open):
         cfg = load_config(docs_dir=docs_dir)
         file = File('missing.md', cfg.docs_dir, cfg.site_dir, cfg.use_directory_urls)
@@ -763,7 +763,7 @@ class BuildTests(PathAssertionMixin, unittest.TestCase):
             # Plugin 2 reads that file and uses it to configure the nav.
             f = files.get_file_from_path('SUMMARY.md')
             assert f is not None
-            config.nav = Path(f.abs_src_path).read_text().splitlines()
+            config.nav = f.content_string.splitlines()
 
         for serve_url in None, 'http://localhost:123/':
             for exclude in 'full', 'drafts', 'nav', None:
diff --git a/mkdocs/tests/structure/file_tests.py b/mkdocs/tests/structure/file_tests.py
index a646593c..fe3c4143 100644
--- a/mkdocs/tests/structure/file_tests.py
+++ b/mkdocs/tests/structure/file_tests.py
@@ -3,7 +3,7 @@ import sys
 import unittest
 from unittest import mock
 
-from mkdocs.structure.files import File, Files, _sort_files, get_files
+from mkdocs.structure.files import File, Files, _sort_files, file_sort_key, get_files
 from mkdocs.tests.base import PathAssertionMixin, load_config, tempdir
 
 
@@ -57,6 +57,16 @@ class TestFiles(PathAssertionMixin, unittest.TestCase):
             ['README.md', 'A.md', 'B.md'],
         )
 
+    def test_file_sort_key(self):
+        for case in [
+            ["a/b.md", "b/index.md", "b/a.md"],
+            ["SUMMARY.md", "foo/z.md", "foo/bar/README.md", "foo/bar/index.md", "foo/bar/a.md"],
+        ]:
+            with self.subTest(case):
+                files = [File(f, "", "", use_directory_urls=True) for f in case]
+                for a, b in zip(files, files[1:]):
+                    self.assertLess(file_sort_key(a), file_sort_key(b))
+
     def test_md_file(self):
         for use_directory_urls in True, False:
             with self.subTest(use_directory_urls=use_directory_urls):
@@ -249,6 +259,67 @@ class TestFiles(PathAssertionMixin, unittest.TestCase):
                     self.assertEqual(f.url, 'stuff/1-foo/index.html')
                 self.assertEqual(f.name, 'foo')
 
+    def test_file_overwrite_attrs(self):
+        f = File('foo.md', '/path/to/docs', '/path/to/site', use_directory_urls=False)
+        self.assertEqual(f.src_uri, 'foo.md')
+
+        self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo.md')
+        f.abs_src_path = '/tmp/foo.md'
+        self.assertPathsEqual(f.abs_src_path, '/tmp/foo.md')
+        del f.abs_src_path
+        self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo.md')
+
+        f.dest_uri = 'a.html'
+        self.assertPathsEqual(f.abs_dest_path, '/path/to/site/a.html')
+        self.assertEqual(f.url, 'a.html')
+        f.abs_dest_path = '/path/to/site/b.html'
+        self.assertPathsEqual(f.abs_dest_path, '/path/to/site/b.html')
+        self.assertEqual(f.url, 'a.html')
+        del f.url
+        del f.dest_uri
+        del f.abs_dest_path
+        self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo.html')
+
+        self.assertTrue(f.is_documentation_page())
+        f.src_uri = 'foo.html'
+        del f.name
+        self.assertFalse(f.is_documentation_page())
+
+    def test_generated_file(self):
+        f = File(
+            'foo/bar.md',
+            src_dir=None,
+            dest_dir='/path/to/site',
+            use_directory_urls=False,
+        )
+        f.content_string = 'вміст'
+        f.generated_by = 'some-plugin'
+        self.assertEqual(f.generated_by, 'some-plugin')
+        self.assertEqual(f.src_uri, 'foo/bar.md')
+        self.assertIsNone(f.abs_src_path)
+        self.assertIsNone(f.src_dir)
+        self.assertEqual(f.dest_uri, 'foo/bar.html')
+        self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.html')
+        self.assertEqual(f.content_string, 'вміст')
+        self.assertEqual(f.edit_uri, None)
+
+    @tempdir(files={'x.md': 'вміст'})
+    def test_generated_file_constructor(self, tdir) -> None:
+        config = load_config(site_dir='/path/to/site', use_directory_urls=False)
+        config.plugins._current_plugin = 'foo'
+        for f in [
+            File.generated(config, 'foo/bar.md', content='вміст'),
+            File.generated(config, 'foo/bar.md', content='вміст'.encode()),
+            File.generated(config, 'foo/bar.md', abs_src_path=os.path.join(tdir, 'x.md')),
+        ]:
+            self.assertEqual(f.src_uri, 'foo/bar.md')
+            self.assertIsNone(f.src_dir)
+            self.assertEqual(f.dest_uri, 'foo/bar.html')
+            self.assertPathsEqual(f.abs_dest_path, os.path.abspath('/path/to/site/foo/bar.html'))
+            self.assertEqual(f.content_string, 'вміст')
+            self.assertEqual(f.content_bytes, 'вміст'.encode())
+            self.assertEqual(f.edit_uri, None)
+
     def test_files(self):
         fs = [
             File('index.md', '/path/to/docs', '/path/to/site', use_directory_urls=True),
@@ -584,6 +655,8 @@ class TestFiles(PathAssertionMixin, unittest.TestCase):
     @tempdir(files={'test.txt': 'source content'})
     def test_copy_file_clean_modified(self, src_dir, dest_dir):
         file = File('test.txt', src_dir, dest_dir, use_directory_urls=False)
+        self.assertEqual(file.content_string, 'source content')
+        self.assertEqual(file.content_bytes, b'source content')
         file.is_modified = mock.Mock(return_value=True)
         dest_path = os.path.join(dest_dir, 'test.txt')
         file.copy_file(dirty=False)
@@ -613,6 +686,28 @@ class TestFiles(PathAssertionMixin, unittest.TestCase):
         with open(dest_path, encoding='utf-8') as f:
             self.assertEqual(f.read(), 'destination content')
 
+    @tempdir()
+    def test_copy_file_from_content(self, dest_dir):
+        file = File('test.txt', src_dir='unused', dest_dir=dest_dir, use_directory_urls=False)
+        file.content_string = 'ö'
+        self.assertIsNone(file.abs_src_path)
+        dest_path = os.path.join(dest_dir, 'test.txt')
+
+        file.copy_file()
+        self.assertPathIsFile(dest_path)
+        with open(dest_path, encoding='utf-8') as f:
+            self.assertEqual(f.read(), 'ö')
+
+        file.content_bytes = b'\x01\x02\x03'
+        file.copy_file()
+        with open(dest_path, 'rb') as f:
+            self.assertEqual(f.read(), b'\x01\x02\x03')
+
+        file.content_bytes = b'\xc3\xb6'
+        file.copy_file()
+        with open(dest_path, encoding='utf-8') as f:
+            self.assertEqual(f.read(), 'ö')
+
     def test_files_append_remove_src_paths(self):
         fs = [
             File('index.md', '/path/to/docs', '/path/to/site', use_directory_urls=True),
@@ -635,3 +730,17 @@ class TestFiles(PathAssertionMixin, unittest.TestCase):
         self.assertEqual(len(files), 6)
         self.assertEqual(len(files.src_uris), 6)
         self.assertFalse(extra_file.src_uri in files.src_uris)
+
+    def test_files_move_to_end(self):
+        fs = [
+            File('a.md', '/path/to/docs', '/path/to/site', use_directory_urls=True),
+            File('b.jpg', '/path/to/docs', '/path/to/site', use_directory_urls=True),
+        ]
+        files = Files(fs)
+        self.assertEqual(len(files), 2)
+        self.assertEqual(list(files)[0].src_uri, 'a.md')
+        with self.assertWarns(DeprecationWarning):
+            files.append(fs[0])
+        self.assertEqual(len(files), 2)
+        self.assertEqual(list(files)[0].src_uri, 'b.jpg')
+        self.assertEqual(list(files)[1].src_uri, 'a.md')
diff --git a/mkdocs/tests/structure/page_tests.py b/mkdocs/tests/structure/page_tests.py
index 22b46941..ee6aa159 100644
--- a/mkdocs/tests/structure/page_tests.py
+++ b/mkdocs/tests/structure/page_tests.py
@@ -677,6 +677,26 @@ class PageTests(unittest.TestCase):
                 self.assertEqual(pg.edit_url, case['edit_url'])
                 self.assertEqual(cm.output, [case['warning']])
 
+    def test_page_edit_url_custom_from_file(self):
+        for case in [
+            dict(
+                edit_uri='hooks.py',
+                expected_edit_url='https://github.com/mkdocs/mkdocs/edit/master/docs/hooks.py',
+            ),
+            dict(
+                edit_uri='../scripts/hooks.py',
+                expected_edit_url='https://github.com/mkdocs/mkdocs/edit/master/scripts/hooks.py',
+            ),
+            dict(edit_uri=None, expected_edit_url=None),
+        ]:
+            with self.subTest(case['edit_uri']):
+                cfg = load_config(repo_url='https://github.com/mkdocs/mkdocs')
+                fl = File('testing.md', cfg.docs_dir, cfg.site_dir, cfg.use_directory_urls)
+                fl.edit_uri = case['edit_uri']
+                pg = Page('Foo', fl, cfg)
+                self.assertEqual(pg.url, 'testing/')
+                self.assertEqual(pg.edit_url, case['expected_edit_url'])
+
     def test_page_render(self):
         cfg = load_config()
         fl = File('testing.md', cfg.docs_dir, cfg.site_dir, cfg.use_directory_urls)
@@ -737,7 +757,7 @@ class RelativePathExtensionTests(unittest.TestCase):
         fs = [File(f, cfg.docs_dir, cfg.site_dir, cfg.use_directory_urls) for f in files]
         pg = Page('Foo', fs[0], cfg)
 
-        with mock.patch('mkdocs.structure.pages.open', mock.mock_open(read_data=content)):
+        with mock.patch('mkdocs.structure.files.open', mock.mock_open(read_data=content)):
             pg.read_source(cfg)
         if logs:
             with self.assertLogs('mkdocs.structure.pages') as cm: