Repository: mkdocs/mkdocs
Pull request #2973 (2022-09-11 23:00:44)

Let plugins choose the priority order of their events
Add a decorator that sets a priority value for plugins' 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.

Recommended priority values:
`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.

```python
@plugins.event_priority(-100)  # Wishing to run this after all other plugins' `on_files` events.
def on_files(self, files, config, **kwargs):
    ...
```



Patch:
diff --git a/docs/css/extra.css b/docs/css/extra.css
index 5b5f8290..75040dcd 100644
--- a/docs/css/extra.css
+++ b/docs/css/extra.css
@@ -60,6 +60,11 @@ body.homepage>div.container>div.row>div.col-md-9 {
 
 /* mkdocstrings */
 
+.doc-object {
+    padding-left: 10px;
+    border-left: 4px solid rgba(230, 230, 230);
+}
+
 .doc-contents .field-body p:first-of-type {
     display: inline;
 }
diff --git a/docs/dev-guide/plugins.md b/docs/dev-guide/plugins.md
index 2d692fc6..b4cd7c3e 100644
--- a/docs/dev-guide/plugins.md
+++ b/docs/dev-guide/plugins.md
@@ -295,6 +295,16 @@ page events are called after the [post_template] event and before the
         show_root_heading: false
         show_root_toc_entry: false
 
+### Event Priorities
+
+For each event type, corresponding methods of plugins are called in the order that the plugins appear in the `plugins` [config][].
+
+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
+
 ### Handling Errors
 
 MkDocs defines four error types:
diff --git a/docs/user-guide/configuration.md b/docs/user-guide/configuration.md
index 953ce14f..8d4c2660 100644
--- a/docs/user-guide/configuration.md
+++ b/docs/user-guide/configuration.md
@@ -93,6 +93,24 @@ directory.
 edit_uri: root/path/docs/
 ```
 
