Repository: mkdocs/mkdocs

Issue:
Validate links to anchors on Markdown documents
We do a reasonable job of checking that links to local markdown files are valid. However, the standard pattern for linking to sections is to use anchors in headers. This doesn't work well if the title is moved or renamed.

With the TOC code, we should be able to keep a set of valid anchors for each page and verify it with that.


Discussion:

Patch:
diff --git a/.markdownlintrc b/.markdownlintrc
index aa9fc26c..c0e8a9b3 100644
--- a/.markdownlintrc
+++ b/.markdownlintrc
@@ -20,5 +20,8 @@
   "MD024": { "siblings_only": true },
 
   // Allow inline HTML
-  "MD033": false
+  "MD033": false,
+
+  // Disable named references validation
+  "MD052": false
 }
diff --git a/docs/about/release-notes.md b/docs/about/release-notes.md
index 3eab10e7..e048c1fd 100644
--- a/docs/about/release-notes.md
+++ b/docs/about/release-notes.md
@@ -1262,7 +1262,7 @@ Users can review the [configuration options][search config] available and theme
 authors should review how [search and themes] interact.
 
 [search config]: ../user-guide/configuration.md#search
-[search and themes]: ../dev-guide/themes.md#search_and_themes
+[search and themes]: ../dev-guide/themes.md#search-and-themes
 
 #### `theme_dir` Configuration Option fully Deprecated
 
@@ -1406,25 +1406,18 @@ version 1.0.
 Any of the following old page variables should be updated to the new ones in
 user created and third-party templates:
 
-| Old Variable Name | New Variable Name   |
-| ----------------- | ------------------- |
-| current_page      | [page]              |
-| page_title        | [page.title]        |
-| content           | [page.content]      |
-| toc               | [page.toc]          |
-| meta              | [page.meta]         |
-| canonical_url     | [page.canonical_url]|
-| previous_page     | [page.previous_page]|
-| next_page         | [page.next_page]    |
+| Old Variable Name | New Variable Name |
+| ----------------- | ----------------- |
+| current_page      | page              |
+| page_title        | page.title        |
+| content           | page.content      |
+| toc               | page.toc          |
+| meta              | page.meta         |
+| canonical_url     | page.canonical_url|
+| previous_page     | page.previous_page|
+| next_page         | page.next_page    |
 
 [page]: ../dev-guide/themes.md#page
-[page.title]: ../dev-guide/themes.md#pagetitle
-[page.content]: ../dev-guide/themes.md#pagecontent
-[page.toc]: ../dev-guide/themes.md#pagetoc
-[page.meta]: ../dev-guide/themes.md#pagemeta
-[page.canonical_url]: ../dev-guide/themes.md#pagecanonical_url
-[page.previous_page]: ../dev-guide/themes.md#pageprevious_page
-[page.next_page]: ../dev-guide/themes.md#pagenext_page
 
 Additionally, a number of global variables have been altered and/or removed
 and user created and third-party templates should be updated as outlined below:
@@ -1793,12 +1786,11 @@ no configuration is needed to enable it.
 
 #### Change the pages configuration
 
-Provide a [new way] to define pages, and specifically [nested pages], in the
+Provide a [new way] to define pages, and specifically nested pages, in the
 mkdocs.yml file and deprecate the existing approach, support will be removed
 with MkDocs 1.0.
 
 [new way]: ../user-guide/writing-your-docs.md#configure-pages-and-navigation
-[nested pages]: ../user-guide/writing-your-docs.md#multilevel-documentation
 
 #### Warn users about the removal of builtin themes
 
diff --git a/docs/dev-guide/plugins.md b/docs/dev-guide/plugins.md
index 8d4a8c63..118bae24 100644
--- a/docs/dev-guide/plugins.md
+++ b/docs/dev-guide/plugins.md
@@ -473,7 +473,7 @@ class MyPlugin(BasePlugin):
             # some code that could throw a KeyError
             ...
         except KeyError as error:
-            raise PluginError(str(error))
+            raise PluginError(f"Failed to find the item by key: '{error}'")
 
     def on_build_error(self, error, **kwargs):
         # some code to clean things up
@@ -482,10 +482,31 @@ class MyPlugin(BasePlugin):
 
 ### Logging in plugins
 
