Repository: mkdocs/mkdocs
Pull request #2978 (2022-09-15 19:12:07)

Hooks feature: run plugin events from local Python files
Add a new `hooks` config to `mkdocs.yml`:

A list of paths to Python scripts (relative to `mkdocs.yml`) that are loaded and used as [plugin](https://www.mkdocs.org/dev-guide/plugins/) instances.

For example:

```yaml
hooks:
    - my_hooks.py
```

Then the file *my_hooks.py* can contain any [plugin event handlers](https://www.mkdocs.org/dev-guide/plugins/#events) (without `self`), e.g.:

```python
def on_page_markdown(markdown, **kwargs):
    return markdown.replace('a', 'z')
```

This does not enable any new abilities compared to plugins, it only simplifies one-off usages, as these don't need to be *installed* like plugins do.

Note that for `mkdocs serve` the hook module will *not* be reloaded on each build.

You might have seen this feature in the [mkdocs-simple-hooks plugin](https://github.com/aklajnert/mkdocs-simple-hooks) by @aklajnert. If using standard method names, it can be directly replaced, e.g.:

```diff
-plugins:
-  - mkdocs-simple-hooks:
-      hooks:
-        on_page_markdown: 'my_hooks:on_page_markdown'
+hooks:
+  - my_hooks.py
```


ultrabug: > You might have seen this feature in the [mkdocs-simple-hooks plugin](https://github.com/aklajnert/mkdocs-simple-hooks) by @aklajnert. If using standard method names, it can be directly replaced, e.g.:

Maybe as a courtesy for @aklajnert you could share your rationale in implementing this straight in MkDocs (other than not having to *install* something)?

Some users may argue that we often push back such ideas to the responsibility of plugins, so I'd like it to be clear here.

Thanks
oprypin: Sure, you have a point there.

I think it's been too many times that I wanted to suggest a solution to someone in Discussions which was just to throw a few lines of code into a file, at which point installing that plugin constituted more than half of the remaining complexity.

And this plays better with (mainly new) MkDocs features: priorities also work, persisting state within `mkdocs serve` works, paths are handled correctly with `mkdocs build -f foo/mkdocs.yml`.

And pushing back to plugins is something that I want to reduce, particularly when a feature is unlikely to invite "configuration creep", that is, when people would each start to request a tiny tweak to the configuration for their use case.
Here I expect to require no configuration.
aklajnert: FYI, initially I asked to put it into the core (see #2091), but I decided it will be faster, and easier to implement as a plugin. 

Patch:
diff --git a/docs/css/extra.css b/docs/css/extra.css
index 75040dcd..334b05a8 100644
--- a/docs/css/extra.css
+++ b/docs/css/extra.css
@@ -23,6 +23,11 @@ code.no-highlight {
 div.admonition.block>.admonition-title {
     display: none;
 }
+div.admonition.new {
+    color: #15654a;
+    background-color: #e4f7f1;
+    border-color: #bcf1e8;
+}
 
 /* Definition List styles */
 
diff --git a/docs/dev-guide/index.md b/docs/dev-guide/README.md
similarity index 89%
rename from docs/dev-guide/index.md
rename to docs/dev-guide/README.md
index b91d76a5..183365e0 100644
--- a/docs/dev-guide/index.md
+++ b/docs/dev-guide/README.md
@@ -11,6 +11,8 @@ below, or use the *next* and *previous* buttons in the navigation bar at the
 top of the page to move through the documentation in order.
 
 - [Themes](themes.md)
+- [Translations](translations.md)
 - [Plugins](plugins.md)
+- [API Reference](api.md)
 
 [Contributing Guide]: ../about/contributing.md
diff --git a/docs/dev-guide/plugins.md b/docs/dev-guide/plugins.md
index e8267356..77c29b97 100644
--- a/docs/dev-guide/plugins.md
+++ b/docs/dev-guide/plugins.md
@@ -319,34 +319,19 @@ For each event type, corresponding methods of plugins are called in the order th
 
 Since MkDocs 1.4, plugins can choose to set a priority value for their events. Events with higher priority are called first. Events without a chosen priority get a default of 0. Events that have the same priority are ordered as they appear in the config.
 
-::: mkdocs.plugins.event_priority
-    options:
-      show_root_heading: true
+#### ::: mkdocs.plugins.event_priority
 
 ### Handling Errors
 
 MkDocs defines four error types:
 
-#### `mkdocs.exceptions.MkDocsException`
-
-*   The base class which all MkDocs exceptions inherit from. This should
-    not be raised directly. One of the subclasses should be raised instead.
-
-#### `mkdocs.exceptions.ConfigurationError`
-
-*   This error is raised by configuration validation when a validation error
-    is encountered. This error should be raised by any configuration options
-    defined in a plugin's [config_scheme].
-
-#### `mkdocs.exceptions.BuildError`
+#### ::: mkdocs.exceptions.MkDocsException
 
-*   This error may be raised by MkDocs during the build process. Plugins should
-    not raise this error.
+#### ::: mkdocs.exceptions.ConfigurationError
 
-#### `mkdocs.exceptions.PluginError`
+#### ::: mkdocs.exceptions.BuildError
 
-*   A subclass of `mkdocs.exceptions.BuildError` which can be raised by plugin
-    events.
+#### ::: mkdocs.exceptions.PluginError
 
 Unexpected and uncaught exceptions will interrupt the build process and produce
 typical Python tracebacks, which are useful for debugging your code. However,
@@ -428,4 +413,3 @@ tell MkDocs to use it via the config.
 [Template Events]: #template-events
 [MkDocs Plugins]: https://github.com/mkdocs/mkdocs/wiki/MkDocs-Plugins
 [on_build_error]: #on_build_error
-[config_scheme]: #config_scheme
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 50f3a078..3c324149 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -218,4 +218,4 @@ To get help with MkDocs, please use the [GitHub discussions] or [GitHub issues].
 [site_name]: user-guide/configuration.md#site_name
 [site_url]: user-guide/configuration.md#site_url
 [theme]: user-guide/configuration.md#theme
-[User Guide]: user-guide/index.md
+[User Guide]: user-guide/README.md
diff --git a/docs/index.md b/docs/index.md
index f58af717..5f35674d 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -11,7 +11,7 @@ configuration file. Start by reading the [introductory tutorial], then check the
 [User Guide] for more information.
 
 [introductory tutorial]: getting-started.md
-[User Guide]: user-guide/index.md
+[User Guide]: user-guide/README.md
 
 <div class="text-center">
 <a href="getting-started/" class="btn btn-primary" role="button">Getting Started</a>
diff --git a/docs/user-guide/index.md b/docs/user-guide/README.md
similarity index 92%
rename from docs/user-guide/index.md
rename to docs/user-guide/README.md
index 7e8dfa9a..efa58d4c 100644
--- a/docs/user-guide/index.md
+++ b/docs/user-guide/README.md
@@ -13,6 +13,7 @@ bar at the top of the page to move through the documentation in order.
 - [Writing Your Docs](writing-your-docs.md)
 - [Choosing Your Theme](choosing-your-theme.md)
 - [Customizing Your Theme](customizing-your-theme.md)
+- [Localizing Your Theme](localizing-your-theme.md)
 - [Configuration](configuration.md)
 - [Deploying Your Docs](deploying-your-docs.md)
 
diff --git a/docs/user-guide/choosing-your-theme.md b/docs/user-guide/choosing-your-theme.md
index 107600d6..df2431f5 100644
--- a/docs/user-guide/choosing-your-theme.md
+++ b/docs/user-guide/choosing-your-theme.md
@@ -109,6 +109,8 @@ supports the following options:
     * `fa`: Persian (Farsi)
     * `it`: Italian
     * `tr_TR`: Turkish (Turkey)
+    * `ru`: Russian
+    * `uk`: Ukrainian
 
     See the guide on [localizing your theme] for more information.
 
@@ -190,6 +192,8 @@ theme supports the following options:
     * `fa`: Persian (Farsi)
     * `it`: Italian
     * `tr_TR`: Turkish (Turkey)
+    * `ru`: Russian
+    * `uk`: Ukrainian
 
     See the guide on [localizing your theme] for more information.
 
diff --git a/docs/user-guide/configuration.md b/docs/user-guide/configuration.md
index 8d4c2660..f32a26a2 100644
--- a/docs/user-guide/configuration.md
+++ b/docs/user-guide/configuration.md
@@ -480,6 +480,8 @@ creates links that point directly to the target *file* rather than the target
 Determines how warnings are handled. Set to `true` to halt processing when a
 warning is raised. Set to `false` to print a warning and continue processing.
 
+This is also available as a command line flag: `--strict`.
+
 **default**: `false`
 
 ### dev_addr
@@ -577,6 +579,56 @@ This alternative syntax is required if you intend to override some options via
 
 **default**: `[]` (an empty list).
 
+### hooks
+
+NEW: **New in version 1.4.**
+
+A list of paths to Python scripts (relative to `mkdocs.yml`) that are loaded and used as [plugin](#plugins) instances.
+
+For example:
+
+```yaml
+hooks:
+    - my_hooks.py
+```
+
+Then the file *my_hooks.py* can contain any [plugin event handlers](../dev-guide/plugins.md#events) (without `self`), e.g.:
+
+```python
+def on_page_markdown(markdown, **kwargs):
+    return markdown.replace('a', 'z')
+```
+
+Advanced example that produces warnings based on the Markdown content (and warnings are fatal in [strict](#strict) mode):
+
+```python
+import logging, re
+import mkdocs.plugins
+
+log = logging.getLogger('mkdocs')
+
+@mkdocs.plugins.event_priority(-50)
+def on_page_markdown(markdown, page, **kwargs):
+    path = page.file.src_uri
+    for m in re.finditer(r'\bhttp://[^) ]+', markdown):
+        log.warning(f"Documentation file '{path}' contains a non-HTTPS link: {m[0]}")
+```
+
+This does not enable any new abilities compared to [plugins][], it only simplifies one-off usages, as these don't need to be *installed* like plugins do.
+
+Note that for `mkdocs serve` the hook module will *not* be reloaded on each build.
+
+You might have seen this feature in the [mkdocs-simple-hooks plugin](https://github.com/aklajnert/mkdocs-simple-hooks). If using standard method names, it can be directly replaced, e.g.:
+
+```diff
+-plugins:
+-  - mkdocs-simple-hooks:
+-      hooks:
+-        on_page_markdown: 'my_hooks:on_page_markdown'
++hooks:
++  - my_hooks.py
+```
+
 ### plugins
 
 A list of plugins (with optional configuration settings) to use when building
diff --git a/mkdocs.yml b/mkdocs.yml
index 4b505f4f..c542166d 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -18,21 +18,8 @@ theme:
 nav:
     - Home: index.md
     - Getting Started: getting-started.md
-    - User Guide:
-        - Overview: user-guide/index.md
-        - Installation: user-guide/installation.md
-        - Writing Your Docs: user-guide/writing-your-docs.md
-        - Choosing Your Theme: user-guide/choosing-your-theme.md
-        - Customizing Your Theme: user-guide/customizing-your-theme.md
-        - Localizing Your Theme: user-guide/localizing-your-theme.md
-        - Configuration: user-guide/configuration.md
-        - Deploying Your Docs: user-guide/deploying-your-docs.md
-    - Developer Guide:
-        - Overview: dev-guide/index.md
-        - Themes: dev-guide/themes.md
-        - Translations: dev-guide/translations.md
-        - Plugins: dev-guide/plugins.md
-        - API Reference: dev-guide/api.md
+    - User Guide: user-guide/
+    - Developer Guide: dev-guide/
     - About:
         - Release Notes: about/release-notes.md
         - Contributing: about/contributing.md
@@ -62,6 +49,9 @@ plugins:
             user-guide/custom-themes.md: dev-guide/themes.md
             user-guide/styling-your-docs.md: user-guide/choosing-your-theme.md
     - autorefs
+    - literate-nav:
+        nav_file: README.md
+        implicit_index: true
     - mkdocstrings:
         handlers:
           python:
diff --git a/mkdocs/commands/babel.py b/mkdocs/commands/babel.py
index 2ccadc28..9de5be8f 100644
--- a/mkdocs/commands/babel.py
+++ b/mkdocs/commands/babel.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import sys
 from distutils.errors import DistutilsOptionError
 from os import path
 
@@ -31,6 +32,12 @@ class compile_catalog(babel.compile_catalog, ThemeMixin):
         ("theme=", "t", "theme name to work on"),
     ]
 
+    def run(self):
+        # Possible bug in Babel - produces unused return value:
+        # https://github.com/python-babel/babel/blob/v2.10.3/babel/messages/frontend.py#L194
+        if super().run():
+            sys.exit(1)
+
     def initialize_options(self):
         super().initialize_options()
         self.theme = None
diff --git a/mkdocs/config/config_options.py b/mkdocs/config/config_options.py
index c955b674..c04a3338 100644
--- a/mkdocs/config/config_options.py
+++ b/mkdocs/config/config_options.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import functools
 import ipaddress
 import os
 import string
@@ -798,10 +799,10 @@ class Plugins(OptionallyRequired):
     def run_validation(self, value):
         if not isinstance(value, (list, tuple, dict)):
             raise ValidationError('Invalid Plugins configuration. Expected a list or dict.')
-        plgins = plugins.PluginCollection()
+        self.plugins = plugins.PluginCollection()
         if isinstance(value, dict):
             for name, cfg in value.items():
-                plgins[name] = self.load_plugin(name, cfg)
+                self.plugins[name] = self.load_plugin(name, cfg)
         else:
             for item in value:
                 if isinstance(item, dict):
@@ -811,8 +812,8 @@ class Plugins(OptionallyRequired):
                     item = name
                 else:
                     cfg = {}
-                plgins[item] = self.load_plugin(item, cfg)
-        return plgins
+                self.plugins[item] = self.load_plugin(item, cfg)
+        return self.plugins
 
     def load_plugin(self, name, config):
         if not isinstance(name, str):
@@ -846,3 +847,34 @@ class Plugins(OptionallyRequired):
         if errors_message:
             raise ValidationError(errors_message)
         return plugin
+
+
+class Hooks(ListOfItems):
+    """A list of Python scripts to be treated as instances of plugins."""
+
+    def __init__(self, plugins_key: str):
+        super().__init__(File(exists=True), default=[])
+        self.plugins_key = plugins_key
+
+    def run_validation(self, value):
+        paths = super().run_validation(value)
+        hooks = {}
+        for name, path in zip(value, paths):
+            hooks[name] = self._load_hook(name, path)
+        return hooks
+
+    @functools.lru_cache(maxsize=None)
+    def _load_hook(self, name, path):
+        import importlib.util
+
+        spec = importlib.util.spec_from_file_location(name, path)
+        if spec is None:
+            raise ValidationError(f"Cannot import path '{path}' as a Python module")
+        module = importlib.util.module_from_spec(spec)
+        spec.loader.exec_module(module)
+        return module
+
+    def post_validation(self, config, key_name):
+        plugins = config[self.plugins_key]
+        for name, hook in config[key_name].items():
+            plugins[name] = hook
diff --git a/mkdocs/config/defaults.py b/mkdocs/config/defaults.py
index cf4b4073..4c36a6eb 100644
--- a/mkdocs/config/defaults.py
+++ b/mkdocs/config/defaults.py
@@ -119,5 +119,9 @@ class _MkDocsConfig:
     A key value pair should be the string name (as the key) and a dict of config
     options (as the value)."""
 
+    hooks = config_options.Hooks('plugins')
+    """A list of filenames that will be imported as Python modules and used as
+    an instance of a plugin each."""
+
     watch = config_options.ListOfPaths(default=[])
     """A list of extra paths to watch while running `mkdocs serve`."""
diff --git a/mkdocs/contrib/search/__init__.py b/mkdocs/contrib/search/__init__.py
index 1be6c440..ebff2682 100644
--- a/mkdocs/contrib/search/__init__.py
+++ b/mkdocs/contrib/search/__init__.py
@@ -18,8 +18,10 @@ class LangOption(config_options.OptionallyRequired):
     """Validate Language(s) provided in config are known languages."""
 
     def get_lunr_supported_lang(self, lang):
+        fallback = {'uk': 'ru'}
         for lang_part in lang.split("_"):
             lang_part = lang_part.lower()
+            lang_part = fallback.get(lang_part, lang_part)
             if os.path.isfile(os.path.join(base_path, 'lunr-language', f'lunr.{lang_part}.js')):
                 return lang_part
 
diff --git a/mkdocs/exceptions.py b/mkdocs/exceptions.py
index fc772e4e..e7307ad7 100644
--- a/mkdocs/exceptions.py
+++ b/mkdocs/exceptions.py
@@ -4,7 +4,8 @@ from click import ClickException, echo
 
 
 class MkDocsException(ClickException):
-    """Base exceptions for all MkDocs Exceptions"""
+    """The base class which all MkDocs exceptions inherit from. This should
+    not be raised directly. One of the subclasses should be raised instead."""
 
 
 class Abort(MkDocsException):
@@ -15,12 +16,16 @@ class Abort(MkDocsException):
 
 
 class ConfigurationError(MkDocsException):
-    """Error in configuration"""
+    """This error is raised by configuration validation when a validation error
+    is encountered. This error should be raised by any configuration options
+    defined in a plugin's [config_scheme][]."""
 
 
 class BuildError(MkDocsException):
-    """Error during the build process"""
+    """This error may be raised by MkDocs during the build process. Plugins should
+    not raise this error."""
 
 
 class PluginError(BuildError):
-    """Error in a plugin"""
+    """A subclass of [`mkdocs.exceptions.BuildError`][] which can be raised by plugin
+    events."""
diff --git a/mkdocs/livereload/__init__.py b/mkdocs/livereload/__init__.py
index c0c99bdf..540c1755 100644
--- a/mkdocs/livereload/__init__.py
+++ b/mkdocs/livereload/__init__.py
@@ -17,7 +17,7 @@ import threading
 import time
 import warnings
 import wsgiref.simple_server
-from typing import Callable, Dict, Optional, Tuple
+from typing import Any, Callable, Dict, Optional, Tuple
 
 import watchdog.events
 import watchdog.observers.polling
@@ -100,7 +100,8 @@ class LiveReloadServer(socketserver.ThreadingMixIn, wsgiref.simple_server.WSGISe
         self.serve_thread = threading.Thread(target=lambda: self.serve_forever(shutdown_delay))
         self.observer = watchdog.observers.polling.PollingObserver(timeout=polling_interval)
 
-        self._watched_paths: Dict[str, bool] = {}  # Used as an ordered set.
+        self._watched_paths: Dict[str, int] = {}
+        self._watch_refs: Dict[str, Any] = {}
 
     def watch(self, path: str, func: Optional[Callable] = None, recursive: bool = True) -> None:
         """Add the 'path' to watched paths, call the function and reload when any file changes under it."""
@@ -116,7 +117,9 @@ class LiveReloadServer(socketserver.ThreadingMixIn, wsgiref.simple_server.WSGISe
             )
 
         if path in self._watched_paths:
+            self._watched_paths[path] += 1
             return
+        self._watched_paths[path] = 1
 
         def callback(event):
             if event.is_directory:
@@ -129,9 +132,16 @@ class LiveReloadServer(socketserver.ThreadingMixIn, wsgiref.simple_server.WSGISe
         handler = watchdog.events.FileSystemEventHandler()
         handler.on_any_event = callback
         log.debug(f"Watching '{path}'")
-        self.observer.schedule(handler, path, recursive=recursive)
+        self._watch_refs[path] = self.observer.schedule(handler, path, recursive=recursive)
 
-        self._watched_paths[path] = True
+    def unwatch(self, path: str) -> None:
+        """Stop watching file changes for path. Raises if there was no corresponding `watch` call."""
+        path = os.path.abspath(path)
+
+        self._watched_paths[path] -= 1
+        if self._watched_paths[path] <= 0:
+            self._watched_paths.pop(path)
+            self.observer.unschedule(self._watch_refs.pop(path))
 
     def serve(self):
         self.observer.start()
diff --git a/mkdocs/plugins.py b/mkdocs/plugins.py
index 0793466d..25d63f7a 100644
--- a/mkdocs/plugins.py
+++ b/mkdocs/plugins.py
@@ -442,12 +442,6 @@ class PluginCollection(OrderedDict):
         )
 
     def __setitem__(self, key: str, value: BasePlugin, **kwargs) -> None:
-        if not isinstance(value, BasePlugin):
-            raise TypeError(
-                f'{self.__module__}.{self.__name__} only accepts values which'
-                f' are instances of {BasePlugin.__module__}.{BasePlugin.__name__}'
-                ' subclasses'
-            )
         super().__setitem__(key, value, **kwargs)
         # Register all of the event methods defined for this Plugin.
         for event_name in (x for x in dir(value) if x.startswith('on_')):
diff --git a/mkdocs/structure/files.py b/mkdocs/structure/files.py
index bbbbf20b..b9331376 100644
--- a/mkdocs/structure/files.py
+++ b/mkdocs/structure/files.py
@@ -6,7 +6,18 @@ import os
 import posixpath
 import shutil
 from pathlib import PurePath
-from typing import TYPE_CHECKING, Dict, Iterable, Iterator, List, Optional, Sequence
+from typing import (
+    TYPE_CHECKING,
+    Any,
+    Dict,
+    Iterable,
+    Iterator,
+    List,
+    Mapping,
+    Optional,
+    Sequence,
+    Union,
+)
 from urllib.parse import quote as urlquote
 
 import jinja2.environment
@@ -267,7 +278,7 @@ class File:
         return self.src_uri.endswith('.css')
 
 
-def get_files(config: Config) -> Files:
+def get_files(config: Union[Config, Mapping[str, Any]]) -> Files:
     """Walk the `docs_dir` and return a Files collection."""
     files = []
     exclude = ['.*', '/templates']
diff --git a/mkdocs/structure/nav.py b/mkdocs/structure/nav.py
index cc73b168..489ce395 100644
--- a/mkdocs/structure/nav.py
+++ b/mkdocs/structure/nav.py
@@ -1,7 +1,7 @@
 from __future__ import annotations
 
 import logging
-from typing import Iterator, List, Optional, Type, TypeVar, Union
+from typing import Any, Iterator, List, Mapping, Optional, Type, TypeVar, Union
 from urllib.parse import urlsplit
 
 from mkdocs.config.base import Config
@@ -142,7 +142,7 @@ class Link:
         return '{}{}'.format('    ' * depth, repr(self))
 
 
-def get_navigation(files: Files, config: Config) -> Navigation:
+def get_navigation(files: Files, config: Union[Config, Mapping[str, Any]]) -> Navigation:
     """Build site navigation from config and files."""
     nav_config = config['nav'] or nest_paths(f.src_uri for f in files.documentation_pages())
     items = _data_to_navigation(nav_config, files, config)
@@ -189,7 +189,7 @@ def get_navigation(files: Files, config: Config) -> Navigation:
     return Navigation(items, pages)
 
 
-def _data_to_navigation(data, files: Files, config: Config):
+def _data_to_navigation(data, files: Files, config: Union[Config, Mapping[str, Any]]):
     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 7f9d2ef5..ffe74d98 100644
--- a/mkdocs/structure/pages.py
+++ b/mkdocs/structure/pages.py
@@ -3,7 +3,7 @@ from __future__ import annotations
 import logging
 import os
 import posixpath
-from typing import TYPE_CHECKING, Any, Mapping, Optional
+from typing import TYPE_CHECKING, Any, Mapping, MutableMapping, Optional, Union
 from urllib.parse import unquote as urlunquote
 from urllib.parse import urljoin, urlsplit, urlunsplit
 from xml.etree.ElementTree import Element
@@ -26,7 +26,9 @@ log = logging.getLogger(__name__)
 
 
 class Page:
-    def __init__(self, title: Optional[str], file: File, config: Config) -> None:
+    def __init__(
+        self, title: Optional[str], file: File, config: Union[Config, Mapping[str, Any]]
+    ) -> None:
         file.page = self
         self.file = file
         self.title = title
@@ -79,7 +81,7 @@ class Page:
     """An iterable object representing the Table of contents for a page. Each item in
     the `toc` is an [`AnchorLink`][mkdocs.structure.toc.AnchorLink]."""
 
-    meta: Mapping[str, Any]
+    meta: MutableMapping[str, Any]
     """A mapping of the metadata included at the top of the markdown page."""
 
     @property
diff --git a/mkdocs/themes/mkdocs/locales/ru/LC_MESSAGES/messages.po b/mkdocs/themes/mkdocs/locales/ru/LC_MESSAGES/messages.po
new file mode 100644
index 00000000..c275758a
--- /dev/null
+++ b/mkdocs/themes/mkdocs/locales/ru/LC_MESSAGES/messages.po
@@ -0,0 +1,102 @@
+# Russian translations for MkDocs.
+# Copyright (C) 2022 MkDocs
+# This file is distributed under the same license as the MkDocs project.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: MkDocs 1.2\n"
+"Report-Msgid-Bugs-To: https://github.com/mkdocs/mkdocs/issues\n"
+"POT-Creation-Date: 2022-09-13 23:23+0300\n"
+"PO-Revision-Date: 2022-09-13 23:23+0300\n"
+"Last-Translator: Alexander Krasnikov <alexander@krasnikov.net>\n"
+"Language: ru\n"
+"Language-Team: ru <LL@li.org>\n"
+"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
+"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=utf-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Generated-By: Babel 2.10.3\n"
+
+#: mkdocs/themes/mkdocs/404.html:8
+msgid "Page not found"
+msgstr "Страница не найдена"
+
+#: mkdocs/themes/mkdocs/base.html:116
+#: mkdocs/themes/mkdocs/keyboard-modal.html:31
+#: mkdocs/themes/mkdocs/search-modal.html:5
+msgid "Search"
+msgstr "Поиск"
+
+#: mkdocs/themes/mkdocs/base.html:126
+msgid "Previous"
+msgstr "Назад"
+
+#: mkdocs/themes/mkdocs/base.html:131
+msgid "Next"
+msgstr "Вперед"
+
+#: mkdocs/themes/mkdocs/base.html:142 mkdocs/themes/mkdocs/base.html:144
+#: mkdocs/themes/mkdocs/base.html:146 mkdocs/themes/mkdocs/base.html:148
+#, python-format
+msgid "Edit on %(repo_name)s"
+msgstr "Редактировать в %(repo_name)s"
+
+#: mkdocs/themes/mkdocs/base.html:150
+msgid "Edit"
+msgstr "Редактировать"
+
+#: mkdocs/themes/mkdocs/base.html:190
+#, python-format
+msgid "Documentation built with %(mkdocs_link)s."
+msgstr "Документация создана с помощью %(mkdocs_link)s."
+
+#: mkdocs/themes/mkdocs/keyboard-modal.html:5
+msgid "Keyboard Shortcuts"
+msgstr "Сочетания клавиш"
+
+#: mkdocs/themes/mkdocs/keyboard-modal.html:6
+#: mkdocs/themes/mkdocs/search-modal.html:6
+msgid "Close"
+msgstr "Закрыть"
+
+#: mkdocs/themes/mkdocs/keyboard-modal.html:12
+msgid "Keys"
+msgstr "Клавиши"
+
+#: mkdocs/themes/mkdocs/keyboard-modal.html:13
+msgid "Action"
+msgstr "Действие"
+
+#: mkdocs/themes/mkdocs/keyboard-modal.html:19
+msgid "Open this help"
+msgstr "Открыть справку"
+
+#: mkdocs/themes/mkdocs/keyboard-modal.html:23
+msgid "Next page"
+msgstr "Следующая страница"
+
+#: mkdocs/themes/mkdocs/keyboard-modal.html:27
+msgid "Previous page"
+msgstr "Предыдущая страница"
+
+#: mkdocs/themes/mkdocs/search-modal.html:9
+msgid "From here you can search these documents. Enter your search terms below."
+msgstr "Чтобы воспользоваться поиском по документам, введите поисковый запрос ниже."
+
+#: mkdocs/themes/mkdocs/search-modal.html:12
+msgid "Search..."
+msgstr "Поиск..."
+
+#: mkdocs/themes/mkdocs/search-modal.html:12
+msgid "Type search term here"
+msgstr "Введите поисковый запрос"
+
+#: mkdocs/themes/mkdocs/search-modal.html:15
+msgid "No results found"
+msgstr "Ничего не найдено"
+
+#: mkdocs/themes/mkdocs/toc.html:3
+msgid "Table of Contents"
+msgstr "Содержание"
+
diff --git a/mkdocs/themes/mkdocs/locales/uk/LC_MESSAGES/messages.po b/mkdocs/themes/mkdocs/locales/uk/LC_MESSAGES/messages.po
new file mode 100644
index 00000000..681b791c
--- /dev/null
+++ b/mkdocs/themes/mkdocs/locales/uk/LC_MESSAGES/messages.po
@@ -0,0 +1,101 @@
+# Ukrainian translations for MkDocs.
+# Copyright (C) 2022 MkDocs
+# This file is distributed under the same license as the MkDocs project.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: MkDocs 1.3.1\n"
+"Report-Msgid-Bugs-To: \"https://github.com/mkdocs/mkdocs/issues\"\n"
+"POT-Creation-Date: 2022-08-15 17:05+0200\n"
+"PO-Revision-Date: 2022-09-17 15:51+0200\n"
+"Last-Translator: Oleh Prypin <@oprypin>\n"
+"Language: uk\n"
+"Language-Team: uk <LL@li.org>\n"
+"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
+"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=utf-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Generated-By: Babel 2.10.3\n"
+
+#: mkdocs/themes/mkdocs/404.html:8
+msgid "Page not found"
+msgstr "Сторінку не знайдено"
+
+#: mkdocs/themes/mkdocs/base.html:116
+#: mkdocs/themes/mkdocs/keyboard-modal.html:31
+#: mkdocs/themes/mkdocs/search-modal.html:5
+msgid "Search"
+msgstr "Пошук"
+
+#: mkdocs/themes/mkdocs/base.html:126
+msgid "Previous"
+msgstr "Назад"
+
+#: mkdocs/themes/mkdocs/base.html:131
+msgid "Next"
+msgstr "Вперед"
+
+#: mkdocs/themes/mkdocs/base.html:142 mkdocs/themes/mkdocs/base.html:144
+#: mkdocs/themes/mkdocs/base.html:146 mkdocs/themes/mkdocs/base.html:148
+#, python-format
+msgid "Edit on %(repo_name)s"
+msgstr "Редагувати в %(repo_name)s"
+
+#: mkdocs/themes/mkdocs/base.html:150
+msgid "Edit"
+msgstr "Редагувати"
+
+#: mkdocs/themes/mkdocs/base.html:190
+#, python-format
+msgid "Documentation built with %(mkdocs_link)s."
+msgstr "Документація збудована з %(mkdocs_link)s."
+
+#: mkdocs/themes/mkdocs/keyboard-modal.html:5
+msgid "Keyboard Shortcuts"
+msgstr "Комбінації клавіш"
+
+#: mkdocs/themes/mkdocs/keyboard-modal.html:6
+#: mkdocs/themes/mkdocs/search-modal.html:6
+msgid "Close"
+msgstr "Закрити"
+
+#: mkdocs/themes/mkdocs/keyboard-modal.html:12
+msgid "Keys"
+msgstr "Клавіші"
+
+#: mkdocs/themes/mkdocs/keyboard-modal.html:13
+msgid "Action"
+msgstr "Дія"
+
+#: mkdocs/themes/mkdocs/keyboard-modal.html:19
+msgid "Open this help"
+msgstr "Відкрити цю довідку"
+
+#: mkdocs/themes/mkdocs/keyboard-modal.html:23
+msgid "Next page"
+msgstr "Наступна сторінка"
+
+#: mkdocs/themes/mkdocs/keyboard-modal.html:27
+msgid "Previous page"
+msgstr "Попередня сторінка"
+
+#: mkdocs/themes/mkdocs/search-modal.html:9
+msgid "From here you can search these documents. Enter your search terms below."
+msgstr "Звідси ви можете шукати документи. Введіть пошуковий запит знизу."
+
+#: mkdocs/themes/mkdocs/search-modal.html:12
+msgid "Search..."
+msgstr "Пошук..."
+
+#: mkdocs/themes/mkdocs/search-modal.html:12
+msgid "Type search term here"
+msgstr "Введіть пошуковий запит"
+
+#: mkdocs/themes/mkdocs/search-modal.html:15
+msgid "No results found"
+msgstr "Нічого не знайдено"
+
+#: mkdocs/themes/mkdocs/toc.html:3
+msgid "Table of Contents"
+msgstr "Зміст"
diff --git a/mkdocs/themes/readthedocs/locales/ru/LC_MESSAGES/messages.po b/mkdocs/themes/readthedocs/locales/ru/LC_MESSAGES/messages.po
new file mode 100644
index 00000000..41c21774
--- /dev/null
+++ b/mkdocs/themes/readthedocs/locales/ru/LC_MESSAGES/messages.po
@@ -0,0 +1,110 @@
+# Russian translations for MkDocs.
+# Copyright (C) 2022 MkDocs
+# This file is distributed under the same license as the MkDocs project.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: MkDocs 1.2\n"
+"Report-Msgid-Bugs-To: https://github.com/mkdocs/mkdocs/issues\n"
+"POT-Creation-Date: 2022-09-13 23:23+0300\n"
+"PO-Revision-Date: 2022-09-13 23:23+0300\n"
+"Last-Translator: Alexander Krasnikov <alexander@krasnikov.net>\n"
+"Language: ru\n"
+"Language-Team: ru\n"
+"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
+"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=utf-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Generated-By: Babel 2.10.3\n"
+
+#: mkdocs/themes/readthedocs/404.html:7
+msgid "Page not found"
+msgstr "Страница не найдена"
+
+#: mkdocs/themes/readthedocs/base.html:97
+msgid "Logo"
+msgstr "Логотип"
+
+#: mkdocs/themes/readthedocs/base.html:111
+msgid "Navigation menu"
+msgstr "Навигационное меню"
+
+#: mkdocs/themes/readthedocs/base.html:140
+msgid "Mobile navigation menu"
+msgstr "Мобильная версия навигации"
+
+#: mkdocs/themes/readthedocs/breadcrumbs.html:3
+msgid "Docs"
+msgstr "Документация"
+
+#: mkdocs/themes/readthedocs/breadcrumbs.html:18
+#: mkdocs/themes/readthedocs/breadcrumbs.html:20
+#: mkdocs/themes/readthedocs/breadcrumbs.html:22
+#: mkdocs/themes/readthedocs/breadcrumbs.html:24
+#, python-format
+msgid "Edit on %(repo_name)s"
+msgstr "Редактировать в %(repo_name)s"
+
+#: mkdocs/themes/readthedocs/breadcrumbs.html:26
+msgid "Edit"
+msgstr "Редактировать"
+
+#: mkdocs/themes/readthedocs/breadcrumbs.html:34
+msgid "Breadcrumb Navigation"
+msgstr "Навигационная цепочка"
+
+#: mkdocs/themes/readthedocs/breadcrumbs.html:36
+#: mkdocs/themes/readthedocs/footer.html:7
+#: mkdocs/themes/readthedocs/versions.html:17
+msgid "Previous"
+msgstr "Назад"
+
+#: mkdocs/themes/readthedocs/breadcrumbs.html:39
+#: mkdocs/themes/readthedocs/footer.html:10
+#: mkdocs/themes/readthedocs/versions.html:20
+msgid "Next"
+msgstr "Вперед"
+
+#: mkdocs/themes/readthedocs/footer.html:5
+msgid "Footer Navigation"
+msgstr "Навигация по странице"
+
+#: mkdocs/themes/readthedocs/footer.html:25
+#, python-format
+msgid ""
+"Built with %(mkdocs_link)s using a %(sphinx_link)s provided by "
+"%(rtd_link)s."
+msgstr ""
+"Собрано в %(mkdocs_link)s с использованием %(sphinx_link)s от "
+"%(rtd_link)s."
+
+#: mkdocs/themes/readthedocs/search.html:5
+msgid "Search Results"
+msgstr "Результаты поиска"
+
+#: mkdocs/themes/readthedocs/search.html:9
+msgid "Search the Docs"
+msgstr "Поиск в документах"
+
+#: mkdocs/themes/readthedocs/search.html:9
+#: mkdocs/themes/readthedocs/searchbox.html:3
+msgid "Type search term here"
+msgstr "Введите поисковый запрос"
+
+#: mkdocs/themes/readthedocs/search.html:12
+msgid "No results found"
+msgstr "Ничего не найдено"
+
+#: mkdocs/themes/readthedocs/search.html:13
+msgid "Searching..."
+msgstr "Поиск..."
+
+#: mkdocs/themes/readthedocs/searchbox.html:3
+msgid "Search docs"
+msgstr "Поиск в документации"
+
+#: mkdocs/themes/readthedocs/versions.html:1
+msgid "Versions"
+msgstr "Версии"
+
diff --git a/mkdocs/themes/readthedocs/locales/uk/LC_MESSAGES/messages.po b/mkdocs/themes/readthedocs/locales/uk/LC_MESSAGES/messages.po
new file mode 100644
index 00000000..739f4184
--- /dev/null
+++ b/mkdocs/themes/readthedocs/locales/uk/LC_MESSAGES/messages.po
@@ -0,0 +1,110 @@
+# Ukrainian translations for MkDocs.
+# Copyright (C) 2022 MkDocs
+# This file is distributed under the same license as the MkDocs project.
+# FIRST AUTHOR <EMAIL@ADDRESS>, 2021.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: MkDocs 1.3.1\n"
+"Report-Msgid-Bugs-To: \"https://github.com/mkdocs/mkdocs/issues\"\n"
+"POT-Creation-Date: 2022-08-15 17:05+0200\n"
+"PO-Revision-Date: 2022-09-17 15:51+0200\n"
+"Last-Translator: Oleh Prypin <@oprypin>\n"
+"Language: uk\n"
+"Language-Team: uk <LL@li.org>\n"
+"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
+"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=utf-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Generated-By: Babel 2.10.3\n"
+
+#: mkdocs/themes/readthedocs/404.html:7
+msgid "Page not found"
+msgstr "Сторінку не знайдено"
+
+#: mkdocs/themes/readthedocs/base.html:97
+msgid "Logo"
+msgstr "Логотип"
+
+#: mkdocs/themes/readthedocs/base.html:111
+msgid "Navigation menu"
+msgstr "Меню навігації"
+
+#: mkdocs/themes/readthedocs/base.html:140
+msgid "Mobile navigation menu"
+msgstr "Мобільна версія меню"
+
+#: mkdocs/themes/readthedocs/breadcrumbs.html:3
+msgid "Docs"
+msgstr "Документація"
+
+#: mkdocs/themes/readthedocs/breadcrumbs.html:18
+#: mkdocs/themes/readthedocs/breadcrumbs.html:20
+#: mkdocs/themes/readthedocs/breadcrumbs.html:22
+#: mkdocs/themes/readthedocs/breadcrumbs.html:24
+#, python-format
+msgid "Edit on %(repo_name)s"
+msgstr "Редагувати в %(repo_name)s"
+
+#: mkdocs/themes/readthedocs/breadcrumbs.html:26
+msgid "Edit"
+msgstr "Редагувати"
+
+#: mkdocs/themes/readthedocs/breadcrumbs.html:34
+msgid "Breadcrumb Navigation"
+msgstr "Навігаційна стежка"
+
+#: mkdocs/themes/readthedocs/breadcrumbs.html:36
+#: mkdocs/themes/readthedocs/footer.html:7
+#: mkdocs/themes/readthedocs/versions.html:17
+msgid "Previous"
+msgstr "Назад"
+
+#: mkdocs/themes/readthedocs/breadcrumbs.html:39
+#: mkdocs/themes/readthedocs/footer.html:10
+#: mkdocs/themes/readthedocs/versions.html:20
+msgid "Next"
+msgstr "Вперед"
+
+#: mkdocs/themes/readthedocs/footer.html:5
+msgid "Footer Navigation"
+msgstr "Нижнє меню навігації"
+
+#: mkdocs/themes/readthedocs/footer.html:25
+#, python-format
+msgid ""
+"Built with %(mkdocs_link)s using a %(sphinx_link)s provided by "
+"%(rtd_link)s."
+msgstr ""
+"Зібрано у %(mkdocs_link)s з використанням %(sphinx_link)s від "
+"%(rtd_link)s."
+
+#: mkdocs/themes/readthedocs/search.html:5
+msgid "Search Results"
+msgstr "Результати пошуку"
+
+#: mkdocs/themes/readthedocs/search.html:9
+msgid "Search the Docs"
+msgstr "Пошук у документах"
+
+#: mkdocs/themes/readthedocs/search.html:9
+#: mkdocs/themes/readthedocs/searchbox.html:3
+msgid "Type search term here"
+msgstr "Введіть пошуковий запит"
+
+#: mkdocs/themes/readthedocs/search.html:12
+msgid "No results found"
+msgstr "Нічого не знайдено"
+
+#: mkdocs/themes/readthedocs/search.html:13
+msgid "Searching..."
+msgstr "Пошук..."
+
+#: mkdocs/themes/readthedocs/searchbox.html:3
+msgid "Search docs"
+msgstr "Пошук у документації"
+
+#: mkdocs/themes/readthedocs/versions.html:1
+msgid "Versions"
+msgstr "Версії"
diff --git a/requirements/project-min.txt b/requirements/project-min.txt
index 8919601d..1dee5c96 100644
--- a/requirements/project-min.txt
+++ b/requirements/project-min.txt
@@ -15,6 +15,7 @@ babel==2.9.0
 colorama==0.4; platform_system == 'Windows'
 mdx_gh_links==0.2
 markdown-callouts==0.2
+mkdocs-literate-nav==0.5.0
 mkdocs-redirects==1.0.1
 pymdown-extensions==8.0.1
 mkdocstrings-python==0.7.1
diff --git a/requirements/project.txt b/requirements/project.txt
index dac875a9..2a1bf6fa 100644
--- a/requirements/project.txt
+++ b/requirements/project.txt
@@ -15,6 +15,7 @@ babel>=2.9.0
 colorama>=0.4; platform_system == 'Windows'
 mdx_gh_links>=0.2
 markdown-callouts>=0.2
+mkdocs-literate-nav>=0.5.0
 mkdocs-redirects>=1.0.1
 pymdown-extensions>=8.0.1
 mkdocstrings-python>=0.7.1


Test patch:
diff --git a/mkdocs/tests/config/config_options_tests.py b/mkdocs/tests/config/config_options_tests.py
index a1b0b3c1..738c7fbe 100644
--- a/mkdocs/tests/config/config_options_tests.py
+++ b/mkdocs/tests/config/config_options_tests.py
@@ -9,7 +9,7 @@ from unittest.mock import patch
 import mkdocs
 from mkdocs.config import base, config_options
 from mkdocs.tests.base import tempdir
-from mkdocs.utils import yaml_load
+from mkdocs.utils import write_file, yaml_load
 
 
 class UnexpectedError(Exception):
@@ -1536,3 +1536,34 @@ class MarkdownExtensionsTest(TestCase):
             {},
         )
         self.assertIsNone(conf['mdx_configs'].get('toc'))
+
+
+class TestHooks(TestCase):
+    class Schema:
+        plugins = config_options.Plugins(default=[])
+        hooks = config_options.Hooks('plugins')
+
+    @tempdir()
+    def test_hooks(self, src_dir):
+        write_file(
+            b'def on_page_markdown(markdown, **kwargs): return markdown.replace("f", "z")',
+            os.path.join(src_dir, 'hooks', 'my_hook.py'),
+        )
+        write_file(
+            b'foo foo',
+            os.path.join(src_dir, 'docs', 'index.md'),
+        )
+        conf = self.get_config(
+            self.Schema,
+            {'hooks': ['hooks/my_hook.py']},
+            config_file_path=os.path.join(src_dir, 'mkdocs.yml'),
+        )
+        self.assertIn('hooks/my_hook.py', conf['plugins'])
+        hook = conf['plugins']['hooks/my_hook.py']
+        self.assertTrue(hasattr(hook, 'on_page_markdown'))
+        self.assertEqual(
+            {**conf['plugins'].events, 'page_markdown': [hook.on_page_markdown]},
+            conf['plugins'].events,
+        )
+        self.assertEqual(hook.on_page_markdown('foo foo'), 'zoo zoo')
+        self.assertFalse(hasattr(hook, 'on_nav'))
diff --git a/mkdocs/tests/livereload_tests.py b/mkdocs/tests/livereload_tests.py
index 6a56a784..7c2d762d 100644
--- a/mkdocs/tests/livereload_tests.py
+++ b/mkdocs/tests/livereload_tests.py
@@ -158,6 +158,30 @@ class BuildTests(unittest.TestCase):
 
                 self.assertTrue(started_building.wait(timeout=10))
 
+    @tempdir()
+    def test_unwatch(self, site_dir):
+        started_building = threading.Event()
+
+        with testing_server(site_dir, started_building.set) as server:
+            with self.assertRaises(KeyError):
+                server.unwatch(site_dir)
+
+            server.watch(site_dir)
+            server.watch(site_dir)
+            server.unwatch(site_dir)
+            time.sleep(0.01)
+
+            Path(site_dir, "foo").write_text("foo")
+            self.assertTrue(started_building.wait(timeout=10))
+            started_building.clear()
+
+            server.unwatch(site_dir)
+            Path(site_dir, "foo").write_text("bar")
+            self.assertFalse(started_building.wait(timeout=0.5))
+
+            with self.assertRaises(KeyError):
+                server.unwatch(site_dir)
+
     @tempdir({"foo.docs": "a"})
     @tempdir({"foo.site": "original"})
     def test_custom_action_warns(self, site_dir, docs_dir):