From 2070bf1cbe4ca6eff4078113f6d3665dcb43f77c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Mazzucotelli?= Date: Thu, 17 Sep 2026 17:04:44 +0000 Subject: [PATCH] fix: don't add dotfiles to final site (#945) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Timothée Mazzucotelli --- crates/zensical/src/compat/mkdocs/resource.rs | 30 ++++++++++++++++ crates/zensical/src/path/site.rs | 7 ++++ crates/zensical/src/path/source.rs | 7 ++++ crates/zensical/src/workflow.rs | 21 +++++++----- python/tests/integration/test_awesome_nav.py | 34 +++++++++++++++++++ 5 files changed, 91 insertions(+), 8 deletions(-) diff --git a/crates/zensical/src/compat/mkdocs/resource.rs b/crates/zensical/src/compat/mkdocs/resource.rs index b868610..eab6226 100644 --- a/crates/zensical/src/compat/mkdocs/resource.rs +++ b/crates/zensical/src/compat/mkdocs/resource.rs @@ -142,6 +142,9 @@ impl Classifier { .unwrap_or(usize::MAX) }; let path = id.location().parse::()?; + if path.is_hidden() { + return Ok(None); + } if is_docs { if has_extension(&path, "md") || meta::claims(path.as_str(), &self.meta) @@ -267,6 +270,33 @@ mod tests { } } + #[test] + fn excludes_hidden_resources() { + let classifier = classifier(); + for (context, path) in [ + ("docs", ".private.yml"), + ("docs", "section/.nav.yml"), + ("templates/0", ".icons/logo.svg"), + ("templates/0", "assets/.private.css"), + ] { + assert!( + classifier + .classify(&id(context, path), &source(path)) + .unwrap() + .is_none(), + "{path}" + ); + } + + assert!(classifier + .classify( + &id("docs", "assets/app.min.js"), + &source("assets/app.min.js") + ) + .unwrap() + .is_some()); + } + #[test] fn ignores_sources_outside_docs_and_themes() { assert!(classifier() diff --git a/crates/zensical/src/path/site.rs b/crates/zensical/src/path/site.rs index b2969d1..66c06e1 100644 --- a/crates/zensical/src/path/site.rs +++ b/crates/zensical/src/path/site.rs @@ -63,6 +63,13 @@ impl SitePath { self.0.components() } + /// Returns whether any output path component starts with a dot. + #[must_use] + pub fn is_hidden(&self) -> bool { + self.components() + .any(|component| component.starts_with('.')) + } + /// Returns the output file name. #[must_use] pub fn file_name(&self) -> &str { diff --git a/crates/zensical/src/path/source.rs b/crates/zensical/src/path/source.rs index 19dad94..5001d85 100644 --- a/crates/zensical/src/path/source.rs +++ b/crates/zensical/src/path/source.rs @@ -63,6 +63,13 @@ impl SourcePath { self.0.components() } + /// Returns whether any source path component starts with a dot. + #[must_use] + pub fn is_hidden(&self) -> bool { + self.components() + .any(|component| component.starts_with('.')) + } + /// Returns the source file name. #[must_use] pub fn file_name(&self) -> &str { diff --git a/crates/zensical/src/workflow.rs b/crates/zensical/src/workflow.rs index 990fa0a..13fc449 100644 --- a/crates/zensical/src/workflow.rs +++ b/crates/zensical/src/workflow.rs @@ -430,14 +430,19 @@ fn route_markdown( .expect("invariant"), ); let config = config.clone(); - files - .filter(move |id: &Id| matcher.is_match(id).expect("invariant")) - .map(move |id: &Id, input: &Input| { - Ok::<_, crate::path::PathError>(RoutedMarkdown { - input: input.clone(), - route: PageRoute::new(&config, id)?, - }) - }) + files.filter_map(move |id: &Id, input: &Input| { + if !matcher.is_match(id).expect("invariant") { + return Ok(None); + } + let source = id.location().parse::()?; + if source.is_hidden() { + return Ok(None); + } + Ok::<_, crate::path::PathError>(Some(RoutedMarkdown { + input: input.clone(), + route: PageRoute::from_source(&config, source)?, + })) + }) } /// Create a stream to process routed Markdown files. diff --git a/python/tests/integration/test_awesome_nav.py b/python/tests/integration/test_awesome_nav.py index 948fc43..390f52b 100644 --- a/python/tests/integration/test_awesome_nav.py +++ b/python/tests/integration/test_awesome_nav.py @@ -168,6 +168,40 @@ nav: ] +def test_dotfiles_configure_navigation_without_being_published( + tmp_path: Path, +) -> None: + """Navigation can read dotfiles without copying them into the site.""" + docs = tmp_path / "docs" + section = docs / "section" + section.mkdir(parents=True) + _write_template(tmp_path) + (docs / "index.md").write_text("# Home\n", encoding="utf-8") + (section / "page.md").write_text("# Page\n", encoding="utf-8") + (section / "asset.yml").write_text("public: true\n", encoding="utf-8") + (docs / ".hidden.md").write_text("# Hidden page\n", encoding="utf-8") + (docs / ".nav.yml").write_text( + "nav: [section, index.md]\n", encoding="utf-8" + ) + (section / ".nav.yml").write_text( + "title: Configured section\n", encoding="utf-8" + ) + (docs / ".private.yml").write_text("private: true\n", encoding="utf-8") + + zensical.build(str(_write_config(tmp_path)), _BUILD_OPTIONS) + + assert _items(tmp_path) == [ + (0, "Configured section", ""), + (1, "Page", "section/page/"), + (0, "Home", ""), + ] + assert (tmp_path / "site" / "section" / "asset.yml").exists() + assert not (tmp_path / "site" / ".nav.yml").exists() + assert not (tmp_path / "site" / "section" / ".nav.yml").exists() + assert not (tmp_path / "site" / ".private.yml").exists() + assert not (tmp_path / "site" / ".hidden").exists() + + def test_explicit_pages_are_claimed_before_earlier_patterns( tmp_path: Path, ) -> None: