Repository: mkdocs/mkdocs

Issue:
Feature idea: Allow to override docs_dir with commandline option
The build command offers several options. One of them is `--site-dir` to override the setting `site_dir` in a `mkdocs.yml`.

Is it possible to add an option `--docs-dir` to override the setting `docs_dir` as well?

Use case: Use the same mkdocs.yml configuration file to repeatedly execute the `build` command on various different source directories.

    mkdocs build --config-file mkdocs.yml  --docs-dir /path/to/1/docs/ --site-dir /path/build/1/
    mkdocs build --config-file mkdocs.yml  --docs-dir /path/to/2/docs/ --site-dir /path/build/2/
    mkdocs build --config-file mkdocs.yml  --docs-dir /path/to/3/docs/ --site-dir /path/special/3
    …


Discussion:

Patch:
diff --git a/docs/dev-guide/themes.md b/docs/dev-guide/themes.md
index 32496f04..1ba3a021 100644
--- a/docs/dev-guide/themes.md
+++ b/docs/dev-guide/themes.md
@@ -408,7 +408,7 @@ on the homepage:
         show_root_full_path: false
         heading_level: 5
 
-::: mkdocs.structure.pages.Page.parent
+::: mkdocs.structure.StructureItem.parent
     options:
         show_root_full_path: false
         heading_level: 5
@@ -479,7 +479,7 @@ The following attributes are available on `section` objects:
         show_root_full_path: false
         heading_level: 5
 
-::: mkdocs.structure.nav.Section.parent
+::: mkdocs.structure.StructureItem.parent
     options:
         show_root_full_path: false
         heading_level: 5
@@ -533,7 +533,7 @@ The following attributes are available on `link` objects:
         show_root_full_path: false
         heading_level: 5
 
-::: mkdocs.structure.nav.Link.parent
+::: mkdocs.structure.StructureItem.parent
     options:
         show_root_full_path: false
         heading_level: 5
@@ -633,7 +633,7 @@ and themes should account for this. It is recommended that theme templates wrap
 search specific markup with a check for the plugin:
 
 ```django
-{% if 'search' in config['plugins'] %}
+{% if 'search' in config.plugins %}
     search stuff here...
 {% endif %}
 ```
diff --git a/docs/user-guide/configuration.md b/docs/user-guide/configuration.md
index 1fb55c94..e50a76b0 100644
--- a/docs/user-guide/configuration.md
+++ b/docs/user-guide/configuration.md
@@ -1021,6 +1021,12 @@ Therefore, defining paths in a parent file which is inherited by multiple
 different sites may not work as expected. It is generally best to define
 path based options in the primary configuration file only.
 
+The inheritance can also be used as a quick way to override keys on the command line - by using stdin as the config file. For example:
+
+```bash
+echo '{INHERIT: mkdocs.yml, site_name: "Renamed site"}' | mkdocs build -f -
+```
+
 [Theme Developer Guide]: ../dev-guide/themes.md
 [pymdk-extensions]: https://python-markdown.github.io/extensions/
 [pymkd]: https://python-markdown.github.io/
diff --git a/mkdocs/__main__.py b/mkdocs/__main__.py
index be33aa71..6c712b74 100644
--- a/mkdocs/__main__.py
+++ b/mkdocs/__main__.py
@@ -106,7 +106,9 @@ class State:
 pass_state = click.make_pass_decorator(State, ensure=True)
 
 clean_help = "Remove old files from the site_dir before building (the default)."
-config_help = "Provide a specific MkDocs config"
+config_help = (
+    "Provide a specific MkDocs config. This can be a file name, or '-' to read from stdin."
+)
 dev_addr_help = "IP address and port to serve documentation locally (default: localhost:8000)"
 strict_help = "Enable strict mode. This will cause MkDocs to abort the build on any warnings."
 theme_help = "The theme to use when building your documentation."
@@ -255,11 +257,11 @@ def build_command(clean, **kwargs):
 
     _enable_warnings()
     cfg = config.load_config(**kwargs)
-    cfg['plugins'].run_event('startup', command='build', dirty=not clean)
+    cfg.plugins.on_startup(command='build', dirty=not clean)
     try:
         build.build(cfg, dirty=not clean)
     finally:
-        cfg['plugins'].run_event('shutdown')
+        cfg.plugins.on_shutdown()
 
 
 @cli.command(name="gh-deploy")
