Repository: mkdocs/mkdocs

Issue:
Allow plugins to override `Page` when populating navigation
Following up on https://github.com/squidfunk/mkdocs-material/issues/5909#issuecomment-1696958973, I'm suggesting to adjust the logic that MkDocs implies to create `Page` objects from/in files, i.e., `File` objects. Currently, [`_data_to_navigation`](https://github.com/mkdocs/mkdocs/blob/master/mkdocs/structure/nav.py#L179) will always create (and thus overwrite) `Page` on everything that doesn't look like a `Link` or `Section`. Excerpt of the comment linked:

> MkDocs will always generate either a [`Link` or a `Page` for a file that is not excluded](https://github.com/mkdocs/mkdocs/blob/94e9f17cd7a69e70c18ae282d55ba2f34f93c542/mkdocs/structure/nav.py#L203-L204). As you've probably noticed, the blog plugin makes use of subclasses for pages of specific types, e.g. [`Post`](https://github.com/squidfunk/mkdocs-material/blob/de0c5b7719deff6d092dc673e82af701c6f36001/src/plugins/blog/structure/__init__.py#L48), [`View`](https://github.com/squidfunk/mkdocs-material/blob/de0c5b7719deff6d092dc673e82af701c6f36001/src/plugins/blog/structure/__init__.py#L204), etc., and relations among different concepts in the plugin are managed inside the instances of those subclasses. The problem is, that __MkDocs will always generate a plain old `Page` instance, overwriting the more specific instance that has been generated by the plugin before__ [after `on_files` and before `on_nav`](https://github.com/mkdocs/mkdocs/blob/94e9f17cd7a69e70c18ae282d55ba2f34f93c542/mkdocs/commands/build.py#L303-L311). We could still solve it by splitting file generation and page instantiation, but this would make things much more complicated and non-linear, because during generation, [we already assign categories to pages and vice versa](https://github.com/squidfunk/mkdocs-material/blob/de0c5b7719deff6d092dc673e82af701c6f36001/src/plugins/blog/plugin.py#L598-L599), etc. We would need to split this into two stages, blowing up the code base of the plugin and also the runtime, as we're doing two passes. This is clearly not favorable from our perspective.
>
> __Moving forward__: I believe that this problem can be mitigated with a very small but important change in MkDocs: if a `File` already defines a dedicated `Page` in `file.page`, MkDocs should just use that instead of creating a new one:
> 
> ``` py
> return file.page if isinstance(file.page, Page) else Page(title, file, config)
> ```
>
> It's essentially a single line change in MkDocs that would make things much, much simpler for plugin developers. I've written several plugins now, and I repeatedly had to work around the oddities of this behavior.

Thus, I'm proposing to change [the following line](https://github.com/mkdocs/mkdocs/blob/94e9f17cd7a69e70c18ae282d55ba2f34f93c542/mkdocs/structure/nav.py#L203):

``` py
# Now: always create a page
return Page(title, file, config)

# Better: only create a page when one was not created before
return file.page if isinstance(file.page, Page) else Page(title, file, config)
```

Currently, the only way to work around this limitation is to overwrite `Page.__class__` on the resulting instance in `on_nav`, e.g., like done in [section-index](https://github.com/oprypin/mkdocs-section-index/blob/f5414c0775848cec96f1181f74c622fe3e14985e/mkdocs_section_index/plugin.py#L33-L34), which, AFAIK, does not call `__init__` or initializes instance variables:

``` py
page.__class__ = CustomPage
assert isinstance(page, CustomPage)
```


Discussion:

Resolving patch:
diff --git a/mkdocs/structure/nav.py b/mkdocs/structure/nav.py
--- a/mkdocs/structure/nav.py
+++ b/mkdocs/structure/nav.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import logging
+import warnings
 from typing import TYPE_CHECKING, Iterator, TypeVar
 from urllib.parse import urlsplit
 
@@ -200,6 +201,12 @@ def _data_to_navigation(data, files: Files, config: MkDocsConfig):
                 f"A reference to '{file.src_path}' is included in the 'nav' "
                 "configuration, but this file is excluded from the built site.",
             )
+        if file.page is not None:
+            if not isinstance(file.page, Page):
+                warnings.warn(  # type: ignore[unreachable]
+                    "File.page should not be set to any type other than Page", DeprecationWarning
+                )
+            return file.page
         return Page(title, file, config)
     return Link(title, path)
 


Test patch:
diff --git a/mkdocs/tests/structure/nav_tests.py b/mkdocs/tests/structure/nav_tests.py
--- a/mkdocs/tests/structure/nav_tests.py
+++ b/mkdocs/tests/structure/nav_tests.py
@@ -378,6 +378,34 @@ def test_nav_from_nested_files(self):
         self.assertEqual(len(site_navigation.pages), 7)
         self.assertEqual(repr(site_navigation.homepage), "Page(title=[blank], url='/')")
 
+    def test_nav_page_subclass(self):
+        class PageSubclass(Page):
+            pass
+
+        nav_cfg = [
+            {'Home': 'index.md'},
+            {'About': 'about.md'},
+        ]
+        expected = dedent(
+            """
+            PageSubclass(title=[blank], url='/')
+            PageSubclass(title=[blank], url='/about/')
+            """
+        )
+        cfg = load_config(nav=nav_cfg, site_url='http://example.com/')
+        fs = [
+            File(list(item.values())[0], cfg.docs_dir, cfg.site_dir, cfg.use_directory_urls)
+            for item in nav_cfg
+        ]
+        files = Files(fs)
+        for file in files:
+            PageSubclass(None, file, cfg)
+        site_navigation = get_navigation(files, cfg)
+        self.assertEqual(str(site_navigation).strip(), expected)
+        self.assertEqual(len(site_navigation.items), 2)
+        self.assertEqual(len(site_navigation.pages), 2)
+        self.assertEqual(repr(site_navigation.homepage), "PageSubclass(title=[blank], url='/')")
+
     def test_active(self):
         nav_cfg = [
             {'Home': 'index.md'},