diff --git a/crates/zensical/src/config.rs b/crates/zensical/src/config.rs index 404f5db..bb9f656 100644 --- a/crates/zensical/src/config.rs +++ b/crates/zensical/src/config.rs @@ -112,6 +112,7 @@ impl Config { let hash = { let mut hasher = DefaultHasher::default(); project.hash(&mut hasher); + hasher.write_u64(project.template_hash); hasher.finish() }; diff --git a/crates/zensical/src/config/project.rs b/crates/zensical/src/config/project.rs index 5aaa322..b9b8140 100644 --- a/crates/zensical/src/config/project.rs +++ b/crates/zensical/src/config/project.rs @@ -88,4 +88,6 @@ pub struct Project { pub plugins: Plugins, /// Navigation structure. pub nav: Vec, + /// Template hash. + pub template_hash: u64, } diff --git a/crates/zensical/src/watcher.rs b/crates/zensical/src/watcher.rs index e7f4980..c15740c 100644 --- a/crates/zensical/src/watcher.rs +++ b/crates/zensical/src/watcher.rs @@ -27,6 +27,7 @@ use crossbeam::channel::Sender; use mio::Waker; +use std::collections::BTreeSet; use std::fs; use std::path::PathBuf; use std::sync::Arc; @@ -78,9 +79,11 @@ impl Watcher { sources.push((config.get_site_dir(), config.project.site_dir.clone())); sources.push((path, String::from("."))); + // Track seen files to restart on config or template change + let mut seen = BTreeSet::new(); + // Initialize file agent - we use a debounce interval of 20ms, which // should be sufficient to correctly determine rename events - let mut initial = false; let agent = Agent::new(Duration::from_millis(20), { let config = config.clone(); move |res| { @@ -93,11 +96,20 @@ 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 { - if initial { + if *event.path() == config.path + && !seen.insert(config.path.clone()) + { + return Err(Error::Disconnected); + } + + // Check if the event is in any of the theme directories + // and restart the build if we've already seen the file + for dir in &config.theme_dirs { + if event.path().starts_with(dir) + && !seen.insert((*event.path()).clone()) + { return Err(Error::Disconnected); } - initial = true; } // Ignore events in the site directory, since they are files diff --git a/python/zensical/config.py b/python/zensical/config.py index 465ec75..0d52230 100644 --- a/python/zensical/config.py +++ b/python/zensical/config.py @@ -118,6 +118,12 @@ def get_theme_dir() -> str: return os.path.join(path, "templates") +def get_custom_theme_dir(config: dict) -> str | None: + """Return the custom theme directory.""" + path = os.path.dirname(os.path.abspath(__file__)) + return os.path.join(path, config["theme"]["custom_dir"]) + + def _apply_defaults(config: dict, path: str) -> dict: """Apply default settings in configuration. @@ -424,6 +430,8 @@ def _apply_defaults(config: dict, path: str) -> dict: config["markdown_extensions"].append("mkdocstrings") config["mdx_configs"]["mkdocstrings"] = mkdocstrings_config + # Hash all templates, so we rebuild if something changes + config["template_hash"] = _hash(_list_templates(config)) return config @@ -456,6 +464,29 @@ def _hash(data: Any) -> int: return int(hash.hexdigest(), 16) % (2**64) +def _list_templates(config: dict) -> list[tuple[str, int]]: + """List all template files in the theme directories.""" + dirs = [get_theme_dir()] + if "custom_dir" in config["theme"]: + custom_dir = get_custom_theme_dir(config) + if custom_dir is not None: + dirs.append(custom_dir) + + # Collect file paths and their mtimes + files_with_mtime = [] + for directory in dirs: + for path, _, files in os.walk(directory): + if ".icons" in path: + continue + for file in files: + file_path = os.path.join(path, file) + mtime = os.path.getsize(file_path) + files_with_mtime.append((file_path, mtime)) + + # Sort by file path for deterministic order + return sorted(files_with_mtime) + + def _convert_extra(data: dict | list) -> dict | list: """Recursively convert all None values in a dictionary or list to empty strings.""" if isinstance(data, dict):