@@ -282,11 +284,11 @@ def gh_deploy_command(
 
     _enable_warnings()
     cfg = config.load_config(remote_branch=remote_branch, remote_name=remote_name, **kwargs)
-    cfg['plugins'].run_event('startup', command='gh-deploy', dirty=not clean)
+    cfg.plugins.on_startup(command='gh-deploy', dirty=not clean)
     try:
         build.build(cfg, dirty=not clean)
     finally:
-        cfg['plugins'].run_event('shutdown')
+        cfg.plugins.on_shutdown()
     gh_deploy.gh_deploy(
         cfg,
         message=message,
diff --git a/mkdocs/commands/build.py b/mkdocs/commands/build.py
index e3bc1b05..dcd2040f 100644
--- a/mkdocs/commands/build.py
+++ b/mkdocs/commands/build.py
@@ -67,7 +67,7 @@ def _build_template(
     Return rendered output for given template as a string.
     """
     # Run `pre_template` plugin events.
-    template = config.plugins.run_event('pre_template', template, template_name=name, config=config)
+    template = config.plugins.on_pre_template(template, template_name=name, config=config)
 
     if utils.is_error_template(name):
         # Force absolute URLs in the nav of error pages and account for the
@@ -82,14 +82,12 @@ def _build_template(
     context = get_context(nav, files, config, base_url=base_url)
 
     # Run `template_context` plugin events.
-    context = config.plugins.run_event(
-        'template_context', context, template_name=name, config=config
-    )
+    context = config.plugins.on_template_context(context, template_name=name, config=config)
 
     output = template.render(context)
 
     # Run `post_template` plugin events.
-    output = config.plugins.run_event('post_template', output, template_name=name, config=config)
+    output = config.plugins.on_post_template(output, template_name=name, config=config)
 
     return output
 
@@ -161,20 +159,22 @@ def _populate_page(page: Page, config: MkDocsConfig, files: Files, dirty: bool =
             return
 
         # Run the `pre_page` plugin event
-        page = config.plugins.run_event('pre_page', page, config=config, files=files)
+        page = config.plugins.on_pre_page(page, config=config, files=files)
 
         page.read_source(config)
+        assert page.markdown is not None
 
         # Run `page_markdown` plugin events.
-        page.markdown = config.plugins.run_event(
-            'page_markdown', page.markdown, page=page, config=config, files=files
+        page.markdown = config.plugins.on_page_markdown(
+            page.markdown, page=page, config=config, files=files
         )
 
         page.render(config, files)
+        assert page.content is not None
 
         # Run `page_content` plugin events.
-        page.content = config.plugins.run_event(
-            'page_content', page.content, page=page, config=config, files=files
+        page.content = config.plugins.on_page_content(
+            page.content, page=page, config=config, files=files
         )
     except Exception as e:
         message = f"Error reading page '{page.file.src_uri}':"
@@ -216,9 +216,7 @@ def _build_page(
             template = env.get_template('main.html')
 
         # Run `page_context` plugin events.
-        context = config.plugins.run_event(
-            'page_context', context, page=page, config=config, nav=nav
-        )
+        context = config.plugins.on_page_context(context, page=page, config=config, nav=nav)
 
         if excluded:
             page.content = (
@@ -231,7 +229,7 @@ def _build_page(
         output = template.render(context)
 
         # Run `post_page` plugin events.
-        output = config.plugins.run_event('post_page', output, page=page, config=config)
+        output = config.plugins.on_post_page(output, page=page, config=config)
 
         # Write the output file.
         if output.strip():
@@ -271,10 +269,10 @@ def build(
         start = time.monotonic()
 
         # Run `config` plugin events.
-        config = config.plugins.run_event('config', config)
+        config = config.plugins.on_config(config)
 
         # Run `pre_build` plugin events.
-        config.plugins.run_event('pre_build', config=config)
+        config.plugins.on_pre_build(config=config)
 
         if not dirty:
             log.info("Cleaning site directory")
@@ -298,14 +296,14 @@ def build(
         files.add_files_from_theme(env, config)
 
         # Run `files` plugin events.
-        files = config.plugins.run_event('files', files, config=config)
+        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)
 
         nav = get_navigation(files, config)
 
         # Run `nav` plugin events.
-        nav = config.plugins.run_event('nav', nav, config=config, files=files)
+        nav = config.plugins.on_nav(nav, config=config, files=files)
 
         log.debug("Reading markdown pages.")
         excluded = []
@@ -325,7 +323,7 @@ def build(
             )
 
         # Run `env` plugin events.
-        env = config.plugins.run_event('env', env, config=config, files=files)
+        env = config.plugins.on_env(env, config=config, files=files)
 
         # Start writing files to site_dir now that all data is gathered. Note that order matters. Files
         # with lower precedence get written first so that files with higher precedence can overwrite them.
@@ -348,7 +346,7 @@ def build(
             )
 
         # Run `post_build` plugin events.
-        config.plugins.run_event('post_build', config=config)
+        config.plugins.on_post_build(config=config)
 
         counts = warning_counter.get_counts()
         if counts:
@@ -359,7 +357,7 @@ def build(
 
     except Exception as e:
         # Run `build_error` plugin events.
-        config.plugins.run_event('build_error', error=e)
+        config.plugins.on_build_error(error=e)
         if isinstance(e, BuildError):
             log.error(str(e))
             raise Abort('Aborted with a BuildError!')
diff --git a/mkdocs/commands/gh_deploy.py b/mkdocs/commands/gh_deploy.py
index f6e5fa05..de5fb99b 100644
--- a/mkdocs/commands/gh_deploy.py
+++ b/mkdocs/commands/gh_deploy.py
@@ -116,7 +116,7 @@ def gh_deploy(
 
     if message is None:
         message = default_message
-    sha = _get_current_sha(os.path.dirname(config.config_file_path))
+    sha = _get_current_sha(os.path.dirname(config.config_file_path or ''))
     message = message.format(version=mkdocs.__version__, sha=sha)
 
     log.info(
diff --git a/mkdocs/commands/serve.py b/mkdocs/commands/serve.py
index 013f0120..48095f9b 100644
--- a/mkdocs/commands/serve.py
+++ b/mkdocs/commands/serve.py
@@ -63,9 +63,7 @@ def serve(
     is_dirty = build_type == 'dirty'
 
     config = get_config()
-    config['plugins'].run_event(
-        'startup', command=('build' if is_clean else 'serve'), dirty=is_dirty
-    )
+    config.plugins.on_startup(command=('build' if is_clean else 'serve'), dirty=is_dirty)
 
     def builder(config: MkDocsConfig | None = None):
         log.info("Building documentation...")
@@ -109,7 +107,7 @@ def serve(
                     server.watch(d)
 
             # Run `serve` plugin events.
-            server = config.plugins.run_event('serve', server, config=config, builder=builder)
+            server = config.plugins.on_serve(server, config=config, builder=builder)
 
             for item in config.watch:
                 server.watch(item)
@@ -127,6 +125,6 @@ def serve(
         # Avoid ugly, unhelpful traceback
         raise Abort(f'{type(e).__name__}: {e}')
     finally:
-        config['plugins'].run_event('shutdown')
+        config.plugins.on_shutdown()
         if isdir(site_dir):
             shutil.rmtree(site_dir)
diff --git a/mkdocs/config/base.py b/mkdocs/config/base.py
index f4e8279e..3d14d621 100644
--- a/mkdocs/config/base.py
+++ b/mkdocs/config/base.py
@@ -331,7 +331,10 @@ def _open_config_file(config_file: str | IO | None) -> Iterator[IO]:
     else:
         log.debug(f"Loading configuration file: {result_config_file}")
         # Ensure file descriptor is at beginning
-        result_config_file.seek(0)
+        try:
+            result_config_file.seek(0)
+        except OSError:
+            pass
 
     try:
         yield result_config_file
diff --git a/mkdocs/config/config_options.py b/mkdocs/config/config_options.py
index fb8a912f..9a5ed70f 100644
--- a/mkdocs/config/config_options.py
+++ b/mkdocs/config/config_options.py
@@ -814,8 +814,7 @@ class Theme(BaseConfigOption[theme.Theme]):
 
         # Ensure custom_dir is an absolute path
         if 'custom_dir' in theme_config and not os.path.isabs(theme_config['custom_dir']):
-            assert self.config_file_path is not None
-            config_dir = os.path.dirname(self.config_file_path)
+            config_dir = os.path.dirname(self.config_file_path or '')
             theme_config['custom_dir'] = os.path.join(config_dir, theme_config['custom_dir'])
 
         if 'custom_dir' in theme_config and not os.path.isdir(theme_config['custom_dir']):
diff --git a/mkdocs/config/defaults.py b/mkdocs/config/defaults.py
index ade08ff8..3a2d69d0 100644
--- a/mkdocs/config/defaults.py
+++ b/mkdocs/config/defaults.py
@@ -16,8 +16,8 @@ def get_schema() -> base.PlainConfigSchema:
 class MkDocsConfig(base.Config):
     """The configuration of MkDocs itself (the root object of mkdocs.yml)."""
 
-    config_file_path: str = c.Optional(c.Type(str))  # type: ignore[assignment]
-    """Reserved for internal use, stores the mkdocs.yml config file."""
+    config_file_path: str | None = c.Optional(c.Type(str))  # type: ignore[assignment]
+    """The path to the mkdocs.yml config file. Can't be populated from the config."""
 
     site_name = c.Type(str)
     """The title to use for the documentation."""
@@ -136,3 +136,8 @@ class MkDocsConfig(base.Config):
 
     watch = c.ListOfPaths(default=[])
     """A list of extra paths to watch while running `mkdocs serve`."""
+
+    def load_dict(self, patch: dict) -> None:
+        super().load_dict(patch)
+        if 'config_file_path' in patch:
+            raise base.ValidationError("Can't set config_file_path in config")
diff --git a/mkdocs/plugins.py b/mkdocs/plugins.py
index 2c8ecca0..308ca640 100644
--- a/mkdocs/plugins.py
+++ b/mkdocs/plugins.py
@@ -153,7 +153,7 @@ class BasePlugin(Generic[SomeConfig]):
 
     # Global events
 
-    def on_config(self, config: MkDocsConfig) -> Config | None:
+    def on_config(self, config: MkDocsConfig) -> MkDocsConfig | None:
         """
         The `config` event is the first event called on build and is run immediately
         after the user configuration is loaded and validated. Any alterations to the
@@ -235,7 +235,7 @@ class BasePlugin(Generic[SomeConfig]):
             config: global configuration object
         """
 
-    def on_build_error(self, error: Exception) -> None:
+    def on_build_error(self, *, error: Exception) -> None:
         """
         The `build_error` event is called after an exception of any kind
         is caught by MkDocs during the build process.
@@ -481,7 +481,7 @@ class PluginCollection(dict, MutableMapping[str, BasePlugin]):
                 self._register_event(event_name[3:], method)
 
     @overload
-    def run_event(self, name: str, item: None = None, **kwargs) -> Any:
+    def run_event(self, name: str, **kwargs) -> Any:
         ...
 
     @overload
@@ -511,6 +511,79 @@ class PluginCollection(dict, MutableMapping[str, BasePlugin]):
                 item = result
         return item
 
+    def on_startup(self, *, command: Literal['build', 'gh-deploy', 'serve'], dirty: bool) -> None:
+        return self.run_event('startup', command=command, dirty=dirty)
+
+    def on_shutdown(self) -> None:
+        return self.run_event('shutdown')
+
+    def on_serve(
+        self, server: LiveReloadServer, *, config: MkDocsConfig, builder: Callable
+    ) -> LiveReloadServer:
+        return self.run_event('serve', server, config=config, builder=builder)
+
+    def on_config(self, config: MkDocsConfig) -> MkDocsConfig:
+        return self.run_event('config', config)
+
+    def on_pre_build(self, *, config: MkDocsConfig) -> None:
+        return self.run_event('pre_build', config=config)
+
+    def on_files(self, files: Files, *, config: MkDocsConfig) -> Files:
+        return self.run_event('files', files, config=config)
+
+    def on_nav(self, nav: Navigation, *, config: MkDocsConfig, files: Files) -> Navigation:
+        return self.run_event('nav', nav, config=config, files=files)
+
+    def on_env(self, env: jinja2.Environment, *, config: MkDocsConfig, files: Files):
+        return self.run_event('env', env, config=config, files=files)
+
+    def on_post_build(self, *, config: MkDocsConfig) -> None:
+        return self.run_event('post_build', config=config)
+
+    def on_build_error(self, *, error: Exception) -> None:
+        return self.run_event('build_error', error=error)
+
+    def on_pre_template(
+        self, template: jinja2.Template, *, template_name: str, config: MkDocsConfig
+    ) -> jinja2.Template:
+        return self.run_event('pre_template', template, template_name=template_name, config=config)
+
+    def on_template_context(
+        self, context: dict[str, Any], *, template_name: str, config: MkDocsConfig
+    ) -> dict[str, Any]:
+        return self.run_event(
+            'template_context', context, template_name=template_name, config=config
+        )
+
+    def on_post_template(
+        self, output_content: str, *, template_name: str, config: MkDocsConfig
+    ) -> str:
+        return self.run_event(
+            'post_template', output_content, template_name=template_name, config=config
+        )
+
+    def on_pre_page(self, page: Page, *, config: MkDocsConfig, files: Files) -> Page:
+        return self.run_event('pre_page', page, config=config, files=files)
+
+    def on_page_read_source(self, *, page: Page, config: MkDocsConfig) -> str | None:
+        return self.run_event('page_read_source', page=page, config=config)
+
+    def on_page_markdown(
+        self, markdown: str, *, page: Page, config: MkDocsConfig, files: Files
+    ) -> str:
+        return self.run_event('page_markdown', markdown, page=page, config=config, files=files)
+
+    def on_page_content(self, html: str, *, page: Page, config: MkDocsConfig, files: Files) -> str:
+        return self.run_event('page_content', html, page=page, config=config, files=files)
+
+    def on_page_context(
+        self, context: dict[str, Any], *, page: Page, config: MkDocsConfig, nav: Navigation
+    ) -> dict[str, Any]:
+        return self.run_event('page_context', context, page=page, config=config, nav=nav)
+
+    def on_post_page(self, output: str, *, page: Page, config: MkDocsConfig) -> str:
+        return self.run_event('post_page', output, page=page, config=config)
+
 
 class PrefixedLogger(logging.LoggerAdapter):
     """A logger adapter to prefix log messages."""
diff --git a/mkdocs/structure/__init__.py b/mkdocs/structure/__init__.py
index e69de29b..69356405 100644
--- a/mkdocs/structure/__init__.py
+++ b/mkdocs/structure/__init__.py
@@ -0,0 +1,36 @@
+from __future__ import annotations
+
+import abc
+from typing import TYPE_CHECKING, Iterable
+
+if TYPE_CHECKING:
+    from mkdocs.structure.nav import Section
+
+
+class StructureItem(metaclass=abc.ABCMeta):
+    """An item in MkDocs structure - see concrete subclasses Section, Page or Link."""
+
+    @abc.abstractmethod
+    def __init__(self):
+        ...
+
+    parent: Section | None = None
+    """The immediate parent of the item in the site navigation. `None` if it's at the top level."""
+
+    @property
+    def is_top_level(self) -> bool:
+        return self.parent is None
+
+    title: str | None
+    is_section: bool = False
+    is_page: bool = False
+    is_link: bool = False
+
+    @property
+    def ancestors(self) -> Iterable[StructureItem]:
+        if self.parent is None:
+            return []
+        return [self.parent, *self.parent.ancestors]
+
+    def _indent_print(self, depth=0):
+        return ('    ' * depth) + repr(self)
diff --git a/mkdocs/structure/files.py b/mkdocs/structure/files.py
index 49f7565a..7b47f638 100644
--- a/mkdocs/structure/files.py
+++ b/mkdocs/structure/files.py
@@ -8,7 +8,7 @@ import posixpath
 import shutil
 import warnings
 from pathlib import PurePath
-from typing import TYPE_CHECKING, Any, Callable, Iterable, Iterator, Mapping, Sequence
+from typing import TYPE_CHECKING, Callable, Iterable, Iterator, Sequence
 from urllib.parse import quote as urlquote
 
 import jinja2.environment
@@ -319,7 +319,7 @@ class File:
 _default_exclude = pathspec.gitignore.GitIgnoreSpec.from_lines(['.*', '/templates/'])
 
 
-def _set_exclusions(files: Iterable[File], config: MkDocsConfig | Mapping[str, Any]) -> None:
+def _set_exclusions(files: Iterable[File], config: MkDocsConfig) -> None:
     """Re-calculate which files are excluded, based on the patterns in the config."""
     exclude: pathspec.gitignore.GitIgnoreSpec | None = config.get('exclude_docs')
     exclude = _default_exclude + exclude if exclude else _default_exclude
@@ -335,7 +335,7 @@ 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]] = []
diff --git a/mkdocs/structure/nav.py b/mkdocs/structure/nav.py
index 4e7b7dc3..f2aadd9f 100644
--- a/mkdocs/structure/nav.py
+++ b/mkdocs/structure/nav.py
@@ -1,9 +1,10 @@
 from __future__ import annotations
 
 import logging
-from typing import TYPE_CHECKING, Any, Iterator, Mapping, TypeVar
+from typing import TYPE_CHECKING, Iterator, TypeVar
 from urllib.parse import urlsplit
 
+from mkdocs.structure import StructureItem
 from mkdocs.structure.pages import Page
 from mkdocs.utils import nest_paths
 
@@ -16,7 +17,7 @@ log = logging.getLogger(__name__)
 
 
 class Navigation:
-    def __init__(self, items: list[Page | Section | Link], pages: list[Page]) -> None:
+    def __init__(self, items: list, pages: list[Page]) -> None:
         self.items = items  # Nested List with full navigation of Sections, Pages, and Links.
         self.pages = pages  # Flat List of subset of Pages in nav, in order.
 
@@ -32,34 +33,30 @@ class Navigation:
     pages: list[Page]
     """A flat list of all [page][mkdocs.structure.pages.Page] objects contained in the navigation."""
 
-    def __repr__(self):
+    def __str__(self) -> str:
         return '\n'.join(item._indent_print() for item in self)
 
-    def __iter__(self) -> Iterator[Page | Section | Link]:
+    def __iter__(self) -> Iterator:
         return iter(self.items)
 
     def __len__(self) -> int:
         return len(self.items)
 
 
-class Section:
-    def __init__(self, title: str, children: list[Page | Section | Link]) -> None:
+class Section(StructureItem):
+    def __init__(self, title: str, children: list[StructureItem]) -> None:
         self.title = title
         self.children = children
 
-        self.parent = None
         self.active = False
 
     def __repr__(self):
-        return f"Section(title='{self.title}')"
+        return f"Section(title={self.title!r})"
 
     title: str
     """The title of the section."""
 
-    parent: Section | None
-    """The immediate parent of the section or `None` if the section is at the top level."""
-
-    children: list[Page | Section | Link]
+    children: list[StructureItem]
     """An iterable of all child navigation objects. Children may include nested sections, pages and links."""
 
     @property
@@ -87,28 +84,21 @@ class Section:
     is_link: bool = False
     """Indicates that the navigation object is a "link" object. Always `False` for section objects."""
 
-    @property
-    def ancestors(self):
-        if self.parent is None:
-            return []
-        return [self.parent] + self.parent.ancestors
-
-    def _indent_print(self, depth=0):
-        ret = ['{}{}'.format('    ' * depth, repr(self))]
+    def _indent_print(self, depth: int = 0):
+        ret = [super()._indent_print(depth)]
         for item in self.children:
             ret.append(item._indent_print(depth + 1))
         return '\n'.join(ret)
 
 
-class Link:
+class Link(StructureItem):
     def __init__(self, title: str, url: str):
         self.title = title
         self.url = url
-        self.parent = None
 
     def __repr__(self):
-        title = f"'{self.title}'" if (self.title is not None) else '[blank]'
-        return f"Link(title={title}, url='{self.url}')"
+        title = f"{self.title!r}" if self.title is not None else '[blank]'
+        return f"Link(title={title}, url={self.url!r})"
 
     title: str
     """The title of the link. This would generally be used as the label of the link."""
@@ -117,9 +107,6 @@ class Link:
     """The URL that the link points to. The URL should always be an absolute URLs and
     should not need to have `base_url` prepended."""
 
-    parent: Section | None
-    """The immediate parent of the link. `None` if the link is at the top level."""
-
     children: None = None
     """Links do not contain children and the attribute is always `None`."""
 
@@ -135,17 +122,8 @@ class Link:
     is_link: bool = True
     """Indicates that the navigation object is a "link" object. Always `True` for link objects."""
 
-    @property
-    def ancestors(self):
-        if self.parent is None:
-            return []
-        return [self.parent] + self.parent.ancestors
-
-    def _indent_print(self, depth=0):
-        return '{}{}'.format('    ' * depth, repr(self))
-
 
-def get_navigation(files: Files, config: MkDocsConfig | Mapping[str, Any]) -> Navigation:
+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)
@@ -193,7 +171,7 @@ def get_navigation(files: Files, config: MkDocsConfig | Mapping[str, Any]) -> Na
     return Navigation(items, pages)
 
 
