Repository: mkdocs/mkdocs

Issue:
relative url start with "../" in nav no longer works
When I tried to make a link to an external resource in `nav` section of mkdos.yml, using the following example, the actual output HTML omits "../" in the relative URL and I couldn't point to external resources out of mkdocs with relative URL.
https://www.mkdocs.org/user-guide/configuration/#nav

The minimal mkdocs.yml to reproduce the problem is as follows:
```yaml
site_name: Example
site_url: https://example.com/foo/

nav:
    - Parent: '../'
    - Sibling: '../some/dir/'
    - Home: './'
    - Index: 'index.md'
```
When I compile it with empty docs/index.md file, the navigation section in site/index.html became as follows:
```html
<!-- Main navigation -->
<ul class="nav navbar-nav">
    <li class="navitem">
        <a href="./" class="nav-link">Parent</a>
    </li>
    <li class="navitem">
        <a href="some/dir/" class="nav-link">Sibling</a>
    </li>
    <li class="navitem">
        <a href="./" class="nav-link">Home</a>
    </li>
    <li class="navitem active">
        <a href="." class="nav-link">Index</a>
    </li>
</ul>
```
What I expect was that the link of "Parent" should point to "../"(= https://example.com/) and the link of Sibling should point to "../some/dir"(=https://example.com/some/dir/), which are outside of the generated doc.

I remember this way (Sibling case) worked in past, but when I recompiled the document with the latest mkdocs,  it started emitting unexpected URL.


Discussion:
I just encountered the same issue.  Surprised such a killer bug has not received any response

I confirm that https://github.com/mkdocs/mkdocs/pull/2296 (released in 1.2) changed this behavior.

The thing is, this usage was never supported and always showed warnings anyway:
```
WARNING -  A relative path to '../' is included in the 'nav' configuration, which is not found in the documentation files 
```

In MkDocs nav, relative links are always assumed to be links to Markdown documents within the site.

You can use this appoach instead:
```yaml
site_url: https://example.com/known/absolute/path/

nav:
    - Sibling: '/known/absolute/some/dir/'
```

I am not sure if this will be revisited.
The reason why I'm using this relative URL approach is that I'm deploying the same document to different path of the server, like /docs/docA/ and /docs_testing/docA/, in order to check the appearance before deploying it to the public, and want to make links to /docs/docB/ and /docs_testing/docB/ with single '../docB/'.
I made this config because I found such '../' way in the example in the official user guide, and it worked before, even though it emits some warnings.
I would be happy if this restriction can be switched off with some configuration in the mkdocs.yml.
At least, the documentation should be updated if '../' is no longer supported.
Oh wow, you're right! :disappointed: 

https://github.com/mkdocs/mkdocs/blob/f725e225adf9b6cfab45042e44637f137f56d1bd/docs/user-guide/configuration.md?plain=1#L195-L206
Is there any fix or workaround planned for this?
I also would like this behaviour restored. My use case is wanting to deploy effectively two linked sites under the same base URL.

I want to do this so that each sub-site can have it's own navigation tabs, and to make a clear distinction between the two sub-sites by styling them with different colours and titles.

I can achieve the sub-sites easily, by having `site1_mkdocs.yml` and `site2_mkdocs.yml`, each of which build to a subdirectory (`main/site1` and `main/site2`). The problem comes that I want each sub-site to link back to the other with a relative link, `../site1` and `../site2`, and then I get the error and the URL stripped of the relative path.

Also being bit by the same bug. In my case I want to link in my nav to some sphinx docs that are built and available via a relative URL locally. Absolute paths are not an option and neither are URLs.

Resolving patch:
diff --git a/mkdocs/structure/pages.py b/mkdocs/structure/pages.py
--- a/mkdocs/structure/pages.py
+++ b/mkdocs/structure/pages.py
@@ -88,7 +88,10 @@ def _indent_print(self, depth=0):
     @property
     def url(self) -> str:
         """The URL of the page relative to the MkDocs `site_dir`."""
-        return '' if self.file.url in ('.', './') else self.file.url
+        url = self.file.url
+        if url in ('.', './'):
+            return ''
+        return url
 
     file: File
     """The documentation [`File`][mkdocs.structure.files.File] that the page is being rendered from."""
diff --git a/mkdocs/utils/__init__.py b/mkdocs/utils/__init__.py
--- a/mkdocs/utils/__init__.py
+++ b/mkdocs/utils/__init__.py
@@ -297,16 +297,20 @@ def get_relative_url(url: str, other: str) -> str:
 
 def normalize_url(path: str, page: Optional[Page] = None, base: str = '') -> str:
     """Return a URL relative to the given page or using the base."""
-    path, is_abs = _get_norm_url(path)
-    if is_abs:
+    path, relative_level = _get_norm_url(path)
+    if relative_level == -1:
         return path
     if page is not None:
-        return get_relative_url(path, page.url)
+        result = get_relative_url(path, page.url)
+        if relative_level > 0:
+            result = '../' * relative_level + result
+        return result
+
     return posixpath.join(base, path)
 
 
 @functools.lru_cache(maxsize=None)
-def _get_norm_url(path: str) -> Tuple[str, bool]:
+def _get_norm_url(path: str) -> Tuple[str, int]:
     if not path:
         path = '.'
     elif '\\' in path:
@@ -318,8 +322,14 @@ def _get_norm_url(path: str) -> Tuple[str, bool]:
     # Allow links to be fully qualified URLs
     parsed = urlsplit(path)
     if parsed.scheme or parsed.netloc or path.startswith(('/', '#')):
-        return path, True
-    return path, False
+        return path, -1
+
+    # Relative path - preserve information about it
+    norm = posixpath.normpath(path) + '/'
+    relative_level = 0
+    while norm.startswith('../', relative_level * 3):
+        relative_level += 1
+    return path, relative_level
 
 
 def create_media_urls(


Test patch:
diff --git a/mkdocs/tests/structure/file_tests.py b/mkdocs/tests/structure/file_tests.py
--- a/mkdocs/tests/structure/file_tests.py
+++ b/mkdocs/tests/structure/file_tests.py
@@ -444,7 +444,6 @@ def test_get_relative_url_use_directory_urls(self):
             'foo/bar.md',
             'foo/bar/baz.md',
         ]
-
         to_file_urls = [
             './',
             'foo/',
@@ -456,6 +455,8 @@ def test_get_relative_url_use_directory_urls(self):
         ]
 
         from_file = File('img.jpg', '/path/to/docs', '/path/to/site', use_directory_urls=True)
+        self.assertEqual(from_file.url, 'img.jpg')
+
         expected = [
             'img.jpg',  # img.jpg relative to .
             '../img.jpg',  # img.jpg relative to foo/
@@ -465,15 +466,15 @@ def test_get_relative_url_use_directory_urls(self):
             '../../img.jpg',  # img.jpg relative to foo/bar
             '../../../img.jpg',  # img.jpg relative to foo/bar/baz
         ]
-
         for i, filename in enumerate(to_files):
             file = File(filename, '/path/to/docs', '/path/to/site', use_directory_urls=True)
-            self.assertEqual(from_file.url, 'img.jpg')
             self.assertEqual(file.url, to_file_urls[i])
             self.assertEqual(from_file.url_relative_to(file.url), expected[i])
             self.assertEqual(from_file.url_relative_to(file), expected[i])
 
         from_file = File('foo/img.jpg', '/path/to/docs', '/path/to/site', use_directory_urls=True)
+        self.assertEqual(from_file.url, 'foo/img.jpg')
+
         expected = [
             'foo/img.jpg',  # foo/img.jpg relative to .
             'img.jpg',  # foo/img.jpg relative to foo/
@@ -483,15 +484,15 @@ def test_get_relative_url_use_directory_urls(self):
             '../img.jpg',  # foo/img.jpg relative to foo/bar
             '../../img.jpg',  # foo/img.jpg relative to foo/bar/baz
         ]
-
         for i, filename in enumerate(to_files):
             file = File(filename, '/path/to/docs', '/path/to/site', use_directory_urls=True)
-            self.assertEqual(from_file.url, 'foo/img.jpg')
             self.assertEqual(file.url, to_file_urls[i])
             self.assertEqual(from_file.url_relative_to(file.url), expected[i])
             self.assertEqual(from_file.url_relative_to(file), expected[i])
 
         from_file = File('index.html', '/path/to/docs', '/path/to/site', use_directory_urls=True)
+        self.assertEqual(from_file.url, './')
+
         expected = [
             './',  # . relative to .
             '../',  # . relative to foo/
@@ -501,15 +502,15 @@ def test_get_relative_url_use_directory_urls(self):
             '../../',  # . relative to foo/bar
             '../../../',  # . relative to foo/bar/baz
         ]
-
         for i, filename in enumerate(to_files):
             file = File(filename, '/path/to/docs', '/path/to/site', use_directory_urls=True)
-            self.assertEqual(from_file.url, './')
             self.assertEqual(file.url, to_file_urls[i])
             self.assertEqual(from_file.url_relative_to(file.url), expected[i])
             self.assertEqual(from_file.url_relative_to(file), expected[i])
 
         from_file = File('file.md', '/path/to/docs', '/path/to/site', use_directory_urls=True)
+        self.assertEqual(from_file.url, 'file/')
+
         expected = [
             'file/',  # file relative to .
             '../file/',  # file relative to foo/
@@ -519,10 +520,8 @@ def test_get_relative_url_use_directory_urls(self):
             '../../file/',  # file relative to foo/bar
             '../../../file/',  # file relative to foo/bar/baz
         ]
-
         for i, filename in enumerate(to_files):
             file = File(filename, '/path/to/docs', '/path/to/site', use_directory_urls=True)
-            self.assertEqual(from_file.url, 'file/')
             self.assertEqual(file.url, to_file_urls[i])
             self.assertEqual(from_file.url_relative_to(file.url), expected[i])
             self.assertEqual(from_file.url_relative_to(file), expected[i])
@@ -537,7 +536,6 @@ def test_get_relative_url(self):
             'foo/bar.md',
             'foo/bar/baz.md',
         ]
