diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 995d123..0f6fd8f 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -468,13 +468,14 @@ Templates are stored as files with frontmatter metadata in the project data dire Publishing in RuDS is a staged process: publish content locally, generate or validate-and-apply site changes, commit the result, then deploy when ready. -Full generation builds the entire static site. Site validation detects missing, extra, and updated routes so RuDS can re-render only what changed. This is the practical incremental workflow for most daily editorial changes. +Full generation builds the entire static site and skips files whose tracked content hash is unchanged. Use **Blog → Force Render Site** (Command/Ctrl+Shift+R) when every generated file must be rewritten regardless of its stored hash. Site validation detects missing, extra, and updated routes so RuDS can re-render only what changed. This is the practical incremental workflow for most daily editorial changes. When blog languages are configured, generation produces language-aware route trees, per-language feeds, and alternate language metadata. ### Key takeaways - Full generation produces the complete site. +- Force Render Site rewrites the complete site and search index without relying on stored hashes. - Validate and Apply is the efficient daily workflow for incremental publishing. - Public Base URL must be set before generation. - Commit generated output before deploying for recoverability. diff --git a/README.md b/README.md index 1cd934b..7e92c6a 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ The project is under active development. Core blogging workflows are broadly ava - Local MCP automation over stdio or a localhost-only stateless HTTP endpoint, with project resources, read/search/count tools, uniquely identified inert write proposals, clean duplicate-pending rejection, explicit desktop approval, and opt-in Claude Code/Copilot configuration. - A fully localized Ratatui terminal workspace, available locally through `bds-cli tui`/`BDS_MODE=tui` and remotely through authenticated SSH shell sessions, with shared post/template/script editing and publishing, project/search/command overlays, settings, tags, Git, reports, task progress, live multi-client locale updates, and airplane-mode AI gating. - `bds-cli server` hosting the shared application engines over a loopback-by-default, public-key-only SSH service, with restrictive private key material, live authorization updates, terminal-session transport, CLI-change synchronization, ordered domain/task events, and native desktop remote-project selection. -- bDS2-compatible Markdown/Liquid rendering with built-in macros rendered from bundled Liquid templates in isolated scopes (customizable with `macros/*` partial-template slugs), descriptive category archive titles, canonical multilingual and flat page routes for every configured blog language, recursive menus, calendar archives, feeds, a root hreflang sitemap, Pagefind, and fast route/mtime-based incremental validation with targeted repair through cancellable section task groups. +- bDS2-compatible Markdown/Liquid rendering with built-in macros rendered from bundled Liquid templates in isolated scopes (customizable with `macros/*` partial-template slugs), descriptive category archive titles, canonical multilingual and flat page routes for every configured blog language, recursive menus, calendar archives, feeds, a root hreflang sitemap, Pagefind, change-aware and forced full renders from the native Blog menu/CLI/TUI, and fast route/mtime-based incremental validation with targeted repair through cancellable section task groups. - Navigable generated-route preview in the app or system browser, with draft database overlays and published filesystem content. - Optional one-shot AI translation, description, analysis, taxonomy, and language-detection operations run in background tasks with editor-level waiting indicators, using provider-portable JSON-only requests through independent online and local OpenAI-compatible profiles. Each profile has secure credentials, persistently discovered chat/title/image model selections, explicit tool/vision overrides, chat testing, and restart-persistent status-bar airplane-mode routing. - 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. diff --git a/crates/bds-cli/src/lib.rs b/crates/bds-cli/src/lib.rs index 79c5443..b966af3 100644 --- a/crates/bds-cli/src/lib.rs +++ b/crates/bds-cli/src/lib.rs @@ -521,17 +521,26 @@ fn render(db: &Database, incremental: bool, force: bool) -> Result EngineResult { - use crate::db::schema::generated_file_hashes; - use diesel::prelude::*; - - Ok(conn.with(|connection| { - diesel::delete( - generated_file_hashes::table.filter(generated_file_hashes::project_id.eq(project_id)), - ) - .execute(connection) - })?) +pub fn generate_starter_site_forced( + conn: &Connection, + output_dir: &Path, + project_id: &str, + metadata: &ProjectMetadata, + posts: &[PublishedPostSource], + language: &str, +) -> EngineResult { + generate_starter_site_with_progress_mode( + conn, + output_dir, + project_id, + metadata, + posts, + language, + true, + |_current, _total, _path| {}, + ) } pub fn generate_starter_site_with_progress( @@ -123,23 +130,49 @@ pub fn generate_starter_site_with_progress( metadata: &ProjectMetadata, posts: &[PublishedPostSource], _language: &str, + on_page: impl FnMut(usize, usize, &str), +) -> EngineResult { + generate_starter_site_with_progress_mode( + conn, output_dir, project_id, metadata, posts, _language, false, on_page, + ) +} + +#[expect( + clippy::too_many_arguments, + reason = "full generation adds write mode to the existing generation context" +)] +fn generate_starter_site_with_progress_mode( + conn: &Connection, + output_dir: &Path, + project_id: &str, + metadata: &ProjectMetadata, + posts: &[PublishedPostSource], + _language: &str, + force: bool, mut on_page: impl FnMut(usize, usize, &str), ) -> EngineResult { let mut report = GenerationReport::default(); for section in GenerationSection::ALL { - report.append(render_site_section_with_progress( + report.append(render_site_section_with_progress_mode( conn, output_dir, project_id, metadata, posts, section, + force, &mut on_page, || false, )?); } - report.append(build_site_search_index( - conn, output_dir, project_id, metadata, + report.append(build_site_search_index_with_progress_mode( + conn, + output_dir, + project_id, + metadata, + force, + |_current, _total, _path| {}, + || false, )?); Ok(report) } @@ -157,6 +190,61 @@ pub fn render_site_section_with_progress( section: GenerationSection, mut on_page: impl FnMut(usize, usize, &str), mut is_cancelled: impl FnMut() -> bool, +) -> EngineResult { + render_site_section_with_progress_mode( + conn, + output_dir, + project_id, + metadata, + posts, + section, + false, + &mut on_page, + &mut is_cancelled, + ) +} + +#[expect( + clippy::too_many_arguments, + reason = "forced section rendering adds write mode to the existing generation context" +)] +pub fn render_site_section_forced_with_progress( + conn: &Connection, + output_dir: &Path, + project_id: &str, + metadata: &ProjectMetadata, + posts: &[PublishedPostSource], + section: GenerationSection, + mut on_page: impl FnMut(usize, usize, &str), + mut is_cancelled: impl FnMut() -> bool, +) -> EngineResult { + render_site_section_with_progress_mode( + conn, + output_dir, + project_id, + metadata, + posts, + section, + true, + &mut on_page, + &mut is_cancelled, + ) +} + +#[expect( + clippy::too_many_arguments, + reason = "section rendering uses the existing generation context, write mode, and callbacks" +)] +fn render_site_section_with_progress_mode( + conn: &Connection, + output_dir: &Path, + project_id: &str, + metadata: &ProjectMetadata, + posts: &[PublishedPostSource], + section: GenerationSection, + force: bool, + mut on_page: impl FnMut(usize, usize, &str), + mut is_cancelled: impl FnMut() -> bool, ) -> EngineResult { if is_cancelled() { return Err(EngineError::Validation("cancelled".to_string())); @@ -188,6 +276,8 @@ pub fn render_site_section_with_progress( &page.relative_path, &page.html, &mut report, + force, + false, )?; on_page(index + 1, total_pages, &page.url_path); } @@ -204,6 +294,8 @@ pub fn render_site_section_with_progress( None, &mut report, &mut is_cancelled, + force, + false, )?; } Ok(report) @@ -345,6 +437,8 @@ pub fn apply_validation_section_with_progress( &page.relative_path, &page.html, &mut report, + false, + true, )?; on_page(index + 1, total_pages, &page.url_path); } @@ -361,6 +455,8 @@ pub fn apply_validation_section_with_progress( (!fallback).then_some(&requested), &mut report, &mut is_cancelled, + false, + true, )?; } @@ -415,9 +511,11 @@ fn write_core_outputs( requested: Option<&HashSet>, report: &mut GenerationReport, is_cancelled: &mut impl FnMut() -> bool, + force: bool, + verify_output: bool, ) -> EngineResult<()> { if requested.is_none() { - write_bundled_site_assets(conn, output_dir, project_id, report)?; + write_bundled_site_assets(conn, output_dir, project_id, report, force)?; } let mut outputs = vec![( "calendar.json".to_string(), @@ -483,12 +581,25 @@ fn write_core_outputs( return Err(EngineError::Validation("cancelled".to_string())); } if requested.is_none_or(|requested| requested.contains(&path)) { - write_out(conn, output_dir, project_id, &path, &content, report)?; + write_out( + conn, + output_dir, + project_id, + &path, + &content, + report, + force, + verify_output, + )?; } } Ok(()) } +#[expect( + clippy::too_many_arguments, + reason = "generated output needs its render context, report, and write mode" +)] fn write_out( conn: &Connection, output_dir: &Path, @@ -496,10 +607,18 @@ fn write_out( relative_path: &str, content: &str, report: &mut GenerationReport, + force: bool, + verify_output: bool, ) -> EngineResult<()> { - match write_generated_file(conn, output_dir, project_id, relative_path, content) - .map_err(|error| EngineError::Parse(error.to_string()))? - { + let outcome = if force { + write_generated_file_forced(conn, output_dir, project_id, relative_path, content) + } else if verify_output { + write_generated_file_verified(conn, output_dir, project_id, relative_path, content) + } else { + write_generated_file(conn, output_dir, project_id, relative_path, content) + } + .map_err(|error| EngineError::Parse(error.to_string()))?; + match outcome { GeneratedWriteOutcome::Written => report.written_paths.push(relative_path.to_string()), GeneratedWriteOutcome::SkippedUnchanged => { report.skipped_paths.push(relative_path.to_string()) @@ -524,6 +643,25 @@ pub fn build_site_search_index( ) } +pub fn build_site_search_index_forced_with_progress( + conn: &Connection, + output_dir: &Path, + project_id: &str, + metadata: &ProjectMetadata, + on_file: impl FnMut(usize, usize, &str), + is_cancelled: impl FnMut() -> bool, +) -> EngineResult { + build_site_search_index_with_progress_mode( + conn, + output_dir, + project_id, + metadata, + true, + on_file, + is_cancelled, + ) +} + pub fn build_site_search_index_with_progress( conn: &Connection, output_dir: &Path, @@ -531,6 +669,26 @@ pub fn build_site_search_index_with_progress( metadata: &ProjectMetadata, mut on_file: impl FnMut(usize, usize, &str), mut is_cancelled: impl FnMut() -> bool, +) -> EngineResult { + build_site_search_index_with_progress_mode( + conn, + output_dir, + project_id, + metadata, + false, + &mut on_file, + &mut is_cancelled, + ) +} + +fn build_site_search_index_with_progress_mode( + conn: &Connection, + output_dir: &Path, + project_id: &str, + metadata: &ProjectMetadata, + force: bool, + mut on_file: impl FnMut(usize, usize, &str), + mut is_cancelled: impl FnMut() -> bool, ) -> EngineResult { let mut documents = Vec::new(); if output_dir.exists() { @@ -639,9 +797,13 @@ pub fn build_site_search_index_with_progress( if is_cancelled() { return Err(EngineError::Validation("cancelled".to_string())); } - match write_generated_bytes(conn, output_dir, project_id, &relative, &contents) - .map_err(|error| EngineError::Parse(error.to_string()))? - { + let outcome = if force { + write_generated_bytes_forced(conn, output_dir, project_id, &relative, &contents) + } else { + write_generated_bytes(conn, output_dir, project_id, &relative, &contents) + } + .map_err(|error| EngineError::Parse(error.to_string()))?; + match outcome { GeneratedWriteOutcome::Written => report.written_paths.push(relative.clone()), GeneratedWriteOutcome::SkippedUnchanged => report.skipped_paths.push(relative.clone()), } diff --git a/crates/bds-core/src/engine/site_assets.rs b/crates/bds-core/src/engine/site_assets.rs index bff768b..6214575 100644 --- a/crates/bds-core/src/engine/site_assets.rs +++ b/crates/bds-core/src/engine/site_assets.rs @@ -257,17 +257,28 @@ pub(crate) fn write_bundled_site_assets( output_dir: &Path, project_id: &str, report: &mut crate::engine::generation::GenerationReport, + force: bool, ) -> EngineResult<()> { for asset in BUNDLED_SITE_ASSETS { - match write_generated_bytes( - conn, - output_dir, - project_id, - asset.relative_path, - asset.bytes, - ) - .map_err(|error| EngineError::Parse(error.to_string()))? - { + let outcome = if force { + crate::render::write_generated_bytes_forced( + conn, + output_dir, + project_id, + asset.relative_path, + asset.bytes, + ) + } else { + write_generated_bytes( + conn, + output_dir, + project_id, + asset.relative_path, + asset.bytes, + ) + } + .map_err(|error| EngineError::Parse(error.to_string()))?; + match outcome { GeneratedWriteOutcome::Written => { report.written_paths.push(asset.relative_path.to_string()) } diff --git a/crates/bds-core/src/render/generation.rs b/crates/bds-core/src/render/generation.rs index f0c948b..3712967 100644 --- a/crates/bds-core/src/render/generation.rs +++ b/crates/bds-core/src/render/generation.rs @@ -29,13 +29,70 @@ pub fn write_generated_file( project_id: &str, relative_path: &str, content: &str, +) -> Result> { + write_generated_file_with_mode( + conn, + output_dir, + project_id, + relative_path, + content, + false, + false, + ) +} + +pub fn write_generated_file_verified( + conn: &Connection, + output_dir: &Path, + project_id: &str, + relative_path: &str, + content: &str, +) -> Result> { + write_generated_file_with_mode( + conn, + output_dir, + project_id, + relative_path, + content, + false, + true, + ) +} + +pub fn write_generated_file_forced( + conn: &Connection, + output_dir: &Path, + project_id: &str, + relative_path: &str, + content: &str, +) -> Result> { + write_generated_file_with_mode( + conn, + output_dir, + project_id, + relative_path, + content, + true, + false, + ) +} + +fn write_generated_file_with_mode( + conn: &Connection, + output_dir: &Path, + project_id: &str, + relative_path: &str, + content: &str, + force: bool, + verify_output: bool, ) -> Result> { let hash = content_hash(content.as_bytes()); let target_path = output_dir.join(relative_path); - if let Ok(existing) = qhash::get_generated_file_hash(conn, project_id, relative_path) + if !force + && let Ok(existing) = qhash::get_generated_file_hash(conn, project_id, relative_path) && existing.content_hash == hash && target_path.exists() - && file_hash(&target_path)? == hash + && (!verify_output || file_hash(&target_path)? == hash) { return Ok(GeneratedWriteOutcome::SkippedUnchanged); } @@ -64,13 +121,34 @@ pub fn write_generated_bytes( project_id: &str, relative_path: &str, content: &[u8], +) -> Result> { + write_generated_bytes_with_force(conn, output_dir, project_id, relative_path, content, false) +} + +pub fn write_generated_bytes_forced( + conn: &Connection, + output_dir: &Path, + project_id: &str, + relative_path: &str, + content: &[u8], +) -> Result> { + write_generated_bytes_with_force(conn, output_dir, project_id, relative_path, content, true) +} + +fn write_generated_bytes_with_force( + conn: &Connection, + output_dir: &Path, + project_id: &str, + relative_path: &str, + content: &[u8], + force: bool, ) -> Result> { let hash = content_hash(content); let target_path = output_dir.join(relative_path); - if let Ok(existing) = qhash::get_generated_file_hash(conn, project_id, relative_path) + if !force + && let Ok(existing) = qhash::get_generated_file_hash(conn, project_id, relative_path) && existing.content_hash == hash && target_path.exists() - && file_hash(&target_path)? == hash { return Ok(GeneratedWriteOutcome::SkippedUnchanged); } diff --git a/crates/bds-core/src/render/mod.rs b/crates/bds-core/src/render/mod.rs index f7b5df1..5277f65 100644 --- a/crates/bds-core/src/render/mod.rs +++ b/crates/bds-core/src/render/mod.rs @@ -8,7 +8,8 @@ mod template_lookup; pub use generation::{ CalendarArchiveData, GeneratedWriteOutcome, build_calendar_json, build_core_generation_paths, - write_generated_bytes, write_generated_file, + write_generated_bytes, write_generated_bytes_forced, write_generated_file, + write_generated_file_forced, write_generated_file_verified, }; pub use markdown::render_markdown_to_html; pub use page_renderer::{RenderError, render_liquid_template}; diff --git a/crates/bds-core/tests/m4_generation_engine.rs b/crates/bds-core/tests/m4_generation_engine.rs index 81b7584..f842cff 100644 --- a/crates/bds-core/tests/m4_generation_engine.rs +++ b/crates/bds-core/tests/m4_generation_engine.rs @@ -6,7 +6,8 @@ use bds_core::db::queries::template::insert_template; use bds_core::engine::generation::{ GenerationSection, PublishedPostSource, apply_validation_section_with_progress, apply_validation_sections, build_site_search_index, generate_starter_site, - load_published_post_source, render_site_section_with_progress, sections_from_validation_report, + generate_starter_site_forced, load_published_post_source, render_site_section_with_progress, + sections_from_validation_report, }; use bds_core::engine::meta::write_category_meta_json; use bds_core::engine::validate_site::validate_site; @@ -742,6 +743,61 @@ fn generation_engine_skips_unchanged_outputs_on_second_run() { ); } +#[test] +fn forced_generation_rewrites_unchanged_outputs_without_erasing_unrelated_cache_records() { + let (db, dir) = setup(); + let metadata = make_metadata(); + let posts = vec![PublishedPostSource { + post: make_post("hello", 1_710_000_000_000), + body_markdown: "Hello **world**".into(), + }]; + + generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap(); + let unchanged = + generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap(); + assert!(unchanged.skipped_paths.contains(&"index.html".to_string())); + std::fs::write(dir.path().join("index.html"), "tampered").unwrap(); + let normal_after_drift = + generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap(); + assert!( + normal_after_drift + .skipped_paths + .contains(&"index.html".to_string()) + ); + assert_eq!( + std::fs::read_to_string(dir.path().join("index.html")).unwrap(), + "tampered" + ); + bds_core::db::queries::generated_file_hash::upsert_generated_file_hash( + db.conn(), + &bds_core::model::GeneratedFileHash { + project_id: "p1".into(), + relative_path: "legacy-output.txt".into(), + content_hash: "legacy".into(), + updated_at: 42, + }, + ) + .unwrap(); + + let forced = + generate_starter_site_forced(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap(); + + assert!(forced.written_paths.contains(&"index.html".to_string())); + assert!(forced.skipped_paths.is_empty()); + assert_ne!( + std::fs::read_to_string(dir.path().join("index.html")).unwrap(), + "tampered" + ); + assert!( + bds_core::db::queries::generated_file_hash::get_generated_file_hash( + db.conn(), + "p1", + "legacy-output.txt", + ) + .is_ok() + ); +} + #[test] fn site_validation_matches_bds2_index_route_comparison() { let (db, dir) = setup(); diff --git a/crates/bds-core/tests/m4_generation_primitives.rs b/crates/bds-core/tests/m4_generation_primitives.rs index 6017516..187fc1c 100644 --- a/crates/bds-core/tests/m4_generation_primitives.rs +++ b/crates/bds-core/tests/m4_generation_primitives.rs @@ -6,6 +6,7 @@ use bds_core::db::queries::project::insert_project; use bds_core::model::{Post, PostStatus, Project}; use bds_core::render::{ GeneratedWriteOutcome, build_calendar_json, build_core_generation_paths, write_generated_file, + write_generated_file_forced, }; use tempfile::TempDir; @@ -95,7 +96,7 @@ fn generated_write_rewrites_missing_file_even_when_hash_matches() { } #[test] -fn generated_write_rewrites_stale_file_even_when_db_hash_matches() { +fn generated_write_trusts_the_cached_hash_like_bds2() { let (db, dir) = setup(); let first = write_generated_file(db.conn(), dir.path(), "p1", "index.html", "hello").unwrap(); @@ -103,7 +104,23 @@ fn generated_write_rewrites_stale_file_even_when_db_hash_matches() { let second = write_generated_file(db.conn(), dir.path(), "p1", "index.html", "hello").unwrap(); assert_eq!(first, GeneratedWriteOutcome::Written); - assert_eq!(second, GeneratedWriteOutcome::Written); + assert_eq!(second, GeneratedWriteOutcome::SkippedUnchanged); + assert_eq!( + fs::read_to_string(dir.path().join("index.html")).unwrap(), + "tampered" + ); +} + +#[test] +fn forced_generated_write_ignores_the_cached_hash() { + let (db, dir) = setup(); + + write_generated_file(db.conn(), dir.path(), "p1", "index.html", "hello").unwrap(); + fs::write(dir.path().join("index.html"), "tampered").unwrap(); + let forced = + write_generated_file_forced(db.conn(), dir.path(), "p1", "index.html", "hello").unwrap(); + + assert_eq!(forced, GeneratedWriteOutcome::Written); assert_eq!( fs::read_to_string(dir.path().join("index.html")).unwrap(), "hello" diff --git a/crates/bds-server/src/tui.rs b/crates/bds-server/src/tui.rs index 3321216..bf28aa8 100644 --- a/crates/bds-server/src/tui.rs +++ b/crates/bds-server/src/tui.rs @@ -2843,7 +2843,7 @@ impl TuiApp { let db = Database::open(&database_path)?; let metadata = engine::meta::read_project_json(&data_dir)?; let posts = published_sources(db.conn(), &data_dir, &project_id)?; - let report = engine::generation::generate_starter_site( + let report = engine::generation::generate_starter_site_forced( db.conn(), &data_dir.join("html"), &project_id, diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index 89fc27b..42be52f 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -258,6 +258,7 @@ pub enum Message { ), ValidateMedia, GenerateSite, + ForceGenerateSite, RunMetadataDiff, MetadataDiffLoaded(Result), RepairMetadataDiffItem { @@ -428,6 +429,7 @@ pub enum Message { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum SiteGenerationKind { Full, + Forced, Validation, } @@ -2839,6 +2841,7 @@ impl BdsApp { | Message::TranslationValidationLoaded(_) | Message::ValidateMedia | Message::GenerateSite + | Message::ForceGenerateSite | Message::RunMetadataDiff | Message::MetadataDiffLoaded(_) | Message::RepairMetadataDiffItem { .. } @@ -4444,6 +4447,7 @@ impl BdsApp { ) } MenuAction::GenerateSitemap => Task::done(Message::GenerateSite), + MenuAction::ForceRenderSite => Task::done(Message::ForceGenerateSite), MenuAction::ValidateSite => { self.open_singleton_tab(TabType::SiteValidation, "tabBar.siteValidation"); self.start_site_validation() @@ -10137,7 +10141,7 @@ fn remote_error_closes_connection(code: &str) -> bool { mod tests { use super::{ BdsApp, Message, POST_AUTO_SAVE_DELAY_MS, PersistedMediaState, PersistedPostState, - PostStatus, SettingsMsg, active_post_tab_id, dropped_image_target, + PostStatus, SettingsMsg, SiteGenerationKind, active_post_tab_id, dropped_image_target, flush_embeddings_and_exit, localize_chat_error, month_abbreviation, persist_media_editor_state_impl, persist_post_editor_preview_state_impl, persist_post_editor_state_impl, remote_error_closes_connection, @@ -11215,6 +11219,28 @@ mod tests { ); } + #[test] + fn forced_generation_uses_its_own_full_site_task_group() { + let (db, project, tmp) = setup(); + enable_generation(&tmp); + let mut app = make_app(db, project, &tmp); + + let _task = app.handle_engine_message(Message::ForceGenerateSite); + let snapshots = app.task_manager.snapshots(); + + assert_eq!(snapshots.len(), 5); + assert!( + snapshots + .iter() + .all(|task| { task.group_name.as_deref() == Some("Force Render Site") }) + ); + let group_id = snapshots[0].group_id.as_ref().unwrap(); + assert_eq!( + app.site_generation_workflows[group_id].kind, + SiteGenerationKind::Forced + ); + } + #[test] fn search_index_is_queued_only_after_every_render_task_succeeds() { let (db, project, tmp) = setup(); diff --git a/crates/bds-ui/src/app/engine_handlers.rs b/crates/bds-ui/src/app/engine_handlers.rs index f0948a3..912f34b 100644 --- a/crates/bds-ui/src/app/engine_handlers.rs +++ b/crates/bds-ui/src/app/engine_handlers.rs @@ -144,6 +144,7 @@ impl BdsApp { ) } Message::GenerateSite => self.queue_site_generation(None), + Message::ForceGenerateSite => self.queue_forced_site_generation(), Message::RunMetadataDiff => { self.open_singleton_tab(TabType::MetadataDiff, "tabBar.metadataDiff"); self.start_metadata_diff() @@ -311,19 +312,22 @@ impl BdsApp { Err(error) => { self.task_manager.fail(task_id, error.clone()); self.task_manager.cancel_group(&group_id); - if let Some(workflow) = self.site_generation_workflows.remove(&group_id) - && workflow.kind == SiteGenerationKind::Validation + let workflow = self.site_generation_workflows.remove(&group_id); + if workflow + .as_ref() + .is_some_and(|workflow| workflow.kind == SiteGenerationKind::Validation) { self.site_validation_state.is_applying = false; self.site_validation_state.error_message = Some(error.clone()); } + let operation = workflow.map_or_else( + || t(self.ui_locale, "engine.renderSiteGroup"), + |workflow| workflow.group_name, + ); let message = tw( self.ui_locale, "common.operationFailed", - &[ - ("operation", &t(self.ui_locale, "engine.renderSiteGroup")), - ("error", &error), - ], + &[("operation", &operation), ("error", &error)], ); self.notify(ToastLevel::Error, &message); self.refresh_task_snapshots(); diff --git a/crates/bds-ui/src/app/tasks.rs b/crates/bds-ui/src/app/tasks.rs index a7154d7..ae6e46e 100644 --- a/crates/bds-ui/src/app/tasks.rs +++ b/crates/bds-ui/src/app/tasks.rs @@ -36,9 +36,23 @@ impl BdsApp { pub(super) fn queue_site_generation( &mut self, validation: Option, + ) -> Task { + self.queue_site_generation_mode(validation, false) + } + + pub(super) fn queue_forced_site_generation(&mut self) -> Task { + self.queue_site_generation_mode(None, true) + } + + fn queue_site_generation_mode( + &mut self, + validation: Option, + force: bool, ) -> Task { let kind = if validation.is_some() { SiteGenerationKind::Validation + } else if force { + SiteGenerationKind::Forced } else { SiteGenerationKind::Full }; @@ -94,10 +108,10 @@ impl BdsApp { let group_id = format!("site-generation:{}", Uuid::new_v4()); let group_name = t( self.ui_locale, - if kind == SiteGenerationKind::Full { - "engine.renderSiteGroup" - } else { - "engine.applyValidationGroup" + match kind { + SiteGenerationKind::Full => "engine.renderSiteGroup", + SiteGenerationKind::Forced => "engine.forceRenderSiteGroup", + SiteGenerationKind::Validation => "engine.applyValidationGroup", }, ); let mut render_task_ids = Vec::new(); @@ -128,6 +142,7 @@ impl BdsApp { task_id, section, task_validation, + force, locale, ) }) @@ -189,33 +204,46 @@ impl BdsApp { let output_dir = workflow.data_dir.join("html"); let progress_manager = Arc::clone(&task_manager); let cancel_manager = Arc::clone(&task_manager); - engine::generation::build_site_search_index_with_progress( - db.conn(), - &output_dir, - &workflow.project_id, - &metadata, - move |current, total, path| { - let progress = if total == 0 { - 1.0 - } else { - current as f32 / total as f32 - }; - progress_manager.report_progress( - task_id, - Some(progress), - Some(tw( - locale, - "engine.builtSearchFile", - &[ - ("path", path), - ("current", ¤t.to_string()), - ("total", &total.to_string()), - ], - )), - ); - }, - move || cancel_manager.is_cancelled(task_id), - ) + let on_file = move |current: usize, total: usize, path: &str| { + let progress = if total == 0 { + 1.0 + } else { + current as f32 / total as f32 + }; + progress_manager.report_progress( + task_id, + Some(progress), + Some(tw( + locale, + "engine.builtSearchFile", + &[ + ("path", path), + ("current", ¤t.to_string()), + ("total", &total.to_string()), + ], + )), + ); + }; + let is_cancelled = move || cancel_manager.is_cancelled(task_id); + if workflow.kind == SiteGenerationKind::Forced { + engine::generation::build_site_search_index_forced_with_progress( + db.conn(), + &output_dir, + &workflow.project_id, + &metadata, + on_file, + is_cancelled, + ) + } else { + engine::generation::build_site_search_index_with_progress( + db.conn(), + &output_dir, + &workflow.project_id, + &metadata, + on_file, + is_cancelled, + ) + } .map_err(|error| error.to_string()) }) .await @@ -422,6 +450,7 @@ fn run_site_generation_section( task_id: TaskId, section: engine::generation::GenerationSection, validation: Option, + force: bool, locale: UiLocale, ) -> Result { if !task_manager.wait_until_runnable(task_id) { @@ -487,6 +516,16 @@ fn run_site_generation_section( on_page, is_cancelled, ), + None if force => engine::generation::render_site_section_forced_with_progress( + db.conn(), + &output_dir, + &project_id, + &metadata, + &sources, + section, + on_page, + is_cancelled, + ), None => engine::generation::render_site_section_with_progress( db.conn(), &output_dir, diff --git a/crates/bds-ui/src/platform/menu.rs b/crates/bds-ui/src/platform/menu.rs index c0984e4..1758c6c 100644 --- a/crates/bds-ui/src/platform/menu.rs +++ b/crates/bds-ui/src/platform/menu.rs @@ -8,6 +8,13 @@ use crate::app::Message; use crate::state::tabs::TabType; use bds_core::i18n::{UiLocale, translate}; +const BLOG_SITE_ACTIONS: [MenuAction; 4] = [ + MenuAction::GenerateSitemap, + MenuAction::ForceRenderSite, + MenuAction::ValidateSite, + MenuAction::UploadSite, +]; + /// Every custom menu item that the application handles. /// /// Edit commands are custom actions because Iced widgets are not native Cocoa @@ -51,6 +58,7 @@ pub enum MenuAction { ValidateTranslations, FillMissingTranslations, GenerateSitemap, + ForceRenderSite, ValidateSite, UploadSite, // Help @@ -98,6 +106,7 @@ impl MenuAction { MenuAction::ValidateTranslations, MenuAction::FillMissingTranslations, MenuAction::GenerateSitemap, + MenuAction::ForceRenderSite, MenuAction::ValidateSite, MenuAction::UploadSite, MenuAction::About, @@ -142,7 +151,8 @@ impl MenuAction { "regenerate_calendar" => Self::RegenerateCalendar, "validate_translations" => Self::ValidateTranslations, "fill_missing_translations" => Self::FillMissingTranslations, - "generate_sitemap" | "force_render_site" => Self::GenerateSitemap, + "generate_sitemap" => Self::GenerateSitemap, + "force_render_site" => Self::ForceRenderSite, "validate_site" => Self::ValidateSite, "upload_site" => Self::UploadSite, "about" => Self::About, @@ -189,6 +199,7 @@ impl MenuAction { Self::ValidateTranslations => "menu.item.validateTranslations", Self::FillMissingTranslations => "menu.item.fillMissingTranslations", Self::GenerateSitemap => "menu.item.generateSitemap", + Self::ForceRenderSite => "menu.item.forceRenderSite", Self::ValidateSite => "menu.item.validateSite", Self::UploadSite => "menu.item.uploadSite", Self::About => "menu.item.about", @@ -231,6 +242,7 @@ pub(crate) fn action_enabled( | MenuAction::ValidateTranslations | MenuAction::FillMissingTranslations | MenuAction::GenerateSitemap + | MenuAction::ForceRenderSite | MenuAction::ValidateSite | MenuAction::UploadSite ); @@ -264,6 +276,7 @@ pub(crate) fn action_enabled( | MenuAction::ValidateTranslations | MenuAction::FillMissingTranslations | MenuAction::GenerateSitemap + | MenuAction::ForceRenderSite | MenuAction::ValidateSite => has_project, _ => true, } @@ -531,30 +544,22 @@ pub fn build_menu_bar(locale: UiLocale) -> (Menu, MenuRegistry) { None, )); let _ = blog_menu.append(&PredefinedMenuItem::separator()); - let _ = blog_menu.append(&item( - &mut reg, - MenuAction::GenerateSitemap, - locale, - Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyR)), - )); - let _ = blog_menu.append(&item( - &mut reg, - MenuAction::ValidateSite, - locale, - Some(Accelerator::new( - Some(CMD_OR_CTRL | Modifiers::SHIFT), - Code::KeyL, - )), - )); - let _ = blog_menu.append(&item( - &mut reg, - MenuAction::UploadSite, - locale, - Some(Accelerator::new( - Some(CMD_OR_CTRL | Modifiers::SHIFT), - Code::KeyU, - )), - )); + for action in BLOG_SITE_ACTIONS { + let accelerator = match action { + MenuAction::GenerateSitemap => Accelerator::new(Some(CMD_OR_CTRL), Code::KeyR), + MenuAction::ForceRenderSite => { + Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyR) + } + MenuAction::ValidateSite => { + Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyL) + } + MenuAction::UploadSite => { + Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyU) + } + _ => unreachable!("site action list contains only site actions"), + }; + let _ = blog_menu.append(&item(&mut reg, action, locale, Some(accelerator))); + } // -- Help -- let help_menu = Submenu::new(translate(locale, "menu.group.help"), true); @@ -680,6 +685,24 @@ mod tests { } } + #[test] + fn force_render_script_action_is_distinct_from_normal_render() { + assert_eq!( + MenuAction::from_script_name("force_render_site"), + Some(MenuAction::ForceRenderSite) + ); + } + + #[test] + fn force_render_is_immediately_below_normal_render_in_the_blog_menu() { + let normal = BLOG_SITE_ACTIONS + .iter() + .position(|action| *action == MenuAction::GenerateSitemap) + .unwrap(); + + assert_eq!(BLOG_SITE_ACTIONS[normal + 1], MenuAction::ForceRenderSite); + } + #[test] fn enabled_state_follows_application_rules() { assert!(action_enabled( diff --git a/crates/bds-ui/src/platform/script_host.rs b/crates/bds-ui/src/platform/script_host.rs index 71c7937..8babc4e 100644 --- a/crates/bds-ui/src/platform/script_host.rs +++ b/crates/bds-ui/src/platform/script_host.rs @@ -128,6 +128,14 @@ mod tests { &[serde_json::json!("new_post")], ) .unwrap(); - assert_eq!(queued.lock().unwrap().as_slice(), &[MenuAction::NewPost]); + handler(Arc::clone(&queued), String::new())( + "trigger_menu_action", + &[serde_json::json!("force_render_site")], + ) + .unwrap(); + assert_eq!( + queued.lock().unwrap().as_slice(), + &[MenuAction::NewPost, MenuAction::ForceRenderSite] + ); } } diff --git a/locales/ui/de.ftl b/locales/ui/de.ftl index 4431922..cf25c39 100644 --- a/locales/ui/de.ftl +++ b/locales/ui/de.ftl @@ -67,6 +67,7 @@ embeddings-indexed = Der semantische Index ist für { $count } Beiträge bereit. menu-item-metadataDiff = Metadaten-Diff-Werkzeug menu-item-editMenu = Blog-Menü bearbeiten menu-item-generateSitemap = Site rendern +menu-item-forceRenderSite = Website vollständig neu generieren menu-item-regenerateCalendar = Kalender neu erzeugen menu-item-validateSite = Website validieren menu-item-validateTranslations = Übersetzungen validieren @@ -676,6 +677,7 @@ blogmark-failed = Blogmark-Import fehlgeschlagen: { $error } engine-generatedPage = Erstellt { $url } ({ $current }/{ $total }) engine-rewrotePage = Neu geschrieben { $url } ({ $current }/{ $total }) engine-renderSiteGroup = Website rendern +engine-forceRenderSiteGroup = Website vollständig neu generieren engine-applyValidationGroup = Website-Validierung anwenden engine-renderSiteCore = Website-Kern rendern engine-renderSinglePosts = Einzelbeiträge rendern diff --git a/locales/ui/en.ftl b/locales/ui/en.ftl index 77e0eba..0bae93c 100644 --- a/locales/ui/en.ftl +++ b/locales/ui/en.ftl @@ -67,6 +67,7 @@ embeddings-indexed = Semantic index is ready for { $count } posts. menu-item-metadataDiff = Metadata Diff Tool menu-item-editMenu = Edit Blog Menu menu-item-generateSitemap = Render Site +menu-item-forceRenderSite = Force Render Site menu-item-regenerateCalendar = Regenerate Calendar menu-item-validateSite = Validate Site menu-item-validateTranslations = Validate Translations @@ -676,6 +677,7 @@ blogmark-failed = Blogmark import failed: { $error } engine-generatedPage = Generated { $url } ({ $current }/{ $total }) engine-rewrotePage = Rewrote { $url } ({ $current }/{ $total }) engine-renderSiteGroup = Render Site +engine-forceRenderSiteGroup = Force Render Site engine-applyValidationGroup = Apply Site Validation engine-renderSiteCore = Render Site Core engine-renderSinglePosts = Render Single Posts diff --git a/locales/ui/es.ftl b/locales/ui/es.ftl index 05e3723..553966b 100644 --- a/locales/ui/es.ftl +++ b/locales/ui/es.ftl @@ -67,6 +67,7 @@ embeddings-indexed = El índice semántico está listo para { $count } publicaci menu-item-metadataDiff = Herramienta diff de metadatos menu-item-editMenu = Editar menú del blog menu-item-generateSitemap = Renderizar sitio +menu-item-forceRenderSite = Forzar la regeneración del sitio menu-item-regenerateCalendar = Regenerar calendario menu-item-validateSite = Validar sitio menu-item-validateTranslations = Validar traducciones @@ -676,6 +677,7 @@ blogmark-failed = Error al importar el blogmark: { $error } engine-generatedPage = Generado { $url } ({ $current }/{ $total }) engine-rewrotePage = Reescrito { $url } ({ $current }/{ $total }) engine-renderSiteGroup = Renderizar sitio +engine-forceRenderSiteGroup = Forzar la regeneración del sitio engine-applyValidationGroup = Aplicar validación del sitio engine-renderSiteCore = Renderizar núcleo del sitio engine-renderSinglePosts = Renderizar entradas individuales diff --git a/locales/ui/fr.ftl b/locales/ui/fr.ftl index b81baad..c912557 100644 --- a/locales/ui/fr.ftl +++ b/locales/ui/fr.ftl @@ -67,6 +67,7 @@ embeddings-indexed = L’index sémantique est prêt pour { $count } articles. menu-item-metadataDiff = Outil de diff des métadonnées menu-item-editMenu = Modifier le menu du blog menu-item-generateSitemap = Rendre le site +menu-item-forceRenderSite = Forcer la régénération du site menu-item-regenerateCalendar = Régénérer le calendrier menu-item-validateSite = Valider le site menu-item-validateTranslations = Valider les traductions @@ -676,6 +677,7 @@ blogmark-failed = Échec de l’import du blogmark : { $error } engine-generatedPage = Généré { $url } ({ $current }/{ $total }) engine-rewrotePage = Réécrit { $url } ({ $current }/{ $total }) engine-renderSiteGroup = Générer le site +engine-forceRenderSiteGroup = Forcer la régénération du site engine-applyValidationGroup = Appliquer la validation du site engine-renderSiteCore = Générer le cœur du site engine-renderSinglePosts = Générer les articles individuels diff --git a/locales/ui/it.ftl b/locales/ui/it.ftl index 57d8306..a99153a 100644 --- a/locales/ui/it.ftl +++ b/locales/ui/it.ftl @@ -67,6 +67,7 @@ embeddings-indexed = L’indice semantico è pronto per { $count } post. menu-item-metadataDiff = Strumento diff metadati menu-item-editMenu = Modifica menu blog menu-item-generateSitemap = Renderizza sito +menu-item-forceRenderSite = Forza la rigenerazione del sito menu-item-regenerateCalendar = Rigenera calendario menu-item-validateSite = Valida sito menu-item-validateTranslations = Valida traduzioni @@ -676,6 +677,7 @@ blogmark-failed = Importazione del blogmark non riuscita: { $error } engine-generatedPage = Generato { $url } ({ $current }/{ $total }) engine-rewrotePage = Riscritto { $url } ({ $current }/{ $total }) engine-renderSiteGroup = Genera sito +engine-forceRenderSiteGroup = Forza la rigenerazione del sito engine-applyValidationGroup = Applica convalida del sito engine-renderSiteCore = Genera nucleo del sito engine-renderSinglePosts = Genera articoli singoli diff --git a/specs/generation.allium b/specs/generation.allium index 9c397db..ad09cf4 100644 --- a/specs/generation.allium +++ b/specs/generation.allium @@ -17,6 +17,13 @@ surface GenerationControlSurface { GenerateSiteRequested(generation) ValidateSiteRequested(project) ApplyValidationRequested(project_id, sections) + + @guarantee FullRenderCommands + -- Blog > Render Site (Ctrl/Cmd+R) performs a change-aware full render. + -- Force Render Site is immediately below it (Ctrl/Cmd+Shift+R) and + -- performs the same five sections plus Pagefind with force=true. + -- The headless CLI `render [--force]` and TUI `force-render` command + -- use the same shared generation behavior. } surface GenerationRuntimeSurface { @@ -65,6 +72,7 @@ entity SiteGeneration { max_posts_per_page: Integer pico_theme: String? sections: Set + force: Boolean -- Output tracking generated_files: GeneratedFile with project_id = this.project_id @@ -102,9 +110,23 @@ invariant GenerationPublishedOnly { } invariant IncrementalByContentHash { - -- Files are only written when content_hash changes - -- generatedFileHashes table tracks (projectId, relativePath, contentHash) - -- A file with unchanged hash is skipped on regeneration + -- For a normal full render, files are only written when content_hash + -- changes or the output file is missing. generatedFileHashes tracks + -- (projectId, relativePath, contentHash). A matching stored hash and an + -- existing output file skip the write without hashing or reading output. +} + +invariant ForcedFullRender { + -- force=true renders the same complete section set and Pagefind index as + -- normal full render, but ignores stored hashes and atomically rewrites + -- every output. Each rewritten output upserts its current hash and time; + -- unrelated cache records are not deleted as a shortcut. +} + +rule SelectFullRenderWriteMode { + when: GenerateSiteRequested(generation) + requires: generation.force + ensures: StoredContentHashesIgnored(generation.project_id) } invariant MultiLanguageRoutes {