diff --git a/README.md b/README.md index e8e38ed..033c2e6 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,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, inert write proposals, 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, recursive menus, calendar archives, feeds, a root hreflang sitemap, Pagefind, and incremental site generation 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, and incremental site generation 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 3f1f770..a7f0272 100644 --- a/crates/bds-cli/src/lib.rs +++ b/crates/bds-cli/src/lib.rs @@ -498,7 +498,7 @@ fn render(db: &Database, incremental: bool, force: bool) -> Result Vec { +pub fn sections_from_validation_report( + report: &SiteValidationReport, + metadata: &ProjectMetadata, +) -> Vec { let mut sections = HashSet::new(); let mut saw_unknown = false; @@ -220,7 +223,7 @@ pub fn sections_from_validation_report(report: &SiteValidationReport) -> Vec { sections.insert(section); } @@ -276,7 +279,13 @@ pub fn apply_validation_sections( )?); } - remove_extra_section_paths(output_dir, &expected_paths, §ion_set, &mut report)?; + remove_extra_section_paths( + output_dir, + &expected_paths, + metadata, + §ion_set, + &mut report, + )?; report.append(build_site_search_index( conn, output_dir, project_id, metadata, )?); @@ -318,7 +327,7 @@ pub fn apply_validation_section_with_progress( .iter() .chain(validation.extra_pages.iter()) .chain(validation.stale_pages.iter()) - .any(|path| classify_generated_path(path).is_none()); + .any(|path| classify_generated_path(path, metadata).is_none()); let artifacts = if fallback { build_site_section_render_artifacts( conn, @@ -376,7 +385,7 @@ pub fn apply_validation_section_with_progress( if is_cancelled() { return Err(EngineError::Validation("cancelled".to_string())); } - let owned_by_section = classify_generated_path(path) + let owned_by_section = classify_generated_path(path, metadata) .map_or(section == GenerationSection::Core, |owner| owner == section); if owned_by_section && output_dir.join(path).is_file() { std::fs::remove_file(output_dir.join(path)).map_err(EngineError::Io)?; @@ -680,7 +689,7 @@ fn expected_paths_for_sections( ) -> HashSet { let mut expected = pages .iter() - .filter(|page| path_matches_sections(&page.relative_path, sections)) + .filter(|page| path_matches_sections(&page.relative_path, metadata, sections)) .map(|page| page.relative_path.clone()) .collect::>(); @@ -710,6 +719,7 @@ fn expected_paths_for_sections( fn remove_extra_section_paths( output_dir: &Path, expected: &HashSet, + metadata: &ProjectMetadata, sections: &HashSet, report: &mut GenerationReport, ) -> EngineResult<()> { @@ -738,7 +748,7 @@ fn remove_extra_section_paths( continue; } if !matches_generated_extension(&rel) - || !path_matches_sections(&rel, sections) + || !path_matches_sections(&rel, metadata, sections) || expected.contains(&rel) { continue; @@ -752,13 +762,20 @@ fn remove_extra_section_paths( Ok(()) } -fn path_matches_sections(path: &str, sections: &HashSet) -> bool { - classify_generated_path(path) +fn path_matches_sections( + path: &str, + metadata: &ProjectMetadata, + sections: &HashSet, +) -> bool { + classify_generated_path(path, metadata) .map(|section| sections.contains(§ion)) .unwrap_or(false) } -pub(crate) fn classify_generated_path(path: &str) -> Option { +pub(crate) fn classify_generated_path( + path: &str, + metadata: &ProjectMetadata, +) -> Option { if path.ends_with(".xml") || path.ends_with(".json") { return Some(GenerationSection::Core); } @@ -767,7 +784,7 @@ pub(crate) fn classify_generated_path(path: &str) -> Option { if parts.is_empty() { return None; } - if has_language_prefix(&parts) { + if has_language_prefix(&parts, metadata) { parts.remove(0); } @@ -805,10 +822,12 @@ pub(crate) fn classify_generated_path(path: &str) -> Option { } } -fn has_language_prefix(parts: &[&str]) -> bool { +fn has_language_prefix(parts: &[&str], metadata: &ProjectMetadata) -> bool { match parts { [first, second, ..] => { - matches!(*first, "de" | "en" | "es" | "fr" | "it") + render_languages(metadata) + .iter() + .any(|language| language.eq_ignore_ascii_case(first)) && (*second == "index.html" || *second == "404.html" || *second == "page" diff --git a/crates/bds-core/src/render/site.rs b/crates/bds-core/src/render/site.rs index d1b1de3..398c354 100644 --- a/crates/bds-core/src/render/site.rs +++ b/crates/bds-core/src/render/site.rs @@ -227,7 +227,7 @@ fn build_site_render_artifacts_with_mode( .par_iter() .filter(|route| { section.is_none_or(|section| { - classify_generated_path(&route.relative_path) == Some(section) + classify_generated_path(&route.relative_path, metadata) == Some(section) }) && requested_paths .is_none_or(|requested| requested.contains(&route.relative_path)) }) diff --git a/crates/bds-core/tests/m4_generation_engine.rs b/crates/bds-core/tests/m4_generation_engine.rs index c31e689..001c233 100644 --- a/crates/bds-core/tests/m4_generation_engine.rs +++ b/crates/bds-core/tests/m4_generation_engine.rs @@ -782,7 +782,7 @@ fn apply_validation_repairs_core_section_outputs() { std::fs::remove_file(dir.path().join("atom.xml")).unwrap(); let report = validate_site(db.conn(), dir.path(), "p1").unwrap(); - let sections = sections_from_validation_report(&report); + let sections = sections_from_validation_report(&report, &metadata); let apply_report = apply_validation_sections(db.conn(), dir.path(), "p1", &metadata, &posts, §ions) .unwrap(); @@ -817,7 +817,7 @@ fn apply_validation_removes_extra_section_outputs() { .extra_pages .contains(&"tag/ghost/index.html".to_string()) ); - let sections = sections_from_validation_report(&report); + let sections = sections_from_validation_report(&report, &metadata); let apply_report = apply_validation_sections(db.conn(), dir.path(), "p1", &metadata, &posts, §ions) .unwrap(); @@ -831,6 +831,59 @@ fn apply_validation_removes_extra_section_outputs() { assert!(repaired.extra_pages.is_empty()); } +#[test] +fn configured_language_prefixes_select_their_actual_validation_section() { + let mut metadata = make_metadata(); + metadata.blog_languages = vec!["en".into(), "pt".into(), "ja".into()]; + let report = bds_core::engine::validate_site::SiteValidationReport { + missing_pages: vec![ + "pt/category/ghost/index.html".into(), + "ja/category/ghost/index.html".into(), + ], + extra_pages: Vec::new(), + stale_pages: Vec::new(), + }; + + assert_eq!( + sections_from_validation_report(&report, &metadata), + vec![GenerationSection::Category] + ); +} + +#[test] +fn validation_apply_removes_extra_output_under_configured_language() { + let (db, dir) = setup(); + let mut metadata = make_metadata(); + metadata.blog_languages = vec!["en".into(), "pt".into(), "ja".into()]; + bds_core::engine::meta::write_project_json(dir.path(), &metadata).unwrap(); + let mut post = make_post("hello", 1_710_000_000_000); + write_published_snapshot(&dir, &mut post, "Hello **world**"); + bds_core::db::queries::post::insert_post(db.conn(), &post).unwrap(); + let posts = vec![PublishedPostSource { + post, + body_markdown: "Hello **world**".into(), + }]; + + generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap(); + let extra = dir.path().join("pt/category/ghost/index.html"); + std::fs::create_dir_all(extra.parent().unwrap()).unwrap(); + std::fs::write(&extra, "ghost").unwrap(); + + let validation = validate_site(db.conn(), dir.path(), "p1").unwrap(); + let sections = sections_from_validation_report(&validation, &metadata); + let applied = + apply_validation_sections(db.conn(), dir.path(), "p1", &metadata, &posts, §ions) + .unwrap(); + + assert_eq!(sections, vec![GenerationSection::Category]); + assert!( + applied + .deleted_paths + .contains(&"pt/category/ghost/index.html".to_string()) + ); + assert!(!extra.exists()); +} + #[test] fn site_validation_uses_html_output_directory_when_present() { let (db, dir) = setup(); diff --git a/crates/bds-server/src/tui.rs b/crates/bds-server/src/tui.rs index fe8ada0..57cd215 100644 --- a/crates/bds-server/src/tui.rs +++ b/crates/bds-server/src/tui.rs @@ -2965,7 +2965,8 @@ impl TuiApp { engine::validate_site::validate_site(db.conn(), &data_dir, &project_id)?; let metadata = engine::meta::read_project_json(&data_dir)?; let posts = published_sources(db.conn(), &data_dir, &project_id)?; - let sections = engine::generation::sections_from_validation_report(&validation); + let sections = + engine::generation::sections_from_validation_report(&validation, &metadata); let report = engine::generation::apply_validation_sections( db.conn(), &data_dir.join("html"), diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index 21d0606..379e7e7 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -5355,8 +5355,10 @@ impl BdsApp { extra_pages: self.site_validation_state.extra_files.clone(), stale_pages: self.site_validation_state.stale_files.clone(), }; - let sections = engine::generation::sections_from_validation_report(&report); - if sections.is_empty() { + if report.missing_pages.is_empty() + && report.extra_pages.is_empty() + && report.stale_pages.is_empty() + { return Task::none(); } diff --git a/crates/bds-ui/src/app/tasks.rs b/crates/bds-ui/src/app/tasks.rs index 65efdac..a7154d7 100644 --- a/crates/bds-ui/src/app/tasks.rs +++ b/crates/bds-ui/src/app/tasks.rs @@ -82,7 +82,7 @@ impl BdsApp { let sections = validation.as_ref().map_or_else( || engine::generation::GenerationSection::ALL.to_vec(), - engine::generation::sections_from_validation_report, + |validation| engine::generation::sections_from_validation_report(validation, &metadata), ); if sections.is_empty() { return Task::none();