Repository: mkdocs/mkdocs

Issue:
'?' and '=' get escaped in a relative path in extra_css
# Description

I use a relative path with `?v=xxx` in `extra_css` as a simple cache refresher for CDN/browser.
But the URL gets accidentally escape as `%3Fv%3Dxxx`.


# Steps to reproduce

1. Make sure you are using Windows OS (simply because I cannot reproduce this on Ubuntu)
2. `mkdocs new my-project` and `cd my-project`
3. Add the following config into `mkdocs.yml`

```yaml
extra_css:
  # a "relative" path with "?" and "="
  - 'css/my.css?v=20180308c'
```

4. `mkdocs build`
5. In `site/index.html`, on line 16 it's

```html
<link href="./css/my.css%3Fv%3D20180308c" rel="stylesheet">
```

which should be

```html
<link href="./css/my.css?v=20180308c" rel="stylesheet">
```

# Environments

- Both `Win7 SP1 x64` and `Win10 (1709) x64` are tested. But cannot be reproduced on my Ubuntu VM.
- Python 3.6.4
- MkDocs 0.17.3


# References

- https://github.com/squidfunk/mkdocs-material/issues/729


Discussion:
@jfcherng we need to determine where this escaping is happening. Can you try the following from the Python command prompt:

```python
>>> from mkdocs.tests.base import load_config
>>> config = load_config(extra_css=['assets/css/my.css?v=20180308c'])
>>> config['extra_css']
['assets/css/my.css?v=20180308c']
```

Note that I tried the above from Windows and it appears to be working properly. If you get the same behavior, then the problem is not in the config validation and we can explore other possible areas.
@waylan It shows the same with yours.