-def _data_to_navigation(data, files: Files, config: MkDocsConfig | Mapping[str, Any]):
+def _data_to_navigation(data, files: Files, config: MkDocsConfig):
     if isinstance(data, dict):
         return [
             _data_to_navigation((key, value), files, config)
diff --git a/mkdocs/structure/pages.py b/mkdocs/structure/pages.py
index 4f38f2ea..5f2676af 100644
--- a/mkdocs/structure/pages.py
+++ b/mkdocs/structure/pages.py
@@ -5,7 +5,7 @@ import logging
 import os
 import posixpath
 import warnings
-from typing import TYPE_CHECKING, Any, Callable, Mapping, MutableMapping
+from typing import TYPE_CHECKING, Any, Callable, MutableMapping
 from urllib.parse import unquote as urlunquote
 from urllib.parse import urljoin, urlsplit, urlunsplit
 
@@ -15,6 +15,7 @@ import markdown.postprocessors
 import markdown.treeprocessors
 from markdown.util import AMP_SUBSTITUTE
 
+from mkdocs.structure import StructureItem
 from mkdocs.structure.toc import get_toc
 from mkdocs.utils import get_build_date, get_markdown_title, meta, weak_property
 
@@ -23,7 +24,6 @@ if TYPE_CHECKING:
 
     from mkdocs.config.defaults import MkDocsConfig
     from mkdocs.structure.files import File, Files
-    from mkdocs.structure.nav import Section
     from mkdocs.structure.toc import TableOfContents
 
 _unescape: Callable[[str], str]
@@ -36,17 +36,14 @@ except AttributeError:
 log = logging.getLogger(__name__)
 
 
-class Page:
-    def __init__(
-        self, title: str | None, file: File, config: MkDocsConfig | Mapping[str, Any]
-    ) -> None:
+class Page(StructureItem):
+    def __init__(self, title: str | None, file: File, config: MkDocsConfig) -> None:
         file.page = self
         self.file = file
         if title is not None:
             self.title = title
 
         # Navigation attributes
-        self.parent = None
         self.children = None
         self.previous_page = None
         self.next_page = None
@@ -74,12 +71,9 @@ class Page:
         )
 
     def __repr__(self):
