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. 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. When blog languages are configured, generation produces language-aware route trees, per-language feeds, and alternate language metadata.
### Key takeaways ### Key takeaways
- Full generation produces the complete site. - 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. - Validate and Apply is the efficient daily workflow for incremental publishing.
- Public Base URL must be set before generation. - Public Base URL must be set before generation.
- Commit generated output before deploying for recoverability. - 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. - 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, 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. - 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

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

View File

@@ -29,13 +29,70 @@ pub fn write_generated_file(
project_id: &str, project_id: &str,
relative_path: &str, relative_path: &str,
content: &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>> { ) -> Result<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> {
let hash = content_hash(content.as_bytes()); let hash = content_hash(content.as_bytes());
let target_path = output_dir.join(relative_path); 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 && existing.content_hash == hash
&& target_path.exists() && target_path.exists()
&& file_hash(&target_path)? == hash && (!verify_output || file_hash(&target_path)? == hash)
{ {
return Ok(GeneratedWriteOutcome::SkippedUnchanged); return Ok(GeneratedWriteOutcome::SkippedUnchanged);
} }
@@ -64,13 +121,34 @@ pub fn write_generated_bytes(
project_id: &str, project_id: &str,
relative_path: &str, relative_path: &str,
content: &[u8], 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>> { ) -> Result<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> {
let hash = content_hash(content); let hash = content_hash(content);
let target_path = output_dir.join(relative_path); 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 && existing.content_hash == hash
&& target_path.exists() && target_path.exists()
&& file_hash(&target_path)? == hash
{ {
return Ok(GeneratedWriteOutcome::SkippedUnchanged); return Ok(GeneratedWriteOutcome::SkippedUnchanged);
} }

View File

@@ -8,7 +8,8 @@ mod template_lookup;
pub use generation::{ pub use generation::{
CalendarArchiveData, GeneratedWriteOutcome, build_calendar_json, build_core_generation_paths, 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 markdown::render_markdown_to_html;
pub use page_renderer::{RenderError, render_liquid_template}; 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::{ use bds_core::engine::generation::{
GenerationSection, PublishedPostSource, apply_validation_section_with_progress, GenerationSection, PublishedPostSource, apply_validation_section_with_progress,
apply_validation_sections, build_site_search_index, generate_starter_site, 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::meta::write_category_meta_json;
use bds_core::engine::validate_site::validate_site; 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] #[test]
fn site_validation_matches_bds2_index_route_comparison() { fn site_validation_matches_bds2_index_route_comparison() {
let (db, dir) = setup(); 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::model::{Post, PostStatus, Project};
use bds_core::render::{ use bds_core::render::{
GeneratedWriteOutcome, build_calendar_json, build_core_generation_paths, write_generated_file, GeneratedWriteOutcome, build_calendar_json, build_core_generation_paths, write_generated_file,
write_generated_file_forced,
}; };
use tempfile::TempDir; use tempfile::TempDir;
@@ -95,7 +96,7 @@ fn generated_write_rewrites_missing_file_even_when_hash_matches() {
} }
#[test] #[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 (db, dir) = setup();
let first = write_generated_file(db.conn(), dir.path(), "p1", "index.html", "hello").unwrap(); 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(); let second = write_generated_file(db.conn(), dir.path(), "p1", "index.html", "hello").unwrap();
assert_eq!(first, GeneratedWriteOutcome::Written); 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!( assert_eq!(
fs::read_to_string(dir.path().join("index.html")).unwrap(), fs::read_to_string(dir.path().join("index.html")).unwrap(),
"hello" "hello"

View File

@@ -2843,7 +2843,7 @@ impl TuiApp {
let db = Database::open(&database_path)?; let db = Database::open(&database_path)?;
let metadata = engine::meta::read_project_json(&data_dir)?; let metadata = engine::meta::read_project_json(&data_dir)?;
let posts = published_sources(db.conn(), &data_dir, &project_id)?; 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(), db.conn(),
&data_dir.join("html"), &data_dir.join("html"),
&project_id, &project_id,

View File

@@ -258,6 +258,7 @@ pub enum Message {
), ),
ValidateMedia, ValidateMedia,
GenerateSite, GenerateSite,
ForceGenerateSite,
RunMetadataDiff, RunMetadataDiff,
MetadataDiffLoaded(Result<engine::metadata_diff::DiffReport, String>), MetadataDiffLoaded(Result<engine::metadata_diff::DiffReport, String>),
RepairMetadataDiffItem { RepairMetadataDiffItem {
@@ -428,6 +429,7 @@ pub enum Message {
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SiteGenerationKind { enum SiteGenerationKind {
Full, Full,
Forced,
Validation, Validation,
} }
@@ -2839,6 +2841,7 @@ impl BdsApp {
| Message::TranslationValidationLoaded(_) | Message::TranslationValidationLoaded(_)
| Message::ValidateMedia | Message::ValidateMedia
| Message::GenerateSite | Message::GenerateSite
| Message::ForceGenerateSite
| Message::RunMetadataDiff | Message::RunMetadataDiff
| Message::MetadataDiffLoaded(_) | Message::MetadataDiffLoaded(_)
| Message::RepairMetadataDiffItem { .. } | Message::RepairMetadataDiffItem { .. }
@@ -4444,6 +4447,7 @@ impl BdsApp {
) )
} }
MenuAction::GenerateSitemap => Task::done(Message::GenerateSite), MenuAction::GenerateSitemap => Task::done(Message::GenerateSite),
MenuAction::ForceRenderSite => Task::done(Message::ForceGenerateSite),
MenuAction::ValidateSite => { MenuAction::ValidateSite => {
self.open_singleton_tab(TabType::SiteValidation, "tabBar.siteValidation"); self.open_singleton_tab(TabType::SiteValidation, "tabBar.siteValidation");
self.start_site_validation() self.start_site_validation()
@@ -10137,7 +10141,7 @@ fn remote_error_closes_connection(code: &str) -> bool {
mod tests { mod tests {
use super::{ use super::{
BdsApp, Message, POST_AUTO_SAVE_DELAY_MS, PersistedMediaState, PersistedPostState, 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, flush_embeddings_and_exit, localize_chat_error, month_abbreviation,
persist_media_editor_state_impl, persist_post_editor_preview_state_impl, persist_media_editor_state_impl, persist_post_editor_preview_state_impl,
persist_post_editor_state_impl, remote_error_closes_connection, 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] #[test]
fn search_index_is_queued_only_after_every_render_task_succeeds() { fn search_index_is_queued_only_after_every_render_task_succeeds() {
let (db, project, tmp) = setup(); let (db, project, tmp) = setup();

View File

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

View File

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

View File

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

View File

@@ -128,6 +128,14 @@ mod tests {
&[serde_json::json!("new_post")], &[serde_json::json!("new_post")],
) )
.unwrap(); .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-metadataDiff = Metadaten-Diff-Werkzeug
menu-item-editMenu = Blog-Menü bearbeiten menu-item-editMenu = Blog-Menü bearbeiten
menu-item-generateSitemap = Site rendern menu-item-generateSitemap = Site rendern
menu-item-forceRenderSite = Website vollständig neu generieren
menu-item-regenerateCalendar = Kalender neu erzeugen menu-item-regenerateCalendar = Kalender neu erzeugen
menu-item-validateSite = Website validieren menu-item-validateSite = Website validieren
menu-item-validateTranslations = Übersetzungen validieren menu-item-validateTranslations = Übersetzungen validieren
@@ -676,6 +677,7 @@ blogmark-failed = Blogmark-Import fehlgeschlagen: { $error }
engine-generatedPage = Erstellt { $url } ({ $current }/{ $total }) engine-generatedPage = Erstellt { $url } ({ $current }/{ $total })
engine-rewrotePage = Neu geschrieben { $url } ({ $current }/{ $total }) engine-rewrotePage = Neu geschrieben { $url } ({ $current }/{ $total })
engine-renderSiteGroup = Website rendern engine-renderSiteGroup = Website rendern
engine-forceRenderSiteGroup = Website vollständig neu generieren
engine-applyValidationGroup = Website-Validierung anwenden engine-applyValidationGroup = Website-Validierung anwenden
engine-renderSiteCore = Website-Kern rendern engine-renderSiteCore = Website-Kern rendern
engine-renderSinglePosts = Einzelbeiträge 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-metadataDiff = Metadata Diff Tool
menu-item-editMenu = Edit Blog Menu menu-item-editMenu = Edit Blog Menu
menu-item-generateSitemap = Render Site menu-item-generateSitemap = Render Site
menu-item-forceRenderSite = Force Render Site
menu-item-regenerateCalendar = Regenerate Calendar menu-item-regenerateCalendar = Regenerate Calendar
menu-item-validateSite = Validate Site menu-item-validateSite = Validate Site
menu-item-validateTranslations = Validate Translations menu-item-validateTranslations = Validate Translations
@@ -676,6 +677,7 @@ blogmark-failed = Blogmark import failed: { $error }
engine-generatedPage = Generated { $url } ({ $current }/{ $total }) engine-generatedPage = Generated { $url } ({ $current }/{ $total })
engine-rewrotePage = Rewrote { $url } ({ $current }/{ $total }) engine-rewrotePage = Rewrote { $url } ({ $current }/{ $total })
engine-renderSiteGroup = Render Site engine-renderSiteGroup = Render Site
engine-forceRenderSiteGroup = Force Render Site
engine-applyValidationGroup = Apply Site Validation engine-applyValidationGroup = Apply Site Validation
engine-renderSiteCore = Render Site Core engine-renderSiteCore = Render Site Core
engine-renderSinglePosts = Render Single Posts 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-metadataDiff = Herramienta diff de metadatos
menu-item-editMenu = Editar menú del blog menu-item-editMenu = Editar menú del blog
menu-item-generateSitemap = Renderizar sitio menu-item-generateSitemap = Renderizar sitio
menu-item-forceRenderSite = Forzar la regeneración del sitio
menu-item-regenerateCalendar = Regenerar calendario menu-item-regenerateCalendar = Regenerar calendario
menu-item-validateSite = Validar sitio menu-item-validateSite = Validar sitio
menu-item-validateTranslations = Validar traducciones menu-item-validateTranslations = Validar traducciones
@@ -676,6 +677,7 @@ blogmark-failed = Error al importar el blogmark: { $error }
engine-generatedPage = Generado { $url } ({ $current }/{ $total }) engine-generatedPage = Generado { $url } ({ $current }/{ $total })
engine-rewrotePage = Reescrito { $url } ({ $current }/{ $total }) engine-rewrotePage = Reescrito { $url } ({ $current }/{ $total })
engine-renderSiteGroup = Renderizar sitio engine-renderSiteGroup = Renderizar sitio
engine-forceRenderSiteGroup = Forzar la regeneración del sitio
engine-applyValidationGroup = Aplicar validación del sitio engine-applyValidationGroup = Aplicar validación del sitio
engine-renderSiteCore = Renderizar núcleo del sitio engine-renderSiteCore = Renderizar núcleo del sitio
engine-renderSinglePosts = Renderizar entradas individuales 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-metadataDiff = Outil de diff des métadonnées
menu-item-editMenu = Modifier le menu du blog menu-item-editMenu = Modifier le menu du blog
menu-item-generateSitemap = Rendre le site 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-regenerateCalendar = Régénérer le calendrier
menu-item-validateSite = Valider le site menu-item-validateSite = Valider le site
menu-item-validateTranslations = Valider les traductions 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-generatedPage = Généré { $url } ({ $current }/{ $total })
engine-rewrotePage = Réécrit { $url } ({ $current }/{ $total }) engine-rewrotePage = Réécrit { $url } ({ $current }/{ $total })
engine-renderSiteGroup = Générer le site 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-applyValidationGroup = Appliquer la validation du site
engine-renderSiteCore = Générer le cœur du site engine-renderSiteCore = Générer le cœur du site
engine-renderSinglePosts = Générer les articles individuels 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-metadataDiff = Strumento diff metadati
menu-item-editMenu = Modifica menu blog menu-item-editMenu = Modifica menu blog
menu-item-generateSitemap = Renderizza sito menu-item-generateSitemap = Renderizza sito
menu-item-forceRenderSite = Forza la rigenerazione del sito
menu-item-regenerateCalendar = Rigenera calendario menu-item-regenerateCalendar = Rigenera calendario
menu-item-validateSite = Valida sito menu-item-validateSite = Valida sito
menu-item-validateTranslations = Valida traduzioni menu-item-validateTranslations = Valida traduzioni
@@ -676,6 +677,7 @@ blogmark-failed = Importazione del blogmark non riuscita: { $error }
engine-generatedPage = Generato { $url } ({ $current }/{ $total }) engine-generatedPage = Generato { $url } ({ $current }/{ $total })
engine-rewrotePage = Riscritto { $url } ({ $current }/{ $total }) engine-rewrotePage = Riscritto { $url } ({ $current }/{ $total })
engine-renderSiteGroup = Genera sito engine-renderSiteGroup = Genera sito
engine-forceRenderSiteGroup = Forza la rigenerazione del sito
engine-applyValidationGroup = Applica convalida del sito engine-applyValidationGroup = Applica convalida del sito
engine-renderSiteCore = Genera nucleo del sito engine-renderSiteCore = Genera nucleo del sito
engine-renderSinglePosts = Genera articoli singoli engine-renderSinglePosts = Genera articoli singoli

View File

@@ -17,6 +17,13 @@ surface GenerationControlSurface {
GenerateSiteRequested(generation) GenerateSiteRequested(generation)
ValidateSiteRequested(project) ValidateSiteRequested(project)
ApplyValidationRequested(project_id, sections) 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 { surface GenerationRuntimeSurface {
@@ -65,6 +72,7 @@ entity SiteGeneration {
max_posts_per_page: Integer max_posts_per_page: Integer
pico_theme: String? pico_theme: String?
sections: Set<GenerationSection> sections: Set<GenerationSection>
force: Boolean
-- Output tracking -- Output tracking
generated_files: GeneratedFile with project_id = this.project_id generated_files: GeneratedFile with project_id = this.project_id
@@ -102,9 +110,23 @@ invariant GenerationPublishedOnly {
} }
invariant IncrementalByContentHash { invariant IncrementalByContentHash {
-- Files are only written when content_hash changes -- For a normal full render, files are only written when content_hash
-- generatedFileHashes table tracks (projectId, relativePath, contentHash) -- changes or the output file is missing. generatedFileHashes tracks
-- A file with unchanged hash is skipped on regeneration -- (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 { invariant MultiLanguageRoutes {