diff --git a/crates/zensical/src/watcher.rs b/crates/zensical/src/watcher.rs index bff4658..6becf6a 100644 --- a/crates/zensical/src/watcher.rs +++ b/crates/zensical/src/watcher.rs @@ -158,7 +158,8 @@ impl Watcher { } sources.push(SourceMount::new(path, String::from("."))); - // Track seen files to restart on config or template change + // Original paths are tracked so discovery of a symlink and its target + // is not mistaken for a change to an already discovered file. let mut seen = BTreeSet::new(); // Normalize watched paths once, so path comparisons stay stable across @@ -235,9 +236,7 @@ impl Watcher { } // Check if the config file reloaded, and terminate agent, // as we need to kick off the entire pipeline again - if event_path == config_path - && !seen.insert(config_path.clone()) - { + if event_path == config_path && !seen.insert(event.path()) { return Err(Error::Disconnected); } @@ -245,7 +244,7 @@ impl Watcher { // and restart the build if we've already seen the file for dir in &theme_dirs { if event_path.starts_with(dir) - && !seen.insert(event_path.clone()) + && !seen.insert(event.path()) { return Err(Error::Disconnected); } @@ -254,7 +253,7 @@ impl Watcher { // Check if one of the source files managed by mkdocstrings // changed, and restart the build if watched_files.contains(&event_path) - && !seen.insert(event_path.clone()) + && !seen.insert(event.path()) { return Err(Error::Disconnected); } @@ -298,11 +297,19 @@ impl Watcher { continue; } - // Compute an identifier from the path and known contexts. + // Rebuilds can be triggered by external watched dependencies. + // Only paths with a source mount are admitted to the workflow. + let mounted = |path: &Path| { + sources + .iter() + .any(|source| path.starts_with(&source.root)) + }; match event { // File was created or modified Event::Create { path, .. } - | Event::Modify { path, .. } => { + | Event::Modify { path, .. } + if mounted(&path) => + { batch.push(Change::Insert( to_id(&path, &sources)?.into(), Source::from(path), @@ -311,21 +318,26 @@ impl Watcher { // File was renamed Event::Rename { from, to, .. } => { - batch.push(Change::Remove( - to_id(&from, &sources)?.into(), - )); - batch.push(Change::Insert( - to_id(&to, &sources)?.into(), - Source::from(to), - )); + if mounted(&from) { + batch.push(Change::Remove( + to_id(&from, &sources)?.into(), + )); + } + if mounted(&to) { + batch.push(Change::Insert( + to_id(&to, &sources)?.into(), + Source::from(to), + )); + } } // File was removed - Event::Remove { path, .. } => { + Event::Remove { path, .. } if mounted(&path) => { batch.push(Change::Remove( to_id(&path, &sources)?.into(), )); } + _ => {} } } diff --git a/python/tests/integration/test_config.py b/python/tests/integration/test_config.py index ccf0df5..7c8b771 100644 --- a/python/tests/integration/test_config.py +++ b/python/tests/integration/test_config.py @@ -158,6 +158,73 @@ def test_build_with_config_in_watch( assert (tmp_path / "site" / "index.html").is_file() +@pytest.mark.parametrize("toml", [False, True], ids=["yaml", "toml"]) +@pytest.mark.parametrize( + "watch_path", ["extras/page.md", "extras", "docs/index.md", "."] +) +@pytest.mark.parametrize("external_target", [False, True]) +def test_build_with_symlinked_page_in_watch( + tmp_path: Path, toml: bool, watch_path: str, external_target: bool +) -> None: + project = tmp_path / "project" + project.mkdir() + if external_target and watch_path.startswith("extras"): + watch_path = "../" + watch_path + + config = ( + _make_toml_project(project, toml_extra=f'watch = ["{watch_path}"]') + if toml + else _make_yml_project(project, yml_extra=f'watch: ["{watch_path}"]') + ) + extras = (tmp_path if external_target else project) / "extras" + extras.mkdir() + target = extras / "page.md" + target.write_text("# Symlinked page\n", encoding="utf-8") + + # The watched target is exposed through two independent docs paths. + (project / "docs" / "index.md").unlink() + for name in ["index.md", "alias.md"]: + try: + (project / "docs" / name).symlink_to(target) + except OSError as error: + pytest.skip(f"symbolic links unavailable: {error}") + + _build(config) + + for output in ["index.html", "alias/index.html"]: + html = (project / "site" / output).read_text(encoding="utf-8") + assert "Symlinked page" in html + + +@pytest.mark.parametrize("toml", [False, True], ids=["yaml", "toml"]) +@pytest.mark.parametrize("dependencies", ["macros", "watch", "both"]) +def test_build_ignores_dangling_symlinks_in_watched_directories( + tmp_path: Path, toml: bool, dependencies: str +) -> None: + if toml: + options = 'watch = ["docs"]\n' if dependencies != "macros" else "" + if dependencies != "watch": + options += "[project.plugins.macros]\n" + config = _make_toml_project(tmp_path, toml_extra=options) + else: + options = "watch: [docs]\n" if dependencies != "macros" else "" + if dependencies != "watch": + options += "plugins: [macros]\n" + config = _make_yml_project(tmp_path, yml_extra=options) + + # A dangling link is ignored while the existing page is still rendered. + try: + (tmp_path / "docs" / "broken.md").symlink_to("missing.md") + except OSError as error: + pytest.skip(f"symbolic links unavailable: {error}") + + _build(config) + + html = (tmp_path / "site" / "index.html").read_text(encoding="utf-8") + assert "Hello" in html + assert not (tmp_path / "site" / "broken").exists() + + @pytest.mark.parametrize("config_format", ["yaml", "toml"]) @pytest.mark.parametrize( ("enabled", "version_selector", "expected"), diff --git a/python/tests/integration/test_macros.py b/python/tests/integration/test_macros.py index 3fd958f..395f233 100644 --- a/python/tests/integration/test_macros.py +++ b/python/tests/integration/test_macros.py @@ -29,6 +29,7 @@ import json import os import subprocess import sys +import time from textwrap import dedent from typing import TYPE_CHECKING from urllib.parse import parse_qs, urlsplit @@ -74,6 +75,99 @@ def _write_config( return config +@pytest.mark.parametrize("toml", [False, True], ids=["yaml", "toml"]) +@pytest.mark.parametrize("target_location", ["docs", "project", "external"]) +def test_default_include_directory_builds_symlinked_pages( + tmp_path: Path, toml: bool, target_location: str +) -> None: + project = tmp_path / "project" + project.mkdir() + config = _write_config(project, {"on_error_fail": True}, toml=toml) + docs = project / "content" + target_dir = {"docs": docs, "project": project, "external": tmp_path} + target = target_dir[target_location] / "page.md" + target.write_text("# Hello\n\nValue: {{ 1 + 1 }}\n", encoding="utf-8") + + # Two aliases are discovered alongside the target without ending the build. + for name in ["index.md", "alias.md"]: + try: + (docs / name).symlink_to(target) + except OSError as error: + pytest.skip(f"symbolic links unavailable: {error}") + + zensical.build(str(config), {"clean": True, "strict": False}) + + for output in ["index.html", "alias/index.html"]: + html = (project / "site" / output).read_text(encoding="utf-8") + assert "Value: 2" in html + + +@pytest.mark.parametrize("dependency", ["page", "include"]) +def test_serve_rebuilds_after_symlinked_macro_dependency_changes( + tmp_path: Path, dependency: str +) -> None: + config = _write_config(tmp_path, {"on_error_fail": True}) + with config.open("a", encoding="utf-8") as stream: + stream.write("dev_addr: 127.0.0.1:0\n") + + docs = tmp_path / "content" + target = tmp_path / ("page.md" if dependency == "page" else "fragment.j2") + target.write_text("First value: {{ 1 + 1 }}\n", encoding="utf-8") + alias = docs / ("index.md" if dependency == "page" else "fragment.j2") + try: + alias.symlink_to(target) + except OSError as error: + pytest.skip(f"symbolic links unavailable: {error}") + + if dependency == "include": + (docs / "index.md").write_text( + '{% include "fragment.j2" %}\n', encoding="utf-8" + ) + + output = tmp_path / "site" / "index.html" + with (tmp_path / "serve.log").open("w+", encoding="utf-8") as log: + process = subprocess.Popen( # noqa: S603 + [sys.executable, "-m", "zensical", "serve", "-f", str(config)], + cwd=tmp_path, + stdout=log, + stderr=subprocess.STDOUT, + ) + + def wait_for(text: str) -> None: + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + try: + content = output.read_text(encoding="utf-8") + except FileNotFoundError: + content = "" + if text in content: + return + if process.poll() is not None: + break + time.sleep(0.02) + + log.seek(0) + pytest.fail(f"serve did not rebuild: {log.read()}") + + try: + wait_for("First value: 2") + + # Canonical targets are still watched after alias discovery. + target.write_text("Updated value: {{ 1 + 1 }}\n", encoding="utf-8") + timestamp = target.stat().st_mtime + 2 + os.utime(target, (timestamp, timestamp)) + + wait_for("Updated value: 2") + assert process.poll() is None + finally: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + + @pytest.mark.parametrize("toml", [False, True], ids=["yaml", "toml"]) def test_force_render_paths_uses_docs_paths_and_page_metadata( tmp_path: Path, toml: bool diff --git a/python/zensical/config.py b/python/zensical/config.py index c36dd26..9b4b18f 100644 --- a/python/zensical/config.py +++ b/python/zensical/config.py @@ -1320,6 +1320,8 @@ def _list_macros_files(config: dict, config_file: str) -> set[tuple[str, int]]: for root, _, files in os.walk(candidate_dir): for file in files: file_path = os.path.join(root, file) + if not os.path.isfile(file_path): + continue mtime = int(os.path.getmtime(file_path)) files_with_mtime.add((file_path, mtime)) @@ -1353,6 +1355,8 @@ def _list_watch_files(config: dict, config_file: str) -> set[tuple[str, int]]: for dirpath, _, files in os.walk(path): for file in files: file_path = os.path.join(dirpath, file) + if not os.path.isfile(file_path): + continue mtime = int(os.path.getmtime(file_path)) files_with_mtime.add((file_path, mtime)) return files_with_mtime