chiark / gitweb /
documentation/python: make it possible to configure how URLs look.
authorVladimír Vondruš <mosra@centrum.cz>
Wed, 10 Jul 2019 20:45:15 +0000 (22:45 +0200)
committerVladimír Vondruš <mosra@centrum.cz>
Sun, 14 Jul 2019 17:11:08 +0000 (19:11 +0200)
Well, this is a bit overdone preparation for links to actual class /
module members, but can't hurt, no?? :D

18 files changed:
doc/documentation/python.rst
documentation/python.py
documentation/templates/python/base.html
documentation/test_python/layout/about.rst [new file with mode: 0644]
documentation/test_python/layout/getting-started.rst [new file with mode: 0644]
documentation/test_python/layout/troubleshooting.rst [new file with mode: 0644]
documentation/test_python/link_formatting/c.link_formatting.Class.Sub.html [new file with mode: 0644]
documentation/test_python/link_formatting/c.link_formatting.Class.html [new file with mode: 0644]
documentation/test_python/link_formatting/link_formatting/__init__.py [new file with mode: 0644]
documentation/test_python/link_formatting/link_formatting/sub.py [new file with mode: 0644]
documentation/test_python/link_formatting/m.link_formatting.html [new file with mode: 0644]
documentation/test_python/link_formatting/m.link_formatting.sub.html [new file with mode: 0644]
documentation/test_python/link_formatting/page.rst [new file with mode: 0644]
documentation/test_python/link_formatting/s.classes.html [new file with mode: 0644]
documentation/test_python/link_formatting/s.modules.html [new file with mode: 0644]
documentation/test_python/link_formatting/s.pages.html [new file with mode: 0644]
documentation/test_python/test_layout.py
documentation/test_python/test_link_formatting.py [new file with mode: 0644]

index 777370b97666e43faca40a4bf40b5db2e590aefc..1bb3a40184eab7fe788c1056f6a920b2be0a8038 100644 (file)
@@ -197,8 +197,8 @@ Variable                            Description
 :py:`PAGE_HEADER: str`              :abbr:`reST <reStructuredText>` markup to
                                     put at the top of every page. If not set,
                                     nothing is added anywhere. The
-                                    ``{filename}`` placeholder is replaced with
-                                    current file name.
+                                    ``{url}`` placeholder is replaced with
+                                    current file URL.
 :py:`FINE_PRINT: str`               :abbr:`reST <reStructuredText>` markup to
                                     put into the footer. If not set, a default
                                     generic text is used. If empty, no footer
@@ -250,6 +250,10 @@ Variable                            Description
                                     :py:`SEARCH_DISABLED` is not :py:`True`.
 :py:`DOCUTILS_SETTINGS: Dict[Any]`  Additional docutils settings. Key/value
                                     pairs as described in `the docs <http://docutils.sourceforge.net/docs/user/config.html>`_.
+:py:`URL_FORMATTER: Callable`       Function for creating filenames and URLs
+                                    for modules, classes, pages and index
+                                    pages. See `Custom URL formatters`_ for
+                                    more information.
 =================================== ===========================================
 
 `Theme selection`_
@@ -303,12 +307,14 @@ CSS files.
 The :py:`LINKS_NAVBAR1` and :py:`LINKS_NAVBAR2` options define which links are
 shown on the top navbar, split into left and right column on small screen
 sizes. These options take a list of :py:`(title, path, sub)` tuples ---
-``title`` is the link title, ``path`` is path to a particular page or
-module/class (in the form of ``module.sub.ClassName``, for example) and ``sub``
-is an optional submenu, containing :py:`(title, path)` tuples. The ``path`` can
-be also one of ``pages``, ``modules`` or ``classes``, linking to the page /
-module / class index. When rendering, the path is converted to an actual URL to
-the destination file.
+``title`` is the link title; ``path`` is either one of :py:`'index'`,
+:py:`'pages'`, :py:`'modules'` or :py:`'classes'` (linking to the main page or
+page / module / class index path), a full URL (pasted as-is) or a *path* to a
+particular page or module/class (in the form of
+:py:`['module', 'sub', 'ClassName']` for :py:`module.sub.ClassName`, which then
+gets formatted according to `URL formatting rules <#custom-url-formatters>`_);
+and ``sub`` is an optional submenu, containing :py:`(title, path)` tuples, with
+``path`` being interpreted the same way.
 
 By default the variables are defined like following --- there's just three
 items in the left column, with no submenus and the right column is empty:
@@ -372,6 +378,30 @@ search to a subdomain:
 
     SEARCH_EXTERNAL_URL = 'https://google.com/search?q=site:doc.magnum.graphics+{query}'
 
