Repository: mkdocs/mkdocs

Issue:
Allow multiple hook files
When there are multiple hook files, only one instance of a type of plugin can be used. For example, I had a hook file that defined `on_page_read_source` and when I added a second that defined the same, only the hook that came last executed which by sheer luck I discovered while debugging something else.

Discussion:

Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 629f57eb..b89ac489 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,7 +1,9 @@
 name: CI
-
-on: [push, pull_request]
-
+on:
+  push:
+  pull_request:
+  schedule:
+    - cron: '0 6 * * 6'
 jobs:
   test:
     strategy:
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 00000000..66223496
--- /dev/null
+++ b/.github/workflows/docs.yml
@@ -0,0 +1,20 @@
+name: Docs
+on:
+  push:
+  pull_request:
+  schedule:
+    - cron: '0 6 * * 6'
+jobs:
+  build:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Download source
+        uses: actions/checkout@v4
+      - name: Install Python
+        uses: actions/setup-python@v4
+        with:
+          python-version: '3.11'
+      - name: Install dependencies
+        run: pip install --no-deps -r requirements/requirements-docs.txt
+      - name: Build site
+        run: mkdocs build --strict
diff --git a/mkdocs/commands/build.py b/mkdocs/commands/build.py
index 2c9e9793..2eec30aa 100644
--- a/mkdocs/commands/build.py
+++ b/mkdocs/commands/build.py
@@ -110,7 +110,9 @@ def _build_theme_template(
             log.debug(f"Gzipping template: {template_name}")
             gz_filename = f'{output_path}.gz'
             with open(gz_filename, 'wb') as f:
-                timestamp = utils.get_build_timestamp()
+                timestamp = utils.get_build_timestamp(
+                    pages=[f.page for f in files.documentation_pages() if f.page is not None]
+                )
                 with gzip.GzipFile(
                     fileobj=f, filename=gz_filename, mode='wb', mtime=timestamp
                 ) as gz_buf:
diff --git a/mkdocs/plugins.py b/mkdocs/plugins.py
index 98a99f38..aaf3b203 100644
--- a/mkdocs/plugins.py
+++ b/mkdocs/plugins.py
@@ -506,9 +506,15 @@ class PluginCollection(dict, MutableMapping[str, BasePlugin]):
             for sub in method.methods:
                 self._register_event(event_name, sub, plugin_name=plugin_name)
         else:
-            utils.insort(
-                self.events[event_name], method, key=lambda m: -getattr(m, 'mkdocs_priority', 0)
-            )
+            events = self.events[event_name]
+            if event_name == 'page_read_source' and len(events) == 1:
+                plugin1 = self._event_origins.get(next(iter(events)), '<unknown>')
+                plugin2 = plugin_name or '<unknown>'
+                log.warning(
+                    "Multiple 'on_page_read_source' handlers can't work "
+                    f"(both plugins '{plugin1}' and '{plugin2}' registered one)."
+                )
+            utils.insort(events, method, key=lambda m: -getattr(m, 'mkdocs_priority', 0))
             if plugin_name:
                 try:
                     self._event_origins[method] = plugin_name
diff --git a/mkdocs/structure/pages.py b/mkdocs/structure/pages.py
index 84c1430f..f5bd041f 100644
--- a/mkdocs/structure/pages.py
+++ b/mkdocs/structure/pages.py
@@ -42,7 +42,7 @@ class Page(StructureItem):
         self.next_page = None
         self.active = False
 
-        self.update_date = get_build_date()
+        self.update_date: str = get_build_date()
 
         self._set_canonical_url(config.get('site_url', None))
         self._set_edit_url(
diff --git a/mkdocs/utils/__init__.py b/mkdocs/utils/__init__.py
index 1560dfa1..602e99fe 100644
--- a/mkdocs/utils/__init__.py
+++ b/mkdocs/utils/__init__.py
@@ -44,18 +44,19 @@ markdown_extensions = (
 )
 
 
-def get_build_timestamp() -> int:
+def get_build_timestamp(*, pages: Collection[Page] | None = None) -> int:
     """
-    Returns the number of seconds since the epoch.
+    Returns the number of seconds since the epoch for the latest updated page.
 
-    Support SOURCE_DATE_EPOCH environment variable for reproducible builds.
-    See https://reproducible-builds.org/specs/source-date-epoch/
+    In reality this is just today's date because that's how pages' update time is populated.
     """
-    source_date_epoch = os.environ.get('SOURCE_DATE_EPOCH')
-    if source_date_epoch is None:
-        return int(datetime.now(timezone.utc).timestamp())
-
-    return int(source_date_epoch)
+    if pages:
+        # Lexicographic comparison is OK for ISO date.
+        date_string = max(p.update_date for p in pages)
+        dt = datetime.fromisoformat(date_string)
+    else:
+        dt = get_build_datetime()
+    return int(dt.timestamp())
 
 
 def get_build_datetime() -> datetime:
diff --git a/pyproject.toml b/pyproject.toml
index 423351e4..f1fe5a41 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -102,6 +102,9 @@ dependencies = [
     "setuptools; python_version >= '3.12'" # Workaround: babel doesn't declare its dependency
 ]
 
+[tool.hatch.env]
+requires = ["hatch-mkdocs", "hatch-pip-compile"]
+
 [tool.hatch.envs.default.scripts]
 all = [
     "hatch run style:fix",
@@ -135,8 +138,8 @@ matrix.type.scripts = [
     { key = "with-coverage", value = "_coverage", if = ["default"] },
 ]
 
-[tool.hatch.envs.integration]
-template = "docs"
+[tool.hatch.env.collectors.mkdocs.integration]
+path = "mkdocs.yml"
 [tool.hatch.envs.integration.scripts]
 test = "python -m mkdocs.tests.integration"
 [[tool.hatch.envs.integration.matrix]]
@@ -197,17 +200,11 @@ js = "npm exec --yes -- jshint mkdocs/"
 css = "npm exec --yes -- csslint --quiet mkdocs/"
 check = ["markdown", "js", "css", "spelling"]
 
+[tool.hatch.env.collectors.mkdocs.docs]
+path = "mkdocs.yml"
 [tool.hatch.envs.docs]
-dependencies = [
-    "Markdown >=3.3.3",
-    "mdx_gh_links >=0.2",
-    "markdown-callouts >=0.3.0",
-    "mkdocs-literate-nav >=0.5.0",
-    "mkdocs-redirects >=1.0.1",
-    "pymdown-extensions >=8.0.1",
-    "mkdocstrings-python >=0.7.1",
-    "mkdocs-click >=0.8.0",
-]
+type = "pip-compile"
+pip-compile-hashes = false
 
 [tool.black]
 line-length = 100
diff --git a/requirements/requirements-docs.txt b/requirements/requirements-docs.txt
new file mode 100644
index 00000000..20bc8169
--- /dev/null
+++ b/requirements/requirements-docs.txt
@@ -0,0 +1,98 @@
+#
+# This file is autogenerated by hatch-pip-compile with Python 3.11
+#
+# - markdown-callouts
+# - mdx-gh-links
+# - mkdocs
+# - mkdocs-autorefs
+# - mkdocs-click
+# - mkdocs-literate-nav
+# - mkdocs-redirects
+# - mkdocstrings
+# - mkdocstrings-python
+# - pymdown-extensions
+#
+
+click==8.1.7
+    # via
+    #   mkdocs
+    #   mkdocs-click
+    #   mkdocstrings
+colorama==0.4.6
+    # via griffe
+ghp-import==2.1.0
+    # via mkdocs
+griffe==0.38.0
+    # via mkdocstrings-python
+jinja2==3.1.2
+    # via
+    #   mkdocs
+    #   mkdocstrings
+markdown==3.5.1
+    # via
+    #   markdown-callouts
+    #   mdx-gh-links
+    #   mkdocs
+    #   mkdocs-autorefs
+    #   mkdocs-click
+    #   mkdocstrings
+    #   pymdown-extensions
+markdown-callouts==0.3.0
+    # via hatch.envs.docs
+markupsafe==2.1.3
+    # via
+    #   jinja2
+    #   mkdocs
+    #   mkdocstrings
+mdx-gh-links==0.3.1
+    # via hatch.envs.docs
+mergedeep==1.3.4
+    # via mkdocs
+mkdocs==1.5.3
+    # via
+    #   hatch.envs.docs
+    #   mkdocs-autorefs
+    #   mkdocs-literate-nav
+    #   mkdocs-redirects
+    #   mkdocstrings
+mkdocs-autorefs==0.5.0
+    # via
+    #   hatch.envs.docs
+    #   mkdocstrings
+mkdocs-click==0.8.1
+    # via hatch.envs.docs
+mkdocs-literate-nav==0.6.1
+    # via hatch.envs.docs
+mkdocs-redirects==1.2.1
+    # via hatch.envs.docs
+mkdocstrings==0.24.0
+    # via
+    #   hatch.envs.docs
+    #   mkdocstrings-python
+mkdocstrings-python==1.7.5
+    # via hatch.envs.docs
+packaging==23.2
+    # via mkdocs
+pathspec==0.11.2
+    # via mkdocs
+platformdirs==4.0.0
+    # via
+    #   mkdocs
+    #   mkdocstrings
+pymdown-extensions==10.4
+    # via
+    #   hatch.envs.docs
+    #   mkdocstrings
+python-dateutil==2.8.2
+    # via ghp-import
+pyyaml==6.0.1
+    # via
+    #   mkdocs
+    #   pymdown-extensions
+    #   pyyaml-env-tag
+pyyaml-env-tag==0.1
+    # via mkdocs
+six==1.16.0
+    # via python-dateutil
+watchdog==3.0.0
+    # via mkdocs


Test patch:
diff --git a/mkdocs/tests/build_tests.py b/mkdocs/tests/build_tests.py
index 95145298..ae1b474c 100644
--- a/mkdocs/tests/build_tests.py
+++ b/mkdocs/tests/build_tests.py
@@ -225,7 +225,7 @@ class BuildTests(PathAssertionMixin, unittest.TestCase):
     def test_build_theme_template(self, mock_build_template, mock_write_file):
         cfg = load_config()
         env = cfg.theme.get_env()
-        build._build_theme_template('main.html', env, mock.Mock(), cfg, mock.Mock())
+        build._build_theme_template('main.html', env, Files([]), cfg, mock.Mock())
         mock_write_file.assert_called_once()
         mock_build_template.assert_called_once()
 
@@ -238,7 +238,7 @@ class BuildTests(PathAssertionMixin, unittest.TestCase):
     ):
         cfg = load_config(site_dir=site_dir)
         env = cfg.theme.get_env()
-        build._build_theme_template('sitemap.xml', env, mock.Mock(), cfg, mock.Mock())
+        build._build_theme_template('sitemap.xml', env, Files([]), cfg, mock.Mock())
         mock_write_file.assert_called_once()
         mock_build_template.assert_called_once()
         mock_gzip_gzipfile.assert_called_once()
@@ -249,7 +249,7 @@ class BuildTests(PathAssertionMixin, unittest.TestCase):
         cfg = load_config()
         env = cfg.theme.get_env()
         with self.assertLogs('mkdocs') as cm:
-            build._build_theme_template('missing.html', env, mock.Mock(), cfg, mock.Mock())
+            build._build_theme_template('missing.html', env, Files([]), cfg, mock.Mock())
         self.assertEqual(
             '\n'.join(cm.output),
             "WARNING:mkdocs.commands.build:Template skipped: 'missing.html' not found in theme directories.",
@@ -263,7 +263,7 @@ class BuildTests(PathAssertionMixin, unittest.TestCase):
         cfg = load_config()
         env = cfg.theme.get_env()
         with self.assertLogs('mkdocs') as cm:
-            build._build_theme_template('main.html', env, mock.Mock(), cfg, mock.Mock())
+            build._build_theme_template('main.html', env, Files([]), cfg, mock.Mock())
         self.assertEqual(
             '\n'.join(cm.output),
             "INFO:mkdocs.commands.build:Template skipped: 'main.html' generated empty output.",
diff --git a/mkdocs/tests/config/base_tests.py b/mkdocs/tests/config/base_tests.py
index a57470f3..fe738a20 100644
--- a/mkdocs/tests/config/base_tests.py
+++ b/mkdocs/tests/config/base_tests.py
@@ -65,7 +65,7 @@ class ConfigBaseTests(unittest.TestCase):
             self.assertTrue(isinstance(cfg, defaults.MkDocsConfig))
             self.assertEqual(cfg.site_name, 'MkDocs Test')
 
-    @tempdir
+    @tempdir()
     def test_load_default_file_with_yaml(self, temp_dir):
         """Test that `mkdocs.yml` will be loaded when '--config' is not set."""
         with open(os.path.join(temp_dir, 'mkdocs.yaml'), 'w') as config_file:
@@ -125,7 +125,7 @@ class ConfigBaseTests(unittest.TestCase):
         self.assertTrue(isinstance(cfg, defaults.MkDocsConfig))
         self.assertEqual(cfg.site_name, 'MkDocs Test')
 
-    @tempdir
+    @tempdir()
     def test_load_missing_required(self, temp_dir):
         """`site_name` is a required setting."""
         with open(os.path.join(temp_dir, 'mkdocs.yml'), 'w') as config_file:
diff --git a/mkdocs/tests/localization_tests.py b/mkdocs/tests/localization_tests.py
index 05a966c3..0dfbe275 100644
--- a/mkdocs/tests/localization_tests.py
+++ b/mkdocs/tests/localization_tests.py
@@ -44,7 +44,7 @@ class LocalizationTests(unittest.TestCase):
         )
         self.env.install_null_translations.assert_called_once()
 
-    @tempdir
+    @tempdir()
     def test_translations_found(self, tdir):
         translations = mock.Mock()
 
diff --git a/mkdocs/tests/plugin_tests.py b/mkdocs/tests/plugin_tests.py
index 8c09cf4f..7f32146f 100644
--- a/mkdocs/tests/plugin_tests.py
+++ b/mkdocs/tests/plugin_tests.py
@@ -159,7 +159,13 @@ class TestPluginCollection(unittest.TestCase):
 
         collection = plugins.PluginCollection()
         collection['dummy'] = dummy = DummyPlugin()
-        collection['prio'] = prio = PrioPlugin()
+        with self.assertLogs('mkdocs', level='WARNING') as cm:
+            collection['prio'] = prio = PrioPlugin()
+        self.assertEqual(
+            '\n'.join(cm.output),
+            "WARNING:mkdocs.plugins:Multiple 'on_page_read_source' handlers can't work (both plugins 'dummy' and 'prio' registered one).",
+        )
+
         self.assertEqual(
             collection.events['page_content'],
             [prio.on_page_content, dummy.on_page_content],
@@ -188,7 +194,12 @@ class TestPluginCollection(unittest.TestCase):
         plugin1 = DummyPlugin()
         collection['foo'] = plugin1
         plugin2 = DummyPlugin()
-        collection['bar'] = plugin2
+        with self.assertLogs('mkdocs', level='WARNING') as cm:
+            collection['bar'] = plugin2
+        self.assertEqual(
+            '\n'.join(cm.output),
+            "WARNING:mkdocs.plugins:Multiple 'on_page_read_source' handlers can't work (both plugins 'foo' and 'bar' registered one).",
+        )
         self.assertEqual(list(collection.items()), [('foo', plugin1), ('bar', plugin2)])
 
     def test_run_event_on_collection(self):
@@ -208,7 +219,12 @@ class TestPluginCollection(unittest.TestCase):
         collection['foo'] = plugin1
         plugin2 = DummyPlugin()
         plugin2.load_config({'foo': 'second'})
-        collection['bar'] = plugin2
+        with self.assertLogs('mkdocs', level='WARNING') as cm:
+            collection['bar'] = plugin2
+        self.assertEqual(
+            '\n'.join(cm.output),
+            "WARNING:mkdocs.plugins:Multiple 'on_page_read_source' handlers can't work (both plugins 'foo' and 'bar' registered one).",
+        )
         self.assertEqual(
             collection.on_page_content('page content', page=None, config={}, files=[]),
             'second new page content',