fix: align rendering process with bds2
This commit is contained in:
@@ -22,7 +22,7 @@ The project is under active development. Core blogging workflows are broadly ava
|
||||
- Local MCP automation over stdio or a localhost-only stateless HTTP endpoint, with project resources, read/search/count tools, uniquely identified inert write proposals, clean duplicate-pending rejection, explicit desktop approval, and opt-in Claude Code/Copilot configuration.
|
||||
- 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.
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(), ¯o_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(), ¯o_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();
|
||||
|
||||
|
||||
@@ -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(¬_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>,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user