use crate::config::SiteConfig;
use crate::parser::{ParsedPage, PageId};
use crate::render::RenderedPage;
use anyhow::Result;
use colored::Colorize;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{mpsc, Arc};
use std::time::{Duration, SystemTime};
pub const RELOAD_SCRIPT: &str = r#"<script>
(function() {
let retries = 0;
function connect() {
const es = new EventSource('/__reload');
es.onmessage = function(e) {
if (e.data === 'reload') {
window.location.reload();
}
retries = 0;
};
es.onerror = function() {
es.close();
if (retries < 120) {
retries++;
setTimeout(connect, 1000);
}
};
}
connect();
})();
</script>"#;
/// Cached state for incremental rebuilds.
struct BuildCache {
/// source_path โ (mtime, ParsedPage) โ skip re-parsing unchanged files
parse_cache: HashMap<PathBuf, (SystemTime, ParsedPage)>,
/// source_path โ DiscoveredFile โ needed by fast path to re-parse without scanning
file_cache: HashMap<PathBuf, crate::scanner::DiscoveredFile>,
/// page_id โ RenderedPage โ skip re-rendering unchanged pages
render_cache: HashMap<PageId, RenderedPage>,
/// page_id โ content_md hash โ detect content changes
content_hashes: HashMap<PageId, u64>,
/// page_id โ tags hash โ detect tag changes
tag_hashes: HashMap<PageId, u64>,
/// page_id โ meta hash (title + aliases + icon + stake + tags) โ detect frontmatter changes
meta_hashes: HashMap<PageId, u64>,
/// page_id โ outgoing links hash โ detect link changes
link_hashes: HashMap<PageId, u64>,
/// page_id โ sorted backlink ids โ detect backlink changes
backlink_snapshots: HashMap<PageId, Vec<PageId>>,
/// Content page IDs from the last build (excludes stubs) โ detect structural changes
last_content_page_ids: HashSet<PageId>,
/// Namespace tree keys from last build โ detect namespace parent changes
last_namespace_keys: HashSet<String>,
/// Whether the initial full build has completed
initialized: bool,
/// Cached subgraph parsed pages (rebuilt only when a subgraph file changes)
subgraph_pages: Vec<crate::parser::ParsedPage>,
/// Subgraph repo paths for change detection
subgraph_repo_paths: Vec<PathBuf>,
/// Cached graph store โ reused when no structural change (avoids expensive PageRank/gravity)
cached_store: Option<crate::graph::PageStore>,
}
impl BuildCache {
fn new() -> Self {
Self {
parse_cache: HashMap::new(),
file_cache: HashMap::new(),
render_cache: HashMap::new(),
content_hashes: HashMap::new(),
tag_hashes: HashMap::new(),
meta_hashes: HashMap::new(),
link_hashes: HashMap::new(),
backlink_snapshots: HashMap::new(),
last_content_page_ids: HashSet::new(),
last_namespace_keys: HashSet::new(),
initialized: false,
subgraph_pages: Vec::new(),
subgraph_repo_paths: Vec::new(),
cached_store: None,
}
}
}
/// Simple hash for content change detection (not cryptographic).
fn hash_str(s: &str) -> u64 {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut h = DefaultHasher::new();
s.hash(&mut h);
h.finish()
}
/// Hash frontmatter metadata fields that affect other pages (title, aliases, icon, stake, tags).
fn hash_meta(page: &ParsedPage) -> u64 {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut h = DefaultHasher::new();
page.meta.title.hash(&mut h);
let mut aliases = page.meta.aliases.clone();
aliases.sort();
aliases.join(",").hash(&mut h);
page.meta.icon.hash(&mut h);
page.meta.stake.hash(&mut h);
let mut tags = page.meta.tags.clone();
tags.sort();
tags.join(",").hash(&mut h);
h.finish()
}
/// Hash outgoing links to detect when a page's link set changes (affects backlinks on targets).
fn hash_links(page: &ParsedPage) -> u64 {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut h = DefaultHasher::new();
let mut sorted_links = page.outgoing_links.clone();
sorted_links.sort();
sorted_links.hash(&mut h);
h.finish()
}
/// Start a background thread that watches for file changes and rebuilds.
/// Increments `build_version` after each successful rebuild so SSE clients know to reload.
pub fn start_watch_rebuild(config: SiteConfig, build_version: Arc<AtomicU64>) {
std::thread::spawn(move || {
if let Err(e) = watch_and_rebuild_loop(&config, &build_version) {
eprintln!(" {} File watcher error: {}", "Error".red(), e);
}
});
}
fn watch_and_rebuild_loop(config: &SiteConfig, build_version: &Arc<AtomicU64>) -> Result<()> {
use notify::Watcher;
// Channel now carries file paths so we know what changed
let (tx, rx) = mpsc::channel::<Vec<PathBuf>>();
let mut watcher =
notify::recommended_watcher(move |res: Result<notify::Event, notify::Error>| {
if let Ok(event) = res {
if matches!(
event.kind,
notify::EventKind::Modify(_)
| notify::EventKind::Create(_)
| notify::EventKind::Remove(_)
) {
let _ = tx.send(event.paths);
}
}
})
.map_err(|e| anyhow::anyhow!("Failed to create file watcher: {}", e))?;
// Watch graph directory (primary: "root", fallback: "graph", then "pages")
let graph_dir = {
let root = config.build.input_dir.join("root");
let graph = config.build.input_dir.join("graph");
if root.exists() { root } else if graph.exists() { graph } else { config.build.input_dir.join("pages") }
};
// Watch blog directory (primary: "blog", fallback: "journals")
let blog_dir = {
let primary = config.build.input_dir.join("blog");
if primary.exists() { primary } else { config.build.input_dir.join("journals") }
};
if graph_dir.exists() {
watcher.watch(&graph_dir, notify::RecursiveMode::Recursive)?;
}
if blog_dir.exists() {
watcher.watch(&blog_dir, notify::RecursiveMode::Recursive)?;
}
// Watch subgraph repo directories for changes
{
let discovered = crate::scanner::scan(&config.build.input_dir, &config.content)?;
let root_parsed = crate::parser::parse_all(&discovered)?;
let subgraph_decls = crate::scanner::subgraph::discover_subgraphs(
&root_parsed,
&config.build.input_dir,
);
for decl in &subgraph_decls {
if decl.repo_path.exists() {
eprintln!(" {} Watching subgraph '{}': {}", "Watch".dimmed(), decl.name, decl.repo_path.display());
watcher.watch(&decl.repo_path, notify::RecursiveMode::Recursive)?;
}
}
}
let mut cache = BuildCache::new();
// Warm up: pre-populate subgraph cache so first incremental rebuild is fast.
// Without this, the first file change triggers a full re-render of all 12K pages.
{
let discovered = crate::scanner::scan(&config.build.input_dir, &config.content)?;
let root_parsed = crate::parser::parse_all(&discovered)?;
let subgraph_decls = crate::scanner::subgraph::discover_subgraphs(
&root_parsed,
&config.build.input_dir,
);
for decl in &subgraph_decls {
cache.subgraph_repo_paths.push(decl.repo_path.clone());
let subgraph_files = crate::scanner::subgraph::scan_subgraph(decl)?;
let declaring_page = root_parsed
.iter()
.find(|p| p.id == decl.declaring_page_id)
.cloned();
for file in &subgraph_files {
if file.kind == crate::scanner::FileKind::Page {
let mut page = crate::parser::parse_file(file)?;
if page.id == decl.declaring_page_id || page.id == crate::parser::slugify_page_name(&decl.declaring_page_id) {
if let Some(ref dp) = declaring_page {
page.meta.tags = dp.meta.tags.clone();
page.meta.aliases = dp.meta.aliases.clone();
page.meta.properties = dp.meta.properties.clone();
page.meta.public = dp.meta.public;
page.meta.icon = dp.meta.icon.clone();
page.meta.stake = dp.meta.stake;
if !dp.content_md.trim().is_empty() {
let readme_content = std::mem::take(&mut page.content_md);
page.content_md = dp.content_md.clone();
page.content_md.push_str(&format!(
"\n\n---\n\n## from subgraph {}\n\n",
decl.name
));
page.content_md.push_str(&readme_content);
}
for link in &dp.outgoing_links {
if !page.outgoing_links.contains(link) {
page.outgoing_links.push(link.clone());
}
}
}
}
cache.subgraph_pages.push(page);
}
}
}
// Pre-populate parse cache and file cache for root graph files
for file in discovered.pages.iter().chain(discovered.journals.iter()) {
let mtime = std::fs::metadata(&file.path)
.ok()
.and_then(|m| m.modified().ok())
.unwrap_or(SystemTime::UNIX_EPOCH);
cache.file_cache.insert(file.path.clone(), file.clone());
if let Ok(page) = crate::parser::parse_file(file) {
cache.parse_cache.insert(file.path.clone(), (mtime, page));
}
}
// Build the full graph to snapshot page IDs (including stubs) and hashes.
// This matches what incremental_rebuild produces, preventing false structural changes.
{
let mut warmup_pages: Vec<ParsedPage> = cache.parse_cache.values()
.map(|(_, p)| p.clone())
.collect();
// Add non-md files
let non_md = crate::parser::parse_all(&crate::scanner::DiscoveredFiles {
pages: vec![],
journals: vec![],
media: discovered.media.clone(),
files: discovered.files.clone(),
})?;
warmup_pages.extend(non_md);
// Add subgraph pages and enforce namespace monopoly
let subgraph_namespaces: Vec<String> =
subgraph_decls.iter().map(|d| d.name.clone()).collect();
crate::scanner::subgraph::enforce_namespace_monopoly(
&mut warmup_pages,
&subgraph_namespaces,
);
for decl in &subgraph_decls {
warmup_pages.retain(|p| {
!(p.id == decl.declaring_page_id && p.subgraph.is_none())
});
}
warmup_pages.extend(cache.subgraph_pages.clone());
// Snapshot content page IDs and hashes BEFORE graph build.
// This must match what incremental_rebuild hashes from all_parsed (pre-graph).
for page in &warmup_pages {
cache.last_content_page_ids.insert(page.id.clone());
cache.content_hashes.insert(page.id.clone(), hash_str(&page.content_md));
cache.tag_hashes.insert(page.id.clone(), hash_str(&page.meta.tags.join(",")));
cache.meta_hashes.insert(page.id.clone(), hash_meta(page));
cache.link_hashes.insert(page.id.clone(), hash_links(page));
}
// Build graph to get full store (with stubs, links, PageRank, etc.)
let store = crate::graph::build_graph(warmup_pages)?;
// Snapshot backlinks
for (page_id, backlinks) in &store.backlinks {
let mut sorted = backlinks.clone();
sorted.sort();
cache.backlink_snapshots.insert(page_id.clone(), sorted);
}
// Snapshot namespace tree keys
cache.last_namespace_keys = store.namespace_tree.keys().cloned().collect();
// Cache the store so fast path can reuse it
cache.cached_store = Some(store);
}
cache.initialized = true;
}
loop {
if let Ok(paths) = rx.recv() {
// Debounce: wait 100ms and collect all changed paths
std::thread::sleep(Duration::from_millis(100));
let mut changed: HashSet<PathBuf> = paths.into_iter().collect();
while let Ok(more) = rx.try_recv() {
changed.extend(more);
}
// Filter to content files, excluding .git/target/node_modules/build.
// Accept any file (not just .md) โ the scanner handles file type classification.
changed.retain(|p| {
let path_str = p.to_string_lossy();
!path_str.contains("/.git/")
&& !path_str.contains("/target/")
&& !path_str.contains("/node_modules/")
&& !path_str.contains("/build/")
&& !path_str.contains("/.claude/")
});
if changed.is_empty() {
continue;
}
let n = changed.len();
let start = std::time::Instant::now();
// Try fast path first (skips filesystem scan for content-only edits)
if let Some(result) = try_fast_path(config, &mut cache, &changed) {
match result {
Ok((rendered_count, dirty_count)) => {
let elapsed = start.elapsed();
build_version.fetch_add(1, Ordering::SeqCst);
eprintln!(
" {} {} file{} โ fast rebuild {}/{} pages in {:.2}s",
"Done".green(),
n,
if n == 1 { "" } else { "s" },
dirty_count,
rendered_count,
elapsed.as_secs_f64()
);
}
Err(e) => {
eprintln!(" {} Fast rebuild failed: {}", "Error".red(), e);
}
}
continue;
}
// Full incremental rebuild (with scan)
eprintln!(
" {} {} file{} changed, rebuilding...",
"Watch".yellow(),
n,
if n == 1 { "" } else { "s" }
);
match incremental_rebuild(config, &mut cache, &changed) {
Ok((rendered_count, dirty_count)) => {
let elapsed = start.elapsed();
build_version.fetch_add(1, Ordering::SeqCst);
eprintln!(
" {} Rebuilt {}/{} pages in {:.2}s",
"Done".green(),
dirty_count,
rendered_count,
elapsed.as_secs_f64()
);
}
Err(e) => {
eprintln!(" {} Rebuild failed: {}", "Error".red(), e);
}
}
}
}
}
/// Fast path for content-only edits: skip the full filesystem scan entirely.
/// Returns Some((total, dirty)) on success, None if the fast path doesn't apply.
fn try_fast_path(
config: &SiteConfig,
cache: &mut BuildCache,
changed_paths: &HashSet<PathBuf>,
) -> Option<Result<(usize, usize)>> {
// Preconditions: cache must be initialized with a cached store
if !cache.initialized || cache.cached_store.is_none() {
return None;
}
// All changed paths must be known .md files already in our caches (not new, not deleted)
for path in changed_paths {
if !path.exists() {
return None; // File was deleted โ need full scan
}
if !cache.file_cache.contains_key(path) {
return None; // Unknown file (new file?) โ need full scan
}
if !cache.parse_cache.contains_key(path) {
return None; // Not previously parsed โ need full scan
}
}
// No subgraph files
if changed_paths.iter().any(|p| {
cache.subgraph_repo_paths.iter().any(|repo| p.starts_with(repo))
}) {
return None;
}
// Re-parse only the changed files
let mut dirty_ids: HashSet<PageId> = HashSet::new();
let mut meta_changed = false;
let mut links_changed = false;
for path in changed_paths {
let file = cache.file_cache.get(path).unwrap().clone();
let page = match crate::parser::parse_file(&file) {
Ok(p) => p,
Err(e) => return Some(Err(e)),
};
let mtime = std::fs::metadata(path)
.ok()
.and_then(|m| m.modified().ok())
.unwrap_or(SystemTime::UNIX_EPOCH);
let page_id = page.id.clone();
// Content hash
let new_content = hash_str(&page.content_md);
if cache.content_hashes.get(&page_id).copied() != Some(new_content) {
dirty_ids.insert(page_id.clone());
}
cache.content_hashes.insert(page_id.clone(), new_content);
// Meta hash
let new_meta = hash_meta(&page);
if cache.meta_hashes.get(&page_id).copied() != Some(new_meta) {
dirty_ids.insert(page_id.clone());
meta_changed = true;
}
cache.meta_hashes.insert(page_id.clone(), new_meta);
// Link hash
let new_links = hash_links(&page);
if cache.link_hashes.get(&page_id).copied() != Some(new_links) {
dirty_ids.insert(page_id.clone());
links_changed = true;
}
cache.link_hashes.insert(page_id.clone(), new_links);
// Tag hash
let new_tags = hash_str(&page.meta.tags.join(","));
cache.tag_hashes.insert(page_id.clone(), new_tags);
// Update parse cache
cache.parse_cache.insert(path.clone(), (mtime, page));
}
// If meta/links/tags changed, this is structural โ bail to full rebuild
if meta_changed || links_changed {
// Revert: the hashes were updated but we need full rebuild.
// The full path will recompute everything, so this is fine.
return None;
}
if dirty_ids.is_empty() {
return Some(Ok((0, 0)));
}
// Content-only change: update cached store in-place and re-render dirty pages
{
let store = cache.cached_store.as_mut().unwrap();
for dirty_id in &dirty_ids {
// Find the freshly-parsed page in parse_cache
if let Some((_, new_page)) = cache.parse_cache.values()
.find(|(_, p)| p.id == **dirty_id)
{
if let Some(cached_page) = store.pages.get_mut(dirty_id) {
cached_page.content_md = new_page.content_md.clone();
cached_page.meta = new_page.meta.clone();
cached_page.outgoing_links = new_page.outgoing_links.clone();
}
}
}
}
let store = cache.cached_store.as_ref().unwrap();
// Render only the dirty pages (not all pages โ avoids iterating 11K cached entries)
let dirty_count = dirty_ids.len();
let mut rendered_dirty = Vec::with_capacity(dirty_count);
{
let env = match crate::render::make_template_env(config) {
Ok(e) => e,
Err(e) => return Some(Err(e)),
};
for dirty_id in &dirty_ids {
if let Some(page) = store.pages.get(dirty_id) {
if !crate::graph::PageStore::is_page_public(page, &config.content) {
continue;
}
let rp = match crate::render::render_single_page(page, dirty_id, store, config, &env) {
Ok(r) => r,
Err(e) => return Some(Err(e)),
};
cache.render_cache.insert(dirty_id.clone(), rp.clone());
rendered_dirty.push(rp);
}
}
}
// Write only dirty pages
if let Err(e) = crate::output::write_dirty_pages(&rendered_dirty, &dirty_ids, config) {
return Some(Err(e));
}
Some(Ok((dirty_count, dirty_count)))
}
/// Incremental rebuild: selective parse โ full graph โ selective render โ incremental output.
/// Returns (total_rendered, dirty_count).
fn incremental_rebuild(
config: &SiteConfig,
cache: &mut BuildCache,
changed_paths: &HashSet<PathBuf>,
) -> Result<(usize, usize)> {
// Step 1: Scan (always full โ it's fast)
let discovered = crate::scanner::scan(&config.build.input_dir, &config.content)?;
// Step 2: Selective parse โ only re-parse files whose mtime changed
let mut all_parsed: Vec<ParsedPage> = Vec::new();
let mut changed_page_ids: HashSet<PageId> = HashSet::new();
for file in discovered.pages.iter().chain(discovered.journals.iter()) {
let mtime = std::fs::metadata(&file.path)
.ok()
.and_then(|m| m.modified().ok())
.unwrap_or(SystemTime::UNIX_EPOCH);
// Keep file_cache in sync for fast path
cache.file_cache.insert(file.path.clone(), file.clone());
if let Some((cached_mtime, cached_page)) = cache.parse_cache.get(&file.path) {
if *cached_mtime == mtime && !changed_paths.contains(&file.path) {
// File unchanged โ use cached parse
all_parsed.push(cached_page.clone());
continue;
}
}
// Parse (new or changed file)
let page = crate::parser::parse_file(file)?;
changed_page_ids.insert(page.id.clone());
cache.parse_cache.insert(file.path.clone(), (mtime, page.clone()));
all_parsed.push(page);
}
// Non-markdown files: always re-parse (they're few and cheap)
let non_md = crate::parser::parse_all(&crate::scanner::DiscoveredFiles {
pages: vec![],
journals: vec![],
media: discovered.media.clone(),
files: discovered.files.clone(),
})?;
all_parsed.extend(non_md);
// Remove stale cache entries for deleted files
let current_paths: HashSet<&PathBuf> = discovered
.pages
.iter()
.chain(discovered.journals.iter())
.map(|f| &f.path)
.collect();
cache.parse_cache.retain(|path, _| current_paths.contains(path));
cache.file_cache.retain(|path, _| current_paths.contains(path));
// Step 2b: Discover and merge subgraphs โ use cached pages unless a subgraph file changed
let subgraph_decls = crate::scanner::subgraph::discover_subgraphs(
&all_parsed,
&config.build.input_dir,
);
if !subgraph_decls.is_empty() {
let subgraph_namespaces: Vec<String> =
subgraph_decls.iter().map(|d| d.name.clone()).collect();
let _evicted = crate::scanner::subgraph::enforce_namespace_monopoly(
&mut all_parsed,
&subgraph_namespaces,
);
// Check if any changed file is inside a subgraph repo
let subgraph_changed = changed_paths.iter().any(|p| {
cache.subgraph_repo_paths.iter().any(|repo| p.starts_with(repo))
});
if subgraph_changed || cache.subgraph_pages.is_empty() {
// Re-scan and re-parse subgraphs (a subgraph file was modified)
let mut new_subgraph_pages = Vec::new();
let mut new_repo_paths = Vec::new();
for decl in &subgraph_decls {
new_repo_paths.push(decl.repo_path.clone());
let subgraph_files = crate::scanner::subgraph::scan_subgraph(decl)?;
let declaring_page = all_parsed
.iter()
.find(|p| p.id == decl.declaring_page_id)
.cloned();
for file in &subgraph_files {
if file.kind == crate::scanner::FileKind::Page {
let mut page = crate::parser::parse_file(file)?;
if page.id == decl.declaring_page_id || page.id == crate::parser::slugify_page_name(&decl.declaring_page_id) {
if let Some(ref dp) = declaring_page {
page.meta.tags = dp.meta.tags.clone();
page.meta.aliases = dp.meta.aliases.clone();
page.meta.properties = dp.meta.properties.clone();
page.meta.public = dp.meta.public;
page.meta.icon = dp.meta.icon.clone();
page.meta.stake = dp.meta.stake;
if !dp.content_md.trim().is_empty() {
let readme_content = std::mem::take(&mut page.content_md);
page.content_md = dp.content_md.clone();
page.content_md.push_str(&format!(
"\n\n---\n\n## from subgraph {}\n\n",
decl.name
));
page.content_md.push_str(&readme_content);
}
for link in &dp.outgoing_links {
if !page.outgoing_links.contains(link) {
page.outgoing_links.push(link.clone());
}
}
}
}
new_subgraph_pages.push(page);
}
}
if declaring_page.is_some() {
all_parsed.retain(|p| {
!(p.id == decl.declaring_page_id && p.subgraph.is_none())
});
}
}
cache.subgraph_pages = new_subgraph_pages;
cache.subgraph_repo_paths = new_repo_paths;
} else {
// No subgraph file changed โ evict declaring pages and reuse cache
for decl in &subgraph_decls {
let has_declaring = all_parsed.iter().any(|p| p.id == decl.declaring_page_id);
if has_declaring {
all_parsed.retain(|p| {
!(p.id == decl.declaring_page_id && p.subgraph.is_none())
});
}
}
}
all_parsed.extend(cache.subgraph_pages.clone());
// Generate directory index pages for intermediate dirs without README
let existing_ids: std::collections::HashSet<String> = all_parsed
.iter()
.map(|p| p.id.clone())
.collect();
let mut seen_dirs: std::collections::HashSet<String> = std::collections::HashSet::new();
for page in all_parsed.iter() {
if let Some(ref ns) = page.namespace {
for decl in &subgraph_decls {
if ns.starts_with(&decl.name) {
let after_root = ns.strip_prefix(&format!("{}/", decl.name)).unwrap_or("");
let mut accumulated = decl.name.clone();
for segment in after_root.split('/').filter(|s| !s.is_empty()) {
accumulated = format!("{}/{}", accumulated, segment);
seen_dirs.insert(accumulated.clone());
}
}
}
}
}
for dir_name in &seen_dirs {
let dir_slug = crate::parser::slugify_page_name(dir_name);
if !existing_ids.contains(&dir_slug) {
let short_name = dir_name.rsplit('/').next().unwrap_or(dir_name);
all_parsed.push(crate::parser::ParsedPage {
id: dir_slug,
meta: crate::parser::PageMeta {
title: dir_name.clone(),
properties: std::collections::HashMap::new(),
tags: vec![],
public: Some(true),
aliases: vec![],
date: None,
icon: None,
menu_order: None,
stake: None,
},
kind: crate::parser::PageKind::Page,
source_path: std::path::PathBuf::new(),
namespace: {
dir_name.rsplitn(2, '/').nth(1).map(|s| s.to_string())
},
subgraph: None,
content_md: format!("# {}\n", short_name),
outgoing_links: vec![],
});
}
}
}
// Step 3: Detect content, meta, and link changes BEFORE building graph (cheap hash comparison).
let mut dirty_ids: HashSet<PageId> = HashSet::new();
let mut content_page_ids: HashSet<PageId> = HashSet::new();
let mut meta_changed = false;
let mut links_changed = false;
for page in &all_parsed {
content_page_ids.insert(page.id.clone());
// Only mark pages dirty if they were actually re-parsed from a changed file.
// Subgraph pages and unchanged pages may have non-deterministic hash diffs
// due to merge order differences โ these must not pollute dirty_ids.
let was_reparsed = changed_page_ids.contains(&page.id);
// Content hash โ detect body text changes
let new_hash = hash_str(&page.content_md);
if let Some(&old_hash) = cache.content_hashes.get(&page.id) {
if old_hash != new_hash && was_reparsed {
dirty_ids.insert(page.id.clone());
}
} else if was_reparsed {
dirty_ids.insert(page.id.clone());
}
cache.content_hashes.insert(page.id.clone(), new_hash);
// Meta hash โ detect frontmatter changes (title, aliases, icon, stake, tags)
let new_meta_hash = hash_meta(page);
if let Some(&old_meta_hash) = cache.meta_hashes.get(&page.id) {
if old_meta_hash != new_meta_hash && was_reparsed {
dirty_ids.insert(page.id.clone());
meta_changed = true;
}
} else if cache.initialized && was_reparsed {
meta_changed = true;
}
cache.meta_hashes.insert(page.id.clone(), new_meta_hash);
// Outgoing links hash โ detect link set changes
let new_link_hash = hash_links(page);
if let Some(&old_link_hash) = cache.link_hashes.get(&page.id) {
if old_link_hash != new_link_hash && was_reparsed {
dirty_ids.insert(page.id.clone());
links_changed = true;
}
} else if cache.initialized && was_reparsed {
links_changed = true;
}
cache.link_hashes.insert(page.id.clone(), new_link_hash);
}
// Check for structural change: pages added or removed, or tags changed, or meta/links changed.
let pages_added_or_removed = content_page_ids != cache.last_content_page_ids;
let tags_changed = dirty_ids.iter().any(|dirty_id| {
all_parsed.iter().find(|p| &p.id == dirty_id).map(|page| {
let new_tag_hash = hash_str(&page.meta.tags.join(","));
let changed = cache.tag_hashes.get(dirty_id)
.map(|&old| old != new_tag_hash)
.unwrap_or(true);
cache.tag_hashes.insert(dirty_id.clone(), new_tag_hash);
changed
}).unwrap_or(false)
});
// Meta or link changes escalate to structural rebuild since they affect other pages
let structural_change = !cache.initialized
|| pages_added_or_removed
|| tags_changed
|| meta_changed
|| links_changed;
// Step 4: Build or reuse graph store.
// Full graph build (PageRank, gravity, etc.) is expensive โ only do it for structural changes.
if structural_change || cache.cached_store.is_none() {
let old_namespace_keys = cache.last_namespace_keys.clone();
let store = crate::graph::build_graph(all_parsed)?;
cache.last_content_page_ids = content_page_ids.clone();
// Fix 4: Compare backlink snapshots โ mark pages with changed backlinks dirty
for (page_id, backlinks) in &store.backlinks {
let mut sorted = backlinks.clone();
sorted.sort();
let old_snapshot = cache.backlink_snapshots.get(page_id);
if old_snapshot != Some(&sorted) {
dirty_ids.insert(page_id.clone());
}
cache.backlink_snapshots.insert(page_id.clone(), sorted);
}
// Also check pages that used to have backlinks but no longer do
let new_backlink_keys: HashSet<&PageId> = store.backlinks.keys().collect();
let stale_backlink_pages: Vec<PageId> = cache.backlink_snapshots.keys()
.filter(|k| !new_backlink_keys.contains(k))
.cloned()
.collect();
for page_id in &stale_backlink_pages {
if cache.backlink_snapshots.get(page_id).map(|v| !v.is_empty()).unwrap_or(false) {
dirty_ids.insert(page_id.clone());
}
cache.backlink_snapshots.remove(page_id);
}
// Fix 3: Mark namespace parents dirty on structural changes
for ns_key in store.namespace_tree.keys() {
// Namespace parent pages need re-rendering (they show child lists)
dirty_ids.insert(ns_key.clone());
}
// Also mark parents of removed namespaces dirty
for old_key in &old_namespace_keys {
if !store.namespace_tree.contains_key(old_key) {
dirty_ids.insert(old_key.clone());
}
}
cache.last_namespace_keys = store.namespace_tree.keys().cloned().collect();
for (page_id, page) in &store.pages {
if !cache.content_hashes.contains_key(page_id) {
cache.content_hashes.insert(page_id.clone(), hash_str(&page.content_md));
}
if !cache.meta_hashes.contains_key(page_id) {
cache.meta_hashes.insert(page_id.clone(), hash_meta(page));
}
if !cache.link_hashes.contains_key(page_id) {
cache.link_hashes.insert(page_id.clone(), hash_links(page));
}
}
cache.cached_store = Some(store);
} else {
// Content-only change: update page content in the cached store in-place.
// Skip expensive PageRank/gravity/trikernel recomputation.
let store = cache.cached_store.as_mut().unwrap();
for dirty_id in &dirty_ids {
if let Some(new_page) = all_parsed.iter().find(|p| &p.id == dirty_id) {
if let Some(cached_page) = store.pages.get_mut(dirty_id) {
cached_page.content_md = new_page.content_md.clone();
cached_page.meta = new_page.meta.clone();
cached_page.outgoing_links = new_page.outgoing_links.clone();
}
}
}
}
// Fix 5: Prune stale cache entries โ only keep current page IDs
{
let store = cache.cached_store.as_ref().unwrap();
let current_ids: HashSet<&PageId> = store.pages.keys().collect();
cache.content_hashes.retain(|k, _| current_ids.contains(k));
cache.tag_hashes.retain(|k, _| current_ids.contains(k));
cache.meta_hashes.retain(|k, _| current_ids.contains(k));
cache.link_hashes.retain(|k, _| current_ids.contains(k));
cache.backlink_snapshots.retain(|k, _| current_ids.contains(k));
cache.render_cache.retain(|k, _| current_ids.contains(k)
|| k.starts_with("__")); // Keep synthetic page caches
}
if structural_change {
dirty_ids.insert("__structural__".to_string());
}
let dirty_count = dirty_ids.len();
let store = cache.cached_store.as_ref().unwrap();
// Step 5: Selective render
let dirty_ref = if cache.initialized {
Some(&dirty_ids)
} else {
None
};
let rendered = crate::render::render_cached(
store,
config,
&mut cache.render_cache,
dirty_ref,
)?;
let total = rendered.len();
// Step 6: Output โ write only what changed
if !cache.initialized {
// First build: full output
crate::output::write_output(&rendered, &store, config, &discovered)?;
cache.initialized = true;
} else if structural_change {
// Structural change: write all pages + regenerate indexes
crate::output::write_incremental(&rendered, &store, config, &discovered)?;
} else {
// Content-only change: write just the dirty pages
crate::output::write_dirty_pages(&rendered, &dirty_ids, config)?;
}
Ok((total, dirty_count))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::{PageMeta, PageKind, ParsedPage};
use std::collections::HashMap;
use std::path::PathBuf;
/// Helper to build a minimal ParsedPage for testing hash functions.
fn make_page(id: &str, title: &str, content: &str) -> ParsedPage {
ParsedPage {
id: id.to_string(),
meta: PageMeta {
title: title.to_string(),
properties: HashMap::new(),
tags: vec![],
public: Some(true),
aliases: vec![],
date: None,
icon: None,
menu_order: None,
stake: None,
},
kind: PageKind::Page,
source_path: PathBuf::new(),
namespace: None,
subgraph: None,
content_md: content.to_string(),
outgoing_links: vec![],
}
}
#[test]
fn test_hash_str_deterministic() {
let input = "hello world";
assert_eq!(hash_str(input), hash_str(input));
// Call multiple times to ensure stability
let h1 = hash_str(input);
let h2 = hash_str(input);
let h3 = hash_str(input);
assert_eq!(h1, h2);
assert_eq!(h2, h3);
}
#[test]
fn test_hash_str_different() {
let h1 = hash_str("hello");
let h2 = hash_str("world");
assert_ne!(h1, h2, "different inputs must produce different hashes");
// Also test subtle differences
let h3 = hash_str("Hello");
assert_ne!(h1, h3, "case difference must produce different hash");
}
#[test]
fn test_hash_meta_detects_title_change() {
let page_a = make_page("test", "Title A", "content");
let page_b = make_page("test", "Title B", "content");
assert_ne!(
hash_meta(&page_a),
hash_meta(&page_b),
"different titles must produce different meta hashes"
);
}
#[test]
fn test_hash_meta_detects_icon_change() {
let mut page_a = make_page("test", "Title", "content");
let mut page_b = make_page("test", "Title", "content");
page_a.meta.icon = Some("๐ต".to_string());
page_b.meta.icon = Some("๐ข".to_string());
assert_ne!(
hash_meta(&page_a),
hash_meta(&page_b),
"different icons must produce different meta hashes"
);
// Also check None vs Some
let page_c = make_page("test", "Title", "content");
assert_ne!(
hash_meta(&page_a),
hash_meta(&page_c),
"icon Some vs None must produce different meta hashes"
);
}
#[test]
fn test_hash_meta_detects_alias_change() {
let mut page_a = make_page("test", "Title", "content");
let mut page_b = make_page("test", "Title", "content");
page_a.meta.aliases = vec!["alias1".to_string()];
page_b.meta.aliases = vec!["alias2".to_string()];
assert_ne!(
hash_meta(&page_a),
hash_meta(&page_b),
"different aliases must produce different meta hashes"
);
// Same aliases โ same hash
let mut page_c = make_page("test", "Title", "content");
page_c.meta.aliases = vec!["alias1".to_string()];
assert_eq!(
hash_meta(&page_a),
hash_meta(&page_c),
"identical aliases must produce same meta hash"
);
}
#[test]
fn test_hash_links_detects_link_change() {
let mut page_a = make_page("test", "Title", "content");
let mut page_b = make_page("test", "Title", "content");
page_a.outgoing_links = vec!["link-a".to_string()];
page_b.outgoing_links = vec!["link-b".to_string()];
assert_ne!(
hash_links(&page_a),
hash_links(&page_b),
"different link sets must produce different link hashes"
);
}
#[test]
fn test_hash_links_order_independent() {
let mut page_a = make_page("test", "Title", "content");
let mut page_b = make_page("test", "Title", "content");
page_a.outgoing_links = vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()];
page_b.outgoing_links = vec!["gamma".to_string(), "alpha".to_string(), "beta".to_string()];
assert_eq!(
hash_links(&page_a),
hash_links(&page_b),
"hash_links must be order-independent (it sorts internally)"
);
}
}
optica/src/server/reload.rs
ฯ 0.0%
use crateSiteConfig;
use crate;
use crateRenderedPage;
use Result;
use Colorize;
use ;
use PathBuf;
use ;
use ;
use ;
pub const RELOAD_SCRIPT: &str = r#"<script>
(function() {
let retries = 0;
function connect() {
const es = new EventSource('/__reload');
es.onmessage = function(e) {
if (e.data === 'reload') {
window.location.reload();
}
retries = 0;
};
es.onerror = function() {
es.close();
if (retries < 120) {
retries++;
setTimeout(connect, 1000);
}
};
}
connect();
})();
</script>"#;
/// Cached state for incremental rebuilds.
/// Simple hash for content change detection (not cryptographic).
/// Hash frontmatter metadata fields that affect other pages (title, aliases, icon, stake, tags).
/// Hash outgoing links to detect when a page's link set changes (affects backlinks on targets).
/// Start a background thread that watches for file changes and rebuilds.
/// Increments `build_version` after each successful rebuild so SSE clients know to reload.
/// Fast path for content-only edits: skip the full filesystem scan entirely.
/// Returns Some((total, dirty)) on success, None if the fast path doesn't apply.
/// Incremental rebuild: selective parse โ full graph โ selective render โ incremental output.
/// Returns (total_rendered, dirty_count).