-MkDocs provides a `get_plugin_logger` function which returns
-a logger that can be used to log messages.
+To ensure that your plugins' log messages adhere with MkDocs' formatting and `--verbose`/`--debug` flags, please write the logs to a logger under the `mkdocs.plugins.` namespace.
 
-#### ::: mkdocs.plugins.get_plugin_logger
+> EXAMPLE:
+>
+> ```python
+> import logging
+>
+> log = logging.getLogger(f"mkdocs.plugins.{__name__}")
+>
+> log.warning("File '%s' not found. Breaks the build if --strict is passed", my_file_name)
+> log.info("Shown normally")
+> log.debug("Shown only with `--verbose`")
+>
+> if log.getEffectiveLevel() <= logging.DEBUG:
+>     log.debug("Very expensive calculation only for debugging: %s", get_my_diagnostics())
+> ```
+
+`log.error()` is another logging level that is differentiated by its look, but in all other ways it functions the same as `warning`, so it's strange to use it. If your plugin encounters an actual error, it is best to just interrupt the build by raising [`mkdocs.exceptions.PluginError`][] (which will also log an ERROR message).
+
+<!-- -->
+> NEW: New in MkDocs 1.5
+>
+> MkDocs now provides a `get_plugin_logger()` convenience function that returns a logger like the above that is also prefixed with the plugin's name.
+>
+> #### ::: mkdocs.plugins.get_plugin_logger
 
 ### Entry Point
 