-
         to_file_urls = [
             'index.html',
             'foo/index.html',
@@ -549,6 +547,8 @@ def test_get_relative_url(self):
         ]
 
         from_file = File('img.jpg', '/path/to/docs', '/path/to/site', use_directory_urls=False)
+        self.assertEqual(from_file.url, 'img.jpg')
+
         expected = [
             'img.jpg',  # img.jpg relative to .
             '../img.jpg',  # img.jpg relative to foo/
@@ -558,16 +558,16 @@ def test_get_relative_url(self):
             '../img.jpg',  # img.jpg relative to foo/bar.html
             '../../img.jpg',  # img.jpg relative to foo/bar/baz.html
         ]
-
         for i, filename in enumerate(to_files):
             with self.subTest(from_file=from_file.src_path, to_file=filename):
                 file = File(filename, '/path/to/docs', '/path/to/site', use_directory_urls=False)
-                self.assertEqual(from_file.url, 'img.jpg')
                 self.assertEqual(file.url, to_file_urls[i])
                 self.assertEqual(from_file.url_relative_to(file.url), expected[i])
                 self.assertEqual(from_file.url_relative_to(file), expected[i])
 
         from_file = File('foo/img.jpg', '/path/to/docs', '/path/to/site', use_directory_urls=False)
+        self.assertEqual(from_file.url, 'foo/img.jpg')
+
         expected = [
             'foo/img.jpg',  # foo/img.jpg relative to .
             'img.jpg',  # foo/img.jpg relative to foo/
@@ -577,16 +577,16 @@ def test_get_relative_url(self):
             'img.jpg',  # foo/img.jpg relative to foo/bar.html
             '../img.jpg',  # foo/img.jpg relative to foo/bar/baz.html
         ]
