fix: fixed rendering from validate-apply
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
§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]
|
#[test]
|
||||||
fn apply_validation_removes_extra_section_outputs() {
|
fn apply_validation_removes_extra_section_outputs() {
|
||||||
let (db, dir) = setup();
|
let (db, dir) = setup();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
for task_id in render_ids {
|
||||||
let _task = app.handle_engine_message(Message::SiteGenerationSectionDone {
|
let _task = app.handle_engine_message(Message::SiteGenerationSectionDone {
|
||||||
group_id,
|
group_id: group_id.clone(),
|
||||||
task_id: snapshots[0].id,
|
task_id,
|
||||||
result: Ok(GenerationReport::default()),
|
result: Ok(GenerationReport::default()),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
assert!(
|
assert!(
|
||||||
app.task_manager
|
app.task_manager
|
||||||
.snapshots()
|
.snapshots()
|
||||||
|
|||||||
@@ -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
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user