+`Custom URL formatters`_
+------------------------
+
+The :py:`URL_FORMATTER` option allows you to control how *all* filenames and
+generated URLs look like. It takes an entry type and a "path" as a list of
+strings (so for example :py:`my.module.Class` is represented as
+:py:`['my', 'module', 'Class']`), returning a tuple a filename and an URL.
+Those can be the same, but also different (for example a file getting saved
+into ``my/module/Class/index.html`` but the actual URL being
+``https://docs.my.module/Class/``). The default implementation looks like this,
+producing both filenames and URLs in the form of ``my.module.Class.html``:
+
+.. code:: py
+
+    def default_url_formatter(type: EntryType, path: List[str]) -> Tuple[str, str]:
+        url = '.'.join(path) + '.html'
+        return url, url
+
+The ``type`` is an enum, if you don't want to fiddle with imports, compare
+:py:`str(type)` against a string, which is one of :py:`'PAGE'`, :py:`'MODULE'`,
+:py:`'CLASS'` or :py:`'SPECIAL'`. The :py:`'SPECIAL'` is for index pages and in
+that case the ``path`` has always just one item, one of :py:`'pages'`,
+:py:`'modules'` or :py:`'classes'`.
+
 `Module inspection`_
 ====================
 
@@ -824,10 +854,10 @@ Filename                Use
 ======================= =======================================================
 
 Each template gets passed all configuration values from the `Configuration`_
-table as-is, together with a :py:`FILENAME` variable with name of given output
-file. In addition to builtin Jinja2 filters, the ``basename_or_url`` filter
-returns either a basename of file path, if the path is relative; or a full URL,
-if the argument is an absolute URL. It's useful in cases like this:
+table as-is, together with a :py:`URL` variable with URL of given output file.
+In addition to builtin Jinja2 filters, the ``basename_or_url`` filter returns
+either a basename of file path, if the path is relative; or a full URL, if the
+argument is an absolute URL. It's useful in cases like this:
 
 .. code:: html+jinja
 
@@ -845,7 +875,8 @@ HTML markup without any additional escaping.
 Property                                Description
 ======================================= =======================================
 :py:`page.summary`                      Doc summary
-:py:`page.url`                          File URL
+:py:`page.filename`                     File name [3]_
+:py:`page.url`                          File URL [3]_
 :py:`page.breadcrumb`                   List of :py:`(title, URL)` tuples for
                                         breadcrumb navigation.
 :py:`page.content`                      Detailed documentation, if any
@@ -1077,7 +1108,7 @@ Filename                Use
 ======================= =======================================================
 
 Each template is passed all configuration values from the `Configuration`_
-table as-is, together with a :py:`FILENAME`, as above. The navigation tree is
+table as-is, together with an :py:`URL`, as above. The navigation tree is
 provided in an :py:`index` object, which has the following properties:
 
 .. class:: m-table m-fullwidth
@@ -1117,3 +1148,5 @@ Module/class list is ordered in a way that all modules are before all classes.
     *documented* enum value listing that makes it worth to render the full
     description block. If :py:`False`, the member should be included only in
     the summary listing on top of the page to avoid unnecessary repetition.
+.. [3] :py:`page.filename` and :py:`page.url` is generated by an URL formatter,
+    see `Custom URL formatters`_ for more information
index 814e43ed2f9d4358c39e08aa75a75dae018c02b8..8d8d45acc95e9ed6e16d70c2ab5ee06874ded127 100755 (executable)
@@ -42,7 +42,7 @@ import shutil
 from enum import Enum
 from types import SimpleNamespace as Empty
 from importlib.machinery import SourceFileLoader
-from typing import Tuple, Dict, Set, Any, List
+from typing import Tuple, Dict, Set, Any, List, Callable
 from urllib.parse import urljoin
 from distutils.version import LooseVersion
 from docutils.transforms import Transform
@@ -54,15 +54,24 @@ import m.htmlsanity
 
 default_templates = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'templates/python/')
 
+special_pages = ['index', 'modules', 'classes', 'pages']
+
 class EntryType(Enum):