-
         for i, filename in enumerate(to_files):
             with self.subTest(from_file=from_file.src_path, to_file=filename):
                 file = File(filename, '/path/to/docs', '/path/to/site', use_directory_urls=False)
-                self.assertEqual(from_file.url, 'foo/img.jpg')
                 self.assertEqual(file.url, to_file_urls[i])
                 self.assertEqual(from_file.url_relative_to(file.url), expected[i])
                 self.assertEqual(from_file.url_relative_to(file), expected[i])
 
         from_file = File('index.html', '/path/to/docs', '/path/to/site', use_directory_urls=False)
+        self.assertEqual(from_file.url, 'index.html')
+
         expected = [
             'index.html',  # index.html relative to .
             '../index.html',  # index.html relative to foo/
@@ -596,16 +596,16 @@ def test_get_relative_url(self):
             '../index.html',  # index.html relative to foo/bar.html
             '../../index.html',  # index.html relative to foo/bar/baz.html
         ]
-
         for i, filename in enumerate(to_files):
             with self.subTest(from_file=from_file.src_path, to_file=filename):
                 file = File(filename, '/path/to/docs', '/path/to/site', use_directory_urls=False)
-                self.assertEqual(from_file.url, 'index.html')
                 self.assertEqual(file.url, to_file_urls[i])
                 self.assertEqual(from_file.url_relative_to(file.url), expected[i])
                 self.assertEqual(from_file.url_relative_to(file), expected[i])
 
         from_file = File('file.html', '/path/to/docs', '/path/to/site', use_directory_urls=False)