+For example, having this config:
+
+```yaml
+repo_url: https://example.com/project/repo
+edit_uri: blob/main/docs/
+```
+
+means that a page named 'foo/bar.md' will have its edit link lead to:  
+<https://example.com/project/repo/blob/main/docs/foo/bar.md>
+
+`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/
+```
+
+For more flexibility, see [edit_uri_template](#edit_uri_template) below.
+
 > NOTE:
 > On a few known hosts (specifically GitHub, Bitbucket and GitLab), the
 > `edit_uri` is derived from the 'repo_url' and does not need to be set
@@ -124,6 +142,63 @@ access.
 `src/default/docs/` for a Bitbucket repo, if `repo_url` matches those domains,
 otherwise `null`
 
+### edit_uri_template
+
+The more flexible variant of [edit_uri](#edit_uri). These two are equivalent:
+
+```yaml
+edit_uri: 'blob/main/docs/'
+edit_uri_template: 'blob/main/docs/{path}'
+```
+
+(they are also mutually exclusive -- don't specify both).
+
+Starting from here, you can change the positioning or formatting of the path, in case the default behavior of appending the path isn't enough.
+
+The contents of `edit_uri_template` are normal [Python format strings](https://docs.python.org/3/library/string.html#formatstrings), with only these fields available:
+
+* `{path}`, e.g. `foo/bar.md`
+* `{path_noext}`, e.g. `foo/bar`
+
+And the conversion flag `!q` is available, to percent-encode the field:
+
+* `{path!q}`, e.g. `foo%2Fbar.md`
+
+Here are some suggested configurations that can be useful:
+
+GitHub Wiki:  
+(e.g. `https://github.com/project/repo/wiki/foo/bar/_edit`)
+
+```yaml
+repo_url: 'https://github.com/project/repo/wiki'
+edit_uri_template: '{path_noext}/_edit'
+```
+
+BitBucket editor:  
+(e.g. `https://bitbucket.org/project/repo/src/master/docs/foo/bar.md?mode=edit`)
+
+```yaml
+repo_url: 'https://bitbucket.org/project/repo/'
+edit_uri_template: 'src/master/docs/{path}?mode=edit'
+```
+
+GitLab Static Site Editor:  
+(e.g. `https://gitlab.com/project/repo/-/sse/master/docs%2Ffoo%2bar.md`)
+
+```yaml
+repo_url: 'https://gitlab.com/project/repo'
+edit_uri_template: '-/sse/master/docs%2F{path!q}'
+```
+
+GitLab Web IDE:  
+(e.g. `https://gitlab.com/-/ide/project/repo/edit/master/-/docs/foo/bar.md`)
+
+```yaml
+edit_uri_template: 'https://gitlab.com/-/ide/project/repo/edit/master/-/docs/{path}'
+```
+
+**default**: `null`
+
 ### site_description
 
 Set the site description. This will add a meta tag to the generated HTML header.
diff --git a/mkdocs/config/config_options.py b/mkdocs/config/config_options.py
index 0c421866..9839114e 100644
--- a/mkdocs/config/config_options.py
+++ b/mkdocs/config/config_options.py
@@ -2,10 +2,14 @@ from __future__ import annotations
 
 import ipaddress
 import os
+import string
 import sys
 import traceback
 import typing as t
+import warnings
+from collections import UserString
 from typing import NamedTuple
+from urllib.parse import quote as urlquote
 from urllib.parse import urlsplit, urlunsplit
 
 import markdown
@@ -347,12 +351,11 @@ class URL(OptionallyRequired):
 
 
 class RepoURL(URL):
-    """
-    Repo URL Config Option
-
-    A small extension to the URL config that sets the repo_name and edit_uri,
-    based on the url if they haven't already been provided.
-    """
+    def __init__(self, *args, **kwargs):
+        warnings.warn(
+            "RepoURL is no longer used in MkDocs and will be removed.", DeprecationWarning
+        )
+        super().__init__(*args, **kwargs)
 
     def post_validation(self, config, key_name):
         repo_host = urlsplit(config['repo_url']).netloc.lower()
@@ -385,6 +388,88 @@ class RepoURL(URL):
         config['edit_uri'] = edit_uri
 
 
+class EditURI(Type):
+    def __init__(self, repo_url_key):
+        super().__init__(str)
+        self.repo_url_key = repo_url_key
+
+    def post_validation(self, config, key_name):
+        edit_uri = config.get(key_name)
+        repo_url = config.get(self.repo_url_key)
+
+        if edit_uri is None and repo_url is not None:
+            repo_host = urlsplit(repo_url).netloc.lower()
+            if repo_host == 'github.com' or repo_host == 'gitlab.com':
+                edit_uri = 'edit/master/docs/'
+            elif repo_host == 'bitbucket.org':
+                edit_uri = 'src/default/docs/'
+
+        # ensure a well-formed edit_uri
+        if edit_uri and not edit_uri.endswith('/'):
+            edit_uri += '/'
+
+        config[key_name] = edit_uri
+
+
+class EditURITemplate(OptionallyRequired):
+    class Formatter(string.Formatter):
+        def convert_field(self, value, conversion):
+            if conversion == 'q':
+                return urlquote(value, safe='')
+            return super().convert_field(value, conversion)
+
+    class Template(UserString):
+        def __init__(self, formatter, data):
+            super().__init__(data)
+            self.formatter = formatter
+            try:
+                self.format('', '')
+            except KeyError as e:
+                raise ValueError(f"Unknown template substitute: {e}")
+
+        def format(self, path, path_noext):
+            return self.formatter.format(self.data, path=path, path_noext=path_noext)
+
+    def __init__(self, edit_uri_key=None):
+        super().__init__()
+        self.edit_uri_key = edit_uri_key
+
+    def run_validation(self, value):
+        try:
+            return self.Template(self.Formatter(), value)
+        except Exception as e:
+            raise ValidationError(e)
+
+    def post_validation(self, config, key_name):
+        if self.edit_uri_key and config.get(key_name) and config.get(self.edit_uri_key):
+            self.warnings.append(
+                f"The option '{self.edit_uri_key}' has no effect when '{key_name}' is set."
+            )
+
+
+class RepoName(Type):
+    def __init__(self, repo_url_key):
+        super().__init__(str)
+        self.repo_url_key = repo_url_key
+
+    def post_validation(self, config, key_name):
+        repo_name = config.get(key_name)
+        repo_url = config.get(self.repo_url_key)
+
+        # derive repo_name from repo_url if unset
+        if repo_url is not None and repo_name is None:
+            repo_host = urlsplit(config['repo_url']).netloc.lower()
+            if repo_host == 'github.com':
+                repo_name = 'GitHub'
+            elif repo_host == 'bitbucket.org':
+                repo_name = 'Bitbucket'
+            elif repo_host == 'gitlab.com':
+                repo_name = 'GitLab'
+            else:
+                repo_name = repo_host.split('.')[0].title()
+            config[key_name] = repo_name
+
+
 class FilesystemObject(Type):
     """
     Base class for options that point to filesystem objects.
diff --git a/mkdocs/config/defaults.py b/mkdocs/config/defaults.py
index d2f99845..cf4b4073 100644
--- a/mkdocs/config/defaults.py
+++ b/mkdocs/config/defaults.py
@@ -63,17 +63,18 @@ class _MkDocsConfig:
     True generates nicer URLs, but False is useful if browsing the output on
     a filesystem."""
 
-    repo_url = config_options.RepoURL()
+    repo_url = config_options.URL()
     """Specify a link to the project source repo to be included
     in the documentation pages."""
 
-    repo_name = config_options.Type(str)
+    repo_name = config_options.RepoName('repo_url')
     """A name to use for the link to the project source repo.
     Default, If repo_url is unset then None, otherwise
     "GitHub", "Bitbucket" or "GitLab" for known url or Hostname
     for unknown urls."""
 
-    edit_uri = config_options.Type(str)
+    edit_uri_template = config_options.EditURITemplate('edit_uri')
+    edit_uri = config_options.EditURI('repo_url')
     """Specify a URI to the docs dir in the project source repo, relative to the
     repo_url. When set, a link directly to the page in the source repo will
     be added to the generated HTML. If repo_url is not set also, this option
diff --git a/mkdocs/plugins.py b/mkdocs/plugins.py
index c7afc758..494827a9 100644
--- a/mkdocs/plugins.py
+++ b/mkdocs/plugins.py
@@ -16,6 +16,7 @@ else:
 
 import jinja2.environment
 
+from mkdocs import utils
 from mkdocs.config.base import Config, ConfigErrors, ConfigWarnings, PlainConfigSchema
 from mkdocs.livereload import LiveReloadServer
 from mkdocs.structure.files import Files
@@ -346,6 +347,37 @@ for k in EVENTS:
 T = TypeVar('T')
 
 
+def event_priority(priority: float) -> Callable[[T], T]:
+    """A decorator to set an event priority for an event handler method.
+
+    Recommended priority values:
+    `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.
+
+    ```python
+    @plugins.event_priority(-100)  # Wishing to run this after all other plugins' `on_files` events.
+    def on_files(self, files, config, **kwargs):
+        ...
+    ```
+
+    New in MkDocs 1.4.
+    Recommended shim for backwards compatibility:
+
+    ```python
+    try:
+        from mkdocs.plugins import event_priority
+    except ImportError:
+        event_priority = lambda priority: lambda f: f  # No-op fallback
+    ```
+    """
+
+    def decorator(event_method):
+        event_method.mkdocs_priority = priority
+        return event_method
+
+    return decorator
+
+
 class PluginCollection(OrderedDict):
     """
     A collection of plugins.
@@ -361,7 +393,9 @@ class PluginCollection(OrderedDict):
 
     def _register_event(self, event_name: str, method: Callable) -> None:
         """Register a method for an event."""
-        self.events[event_name].append(method)
+        utils.insort(
+            self.events[event_name], method, key=lambda m: -getattr(m, 'mkdocs_priority', 0)
+        )
 
     def __setitem__(self, key: str, value: BasePlugin, **kwargs) -> None:
         if not isinstance(value, BasePlugin):
diff --git a/mkdocs/structure/pages.py b/mkdocs/structure/pages.py
index 651f8bca..7f9d2ef5 100644
--- a/mkdocs/structure/pages.py
+++ b/mkdocs/structure/pages.py
@@ -41,7 +41,9 @@ class Page:
         self.update_date = get_build_date()
 
         self._set_canonical_url(config.get('site_url', None))
-        self._set_edit_url(config.get('repo_url', None), config.get('edit_uri', None))
+        self._set_edit_url(
+            config.get('repo_url', None), config.get('edit_uri'), config.get('edit_uri_template')
+        )
 
         # Placeholders to be filled in later in the build process.
         self.markdown = None
@@ -172,10 +174,20 @@ class Page:
             self.canonical_url = None
             self.abs_url = None
 
-    def _set_edit_url(self, repo_url: Optional[str], edit_uri: Optional[str]) -> None:
-        if edit_uri:
+    def _set_edit_url(
+        self,
+        repo_url: Optional[str],
+        edit_uri: Optional[str] = None,
+        edit_uri_template: Optional[str] = None,
+    ) -> None:
+        if edit_uri or edit_uri_template:
             src_uri = self.file.src_uri
-            edit_uri += src_uri
+            if edit_uri_template:
+                noext = posixpath.splitext(src_uri)[0]
+                edit_uri = edit_uri_template.format(path=src_uri, path_noext=noext)
+            else:
+                assert edit_uri is not None and edit_uri.endswith('/')
+                edit_uri += src_uri
             if repo_url:
                 # Ensure urljoin behavior is correct
                 if not edit_uri.startswith(('?', '#')) and not repo_url.endswith('/'):
diff --git a/mkdocs/utils/__init__.py b/mkdocs/utils/__init__.py
index c9d2a552..f4b9c696 100644
--- a/mkdocs/utils/__init__.py
+++ b/mkdocs/utils/__init__.py
@@ -17,7 +17,18 @@ import warnings
 from collections import defaultdict
 from datetime import datetime, timezone
 from pathlib import PurePath
-from typing import IO, TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Tuple
+from typing import (
+    IO,
+    TYPE_CHECKING,
+    Any,
+    Dict,
+    Iterable,
+    List,
+    MutableSequence,
+    Optional,
+    Tuple,
+    TypeVar,
+)
 from urllib.parse import urlsplit
 
 if sys.version_info >= (3, 10):
@@ -34,6 +45,8 @@ from mkdocs import exceptions
 if TYPE_CHECKING:
     from mkdocs.structure.pages import Page
 
+T = TypeVar('T')
+
 log = logging.getLogger(__name__)
 
 markdown_extensions = (
@@ -132,6 +145,18 @@ def reduce_list(data_set: Iterable[str]) -> List[str]:
     return list(dict.fromkeys(data_set))
 
 
+if sys.version_info >= (3, 10):
+    from bisect import insort
+else:
+
+    def insort(a: MutableSequence[T], x: T, *, key=lambda v: v) -> None:
+        kx = key(x)
+        i = len(a)
+        while i > 0 and kx < key(a[i - 1]):
+            i -= 1
+        a.insert(i, x)
+
+
 def copy_file(source_path: str, output_path: str) -> None:
     """
     Copy source_path to output_path, making sure any parent directories exist.


Test patch:
diff --git a/mkdocs/tests/config/config_options_tests.py b/mkdocs/tests/config/config_options_tests.py
index 3ad71f2f..a1b0b3c1 100644
--- a/mkdocs/tests/config/config_options_tests.py
+++ b/mkdocs/tests/config/config_options_tests.py
@@ -417,11 +417,12 @@ class URLTest(TestCase):
             self.get_config(Schema, {'option': 1})
 
 
-class RepoURLTest(TestCase):
+class EditURITest(TestCase):
     class Schema:
-        repo_url = config_options.RepoURL()
-        repo_name = config_options.Type(str)
-        edit_uri = config_options.Type(str)
+        repo_url = config_options.URL()
+        repo_name = config_options.RepoName('repo_url')
+        edit_uri_template = config_options.EditURITemplate('edit_uri')
+        edit_uri = config_options.EditURI('repo_url')
 
     def test_repo_name_github(self):
         config = self.get_config(
@@ -479,7 +480,7 @@ class RepoURLTest(TestCase):
             self.Schema,
             {'repo_url': "https://launchpad.net/python-tuskarclient"},
         )
-        self.assertEqual(config.get('edit_uri'), '')
+        self.assertEqual(config.get('edit_uri'), None)
         self.assertEqual(config['repo_url'], "https://launchpad.net/python-tuskarclient")
 
     def test_repo_name_custom_and_empty_edit_uri(self):
@@ -489,6 +490,60 @@ class RepoURLTest(TestCase):
         )
         self.assertEqual(config.get('edit_uri'), 'edit/master/docs/')
 
+    def test_edit_uri_template_ok(self):
+        config = self.get_config(
+            self.Schema,
+            {
+                'repo_url': "https://github.com/mkdocs/mkdocs",
+                'edit_uri_template': 'edit/foo/docs/{path}',
+            },
+        )
+        self.assertEqual(config['edit_uri_template'], 'edit/foo/docs/{path}')
+
+    def test_edit_uri_template_errors(self):
+        with self.expect_error(
+            edit_uri_template=re.compile(r'.*[{}].*')  # Complains about unclosed '{' or missing '}'
+        ):
+            self.get_config(
+                self.Schema,
+                {
+                    'repo_url': "https://github.com/mkdocs/mkdocs",
+                    'edit_uri_template': 'edit/master/{path',
+                },
+            )
+
+        with self.expect_error(edit_uri_template=re.compile(r'.*\bz\b.*')):
+            self.get_config(
+                self.Schema,
+                {
+                    'repo_url': "https://github.com/mkdocs/mkdocs",
+                    'edit_uri_template': 'edit/master/{path!z}',
+                },
+            )
+
+        with self.expect_error(edit_uri_template="Unknown template substitute: 'foo'"):
+            self.get_config(
+                self.Schema,
+                {
+                    'repo_url': "https://github.com/mkdocs/mkdocs",
+                    'edit_uri_template': 'edit/master/{foo}',
+                },
+            )
+
+    def test_edit_uri_template_warning(self):
+        config = self.get_config(
+            self.Schema,
+            {
+                'repo_url': "https://github.com/mkdocs/mkdocs",
+                'edit_uri': 'edit',
+                'edit_uri_template': 'edit/master/{path}',
+            },
+            warnings=dict(
+                edit_uri_template="The option 'edit_uri' has no effect when 'edit_uri_template' is set."
+            ),
+        )
+        self.assertEqual(config['edit_uri_template'], 'edit/master/{path}')
+
 
 class ListOfItemsTest(TestCase):
     def test_int_type(self):
diff --git a/mkdocs/tests/plugin_tests.py b/mkdocs/tests/plugin_tests.py
index 7b1f6866..073512bf 100644
--- a/mkdocs/tests/plugin_tests.py
+++ b/mkdocs/tests/plugin_tests.py
@@ -117,6 +117,45 @@ class TestPluginCollection(unittest.TestCase):
             },
         )
 
+    def test_event_priorities(self):
+        class PrioPlugin(plugins.BasePlugin):
+            config_scheme = base.get_schema(_DummyPluginConfig)
+
+            @plugins.event_priority(100)
+            def on_pre_page(self, content, **kwargs):
+                pass
+
+            @plugins.event_priority(-100)
+            def on_nav(self, item, **kwargs):
+                pass
+
+            def on_page_read_source(self, **kwargs):
+                pass
+
+            @plugins.event_priority(-50)
+            def on_post_build(self, **kwargs):
+                pass
+
+        collection = plugins.PluginCollection()
+        collection['dummy'] = dummy = DummyPlugin()
+        collection['prio'] = prio = PrioPlugin()
+        self.assertEqual(
+            collection.events['pre_page'],
+            [prio.on_pre_page, dummy.on_pre_page],
+        )
+        self.assertEqual(
+            collection.events['nav'],
+            [dummy.on_nav, prio.on_nav],
+        )
+        self.assertEqual(
+            collection.events['page_read_source'],
+            [dummy.on_page_read_source, prio.on_page_read_source],
+        )
+        self.assertEqual(
+            collection.events['post_build'],
+            [prio.on_post_build],
+        )
+
     def test_set_plugin_on_collection(self):
         collection = plugins.PluginCollection()
         plugin = DummyPlugin()
diff --git a/mkdocs/tests/structure/page_tests.py b/mkdocs/tests/structure/page_tests.py
index a0d1e316..24b17a01 100644
--- a/mkdocs/tests/structure/page_tests.py
+++ b/mkdocs/tests/structure/page_tests.py
@@ -429,156 +429,139 @@ class PageTests(unittest.TestCase):
         self.assertEqual(pg.markdown, md_src)
         self.assertEqual(pg.meta, {})
 
-    def test_page_edit_url(self):
+    def test_page_edit_url(
+        self, paths={'testing.md': 'testing/', 'sub1/non-index.md': 'sub1/non-index/'}
+    ):
         for case in [
             dict(
                 config={'repo_url': 'http://github.com/mkdocs/mkdocs'},
                 edit_url='http://github.com/mkdocs/mkdocs/edit/master/docs/testing.md',
+                edit_url2='http://github.com/mkdocs/mkdocs/edit/master/docs/sub1/non-index.md',
             ),
             dict(
                 config={'repo_url': 'https://github.com/mkdocs/mkdocs/'},
                 edit_url='https://github.com/mkdocs/mkdocs/edit/master/docs/testing.md',
+                edit_url2='https://github.com/mkdocs/mkdocs/edit/master/docs/sub1/non-index.md',
             ),
             dict(
                 config={'repo_url': 'http://example.com'},
                 edit_url=None,
+                edit_url2=None,
             ),
             dict(
                 config={'repo_url': 'http://example.com', 'edit_uri': 'edit/master'},
                 edit_url='http://example.com/edit/master/testing.md',
+                edit_url2='http://example.com/edit/master/sub1/non-index.md',
             ),
             dict(
                 config={'repo_url': 'http://example.com', 'edit_uri': '/edit/master'},
                 edit_url='http://example.com/edit/master/testing.md',
+                edit_url2='http://example.com/edit/master/sub1/non-index.md',
             ),
             dict(
                 config={'repo_url': 'http://example.com/foo/', 'edit_uri': '/edit/master/'},
                 edit_url='http://example.com/edit/master/testing.md',
+                edit_url2='http://example.com/edit/master/sub1/non-index.md',
             ),
             dict(
                 config={'repo_url': 'http://example.com/foo', 'edit_uri': '/edit/master/'},
                 edit_url='http://example.com/edit/master/testing.md',
+                edit_url2='http://example.com/edit/master/sub1/non-index.md',
             ),
             dict(
                 config={'repo_url': 'http://example.com/foo/', 'edit_uri': '/edit/master'},
                 edit_url='http://example.com/edit/master/testing.md',
+                edit_url2='http://example.com/edit/master/sub1/non-index.md',
             ),
             dict(
                 config={'repo_url': 'http://example.com/foo/', 'edit_uri': 'edit/master/'},
                 edit_url='http://example.com/foo/edit/master/testing.md',
+                edit_url2='http://example.com/foo/edit/master/sub1/non-index.md',
             ),
             dict(
                 config={'repo_url': 'http://example.com/foo', 'edit_uri': 'edit/master/'},
                 edit_url='http://example.com/foo/edit/master/testing.md',
+                edit_url2='http://example.com/foo/edit/master/sub1/non-index.md',
             ),
             dict(
                 config={'repo_url': 'http://example.com', 'edit_uri': '?query=edit/master'},
                 edit_url='http://example.com?query=edit/master/testing.md',
+                edit_url2='http://example.com?query=edit/master/sub1/non-index.md',
             ),
             dict(
                 config={'repo_url': 'http://example.com/', 'edit_uri': '?query=edit/master/'},
                 edit_url='http://example.com/?query=edit/master/testing.md',
+                edit_url2='http://example.com/?query=edit/master/sub1/non-index.md',
             ),
             dict(
                 config={'repo_url': 'http://example.com', 'edit_uri': '#edit/master'},
                 edit_url='http://example.com#edit/master/testing.md',
+                edit_url2='http://example.com#edit/master/sub1/non-index.md',
             ),
             dict(
                 config={'repo_url': 'http://example.com/', 'edit_uri': '#edit/master/'},
                 edit_url='http://example.com/#edit/master/testing.md',
+                edit_url2='http://example.com/#edit/master/sub1/non-index.md',
             ),
             dict(
                 config={'edit_uri': 'http://example.com/edit/master'},
                 edit_url='http://example.com/edit/master/testing.md',
+                edit_url2='http://example.com/edit/master/sub1/non-index.md',
             ),
             dict(
-                config={'repo_url': 'http://example.com', 'edit_uri': ''},  # Set to blank value
-                edit_url=None,
-            ),
-            dict(config={}, edit_url=None),  # Nothing defined
-        ]:
-            with self.subTest(case['config']):
-                cfg = load_config(**case['config'])
-                fl = File('testing.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls'])
-                pg = Page('Foo', fl, cfg)
-                self.assertEqual(pg.url, 'testing/')
-                self.assertEqual(pg.edit_url, case['edit_url'])
-
-    def test_nested_page_edit_url(self, file_src_path='sub1/non-index.md'):
-        for case in [
-            dict(
-                config={'repo_url': 'http://github.com/mkdocs/mkdocs'},
-                edit_url='http://github.com/mkdocs/mkdocs/edit/master/docs/sub1/non-index.md',
-            ),
-            dict(
-                config={'repo_url': 'https://github.com/mkdocs/mkdocs/'},
-                edit_url='https://github.com/mkdocs/mkdocs/edit/master/docs/sub1/non-index.md',
-            ),
-            dict(
-                config={'repo_url': 'http://example.com'},
-                edit_url=None,
-            ),
-            dict(
-                config={'repo_url': 'http://example.com', 'edit_uri': 'edit/master'},
-                edit_url='http://example.com/edit/master/sub1/non-index.md',
-            ),
-            dict(
-                config={'repo_url': 'http://example.com', 'edit_uri': '/edit/master'},
-                edit_url='http://example.com/edit/master/sub1/non-index.md',
-            ),
-            dict(
-                config={'repo_url': 'http://example.com/foo/', 'edit_uri': '/edit/master/'},
-                edit_url='http://example.com/edit/master/sub1/non-index.md',
-            ),
-            dict(
-                config={'repo_url': 'http://example.com/foo', 'edit_uri': '/edit/master/'},
-                edit_url='http://example.com/edit/master/sub1/non-index.md',
-            ),
-            dict(
-                config={'repo_url': 'http://example.com/foo/', 'edit_uri': '/edit/master'},
-                edit_url='http://example.com/edit/master/sub1/non-index.md',
-            ),
-            dict(
-                config={'repo_url': 'http://example.com/foo/', 'edit_uri': 'edit/master/'},
-                edit_url='http://example.com/foo/edit/master/sub1/non-index.md',
-            ),
-            dict(
-                config={'repo_url': 'http://example.com/foo', 'edit_uri': 'edit/master/'},
-                edit_url='http://example.com/foo/edit/master/sub1/non-index.md',
-            ),
-            dict(
-                config={'repo_url': 'http://example.com', 'edit_uri': '?query=edit/master'},
-                edit_url='http://example.com?query=edit/master/sub1/non-index.md',
-            ),
-            dict(
-                config={'repo_url': 'http://example.com/', 'edit_uri': '?query=edit/master/'},
-                edit_url='http://example.com/?query=edit/master/sub1/non-index.md',
+                config={'edit_uri_template': 'https://github.com/project/repo/wiki/{path_noext}'},
+                edit_url='https://github.com/project/repo/wiki/testing',
+                edit_url2='https://github.com/project/repo/wiki/sub1/non-index',
             ),
             dict(
-                config={'repo_url': 'http://example.com', 'edit_uri': '#edit/master'},
-                edit_url='http://example.com#edit/master/sub1/non-index.md',
+                config={
+                    'repo_url': 'https://github.com/project/repo/wiki',
+                    'edit_uri_template': '{path_noext}/_edit',
+                },
+                edit_url='https://github.com/project/repo/wiki/testing/_edit',
+                edit_url2='https://github.com/project/repo/wiki/sub1/non-index/_edit',
             ),
             dict(
-                config={'repo_url': 'http://example.com/', 'edit_uri': '#edit/master/'},
-                edit_url='http://example.com/#edit/master/sub1/non-index.md',
+                config={
+                    'repo_url': 'https://gitlab.com/project/repo',
+                    'edit_uri_template': '-/sse/master/docs%2F{path!q}',
+                },
+                edit_url='https://gitlab.com/project/repo/-/sse/master/docs%2Ftesting.md',
+                edit_url2='https://gitlab.com/project/repo/-/sse/master/docs%2Fsub1%2Fnon-index.md',
             ),
             dict(
-                config={'edit_uri': 'http://example.com/edit/master'},
-                edit_url='http://example.com/edit/master/sub1/non-index.md',
+                config={
+                    'repo_url': 'https://bitbucket.org/project/repo/',
+                    'edit_uri_template': 'src/master/docs/{path}?mode=edit',
+                },
+                edit_url='https://bitbucket.org/project/repo/src/master/docs/testing.md?mode=edit',
+                edit_url2='https://bitbucket.org/project/repo/src/master/docs/sub1/non-index.md?mode=edit',
             ),
             dict(
-                config={'repo_url': 'http://example.com', 'edit_uri': ''},  # Set to blank value
+                config={
+                    'repo_url': 'http://example.com',
+                    'edit_uri': '',
+                    'edit_uri_template': '',
+                },  # Set to blank value
                 edit_url=None,
+                edit_url2=None,
             ),
+            dict(config={}, edit_url=None, edit_url2=None),  # Nothing defined
         ]:
-            with self.subTest(case['config']):
-                cfg = load_config(**case['config'], docs_dir=self.DOCS_DIR)
-                fl = File(
-                    file_src_path, cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']
-                )
-                pg = Page('Foo', fl, cfg)
-                self.assertEqual(pg.url, 'sub1/non-index/')
-                self.assertEqual(pg.edit_url, case['edit_url'])
+            for i, path in enumerate(paths, 1):
+                edit_url_key = f'edit_url{i}' if i > 1 else 'edit_url'
+                with self.subTest(case['config'], path=path):
+                    cfg = load_config(**case['config'])
+                    fl = File(path, cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls'])
+                    pg = Page('Foo', fl, cfg)
+                    self.assertEqual(pg.url, paths[path])
+                    self.assertEqual(pg.edit_url, case[edit_url_key])
+
+    @unittest.skipUnless(sys.platform.startswith("win"), "requires Windows")
+    def test_page_edit_url_windows(self):
+        self.test_page_edit_url(
+            paths={'testing.md': 'testing/', 'sub1\\non-index.md': 'sub1/non-index/'}
+        )
 
     def test_page_edit_url_warning(self):
         for case in [
@@ -600,10 +583,6 @@ class PageTests(unittest.TestCase):
                 self.assertEqual(pg.edit_url, case['edit_url'])
                 self.assertEqual(cm.output, [case['warning']])
 
-    @unittest.skipUnless(sys.platform.startswith("win"), "requires Windows")
-    def test_nested_page_edit_url_windows(self):
-        self.test_nested_page_edit_url(file_src_path='sub1\\non-index.md')
-
     def test_page_render(self):
         cfg = load_config()
         fl = File('testing.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls'])
diff --git a/mkdocs/tests/utils/utils_tests.py b/mkdocs/tests/utils/utils_tests.py
index c4eadda6..9ea9b382 100644
--- a/mkdocs/tests/utils/utils_tests.py
+++ b/mkdocs/tests/utils/utils_tests.py
@@ -285,6 +285,22 @@ class UtilsTests(unittest.TestCase):
             [1, 2, 3, 4, 5, 6, 7, 8],
         )
 
+    def test_insort(self):
+        a = [1, 2, 3]
+        utils.insort(a, 5)
+        self.assertEqual(a, [1, 2, 3, 5])
+        utils.insort(a, -1)
+        self.assertEqual(a, [-1, 1, 2, 3, 5])
+        utils.insort(a, 2)
+        self.assertEqual(a, [-1, 1, 2, 2, 3, 5])
+        utils.insort(a, 4)
+        self.assertEqual(a, [-1, 1, 2, 2, 3, 4, 5])
+
+    def test_insort_key(self):
+        a = [(1, 'a'), (1, 'b'), (2, 'c')]
+        utils.insort(a, (1, 'a'), key=lambda v: v[0])
+        self.assertEqual(a, [(1, 'a'), (1, 'b'), (1, 'a'), (2, 'c')])
+
     def test_get_themes(self):
         themes = utils.get_theme_names()
         self.assertIn('mkdocs', themes)