Add forced full-site rendering

This commit is contained in:
2026-07-23 10:23:02 +02:00
parent d87e80f96b
commit 2844ed5a47
21 changed files with 611 additions and 122 deletions

View File

@@ -468,13 +468,14 @@ Templates are stored as files with frontmatter metadata in the project data dire
Publishing in RuDS is a staged process: publish content locally, generate or validate-and-apply site changes, commit the result, then deploy when ready.
Full generation builds the entire static site. Site validation detects missing, extra, and updated routes so RuDS can re-render only what changed. This is the practical incremental workflow for most daily editorial changes.
Full generation builds the entire static site and skips files whose tracked content hash is unchanged. Use **Blog → Force Render Site** (Command/Ctrl+Shift+R) when every generated file must be rewritten regardless of its stored hash. Site validation detects missing, extra, and updated routes so RuDS can re-render only what changed. This is the practical incremental workflow for most daily editorial changes.
When blog languages are configured, generation produces language-aware route trees, per-language feeds, and alternate language metadata.
### Key takeaways
- Full generation produces the complete site.
- Force Render Site rewrites the complete site and search index without relying on stored hashes.
- Validate and Apply is the efficient daily workflow for incremental publishing.
- Public Base URL must be set before generation.
- Commit generated output before deploying for recoverability.

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.
- 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, 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), 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.
- 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

