Repository: mkdocs/mkdocs

Issue:
Allow nav links with depth > 2
The default theme doesn't necessarily need to deal with styling them, but they should at least be allowed.



Discussion:
Just thought I'd throw my support behind this happening—once this is possible, as well as automatic navigation generation, this would be the top candidate for a documentation project I'm working on.

This would be cool. Any thoughts on how this should be implemented?

So is this being done? When will it be released, I need it for a project I'm documenting

Nobody is currently working on this to my knowledge, but yes, it is still wanted.

I modified pages config to support this. Actually it's a backwards incompatible change. 

The pages config looks like:

``` yaml
- Home: index.md
- Technology: 
  - Python:
    - Installation: tech/python/install.md
    - Tutorial: tech/python/tutorial.md
  - Ruby:
    - Installation: tech/ruby/install.md
    - Tutorial: tech/ruby/tutorial.md
```

I'm wondering whether this config is acceptable.

@jeoygin That structure is very like what I had imaged. It represents the pages as a tree which makes far more sense to me. So, yes, I think making a change like that would be a good idea. However, I'm not sure we want to make it before a 1.0 release given that it is backwards incompatible. I don't want to support two formats either ideally.

+1 on this :) 

The RTD theme has never really supported it, but it would be easy enough in the bootstrap ones with the third level being a nav menu popout to the right on the dropdown. 

This seems like a great way to format the yml, and it's definitely a lot more readable.

> However, I'm not sure we want to make it before a 1.0 release given that it is backwards incompatible.

"Before 1.0" really seems like the best time to do it. If you go by "semantic versioning", anything can change at any time before 1.0, but once you release 1.0, the API needs to remain backwards-compatible until 2.0. I think most people should be ok with things being in flux before 1.0. I generally just stick to a given git revision if I'm not in the mood to deal with breaking changes at the moment.

Sure, bad wording perhaps. I mean that we would make that change with 1.0, thus not breaking it until then.

Ok, that sounds reasonable, as long as this isn't blocking something else. :)

+1 to all of this. We're using MkDocs with the RTD theme over on http://docs.withknown.com/ and multi-level documentation would be very much appreciated. Rather than very long pages with lots of subheadings, we'd find it useful to point to nested sub-pages.

I think more control over the navigation in general would be welcome. For example, our initial testing suggests that having subheadings displayed in the left-hand navigation on short pages is confusing; it'd be awesome to be able to control the level of detail.

@benwerd I'm surprised that you find the subheadings confusing. That level of control would probably need to be done in a custom theme.

@d0ugal I didn't find it confusing, but people I tested with did.

Noted, thanks. I'm surprised they did then :) 

Wow, looking forward to a pull-request so that everybody can use it :+1: 

+1 on seeing that PR

https://github.com/mkdocs/mkdocs/issues/6#issuecomment-68661197 makes a lot of sense to me. It's essentially what I tried initially, and was surprised that it wasn't supported.

It'd be good to get this in sooner rather than later. Perhaps we could add a configuration option that defaults to the current behaviour, but allows more depth. This'd avoid backward-compatibility breakage, but give those who need it access to this feature. Switching which is default at some later point, and deprecating the current behaviour becomes much less disruptive.

The problem with the solution in https://github.com/mkdocs/mkdocs/issues/6#issuecomment-68661197 is that it requires the user to enter the title for every page. That would stop us doing #316. Something like this could possibly work, but it isn't as nice.

```
- Home: index.md
- Technology: 
  - tech/something.md
  - Python:
    - tech/python/install.md
    - tech/python/tutorial.md:
        title: Python Tutorial
  - Ruby:
    - tech/ruby/install.md
        title: Ruby Tutorial
    - tech/ruby/tutorial.md
```

The only things stopping this, is that I've yet to see a fully thought through structure for a new pages config.

For my part, I'd certainly prioritize allowing more depth to menus over a small convenience shortcut. But I don't really see why it'd be either/or. I think it'd be important to support https://github.com/mkdocs/mkdocs/issues/73 too. It seems like this could work:

```
- Home: index.md
- Technology: ~
  - Python: tech/python.md
    - tech/python/install.md
      - tech/python/install/debian.md
    - Tutorial: tech/python/tutorial.md
  - Ruby: ~
    - Installation: tech/ruby/install.md
    - tech/ruby/tutorial.md
```

This would probably work (as it's valid yaml, unlike my previous suggestion):

```
- Home: index.md
- Technology: ~
  submenu:
    - Python: tech/python.md
      submenu:    
        - tech/python/install.md
        - Apt Install: tech/python/install/debian.md
    - Tutorial: tech/python/tutorial.md
  - Ruby: ~
    - Installation: tech/ruby/install.md
    - tech/ruby/tutorial.md
```

I've started tinkering with this is in [my fork](https://github.com/ergonlogic/mkdocs/tree/dev/6)

So far, it continues to work as normal, but also supports a new config option and menu format:

```
use_new_menu: True
pages:
- Home: index.md
- Technology: tech.md
- Python: tech/python.md
- tech/python/install.md
 [...]
```

Obviously, no hierarchy to the navigation yet.

@ergonlogic Neat - that looks interesting. I'll take a look.

+1 for a PR on more levels to navigation


Resolving patch:
diff --git a/mkdocs/config.py b/mkdocs/config.py
--- a/mkdocs/config.py
+++ b/mkdocs/config.py
@@ -7,7 +7,7 @@
 
 from six.moves.urllib.parse import urlparse
 
-from mkdocs import utils
+from mkdocs import utils, legacy
 from mkdocs.exceptions import ConfigurationError
 
 log = logging.getLogger(__name__)
@@ -68,7 +68,7 @@
 
     # enabling strict mode causes MkDocs to stop the build when a problem is
     # encountered rather than display an error.
-    'strict': False
+    'strict': False,
 }
 
 
