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='![image](image.png)', files=['index.md', 'image.png'] - ), - 'image', # no opening ./ - ) + for use_directory_urls in True, False: + self.assertEqual( + self.get_rendered_result( + use_directory_urls=use_directory_urls, + content='![image](image.png)', + files=['index.md', 'image.png'], + ), + 'image', # no opening ./ + ) def test_relative_image_link_from_subpage(self): self.assertEqual( @@ -850,6 +922,14 @@ class RelativePathExtensionTests(unittest.TestCase): ), 'image', ) + self.assertEqual( + self.get_rendered_result( + use_directory_urls=False, + content='![image](image.png)', + files=['non-index.md', 'image.png'], + ), + 'image', + ) 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', + )