Repository: mkdocs/mkdocs
Pull request #2930 (2022-08-14 09:01:21)

Refactor URI handling to not have to deal with backslashes
The only breaking change is supposed to be that in some cases `\` will no longer be converted to `/` - that behavior will be exclusive to Windows.


Patch:
diff --git a/mkdocs/commands/build.py b/mkdocs/commands/build.py
index c09cd25f..e133661d 100644
--- a/mkdocs/commands/build.py
+++ b/mkdocs/commands/build.py
@@ -173,7 +173,7 @@ def _populate_page(page, config, files, dirty=False):
             'page_content', page.content, page=page, config=config, files=files
         )
     except Exception as e:
-        message = f"Error reading page '{page.file.src_path}':"
+        message = f"Error reading page '{page.file.src_uri}':"
         # Prevent duplicated the error message because it will be printed immediately afterwards.
         if not isinstance(e, BuildError):
             message += f" {e}"
@@ -190,7 +190,7 @@ def _build_page(page, config, doc_files, nav, env, dirty=False):
         if dirty and not page.file.is_modified():
             return
 
-        log.debug(f"Building page {page.file.src_path}")
+        log.debug(f"Building page {page.file.src_uri}")
 
         # Activate page. Signals to theme that this is the current page.
         page.active = True
@@ -220,12 +220,12 @@ def _build_page(page, config, doc_files, nav, env, dirty=False):
                 output.encode('utf-8', errors='xmlcharrefreplace'), page.file.abs_dest_path
             )
         else:
-            log.info(f"Page skipped: '{page.file.src_path}'. Generated empty output.")
+            log.info(f"Page skipped: '{page.file.src_uri}'. Generated empty output.")
 
         # Deactivate page
         page.active = False
     except Exception as e:
-        message = f"Error building page '{page.file.src_path}':"
+        message = f"Error building page '{page.file.src_uri}':"
         # Prevent duplicated the error message because it will be printed immediately afterwards.
         if not isinstance(e, BuildError):
             message += f" {e}"
@@ -286,7 +286,7 @@ def build(config, live_server=False, dirty=False):
 
         log.debug("Reading markdown pages.")
         for file in files.documentation_pages():
-            log.debug(f"Reading: {file.src_path}")
+            log.debug(f"Reading: {file.src_uri}")
             _populate_page(file.page, config, files, dirty)
 
         # Run `env` plugin events.
diff --git a/mkdocs/structure/files.py b/mkdocs/structure/files.py
index 4713eade..0de55605 100644
--- a/mkdocs/structure/files.py
+++ b/mkdocs/structure/files.py
@@ -1,6 +1,8 @@
 import fnmatch
 import logging
 import os
+import posixpath
+from pathlib import PurePath
 from urllib.parse import quote as urlquote
 
 from mkdocs import utils
@@ -13,6 +15,7 @@ class Files:
 
     def __init__(self, files):
         self._files = files
+        self._src_uris = None
 
     def __iter__(self):
         return iter(self._files)
@@ -21,22 +24,31 @@ class Files:
         return len(self._files)
 
     def __contains__(self, path):
-        return path in self.src_paths
+        return PurePath(path).as_posix() in self.src_uris
 
     @property
     def src_paths(self):
+        """Soft-deprecated, prefer `.src_uris`."""
         return {file.src_path: file for file in self._files}
 
+    @property
+    def src_uris(self):
+        if self._src_uris is None:
+            self._src_uris = {file.src_uri: file for file in self._files}
+        return self._src_uris
+
     def get_file_from_path(self, path):
-        """Return a File instance with File.src_path equal to path."""
-        return self.src_paths.get(os.path.normpath(path))
+        """Return a File instance with File.src_uri equal to path."""
+        return self.src_uris.get(PurePath(path).as_posix())
 
     def append(self, file):
         """Append file to Files collection."""
+        self._src_uris = None
         self._files.append(file)
 
     def remove(self, file):
         """Remove file from Files collection."""
+        self._src_uris = None
         self._files.remove(file)
 
     def copy_static_files(self, dirty=False):
@@ -82,8 +94,8 @@ class Files:
 
         for path in env.list_templates(filter_func=filter):
             # Theme files do not override docs_dir files
-            path = os.path.normpath(path)
-            if path not in self:
+            path = PurePath(path).as_posix()
+            if path not in self.src_uris:
                 for dir in config['theme'].dirs:
                     # Find the first theme dir which contains path
                     if os.path.isfile(os.path.join(dir, path)):
@@ -110,14 +122,14 @@ class File:
 
     File objects have the following properties, which are Unicode strings:
 
-    File.src_path
-        The pure path of the source file relative to the source directory.
+    File.src_uri
+        The pure path (always '/'-separated) of the source file relative to the source directory.
 
     File.abs_src_path
         The absolute concrete path of the source file.
 
-    File.dest_path
-        The pure path of the destination file relative to the destination directory.
+    File.dest_uri
+        The pure path (always '/'-separated) of the destination file relative to the destination directory.
 
     File.abs_dest_path
         The absolute concrete path of the destination file.
@@ -128,50 +140,66 @@ class File:
 
     def __init__(self, path, src_dir, dest_dir, use_directory_urls):
         self.page = None
-        self.src_path = os.path.normpath(path)
+        self.src_path = path
         self.abs_src_path = os.path.normpath(os.path.join(src_dir, self.src_path))
         self.name = self._get_stem()
-        self.dest_path = self._get_dest_path(use_directory_urls)
+        self.dest_uri = self._get_dest_path(use_directory_urls)
         self.abs_dest_path = os.path.normpath(os.path.join(dest_dir, self.dest_path))
         self.url = self._get_url(use_directory_urls)
 
+    @property
+    def src_path(self):
+        return os.path.normpath(self.src_uri)
+
+    @src_path.setter
+    def src_path(self, value):
+        self.src_uri = PurePath(value).as_posix()
+
+    @property
+    def dest_path(self):
+        return os.path.normpath(self.dest_uri)
+
+    @dest_path.setter
+    def dest_path(self, value):
+        self.dest_uri = PurePath(value).as_posix()
+
     def __eq__(self, other):
         return (
             isinstance(other, self.__class__)
-            and self.src_path == other.src_path
+            and self.src_uri == other.src_uri
             and self.abs_src_path == other.abs_src_path
             and self.url == other.url
         )
 
     def __repr__(self):
         return (
-            f"File(src_path='{self.src_path}', dest_path='{self.dest_path}',"
+            f"File(src_uri='{self.src_uri}', dest_uri='{self.dest_uri}',"
             f" name='{self.name}', url='{self.url}')"
         )
 
     def _get_stem(self):
         """Return the name of the file without it's extension."""
-        filename = os.path.basename(self.src_path)
-        stem, ext = os.path.splitext(filename)
+        filename = posixpath.basename(self.src_uri)
+        stem, ext = posixpath.splitext(filename)
         return 'index' if stem in ('index', 'README') else stem
 
     def _get_dest_path(self, use_directory_urls):
         """Return destination path based on source path."""
         if self.is_documentation_page():
-            parent, filename = os.path.split(self.src_path)
+            parent, filename = posixpath.split(self.src_uri)
             if not use_directory_urls or self.name == 'index':
                 # index.md or README.md => index.html
                 # foo.md => foo.html
-                return os.path.join(parent, self.name + '.html')
+                return posixpath.join(parent, self.name + '.html')
             else:
                 # foo.md => foo/index.html
-                return os.path.join(parent, self.name, 'index.html')
-        return self.src_path
+                return posixpath.join(parent, self.name, 'index.html')
+        return self.src_uri
 
     def _get_url(self, use_directory_urls):
         """Return url based in destination path."""
-        url = self.dest_path.replace(os.path.sep, '/')
-        dirname, filename = os.path.split(url)
+        url = self.dest_uri
+        dirname, filename = posixpath.split(url)
         if use_directory_urls and filename == 'index.html':
             if dirname == '':
                 url = '.'
@@ -186,9 +214,9 @@ class File:
     def copy_file(self, dirty=False):
         """Copy source file to destination, ensuring parent directories exist."""
         if dirty and not self.is_modified():
-            log.debug(f"Skip copying unmodified file: '{self.src_path}'")
+            log.debug(f"Skip copying unmodified file: '{self.src_uri}'")
         else:
-            log.debug(f"Copying media file: '{self.src_path}'")
+            log.debug(f"Copying media file: '{self.src_uri}'")
             utils.copy_file(self.abs_src_path, self.abs_dest_path)
 
     def is_modified(self):
@@ -198,11 +226,11 @@ class File:
 
     def is_documentation_page(self):
         """Return True if file is a Markdown page."""
-        return utils.is_markdown_file(self.src_path)
+        return utils.is_markdown_file(self.src_uri)
 
     def is_static_page(self):
         """Return True if file is a static page (html, xml, json)."""
-        return self.src_path.endswith(('.html', '.htm', '.xml', '.json'))
+        return self.src_uri.endswith(('.html', '.htm', '.xml', '.json'))
 
     def is_media_file(self):
         """Return True if file is not a documentation or static page."""
@@ -210,11 +238,11 @@ class File:
 
     def is_javascript(self):
         """Return True if file is a JavaScript file."""
-        return self.src_path.endswith(('.js', '.javascript'))
+        return self.src_uri.endswith(('.js', '.javascript'))
 
     def is_css(self):
         """Return True if file is a CSS file."""
-        return self.src_path.endswith('.css')
+        return self.src_uri.endswith('.css')
 
 
 def get_files(config):
diff --git a/mkdocs/structure/nav.py b/mkdocs/structure/nav.py
index e5433868..551cf5b0 100644
--- a/mkdocs/structure/nav.py
+++ b/mkdocs/structure/nav.py
@@ -97,7 +97,7 @@ class Link:
 
 def get_navigation(files, config):
     """Build site navigation from config and files."""
-    nav_config = config['nav'] or nest_paths(f.src_path for f in files.documentation_pages())
+    nav_config = config['nav'] or nest_paths(f.src_uri for f in files.documentation_pages())
     items = _data_to_navigation(nav_config, files, config)
     if not isinstance(items, list):
         items = [items]
diff --git a/mkdocs/structure/pages.py b/mkdocs/structure/pages.py
index f5adfe87..6b487c41 100644
--- a/mkdocs/structure/pages.py
+++ b/mkdocs/structure/pages.py
@@ -1,5 +1,6 @@
 import logging
 import os
+import posixpath
 from urllib.parse import unquote as urlunquote
 from urllib.parse import urljoin, urlsplit, urlunsplit
 
@@ -103,11 +104,11 @@ class Page:
 
     def _set_edit_url(self, repo_url, edit_uri):
         if repo_url and edit_uri:
-            src_path = self.file.src_path.replace('\\', '/')
+            src_uri = self.file.src_uri
             # Ensure urljoin behavior is correct
             if not edit_uri.startswith(('?', '#')) and not repo_url.endswith('/'):
                 repo_url += '/'
-            self.edit_url = urljoin(repo_url, edit_uri + src_path)
+            self.edit_url = urljoin(repo_url, edit_uri + src_uri)
         else:
             self.edit_url = None
 
@@ -216,17 +217,17 @@ class _RelativePathTreeprocessor(Treeprocessor):
             return url
 
         # Determine the filepath of the target.
-        target_path = os.path.join(os.path.dirname(self.file.src_path), urlunquote(path))
-        target_path = os.path.normpath(target_path).lstrip(os.sep)
+        target_uri = posixpath.join(posixpath.dirname(self.file.src_uri), urlunquote(path))
+        target_uri = posixpath.normpath(target_uri).lstrip('/')
 
         # Validate that the target exists in files collection.
-        if target_path not in self.files:
+        if target_uri not in self.files:
             log.warning(
-                f"Documentation file '{self.file.src_path}' contains a link to "
-                f"'{target_path}' which is not found in the documentation files."
+                f"Documentation file '{self.file.src_uri}' contains a link to "
+                f"'{target_uri}' which is not found in the documentation files."
             )
             return url
-        target_file = self.files.get_file_from_path(target_path)
+        target_file = self.files.get_file_from_path(target_uri)
         path = target_file.url_relative_to(self.file)
         components = (scheme, netloc, path, query, fragment)
         return urlunsplit(components)
diff --git a/mkdocs/utils/__init__.py b/mkdocs/utils/__init__.py
index c29abfac..a325bd31 100644
--- a/mkdocs/utils/__init__.py
+++ b/mkdocs/utils/__init__.py
@@ -15,6 +15,7 @@ import shutil
 import warnings
 from collections import defaultdict
 from datetime import datetime, timezone
+from pathlib import PurePath
 from urllib.parse import urlsplit
 
 import importlib_metadata
@@ -181,7 +182,7 @@ def get_url_path(path, use_directory_urls=True):
         "get_url_path is never used in MkDocs and will be removed soon.", DeprecationWarning
     )
     path = get_html_path(path)
-    url = '/' + path.replace(os.path.sep, '/')
+    url = '/' + path.replace(os.sep, '/')
     if use_directory_urls:
         return url[: -len('index.html')]
     return url
@@ -268,7 +269,14 @@ def normalize_url(path, page=None, base=''):
 
 @functools.lru_cache(maxsize=None)
 def _get_norm_url(path):
-    path = path_to_url(path or '.')
+    if not path:
+        path = '.'
+    elif os.sep != '/' and os.sep in path:
+        log.warning(
+            f"Path '{path}' uses OS-specific separator '{os.sep}', "
+            f"change it to '/' so it is recognized on other systems."
+        )
+        path = path.replace(os.sep, '/')
     # Allow links to be fully qualified URLs
     parsed = urlsplit(path)
     if parsed.scheme or parsed.netloc or path.startswith(('/', '#')):
@@ -284,9 +292,8 @@ def create_media_urls(path_list, page=None, base=''):
 
 
 def path_to_url(path):
-    """Convert a system path to a URL."""
-
-    return '/'.join(path.split('\\'))
+    """Soft-deprecated, do not use."""
+    return path.replace('\\', '/')
 
 
 def get_theme_dir(name):
@@ -385,13 +392,7 @@ def nest_paths(paths):
     nested = []
 
     for path in paths:
-
-        if os.path.sep not in path:
-            nested.append(path)
-            continue
-
-        directory, _ = os.path.split(path)
-        parts = directory.split(os.path.sep)
+        parts = PurePath(path).parent.parts
 
         branch = nested
         for part in parts:


Test patch:
diff --git a/mkdocs/tests/base.py b/mkdocs/tests/base.py
index 763483b7..c94009a6 100644
--- a/mkdocs/tests/base.py
+++ b/mkdocs/tests/base.py
@@ -92,7 +92,7 @@ class PathAssertionMixin:
     """
 
     def assertPathsEqual(self, a, b, msg=None):
-        self.assertEqual(a.replace('\\', '/'), b.replace('\\', '/'))
+        self.assertEqual(a.replace(os.sep, '/'), b.replace(os.sep, '/'))
 
     def assertPathExists(self, *parts):
         path = os.path.join(*parts)
diff --git a/mkdocs/tests/build_tests.py b/mkdocs/tests/build_tests.py
index 6198aadc..c0288340 100644
--- a/mkdocs/tests/build_tests.py
+++ b/mkdocs/tests/build_tests.py
@@ -1,5 +1,6 @@
 #!/usr/bin/env python
 
+import sys
 import unittest
 from unittest import mock
 
@@ -179,6 +180,32 @@ class BuildTests(PathAssertionMixin, unittest.TestCase):
         self.assertEqual(context['extra_css'], ['../../style.css'])
         self.assertEqual(context['extra_javascript'], ['../../script.js'])
 
+    @unittest.skipUnless(sys.platform.startswith("win"), "requires Windows")
+    def test_context_extra_css_path_warning(self):
+        nav_cfg = [
+            {'Home': 'index.md'},
+        ]
+        cfg = load_config(
+            nav=nav_cfg,
+            extra_css=['assets\\style.css'],
+            use_directory_urls=False,
+        )
+        fs = [
+            File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']),
+        ]
+        files = Files(fs)
+        nav = get_navigation(files, cfg)
+        with self.assertLogs('mkdocs', level='WARN') as cm:
+            context = build.get_context(nav, files, cfg, nav.pages[0])
+        self.assertEqual(context['extra_css'], ['assets/style.css'])
+        self.assertEqual(
+            cm.output,
+            [
+                "WARNING:mkdocs.utils:Path 'assets\\style.css' uses OS-specific separator '\\', "
+                "change it to '/' so it is recognized on other systems."
+            ],
+        )
+
     def test_context_extra_css_js_no_page(self):
         cfg = load_config(extra_css=['style.css'], extra_javascript=['script.js'])
         context = build.get_context(mock.Mock(), mock.Mock(), cfg, base_url='..')
diff --git a/mkdocs/tests/structure/file_tests.py b/mkdocs/tests/structure/file_tests.py
index fb6b2a2a..ae412429 100644
--- a/mkdocs/tests/structure/file_tests.py
+++ b/mkdocs/tests/structure/file_tests.py
@@ -1,4 +1,5 @@
 import os
+import sys
 import unittest
 from unittest import mock
 
@@ -28,6 +29,24 @@ class TestFiles(PathAssertionMixin, unittest.TestCase):
             file != File('a.md', '/path/to/docs', '/path/to/site', use_directory_urls=True)
         )
 
+    @unittest.skipUnless(sys.platform.startswith("win"), "requires Windows")
+    def test_src_path_windows(self):
+        f = File('foo\\a.md', '/path/to/docs', '/path/to/site', use_directory_urls=False)
+        self.assertEqual(f.src_uri, 'foo/a.md')
+        self.assertEqual(f.src_path, 'foo\\a.md')
+        f.src_uri = 'foo/b.md'
+        self.assertEqual(f.src_uri, 'foo/b.md')
+        self.assertEqual(f.src_path, 'foo\\b.md')
+        f.src_path = 'foo/c.md'
+        self.assertEqual(f.src_uri, 'foo/c.md')
+        self.assertEqual(f.src_path, 'foo\\c.md')
+        f.src_path = 'foo\\d.md'
+        self.assertEqual(f.src_uri, 'foo/d.md')
+        self.assertEqual(f.src_path, 'foo\\d.md')
+        f.src_uri = 'foo\\e.md'
+        self.assertEqual(f.src_uri, 'foo\\e.md')
+        self.assertEqual(f.src_path, 'foo\\e.md')
+
     def test_sort_files(self):
         self.assertEqual(
             _sort_files(['b.md', 'bb.md', 'a.md', 'index.md', 'aa.md']),
@@ -61,9 +80,9 @@ class TestFiles(PathAssertionMixin, unittest.TestCase):
 
     def test_md_file(self):
         f = File('foo.md', '/path/to/docs', '/path/to/site', use_directory_urls=False)
-        self.assertPathsEqual(f.src_path, 'foo.md')
+        self.assertEqual(f.src_uri, 'foo.md')
         self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo.md')
-        self.assertPathsEqual(f.dest_path, 'foo.html')
+        self.assertEqual(f.dest_uri, 'foo.html')
         self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo.html')
         self.assertEqual(f.url, 'foo.html')
         self.assertEqual(f.name, 'foo')
@@ -75,9 +94,9 @@ class TestFiles(PathAssertionMixin, unittest.TestCase):
 
     def test_md_file_use_directory_urls(self):
         f = File('foo.md', '/path/to/docs', '/path/to/site', use_directory_urls=True)
-        self.assertPathsEqual(f.src_path, 'foo.md')
+        self.assertEqual(f.src_uri, 'foo.md')
         self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo.md')
-        self.assertPathsEqual(f.dest_path, 'foo/index.html')
+        self.assertEqual(f.dest_uri, 'foo/index.html')
         self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/index.html')
         self.assertEqual(f.url, 'foo/')
         self.assertEqual(f.name, 'foo')
@@ -89,9 +108,9 @@ class TestFiles(PathAssertionMixin, unittest.TestCase):
 
     def test_md_file_nested(self):
         f = File('foo/bar.md', '/path/to/docs', '/path/to/site', use_directory_urls=False)
-        self.assertPathsEqual(f.src_path, 'foo/bar.md')
+        self.assertEqual(f.src_uri, 'foo/bar.md')
         self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.md')
-        self.assertPathsEqual(f.dest_path, 'foo/bar.html')
+        self.assertEqual(f.dest_uri, 'foo/bar.html')
         self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.html')
         self.assertEqual(f.url, 'foo/bar.html')
         self.assertEqual(f.name, 'bar')
@@ -103,9 +122,9 @@ class TestFiles(PathAssertionMixin, unittest.TestCase):
 
     def test_md_file_nested_use_directory_urls(self):
         f = File('foo/bar.md', '/path/to/docs', '/path/to/site', use_directory_urls=True)
-        self.assertPathsEqual(f.src_path, 'foo/bar.md')
+        self.assertEqual(f.src_uri, 'foo/bar.md')
         self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.md')
-        self.assertPathsEqual(f.dest_path, 'foo/bar/index.html')
+        self.assertEqual(f.dest_uri, 'foo/bar/index.html')
         self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar/index.html')
         self.assertEqual(f.url, 'foo/bar/')
         self.assertEqual(f.name, 'bar')
@@ -117,9 +136,9 @@ class TestFiles(PathAssertionMixin, unittest.TestCase):
 
     def test_md_index_file(self):
         f = File('index.md', '/path/to/docs', '/path/to/site', use_directory_urls=False)
-        self.assertPathsEqual(f.src_path, 'index.md')
+        self.assertEqual(f.src_uri, 'index.md')
         self.assertPathsEqual(f.abs_src_path, '/path/to/docs/index.md')
-        self.assertPathsEqual(f.dest_path, 'index.html')
+        self.assertEqual(f.dest_uri, 'index.html')
         self.assertPathsEqual(f.abs_dest_path, '/path/to/site/index.html')
         self.assertEqual(f.url, 'index.html')
         self.assertEqual(f.name, 'index')
@@ -131,9 +150,9 @@ class TestFiles(PathAssertionMixin, unittest.TestCase):
 
     def test_md_readme_index_file(self):
         f = File('README.md', '/path/to/docs', '/path/to/site', use_directory_urls=False)
-        self.assertPathsEqual(f.src_path, 'README.md')
+        self.assertEqual(f.src_uri, 'README.md')
         self.assertPathsEqual(f.abs_src_path, '/path/to/docs/README.md')
-        self.assertPathsEqual(f.dest_path, 'index.html')
+        self.assertEqual(f.dest_uri, 'index.html')
         self.assertPathsEqual(f.abs_dest_path, '/path/to/site/index.html')
         self.assertEqual(f.url, 'index.html')
         self.assertEqual(f.name, 'index')
@@ -145,9 +164,9 @@ class TestFiles(PathAssertionMixin, unittest.TestCase):
 
     def test_md_index_file_use_directory_urls(self):
         f = File('index.md', '/path/to/docs', '/path/to/site', use_directory_urls=True)
-        self.assertPathsEqual(f.src_path, 'index.md')
+        self.assertEqual(f.src_uri, 'index.md')
         self.assertPathsEqual(f.abs_src_path, '/path/to/docs/index.md')
-        self.assertPathsEqual(f.dest_path, 'index.html')
+        self.assertEqual(f.dest_uri, 'index.html')
         self.assertPathsEqual(f.abs_dest_path, '/path/to/site/index.html')
         self.assertEqual(f.url, '.')
         self.assertEqual(f.name, 'index')
@@ -159,9 +178,9 @@ class TestFiles(PathAssertionMixin, unittest.TestCase):
 
     def test_md_readme_index_file_use_directory_urls(self):
         f = File('README.md', '/path/to/docs', '/path/to/site', use_directory_urls=True)
-        self.assertPathsEqual(f.src_path, 'README.md')
+        self.assertEqual(f.src_uri, 'README.md')
         self.assertPathsEqual(f.abs_src_path, '/path/to/docs/README.md')
-        self.assertPathsEqual(f.dest_path, 'index.html')
+        self.assertEqual(f.dest_uri, 'index.html')
         self.assertPathsEqual(f.abs_dest_path, '/path/to/site/index.html')
         self.assertEqual(f.url, '.')
         self.assertEqual(f.name, 'index')
@@ -173,9 +192,9 @@ class TestFiles(PathAssertionMixin, unittest.TestCase):
 
     def test_md_index_file_nested(self):
         f = File('foo/index.md', '/path/to/docs', '/path/to/site', use_directory_urls=False)
-        self.assertPathsEqual(f.src_path, 'foo/index.md')
+        self.assertEqual(f.src_uri, 'foo/index.md')
         self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/index.md')
-        self.assertPathsEqual(f.dest_path, 'foo/index.html')
+        self.assertEqual(f.dest_uri, 'foo/index.html')
         self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/index.html')
         self.assertEqual(f.url, 'foo/index.html')
         self.assertEqual(f.name, 'index')
@@ -187,9 +206,9 @@ class TestFiles(PathAssertionMixin, unittest.TestCase):
 
     def test_md_index_file_nested_use_directory_urls(self):
         f = File('foo/index.md', '/path/to/docs', '/path/to/site', use_directory_urls=True)
-        self.assertPathsEqual(f.src_path, 'foo/index.md')
+        self.assertEqual(f.src_uri, 'foo/index.md')
         self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/index.md')
-        self.assertPathsEqual(f.dest_path, 'foo/index.html')
+        self.assertEqual(f.dest_uri, 'foo/index.html')
         self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/index.html')
         self.assertEqual(f.url, 'foo/')
         self.assertEqual(f.name, 'index')
@@ -201,9 +220,9 @@ class TestFiles(PathAssertionMixin, unittest.TestCase):
 
     def test_static_file(self):
         f = File('foo/bar.html', '/path/to/docs', '/path/to/site', use_directory_urls=False)
-        self.assertPathsEqual(f.src_path, 'foo/bar.html')
+        self.assertEqual(f.src_uri, 'foo/bar.html')
         self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.html')
-        self.assertPathsEqual(f.dest_path, 'foo/bar.html')
+        self.assertEqual(f.dest_uri, 'foo/bar.html')
         self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.html')
         self.assertEqual(f.url, 'foo/bar.html')
         self.assertEqual(f.name, 'bar')
@@ -215,9 +234,9 @@ class TestFiles(PathAssertionMixin, unittest.TestCase):
 
     def test_static_file_use_directory_urls(self):
         f = File('foo/bar.html', '/path/to/docs', '/path/to/site', use_directory_urls=True)
-        self.assertPathsEqual(f.src_path, 'foo/bar.html')
+        self.assertEqual(f.src_uri, 'foo/bar.html')
         self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.html')
-        self.assertPathsEqual(f.dest_path, 'foo/bar.html')
+        self.assertEqual(f.dest_uri, 'foo/bar.html')
         self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.html')
         self.assertEqual(f.url, 'foo/bar.html')
         self.assertEqual(f.name, 'bar')
@@ -229,9 +248,9 @@ class TestFiles(PathAssertionMixin, unittest.TestCase):
 
     def test_media_file(self):
         f = File('foo/bar.jpg', '/path/to/docs', '/path/to/site', use_directory_urls=False)
-        self.assertPathsEqual(f.src_path, 'foo/bar.jpg')
+        self.assertEqual(f.src_uri, 'foo/bar.jpg')
         self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.jpg')
-        self.assertPathsEqual(f.dest_path, 'foo/bar.jpg')
+        self.assertEqual(f.dest_uri, 'foo/bar.jpg')
         self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.jpg')
         self.assertEqual(f.url, 'foo/bar.jpg')
         self.assertEqual(f.name, 'bar')
@@ -243,9 +262,9 @@ class TestFiles(PathAssertionMixin, unittest.TestCase):
 
     def test_media_file_use_directory_urls(self):
         f = File('foo/bar.jpg', '/path/to/docs', '/path/to/site', use_directory_urls=True)
-        self.assertPathsEqual(f.src_path, 'foo/bar.jpg')
+        self.assertEqual(f.src_uri, 'foo/bar.jpg')
         self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.jpg')
-        self.assertPathsEqual(f.dest_path, 'foo/bar.jpg')
+        self.assertEqual(f.dest_uri, 'foo/bar.jpg')
         self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.jpg')
         self.assertEqual(f.url, 'foo/bar.jpg')
         self.assertEqual(f.name, 'bar')
@@ -257,9 +276,9 @@ class TestFiles(PathAssertionMixin, unittest.TestCase):
 
     def test_javascript_file(self):
         f = File('foo/bar.js', '/path/to/docs', '/path/to/site', use_directory_urls=False)
-        self.assertPathsEqual(f.src_path, 'foo/bar.js')
+        self.assertEqual(f.src_uri, 'foo/bar.js')
         self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.js')
-        self.assertPathsEqual(f.dest_path, 'foo/bar.js')
+        self.assertEqual(f.dest_uri, 'foo/bar.js')
         self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.js')
         self.assertEqual(f.url, 'foo/bar.js')
         self.assertEqual(f.name, 'bar')
@@ -271,9 +290,9 @@ class TestFiles(PathAssertionMixin, unittest.TestCase):
 
     def test_javascript_file_use_directory_urls(self):
         f = File('foo/bar.js', '/path/to/docs', '/path/to/site', use_directory_urls=True)
-        self.assertPathsEqual(f.src_path, 'foo/bar.js')
+        self.assertEqual(f.src_uri, 'foo/bar.js')
         self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.js')
-        self.assertPathsEqual(f.dest_path, 'foo/bar.js')
+        self.assertEqual(f.dest_uri, 'foo/bar.js')
         self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.js')
         self.assertEqual(f.url, 'foo/bar.js')
         self.assertEqual(f.name, 'bar')
@@ -285,9 +304,9 @@ class TestFiles(PathAssertionMixin, unittest.TestCase):
 
     def test_css_file(self):
         f = File('foo/bar.css', '/path/to/docs', '/path/to/site', use_directory_urls=False)
-        self.assertPathsEqual(f.src_path, 'foo/bar.css')
+        self.assertEqual(f.src_uri, 'foo/bar.css')
         self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.css')
-        self.assertPathsEqual(f.dest_path, 'foo/bar.css')
+        self.assertEqual(f.dest_uri, 'foo/bar.css')
         self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.css')
         self.assertEqual(f.url, 'foo/bar.css')
         self.assertEqual(f.name, 'bar')
@@ -299,9 +318,9 @@ class TestFiles(PathAssertionMixin, unittest.TestCase):
 
     def test_css_file_use_directory_urls(self):
         f = File('foo/bar.css', '/path/to/docs', '/path/to/site', use_directory_urls=True)
-        self.assertPathsEqual(f.src_path, 'foo/bar.css')
+        self.assertEqual(f.src_uri, 'foo/bar.css')
         self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo/bar.css')
-        self.assertPathsEqual(f.dest_path, 'foo/bar.css')
+        self.assertEqual(f.dest_uri, 'foo/bar.css')
         self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo/bar.css')
         self.assertEqual(f.url, 'foo/bar.css')
         self.assertEqual(f.name, 'bar')
@@ -313,9 +332,9 @@ class TestFiles(PathAssertionMixin, unittest.TestCase):
 
     def test_file_name_with_space(self):
         f = File('foo bar.md', '/path/to/docs', '/path/to/site', use_directory_urls=False)
-        self.assertPathsEqual(f.src_path, 'foo bar.md')
+        self.assertEqual(f.src_uri, 'foo bar.md')
         self.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo bar.md')
-        self.assertPathsEqual(f.dest_path, 'foo bar.html')
+        self.assertEqual(f.dest_uri, 'foo bar.html')
         self.assertPathsEqual(f.abs_dest_path, '/path/to/site/foo bar.html')
         self.assertEqual(f.url, 'foo%20bar.html')
         self.assertEqual(f.name, 'foo bar')
@@ -340,13 +359,13 @@ class TestFiles(PathAssertionMixin, unittest.TestCase):
         self.assertEqual(files.get_file_from_path('foo/bar.jpg'), fs[3])
         self.assertEqual(files.get_file_from_path('foo/bar.jpg'), fs[3])
         self.assertEqual(files.get_file_from_path('missing.jpg'), None)
-        self.assertTrue(fs[2].src_path in files)
-        self.assertTrue(fs[2].src_path in files)
+        self.assertTrue(fs[2].src_uri in files.src_uris)
+        self.assertTrue(fs[2].src_uri in files.src_uris)
         extra_file = File('extra.md', '/path/to/docs', '/path/to/site', use_directory_urls=True)
-        self.assertFalse(extra_file.src_path in files)
+        self.assertFalse(extra_file.src_uri in files.src_uris)
         files.append(extra_file)
         self.assertEqual(len(files), 7)
-        self.assertTrue(extra_file.src_path in files)
+        self.assertTrue(extra_file.src_uri in files.src_uris)
         self.assertEqual(files.documentation_pages(), [fs[0], fs[1], extra_file])
 
     @tempdir(
@@ -708,14 +727,14 @@ class TestFiles(PathAssertionMixin, unittest.TestCase):
         ]
         files = Files(fs)
         self.assertEqual(len(files), 6)
-        self.assertEqual(len(files.src_paths), 6)
+        self.assertEqual(len(files.src_uris), 6)
         extra_file = File('extra.md', '/path/to/docs', '/path/to/site', use_directory_urls=True)
-        self.assertFalse(extra_file.src_path in files)
+        self.assertFalse(extra_file.src_uri in files.src_uris)
         files.append(extra_file)
         self.assertEqual(len(files), 7)
-        self.assertEqual(len(files.src_paths), 7)
-        self.assertTrue(extra_file.src_path in files)
+        self.assertEqual(len(files.src_uris), 7)
+        self.assertTrue(extra_file.src_uri in files.src_uris)
         files.remove(extra_file)
         self.assertEqual(len(files), 6)
-        self.assertEqual(len(files.src_paths), 6)
-        self.assertFalse(extra_file.src_path in files)
+        self.assertEqual(len(files.src_uris), 6)
+        self.assertFalse(extra_file.src_uri in files.src_uris)
diff --git a/mkdocs/tests/structure/page_tests.py b/mkdocs/tests/structure/page_tests.py
index 29e59b52..093d7867 100644
--- a/mkdocs/tests/structure/page_tests.py
+++ b/mkdocs/tests/structure/page_tests.py
@@ -628,12 +628,7 @@ class RelativePathExtensionTests(unittest.TestCase):
 
     def get_rendered_result(self, files):
         cfg = load_config(docs_dir=self.DOCS_DIR)
-        fs = [
-            File(
-                f.replace('/', os.sep), cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']
-            )
-            for f in files
-        ]
+        fs = [File(f, cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) for f in files]
         pg = Page('Foo', fs[0], cfg)
         pg.read_source(cfg)
         pg.render(cfg, Files(fs))
diff --git a/mkdocs/tests/utils/utils_tests.py b/mkdocs/tests/utils/utils_tests.py
index 80d71a0e..a59ce5de 100644
--- a/mkdocs/tests/utils/utils_tests.py
+++ b/mkdocs/tests/utils/utils_tests.py
@@ -1,11 +1,12 @@
 #!/usr/bin/env python
 
-
 import datetime
 import logging
 import os
+import posixpath
 import shutil
 import stat
+import sys
 import tempfile
 import unittest
 from unittest import mock
@@ -140,11 +141,6 @@ class UtilsTests(unittest.TestCase):
                 'local/file/jquery.js',
                 '../local/file/jquery.js',
             ],
-            'local\\windows\\file\\jquery.js': [
-                'local/windows/file/jquery.js',
-                'local/windows/file/jquery.js',
-                '../local/windows/file/jquery.js',
-            ],
             'image.png': [
                 'image.png',
                 'image.png',
@@ -213,11 +209,6 @@ class UtilsTests(unittest.TestCase):
                 '../local/file/jquery.js',
                 '../../local/file/jquery.js',
             ],
-            'local\\windows\\file\\jquery.js': [
-                'local/windows/file/jquery.js',
-                '../local/windows/file/jquery.js',
-                '../../local/windows/file/jquery.js',
-            ],
             'image.png': [
                 'image.png',
                 '../image.png',
@@ -258,6 +249,40 @@ class UtilsTests(unittest.TestCase):
             urls = utils.create_media_urls(expected_results.keys(), page)
             self.assertEqual([v[i] for v in expected_results.values()], urls)
 
+    @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,
+            ),
+        ]
+
+        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_reduce_list(self):
         self.assertEqual(
             utils.reduce_list([1, 2, 3, 4, 5, 5, 2, 4, 6, 7, 8]),
@@ -335,10 +360,7 @@ class UtilsTests(unittest.TestCase):
         with self.assertRaises(exceptions.ConfigurationError):
             utils.get_theme_names()
 
-    def test_nest_paths(self):
-
-        j = os.path.join
-
+    def test_nest_paths(self, j=posixpath.join):
         result = utils.nest_paths(
             [
                 'index.md',
@@ -372,6 +394,9 @@ class UtilsTests(unittest.TestCase):
             ],
         )
 
+    def test_nest_paths_native(self):
+        self.test_nest_paths(os.path.join)
+
     def test_unicode_yaml(self):
 
         yaml_src = dedent(