Repository: mkdocs/mkdocs
Pull request #3448 (2023-10-29 00:13:36)

Allow adding event handlers at multiple priorities
-within one plugin.


Patch:
diff --git a/docs/about/contributing.md b/docs/about/contributing.md
index 49f0fb23..6d03be0a 100644
--- a/docs/about/contributing.md
+++ b/docs/about/contributing.md
@@ -85,7 +85,7 @@ most likely need to have tests and documentation if it is a new feature.
 
 ### Submitting changes to the builtin themes
 
-When installed with `i18n` support (`pip install mkdocs[i18n]`), MkDocs allows
+When installed with `i18n` support (`pip install 'mkdocs[i18n]'`), MkDocs allows
 themes to support being translated into various languages (referred to as
 locales) if they respect [Jinja's i18n extension] by wrapping text placeholders
 with `{% trans %}` and `{% endtrans %}` tags.
diff --git a/docs/about/release-notes.md b/docs/about/release-notes.md
index d00f5be1..3eab10e7 100644
--- a/docs/about/release-notes.md
+++ b/docs/about/release-notes.md
@@ -733,7 +733,7 @@ happen by default. Users must first install the necessary dependencies with
 the following command:
 
 ```bash
-pip install mkdocs[i18n]
+pip install 'mkdocs[i18n]'
 ```
 
 Translation contributions are welcome and detailed in the [Translation
diff --git a/docs/dev-guide/plugins.md b/docs/dev-guide/plugins.md
index 3ec2ac90..8d4a8c63 100644
--- a/docs/dev-guide/plugins.md
+++ b/docs/dev-guide/plugins.md
@@ -429,6 +429,12 @@ Since MkDocs 1.4, plugins can choose to set a priority value for their events. E
 
 #### ::: mkdocs.plugins.event_priority
 
+There may also arise a need to register a handler for the same event at multiple different priorities.
+
+`CombinedEvent` makes this possible since MkDocs 1.6.
+
+#### ::: mkdocs.plugins.CombinedEvent
+
 ### Handling Errors
 
 MkDocs defines four error types:
diff --git a/docs/dev-guide/themes.md b/docs/dev-guide/themes.md
index 0bba0edd..51290b66 100644
--- a/docs/dev-guide/themes.md
+++ b/docs/dev-guide/themes.md
@@ -1038,7 +1038,7 @@ WARNING:
 As **[pybabel] is not installed by default** and most users will not have
 pybabel installed, theme developers and/or translators should make sure to
 have installed the necessary dependencies
-(using `pip install mkdocs[i18n]`) in order for the commands to be
+(using `pip install 'mkdocs[i18n]'`) in order for the commands to be
 available for use.
 
 The translation commands should be called from the root of your theme's working tree.
diff --git a/docs/dev-guide/translations.md b/docs/dev-guide/translations.md
index 13d0226e..b01f5703 100644
--- a/docs/dev-guide/translations.md
+++ b/docs/dev-guide/translations.md
@@ -38,7 +38,7 @@ are working from a properly configured development environment.
 Make sure translation requirements are installed in your environment:
 
 ```bash
-pip install mkdocs[i18n]
+pip install 'mkdocs[i18n]'
 ```
 
 [babel]: https://babel.pocoo.org/en/latest/cmdline.html
diff --git a/docs/user-guide/configuration.md b/docs/user-guide/configuration.md
index 9c6eff4e..8353b0cf 100644
--- a/docs/user-guide/configuration.md
+++ b/docs/user-guide/configuration.md
@@ -333,7 +333,9 @@ exclude_docs: |
 
 NEW: **New in version 1.5.**
 
-NOTE: This option does *not* actually exclude anything from the nav.
+> NEW: **New in version 1.6:**
+>
+> If the [`nav`](#nav) config is not specified at all, pages specified in this config will now be excluded from the inferred navigation.
 
 If you want to include some docs into the site but intentionally exclude them from the nav, normally MkDocs warns about this.
 
@@ -798,6 +800,8 @@ You might have seen this feature in the [mkdocs-simple-hooks plugin](https://git
 A list of plugins (with optional configuration settings) to use when building
 the site. See the [Plugins] documentation for full details.
 
+**default**: `['search']` (the "search" plugin included with MkDocs).
+
 If the `plugins` config setting is defined in the `mkdocs.yml` config file, then
 any defaults (such as `search`) are ignored and you need to explicitly re-enable
 the defaults if you would like to continue using them:
@@ -818,6 +822,30 @@ plugins:
       option2: other value
 ```
 
+To completely disable all plugins, including any defaults, set the `plugins`
+setting to an empty list:
+
+```yaml
+plugins: []
+```
+
+#### `enabled` option
+
+> NEW: **New in MkDocs 1.6.**
+>
+> Each plugin has its own options keys. However MkDocs also ensures that each plugin has the `enabled` boolean option. This can be used to conditionally enable a particular plugin, as in the following example:
+>
+> ```yaml
+> plugins:
+>   - search
+>   - code-validator:
+>       enabled: !ENV [LINT, false]
+> ```
+>
+> See: [Environment variables](#environment-variables)
+
+#### Alternate syntax
+
 In the above examples, each plugin is a list item (starts with a `-`). As an
 alternative, key/value pairs can be used instead. However, in that case an empty
 value must be provided for plugins for which no options are defined. Therefore,
@@ -834,15 +862,6 @@ plugins:
 This alternative syntax is required if you intend to override some options via
 [inheritance].
 
-To completely disable all plugins, including any defaults, set the `plugins`
-setting to an empty list:
-
-```yaml
-plugins: []
-```
-
-**default**: `['search']` (the "search" plugin included with MkDocs).
-
 #### Search
 
 A search plugin is provided by default with MkDocs which uses [lunr.js] as a
diff --git a/docs/user-guide/localizing-your-theme.md b/docs/user-guide/localizing-your-theme.md
index 42c81fc9..279f4a8e 100644
--- a/docs/user-guide/localizing-your-theme.md
+++ b/docs/user-guide/localizing-your-theme.md
@@ -17,7 +17,7 @@ For theme localization to work, you must use a theme which supports it and
 enable `i18n` (internationalization) support by installing `mkdocs[i18n]`:
 
 ```bash
-pip install mkdocs[i18n]
+pip install 'mkdocs[i18n]'
 ```
 
 ## Supported locales
diff --git a/mkdocs/commands/build.py b/mkdocs/commands/build.py
index e8af54fb..4a21a3da 100644
--- a/mkdocs/commands/build.py
+++ b/mkdocs/commands/build.py
@@ -13,7 +13,7 @@ from jinja2.exceptions import TemplateNotFound
 import mkdocs
 from mkdocs import utils
 from mkdocs.exceptions import Abort, BuildError
-from mkdocs.structure.files import File, Files, InclusionLevel, _set_exclusions, get_files
+from mkdocs.structure.files import File, Files, InclusionLevel, get_files, set_exclusions
 from mkdocs.structure.nav import Navigation, get_navigation
 from mkdocs.structure.pages import Page
 from mkdocs.utils import DuplicateFilter  # noqa: F401 - legacy re-export
@@ -294,7 +294,7 @@ def build(
         # 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._files, config)
 
         nav = get_navigation(files, config)
 
@@ -305,8 +305,8 @@ def build(
         excluded = []
         for file in files.documentation_pages(inclusion=inclusion):
             log.debug(f"Reading: {file.src_uri}")
-            if file.page is None and file.inclusion.is_excluded():
-                if live_server:
+            if file.page is None and file.inclusion.is_not_in_nav():
+                if live_server and file.inclusion.is_excluded():
                     excluded.append(urljoin(live_server.url, file.url))
                 Page(None, file, config)
             assert file.page is not None
diff --git a/mkdocs/commands/serve.py b/mkdocs/commands/serve.py
index 0062b91c..dc472a66 100644
--- a/mkdocs/commands/serve.py
+++ b/mkdocs/commands/serve.py
@@ -7,11 +7,8 @@ from os.path import isdir, isfile, join
 from typing import TYPE_CHECKING
 from urllib.parse import urlsplit
 
-import jinja2.exceptions
-
 from mkdocs.commands.build import build
 from mkdocs.config import load_config
-from mkdocs.exceptions import Abort
 from mkdocs.livereload import LiveReloadServer
 
 if TYPE_CHECKING:
@@ -107,12 +104,6 @@ def serve(
             log.info("Shutting down...")
         finally:
             server.shutdown()
-    except jinja2.exceptions.TemplateError:
-        # This is a subclass of OSError, but shouldn't be suppressed.
-        raise
-    except OSError as e:  # pragma: no cover
-        # Avoid ugly, unhelpful traceback
-        raise Abort(f'{type(e).__name__}: {e}')
     finally:
         config.plugins.on_shutdown()
         if isdir(site_dir):
diff --git a/mkdocs/config/config_options.py b/mkdocs/config/config_options.py
index 434dfd6a..aacff403 100644
--- a/mkdocs/config/config_options.py
+++ b/mkdocs/config/config_options.py
@@ -45,6 +45,8 @@ from mkdocs.exceptions import ConfigurationError
 T = TypeVar('T')
 SomeConfig = TypeVar('SomeConfig', bound=Config)
 
+log = logging.getLogger(__name__)
+
 
 class SubConfig(Generic[SomeConfig], BaseConfigOption[SomeConfig]):
     """
@@ -1137,6 +1139,17 @@ class Plugins(OptionallyRequired[plugins.PluginCollection]):
                 "because the plugin doesn't declare `supports_multiple_instances`."
             )
 
+        # Only if the plugin doesn't have its own "enabled" config, apply a generic one.
+        if 'enabled' in config and not any(pair[0] == 'enabled' for pair in plugin.config_scheme):
+            enabled = config.pop('enabled')
+            if not isinstance(enabled, bool):
+                raise ValidationError(
+                    f"Plugin '{name}' option 'enabled': Expected boolean but received: {type(enabled)}"
+                )
+            if not enabled:
+                log.debug(f"Plugin '{inst_name}' is disabled in the config, skipping.")
+                return plugin
+
         errors, warns = plugin.load_config(
             config, self._config.config_file_path if self._config else None
         )
diff --git a/mkdocs/localization.py b/mkdocs/localization.py
index b9c70790..aff643fd 100644
--- a/mkdocs/localization.py
+++ b/mkdocs/localization.py
@@ -49,9 +49,9 @@ def install_translations(
         env.add_extension('jinja2.ext.i18n')
         translations = _get_merged_translations(theme_dirs, 'locales', locale)
         if translations is not None:
-            env.install_gettext_translations(translations)
+            env.install_gettext_translations(translations)  # type: ignore[attr-defined]
         else:
-            env.install_null_translations()
+            env.install_null_translations()  # type: ignore[attr-defined]
             if locale.language != 'en':
                 log.warning(
                     f"No translations could be found for the locale '{locale}'. "
@@ -60,7 +60,7 @@ def install_translations(
     else:  # pragma: no cover
         # no babel installed, add dummy support for trans/endtrans blocks
         env.add_extension(NoBabelExtension)
-        env.install_null_translations()
+        env.install_null_translations()  # type: ignore[attr-defined]
 
 
 def _get_merged_translations(
@@ -80,6 +80,8 @@ def _get_merged_translations(
         if type(translations) is NullTranslations:
             log.debug(f"No translations found here: '{dirname}'")
             continue
+        if TYPE_CHECKING:
+            assert isinstance(translations, Translations)
 
         log.debug(f"Translations found here: '{dirname}'")
         if merged_translations is None:
diff --git a/mkdocs/plugins.py b/mkdocs/plugins.py
index fe55b4b7..98a99f38 100644
--- a/mkdocs/plugins.py
+++ b/mkdocs/plugins.py
@@ -24,6 +24,14 @@ if TYPE_CHECKING:
     from mkdocs.structure.pages import Page
     from mkdocs.utils.templates import TemplateContext
 
+if TYPE_CHECKING:
+    from typing_extensions import Concatenate, ParamSpec
+else:
+    ParamSpec = TypeVar
+
+P = ParamSpec('P')
+T = TypeVar('T')
+
 
 log = logging.getLogger('mkdocs.plugins')
 
@@ -123,7 +131,7 @@ class BasePlugin(Generic[SomeConfig]):
         """
 
     def on_serve(
-        self, server: LiveReloadServer, *, config: MkDocsConfig, builder: Callable
+        self, server: LiveReloadServer, /, *, config: MkDocsConfig, builder: Callable
     ) -> LiveReloadServer | None:
         """
         The `serve` event is only called when the `serve` command is used during
@@ -167,7 +175,7 @@ class BasePlugin(Generic[SomeConfig]):
             config: global configuration object
         """
 
-    def on_files(self, files: Files, *, config: MkDocsConfig) -> Files | None:
+    def on_files(self, files: Files, /, *, config: MkDocsConfig) -> Files | None:
         """
         The `files` event is called after the files collection is populated from the
         `docs_dir`. Use this event to add, remove, or alter files in the
@@ -184,7 +192,9 @@ class BasePlugin(Generic[SomeConfig]):
         """
         return files
 
-    def on_nav(self, nav: Navigation, *, config: MkDocsConfig, files: Files) -> Navigation | None:
+    def on_nav(
+        self, nav: Navigation, /, *, config: MkDocsConfig, files: Files
+    ) -> Navigation | None:
         """
         The `nav` event is called after the site navigation is created and can
         be used to alter the site navigation.
@@ -200,7 +210,7 @@ class BasePlugin(Generic[SomeConfig]):
         return nav
 
     def on_env(
-        self, env: jinja2.Environment, *, config: MkDocsConfig, files: Files
+        self, env: jinja2.Environment, /, *, config: MkDocsConfig, files: Files
     ) -> jinja2.Environment | None:
         """
         The `env` event is called after the Jinja template environment is created
@@ -241,7 +251,7 @@ class BasePlugin(Generic[SomeConfig]):
     # Template events
 
     def on_pre_template(
-        self, template: jinja2.Template, *, template_name: str, config: MkDocsConfig
+        self, template: jinja2.Template, /, *, template_name: str, config: MkDocsConfig
     ) -> jinja2.Template | None:
         """
         The `pre_template` event is called immediately after the subject template is
@@ -258,7 +268,7 @@ class BasePlugin(Generic[SomeConfig]):
         return template
 
     def on_template_context(
-        self, context: TemplateContext, *, template_name: str, config: MkDocsConfig
+        self, context: TemplateContext, /, *, template_name: str, config: MkDocsConfig
     ) -> TemplateContext | None:
         """
         The `template_context` event is called immediately after the context is created
@@ -276,7 +286,7 @@ class BasePlugin(Generic[SomeConfig]):
         return context
 
     def on_post_template(
-        self, output_content: str, *, template_name: str, config: MkDocsConfig
+        self, output_content: str, /, *, template_name: str, config: MkDocsConfig
     ) -> str | None:
         """
         The `post_template` event is called after the template is rendered, but before
@@ -296,7 +306,7 @@ class BasePlugin(Generic[SomeConfig]):
 
     # Page events
 
-    def on_pre_page(self, page: Page, *, config: MkDocsConfig, files: Files) -> Page | None:
+    def on_pre_page(self, page: Page, /, *, config: MkDocsConfig, files: Files) -> Page | None:
         """
         The `pre_page` event is called before any actions are taken on the subject
         page and can be used to alter the `Page` instance.
@@ -311,7 +321,7 @@ class BasePlugin(Generic[SomeConfig]):
         """
         return page
 
-    def on_page_read_source(self, *, page: Page, config: MkDocsConfig) -> str | None:
+    def on_page_read_source(self, /, *, page: Page, config: MkDocsConfig) -> str | None:
         """
         The `on_page_read_source` event can replace the default mechanism to read
         the contents of a page's source from the filesystem.
@@ -327,7 +337,7 @@ class BasePlugin(Generic[SomeConfig]):
         return None
 
     def on_page_markdown(
-        self, markdown: str, *, page: Page, config: MkDocsConfig, files: Files
+        self, markdown: str, /, *, page: Page, config: MkDocsConfig, files: Files
     ) -> str | None:
         """
         The `page_markdown` event is called after the page's markdown is loaded
@@ -346,7 +356,7 @@ class BasePlugin(Generic[SomeConfig]):
         return markdown
 
     def on_page_content(
-        self, html: str, *, page: Page, config: MkDocsConfig, files: Files
+        self, html: str, /, *, page: Page, config: MkDocsConfig, files: Files
     ) -> str | None:
         """
         The `page_content` event is called after the Markdown text is rendered to
@@ -365,7 +375,7 @@ class BasePlugin(Generic[SomeConfig]):
         return html
 
     def on_page_context(
-        self, context: TemplateContext, *, page: Page, config: MkDocsConfig, nav: Navigation
+        self, context: TemplateContext, /, *, page: Page, config: MkDocsConfig, nav: Navigation
     ) -> TemplateContext | None:
         """
         The `page_context` event is called after the context for a page is created
@@ -382,7 +392,7 @@ class BasePlugin(Generic[SomeConfig]):
         """
         return context
 
-    def on_post_page(self, output: str, *, page: Page, config: MkDocsConfig) -> str | None:
+    def on_post_page(self, output: str, /, *, page: Page, config: MkDocsConfig) -> str | None:
         """
         The `post_page` event is called after the template is rendered, but
         before it is written to disc and can be used to alter the output of the
@@ -407,9 +417,6 @@ for k in EVENTS:
     delattr(BasePlugin, 'on_' + k)
 
 
-T = TypeVar('T')
-
-
 def event_priority(priority: float) -> Callable[[T], T]:
     """
     A decorator to set an event priority for an event handler method.
@@ -418,6 +425,8 @@ def event_priority(priority: float) -> Callable[[T], T]:
     `100` "first", `50` "early", `0` "default", `-50` "late", `-100` "last".
     As different plugins discover more precise relations to each other, the values should be further tweaked.
 
+    Usage example:
+
     ```python
     @plugins.event_priority(-100)  # Wishing to run this after all other plugins' `on_files` events.
     def on_files(self, files, config, **kwargs):
@@ -442,6 +451,39 @@ def event_priority(priority: float) -> Callable[[T], T]:
     return decorator
 
 
+class CombinedEvent(Generic[P, T]):
+    """
+    A descriptor that allows defining multiple event handlers and declaring them under one event's name.
+
+    Usage example:
+
+    ```python
+    @plugins.event_priority(100)
+    def _on_page_markdown_1(self, markdown: str, **kwargs):
+        ...
+
+    @plugins.event_priority(-50)
+    def _on_page_markdown_2(self, markdown: str, **kwargs):
+        ...
+
+    on_page_markdown = plugins.CombinedEvent(_on_page_markdown_1, _on_page_markdown_2)
+    ```
+
+    NOTE: The names of the sub-methods **can't** start with `on_`;
+    instead they can start with `_on_` like in the the above example, or anything else.
+    """
+
+    def __init__(self, *methods: Callable[Concatenate[Any, P], T]):
+        self.methods = methods
+
+    # This is only for mypy, so CombinedEvent can be a valid override of the methods in BasePlugin
+    def __call__(self, instance: BasePlugin, *args: P.args, **kwargs: P.kwargs) -> T:
+        raise TypeError(f"{type(self).__name__!r} object is not callable")
+
+    def __get__(self, instance, owner=None):
+        return CombinedEvent(*(f.__get__(instance, owner) for f in self.methods))
+
+
 class PluginCollection(dict, MutableMapping[str, BasePlugin]):
     """
     A collection of plugins.
@@ -457,17 +499,21 @@ class PluginCollection(dict, MutableMapping[str, BasePlugin]):
         self._event_origins: dict[Callable, str] = {}
 
     def _register_event(
-        self, event_name: str, method: Callable, plugin_name: str | None = None
+        self, event_name: str, method: CombinedEvent | Callable, plugin_name: str | None = None
     ) -> None:
         """Register a method for an event."""
-        utils.insort(
-            self.events[event_name], method, key=lambda m: -getattr(m, 'mkdocs_priority', 0)
-        )
-        if plugin_name:
-            try:
-                self._event_origins[method] = plugin_name
-            except TypeError:  # If the method is somehow not hashable.
-                pass
+        if isinstance(method, CombinedEvent):
+            for sub in method.methods:
+                self._register_event(event_name, sub, plugin_name=plugin_name)
+        else:
+            utils.insort(
+                self.events[event_name], method, key=lambda m: -getattr(m, 'mkdocs_priority', 0)
+            )
+            if plugin_name:
+                try:
+                    self._event_origins[method] = plugin_name
+                except TypeError:  # If the method is somehow not hashable.
+                    pass
 
     def __getitem__(self, key: str) -> BasePlugin:
         return super().__getitem__(key)
diff --git a/mkdocs/structure/files.py b/mkdocs/structure/files.py
index b1c4df5a..01d2f639 100644
--- a/mkdocs/structure/files.py
+++ b/mkdocs/structure/files.py
@@ -322,7 +322,7 @@ class File:
 _default_exclude = pathspec.gitignore.GitIgnoreSpec.from_lines(['.*', '/templates/'])
 
 
-def _set_exclusions(files: Iterable[File], config: MkDocsConfig) -> 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
@@ -362,7 +362,7 @@ def get_files(config: MkDocsConfig) -> Files:
             files.append(file)
             prev_file = file
 
-    _set_exclusions(files, config)
+    set_exclusions(files, config)
     # Skip README.md if an index file also exists in dir (part 2)
     for a, b in conflicting_files:
         if b.inclusion.is_included():
diff --git a/mkdocs/structure/nav.py b/mkdocs/structure/nav.py
index 25d3e97b..d122c821 100644
--- a/mkdocs/structure/nav.py
+++ b/mkdocs/structure/nav.py
@@ -1,10 +1,10 @@
 from __future__ import annotations
 
 import logging
-import warnings
 from typing import TYPE_CHECKING, Iterator, TypeVar
 from urllib.parse import urlsplit
 
+from mkdocs.exceptions import BuildError
 from mkdocs.structure import StructureItem
 from mkdocs.structure.pages import Page
 from mkdocs.utils import nest_paths
@@ -129,7 +129,9 @@ 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)
+    nav_config = config['nav'] or 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]
@@ -202,20 +204,9 @@ def _data_to_navigation(data, files: Files, config: MkDocsConfig):
             )
         page = file.page
         if page is not None:
-            if isinstance(page, Page):
-                if type(page) is not Page:  # Strict subclass
-                    return page
-                warnings.warn(
-                    "A plugin has set File.page to an instance of Page and it got overwritten. "
-                    "The behavior of this will change in MkDocs 1.6.",
-                    DeprecationWarning,
-                )
-            else:
-                warnings.warn(  # type: ignore[unreachable]
-                    "A plugin has set File.page to a type other than Page. "
-                    "This will be an error in MkDocs 1.6.",
-                    DeprecationWarning,
-                )
+            if not isinstance(page, Page):
+                raise BuildError("A plugin has set File.page to a type other than Page.")
+            return page
         return Page(title, file, config)
     return Link(title, path)
 
diff --git a/mkdocs/structure/pages.py b/mkdocs/structure/pages.py
index b7204b94..84c1430f 100644
--- a/mkdocs/structure/pages.py
+++ b/mkdocs/structure/pages.py
@@ -392,6 +392,9 @@ class _RelativePathTreeprocessor(markdown.treeprocessors.Treeprocessor):
                 )
 
         if warning:
+            if self.file.inclusion.is_excluded():
+                warning_level = min(logging.INFO, warning_level)
+
             # There was no match, so try to guess what other file could've been intended.
             if warning_level > logging.DEBUG:
                 suggest_url = ''
@@ -414,7 +417,10 @@ class _RelativePathTreeprocessor(markdown.treeprocessors.Treeprocessor):
         assert target_uri is not None
         assert target_file is not None
         if target_file.inclusion.is_excluded():
-            warning_level = min(logging.INFO, self.config.validation.links.not_found)
+            if self.file.inclusion.is_excluded():
+                warning_level = logging.DEBUG
+            else:
+                warning_level = min(logging.INFO, self.config.validation.links.not_found)
             warning = (
                 f"Doc file '{self.file.src_uri}' contains a link to "
                 f"'{target_uri}' which is excluded from the built site."
diff --git a/pyproject.toml b/pyproject.toml
index 1d966e5d..0a9a102d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -150,9 +150,9 @@ matrix.type.features = [
 [tool.hatch.envs.types]
 dependencies = [
     "mypy",
-    "types-babel",
-    "types-Jinja2",
+    "babel",
     "types-Markdown",
+    "types-pytz",
     "types-PyYAML",
     "types-setuptools",
     "typing-extensions",


Test patch:
diff --git a/mkdocs/tests/build_tests.py b/mkdocs/tests/build_tests.py
index f668f369..275aad37 100644
--- a/mkdocs/tests/build_tests.py
+++ b/mkdocs/tests/build_tests.py
@@ -582,7 +582,7 @@ class BuildTests(PathAssertionMixin, unittest.TestCase):
     @tempdir(
         files={
             'test/foo.md': 'page1 content, [bar](bar.md)',
-            'test/bar.md': 'page2 content, [baz](baz.md)',
+            'test/bar.md': 'page2 content, [baz](baz.md), [nonexistent](nonexistent.md)',
             'test/baz.md': 'page3 content, [foo](foo.md)',
             '.zoo.md': 'page4 content',
         }
@@ -609,7 +609,7 @@ class BuildTests(PathAssertionMixin, unittest.TestCase):
         server = testing_server(site_dir, mount_path='/documentation/')
         with self.subTest(live_server=server):
             expected_logs = '''
-                INFO:Doc file 'test/bar.md' contains a link to 'test/baz.md' which is excluded from the built site.
+                INFO:Doc file 'test/bar.md' contains a relative link 'nonexistent.md', but the target 'test/nonexistent.md' is not found among documentation files.
                 INFO:Doc file 'test/foo.md' contains a link to 'test/bar.md' which is excluded from the built site.
                 INFO:The following pages are being built only for the preview but will be excluded from `mkdocs build` per `exclude_docs`:
                   - http://localhost:123/documentation/.zoo.html
diff --git a/mkdocs/tests/config/config_options_tests.py b/mkdocs/tests/config/config_options_tests.py
index 2c8a33ae..ecab8707 100644
--- a/mkdocs/tests/config/config_options_tests.py
+++ b/mkdocs/tests/config/config_options_tests.py
@@ -1926,6 +1926,15 @@ class FakePlugin2(BasePlugin[_FakePlugin2Config]):
     supports_multiple_instances = True
 
 
+class _EnabledPluginConfig(Config):
+    enabled = c.Type(bool, default=True)
+    bar = c.Type(int, default=0)
+
+
+class EnabledPlugin(BasePlugin[_EnabledPluginConfig]):
+    pass
+
+
 class ThemePlugin(BasePlugin[_FakePluginConfig]):
     pass
 
@@ -1949,6 +1958,7 @@ class FakeEntryPoint:
         return_value=[
             FakeEntryPoint('sample', FakePlugin),
             FakeEntryPoint('sample2', FakePlugin2),
+            FakeEntryPoint('sample-e', EnabledPlugin),
             FakeEntryPoint('readthedocs/sub_plugin', ThemePlugin),
             FakeEntryPoint('overridden', FakePlugin2),
             FakeEntryPoint('readthedocs/overridden', ThemePlugin2),
@@ -2096,6 +2106,49 @@ class PluginsTest(TestCase):
         self.assertEqual(set(conf.plugins), {'overridden'})
         self.assertIsInstance(conf.plugins['overridden'], FakePlugin2)
 
+    def test_plugin_config_enabled_for_any_plugin(self) -> None:
+        class Schema(Config):
+            theme = c.Theme(default='mkdocs')
+            plugins = c.Plugins(theme_key='theme')
+
+        cfg = {'theme': 'readthedocs', 'plugins': {'sample': {'enabled': False, 'bar': 3}}}
+        conf = self.get_config(Schema, cfg)
+        self.assertEqual(set(conf.plugins), set())
+
+        cfg = {'theme': 'readthedocs', 'plugins': {'sample': {'enabled': True, 'bar': 3}}}
+        conf = self.get_config(Schema, cfg)
+        self.assertEqual(set(conf.plugins), {'sample'})
+        self.assertEqual(conf.plugins['sample'].config.bar, 3)
+
+        cfg = {'theme': 'readthedocs', 'plugins': {'sample': {'enabled': 5}}}
+        with self.expect_error(
+            plugins="Plugin 'sample' option 'enabled': Expected boolean but received: <class 'int'>"
+        ):
+            self.get_config(Schema, cfg)
+
+    def test_plugin_config_enabled_for_plugin_with_setting(self) -> None:
+        class Schema(Config):
+            theme = c.Theme(default='mkdocs')
+            plugins = c.Plugins(theme_key='theme')
+
+        cfg = {'theme': 'readthedocs', 'plugins': {'sample-e': {'enabled': False, 'bar': 3}}}
+        conf = self.get_config(Schema, cfg)
+        self.assertEqual(set(conf.plugins), {'sample-e'})
+        self.assertEqual(conf.plugins['sample-e'].config.enabled, False)
+        self.assertEqual(conf.plugins['sample-e'].config.bar, 3)
+
+        cfg = {'theme': 'readthedocs', 'plugins': {'sample-e': {'enabled': True, 'bar': 3}}}
+        conf = self.get_config(Schema, cfg)
+        self.assertEqual(set(conf.plugins), {'sample-e'})
+        self.assertEqual(conf.plugins['sample-e'].config.enabled, True)
+        self.assertEqual(conf.plugins['sample-e'].config.bar, 3)
+
+        cfg = {'theme': 'readthedocs', 'plugins': {'sample-e': {'enabled': 5}}}
+        with self.expect_error(
+            plugins="Plugin 'sample-e' option 'enabled': Expected type: <class 'bool'> but received: <class 'int'>"
+        ):
+            self.get_config(Schema, cfg)
+
     def test_plugin_config_with_multiple_instances(self) -> None:
         class Schema(Config):
             theme = c.Theme(default='mkdocs')
diff --git a/mkdocs/tests/plugin_tests.py b/mkdocs/tests/plugin_tests.py
index 6fa10142..8c09cf4f 100644
--- a/mkdocs/tests/plugin_tests.py
+++ b/mkdocs/tests/plugin_tests.py
@@ -7,6 +7,8 @@ from typing import TYPE_CHECKING, Optional
 
 if TYPE_CHECKING:
     from typing_extensions import assert_type
+
+    from mkdocs.structure.nav import Navigation
 else:
 
     def assert_type(val, typ):
@@ -138,10 +140,16 @@ class TestPluginCollection(unittest.TestCase):
             def on_page_content(self, html, **kwargs) -> None:
                 pass
 
+            @plugins.event_priority(50)
+            def _on_nav_1(self, nav: Navigation, **kwargs) -> None:
+                pass
+
             @plugins.event_priority(-100)
-            def on_nav(self, nav, **kwargs) -> None:
+            def _on_nav_2(self, nav, **kwargs) -> None:
                 pass
 
+            on_nav = plugins.CombinedEvent(_on_nav_1, _on_nav_2)
+
             def on_page_read_source(self, **kwargs) -> None:
                 pass
 
@@ -158,7 +166,7 @@ class TestPluginCollection(unittest.TestCase):
         )
         self.assertEqual(
             collection.events['nav'],
-            [dummy.on_nav, prio.on_nav],
+            [prio._on_nav_1, dummy.on_nav, prio._on_nav_2],
         )
         self.assertEqual(
             collection.events['page_read_source'],
diff --git a/mkdocs/tests/structure/file_tests.py b/mkdocs/tests/structure/file_tests.py
index 1c301cb5..200efd73 100644
--- a/mkdocs/tests/structure/file_tests.py
+++ b/mkdocs/tests/structure/file_tests.py
@@ -79,256 +79,167 @@ class TestFiles(PathAssertionMixin, unittest.TestCase):
         )
 
     def test_md_file(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')
-        self.assertEqual(f.dest_uri, 'foo.html')
-        self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo.html')
-        self.assertEqual(f.url, 'foo.html')
-        self.assertEqual(f.name, 'foo')
-        self.assertTrue(f.is_documentation_page())
-        self.assertFalse(f.is_static_page())
-        self.assertFalse(f.is_media_file())
-        self.assertFalse(f.is_javascript())
-        self.assertFalse(f.is_css())
-
-    def test_md_file_use_directory_urls(self):
-        f = File('foo.md', '/path/to/docs', '/path/to/site', use_directory_urls=True)
-        self.assertEqual(f.src_uri, 'foo.md')
-        self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo.md')
-        self.assertEqual(f.dest_uri, 'foo/index.html')
-        self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/index.html')
-        self.assertEqual(f.url, 'foo/')
-        self.assertEqual(f.name, 'foo')
-        self.assertTrue(f.is_documentation_page())
-        self.assertFalse(f.is_static_page())
-        self.assertFalse(f.is_media_file())
-        self.assertFalse(f.is_javascript())
-        self.assertFalse(f.is_css())
+        for use_directory_urls in True, False:
+            with self.subTest(use_directory_urls=use_directory_urls):
+                f = File('foo.md', '/path/to/docs', '/path/to/site', use_directory_urls)
+                self.assertEqual(f.src_uri, 'foo.md')
+                self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo.md')
+                if use_directory_urls:
+                    self.assertEqual(f.dest_uri, 'foo/index.html')
+                    self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/index.html')
+                    self.assertEqual(f.url, 'foo/')
+                else:
+                    self.assertEqual(f.dest_uri, 'foo.html')
+                    self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo.html')
+                    self.assertEqual(f.url, 'foo.html')
+                self.assertEqual(f.name, 'foo')
+                self.assertTrue(f.is_documentation_page())
+                self.assertFalse(f.is_static_page())
+                self.assertFalse(f.is_media_file())
+                self.assertFalse(f.is_javascript())
+                self.assertFalse(f.is_css())
 
     def test_md_file_nested(self):
-        f = File('foo/bar.md', '/path/to/docs', '/path/to/site', use_directory_urls=False)
-        self.assertEqual(f.src_uri, 'foo/bar.md')
-        self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.md')
-        self.assertEqual(f.dest_uri, 'foo/bar.html')
-        self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.html')
-        self.assertEqual(f.url, 'foo/bar.html')
-        self.assertEqual(f.name, 'bar')
-        self.assertTrue(f.is_documentation_page())
-        self.assertFalse(f.is_static_page())
-        self.assertFalse(f.is_media_file())
-        self.assertFalse(f.is_javascript())
-        self.assertFalse(f.is_css())
-
-    def test_md_file_nested_use_directory_urls(self):
-        f = File('foo/bar.md', '/path/to/docs', '/path/to/site', use_directory_urls=True)
-        self.assertEqual(f.src_uri, 'foo/bar.md')
-        self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.md')
-        self.assertEqual(f.dest_uri, 'foo/bar/index.html')
-        self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar/index.html')
-        self.assertEqual(f.url, 'foo/bar/')
-        self.assertEqual(f.name, 'bar')
-        self.assertTrue(f.is_documentation_page())
-        self.assertFalse(f.is_static_page())
-        self.assertFalse(f.is_media_file())
-        self.assertFalse(f.is_javascript())
-        self.assertFalse(f.is_css())
+        for use_directory_urls in True, False:
+            with self.subTest(use_directory_urls=use_directory_urls):
+                f = File('foo/bar.md', '/path/to/docs', '/path/to/site', use_directory_urls)
+                self.assertEqual(f.src_uri, 'foo/bar.md')
+                self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.md')
+                if use_directory_urls:
+                    self.assertEqual(f.dest_uri, 'foo/bar/index.html')
+                    self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar/index.html')
+                    self.assertEqual(f.url, 'foo/bar/')
+                else:
+                    self.assertEqual(f.dest_uri, 'foo/bar.html')
+                    self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.html')
+                    self.assertEqual(f.url, 'foo/bar.html')
+                self.assertEqual(f.name, 'bar')
+                self.assertTrue(f.is_documentation_page())
+                self.assertFalse(f.is_static_page())
+                self.assertFalse(f.is_media_file())
+                self.assertFalse(f.is_javascript())
+                self.assertFalse(f.is_css())
 
     def test_md_index_file(self):
-        f = File('index.md', '/path/to/docs', '/path/to/site', use_directory_urls=False)
-        self.assertEqual(f.src_uri, 'index.md')
-        self.assertPathsEqual(f.abs_src_path, '/path/to/docs/index.md')
-        self.assertEqual(f.dest_uri, 'index.html')
-        self.assertPathsEqual(f.abs_dest_path, '/path/to/site/index.html')
-        self.assertEqual(f.url, 'index.html')
-        self.assertEqual(f.name, 'index')
-        self.assertTrue(f.is_documentation_page())
-        self.assertFalse(f.is_static_page())
-        self.assertFalse(f.is_media_file())
-        self.assertFalse(f.is_javascript())
-        self.assertFalse(f.is_css())
+        for use_directory_urls in True, False:
+            with self.subTest(use_directory_urls=use_directory_urls):
+                f = File('index.md', '/path/to/docs', '/path/to/site', use_directory_urls)
+                self.assertEqual(f.src_uri, 'index.md')
+                self.assertPathsEqual(f.abs_src_path, '/path/to/docs/index.md')
+                self.assertEqual(f.dest_uri, 'index.html')
+                self.assertPathsEqual(f.abs_dest_path, '/path/to/site/index.html')
+                if use_directory_urls:
+                    self.assertEqual(f.url, './')
+                else:
+                    self.assertEqual(f.url, 'index.html')
+                self.assertEqual(f.name, 'index')
+                self.assertTrue(f.is_documentation_page())
+                self.assertFalse(f.is_static_page())
+                self.assertFalse(f.is_media_file())
+                self.assertFalse(f.is_javascript())
+                self.assertFalse(f.is_css())
 
     def test_md_readme_index_file(self):
-        f = File('README.md', '/path/to/docs', '/path/to/site', use_directory_urls=False)
-        self.assertEqual(f.src_uri, 'README.md')
-        self.assertPathsEqual(f.abs_src_path, '/path/to/docs/README.md')
-        self.assertEqual(f.dest_uri, 'index.html')
-        self.assertPathsEqual(f.abs_dest_path, '/path/to/site/index.html')
-        self.assertEqual(f.url, 'index.html')
-        self.assertEqual(f.name, 'index')
-        self.assertTrue(f.is_documentation_page())
-        self.assertFalse(f.is_static_page())
-        self.assertFalse(f.is_media_file())
-        self.assertFalse(f.is_javascript())
-        self.assertFalse(f.is_css())
-
-    def test_md_index_file_use_directory_urls(self):
-        f = File('index.md', '/path/to/docs', '/path/to/site', use_directory_urls=True)
-        self.assertEqual(f.src_uri, 'index.md')
-        self.assertPathsEqual(f.abs_src_path, '/path/to/docs/index.md')
-        self.assertEqual(f.dest_uri, 'index.html')
-        self.assertPathsEqual(f.abs_dest_path, '/path/to/site/index.html')
-        self.assertEqual(f.url, './')
-        self.assertEqual(f.name, 'index')
-        self.assertTrue(f.is_documentation_page())
-        self.assertFalse(f.is_static_page())
-        self.assertFalse(f.is_media_file())
-        self.assertFalse(f.is_javascript())
-        self.assertFalse(f.is_css())
-
-    def test_md_readme_index_file_use_directory_urls(self):
-        f = File('README.md', '/path/to/docs', '/path/to/site', use_directory_urls=True)
-        self.assertEqual(f.src_uri, 'README.md')
-        self.assertPathsEqual(f.abs_src_path, '/path/to/docs/README.md')
-        self.assertEqual(f.dest_uri, 'index.html')
-        self.assertPathsEqual(f.abs_dest_path, '/path/to/site/index.html')
-        self.assertEqual(f.url, './')
-        self.assertEqual(f.name, 'index')
-        self.assertTrue(f.is_documentation_page())
-        self.assertFalse(f.is_static_page())
-        self.assertFalse(f.is_media_file())
-        self.assertFalse(f.is_javascript())
-        self.assertFalse(f.is_css())
+        for use_directory_urls in True, False:
+            with self.subTest(use_directory_urls=use_directory_urls):
+                f = File('README.md', '/path/to/docs', '/path/to/site', use_directory_urls)
+                self.assertEqual(f.src_uri, 'README.md')
+                self.assertPathsEqual(f.abs_src_path, '/path/to/docs/README.md')
+                self.assertEqual(f.dest_uri, 'index.html')
+                self.assertPathsEqual(f.abs_dest_path, '/path/to/site/index.html')
+                if use_directory_urls:
+                    self.assertEqual(f.url, './')
+                else:
+                    self.assertEqual(f.url, 'index.html')
+                self.assertEqual(f.name, 'index')
+                self.assertTrue(f.is_documentation_page())
+                self.assertFalse(f.is_static_page())
+                self.assertFalse(f.is_media_file())
+                self.assertFalse(f.is_javascript())
+                self.assertFalse(f.is_css())
 
     def test_md_index_file_nested(self):
-        f = File('foo/index.md', '/path/to/docs', '/path/to/site', use_directory_urls=False)
-        self.assertEqual(f.src_uri, 'foo/index.md')
-        self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/index.md')
-        self.assertEqual(f.dest_uri, 'foo/index.html')
-        self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/index.html')
-        self.assertEqual(f.url, 'foo/index.html')
-        self.assertEqual(f.name, 'index')
-        self.assertTrue(f.is_documentation_page())
-        self.assertFalse(f.is_static_page())
-        self.assertFalse(f.is_media_file())
-        self.assertFalse(f.is_javascript())
-        self.assertFalse(f.is_css())
-
-    def test_md_index_file_nested_use_directory_urls(self):
-        f = File('foo/index.md', '/path/to/docs', '/path/to/site', use_directory_urls=True)
-        self.assertEqual(f.src_uri, 'foo/index.md')
-        self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/index.md')
-        self.assertEqual(f.dest_uri, 'foo/index.html')
-        self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/index.html')
-        self.assertEqual(f.url, 'foo/')
-        self.assertEqual(f.name, 'index')
-        self.assertTrue(f.is_documentation_page())
-        self.assertFalse(f.is_static_page())
-        self.assertFalse(f.is_media_file())
-        self.assertFalse(f.is_javascript())
-        self.assertFalse(f.is_css())
+        for use_directory_urls in True, False:
+            with self.subTest(use_directory_urls=use_directory_urls):
+                f = File('foo/index.md', '/path/to/docs', '/path/to/site', use_directory_urls)
+                self.assertEqual(f.src_uri, 'foo/index.md')
+                self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/index.md')
+                self.assertEqual(f.dest_uri, 'foo/index.html')
+                self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/index.html')
+                if use_directory_urls:
+                    self.assertEqual(f.url, 'foo/')
+                else:
+                    self.assertEqual(f.url, 'foo/index.html')
+                self.assertEqual(f.name, 'index')
+                self.assertTrue(f.is_documentation_page())
+                self.assertFalse(f.is_static_page())
+                self.assertFalse(f.is_media_file())
+                self.assertFalse(f.is_javascript())
+                self.assertFalse(f.is_css())
 
     def test_static_file(self):
-        f = File('foo/bar.html', '/path/to/docs', '/path/to/site', use_directory_urls=False)
-        self.assertEqual(f.src_uri, 'foo/bar.html')
-        self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.html')
-        self.assertEqual(f.dest_uri, 'foo/bar.html')
-        self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.html')
-        self.assertEqual(f.url, 'foo/bar.html')
-        self.assertEqual(f.name, 'bar')
-        self.assertFalse(f.is_documentation_page())
-        self.assertTrue(f.is_static_page())
-        self.assertFalse(f.is_media_file())
-        self.assertFalse(f.is_javascript())
-        self.assertFalse(f.is_css())
-
-    def test_static_file_use_directory_urls(self):
-        f = File('foo/bar.html', '/path/to/docs', '/path/to/site', use_directory_urls=True)
-        self.assertEqual(f.src_uri, 'foo/bar.html')
-        self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.html')
-        self.assertEqual(f.dest_uri, 'foo/bar.html')
-        self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.html')
-        self.assertEqual(f.url, 'foo/bar.html')
-        self.assertEqual(f.name, 'bar')
-        self.assertFalse(f.is_documentation_page())
-        self.assertTrue(f.is_static_page())
-        self.assertFalse(f.is_media_file())
-        self.assertFalse(f.is_javascript())
-        self.assertFalse(f.is_css())
+        for use_directory_urls in True, False:
+            with self.subTest(use_directory_urls=use_directory_urls):
+                f = File('foo/bar.html', '/path/to/docs', '/path/to/site', use_directory_urls)
+                self.assertEqual(f.src_uri, 'foo/bar.html')
+                self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.html')
+                self.assertEqual(f.dest_uri, 'foo/bar.html')
+                self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.html')
+                self.assertEqual(f.url, 'foo/bar.html')
+                self.assertEqual(f.name, 'bar')
+                self.assertFalse(f.is_documentation_page())
+                self.assertTrue(f.is_static_page())
+                self.assertFalse(f.is_media_file())
+                self.assertFalse(f.is_javascript())
+                self.assertFalse(f.is_css())
 
     def test_media_file(self):
-        f = File('foo/bar.jpg', '/path/to/docs', '/path/to/site', use_directory_urls=False)
-        self.assertEqual(f.src_uri, 'foo/bar.jpg')
-        self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.jpg')
-        self.assertEqual(f.dest_uri, 'foo/bar.jpg')
-        self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.jpg')
-        self.assertEqual(f.url, 'foo/bar.jpg')
-        self.assertEqual(f.name, 'bar')
-        self.assertFalse(f.is_documentation_page())
-        self.assertFalse(f.is_static_page())
-        self.assertTrue(f.is_media_file())
-        self.assertFalse(f.is_javascript())
-        self.assertFalse(f.is_css())
-
-    def test_media_file_use_directory_urls(self):
-        f = File('foo/bar.jpg', '/path/to/docs', '/path/to/site', use_directory_urls=True)
-        self.assertEqual(f.src_uri, 'foo/bar.jpg')
-        self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.jpg')
-        self.assertEqual(f.dest_uri, 'foo/bar.jpg')
-        self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.jpg')
-        self.assertEqual(f.url, 'foo/bar.jpg')
-        self.assertEqual(f.name, 'bar')
-        self.assertFalse(f.is_documentation_page())
-        self.assertFalse(f.is_static_page())
-        self.assertTrue(f.is_media_file())
-        self.assertFalse(f.is_javascript())
-        self.assertFalse(f.is_css())
+        for use_directory_urls in True, False:
+            with self.subTest(use_directory_urls=use_directory_urls):
+                f = File('foo/bar.jpg', '/path/to/docs', '/path/to/site', use_directory_urls)
+                self.assertEqual(f.src_uri, 'foo/bar.jpg')
+                self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.jpg')
+                self.assertEqual(f.dest_uri, 'foo/bar.jpg')
+                self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.jpg')
+                self.assertEqual(f.url, 'foo/bar.jpg')
+                self.assertEqual(f.name, 'bar')
+                self.assertFalse(f.is_documentation_page())
+                self.assertFalse(f.is_static_page())
+                self.assertTrue(f.is_media_file())
+                self.assertFalse(f.is_javascript())
+                self.assertFalse(f.is_css())
 
     def test_javascript_file(self):
-        f = File('foo/bar.js', '/path/to/docs', '/path/to/site', use_directory_urls=False)
-        self.assertEqual(f.src_uri, 'foo/bar.js')
-        self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.js')
-        self.assertEqual(f.dest_uri, 'foo/bar.js')
-        self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.js')
-        self.assertEqual(f.url, 'foo/bar.js')
-        self.assertEqual(f.name, 'bar')
-        self.assertFalse(f.is_documentation_page())
-        self.assertFalse(f.is_static_page())
-        self.assertTrue(f.is_media_file())
-        self.assertTrue(f.is_javascript())
-        self.assertFalse(f.is_css())
-
-    def test_javascript_file_use_directory_urls(self):
-        f = File('foo/bar.js', '/path/to/docs', '/path/to/site', use_directory_urls=True)
-        self.assertEqual(f.src_uri, 'foo/bar.js')
-        self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.js')
-        self.assertEqual(f.dest_uri, 'foo/bar.js')
-        self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.js')
-        self.assertEqual(f.url, 'foo/bar.js')
-        self.assertEqual(f.name, 'bar')
-        self.assertFalse(f.is_documentation_page())
-        self.assertFalse(f.is_static_page())
-        self.assertTrue(f.is_media_file())
-        self.assertTrue(f.is_javascript())
-        self.assertFalse(f.is_css())
+        for use_directory_urls in True, False:
+            with self.subTest(use_directory_urls=use_directory_urls):
+                f = File('foo/bar.js', '/path/to/docs', '/path/to/site', use_directory_urls)
+                self.assertEqual(f.src_uri, 'foo/bar.js')
+                self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.js')
+                self.assertEqual(f.dest_uri, 'foo/bar.js')
+                self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.js')
+                self.assertEqual(f.url, 'foo/bar.js')
+                self.assertEqual(f.name, 'bar')
+                self.assertFalse(f.is_documentation_page())
+                self.assertFalse(f.is_static_page())
+                self.assertTrue(f.is_media_file())
+                self.assertTrue(f.is_javascript())
+                self.assertFalse(f.is_css())
 
     def test_css_file(self):
-        f = File('foo/bar.css', '/path/to/docs', '/path/to/site', use_directory_urls=False)
-        self.assertEqual(f.src_uri, 'foo/bar.css')
-        self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.css')
-        self.assertEqual(f.dest_uri, 'foo/bar.css')
-        self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.css')
-        self.assertEqual(f.url, 'foo/bar.css')
-        self.assertEqual(f.name, 'bar')
-        self.assertFalse(f.is_documentation_page())
-        self.assertFalse(f.is_static_page())
-        self.assertTrue(f.is_media_file())
-        self.assertFalse(f.is_javascript())
-        self.assertTrue(f.is_css())
-
-    def test_css_file_use_directory_urls(self):
-        f = File('foo/bar.css', '/path/to/docs', '/path/to/site', use_directory_urls=True)
-        self.assertEqual(f.src_uri, 'foo/bar.css')
-        self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.css')
-        self.assertEqual(f.dest_uri, 'foo/bar.css')
-        self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.css')
-        self.assertEqual(f.url, 'foo/bar.css')
-        self.assertEqual(f.name, 'bar')
-        self.assertFalse(f.is_documentation_page())
-        self.assertFalse(f.is_static_page())
-        self.assertTrue(f.is_media_file())
-        self.assertFalse(f.is_javascript())
-        self.assertTrue(f.is_css())
+        for use_directory_urls in True, False:
+            with self.subTest(use_directory_urls=use_directory_urls):
+                f = File('foo/bar.css', '/path/to/docs', '/path/to/site', use_directory_urls)
+                self.assertEqual(f.src_uri, 'foo/bar.css')
+                self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.css')
+                self.assertEqual(f.dest_uri, 'foo/bar.css')
+                self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.css')
+                self.assertEqual(f.url, 'foo/bar.css')
+                self.assertEqual(f.name, 'bar')
+                self.assertFalse(f.is_documentation_page())
+                self.assertFalse(f.is_static_page())
+                self.assertTrue(f.is_media_file())
+                self.assertFalse(f.is_javascript())
+                self.assertTrue(f.is_css())
 
     def test_file_name_with_space(self):
         f = File('foo bar.md', '/path/to/docs', '/path/to/site', use_directory_urls=False)
diff --git a/mkdocs/tests/structure/nav_tests.py b/mkdocs/tests/structure/nav_tests.py
index 44ae2adc..04846111 100644
--- a/mkdocs/tests/structure/nav_tests.py
+++ b/mkdocs/tests/structure/nav_tests.py
@@ -3,7 +3,7 @@
 import sys
 import unittest
 
-from mkdocs.structure.files import File, Files
+from mkdocs.structure.files import File, Files, set_exclusions
 from mkdocs.structure.nav import Section, _get_by_type, get_navigation
 from mkdocs.structure.pages import Page
 from mkdocs.tests.base import dedent, load_config
@@ -378,6 +378,35 @@ class SiteNavigationTests(unittest.TestCase):
         self.assertEqual(len(site_navigation.pages), 7)
         self.assertEqual(repr(site_navigation.homepage), "Page(title=[blank], url='/')")
 
+    def test_nav_with_exclusion(self):
+        expected = dedent(
+            """
+            Page(title=[blank], url='index.html')
+            Section(title='About')
+                Page(title=[blank], url='about/license.html')
+                Page(title=[blank], url='about/release-notes.html')
+            Section(title='Api guide')
+                Page(title=[blank], url='api-guide/running.html')
+                Page(title=[blank], url='api-guide/testing.html')
+            """
+        )
+        cfg = load_config(use_directory_urls=False, not_in_nav='*ging.md\n/foo.md\n')
+        fs = [
+            'index.md',
+            'foo.md',
+            'about/license.md',
+            'about/release-notes.md',
+            'api-guide/debugging.md',
+            'api-guide/running.md',
+            'api-guide/testing.md',
+        ]
+        files = Files([File(s, cfg.docs_dir, cfg.site_dir, cfg.use_directory_urls) for s in fs])
+        set_exclusions(files, cfg)
+        site_navigation = get_navigation(files, cfg)
+        self.assertEqual(str(site_navigation).strip(), expected)
+        self.assertEqual(len(site_navigation.items), 3)
+        self.assertEqual(len(site_navigation.pages), 5)
+
     def test_nav_page_subclass(self):
         class PageSubclass(Page):
             pass