fix: align rendering process with bds2

This commit is contained in:
2026-07-24 06:22:45 +02:00
parent 28119c15b7
commit 473c48937d
15 changed files with 1022 additions and 581 deletions

View File

@@ -22,7 +22,7 @@ The project is under active development. Core blogging workflows are broadly ava
- Local MCP automation over stdio or a localhost-only stateless HTTP endpoint, with project resources, read/search/count tools, uniquely identified inert write proposals, clean duplicate-pending rejection, explicit desktop approval, and opt-in Claude Code/Copilot configuration.
- 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), category-controlled list title visibility, descriptive category archive titles, canonical multilingual and flat page routes for every configured blog language, recursive menus, calendar archives, feeds, a root hreflang sitemap, Pagefind, change-aware and forced full renders from the native Blog menu/CLI/TUI, and fast route/mtime-based incremental validation whose targeted repair refreshes affected aggregate pages through cancellable section task groups with bDS2-style per-URL progress.
- bDS2-compatible Markdown/Liquid rendering with built-in macros rendered from bundled Liquid templates in isolated scopes (customizable with `macros/*` partial-template slugs), category-controlled list title visibility, descriptive category archive titles, canonical multilingual and flat page routes for every configured blog language, recursive menus, calendar archives, feeds, a root hreflang sitemap, Pagefind, shared cached multicore full-site rendering, change-aware and forced full renders from the native Blog menu/CLI/TUI, and fast route/mtime-based incremental validation whose targeted repair refreshes affected aggregate pages through cancellable section task groups with bDS2-style per-URL progress.
- 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

@@ -51,6 +51,32 @@ pub fn upsert_generated_file_hash(
})
}
pub fn upsert_generated_file_hashes(
conn: &DbConnection,
hashes: &[GeneratedFileHash],
) -> QueryResult<()> {
conn.with(|c| {
hashes.chunks(200).try_for_each(|chunk| {
diesel::insert_into(generated_file_hashes::table)
.values(chunk)
.on_conflict((
generated_file_hashes::project_id,
generated_file_hashes::relative_path,
))
.do_update()
.set((
generated_file_hashes::content_hash.eq(diesel::upsert::excluded(
generated_file_hashes::content_hash,
)),
generated_file_hashes::updated_at
.eq(diesel::upsert::excluded(generated_file_hashes::updated_at)),
))
.execute(c)
.map(|_| ())
})
})
}
pub fn touch_generated_file_hashes(
conn: &DbConnection,
project_id: &str,
@@ -111,4 +137,36 @@ mod tests {
assert_eq!(stored.content_hash, "def");
assert_eq!(stored.updated_at, 99);
}
#[test]
fn batch_upsert_inserts_and_updates_hashes() {
let db = setup();
let mut hashes = vec![
GeneratedFileHash {
project_id: "p1".into(),
relative_path: "index.html".into(),
content_hash: "one".into(),
updated_at: 1,
},
GeneratedFileHash {
project_id: "p1".into(),
relative_path: "rss.xml".into(),
content_hash: "two".into(),
updated_at: 1,
},
];
upsert_generated_file_hashes(db.conn(), &hashes).unwrap();
hashes[0].content_hash = "changed".into();
hashes[0].updated_at = 2;
upsert_generated_file_hashes(db.conn(), &hashes[..1]).unwrap();
let stored = list_generated_file_hashes(db.conn(), "p1").unwrap();
assert_eq!(stored.len(), 2);
assert_eq!(
get_generated_file_hash(db.conn(), "p1", "index.html")
.unwrap()
.content_hash,
"changed"
);
}
}

View File