![37176506-87c970b6-2357-11e8-84b8-86839faf54e2](https://user-images.githubusercontent.com/6594915/37176775-554f866a-2358-11e8-8a32-9efc43c605cc.png)


FYI, I modify `C:\Program Files\Python36\Lib\site-packages\mkdocs\themes\mkdocs\base.html` line 24-26 from 
```jinja
        {%- for path in extra_css %}
        <link href="{{ path }}" rel="stylesheet">
        {%- endfor %}
```
to
```jinja
        {%- for path in extra_css %}
        <link href="{{ path|safe }} - bug?" rel="stylesheet">
        {%- endfor %}
```

And still get bothered.

![image](https://user-images.githubusercontent.com/6594915/37177287-f1d5d948-2359-11e8-9c74-1a1fcc999ea6.png)



I found the problem. It appears to be in the [mkdocs.utils.create_media_urls][1] function:

```python
>>> from mkdocs.tests.base import load_config
>>> config = load_config(extra_css=['assets/css/my.css?v=20180308c'])
>>> config['extra_css']
['assets/css/my.css?v=20180308c']
>>> from mkdocs.nav import SiteNavigation
>>> nav = SiteNavigation(config)
>>> from mkdocs.utils import create_media_urls
>>> create_media_urls(nav, config['extra_css'])
[u'./assets/css/my.css%3Fv%3D20180308c']
```

Looking at the code, we are relying on [urllib.request.pathname2url][2] to convert the local path to a proper URL.

```python
>>> pathname2url('assets/css/my.css?v=20180308c')
'assets/css/my.css%3Fv%3D20180308c'
```

We are passing in the entire value, not just the path component. After all, the values in the config should be local paths, and local paths only have path components. Therefore, my initial inclination is to say that the current behavior is intentional. But then users can't do stuff like `?v=20180308c` which is a real thing. However, I also notice that `mkdocs.utils.create_media_urls` first checks if a given value is already a URL and if it is, bypasses the conversion. If does that check by using [urllib.parse.urlparse][3] to check for a netlock (domain). if one is present, then it is assumed to be a URL (probably pointing to a CDN) and passed through unaltered.

Therefore, as a workaround, you could alter your config entry to point to the full URL including the domain: `http://example.com/assets/css/my.css?v=20180308c`

```python
>>> create_media_urls(nav, ['http://example.com/assets/css/my.css?v=20180308c'])
['http://example.com/assets/css/my.css?v=20180308c']
```

The question is if we want to expand how we determine if an item is already a URL or not. I'm inclined to leave this for the planned [Pages Refactor][4] as the way URLs are created is directly connected to that refactor.

[1]: https://github.com/mkdocs/mkdocs/blob/a991b7a7e6a783991c0e96ebffde18b45ac23871/mkdocs/utils/__init__.py#L290
[2]: https://docs.python.org/3.5/library/urllib.request.html#urllib.request.pathname2url
[3]: https://docs.python.org/3.5/library/urllib.parse.html#urllib.parse.urlparse
[4]: https://github.com/mkdocs/mkdocs/projects/2
> the values in the config should be local paths, and local paths only have path components.

Btw, this is also way you are getting a warning that the file doesn't exist. It is true that a file named `my.css?v=20180308c` or `my.css%3Fv%3D20180308c` does not exist. It never checks for a file named `my.css` which is what you were expecting.
@waylan Thanks for the workaround. But it is a little troublesome because I work on a Windows machine and the deploy server is a CentOS machine, which wont reproduce this issue. That is, this issue only happens when I develop locally. So if I prepend the remote domain, what I saw is not a real-time local file. But if I prepend the local domain `http://127.0.0.1:8000/`, I have to remove the prepend domain before deploying.


I find it odd that the problem only occurs on Windows. The behavior of `urllib.request.pathname2url` is different on Windows compared to non-windows systems. However, on [non-windows systems][1] all it does is pass the value through `urllib.parse.quote`, which behaves as follows:

```python
>>> from urllib.parse import quote
>>> quote('assets/css/my.css?v=20180308c')
'assets/css/my.css%3Fv%3D20180308c'
```
On windows, [nturl2path.pathname2url][2] is called instead, which also passes the value through `urllib.parse.quote`. However, looking at that function, we really don't need most of the stuff in there. And I don't think we even test for most of that. 

Interestingly, the comments in both implementations state "not recommended for general use." Perhaps we should be doing something simpler. I think all we really need is to convert windows backslashes to forward slashes.

[1]: https://github.com/python/cpython/blob/3.5/Lib/urllib/request.py#L1591
[2]: https://github.com/python/cpython/blob/3.5/Lib/nturl2path.py#L40
Ah, I got it. In [mkdocs.utils.path_2_url][1] we only call `urllib.request.pathname2url` if `os.path.sep != '/'`. So that effectively means that `urllib.request.pathname2url` is only ever called on Windows. As that function does a lot more than we need, we should probably provide our own more limited functionality (and avoid calling `urllib.parse.quote`).

[1]: https://github.com/python/cpython/blob/3.5/Lib/nturl2path.py#L40

Resolving patch:
diff --git a/mkdocs/utils/__init__.py b/mkdocs/utils/__init__.py
--- a/mkdocs/utils/__init__.py
+++ b/mkdocs/utils/__init__.py
@@ -22,11 +22,9 @@
 
 try:                                                        # pragma: no cover
     from urllib.parse import urlparse, urlunparse, urljoin  # noqa
-    from urllib.request import pathname2url                 # noqa
     from collections import UserDict                        # noqa
 except ImportError:                                         # pragma: no cover
     from urlparse import urlparse, urlunparse, urljoin      # noqa
-    from urllib import pathname2url                         # noqa
     from UserDict import UserDict                           # noqa
 
 
@@ -360,12 +358,7 @@ def create_relative_media_url(nav, url):
 def path_to_url(path):
     """Convert a system path to a URL."""
 
-    if os.path.sep == '/':
-        return path
-
-    if sys.version_info < (3, 0):
-        path = path.encode('utf8')
-    return pathname2url(path)
+    return '/'.join(path.split('\\'))
 
 
 def get_theme_dir(name):


Test patch:
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
@@ -75,7 +75,9 @@ def test_create_media_urls(self):
             '//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\\windows\\file\\jquery.js': './local/windows/file/jquery.js',
             'image.png': './image.png',
+            'style.css?v=20180308c': './style.css?v=20180308c'
         }
         site_navigation = nav.SiteNavigation(load_config(pages=pages))
         for path, expected_result in expected_results.items():