From 1f03d91c5f938dc93cd74213579fb6c295a5bc55 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sat, 15 Aug 2026 10:23:10 +0200 Subject: [PATCH] Refresh every stale post route during validation. --- README.md | 2 +- crates/bds-core/src/engine/validate_site.rs | 37 +++++---- crates/bds-core/src/render/mod.rs | 4 +- crates/bds-core/src/render/routes.rs | 16 ++++ crates/bds-core/src/render/site.rs | 56 +++++-------- crates/bds-core/tests/m4_generation_engine.rs | 80 +++++++++++++++++++ crates/bds-release/src/packaging.rs | 21 +++-- crates/bds-ui/tests/packaging_assets.rs | 3 +- specs/generation.allium | 5 +- 9 files changed, 149 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index 372b64b..1c1acc6 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ The project is under active development. Core blogging workflows are broadly ava - Persistent conversational AI with safe Markdown, streamed and cancellable responses, model/session/token tracking, bounded project-aware blog tools, and localized conversation management in the Chat workspace. Allowlisted render tools add persistent native cards, charts, forms, lists, metrics, mind maps, tables, and tabs without executing assistant-provided HTML or JavaScript. - SSH-agent-based SCP or rsync publishing. - Integrated Git workflow for each blog project's current repository, with repository initialization, read-only origin discovery, Git LFS image tracking, status and diffs, branch/file history with bDS2-compatible sync-status colors, commits, cancellable fetch/pull/push, and post-pull filesystem reconciliation; network actions respect airplane mode. -- Site, media, and translation validation plus `ruds://new-post` Blogmark capture and Lua transforms; captures open directly in the post editor as soon as the draft exists without changing the current or restored sidebar state or waiting for on-device semantic indexing, and defer automatic translation until an explicit manual save, which queues one task per still-missing language. Rendering tasks keep long generated URLs compact in progress messages. Publishing never starts automatic translation. bDS2 keeps its separate `bds2://` bookmarklet protocol. +- Site validation detects and repairs stale content at every generated post URL, alongside media and translation validation plus `ruds://new-post` Blogmark capture and Lua transforms; captures open directly in the post editor as soon as the draft exists without changing the current or restored sidebar state or waiting for on-device semantic indexing, and defer automatic translation until an explicit manual save, which queues one task per still-missing language. Rendering tasks keep long generated URLs compact in progress messages. Publishing never starts automatic translation. bDS2 keeps its separate `bds2://` bookmarklet protocol. RuDS uses no JavaScript application runtime and loads no CSS or JavaScript from CDNs. The preview is served by the Rust application and displayed by the operating-system webview. diff --git a/crates/bds-core/src/engine/validate_site.rs b/crates/bds-core/src/engine/validate_site.rs index fb4efa4..9db9837 100644 --- a/crates/bds-core/src/engine/validate_site.rs +++ b/crates/bds-core/src/engine/validate_site.rs @@ -9,7 +9,7 @@ use crate::db::queries; use crate::engine::generation::has_published_snapshot; use crate::engine::{EngineError, EngineResult}; use crate::model::Post; -use crate::render::{build_canonical_post_path, build_site_route_manifest}; +use crate::render::{build_post_route_paths, build_site_route_manifest}; const MTIME_GRANULARITY_TOLERANCE_MS: i64 = 1_000; @@ -145,24 +145,23 @@ fn stale_post_paths( if language != main_language && post.do_not_translate { continue; } - let relative_path = format!( - "{}/index.html", - build_canonical_post_path(post, language, main_language).trim_start_matches('/') - ); - if !expected.contains(&relative_path) || !actual.contains(&relative_path) { - continue; - } - let Some(output_modified) = modified_ms(&output_dir.join(&relative_path)) else { - continue; - }; - let effective_generated = output_modified.max( - generated_at - .get(&relative_path) - .copied() - .unwrap_or_default(), - ); - if source_modified > effective_generated + MTIME_GRANULARITY_TOLERANCE_MS { - stale.push(relative_path); + for url_path in build_post_route_paths(post, language, main_language) { + let relative_path = format!("{}/index.html", url_path.trim_start_matches('/')); + if !expected.contains(&relative_path) || !actual.contains(&relative_path) { + continue; + } + let Some(output_modified) = modified_ms(&output_dir.join(&relative_path)) else { + continue; + }; + let effective_generated = output_modified.max( + generated_at + .get(&relative_path) + .copied() + .unwrap_or_default(), + ); + if source_modified > effective_generated + MTIME_GRANULARITY_TOLERANCE_MS { + stale.push(relative_path); + } } } } diff --git a/crates/bds-core/src/render/mod.rs b/crates/bds-core/src/render/mod.rs index 3b83f06..e86084d 100644 --- a/crates/bds-core/src/render/mod.rs +++ b/crates/bds-core/src/render/mod.rs @@ -15,7 +15,9 @@ pub use generation::{ pub use markdown::render_markdown_to_html; pub(crate) use page_renderer::validate_liquid_template_syntax; pub use page_renderer::{RenderError, render_liquid_template}; -pub(crate) use routes::{PostLanguageVariant, blog_page_title, select_post_language_variant}; +pub(crate) use routes::{ + PostLanguageVariant, blog_page_title, build_post_route_paths, select_post_language_variant, +}; pub use routes::{ RenderedPage, build_canonical_post_path, render_starter_list_page, render_starter_list_page_with_media_map, render_starter_single_post_page, diff --git a/crates/bds-core/src/render/routes.rs b/crates/bds-core/src/render/routes.rs index 222320f..91091b9 100644 --- a/crates/bds-core/src/render/routes.rs +++ b/crates/bds-core/src/render/routes.rs @@ -161,6 +161,22 @@ pub fn build_canonical_post_path(post: &Post, language: &str, main_language: &st } } +pub(crate) fn build_post_route_paths( + post: &Post, + language: &str, + main_language: &str, +) -> Vec { + let mut paths = vec![build_canonical_post_path(post, language, main_language)]; + if post.categories.iter().any(|category| category == "page") { + paths.push(if language == main_language { + format!("/{}", post.slug) + } else { + format!("/{language}/{}", post.slug) + }); + } + paths +} + pub fn render_starter_single_post_page( post: &Post, body_markdown: &str, diff --git a/crates/bds-core/src/render/site.rs b/crates/bds-core/src/render/site.rs index 4ad279f..af3e57b 100644 --- a/crates/bds-core/src/render/site.rs +++ b/crates/bds-core/src/render/site.rs @@ -19,7 +19,8 @@ use crate::model::{ }; use crate::render::{ PostLanguageVariant, RenderCategorySettings, RenderTemplateLookup, blog_page_title, - build_canonical_post_path, resolve_post_template, select_post_language_variant, + build_canonical_post_path, build_post_route_paths, resolve_post_template, + select_post_language_variant, }; use crate::scripting::{CoreHost, HostApi, UnavailableHost}; use crate::util::frontmatter::{read_script_file, read_template_file, read_translation_file}; @@ -203,26 +204,16 @@ pub fn build_site_route_manifest( ); for record in posts { - let canonical_path = build_canonical_post_path(&record.post, &language, &main_language); - let mut paths = vec![canonical_path]; - if record - .post - .categories - .iter() - .any(|category| category == "page") - { - paths.push(if language == main_language { - format!("/{}", record.post.slug) - } else { - format!("/{language}/{}", record.post.slug) - }); - } - manifest.extend(paths.into_iter().map(|url_path| SitePage { - language: language.clone(), - relative_path: format!("{}/index.html", url_path.trim_start_matches('/')), - url_path, - html: String::new(), - })); + manifest.extend( + build_post_route_paths(&record.post, &language, &main_language) + .into_iter() + .map(|url_path| SitePage { + language: language.clone(), + relative_path: format!("{}/index.html", url_path.trim_start_matches('/')), + url_path, + html: String::new(), + }), + ); } } @@ -521,23 +512,12 @@ pub fn build_site_render_artifacts_from_context( let mut single_routes = Vec::new(); for record in localized_posts { let canonical_path = build_canonical_post_path(&record.post, language, main_language); - let mut post_paths = vec![(canonical_path, GenerationSection::Single)]; - if record - .post - .categories - .iter() - .any(|category| category == "page") - { - post_paths.push(( - if language == main_language { - format!("/{}", record.post.slug) - } else { - format!("/{language}/{}", record.post.slug) - }, - GenerationSection::Core, - )); - } - for (url_path, route_section) in post_paths { + for url_path in build_post_route_paths(&record.post, language, main_language) { + let route_section = if url_path == canonical_path { + GenerationSection::Single + } else { + GenerationSection::Core + }; let relative_path = format!("{}/index.html", url_path.trim_start_matches('/')); artifacts.route_manifest.push(SitePage { language: language.clone(), diff --git a/crates/bds-core/tests/m4_generation_engine.rs b/crates/bds-core/tests/m4_generation_engine.rs index 56dbe02..ea545db 100644 --- a/crates/bds-core/tests/m4_generation_engine.rs +++ b/crates/bds-core/tests/m4_generation_engine.rs @@ -1023,6 +1023,86 @@ fn site_validation_detects_post_sources_newer_than_generated_routes() { ); } +#[test] +fn site_validation_repairs_every_stale_route_for_page_posts() { + use std::fs::{File, FileTimes}; + use std::time::{Duration, UNIX_EPOCH}; + + let (db, dir) = setup(); + let metadata = make_metadata(); + let mut post = make_post("wiki", 1_710_000_000_000); + post.categories = vec!["page".into(), "wiki".into()]; + write_published_snapshot(&dir, &mut post, "Old wiki body"); + bds_core::db::queries::post::insert_post(db.conn(), &post).unwrap(); + let old_source = PublishedPostSource { + post: post.clone(), + body_markdown: "Old wiki body".into(), + }; + + generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &[old_source], "en").unwrap(); + write_published_snapshot(&dir, &mut post, "Updated wiki body"); + + let canonical_path = "2024/03/09/wiki/index.html"; + let flat_path = "wiki/index.html"; + for path in [canonical_path, flat_path] { + File::options() + .write(true) + .open(dir.path().join(path)) + .unwrap() + .set_times(FileTimes::new().set_modified(UNIX_EPOCH + Duration::from_secs(10))) + .unwrap(); + let mut hash = bds_core::db::queries::generated_file_hash::get_generated_file_hash( + db.conn(), + "p1", + path, + ) + .unwrap(); + hash.updated_at = 10_000; + bds_core::db::queries::generated_file_hash::upsert_generated_file_hash(db.conn(), &hash) + .unwrap(); + } + File::options() + .write(true) + .open(dir.path().join(&post.file_path)) + .unwrap() + .set_times(FileTimes::new().set_modified(UNIX_EPOCH + Duration::from_secs(20))) + .unwrap(); + + let validation = validate_site(db.conn(), dir.path(), "p1").unwrap(); + assert_eq!( + validation.stale_pages, + vec![canonical_path.to_string(), flat_path.to_string()] + ); + + let updated_source = load_published_post_source(dir.path(), post) + .unwrap() + .unwrap(); + let sections = sections_from_validation_report(&validation, &metadata); + apply_validation_sections( + db.conn(), + dir.path(), + "p1", + &metadata, + &[updated_source], + &validation, + §ions, + ) + .unwrap(); + + assert!( + std::fs::read_to_string(dir.path().join(canonical_path)) + .unwrap() + .contains("Updated wiki body") + ); + assert!( + std::fs::read_to_string(dir.path().join(flat_path)) + .unwrap() + .contains("Updated wiki body") + ); + let repaired = validate_site(db.conn(), dir.path(), "p1").unwrap(); + assert!(repaired.stale_pages.is_empty()); +} + #[test] fn site_validation_refreshes_sitemap_without_rendering_pages() { let (db, dir) = setup(); diff --git a/crates/bds-release/src/packaging.rs b/crates/bds-release/src/packaging.rs index d3097bc..570975e 100644 --- a/crates/bds-release/src/packaging.rs +++ b/crates/bds-release/src/packaging.rs @@ -82,15 +82,14 @@ fn prepare_macos_x64_runtime_from_archive( for entry in archive.entries()? { let mut entry = entry?; let path = entry.path()?; - let destination = if path.ends_with(format!( - "lib/libonnxruntime.{MACOS_X64_ORT_VERSION}.dylib" - )) { - Some(format!("libonnxruntime.{MACOS_X64_ORT_VERSION}.dylib")) - } else if path.ends_with("LICENSE") { - Some("ONNXRuntime-LICENSE.txt".to_owned()) - } else { - None - }; + let destination = + if path.ends_with(format!("lib/libonnxruntime.{MACOS_X64_ORT_VERSION}.dylib")) { + Some(format!("libonnxruntime.{MACOS_X64_ORT_VERSION}.dylib")) + } else if path.ends_with("LICENSE") { + Some("ONNXRuntime-LICENSE.txt".to_owned()) + } else { + None + }; if let Some(destination) = destination { io::copy(&mut entry, &mut File::create(directory.join(destination))?)?; } @@ -335,9 +334,7 @@ fn runtime_files( .filter_map(|name| name.into_string().ok()) .filter(|name| name.starts_with("libonnxruntime") && name.contains(".dylib")) .collect::>(); - let has_versioned = names - .iter() - .any(|name| name != "libonnxruntime.dylib"); + let has_versioned = names.iter().any(|name| name != "libonnxruntime.dylib"); if has_versioned { names.retain(|name| name != "libonnxruntime.dylib"); } diff --git a/crates/bds-ui/tests/packaging_assets.rs b/crates/bds-ui/tests/packaging_assets.rs index 93bbcec..c9815ba 100644 --- a/crates/bds-ui/tests/packaging_assets.rs +++ b/crates/bds-ui/tests/packaging_assets.rs @@ -260,8 +260,7 @@ fn tagged_releases_are_built_and_packaged_with_rust_tools() { let workspace_manifest = fs::read_to_string(workspace.join("Cargo.toml")).unwrap(); let core_manifest = fs::read_to_string(workspace.join("crates/bds-core/Cargo.toml")).unwrap(); assert!( - workspace_manifest - .contains("pagefind = { version = \"1.5.2\", default-features = false }"), + workspace_manifest.contains("pagefind = { version = \"1.5.2\", default-features = false }"), "Pagefind's unused Actix serving stack must stay disabled" ); assert!( diff --git a/specs/generation.allium b/specs/generation.allium index d7409e2..efe8d32 100644 --- a/specs/generation.allium +++ b/specs/generation.allium @@ -296,8 +296,9 @@ rule ValidateSite { -- missing: expected routes with no non-empty index.html -- extra: index.html routes absent from the expected set, including -- zero-byte files - -- stale: existing post routes whose source mtime is more than one second - -- newer than both the output mtime and tracked generation time + -- stale: every existing generated route for a post whose source mtime is + -- more than one second newer than both the output mtime and + -- tracked generation time, including canonical and flat page URLs -- Standalone HTML, XML, JSON, assets, Pagefind files, and content hashes are -- not validation comparison inputs. ensures: ValidationReport(missing_pages, extra_pages, stale_pages)