-        title = f"'{self.title}'" if (self.title is not None) else '[blank]'
+        title = f"{self.title!r}" if self.title is not None else '[blank]'
         url = self.abs_url or self.file.url
-        return f"Page(title={title}, url='{url}')"
-
-    def _indent_print(self, depth=0):
-        return '{}{}'.format('    ' * depth, repr(self))
+        return f"Page(title={title}, url={url!r})"
 
     markdown: str | None
     """The original Markdown content from the file."""
@@ -133,10 +127,6 @@ class Page:
     def is_index(self) -> bool:
         return self.file.name == 'index'
 
-    @property
-    def is_top_level(self) -> bool:
-        return self.parent is None
-
     edit_url: str | None
     """The full URL to the source page in the source repository. Typically used to
     provide a link to edit the source page. [base_url][] should not be used with this
@@ -157,10 +147,6 @@ class Page:
     The value will be `None` if the current page is the last item in the site navigation
     or if the current page is not included in the navigation at all."""
 
-    parent: Section | None
-    """The immediate parent of the page in the site navigation. `None` if the
-    page is at the top level."""
-
     children: None = None
     """Pages do not contain children and the attribute is always `None`."""
 
@@ -173,12 +159,6 @@ class Page:
     is_link: bool = False
     """Indicates that the navigation object is a "link" object. Always `False` for page objects."""
 
