fix: fixed rendering from validate-apply

This commit is contained in:
2026-07-23 21:51:12 +02:00
parent 2844ed5a47
commit ea90167a5a
6 changed files with 216 additions and 13 deletions

View File

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

@@ -315,6 +315,9 @@ pub fn sections_from_validation_report(
.chain(report.stale_pages.iter()) .chain(report.stale_pages.iter())
{ {
match classify_generated_path(path, metadata) { match classify_generated_path(path, metadata) {
Some(GenerationSection::Single) => {
sections.extend(GenerationSection::ALL);
}
Some(section) => { Some(section) => {
sections.insert(section); sections.insert(section);
} }

View File

@@ -269,6 +269,16 @@ fn build_site_render_artifacts_with_mode(
&tags, &tags,
&category_settings, &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 artifacts
.route_manifest .route_manifest
.extend(routes.iter().map(|route| SitePage { .extend(routes.iter().map(|route| SitePage {
@@ -869,6 +879,86 @@ fn paginated_route_specs(
pages pages
} }
fn expand_requested_aggregate_paths(
requested: &HashSet<String>,
posts: &[RenderPostRecord],
routes: &[RouteSpec],
language: &str,
metadata: &ProjectMetadata,
) -> HashSet<String> {
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( #[expect(
clippy::too_many_arguments, clippy::too_many_arguments,
reason = "render inputs are existing domain data with distinct lifetimes" reason = "render inputs are existing domain data with distinct lifetimes"
@@ -1276,7 +1366,7 @@ fn should_show_list_title(
category_settings category_settings
.get(category) .get(category)
.map(|settings| !settings.show_title) .map(|settings| !settings.show_title)
.unwrap_or(false) .unwrap_or(category == "aside")
}) })
} }

View File

@@ -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); let mut german_source = make_post("german-source", 1_710_086_400_000);
german_source.language = Some("de".into()); german_source.language = Some("de".into());
german_source.title = "Deutsche Quelle".into(); german_source.title = "Deutsche Quelle".into();
german_source.categories = vec!["aside".into()];
write_published_snapshot(&dir, &mut german_source, "Deutscher Quelltext"); write_published_snapshot(&dir, &mut german_source, "Deutscher Quelltext");
bds_core::db::queries::post::insert_post(db.conn(), &german_source).unwrap(); bds_core::db::queries::post::insert_post(db.conn(), &german_source).unwrap();
let english_translation = PostTranslation { 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!(canonical_german_source.contains("Deutscher Quelltext"));
assert!(localized_german_source.contains("English translation body")); assert!(localized_german_source.contains("English translation body"));
assert!(localized_fallback.contains("Unübersetzter Quelltext")); 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</a></h2>"));
assert!( assert!(
output output
.join("2024/03/12/german-private/index.html") .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()); 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,
&sections,
)
.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] #[test]
fn apply_validation_removes_extra_section_outputs() { fn apply_validation_removes_extra_section_outputs() {
let (db, dir) = setup(); let (db, dir) = setup();

View File

@@ -11329,7 +11329,7 @@ mod tests {
} }
#[test] #[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(); let (db, project, tmp) = setup();
enable_generation(&tmp); enable_generation(&tmp);
let mut app = make_app(db, project, &tmp); let mut app = make_app(db, project, &tmp);
@@ -11340,19 +11340,36 @@ mod tests {
let _task = app.queue_site_generation(Some(validation)); let _task = app.queue_site_generation(Some(validation));
let snapshots = app.task_manager.snapshots(); let snapshots = app.task_manager.snapshots();
assert_eq!(snapshots.len(), 1);
assert_eq!(snapshots[0].label, "Render Single Posts");
assert_eq!( assert_eq!(
snapshots[0].group_name.as_deref(), snapshots
Some("Apply Site Validation") .iter()
.map(|task| task.label.as_str())
.collect::<Vec<_>>(),
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 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 { for task_id in render_ids {
group_id, let _task = app.handle_engine_message(Message::SiteGenerationSectionDone {
task_id: snapshots[0].id, group_id: group_id.clone(),
result: Ok(GenerationReport::default()), task_id,
}); result: Ok(GenerationReport::default()),
});
}
assert!( assert!(
app.task_manager app.task_manager
.snapshots() .snapshots()

View File

@@ -300,7 +300,9 @@ rule ValidateSite {
rule ApplyValidation { rule ApplyValidation {
when: ApplyValidationRequested(project_id, sections) 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 -- unchanged post route refreshes its tracked generation time so the
-- automatic follow-up validation does not report it stale again. -- automatic follow-up validation does not report it stale again.
ensures: GenerateSiteRequested(plan_generation(project_id, sections)) 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 -- 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 -- SEARCH INDEX: PAGEFIND
-- ============================================================================ -- ============================================================================