Repository: mkdocs/mkdocs
Pull request #3026 (2022-10-29 20:16:20)

Mention in the documentation that a fork of the repository is necessary for translating the included templates
The documentation for translating plugins is great. I hope this contribution can make it even better.

Sorry for the mess with the commits below …


Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 27c0b0dc..5b0f8634 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -53,7 +53,7 @@ jobs:
         shell: bash
       - name: Upload Codecov Results
         if: success()
-        uses: codecov/codecov-action@v1
+        uses: codecov/codecov-action@v3
         with:
           file: ./coverage.xml
           flags: unittests
@@ -72,7 +72,7 @@ jobs:
         run: |
           python -m pip install --upgrade hatch
       - name: Setup Node
-        uses: actions/setup-node@v1
+        uses: actions/setup-node@v3
         with:
           node-version: 16
       - name: Check with black + isort
diff --git a/docs/about/release-notes.md b/docs/about/release-notes.md
index 903f4e9e..83ed461c 100644
--- a/docs/about/release-notes.md
+++ b/docs/about/release-notes.md
@@ -27,6 +27,32 @@ The current and past members of the MkDocs team.
 * [@oprypin](https://github.com/oprypin/)
 * [@ultrabug](https://github.com/ultrabug/)
 
+## Version 1.4.2 (2022-11-01)
+
+*   Officially support Python 3.11 (#3020)
+
+    NEW: **Tip:** Simply upgrading to Python 3.11 can cut off 10-15% of your site's build time.
+
+*   Support multiple instances of the same plugin (#3027)
+
+    If a plugin is specified multiple times in the list under the `plugins:` config, that will create 2 (or more) instances of the plugin with their own config each.
+
+    Previously this case was unforeseen and, as such, bugged.
+
+    Now even though this works, by default a warning will appear from MkDocs anyway, unless the plugin adds a class variable `supports_multiple_instances = True`.
+
+*   Bugfix (regression in 1.4.1): Don't error when a plugin puts a plain string into `warnings` (#3016)
+
+*   Bugfix: Relative links will always render with a trailing slash (#3022)
+
+    Previously under `use_directory_urls`, links *from* a sub-page *to* the main index page rendered as e.g. `<a href="../..">` even though in all other cases the links look like `<a href="../../">`. This caused unwanted behavior on some combinations of Web browsers and servers. Now this special-case bug was removed.
+
+*   Built-in "mkdocs" theme now also supports Norwegian language (#3024)
+
+*   Plugin-related warnings look more readable (#3016)
+
+See [commit log](https://github.com/mkdocs/mkdocs/compare/1.4.1...1.4.2).
+
 ## Version 1.4.1 (2022-10-15)
 
 *   Support theme-namespaced plugin loading (#2998)
diff --git a/docs/dev-guide/plugins.md b/docs/dev-guide/plugins.md
index 8447f5a7..b7a45213 100644
--- a/docs/dev-guide/plugins.md
+++ b/docs/dev-guide/plugins.md
@@ -239,7 +239,7 @@ class MyPlugin(BasePlugin):
 > ```python
 > def on_config(self, config: MkDocsConfig):
 >     config.theme.static_templates.add('my_template.html')
->         return config
+>     return config
 > ```
 
 ### Events
diff --git a/docs/dev-guide/translations.md b/docs/dev-guide/translations.md
index 9763b067..3d686093 100644
--- a/docs/dev-guide/translations.md
+++ b/docs/dev-guide/translations.md
@@ -54,11 +54,12 @@ translation by following the steps below.
 
 Here is a quick summary of what you'll need to do:
 
-1. [Initialize new localization catalogs](#initializing-the-localization-catalogs) for your language (if a translation for your locale already exists, follow the instructions for [updating theme localization files](/user-guide/custom-themes/#localizing-themes) instead).
-2. [Add a translation](#translating-the-mkdocs-themes) for every text placeholder in the localized catalogs.
-3. [Locally serve and test](#testing-theme-translations) the translated themes for your language.
-4. [Update the documentation](#updating-theme-documentation) about supported translations for each translated theme.
-5. [Contribute your translation](#contributing-translations) through a Pull Request.
+1. [Fork and clone the MkDocs repository](#fork-and-clone-the-mkdocs-repository) and then [install MkDocs for development](/about/contributing/#installing-for-development) for adding and testing translations.
+2. [Initialize new localization catalogs](#initializing-the-localization-catalogs) for your language (if a translation for your locale already exists, follow the instructions for [updating theme localization files](/user-guide/custom-themes/#localizing-themes) instead).
+3. [Add a translation](#translating-the-mkdocs-themes) for every text placeholder in the localized catalogs.
+4. [Locally serve and test](#testing-theme-translations) the translated themes for your language.
+5. [Update the documentation](#updating-theme-documentation) about supported translations for each translated theme.
+6. [Contribute your translation](#contributing-translations) through a Pull Request.
 
 NOTE:
 Translation locales are usually identified using the [ISO-639-1] (2-letter)
@@ -69,6 +70,15 @@ use of a term which differs from the general language translation.
 
 [ISO-639-1]: https://en.wikipedia.org/wiki/ISO_639-1
 
+### Fork and clone the MkDocs repository
+
+In the following steps you'll work with a fork of the MkDocs repository. Follow
+the instructions for [forking and cloning the MkDocs
+repository](/about/contributing/#installing-for-development).
+
+To test the translations you also need to [install MkDocs for
+development](/about/contributing/#installing-for-development) from your fork.
+
 ### Initializing the localization catalogs
 
 The templates for each theme contain text placeholders that have been extracted
diff --git a/docs/index.md b/docs/index.md
index 5f35674d..ec6a134f 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -79,7 +79,7 @@ configuration file. Start by reading the [introductory tutorial], then check the
         <h3 class="card-title">Host anywhere</h3>
         <p class="card-text">
             MkDocs builds completely static HTML sites that you can host on
-            GitHub pages, Amazon S3, or <a
+            GitHub Pages, Amazon S3, or <a
             href="user-guide/deploying-your-docs/">anywhere</a> else you
             choose.
         </p>
diff --git a/docs/user-guide/configuration.md b/docs/user-guide/configuration.md
index fc6800d8..fbfb2afa 100644
--- a/docs/user-guide/configuration.md
+++ b/docs/user-guide/configuration.md
@@ -106,7 +106,7 @@ means that a page named 'foo/bar.md' will have its edit link lead to:
 `edit_uri` can actually be just an absolute URL, not necessarily relative to `repo_url`, so this can achieve the same result:
 
 ```yaml
-repo_url: https://example.com/project/repo/blob/main/docs/
+edit_uri: https://example.com/project/repo/blob/main/docs/
 ```
 
 For more flexibility, see [edit_uri_template](#edit_uri_template) below.
diff --git a/docs/user-guide/deploying-your-docs.md b/docs/user-guide/deploying-your-docs.md
index 279062b7..27b9847b 100644
--- a/docs/user-guide/deploying-your-docs.md
+++ b/docs/user-guide/deploying-your-docs.md
@@ -40,6 +40,10 @@ You should never edit files in your pages repository by hand if you're using
 the `gh-deploy` command because you will lose your work the next time you
 run the command.
 
+WARNING:
+If there are untracked files or uncommitted work in the local repository where
+`mkdocs gh-deploy` is run, these will be included in the pages that are deployed.
+
 ### Organization and User Pages
 
 User and Organization Pages sites are not tied to a specific project, and the
diff --git a/docs/user-guide/writing-your-docs.md b/docs/user-guide/writing-your-docs.md
index ea18e8d5..12f24667 100644
--- a/docs/user-guide/writing-your-docs.md
+++ b/docs/user-guide/writing-your-docs.md
@@ -309,7 +309,7 @@ As well as the Markdown source files, you can also include other file types in
 your documentation, which will be copied across when generating your
 documentation site. These might include images and other media.
 
-For example, if your project documentation needed to include a [GitHub pages
+For example, if your project documentation needed to include a [GitHub Pages
 CNAME file] and a PNG formatted screenshot image then your file layout might
 look as follows:
 
@@ -338,7 +338,7 @@ Cupcake indexer is a snazzy new project for indexing small cakes.
 Your image will now be embedded when you build the documentation, and should
 also be previewed if you're working on the documentation with a Markdown editor.
 
-[GitHub pages CNAME file]: https://help.github.com/articles/using-a-custom-domain-with-github-pages/
+[GitHub Pages CNAME file]: https://help.github.com/articles/using-a-custom-domain-with-github-pages/
 
 #### Linking from raw HTML
 
diff --git a/mkdocs.yml b/mkdocs.yml
index 844f236c..b0129746 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -44,7 +44,7 @@ markdown_extensions:
         repo: mkdocs
     - mkdocs-click
 
-copyright: Copyright &copy; 2014 <a href="https://twitter.com/_tomchristie">Tom Christie</a>, Maintained by the <a href="/about/release-notes/#maintenance-team">MkDocs Team</a>.
+copyright: Copyright &copy; 2014 <a href="https://twitter.com/starletdreaming">Tom Christie</a>, Maintained by the <a href="/about/release-notes/#maintenance-team">MkDocs Team</a>.
 
 plugins:
     - search
diff --git a/mkdocs/__init__.py b/mkdocs/__init__.py
index feb19bc9..2c222320 100644
--- a/mkdocs/__init__.py
+++ b/mkdocs/__init__.py
@@ -2,4 +2,4 @@
 
 
 # For acceptable version formats, see https://www.python.org/dev/peps/pep-0440/
-__version__ = '1.4.1'
+__version__ = '1.4.2'
diff --git a/mkdocs/commands/build.py b/mkdocs/commands/build.py
index 60716656..e46ecc94 100644
--- a/mkdocs/commands/build.py
+++ b/mkdocs/commands/build.py
@@ -336,7 +336,7 @@ def build(config: MkDocsConfig, live_server: bool = False, dirty: bool = False)
             msg = ', '.join(f'{v} {k.lower()}s' for k, v in counts)
             raise Abort(f'\nAborted with {msg} in strict mode!')
 
-        log.info('Documentation built in %.2f seconds', time.monotonic() - start)
+        log.info(f'Documentation built in {time.monotonic() - start:.2f} seconds')
 
     except Exception as e:
         # Run `build_error` plugin events.
diff --git a/mkdocs/commands/gh_deploy.py b/mkdocs/commands/gh_deploy.py
index 9f7bcae0..d107a794 100644
--- a/mkdocs/commands/gh_deploy.py
+++ b/mkdocs/commands/gh_deploy.py
@@ -47,7 +47,7 @@ def _get_current_sha(repo_path) -> str:
 
 def _get_remote_url(remote_name: str) -> Union[Tuple[str, str], Tuple[None, None]]:
     # No CNAME found.  We will use the origin URL to determine the GitHub
-    # pages location.
+    # Pages location.
     remote = f"remote.{remote_name}.url"
     proc = subprocess.Popen(
         ["git", "config", "--get", remote],
@@ -140,9 +140,9 @@ def gh_deploy(
         raise Abort('Deployment Aborted!')
 
     cname_file = os.path.join(config.site_dir, 'CNAME')
-    # Does this repository have a CNAME set for GitHub pages?
+    # Does this repository have a CNAME set for GitHub Pages?
     if os.path.isfile(cname_file):
-        # This GitHub pages repository has a CNAME configured.
+        # This GitHub Pages repository has a CNAME configured.
         with open(cname_file) as f:
             cname_host = f.read().strip()
         log.info(
diff --git a/mkdocs/config/base.py b/mkdocs/config/base.py
index 9c99018f..88fb77c3 100644
--- a/mkdocs/config/base.py
+++ b/mkdocs/config/base.py
@@ -35,7 +35,7 @@ T = TypeVar('T')
 
 
 class BaseConfigOption(Generic[T]):
-    def __init__(self):
+    def __init__(self) -> None:
         self.warnings: List[str] = []
         self.default = None
 
@@ -239,7 +239,7 @@ class Config(UserDict):
 
         return failed, warnings
 
-    def load_dict(self, patch: Optional[dict]) -> None:
+    def load_dict(self, patch: dict) -> None:
         """Load config options from a dictionary."""
 
         if not isinstance(patch, dict):
diff --git a/mkdocs/config/config_options.py b/mkdocs/config/config_options.py
index 44cb7492..366c782b 100644
--- a/mkdocs/config/config_options.py
+++ b/mkdocs/config/config_options.py
@@ -9,15 +9,17 @@ import traceback
 import types
 import typing as t
 import warnings
-from collections import UserString
+from collections import Counter, UserString
 from typing import (
     Any,
+    Callable,
     Collection,
     Dict,
     Generic,
     Iterator,
     List,
     Mapping,
+    MutableMapping,
     NamedTuple,
     Tuple,
     TypeVar,
@@ -602,7 +604,7 @@ class FilesystemObject(Type[str]):
     Base class for options that point to filesystem objects.
     """
 
-    existence_test = staticmethod(os.path.exists)
+    existence_test: Callable[[str], bool] = staticmethod(os.path.exists)
     name = 'file or directory'
 
     def __init__(self, exists: bool = False, **kwargs) -> None:
@@ -933,9 +935,9 @@ class Plugins(OptionallyRequired[plugins.PluginCollection]):
         if not isinstance(value, (list, tuple, dict)):
             raise ValidationError('Invalid Plugins configuration. Expected a list or dict.')
         self.plugins = plugins.PluginCollection()
+        self._instance_counter: MutableMapping[str, int] = Counter()
         for name, cfg in self._parse_configs(value):
-            name, plugin = self.load_plugin_with_namespace(name, cfg)
-            self.plugins[name] = plugin
+            self.load_plugin_with_namespace(name, cfg)
         return self.plugins
 
     @classmethod
@@ -981,7 +983,13 @@ class Plugins(OptionallyRequired[plugins.PluginCollection]):
         if not isinstance(config, dict):
             raise ValidationError(f"Invalid config options for the '{name}' plugin.")
 
-        plugin = self.plugin_cache.get(name)
+        self._instance_counter[name] += 1
+        inst_number = self._instance_counter[name]
+        inst_name = name
+        if inst_number > 1:
+            inst_name += f' #{inst_number}'
+
+        plugin = self.plugin_cache.get(inst_name)
         if plugin is None:
             plugin_cls = self.installed_plugins[name].load()
 
@@ -994,21 +1002,28 @@ class Plugins(OptionallyRequired[plugins.PluginCollection]):
             plugin = plugin_cls()
 
             if hasattr(plugin, 'on_startup') or hasattr(plugin, 'on_shutdown'):
-                self.plugin_cache[name] = plugin
+                self.plugin_cache[inst_name] = plugin
+
+        if inst_number > 1 and not getattr(plugin, 'supports_multiple_instances', False):
+            self.warnings.append(
+                f"Plugin '{name}' was specified multiple times - this is likely a mistake, "
+                "because the plugin doesn't declare `supports_multiple_instances`."
+            )
 
         errors, warns = plugin.load_config(
             config, self._config.config_file_path if self._config else None
         )
         for warning in warns:
             if isinstance(warning, str):
-                self.warnings.append(f"Plugin '{name}': {warning}")
+                self.warnings.append(f"Plugin '{inst_name}': {warning}")
             else:
                 key, msg = warning
-                self.warnings.append(f"Plugin '{name}' option '{key}': {msg}")
+                self.warnings.append(f"Plugin '{inst_name}' option '{key}': {msg}")
 
         errors_message = '\n'.join(f"Plugin '{name}' option '{key}': {msg}" for key, msg in errors)
         if errors_message:
             raise ValidationError(errors_message)
+        self.plugins[inst_name] = plugin
         return plugin
 
 
diff --git a/mkdocs/contrib/search/search_index.py b/mkdocs/contrib/search/search_index.py
index 18e406f5..9eb43e8d 100644
--- a/mkdocs/contrib/search/search_index.py
+++ b/mkdocs/contrib/search/search_index.py
@@ -143,7 +143,7 @@ class SearchIndex:
 class ContentSection:
     """
     Used by the ContentParser class to capture the information we
-    need when it is parsing the HMTL.
+    need when it is parsing the HTML.
     """
 
     def __init__(
diff --git a/mkdocs/livereload/__init__.py b/mkdocs/livereload/__init__.py
index e136ff05..b959a067 100644
--- a/mkdocs/livereload/__init__.py
+++ b/mkdocs/livereload/__init__.py
@@ -137,7 +137,7 @@ class LiveReloadServer(socketserver.ThreadingMixIn, wsgiref.simple_server.WSGISe
                 self._rebuild_cond.notify_all()
 
         handler = watchdog.events.FileSystemEventHandler()
-        handler.on_any_event = callback
+        handler.on_any_event = callback  # type: ignore[method-assign]
         log.debug(f"Watching '{path}'")
         self._watch_refs[path] = self.observer.schedule(handler, path, recursive=recursive)
 
diff --git a/mkdocs/plugins.py b/mkdocs/plugins.py
index 40cac267..d33ab461 100644
--- a/mkdocs/plugins.py
+++ b/mkdocs/plugins.py
@@ -77,6 +77,9 @@ class BasePlugin(Generic[SomeConfig]):
     config_scheme: PlainConfigSchema = ()
     config: SomeConfig = {}  # type: ignore[assignment]
 
+    supports_multiple_instances: bool = False
+    """Set to true in subclasses to declare support for adding the same plugin multiple times."""
+
     def __class_getitem__(cls, config_class: Type[Config]):
         """Eliminates the need to write `config_class = FooConfig` when subclassing BasePlugin[FooConfig]"""
         name = f'{cls.__name__}[{config_class.__name__}]'
@@ -322,12 +325,12 @@ class BasePlugin(Generic[SomeConfig]):
         page and can be used to alter the `Page` instance.
 
         Parameters:
-            page: `mkdocs.nav.Page` instance
+            page: `mkdocs.structure.pages.Page` instance
             config: global configuration object
             files: global files collection
 
         Returns:
-            `mkdocs.nav.Page` instance
+            `mkdocs.structure.pages.Page` instance
         """
         return page
 
@@ -337,7 +340,7 @@ class BasePlugin(Generic[SomeConfig]):
         the contents of a page's source from the filesystem.
 
         Parameters:
-            page: `mkdocs.nav.Page` instance
+            page: `mkdocs.structure.pages.Page` instance
             config: global configuration object
 
         Returns:
@@ -356,7 +359,7 @@ class BasePlugin(Generic[SomeConfig]):
 
         Parameters:
             markdown: Markdown source text of page as string
-            page: `mkdocs.nav.Page` instance
+            page: `mkdocs.structure.pages.Page` instance
             config: global configuration object
             files: global files collection
 
@@ -375,7 +378,7 @@ class BasePlugin(Generic[SomeConfig]):
 
         Parameters:
             html: HTML rendered from Markdown source as string
-            page: `mkdocs.nav.Page` instance
+            page: `mkdocs.structure.pages.Page` instance
             config: global configuration object
             files: global files collection
 
@@ -393,7 +396,7 @@ class BasePlugin(Generic[SomeConfig]):
 
         Parameters:
             context: dict of template context variables
-            page: `mkdocs.nav.Page` instance
+            page: `mkdocs.structure.pages.Page` instance
             config: global configuration object
             nav: global navigation object
 
@@ -411,7 +414,7 @@ class BasePlugin(Generic[SomeConfig]):
 
         Parameters:
             output: output of rendered template as string
-            page: `mkdocs.nav.Page` instance
+            page: `mkdocs.structure.pages.Page` instance
             config: global configuration object
 
         Returns:
diff --git a/mkdocs/structure/files.py b/mkdocs/structure/files.py
index 91679f82..e3ed45a7 100644
--- a/mkdocs/structure/files.py
+++ b/mkdocs/structure/files.py
@@ -233,7 +233,7 @@ class File:
             url = (dirname or '.') + '/'
         return urlquote(url)
 
-    def url_relative_to(self, other: File) -> str:
+    def url_relative_to(self, other: Union[File, str]) -> str:
         """Return url for file relative to other file."""
         return utils.get_relative_url(self.url, other.url if isinstance(other, File) else other)
 
diff --git a/mkdocs/templates/sitemap.xml b/mkdocs/templates/sitemap.xml
index e3a935a6..e60c7d01 100644
--- a/mkdocs/templates/sitemap.xml
+++ b/mkdocs/templates/sitemap.xml
@@ -1,7 +1,7 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
 {%- for file in pages -%}
-    {% if not file.page.is_link %}
+    {% if not file.page.is_link and (file.page.abs_url or file.page.canonical_url) %}
     <url>
          <loc>{% if file.page.canonical_url %}{{ file.page.canonical_url|e }}{% else %}{{ file.page.abs_url|e }}{% endif %}</loc>
          {% if file.page.update_date %}<lastmod>{{file.page.update_date}}</lastmod>{% endif %}
diff --git a/mkdocs/theme.py b/mkdocs/theme.py
index 06b69d46..2cdc2834 100644
--- a/mkdocs/theme.py
+++ b/mkdocs/theme.py
@@ -88,8 +88,6 @@ class Theme:
             file_path = os.path.join(theme_dir, 'mkdocs_theme.yml')
             with open(file_path, 'rb') as f:
                 theme_config = utils.yaml_load(f)
-                if theme_config is None:
-                    theme_config = {}
         except OSError as e:
             log.debug(e)
             raise ValidationError(
diff --git a/mkdocs/themes/mkdocs/locales/zh_CN/LC_MESSAGES/messages.po b/mkdocs/themes/mkdocs/locales/zh_CN/LC_MESSAGES/messages.po
index 6a17b1f2..ff6c135d 100644
--- a/mkdocs/themes/mkdocs/locales/zh_CN/LC_MESSAGES/messages.po
+++ b/mkdocs/themes/mkdocs/locales/zh_CN/LC_MESSAGES/messages.po
@@ -30,11 +30,11 @@ msgstr "搜索"
 
 #: mkdocs/themes/mkdocs/base.html:126
 msgid "Previous"
-msgstr "上一张"
+msgstr "上一章"
 
 #: mkdocs/themes/mkdocs/base.html:131
 msgid "Next"
-msgstr "下一张"
+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
diff --git a/mkdocs/themes/readthedocs/locales/zh_CN/LC_MESSAGES/messages.po b/mkdocs/themes/readthedocs/locales/zh_CN/LC_MESSAGES/messages.po
index 34641be8..6e7999a4 100644
--- a/mkdocs/themes/readthedocs/locales/zh_CN/LC_MESSAGES/messages.po
+++ b/mkdocs/themes/readthedocs/locales/zh_CN/LC_MESSAGES/messages.po
@@ -60,13 +60,13 @@ msgstr "面包屑导航"
 #: mkdocs/themes/readthedocs/footer.html:7
 #: mkdocs/themes/readthedocs/versions.html:17
 msgid "Previous"
-msgstr "上一张"
+msgstr "上一章"
 
 #: mkdocs/themes/readthedocs/breadcrumbs.html:39
 #: mkdocs/themes/readthedocs/footer.html:10
 #: mkdocs/themes/readthedocs/versions.html:20
 msgid "Next"
-msgstr "下一张"
+msgstr "下一章"
 
 #: mkdocs/themes/readthedocs/footer.html:5
 msgid "Footer Navigation"
diff --git a/mkdocs/utils/__init__.py b/mkdocs/utils/__init__.py
index e54413e0..d2973c56 100644
--- a/mkdocs/utils/__init__.py
+++ b/mkdocs/utils/__init__.py
@@ -30,6 +30,7 @@ from typing import (
     Tuple,
     Type,
     TypeVar,
+    Union,
 )
 from urllib.parse import urlsplit
 
@@ -76,11 +77,13 @@ def get_yaml_loader(loader=yaml.Loader):
     return Loader
 
 
-def yaml_load(source: IO, loader: Optional[Type[yaml.Loader]] = None) -> Optional[Dict[str, Any]]:
+def yaml_load(source: Union[IO, str], loader: Optional[Type[yaml.Loader]] = None) -> Dict[str, Any]:
     """Return dict of source YAML file using loader, recursively deep merging inherited parent."""
     Loader = loader or get_yaml_loader()
     result = yaml.load(source, Loader=Loader)
-    if result is not None and 'INHERIT' in result:
+    if result is None:
+        return {}
+    if 'INHERIT' in result and not isinstance(source, str):
         relpath = result.pop('INHERIT')
         abspath = os.path.normpath(os.path.join(os.path.dirname(source.name), relpath))
         if not os.path.exists(abspath):
@@ -142,7 +145,7 @@ def get_build_date() -> str:
     return get_build_datetime().strftime('%Y-%m-%d')
 
 
-def reduce_list(data_set: Iterable[str]) -> List[str]:
+def reduce_list(data_set: Iterable[T]) -> List[T]:
     """Reduce duplicate items in a list and preserve order"""
     return list(dict.fromkeys(data_set))
 
@@ -320,7 +323,7 @@ def _get_norm_url(path: str) -> Tuple[str, bool]:
 
 
 def create_media_urls(
-    path_list: List[str], page: Optional[Page] = None, base: str = ''
+    path_list: Iterable[str], page: Optional[Page] = None, base: str = ''
 ) -> List[str]:
     """
     Return a list of URLs relative to the given page or using the base.


Test patch:
diff --git a/mkdocs/tests/__init__.py b/mkdocs/tests/__init__.py
old mode 100755
new mode 100644
diff --git a/mkdocs/tests/config/config_options_tests.py b/mkdocs/tests/config/config_options_tests.py
index a34f5d60..3c72eb16 100644
--- a/mkdocs/tests/config/config_options_tests.py
+++ b/mkdocs/tests/config/config_options_tests.py
@@ -1633,7 +1633,7 @@ class _FakePlugin2Config(_FakePluginConfig):
 
 
 class FakePlugin2(BasePlugin[_FakePlugin2Config]):
-    pass
+    supports_multiple_instances = True
 
 
 class ThemePlugin(BasePlugin[_FakePluginConfig]):
@@ -1804,6 +1804,57 @@ class PluginsTest(TestCase):
         self.assertEqual(set(conf.plugins), {'overridden'})
         self.assertIsInstance(conf.plugins['overridden'], FakePlugin2)
 
+    def test_plugin_config_with_multiple_instances(self, mock_class) -> None:
+        class Schema(Config):
+            theme = c.Theme(default='mkdocs')
+            plugins = c.Plugins(theme_key='theme')
+
+        cfg = {
+            'plugins': [
+                {'sample2': {'foo': 'foo value', 'bar': 42}},
+                {'sample2': {'foo': 'foo2 value'}},
+            ],
+        }
+        conf = self.get_config(Schema, cfg)
+
+        self.assertEqual(
+            set(conf.plugins),
+            {'sample2', 'sample2 #2'},
+        )
+        self.assertEqual(conf.plugins['sample2'].config['bar'], 42)
+        self.assertEqual(conf.plugins['sample2 #2'].config['bar'], 0)
+
+    def test_plugin_config_with_multiple_instances_and_warning(self, mock_class) -> None:
+        class Schema(Config):
+            theme = c.Theme(default='mkdocs')
+            plugins = c.Plugins(theme_key='theme')
+
+        test_cfgs: List[Dict[str, Any]] = [
+            {
+                'theme': 'readthedocs',
+                'plugins': [{'sub_plugin': {}}, {'sample2': {}}, {'sub_plugin': {}}, 'sample2'],
+            },
+            {
+                'theme': 'readthedocs',
+                'plugins': ['sub_plugin', 'sample2', 'sample2', 'sub_plugin'],
+            },
+        ]
+
+        for cfg in test_cfgs:
+            conf = self.get_config(
+                Schema,
+                cfg,
+                warnings=dict(
+                    plugins="Plugin 'readthedocs/sub_plugin' was specified multiple times - "
+                    "this is likely a mistake, because the plugin doesn't declare "
+                    "`supports_multiple_instances`."
+                ),
+            )
+            self.assertEqual(
+                set(conf.plugins),
+                {'readthedocs/sub_plugin', 'readthedocs/sub_plugin #2', 'sample2', 'sample2 #2'},
+            )
+
     def test_plugin_config_empty_list_with_empty_default(self, mock_class) -> None:
         class Schema(Config):
             plugins = c.Plugins(default=[])
diff --git a/mkdocs/tests/config/config_tests.py b/mkdocs/tests/config/config_tests.py
index e0981789..e1cb191a 100644
--- a/mkdocs/tests/config/config_tests.py
+++ b/mkdocs/tests/config/config_tests.py
@@ -27,10 +27,6 @@ class ConfigTests(unittest.TestCase):
         )
         self.assertEqual(warnings, [])
 
-    def test_empty_config(self):
-        with self.assertRaises(ConfigurationError):
-            config.load_config(config_file='/dev/null')
-
     def test_nonexistant_config(self):
         with self.assertRaises(ConfigurationError):
             config.load_config(config_file='/path/that/is/not/real')
diff --git a/mkdocs/tests/theme_tests.py b/mkdocs/tests/theme_tests.py
index 213e2644..bb183da1 100644
--- a/mkdocs/tests/theme_tests.py
+++ b/mkdocs/tests/theme_tests.py
@@ -81,7 +81,7 @@ class ThemeTests(unittest.TestCase):
         self.assertTrue('new' in theme)
         self.assertEqual(theme['new'], 42)
 
-    @mock.patch('mkdocs.utils.yaml_load', return_value=None)
+    @mock.patch('mkdocs.utils.yaml_load', return_value={})
     def test_no_theme_config(self, m):
         theme = Theme(name='mkdocs')
         self.assertEqual(m.call_count, 1)
diff --git a/mkdocs/tests/utils/utils_tests.py b/mkdocs/tests/utils/utils_tests.py
index 5b7a2d13..5be58fb7 100644
--- a/mkdocs/tests/utils/utils_tests.py
+++ b/mkdocs/tests/utils/utils_tests.py
@@ -424,7 +424,7 @@ class UtilsTests(unittest.TestCase):
             key2:
               - value
             '''
-        ).encode('utf-8')
+        )
 
         config = utils.yaml_load(yaml_src)
         self.assertTrue(isinstance(config['key'], str))