From ea90167a5a9055227d30043177fa1638323c9cee Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Thu, 23 Jul 2026 21:51:12 +0200 Subject: [PATCH] fix: fixed rendering from validate-apply --- README.md | 2 +- crates/bds-core/src/engine/generation.rs | 3 + crates/bds-core/src/render/site.rs | 92 ++++++++++++++++++- crates/bds-core/tests/m4_generation_engine.rs | 85 +++++++++++++++++ crates/bds-ui/src/app.rs | 37 ++++++-- specs/generation.allium | 10 +- 6 files changed, 216 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 7e92c6a..2e0b0db 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, 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. +- bDS2-compatible Markdown/Liquid rendering with built-in macros rendered from bundled Liquid templates in isolated scopes (customizable with `macros/*` partial-template slugs), category-controlled list title visibility, 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 whose targeted repair refreshes affected aggregate pages 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-core/src/engine/generation.rs b/crates/bds-core/src/engine/generation.rs index 43b5ca2..6f5eca6 100644 --- a/crates/bds-core/src/engine/generation.rs +++ b/crates/bds-core/src/engine/generation.rs @@ -315,6 +315,9 @@ pub fn sections_from_validation_report( .chain(report.stale_pages.iter()) { match classify_generated_path(path, metadata) { + Some(GenerationSection::Single) => { + sections.extend(GenerationSection::ALL); + } Some(section) => { sections.insert(section); } diff --git a/crates/bds-core/src/render/site.rs b/crates/bds-core/src/render/site.rs index ce4d929..18b11d1 100644 --- a/crates/bds-core/src/render/site.rs +++ b/crates/bds-core/src/render/site.rs @@ -269,6 +269,16 @@ fn build_site_render_artifacts_with_mode( &tags, &category_settings, ); + let expanded_requested_paths = requested_paths.map(|requested| { + expand_requested_aggregate_paths( + requested, + &localized_posts, + &routes, + &language, + metadata, + ) + }); + let requested_paths = expanded_requested_paths.as_ref(); artifacts .route_manifest .extend(routes.iter().map(|route| SitePage { @@ -869,6 +879,86 @@ fn paginated_route_specs( pages } +fn expand_requested_aggregate_paths( + requested: &HashSet, + posts: &[RenderPostRecord], + routes: &[RouteSpec], + language: &str, + metadata: &ProjectMetadata, +) -> HashSet { + let mut expanded = requested.clone(); + let root = language_root_prefix(language, metadata) + .trim_matches('/') + .to_string(); + let prefixed = |suffix: String| { + if root.is_empty() { + suffix + } else { + format!("{root}/{suffix}") + } + }; + + for record in posts { + let post_path = format!( + "{}/index.html", + build_canonical_post_path(&record.post, language, main_language(metadata)) + .trim_start_matches('/') + ); + if !requested.contains(&post_path) { + continue; + } + + let mut families = vec![root.clone()]; + families.extend( + record + .post + .categories + .iter() + .map(|category| prefixed(format!("category/{}", slugify(category)))), + ); + families.extend( + record + .post + .tags + .iter() + .map(|tag| prefixed(format!("tag/{}", slugify(tag)))), + ); + if let Some(date) = Local.timestamp_millis_opt(record.post.created_at).single() { + families.extend([ + prefixed(format!("{:04}", date.year())), + prefixed(format!("{:04}/{:02}", date.year(), date.month())), + prefixed(format!( + "{:04}/{:02}/{:02}", + date.year(), + date.month(), + date.day() + )), + ]); + } + + expanded.extend( + routes + .iter() + .filter(|route| { + families + .iter() + .any(|family| route_belongs_to_family(&route.relative_path, family)) + }) + .map(|route| route.relative_path.clone()), + ); + } + + expanded +} + +fn route_belongs_to_family(path: &str, family: &str) -> bool { + if family.is_empty() { + path == "index.html" || path.starts_with("page/") + } else { + path == format!("{family}/index.html") || path.starts_with(&format!("{family}/page/")) + } +} + #[expect( clippy::too_many_arguments, reason = "render inputs are existing domain data with distinct lifetimes" @@ -1276,7 +1366,7 @@ fn should_show_list_title( category_settings .get(category) .map(|settings| !settings.show_title) - .unwrap_or(false) + .unwrap_or(category == "aside") }) } diff --git a/crates/bds-core/tests/m4_generation_engine.rs b/crates/bds-core/tests/m4_generation_engine.rs index f842cff..f5122e5 100644 --- a/crates/bds-core/tests/m4_generation_engine.rs +++ b/crates/bds-core/tests/m4_generation_engine.rs @@ -312,6 +312,7 @@ fn mixed_source_languages_use_the_main_language_at_canonical_urls() { let mut german_source = make_post("german-source", 1_710_086_400_000); german_source.language = Some("de".into()); german_source.title = "Deutsche Quelle".into(); + german_source.categories = vec!["aside".into()]; write_published_snapshot(&dir, &mut german_source, "Deutscher Quelltext"); bds_core::db::queries::post::insert_post(db.conn(), &german_source).unwrap(); let english_translation = PostTranslation { @@ -386,6 +387,9 @@ fn mixed_source_languages_use_the_main_language_at_canonical_urls() { assert!(canonical_german_source.contains("Deutscher Quelltext")); assert!(localized_german_source.contains("English translation body")); assert!(localized_fallback.contains("Unübersetzter Quelltext")); + let english_index = std::fs::read_to_string(output.join("en/index.html")).unwrap(); + assert!(english_index.contains("English translation body")); + assert!(!english_index.contains(">English translation")); assert!( output .join("2024/03/12/german-private/index.html") @@ -997,6 +1001,87 @@ fn validation_apply_clears_unchanged_touched_post_routes() { assert!(repaired.stale_pages.is_empty()); } +#[test] +fn validation_apply_expands_a_missing_post_to_its_aggregates() { + let (db, dir) = setup(); + let mut metadata = make_metadata(); + metadata.max_posts_per_page = 1; + let first = make_post("first", 1_710_000_000_000); + let second = make_post("second", 1_710_000_001_000); + + generate_starter_site( + db.conn(), + dir.path(), + "p1", + &metadata, + &[PublishedPostSource { + post: first.clone(), + body_markdown: "First body".into(), + }], + "en", + ) + .unwrap(); + + let posts = vec![ + PublishedPostSource { + post: first, + body_markdown: "First body".into(), + }, + PublishedPostSource { + post: second, + body_markdown: "Second body".into(), + }, + ]; + let validation = bds_core::engine::validate_site::SiteValidationReport { + missing_pages: vec!["2024/03/09/second/index.html".into()], + extra_pages: Vec::new(), + stale_pages: Vec::new(), + }; + let sections = sections_from_validation_report(&validation, &metadata); + + apply_validation_sections( + db.conn(), + dir.path(), + "p1", + &metadata, + &posts, + &validation, + §ions, + ) + .unwrap(); + + for path in [ + "index.html", + "category/article/index.html", + "tag/rust/index.html", + "2024/index.html", + "2024/03/index.html", + "2024/03/09/index.html", + ] { + assert!( + std::fs::read_to_string(dir.path().join(path)) + .unwrap() + .contains("second"), + "aggregate {path} was not refreshed" + ); + } + for path in [ + "page/2/index.html", + "category/article/page/2/index.html", + "tag/rust/page/2/index.html", + "2024/page/2/index.html", + "2024/03/page/2/index.html", + "2024/03/09/page/2/index.html", + ] { + assert!( + std::fs::read_to_string(dir.path().join(path)) + .unwrap() + .contains("first"), + "aggregate pagination {path} was not refreshed" + ); + } +} + #[test] fn apply_validation_removes_extra_section_outputs() { let (db, dir) = setup(); diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index 42be52f..c94848a 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -11329,7 +11329,7 @@ mod tests { } #[test] - fn validation_apply_queues_only_affected_sections_then_index() { + fn validation_apply_queues_post_and_aggregate_sections_then_index() { let (db, project, tmp) = setup(); enable_generation(&tmp); let mut app = make_app(db, project, &tmp); @@ -11340,19 +11340,36 @@ mod tests { let _task = app.queue_site_generation(Some(validation)); let snapshots = app.task_manager.snapshots(); - assert_eq!(snapshots.len(), 1); - assert_eq!(snapshots[0].label, "Render Single Posts"); assert_eq!( - snapshots[0].group_name.as_deref(), - Some("Apply Site Validation") + snapshots + .iter() + .map(|task| task.label.as_str()) + .collect::>(), + vec![ + "Render Site Core", + "Render Single Posts", + "Render Category Archives", + "Render Tag Archives", + "Render Date Archives", + ] + ); + assert!( + snapshots + .iter() + .all(|task| task.group_name.as_deref() == Some("Apply Site Validation")) ); let group_id = snapshots[0].group_id.clone().unwrap(); + let render_ids = app.site_generation_workflows[&group_id] + .render_task_ids + .clone(); - let _task = app.handle_engine_message(Message::SiteGenerationSectionDone { - group_id, - task_id: snapshots[0].id, - result: Ok(GenerationReport::default()), - }); + for task_id in render_ids { + let _task = app.handle_engine_message(Message::SiteGenerationSectionDone { + group_id: group_id.clone(), + task_id, + result: Ok(GenerationReport::default()), + }); + } assert!( app.task_manager .snapshots() diff --git a/specs/generation.allium b/specs/generation.allium index ad09cf4..7e75e19 100644 --- a/specs/generation.allium +++ b/specs/generation.allium @@ -300,7 +300,9 @@ rule ValidateSite { rule ApplyValidation { when: ApplyValidationRequested(project_id, sections) - -- Targeted re-rendering for affected report paths only. Applying an + -- Targeted re-rendering for affected report paths. A missing or stale post + -- route also re-renders every affected root, category, tag, year, month, + -- and day aggregate, including each aggregate's pagination. Applying an -- unchanged post route refreshes its tracked generation time so the -- automatic follow-up validation does not report it stale again. ensures: GenerateSiteRequested(plan_generation(project_id, sections)) @@ -312,6 +314,12 @@ invariant ArchiveDayBlocks { -- Each day block has a date header and the posts for that day } +invariant CategoryListTitleVisibility { + -- A post title is hidden on every language's aggregate pages when any of + -- the post's categories has show_title disabled. The built-in aside + -- category defaults to title-hidden when no explicit setting exists. +} + -- ============================================================================ -- SEARCH INDEX: PAGEFIND -- ============================================================================