+        self.assertEqual(from_file.url, 'file.html')
+
         expected = [
             'file.html',  # file.html relative to .
             '../file.html',  # file.html relative to foo/
@@ -615,11 +615,9 @@ def test_get_relative_url(self):
             '../file.html',  # file.html relative to foo/bar.html
             '../../file.html',  # file.html relative to foo/bar/baz.html
         ]
-
         for i, filename in enumerate(to_files):
             with self.subTest(from_file=from_file.src_path, to_file=filename):
                 file = File(filename, '/path/to/docs', '/path/to/site', use_directory_urls=False)
-                self.assertEqual(from_file.url, 'file.html')
                 self.assertEqual(file.url, to_file_urls[i])
                 self.assertEqual(from_file.url_relative_to(file.url), expected[i])
                 self.assertEqual(from_file.url_relative_to(file), expected[i])
diff --git a/mkdocs/tests/utils/utils_tests.py b/mkdocs/tests/utils/utils_tests.py
--- a/mkdocs/tests/utils/utils_tests.py
+++ b/mkdocs/tests/utils/utils_tests.py
@@ -1,5 +1,6 @@
 #!/usr/bin/env python
 
+import dataclasses
 import datetime
 import logging
 import os
@@ -9,9 +10,7 @@
 from unittest import mock
 
 from mkdocs import exceptions, utils