@@ -153,11 +153,27 @@ def validate_config(user_config):
         check for Windows style paths. If they are found, output a warning
         and continue.
         """
+
+        # TODO: Remove in 1.0
+        config_types = set(type(l) for l in config['pages'])
+        if list in config_types and dict not in config_types:
+            config['pages'] = legacy.pages_compat_shim(config['pages'])
+        elif list in config_types:
+            raise ConfigurationError("")
+
         for page_config in config['pages']:
+
             if isinstance(page_config, str):
                 path = page_config
+            elif isinstance(page_config, dict):
+                if len(page_config) > 1:
+                    raise ConfigurationError(
+                        "Invalid page config. Should have one key value only")
+                _, path = next(iter(page_config.items()))
             elif len(page_config) in (1, 2, 3):
                 path = page_config[0]
+            else:
+                raise ConfigurationError("Unrecognised page config")
 
             if ntpath.sep in path:
                 log.warning("The config path contains Windows style paths (\\ "
diff --git a/mkdocs/legacy.py b/mkdocs/legacy.py
new file mode 100644
--- /dev/null
+++ b/mkdocs/legacy.py
@@ -0,0 +1,120 @@
+import logging
+
+from mkdocs.exceptions import ConfigurationError
+
+log = logging.getLogger(__name__)
+
+
+def pages_compat_shim(original_pages):
+    """
+    Support legacy pages configuration
+
+    Re-write the pages config fron MkDocs <=0.12 to match the
+    new nested structure added in 0.13.
+
+    Given a pages configuration in the old style of:
+
+        pages:
+        - ['index.md', 'Home']
+        - ['user-guide/writing-your-docs.md', 'User Guide']
+        - ['user-guide/styling-your-docs.md', 'User Guide']
+        - ['about/license.md', 'About', 'License']
+        - ['about/release-notes.md', 'About']
+        - ['help/contributing.md', 'Help', 'Contributing']
+        - ['support.md']
+        - ['cli.md', 'CLI Guide']
+
+    Rewrite it to look like:
+
+        pages:
+        - Home: index.md
+        - User Guide:
+            - user-guide/writing-your-docs.md
+            - user-guide/styling-your-docs.md
+        - About:
+            - License: about/license.md
+            - about/release-notes.md
+        - Help:
+            - Contributing: about/contributing.md
+        - support.md
+        - CLI Guide: cli.md
+
+    TODO: Remove in 1.0
+    """
+
+    log.warning("The pages config in the mkdocs.yml uses the deprecated "
+                "structure. This will be removed in the next release of "
+                "MkDocs. See for details on updating: "
+                "http://www.mkdocs.org/about/release-notes/")
+
+    new_pages = []
+
+    for config_line in original_pages:
+
+        if len(config_line) not in (1, 2, 3):
+            msg = (
+                "Line in 'page' config contained {0} items. In Line {1}. "
+                "Expected 1, 2 or 3 strings.".format(
+                    config_line, len(config_line))
+            )
+            raise ConfigurationError(msg)
+
+        # First we need to pad out the config line as it could contain
+        # 1-3 items.
+        path, category, title = (list(config_line) + [None, None])[:3]
+
+        if len(new_pages) > 0:
+            # Get the previous top-level page so we can see if the category
+            # matches up with the one we have now.
+            prev_cat, subpages = next(iter(new_pages[-1].items()))
+        else:
+            # We are on the first page
+            prev_cat, subpages = None, []
+
+        # If the category is different, add a new top level category. If the
+        # previous category is None, the it's another top level one too.
+        if prev_cat is None or prev_cat != category:
+            subpages = []
+            new_pages.append({category: subpages})
+
+        # Add the current page to the determined category.
+        subpages.append({title: path})
+
+    # We need to do a bit of cleaning up to match the new structure. In the
+    # above example, pages can either be `- file.md` or `- Title: file.md`.
+    # For pages without a title we currently have `- None: file.md` - so we
+    # need to remove those Nones by changing from a dict to just a string with
+    # the path.
+    for i, category in enumerate(new_pages):
+
+        # Categories are a dictionary with one key as the name and the value
+        # is a list of pages. So, grab that from the dict.
+        category, pages = next(iter(category.items()))
+
+        # If we only have one page, then we can assume it is a top level
+        # category and no further nesting is required unless that single page
+        # has a title itself,
+        if len(pages) == 1:
+            title, path = pages.pop().popitem()
+            # If we have a title, it should be a sub page
+            if title is not None:
+                pages.append({title: path})
+            # if we have a category, but no title it should be a top-level page
+            elif category is not None:
+                new_pages[i] = {category: path}
+            # if we have no category or title, it must be a top level page with
+            # an atomatic title.
+            else:
+                new_pages[i] = path
+        else:
+            # We have more than one page, so the category is valid. We just
+            # need to iterate through and convert any {None: path} dicts to
+            # be just the path string.
+            for j, page in enumerate(pages):
+                title, path = page.popitem()
+                if title:
+                    pages[j] = {title: path}
+                else:
+                    pages[j] = path
+
+    return new_pages
diff --git a/mkdocs/nav.py b/mkdocs/nav.py
--- a/mkdocs/nav.py
+++ b/mkdocs/nav.py
@@ -28,7 +28,8 @@ def file_to_title(filename):
     try:
         with open(filename, 'r') as f:
             lines = f.read()
-            _, table_of_contents, meta = utils.convert_markdown(lines, ['meta', 'toc'])
+            _, table_of_contents, meta = utils.convert_markdown(
+                lines, ['meta', 'toc'])
             if "title" in meta:
                 return meta["title"][0]
             if len(table_of_contents.items) > 0:
@@ -53,8 +54,8 @@ class SiteNavigation(object):
     def __init__(self, pages_config, use_directory_urls=True):
         self.url_context = URLContext()
         self.file_context = FileContext()
-        self.nav_items, self.pages = \
-            _generate_site_navigation(pages_config, self.url_context, use_directory_urls)
+        self.nav_items, self.pages = _generate_site_navigation(
+            pages_config, self.url_context, use_directory_urls)
         self.homepage = self.pages[0] if self.pages else None
         self.use_directory_urls = use_directory_urls
 
@@ -122,7 +123,8 @@ def make_relative(self, url):
                 return '.'
             return url.lstrip('/')
         # Under Python 2.6, relative_path adds an extra '/' at the end.
-        relative_path = os.path.relpath(url, start=self.base_path).rstrip('/') + suffix
+        relative_path = os.path.relpath(url, start=self.base_path)
+        relative_path = relative_path.rstrip('/') + suffix
 
         return utils.path_to_url(relative_path)
 
@@ -154,6 +156,7 @@ def make_absolute(self, path):
 
 class Page(object):
     def __init__(self, title, url, path, url_context):
+
         self.title = title
         self.abs_url = url
         self.active = False
@@ -177,6 +180,10 @@ def url(self):
     def is_homepage(self):
         return utils.is_homepage(self.input_path)
 
+    @property
+    def is_top_level(self):
+        return len(self.ancestors) == 0
+
     def __str__(self):
         return self._indent_print()
 
@@ -196,10 +203,15 @@ class Header(object):
     def __init__(self, title, children):
         self.title, self.children = title, children
         self.active = False
+        self.ancestors = []
 
     def __str__(self):
         return self._indent_print()
 
+    @property
+    def is_top_level(self):
+        return len(self.ancestors) == 0
+
     def _indent_print(self, depth=0):
         indent = '    ' * depth
         active_marker = ' [*]' if self.active else ''
@@ -209,74 +221,102 @@ def _indent_print(self, depth=0):
         return ret
 
 
-def _generate_site_navigation(pages_config, url_context, use_directory_urls=True):
+def _path_to_page(path, title, url_context, use_directory_urls):
+    if title is None:
+        title = file_to_title(path.split(os.path.sep)[-1])
+    url = utils.get_url_path(path, use_directory_urls)
+    return Page(title=title, url=url, path=path,
+                url_context=url_context)
+
+
+def _follow(config_line, url_context, use_dir_urls, header=None, title=None):
+
+    if isinstance(config_line, str):
+        path = os.path.normpath(config_line)
+        page = _path_to_page(path, title, url_context, use_dir_urls)
+
+        if header:
+            page.ancestors = [header]
+            header.children.append(page)
+
+        yield page
+        raise StopIteration
+
+    elif not isinstance(config_line, dict):
+        msg = ("Line in 'page' config is of type {0}, dict or string "
+               "expected. Config: {1}").format(type(config_line), config_line)
+        raise exceptions.ConfigurationError(msg)
+
+    if len(config_line) > 1:
+        raise exceptions.ConfigurationError(
+            "Page configs should be in the format 'name: markdown.md'. The "
+            "config contains an invalid entry: {0}".format(config_line))
+    elif len(config_line) == 0:
+        log.warning("Ignoring empty line in the pages config.")
+        raise StopIteration
+
+    next_cat_or_title, subpages_or_path = next(iter(config_line.items()))
+
+    if isinstance(subpages_or_path, str):
+        path = subpages_or_path
+        for sub in _follow(path, url_context, use_dir_urls, header=header, title=next_cat_or_title):
+            yield sub
+        raise StopIteration
+
+    elif not isinstance(subpages_or_path, list):
+        msg = ("Line in 'page' config is of type {0}, list or string "
+               "expected for sub pages. Config: {1}"
+               ).format(type(config_line), config_line)
+        raise exceptions.ConfigurationError(msg)
+
+    next_header = Header(title=next_cat_or_title, children=[])
+    if header:
+        next_header.ancestors = [header]
+        header.children.append(next_header)
+    yield next_header
+
+    subpages = subpages_or_path
+
+    for subpage in subpages:
+        for sub in _follow(subpage, url_context, use_dir_urls, next_header):
+            yield sub
+
+
+def _generate_site_navigation(pages_config, url_context, use_dir_urls=True):
     """
     Returns a list of Page and Header instances that represent the
     top level site navigation.
     """
     nav_items = []
     pages = []
+
     previous = None
 
     for config_line in pages_config:
-        if isinstance(config_line, str):
-            path = os.path.normpath(config_line)
-            title, child_title = None, None
-        elif len(config_line) in (1, 2, 3):
-            # Pad any items that don't exist with 'None'
-            padded_config = (list(config_line) + [None, None])[:3]
-            path, title, child_title = padded_config
-            path = os.path.normpath(path)
-        else:
-            msg = (
-                "Line in 'page' config contained %d items.  "
-                "Expected 1, 2 or 3 strings." % len(config_line)
-            )
-            raise exceptions.ConfigurationError(msg)
-
-        # If both the title and child_title are None, then we
-        # have just been given a path. If that path contains a /
-        # then lets automatically nest it.
-        if title is None and child_title is None and os.path.sep in path:
-            filename = path.split(os.path.sep)[-1]
-            child_title = file_to_title(filename)
-
-        if title is None:
-            filename = path.split(os.path.sep)[0]
-            title = file_to_title(filename)
-
-        # If we don't have a child title but the other title is the same, we
-        # should be within a section and the child title needs to be inferred
-        # from the filename.
-        if len(nav_items) and title == nav_items[-1].title == title and child_title is None:
-            filename = path.split(os.path.sep)[-1]
-            child_title = file_to_title(filename)
-
-        url = utils.get_url_path(path, use_directory_urls)
-
-        if not child_title:
-            # New top level page.
-            page = Page(title=title, url=url, path=path, url_context=url_context)
-            nav_items.append(page)
-        elif not nav_items or (nav_items[-1].title != title):
-            # New second level page.
-            page = Page(title=child_title, url=url, path=path, url_context=url_context)
-            header = Header(title=title, children=[page])
-            nav_items.append(header)
-            page.ancestors = [header]
-        else:
-            # Additional second level page.
-            page = Page(title=child_title, url=url, path=path, url_context=url_context)
-            header = nav_items[-1]
-            header.children.append(page)
-            page.ancestors = [header]
 
-        # Add in previous and next information.
-        if previous:
-            page.previous_page = previous
-            previous.next_page = page
-        previous = page
+        for page_or_header in _follow(
+                config_line, url_context, use_dir_urls):
+
+            if isinstance(page_or_header, Header):
+
+                if page_or_header.is_top_level:
+                    nav_items.append(page_or_header)
+
+            elif isinstance(page_or_header, Page):
+
+                if page_or_header.is_top_level:
+                    nav_items.append(page_or_header)
+
+                pages.append(page_or_header)
+
+                if previous:
+                    page_or_header.previous_page = previous
+                    previous.next_page = page_or_header
+                previous = page_or_header
 
-        pages.append(page)
+    if len(pages) == 0:
+        raise exceptions.ConfigurationError(
+            "No pages found in the pages config. "
+            "Remove it entirely to enable automatic page discovery.")
 
     return (nav_items, pages)


Test patch:
diff --git a/mkdocs/tests/build_tests.py b/mkdocs/tests/build_tests.py
--- a/mkdocs/tests/build_tests.py
+++ b/mkdocs/tests/build_tests.py
@@ -84,9 +84,9 @@ def test_convert_internal_link_with_anchor(self):
     def test_convert_internal_media(self):
         """Test relative image URL's are the same for different base_urls"""
         pages = [
-            ('index.md',),
-            ('internal.md',),
-            ('sub/internal.md')
+            'index.md',
+            'internal.md',
+            'sub/internal.md',
         ]
 
         site_navigation = nav.SiteNavigation(pages)
