From 75995d9b5d988c8efd62fbb77ee8735860a9c189 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Mazzucotelli?= Date: Mon, 12 Oct 2026 12:41:21 +0200 Subject: [PATCH] feature: support `exclude_docs`, `draft_docs`, and `not_in_nav` settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Timothée Mazzucotelli --- crates/zensical-watch/src/agent.rs | 15 + crates/zensical-watch/src/agent/manager.rs | 30 +- .../src/compat/mkdocs/plugin/exclude.rs | 12 +- .../src/compat/mkdocs/plugin/literate_nav.rs | 18 +- crates/zensical/src/compat/mkdocs/resource.rs | 35 +- crates/zensical/src/config.rs | 4 + crates/zensical/src/config/inclusion.rs | 114 +++++ crates/zensical/src/config/project.rs | 6 + crates/zensical/src/lib.rs | 4 +- crates/zensical/src/watcher.rs | 39 +- crates/zensical/src/workflow.rs | 27 +- .../tests/integration/test_file_inclusion.py | 451 ++++++++++++++++++ python/zensical/config.py | 6 + 13 files changed, 729 insertions(+), 32 deletions(-) create mode 100644 crates/zensical/src/config/inclusion.rs create mode 100644 python/tests/integration/test_file_inclusion.py diff --git a/crates/zensical-watch/src/agent.rs b/crates/zensical-watch/src/agent.rs index 8499152..a3302c7 100644 --- a/crates/zensical-watch/src/agent.rs +++ b/crates/zensical-watch/src/agent.rs @@ -83,6 +83,20 @@ impl Agent { /// /// Panics if thread creation fails. pub fn new(timeout: Duration, mode: bool, f: F) -> Self + where + F: FnMut(Vec>) -> Result + Send + 'static, + { + Self::with_manager(timeout, mode, Manager::new(), f) + } + + /// A file agent is created with the supplied discovery settings. + /// + /// # Panics + /// + /// A panic is raised if thread creation fails. + pub fn with_manager( + timeout: Duration, mode: bool, manager: Manager, f: F, + ) -> Self where F: FnMut(Vec>) -> Result + Send + 'static, { @@ -92,6 +106,7 @@ impl Agent { .receiver(receiver) .handler(f) .monitor(Monitor::default()) + .manager(manager) .build()?; // Start event loop, which will automatically exit when the file diff --git a/crates/zensical-watch/src/agent/manager.rs b/crates/zensical-watch/src/agent/manager.rs index 71dfd3c..026b7d1 100644 --- a/crates/zensical-watch/src/agent/manager.rs +++ b/crates/zensical-watch/src/agent/manager.rs @@ -94,6 +94,8 @@ pub struct Manager { links: BTreeMap, Vec>>, /// File identifiers map. ids: HashMap>, + /// Roots beneath which hidden directories are discovered. + hidden_roots: Vec, } // ---------------------------------------------------------------------------- @@ -101,6 +103,13 @@ pub struct Manager { // ---------------------------------------------------------------------------- impl Manager { + /// Hidden directories beneath the given absolute root are included. + #[must_use] + pub fn include_hidden(mut self, root: PathBuf) -> Self { + self.hidden_roots.push(root); + self + } + /// Creates a file manager. /// /// # Examples @@ -294,7 +303,7 @@ impl Manager { /// Handles a creation event. fn handle_create(&mut self, root: &PathBuf) -> Vec> { - let iter = walk(root).filter_map(|item| { + let iter = walk(root, &self.hidden_roots).filter_map(|item| { item.and_then(|entry| { let kind = entry.file_type(); let path = entry.into_path(); @@ -355,7 +364,7 @@ impl Manager { /// Handles a rename event. fn handle_rename(&mut self, root: &PathBuf) -> Vec> { - let iter = walk(root).filter_map(|item| { + let iter = walk(root, &self.hidden_roots).filter_map(|item| { item.and_then(|entry| { let path = entry.path(); @@ -759,7 +768,9 @@ impl Manager { /// defined. Although it's possible to sort the files inside of a folder before /// yielding, it would be a significant performance hit for a merely cosmetic /// benefit, as the order of files inside of a folder is not relevant for us. -fn walk

(path: P) -> impl Iterator> +fn walk

( + path: P, hidden_roots: &[PathBuf], +) -> impl Iterator> where P: AsRef, { @@ -767,13 +778,14 @@ where .follow_root_links(false) .follow_links(false) .into_iter() - // For now we skip hidden directories to speed up the build, since we - // do not need to watch icons, but in general we need to find a better - // method in the future when we integrate large asset directories and - // libraries that include thousands of icons. - .filter_entry(|item| { + // Hidden directories are skipped outside explicitly selected roots, + // so theme icons and project caches are not recursively discovered. + .filter_entry(move |item| { !(item.file_type().is_dir() - && item.file_name().to_str().unwrap_or("").starts_with('.')) + && item.file_name().to_str().unwrap_or("").starts_with('.') + && !hidden_roots + .iter() + .any(|root| item.path().starts_with(root))) }) .map(|item| item.map_err(Into::into)) } diff --git a/crates/zensical/src/compat/mkdocs/plugin/exclude.rs b/crates/zensical/src/compat/mkdocs/plugin/exclude.rs index 10ac891..fc9baeb 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/exclude.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/exclude.rs @@ -33,6 +33,7 @@ use zrx::id::Id; use zrx::stream::{Stream, Value}; use crate::compat::mkdocs::resource::Resource; +use crate::config::inclusion::Inclusion; use crate::config::Config; use crate::path::SourcePath; @@ -50,6 +51,7 @@ pub struct Exclude { struct Settings { enabled: bool, docs: String, + inclusion: Inclusion, globs: Vec, regexes: Vec>, } @@ -61,6 +63,7 @@ impl Exclude { let mut settings = Settings { enabled: plugin.enabled, docs: config.project.docs_dir.clone(), + inclusion: config.inclusion.clone(), globs: Vec::new(), regexes: Vec::new(), }; @@ -100,9 +103,12 @@ impl Exclude { sources.filter_map(move |id: &Id, value: &T| { if id.context() == settings.docs { let path = id.location().parse::()?; - // Hidden files can configure other plugins, but MkDocs does - // not include them in the Files collection being filtered. - if !path.is_hidden() && !settings.includes(path.as_str())? { + // Hidden control files are retained unless they are explicitly + // included in the site by the core exclusion settings. + if (!path.is_hidden() + || settings.inclusion.is_included(path.as_str(), true)?) + && !settings.includes(path.as_str())? + { return Ok(None); } } diff --git a/crates/zensical/src/compat/mkdocs/plugin/literate_nav.rs b/crates/zensical/src/compat/mkdocs/plugin/literate_nav.rs index 6dfe351..233e5f2 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/literate_nav.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/literate_nav.rs @@ -51,6 +51,7 @@ mod resolver; pub struct LiterateNav { settings: Arc, api: Arc, + inclusion: crate::config::inclusion::Inclusion, } /// Inputs required to derive revision-complete navigation. @@ -115,6 +116,7 @@ impl LiterateNav { let plugin = &config.project.plugins.literate_nav.config; Self { api: config.api.clone(), + inclusion: config.inclusion.clone(), settings: Arc::new(Settings { enabled: plugin.enabled || config.project.plugins.autoapi.config.enabled, @@ -178,6 +180,7 @@ impl LiterateNav { ); let api = self.api.clone(); + let inclusion = self.inclusion.clone(); let navigation = pages.product(&documents).map( move |pages: &Pages, docs: &Documents| { let mut settings = settings.as_ref().clone(); @@ -186,10 +189,17 @@ impl LiterateNav { if settings.enabled { resolver::resolve(&settings, &docs.0, pages.0.as_ref()) } else { - Ok(Navigation::resolve( - settings.configured.clone(), - pages.0.as_ref().clone(), - )) + let mut pages = pages.0.as_ref().clone(); + if settings.configured.is_empty() { + let mut included = Vec::new(); + for page in pages { + if inclusion.is_in_nav(page.source().as_str())? { + included.push(page); + } + } + pages = included; + } + Ok(Navigation::resolve(settings.configured.clone(), pages)) } }, ); diff --git a/crates/zensical/src/compat/mkdocs/resource.rs b/crates/zensical/src/compat/mkdocs/resource.rs index 85856fc..e9ed48f 100644 --- a/crates/zensical/src/compat/mkdocs/resource.rs +++ b/crates/zensical/src/compat/mkdocs/resource.rs @@ -31,6 +31,7 @@ use zrx::id::Id; use zrx::stream::function::Collection; use zrx::stream::{Key, Stream, Value}; +use crate::config::inclusion::Inclusion; use crate::config::Config; use crate::path::SitePath; use crate::watcher::Source; @@ -46,6 +47,8 @@ use super::plugin::meta; pub struct Resources { /// Immutable classification rules for one workflow lifetime. classifier: Classifier, + inclusion: Inclusion, + serve: bool, } /// Inputs required to derive effective resources. @@ -86,9 +89,11 @@ struct Classifier { impl Resources { /// Resolves the private settings owned by this module instance. - pub fn new(config: &Config, meta: &meta::Meta) -> Self { + pub fn new(config: &Config, meta: &meta::Meta, serve: bool) -> Self { Self { classifier: Classifier::new(config, meta), + inclusion: config.inclusion.clone(), + serve, } } @@ -107,14 +112,26 @@ impl Resources { // Settle precedence before consumers transform or write a resource. // Removing a docs override therefore reveals its theme fallback in // the same revision without transiently deleting the logical output. - resources.reduce_by_key( + let resources = resources.reduce_by_key( |resource: &Resource| resource_key(&resource.path), |resources: &dyn Collection, Resource>| { Ok::<_, anyhow::Error>(preferred( resources.iter().map(|(_, resource)| resource), )) }, - ) + ); + let inclusion = self.inclusion.clone(); + let serve = self.serve; + // Exclusion is applied after precedence so an excluded docs override + // cannot be replaced by the theme's version of the same asset. + resources.filter_map(move |resource: &Resource| { + Ok::<_, anyhow::Error>( + (resource.priority != 0 + || inclusion + .is_included(resource.source_path.as_str(), serve)?) + .then(|| resource.clone()), + ) + }) } } @@ -168,7 +185,7 @@ impl Classifier { .unwrap_or(usize::MAX) }; let mut path = id.location().parse::()?; - if path.is_hidden() { + if !is_docs && path.is_hidden() { return Ok(None); } let source_path = path.clone(); @@ -334,11 +351,9 @@ mod tests { } #[test] - fn excludes_hidden_resources() { + fn excludes_hidden_theme_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"), ] { @@ -351,6 +366,12 @@ mod tests { ); } + // Hidden docs assets are retained until configured exclusions are applied. + assert!(classifier + .classify(&id("docs", ".assets/logo.svg"), &source("logo.svg")) + .unwrap() + .is_some()); + assert!(classifier .classify( &id("docs", "assets/app.min.js"), diff --git a/crates/zensical/src/config.rs b/crates/zensical/src/config.rs index 5b1456e..e907d69 100644 --- a/crates/zensical/src/config.rs +++ b/crates/zensical/src/config.rs @@ -40,6 +40,7 @@ use crate::path::{OutputRoot, SourceRoot}; mod error; pub mod extra; +pub mod inclusion; pub mod mdx; pub mod plugins; mod project; @@ -61,6 +62,8 @@ pub use project::Project; /// so we can move them out one by one once we start refactoring configuration. #[derive(Clone, Debug)] pub struct Config { + /// Compiled rules by which documentation files are included. + pub inclusion: inclusion::Inclusion, /// API sources and navigation generated for this build. pub api: Arc, /// Path to configuration file. @@ -180,6 +183,7 @@ impl Config { // Return configuration Ok(Config { + inclusion: inclusion::Inclusion::new(&project)?, api: Arc::default(), path, project: Arc::new(project), diff --git a/crates/zensical/src/config/inclusion.rs b/crates/zensical/src/config/inclusion.rs new file mode 100644 index 0000000..65b4f9a --- /dev/null +++ b/crates/zensical/src/config/inclusion.rs @@ -0,0 +1,114 @@ +// Copyright (c) 2025-2026 Zensical and contributors + +// SPDX-License-Identifier: MIT +// All contributions are certified under the DCO + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! File inclusion rules for MkDocs configuration. + +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use std::sync::Arc; + +use super::Project; + +/// Compiled gitignore rules shared by sources, assets and navigation. +#[derive(Clone, Debug)] +pub struct Inclusion { + excluded: Patterns, + drafts: Patterns, + unlisted: Patterns, +} + +/// An optional pattern set compiled by the existing pathspec dependency. +#[derive(Clone, Debug)] +struct Patterns(Option>>); + +impl Inclusion { + /// The configured patterns are compiled once when configuration is loaded. + pub fn new(project: &Project) -> PyResult { + Ok(Self { + excluded: Patterns::new( + "exclude_docs", + &format!( + ".*\n/templates/\n{}", + project.exclude_docs.as_deref().unwrap_or_default() + ), + )?, + drafts: Patterns::new( + "draft_docs", + project.draft_docs.as_deref().unwrap_or_default(), + )?, + unlisted: Patterns::new( + "not_in_nav", + project.not_in_nav.as_deref().unwrap_or_default(), + )?, + }) + } + + /// Whether the file is included in the current build mode is returned. + pub fn is_included(&self, path: &str, serve: bool) -> PyResult { + Ok(!self.excluded.matches(path)? && (serve || !self.is_draft(path)?)) + } + + /// Whether the file is marked as a draft is returned. + pub fn is_draft(&self, path: &str) -> PyResult { + self.drafts.matches(path) + } + + /// Whether the page is included in inferred navigation is returned. + pub fn is_in_nav(&self, path: &str) -> PyResult { + Ok(self.is_included(path, false)? && !self.unlisted.matches(path)?) + } +} + +impl Patterns { + fn new(setting: &str, text: &str) -> PyResult { + if text.is_empty() { + return Ok(Self(None)); + } + Python::attach(|py| { + let patterns = py + .import("pathspec")? + .getattr("GitIgnoreSpec")? + .call_method1("from_lines", (text.lines().collect::>(),)) + .map_err(|error| { + PyValueError::new_err(format!( + "invalid {setting} patterns: {error}" + )) + })?; + Ok(Self(Some(Arc::new(patterns.unbind())))) + }) + } + + fn matches(&self, path: &str) -> PyResult { + let Some(patterns) = &self.0 else { + return Ok(false); + }; + Python::attach(|py| { + patterns + .bind(py) + .call_method1("match_file", (path,))? + .extract() + }) + } +} diff --git a/crates/zensical/src/config/project.rs b/crates/zensical/src/config/project.rs index b021a58..9dadce4 100644 --- a/crates/zensical/src/config/project.rs +++ b/crates/zensical/src/config/project.rs @@ -58,6 +58,12 @@ pub struct Project { pub site_author: Option, /// Docs directory (sources). pub docs_dir: String, + /// Files to be excluded, expressed as gitignore patterns. + pub exclude_docs: Option, + /// Files to be included only during preview, expressed as gitignore patterns. + pub draft_docs: Option, + /// Pages to be omitted from inferred navigation, expressed as gitignore patterns. + pub not_in_nav: Option, /// Site directory (outputs). pub site_dir: String, /// Whether to use directory URLs. diff --git a/crates/zensical/src/lib.rs b/crates/zensical/src/lib.rs index 066c421..db0123f 100644 --- a/crates/zensical/src/lib.rs +++ b/crates/zensical/src/lib.rs @@ -329,7 +329,9 @@ fn run(config_file: &PathBuf, mode: Mode) -> PyResult { && let Ok(path) = key[0].location().parse::() && path.extension() == Some("md") - && !path.is_hidden() + && config + .inclusion + .is_included(path.as_str(), serve)? && has_snippets(&source.read_to_string()?) { snippets.insert(key.clone(), source.clone()); diff --git a/crates/zensical/src/watcher.rs b/crates/zensical/src/watcher.rs index 6becf6a..6eb90ee 100644 --- a/crates/zensical/src/watcher.rs +++ b/crates/zensical/src/watcher.rs @@ -35,6 +35,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; +use zensical_watch::agent::Manager; use zensical_watch::event::{Event, Kind}; use zensical_watch::{Agent, Error, Result}; use zrx::id::Id; @@ -179,7 +180,9 @@ impl Watcher { .collect::>(); // Initialize file agent - we use a debounce interval of 20ms, which // should be sufficient to correctly determine rename events - let agent = Agent::new(Duration::from_millis(20), serve, { + let manager = Manager::new() + .include_hidden(config.docs_root().as_path().to_owned()); + let handler = { let config = config.clone(); let mut discovered = false; move |results| { @@ -358,7 +361,13 @@ impl Watcher { } Ok(()) } - }); + }; + let agent = Agent::with_manager( + Duration::from_millis(20), + serve, + manager, + handler, + ); // Watch docs and template directories agent.watch(&config.path)?; @@ -475,7 +484,31 @@ mod tests { use std::{fs, io}; use tempfile::tempdir; - use super::{to_id, SourceMount}; + use super::{to_id, Manager, SourceMount}; + + #[test] + fn hidden_directory_discovery_is_limited_to_documentation() { + let directory = tempdir().unwrap(); + let root = directory.path().join("project"); + let docs = root.join("docs"); + let page = docs.join(".private/page.md"); + let icon = root.join("theme/.icons/icon.svg"); + for file in [&page, &icon] { + fs::create_dir_all(file.parent().unwrap()).unwrap(); + fs::write(file, "content").unwrap(); + } + + // Hidden docs files are discovered while theme icons remain skipped. + let mut manager = Manager::new().include_hidden(docs); + let paths = manager + .handle([root]) + .into_iter() + .map(|event| event.unwrap().path()) + .collect::>(); + + assert!(paths.iter().any(|path| path.as_ref() == &page)); + assert!(!paths.iter().any(|path| path.as_ref() == &icon)); + } #[test] fn output_identifiers_do_not_contain_the_physical_root() { diff --git a/crates/zensical/src/workflow.rs b/crates/zensical/src/workflow.rs index 2b3b967..6d368aa 100644 --- a/crates/zensical/src/workflow.rs +++ b/crates/zensical/src/workflow.rs @@ -276,11 +276,24 @@ impl Main { // Set up workflow to process static assets and Markdown files. let sources = files.map(|input: &Input| input.source.clone()); - let resources = resource::Resources::new(&self.config, &self.meta) - .setup(resource::Dependencies { sources: &sources }); + let resources = + resource::Resources::new(&self.config, &self.meta, self.serve) + .setup(resource::Dependencies { sources: &sources }); let resources = self.exclude.resources(&resources); let assets = minify.setup(minify::Dependencies { resources: &resources }); + let inclusion = self.config.inclusion.clone(); + let docs = self.config.project.docs_dir.clone(); + let serve = self.serve; + // Control files are retained in sources for navigation and metadata. + // Only published files are passed to Markdown and extra templates. + let files = files.filter_map(move |id: &Id, input: &Input| { + Ok::<_, anyhow::Error>( + (id.context() != docs + || inclusion.is_included(&id.location(), serve)?) + .then(|| input.clone()), + ) + }); let documents = read_documents(&self.config, &files); let plugins = plugin::Settings::new(&self.config, self.serve); let blogs = plugins.blog.clone(); @@ -689,9 +702,6 @@ fn read_documents( return Ok(None); } let source = id.location().parse::()?; - if source.is_hidden() { - return Ok(None); - } let data = input.source.read_to_string()?; let (body, page_meta) = meta::front_matter(&source, &data)?; let resolved = input.metadata.resolve(&source, page_meta)?; @@ -1013,6 +1023,13 @@ fn render_pages( let config = config.clone(); let rendered = pages.map(move |input: &PageRender| { let mut page = input.input.page.clone(); + if config.inclusion.is_draft(page.source().as_str())? { + let content = format!( + "

DRAFT
{}", + page.content, + ); + page.apply_derived(Some(content), None, BTreeMap::new()); + } let references = &input.input.autorefs; let id = page.url.clone(); diff --git a/python/tests/integration/test_file_inclusion.py b/python/tests/integration/test_file_inclusion.py new file mode 100644 index 0000000..a99022e --- /dev/null +++ b/python/tests/integration/test_file_inclusion.py @@ -0,0 +1,451 @@ +# Copyright (c) 2025-2026 Zensical and contributors + +# SPDX-License-Identifier: MIT +# All contributions are certified under the DCO + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + + +"""Test MkDocs file inclusion settings across builds and previews.""" + +from __future__ import annotations + +import json +import subprocess +import sys +import time +from typing import TYPE_CHECKING, Any + +import pytest +import yaml + +import zensical +from zensical.config import ConfigurationError + +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + + +_TEMPLATE = """\ +{{ page.content }} + +""" + + +def _project( + root: Path, files: dict[str, str], *, toml: bool = False, **settings: Any +) -> Path: + """Create a project with observable content and navigation.""" + for name, content in {"index.md": "# Home\n", **files}.items(): + path = root / "docs" / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + overrides = root / "overrides" + overrides.mkdir() + (overrides / "main.html").write_text(_TEMPLATE, encoding="utf-8") + config = { + "site_name": "Inclusion", + "site_url": "https://example.com/", + "dev_addr": "127.0.0.1:0", + **settings, + } + if toml: + path = root / "zensical.toml" + text = "[project]\n" + "\n".join( + f"{key} = {json.dumps(value)}" for key, value in config.items() + ) + text += '\n[project.theme]\ncustom_dir = "overrides"\n' + else: + path = root / "mkdocs.yml" + config["theme"] = {"custom_dir": "overrides"} + text = yaml.safe_dump(config) + path.write_text(text, encoding="utf-8") + return path + + +def _build(config: Path) -> None: + zensical.build(str(config), {"clean": False, "strict": True}) + + +def _nav(site: Path) -> str: + return ( + (site / "index.html") + .read_text() + .split("", 1)[0] + ) + + +@pytest.mark.parametrize("toml", [False, True]) +@pytest.mark.parametrize( + "setting", ["exclude_docs", "draft_docs", "not_in_nav"] +) +def test_gitignore_patterns_and_negations( + tmp_path: Path, toml: bool, setting: str +) -> None: + # Anchors, recursive paths, escaped prefixes and ordered negations + # are exercised together. + excluded = [ + "root.md", + "nested/secret.md", + "secret.md", + "drafts/deep/page.md", + "guide/old.md", + "#private.md", + "!private.md", + ] + included = [ + "nested/root.md", + "drafts/keep.md", + "guide/new.md", + "nested/guide/old.md", + "keep.md", + ] + patterns = r"""# comment +/root.md +secret.md +drafts/ +!drafts/keep.md +/guide/[a-z]*.md +!/guide/new.md +\#private.md +\!private.md +""" + config = _project( + tmp_path, + {name: f"# {name}\n" for name in excluded + included}, + toml=toml, + **{setting: patterns}, + ) + + _build(config) + + site = tmp_path / "site" + nav = _nav(site) + search = json.loads((site / "search.json").read_text()) + sitemap = (site / "sitemap.xml").read_text() + for name in included + excluded: + output = site / name.removesuffix(".md") / "index.html" + assert output.is_file() == ( + name in included or setting == "not_in_nav" + ), name + assert (f">{name}" in nav) == (name in included), name + # Unlisted pages are included in search and the sitemap. + assert ("nested/secret/" in json.dumps(search)) == (setting == "not_in_nav") + assert ("nested/secret/" in sitemap) == (setting == "not_in_nav") + + +@pytest.mark.parametrize("restore", [False, True]) +def test_default_exclusions_can_be_negated( + tmp_path: Path, restore: bool +) -> None: + files = { + ".hidden.md": "# Hidden", + ".assets/logo.svg": "asset", + "nested/.hidden/page.md": "# Nested hidden", + "templates/page.md": "# Template", + "templates/data.txt": "data", + "nested/templates/page.md": "# Ordinary page", + } + config = _project( + tmp_path, files, exclude_docs="!.*\n!/templates/" if restore else None + ) + + _build(config) + + site = tmp_path / "site" + for output in [ + ".hidden/index.html", + ".assets/logo.svg", + "nested/.hidden/page/index.html", + "templates/page/index.html", + "templates/data.txt", + ]: + assert (site / output).is_file() == restore, output + assert (site / "nested/templates/page/index.html").is_file() + + +@pytest.mark.parametrize("setting", ["exclude_docs", "draft_docs"]) +def test_exclusions_cover_resources_and_templates_before_processing( + tmp_path: Path, setting: str +) -> None: + files = { + "broken.md": "---\ninvalid: [\n---", + "private.txt": "private", + "export.html": "{{ must_not_render() }}", + } + config = _project( + tmp_path, + files, + extra_templates=["export.html"], + **{setting: "broken.md\n*.txt\nexport.html"}, # ty:ignore[invalid-argument-type] + ) + (tmp_path / "overrides/private.txt").write_text("theme fallback") + (tmp_path / "overrides/theme.txt").write_text("theme asset") + + _build(config) + + site = tmp_path / "site" + for output in ["broken/index.html", "private.txt", "export.html"]: + assert not (site / output).exists(), output + assert (site / "theme.txt").read_text() == "theme asset" + + +def test_explicit_navigation_can_include_unlisted_pages(tmp_path: Path) -> None: + config = _project( + tmp_path, + {"hidden.md": "# Unlisted"}, + not_in_nav="hidden.md", + nav=[{"Home": "index.md"}, {"Explicit title": "hidden.md"}], + ) + + _build(config) + + assert 'href="hidden/">Explicit title' in _nav(tmp_path / "site") + + +def test_exclude_plugin_can_filter_explicitly_included_hidden_pages( + tmp_path: Path, +) -> None: + files = { + ".hidden/keep.md": "# Kept", + ".hidden/drop.md": "---\ninvalid: [\n---", + } + config = _project( + tmp_path, + files, + exclude_docs="!.hidden/", + plugins=[{"exclude": {"glob": "*/drop.md"}}], + ) + + _build(config) + + assert (tmp_path / "site/.hidden/keep/index.html").is_file() + assert not (tmp_path / "site/.hidden/drop/index.html").exists() + + +def test_exclusions_take_precedence_and_control_files_remain_available( + tmp_path: Path, +) -> None: + files = { + ".nav.yml": "nav:\n - Custom home: index.md\n - '*'\n", + "private.md": "---\ninvalid: [\n---", + "draft.md": "# Draft", + "unlisted.md": "# Unlisted", + } + config = _project( + tmp_path, + files, + exclude_docs="private.md", + draft_docs="private.md\ndraft.md", + not_in_nav="!private.md\n!draft.md\nunlisted.md", + plugins=["awesome-nav"], + ) + + _build(config) + + site = tmp_path / "site" + assert "Custom home" in _nav(site) + assert not (site / "private/index.html").exists() + assert not (site / "draft/index.html").exists() + assert (site / "unlisted/index.html").is_file() + + +@pytest.mark.parametrize( + "setting", ["exclude_docs", "draft_docs", "not_in_nav"] +) +@pytest.mark.parametrize("value", [True, 42, ["*.md"], {"pattern": "*.md"}]) +def test_rejects_non_string_patterns( + tmp_path: Path, setting: str, value: Any +) -> None: + config = _project(tmp_path, {}, **{setting: value}) + + with pytest.raises(ConfigurationError, match=setting): + _build(config) + + +@pytest.mark.parametrize( + "setting", ["exclude_docs", "draft_docs", "not_in_nav"] +) +def test_reports_invalid_patterns(tmp_path: Path, setting: str) -> None: + config = _project(tmp_path, {}, **{setting: "!"}) # ty:ignore[invalid-argument-type] + + with pytest.raises(ValueError, match=setting): + _build(config) + + +def test_serve_includes_marked_drafts_and_updates_inclusion( + tmp_path: Path, +) -> None: + files = { + "draft.md": "# Draft page", + "draft.txt": "draft asset", + "private.md": "# Private", + "moving.md": "# Moving", + "moving.txt": "moving asset", + "unlisted.md": "# Unlisted", + ".included/page.md": "# Hidden page", + ".included/data.txt": "hidden asset", + } + config = _project( + tmp_path, + files, + exclude_docs="private.md\n*.excluded.md\n*.excluded.txt\n!.included/", + draft_docs="draft.*", + not_in_nav="unlisted.md", + ) + site = tmp_path / "site" + + with (tmp_path / "serve.log").open("w+", encoding="utf-8") as log: + process = subprocess.Popen( # noqa: S603 + [ + sys.executable, + "-m", + "zensical", + "serve", + "--config-file", + str(config), + ], + cwd=tmp_path, + stdout=log, + stderr=subprocess.STDOUT, + ) + + def wait_for(condition: Callable[[], bool]) -> None: + deadline = time.monotonic() + 20 + while time.monotonic() < deadline: + if condition(): + return + if process.poll() is not None: + break + time.sleep(0.02) + log.flush() + log.seek(0) + raise AssertionError( + f"serve did not update file inclusion: {log.read()}" + ) + + try: + wait_for( + lambda: ( + (site / "draft/index.html").exists() + and (site / "index.html").exists() + and (site / "moving.txt").exists() + ) + ) + + # Drafts are rendered with a marker + # and are omitted from inferred navigation. + assert ( + 'class="mkdocs-draft-marker zensical-draft-marker"' + in (site / "draft/index.html").read_text() + ) + assert (site / "draft.txt").read_text() == "draft asset" + assert "Draft page" not in _nav(site) + assert "Unlisted" not in _nav(site) + assert not (site / "private/index.html").exists() + wait_for( + lambda: ( + (site / ".included/page/index.html").exists() + and (site / ".included/data.txt").exists() + ) + ) + + # Hidden directories are discovered + # when they are created during preview. + nested = tmp_path / "docs/.included/.new" + nested.mkdir() + (nested / "page.md").write_text("# New hidden page") + wait_for((site / ".included/.new/page/index.html").exists) + + # Renamed hidden directories retain their pages and assets. + (tmp_path / "docs/.included").rename(tmp_path / "docs/visible") + wait_for( + lambda: ( + (site / "visible/page/index.html").exists() + and not (site / ".included/page/index.html").exists() + ) + ) + + # Outputs are removed when their sources + # are renamed into excluded paths. + (tmp_path / "docs/moving.md").rename( + tmp_path / "docs/moving.excluded.md" + ) + (tmp_path / "docs/moving.txt").rename( + tmp_path / "docs/moving.excluded.txt" + ) + wait_for( + lambda: ( + not (site / "moving/index.html").exists() + and not (site / "moving.txt").exists() + ) + ) + + # Updated drafts are included in subsequent revisions + # with the same marker. + (tmp_path / "docs/draft.md").write_text("# Updated draft") + wait_for( + lambda: ( + "Updated draft" in (site / "draft/index.html").read_text() + ) + ) + assert ( + 'class="mkdocs-draft-marker zensical-draft-marker"' + in (site / "draft/index.html").read_text() + ) + assert not (site / "moving.excluded/index.html").exists() + assert not (site / "moving.excluded.txt").exists() + + # Changed patterns are reloaded and + # the previously excluded sources are published. + settings = yaml.safe_load(config.read_text()) + settings.update(exclude_docs=None, draft_docs=None, not_in_nav=None) + config.write_text(yaml.safe_dump(settings)) + wait_for( + lambda: ( + (site / "private/index.html").exists() + and (site / "moving.excluded.txt").exists() + and "Updated draft" in _nav(site) + ) + ) + assert ( + 'class="mkdocs-draft-marker zensical-draft-marker"' + not in (site / "draft/index.html").read_text() + ) + finally: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + + # A build after preview excludes drafts even when the render cache is warm. + settings.update(draft_docs="draft.*") + config.write_text(yaml.safe_dump(settings)) + _build(config) + + assert not (site / "draft/index.html").exists() + assert not (site / "draft.txt").exists() diff --git a/python/zensical/config.py b/python/zensical/config.py index 088e5c9..fdbe6e3 100644 --- a/python/zensical/config.py +++ b/python/zensical/config.py @@ -533,6 +533,12 @@ def _apply_defaults(config: dict, path: str) -> dict: set_default(config, "copyright", None, str) set_default(config, "watch", [], list) + # Keep file patterns as text for native configuration and template access. + for setting in ("exclude_docs", "draft_docs", "not_in_nav"): + value = config.setdefault(setting, None) + if value is not None and not isinstance(value, str): + raise ConfigurationError(f"'{setting}' must be a multiline string.") + # Validate watch setting if not all(isinstance(path, str) for path in config["watch"]): raise ConfigurationError("'watch' entries must be strings.")