Repository: mkdocs/mkdocs

Issue:
Links with hex encoded characters do not work (specifically %20)
For the app Vespucci (see [vespucci.io](https://vespucci.io/)) we use mkdocs to create the website and a pegdown based system to use the same markdown files for on device  documentation.

For various reasons the file names of the individual documents contain spaces and it would be difficult to avoid this. Inter document links work with no issue on device by encoding the spaces as %20, however this does not work with mkdocs because the logic in https://github.com/mkdocs/mkdocs/blob/master/mkdocs/structure/pages.py line 217 does not decode the path before checking if the corresponding file exists. 

Naturally the same issue exists for other hex encoded characters and for using "+" as a space replacement. 

PS: yes, I know it works if one does not encode the spaces, however that is not "valid" markdown and causes issues with other markdown parsers, for example pegdown.


Discussion:
> that is not "valid" markdown

There is some [disagreement][1] about that, even among versions of the reference implementation. However...

> ... and causes issues with other markdown parsers, for example pegdown.

That is true and a real concern. Personally, I never use spaces in my paths and generally consider them a bad idea. But of course, others do use spaces and MkDocs should support that.

And you are correct, the way to address this is to...

> decode the path before checking if the corresponding file exists.

I suppose we should also be encoding the URLs that we generate.

[1]: http://johnmacfarlane.net/babelmark2/?normalize=1&text=%5Bfoo%5D(foo+bar)
@waylan there's a reason I put "valid" in quotes :-). On output the links seem to be correctly encoded as far as I've checked, Wrt a fix: it isn't quite clear to me if there are any performance concerns if there are not, then simply decoding all paths before tacking on the directory and so on, would be the simple fix (and essentially what the money patch I'm using right now does).

Resolving patch:
diff --git a/mkdocs/structure/files.py b/mkdocs/structure/files.py
--- a/mkdocs/structure/files.py
+++ b/mkdocs/structure/files.py
@@ -165,7 +165,7 @@ def _get_url(self, use_directory_urls):
                 url = '.'
             else:
                 url = dirname + '/'
-        return url
+        return utils.urlquote(url)
 
     def url_relative_to(self, other):
         """ Return url for file relative to other file. """
diff --git a/mkdocs/structure/pages.py b/mkdocs/structure/pages.py
--- a/mkdocs/structure/pages.py
+++ b/mkdocs/structure/pages.py
@@ -13,7 +13,7 @@
 from markdown.util import AMP_SUBSTITUTE
 
 from mkdocs.structure.toc import get_toc
-from mkdocs.utils import meta, urlparse, urlunparse, urljoin, get_markdown_title, warning_filter
+from mkdocs.utils import meta, urlparse, urlunparse, urljoin, urlunquote, get_markdown_title, warning_filter
 
 log = logging.getLogger(__name__)
 log.addFilter(warning_filter)
@@ -222,7 +222,7 @@ def path_to_url(self, url):
             return url
 
         # Determine the filepath of the target.
-        target_path = os.path.join(os.path.dirname(self.file.src_path), path)
+        target_path = os.path.join(os.path.dirname(self.file.src_path), urlunquote(path))
         target_path = os.path.normpath(target_path).lstrip(os.sep)
 
         # Validate that the target exists in files collection.
diff --git a/mkdocs/utils/__init__.py b/mkdocs/utils/__init__.py
--- a/mkdocs/utils/__init__.py
+++ b/mkdocs/utils/__init__.py
@@ -23,9 +23,13 @@
 
 try:                                                        # pragma: no cover
     from urllib.parse import urlparse, urlunparse, urljoin  # noqa
+    from urllib.parse import quote as urlquote              # noqa
+    from urllib.parse import unquote as urlunquote          # noqa
     from collections import UserDict                        # noqa
 except ImportError:                                         # pragma: no cover
     from urlparse import urlparse, urlunparse, urljoin      # noqa
+    from urllib import quote                                # noqa
+    from urllib import unquote                              # noqa
     from UserDict import UserDict                           # noqa
 
 
@@ -38,6 +42,12 @@
     string_types = basestring,  # noqa
     text_type = unicode         # noqa
 
+    def urlunquote(path):  # noqa
+        return unquote(path.encode('utf8', errors='backslashreplace')).decode('utf8', errors='replace')
+
+    def urlquote(path):  # noqa
+        return quote(path.encode('utf8', errors='backslashreplace')).decode('utf8', errors='replace')
+
 log = logging.getLogger(__name__)
 
 markdown_extensions = [


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
@@ -277,6 +277,15 @@ def test_css_file_use_directory_urls(self):
         self.assertFalse(f.is_javascript())
         self.assertTrue(f.is_css())
 
+    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.assertPathsEqual(f.abs_src_path, '/path/to/docs/foo bar.md')
+        self.assertPathsEqual(f.dest_path, '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')
+
     def test_files(self):
         fs = [
             File('index.md', '/path/to/docs', '/path/to/site', use_directory_urls=True),
diff --git a/mkdocs/tests/structure/page_tests.py b/mkdocs/tests/structure/page_tests.py
--- a/mkdocs/tests/structure/page_tests.py
+++ b/mkdocs/tests/structure/page_tests.py
@@ -689,6 +689,20 @@ def test_relative_html_link_sub_page(self):
             '<p><a href="sub2/non-index/">link</a></p>'  # No trailing /
         )
 
+    @mock.patch('io.open', mock.mock_open(read_data='[link](file%20name.md)'))
+    def test_relative_html_link_with_encoded_space(self):
+        self.assertEqual(
+            self.get_rendered_result(['index.md', 'file name.md']),
+            '<p><a href="file%20name/">link</a></p>'
+        )
+
+    @mock.patch('io.open', mock.mock_open(read_data='[link](file name.md)'))
+    def test_relative_html_link_with_unencoded_space(self):
+        self.assertEqual(
+            self.get_rendered_result(['index.md', 'file name.md']),
+            '<p><a href="file%20name/">link</a></p>'
+        )
+
     @mock.patch('io.open', mock.mock_open(read_data='[link](../index.md)'))
     def test_relative_html_link_parent_index(self):
         self.assertEqual(