-    @property
-    def ancestors(self):
-        if self.parent is None:
-            return []
-        return [self.parent] + self.parent.ancestors
-
     def _set_canonical_url(self, base: str | None) -> None:
         if base:
             if not base.endswith('/'):
@@ -222,7 +202,7 @@ class Page:
             self.edit_url = None
 
     def read_source(self, config: MkDocsConfig) -> None:
-        source = config['plugins'].run_event('page_read_source', page=self, config=config)
+        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:
@@ -242,7 +222,7 @@ class Page:
         )
 
     @weak_property
-    def title(self) -> str | None:
+    def title(self) -> str | None:  # type: ignore[override]
         """
         Returns the title for the current page.
 
diff --git a/mkdocs/themes/mkdocs/base.html b/mkdocs/themes/mkdocs/base.html
index 2dbebd72..83def3a5 100644
--- a/mkdocs/themes/mkdocs/base.html
+++ b/mkdocs/themes/mkdocs/base.html
@@ -5,7 +5,7 @@
         <meta charset="utf-8">
         <meta http-equiv="X-UA-Compatible" content="IE=edge">
         <meta name="viewport" content="width=device-width, initial-scale=1.0">
-        {% if page and page.is_homepage %}<meta name="description" content="{{ config['site_description'] }}">{% endif %}
+        {% if page and page.is_homepage %}<meta name="description" content="{{ config.site_description }}">{% endif %}
         {% if config.site_author %}<meta name="author" content="{{ config.site_author }}">{% endif %}
         {% if page and page.canonical_url %}<link rel="canonical" href="{{ page.canonical_url }}">{% endif %}
         {% if config.site_favicon %}<link rel="shortcut icon" href="{{ config.site_favicon|url }}">
@@ -110,7 +110,7 @@
 
                     <ul class="nav navbar-nav ml-auto">
                       {%- block search_button %}
-                        {%- if 'search' in config['plugins'] %}
+                        {%- if 'search' in config.plugins %}
                         <li class="nav-item">
                             <a href="#" class="nav-link" data-toggle="modal" data-target="#mkdocs_search_modal">
                                 <i class="fa fa-search"></i> {% trans %}Search{% endtrans %}
@@ -202,7 +202,7 @@
         {%- endfor %}
       {%- endblock %}
 
-        {% if 'search' in config['plugins'] %}{%- include "search-modal.html" %}{% endif %}
+        {% if 'search' in config.plugins %}{%- include "search-modal.html" %}{% endif %}
         {%- include "keyboard-modal.html" %}
 
     </body>
diff --git a/mkdocs/themes/readthedocs/base.html b/mkdocs/themes/readthedocs/base.html
index 6caa756a..6c255e06 100644
--- a/mkdocs/themes/readthedocs/base.html
+++ b/mkdocs/themes/readthedocs/base.html
@@ -104,7 +104,7 @@
         </div>
       {%- endif %}
       {%- block search_button %}
-        {%- if 'search' in config['plugins'] %}{%- include "searchbox.html" %}{%- endif %}
+        {%- if 'search' in config.plugins %}{%- include "searchbox.html" %}{%- endif %}
       {%- endblock %}
       </div>
 


Test patch:
diff --git a/mkdocs/tests/base.py b/mkdocs/tests/base.py
index 28e3fd3c..c480eaa2 100644
--- a/mkdocs/tests/base.py
+++ b/mkdocs/tests/base.py
@@ -23,20 +23,17 @@ def get_markdown_toc(markdown_source):
     return md.toc_tokens
 
 
-def load_config(**cfg) -> MkDocsConfig:
+def load_config(config_file_path: str | None = None, **cfg) -> MkDocsConfig:
     """Helper to build a simple config for testing."""
     path_base = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'integration', 'minimal')
-    cfg = cfg or {}
     if 'site_name' not in cfg:
         cfg['site_name'] = 'Example'
-    if 'config_file_path' not in cfg:
-        cfg['config_file_path'] = os.path.join(path_base, 'mkdocs.yml')
     if 'docs_dir' not in cfg:
         # Point to an actual dir to avoid a 'does not exist' error on validation.
         cfg['docs_dir'] = os.path.join(path_base, 'docs')
     if 'plugins' not in cfg:
         cfg['plugins'] = []
-    conf = MkDocsConfig(config_file_path=cfg['config_file_path'])
+    conf = MkDocsConfig(config_file_path=config_file_path or os.path.join(path_base, 'mkdocs.yml'))
     conf.load_dict(cfg)
 
     errors_warnings = conf.validate()
diff --git a/mkdocs/tests/config/config_tests.py b/mkdocs/tests/config/config_tests.py
index 10d092e9..c43690c4 100644
--- a/mkdocs/tests/config/config_tests.py
+++ b/mkdocs/tests/config/config_tests.py
@@ -218,13 +218,10 @@ class ConfigTests(unittest.TestCase):
                 self.assertEqual({k: conf['theme'][k] for k in iter(conf['theme'])}, result['vars'])
 
     def test_empty_nav(self):
-        conf = defaults.MkDocsConfig()
-        conf.load_dict(
-            {
-                'site_name': 'Example',
-                'config_file_path': os.path.join(os.path.abspath('.'), 'mkdocs.yml'),
-            }
+        conf = defaults.MkDocsConfig(
+            config_file_path=os.path.join(os.path.abspath('.'), 'mkdocs.yml')
         )
+        conf.load_dict({'site_name': 'Example'})
         conf.validate()
         self.assertEqual(conf['nav'], None)
 
@@ -242,10 +239,8 @@ class ConfigTests(unittest.TestCase):
         self.assertEqual(warnings, [])
 
     def test_doc_dir_in_site_dir(self):
-        j = os.path.join
-
         test_configs = (
-            {'docs_dir': j('site', 'docs'), 'site_dir': 'site'},
+            {'docs_dir': os.path.join('site', 'docs'), 'site_dir': 'site'},
             {'docs_dir': 'docs', 'site_dir': '.'},
             {'docs_dir': '.', 'site_dir': '.'},
             {'docs_dir': 'docs', 'site_dir': ''},
@@ -253,23 +248,17 @@ class ConfigTests(unittest.TestCase):
             {'docs_dir': 'docs', 'site_dir': 'docs'},
         )
 
-        cfg = {
-            'config_file_path': j(os.path.abspath('..'), 'mkdocs.yml'),
-        }
-
         for test_config in test_configs:
             with self.subTest(test_config):
-                patch = {**cfg, **test_config}
-
                 # Same as the default schema, but don't verify the docs_dir exists.
                 conf = config.Config(
                     schema=(
                         ('docs_dir', c.Dir(default='docs')),
                         ('site_dir', c.SiteDir(default='site')),
-                        ('config_file_path', c.Type(str)),
-                    )
+                    ),
+                    config_file_path=os.path.join(os.path.abspath('..'), 'mkdocs.yml'),
                 )
-                conf.load_dict(patch)
+                conf.load_dict(test_config)
 
                 errors, warnings = conf.validate()
 
diff --git a/mkdocs/tests/plugin_tests.py b/mkdocs/tests/plugin_tests.py
index 316bd5c7..7474f607 100644
--- a/mkdocs/tests/plugin_tests.py
+++ b/mkdocs/tests/plugin_tests.py
@@ -29,19 +29,19 @@ class _DummyPluginConfig(base.Config):
 
 
 class DummyPlugin(plugins.BasePlugin[_DummyPluginConfig]):
-    def on_pre_page(self, content, **kwargs):
+    def on_page_content(self, html, **kwargs) -> str:
         """modify page content by prepending `foo` config value."""
-        return f'{self.config.foo} {content}'
+        return f'{self.config.foo} {html}'
 
-    def on_nav(self, item, **kwargs):
+    def on_nav(self, nav, **kwargs) -> None:
         """do nothing (return None) to not modify item."""
         return None
 
-    def on_page_read_source(self, **kwargs):
+    def on_page_read_source(self, **kwargs) -> str:
         """create new source by prepending `foo` config value to 'source'."""
         return f'{self.config.foo} source'
 
-    def on_pre_build(self, **kwargs):
+    def on_pre_build(self, **kwargs) -> None:
         """do nothing (return None)."""
         return None
 
@@ -121,40 +121,40 @@ class TestPluginCollection(unittest.TestCase):
                 'pre_template': [],
                 'template_context': [],
                 'post_template': [],
-                'pre_page': [plugin.on_pre_page],
+                'pre_page': [],
                 'page_read_source': [plugin.on_page_read_source],
                 'page_markdown': [],
-                'page_content': [],
+                'page_content': [plugin.on_page_content],
                 'page_context': [],
                 'post_page': [],
             },
         )
 
-    def test_event_priorities(self):
+    def test_event_priorities(self) -> None:
         class PrioPlugin(plugins.BasePlugin):
             config_scheme = base.get_schema(_DummyPluginConfig)
 
             @plugins.event_priority(100)
-            def on_pre_page(self, content, **kwargs):
+            def on_page_content(self, html, **kwargs) -> None:
                 pass
 
             @plugins.event_priority(-100)
-            def on_nav(self, item, **kwargs):
+            def on_nav(self, nav, **kwargs) -> None:
                 pass
 
-            def on_page_read_source(self, **kwargs):
+            def on_page_read_source(self, **kwargs) -> None:
                 pass
 
             @plugins.event_priority(-50)
-            def on_post_build(self, **kwargs):
+            def on_post_build(self, **kwargs) -> None:
                 pass
 
         collection = plugins.PluginCollection()
         collection['dummy'] = dummy = DummyPlugin()
         collection['prio'] = prio = PrioPlugin()
         self.assertEqual(
-            collection.events['pre_page'],
-            [prio.on_pre_page, dummy.on_pre_page],
+            collection.events['page_content'],
+            [prio.on_page_content, dummy.on_page_content],
         )
         self.assertEqual(
             collection.events['nav'],
@@ -190,7 +190,10 @@ class TestPluginCollection(unittest.TestCase):
         plugin = DummyPlugin()
         plugin.load_config({'foo': 'new'})
         collection['foo'] = plugin
-        self.assertEqual(collection.run_event('pre_page', 'page content'), 'new page content')
+        self.assertEqual(
+            collection.on_page_content('page content', page=None, config={}, files=[]),
+            'new page content',
+        )
 
     def test_run_event_twice_on_collection(self):
         collection = plugins.PluginCollection()
@@ -201,7 +204,8 @@ class TestPluginCollection(unittest.TestCase):
         plugin2.load_config({'foo': 'second'})
         collection['bar'] = plugin2
         self.assertEqual(
-            collection.run_event('pre_page', 'page content'), 'second new page content'
+            collection.on_page_content('page content', page=None, config={}, files=[]),
+            'second new page content',
         )
 
     def test_event_returns_None(self):
@@ -209,25 +213,28 @@ class TestPluginCollection(unittest.TestCase):
         plugin = DummyPlugin()
         plugin.load_config({'foo': 'new'})
         collection['foo'] = plugin
-        self.assertEqual(collection.run_event('nav', 'nav item'), 'nav item')
+        self.assertEqual(collection.on_nav(['nav item'], config={}, files=[]), ['nav item'])
 
     def test_event_empty_item(self):
         collection = plugins.PluginCollection()
         plugin = DummyPlugin()
         plugin.load_config({'foo': 'new'})
         collection['foo'] = plugin
-        self.assertEqual(collection.run_event('page_read_source'), 'new source')
+        self.assertEqual(collection.on_page_read_source(page=None, config={}), 'new source')
 
     def test_event_empty_item_returns_None(self):
         collection = plugins.PluginCollection()
         plugin = DummyPlugin()
         plugin.load_config({'foo': 'new'})
         collection['foo'] = plugin
-        self.assertEqual(collection.run_event('pre_build'), None)
+        self.assertEqual(collection.on_pre_build(config={}), None)
 
     def test_run_undefined_event_on_collection(self):
         collection = plugins.PluginCollection()
-        self.assertEqual(collection.run_event('pre_page', 'page content'), 'page content')
+        self.assertEqual(
+            collection.on_page_markdown('page markdown', page=None, config={}, files=[]),
+            'page markdown',
+        )
 
     def test_run_unknown_event_on_collection(self):
         collection = plugins.PluginCollection()