@@ -521,17 +521,26 @@ fn render(db: &Database, incremental: bool, force: bool) -> Result<CommandOutput
json!({"written": report.written_paths.len(), "skipped": report.skipped_paths.len(), "deleted": report.deleted_paths.len()}),
));
}
if force {
engine::generation::clear_generation_cache(db.conn(), &project.id)?;
}
let report = engine::generation::generate_starter_site(
db.conn(),
&output_dir,
&project.id,
&metadata,
&posts,
metadata.main_language.as_deref().unwrap_or("en"),
)?;
let language = metadata.main_language.as_deref().unwrap_or("en");
let report = if force {
engine::generation::generate_starter_site_forced(
db.conn(),
&output_dir,
&project.id,
&metadata,
&posts,
language,
)?
} else {
engine::generation::generate_starter_site(
db.conn(),
&output_dir,
&project.id,
&metadata,
&posts,
language,
)?
};
Ok(output(
"Site rendered",
json!({"written": report.written_paths.len(), "skipped": report.skipped_paths.len(), "deleted": report.deleted_paths.len(), "force": force}),
@@ -1538,7 +1547,29 @@ mod tests {
metadata.semantic_similarity_enabled = false;
engine::meta::write_project_json(&fixture.project_dir, &metadata).unwrap();
fixture.run(&["render"], "").unwrap();
let db = open_database(&fixture.database_path).unwrap();
let (project, _) = active_project(&db).unwrap();
bds_core::db::queries::generated_file_hash::upsert_generated_file_hash(
db.conn(),
&bds_core::model::GeneratedFileHash {
project_id: project.id.clone(),
relative_path: "legacy-output.txt".into(),
content_hash: "legacy".into(),
updated_at: 42,
},
)
.unwrap();
drop(db);
fixture.run(&["render", "--force"], "").unwrap();
let db = open_database(&fixture.database_path).unwrap();
assert!(
bds_core::db::queries::generated_file_hash::get_generated_file_hash(
db.conn(),
&project.id,
"legacy-output.txt",
)
.is_ok()
);
fixture.run(&["render", "--incremental"], "").unwrap();
assert!(fixture.project_dir.join("html/index.html").is_file());

View File

@@ -15,7 +15,8 @@ use crate::model::{CategorySettings, Post, ProjectMetadata};
use crate::render::{
GeneratedWriteOutcome, PostLanguageVariant, build_calendar_json, build_canonical_post_path,
build_site_section_render_artifacts, build_targeted_site_section_render_artifacts,
select_post_language_variant, write_generated_bytes, write_generated_file,
select_post_language_variant, write_generated_bytes, write_generated_bytes_forced,
write_generated_file, write_generated_file_forced, write_generated_file_verified,
};
#[derive(Debug, Clone)]
@@ -102,18 +103,24 @@ pub fn generate_starter_site(
)
}
/// Forget stored generated-file hashes so the next render writes every
/// artifact while repopulating the cache with its current content hash.
pub fn clear_generation_cache(conn: &Connection, project_id: &str) -> EngineResult<usize> {
use crate::db::schema::generated_file_hashes;
use diesel::prelude::*;
Ok(conn.with(|connection| {
diesel::delete(
generated_file_hashes::table.filter(generated_file_hashes::project_id.eq(project_id)),
)
.execute(connection)
})?)
pub fn generate_starter_site_forced(
conn: &Connection,
output_dir: &Path,
project_id: &str,
metadata: &ProjectMetadata,
posts: &[PublishedPostSource],
language: &str,
) -> EngineResult<GenerationReport> {
generate_starter_site_with_progress_mode(
conn,
output_dir,
project_id,
metadata,
posts,
language,
true,
|_current, _total, _path| {},
)
}
pub fn generate_starter_site_with_progress(
@@ -123,23 +130,49 @@ pub fn generate_starter_site_with_progress(
metadata: &ProjectMetadata,
posts: &[PublishedPostSource],
_language: &str,
on_page: impl FnMut(usize, usize, &str),
) -> EngineResult<GenerationReport> {
generate_starter_site_with_progress_mode(
conn, output_dir, project_id, metadata, posts, _language, false, on_page,
)
}
#[expect(
clippy::too_many_arguments,
reason = "full generation adds write mode to the existing generation context"
)]
fn generate_starter_site_with_progress_mode(
conn: &Connection,
output_dir: &Path,
project_id: &str,
metadata: &ProjectMetadata,
posts: &[PublishedPostSource],
_language: &str,
force: bool,
mut on_page: impl FnMut(usize, usize, &str),
) -> EngineResult<GenerationReport> {
let mut report = GenerationReport::default();
for section in GenerationSection::ALL {
report.append(render_site_section_with_progress(
report.append(render_site_section_with_progress_mode(
conn,
output_dir,
project_id,
metadata,
posts,
section,
force,
&mut on_page,
|| false,
)?);
}
report.append(build_site_search_index(
conn, output_dir, project_id, metadata,
report.append(build_site_search_index_with_progress_mode(
conn,
output_dir,
project_id,
metadata,
force,
|_current, _total, _path| {},
|| false,
)?);
Ok(report)
}
@@ -157,6 +190,61 @@ pub fn render_site_section_with_progress(
section: GenerationSection,
mut on_page: impl FnMut(usize, usize, &str),
mut is_cancelled: impl FnMut() -> bool,
) -> EngineResult<GenerationReport> {
render_site_section_with_progress_mode(
conn,
output_dir,
project_id,
metadata,
posts,
section,
false,
&mut on_page,
&mut is_cancelled,
)
}
#[expect(
clippy::too_many_arguments,
reason = "forced section rendering adds write mode to the existing generation context"
)]
pub fn render_site_section_forced_with_progress(
conn: &Connection,
output_dir: &Path,
project_id: &str,
metadata: &ProjectMetadata,
posts: &[PublishedPostSource],
section: GenerationSection,
mut on_page: impl FnMut(usize, usize, &str),
mut is_cancelled: impl FnMut() -> bool,
) -> EngineResult<GenerationReport> {
render_site_section_with_progress_mode(
conn,
output_dir,
project_id,
metadata,
posts,
section,
true,
&mut on_page,
&mut is_cancelled,
)
}
#[expect(
clippy::too_many_arguments,
reason = "section rendering uses the existing generation context, write mode, and callbacks"
)]
fn render_site_section_with_progress_mode(
conn: &Connection,
output_dir: &Path,
project_id: &str,
metadata: &ProjectMetadata,
posts: &[PublishedPostSource],
section: GenerationSection,
force: bool,
mut on_page: impl FnMut(usize, usize, &str),
mut is_cancelled: impl FnMut() -> bool,
) -> EngineResult<GenerationReport> {
if is_cancelled() {
return Err(EngineError::Validation("cancelled".to_string()));
@@ -188,6 +276,8 @@ pub fn render_site_section_with_progress(
&page.relative_path,
&page.html,
&mut report,
force,
false,
)?;
on_page(index + 1, total_pages, &page.url_path);
}
@@ -204,6 +294,8 @@ pub fn render_site_section_with_progress(
None,
&mut report,
&mut is_cancelled,
force,
false,
)?;
}
Ok(report)
@@ -345,6 +437,8 @@ pub fn apply_validation_section_with_progress(
&page.relative_path,
&page.html,
&mut report,
false,
true,
)?;
on_page(index + 1, total_pages, &page.url_path);
}
@@ -361,6 +455,8 @@ pub fn apply_validation_section_with_progress(
(!fallback).then_some(&requested),
&mut report,
&mut is_cancelled,
false,
true,
)?;
}
@@ -415,9 +511,11 @@ fn write_core_outputs(
requested: Option<&HashSet<String>>,
report: &mut GenerationReport,
is_cancelled: &mut impl FnMut() -> bool,
force: bool,
verify_output: bool,
) -> EngineResult<()> {
if requested.is_none() {
write_bundled_site_assets(conn, output_dir, project_id, report)?;
write_bundled_site_assets(conn, output_dir, project_id, report, force)?;
}
let mut outputs = vec![(
"calendar.json".to_string(),
@@ -483,12 +581,25 @@ fn write_core_outputs(
return Err(EngineError::Validation("cancelled".to_string()));
}
if requested.is_none_or(|requested| requested.contains(&path)) {
write_out(conn, output_dir, project_id, &path, &content, report)?;
write_out(
conn,
output_dir,
project_id,
&path,
&content,
report,
force,
verify_output,
)?;
}
}
Ok(())
}
#[expect(
clippy::too_many_arguments,
reason = "generated output needs its render context, report, and write mode"
)]
fn write_out(
conn: &Connection,
output_dir: &Path,
@@ -496,10 +607,18 @@ fn write_out(
relative_path: &str,
content: &str,
report: &mut GenerationReport,
force: bool,
verify_output: bool,
) -> EngineResult<()> {
match write_generated_file(conn, output_dir, project_id, relative_path, content)
.map_err(|error| EngineError::Parse(error.to_string()))?
{
let outcome = if force {
write_generated_file_forced(conn, output_dir, project_id, relative_path, content)
} else if verify_output {
write_generated_file_verified(conn, output_dir, project_id, relative_path, content)
} else {
write_generated_file(conn, output_dir, project_id, relative_path, content)
}
.map_err(|error| EngineError::Parse(error.to_string()))?;
match outcome {
GeneratedWriteOutcome::Written => report.written_paths.push(relative_path.to_string()),
GeneratedWriteOutcome::SkippedUnchanged => {
report.skipped_paths.push(relative_path.to_string())
@@ -524,6 +643,25 @@ pub fn build_site_search_index(
)
}
pub fn build_site_search_index_forced_with_progress(
conn: &Connection,
output_dir: &Path,
project_id: &str,
metadata: &ProjectMetadata,
on_file: impl FnMut(usize, usize, &str),
is_cancelled: impl FnMut() -> bool,
) -> EngineResult<GenerationReport> {
build_site_search_index_with_progress_mode(
conn,
output_dir,
project_id,
metadata,
true,
on_file,
is_cancelled,
)
}
pub fn build_site_search_index_with_progress(
conn: &Connection,
output_dir: &Path,
@@ -531,6 +669,26 @@ pub fn build_site_search_index_with_progress(
metadata: &ProjectMetadata,
mut on_file: impl FnMut(usize, usize, &str),
mut is_cancelled: impl FnMut() -> bool,
) -> EngineResult<GenerationReport> {
build_site_search_index_with_progress_mode(
conn,
output_dir,
project_id,
metadata,
false,
&mut on_file,
&mut is_cancelled,
)
}
fn build_site_search_index_with_progress_mode(
conn: &Connection,
output_dir: &Path,
project_id: &str,
metadata: &ProjectMetadata,
force: bool,
mut on_file: impl FnMut(usize, usize, &str),
mut is_cancelled: impl FnMut() -> bool,
) -> EngineResult<GenerationReport> {
let mut documents = Vec::new();
if output_dir.exists() {
@@ -639,9 +797,13 @@ pub fn build_site_search_index_with_progress(
if is_cancelled() {
return Err(EngineError::Validation("cancelled".to_string()));
}
match write_generated_bytes(conn, output_dir, project_id, &relative, &contents)
.map_err(|error| EngineError::Parse(error.to_string()))?
{
let outcome = if force {
write_generated_bytes_forced(conn, output_dir, project_id, &relative, &contents)
} else {
write_generated_bytes(conn, output_dir, project_id, &relative, &contents)
}
.map_err(|error| EngineError::Parse(error.to_string()))?;
match outcome {
GeneratedWriteOutcome::Written => report.written_paths.push(relative.clone()),
GeneratedWriteOutcome::SkippedUnchanged => report.skipped_paths.push(relative.clone()),
}

View File

@@ -257,17 +257,28 @@ pub(crate) fn write_bundled_site_assets(
output_dir: &Path,
project_id: &str,
report: &mut crate::engine::generation::GenerationReport,
force: bool,
) -> EngineResult<()> {
for asset in BUNDLED_SITE_ASSETS {
match write_generated_bytes(
conn,
output_dir,
project_id,
asset.relative_path,
asset.bytes,
)
.map_err(|error| EngineError::Parse(error.to_string()))?
{
let outcome = if force {
crate::render::write_generated_bytes_forced(
conn,
output_dir,
project_id,
asset.relative_path,
asset.bytes,
)
} else {
write_generated_bytes(
conn,
output_dir,
project_id,
asset.relative_path,
asset.bytes,
)
}
.map_err(|error| EngineError::Parse(error.to_string()))?;
match outcome {
GeneratedWriteOutcome::Written => {
report.written_paths.push(asset.relative_path.to_string())
}

View File

@@ -29,13 +29,70 @@ pub fn write_generated_file(
project_id: &str,
relative_path: &str,
content: &str,
) -> Result<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> {
write_generated_file_with_mode(
conn,
output_dir,
project_id,
relative_path,
content,
false,
false,
)
}
pub fn write_generated_file_verified(
conn: &Connection,
output_dir: &Path,
project_id: &str,
relative_path: &str,
content: &str,
) -> Result<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> {
write_generated_file_with_mode(
conn,
output_dir,
project_id,
relative_path,
content,
false,
true,
)
}
pub fn write_generated_file_forced(
conn: &Connection,
output_dir: &Path,
project_id: &str,
relative_path: &str,
content: &str,
) -> Result<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> {
write_generated_file_with_mode(
conn,
output_dir,
project_id,
relative_path,
content,
true,
false,
)
}
fn write_generated_file_with_mode(
conn: &Connection,
output_dir: &Path,
project_id: &str,
relative_path: &str,
content: &str,
force: bool,
verify_output: bool,
) -> Result<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> {
let hash = content_hash(content.as_bytes());
let target_path = output_dir.join(relative_path);
if let Ok(existing) = qhash::get_generated_file_hash(conn, project_id, relative_path)
if !force
&& let Ok(existing) = qhash::get_generated_file_hash(conn, project_id, relative_path)
&& existing.content_hash == hash
&& target_path.exists()
&& file_hash(&target_path)? == hash
&& (!verify_output || file_hash(&target_path)? == hash)
{
return Ok(GeneratedWriteOutcome::SkippedUnchanged);
}
@@ -64,13 +121,34 @@ pub fn write_generated_bytes(
project_id: &str,
relative_path: &str,
content: &[u8],
) -> Result<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> {
write_generated_bytes_with_force(conn, output_dir, project_id, relative_path, content, false)
}
pub fn write_generated_bytes_forced(
conn: &Connection,
output_dir: &Path,
project_id: &str,
relative_path: &str,
content: &[u8],
) -> Result<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> {
write_generated_bytes_with_force(conn, output_dir, project_id, relative_path, content, true)
}
fn write_generated_bytes_with_force(
conn: &Connection,
output_dir: &Path,
project_id: &str,
relative_path: &str,
content: &[u8],
force: bool,
) -> Result<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> {
let hash = content_hash(content);
let target_path = output_dir.join(relative_path);
if let Ok(existing) = qhash::get_generated_file_hash(conn, project_id, relative_path)
if !force
&& let Ok(existing) = qhash::get_generated_file_hash(conn, project_id, relative_path)
&& existing.content_hash == hash
&& target_path.exists()
&& file_hash(&target_path)? == hash
{
return Ok(GeneratedWriteOutcome::SkippedUnchanged);
}

View File

@@ -8,7 +8,8 @@ mod template_lookup;
pub use generation::{
CalendarArchiveData, GeneratedWriteOutcome, build_calendar_json, build_core_generation_paths,
write_generated_bytes, write_generated_file,
write_generated_bytes, write_generated_bytes_forced, write_generated_file,
write_generated_file_forced, write_generated_file_verified,
};
pub use markdown::render_markdown_to_html;
pub use page_renderer::{RenderError, render_liquid_template};

View File

@@ -6,7 +6,8 @@ use bds_core::db::queries::template::insert_template;
use bds_core::engine::generation::{
GenerationSection, PublishedPostSource, apply_validation_section_with_progress,
apply_validation_sections, build_site_search_index, generate_starter_site,
load_published_post_source, render_site_section_with_progress, sections_from_validation_report,
generate_starter_site_forced, load_published_post_source, render_site_section_with_progress,
sections_from_validation_report,
};
use bds_core::engine::meta::write_category_meta_json;
use bds_core::engine::validate_site::validate_site;
@@ -742,6 +743,61 @@ fn generation_engine_skips_unchanged_outputs_on_second_run() {
);
}
#[test]
fn forced_generation_rewrites_unchanged_outputs_without_erasing_unrelated_cache_records() {
let (db, dir) = setup();
let metadata = make_metadata();
let posts = vec![PublishedPostSource {
post: make_post("hello", 1_710_000_000_000),
body_markdown: "Hello **world**".into(),
}];
generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
let unchanged =
generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
assert!(unchanged.skipped_paths.contains(&"index.html".to_string()));
std::fs::write(dir.path().join("index.html"), "tampered").unwrap();
let normal_after_drift =
generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
assert!(
normal_after_drift
.skipped_paths
.contains(&"index.html".to_string())
);
assert_eq!(
std::fs::read_to_string(dir.path().join("index.html")).unwrap(),
"tampered"
);
bds_core::db::queries::generated_file_hash::upsert_generated_file_hash(
db.conn(),
&bds_core::model::GeneratedFileHash {
project_id: "p1".into(),
relative_path: "legacy-output.txt".into(),
content_hash: "legacy".into(),
updated_at: 42,
},
)
.unwrap();
let forced =
generate_starter_site_forced(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
assert!(forced.written_paths.contains(&"index.html".to_string()));
assert!(forced.skipped_paths.is_empty());
assert_ne!(
std::fs::read_to_string(dir.path().join("index.html")).unwrap(),
"tampered"
);
assert!(
bds_core::db::queries::generated_file_hash::get_generated_file_hash(
db.conn(),
"p1",
"legacy-output.txt",
)
.is_ok()
);
}
#[test]
fn site_validation_matches_bds2_index_route_comparison() {
let (db, dir) = setup();

View File

@@ -6,6 +6,7 @@ use bds_core::db::queries::project::insert_project;
use bds_core::model::{Post, PostStatus, Project};
use bds_core::render::{
GeneratedWriteOutcome, build_calendar_json, build_core_generation_paths, write_generated_file,
write_generated_file_forced,
};
use tempfile::TempDir;
@@ -95,7 +96,7 @@ fn generated_write_rewrites_missing_file_even_when_hash_matches() {
}
#[test]
fn generated_write_rewrites_stale_file_even_when_db_hash_matches() {
fn generated_write_trusts_the_cached_hash_like_bds2() {
let (db, dir) = setup();
let first = write_generated_file(db.conn(), dir.path(), "p1", "index.html", "hello").unwrap();
@@ -103,7 +104,23 @@ fn generated_write_rewrites_stale_file_even_when_db_hash_matches() {
let second = write_generated_file(db.conn(), dir.path(), "p1", "index.html", "hello").unwrap();
assert_eq!(first, GeneratedWriteOutcome::Written);
assert_eq!(second, GeneratedWriteOutcome::Written);
assert_eq!(second, GeneratedWriteOutcome::SkippedUnchanged);
assert_eq!(
fs::read_to_string(dir.path().join("index.html")).unwrap(),
"tampered"
);
}
#[test]
fn forced_generated_write_ignores_the_cached_hash() {
let (db, dir) = setup();
write_generated_file(db.conn(), dir.path(), "p1", "index.html", "hello").unwrap();
fs::write(dir.path().join("index.html"), "tampered").unwrap();
let forced =
write_generated_file_forced(db.conn(), dir.path(), "p1", "index.html", "hello").unwrap();
assert_eq!(forced, GeneratedWriteOutcome::Written);
assert_eq!(
fs::read_to_string(dir.path().join("index.html")).unwrap(),
"hello"

View File

@@ -2843,7 +2843,7 @@ impl TuiApp {
let db = Database::open(&database_path)?;
let metadata = engine::meta::read_project_json(&data_dir)?;
let posts = published_sources(db.conn(), &data_dir, &project_id)?;
let report = engine::generation::generate_starter_site(
let report = engine::generation::generate_starter_site_forced(
db.conn(),
&data_dir.join("html"),
&project_id,

View File

@@ -258,6 +258,7 @@ pub enum Message {
),
ValidateMedia,
GenerateSite,
ForceGenerateSite,
RunMetadataDiff,
MetadataDiffLoaded(Result<engine::metadata_diff::DiffReport, String>),
RepairMetadataDiffItem {
@@ -428,6 +429,7 @@ pub enum Message {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SiteGenerationKind {
Full,
Forced,
Validation,
}
@@ -2839,6 +2841,7 @@ impl BdsApp {
| Message::TranslationValidationLoaded(_)
| Message::ValidateMedia
| Message::GenerateSite
| Message::ForceGenerateSite
| Message::RunMetadataDiff
| Message::MetadataDiffLoaded(_)
| Message::RepairMetadataDiffItem { .. }
@@ -4444,6 +4447,7 @@ impl BdsApp {
)
}
MenuAction::GenerateSitemap => Task::done(Message::GenerateSite),
MenuAction::ForceRenderSite => Task::done(Message::ForceGenerateSite),
MenuAction::ValidateSite => {
self.open_singleton_tab(TabType::SiteValidation, "tabBar.siteValidation");
self.start_site_validation()
@@ -10137,7 +10141,7 @@ fn remote_error_closes_connection(code: &str) -> bool {
mod tests {
use super::{
BdsApp, Message, POST_AUTO_SAVE_DELAY_MS, PersistedMediaState, PersistedPostState,
PostStatus, SettingsMsg, active_post_tab_id, dropped_image_target,
PostStatus, SettingsMsg, SiteGenerationKind, active_post_tab_id, dropped_image_target,
flush_embeddings_and_exit, localize_chat_error, month_abbreviation,
persist_media_editor_state_impl, persist_post_editor_preview_state_impl,
persist_post_editor_state_impl, remote_error_closes_connection,
@@ -11215,6 +11219,28 @@ mod tests {
);
}
#[test]
fn forced_generation_uses_its_own_full_site_task_group() {
let (db, project, tmp) = setup();
enable_generation(&tmp);
let mut app = make_app(db, project, &tmp);
let _task = app.handle_engine_message(Message::ForceGenerateSite);
let snapshots = app.task_manager.snapshots();
assert_eq!(snapshots.len(), 5);
assert!(
snapshots
.iter()
.all(|task| { task.group_name.as_deref() == Some("Force Render Site") })
);
let group_id = snapshots[0].group_id.as_ref().unwrap();
assert_eq!(
app.site_generation_workflows[group_id].kind,
SiteGenerationKind::Forced
);
}
#[test]
fn search_index_is_queued_only_after_every_render_task_succeeds() {
let (db, project, tmp) = setup();

View File

@@ -144,6 +144,7 @@ impl BdsApp {
)
}
Message::GenerateSite => self.queue_site_generation(None),
Message::ForceGenerateSite => self.queue_forced_site_generation(),
Message::RunMetadataDiff => {
self.open_singleton_tab(TabType::MetadataDiff, "tabBar.metadataDiff");
self.start_metadata_diff()
@@ -311,19 +312,22 @@ impl BdsApp {
Err(error) => {
self.task_manager.fail(task_id, error.clone());
self.task_manager.cancel_group(&group_id);
if let Some(workflow) = self.site_generation_workflows.remove(&group_id)
&& workflow.kind == SiteGenerationKind::Validation
let workflow = self.site_generation_workflows.remove(&group_id);
if workflow
.as_ref()
.is_some_and(|workflow| workflow.kind == SiteGenerationKind::Validation)
{
self.site_validation_state.is_applying = false;
self.site_validation_state.error_message = Some(error.clone());
}
let operation = workflow.map_or_else(
|| t(self.ui_locale, "engine.renderSiteGroup"),
|workflow| workflow.group_name,
);
let message = tw(
self.ui_locale,
"common.operationFailed",
&[
("operation", &t(self.ui_locale, "engine.renderSiteGroup")),
("error", &error),
],
&[("operation", &operation), ("error", &error)],
);
self.notify(ToastLevel::Error, &message);
self.refresh_task_snapshots();

View File

@@ -36,9 +36,23 @@ impl BdsApp {
pub(super) fn queue_site_generation(
&mut self,
validation: Option<engine::validate_site::SiteValidationReport>,
) -> Task<Message> {
self.queue_site_generation_mode(validation, false)
}
pub(super) fn queue_forced_site_generation(&mut self) -> Task<Message> {
self.queue_site_generation_mode(None, true)
}
fn queue_site_generation_mode(
&mut self,
validation: Option<engine::validate_site::SiteValidationReport>,
force: bool,
) -> Task<Message> {
let kind = if validation.is_some() {
SiteGenerationKind::Validation
} else if force {
SiteGenerationKind::Forced
} else {
SiteGenerationKind::Full
};
@@ -94,10 +108,10 @@ impl BdsApp {
let group_id = format!("site-generation:{}", Uuid::new_v4());
let group_name = t(
self.ui_locale,
if kind == SiteGenerationKind::Full {
"engine.renderSiteGroup"
} else {
"engine.applyValidationGroup"
match kind {
SiteGenerationKind::Full => "engine.renderSiteGroup",
SiteGenerationKind::Forced => "engine.forceRenderSiteGroup",
SiteGenerationKind::Validation => "engine.applyValidationGroup",
},
);
let mut render_task_ids = Vec::new();
@@ -128,6 +142,7 @@ impl BdsApp {
task_id,
section,
task_validation,
force,
locale,
)
})
@@ -189,33 +204,46 @@ impl BdsApp {
let output_dir = workflow.data_dir.join("html");
let progress_manager = Arc::clone(&task_manager);
let cancel_manager = Arc::clone(&task_manager);
engine::generation::build_site_search_index_with_progress(
db.conn(),
&output_dir,
&workflow.project_id,
&metadata,
move |current, total, path| {
let progress = if total == 0 {
1.0
} else {
current as f32 / total as f32
};
progress_manager.report_progress(
task_id,
Some(progress),
Some(tw(
locale,
"engine.builtSearchFile",
&[
("path", path),
("current", &current.to_string()),
("total", &total.to_string()),
],
)),
);
},
move || cancel_manager.is_cancelled(task_id),
)
let on_file = move |current: usize, total: usize, path: &str| {
let progress = if total == 0 {
1.0
} else {
current as f32 / total as f32
};
progress_manager.report_progress(
task_id,
Some(progress),
Some(tw(
locale,
"engine.builtSearchFile",
&[
("path", path),
("current", &current.to_string()),
("total", &total.to_string()),
],
)),
);
};
let is_cancelled = move || cancel_manager.is_cancelled(task_id);
if workflow.kind == SiteGenerationKind::Forced {
engine::generation::build_site_search_index_forced_with_progress(
db.conn(),
&output_dir,
&workflow.project_id,
&metadata,
on_file,
is_cancelled,
)
} else {
engine::generation::build_site_search_index_with_progress(
db.conn(),
&output_dir,
&workflow.project_id,
&metadata,
on_file,
is_cancelled,
)
}
.map_err(|error| error.to_string())
})
.await
@@ -422,6 +450,7 @@ fn run_site_generation_section(
task_id: TaskId,
section: engine::generation::GenerationSection,
validation: Option<engine::validate_site::SiteValidationReport>,
force: bool,
locale: UiLocale,
) -> Result<engine::generation::GenerationReport, String> {
if !task_manager.wait_until_runnable(task_id) {
@@ -487,6 +516,16 @@ fn run_site_generation_section(
on_page,
is_cancelled,
),
None if force => engine::generation::render_site_section_forced_with_progress(
db.conn(),
&output_dir,
&project_id,
&metadata,
&sources,
section,
on_page,
is_cancelled,
),
None => engine::generation::render_site_section_with_progress(
db.conn(),
&output_dir,

View File

@@ -8,6 +8,13 @@ use crate::app::Message;
use crate::state::tabs::TabType;
use bds_core::i18n::{UiLocale, translate};
const BLOG_SITE_ACTIONS: [MenuAction; 4] = [
MenuAction::GenerateSitemap,
MenuAction::ForceRenderSite,
MenuAction::ValidateSite,
MenuAction::UploadSite,
];
/// Every custom menu item that the application handles.
///
/// Edit commands are custom actions because Iced widgets are not native Cocoa
@@ -51,6 +58,7 @@ pub enum MenuAction {
ValidateTranslations,
FillMissingTranslations,
GenerateSitemap,
ForceRenderSite,
ValidateSite,
UploadSite,
// Help
@@ -98,6 +106,7 @@ impl MenuAction {
MenuAction::ValidateTranslations,
MenuAction::FillMissingTranslations,
MenuAction::GenerateSitemap,
MenuAction::ForceRenderSite,
MenuAction::ValidateSite,
MenuAction::UploadSite,
MenuAction::About,
@@ -142,7 +151,8 @@ impl MenuAction {
"regenerate_calendar" => Self::RegenerateCalendar,
"validate_translations" => Self::ValidateTranslations,
"fill_missing_translations" => Self::FillMissingTranslations,
"generate_sitemap" | "force_render_site" => Self::GenerateSitemap,
"generate_sitemap" => Self::GenerateSitemap,
"force_render_site" => Self::ForceRenderSite,
"validate_site" => Self::ValidateSite,
"upload_site" => Self::UploadSite,
"about" => Self::About,
@@ -189,6 +199,7 @@ impl MenuAction {
Self::ValidateTranslations => "menu.item.validateTranslations",
Self::FillMissingTranslations => "menu.item.fillMissingTranslations",
Self::GenerateSitemap => "menu.item.generateSitemap",
Self::ForceRenderSite => "menu.item.forceRenderSite",
Self::ValidateSite => "menu.item.validateSite",
Self::UploadSite => "menu.item.uploadSite",
Self::About => "menu.item.about",
@@ -231,6 +242,7 @@ pub(crate) fn action_enabled(
| MenuAction::ValidateTranslations
| MenuAction::FillMissingTranslations
| MenuAction::GenerateSitemap
| MenuAction::ForceRenderSite
| MenuAction::ValidateSite
| MenuAction::UploadSite
);
@@ -264,6 +276,7 @@ pub(crate) fn action_enabled(
| MenuAction::ValidateTranslations
| MenuAction::FillMissingTranslations
| MenuAction::GenerateSitemap
| MenuAction::ForceRenderSite
| MenuAction::ValidateSite => has_project,
_ => true,
}
@@ -531,30 +544,22 @@ pub fn build_menu_bar(locale: UiLocale) -> (Menu, MenuRegistry) {
None,
));
let _ = blog_menu.append(&PredefinedMenuItem::separator());
let _ = blog_menu.append(&item(
&mut reg,
MenuAction::GenerateSitemap,
locale,
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyR)),
));
let _ = blog_menu.append(&item(
&mut reg,
MenuAction::ValidateSite,
locale,
Some(Accelerator::new(
Some(CMD_OR_CTRL | Modifiers::SHIFT),
Code::KeyL,
)),
));
let _ = blog_menu.append(&item(
&mut reg,
MenuAction::UploadSite,
locale,
Some(Accelerator::new(
Some(CMD_OR_CTRL | Modifiers::SHIFT),
Code::KeyU,
)),
));
for action in BLOG_SITE_ACTIONS {
let accelerator = match action {
MenuAction::GenerateSitemap => Accelerator::new(Some(CMD_OR_CTRL), Code::KeyR),
MenuAction::ForceRenderSite => {
Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyR)
}
MenuAction::ValidateSite => {
Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyL)
}
MenuAction::UploadSite => {
Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyU)
}
_ => unreachable!("site action list contains only site actions"),
};
let _ = blog_menu.append(&item(&mut reg, action, locale, Some(accelerator)));
}
// -- Help --
let help_menu = Submenu::new(translate(locale, "menu.group.help"), true);
@@ -680,6 +685,24 @@ mod tests {
}
}
#[test]
fn force_render_script_action_is_distinct_from_normal_render() {
assert_eq!(
MenuAction::from_script_name("force_render_site"),
Some(MenuAction::ForceRenderSite)
);
}
#[test]
fn force_render_is_immediately_below_normal_render_in_the_blog_menu() {
let normal = BLOG_SITE_ACTIONS
.iter()
.position(|action| *action == MenuAction::GenerateSitemap)
.unwrap();
assert_eq!(BLOG_SITE_ACTIONS[normal + 1], MenuAction::ForceRenderSite);
}
#[test]
fn enabled_state_follows_application_rules() {
assert!(action_enabled(

View File

@@ -128,6 +128,14 @@ mod tests {
&[serde_json::json!("new_post")],
)
.unwrap();
assert_eq!(queued.lock().unwrap().as_slice(), &[MenuAction::NewPost]);
handler(Arc::clone(&queued), String::new())(
"trigger_menu_action",
&[serde_json::json!("force_render_site")],
)
.unwrap();
assert_eq!(
queued.lock().unwrap().as_slice(),
&[MenuAction::NewPost, MenuAction::ForceRenderSite]
);
}
}

View File

@@ -67,6 +67,7 @@ embeddings-indexed = Der semantische Index ist für { $count } Beiträge bereit.
menu-item-metadataDiff = Metadaten-Diff-Werkzeug
menu-item-editMenu = Blog-Menü bearbeiten
menu-item-generateSitemap = Site rendern
menu-item-forceRenderSite = Website vollständig neu generieren
menu-item-regenerateCalendar = Kalender neu erzeugen
menu-item-validateSite = Website validieren
menu-item-validateTranslations = Übersetzungen validieren
@@ -676,6 +677,7 @@ blogmark-failed = Blogmark-Import fehlgeschlagen: { $error }
engine-generatedPage = Erstellt { $url } ({ $current }/{ $total })
engine-rewrotePage = Neu geschrieben { $url } ({ $current }/{ $total })
engine-renderSiteGroup = Website rendern
engine-forceRenderSiteGroup = Website vollständig neu generieren
engine-applyValidationGroup = Website-Validierung anwenden
engine-renderSiteCore = Website-Kern rendern
engine-renderSinglePosts = Einzelbeiträge rendern

View File

@@ -67,6 +67,7 @@ embeddings-indexed = Semantic index is ready for { $count } posts.
menu-item-metadataDiff = Metadata Diff Tool
menu-item-editMenu = Edit Blog Menu
menu-item-generateSitemap = Render Site
menu-item-forceRenderSite = Force Render Site
menu-item-regenerateCalendar = Regenerate Calendar
menu-item-validateSite = Validate Site
menu-item-validateTranslations = Validate Translations
@@ -676,6 +677,7 @@ blogmark-failed = Blogmark import failed: { $error }
engine-generatedPage = Generated { $url } ({ $current }/{ $total })
engine-rewrotePage = Rewrote { $url } ({ $current }/{ $total })
engine-renderSiteGroup = Render Site
engine-forceRenderSiteGroup = Force Render Site
engine-applyValidationGroup = Apply Site Validation
engine-renderSiteCore = Render Site Core
engine-renderSinglePosts = Render Single Posts

View File

@@ -67,6 +67,7 @@ embeddings-indexed = El índice semántico está listo para { $count } publicaci
menu-item-metadataDiff = Herramienta diff de metadatos
menu-item-editMenu = Editar menú del blog
menu-item-generateSitemap = Renderizar sitio
menu-item-forceRenderSite = Forzar la regeneración del sitio
menu-item-regenerateCalendar = Regenerar calendario
menu-item-validateSite = Validar sitio
menu-item-validateTranslations = Validar traducciones
@@ -676,6 +677,7 @@ blogmark-failed = Error al importar el blogmark: { $error }
engine-generatedPage = Generado { $url } ({ $current }/{ $total })
engine-rewrotePage = Reescrito { $url } ({ $current }/{ $total })
engine-renderSiteGroup = Renderizar sitio
engine-forceRenderSiteGroup = Forzar la regeneración del sitio
engine-applyValidationGroup = Aplicar validación del sitio
engine-renderSiteCore = Renderizar núcleo del sitio
engine-renderSinglePosts = Renderizar entradas individuales

View File

@@ -67,6 +67,7 @@ embeddings-indexed = Lindex sémantique est prêt pour { $count } articles.
menu-item-metadataDiff = Outil de diff des métadonnées
menu-item-editMenu = Modifier le menu du blog
menu-item-generateSitemap = Rendre le site
menu-item-forceRenderSite = Forcer la régénération du site
menu-item-regenerateCalendar = Régénérer le calendrier
menu-item-validateSite = Valider le site
menu-item-validateTranslations = Valider les traductions
@@ -676,6 +677,7 @@ blogmark-failed = Échec de limport du blogmark : { $error }
engine-generatedPage = Généré { $url } ({ $current }/{ $total })
engine-rewrotePage = Réécrit { $url } ({ $current }/{ $total })
engine-renderSiteGroup = Générer le site
engine-forceRenderSiteGroup = Forcer la régénération du site
engine-applyValidationGroup = Appliquer la validation du site
engine-renderSiteCore = Générer le cœur du site
engine-renderSinglePosts = Générer les articles individuels

View File

@@ -67,6 +67,7 @@ embeddings-indexed = Lindice semantico è pronto per { $count } post.
menu-item-metadataDiff = Strumento diff metadati
menu-item-editMenu = Modifica menu blog
menu-item-generateSitemap = Renderizza sito
menu-item-forceRenderSite = Forza la rigenerazione del sito
menu-item-regenerateCalendar = Rigenera calendario
menu-item-validateSite = Valida sito
menu-item-validateTranslations = Valida traduzioni
@@ -676,6 +677,7 @@ blogmark-failed = Importazione del blogmark non riuscita: { $error }
engine-generatedPage = Generato { $url } ({ $current }/{ $total })
engine-rewrotePage = Riscritto { $url } ({ $current }/{ $total })
engine-renderSiteGroup = Genera sito
engine-forceRenderSiteGroup = Forza la rigenerazione del sito
engine-applyValidationGroup = Applica convalida del sito
engine-renderSiteCore = Genera nucleo del sito
engine-renderSinglePosts = Genera articoli singoli

View File

@@ -17,6 +17,13 @@ surface GenerationControlSurface {
GenerateSiteRequested(generation)
ValidateSiteRequested(project)
ApplyValidationRequested(project_id, sections)
@guarantee FullRenderCommands
-- Blog > Render Site (Ctrl/Cmd+R) performs a change-aware full render.
-- Force Render Site is immediately below it (Ctrl/Cmd+Shift+R) and
-- performs the same five sections plus Pagefind with force=true.
-- The headless CLI `render [--force]` and TUI `force-render` command
-- use the same shared generation behavior.
}
surface GenerationRuntimeSurface {
@@ -65,6 +72,7 @@ entity SiteGeneration {
max_posts_per_page: Integer
pico_theme: String?
sections: Set<GenerationSection>
force: Boolean
-- Output tracking
generated_files: GeneratedFile with project_id = this.project_id
@@ -102,9 +110,23 @@ invariant GenerationPublishedOnly {
}
invariant IncrementalByContentHash {
-- Files are only written when content_hash changes
-- generatedFileHashes table tracks (projectId, relativePath, contentHash)
-- A file with unchanged hash is skipped on regeneration
-- For a normal full render, files are only written when content_hash
-- changes or the output file is missing. generatedFileHashes tracks
-- (projectId, relativePath, contentHash). A matching stored hash and an
-- existing output file skip the write without hashing or reading output.
}
invariant ForcedFullRender {
-- force=true renders the same complete section set and Pagefind index as
-- normal full render, but ignores stored hashes and atomically rewrites
-- every output. Each rewritten output upserts its current hash and time;
-- unrelated cache records are not deleted as a shortcut.
}
rule SelectFullRenderWriteMode {
when: GenerateSiteRequested(generation)
requires: generation.force
ensures: StoredContentHashesIgnored(generation.project_id)
}
invariant MultiLanguageRoutes {