-    PAGE = 0
-    MODULE = 1
-    CLASS = 2
-    ENUM = 3
-    ENUM_VALUE = 4
-    FUNCTION = 5
-    PROPERTY = 6
-    DATA = 7
+    SPECIAL = 0 # one of files from special_pages
+    PAGE = 1
+    MODULE = 2
+    CLASS = 3
+    ENUM = 4
+    ENUM_VALUE = 5
+    FUNCTION = 6
+    PROPERTY = 7
+    DATA = 8
+
+def default_url_formatter(type: EntryType, path: List[str]) -> Tuple[str, str]:
+    # TODO: what about nested pages, how to format?
+    url = '.'.join(path) + '.html'
+    assert '/' not in url # TODO
+    return url, url
 
 default_config = {
     'PROJECT_TITLE': 'My Python Project',
@@ -115,6 +124,8 @@ default_config = {
 """,
     'SEARCH_BASE_URL': None,
     'SEARCH_EXTERNAL_URL': None,
+
+    'URL_FORMATTER': default_url_formatter
 }
 
 class State:
@@ -190,9 +201,6 @@ def is_internal_or_imported_module_member(state: State, parent, path: str, name:
 def is_enum(state: State, object) -> bool:
     return (inspect.isclass(object) and issubclass(object, enum.Enum)) or (state.config['PYBIND11_COMPATIBILITY'] and hasattr(object, '__members__'))
 
-def make_url(path: List[str]) -> str:
-    return '.'.join(path) + '.html'
-
 def object_type(state: State, object) -> EntryType:
     if inspect.ismodule(object): return EntryType.MODULE
     if inspect.isclass(object):
@@ -517,8 +525,8 @@ def extract_annotation(state: State, annotation) -> str:
 
 def render(config, template: str, page, env: jinja2.Environment):
     template = env.get_template(template)
-    rendered = template.render(page=page, FILENAME=page.url, **config)
-    with open(os.path.join(config['OUTPUT'], page.url), 'wb') as f:
+    rendered = template.render(page=page, URL=page.url, **config)
+    with open(os.path.join(config['OUTPUT'], page.filename), 'wb') as f:
         f.write(rendered.encode('utf-8'))
         # Add back a trailing newline so we don't need to bother with
         # patching test files to include a trailing newline to make Git
@@ -530,7 +538,7 @@ def extract_module_doc(state: State, path: List[str], module):
     assert inspect.ismodule(module)
 
     out = Empty()
-    out.url = make_url(path)
+    out.url = state.config['URL_FORMATTER'](EntryType.MODULE, path)[1]
     out.name = path[-1]
     out.summary = extract_summary(state, state.class_docs, path, module.__doc__)
     return out
@@ -539,7 +547,7 @@ def extract_class_doc(state: State, path: List[str], class_):
     assert inspect.isclass(class_)
 
     out = Empty()
-    out.url = make_url(path)
+    out.url = state.config['URL_FORMATTER'](EntryType.CLASS, path)[1]
     out.name = path[-1]
     out.summary = extract_summary(state, state.class_docs, path, class_.__doc__)
     return out
@@ -783,20 +791,24 @@ def extract_data_doc(state: State, parent, path: List[str], data):
     return out
 
 def render_module(state: State, path, module, env):
-    logging.debug("generating %s.html", '.'.join(path))
+    # Generate breadcrumb as the first thing as it generates the output
+    # filename as a side effect
+    breadcrumb = []
+    filename: str
+    url: str
+    for i in range(len(path)):
+        filename, url = state.config['URL_FORMATTER'](EntryType.MODULE, path[:i + 1])
+        breadcrumb += [(path[i], url)]
+
+    logging.debug("generating %s", filename)
 
     # Call all registered page begin hooks
     for hook in state.hooks_pre_page: hook()
 
-    url_base = ''
-    breadcrumb = []
-    for i in path:
-        url_base += i + '.'
-        breadcrumb += [(i, url_base + 'html')]
-
     page = Empty()
     page.summary = extract_summary(state, state.module_docs, path, module.__doc__)
-    page.url = breadcrumb[-1][1]
+    page.filename = filename
+    page.url = url
     page.breadcrumb = breadcrumb
     page.prefix_wbr = '.<wbr />'.join(path + [''])
     page.modules = []
@@ -895,20 +907,26 @@ _filtered_builtin_properties = set([
 ])
 
 def render_class(state: State, path, class_, env):
-    logging.debug("generating %s.html", '.'.join(path))
+    # Generate breadcrumb as the first thing as it generates the output
+    # filename as a side effect. It's a bit hairy because we need to figure out
+    # proper entry type for the URL formatter for each part of the breadcrumb.
+    breadcrumb = []
+    filename: str
+    url: str
+    for i in range(len(path)):
+        type = state.name_map['.'.join(path[:i + 1])].type
+        filename, url = state.config['URL_FORMATTER'](type, path[:i + 1])
+        breadcrumb += [(path[i], url)]
+
+    logging.debug("generating %s", filename)
 
     # Call all registered page begin hooks
     for hook in state.hooks_pre_page: hook()
 
-    url_base = ''
-    breadcrumb = []
-    for i in path:
-        url_base += i + '.'
-        breadcrumb += [(i, url_base + 'html')]
-
     page = Empty()
     page.summary = extract_summary(state, state.class_docs, path, class_.__doc__)
-    page.url = breadcrumb[-1][1]
+    page.filename = filename
+    page.url = url
     page.breadcrumb = breadcrumb
     page.prefix_wbr = '.<wbr />'.join(path + [''])
     page.classes = []
@@ -1040,14 +1058,16 @@ def render_doc(state: State, filename):
     # discard the output afterwards.
     with open(filename, 'r') as f: publish_rst(state, f.read())
 
-def render_page(state: State, path, filename, env):
-    logging.debug("generating %s.html", '.'.join(path))
+def render_page(state: State, path, input_filename, env):
+    filename, url = state.config['URL_FORMATTER'](EntryType.PAGE, path)
+
+    logging.debug("generating %s", filename)
 
     # Call all registered page begin hooks
     for hook in state.hooks_pre_page: hook()
 
     # Render the file
-    with open(filename, 'r') as f: pub = publish_rst(state, f.read(), source_path=filename)
+    with open(input_filename, 'r') as f: pub = publish_rst(state, f.read(), source_path=input_filename)
 
     # Extract metadata from the page
     metadata = {}
@@ -1072,10 +1092,11 @@ def render_page(state: State, path, filename, env):
 
     # Breadcrumb, we don't do page hierarchy yet
     assert len(path) == 1
-    breadcrumb = [(pub.writer.parts.get('title'), path[0] + '.html')]
+    breadcrumb = [(pub.writer.parts.get('title'), url)]
 
     page = Empty()
-    page.url = breadcrumb[-1][1]
+    page.filename = filename
+    page.url = url
     page.breadcrumb = breadcrumb
     page.prefix_wbr = path[0]
 
@@ -1094,22 +1115,6 @@ def render_page(state: State, path, filename, env):
     render(state.config, 'page.html', page, env)
 
 def run(basedir, config, templates):
-    # Prepare Jinja environment
-    env = jinja2.Environment(
-        loader=jinja2.FileSystemLoader(templates), trim_blocks=True,
-        lstrip_blocks=True, enable_async=True)
-    # Filter to return file basename or the full URL, if absolute
-    def basename_or_url(path):
-        if urllib.parse.urlparse(path).netloc: return path
-        return os.path.basename(path)
-    # Filter to return URL for given symbol or the full URL, if absolute
-    def path_to_url(path):
-        if urllib.parse.urlparse(path).netloc: return path
-        return path + '.html'
-    env.filters['basename_or_url'] = basename_or_url
-    env.filters['path_to_url'] = path_to_url
-    env.filters['urljoin'] = urljoin
-
     # Populate the INPUT, if not specified, make it absolute
     if config['INPUT'] is None: config['INPUT'] = basedir
     else: config['INPUT'] = os.path.join(basedir, config['INPUT'])
@@ -1124,6 +1129,28 @@ def run(basedir, config, templates):
 
     state = State(config)
 
+    # Prepare Jinja environment
+    env = jinja2.Environment(
+        loader=jinja2.FileSystemLoader(templates), trim_blocks=True,
+        lstrip_blocks=True, enable_async=True)
+    # Filter to return file basename or the full URL, if absolute
+    def basename_or_url(path):
+        if urllib.parse.urlparse(path).netloc: return path
+        return os.path.basename(path)
+    # Filter to return URL for given symbol. If the path is a string, first try
+    # to treat it as an URL. If that fails, turn it into a list and try to look
+    # it up in various dicts.
+    def path_to_url(path):
+        if isinstance(path, str):
+            if urllib.parse.urlparse(path).netloc: return path
+            path = [path]
+        entry = state.name_map['.'.join(path)]
+        return state.config['URL_FORMATTER'](entry.type, entry.path)[1]
+
+    env.filters['basename_or_url'] = basename_or_url
+    env.filters['path_to_url'] = path_to_url
+    env.filters['urljoin'] = urljoin
+
     # Set up extra plugin paths. The one for m.css plugins was added above.
     for path in config['PLUGIN_PATHS']:
         if path not in sys.path: sys.path.append(os.path.join(config['INPUT'], path))
@@ -1156,6 +1183,14 @@ def run(basedir, config, templates):
         crawl_module(state, [module_name], module)
         class_index += [module_name]
 
+    # Add special pages to the name map. The pages are done after so they can
+    # override these.
+    for page in special_pages:
+        entry = Empty()
+        entry.type = EntryType.SPECIAL
+        entry.path = [page]
+        state.name_map[page] = entry
+
     # Do the same for pages
     # TODO: turn also into some crawl_page() function? once we have subpages?
     page_index = []
@@ -1205,7 +1240,7 @@ def run(basedir, config, templates):
         index_entry = Empty()
         index_entry.kind = 'module' if entry.type == EntryType.MODULE else 'class'
         index_entry.name = entry.path[-1]
-        index_entry.url = make_url(entry.path)
+        index_entry.url = state.config['URL_FORMATTER'](entry.type, entry.path)[1]
         index_entry.summary = entry.summary
         index_entry.has_nestable_children = False
         index_entry.children = []
@@ -1236,7 +1271,7 @@ def run(basedir, config, templates):
         index_entry = Empty()
         index_entry.kind = 'page'
         index_entry.name = entry.name
-        index_entry.url = make_url(entry.path)
+        index_entry.url = state.config['URL_FORMATTER'](entry.type, entry.path)[1]
         index_entry.summary = entry.summary
         index_entry.has_nestable_children = False
         index_entry.children = []
@@ -1246,10 +1281,11 @@ def run(basedir, config, templates):
     index = Empty()
     index.classes = class_index
     index.pages = page_index
-    for file in ['modules.html', 'classes.html', 'pages.html']:
-        template = env.get_template(file)
-        rendered = template.render(index=index, FILENAME=file, **config)
-        with open(os.path.join(config['OUTPUT'], file), 'wb') as f:
+    for file in special_pages[1:]: # exclude index
+        template = env.get_template(file + '.html')
+        filename, url = state.config['URL_FORMATTER'](EntryType.SPECIAL, [file])
+        rendered = template.render(index=index, URL=url, **config)
+        with open(os.path.join(config['OUTPUT'], filename), 'wb') as f:
             f.write(rendered.encode('utf-8'))
             # Add back a trailing newline so we don't need to bother with
             # patching test files to include a trailing newline to make Git
@@ -1261,9 +1297,12 @@ def run(basedir, config, templates):
     if 'index.rst' not in [os.path.basename(i) for i in config['INPUT_PAGES']]:
         logging.debug("writing index.html for an empty main page")
 
+        filename, url = state.config['URL_FORMATTER'](EntryType.SPECIAL, ['index'])
+
         page = Empty()
-        page.breadcrumb = [(config['PROJECT_TITLE'], 'index.html')]
-        page.url = page.breadcrumb[-1][1]
+        page.filename = filename
+        page.url = url
+        page.breadcrumb = [(config['PROJECT_TITLE'], url)]
         render(config, 'page.html', page, env)
 
     # Copy referenced files
index aaa3197f3cbde1e4db9e16b31558b8443844120e..84f373870182790af8746a669c9dcd85401f0d41 100644 (file)
     <div class="m-row">
       {% if MAIN_PROJECT_URL and PROJECT_TITLE %}
       <span id="m-navbar-brand" class="m-col-t-8 m-col-m-none m-left-m">
-        <a href="{{ MAIN_PROJECT_URL }}">{{ PROJECT_TITLE }}</a> <span class="m-breadcrumb">|</span> <a href="index.html" class="m-thin">{{ PROJECT_SUBTITLE }}</a>
+        <a href="{{ MAIN_PROJECT_URL }}">{{ PROJECT_TITLE }}</a> <span class="m-breadcrumb">|</span> <a href="{{ 'index'|path_to_url }}" class="m-thin">{{ PROJECT_SUBTITLE }}</a>
       </span>
       {% else %}
-      <a href="index.html" id="m-navbar-brand" class="m-col-t-8 m-col-m-none m-left-m">{{ PROJECT_TITLE }}{% if PROJECT_SUBTITLE %} <span class="m-thin">{{ PROJECT_SUBTITLE }}</span>{% endif %}</a>
+      <a href="{{ 'index'|path_to_url }}" id="m-navbar-brand" class="m-col-t-8 m-col-m-none m-left-m">{{ PROJECT_TITLE }}{% if PROJECT_SUBTITLE %} <span class="m-thin">{{ PROJECT_SUBTITLE }}</span>{% endif %}</a>
       {% endif %}
       {% if LINKS_NAVBAR1 or LINKS_NAVBAR2 or not SEARCH_DISABLED %}
       <div class="m-col-t-4 m-hide-m m-text-right m-nopadr">
@@ -96,7 +96,7 @@
     <div class="m-row">
       <div class="m-col-l-10 m-push-l-1">
         {% if PAGE_HEADER %}
-        {{ PAGE_HEADER|render_rst|replace('{filename}', FILENAME) }}
+        {{ PAGE_HEADER|render_rst|replace('{url}', URL) }}
         {% endif %}
 {% block main %}
 {% endblock %}
diff --git a/documentation/test_python/layout/about.rst b/documentation/test_python/layout/about.rst
new file mode 100644 (file)
index 0000000..fed2083
--- /dev/null
@@ -0,0 +1 @@
+.. this is here only to make the LINKS_NAVBAR work
diff --git a/documentation/test_python/layout/getting-started.rst b/documentation/test_python/layout/getting-started.rst
new file mode 100644 (file)
index 0000000..fed2083
--- /dev/null
@@ -0,0 +1 @@
+.. this is here only to make the LINKS_NAVBAR work
diff --git a/documentation/test_python/layout/troubleshooting.rst b/documentation/test_python/layout/troubleshooting.rst
new file mode 100644 (file)
index 0000000..fed2083
--- /dev/null
@@ -0,0 +1 @@
+.. this is here only to make the LINKS_NAVBAR work
diff --git a/documentation/test_python/link_formatting/c.link_formatting.Class.Sub.html b/documentation/test_python/link_formatting/c.link_formatting.Class.Sub.html
new file mode 100644 (file)
index 0000000..0833419
--- /dev/null
@@ -0,0 +1,49 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <title>link_formatting.Class.Sub | My Python Project</title>
+  <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,400i,600,600i%7CSource+Code+Pro:400,400i,600" />
+  <link rel="stylesheet" href="m-dark+documentation.compiled.css" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+</head>
+<body>
+<header><nav id="navigation">
+  <div class="m-container">
+    <div class="m-row">
+      <a href="s.index.html#this-is-an-url" id="m-navbar-brand" class="m-col-t-8 m-col-m-none m-left-m">My Python Project</a>
+      <div class="m-col-t-4 m-hide-m m-text-right m-nopadr">
+        <a id="m-navbar-show" href="#navigation" title="Show navigation"></a>
+        <a id="m-navbar-hide" href="#" title="Hide navigation"></a>
+      </div>
+      <div id="m-navbar-collapse" class="m-col-t-12 m-show-m m-col-m-none m-right-m">
+        <div class="m-row">
+          <ol class="m-col-t-12 m-col-m-none">
+            <li><a href="s.pages.html#this-is-an-url">Pages</a></li>
+            <li><a href="s.modules.html#this-is-an-url">Modules</a></li>
+            <li><a href="s.classes.html#this-is-an-url">Classes</a></li>
+          </ol>
+          <ol class="m-col-t-6 m-col-m-none" start="4">
+            <li><a href="p.page.html#this-is-an-url">A page</a></li>
+            <li><a href="m.link_formatting.html#this-is-an-url">A module</a></li>
+            <li><a href="c.link_formatting.Class.html#this-is-an-url">The class</a></li>
+          </ol>
+        </div>
+      </div>
+    </div>
+  </div>
+</nav></header>
+<main><article>
+  <div class="m-container m-container-inflatable">
+    <div class="m-row">
+      <div class="m-col-l-10 m-push-l-1">
+        <h1>
+          <span class="m-breadcrumb"><a href="m.link_formatting.html#this-is-an-url">link_formatting</a>.<wbr/></span><span class="m-breadcrumb"><a href="c.link_formatting.Class.html#this-is-an-url">Class</a>.<wbr/></span>Sub <span class="m-thin">class</span>
+        </h1>
+        <p>And a nice subclass, oh.</p>
+      </div>
+    </div>
+  </div>
+</article></main>
+</body>
+</html>
diff --git a/documentation/test_python/link_formatting/c.link_formatting.Class.html b/documentation/test_python/link_formatting/c.link_formatting.Class.html
new file mode 100644 (file)
index 0000000..b54f5c2
--- /dev/null
@@ -0,0 +1,67 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <title>link_formatting.Class | My Python Project</title>
+  <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,400i,600,600i%7CSource+Code+Pro:400,400i,600" />
+  <link rel="stylesheet" href="m-dark+documentation.compiled.css" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+</head>
+<body>
+<header><nav id="navigation">
+  <div class="m-container">
+    <div class="m-row">
+      <a href="s.index.html#this-is-an-url" id="m-navbar-brand" class="m-col-t-8 m-col-m-none m-left-m">My Python Project</a>
+      <div class="m-col-t-4 m-hide-m m-text-right m-nopadr">
+        <a id="m-navbar-show" href="#navigation" title="Show navigation"></a>
+        <a id="m-navbar-hide" href="#" title="Hide navigation"></a>
+      </div>
+      <div id="m-navbar-collapse" class="m-col-t-12 m-show-m m-col-m-none m-right-m">
+        <div class="m-row">
+          <ol class="m-col-t-12 m-col-m-none">
+            <li><a href="s.pages.html#this-is-an-url">Pages</a></li>
+            <li><a href="s.modules.html#this-is-an-url">Modules</a></li>
+            <li><a href="s.classes.html#this-is-an-url">Classes</a></li>
+          </ol>
+          <ol class="m-col-t-6 m-col-m-none" start="4">
+            <li><a href="p.page.html#this-is-an-url">A page</a></li>
+            <li><a href="m.link_formatting.html#this-is-an-url">A module</a></li>
+            <li><a href="c.link_formatting.Class.html#this-is-an-url">The class</a></li>
+          </ol>
+        </div>
+      </div>
+    </div>
+  </div>
+</nav></header>
+<main><article>
+  <div class="m-container m-container-inflatable">
+    <div class="m-row">
+      <div class="m-col-l-10 m-push-l-1">
+        <h1>
+          <span class="m-breadcrumb"><a href="m.link_formatting.html#this-is-an-url">link_formatting</a>.<wbr/></span>Class <span class="m-thin">class</span>
+        </h1>
+        <p>This is a nice class.</p>
+        <div class="m-block m-default">
+          <h3>Contents</h3>
+          <ul>
+            <li>
+              Reference
+              <ul>
+                <li><a href="#classes">Classes</a></li>
+              </ul>
+            </li>
+          </ul>
+        </div>
+        <section id="classes">
+          <h2><a href="#classes">Classes</a></h2>
+          <dl class="m-doc">
+            <dt>class <a href="c.link_formatting.Class.Sub.html#this-is-an-url" class="m-doc">Sub</a></dt>
+            <dd>And a nice subclass, oh.</dd>
+          </dl>
+        </section>
+      </div>
+    </div>
+  </div>
+</article></main>
+</body>
+</html>
diff --git a/documentation/test_python/link_formatting/link_formatting/__init__.py b/documentation/test_python/link_formatting/link_formatting/__init__.py
new file mode 100644 (file)
index 0000000..5ccfb30
--- /dev/null
@@ -0,0 +1,9 @@
+"""This is a module."""
+
+from . import sub
+
+class Class:
+    """This is a nice class."""
+
+    class Sub:
+        """And a nice subclass, oh."""
diff --git a/documentation/test_python/link_formatting/link_formatting/sub.py b/documentation/test_python/link_formatting/link_formatting/sub.py
new file mode 100644 (file)
index 0000000..0b4339b
--- /dev/null
@@ -0,0 +1 @@
+"""This is a nice submodule."""
diff --git a/documentation/test_python/link_formatting/m.link_formatting.html b/documentation/test_python/link_formatting/m.link_formatting.html
new file mode 100644 (file)
index 0000000..cb18ca8
--- /dev/null
@@ -0,0 +1,75 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <title>link_formatting | My Python Project</title>
+  <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,400i,600,600i%7CSource+Code+Pro:400,400i,600" />
+  <link rel="stylesheet" href="m-dark+documentation.compiled.css" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+</head>
+<body>
+<header><nav id="navigation">
+  <div class="m-container">
+    <div class="m-row">
+      <a href="s.index.html#this-is-an-url" id="m-navbar-brand" class="m-col-t-8 m-col-m-none m-left-m">My Python Project</a>
+      <div class="m-col-t-4 m-hide-m m-text-right m-nopadr">
+        <a id="m-navbar-show" href="#navigation" title="Show navigation"></a>
+        <a id="m-navbar-hide" href="#" title="Hide navigation"></a>
+      </div>
+      <div id="m-navbar-collapse" class="m-col-t-12 m-show-m m-col-m-none m-right-m">
+        <div class="m-row">
+          <ol class="m-col-t-12 m-col-m-none">
+            <li><a href="s.pages.html#this-is-an-url">Pages</a></li>
+            <li><a href="s.modules.html#this-is-an-url">Modules</a></li>
+            <li><a href="s.classes.html#this-is-an-url">Classes</a></li>
+          </ol>
+          <ol class="m-col-t-6 m-col-m-none" start="4">
+            <li><a href="p.page.html#this-is-an-url">A page</a></li>
+            <li><a href="m.link_formatting.html#this-is-an-url">A module</a></li>
+            <li><a href="c.link_formatting.Class.html#this-is-an-url">The class</a></li>
+          </ol>
+        </div>
+      </div>
+    </div>
+  </div>
+</nav></header>
+<main><article>
+  <div class="m-container m-container-inflatable">
+    <div class="m-row">
+      <div class="m-col-l-10 m-push-l-1">
+        <h1>
+          link_formatting <span class="m-thin">module</span>
+        </h1>
+        <p>This is a module.</p>
+        <div class="m-block m-default">
+          <h3>Contents</h3>
+          <ul>
+            <li>
+              Reference
+              <ul>
+                <li><a href="#packages">Modules</a></li>
+                <li><a href="#classes">Classes</a></li>
+              </ul>
+            </li>
+          </ul>
+        </div>
+        <section id="namespaces">
+          <h2><a href="#namespaces">Modules</a></h2>
+          <dl class="m-doc">
+            <dt>module <a href="m.link_formatting.sub.html#this-is-an-url" class="m-doc">sub</a></dt>
+            <dd>This is a nice submodule.</dd>
+          </dl>
+        </section>
+        <section id="classes">
+          <h2><a href="#classes">Classes</a></h2>
+          <dl class="m-doc">
+            <dt>class <a href="c.link_formatting.Class.html#this-is-an-url" class="m-doc">Class</a></dt>
+            <dd>This is a nice class.</dd>
+          </dl>
+        </section>
+      </div>
+    </div>
+  </div>
+</article></main>
+</body>
+</html>
diff --git a/documentation/test_python/link_formatting/m.link_formatting.sub.html b/documentation/test_python/link_formatting/m.link_formatting.sub.html
new file mode 100644 (file)
index 0000000..9dcb920
--- /dev/null
@@ -0,0 +1,49 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <title>link_formatting.sub | My Python Project</title>
+  <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,400i,600,600i%7CSource+Code+Pro:400,400i,600" />
+  <link rel="stylesheet" href="m-dark+documentation.compiled.css" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+</head>
+<body>
+<header><nav id="navigation">
+  <div class="m-container">
+    <div class="m-row">
+      <a href="s.index.html#this-is-an-url" id="m-navbar-brand" class="m-col-t-8 m-col-m-none m-left-m">My Python Project</a>
+      <div class="m-col-t-4 m-hide-m m-text-right m-nopadr">
+        <a id="m-navbar-show" href="#navigation" title="Show navigation"></a>
+        <a id="m-navbar-hide" href="#" title="Hide navigation"></a>
+      </div>
+      <div id="m-navbar-collapse" class="m-col-t-12 m-show-m m-col-m-none m-right-m">
+        <div class="m-row">
+          <ol class="m-col-t-12 m-col-m-none">
+            <li><a href="s.pages.html#this-is-an-url">Pages</a></li>
+            <li><a href="s.modules.html#this-is-an-url">Modules</a></li>
+            <li><a href="s.classes.html#this-is-an-url">Classes</a></li>
+          </ol>
+          <ol class="m-col-t-6 m-col-m-none" start="4">
+            <li><a href="p.page.html#this-is-an-url">A page</a></li>
+            <li><a href="m.link_formatting.html#this-is-an-url">A module</a></li>
+            <li><a href="c.link_formatting.Class.html#this-is-an-url">The class</a></li>
+          </ol>
+        </div>
+      </div>
+    </div>
+  </div>
+</nav></header>
+<main><article>
+  <div class="m-container m-container-inflatable">
+    <div class="m-row">
+      <div class="m-col-l-10 m-push-l-1">
+        <h1>
+          <span class="m-breadcrumb"><a href="m.link_formatting.html#this-is-an-url">link_formatting</a>.<wbr/></span>sub <span class="m-thin">module</span>
+        </h1>
+        <p>This is a nice submodule.</p>
+      </div>
+    </div>
+  </div>
+</article></main>
+</body>
+</html>
diff --git a/documentation/test_python/link_formatting/page.rst b/documentation/test_python/link_formatting/page.rst
new file mode 100644 (file)
index 0000000..aae9406
--- /dev/null
@@ -0,0 +1,2 @@
+This is a page
+##############
diff --git a/documentation/test_python/link_formatting/s.classes.html b/documentation/test_python/link_formatting/s.classes.html
new file mode 100644 (file)
index 0000000..7c6598b
--- /dev/null
@@ -0,0 +1,76 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <title>My Python Project</title>
+  <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,400i,600,600i%7CSource+Code+Pro:400,400i,600" />
+  <link rel="stylesheet" href="m-dark+documentation.compiled.css" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+</head>
+<body>
+<header><nav id="navigation">
+  <div class="m-container">
+    <div class="m-row">
+      <a href="s.index.html#this-is-an-url" id="m-navbar-brand" class="m-col-t-8 m-col-m-none m-left-m">My Python Project</a>
+      <div class="m-col-t-4 m-hide-m m-text-right m-nopadr">
+        <a id="m-navbar-show" href="#navigation" title="Show navigation"></a>
+        <a id="m-navbar-hide" href="#" title="Hide navigation"></a>
+      </div>
+      <div id="m-navbar-collapse" class="m-col-t-12 m-show-m m-col-m-none m-right-m">
+        <div class="m-row">
+          <ol class="m-col-t-12 m-col-m-none">
+            <li><a href="s.pages.html#this-is-an-url">Pages</a></li>
+            <li><a href="s.modules.html#this-is-an-url">Modules</a></li>
+            <li><a href="s.classes.html#this-is-an-url" id="m-navbar-current">Classes</a></li>
+          </ol>
+          <ol class="m-col-t-6 m-col-m-none" start="4">
+            <li><a href="p.page.html#this-is-an-url">A page</a></li>
+            <li><a href="m.link_formatting.html#this-is-an-url">A module</a></li>
+            <li><a href="c.link_formatting.Class.html#this-is-an-url">The class</a></li>
+          </ol>
+        </div>
+      </div>
+    </div>
+  </div>
+</nav></header>
+<main><article>
+  <div class="m-container m-container-inflatable">
+    <div class="m-row">
+      <div class="m-col-l-10 m-push-l-1">
+        <h1>Classes</h2>
+        <ul class="m-doc">
+          <li class="m-doc-collapsible">
+            <a href="#" onclick="return toggle(this)">module</a> <a href="m.link_formatting.html#this-is-an-url" class="m-doc">link_formatting</a> <span class="m-doc">This is a module.</span>
+            <ul class="m-doc">
+              <li>module <a href="m.link_formatting.sub.html#this-is-an-url" class="m-doc">sub</a> <span class="m-doc">This is a nice submodule.</span></li>
+              <li class="m-doc-collapsible collapsed">
+                <a href="#" onclick="return toggle(this)">class</a> <a href="c.link_formatting.Class.html#this-is-an-url" class="m-doc">Class</a> <span class="m-doc">This is a nice class.</span>
+                <ul class="m-doc">
+                  <li>class <a href="c.link_formatting.Class.Sub.html#this-is-an-url" class="m-doc">Sub</a> <span class="m-doc">And a nice subclass, oh.</span></li>
+                </ul>
+              </li>
+            </ul>
+          </li>
+        </ul>
+        <script>
+        function toggle(e) {
+            e.parentElement.className = e.parentElement.className == 'm-doc-collapsible' ?
+                'm-doc-expansible' : 'm-doc-collapsible';
+            return false;
+        }
+        /* Collapse all nodes marked as such. Doing it via JS instead of
+           directly in markup so disabling it doesn't harm usability. The list
+           is somehow regenerated on every iteration and shrinks as I change
+           the classes. It's not documented anywhere and I'm not sure if this
+           is the same across browsers, so I am going backwards in that list to
+           be sure. */
+        var collapsed = document.getElementsByClassName("collapsed");
+        for(var i = collapsed.length - 1; i >= 0; --i)
+            collapsed[i].className = 'm-doc-expansible';
+        </script>
+      </div>
+    </div>
+  </div>
+</article></main>
+</body>
+</html>
diff --git a/documentation/test_python/link_formatting/s.modules.html b/documentation/test_python/link_formatting/s.modules.html
new file mode 100644 (file)
index 0000000..731ae5d
--- /dev/null
@@ -0,0 +1,70 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <title>My Python Project</title>
+  <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,400i,600,600i%7CSource+Code+Pro:400,400i,600" />
+  <link rel="stylesheet" href="m-dark+documentation.compiled.css" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+</head>
+<body>
+<header><nav id="navigation">
+  <div class="m-container">
+    <div class="m-row">
+      <a href="s.index.html#this-is-an-url" id="m-navbar-brand" class="m-col-t-8 m-col-m-none m-left-m">My Python Project</a>
+      <div class="m-col-t-4 m-hide-m m-text-right m-nopadr">
+        <a id="m-navbar-show" href="#navigation" title="Show navigation"></a>
+        <a id="m-navbar-hide" href="#" title="Hide navigation"></a>
+      </div>
+      <div id="m-navbar-collapse" class="m-col-t-12 m-show-m m-col-m-none m-right-m">
+        <div class="m-row">
+          <ol class="m-col-t-12 m-col-m-none">
+            <li><a href="s.pages.html#this-is-an-url">Pages</a></li>
+            <li><a href="s.modules.html#this-is-an-url" id="m-navbar-current">Modules</a></li>
+            <li><a href="s.classes.html#this-is-an-url">Classes</a></li>
+          </ol>
+          <ol class="m-col-t-6 m-col-m-none" start="4">
+            <li><a href="p.page.html#this-is-an-url">A page</a></li>
+            <li><a href="m.link_formatting.html#this-is-an-url">A module</a></li>
+            <li><a href="c.link_formatting.Class.html#this-is-an-url">The class</a></li>
+          </ol>
+        </div>
+      </div>
+    </div>
+  </div>
+</nav></header>
+<main><article>
+  <div class="m-container m-container-inflatable">
+    <div class="m-row">
+      <div class="m-col-l-10 m-push-l-1">
+        <h1>Modules</h2>
+        <ul class="m-doc">
+          <li class="m-doc-collapsible">
+            <a href="#" onclick="return toggle(this)">module</a> <a href="m.link_formatting.html#this-is-an-url" class="m-doc">link_formatting</a> <span class="m-doc">This is a module.</span>
+            <ul class="m-doc">
+              <li>module <a href="m.link_formatting.sub.html#this-is-an-url" class="m-doc">sub</a> <span class="m-doc">This is a nice submodule.</span></li>
+            </ul>
+          </li>
+        </ul>
+        <script>
+        function toggle(e) {
+            e.parentElement.className = e.parentElement.className == 'm-doc-collapsible' ?
+                'm-doc-expansible' : 'm-doc-collapsible';
+            return false;
+        }
+        /* Collapse all nodes marked as such. Doing it via JS instead of
+           directly in markup so disabling it doesn't harm usability. The list
+           is somehow regenerated on every iteration and shrinks as I change
+           the classes. It's not documented anywhere and I'm not sure if this
+           is the same across browsers, so I am going backwards in that list to
+           be sure. */
+        var collapsed = document.getElementsByClassName("collapsed");
+        for(var i = collapsed.length - 1; i >= 0; --i)
+            collapsed[i].className = 'm-doc-expansible';
+        </script>
+      </div>
+    </div>
+  </div>
+</article></main>
+</body>
+</html>
diff --git a/documentation/test_python/link_formatting/s.pages.html b/documentation/test_python/link_formatting/s.pages.html
new file mode 100644 (file)
index 0000000..408243f
--- /dev/null
@@ -0,0 +1,65 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8" />
+  <title>My Python Project</title>
+  <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,400i,600,600i%7CSource+Code+Pro:400,400i,600" />
+  <link rel="stylesheet" href="m-dark+documentation.compiled.css" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+</head>
+<body>
+<header><nav id="navigation">
+  <div class="m-container">
+    <div class="m-row">
+      <a href="s.index.html#this-is-an-url" id="m-navbar-brand" class="m-col-t-8 m-col-m-none m-left-m">My Python Project</a>
+      <div class="m-col-t-4 m-hide-m m-text-right m-nopadr">
+        <a id="m-navbar-show" href="#navigation" title="Show navigation"></a>
+        <a id="m-navbar-hide" href="#" title="Hide navigation"></a>
+      </div>
+      <div id="m-navbar-collapse" class="m-col-t-12 m-show-m m-col-m-none m-right-m">
+        <div class="m-row">
+          <ol class="m-col-t-12 m-col-m-none">
+            <li><a href="s.pages.html#this-is-an-url" id="m-navbar-current">Pages</a></li>
+            <li><a href="s.modules.html#this-is-an-url">Modules</a></li>
+            <li><a href="s.classes.html#this-is-an-url">Classes</a></li>
+          </ol>
+          <ol class="m-col-t-6 m-col-m-none" start="4">
+            <li><a href="p.page.html#this-is-an-url">A page</a></li>
+            <li><a href="m.link_formatting.html#this-is-an-url">A module</a></li>
+            <li><a href="c.link_formatting.Class.html#this-is-an-url">The class</a></li>
+          </ol>
+        </div>
+      </div>
+    </div>
+  </div>
+</nav></header>
+<main><article>
+  <div class="m-container m-container-inflatable">
+    <div class="m-row">
+      <div class="m-col-l-10 m-push-l-1">
+        <h1>Pages</h2>
+        <ul class="m-doc">
+          <li><a href="p.page.html#this-is-an-url" class="m-doc">This is a page</a> <span class="m-doc"></span></li>
+        </ul>
+        <script>
+        function toggle(e) {
+            e.parentElement.className = e.parentElement.className == 'm-doc-collapsible' ?
+                'm-doc-expansible' : 'm-doc-collapsible';
+            return false;
+        }
+        /* Collapse all nodes marked as such. Doing it via JS instead of
+           directly in markup so disabling it doesn't harm usability. The list
+           is somehow regenerated on every iteration and shrinks as I change
+           the classes. It's not documented anywhere and I'm not sure if this
+           is the same across browsers, so I am going backwards in that list to
+           be sure. */
+        var collapsed = document.getElementsByClassName("collapsed");
+        for(var i = collapsed.length - 1; i >= 0; --i)
+            collapsed[i].className = 'm-doc-expansible';
+        </script>
+      </div>
+    </div>
+  </div>
+</article></main>
+</body>
+</html>
index e74c9508b4d4e5c1a644251963a8c44f079466ef..0dedeb4f8b90724da6800bec89f14152ead3baa0 100644 (file)
@@ -37,8 +37,9 @@ class Layout(BaseTestCase):
 
             'THEME_COLOR': '#00ffff',
             'FAVICON': 'favicon-light.png',
-            'PAGE_HEADER': "`A self link <{filename}>`_",
+            'PAGE_HEADER': "`A self link <{url}>`_",
             'FINE_PRINT': "This beautiful thing is done thanks to\n`m.css <https://mcss.mosra.cz>`_.",
+            'INPUT_PAGES': ['getting-started.rst', 'troubleshooting.rst', 'about.rst'],
             'LINKS_NAVBAR1': [
                 ('Pages', 'pages', [
                     ('Getting started', 'getting-started'),
diff --git a/documentation/test_python/test_link_formatting.py b/documentation/test_python/test_link_formatting.py
new file mode 100644 (file)
index 0000000..093b631
--- /dev/null
@@ -0,0 +1,77 @@
+#
+#   This file is part of m.css.
+#
+#   Copyright © 2017, 2018, 2019 Vladimír Vondruš <mosra@centrum.cz>
+#
+#   Permission is hereby granted, free of charge, to any person obtaining a
+#   copy of this software and associated documentation files (the "Software"),
+#   to deal in the Software without restriction, including without limitation
+#   the rights to use, copy, modify, merge, publish, distribute, sublicense,
+#   and/or sell copies of the Software, and to permit persons to whom the
+#   Software is furnished to do so, subject to the following conditions:
+#
+#   The above copyright notice and this permission notice shall be included
+#   in all copies or substantial portions of the Software.
+#
+#   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+#   IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+#   FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
+#   THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+#   LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+#   FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+#   DEALINGS IN THE SOFTWARE.
+#
+
+import os
+import unittest
+
+from typing import List
+
+from . import BaseInspectTestCase
+from python import EntryType
+
+def custom_url_formatter(type: EntryType, path: List[str]) -> str:
+    if type == EntryType.CLASS:
+        filename = 'c.' + '.'.join(path) + '.html'
+    elif type == EntryType.MODULE:
+        filename = 'm.' + '.'.join(path) + '.html'
+    elif type == EntryType.PAGE:
+        filename = 'p.' + '.'.join(path) + '.html'
+    elif type == EntryType.SPECIAL:
+        filename = 's.' + '.'.join(path) + '.html'
+    else: assert False
+
+    return filename, filename + "#this-is-an-url"
+
+class Test(BaseInspectTestCase):
+    def __init__(self, *args, **kwargs):
+        super().__init__(__file__, '', *args, **kwargs)
+
+    def test(self):
+        self.run_python({
+            'INPUT_PAGES': ['page.rst'],
+            'URL_FORMATTER': custom_url_formatter,
+            'LINKS_NAVBAR1': [
+                ('Pages', 'pages', []),
+                ('Modules', 'modules', []),
+                ('Classes', 'classes', [])],
+            'LINKS_NAVBAR2': [('A page', 'page', []),
+                              ('A module', 'link_formatting', []),
+                              ('The class', ['link_formatting', 'Class'], [])]
+        })
+        self.assertEqual(*self.actual_expected_contents('m.link_formatting.html'))
+        self.assertEqual(*self.actual_expected_contents('m.link_formatting.sub.html'))
+        self.assertEqual(*self.actual_expected_contents('c.link_formatting.Class.html'))
+        self.assertEqual(*self.actual_expected_contents('c.link_formatting.Class.Sub.html'))
+
+        # There's nothing inside p.page.html that wouldn't be already covered
+        # by others
+        self.assertTrue(os.path.exists(os.path.join(self.path, 'output/p.page.html')))
+
+        self.assertEqual(*self.actual_expected_contents('s.classes.html'))
+        self.assertEqual(*self.actual_expected_contents('s.modules.html'))
+        self.assertEqual(*self.actual_expected_contents('s.pages.html'))
+
+        # There's nothing inside s.index.html that wouldn't be already covered
+        # by others
+        self.assertTrue(os.path.exists(os.path.join(self.path, 'output/s.index.html')))