@@ -1,7 +1,7 @@
use diesel::prelude::*;
use crate::db::DbConnection;
use crate::db::schema::post_links;
use crate::db::schema::{post_links, posts};
use crate::model::PostLink;
pub fn insert_post_link(conn: &DbConnection, link: &PostLink) -> QueryResult<()> {
@@ -55,6 +55,17 @@ pub fn list_links_by_target(
})
}
pub fn list_links_by_project(conn: &DbConnection, project_id: &str) -> QueryResult<Vec<PostLink>> {
conn.with(|c| {
post_links::table
.inner_join(posts::table.on(posts::id.eq(post_links::source_post_id)))
.filter(posts::project_id.eq(project_id))
.order(post_links::created_at)
.select(PostLink::as_select())
.load(c)
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -100,6 +111,14 @@ mod tests {
assert_eq!(links.len(), 2);
}
#[test]
fn list_by_project() {
let db = setup();
insert_post_link(db.conn(), &make_link("l1", "a", "b")).unwrap();
insert_post_link(db.conn(), &make_link("l2", "b", "c")).unwrap();
assert_eq!(list_links_by_project(db.conn(), "p1").unwrap().len(), 2);
}
#[test]
fn delete_by_source() {
let db = setup();

View File

@@ -56,6 +56,23 @@ pub fn list_post_media_by_media(
})
}
pub fn list_post_media_by_project(
conn: &DbConnection,
project_id: &str,
) -> QueryResult<Vec<PostMedia>> {
conn.with(|c| {
post_media::table
.filter(post_media::project_id.eq(project_id))
.order((
post_media::post_id.asc(),
post_media::sort_order.asc(),
post_media::media_id.asc(),
))
.select(PostMedia::as_select())
.load(c)
})
}
pub fn update_sort_order(
conn: &DbConnection,
post_id: &str,
@@ -123,6 +140,20 @@ mod tests {
assert_eq!(list[0].post_id, "post1");
}
#[test]
fn list_by_project_preserves_post_order() {
let db = setup();
link_media(db.conn(), &make_pm("pm1", "m1", 1)).unwrap();
link_media(db.conn(), &make_pm("pm2", "m2", 0)).unwrap();
let list = list_post_media_by_project(db.conn(), "p1").unwrap();
assert_eq!(
list.iter()
.map(|link| link.media_id.as_str())
.collect::<Vec<_>>(),
["m2", "m1"]
);
}
#[test]
fn unlink_removes_association() {
let db = setup();

View File

@@ -54,6 +54,22 @@ pub fn list_post_translations_by_post(
})
}
pub fn list_post_translations_by_project(
conn: &DbConnection,
project_id: &str,
) -> QueryResult<Vec<PostTranslation>> {
conn.with(|c| {
post_translations::table
.filter(post_translations::project_id.eq(project_id))
.order((
post_translations::translation_for,
post_translations::language,
))
.select(PostTranslation::as_select())
.load(c)
})
}
pub fn update_post_translation(conn: &DbConnection, t: &PostTranslation) -> QueryResult<()> {
if !t.status.is_valid_for_translation() {
return Err(diesel::result::Error::SerializationError(
@@ -151,6 +167,15 @@ mod tests {
assert_eq!(list[1].language, "fr");
}
#[test]
fn list_by_project() {
let db = setup();
insert_post_translation(db.conn(), &make_translation("t1", "de")).unwrap();
insert_post_translation(db.conn(), &make_translation("t2", "fr")).unwrap();
let list = list_post_translations_by_project(db.conn(), "p1").unwrap();
assert_eq!(list.len(), 2);
}
#[test]
fn update_translation() {
let db = setup();

View File

@@ -1,5 +1,6 @@
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::Path;
use std::sync::Arc;
use crate::db::DbConnection as Connection;
use chrono::{DateTime, TimeZone, Utc};
@@ -8,15 +9,13 @@ use pagefind::options::PagefindServiceConfig;
use walkdir::WalkDir;
use crate::db::queries;
use crate::engine::site_assets::write_bundled_site_assets;
use crate::engine::site_assets::bundled_site_assets;
use crate::engine::validate_site::SiteValidationReport;
use crate::engine::{EngineError, EngineResult};
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_bytes_forced,
write_generated_file, write_generated_file_forced, write_generated_file_verified,
GeneratedFileWriter, GeneratedWriteOutcome, build_calendar_json, build_canonical_post_path,
build_site_render_artifacts_from_context, prepare_site_render_context, write_generated_file,
};
#[derive(Debug, Clone)]
@@ -57,6 +56,41 @@ pub struct GenerationReport {
pub deleted_paths: Vec<String>,
}
pub struct PreparedSiteGeneration {
metadata: ProjectMetadata,
sources: Vec<PublishedPostSource>,
render: crate::render::SiteRenderContext,
generated_hashes: Arc<HashMap<String, String>>,
}
pub fn prepare_site_generation(
conn: &Connection,
data_dir: &Path,
project_id: &str,
metadata: &ProjectMetadata,
sources: &[PublishedPostSource],
) -> EngineResult<PreparedSiteGeneration> {
let input_posts = sources
.iter()
.map(|source| (source.post.clone(), source.body_markdown.clone()))
.collect::<Vec<_>>();
let render =
prepare_site_render_context(conn, data_dir, project_id, metadata, &input_posts, false)
.map_err(|error| EngineError::Parse(error.to_string()))?;
let generated_hashes = Arc::new(
queries::generated_file_hash::list_generated_file_hashes(conn, project_id)?
.into_iter()
.map(|hash| (hash.relative_path, hash.content_hash))
.collect(),
);
Ok(PreparedSiteGeneration {
metadata: metadata.clone(),
sources: sources.to_vec(),
render,
generated_hashes,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum GenerationSection {
Core,
@@ -151,14 +185,15 @@ fn generate_starter_site_with_progress_mode(
force: bool,
mut on_page: impl FnMut(usize, usize, &str),
) -> EngineResult<GenerationReport> {
let data_dir = project_data_dir(output_dir);
let prepared = prepare_site_generation(conn, &data_dir, project_id, metadata, posts)?;
let mut report = GenerationReport::default();
for section in GenerationSection::ALL {
report.append(render_site_section_with_progress_mode(
report.append(render_prepared_site_section_with_progress(
conn,
output_dir,
project_id,
metadata,
posts,
&prepared,
section,
force,
&|_| {},
@@ -256,55 +291,78 @@ fn render_site_section_with_progress_mode(
return Err(EngineError::Validation("cancelled".to_string()));
}
let data_dir = project_data_dir(output_dir);
let input_posts = posts
.iter()
.map(|source| (source.post.clone(), source.body_markdown.clone()))
.collect::<Vec<_>>();
let artifacts = build_site_section_render_artifacts(
let prepared = prepare_site_generation(conn, &data_dir, project_id, metadata, posts)?;
render_prepared_site_section_with_progress(
conn,
&data_dir,
output_dir,
project_id,
metadata,
&input_posts,
&prepared,
section,
force,
on_page_rendered,
&mut on_page,
&mut is_cancelled,
)
}
#[expect(
clippy::too_many_arguments,
reason = "prepared section rendering keeps write mode and callbacks"
)]
pub fn render_prepared_site_section_with_progress(
conn: &Connection,
output_dir: &Path,
project_id: &str,
prepared: &PreparedSiteGeneration,
section: GenerationSection,
force: bool,
on_page_rendered: &(dyn Fn(&str) + Sync),
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()));
}
let artifacts = build_site_render_artifacts_from_context(
&prepared.render,
Some(section),
None,
on_page_rendered,
)
.map_err(|error| EngineError::Parse(error.to_string()))?;
let mut report = GenerationReport::default();
let mut writer = GeneratedFileWriter::with_existing(
conn,
output_dir,
project_id,
Arc::clone(&prepared.generated_hashes),
force,
false,
)
.map_err(|error| EngineError::Parse(error.to_string()))?;
let total_pages = artifacts.pages.len();
for (index, page) in artifacts.pages.iter().enumerate() {
if is_cancelled() {
return Err(EngineError::Validation("cancelled".to_string()));
}
write_out(
conn,
output_dir,
project_id,
&page.relative_path,
&page.html,
&mut report,
force,
false,
)?;
write_out(&mut writer, &page.relative_path, &page.html, &mut report)?;
on_page(index + 1, total_pages, &page.url_path);
}
if section == GenerationSection::Core {
write_core_outputs(
conn,
output_dir,
project_id,
metadata,
&data_dir,
posts,
&mut writer,
prepared,
&project_data_dir(output_dir),
&artifacts.route_manifest,
None,
&mut report,
&mut is_cancelled,
force,
false,
)?;
}
writer
.finish()
.map_err(|error| EngineError::Parse(error.to_string()))?;
Ok(report)
}
@@ -399,10 +457,36 @@ pub fn apply_validation_section_with_progress(
return Err(EngineError::Validation("cancelled".to_string()));
}
let data_dir = project_data_dir(output_dir);
let input_posts = posts
.iter()
.map(|source| (source.post.clone(), source.body_markdown.clone()))
.collect::<Vec<_>>();
let prepared = prepare_site_generation(conn, &data_dir, project_id, metadata, posts)?;
apply_validation_prepared_section_with_progress(
conn,
output_dir,
project_id,
&prepared,
validation,
section,
&mut on_page,
&on_page_rendered,
&mut is_cancelled,
)
}
#[expect(
clippy::too_many_arguments,
reason = "prepared targeted apply keeps validation and callbacks"
)]
pub fn apply_validation_prepared_section_with_progress(
conn: &Connection,
output_dir: &Path,
project_id: &str,
prepared: &PreparedSiteGeneration,
validation: &SiteValidationReport,
section: GenerationSection,
mut on_page: impl FnMut(usize, usize, &str),
on_page_rendered: &(dyn Fn(&str) + Sync),
mut is_cancelled: impl FnMut() -> bool,
) -> EngineResult<GenerationReport> {
let metadata = &prepared.metadata;
let requested = validation
.missing_pages
.iter()
@@ -415,64 +499,46 @@ pub fn apply_validation_section_with_progress(
.chain(validation.extra_pages.iter())
.chain(validation.stale_pages.iter())
.any(|path| classify_generated_path(path, metadata).is_none());
let artifacts = if fallback {
build_site_section_render_artifacts(
conn,
&data_dir,
project_id,
metadata,
&input_posts,
section,
&on_page_rendered,
)
} else {
build_targeted_site_section_render_artifacts(
conn,
&data_dir,
project_id,
metadata,
&input_posts,
section,
&requested,
&on_page_rendered,
)
}
let artifacts = build_site_render_artifacts_from_context(
&prepared.render,
Some(section),
(!fallback).then_some(&requested),
on_page_rendered,
)
.map_err(|error| EngineError::Parse(error.to_string()))?;
let mut report = GenerationReport::default();
let mut writer = GeneratedFileWriter::with_existing(
conn,
output_dir,
project_id,
Arc::clone(&prepared.generated_hashes),
false,
true,
)
.map_err(|error| EngineError::Parse(error.to_string()))?;
let total_pages = artifacts.pages.len();
for (index, page) in artifacts.pages.iter().enumerate() {
if is_cancelled() {
return Err(EngineError::Validation("cancelled".to_string()));
}
write_out(
conn,
output_dir,
project_id,
&page.relative_path,
&page.html,
&mut report,
false,
true,
)?;
write_out(&mut writer, &page.relative_path, &page.html, &mut report)?;
on_page(index + 1, total_pages, &page.url_path);
}
if section == GenerationSection::Core {
write_core_outputs(
conn,
output_dir,
project_id,
metadata,
&data_dir,
posts,
&mut writer,
prepared,
&project_data_dir(output_dir),
&artifacts.route_manifest,
(!fallback).then_some(&requested),
&mut report,
&mut is_cancelled,
false,
true,
)?;
}
writer
.finish()
.map_err(|error| EngineError::Parse(error.to_string()))?;
for path in &validation.extra_pages {
if is_cancelled() {
@@ -510,26 +576,24 @@ fn refresh_route_timestamps(
Ok(())
}
#[expect(
clippy::too_many_arguments,
reason = "generation context is existing domain data"
)]
fn write_core_outputs(
conn: &Connection,
output_dir: &Path,
project_id: &str,
metadata: &ProjectMetadata,
writer: &mut GeneratedFileWriter<'_>,
prepared: &PreparedSiteGeneration,
data_dir: &Path,
published_posts: &[PublishedPostSource],
route_manifest: &[crate::render::SitePage],
requested: Option<&HashSet<String>>,
report: &mut GenerationReport,
is_cancelled: &mut impl FnMut() -> bool,
force: bool,
verify_output: bool,
) -> EngineResult<()> {
let metadata = &prepared.metadata;
let published_posts = &prepared.sources;
if requested.is_none() {
write_bundled_site_assets(conn, output_dir, project_id, report, force)?;
for asset in bundled_site_assets() {
let outcome = writer
.write_bytes(asset.relative_path, asset.bytes)
.map_err(|error| EngineError::Parse(error.to_string()))?;
record_write_outcome(report, asset.relative_path, outcome);
}
}
let mut outputs = vec![(
"calendar.json".to_string(),
@@ -541,8 +605,6 @@ fn write_core_outputs(
)?,
)];
for render_language in render_languages(metadata) {
let localized_posts =
localized_sources(conn, data_dir, published_posts, &render_language, metadata)?;
let is_main = render_language == metadata.main_language.as_deref().unwrap_or("en");
let prefix = if is_main {
String::new()
@@ -550,28 +612,35 @@ fn write_core_outputs(
format!("{render_language}/")
};
let mut feed_posts = if is_main {
published_posts.to_vec()
} else {
localized_posts
published_posts
.iter()
.filter(|source| {
source
.post
.language
.map(|source| &source.post)
.collect::<Vec<_>>()
} else {
prepared
.render
.localized_posts(&render_language)
.filter(|post| {
post.language
.as_deref()
.is_some_and(|language| language.eq_ignore_ascii_case(&render_language))
})
.cloned()
.collect::<Vec<_>>()
};
sort_published_sources(&mut feed_posts);
feed_posts.sort_by(|left, right| {
right
.created_at
.cmp(&left.created_at)
.then_with(|| right.published_at.cmp(&left.published_at))
.then_with(|| left.slug.cmp(&right.slug))
});
outputs.push((
format!("{prefix}rss.xml"),
build_rss_xml(metadata, &feed_posts, &render_language),
build_rss_xml(metadata, feed_posts.iter().copied(), &render_language),
));
outputs.push((
format!("{prefix}atom.xml"),
build_atom_xml(metadata, &feed_posts, &render_language),
build_atom_xml(metadata, feed_posts.iter().copied(), &render_language),
));
if is_main {
let category_settings = load_category_settings(data_dir);
@@ -595,50 +664,36 @@ 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,
force,
verify_output,
)?;
write_out(writer, &path, &content, report)?;
}
}
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,
project_id: &str,
writer: &mut GeneratedFileWriter<'_>,
relative_path: &str,
content: &str,
report: &mut GenerationReport,
force: bool,
verify_output: bool,
) -> EngineResult<()> {
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()))?;
let outcome = writer
.write_str(relative_path, content)
.map_err(|error| EngineError::Parse(error.to_string()))?;
record_write_outcome(report, relative_path, outcome);
Ok(())
}
fn record_write_outcome(
report: &mut GenerationReport,
relative_path: &str,
outcome: GeneratedWriteOutcome,
) {
match outcome {
GeneratedWriteOutcome::Written => report.written_paths.push(relative_path.to_string()),
GeneratedWriteOutcome::SkippedUnchanged => {
report.skipped_paths.push(relative_path.to_string())
}
}
Ok(())
}
pub fn build_site_search_index(
@@ -803,6 +858,8 @@ fn build_site_search_index_with_progress_mode(
});
let total = outputs.len();
let mut report = GenerationReport::default();
let mut writer = GeneratedFileWriter::new(conn, output_dir, project_id, force, false)
.map_err(|error| EngineError::Parse(error.to_string()))?;
let expected = outputs
.iter()
.map(|(relative, _)| relative.clone())
@@ -811,18 +868,15 @@ fn build_site_search_index_with_progress_mode(
if is_cancelled() {
return Err(EngineError::Validation("cancelled".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()),
}
let outcome = writer
.write_bytes(&relative, &contents)
.map_err(|error| EngineError::Parse(error.to_string()))?;
record_write_outcome(&mut report, &relative, outcome);
on_file(index + 1, total, &relative);
}
writer
.finish()
.map_err(|error| EngineError::Parse(error.to_string()))?;
for language in render_languages(metadata) {
let prefix = if language == metadata.main_language.as_deref().unwrap_or("en") {
"pagefind".to_string()
@@ -969,67 +1023,6 @@ fn render_languages(metadata: &ProjectMetadata) -> Vec<String> {
languages
}
fn localized_sources(
conn: &Connection,
data_dir: &Path,
posts: &[PublishedPostSource],
language: &str,
metadata: &ProjectMetadata,
) -> EngineResult<Vec<PublishedPostSource>> {
let main_language = metadata.main_language.as_deref().unwrap_or("en");
let mut localized = Vec::new();
for source in posts {
let translation = queries::post_translation::get_post_translation_by_post_and_language(
conn,
&source.post.id,
language,
)
.ok()
.filter(|translation| {
!translation.file_path.trim().is_empty()
&& data_dir
.join(translation.file_path.trim_start_matches('/'))
.is_file()
});
match select_post_language_variant(
&source.post,
language,
main_language,
translation.is_some(),
) {
Some(PostLanguageVariant::Base) => localized.push(source.clone()),
Some(PostLanguageVariant::Translation) => {
let Some(translation) = translation else {
continue;
};
let raw = std::fs::read_to_string(
data_dir.join(translation.file_path.trim_start_matches('/')),
)
.map_err(EngineError::Io)?;
let (_, body) = crate::util::frontmatter::read_translation_file(&raw)
.map_err(EngineError::Parse)?;
let mut translated_post = source.post.clone();
translated_post.id = translation.id.clone();
translated_post.title = translation.title.clone();
translated_post.excerpt = translation.excerpt.clone();
translated_post.language = Some(translation.language.clone());
translated_post.status = translation.status.clone();
translated_post.file_path = translation.file_path.clone();
translated_post.updated_at = translation.updated_at;
translated_post.published_at =
translation.published_at.or(source.post.published_at);
localized.push(PublishedPostSource {
post: translated_post,
body_markdown: body,
});
}
None => {}
}
}
sort_published_sources(&mut localized);
Ok(localized)
}
fn sort_published_sources(posts: &mut [PublishedPostSource]) {
posts.sort_by(|left, right| {
right
@@ -1093,9 +1086,9 @@ pub(crate) fn refresh_validation_sitemap(
Ok(())
}
pub(crate) fn build_rss_xml(
pub(crate) fn build_rss_xml<'a>(
metadata: &ProjectMetadata,
posts: &[PublishedPostSource],
posts: impl IntoIterator<Item = &'a Post>,
language: &str,
) -> String {
let base_url = metadata
@@ -1109,20 +1102,20 @@ pub(crate) fn build_rss_xml(
escape_xml(language)
);
for source in posts {
let url = post_absolute_url(base_url, metadata, source, language);
for post in posts {
let url = post_absolute_url(base_url, metadata, post, language);
xml.push_str(&format!(
"<item><title>{}</title><link>{url}</link></item>",
escape_xml(&source.post.title)
escape_xml(&post.title)
));
}
xml.push_str("</channel></rss>");
xml
}
pub(crate) fn build_atom_xml(
pub(crate) fn build_atom_xml<'a>(
metadata: &ProjectMetadata,
posts: &[PublishedPostSource],
posts: impl IntoIterator<Item = &'a Post>,
language: &str,
) -> String {
let base_url = metadata
@@ -1136,11 +1129,11 @@ pub(crate) fn build_atom_xml(
escape_xml(language)
);
for source in posts {
let url = post_absolute_url(base_url, metadata, source, language);
for post in posts {
let url = post_absolute_url(base_url, metadata, post, language);
xml.push_str(&format!(
"<entry><title>{}</title><id>{url}</id></entry>",
escape_xml(&source.post.title)
escape_xml(&post.title)
));
}
xml.push_str("</feed>");
@@ -1150,13 +1143,13 @@ pub(crate) fn build_atom_xml(
fn post_absolute_url(
base_url: &str,
metadata: &ProjectMetadata,
source: &PublishedPostSource,
post: &Post,
language: &str,
) -> String {
format!(
"{base_url}{}/",
build_canonical_post_path(
&source.post,
post,
language,
metadata.main_language.as_deref().unwrap_or("en")
)

View File

@@ -243,11 +243,19 @@ fn render_preview_response(
let (content_type, xml) = match kind {
PreviewFeedKind::Rss => (
"application/rss+xml; charset=utf-8",
crate::engine::generation::build_rss_xml(&metadata, &localized_posts, &language),
crate::engine::generation::build_rss_xml(
&metadata,
localized_posts.iter().map(|source| &source.post),
&language,
),
),
PreviewFeedKind::Atom => (
"application/atom+xml; charset=utf-8",
crate::engine::generation::build_atom_xml(&metadata, &localized_posts, &language),
crate::engine::generation::build_atom_xml(
&metadata,
localized_posts.iter().map(|source| &source.post),
&language,
),
),
};
return Ok((StatusCode::OK, [(header::CONTENT_TYPE, content_type)], xml).into_response());

View File

@@ -1,10 +1,7 @@
use std::fs;
use std::path::Path;
use crate::db::DbConnection as Connection;
use crate::engine::{EngineError, EngineResult};
use crate::render::{GeneratedWriteOutcome, write_generated_bytes};
use crate::engine::EngineResult;
#[derive(Debug, Clone, Copy)]
pub(crate) struct BundledSiteAsset {
@@ -238,6 +235,10 @@ pub(crate) fn bundled_site_asset(relative_path: &str) -> Option<&'static [u8]> {
.map(|asset| asset.bytes)
}
pub(crate) fn bundled_site_assets() -> &'static [BundledSiteAsset] {
BUNDLED_SITE_ASSETS
}
pub(crate) fn copy_bundled_site_assets(project_dir: &Path) -> EngineResult<()> {
for asset in BUNDLED_SITE_ASSETS {
let target = project_dir.join(asset.relative_path);
@@ -252,44 +253,6 @@ pub(crate) fn copy_bundled_site_assets(project_dir: &Path) -> EngineResult<()> {
Ok(())
}
pub(crate) fn write_bundled_site_assets(
conn: &Connection,
output_dir: &Path,
project_id: &str,
report: &mut crate::engine::generation::GenerationReport,
force: bool,
) -> EngineResult<()> {
for asset in BUNDLED_SITE_ASSETS {
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())
}
GeneratedWriteOutcome::SkippedUnchanged => {
report.skipped_paths.push(asset.relative_path.to_string())
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::bundled_site_asset;

View File

@@ -14,7 +14,8 @@ use serde::{Deserialize, Serialize};
)]
#[diesel(
table_name = crate::db::schema::generated_file_hashes,
check_for_backend(diesel::sqlite::Sqlite)
check_for_backend(diesel::sqlite::Sqlite),
treat_none_as_default_value = false
)]
pub struct GeneratedFileHash {
pub project_id: String,

View File

@@ -1,6 +1,7 @@
use std::collections::BTreeMap;
use std::collections::{BTreeMap, HashMap};
use std::fs;
use std::path::Path;
use std::sync::Arc;
use crate::db::DbConnection as Connection;
use chrono::{Datelike, Local, TimeZone};
@@ -16,6 +17,117 @@ pub enum GeneratedWriteOutcome {
SkippedUnchanged,
}
pub(crate) struct GeneratedFileWriter<'a> {
conn: &'a Connection,
output_dir: &'a Path,
project_id: &'a str,
existing: Arc<HashMap<String, String>>,
pending: HashMap<String, GeneratedFileHash>,
force: bool,
verify_output: bool,
}
impl<'a> GeneratedFileWriter<'a> {
pub(crate) fn new(
conn: &'a Connection,
output_dir: &'a Path,
project_id: &'a str,
force: bool,
verify_output: bool,
) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
let existing = Arc::new(
qhash::list_generated_file_hashes(conn, project_id)?
.into_iter()
.map(|hash| (hash.relative_path, hash.content_hash))
.collect(),
);
Self::with_existing(conn, output_dir, project_id, existing, force, verify_output)
}
pub(crate) fn with_existing(
conn: &'a Connection,
output_dir: &'a Path,
project_id: &'a str,
existing: Arc<HashMap<String, String>>,
force: bool,
verify_output: bool,
) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
Ok(Self {
conn,
output_dir,
project_id,
existing,
pending: HashMap::new(),
force,
verify_output,
})
}
pub(crate) fn write_str(
&mut self,
relative_path: &str,
content: &str,
) -> Result<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> {
self.write(relative_path, content.as_bytes(), |path| {
atomic_write_str(path, content)
})
}
pub(crate) fn write_bytes(
&mut self,
relative_path: &str,
content: &[u8],
) -> Result<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> {
self.write(relative_path, content, |path| {
crate::util::atomic_write(path, content)
})
}
fn write(
&mut self,
relative_path: &str,
content: &[u8],
write: impl FnOnce(&Path) -> std::io::Result<()>,
) -> Result<GeneratedWriteOutcome, Box<dyn std::error::Error + Send + Sync>> {
let hash = content_hash(content);
let target_path = self.output_dir.join(relative_path);
let existing_hash = self
.pending
.get(relative_path)
.map(|pending| &pending.content_hash)
.or_else(|| self.existing.get(relative_path));
if !self.force
&& existing_hash == Some(&hash)
&& target_path.exists()
&& (!self.verify_output || file_hash(&target_path)? == hash)
{
return Ok(GeneratedWriteOutcome::SkippedUnchanged);
}
if let Some(parent) = target_path.parent() {
fs::create_dir_all(parent)?;
}
write(&target_path)?;
self.pending.insert(
relative_path.to_string(),
GeneratedFileHash {
project_id: self.project_id.to_string(),
relative_path: relative_path.to_string(),
content_hash: hash,
updated_at: now_unix_ms(),
},
);
Ok(GeneratedWriteOutcome::Written)
}
pub(crate) fn finish(self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
qhash::upsert_generated_file_hashes(
self.conn,
&self.pending.into_values().collect::<Vec<_>>(),
)?;
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct CalendarArchiveData {
pub years: BTreeMap<String, usize>,

View File

@@ -6,14 +6,15 @@ mod routes;
mod site;
mod template_lookup;
pub(crate) use generation::GeneratedFileWriter;
pub use generation::{
CalendarArchiveData, GeneratedWriteOutcome, build_calendar_json, build_core_generation_paths,
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(crate) use page_renderer::validate_liquid_template_syntax;
pub use page_renderer::{RenderError, render_liquid_template};
pub(crate) use page_renderer::{render_liquid_template_with_host, validate_liquid_template_syntax};
pub(crate) use routes::{PostLanguageVariant, blog_page_title, select_post_language_variant};
pub use routes::{
RenderedPage, build_canonical_post_path, render_starter_list_page,
@@ -21,9 +22,11 @@ pub use routes::{
render_starter_single_post_page_with_media_map,
};
pub use site::{
PagefindDocument, PreviewRenderResult, SitePage, SiteRenderArtifacts, build_preview_response,
build_site_render_artifacts, build_site_route_manifest, build_site_section_render_artifacts,
PagefindDocument, PreviewRenderResult, SitePage, SiteRenderArtifacts, SiteRenderContext,
build_preview_response, build_site_render_artifacts, build_site_render_artifacts_from_context,
build_site_route_manifest, build_site_section_render_artifacts,
build_targeted_site_section_render_artifacts, estimate_site_render_pages,
prepare_site_render_context,
};
pub use template_lookup::{
RenderCategorySettings, RenderTemplateLookup, TemplateLookupError, resolve_post_template,

View File

@@ -1,6 +1,6 @@
use std::collections::HashMap;
use std::fmt;
use std::sync::Arc;
use std::sync::{Arc, Mutex, OnceLock};
use liquid::ParserBuilder;
use liquid::partials::{EagerCompiler, InMemorySource};
@@ -18,7 +18,7 @@ use crate::i18n::translate_render;
use crate::render::macros::{MacroRenderContext, expand_builtin_macros};
use crate::render::render_markdown_to_html;
use crate::scripting::{HostApi, UnavailableHost};
use crate::util::slugify;
use crate::util::{content_hash, slugify};
#[derive(Debug, Error)]
pub enum RenderError {
@@ -26,6 +26,44 @@ pub enum RenderError {
Liquid(#[from] liquid::Error),
}
#[derive(Clone)]
pub(crate) struct CompiledLiquidTemplate(Arc<liquid::Template>);
#[derive(Clone)]
pub(crate) struct LiquidRenderer {
parser: liquid::Parser,
}
impl LiquidRenderer {
pub(crate) fn new(
partials: &HashMap<String, String>,
host: Arc<dyn HostApi>,
) -> Result<Self, RenderError> {
let mut compiled_partials: EagerCompiler<InMemorySource> = EagerCompiler::empty();
for (name, content) in partials {
compiled_partials.add(format!("{name}.liquid"), content.clone());
}
Ok(Self {
parser: liquid_parser_builder(host, Default::default())
.partials(compiled_partials)
.build()?,
})
}
pub(crate) fn compile(&self, source: &str) -> Result<CompiledLiquidTemplate, RenderError> {
Ok(CompiledLiquidTemplate(Arc::new(self.parser.parse(source)?)))
}
pub(crate) fn render<T: Serialize>(
&self,
template: &CompiledLiquidTemplate,
context: &T,
) -> Result<String, RenderError> {
let globals = liquid::to_object(context)?;
Ok(template.0.render(&globals)?)
}
}
#[derive(Debug, Clone, Default)]
struct HtmlRewriteContext {
canonical_post_path_by_slug: HashMap<String, String>,
@@ -51,21 +89,13 @@ pub(crate) fn render_liquid_template_with_host<T: Serialize>(
context: &T,
host: Arc<dyn HostApi>,
) -> Result<String, RenderError> {
let mut compiled_partials: EagerCompiler<InMemorySource> = EagerCompiler::empty();
for (name, content) in partials {
compiled_partials.add(format!("{name}.liquid"), content.clone());
}
let parser = liquid_parser_builder(host)
.partials(compiled_partials)
.build()?;
let template = parser.parse(template_source)?;
let globals = liquid::to_object(context)?;
Ok(template.render(&globals)?)
let renderer = LiquidRenderer::new(partials, host)?;
let template = renderer.compile(template_source)?;
renderer.render(&template, context)
}
pub(crate) fn validate_liquid_template_syntax(template_source: &str) -> Result<(), String> {
let parser = liquid_parser_builder(Arc::new(UnavailableHost))
let parser = liquid_parser_builder(Arc::new(UnavailableHost), Default::default())
.build()
.map_err(|error| error.to_string())?;
parser
@@ -74,10 +104,15 @@ pub(crate) fn validate_liquid_template_syntax(template_source: &str) -> Result<(
.map_err(|error| error.to_string())
}
fn liquid_parser_builder(host: Arc<dyn HostApi>) -> ParserBuilder {
type MarkdownCache = Arc<Mutex<HashMap<MarkdownCacheKey, Arc<OnceLock<String>>>>>;
fn liquid_parser_builder(host: Arc<dyn HostApi>, markdown_cache: MarkdownCache) -> ParserBuilder {
ParserBuilder::with_stdlib()
.filter(I18n)
.filter(Markdown { host })
.filter(Markdown {
host,
cache: markdown_cache,
})
.filter(Slugify)
}
@@ -162,6 +197,7 @@ struct MarkdownArgs {
)]
struct Markdown {
host: Arc<dyn HostApi>,
cache: MarkdownCache,
}
impl ParseFilter for Markdown {
@@ -169,6 +205,7 @@ impl ParseFilter for Markdown {
Ok(Box::new(MarkdownFilter {
args: MarkdownArgs::from_args(args)?,
host: Arc::clone(&self.host),
cache: Arc::clone(&self.cache),
}))
}
@@ -182,6 +219,7 @@ impl ParseFilter for Markdown {
struct MarkdownFilter {
args: MarkdownArgs,
host: Arc<dyn HostApi>,
cache: MarkdownCache,
}
impl fmt::Debug for MarkdownFilter {
@@ -208,31 +246,54 @@ impl Filter for MarkdownFilter {
.map(value_to_string_map)
.unwrap_or_default(),
};
let post_id = args
.post_id
.as_ref()
.and_then(|value| value.as_scalar().map(|scalar| scalar.to_kstr().to_string()))
.or_else(|| {
runtime
.try_get(&[ScalarCow::new("post"), ScalarCow::new("id")])
.and_then(|value| value.as_scalar().map(|scalar| scalar.to_kstr().to_string()))
});
let language = args
.language
.as_ref()
.and_then(|value| value.as_scalar())
.map(|scalar| scalar.to_kstr().to_string())
.unwrap_or_default();
let macro_context = MacroRenderContext {
roots: collect_macro_roots(runtime),
post_id: args
.post_id
.as_ref()
.and_then(|value| value.as_scalar().map(|scalar| scalar.to_kstr().to_string()))
.or_else(|| {
runtime
.try_get(&[ScalarCow::new("post"), ScalarCow::new("id")])
.and_then(|value| {
value.as_scalar().map(|scalar| scalar.to_kstr().to_string())
})
}),
post_id: post_id.clone(),
host: Arc::clone(&self.host),
};
let expanded = expand_builtin_macros(markdown.as_str(), &macro_context);
let rendered = render_markdown_to_html(&expanded);
Ok(Value::scalar(rewrite_rendered_html_urls(
&rendered,
&rewrite_context,
)))
let key = MarkdownCacheKey {
content_hash: content_hash(markdown.as_bytes()),
language,
post_id,
};
let entry = {
let mut cache = self.cache.lock().unwrap_or_else(|error| error.into_inner());
Arc::clone(cache.entry(key).or_default())
};
Ok(Value::scalar(
entry
.get_or_init(|| {
let expanded = expand_builtin_macros(markdown.as_str(), &macro_context);
let rendered = render_markdown_to_html(&expanded);
rewrite_rendered_html_urls(&rendered, &rewrite_context)
})
.clone(),
))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct MarkdownCacheKey {
content_hash: String,
language: String,
post_id: Option<String>,
}
fn collect_macro_roots(runtime: &dyn Runtime) -> JsonMap<String, JsonValue> {
let mut roots = JsonMap::new();

View File

@@ -9,17 +9,17 @@ use chrono::{Datelike, Local, TimeZone, Utc};
use rayon::prelude::*;
use serde_json::{Value, json};
use super::page_renderer::{CompiledLiquidTemplate, LiquidRenderer};
use crate::db::queries;
use crate::engine::generation::{GenerationSection, classify_generated_path};
use crate::engine::menu::{self, MenuItemKind};
use crate::model::{
CategorySettings, Media, Post, PostStatus, ProjectMetadata, ScriptKind, Tag, Template,
TemplateKind, TemplateStatus,
CategorySettings, Media, Post, PostLink, PostMedia, PostStatus, PostTranslation,
ProjectMetadata, ScriptKind, Tag, Template, TemplateKind, TemplateStatus,
};
use crate::render::{
PostLanguageVariant, RenderCategorySettings, RenderTemplateLookup, blog_page_title,
build_canonical_post_path, render_liquid_template_with_host, resolve_post_template,
select_post_language_variant,
build_canonical_post_path, resolve_post_template, select_post_language_variant,
};
use crate::scripting::{CoreHost, HostApi, UnavailableHost};
use crate::util::frontmatter::{read_script_file, read_template_file, read_translation_file};
@@ -59,7 +59,6 @@ pub struct PagefindDocument {
#[derive(Debug, Clone, Default)]
pub struct SiteRenderArtifacts {
pub pages: Vec<SitePage>,
pub pagefind_documents: Vec<PagefindDocument>,
pub route_manifest: Vec<SitePage>,
}
@@ -72,14 +71,13 @@ pub struct PreviewRenderResult {
#[derive(Clone)]
struct TemplateBundle {
post_templates: Vec<Template>,
template_source_by_slug: HashMap<String, String>,
list_template_sources: HashMap<String, String>,
default_list_template: String,
not_found_template: String,
partials: HashMap<String, String>,
post_template_by_slug: HashMap<String, CompiledLiquidTemplate>,
list_template_by_slug: HashMap<String, CompiledLiquidTemplate>,
default_list_template: CompiledLiquidTemplate,
not_found_template: CompiledLiquidTemplate,
macro_templates: HashMap<String, String>,
macro_scripts: HashMap<String, Value>,
host: Arc<dyn HostApi>,
renderer: LiquidRenderer,
}
#[derive(Debug, Clone)]
@@ -103,6 +101,59 @@ struct RouteSpec {
items_per_page: usize,
}
struct TaxonomyContext {
categories: Vec<Value>,
tags: Vec<Value>,
tag_colors: HashMap<String, String>,
category_counts: HashMap<String, usize>,
tag_counts: HashMap<String, usize>,
}
#[derive(Default)]
struct LinkContext {
outgoing_by_post: HashMap<String, Vec<Value>>,
incoming_by_post: HashMap<String, Vec<Value>>,
backlinks_by_post: HashMap<String, Vec<Value>>,
}
pub struct SiteRenderContext {
metadata: ProjectMetadata,
is_preview: bool,
main_language: String,
tags: Vec<Tag>,
category_settings: HashMap<String, CategorySettings>,
canonical_media_map: HashMap<String, String>,
project_media: Vec<Value>,
project_tags: Vec<Value>,
render_categories: HashMap<String, RenderCategorySettings>,
bundle: TemplateBundle,
languages: Vec<LanguageRenderContext>,
}
impl SiteRenderContext {
pub(crate) fn localized_posts<'a>(
&'a self,
language: &'a str,
) -> impl Iterator<Item = &'a Post> {
self.languages
.iter()
.filter(move |context| context.language == language)
.flat_map(|context| context.posts.iter().map(|record| &record.post))
}
}
struct LanguageRenderContext {
language: String,
posts: Vec<RenderPostRecord>,
routes: Vec<RouteSpec>,
linked_media_by_post_id: HashMap<String, Vec<Value>>,
post_data_json_by_id: HashMap<String, Value>,
menu_items: Vec<Value>,
canonical_post_path_by_slug: HashMap<String, String>,
taxonomy: TaxonomyContext,
links: LinkContext,
}
pub fn build_site_render_artifacts(
conn: &Connection,
data_dir: &Path,
@@ -204,6 +255,10 @@ pub fn build_site_section_render_artifacts(
)
}
#[expect(
clippy::too_many_arguments,
reason = "targeted rendering adds a path filter to the section context"
)]
pub fn build_targeted_site_section_render_artifacts(
conn: &Connection,
data_dir: &Path,
@@ -260,10 +315,47 @@ fn build_site_render_artifacts_with_mode(
requested_paths: Option<&HashSet<String>>,
on_page_rendered: &(dyn Fn(&str) + Sync),
) -> Result<SiteRenderArtifacts, Box<dyn Error + Send + Sync>> {
let context = prepare_site_render_context(
conn,
data_dir,
project_id,
metadata,
published_posts,
is_preview,
)?;
build_site_render_artifacts_from_context(&context, section, requested_paths, on_page_rendered)
}
pub fn prepare_site_render_context(
conn: &Connection,
data_dir: &Path,
project_id: &str,
metadata: &ProjectMetadata,
published_posts: &[(Post, String)],
is_preview: bool,
) -> Result<SiteRenderContext, Box<dyn Error + Send + Sync>> {
let bundle = load_template_bundle(conn, data_dir, project_id)?;
let main_language = main_language(metadata).to_string();
let languages = render_languages(metadata);
let tags = queries::tag::list_tags_by_project(conn, project_id).unwrap_or_default();
let translations =
queries::post_translation::list_post_translations_by_project(conn, project_id)
.unwrap_or_default()
.into_iter()
.map(|translation| {
(
(
translation.translation_for.clone(),
translation.language.clone(),
),
translation,
)
})
.collect::<HashMap<_, _>>();
let post_media =
queries::post_media::list_post_media_by_project(conn, project_id).unwrap_or_default();
let post_links =
queries::post_link::list_links_by_project(conn, project_id).unwrap_or_default();
let category_settings = queries_category_settings(data_dir)?;
let media_items = queries::media::list_media_by_project(conn, project_id).unwrap_or_default();
let media_by_id = media_items
@@ -273,34 +365,84 @@ fn build_site_render_artifacts_with_mode(
.collect::<HashMap<_, _>>();
let project_media = media_items.iter().map(media_context).collect::<Vec<_>>();
let canonical_media_map = canonical_media_paths(&media_items);
let linked_media_by_source_post = build_linked_media_by_source_post(&post_media, &media_by_id);
let project_tags = build_published_tag_counts(published_posts, &tags);
let render_categories = category_settings
.iter()
.map(|(name, settings)| {
(
name.clone(),
RenderCategorySettings {
post_template_slug: settings.post_template_slug.clone(),
},
)
})
.collect::<HashMap<_, _>>();
let mut artifacts = SiteRenderArtifacts::default();
let mut language_contexts = Vec::with_capacity(languages.len());
for language in languages {
let localized_posts = load_language_posts(
conn,
let posts = load_language_posts(
data_dir,
published_posts,
&translations,
&language,
&main_language,
is_preview,
)?;
let localized_list_posts = filter_posts_for_lists(&localized_posts, &category_settings);
let routes = build_language_routes(
&localized_list_posts,
metadata,
&language,
&tags,
&category_settings,
);
let list_posts = filter_posts_for_lists(&posts, &category_settings);
let routes =
build_language_routes(&list_posts, metadata, &language, &tags, &category_settings);
let linked_media_by_post_id =
build_linked_media_by_post_id(&posts, &linked_media_by_source_post);
let post_data_json_by_id = build_post_data_json_by_id(&posts, &linked_media_by_post_id);
let menu_items = build_menu_items(data_dir, &language, &main_language)?;
let canonical_post_path_by_slug =
canonical_post_path_by_slug(&posts, &language, &main_language);
let taxonomy = build_taxonomy_context(&posts, &tags);
let links = build_link_context(&posts, &post_links, &language, &main_language);
language_contexts.push(LanguageRenderContext {
language,
posts,
routes,
linked_media_by_post_id,
post_data_json_by_id,
menu_items,
canonical_post_path_by_slug,
taxonomy,
links,
});
}
Ok(SiteRenderContext {
metadata: metadata.clone(),
is_preview,
main_language,
tags,
category_settings,
canonical_media_map,
project_media,
project_tags,
render_categories,
bundle,
languages: language_contexts,
})
}
pub fn build_site_render_artifacts_from_context(
context: &SiteRenderContext,
section: Option<GenerationSection>,
requested_paths: Option<&HashSet<String>>,
on_page_rendered: &(dyn Fn(&str) + Sync),
) -> Result<SiteRenderArtifacts, Box<dyn Error + Send + Sync>> {
let metadata = &context.metadata;
let main_language = &context.main_language;
let mut artifacts = SiteRenderArtifacts::default();
for language_context in &context.languages {
let language = &language_context.language;
let localized_posts = &language_context.posts;
let routes = &language_context.routes;
let expanded_requested_paths = requested_paths.map(|requested| {
expand_requested_aggregate_paths(
requested,
&localized_posts,
&routes,
&language,
metadata,
)
expand_requested_aggregate_paths(requested, localized_posts, routes, language, metadata)
});
let requested_paths = expanded_requested_paths.as_ref();
artifacts
@@ -311,11 +453,6 @@ fn build_site_render_artifacts_with_mode(
url_path: route.url_path.clone(),
html: String::new(),
}));
let linked_media_by_post_id =
build_linked_media_by_post_id(conn, &localized_posts, &media_by_id);
let post_data_json_by_id =
build_post_data_json_by_id(&localized_posts, &linked_media_by_post_id);
let menu_items = build_menu_items(data_dir, &language, &main_language)?;
let rendered_list_pages = routes
.par_iter()
.filter(|route| {
@@ -328,17 +465,17 @@ fn build_site_render_artifacts_with_mode(
render_list_route(
route,
metadata,
&language,
&localized_list_posts,
&tags,
&category_settings,
&menu_items,
&post_data_json_by_id,
&canonical_media_map,
&project_media,
&project_tags,
&bundle,
is_preview,
language,
&context.category_settings,
&language_context.menu_items,
&language_context.canonical_post_path_by_slug,
&language_context.taxonomy,
&language_context.post_data_json_by_id,
&context.canonical_media_map,
&context.project_media,
&context.project_tags,
&context.bundle,
context.is_preview,
)
.map(|html| {
on_page_rendered(&route.url_path);
@@ -352,15 +489,7 @@ fn build_site_render_artifacts_with_mode(
})
.collect::<Result<Vec<_>, _>>()?;
for page in rendered_list_pages {
artifacts.pagefind_documents.push(PagefindDocument {
language: page.language.clone(),
relative_path: page.relative_path.clone(),
url_path: page.url_path.clone(),
html: page.html.clone(),
});
artifacts.pages.push(page);
}
artifacts.pages.extend(rendered_list_pages);
if section.is_none_or(|section| section == GenerationSection::Core) {
let relative_path = if language == main_language {
@@ -370,8 +499,13 @@ fn build_site_render_artifacts_with_mode(
};
if requested_paths.is_none_or(|requested| requested.contains(&relative_path)) {
let url_path = format!("/{}", relative_path.trim_end_matches(".html"));
let html =
render_not_found_route(&bundle, metadata, &language, &url_path, &menu_items)?;
let html = render_not_found_route(
&context.bundle,
metadata,
language,
&url_path,
&language_context.menu_items,
)?;
on_page_rendered(&url_path);
artifacts.pages.push(SitePage {
language: language.clone(),
@@ -382,10 +516,9 @@ fn build_site_render_artifacts_with_mode(
}
}
let canonical_map =
canonical_post_path_by_slug(&localized_posts, &language, &main_language);
for record in &localized_posts {
let canonical_path = build_canonical_post_path(&record.post, &language, &main_language);
let mut single_routes = Vec::new();
for record in localized_posts {
let canonical_path = build_canonical_post_path(&record.post, language, main_language);
let mut post_paths = vec![(canonical_path, GenerationSection::Single)];
if record
.post
@@ -410,45 +543,47 @@ fn build_site_render_artifacts_with_mode(
url_path: url_path.clone(),
html: String::new(),
});
if section.is_some_and(|section| section != route_section)
|| requested_paths.is_some_and(|requested| !requested.contains(&relative_path))
if section.is_none_or(|section| section == route_section)
&& requested_paths.is_none_or(|requested| requested.contains(&relative_path))
{
continue;
single_routes.push((record, relative_path, url_path));
}
let html = render_post_route(
conn,
metadata,
&language,
&main_language,
record,
&localized_posts,
&tags,
&category_settings,
&linked_media_by_post_id,
&canonical_map,
&menu_items,
&post_data_json_by_id,
&canonical_media_map,
&project_media,
&project_tags,
&bundle,
is_preview,
)?;
on_page_rendered(&url_path);
artifacts.pagefind_documents.push(PagefindDocument {
language: language.clone(),
relative_path: relative_path.clone(),
url_path: url_path.clone(),
html: html.clone(),
});
artifacts.pages.push(SitePage {
language: language.clone(),
relative_path,
url_path,
html,
});
}
}
let rendered_single_pages = single_routes
.par_iter()
.map(|(record, relative_path, url_path)| {
render_post_route(
metadata,
language,
main_language,
record,
&context.tags,
&context.render_categories,
&language_context.linked_media_by_post_id,
&language_context.links,
&language_context.canonical_post_path_by_slug,
&language_context.menu_items,
&language_context.taxonomy,
&language_context.post_data_json_by_id,
&context.canonical_media_map,
&context.project_media,
&context.project_tags,
&context.bundle,
context.is_preview,
)
.map(|html| {
on_page_rendered(url_path);
SitePage {
language: language.clone(),
relative_path: relative_path.clone(),
url_path: url_path.clone(),
html,
}
})
})
.collect::<Result<Vec<_>, _>>()?;
artifacts.pages.extend(rendered_single_pages);
}
Ok(artifacts)
@@ -611,16 +746,27 @@ fn load_template_bundle(
.insert("post".to_string(), STARTER_SINGLE_POST_TEMPLATE.to_string());
}
let renderer = LiquidRenderer::new(&partials, host)?;
let post_template_by_slug = template_source_by_slug
.into_iter()
.map(|(slug, source)| Ok((slug, renderer.compile(&source)?)))
.collect::<Result<HashMap<_, _>, crate::render::RenderError>>()?;
let list_template_by_slug = list_template_sources
.into_iter()
.map(|(slug, source)| Ok((slug, renderer.compile(&source)?)))
.collect::<Result<HashMap<_, _>, crate::render::RenderError>>()?;
let default_list_template = renderer.compile(&default_list_template)?;
let not_found_template = renderer.compile(&not_found_template)?;
Ok(TemplateBundle {
post_templates,
template_source_by_slug,
list_template_sources,
post_template_by_slug,
list_template_by_slug,
default_list_template,
not_found_template,
partials,
macro_templates,
macro_scripts,
host,
renderer,
})
}
@@ -640,26 +786,27 @@ fn load_template_source(
}
fn load_language_posts(
conn: &Connection,
data_dir: &Path,
published_posts: &[(Post, String)],
translations: &HashMap<(String, String), PostTranslation>,
language: &str,
main_language: &str,
is_preview: bool,
) -> Result<Vec<RenderPostRecord>, Box<dyn Error + Send + Sync>> {
let mut posts = Vec::new();
for (post, body) in published_posts {
let translation = queries::post_translation::get_post_translation_by_post_and_language(
conn, &post.id, language,
)
.ok()
.filter(|translation| {
(is_preview && translation.status == PostStatus::Draft && translation.content.is_some())
|| (!translation.file_path.trim().is_empty()
&& data_dir
.join(translation.file_path.trim_start_matches('/'))
.is_file())
});
let translation = translations
.get(&(post.id.clone(), language.to_string()))
.cloned()
.filter(|translation| {
(is_preview
&& translation.status == PostStatus::Draft
&& translation.content.is_some())
|| (!translation.file_path.trim().is_empty()
&& data_dir
.join(translation.file_path.trim_start_matches('/'))
.is_file())
});
match select_post_language_variant(post, language, main_language, translation.is_some()) {
Some(PostLanguageVariant::Base) => posts.push(RenderPostRecord {
post: post.clone(),
@@ -993,10 +1140,10 @@ fn render_list_route(
route: &RouteSpec,
metadata: &ProjectMetadata,
language: &str,
posts: &[RenderPostRecord],
tags: &[Tag],
category_settings: &HashMap<String, CategorySettings>,
menu_items: &[Value],
canonical_post_path_by_slug: &HashMap<String, String>,
taxonomy: &TaxonomyContext,
post_data_json_by_id: &HashMap<String, Value>,
canonical_media_path_by_source_path: &HashMap<String, String>,
project_media: &[Value],
@@ -1005,12 +1152,10 @@ fn render_list_route(
is_preview: bool,
) -> Result<String, Box<dyn Error + Send + Sync>> {
let main_language = main_language(metadata);
let canonical_map = canonical_post_path_by_slug(posts, language, main_language);
let taxonomy_counts = build_taxonomy_counts(posts, tags);
let list_template = route
.list_template_slug
.as_deref()
.and_then(|slug| bundle.list_template_sources.get(slug))
.and_then(|slug| bundle.list_template_by_slug.get(slug))
.unwrap_or(&bundle.default_list_template);
let context = json!({
"language": language,
@@ -1043,40 +1188,35 @@ fn render_list_route(
"total_pages": route.total_pages,
"total_items": route.total_items,
"items_per_page": route.items_per_page,
"canonical_post_path_by_slug": canonical_map,
"canonical_post_path_by_slug": canonical_post_path_by_slug,
"canonical_media_path_by_source_path": canonical_media_path_by_source_path,
"post_data_json_by_id": post_data_json_by_id,
"project": { "media": project_media },
"Tags": project_tags,
"post_categories": taxonomy_counts.0,
"post_tags": taxonomy_counts.1,
"tag_color_by_name": taxonomy_counts.2,
"post_categories": taxonomy.categories,
"post_tags": taxonomy.tags,
"tag_color_by_name": taxonomy.tag_colors,
"backlinks": Vec::<Value>::new(),
"not_found_message": serde_json::Value::Null,
"not_found_back_label": serde_json::Value::Null,
});
Ok(render_liquid_template_with_host(
list_template,
&bundle.partials,
&context,
Arc::clone(&bundle.host),
)?)
Ok(bundle.renderer.render(list_template, &context)?)
}
#[allow(clippy::too_many_arguments)]
fn render_post_route(
conn: &Connection,
metadata: &ProjectMetadata,
language: &str,
main_language: &str,
record: &RenderPostRecord,
all_posts: &[RenderPostRecord],
tags: &[Tag],
category_settings: &HashMap<String, CategorySettings>,
render_categories: &HashMap<String, RenderCategorySettings>,
linked_media_by_post_id: &HashMap<String, Vec<Value>>,
links: &LinkContext,
canonical_post_path_by_slug: &HashMap<String, String>,
menu_items: &[Value],
taxonomy: &TaxonomyContext,
post_data_json_by_id: &HashMap<String, Value>,
canonical_media_path_by_source_path: &HashMap<String, String>,
project_media: &[Value],
@@ -1084,56 +1224,39 @@ fn render_post_route(
bundle: &TemplateBundle,
is_preview: bool,
) -> Result<String, Box<dyn Error + Send + Sync>> {
let render_categories = category_settings
.iter()
.map(|(name, settings)| {
(
name.clone(),
RenderCategorySettings {
post_template_slug: settings.post_template_slug.clone(),
},
)
})
.collect::<HashMap<_, _>>();
let resolved = resolve_post_template(RenderTemplateLookup {
post: &record.post,
templates: &bundle.post_templates,
tags,
category_settings: &render_categories,
category_settings: render_categories,
})
.map_err(|error| format!("template lookup failed: {error:?}"))?;
let template_source = bundle
.template_source_by_slug
let template = bundle
.post_template_by_slug
.get(&resolved.slug)
.cloned()
.or_else(|| resolved.content.clone())
.unwrap_or_else(|| STARTER_SINGLE_POST_TEMPLATE.to_string());
.or_else(|| bundle.post_template_by_slug.get("post").cloned())
.ok_or("default post template was not compiled")?;
let linked_media = linked_media_by_post_id
.get(&record.post.id)
.cloned()
.unwrap_or_default();
let outgoing_links =
queries::post_link::list_links_by_source(conn, &record.source_post_id).unwrap_or_default();
let incoming_links =
queries::post_link::list_links_by_target(conn, &record.source_post_id).unwrap_or_default();
let post_by_id = all_posts
.iter()
.map(|item| (item.source_post_id.clone(), item))
.collect::<HashMap<_, _>>();
let outgoing_link_context = outgoing_links
.iter()
.map(|link| link_context(link, &post_by_id, language, main_language))
.collect::<Vec<_>>();
let incoming_link_context = incoming_links
.iter()
.map(|link| link_context(link, &post_by_id, language, main_language))
.collect::<Vec<_>>();
let backlinks = incoming_links
.iter()
.map(|link| backlink_context(link, &post_by_id, language, main_language))
.collect::<Vec<_>>();
let taxonomy_counts = build_taxonomy_counts(all_posts, tags);
let outgoing_link_context = links
.outgoing_by_post
.get(&record.source_post_id)
.cloned()
.unwrap_or_default();
let incoming_link_context = links
.incoming_by_post
.get(&record.source_post_id)
.cloned()
.unwrap_or_default();
let backlinks = links
.backlinks_by_post
.get(&record.source_post_id)
.cloned()
.unwrap_or_default();
let context = json!({
"language": language,
@@ -1151,9 +1274,9 @@ fn render_post_route(
"calendar_initial_year": calendar_initial_parts(&record.post).0,
"calendar_initial_month": calendar_initial_parts(&record.post).1,
"post": post_context(&record.post, &record.body_markdown, linked_media, outgoing_link_context, incoming_link_context),
"post_categories": taxonomy_items_for_categories(&record.post.categories, all_posts),
"post_tags": taxonomy_items_for_tags(&record.post.tags, all_posts, tags),
"tag_color_by_name": taxonomy_counts.2,
"post_categories": taxonomy_items_for_categories(&record.post.categories, taxonomy),
"post_tags": taxonomy_items_for_tags(&record.post.tags, taxonomy, tags),
"tag_color_by_name": taxonomy.tag_colors,
"backlinks": backlinks,
"canonical_post_path_by_slug": canonical_post_path_by_slug,
"canonical_media_path_by_source_path": canonical_media_path_by_source_path,
@@ -1176,12 +1299,7 @@ fn render_post_route(
"not_found_back_label": serde_json::Value::Null,
});
Ok(render_liquid_template_with_host(
&template_source,
&bundle.partials,
&context,
Arc::clone(&bundle.host),
)?)
Ok(bundle.renderer.render(&template, &context)?)
}
fn render_not_found_route(
@@ -1225,12 +1343,9 @@ fn render_not_found_route(
"canonical_media_path_by_source_path": HashMap::<String, String>::new(),
"post_data_json_by_id": HashMap::<String, Value>::new(),
});
Ok(render_liquid_template_with_host(
&bundle.not_found_template,
&bundle.partials,
&context,
Arc::clone(&bundle.host),
)?)
Ok(bundle
.renderer
.render(&bundle.not_found_template, &context)?)
}
fn build_menu_items(
@@ -1442,24 +1557,40 @@ fn canonical_media_paths(media_items: &[Media]) -> HashMap<String, String> {
map
}
fn build_linked_media_by_post_id(
conn: &Connection,
posts: &[RenderPostRecord],
fn build_linked_media_by_source_post(
post_media: &[PostMedia],
media_by_id: &HashMap<String, Media>,
) -> HashMap<String, Vec<Value>> {
let mut result = HashMap::new();
for record in posts {
let media = queries::post_media::list_post_media_by_post(conn, &record.source_post_id)
.unwrap_or_default()
.into_iter()
.filter_map(|link| media_by_id.get(&link.media_id))
.map(media_context)
.collect();
result.insert(record.post.id.clone(), media);
for link in post_media {
if let Some(media) = media_by_id.get(&link.media_id) {
result
.entry(link.post_id.clone())
.or_insert_with(Vec::new)
.push(media_context(media));
}
}
result
}
fn build_linked_media_by_post_id(
posts: &[RenderPostRecord],
linked_media_by_source_post: &HashMap<String, Vec<Value>>,
) -> HashMap<String, Vec<Value>> {
posts
.iter()
.map(|record| {
(
record.post.id.clone(),
linked_media_by_source_post
.get(&record.source_post_id)
.cloned()
.unwrap_or_default(),
)
})
.collect()
}
fn build_post_data_json_by_id(
posts: &[RenderPostRecord],
linked_media_by_post_id: &HashMap<String, Vec<Value>>,
@@ -1535,20 +1666,25 @@ fn build_published_tag_counts(posts: &[(Post, String)], tags: &[Tag]) -> Vec<Val
items
}
fn build_taxonomy_counts(
posts: &[RenderPostRecord],
tags: &[Tag],
) -> (Vec<Value>, Vec<Value>, HashMap<String, String>) {
fn build_taxonomy_context(posts: &[RenderPostRecord], tags: &[Tag]) -> TaxonomyContext {
let mut category_counts: HashMap<String, usize> = HashMap::new();
let mut tag_counts: HashMap<String, usize> = HashMap::new();
let mut category_counts_by_name: HashMap<String, usize> = HashMap::new();
let mut tag_counts_by_name: HashMap<String, usize> = HashMap::new();
let mut tag_colors = HashMap::new();
for record in posts {
for category in &record.post.categories {
*category_counts.entry(category.clone()).or_default() += 1;
*category_counts_by_name
.entry(category.to_lowercase())
.or_default() += 1;
}
for tag_name in &record.post.tags {
*tag_counts.entry(tag_name.clone()).or_default() += 1;
*tag_counts_by_name
.entry(tag_name.to_lowercase())
.or_default() += 1;
}
}
@@ -1585,23 +1721,24 @@ fn build_taxonomy_counts(
})
.collect::<Vec<_>>();
(categories, tag_items, tag_colors)
TaxonomyContext {
categories,
tags: tag_items,
tag_colors,
category_counts: category_counts_by_name,
tag_counts: tag_counts_by_name,
}
}
fn taxonomy_items_for_categories(names: &[String], posts: &[RenderPostRecord]) -> Vec<Value> {
fn taxonomy_items_for_categories(names: &[String], taxonomy: &TaxonomyContext) -> Vec<Value> {
names
.iter()
.map(|name| {
let count = posts
.iter()
.filter(|record| {
record
.post
.categories
.iter()
.any(|category| category.eq_ignore_ascii_case(name))
})
.count();
let count = taxonomy
.category_counts
.get(&name.to_lowercase())
.copied()
.unwrap_or_default();
json!({
"name": name,
"slug": slugify(name),
@@ -1613,22 +1750,17 @@ fn taxonomy_items_for_categories(names: &[String], posts: &[RenderPostRecord]) -
fn taxonomy_items_for_tags(
names: &[String],
posts: &[RenderPostRecord],
taxonomy: &TaxonomyContext,
tags: &[Tag],
) -> Vec<Value> {
names
.iter()
.map(|name| {
let count = posts
.iter()
.filter(|record| {
record
.post
.tags
.iter()
.any(|tag| tag.eq_ignore_ascii_case(name))
})
.count();
let count = taxonomy
.tag_counts
.get(&name.to_lowercase())
.copied()
.unwrap_or_default();
let color = tags
.iter()
.find(|tag| tag.name.eq_ignore_ascii_case(name))
@@ -1689,6 +1821,37 @@ fn media_context(media: &Media) -> Value {
})
}
fn build_link_context(
posts: &[RenderPostRecord],
links: &[PostLink],
language: &str,
main_language: &str,
) -> LinkContext {
let post_by_id = posts
.iter()
.map(|record| (record.source_post_id.clone(), record))
.collect::<HashMap<_, _>>();
let mut context = LinkContext::default();
for link in links {
context
.outgoing_by_post
.entry(link.source_post_id.clone())
.or_default()
.push(link_context(link, &post_by_id, language, main_language));
context
.incoming_by_post
.entry(link.target_post_id.clone())
.or_default()
.push(link_context(link, &post_by_id, language, main_language));
context
.backlinks_by_post
.entry(link.target_post_id.clone())
.or_default()
.push(backlink_context(link, &post_by_id, language, main_language));
}
context
}
fn link_context(
link: &crate::model::PostLink,
post_by_id: &HashMap<String, &RenderPostRecord>,

View File

@@ -149,6 +149,7 @@ impl BdsApp {
);
let mut render_task_ids = Vec::new();
let mut tasks = Vec::new();
let prepared_generation = Arc::new(std::sync::OnceLock::new());
for section in sections {
let label = t(self.ui_locale, generation_section_label_key(section));
@@ -173,6 +174,7 @@ impl BdsApp {
let task_data_dir = data_dir.clone();
let task_group_id = group_id.clone();
let task_validation = validation.clone();
let task_prepared_generation = Arc::clone(&prepared_generation);
let locale = self.ui_locale;
tasks.push(Task::perform(
async move {
@@ -185,6 +187,7 @@ impl BdsApp {
task_id,
section,
task_validation,
task_prepared_generation,
force,
locale,
page_work,
@@ -494,6 +497,9 @@ fn run_site_generation_section(
task_id: TaskId,
section: engine::generation::GenerationSection,
validation: Option<engine::validate_site::SiteValidationReport>,
prepared_generation: Arc<
std::sync::OnceLock<Result<Arc<engine::generation::PreparedSiteGeneration>, String>>,
>,
force: bool,
locale: UiLocale,
expected_pages: usize,
@@ -511,23 +517,33 @@ fn run_site_generation_section(
)),
);
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
let metadata = engine::meta::read_project_json(&data_dir).map_err(|error| error.to_string())?;
let posts = bds_core::db::queries::post::list_posts_by_project(db.conn(), &project_id)
.map_err(|error| error.to_string())?;
let mut sources = Vec::new();
for post in posts
.into_iter()
.filter(engine::generation::has_published_snapshot)
{
if task_manager.is_cancelled(task_id) {
return Err("cancelled".to_string());
}
if let Some(source) = engine::generation::load_published_post_source(&data_dir, post)
.map_err(|error| error.to_string())?
{
sources.push(source);
}
}
let prepared = prepared_generation
.get_or_init(|| {
let metadata =
engine::meta::read_project_json(&data_dir).map_err(|error| error.to_string())?;
let posts = bds_core::db::queries::post::list_posts_by_project(db.conn(), &project_id)
.map_err(|error| error.to_string())?;
let sources = posts
.into_iter()
.filter(engine::generation::has_published_snapshot)
.map(|post| engine::generation::load_published_post_source(&data_dir, post))
.collect::<Result<Vec<_>, _>>()
.map_err(|error| error.to_string())?
.into_iter()
.flatten()
.collect::<Vec<_>>();
engine::generation::prepare_site_generation(
db.conn(),
&data_dir,
&project_id,
&metadata,
&sources,
)
.map(Arc::new)
.map_err(|error| error.to_string())
})
.as_ref()
.map_err(Clone::clone)?;
let output_dir = data_dir.join("html");
std::fs::create_dir_all(&output_dir).map_err(|error| error.to_string())?;
let render_manager = Arc::clone(&task_manager);
@@ -558,38 +574,26 @@ fn run_site_generation_section(
};
let is_cancelled = move || cancel_manager.is_cancelled(task_id);
match validation {
Some(validation) => engine::generation::apply_validation_section_with_progress(
Some(validation) => engine::generation::apply_validation_prepared_section_with_progress(
db.conn(),
&output_dir,
&project_id,
&metadata,
&sources,
prepared,
&validation,
section,
on_page,
on_rendered,
&on_rendered,
is_cancelled,
),
None if force => engine::generation::render_site_section_forced_with_progress(
None => engine::generation::render_prepared_site_section_with_progress(
db.conn(),
&output_dir,
&project_id,
&metadata,
&sources,
prepared,
section,
force,
&on_rendered,
on_page,
on_rendered,
is_cancelled,
),
None => engine::generation::render_site_section_with_progress(
db.conn(),
&output_dir,
&project_id,
&metadata,
&sources,
section,
on_page,
on_rendered,
is_cancelled,
),
}

View File

@@ -112,33 +112,6 @@ fn close_btn_style(_theme: &Theme, status: button::Status) -> button::Style {
}
}
#[cfg(test)]
mod progress_tests {
use super::*;
#[test]
fn group_progress_includes_pending_tasks_as_zero_like_bds2() {
let complete = TaskSnapshot {
id: 1,
label: String::new(),
group_id: None,
group_name: None,
status: String::new(),
progress: Some(1.0),
message: None,
is_cancellable: false,
};
let pending = TaskSnapshot {
id: 2,
progress: None,
is_cancellable: true,
..complete.clone()
};
assert_eq!(group_progress(&[&complete, &pending]), Some(0.5));
}
}
#[expect(
clippy::too_many_arguments,
reason = "arguments are independent panel state slices"
@@ -459,3 +432,30 @@ fn post_link_button(locale: UiLocale, link: &ResolvedPostLink) -> Element<'stati
.style(tab_inactive)
.into()
}
#[cfg(test)]
mod progress_tests {
use super::*;
#[test]
fn group_progress_includes_pending_tasks_as_zero_like_bds2() {
let complete = TaskSnapshot {
id: 1,
label: String::new(),
group_id: None,
group_name: None,
status: String::new(),
progress: Some(1.0),
message: None,
is_cancellable: false,
};
let pending = TaskSnapshot {
id: 2,
progress: None,
is_cancellable: true,
..complete.clone()
};
assert_eq!(group_progress(&[&complete, &pending]), Some(0.5));
}
}