@@ -107,9 +107,9 @@ def test_convert_internal_media(self):
     def test_convert_internal_asbolute_media(self):
         """Test absolute image URL's are correct for different base_urls"""
         pages = [
-            ('index.md',),
-            ('internal.md',),
-            ('sub/internal.md')
+            'index.md',
+            'internal.md',
+            'sub/internal.md',
         ]
 
         site_navigation = nav.SiteNavigation(pages)
@@ -129,9 +129,9 @@ def test_convert_internal_asbolute_media(self):
 
     def test_dont_convert_code_block_urls(self):
         pages = [
-            ('index.md',),
-            ('internal.md',),
-            ('sub/internal.md')
+            'index.md',
+            'internal.md',
+            'sub/internal.md',
         ]
 
         site_navigation = nav.SiteNavigation(pages)
@@ -150,9 +150,9 @@ def test_dont_convert_code_block_urls(self):
     def test_anchor_only_link(self):
 
         pages = [
-            ('index.md',),
-            ('internal.md',),
-            ('sub/internal.md')
+            'index.md',
+            'internal.md',
+            'sub/internal.md',
         ]
 
         site_navigation = nav.SiteNavigation(pages)
@@ -172,7 +172,7 @@ def test_not_use_directory_urls(self):
         md_text = 'An [internal link](internal.md) to another document.'
         expected = '<p>An <a href="internal/index.html">internal link</a> to another document.</p>'
         pages = [
-            ('internal.md',)
+            'internal.md',
         ]
         site_navigation = nav.SiteNavigation(pages, use_directory_urls=False)
         html, toc, meta = build.convert_markdown(md_text, site_navigation=site_navigation)
