diff --git a/docs/user-guide/configuration.md b/docs/user-guide/configuration.md index e5c58222..9c6eff4e 100644 --- a/docs/user-guide/configuration.md +++ b/docs/user-guide/configuration.md @@ -354,6 +354,70 @@ 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: +> absolute_links: ignore +> unrecognized_links: ignore +> ``` + +>! EXAMPLE: **Recommended settings for most sites (maximal strictness):** +> +> ```yaml +> validation: +> omitted_files: warn +> absolute_links: warn +> unrecognized_links: warn +> ``` + +Note how in the above examples we omitted the 'nav' and 'links' keys. Here `absolute_links:` means setting both `nav: absolute_links:` and `links: absolute_links:`. + +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..09be435f 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 @@ -118,6 +119,28 @@ class SubConfig(Generic[SomeConfig], BaseConfigOption[SomeConfig]): return config +class PropagatingSubConfig(SubConfig[SomeConfig], Generic[SomeConfig]): + """A SubConfig that must consist of SubConfigs with defined schemas. + + Any value set on the top config gets moved to sub-configs with matching keys. + """ + + def run_validation(self, value: object): + if isinstance(value, dict): + to_discard = set() + for k1, v1 in self.config_class._schema: + if isinstance(v1, SubConfig): + for k2, _ in v1.config_class._schema: + if k2 in value: + subdict = value.setdefault(k1, {}) + if isinstance(subdict, dict): + to_discard.add(k2) + subdict.setdefault(k2, value[k2]) + for k in to_discard: + del value[k] + return super().run_validation(value) + + class OptionallyRequired(Generic[T], BaseConfigOption[T]): """ Soft-deprecated, do not use. @@ -1170,3 +1193,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 1a76f1fa..3f94eb54 100644 --- a/mkdocs/config/defaults.py +++ b/mkdocs/config/defaults.py @@ -8,10 +8,6 @@ from mkdocs.config import config_options as c from mkdocs.utils.yaml import get_yaml_loader, yaml_load -def get_schema() -> base.PlainConfigSchema: - return MkDocsConfig._schema - - # NOTE: The order here is important. During validation some config options # depend on others. So, if config option A depends on B, then A should be # listed higher in the schema. @@ -32,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.""" @@ -139,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.PropagatingSubConfig[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.""" @@ -152,3 +181,8 @@ class MkDocsConfig(base.Config): """Load config options from the open file descriptor of a YAML file.""" loader = get_yaml_loader(config=self) self.load_dict(yaml_load(config_file, loader)) + + +def get_schema() -> base.PlainConfigSchema: + """Soft-deprecated, do not use.""" + return MkDocsConfig._schema 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 5f2676af..7b84e1fc 100644 --- a/mkdocs/structure/pages.py +++ b/mkdocs/structure/pages.py @@ -2,10 +2,9 @@ from __future__ import annotations import copy import logging -import os import posixpath import warnings -from typing import TYPE_CHECKING, Any, Callable, MutableMapping +from typing import TYPE_CHECKING, Any, Callable, Iterator, MutableMapping from urllib.parse import unquote as urlunquote from urllib.parse import urljoin, urlsplit, urlunsplit @@ -15,9 +14,10 @@ import markdown.postprocessors import markdown.treeprocessors from markdown.util import AMP_SUBSTITUTE +from mkdocs import utils from mkdocs.structure import StructureItem from mkdocs.structure.toc import get_toc -from mkdocs.utils import get_build_date, get_markdown_title, meta, weak_property +from mkdocs.utils import _removesuffix, get_build_date, get_markdown_title, meta, weak_property if TYPE_CHECKING: from xml.etree import ElementTree as etree @@ -263,25 +263,27 @@ class Page(StructureItem): if self.markdown is None: raise RuntimeError("`markdown` field hasn't been set (via `read_source`)") - relative_path_extension = _RelativePathExtension(self.file, files) - extract_title_extension = _ExtractTitleExtension() md = markdown.Markdown( - extensions=[ - relative_path_extension, - extract_title_extension, - *config['markdown_extensions'], - ], + extensions=config['markdown_extensions'], extension_configs=config['mdx_configs'] or {}, ) + + relative_path_ext = _RelativePathTreeprocessor(self.file, files, config) + relative_path_ext._register(md) + + extract_title_ext = _ExtractTitleTreeprocessor() + extract_title_ext._register(md) + self.content = md.convert(self.markdown) self.toc = get_toc(getattr(md, 'toc_tokens', [])) - self._title_from_render = extract_title_extension.title + self._title_from_render = extract_title_ext.title 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: """ @@ -305,75 +307,136 @@ class _RelativePathTreeprocessor(markdown.treeprocessors.Treeprocessor): return root + @classmethod + def _target_uri(cls, src_path: str, dest_path: str): + return posixpath.normpath( + posixpath.join(posixpath.dirname(src_path), dest_path).lstrip('/') + ) + + @classmethod + def _possible_target_uris( + cls, file: File, path: str, use_directory_urls: bool + ) -> Iterator[str]: + """First yields the resolved file uri for the link, then proceeds to yield guesses for possible mistakes.""" + target_uri = cls._target_uri(file.src_uri, path) + yield target_uri + + if posixpath.normpath(path) == '.': + # Explicitly link to current file. + yield file.src_uri + return + tried = {target_uri} + + prefixes = [target_uri, cls._target_uri(file.url, path)] + if prefixes[0] == prefixes[1]: + prefixes.pop() + + suffixes: list[Callable[[str], str]] = [] + if use_directory_urls: + suffixes.append(lambda p: p) + if not posixpath.splitext(target_uri)[-1]: + suffixes.append(lambda p: posixpath.join(p, 'index.md')) + suffixes.append(lambda p: posixpath.join(p, 'README.md')) + if ( + not target_uri.endswith('.') + and not path.endswith('.md') + and (use_directory_urls or not path.endswith('/')) + ): + suffixes.append(lambda p: _removesuffix(p, '.html') + '.md') + + for pref in prefixes: + for suf in suffixes: + guess = posixpath.normpath(suf(pref)) + if guess not in tried and not guess.startswith('../'): + yield guess + tried.add(guess) + 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. + warning_level, warning = 0, '' + + # 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. + warning_level = self.config.validation.links.absolute_links + warning = f"Doc file '{self.file.src_uri}' contains an absolute link '{url}', it was left as is." + 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 + path = urlunquote(path) # Determine the filepath of the target. - target_uri = posixpath.join(posixpath.dirname(self.file.src_uri), urlunquote(path)) - target_uri = posixpath.normpath(target_uri).lstrip('/') + possible_target_uris = self._possible_target_uris( + self.file, path, self.config.use_directory_urls + ) - # 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 warning: + # For absolute path (already has a warning), the primary lookup path should be preserved as a tip option. + target_uri = url + target_file = None + else: + # Validate that the target exists in files collection. + target_uri = next(possible_target_uris) + target_file = self.files.get_file_from_path(target_uri) + + if target_file is None and not warning: + # Primary lookup path had no match, definitely produce a warning, just choose which one. + if not posixpath.splitext(path)[-1]: + # No '.' in the last part of a path indicates path does not point to a file. + warning_level = self.config.validation.links.unrecognized_links + warning = ( + 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 "" + warning_level = self.config.validation.links.not_found + warning = ( + f"Doc file '{self.file.src_uri}' contains a relative link '{url}', " + f"but the target{target} is not found among documentation files." + ) + + if warning: + # There was no match, so try to guess what other file could've been intended. + if warning_level > logging.DEBUG: + suggest_url = '' + for path in possible_target_uris: + if self.files.get_file_from_path(path) is not None: + if fragment and path == self.file.src_uri: + path = '' + else: + path = utils.get_relative_url(path, self.file.src_uri) + suggest_url = urlunsplit(('', '', path, query, fragment)) + break + else: + if '@' in url and '.' in url and '/' not in url: + suggest_url = f'mailto:{url}' + if suggest_url: + warning += f" Did you mean '{suggest_url}'?" + log.log(warning_level, warning) return url + + assert target_uri is not None + assert target_file is not None if target_file.inclusion.is_excluded(): - log.info( - f"Documentation file '{self.file.src_uri}' contains a link to " + warning_level = min(logging.INFO, self.config.validation.links.not_found) + warning = ( + 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) + log.log(warning_level, warning) + path = utils.get_relative_url(target_file.url, self.file.url) + return urlunsplit(('', '', path, query, fragment)) - -class _RelativePathExtension(markdown.extensions.Extension): - """ - The Extension class is what we pass to markdown, it then - registers the Treeprocessor. - """ - - def __init__(self, file: File, files: Files) -> None: - self.file = file - self.files = files - - def extendMarkdown(self, md: markdown.Markdown) -> None: - relpath = _RelativePathTreeprocessor(self.file, self.files) - md.treeprocessors.register(relpath, "relpath", 0) - - -class _ExtractTitleExtension(markdown.extensions.Extension): - def __init__(self) -> None: - self.title: str | None = None - - def extendMarkdown(self, md: markdown.Markdown) -> None: - md.treeprocessors.register( - _ExtractTitleTreeprocessor(self), - "mkdocs_extract_title", - priority=1, # Close to the end. - ) + def _register(self, md: markdown.Markdown) -> None: + md.treeprocessors.register(self, "relpath", 0) class _ExtractTitleTreeprocessor(markdown.treeprocessors.Treeprocessor): - def __init__(self, ext: _ExtractTitleExtension) -> None: - self.ext = ext + title: str | None = None def run(self, root: etree.Element) -> etree.Element: for el in root: @@ -383,6 +446,13 @@ class _ExtractTitleTreeprocessor(markdown.treeprocessors.Treeprocessor): el = copy.copy(el) del el[-1] # Extract the text only, recursively. - self.ext.title = _unescape(''.join(el.itertext())) + self.title = _unescape(''.join(el.itertext())) break return root + + def _register(self, md: markdown.Markdown) -> None: + md.treeprocessors.register( + self, + "mkdocs_extract_title", + priority=1, # Close to the end. + ) diff --git a/mkdocs/tests/build_tests.py b/mkdocs/tests/build_tests.py index f099e231..ff62a828 100644 --- a/mkdocs/tests/build_tests.py +++ b/mkdocs/tests/build_tests.py @@ -58,9 +58,7 @@ class BuildTests(PathAssertionMixin, unittest.TestCase): {'Home': 'index.md'}, ] cfg = load_config(nav=nav_cfg, use_directory_urls=False) - fs = [ - File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), - ] + fs = [File('index.md', cfg.docs_dir, cfg.site_dir, cfg.use_directory_urls)] files = Files(fs) nav = get_navigation(files, cfg) context = build.get_context(nav, files, cfg, nav.pages[0]) @@ -71,9 +69,7 @@ class BuildTests(PathAssertionMixin, unittest.TestCase): {'Home': 'index.md'}, ] cfg = load_config(nav=nav_cfg) - fs = [ - File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), - ] + fs = [File('index.md', cfg.docs_dir, cfg.site_dir, cfg.use_directory_urls)] files = Files(fs) nav = get_navigation(files, cfg) context = build.get_context(nav, files, cfg, nav.pages[0]) @@ -86,8 +82,8 @@ class BuildTests(PathAssertionMixin, unittest.TestCase): ] cfg = load_config(nav=nav_cfg, use_directory_urls=False) fs = [ - File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), - File('foo/bar.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), + File('index.md', cfg.docs_dir, cfg.site_dir, cfg.use_directory_urls), + File('foo/bar.md', cfg.docs_dir, cfg.site_dir, cfg.use_directory_urls), ] files = Files(fs) nav = get_navigation(files, cfg) @@ -101,8 +97,8 @@ class BuildTests(PathAssertionMixin, unittest.TestCase): ] cfg = load_config(nav=nav_cfg) fs = [ - File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), - File('foo/bar.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), + File('index.md', cfg.docs_dir, cfg.site_dir, cfg.use_directory_urls), + File('foo/bar.md', cfg.docs_dir, cfg.site_dir, cfg.use_directory_urls), ] files = Files(fs) nav = get_navigation(files, cfg) @@ -149,9 +145,7 @@ class BuildTests(PathAssertionMixin, unittest.TestCase): extra_javascript=['script.js'], use_directory_urls=False, ) - fs = [ - File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), - ] + fs = [File('index.md', cfg.docs_dir, cfg.site_dir, cfg.use_directory_urls)] files = Files(fs) nav = get_navigation(files, cfg) context = build.get_context(nav, files, cfg, nav.pages[0]) @@ -170,8 +164,8 @@ class BuildTests(PathAssertionMixin, unittest.TestCase): use_directory_urls=False, ) fs = [ - File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), - File('foo/bar.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), + File('index.md', cfg.docs_dir, cfg.site_dir, cfg.use_directory_urls), + File('foo/bar.md', cfg.docs_dir, cfg.site_dir, cfg.use_directory_urls), ] files = Files(fs) nav = get_navigation(files, cfg) @@ -190,8 +184,8 @@ class BuildTests(PathAssertionMixin, unittest.TestCase): extra_javascript=['script.js'], ) fs = [ - File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), - File('foo/bar.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), + File('index.md', cfg.docs_dir, cfg.site_dir, cfg.use_directory_urls), + File('foo/bar.md', cfg.docs_dir, cfg.site_dir, cfg.use_directory_urls), ] files = Files(fs) nav = get_navigation(files, cfg) @@ -210,9 +204,7 @@ class BuildTests(PathAssertionMixin, unittest.TestCase): extra_css=['assets\\style.css'], use_directory_urls=False, ) - fs = [ - File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), - ] + fs = [File('index.md', cfg.docs_dir, cfg.site_dir, cfg.use_directory_urls)] files = Files(fs) nav = get_navigation(files, cfg) with self.assertLogs('mkdocs') as cm: @@ -241,7 +233,7 @@ class BuildTests(PathAssertionMixin, unittest.TestCase): @mock.patch('mkdocs.commands.build._build_template', return_value='some content') def test_build_theme_template(self, mock_build_template, mock_write_file): cfg = load_config() - env = cfg['theme'].get_env() + env = cfg.theme.get_env() build._build_theme_template('main.html', env, mock.Mock(), cfg, mock.Mock()) mock_write_file.assert_called_once() mock_build_template.assert_called_once() @@ -254,7 +246,7 @@ class BuildTests(PathAssertionMixin, unittest.TestCase): self, site_dir, mock_gzip_gzipfile, mock_build_template, mock_write_file ): cfg = load_config(site_dir=site_dir) - env = cfg['theme'].get_env() + env = cfg.theme.get_env() build._build_theme_template('sitemap.xml', env, mock.Mock(), cfg, mock.Mock()) mock_write_file.assert_called_once() mock_build_template.assert_called_once() @@ -264,7 +256,7 @@ class BuildTests(PathAssertionMixin, unittest.TestCase): @mock.patch('mkdocs.commands.build._build_template', return_value='') def test_skip_missing_theme_template(self, mock_build_template, mock_write_file): cfg = load_config() - env = cfg['theme'].get_env() + env = cfg.theme.get_env() with self.assertLogs('mkdocs') as cm: build._build_theme_template('missing.html', env, mock.Mock(), cfg, mock.Mock()) self.assertEqual( @@ -278,7 +270,7 @@ class BuildTests(PathAssertionMixin, unittest.TestCase): @mock.patch('mkdocs.commands.build._build_template', return_value='') def test_skip_theme_template_empty_output(self, mock_build_template, mock_write_file): cfg = load_config() - env = cfg['theme'].get_env() + env = cfg.theme.get_env() with self.assertLogs('mkdocs') as cm: build._build_theme_template('main.html', env, mock.Mock(), cfg, mock.Mock()) self.assertEqual( @@ -294,18 +286,14 @@ class BuildTests(PathAssertionMixin, unittest.TestCase): @mock.patch('mkdocs.commands.build.open', mock.mock_open(read_data='template content')) def test_build_extra_template(self, site_dir): cfg = load_config(site_dir=site_dir) - fs = [ - File('foo.html', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), - ] + fs = [File('foo.html', cfg.docs_dir, cfg.site_dir, cfg.use_directory_urls)] files = Files(fs) build._build_extra_template('foo.html', files, cfg, mock.Mock()) @mock.patch('mkdocs.commands.build.open', mock.mock_open(read_data='template content')) def test_skip_missing_extra_template(self): cfg = load_config() - fs = [ - File('foo.html', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), - ] + fs = [File('foo.html', cfg.docs_dir, cfg.site_dir, cfg.use_directory_urls)] files = Files(fs) with self.assertLogs('mkdocs') as cm: build._build_extra_template('missing.html', files, cfg, mock.Mock()) @@ -317,9 +305,7 @@ class BuildTests(PathAssertionMixin, unittest.TestCase): @mock.patch('mkdocs.commands.build.open', side_effect=OSError('Error message.')) def test_skip_ioerror_extra_template(self, mock_open): cfg = load_config() - fs = [ - File('foo.html', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), - ] + fs = [File('foo.html', cfg.docs_dir, cfg.site_dir, cfg.use_directory_urls)] files = Files(fs) with self.assertLogs('mkdocs') as cm: build._build_extra_template('foo.html', files, cfg, mock.Mock()) @@ -331,9 +317,7 @@ class BuildTests(PathAssertionMixin, unittest.TestCase): @mock.patch('mkdocs.commands.build.open', mock.mock_open(read_data='')) def test_skip_extra_template_empty_output(self): cfg = load_config() - fs = [ - File('foo.html', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']), - ] + fs = [File('foo.html', cfg.docs_dir, cfg.site_dir, cfg.use_directory_urls)] files = Files(fs) with self.assertLogs('mkdocs') as cm: build._build_extra_template('foo.html', files, cfg, mock.Mock()) @@ -347,7 +331,7 @@ class BuildTests(PathAssertionMixin, unittest.TestCase): @tempdir(files={'index.md': 'page content'}) def test_populate_page(self, docs_dir): cfg = load_config(docs_dir=docs_dir) - file = File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) + file = File('index.md', cfg.docs_dir, cfg.site_dir, cfg.use_directory_urls) page = Page('Foo', file, cfg) build._populate_page(page, cfg, Files([file])) self.assertEqual(page.content, '
page content
') @@ -355,7 +339,7 @@ class BuildTests(PathAssertionMixin, unittest.TestCase): @tempdir(files={'testing.html': 'page content
'}) def test_populate_page_dirty_modified(self, site_dir): cfg = load_config(site_dir=site_dir) - file = File('testing.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) + file = File('testing.md', cfg.docs_dir, cfg.site_dir, cfg.use_directory_urls) page = Page('Foo', file, cfg) build._populate_page(page, cfg, Files([file]), dirty=True) self.assertTrue(page.markdown.startswith('# Welcome to MkDocs')) @@ -367,7 +351,7 @@ class BuildTests(PathAssertionMixin, unittest.TestCase): @tempdir(files={'index.html': 'page content
'}) def test_populate_page_dirty_not_modified(self, site_dir, docs_dir): cfg = load_config(docs_dir=docs_dir, site_dir=site_dir) - file = File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) + file = File('index.md', cfg.docs_dir, cfg.site_dir, cfg.use_directory_urls) page = Page('Foo', file, cfg) build._populate_page(page, cfg, Files([file]), dirty=True) # Content is empty as file read was skipped @@ -378,7 +362,7 @@ class BuildTests(PathAssertionMixin, unittest.TestCase): @mock.patch('mkdocs.structure.pages.open', side_effect=OSError('Error message.')) def test_populate_page_read_error(self, docs_dir, mock_open): cfg = load_config(docs_dir=docs_dir) - file = File('missing.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) + file = File('missing.md', cfg.docs_dir, cfg.site_dir, cfg.use_directory_urls) page = Page('Foo', file, cfg) with self.assertLogs('mkdocs') as cm: with self.assertRaises(OSError): @@ -400,7 +384,7 @@ class BuildTests(PathAssertionMixin, unittest.TestCase): cfg = load_config(docs_dir=docs_dir) cfg.plugins.events['page_markdown'].append(on_page_markdown) - file = File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls']) + file = File('index.md', cfg.docs_dir, cfg.site_dir, cfg.use_directory_urls) page = Page('Foo', file, cfg) with self.assertLogs('mkdocs') as cm: with self.assertRaises(PluginError): @@ -415,7 +399,7 @@ class BuildTests(PathAssertionMixin, unittest.TestCase): @tempdir() def test_build_page(self, site_dir): cfg = load_config(site_dir=site_dir, nav=['index.md']) - fs = [File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls'])] + fs = [File('index.md', cfg.docs_dir, cfg.site_dir, cfg.use_directory_urls)] files = Files(fs) nav = get_navigation(files, cfg) page = files.documentation_pages()[0].page @@ -430,12 +414,12 @@ class BuildTests(PathAssertionMixin, unittest.TestCase): @mock.patch('jinja2.environment.Template.render', return_value='') def test_build_page_empty(self, site_dir, render_mock): cfg = load_config(site_dir=site_dir, nav=['index.md']) - fs = [File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls'])] + fs = [File('index.md', cfg.docs_dir, cfg.site_dir, cfg.use_directory_urls)] files = Files(fs) nav = get_navigation(files, cfg) with self.assertLogs('mkdocs') as cm: build._build_page( - files.documentation_pages()[0].page, cfg, files, nav, cfg['theme'].get_env() + files.documentation_pages()[0].page, cfg, files, nav, cfg.theme.get_env() ) self.assertEqual( '\n'.join(cm.output), @@ -449,7 +433,7 @@ class BuildTests(PathAssertionMixin, unittest.TestCase): @mock.patch('mkdocs.utils.write_file') def test_build_page_dirty_modified(self, site_dir, docs_dir, mock_write_file): cfg = load_config(docs_dir=docs_dir, site_dir=site_dir, nav=['index.md']) - fs = [File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls'])] + fs = [File('index.md', cfg.docs_dir, cfg.site_dir, cfg.use_directory_urls)] files = Files(fs) nav = get_navigation(files, cfg) page = files.documentation_pages()[0].page @@ -466,7 +450,7 @@ class BuildTests(PathAssertionMixin, unittest.TestCase): @mock.patch('mkdocs.utils.write_file') def test_build_page_dirty_not_modified(self, site_dir, mock_write_file): cfg = load_config(site_dir=site_dir, nav=['testing.md']) - fs = [File('testing.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls'])] + fs = [File('testing.md', cfg.docs_dir, cfg.site_dir, cfg.use_directory_urls)] files = Files(fs) nav = get_navigation(files, cfg) page = files.documentation_pages()[0].page @@ -482,7 +466,7 @@ class BuildTests(PathAssertionMixin, unittest.TestCase): @tempdir() def test_build_page_custom_template(self, site_dir): cfg = load_config(site_dir=site_dir, nav=['index.md']) - fs = [File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls'])] + fs = [File('index.md', cfg.docs_dir, cfg.site_dir, cfg.use_directory_urls)] files = Files(fs) nav = get_navigation(files, cfg) page = files.documentation_pages()[0].page @@ -498,7 +482,7 @@ class BuildTests(PathAssertionMixin, unittest.TestCase): @mock.patch('mkdocs.utils.write_file', side_effect=OSError('Error message.')) def test_build_page_error(self, site_dir, mock_write_file): cfg = load_config(site_dir=site_dir, nav=['index.md']) - fs = [File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls'])] + fs = [File('index.md', cfg.docs_dir, cfg.site_dir, cfg.use_directory_urls)] files = Files(fs) nav = get_navigation(files, cfg) page = files.documentation_pages()[0].page @@ -522,7 +506,7 @@ class BuildTests(PathAssertionMixin, unittest.TestCase): cfg = load_config(site_dir=site_dir, nav=['index.md']) cfg.plugins.events['page_context'].append(on_page_context) - fs = [File('index.md', cfg['docs_dir'], cfg['site_dir'], cfg['use_directory_urls'])] + fs = [File('index.md', cfg.docs_dir, cfg.site_dir, cfg.use_directory_urls)] files = Files(fs) nav = get_navigation(files, cfg) page = files.documentation_pages()[0].page @@ -532,7 +516,7 @@ class BuildTests(PathAssertionMixin, unittest.TestCase): page.content = 'page content
' with self.assertLogs('mkdocs') as cm: with self.assertRaises(PluginError): - build._build_page(page, cfg, files, nav, cfg['theme'].get_env()) + build._build_page(page, cfg, files, nav, cfg.theme.get_env()) self.assertEqual( '\n'.join(cm.output), "ERROR:mkdocs.commands.build:Error building page 'index.md':", @@ -615,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) @@ -626,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/config/base_tests.py b/mkdocs/tests/config/base_tests.py index 1a2a85a3..2590b718 100644 --- a/mkdocs/tests/config/base_tests.py +++ b/mkdocs/tests/config/base_tests.py @@ -52,7 +52,7 @@ class ConfigBaseTests(unittest.TestCase): cfg = base.load_config(config_file=config_file.name) self.assertTrue(isinstance(cfg, defaults.MkDocsConfig)) - self.assertEqual(cfg['site_name'], 'MkDocs Test') + self.assertEqual(cfg.site_name, 'MkDocs Test') @tempdir() def test_load_default_file(self, temp_dir): @@ -65,7 +65,7 @@ class ConfigBaseTests(unittest.TestCase): with change_dir(temp_dir): cfg = base.load_config(config_file=None) self.assertTrue(isinstance(cfg, defaults.MkDocsConfig)) - self.assertEqual(cfg['site_name'], 'MkDocs Test') + self.assertEqual(cfg.site_name, 'MkDocs Test') @tempdir def test_load_default_file_with_yaml(self, temp_dir): @@ -78,7 +78,7 @@ class ConfigBaseTests(unittest.TestCase): with change_dir(temp_dir): cfg = base.load_config(config_file=None) self.assertTrue(isinstance(cfg, defaults.MkDocsConfig)) - self.assertEqual(cfg['site_name'], 'MkDocs Test') + self.assertEqual(cfg.site_name, 'MkDocs Test') @tempdir() def test_load_default_file_prefer_yml(self, temp_dir): @@ -94,7 +94,7 @@ class ConfigBaseTests(unittest.TestCase): with change_dir(temp_dir): cfg = base.load_config(config_file=None) self.assertTrue(isinstance(cfg, defaults.MkDocsConfig)) - self.assertEqual(cfg['site_name'], 'MkDocs Test1') + self.assertEqual(cfg.site_name, 'MkDocs Test1') def test_load_from_missing_file(self): with self.assertRaisesRegex( @@ -115,7 +115,7 @@ class ConfigBaseTests(unittest.TestCase): cfg = base.load_config(config_file=config_file) self.assertTrue(isinstance(cfg, defaults.MkDocsConfig)) - self.assertEqual(cfg['site_name'], 'MkDocs Test') + self.assertEqual(cfg.site_name, 'MkDocs Test') # load_config will always close the file self.assertTrue(config_file.closed) @@ -131,7 +131,7 @@ class ConfigBaseTests(unittest.TestCase): cfg = base.load_config(config_file=config_file) self.assertTrue(isinstance(cfg, defaults.MkDocsConfig)) - self.assertEqual(cfg['site_name'], 'MkDocs Test') + self.assertEqual(cfg.site_name, 'MkDocs Test') @tempdir def test_load_missing_required(self, temp_dir): @@ -265,8 +265,8 @@ class ConfigBaseTests(unittest.TestCase): cfg = base.load_config(config_file=config_file) self.assertTrue(isinstance(cfg, defaults.MkDocsConfig)) - self.assertEqual(cfg['site_name'], 'MkDocs Test') - self.assertEqual(cfg['docs_dir'], docs_dir) + self.assertEqual(cfg.site_name, 'MkDocs Test') + self.assertEqual(cfg.docs_dir, docs_dir) self.assertEqual(cfg.config_file_path, config_fname) self.assertIsInstance(cfg.config_file_path, str) diff --git a/mkdocs/tests/config/config_options_tests.py b/mkdocs/tests/config/config_options_tests.py index 885b379b..abd7cb20 100644 --- a/mkdocs/tests/config/config_options_tests.py +++ b/mkdocs/tests/config/config_options_tests.py @@ -3,6 +3,7 @@ from __future__ import annotations import contextlib import copy import io +import logging import os import re import sys @@ -21,6 +22,7 @@ else: import mkdocs from mkdocs.config import config_options as c +from mkdocs.config import defaults from mkdocs.config.base import Config from mkdocs.plugins import BasePlugin, PluginCollection from mkdocs.tests.base import tempdir @@ -1573,6 +1575,94 @@ class SubConfigTest(TestCase): self.assertEqual(passed_config_path, config_path) +class NestedSubConfigTest(TestCase): + def defaults(self): + return { + 'nav': { + 'omitted_files': logging.INFO, + 'not_found': logging.WARNING, + 'absolute_links': logging.INFO, + }, + 'links': { + 'not_found': logging.WARNING, + 'absolute_links': logging.INFO, + 'unrecognized_links': logging.INFO, + }, + } + + class Schema(Config): + validation = c.PropagatingSubConfig[defaults.MkDocsConfig.Validation]() + + def test_unspecified(self) -> None: + for cfg in {}, {'validation': {}}: + with self.subTest(cfg): + conf = self.get_config( + self.Schema, + {}, + ) + self.assertEqual(conf.validation, self.defaults()) + + def test_sets_nested_and_not_nested(self) -> None: + conf = self.get_config( + self.Schema, + {'validation': {'not_found': 'ignore', 'links': {'absolute_links': 'warn'}}}, + ) + expected = self.defaults() + expected['nav']['not_found'] = logging.DEBUG + expected['links']['not_found'] = logging.DEBUG + expected['links']['absolute_links'] = logging.WARNING + self.assertEqual(conf.validation, expected) + + def test_sets_nested_different(self) -> None: + conf = self.get_config( + self.Schema, + {'validation': {'not_found': 'ignore', 'links': {'not_found': 'warn'}}}, + ) + expected = self.defaults() + expected['nav']['not_found'] = logging.DEBUG + expected['links']['not_found'] = logging.WARNING + self.assertEqual(conf.validation, expected) + + def test_sets_only_one_nested(self) -> None: + conf = self.get_config( + self.Schema, + {'validation': {'omitted_files': 'ignore'}}, + ) + expected = self.defaults() + expected['nav']['omitted_files'] = logging.DEBUG + self.assertEqual(conf.validation, expected) + + def test_sets_nested_not_dict(self) -> None: + with self.expect_error( + validation="Sub-option 'links': Sub-option 'unrecognized_links': Expected a string, but a') and content.endswith('
'): + content = content[3:-4] + return content + def test_relative_html_link(self): self.assertEqual( - self.get_rendered_result(['index.md', 'non-index.md']), - '', # No trailing / + self.get_rendered_result( + content='[link](non-index.md)', files=['index.md', 'non-index.md'] + ), + 'link', + ) + self.assertEqual( + self.get_rendered_result( + use_directory_urls=False, + content='[link](non-index.md)', + files=['index.md', 'non-index.md'], + ), + 'link', ) - @mock.patch('mkdocs.structure.pages.open', mock.mock_open(read_data='[link](index.md)')) def test_relative_html_link_index(self): self.assertEqual( - self.get_rendered_result(['non-index.md', 'index.md']), - '', + self.get_rendered_result( + content='[link](index.md)', files=['non-index.md', 'index.md'] + ), + 'link', + ) + self.assertEqual( + self.get_rendered_result( + use_directory_urls=False, + content='[link](index.md)', + files=['non-index.md', 'index.md'], + ), + 'link', ) - @mock.patch('mkdocs.structure.pages.open', mock.mock_open(read_data='[link](sub2/index.md)')) def test_relative_html_link_sub_index(self): self.assertEqual( - self.get_rendered_result(['index.md', 'sub2/index.md']), - '', # No trailing / + self.get_rendered_result( + content='[link](sub2/index.md)', files=['index.md', 'sub2/index.md'] + ), + 'link', + ) + self.assertEqual( + self.get_rendered_result( + use_directory_urls=False, + content='[link](sub2/index.md)', + files=['index.md', 'sub2/index.md'], + ), + 'link', ) - @mock.patch( - 'mkdocs.structure.pages.open', mock.mock_open(read_data='[link](sub2/non-index.md)') - ) def test_relative_html_link_sub_page(self): self.assertEqual( - self.get_rendered_result(['index.md', 'sub2/non-index.md']), - '', # No trailing / + self.get_rendered_result( + content='[link](sub2/non-index.md)', files=['index.md', 'sub2/non-index.md'] + ), + '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', ) - @mock.patch('mkdocs.structure.pages.open', mock.mock_open(read_data='[link](file%20name.md)')) def test_relative_html_link_with_encoded_space(self): self.assertEqual( - self.get_rendered_result(['index.md', 'file name.md']), - '', + self.get_rendered_result( + content='[link](file%20name.md)', files=['index.md', 'file name.md'] + ), + 'link', ) - @mock.patch('mkdocs.structure.pages.open', mock.mock_open(read_data='[link](file name.md)')) def test_relative_html_link_with_unencoded_space(self): self.assertEqual( - self.get_rendered_result(['index.md', 'file name.md']), - '', + self.get_rendered_result( + use_directory_urls=False, + content='[link](file name.md)', + files=['index.md', 'file name.md'], + ), + 'link', ) - @mock.patch('mkdocs.structure.pages.open', mock.mock_open(read_data='[link](../index.md)')) def test_relative_html_link_parent_index(self): self.assertEqual( - self.get_rendered_result(['sub2/non-index.md', 'index.md']), - '', + self.get_rendered_result( + content='[link](../index.md)', files=['sub2/non-index.md', 'index.md'] + ), + 'link', + ) + self.assertEqual( + self.get_rendered_result( + use_directory_urls=False, + content='[link](../index.md)', + files=['sub2/non-index.md', 'index.md'], + ), + 'link', ) - @mock.patch( - 'mkdocs.structure.pages.open', mock.mock_open(read_data='[link](non-index.md#hash)') - ) def test_relative_html_link_hash(self): self.assertEqual( - self.get_rendered_result(['index.md', 'non-index.md']), - '', + self.get_rendered_result( + content='[link](non-index.md#hash)', files=['index.md', 'non-index.md'] + ), + 'link', ) - @mock.patch( - 'mkdocs.structure.pages.open', mock.mock_open(read_data='[link](sub2/index.md#hash)') - ) def test_relative_html_link_sub_index_hash(self): self.assertEqual( - self.get_rendered_result(['index.md', 'sub2/index.md']), - '', + self.get_rendered_result( + content='[link](sub2/index.md#hash)', files=['index.md', 'sub2/index.md'] + ), + 'link', + ) + self.assertEqual( + self.get_rendered_result( + use_directory_urls=False, + content='[link](sub2/index.md#hash)', + files=['index.md', 'sub2/index.md'], + ), + 'link', ) - @mock.patch( - 'mkdocs.structure.pages.open', mock.mock_open(read_data='[link](sub2/non-index.md#hash)') - ) def test_relative_html_link_sub_page_hash(self): self.assertEqual( - self.get_rendered_result(['index.md', 'sub2/non-index.md']), - '', + self.get_rendered_result( + content='[link](sub2/non-index.md#hash)', files=['index.md', 'sub2/non-index.md'] + ), + 'link', ) - @mock.patch('mkdocs.structure.pages.open', mock.mock_open(read_data='[link](#hash)')) def test_relative_html_link_hash_only(self): - self.assertEqual( - self.get_rendered_result(['index.md']), - '', - ) + 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', + ) - @mock.patch('mkdocs.structure.pages.open', mock.mock_open(read_data='')) def test_relative_image_link_from_homepage(self): - self.assertEqual( - self.get_rendered_result(['index.md', 'image.png']), - '
', # no opening ./
+ )
- @mock.patch('mkdocs.structure.pages.open', mock.mock_open(read_data=''))
def test_relative_image_link_from_subpage(self):
self.assertEqual(
- self.get_rendered_result(['sub2/non-index.md', 'image.png']),
- '
',
)
- @mock.patch('mkdocs.structure.pages.open', mock.mock_open(read_data=''))
def test_relative_image_link_from_sibling(self):
self.assertEqual(
- self.get_rendered_result(['non-index.md', 'image.png']),
- '
',
+ )
+ self.assertEqual(
+ self.get_rendered_result(
+ use_directory_urls=False,
+ content='',
+ files=['non-index.md', 'image.png'],
+ ),
+ '
',
)
- @mock.patch('mkdocs.structure.pages.open', mock.mock_open(read_data='*__not__ a link*.'))
def test_no_links(self):
self.assertEqual(
- self.get_rendered_result(['index.md']),
- 'not a link.
', + self.get_rendered_result(content='*__not__ a link*.', files=['index.md']), + 'not a link.', ) - @mock.patch('mkdocs.structure.pages.open', mock.mock_open(read_data='[link](non-existent.md)')) - def test_bad_relative_html_link(self): - with self.assertLogs('mkdocs') as cm: - self.assertEqual( - self.get_rendered_result(['index.md']), - '', - ) + def test_bad_relative_doc_link(self): self.assertEqual( - '\n'.join(cm.output), - "WARNING:mkdocs.structure.pages:Documentation file 'index.md' contains a link " - "to 'non-existent.md' which is not found in the documentation files.", + self.get_rendered_result( + content='[link](non-existent.md)', + files=['index.md'], + 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_relative_slash_link_with_suggestion(self): + self.assertEqual( + self.get_rendered_result( + content='[link](../about/)', + files=['foo/index.md', 'about.md'], + logs="INFO:Doc file 'foo/index.md' contains an unrecognized relative link '../about/', it was left as is. Did you mean '../about.md'?", + ), + 'link', + ) + self.assertEqual( + self.get_rendered_result( + validation=dict(links=dict(unrecognized_links='warn')), + content='[link](../#example)', + files=['foo/bar.md', 'index.md'], + logs="WARNING:Doc file 'foo/bar.md' contains an unrecognized relative link '../#example', it was left as is. Did you mean '../index.md#example'?", + ), + 'link', + ) + + def test_self_anchor_link_with_suggestion(self): + self.assertEqual( + self.get_rendered_result( + content='[link](./#test)', + files=['index.md'], + logs="INFO:Doc file 'index.md' contains an unrecognized relative link './#test', it was left as is. Did you mean '#test'?", + ), + 'link', ) - @mock.patch( - 'mkdocs.structure.pages.open', - mock.mock_open(read_data='[external](http://example.com/index.md)'), - ) def test_external_link(self): self.assertEqual( - self.get_rendered_result(['index.md']), - '', + self.get_rendered_result( + content='[external](http://example.com/index.md)', files=['index.md'] + ), + 'external', + ) + + def test_absolute_link_with_suggestion(self): + self.assertEqual( + self.get_rendered_result( + content='[absolute link](/path/to/file.md)', + files=['index.md', 'path/to/file.md'], + logs="INFO:Doc file 'index.md' contains an absolute link '/path/to/file.md', it was left as is. Did you mean 'path/to/file.md'?", + ), + 'absolute link', + ) + self.assertEqual( + self.get_rendered_result( + use_directory_urls=False, + content='[absolute link](/path/to/file/)', + files=['path/index.md', 'path/to/file.md'], + logs="INFO:Doc file 'path/index.md' contains an absolute link '/path/to/file/', it was left as is.", + ), + 'absolute link', + ) + self.assertEqual( + self.get_rendered_result( + content='[absolute link](/path/to/file)', + files=['path/index.md', 'path/to/file.md'], + logs="INFO:Doc file 'path/index.md' contains an absolute link '/path/to/file', it was left as is. Did you mean 'to/file.md'?", + ), + 'absolute link', ) - @mock.patch( - 'mkdocs.structure.pages.open', mock.mock_open(read_data='[absolute link](/path/to/file.md)') - ) def test_absolute_link(self): self.assertEqual( - self.get_rendered_result(['index.md']), - '', + 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', ) - - @mock.patch( - 'mkdocs.structure.pages.open', - mock.mock_open(read_data='[absolute local path](\\image.png)'), - ) - def test_absolute_win_local_path(self): self.assertEqual( - self.get_rendered_result(['index.md']), - '', + self.get_rendered_result( + validation=dict(links=dict(absolute_links='ignore')), + content='[absolute link](/path/to/file.md)', + files=['index.md'], + ), + 'absolute link', ) - @mock.patch('mkdocs.structure.pages.open', mock.mock_open(read_data='
',
+ )
+ self.assertEqual(
+ self.get_rendered_result(
+ content='',
+ files=['foo/bar.md', 'image.png'],
+ logs="INFO:Doc file 'foo/bar.md' contains an absolute link '/image.png', it was left as is. Did you mean '../image.png'?",
+ ),
+ '
',
+ )
+
+ def test_absolute_win_local_path(self):
+ 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(
- self.get_rendered_result(['index.md']),
+ self.get_rendered_result(content='