diff --git a/docs/dev-guide/themes.md b/docs/dev-guide/themes.md
index 51290b66..ea4b9911 100644
--- a/docs/dev-guide/themes.md
+++ b/docs/dev-guide/themes.md
@@ -245,7 +245,6 @@ used options include:
 * [config.repo_url](../user-guide/configuration.md#repo_url)
 * [config.repo_name](../user-guide/configuration.md#repo_name)
 * [config.copyright](../user-guide/configuration.md#copyright)
-* [config.google_analytics](../user-guide/configuration.md#google_analytics)
 
 #### nav
 
diff --git a/docs/user-guide/configuration.md b/docs/user-guide/configuration.md
index 8353b0cf..60c50a3f 100644
--- a/docs/user-guide/configuration.md
+++ b/docs/user-guide/configuration.md
@@ -364,7 +364,7 @@ Configure the strictness of MkDocs' diagnostic messages when validating links to
 
 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:**
+> EXAMPLE: **Defaults of this config as of MkDocs 1.6:**
 >
 > ```yaml
 > validation:
@@ -374,6 +374,7 @@ This is a tree of configs, and for each one the value can be one of the three: `
 >     absolute_links: info
 >   links:
 >     not_found: warn
+>     anchors: ignore
 >     absolute_links: info
 >     unrecognized_links: info
 > ```
@@ -382,7 +383,7 @@ This is a tree of configs, and for each one the value can be one of the three: `
 
 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):**
+>? EXAMPLE: **Configure MkDocs 1.6 to behave like MkDocs 1.4 and below (reduce strictness):**
 >
 > ```yaml
 > validation:
@@ -397,23 +398,27 @@ The defaults of some of the behaviors already differ from MkDocs 1.4 and below -
 >   omitted_files: warn
 >   absolute_links: warn
 >   unrecognized_links: warn
+>   anchors: warn  # New in MkDocs 1.6
 > ```
 
 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`  
+*   `validation.nav.omitted_files`
     * "The following pages exist in the docs directory, but are not included in the "nav" configuration: ..."
-*   `validation.nav.not_found`  
+*   `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`  
+*   `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."
+*   `validation.links.not_found`
+    * "Doc file 'example.md' contains a 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.anchors`
+    * "Doc file 'example.md' contains a link '../foo/bar.md#some-heading', but the doc 'foo/bar.md' does not contain an anchor '#some-heading'."
+    * "Doc file 'example.md' contains a link '#some-heading', but there is no such anchor on this page."
 *   `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`
@@ -1188,7 +1193,7 @@ echo '{INHERIT: mkdocs.yml, site_name: "Renamed site"}' | mkdocs build -f -
 [Python-Markdown wiki]: https://github.com/Python-Markdown/markdown/wiki/Third-Party-Extensions
 [catalog]: https://github.com/mkdocs/catalog
 [configuring pages and navigation]: writing-your-docs.md#configure-pages-and-navigation
-[theme_dir]: customizing-your-theme.md#using-the-theme_dir
+[theme_dir]: customizing-your-theme.md#using-the-theme-custom_dir
 [choosing your theme]: choosing-your-theme.md
 [Localizing your theme]: localizing-your-theme.md
 [extra_css]: #extra_css
diff --git a/mkdocs/__main__.py b/mkdocs/__main__.py
index baf67adc..1f8d1f49 100644
--- a/mkdocs/__main__.py
+++ b/mkdocs/__main__.py
@@ -89,13 +89,11 @@ class State:
 
     def __init__(self, log_name='mkdocs', level=logging.INFO):
         self.logger = logging.getLogger(log_name)
-        # Don't restrict level on logger; use handler
-        self.logger.setLevel(1)
+        self.logger.setLevel(level)
         self.logger.propagate = False
 
         self.stream = logging.StreamHandler()
         self.stream.setFormatter(ColorFormatter())
-        self.stream.setLevel(level)
         self.stream.name = 'MkDocsStreamHandler'
         self.logger.addHandler(self.stream)
 
@@ -161,7 +159,7 @@ def verbose_option(f):
     def callback(ctx, param, value):
         state = ctx.ensure_object(State)
         if value:
-            state.stream.setLevel(logging.DEBUG)
+            state.logger.setLevel(logging.DEBUG)
 
     return click.option(
         '-v',
@@ -177,7 +175,7 @@ def quiet_option(f):
     def callback(ctx, param, value):
         state = ctx.ensure_object(State)
         if value:
-            state.stream.setLevel(logging.ERROR)
+            state.logger.setLevel(logging.ERROR)
 
     return click.option(
         '-q',
diff --git a/mkdocs/commands/build.py b/mkdocs/commands/build.py
index 4a21a3da..52eca42b 100644
--- a/mkdocs/commands/build.py
+++ b/mkdocs/commands/build.py
@@ -341,6 +341,11 @@ def build(
                 file.page, config, doc_files, nav, env, dirty, excluded=file.inclusion.is_excluded()
             )
 
+        log_level = config.validation.links.anchors
+        for file in doc_files:
+            assert file.page is not None
+            file.page.validate_anchor_links(files=files, log_level=log_level)
+
         # Run `post_build` plugin events.
         config.plugins.on_post_build(config=config)
 
diff --git a/mkdocs/config/defaults.py b/mkdocs/config/defaults.py
index a6f2871b..2ce42bb8 100644
--- a/mkdocs/config/defaults.py
+++ b/mkdocs/config/defaults.py
@@ -166,6 +166,9 @@ class MkDocsConfig(base.Config):
             """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 `/`."""
 
+            anchors = c._LogLevel(default='ignore')
+            """Warning level for when a Markdown doc links to an anchor that's not present on the target page."""
+
         links = c.SubConfig(LinksValidation)
 
     validation = c.PropagatingSubConfig[Validation]()
diff --git a/mkdocs/structure/files.py b/mkdocs/structure/files.py
index 01d2f639..7d0417ba 100644
--- a/mkdocs/structure/files.py
+++ b/mkdocs/structure/files.py
@@ -237,14 +237,6 @@ class File:
         self.abs_dest_path = os.path.normpath(os.path.join(dest_dir, self.dest_uri))
         self.inclusion = inclusion
 
-    def __eq__(self, other) -> bool:
-        return (
-            isinstance(other, self.__class__)
-            and self.src_uri == other.src_uri
-            and self.abs_src_path == other.abs_src_path
-            and self.url == other.url
-        )
-
     def __repr__(self):
         return (
             f"File(src_uri='{self.src_uri}', dest_uri='{self.dest_uri}',"
diff --git a/mkdocs/structure/pages.py b/mkdocs/structure/pages.py
index 84c1430f..07e1737a 100644
--- a/mkdocs/structure/pages.py
+++ b/mkdocs/structure/pages.py
@@ -73,7 +73,9 @@ class Page(StructureItem):
     """The original Markdown content from the file."""
 
     content: str | None
-    """The rendered Markdown as HTML, this is the contents of the documentation."""
+    """The rendered Markdown as HTML, this is the contents of the documentation.
+
+    Populated after `.render()`."""
 
     toc: TableOfContents
     """An iterable object representing the Table of contents for a page. Each item in
@@ -269,6 +271,42 @@ class Page(StructureItem):
         self.content = md.convert(self.markdown)
         self.toc = get_toc(getattr(md, 'toc_tokens', []))
         self._title_from_render = extract_title_ext.title
+        self.present_anchor_ids = relative_path_ext.present_anchor_ids
+        if log.getEffectiveLevel() > logging.DEBUG:
+            self.links_to_anchors = relative_path_ext.links_to_anchors
+
+    present_anchor_ids: set[str] | None = None
+    """Anchor IDs that this page contains (can be linked to in this page)."""
+
+    links_to_anchors: dict[File, dict[str, str]] | None = None
+    """Links to anchors in other files that this page contains.
+
+    The structure is: `{file_that_is_linked_to: {'anchor': 'original_link/to/some_file.md#anchor'}}`.
+    Populated after `.render()`. Populated only if `validation: {anchors: info}` (or greater) is set"""
+
+    def validate_anchor_links(self, *, files: Files, log_level: int) -> None:
+        if not self.links_to_anchors:
+            return
+        for to_file, links in self.links_to_anchors.items():
+            for anchor, original_link in links.items():
+                page = to_file.page
+                if page is None:
+                    continue
+                if page.present_anchor_ids is None:  # Page was somehow not rendered.
+                    continue
+                if anchor in page.present_anchor_ids:
+                    continue
+                context = ""
+                if to_file == self.file:
+                    problem = "there is no such anchor on this page"
+                    if anchor.startswith('fnref:'):
+                        context = " This seems to be a footnote that is never referenced."
+                else:
+                    problem = f"the doc '{to_file.src_uri}' does not contain an anchor '#{anchor}'"
+                log.log(
+                    log_level,
+                    f"Doc file '{self.file.src_uri}' contains a link '{original_link}', but {problem}.{context}",
+                )
 
 
 class _RelativePathTreeprocessor(markdown.treeprocessors.Treeprocessor):
@@ -276,6 +314,8 @@ class _RelativePathTreeprocessor(markdown.treeprocessors.Treeprocessor):
         self.file = file
         self.files = files
         self.config = config
+        self.links_to_anchors: dict[File, dict[str, str]] = {}
+        self.present_anchor_ids: set[str] = set()
 
     def run(self, root: etree.Element) -> etree.Element:
         """
@@ -285,6 +325,8 @@ class _RelativePathTreeprocessor(markdown.treeprocessors.Treeprocessor):
         tags and then makes them relative based on the site navigation
         """
         for element in root.iter():
+            if anchor := element.get('id'):
+                self.present_anchor_ids.add(anchor)
             if element.tag == 'a':
                 key = 'href'
             elif element.tag == 'img':
@@ -300,7 +342,7 @@ class _RelativePathTreeprocessor(markdown.treeprocessors.Treeprocessor):
         return root
 
     @classmethod
-    def _target_uri(cls, src_path: str, dest_path: str):
+    def _target_uri(cls, src_path: str, dest_path: str) -> str:
         return posixpath.normpath(
             posixpath.join(posixpath.dirname(src_path), dest_path).lstrip('/')
         )
@@ -344,7 +386,7 @@ class _RelativePathTreeprocessor(markdown.treeprocessors.Treeprocessor):
                     tried.add(guess)
 
     def path_to_url(self, url: str) -> str:
-        scheme, netloc, path, query, fragment = urlsplit(url)
+        scheme, netloc, path, query, anchor = urlsplit(url)
 
         warning_level, warning = 0, ''
 
@@ -356,7 +398,10 @@ class _RelativePathTreeprocessor(markdown.treeprocessors.Treeprocessor):
             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.
+        elif not path:  # Self-link containing only query or anchor.
+            if anchor:
+                # Register that the page links to itself with an anchor.
+                self.links_to_anchors.setdefault(self.file, {}).setdefault(anchor, url)
             return url
 
         path = urlunquote(path)
@@ -387,7 +432,7 @@ class _RelativePathTreeprocessor(markdown.treeprocessors.Treeprocessor):
                 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"Doc file '{self.file.src_uri}' contains a link '{url}', "
                     f"but the target{target} is not found among documentation files."
                 )
 
@@ -400,11 +445,11 @@ class _RelativePathTreeprocessor(markdown.treeprocessors.Treeprocessor):
                 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:
+                        if anchor and path == self.file.src_uri:
                             path = ''
                         else:
                             path = utils.get_relative_url(path, self.file.src_uri)
-                        suggest_url = urlunsplit(('', '', path, query, fragment))
+                        suggest_url = urlunsplit(('', '', path, query, anchor))
                         break
                 else:
                     if '@' in url and '.' in url and '/' not in url:
@@ -416,6 +461,11 @@ class _RelativePathTreeprocessor(markdown.treeprocessors.Treeprocessor):
 
         assert target_uri is not None
         assert target_file is not None
+
+        if anchor:
+            # Register that this page links to the target file with an anchor.
+            self.links_to_anchors.setdefault(target_file, {}).setdefault(anchor, url)
+
         if target_file.inclusion.is_excluded():
             if self.file.inclusion.is_excluded():
                 warning_level = logging.DEBUG
@@ -427,7 +477,7 @@ class _RelativePathTreeprocessor(markdown.treeprocessors.Treeprocessor):
             )
             log.log(warning_level, warning)
         path = utils.get_relative_url(target_file.url, self.file.url)
-        return urlunsplit(('', '', path, query, fragment))
+        return urlunsplit(('', '', path, query, anchor))
 
     def _register(self, md: markdown.Markdown) -> None:
         md.treeprocessors.register(self, "relpath", 0)


Test patch:
diff --git a/mkdocs/tests/build_tests.py b/mkdocs/tests/build_tests.py
index 275aad37..4dc85c77 100644
--- a/mkdocs/tests/build_tests.py
+++ b/mkdocs/tests/build_tests.py
@@ -609,7 +609,7 @@ class BuildTests(PathAssertionMixin, unittest.TestCase):
         server = testing_server(site_dir, mount_path='/documentation/')
         with self.subTest(live_server=server):
             expected_logs = '''
-                INFO:Doc file 'test/bar.md' contains a relative link 'nonexistent.md', but the target 'test/nonexistent.md' is not found among documentation files.
+                INFO:Doc file 'test/bar.md' contains a link 'nonexistent.md', but the target 'test/nonexistent.md' is not found among documentation files.
                 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
@@ -672,6 +672,88 @@ class BuildTests(PathAssertionMixin, unittest.TestCase):
                 self.assertPathIsFile(index_path)
                 self.assertRegex(index_path.read_text(), r'page1 content')
 
+    @tempdir(
+        files={
+            'test/foo.md': '## page1 heading\n\n[bar](bar.md#page1-heading)',
+            'test/bar.md': '## page2 heading\n\n[aaa](#a)',
+        }
+    )
+    @tempdir()
+    def test_anchor_warning(self, site_dir, docs_dir):
+        cfg = load_config(docs_dir=docs_dir, site_dir=site_dir, validation={'anchors': 'warn'})
+
+        expected_logs = '''
+            WARNING:Doc file 'test/bar.md' contains a link '#a', but there is no such anchor on this page.
+            WARNING:Doc file 'test/foo.md' contains a link 'bar.md#page1-heading', but the doc 'test/bar.md' does not contain an anchor '#page1-heading'.
+        '''
+        with self._assert_build_logs(expected_logs):
+            build.build(cfg)
+
+    @tempdir(
+        files={
+            'test/foo.md': '[bar](bar.md#heading1)',
+            'test/bar.md': '## heading1',
+        }
+    )
+    @tempdir()
+    def test_anchor_no_warning(self, site_dir, docs_dir):
+        cfg = load_config(docs_dir=docs_dir, site_dir=site_dir, validation={'anchors': 'warn'})
+        build.build(cfg)
+
+    @unittest.skip("The implementation is not good enough to understand this yet.")  # TODO
+    @tempdir(
+        files={
+            'test/foo.md': '[bar](bar.md#heading2)',
+            'test/bar.md': '## heading1\n\n<a id="heading2">hi</a>',
+        }
+    )
+    @tempdir()
+    def test_anchor_no_warning_with_html(self, site_dir, docs_dir):
+        cfg = load_config(
+            docs_dir=docs_dir,
+            site_dir=site_dir,
+            validation={'anchors': 'warn'},
+            markdown_extensions=['md_in_html'],
+        )
+        build.build(cfg)
+
+    @tempdir(
+        files={
+            'test/foo.md': '[bar1](bar.md?test#heading1), [bar2](bar.md?test#headingno), [bar3](bar.md?test), [self4](?test), [self5](?test#hi)',
+            'test/bar.md': '## heading1',
+        }
+    )
+    @tempdir()
+    def test_anchor_warning_and_query(self, site_dir, docs_dir):
+        cfg = load_config(docs_dir=docs_dir, site_dir=site_dir, validation={'anchors': 'info'})
+
+        expected_logs = '''
+            INFO:Doc file 'test/foo.md' contains a link 'bar.md?test#headingno', but the doc 'test/bar.md' does not contain an anchor '#headingno'.
+            INFO:Doc file 'test/foo.md' contains a link '?test#hi', but there is no such anchor on this page.
+        '''
+        with self._assert_build_logs(expected_logs):
+            build.build(cfg)
+
+    @tempdir(
+        files={
+            'test/foo.md': 'test[^1]\n\n[^1]: footnote1\n[^2]: footnote2',
+        }
+    )
+    @tempdir()
+    def test_anchor_warning_for_footnote(self, site_dir, docs_dir):
+        cfg = load_config(
+            docs_dir=docs_dir,
+            site_dir=site_dir,
+            validation={'anchors': 'info'},
+            markdown_extensions=['footnotes'],
+        )
+
+        expected_logs = '''
+            INFO:Doc file 'test/foo.md' contains a link '#fnref:2', but there is no such anchor on this page. This seems to be a footnote that is never referenced.
+        '''
+        with self._assert_build_logs(expected_logs):
+            build.build(cfg)
+
     @tempdir(
         files={
             'foo.md': 'page1 content',
diff --git a/mkdocs/tests/cli_tests.py b/mkdocs/tests/cli_tests.py
index 6ce03db1..d8f97322 100644
--- a/mkdocs/tests/cli_tests.py
+++ b/mkdocs/tests/cli_tests.py
@@ -222,8 +222,8 @@ class CLITests(unittest.TestCase):
             use_directory_urls=None,
             site_dir=None,
         )
-        handler = logging._handlers.get('MkDocsStreamHandler')
-        self.assertEqual(handler.level, logging.INFO)
+        for log_name in 'mkdocs', 'mkdocs.structure.pages', 'mkdocs.plugins.foo':
+            self.assertEqual(logging.getLogger(log_name).getEffectiveLevel(), logging.INFO)
 
     @mock.patch('mkdocs.config.load_config', autospec=True)
     @mock.patch('mkdocs.commands.build.build', autospec=True)
@@ -352,8 +352,8 @@ class CLITests(unittest.TestCase):
 
         self.assertEqual(result.exit_code, 0)
         self.assertEqual(mock_build.call_count, 1)
-        handler = logging._handlers.get('MkDocsStreamHandler')
-        self.assertEqual(handler.level, logging.DEBUG)
+        for log_name in 'mkdocs', 'mkdocs.structure.pages', 'mkdocs.plugins.foo':
+            self.assertEqual(logging.getLogger(log_name).getEffectiveLevel(), logging.DEBUG)
 
     @mock.patch('mkdocs.config.load_config', autospec=True)
     @mock.patch('mkdocs.commands.build.build', autospec=True)
@@ -362,8 +362,8 @@ class CLITests(unittest.TestCase):
 
         self.assertEqual(result.exit_code, 0)
         self.assertEqual(mock_build.call_count, 1)
-        handler = logging._handlers.get('MkDocsStreamHandler')
-        self.assertEqual(handler.level, logging.ERROR)
+        for log_name in 'mkdocs', 'mkdocs.structure.pages', 'mkdocs.plugins.foo':
+            self.assertEqual(logging.getLogger(log_name).getEffectiveLevel(), logging.ERROR)
 
     @mock.patch('mkdocs.commands.new.new', autospec=True)
     def test_new(self, mock_new):
diff --git a/mkdocs/tests/config/config_options_tests.py b/mkdocs/tests/config/config_options_tests.py
index ecab8707..9d8592f9 100644
--- a/mkdocs/tests/config/config_options_tests.py
+++ b/mkdocs/tests/config/config_options_tests.py
@@ -1586,6 +1586,7 @@ class NestedSubConfigTest(TestCase):
                 'not_found': logging.WARNING,
                 'absolute_links': logging.INFO,
                 'unrecognized_links': logging.INFO,
+                'anchors': logging.DEBUG,
             },
         }
 
diff --git a/mkdocs/tests/structure/file_tests.py b/mkdocs/tests/structure/file_tests.py
index 200efd73..a646593c 100644
--- a/mkdocs/tests/structure/file_tests.py
+++ b/mkdocs/tests/structure/file_tests.py
@@ -8,27 +8,6 @@ from mkdocs.tests.base import PathAssertionMixin, load_config, tempdir
 
 
 class TestFiles(PathAssertionMixin, unittest.TestCase):
-    def test_file_eq(self):
-        file = File('a.md', '/path/to/docs', '/path/to/site', use_directory_urls=False)
-        self.assertTrue(
-            file == File('a.md', '/path/to/docs', '/path/to/site', use_directory_urls=False)
-        )
-
-    def test_file_ne(self):
-        file = File('a.md', '/path/to/docs', '/path/to/site', use_directory_urls=False)
-        # Different filename
-        self.assertTrue(
-            file != File('b.md', '/path/to/docs', '/path/to/site', use_directory_urls=False)
-        )
-        # Different src_path
-        self.assertTrue(
-            file != File('a.md', '/path/to/other', '/path/to/site', use_directory_urls=False)
-        )
-        # Different URL
-        self.assertTrue(
-            file != File('a.md', '/path/to/docs', '/path/to/site', use_directory_urls=True)
-        )
-
     @unittest.skipUnless(sys.platform.startswith("win"), "requires Windows")
     def test_src_path_windows(self):
         f = File('foo\\a.md', '/path/to/docs', '/path/to/site', use_directory_urls=False)
diff --git a/mkdocs/tests/structure/page_tests.py b/mkdocs/tests/structure/page_tests.py
index 3620f566..f24078bd 100644
--- a/mkdocs/tests/structure/page_tests.py
+++ b/mkdocs/tests/structure/page_tests.py
@@ -960,7 +960,7 @@ class RelativePathExtensionTests(unittest.TestCase):
             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.",
+                logs="WARNING:Doc file 'index.md' contains a link 'non-existent.md', but the target is not found among documentation files.",
             ),
             '<a href="non-existent.md">link</a>',
         )
@@ -969,7 +969,7 @@ class RelativePathExtensionTests(unittest.TestCase):
                 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.",
+                logs="INFO:Doc file 'sub/index.md' contains a link '../non-existent.md', but the target 'non-existent.md' is not found among documentation files.",
             ),
             '<a href="../non-existent.md">link</a>',
         )
@@ -1062,7 +1062,7 @@ class RelativePathExtensionTests(unittest.TestCase):
             self.get_rendered_result(
                 content='![image](../image.png)',
                 files=['foo/bar.md', 'foo/image.png'],
-                logs="WARNING:Doc file 'foo/bar.md' contains a relative link '../image.png', but the target 'image.png' is not found among documentation files. Did you mean 'image.png'?",
+                logs="WARNING:Doc file 'foo/bar.md' contains a link '../image.png', but the target 'image.png' is not found among documentation files. Did you mean 'image.png'?",
             ),
             '<img alt="image" src="../image.png" />',
         )
@@ -1102,7 +1102,7 @@ class RelativePathExtensionTests(unittest.TestCase):
             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. Did you mean 'mailto:mail@example.com'?",
+                logs="WARNING:Doc file 'index.md' contains a link 'mail@example.com', but the target is not found among documentation files. Did you mean 'mailto:mail@example.com'?",
             ),
             '<a href="mail@example.com">contact</a>',
         )