diff --git a/docs/user-guide/configuration.md b/docs/user-guide/configuration.md
index bfb025ea..b433e851 100644
--- a/docs/user-guide/configuration.md
+++ b/docs/user-guide/configuration.md
@@ -343,17 +343,85 @@ Example:
```yaml
nav:
- - Foo: foo.md
- - Bar: bar.md
+ - Foo: foo.md
+ - Bar: bar.md
not_in_nav: |
- /private.md
+ /private.md
```
As the previous option, this follows the .gitignore pattern format.
NOTE: Adding a given file to [`exclude_docs`](#exclude_docs) takes precedence over and implies `not_in_nav`.
+### validation
+
+NEW: **New in version 1.5.**
+
+Configure the strictness of MkDocs' diagnostic messages when validating links to documents.
+
+This is a tree of configs, and for each one the value can be one of the three: `warn`, `info`, `ignore`. Which cause a logging message of the corresponding severity to be produced. The `warn` level is, of course, intended for use with `mkdocs build --strict` (where it becomes an error), which you can employ in continuous testing.
+
+> EXAMPLE: **Defaults of this config as of MkDocs 1.5:**
+>
+> ```yaml
+> validation:
+> nav:
+> omitted_files: info
+> not_found: warn
+> absolute_links: info
+> links:
+> not_found: warn
+> absolute_links: info
+> unrecognized_links: info
+> ```
+>
+> (Note: you shouldn't copy this whole example, because it only duplicates the defaults. Only individual items that differ should be set.)
+
+The defaults of some of the behaviors already differ from MkDocs 1.4 and below - they were ignored before.
+
+>? EXAMPLE: **Configure MkDocs 1.5 to behave like MkDocs 1.4 and below (reduce strictness):**
+>
+> ```yaml
+> validation:
+> nav:
+> absolute_links: ignore
+> links:
+> absolute_links: ignore
+> unrecognized_links: ignore
+> ```
+
+>! EXAMPLE: **Recommended settings for most sites (maximal strictness):**
+>
+> ```yaml
+> validation:
+> nav:
+> omitted_files: warn
+> absolute_links: warn
+> links:
+> absolute_links: warn
+> unrecognized_links: warn
+> ```
+
+Full list of values and examples of log messages that they can hide or make more prominent:
+
+* `validation.nav.omitted_files`
+ * "The following pages exist in the docs directory, but are not included in the "nav" configuration: ..."
+* `validation.nav.not_found`
+ * "A relative path to 'foo/bar.md' is included in the 'nav' configuration, which is not found in the documentation files."
+ * "A reference to 'foo/bar.md' is included in the 'nav' configuration, but this file is excluded from the built site."
+* `validation.nav.absolute_links`
+ * "An absolute path to '/foo/bar.html' is included in the 'nav' configuration, which presumably points to an external resource."
+
+* `validation.links.not_found`
+ * "Doc file 'example.md' contains a relative link '../foo/bar.md', but the target is not found among documentation files."
+ * "Doc file 'example.md' contains a link to 'foo/bar.md' which is excluded from the built site."
+* `validation.links.absolute_links`
+ * "Doc file 'example.md' contains an absolute link '/foo/bar.html', it was left as is. Did you mean 'foo/bar.md'?"
+* `validation.links.unrecognized_links`
+ * "Doc file 'example.md' contains an unrecognized relative link '../foo/bar/', it was left as is. Did you mean 'foo/bar.md'?"
+ * "Doc file 'example.md' contains an unrecognized relative link 'mail\@example.com', it was left as is. Did you mean 'mailto:mail\@example.com'?"
+
## Build directories
### theme
diff --git a/mkdocs/config/config_options.py b/mkdocs/config/config_options.py
index 05994b03..9acda32e 100644
--- a/mkdocs/config/config_options.py
+++ b/mkdocs/config/config_options.py
@@ -2,6 +2,7 @@ from __future__ import annotations
import functools
import ipaddress
+import logging
import os
import string
import sys
@@ -1170,3 +1171,19 @@ class PathSpec(BaseConfigOption[pathspec.gitignore.GitIgnoreSpec]):
return pathspec.gitignore.GitIgnoreSpec.from_lines(lines=value.splitlines())
except ValueError as e:
raise ValidationError(str(e))
+
+
+class _LogLevel(OptionallyRequired[int]):
+ levels: Mapping[str, int] = {
+ "warn": logging.WARNING,
+ "info": logging.INFO,
+ "ignore": logging.DEBUG,
+ }
+
+ def run_validation(self, value: object) -> int:
+ if not isinstance(value, str):
+ raise ValidationError(f'Expected a string, but a {type(value)} was given.')
+ try:
+ return self.levels[value]
+ except KeyError:
+ raise ValidationError(f'Expected one of {list(self.levels)}, got {value!r}')
diff --git a/mkdocs/config/defaults.py b/mkdocs/config/defaults.py
index cf6e630c..c8152956 100644
--- a/mkdocs/config/defaults.py
+++ b/mkdocs/config/defaults.py
@@ -28,7 +28,11 @@ class MkDocsConfig(base.Config):
"""Gitignore-like patterns of files (relative to docs dir) to exclude from the site."""
not_in_nav = c.Optional(c.PathSpec())
- """Gitignore-like patterns of files (relative to docs dir) that are not intended to be in the nav."""
+ """Gitignore-like patterns of files (relative to docs dir) that are not intended to be in the nav.
+
+ This marks doc files that are expected not to be in the nav, otherwise they will cause a log message
+ (see also `validation.nav.omitted_files`).
+ """
site_url = c.Optional(c.URL(is_dir=True))
"""The full URL to where the documentation will be hosted."""
@@ -135,6 +139,35 @@ class MkDocsConfig(base.Config):
watch = c.ListOfPaths(default=[])
"""A list of extra paths to watch while running `mkdocs serve`."""
+ class Validation(base.Config):
+ class NavValidation(base.Config):
+ omitted_files = c._LogLevel(default='info')
+ """Warning level for when a doc file is never mentioned in the navigation.
+ For granular configuration, see `not_in_nav`."""
+
+ not_found = c._LogLevel(default='warn')
+ """Warning level for when the navigation links to a relative path that isn't an existing page on the site."""
+
+ absolute_links = c._LogLevel(default='info')
+ """Warning level for when the navigation links to an absolute path (starting with `/`)."""
+
+ nav = c.SubConfig(NavValidation)
+
+ class LinksValidation(base.Config):
+ not_found = c._LogLevel(default='warn')
+ """Warning level for when a Markdown doc links to a relative path that isn't an existing document on the site."""
+
+ absolute_links = c._LogLevel(default='info')
+ """Warning level for when a Markdown doc links to an absolute path (starting with `/`)."""
+
+ unrecognized_links = c._LogLevel(default='info')
+ """Warning level for when a Markdown doc links to a relative path that doesn't look like
+ it could be a valid internal link. For example, if the link ends with `/`."""
+
+ links = c.SubConfig(LinksValidation)
+
+ validation = c.SubConfig(Validation)
+
_current_page: mkdocs.structure.pages.Page | None = None
"""The currently rendered page. Please do not access this and instead
rely on the `page` argument to event handlers."""
diff --git a/mkdocs/structure/nav.py b/mkdocs/structure/nav.py
index 18937cf0..52eacbe6 100644
--- a/mkdocs/structure/nav.py
+++ b/mkdocs/structure/nav.py
@@ -148,9 +148,10 @@ def get_navigation(files: Files, config: MkDocsConfig) -> Navigation:
if file.inclusion.is_in_nav():
missing_from_config.append(file.src_path)
if missing_from_config:
- log.info(
+ log.log(
+ config.validation.nav.omitted_files,
'The following pages exist in the docs directory, but are not '
- 'included in the "nav" configuration:\n - ' + '\n - '.join(missing_from_config)
+ 'included in the "nav" configuration:\n - ' + '\n - '.join(missing_from_config),
)
links = _get_by_type(items, Link)
@@ -159,14 +160,16 @@ def get_navigation(files: Files, config: MkDocsConfig) -> Navigation:
if scheme or netloc:
log.debug(f"An external link to '{link.url}' is included in the 'nav' configuration.")
elif link.url.startswith('/'):
- log.debug(
+ log.log(
+ config.validation.nav.absolute_links,
f"An absolute path to '{link.url}' is included in the 'nav' "
- "configuration, which presumably points to an external resource."
+ "configuration, which presumably points to an external resource.",
)
else:
- log.warning(
+ log.log(
+ config.validation.nav.not_found,
f"A relative path to '{link.url}' is included in the 'nav' "
- "configuration, which is not found in the documentation files."
+ "configuration, which is not found in the documentation files.",
)
return Navigation(items, pages)
@@ -190,9 +193,10 @@ def _data_to_navigation(data, files: Files, config: MkDocsConfig):
file = files.get_file_from_path(path)
if file:
if file.inclusion.is_excluded():
- log.info(
- f"A relative path to '{file.src_path}' is included in the 'nav' "
- "configuration, but this file is excluded from the built site."
+ log.log(
+ min(logging.INFO, config.validation.nav.not_found),
+ f"A reference to '{file.src_path}' is included in the 'nav' "
+ "configuration, but this file is excluded from the built site.",
)
return Page(title, file, config)
return Link(title, path)
diff --git a/mkdocs/structure/pages.py b/mkdocs/structure/pages.py
index 4ee36aca..43e2b482 100644
--- a/mkdocs/structure/pages.py
+++ b/mkdocs/structure/pages.py
@@ -2,7 +2,6 @@ from __future__ import annotations
import copy
import logging
-import os
import posixpath
import warnings
from typing import TYPE_CHECKING, Any, Callable, MutableMapping
@@ -268,7 +267,7 @@ class Page(StructureItem):
extension_configs=config['mdx_configs'] or {},
)
- relative_path_ext = _RelativePathTreeprocessor(self.file, files)
+ relative_path_ext = _RelativePathTreeprocessor(self.file, files, config)
relative_path_ext._register(md)
extract_title_ext = _ExtractTitleTreeprocessor()
@@ -280,9 +279,10 @@ class Page(StructureItem):
class _RelativePathTreeprocessor(markdown.treeprocessors.Treeprocessor):
- def __init__(self, file: File, files: Files) -> None:
+ def __init__(self, file: File, files: Files, config: MkDocsConfig) -> None:
self.file = file
self.files = files
+ self.config = config
def run(self, root: etree.Element) -> etree.Element:
"""
@@ -309,18 +309,18 @@ class _RelativePathTreeprocessor(markdown.treeprocessors.Treeprocessor):
def path_to_url(self, url: str) -> str:
scheme, netloc, path, query, fragment = urlsplit(url)
- if (
- scheme
- or netloc
- or not path
- or url.startswith('/')
- or url.startswith('\\')
- or AMP_SUBSTITUTE in url
- or '.' not in os.path.split(path)[-1]
- ):
- # Ignore URLs unless they are a relative link to a source file.
- # AMP_SUBSTITUTE is used internally by Markdown only for email.
- # No '.' in the last part of a path indicates path does not point to a file.
+ # Ignore URLs unless they are a relative link to a source file.
+ if scheme or netloc: # External link.
+ return url
+ elif url.startswith('/') or url.startswith('\\'): # Absolute link.
+ log.log(
+ self.config.validation.links.absolute_links,
+ f"Doc file '{self.file.src_uri}' contains an absolute link '{url}', it was left as is.",
+ )
+ return url
+ elif AMP_SUBSTITUTE in url: # AMP_SUBSTITUTE is used internally by Markdown only for email.
+ return url
+ elif not path: # Self-link containing only query or fragment.
return url
# Determine the filepath of the target.
@@ -330,19 +330,29 @@ class _RelativePathTreeprocessor(markdown.treeprocessors.Treeprocessor):
# Validate that the target exists in files collection.
target_file = self.files.get_file_from_path(target_uri)
if target_file is None:
- log.warning(
- f"Documentation file '{self.file.src_uri}' contains a link to "
- f"'{target_uri}' which is not found in the documentation files."
- )
+ if '.' not in posixpath.split(path)[-1]:
+ # No '.' in the last part of a path indicates path does not point to a file.
+ log.log(
+ self.config.validation.links.unrecognized_links,
+ f"Doc file '{self.file.src_uri}' contains an unrecognized relative link '{url}', "
+ f"it was left as is.",
+ )
+ else:
+ target = f" '{target_uri}'" if target_uri != url else ""
+ log.log(
+ self.config.validation.links.not_found,
+ f"Doc file '{self.file.src_uri}' contains a relative link '{url}', "
+ f"but the target{target} is not found among documentation files.",
+ )
return url
if target_file.inclusion.is_excluded():
- log.info(
- f"Documentation file '{self.file.src_uri}' contains a link to "
- f"'{target_uri}' which is excluded from the built site."
+ log.log(
+ min(logging.INFO, self.config.validation.links.not_found),
+ f"Doc file '{self.file.src_uri}' contains a link to "
+ f"'{target_uri}' which is excluded from the built site.",
)
path = target_file.url_relative_to(self.file)
- components = (scheme, netloc, path, query, fragment)
- return urlunsplit(components)
+ return urlunsplit(('', '', path, query, fragment))
def _register(self, md: markdown.Markdown) -> None:
md.treeprocessors.register(self, "relpath", 0)
diff --git a/mkdocs/tests/build_tests.py b/mkdocs/tests/build_tests.py
index 8d0eb0fa..3ea64d42 100644
--- a/mkdocs/tests/build_tests.py
+++ b/mkdocs/tests/build_tests.py
@@ -599,7 +599,7 @@ class BuildTests(PathAssertionMixin, unittest.TestCase):
with self.subTest(live_server=None):
expected_logs = '''
- INFO:Documentation file 'test/foo.md' contains a link to 'test/bar.md' which is excluded from the built site.
+ INFO:Doc file 'test/foo.md' contains a link to 'test/bar.md' which is excluded from the built site.
'''
with self._assert_build_logs(expected_logs):
build.build(cfg)
@@ -610,8 +610,8 @@ class BuildTests(PathAssertionMixin, unittest.TestCase):
server = testing_server(site_dir, mount_path='/documentation/')
with self.subTest(live_server=server):
expected_logs = '''
- INFO:Documentation file 'test/bar.md' contains a link to 'test/baz.md' which is excluded from the built site.
- INFO:Documentation file 'test/foo.md' contains a link to 'test/bar.md' which is excluded from the built site.
+ INFO:Doc file 'test/bar.md' contains a link to 'test/baz.md' which is excluded from the built site.
+ INFO:Doc file 'test/foo.md' contains a link to 'test/bar.md' which is excluded from the built site.
INFO:The following pages are being built only for the preview but will be excluded from `mkdocs build` per `exclude_docs`:
- http://localhost:123/documentation/.zoo.html
- http://localhost:123/documentation/test/bar.html
diff --git a/mkdocs/tests/structure/nav_tests.py b/mkdocs/tests/structure/nav_tests.py
index 1ef971f6..3e4aba5f 100644
--- a/mkdocs/tests/structure/nav_tests.py
+++ b/mkdocs/tests/structure/nav_tests.py
@@ -123,7 +123,7 @@ class SiteNavigationTests(unittest.TestCase):
self.assertEqual(
cm.output,
[
- "DEBUG:mkdocs.structure.nav:An absolute path to '/local.html' is included in the 'nav' configuration, which presumably points to an external resource.",
+ "INFO:mkdocs.structure.nav:An absolute path to '/local.html' is included in the 'nav' configuration, which presumably points to an external resource.",
"DEBUG:mkdocs.structure.nav:An external link to 'http://example.com/external.html' is included in the 'nav' configuration.",
],
)
diff --git a/mkdocs/tests/structure/page_tests.py b/mkdocs/tests/structure/page_tests.py
index 7d5160f3..e4dab0bd 100644
--- a/mkdocs/tests/structure/page_tests.py
+++ b/mkdocs/tests/structure/page_tests.py
@@ -1,6 +1,5 @@
from __future__ import annotations
-import functools
import os
import sys
import textwrap
@@ -10,11 +9,26 @@ from unittest import mock
from mkdocs.config.defaults import MkDocsConfig
from mkdocs.structure.files import File, Files
from mkdocs.structure.pages import Page
-from mkdocs.tests.base import dedent, load_config, tempdir
+from mkdocs.tests.base import dedent, tempdir
-load_config = functools.lru_cache(maxsize=None)(load_config)
+DOCS_DIR = os.path.join(
+ os.path.abspath(os.path.dirname(__file__)), '..', 'integration', 'subpages', 'docs'
+)
-DOCS_DIR = os.path.join(os.path.abspath(os.path.dirname(__file__)), '../integration/subpages/docs')
+
+def load_config(**cfg) -> MkDocsConfig:
+ cfg.setdefault('site_name', 'Example')
+ cfg.setdefault(
+ 'docs_dir',
+ os.path.join(
+ os.path.abspath(os.path.dirname(__file__)), '..', 'integration', 'minimal', 'docs'
+ ),
+ )
+ conf = MkDocsConfig()
+ conf.load_dict(cfg)
+ errors_warnings = conf.validate()
+ assert errors_warnings == ([], []), errors_warnings
+ return conf
class PageTests(unittest.TestCase):
@@ -748,6 +762,14 @@ class RelativePathExtensionTests(unittest.TestCase):
),
'link',
)
+ self.assertEqual(
+ self.get_rendered_result(
+ use_directory_urls=False,
+ content='[link](non-index.md)',
+ files=['index.md', 'non-index.md'],
+ ),
+ 'link',
+ )
def test_relative_html_link_index(self):
self.assertEqual(
@@ -756,6 +778,14 @@ class RelativePathExtensionTests(unittest.TestCase):
),
'link',
)
+ self.assertEqual(
+ self.get_rendered_result(
+ use_directory_urls=False,
+ content='[link](index.md)',
+ files=['non-index.md', 'index.md'],
+ ),
+ 'link',
+ )
def test_relative_html_link_sub_index(self):
self.assertEqual(
@@ -764,6 +794,14 @@ class RelativePathExtensionTests(unittest.TestCase):
),
'link',
)
+ self.assertEqual(
+ self.get_rendered_result(
+ use_directory_urls=False,
+ content='[link](sub2/index.md)',
+ files=['index.md', 'sub2/index.md'],
+ ),
+ 'link',
+ )
def test_relative_html_link_sub_page(self):
self.assertEqual(
@@ -772,6 +810,14 @@ class RelativePathExtensionTests(unittest.TestCase):
),
'link',
)
+ self.assertEqual(
+ self.get_rendered_result(
+ use_directory_urls=False,
+ content='[link](sub2/non-index.md)',
+ files=['index.md', 'sub2/non-index.md'],
+ ),
+ 'link',
+ )
def test_relative_html_link_with_encoded_space(self):
self.assertEqual(
@@ -784,9 +830,11 @@ class RelativePathExtensionTests(unittest.TestCase):
def test_relative_html_link_with_unencoded_space(self):
self.assertEqual(
self.get_rendered_result(
- content='[link](file name.md)', files=['index.md', 'file name.md']
+ use_directory_urls=False,
+ content='[link](file name.md)',
+ files=['index.md', 'file name.md'],
),
- 'link',
+ 'link',
)
def test_relative_html_link_parent_index(self):
@@ -796,6 +844,14 @@ class RelativePathExtensionTests(unittest.TestCase):
),
'link',
)
+ self.assertEqual(
+ self.get_rendered_result(
+ use_directory_urls=False,
+ content='[link](../index.md)',
+ files=['sub2/non-index.md', 'index.md'],
+ ),
+ 'link',
+ )
def test_relative_html_link_hash(self):
self.assertEqual(
@@ -812,6 +868,14 @@ class RelativePathExtensionTests(unittest.TestCase):
),
'link',
)
+ self.assertEqual(
+ self.get_rendered_result(
+ use_directory_urls=False,
+ content='[link](sub2/index.md#hash)',
+ files=['index.md', 'sub2/index.md'],
+ ),
+ 'link',
+ )
def test_relative_html_link_sub_page_hash(self):
self.assertEqual(
@@ -822,18 +886,26 @@ class RelativePathExtensionTests(unittest.TestCase):
)
def test_relative_html_link_hash_only(self):
- self.assertEqual(
- self.get_rendered_result(content='[link](#hash)', files=['index.md']),
- 'link',
- )
+ for use_directory_urls in True, False:
+ self.assertEqual(
+ self.get_rendered_result(
+ use_directory_urls=use_directory_urls,
+ content='[link](#hash)',
+ files=['index.md'],
+ ),
+ 'link',
+ )
def test_relative_image_link_from_homepage(self):
- self.assertEqual(
- self.get_rendered_result(
- content='', files=['index.md', 'image.png']
- ),
- '
', # no opening ./
- )
+ for use_directory_urls in True, False:
+ self.assertEqual(
+ self.get_rendered_result(
+ use_directory_urls=use_directory_urls,
+ content='',
+ files=['index.md', 'image.png'],
+ ),
+ '
', # no opening ./
+ )
def test_relative_image_link_from_subpage(self):
self.assertEqual(
@@ -850,6 +922,14 @@ class RelativePathExtensionTests(unittest.TestCase):
),
'
',
)
+ self.assertEqual(
+ self.get_rendered_result(
+ use_directory_urls=False,
+ content='',
+ files=['non-index.md', 'image.png'],
+ ),
+ '
',
+ )
def test_no_links(self):
self.assertEqual(
@@ -862,10 +942,19 @@ class RelativePathExtensionTests(unittest.TestCase):
self.get_rendered_result(
content='[link](non-existent.md)',
files=['index.md'],
- logs="WARNING:Documentation file 'index.md' contains a link to 'non-existent.md' which is not found in the documentation files.",
+ logs="WARNING:Doc file 'index.md' contains a relative link 'non-existent.md', but the target is not found among documentation files.",
),
'link',
)
+ self.assertEqual(
+ self.get_rendered_result(
+ validation=dict(links=dict(not_found='info')),
+ content='[link](../non-existent.md)',
+ files=['sub/index.md'],
+ logs="INFO:Doc file 'sub/index.md' contains a relative link '../non-existent.md', but the target 'non-existent.md' is not found among documentation files.",
+ ),
+ 'link',
+ )
def test_external_link(self):
self.assertEqual(
@@ -878,18 +967,33 @@ class RelativePathExtensionTests(unittest.TestCase):
def test_absolute_link(self):
self.assertEqual(
self.get_rendered_result(
- content='[absolute link](/path/to/file.md)', files=['index.md']
+ content='[absolute link](/path/to/file.md)',
+ files=['index.md'],
+ logs="INFO:Doc file 'index.md' contains an absolute link '/path/to/file.md', it was left as is.",
+ ),
+ 'absolute link',
+ )
+ self.assertEqual(
+ self.get_rendered_result(
+ validation=dict(links=dict(absolute_links='warn')),
+ content='[absolute link](/path/to/file.md)',
+ files=['index.md'],
+ logs="WARNING:Doc file 'index.md' contains an absolute link '/path/to/file.md', it was left as is.",
),
'absolute link',
)
def test_absolute_win_local_path(self):
- self.assertEqual(
- self.get_rendered_result(
- content='[absolute local path](\\image.png)', files=['index.md']
- ),
- 'absolute local path',
- )
+ for use_directory_urls in True, False:
+ self.assertEqual(
+ self.get_rendered_result(
+ use_directory_urls=use_directory_urls,
+ content='[absolute local path](\\image.png)',
+ files=['index.md'],
+ logs="INFO:Doc file 'index.md' contains an absolute link '\\image.png', it was left as is.",
+ ),
+ 'absolute local path',
+ )
def test_email_link(self):
self.assertEqual(
@@ -900,3 +1004,13 @@ class RelativePathExtensionTests(unittest.TestCase):
'xample.com">mail@'
'example.com',
)
+
+ def test_invalid_email_link(self):
+ self.assertEqual(
+ self.get_rendered_result(
+ content='[contact](mail@example.com)',
+ files=['index.md'],
+ logs="WARNING:Doc file 'index.md' contains a relative link 'mail@example.com', but the target is not found among documentation files.",
+ ),
+ 'contact',
+ )