-from mkdocs.structure.files import File
-from mkdocs.structure.pages import Page
-from mkdocs.tests.base import dedent, load_config, tempdir
+from mkdocs.tests.base import dedent, tempdir
 from mkdocs.utils import meta
 
 BASEYML = """
@@ -111,174 +110,76 @@ def test_get_relative_url_empty(self):
         self.assertEqual(utils.get_relative_url('/', '.'), './')
         self.assertEqual(utils.get_relative_url('/', '/.'), './')
 
-    def test_create_media_urls(self):
-        expected_results = {
-            'https://media.cdn.org/jq.js': [
-                'https://media.cdn.org/jq.js',
-                'https://media.cdn.org/jq.js',
-                'https://media.cdn.org/jq.js',
-            ],
-            'http://media.cdn.org/jquery.js': [
-                'http://media.cdn.org/jquery.js',
-                'http://media.cdn.org/jquery.js',
-                'http://media.cdn.org/jquery.js',
-            ],
-            '//media.cdn.org/jquery.js': [
-                '//media.cdn.org/jquery.js',
-                '//media.cdn.org/jquery.js',
-                '//media.cdn.org/jquery.js',
-            ],
-            'media.cdn.org/jquery.js': [
-                'media.cdn.org/jquery.js',
-                'media.cdn.org/jquery.js',
-                '../media.cdn.org/jquery.js',
-            ],
-            'local/file/jquery.js': [
-                'local/file/jquery.js',
-                'local/file/jquery.js',
-                '../local/file/jquery.js',
-            ],
-            'image.png': [
-                'image.png',
-                'image.png',
-                '../image.png',
-            ],
-            'style.css?v=20180308c': [
-                'style.css?v=20180308c',
-                'style.css?v=20180308c',
-                '../style.css?v=20180308c',
-            ],
-            '#some_id': [
-                '#some_id',
-                '#some_id',
-                '#some_id',
-            ],
-        }
-
-        cfg = load_config(use_directory_urls=False)
-        pages = [
-            Page(
-                'Home',
-                File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']),
-                cfg,
-            ),
-            Page(
-                'About',
-                File('about.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']),
-                cfg,
-            ),
-            Page(
-                'FooBar',
-                File('foo/bar.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']),
-                cfg,
-            ),
-        ]
-
-        for i, page in enumerate(pages):
-            urls = utils.create_media_urls(expected_results.keys(), page)
-            self.assertEqual([v[i] for v in expected_results.values()], urls)
-
-    def test_create_media_urls_use_directory_urls(self):
-        expected_results = {
-            'https://media.cdn.org/jq.js': [
-                'https://media.cdn.org/jq.js',
-                'https://media.cdn.org/jq.js',
-                'https://media.cdn.org/jq.js',
-            ],
-            'http://media.cdn.org/jquery.js': [
-                'http://media.cdn.org/jquery.js',
-                'http://media.cdn.org/jquery.js',
-                'http://media.cdn.org/jquery.js',
-            ],
-            '//media.cdn.org/jquery.js': [
-                '//media.cdn.org/jquery.js',
-                '//media.cdn.org/jquery.js',
-                '//media.cdn.org/jquery.js',
-            ],
-            'media.cdn.org/jquery.js': [
-                'media.cdn.org/jquery.js',
-                '../media.cdn.org/jquery.js',
-                '../../media.cdn.org/jquery.js',
-            ],
-            'local/file/jquery.js': [
-                'local/file/jquery.js',
-                '../local/file/jquery.js',
-                '../../local/file/jquery.js',
-            ],
-            'image.png': [
-                'image.png',
-                '../image.png',
-                '../../image.png',
-            ],
-            'style.css?v=20180308c': [
-                'style.css?v=20180308c',
-                '../style.css?v=20180308c',
-                '../../style.css?v=20180308c',
-            ],
-            '#some_id': [
-                '#some_id',
-                '#some_id',
-                '#some_id',
-            ],
-        }
-
-        cfg = load_config(use_directory_urls=True)
-        pages = [
-            Page(
-                'Home',
-                File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']),
-                cfg,
-            ),
-            Page(
-                'About',
-                File('about.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']),
-                cfg,
-            ),
-            Page(
-                'FooBar',
-                File('foo/bar.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']),
-                cfg,
-            ),
-        ]
-
-        for i, page in enumerate(pages):
-            urls = utils.create_media_urls(expected_results.keys(), page)
-            self.assertEqual([v[i] for v in expected_results.values()], urls)
+    def test_normalize_url(self):
+        def test(path, base, expected):
+            self.assertEqual(utils.normalize_url(path, _Page(base)), expected)
+
+        # Absolute paths and anchors are unaffected.
+        for base in 'about.html', 'foo/bar.html', 'index.html', '', 'about/', 'foo/bar/':
+            test('https://media.cdn.org/jq.js', base, 'https://media.cdn.org/jq.js')
+            test('http://media.cdn.org/jquery.js', base, 'http://media.cdn.org/jquery.js')
+            test('//media.cdn.org/jquery.js', base, '//media.cdn.org/jquery.js')
+            test('#some_id', base, '#some_id')
+
+        path = 'media.cdn.org/jquery.js'
+        test(path, '', 'media.cdn.org/jquery.js')
+        test(path, 'index.html', 'media.cdn.org/jquery.js')
+        test(path, 'about.html', 'media.cdn.org/jquery.js')
+        test(path, 'about/', '../media.cdn.org/jquery.js')
+        test(path, 'foo/bar.html', '../media.cdn.org/jquery.js')
+        test(path, 'foo/bar/', '../../media.cdn.org/jquery.js')
+
+        path = 'local/file/jquery.js'
+        test(path, '', 'local/file/jquery.js')
+        test(path, 'index.html', 'local/file/jquery.js')
+        test(path, 'about.html', 'local/file/jquery.js')
+        test(path, 'about/', '../local/file/jquery.js')
+        test(path, 'foo/bar.html', '../local/file/jquery.js')
+        test(path, 'foo/bar/', '../../local/file/jquery.js')
+
+        path = '../../../../above/jquery.js'
+        test(path, '', '../../../../above/jquery.js')
+        test(path, 'index.html', '../../../../above/jquery.js')
+        test(path, 'about.html', '../../../../above/jquery.js')
+        test(path, 'about/', '../../../../../above/jquery.js')
+        test(path, 'foo/bar.html', '../../../../../above/jquery.js')
+        test(path, 'foo/bar/', '../../../../../../above/jquery.js')
+
+        path = '../some/dir/'
+        test(path, '', '../some/dir/')
+        test(path, 'index.html', '../some/dir/')
+        test(path, 'about.html', '../some/dir/')
+        test(path, 'about/', '../../some/dir/')
+        test(path, 'foo/bar.html', '../../some/dir/')
+        test(path, 'foo/bar/', '../../../some/dir/')
+
+        path = 'image.png'
+        test(path, '', 'image.png')
+        test(path, 'index.html', 'image.png')
+        test(path, 'about.html', 'image.png')
+        test(path, 'about/', '../image.png')
+        test(path, 'foo/bar.html', '../image.png')
+        test(path, 'foo/bar/', '../../image.png')
+
+        path = 'style.css?v=20180308c'
+        test(path, '', 'style.css?v=20180308c')
+        test(path, 'index.html', 'style.css?v=20180308c')
+        test(path, 'about.html', 'style.css?v=20180308c')
+        test(path, 'about/', '../style.css?v=20180308c')
+        test(path, 'foo/bar.html', '../style.css?v=20180308c')
+        test(path, 'foo/bar/', '../../style.css?v=20180308c')
 
     # TODO: This shouldn't pass on Linux
     # @unittest.skipUnless(sys.platform.startswith("win"), "requires Windows")
-    def test_create_media_urls_windows(self):
-        expected_results = {
-            'local\\windows\\file\\jquery.js': [
-                'local/windows/file/jquery.js',
-                'local/windows/file/jquery.js',
-                '../local/windows/file/jquery.js',
-            ],
-        }
-
-        cfg = load_config(use_directory_urls=False)
-        pages = [
-            Page(
-                'Home',
-                File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']),
-                cfg,
-            ),
-            Page(
-                'About',
-                File('about.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']),
-                cfg,
-            ),
-            Page(
-                'FooBar',
-                File('foo/bar.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']),
-                cfg,
-            ),
-        ]
+    def test_normalize_url_windows(self):
+        def test(path, base, expected):
+            self.assertEqual(utils.normalize_url(path, _Page(base)), expected)
 
         with self.assertLogs('mkdocs', level='WARNING'):
-            for i, page in enumerate(pages):
-                urls = utils.create_media_urls(expected_results.keys(), page)
-                self.assertEqual([v[i] for v in expected_results.values()], urls)
+            path = 'local\\windows\\file\\jquery.js'
+            test(path, '', 'local/windows/file/jquery.js')
+            test(path, 'about/', '../local/windows/file/jquery.js')
+            test(path, 'foo/bar/', '../../local/windows/file/jquery.js')
 
     def test_reduce_list(self):
         self.assertEqual(
@@ -689,3 +590,8 @@ def test_log_level(self):
         self.log.warning('counted')
         self.log.info('not counted')
         self.assertEqual(self.counter.get_counts(), [('ERROR', 2), ('WARNING', 1)])
+
+
+@dataclasses.dataclass
+class _Page:
+    url: str