Repository: mkdocs/mkdocs

Issue:
Use use_directory_urls: false breaks on empty site_url 
When setting the `site_url` to `null` (default) and `use_directory_urls` is `false` to make the docs browse-able in a user friendly fashion accessible from the local file-system, in the default mkdocs theme the link to the site on the site-name:

```
<a class="navbar-brand" href=".">{{site_name}}</a>
```

is missing the `/index.html` added to the existing href `.` (dot).


Discussion:
The HMTL fragment in the report is from `base.html`, the href value from `nav.homepage.url`:

https://github.com/mkdocs/mkdocs/blob/ff0b7260564e65b6547fd41753ec971e4237823b/mkdocs/themes/mkdocs/base.html#L66


With best intentions, I think this could be fixed in the [`url` filter](https://github.com/mkdocs/mkdocs/blob/ff0b7260564e65b6547fd41753ec971e4237823b/docs/user-guide/custom-themes.md#url):

* _**Given**_ mkdocs is configured to not use directory urls
* **_When_** the `url` filter is applied and the `url` filter is about to return an url-text having the first and the last character Unicode Character 'FULL STOP' (U+002E)
* **_Then_** url-text is appended with `/index.html` before the `url` filter returns the url-text.


This is indeed a bug of MkDocs which should be fixed. We also had this problem as part of [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/), which I was able to mitigated with https://github.com/squidfunk/mkdocs-material/commit/454339bd359b8708aaf1b2cfb22098b158d475d3 – just in case you're using this template. Other than that, the commit may be helpful for other theme devs.
Given the reliance on `site_url` for some basic functionality, I'm wondering if the config option should be required. I have seen a lot of reports over the years which have all been resolved simply be setting a `site_url`. If we required it, then users wouldn't miss it. Thoughts?
Wouldn't that break local browsing? Some users of Material for MkDocs build their docs and distribute the resulting `*.html` files as part of their software.

It's great to see you're back, @waylan!
> Wouldn't that break local browsing?

That is a potential concern; except that we have never officially supported local file browsing. For the `serve` command, we override the setting using the `dev_addr`. But that doesn't work for local file browsing. Would setting the value to `file://` work? If so, that might be a way for users to specifically indicate that they want local file browsing and we could (possibly) alter some behaviors based on that (like perhaps change the default value of `use_directory_urls`). Yes, that means we could potentially add official support for local file browsing.

I should note that any of the existing solutions for local file browsing are hacks by users. While I don't see a need to break those hacks unnecessarily, any new official way to support such a feature is not likely to work well with the existing hacks.

Finally, the elephant in the room is the builtin search plugin, which does not work with local file browsing and the primary reason why we do not officially support the option. IIRC, the Material theme doesn't have that limitation, but we would need to address that before any official support exists. That said, any changes to the plugin should be discussed in a separate issue. At a minimum, if `site_url` is set to `file://` we might disable the default plugin and require the user to explicitly include a search plugin in their config. In that way, we ensure the user is responsible for providing a working search feature when using local file browsing.

All of the above are just ideas. I havn't looked at the feasibility of any of them. I'm just thinking out load here.
>  we could (possibly) alter some behaviors based on that (like perhaps change the default value of `use_directory_urls`)

That got me thinking... What if we just changed the default of `use_directory_urls` when `site_url` is blank? As previously mentioned,  the `serve` command overrides the setting using the `dev_addr`. But with the `build` command,  `site_url` remains blank if it is not configured. Without a `site_url` we could assume the files will not be hosted behind a server. In that case, the default for `use_directory_urls` should be `False`. In that way, we can only get a conflict between the two options if the user explicitly sets them in conflict. And we can warn against that in documentation.

The one concern here is that there is a distinct difference in output between `serve` and `build` simply by failing to set one optional config option. That's why I keep thinking that the `site_url` option should be required.
> Finally, the elephant in the room is the builtin search plugin, which does not work with local file browsing and the primary reason why we do not officially support the option. IIRC, the Material theme doesn't have that limitation, but we would need to address that before any official support exists. That said, any changes to the plugin should be discussed in a separate issue. At a minimum, if site_url is set to file:// we might disable the default plugin and require the user to explicitly include a search plugin in their config. In that way, we ensure the user is responsible for providing a working search feature when using local file browsing.

Material relies on [mkdocs-localsearch](https://github.com/wilhelmer/mkdocs-localsearch) and [iframe-worker](https://github.com/squidfunk/iframe-worker) to support search on `file://`. A lot of work was put into getting this to work, so it would be quite sad if we'll loose support for local browsing and search. Adding support for local search to the default themes should also be possible with some small additions.

> That got me thinking... What if we just changed the default of use_directory_urls when site_url is blank? As previously mentioned, the serve command overrides the setting using the dev_addr. But with the build command, site_url remains blank if it is not configured. Without a site_url we could assume the files will not be hosted behind a server. In that case, the default for use_directory_urls should be False. In that way, we can only get a conflict between the two options if the user explicitly sets them in conflict. And we can warn against that in documentation.

Hard to say whether that won't break anything. It's only another default, so it can be overridden, but it may result in confusion for users.

I'd like to get @wilhelmer in the loop because he's the own maintaining the localsearch plugin.

Another idea:

``` yaml
site_url: null
```
> Material relies on mkdocs-localsearch and iframe-worker to support search on file://.

I just looked at those for the first time. At a glance, I don't see any reason why that wouldn't work with other themes with a couple minor tweeks. Still, it seems like a lot of work for the user (overriding template blocks) just to enable local file browsing. I wonder if perhaps mkdocs-localsearch could be merged with the search plugin. In fact, before looking at it, I assumed it would be a replacement for the search plugin, not an additional plugin which works beside it. In any event, that is a discussion for a different issue.

So far we have three proposed values for `site_url`: `file://`, `null` and an empty string. The issue with the empty string is that that has been the default for some time. We could change the default behavior in surprising ways for some users who have previously carefully crafted a config in just they way they want. Introducing `null` provides us a way to introduce a new behavior which won't break any existing configs.

I also have some reservations about `file://`. Presumably `site_url` would point at the site root. But for file based browsing, the site root is not the same as the system root. In that case, the value would need to be `file:///path/to/site/root` and that value could change based upon where within the file system a user happens to have copied the files. Using `null` avoids that issue.
In order for search to work locally, the `search_index.json` is served as `search_index.js`, which exposes the `search_index` in `window` scope. The mechanics are entirely different from the normal way this would be done, as XHR is not available. It could be integrated with the search plugin, sure. That would actually be absolutely amazing, but I think it won't work without explicit theme support, as the `search_index.js` would need to be included by the theme before the application logic. As this is now a blocking request, performance would degrade for non-local environments, as `search_index.js` is not loaded asynchronously but is part of the critical rendering path.

Furthermore, if search is implemented as part of a web worker, the `iframe-worker` polyfill is necessary, too, increasing the payload size even more, albeit it's <1kb gzipped.
> I also have some reservations about file://. Presumably site_url would point at the site root. But for file based browsing, the site root is not the same as the system root. In that case, the value would need to be file:///path/to/site/root and that value could change based upon where within the file system a user happens to have copied the files. Using null avoids that issue.

This is the reason why I'm not a fan of `file://` as a value for `site_url` because it's inherently dynamic.
Yeah, let's abandon the `file://` suggestion. However, I would be interested to see what we could do with `null`.
I just checked and the default value for `site_url` is `null` not an empty string as we both assumed above. Here's my proposal:

1. If the user has explicitly set `site_url` to an empty string, we should assume the build will be for local file browsing and validate accordingly.
2. If `site_url` is undefined by the user we should issue a warning, which will be changed to an error in a later release. This provides a graceful path to making the setting required. If a user has a valid use-case for the value to be `null`, they can explicitly set that as the value to avoid the warning/future error.
3. If the user sets `site_url` and `use_directory_urls`, in conflicting ways, then we should issue a validation error. In other words, a validation error would be raised if `use_directory_urls` is `True` and `site_url` is either set to an empty string or `null`. This error should only be raised when at least one of the two settings are explicitly defined by the user. In that way, we avoid this error when the existing default config is used (with neither value being defined). Perhaps in a future version when we change 2 above from a warning to an error, this error could be changed to be raised regardless of whether `null` was explicitly set or not.

Assuming a user has an existing project and has not defined either setting, upon upgrading to the new version of MkDocs, she would get the warning discussed in 2 above. She could simply ignore the warning and the existing behavior would continue. However, she would want to avoid any future updates.

On the other hand, if, in an effort to avoid the warning, she then sets `site_url` to a value which conflicts with  `use_directory_urls`, she would get the error discussed in 3 above. At that point, she could either change the value of `site_url` to something else or properly set `use_directory_urls` to a non-conflicting value.

For a new user who first creates a project with the new version of MkDocs, the generated config file would include an entry for `site_url` which the user would be expected to set explicitly. We would provide a sensible default in the generated default config file for a new project. If the user then edits the config file and sets `site_url` to `null` or an empty string, and either attempts to set `use_directory_urls`  to `True` or leaves it as the default value of `True` by not setting it at all, then they will get the error discussed in 3 above. As with the previous example, this user could either change the value of `site_url` to something else or properly set `use_directory_urls` to a non-conflicting value.

But what is a sensible default to put in the generated config file of a new project? We can't explicitly set the value to `null` or an empty string in the generated file as that would cause the error in 3 above to be raised. 

Therefore, we could insert an example domain, such as `https://example.com` and expect the user to change it. Of course, as `serve` ignores the value, users who want to wait until later to decide on an actual domain can just leave the default value in place until they are ready to deploy and have domain. And users who are deploying to local file browsing would change the value to an empty string (and also set `use_directory_urls` to `False`). A benefit of using  `https://example.com` is that the example value informs users of what is expected to be used as a value of the setting. It should also be a hint to those who want local file browsing that they need to alter some settings to get things to work.

And the documentation for both settings should link to each other and explain the interaction between them. We would include instructions specifically for how to enable local file browsing which explains that both settings need to be set and what values they should be set to.

Finally, note that in this proposal, no defaults have been changed for any settings. I initially started to write this proposal with the idea that the default value for `use_directory_urls` would change if `site_url` was set to an empty string, but in the end determined that that was unnecessary and would only add confusion. Therefore, I abandoned that idea.

I don't feel I'm proficient enough in the internal mechanics of MkDocs to decide whether that covers all cases, but it sounds good to me in general. In essence, we have three cases that should be covered:

- Web hosting with directory URLs: `site_url` set and `use_directory_urls: true` (default)
- Web hosting without directory URLs: `site_url` set and `use_directory_urls: false`
- Local serving: `site_url` not set and `use_directory_urls: true` (must be)

However, there _might_ be cases where people don't set the `site_url` and use `use_directory_urls: true`, e.g. when they want to deploy the same documentation on different domains. As I understood, you'd want to issue a warning then, making it impossible to build with `--strict`. I'd expect some issues rolling in for those cases, as it was the case for #2108.
A minor grip: `site_url` should never be "not set." "Not set" means it is not assigned any value in the config file. By making it required, it must be set to some value, which could be `null` or an empty string.

> However, there might be cases where people don't set the site_url and use use_directory_urls: true, e.g. when they want to deploy the same documentation on different domains.

I'll assume you mean people might want to set `site_url` to `null` or an empty string. Then yes, that would issue a warning. Strictly speaking, that already results in a broken site (see this issue). In fact, technically speaking, we already require them to have a separate config file for each domain they deploy to with  the `site_url` set to that specific domain. 

Note that my solution to this issue (`site_name` is broken when `site_url` is `null` and `use_directory_urls` is `True`) is to not allow that combination of settings to exist.
> when they want to deploy the same documentation on different domains

That is only a concern because we don't offer an official way to define multiple deployments within a config. Therefore, users who want that have come up with various hacks. Note my proposal in #2218 which is intended to provide an official option.  Of course, please keep discussions about #2218 in that issue. I only mention it here to note that if we implemented a solution to #2218, it would remove that as a blocker for this issue. Especially as it seems that that is the only cited reason to not make `site_url` a required option.
@waylan I’m always a fan of generic solutions that solve a lot of problems at once and #2218 looks pretty promising! Unfortunately, I’m currently a little tight on time, but I’ll try to provide input where possible. Feel free to mention me where it makes sense!
I have problems to follow this prolonged discussion but would like to bring things forward in a constructive manner.

Who needs to make what kind of design decision?

Answers to that question would help me to continue with this work.
There is a problem with my proposal to address this issue. I made two incorrect assumptions.

1. I assumed we could differentiate between a `null` value set by the user and a `null` default value. The current implementation does not do that. It is no impossible to do, but would require some changes to the underlying code across all options.
2. I assumed the existing default was a `null` value. Actually it is an empty string. This makes it very difficult to implement the deprecation path I proposed.
In the end, I can still implement my proposal with one change: A null value is not permitted for `site_url`. I think that is fine. We actually didn't ever have a null value before (if the user set it to null it would be converted to an empty string). Therefore, there is no change in behavior in that case.

Resolving patch:
diff --git a/mkdocs/commands/new.py b/mkdocs/commands/new.py
--- a/mkdocs/commands/new.py
+++ b/mkdocs/commands/new.py
@@ -1,7 +1,9 @@
 import logging
 import os
 
-config_text = 'site_name: My Docs\n'
+config_text = """site_name: My Docs
+site_url: https://example.com/
+"""
 index_text = """# Welcome to MkDocs
 
 For full documentation visit [mkdocs.org](https://www.mkdocs.org).
diff --git a/mkdocs/config/config_options.py b/mkdocs/config/config_options.py
--- a/mkdocs/config/config_options.py
+++ b/mkdocs/config/config_options.py
@@ -270,6 +270,15 @@ def __init__(self, default='', required=False, is_dir=False):
         self.is_dir = is_dir
         super().__init__(default, required)
 
+    def pre_validation(self, config, key_name):
+        # TODO: replace this with an error in a future release (1.3?)
+        user_defined_keys = sum([list(x.keys()) for x in config.user_configs], [])
+        if key_name == 'site_url' and key_name not in user_defined_keys:
+            self.warnings.append(
+                'This option is now required. Set to a valid URL or '
+                'an empty string to avoid an error in a future release.'
+            )
+
     def run_validation(self, value):
         if value == '':
             return value
@@ -287,6 +296,16 @@ def run_validation(self, value):
         raise ValidationError(
             "The URL isn't valid, it should include the http:// (scheme)")
 
+    def post_validation(self, config, key_name):
+        if key_name == 'site_url':
+            if config[key_name] in ['', None] and config['use_directory_urls']:
+                config['use_directory_urls'] = False
+                self.warnings.append(
+                    "The 'use_directory_urls' option has been disabled because "
+                    "'site_url' contains an empty value. Either define a valid "
+                    "URL for 'site_url' or set 'use_directory_urls' to False."
+                )
+
 
 class RepoURL(URL):
     """
diff --git a/mkdocs/config/defaults.py b/mkdocs/config/defaults.py
--- a/mkdocs/config/defaults.py
+++ b/mkdocs/config/defaults.py
@@ -24,7 +24,7 @@ def get_schema():
         ('pages', config_options.Nav()),
 
         # The full URL to where the documentation will be hosted
-        ('site_url', config_options.URL(is_dir=True)),
+        ('site_url', config_options.URL(is_dir=True, required=True)),
 
         # A description for the documentation project that will be added to the
         # HTML meta tags.


Test patch:
diff --git a/mkdocs/tests/base.py b/mkdocs/tests/base.py
--- a/mkdocs/tests/base.py
+++ b/mkdocs/tests/base.py
@@ -5,6 +5,7 @@
 from tempfile import TemporaryDirectory
 
 from mkdocs import config
+from mkdocs.config.defaults import get_schema
 from mkdocs import utils
 
 
@@ -27,12 +28,14 @@ def load_config(**cfg):
     cfg = cfg or {}
     if 'site_name' not in cfg:
         cfg['site_name'] = 'Example'
+    if 'site_url' not in cfg:
+        cfg['site_url'] = 'https://example.com'
     if 'config_file_path' not in cfg:
         cfg['config_file_path'] = os.path.join(path_base, 'mkdocs.yml')
     if 'docs_dir' not in cfg:
         # Point to an actual dir to avoid a 'does not exist' error on validation.
         cfg['docs_dir'] = os.path.join(path_base, 'docs')
-    conf = config.Config(schema=config.defaults.get_schema(), config_file_path=cfg['config_file_path'])
+    conf = config.Config(schema=get_schema(), config_file_path=cfg['config_file_path'])
     conf.load_dict(cfg)
 
     errors_warnings = conf.validate()
diff --git a/mkdocs/tests/config/base_tests.py b/mkdocs/tests/config/base_tests.py
--- a/mkdocs/tests/config/base_tests.py
+++ b/mkdocs/tests/config/base_tests.py
@@ -19,10 +19,11 @@ def test_unrecognised_keys(self):
 
         failed, warnings = c.validate()
 
-        self.assertEqual(warnings, [
+        self.assertIn(
             ('not_a_valid_config_option',
-                'Unrecognised configuration name: not_a_valid_config_option')
-        ])
+                'Unrecognised configuration name: not_a_valid_config_option'),
+            warnings
+        )
 
     def test_missing_required(self):
 
@@ -34,7 +35,7 @@ def test_missing_required(self):
         self.assertEqual(errors[0][0], 'site_name')
         self.assertEqual(str(errors[0][1]), 'Required configuration not provided.')
 
-        self.assertEqual(len(warnings), 0)
+        self.assertEqual(len(warnings), 1)
 
     def test_load_from_file(self):
         """
diff --git a/mkdocs/tests/config/config_tests.py b/mkdocs/tests/config/config_tests.py
--- a/mkdocs/tests/config/config_tests.py
+++ b/mkdocs/tests/config/config_tests.py
@@ -23,7 +23,7 @@ def load_missing_config():
 
     def test_missing_site_name(self):
         c = config.Config(schema=defaults.get_schema())
-        c.load_dict({})
+        c.load_dict({'site_url': 'https://example.com'})
         errors, warnings = c.validate()
         self.assertEqual(len(errors), 1)
         self.assertEqual(errors[0][0], 'site_name')
@@ -290,3 +290,123 @@ def testConfigInstancesUnique(self):
         conf.load_dict({'site_name': 'foo'})
         conf.validate()
         self.assertIsNone(conf['mdx_configs'].get('toc'))
+
+    def test_site_url_and_use_directory_urls_undefined(self):
+        conf = config.Config(schema=defaults.get_schema())
+        conf.load_dict({
+            'site_name': 'Example',
+            'config_file_path': os.path.join(os.path.abspath('.'), 'mkdocs.yml')
+        })
+        errors, warnings = conf.validate()
+        self.assertEqual(conf['site_url'], '')
+        self.assertFalse(conf['use_directory_urls'])
+        self.assertEqual(len(warnings), 2)
+        self.assertEqual(len(errors), 0)
+
+    def test_site_url_undefined_and_use_directory_urls_false(self):
+        conf = config.Config(schema=defaults.get_schema())
+        conf.load_dict({
+            'site_name': 'Example',
+            'use_directory_urls': False,
+            'config_file_path': os.path.join(os.path.abspath('.'), 'mkdocs.yml')
+        })
+        errors, warnings = conf.validate()
+        self.assertEqual(conf['site_url'], '')
+        self.assertFalse(conf['use_directory_urls'])
+        self.assertEqual(len(warnings), 1)
+        self.assertEqual(len(errors), 0)
+
+    def test_site_url_undefined_and_use_directory_urls_true(self):
+        conf = config.Config(schema=defaults.get_schema())
+        conf.load_dict({
+            'site_name': 'Example',
+            'use_directory_urls': True,
+            'config_file_path': os.path.join(os.path.abspath('.'), 'mkdocs.yml')
+        })
+        errors, warnings = conf.validate()
+        self.assertEqual(conf['site_url'], '')
+        self.assertFalse(conf['use_directory_urls'])
+        self.assertEqual(len(warnings), 2)
+        self.assertEqual(len(errors), 0)
+
+    def test_site_url_empty_and_use_directory_urls_undefined(self):
+        conf = config.Config(schema=defaults.get_schema())
+        conf.load_dict({
+            'site_name': 'Example',
+            'site_url': '',
+            'config_file_path': os.path.join(os.path.abspath('.'), 'mkdocs.yml')
+        })
+        errors, warnings = conf.validate()
+        self.assertEqual(conf['site_url'], '')
+        self.assertFalse(conf['use_directory_urls'])
+        self.assertEqual(len(warnings), 1)
+        self.assertEqual(len(errors), 0)
+
+    def test_site_url_empty_and_use_directory_urls_false(self):
+        conf = config.Config(schema=defaults.get_schema())
+        conf.load_dict({
+            'site_name': 'Example',
+            'site_url': '',
+            'use_directory_urls': False,
+            'config_file_path': os.path.join(os.path.abspath('.'), 'mkdocs.yml')
+        })
+        errors, warnings = conf.validate()
+        self.assertEqual(conf['site_url'], '')
+        self.assertFalse(conf['use_directory_urls'])
+        self.assertEqual(len(warnings), 0)
+        self.assertEqual(len(errors), 0)
+
+    def test_site_url_empty_and_use_directory_urls_true(self):
+        conf = config.Config(schema=defaults.get_schema())
+        conf.load_dict({
+            'site_name': 'Example',
+            'site_url': '',
+            'use_directory_urls': True,
+            'config_file_path': os.path.join(os.path.abspath('.'), 'mkdocs.yml')
+        })
+        errors, warnings = conf.validate()
+        self.assertEqual(conf['site_url'], '')
+        self.assertFalse(conf['use_directory_urls'])
+        self.assertEqual(len(warnings), 1)
+        self.assertEqual(len(errors), 0)
+
+    def test_site_url_defined_and_use_directory_urls_undefined(self):
+        conf = config.Config(schema=defaults.get_schema())
+        conf.load_dict({
+            'site_name': 'Example',
+            'site_url': 'http://example.com/',
+            'config_file_path': os.path.join(os.path.abspath('.'), 'mkdocs.yml')
+        })
+        errors, warnings = conf.validate()
+        self.assertEqual(conf['site_url'], 'http://example.com/')
+        self.assertTrue(conf['use_directory_urls'])
+        self.assertEqual(len(warnings), 0)
+        self.assertEqual(len(errors), 0)
+
+    def test_site_url_defined_and_use_directory_urls_false(self):
+        conf = config.Config(schema=defaults.get_schema())
+        conf.load_dict({
+            'site_name': 'Example',
+            'site_url': 'http://example.com/',
+            'use_directory_urls': False,
+            'config_file_path': os.path.join(os.path.abspath('.'), 'mkdocs.yml')
+        })
+        errors, warnings = conf.validate()
+        self.assertEqual(conf['site_url'], 'http://example.com/')
+        self.assertFalse(conf['use_directory_urls'])
+        self.assertEqual(len(warnings), 0)
+        self.assertEqual(len(errors), 0)
+
+    def test_site_url_defined_and_use_directory_urls_true(self):
+        conf = config.Config(schema=defaults.get_schema())
+        conf.load_dict({
+            'site_name': 'Example',
+            'site_url': 'http://example.com/',
+            'use_directory_urls': True,
+            'config_file_path': os.path.join(os.path.abspath('.'), 'mkdocs.yml')
+        })
+        errors, warnings = conf.validate()
+        self.assertEqual(conf['site_url'], 'http://example.com/')
+        self.assertTrue(conf['use_directory_urls'])
+        self.assertEqual(len(warnings), 0)
+        self.assertEqual(len(errors), 0)
diff --git a/mkdocs/tests/integration/minimal/mkdocs.yml b/mkdocs/tests/integration/minimal/mkdocs.yml
--- a/mkdocs/tests/integration/minimal/mkdocs.yml
+++ b/mkdocs/tests/integration/minimal/mkdocs.yml
@@ -1,4 +1,5 @@
 site_name: MyTest
+site_url: 'https://example.com'
 
 nav:
     - 'testing.md'
diff --git a/mkdocs/tests/integration/subpages/mkdocs.yml b/mkdocs/tests/integration/subpages/mkdocs.yml
--- a/mkdocs/tests/integration/subpages/mkdocs.yml
+++ b/mkdocs/tests/integration/subpages/mkdocs.yml
@@ -1 +1,2 @@
 site_name: My Docs
+site_url: 'https://example.com'
diff --git a/mkdocs/tests/integration/unicode/mkdocs.yml b/mkdocs/tests/integration/unicode/mkdocs.yml
--- a/mkdocs/tests/integration/unicode/mkdocs.yml
+++ b/mkdocs/tests/integration/unicode/mkdocs.yml
@@ -1 +1,2 @@
 site_name: My Docs
+site_url: 'https://example.com'
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
@@ -20,8 +20,8 @@ def test_homepage(self):
         pg = Page('Foo', fl, cfg)
         self.assertEqual(fl.page, pg)
         self.assertEqual(pg.url, '')
-        self.assertEqual(pg.abs_url, None)
-        self.assertEqual(pg.canonical_url, None)
+        self.assertEqual(pg.abs_url, '/')
+        self.assertEqual(pg.canonical_url, 'https://example.com/')
         self.assertEqual(pg.edit_url, None)
         self.assertEqual(pg.file, fl)
         self.assertEqual(pg.content, None)
@@ -44,8 +44,8 @@ def test_nested_index_page(self):
         pg = Page('Foo', fl, cfg)
         pg.parent = 'foo'
         self.assertEqual(pg.url, 'sub1/')
-        self.assertEqual(pg.abs_url, None)
-        self.assertEqual(pg.canonical_url, None)
+        self.assertEqual(pg.abs_url, '/sub1/')
+        self.assertEqual(pg.canonical_url, 'https://example.com/sub1/')
         self.assertEqual(pg.edit_url, None)
         self.assertEqual(pg.file, fl)
         self.assertEqual(pg.content, None)
@@ -68,8 +68,8 @@ def test_nested_index_page_no_parent(self):
         pg = Page('Foo', fl, cfg)
         pg.parent = None  # non-homepage at nav root level; see #1919.
         self.assertEqual(pg.url, 'sub1/')
-        self.assertEqual(pg.abs_url, None)
-        self.assertEqual(pg.canonical_url, None)
+        self.assertEqual(pg.abs_url, '/sub1/')
+        self.assertEqual(pg.canonical_url, 'https://example.com/sub1/')
         self.assertEqual(pg.edit_url, None)
         self.assertEqual(pg.file, fl)
         self.assertEqual(pg.content, None)
@@ -92,8 +92,8 @@ def test_nested_index_page_no_parent_no_directory_urls(self):
         pg = Page('Foo', fl, cfg)
         pg.parent = None  # non-homepage at nav root level; see #1919.
         self.assertEqual(pg.url, 'sub1/index.html')
-        self.assertEqual(pg.abs_url, None)
-        self.assertEqual(pg.canonical_url, None)
+        self.assertEqual(pg.abs_url, '/sub1/index.html')
+        self.assertEqual(pg.canonical_url, 'https://example.com/sub1/index.html')
         self.assertEqual(pg.edit_url, None)
         self.assertEqual(pg.file, fl)
         self.assertEqual(pg.content, None)
@@ -116,8 +116,8 @@ def test_nested_nonindex_page(self):
         pg = Page('Foo', fl, cfg)
         pg.parent = 'foo'
         self.assertEqual(pg.url, 'sub1/non-index/')
-        self.assertEqual(pg.abs_url, None)
-        self.assertEqual(pg.canonical_url, None)
+        self.assertEqual(pg.abs_url, '/sub1/non-index/')
+        self.assertEqual(pg.canonical_url, 'https://example.com/sub1/non-index/')
         self.assertEqual(pg.edit_url, None)
         self.assertEqual(pg.file, fl)
         self.assertEqual(pg.content, None)
@@ -140,8 +140,8 @@ def test_page_defaults(self):
         pg = Page('Foo', fl, cfg)
         self.assertRegex(pg.update_date, r'\d{4}-\d{2}-\d{2}')
         self.assertEqual(pg.url, 'testing/')
-        self.assertEqual(pg.abs_url, None)
-        self.assertEqual(pg.canonical_url, None)
+        self.assertEqual(pg.abs_url, '/testing/')
+        self.assertEqual(pg.canonical_url, 'https://example.com/testing/')
         self.assertEqual(pg.edit_url, None)
         self.assertEqual(pg.file, fl)
         self.assertEqual(pg.content, None)
@@ -163,8 +163,8 @@ def test_page_no_directory_url(self):
         fl = File('testing.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls'])
         pg = Page('Foo', fl, cfg)
         self.assertEqual(pg.url, 'testing.html')
-        self.assertEqual(pg.abs_url, None)
-        self.assertEqual(pg.canonical_url, None)
+        self.assertEqual(pg.abs_url, '/testing.html')
+        self.assertEqual(pg.canonical_url, 'https://example.com/testing.html')
         self.assertEqual(pg.edit_url, None)
         self.assertEqual(pg.file, fl)
         self.assertEqual(pg.content, None)
@@ -181,13 +181,13 @@ def test_page_no_directory_url(self):
         self.assertEqual(pg.title, 'Foo')
         self.assertEqual(pg.toc, [])
 
-    def test_page_canonical_url(self):
-        cfg = load_config(site_url='http://example.com')
+    def test_page_site_url_empty(self):
+        cfg = load_config(site_url='', use_directory_urls=False)
         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.abs_url, '/testing/')
-        self.assertEqual(pg.canonical_url, 'http://example.com/testing/')
+        self.assertEqual(pg.url, 'testing.html')
+        self.assertEqual(pg.abs_url, None)
+        self.assertEqual(pg.canonical_url, None)
         self.assertEqual(pg.edit_url, None)
         self.assertEqual(pg.file, fl)
         self.assertEqual(pg.content, None)
@@ -204,6 +204,29 @@ def test_page_canonical_url(self):
         self.assertEqual(pg.title, 'Foo')
         self.assertEqual(pg.toc, [])
 
+    def test_homepage_site_url_empty(self):
+        cfg = load_config(site_url='', use_directory_urls=False)
+        fl = File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls'])
+        pg = Page('Foo', fl, cfg)
+        self.assertEqual(pg.url, 'index.html')
+        self.assertEqual(pg.abs_url, None)
+        self.assertEqual(pg.canonical_url, None)
+        self.assertEqual(pg.edit_url, None)
+        self.assertEqual(pg.file, fl)
+        self.assertEqual(pg.content, None)
+        self.assertTrue(pg.is_homepage)
+        self.assertTrue(pg.is_index)
+        self.assertTrue(pg.is_page)
+        self.assertFalse(pg.is_section)
+        self.assertTrue(pg.is_top_level)
+        self.assertEqual(pg.markdown, None)
+        self.assertEqual(pg.meta, {})
+        self.assertEqual(pg.next_page, None)
+        self.assertEqual(pg.parent, None)
+        self.assertEqual(pg.previous_page, None)
+        self.assertEqual(pg.title, 'Foo')
+        self.assertEqual(pg.toc, [])
+
     def test_page_canonical_url_nested(self):
         cfg = load_config(site_url='http://example.com/foo/')
         fl = File('testing.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls'])
@@ -256,8 +279,8 @@ def test_predefined_page_title(self):
         pg = Page('Page Title', fl, cfg)
         pg.read_source(cfg)
         self.assertEqual(pg.url, 'testing/')
-        self.assertEqual(pg.abs_url, None)
-        self.assertEqual(pg.canonical_url, None)
+        self.assertEqual(pg.abs_url, '/testing/')
+        self.assertEqual(pg.canonical_url, 'https://example.com/testing/')
         self.assertEqual(pg.edit_url, None)
         self.assertEqual(pg.file, fl)
         self.assertEqual(pg.content, None)
@@ -280,8 +303,8 @@ def test_page_title_from_markdown(self):
         pg = Page(None, fl, cfg)
         pg.read_source(cfg)
         self.assertEqual(pg.url, 'testing/')
-        self.assertEqual(pg.abs_url, None)
-        self.assertEqual(pg.canonical_url, None)
+        self.assertEqual(pg.abs_url, '/testing/')
+        self.assertEqual(pg.canonical_url, 'https://example.com/testing/')
         self.assertEqual(pg.edit_url, None)
         self.assertEqual(pg.file, fl)
         self.assertEqual(pg.content, None)
@@ -304,8 +327,8 @@ def test_page_title_from_meta(self):
         pg = Page(None, fl, cfg)
         pg.read_source(cfg)
         self.assertEqual(pg.url, 'metadata/')
-        self.assertEqual(pg.abs_url, None)
-        self.assertEqual(pg.canonical_url, None)
+        self.assertEqual(pg.abs_url, '/metadata/')
+        self.assertEqual(pg.canonical_url, 'https://example.com/metadata/')
         self.assertEqual(pg.edit_url, None)
         self.assertEqual(pg.file, fl)
         self.assertEqual(pg.content, None)
@@ -328,8 +351,8 @@ def test_page_title_from_filename(self):
         pg = Page(None, fl, cfg)
         pg.read_source(cfg)
         self.assertEqual(pg.url, 'page-title/')
-        self.assertEqual(pg.abs_url, None)
-        self.assertEqual(pg.canonical_url, None)
+        self.assertEqual(pg.abs_url, '/page-title/')
+        self.assertEqual(pg.canonical_url, 'https://example.com/page-title/')
         self.assertEqual(pg.edit_url, None)
         self.assertEqual(pg.file, fl)
         self.assertEqual(pg.content, None)
@@ -352,8 +375,8 @@ def test_page_title_from_capitalized_filename(self):
         pg = Page(None, fl, cfg)
         pg.read_source(cfg)
         self.assertEqual(pg.url, 'pageTitle/')
-        self.assertEqual(pg.abs_url, None)
-        self.assertEqual(pg.canonical_url, None)
+        self.assertEqual(pg.abs_url, '/pageTitle/')
+        self.assertEqual(pg.canonical_url, 'https://example.com/pageTitle/')
         self.assertEqual(pg.edit_url, None)
         self.assertEqual(pg.file, fl)
         self.assertEqual(pg.content, None)
@@ -376,8 +399,8 @@ def test_page_title_from_homepage_filename(self):
         pg = Page(None, fl, cfg)
         pg.read_source(cfg)
         self.assertEqual(pg.url, '')
-        self.assertEqual(pg.abs_url, None)
-        self.assertEqual(pg.canonical_url, None)
+        self.assertEqual(pg.abs_url, '/')
+        self.assertEqual(pg.canonical_url, 'https://example.com/')
         self.assertEqual(pg.edit_url, None)
         self.assertEqual(pg.file, fl)
         self.assertEqual(pg.content, None)