Use configured languages in generation validation

This commit is contained in:
2026-07-22 18:02:04 +02:00
parent 64e3847ef2
commit ba6a297d9b
8 changed files with 97 additions and 22 deletions

View File

@@ -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.

View File

@@ -498,7 +498,7 @@ fn render(db: &Database, incremental: bool, force: bool) -> Result<CommandOutput
std::fs::create_dir_all(&output_dir)?;
if incremental {
let validation = engine::validate_site::validate_site(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(),
&output_dir,

View File

@@ -210,7 +210,10 @@ pub fn render_site_section_with_progress(
Ok(report)
}
pub fn sections_from_validation_report(report: &SiteValidationReport) -> Vec<GenerationSection> {
pub fn sections_from_validation_report(
report: &SiteValidationReport,
metadata: &ProjectMetadata,
) -> Vec<GenerationSection> {
let mut sections = HashSet::new();
let mut saw_unknown = false;
@@ -220,7 +223,7 @@ pub fn sections_from_validation_report(report: &SiteValidationReport) -> Vec<Gen
.chain(report.extra_pages.iter())
.chain(report.stale_pages.iter())
{
match classify_generated_path(path) {
match classify_generated_path(path, metadata) {
Some(section) => {
sections.insert(section);
}
@@ -276,7 +279,13 @@ pub fn apply_validation_sections(
)?);
}
remove_extra_section_paths(output_dir, &expected_paths, &section_set, &mut report)?;
remove_extra_section_paths(
output_dir,
&expected_paths,
metadata,
&section_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<String> {
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::<HashSet<_>>();
@@ -710,6 +719,7 @@ fn expected_paths_for_sections(
fn remove_extra_section_paths(
output_dir: &Path,
expected: &HashSet<String>,
metadata: &ProjectMetadata,
sections: &HashSet<GenerationSection>,
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<GenerationSection>) -> bool {
classify_generated_path(path)
fn path_matches_sections(
path: &str,
metadata: &ProjectMetadata,
sections: &HashSet<GenerationSection>,
) -> bool {
classify_generated_path(path, metadata)
.map(|section| sections.contains(&section))
.unwrap_or(false)
}
pub(crate) fn classify_generated_path(path: &str) -> Option<GenerationSection> {
pub(crate) fn classify_generated_path(
path: &str,
metadata: &ProjectMetadata,
) -> Option<GenerationSection> {
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<GenerationSection> {
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<GenerationSection> {
}
}
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"

View File

@@ -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))
})

View File

@@ -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, &sections)
.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, &sections)
.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, &sections)
.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();

View File

@@ -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"),

View File

@@ -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();
}

View File

@@ -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();