@@ -297,9 +297,9 @@ def test_copying_media(self):
 
     def test_strict_mode_valid(self):
         pages = [
-            ('index.md',),
-            ('internal.md',),
-            ('sub/internal.md')
+            'index.md',
+            'internal.md',
+            'sub/internal.md',
         ]
         site_nav = nav.SiteNavigation(pages)
 
@@ -309,9 +309,9 @@ def test_strict_mode_valid(self):
 
     def test_strict_mode_invalid(self):
         pages = [
-            ('index.md',),
-            ('internal.md',),
-            ('sub/internal.md')
+            'index.md',
+            'internal.md',
+            'sub/internal.md',
         ]
         site_nav = nav.SiteNavigation(pages)
 
diff --git a/mkdocs/tests/config_tests.py b/mkdocs/tests/config_tests.py
--- a/mkdocs/tests/config_tests.py
+++ b/mkdocs/tests/config_tests.py
@@ -72,7 +72,7 @@ def test_config_option(self):
         expected_result = {
             'site_name': 'Example',
             'pages': [
-                ['index.md', 'Introduction']
+                {'Introduction': 'index.md'}
             ],
         }
         file_contents = dedent("""
diff --git a/mkdocs/tests/legacy_tests.py b/mkdocs/tests/legacy_tests.py
new file mode 100644
--- /dev/null
+++ b/mkdocs/tests/legacy_tests.py
@@ -0,0 +1,65 @@
+import unittest
+
+import yaml
+
+from mkdocs import legacy
+
+
+class TestCompatabilityShim(unittest.TestCase):
+
+    # TODO: Remove in 1.0
+
+    def test_convert(self):
+
+        self.maxDiff = None
+
+        pages_yaml_old = """
+        pages:
+        - ['index.md', 'Home']
+        - ['user-guide/writing-your-docs.md', 'User Guide']
+        - ['user-guide/styling-your-docs.md', 'User Guide']
+        - ['about/license.md', 'About', 'License']
+        - ['about/release-notes.md', 'About']
+        - ['about/contributing.md', 'About', 'Contributing']
+        - ['help/contributing.md', 'Help', 'Contributing']
+        - ['support.md']
+        - ['cli.md', 'CLI Guide']
+        """
+
+        pages_yaml_new = """
+        pages:
+        - Home: index.md
+        - User Guide:
+            - user-guide/writing-your-docs.md
+            - user-guide/styling-your-docs.md
+        - About:
+            - License: about/license.md
+            - about/release-notes.md
+            - Contributing: about/contributing.md
+        - Help:
+            - Contributing: help/contributing.md
+        - support.md
+        - CLI Guide: cli.md
+        """
+        self.assertEqual(
+            legacy.pages_compat_shim(yaml.load(pages_yaml_old)['pages']),
+            yaml.load(pages_yaml_new)['pages'])
+
+    def test_convert_no_home(self):
+
+        self.maxDiff = None
+
+        pages_yaml_old = """
+        pages:
+        - ['index.md']
+        - ['about.md', 'About']
+        """
+
+        pages_yaml_new = """
+        pages:
+        - index.md
+        - About: about.md
+        """
+        self.assertEqual(
+            legacy.pages_compat_shim(yaml.load(pages_yaml_old)['pages']),
+            yaml.load(pages_yaml_new)['pages'])
diff --git a/mkdocs/tests/nav_tests.py b/mkdocs/tests/nav_tests.py
--- a/mkdocs/tests/nav_tests.py
+++ b/mkdocs/tests/nav_tests.py
@@ -5,7 +5,7 @@
 import os
 import unittest
 
-from mkdocs import nav
+from mkdocs import nav, legacy
 from mkdocs.exceptions import ConfigurationError
 from mkdocs.tests.base import dedent
 
@@ -13,8 +13,8 @@
 class SiteNavigationTests(unittest.TestCase):
     def test_simple_toc(self):
         pages = [
-            ('index.md', 'Home'),
-            ('about.md', 'About')
+            {'Home': 'index.md'},
+            {'About': 'about.md'}
         ]
         expected = dedent("""
         Home - /
@@ -27,8 +27,8 @@ def test_simple_toc(self):
 
     def test_empty_toc_item(self):
         pages = [
-            ('index.md',),
-            ('about.md', 'About')
+            'index.md',
+            {'About': 'about.md'}
         ]
         expected = dedent("""
         Home - /
@@ -41,36 +41,16 @@ def test_empty_toc_item(self):
 
     def test_indented_toc(self):
         pages = [
-            ('index.md', 'Home'),
-            ('api-guide/running.md', 'API Guide', 'Running'),
-            ('api-guide/testing.md', 'API Guide', 'Testing'),
-            ('api-guide/debugging.md', 'API Guide', 'Debugging'),
-            ('about/release-notes.md', 'About', 'Release notes'),
-            ('about/license.md', 'About', 'License')
-        ]
-        expected = dedent("""
-        Home - /
-        API Guide
-            Running - /api-guide/running/
-            Testing - /api-guide/testing/
-            Debugging - /api-guide/debugging/
-        About
-            Release notes - /about/release-notes/
-            License - /about/license/
-        """)
-        site_navigation = nav.SiteNavigation(pages)
-        self.assertEqual(str(site_navigation).strip(), expected)
-        self.assertEqual(len(site_navigation.nav_items), 3)
-        self.assertEqual(len(site_navigation.pages), 6)
-
-    def test_indented_toc_missing_child_title(self):
-        pages = [
-            ('index.md', 'Home'),
-            ('api-guide/running.md', 'API Guide', 'Running'),
-            ('api-guide/testing.md', 'API Guide'),
-            ('api-guide/debugging.md', 'API Guide', 'Debugging'),
-            ('about/release-notes.md', 'About', 'Release notes'),
-            ('about/license.md', 'About', 'License')
+            {'Home': 'index.md'},
+            {'API Guide': [
+                {'Running': 'api-guide/running.md'},
+                {'Testing': 'api-guide/testing.md'},
+                {'Debugging': 'api-guide/debugging.md'},
+            ]},
+            {'About': [
+                {'Release notes': 'about/release-notes.md'},
+                {'License': 'about/license.md'}
+            ]}
         ]
         expected = dedent("""
         Home - /
@@ -89,9 +69,9 @@ def test_indented_toc_missing_child_title(self):
 
     def test_nested_ungrouped(self):
         pages = [
-            ('index.md', 'Home'),
-            ('about/contact.md', 'Contact'),
-            ('about/sub/license.md', 'License Title')
+            {'Home': 'index.md'},
+            {'Contact': 'about/contact.md'},
+            {'License Title': 'about/sub/license.md'},
         ]
         expected = dedent("""
         Home - /
@@ -105,45 +85,43 @@ def test_nested_ungrouped(self):
 
     def test_nested_ungrouped_no_titles(self):
         pages = [
-            ('index.md',),
-            ('about/contact.md'),
-            ('about/sub/license.md')
+            'index.md',
+            'about/contact.md',
+            'about/sub/license.md'
         ]
         expected = dedent("""
         Home - /
-        About
-            Contact - /about/contact/
-            License - /about/sub/license/
+        Contact - /about/contact/
+        License - /about/sub/license/
         """)
 
         site_navigation = nav.SiteNavigation(pages)
         self.assertEqual(str(site_navigation).strip(), expected)
-        self.assertEqual(len(site_navigation.nav_items), 2)
+        self.assertEqual(len(site_navigation.nav_items), 3)
         self.assertEqual(len(site_navigation.pages), 3)
 
     @mock.patch.object(os.path, 'sep', '\\')
     def test_nested_ungrouped_no_titles_windows(self):
         pages = [
-            ('index.md',),
-            ('about\\contact.md'),
-            ('about\\sub\\license.md')
+            'index.md',
+            'about\\contact.md',
+            'about\\sub\\license.md',
         ]
         expected = dedent("""
         Home - /
-        About
-            Contact - /about/contact/
-            License - /about/sub/license/
+        Contact - /about/contact/
+        License - /about/sub/license/
         """)
 
         site_navigation = nav.SiteNavigation(pages)
         self.assertEqual(str(site_navigation).strip(), expected)
-        self.assertEqual(len(site_navigation.nav_items), 2)
+        self.assertEqual(len(site_navigation.nav_items), 3)
         self.assertEqual(len(site_navigation.pages), 3)
 
     def test_walk_simple_toc(self):
         pages = [
-            ('index.md', 'Home'),
-            ('about.md', 'About')
+            {'Home': 'index.md'},
+            {'About': 'about.md'}
         ]
         expected = [
             dedent("""
@@ -161,8 +139,8 @@ def test_walk_simple_toc(self):
 
     def test_walk_empty_toc(self):
         pages = [
-            ('index.md',),
-            ('about.md', 'About')
+            'index.md',
+            {'About': 'about.md'}
         ]
         expected = [
             dedent("""
@@ -180,12 +158,16 @@ def test_walk_empty_toc(self):
 
     def test_walk_indented_toc(self):
         pages = [
-            ('index.md', 'Home'),
-            ('api-guide/running.md', 'API Guide', 'Running'),
-            ('api-guide/testing.md', 'API Guide', 'Testing'),
-            ('api-guide/debugging.md', 'API Guide', 'Debugging'),
-            ('about/release-notes.md', 'About', 'Release notes'),
-            ('about/license.md', 'About', 'License')
+            {'Home': 'index.md'},
+            {'API Guide': [
+                {'Running': 'api-guide/running.md'},
+                {'Testing': 'api-guide/testing.md'},
+                {'Debugging': 'api-guide/debugging.md'},
+            ]},
+            {'About': [
+                {'Release notes': 'about/release-notes.md'},
+                {'License': 'about/license.md'}
+            ]}
         ]
         expected = [
             dedent("""
@@ -255,7 +237,7 @@ def test_walk_indented_toc(self):
 
     def test_base_url(self):
         pages = [
-            ('index.md',)
+            'index.md'
         ]
         site_navigation = nav.SiteNavigation(pages, use_directory_urls=False)
         base_url = site_navigation.url_context.make_relative('/')
@@ -263,8 +245,8 @@ def test_base_url(self):
 
     def test_relative_md_links_have_slash(self):
         pages = [
-            ('index.md',),
-            ('user-guide/styling-your-docs.md',)
+            'index.md',
+            'user-guide/styling-your-docs.md'
         ]
         site_navigation = nav.SiteNavigation(pages, use_directory_urls=False)
         site_navigation.url_context.base_path = "/user-guide/configuration"
@@ -277,17 +259,17 @@ def test_generate_site_navigation(self):
         """
 
         pages = [
-            ('index.md', ),
-            ('api-guide/running.md', ),
-            ('about/notes.md', ),
-            ('about/sub/license.md', ),
+            'index.md',
+            'api-guide/running.md',
+            'about/notes.md',
+            'about/sub/license.md',
         ]
 
         url_context = nav.URLContext()
         nav_items, pages = nav._generate_site_navigation(pages, url_context)
 
         self.assertEqual([n.title for n in nav_items],
-                         ['Home', 'Api guide', 'About'])
+                         ['Home', 'Running', 'Notes', 'License'])
         self.assertEqual([p.title for p in pages],
                          ['Home', 'Running', 'Notes', 'License'])
 
@@ -297,25 +279,25 @@ def test_generate_site_navigation_windows(self):
         Verify inferring page titles based on the filename with a windows path
         """
         pages = [
-            ('index.md', ),
-            ('api-guide\\running.md', ),
-            ('about\\notes.md', ),
-            ('about\\sub\\license.md', ),
+            'index.md',
+            'api-guide\\running.md',
+            'about\\notes.md',
+            'about\\sub\\license.md',
         ]
 
         url_context = nav.URLContext()
         nav_items, pages = nav._generate_site_navigation(pages, url_context)
 
         self.assertEqual([n.title for n in nav_items],
-                         ['Home', 'Api guide', 'About'])
+                         ['Home', 'Running', 'Notes', 'License'])
         self.assertEqual([p.title for p in pages],
                          ['Home', 'Running', 'Notes', 'License'])
 
     def test_invalid_pages_config(self):
 
         bad_pages = [
-            (),  # too short
-            ('this', 'is', 'too', 'long'),
+            set(),  # should be dict or string only
+            {"a": "index.md", "b": "index.md"}  # extra key
         ]
 
         for bad_page in bad_pages:
@@ -325,15 +307,28 @@ def _test():
 
             self.assertRaises(ConfigurationError, _test)
 
+    def test_pages_config(self):
+
+        bad_page = {}  # empty
+
+        def _test():
+            return nav._generate_site_navigation((bad_page, ), None)
+
+        self.assertRaises(ConfigurationError, _test)
+
     def test_ancestors(self):
 
         pages = [
-            ('index.md', 'Home'),
-            ('api-guide/running.md', 'API Guide', 'Running'),
-            ('api-guide/testing.md', 'API Guide', 'Testing'),
-            ('api-guide/debugging.md', 'API Guide', 'Debugging'),
-            ('about/release-notes.md', 'About', 'Release notes'),
-            ('about/license.md', 'About', 'License')
+            {'Home': 'index.md'},
+            {'API Guide': [
+                {'Running': 'api-guide/running.md'},
+                {'Testing': 'api-guide/testing.md'},
+                {'Debugging': 'api-guide/debugging.md'},
+            ]},
+            {'About': [
+                {'Release notes': 'about/release-notes.md'},
+                {'License': 'about/license.md'}
+            ]}
         ]
         site_navigation = nav.SiteNavigation(pages)
 
@@ -361,3 +356,197 @@ def test_file_to_tile(self):
 
         title = nav.file_to_title("mkdocs/tests/resources/no_title_metadata.md")
         self.assertEqual(title, "Title")
+
+    def test_nesting(self):
+
+        pages_config = [
+            {'Home': 'index.md'},
+            {'Install': [
+                {'Pre-install': 'install/install-pre.md'},
+                {'The install': 'install/install-actual.md'},
+                {'Post install': 'install/install-post.md'},
+            ]},
+            {'Guide': [
+                {'Tutorial': [
+                    {'Getting Started': 'guide/tutorial/running.md'},
+                    {'Advanced Features': 'guide/tutorial/testing.md'},
+                    {'Further Reading': 'guide/tutorial/debugging.md'},
+                ]},
+                {'API Reference': [
+                    {'Feature 1': 'guide/api-ref/running.md'},
+                    {'Feature 2': 'guide/api-ref/testing.md'},
+                    {'Feature 3': 'guide/api-ref/debugging.md'},
+                ]},
+                {'Testing': 'guide/testing.md'},
+                {'Deploying': 'guide/deploying.md'},
+            ]}
+        ]
+
+        site_navigation = nav.SiteNavigation(pages_config)
+
+        self.assertEqual([n.title for n in site_navigation.nav_items],
+                         ['Home', 'Install', 'Guide'])
+        self.assertEqual(len(site_navigation.pages), 12)
+
+        expected = dedent("""
+        Home - /
+        Install
+            Pre-install - /install/install-pre/
+            The install - /install/install-actual/
+            Post install - /install/install-post/
+        Guide
+            Tutorial
+                Getting Started - /guide/tutorial/running/
+                Advanced Features - /guide/tutorial/testing/
+                Further Reading - /guide/tutorial/debugging/
+            API Reference
+                Feature 1 - /guide/api-ref/running/
+                Feature 2 - /guide/api-ref/testing/
+                Feature 3 - /guide/api-ref/debugging/
+            Testing - /guide/testing/
+            Deploying - /guide/deploying/
+        """)
+
+        self.maxDiff = None
+        self.assertEqual(str(site_navigation).strip(), expected)
+
+
+class TestLegacyPagesConfig(unittest.TestCase):
+
+    def test_walk_simple_toc(self):
+        pages = legacy.pages_compat_shim([
+            ('index.md', 'Home'),
+            ('about.md', 'About')
+        ])
+        expected = [
+            dedent("""
+                Home - / [*]
+                About - /about/
+            """),
+            dedent("""
+                Home - /
+                About - /about/ [*]
+            """)
+        ]
+        site_navigation = nav.SiteNavigation(pages)
+        for index, page in enumerate(site_navigation.walk_pages()):
+            self.assertEqual(str(site_navigation).strip(), expected[index])
+
+    def test_walk_empty_toc(self):
+        pages = legacy.pages_compat_shim([
+            ('index.md',),
+            ('about.md', 'About')
+        ])
+
+        expected = [
+            dedent("""
+                Home - / [*]
+                About - /about/
+            """),
+            dedent("""
+                Home - /
+                About - /about/ [*]
+            """)
+        ]
+        site_navigation = nav.SiteNavigation(pages)
+        for index, page in enumerate(site_navigation.walk_pages()):
+            self.assertEqual(str(site_navigation).strip(), expected[index])
+
+    def test_walk_indented_toc(self):
+        pages = legacy.pages_compat_shim([
+            ('index.md', 'Home'),
+            ('api-guide/running.md', 'API Guide', 'Running'),
+            ('api-guide/testing.md', 'API Guide', 'Testing'),
+            ('api-guide/debugging.md', 'API Guide', 'Debugging'),
+            ('about/release-notes.md', 'About', 'Release notes'),
+            ('about/license.md', 'About', 'License')
+        ])
+        expected = [
+            dedent("""
+                Home - / [*]
+                API Guide
+                    Running - /api-guide/running/
+                    Testing - /api-guide/testing/
+                    Debugging - /api-guide/debugging/
+                About
+                    Release notes - /about/release-notes/
+                    License - /about/license/
+            """),
+            dedent("""
+                Home - /
+                API Guide [*]
+                    Running - /api-guide/running/ [*]
+                    Testing - /api-guide/testing/
+                    Debugging - /api-guide/debugging/
+                About
+                    Release notes - /about/release-notes/
+                    License - /about/license/
+            """),
+            dedent("""
+                Home - /
+                API Guide [*]
+                    Running - /api-guide/running/
+                    Testing - /api-guide/testing/ [*]
+                    Debugging - /api-guide/debugging/
+                About
+                    Release notes - /about/release-notes/
+                    License - /about/license/
+            """),
+            dedent("""
+                Home - /
+                API Guide [*]
+                    Running - /api-guide/running/
+                    Testing - /api-guide/testing/
+                    Debugging - /api-guide/debugging/ [*]
+                About
+                    Release notes - /about/release-notes/
+                    License - /about/license/
+            """),
+            dedent("""
+                Home - /
+                API Guide
+                    Running - /api-guide/running/
+                    Testing - /api-guide/testing/
+                    Debugging - /api-guide/debugging/
+                About [*]
+                    Release notes - /about/release-notes/ [*]
+                    License - /about/license/
+            """),
+            dedent("""
+                Home - /
+                API Guide
+                    Running - /api-guide/running/
+                    Testing - /api-guide/testing/
+                    Debugging - /api-guide/debugging/
+                About [*]
+                    Release notes - /about/release-notes/
+                    License - /about/license/ [*]
+            """)
+        ]
+        site_navigation = nav.SiteNavigation(pages)
+        for index, page in enumerate(site_navigation.walk_pages()):
+            self.assertEqual(str(site_navigation).strip(), expected[index])
+
+    def test_indented_toc_missing_child_title(self):
+        pages = legacy.pages_compat_shim([
+            ('index.md', 'Home'),
+            ('api-guide/running.md', 'API Guide', 'Running'),
+            ('api-guide/testing.md', 'API Guide'),
+            ('api-guide/debugging.md', 'API Guide', 'Debugging'),
+            ('about/release-notes.md', 'About', 'Release notes'),
+            ('about/license.md', 'About', 'License')
+        ])
+        expected = dedent("""
+        Home - /
+        API Guide
+            Running - /api-guide/running/
+            Testing - /api-guide/testing/
+            Debugging - /api-guide/debugging/
+        About
+            Release notes - /about/release-notes/
+            License - /about/license/
+        """)
+        site_navigation = nav.SiteNavigation(pages)
+        self.assertEqual(str(site_navigation).strip(), expected)
+        self.assertEqual(len(site_navigation.nav_items), 3)
+        self.assertEqual(len(site_navigation.pages), 6)
diff --git a/mkdocs/tests/search_tests.py b/mkdocs/tests/search_tests.py
--- a/mkdocs/tests/search_tests.py
+++ b/mkdocs/tests/search_tests.py
@@ -103,8 +103,8 @@ def test_create_search_index(self):
         """
 
         pages = [
-            ('index.md', 'Home'),
-            ('about.md', 'About')
+            {'Home': 'index.md'},
+            {'About': 'about.md'},
         ]
         site_navigation = nav.SiteNavigation(pages)
 
diff --git a/mkdocs/tests/utils_tests.py b/mkdocs/tests/utils_tests.py
--- a/mkdocs/tests/utils_tests.py
+++ b/mkdocs/tests/utils_tests.py
@@ -54,8 +54,8 @@ def test_is_html_file(self):
 
     def test_create_media_urls(self):
         pages = [
-            ('index.md', 'Home'),
-            ('about.md', 'About')
+            {'Home': 'index.md'},
+            {'About': 'about.md'}
         ]
         expected_results = {
             'https://media.cdn.org/jq.js': 'https://media.cdn.org/jq.js',