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.
|
- Local MCP automation over stdio or a localhost-only stateless HTTP endpoint, with project resources, read/search/count tools, uniquely identified inert write proposals, clean duplicate-pending rejection, explicit desktop approval, and opt-in Claude Code/Copilot configuration.
|
||||||
- A fully localized Ratatui terminal workspace, available locally through `bds-cli tui`/`BDS_MODE=tui` and remotely through authenticated SSH shell sessions, with shared post/template/script editing and publishing, project/search/command overlays, settings, tags, Git, reports, task progress, live multi-client locale updates, and airplane-mode AI gating.
|
- A fully localized Ratatui terminal workspace, available locally through `bds-cli tui`/`BDS_MODE=tui` and remotely through authenticated SSH shell sessions, with shared post/template/script editing and publishing, project/search/command overlays, settings, tags, Git, reports, task progress, live multi-client locale updates, and airplane-mode AI gating.
|
||||||
- `bds-cli server` hosting the shared application engines over a loopback-by-default, public-key-only SSH service, with restrictive private key material, live authorization updates, terminal-session transport, CLI-change synchronization, ordered domain/task events, and native desktop remote-project selection.
|
- `bds-cli server` hosting the shared application engines over a loopback-by-default, public-key-only SSH service, with restrictive private key material, live authorization updates, terminal-session transport, CLI-change synchronization, ordered domain/task events, and native desktop remote-project selection.
|
||||||
- bDS2-compatible Markdown/Liquid rendering with built-in macros rendered from bundled Liquid templates in isolated scopes (customizable with `macros/*` partial-template slugs), 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.
|
- Navigable generated-route preview in the app or system browser, with draft database overlays and published filesystem content.
|
||||||
- Optional one-shot AI translation, description, analysis, taxonomy, and language-detection operations run in background tasks with editor-level waiting indicators, using provider-portable JSON-only requests through independent online and local OpenAI-compatible profiles. Each profile has secure credentials, persistently discovered chat/title/image model selections, explicit tool/vision overrides, chat testing, and restart-persistent status-bar airplane-mode routing.
|
- Optional one-shot AI translation, description, analysis, taxonomy, and language-detection operations run in background tasks with editor-level waiting indicators, using provider-portable JSON-only requests through independent online and local OpenAI-compatible profiles. Each profile has secure credentials, persistently discovered chat/title/image model selections, explicit tool/vision overrides, chat testing, and restart-persistent status-bar airplane-mode routing.
|
||||||
- Persistent conversational AI with safe Markdown, streamed and cancellable responses, model/session/token tracking, bounded project-aware blog tools, and localized conversation management in the Chat workspace. Allowlisted render tools add persistent native cards, charts, forms, lists, metrics, mind maps, tables, and tabs without executing assistant-provided HTML or JavaScript.
|
- Persistent conversational AI with safe Markdown, streamed and cancellable responses, model/session/token tracking, bounded project-aware blog tools, and localized conversation management in the Chat workspace. Allowlisted render tools add persistent native cards, charts, forms, lists, metrics, mind maps, tables, and tabs without executing assistant-provided HTML or JavaScript.
|
||||||
|
|||||||
@@ -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(
|
pub fn touch_generated_file_hashes(
|
||||||
conn: &DbConnection,
|
conn: &DbConnection,
|
||||||
project_id: &str,
|
project_id: &str,
|
||||||
@@ -111,4 +137,36 @@ mod tests {
|
|||||||
assert_eq!(stored.content_hash, "def");
|
assert_eq!(stored.content_hash, "def");
|
||||||
assert_eq!(stored.updated_at, 99);
|
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 diesel::prelude::*;
|
||||||
|
|
||||||
use crate::db::DbConnection;
|
use crate::db::DbConnection;
|
||||||
use crate::db::schema::post_links;
|
use crate::db::schema::{post_links, posts};
|
||||||
use crate::model::PostLink;
|
use crate::model::PostLink;
|
||||||
|
|
||||||
pub fn insert_post_link(conn: &DbConnection, link: &PostLink) -> QueryResult<()> {
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -100,6 +111,14 @@ mod tests {
|
|||||||
assert_eq!(links.len(), 2);
|
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]
|
#[test]
|
||||||
fn delete_by_source() {
|
fn delete_by_source() {
|
||||||
let db = setup();
|
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(
|
pub fn update_sort_order(
|
||||||
conn: &DbConnection,
|
conn: &DbConnection,
|
||||||
post_id: &str,
|
post_id: &str,
|
||||||
@@ -123,6 +140,20 @@ mod tests {
|
|||||||
assert_eq!(list[0].post_id, "post1");
|
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]
|
#[test]
|
||||||
fn unlink_removes_association() {
|
fn unlink_removes_association() {
|
||||||
let db = setup();
|
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<()> {
|
pub fn update_post_translation(conn: &DbConnection, t: &PostTranslation) -> QueryResult<()> {
|
||||||
if !t.status.is_valid_for_translation() {
|
if !t.status.is_valid_for_translation() {
|
||||||
return Err(diesel::result::Error::SerializationError(
|
return Err(diesel::result::Error::SerializationError(
|
||||||
@@ -151,6 +167,15 @@ mod tests {
|
|||||||
assert_eq!(list[1].language, "fr");
|
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]
|
#[test]
|
||||||
fn update_translation() {
|
fn update_translation() {
|
||||||
let db = setup();
|
let db = setup();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::db::DbConnection as Connection;
|
use crate::db::DbConnection as Connection;
|
||||||
use chrono::{DateTime, TimeZone, Utc};
|
use chrono::{DateTime, TimeZone, Utc};
|
||||||
@@ -8,15 +9,13 @@ use pagefind::options::PagefindServiceConfig;
|
|||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
use crate::db::queries;
|
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::validate_site::SiteValidationReport;
|
||||||
use crate::engine::{EngineError, EngineResult};
|
use crate::engine::{EngineError, EngineResult};
|
||||||
use crate::model::{CategorySettings, Post, ProjectMetadata};
|
use crate::model::{CategorySettings, Post, ProjectMetadata};
|
||||||
use crate::render::{
|
use crate::render::{
|
||||||
GeneratedWriteOutcome, PostLanguageVariant, build_calendar_json, build_canonical_post_path,
|
GeneratedFileWriter, GeneratedWriteOutcome, build_calendar_json, build_canonical_post_path,
|
||||||
build_site_section_render_artifacts, build_targeted_site_section_render_artifacts,
|
build_site_render_artifacts_from_context, prepare_site_render_context, write_generated_file,
|
||||||
select_post_language_variant, write_generated_bytes, write_generated_bytes_forced,
|
|
||||||
write_generated_file, write_generated_file_forced, write_generated_file_verified,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -57,6 +56,41 @@ pub struct GenerationReport {
|
|||||||
pub deleted_paths: Vec<String>,
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub enum GenerationSection {
|
pub enum GenerationSection {
|
||||||
Core,
|
Core,
|
||||||
@@ -151,14 +185,15 @@ fn generate_starter_site_with_progress_mode(
|
|||||||
force: bool,
|
force: bool,
|
||||||
mut on_page: impl FnMut(usize, usize, &str),
|
mut on_page: impl FnMut(usize, usize, &str),
|
||||||
) -> EngineResult<GenerationReport> {
|
) -> 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();
|
let mut report = GenerationReport::default();
|
||||||
for section in GenerationSection::ALL {
|
for section in GenerationSection::ALL {
|
||||||
report.append(render_site_section_with_progress_mode(
|
report.append(render_prepared_site_section_with_progress(
|
||||||
conn,
|
conn,
|
||||||
output_dir,
|
output_dir,
|
||||||
project_id,
|
project_id,
|
||||||
metadata,
|
&prepared,
|
||||||
posts,
|
|
||||||
section,
|
section,
|
||||||
force,
|
force,
|
||||||
&|_| {},
|
&|_| {},
|
||||||
@@ -256,55 +291,78 @@ fn render_site_section_with_progress_mode(
|
|||||||
return Err(EngineError::Validation("cancelled".to_string()));
|
return Err(EngineError::Validation("cancelled".to_string()));
|
||||||
}
|
}
|
||||||
let data_dir = project_data_dir(output_dir);
|
let data_dir = project_data_dir(output_dir);
|
||||||
let input_posts = posts
|
let prepared = prepare_site_generation(conn, &data_dir, project_id, metadata, posts)?;
|
||||||
.iter()
|
render_prepared_site_section_with_progress(
|
||||||
.map(|source| (source.post.clone(), source.body_markdown.clone()))
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let artifacts = build_site_section_render_artifacts(
|
|
||||||
conn,
|
conn,
|
||||||
&data_dir,
|
output_dir,
|
||||||
project_id,
|
project_id,
|
||||||
metadata,
|
&prepared,
|
||||||
&input_posts,
|
|
||||||
section,
|
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,
|
on_page_rendered,
|
||||||
)
|
)
|
||||||
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||||
let mut report = GenerationReport::default();
|
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();
|
let total_pages = artifacts.pages.len();
|
||||||
for (index, page) in artifacts.pages.iter().enumerate() {
|
for (index, page) in artifacts.pages.iter().enumerate() {
|
||||||
if is_cancelled() {
|
if is_cancelled() {
|
||||||
return Err(EngineError::Validation("cancelled".to_string()));
|
return Err(EngineError::Validation("cancelled".to_string()));
|
||||||
}
|
}
|
||||||
write_out(
|
write_out(&mut writer, &page.relative_path, &page.html, &mut report)?;
|
||||||
conn,
|
|
||||||
output_dir,
|
|
||||||
project_id,
|
|
||||||
&page.relative_path,
|
|
||||||
&page.html,
|
|
||||||
&mut report,
|
|
||||||
force,
|
|
||||||
false,
|
|
||||||
)?;
|
|
||||||
on_page(index + 1, total_pages, &page.url_path);
|
on_page(index + 1, total_pages, &page.url_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
if section == GenerationSection::Core {
|
if section == GenerationSection::Core {
|
||||||
write_core_outputs(
|
write_core_outputs(
|
||||||
conn,
|
&mut writer,
|
||||||
output_dir,
|
prepared,
|
||||||
project_id,
|
&project_data_dir(output_dir),
|
||||||
metadata,
|
|
||||||
&data_dir,
|
|
||||||
posts,
|
|
||||||
&artifacts.route_manifest,
|
&artifacts.route_manifest,
|
||||||
None,
|
None,
|
||||||
&mut report,
|
&mut report,
|
||||||
&mut is_cancelled,
|
&mut is_cancelled,
|
||||||
force,
|
|
||||||
false,
|
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
writer
|
||||||
|
.finish()
|
||||||
|
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||||
Ok(report)
|
Ok(report)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,10 +457,36 @@ pub fn apply_validation_section_with_progress(
|
|||||||
return Err(EngineError::Validation("cancelled".to_string()));
|
return Err(EngineError::Validation("cancelled".to_string()));
|
||||||
}
|
}
|
||||||
let data_dir = project_data_dir(output_dir);
|
let data_dir = project_data_dir(output_dir);
|
||||||
let input_posts = posts
|
let prepared = prepare_site_generation(conn, &data_dir, project_id, metadata, posts)?;
|
||||||
.iter()
|
apply_validation_prepared_section_with_progress(
|
||||||
.map(|source| (source.post.clone(), source.body_markdown.clone()))
|
conn,
|
||||||
.collect::<Vec<_>>();
|
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
|
let requested = validation
|
||||||
.missing_pages
|
.missing_pages
|
||||||
.iter()
|
.iter()
|
||||||
@@ -415,64 +499,46 @@ pub fn apply_validation_section_with_progress(
|
|||||||
.chain(validation.extra_pages.iter())
|
.chain(validation.extra_pages.iter())
|
||||||
.chain(validation.stale_pages.iter())
|
.chain(validation.stale_pages.iter())
|
||||||
.any(|path| classify_generated_path(path, metadata).is_none());
|
.any(|path| classify_generated_path(path, metadata).is_none());
|
||||||
let artifacts = if fallback {
|
let artifacts = build_site_render_artifacts_from_context(
|
||||||
build_site_section_render_artifacts(
|
&prepared.render,
|
||||||
conn,
|
Some(section),
|
||||||
&data_dir,
|
(!fallback).then_some(&requested),
|
||||||
project_id,
|
on_page_rendered,
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||||
let mut report = GenerationReport::default();
|
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();
|
let total_pages = artifacts.pages.len();
|
||||||
for (index, page) in artifacts.pages.iter().enumerate() {
|
for (index, page) in artifacts.pages.iter().enumerate() {
|
||||||
if is_cancelled() {
|
if is_cancelled() {
|
||||||
return Err(EngineError::Validation("cancelled".to_string()));
|
return Err(EngineError::Validation("cancelled".to_string()));
|
||||||
}
|
}
|
||||||
write_out(
|
write_out(&mut writer, &page.relative_path, &page.html, &mut report)?;
|
||||||
conn,
|
|
||||||
output_dir,
|
|
||||||
project_id,
|
|
||||||
&page.relative_path,
|
|
||||||
&page.html,
|
|
||||||
&mut report,
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
)?;
|
|
||||||
on_page(index + 1, total_pages, &page.url_path);
|
on_page(index + 1, total_pages, &page.url_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
if section == GenerationSection::Core {
|
if section == GenerationSection::Core {
|
||||||
write_core_outputs(
|
write_core_outputs(
|
||||||
conn,
|
&mut writer,
|
||||||
output_dir,
|
prepared,
|
||||||
project_id,
|
&project_data_dir(output_dir),
|
||||||
metadata,
|
|
||||||
&data_dir,
|
|
||||||
posts,
|
|
||||||
&artifacts.route_manifest,
|
&artifacts.route_manifest,
|
||||||
(!fallback).then_some(&requested),
|
(!fallback).then_some(&requested),
|
||||||
&mut report,
|
&mut report,
|
||||||
&mut is_cancelled,
|
&mut is_cancelled,
|
||||||
false,
|
|
||||||
true,
|
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
writer
|
||||||
|
.finish()
|
||||||
|
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||||
|
|
||||||
for path in &validation.extra_pages {
|
for path in &validation.extra_pages {
|
||||||
if is_cancelled() {
|
if is_cancelled() {
|
||||||
@@ -510,26 +576,24 @@ fn refresh_route_timestamps(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[expect(
|
|
||||||
clippy::too_many_arguments,
|
|
||||||
reason = "generation context is existing domain data"
|
|
||||||
)]
|
|
||||||
fn write_core_outputs(
|
fn write_core_outputs(
|
||||||
conn: &Connection,
|
writer: &mut GeneratedFileWriter<'_>,
|
||||||
output_dir: &Path,
|
prepared: &PreparedSiteGeneration,
|
||||||
project_id: &str,
|
|
||||||
metadata: &ProjectMetadata,
|
|
||||||
data_dir: &Path,
|
data_dir: &Path,
|
||||||
published_posts: &[PublishedPostSource],
|
|
||||||
route_manifest: &[crate::render::SitePage],
|
route_manifest: &[crate::render::SitePage],
|
||||||
requested: Option<&HashSet<String>>,
|
requested: Option<&HashSet<String>>,
|
||||||
report: &mut GenerationReport,
|
report: &mut GenerationReport,
|
||||||
is_cancelled: &mut impl FnMut() -> bool,
|
is_cancelled: &mut impl FnMut() -> bool,
|
||||||
force: bool,
|
|
||||||
verify_output: bool,
|
|
||||||
) -> EngineResult<()> {
|
) -> EngineResult<()> {
|
||||||
|
let metadata = &prepared.metadata;
|
||||||
|
let published_posts = &prepared.sources;
|
||||||
if requested.is_none() {
|
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![(
|
let mut outputs = vec![(
|
||||||
"calendar.json".to_string(),
|
"calendar.json".to_string(),
|
||||||
@@ -541,8 +605,6 @@ fn write_core_outputs(
|
|||||||
)?,
|
)?,
|
||||||
)];
|
)];
|
||||||
for render_language in render_languages(metadata) {
|
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 is_main = render_language == metadata.main_language.as_deref().unwrap_or("en");
|
||||||
let prefix = if is_main {
|
let prefix = if is_main {
|
||||||
String::new()
|
String::new()
|
||||||
@@ -550,28 +612,35 @@ fn write_core_outputs(
|
|||||||
format!("{render_language}/")
|
format!("{render_language}/")
|
||||||
};
|
};
|
||||||
let mut feed_posts = if is_main {
|
let mut feed_posts = if is_main {
|
||||||
published_posts.to_vec()
|
published_posts
|
||||||
} else {
|
|
||||||
localized_posts
|
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|source| {
|
.map(|source| &source.post)
|
||||||
source
|
.collect::<Vec<_>>()
|
||||||
.post
|
} else {
|
||||||
.language
|
prepared
|
||||||
|
.render
|
||||||
|
.localized_posts(&render_language)
|
||||||
|
.filter(|post| {
|
||||||
|
post.language
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.is_some_and(|language| language.eq_ignore_ascii_case(&render_language))
|
.is_some_and(|language| language.eq_ignore_ascii_case(&render_language))
|
||||||
})
|
})
|
||||||
.cloned()
|
|
||||||
.collect::<Vec<_>>()
|
.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((
|
outputs.push((
|
||||||
format!("{prefix}rss.xml"),
|
format!("{prefix}rss.xml"),
|
||||||
build_rss_xml(metadata, &feed_posts, &render_language),
|
build_rss_xml(metadata, feed_posts.iter().copied(), &render_language),
|
||||||
));
|
));
|
||||||
outputs.push((
|
outputs.push((
|
||||||
format!("{prefix}atom.xml"),
|
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 {
|
if is_main {
|
||||||
let category_settings = load_category_settings(data_dir);
|
let category_settings = load_category_settings(data_dir);
|
||||||
@@ -595,50 +664,36 @@ fn write_core_outputs(
|
|||||||
return Err(EngineError::Validation("cancelled".to_string()));
|
return Err(EngineError::Validation("cancelled".to_string()));
|
||||||
}
|
}
|
||||||
if requested.is_none_or(|requested| requested.contains(&path)) {
|
if requested.is_none_or(|requested| requested.contains(&path)) {
|
||||||
write_out(
|
write_out(writer, &path, &content, report)?;
|
||||||
conn,
|
|
||||||
output_dir,
|
|
||||||
project_id,
|
|
||||||
&path,
|
|
||||||
&content,
|
|
||||||
report,
|
|
||||||
force,
|
|
||||||
verify_output,
|
|
||||||
)?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[expect(
|
|
||||||
clippy::too_many_arguments,
|
|
||||||
reason = "generated output needs its render context, report, and write mode"
|
|
||||||
)]
|
|
||||||
fn write_out(
|
fn write_out(
|
||||||
conn: &Connection,
|
writer: &mut GeneratedFileWriter<'_>,
|
||||||
output_dir: &Path,
|
|
||||||
project_id: &str,
|
|
||||||
relative_path: &str,
|
relative_path: &str,
|
||||||
content: &str,
|
content: &str,
|
||||||
report: &mut GenerationReport,
|
report: &mut GenerationReport,
|
||||||
force: bool,
|
|
||||||
verify_output: bool,
|
|
||||||
) -> EngineResult<()> {
|
) -> EngineResult<()> {
|
||||||
let outcome = if force {
|
let outcome = writer
|
||||||
write_generated_file_forced(conn, output_dir, project_id, relative_path, content)
|
.write_str(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()))?;
|
.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 {
|
match outcome {
|
||||||
GeneratedWriteOutcome::Written => report.written_paths.push(relative_path.to_string()),
|
GeneratedWriteOutcome::Written => report.written_paths.push(relative_path.to_string()),
|
||||||
GeneratedWriteOutcome::SkippedUnchanged => {
|
GeneratedWriteOutcome::SkippedUnchanged => {
|
||||||
report.skipped_paths.push(relative_path.to_string())
|
report.skipped_paths.push(relative_path.to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_site_search_index(
|
pub fn build_site_search_index(
|
||||||
@@ -803,6 +858,8 @@ fn build_site_search_index_with_progress_mode(
|
|||||||
});
|
});
|
||||||
let total = outputs.len();
|
let total = outputs.len();
|
||||||
let mut report = GenerationReport::default();
|
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
|
let expected = outputs
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(relative, _)| relative.clone())
|
.map(|(relative, _)| relative.clone())
|
||||||
@@ -811,18 +868,15 @@ fn build_site_search_index_with_progress_mode(
|
|||||||
if is_cancelled() {
|
if is_cancelled() {
|
||||||
return Err(EngineError::Validation("cancelled".to_string()));
|
return Err(EngineError::Validation("cancelled".to_string()));
|
||||||
}
|
}
|
||||||
let outcome = if force {
|
let outcome = writer
|
||||||
write_generated_bytes_forced(conn, output_dir, project_id, &relative, &contents)
|
.write_bytes(&relative, &contents)
|
||||||
} else {
|
|
||||||
write_generated_bytes(conn, output_dir, project_id, &relative, &contents)
|
|
||||||
}
|
|
||||||
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||||
match outcome {
|
record_write_outcome(&mut report, &relative, outcome);
|
||||||
GeneratedWriteOutcome::Written => report.written_paths.push(relative.clone()),
|
|
||||||
GeneratedWriteOutcome::SkippedUnchanged => report.skipped_paths.push(relative.clone()),
|
|
||||||
}
|
|
||||||
on_file(index + 1, total, &relative);
|
on_file(index + 1, total, &relative);
|
||||||
}
|
}
|
||||||
|
writer
|
||||||
|
.finish()
|
||||||
|
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
||||||
for language in render_languages(metadata) {
|
for language in render_languages(metadata) {
|
||||||
let prefix = if language == metadata.main_language.as_deref().unwrap_or("en") {
|
let prefix = if language == metadata.main_language.as_deref().unwrap_or("en") {
|
||||||
"pagefind".to_string()
|
"pagefind".to_string()
|
||||||
@@ -969,67 +1023,6 @@ fn render_languages(metadata: &ProjectMetadata) -> Vec<String> {
|
|||||||
languages
|
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]) {
|
fn sort_published_sources(posts: &mut [PublishedPostSource]) {
|
||||||
posts.sort_by(|left, right| {
|
posts.sort_by(|left, right| {
|
||||||
right
|
right
|
||||||
@@ -1093,9 +1086,9 @@ pub(crate) fn refresh_validation_sitemap(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn build_rss_xml(
|
pub(crate) fn build_rss_xml<'a>(
|
||||||
metadata: &ProjectMetadata,
|
metadata: &ProjectMetadata,
|
||||||
posts: &[PublishedPostSource],
|
posts: impl IntoIterator<Item = &'a Post>,
|
||||||
language: &str,
|
language: &str,
|
||||||
) -> String {
|
) -> String {
|
||||||
let base_url = metadata
|
let base_url = metadata
|
||||||
@@ -1109,20 +1102,20 @@ pub(crate) fn build_rss_xml(
|
|||||||
escape_xml(language)
|
escape_xml(language)
|
||||||
);
|
);
|
||||||
|
|
||||||
for source in posts {
|
for post in posts {
|
||||||
let url = post_absolute_url(base_url, metadata, source, language);
|
let url = post_absolute_url(base_url, metadata, post, language);
|
||||||
xml.push_str(&format!(
|
xml.push_str(&format!(
|
||||||
"<item><title>{}</title><link>{url}</link></item>",
|
"<item><title>{}</title><link>{url}</link></item>",
|
||||||
escape_xml(&source.post.title)
|
escape_xml(&post.title)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
xml.push_str("</channel></rss>");
|
xml.push_str("</channel></rss>");
|
||||||
xml
|
xml
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn build_atom_xml(
|
pub(crate) fn build_atom_xml<'a>(
|
||||||
metadata: &ProjectMetadata,
|
metadata: &ProjectMetadata,
|
||||||
posts: &[PublishedPostSource],
|
posts: impl IntoIterator<Item = &'a Post>,
|
||||||
language: &str,
|
language: &str,
|
||||||
) -> String {
|
) -> String {
|
||||||
let base_url = metadata
|
let base_url = metadata
|
||||||
@@ -1136,11 +1129,11 @@ pub(crate) fn build_atom_xml(
|
|||||||
escape_xml(language)
|
escape_xml(language)
|
||||||
);
|
);
|
||||||
|
|
||||||
for source in posts {
|
for post in posts {
|
||||||
let url = post_absolute_url(base_url, metadata, source, language);
|
let url = post_absolute_url(base_url, metadata, post, language);
|
||||||
xml.push_str(&format!(
|
xml.push_str(&format!(
|
||||||
"<entry><title>{}</title><id>{url}</id></entry>",
|
"<entry><title>{}</title><id>{url}</id></entry>",
|
||||||
escape_xml(&source.post.title)
|
escape_xml(&post.title)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
xml.push_str("</feed>");
|
xml.push_str("</feed>");
|
||||||
@@ -1150,13 +1143,13 @@ pub(crate) fn build_atom_xml(
|
|||||||
fn post_absolute_url(
|
fn post_absolute_url(
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
metadata: &ProjectMetadata,
|
metadata: &ProjectMetadata,
|
||||||
source: &PublishedPostSource,
|
post: &Post,
|
||||||
language: &str,
|
language: &str,
|
||||||
) -> String {
|
) -> String {
|
||||||
format!(
|
format!(
|
||||||
"{base_url}{}/",
|
"{base_url}{}/",
|
||||||
build_canonical_post_path(
|
build_canonical_post_path(
|
||||||
&source.post,
|
post,
|
||||||
language,
|
language,
|
||||||
metadata.main_language.as_deref().unwrap_or("en")
|
metadata.main_language.as_deref().unwrap_or("en")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -243,11 +243,19 @@ fn render_preview_response(
|
|||||||
let (content_type, xml) = match kind {
|
let (content_type, xml) = match kind {
|
||||||
PreviewFeedKind::Rss => (
|
PreviewFeedKind::Rss => (
|
||||||
"application/rss+xml; charset=utf-8",
|
"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 => (
|
PreviewFeedKind::Atom => (
|
||||||
"application/atom+xml; charset=utf-8",
|
"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());
|
return Ok((StatusCode::OK, [(header::CONTENT_TYPE, content_type)], xml).into_response());
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use crate::db::DbConnection as Connection;
|
use crate::engine::EngineResult;
|
||||||
|
|
||||||
use crate::engine::{EngineError, EngineResult};
|
|
||||||
use crate::render::{GeneratedWriteOutcome, write_generated_bytes};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub(crate) struct BundledSiteAsset {
|
pub(crate) struct BundledSiteAsset {
|
||||||
@@ -238,6 +235,10 @@ pub(crate) fn bundled_site_asset(relative_path: &str) -> Option<&'static [u8]> {
|
|||||||
.map(|asset| asset.bytes)
|
.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<()> {
|
pub(crate) fn copy_bundled_site_assets(project_dir: &Path) -> EngineResult<()> {
|
||||||
for asset in BUNDLED_SITE_ASSETS {
|
for asset in BUNDLED_SITE_ASSETS {
|
||||||
let target = project_dir.join(asset.relative_path);
|
let target = project_dir.join(asset.relative_path);
|
||||||
@@ -252,44 +253,6 @@ pub(crate) fn copy_bundled_site_assets(project_dir: &Path) -> EngineResult<()> {
|
|||||||
Ok(())
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::bundled_site_asset;
|
use super::bundled_site_asset;
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ use serde::{Deserialize, Serialize};
|
|||||||
)]
|
)]
|
||||||
#[diesel(
|
#[diesel(
|
||||||
table_name = crate::db::schema::generated_file_hashes,
|
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 struct GeneratedFileHash {
|
||||||
pub project_id: String,
|
pub project_id: String,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::{BTreeMap, HashMap};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::db::DbConnection as Connection;
|
use crate::db::DbConnection as Connection;
|
||||||
use chrono::{Datelike, Local, TimeZone};
|
use chrono::{Datelike, Local, TimeZone};
|
||||||
@@ -16,6 +17,117 @@ pub enum GeneratedWriteOutcome {
|
|||||||
SkippedUnchanged,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
pub struct CalendarArchiveData {
|
pub struct CalendarArchiveData {
|
||||||
pub years: BTreeMap<String, usize>,
|
pub years: BTreeMap<String, usize>,
|
||||||
|
|||||||
@@ -6,14 +6,15 @@ mod routes;
|
|||||||
mod site;
|
mod site;
|
||||||
mod template_lookup;
|
mod template_lookup;
|
||||||
|
|
||||||
|
pub(crate) use generation::GeneratedFileWriter;
|
||||||
pub use generation::{
|
pub use generation::{
|
||||||
CalendarArchiveData, GeneratedWriteOutcome, build_calendar_json, build_core_generation_paths,
|
CalendarArchiveData, GeneratedWriteOutcome, build_calendar_json, build_core_generation_paths,
|
||||||
write_generated_bytes, write_generated_bytes_forced, write_generated_file,
|
write_generated_bytes, write_generated_bytes_forced, write_generated_file,
|
||||||
write_generated_file_forced, write_generated_file_verified,
|
write_generated_file_forced, write_generated_file_verified,
|
||||||
};
|
};
|
||||||
pub use markdown::render_markdown_to_html;
|
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 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(crate) use routes::{PostLanguageVariant, blog_page_title, select_post_language_variant};
|
||||||
pub use routes::{
|
pub use routes::{
|
||||||
RenderedPage, build_canonical_post_path, render_starter_list_page,
|
RenderedPage, build_canonical_post_path, render_starter_list_page,
|
||||||
@@ -21,9 +22,11 @@ pub use routes::{
|
|||||||
render_starter_single_post_page_with_media_map,
|
render_starter_single_post_page_with_media_map,
|
||||||
};
|
};
|
||||||
pub use site::{
|
pub use site::{
|
||||||
PagefindDocument, PreviewRenderResult, SitePage, SiteRenderArtifacts, build_preview_response,
|
PagefindDocument, PreviewRenderResult, SitePage, SiteRenderArtifacts, SiteRenderContext,
|
||||||
build_site_render_artifacts, build_site_route_manifest, build_site_section_render_artifacts,
|
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,
|
build_targeted_site_section_render_artifacts, estimate_site_render_pages,
|
||||||
|
prepare_site_render_context,
|
||||||
};
|
};
|
||||||
pub use template_lookup::{
|
pub use template_lookup::{
|
||||||
RenderCategorySettings, RenderTemplateLookup, TemplateLookupError, resolve_post_template,
|
RenderCategorySettings, RenderTemplateLookup, TemplateLookupError, resolve_post_template,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
|
||||||
use liquid::ParserBuilder;
|
use liquid::ParserBuilder;
|
||||||
use liquid::partials::{EagerCompiler, InMemorySource};
|
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::macros::{MacroRenderContext, expand_builtin_macros};
|
||||||
use crate::render::render_markdown_to_html;
|
use crate::render::render_markdown_to_html;
|
||||||
use crate::scripting::{HostApi, UnavailableHost};
|
use crate::scripting::{HostApi, UnavailableHost};
|
||||||
use crate::util::slugify;
|
use crate::util::{content_hash, slugify};
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum RenderError {
|
pub enum RenderError {
|
||||||
@@ -26,6 +26,44 @@ pub enum RenderError {
|
|||||||
Liquid(#[from] liquid::Error),
|
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)]
|
#[derive(Debug, Clone, Default)]
|
||||||
struct HtmlRewriteContext {
|
struct HtmlRewriteContext {
|
||||||
canonical_post_path_by_slug: HashMap<String, String>,
|
canonical_post_path_by_slug: HashMap<String, String>,
|
||||||
@@ -51,21 +89,13 @@ pub(crate) fn render_liquid_template_with_host<T: Serialize>(
|
|||||||
context: &T,
|
context: &T,
|
||||||
host: Arc<dyn HostApi>,
|
host: Arc<dyn HostApi>,
|
||||||
) -> Result<String, RenderError> {
|
) -> Result<String, RenderError> {
|
||||||
let mut compiled_partials: EagerCompiler<InMemorySource> = EagerCompiler::empty();
|
let renderer = LiquidRenderer::new(partials, host)?;
|
||||||
for (name, content) in partials {
|
let template = renderer.compile(template_source)?;
|
||||||
compiled_partials.add(format!("{name}.liquid"), content.clone());
|
renderer.render(&template, context)
|
||||||
}
|
|
||||||
|
|
||||||
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)?)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn validate_liquid_template_syntax(template_source: &str) -> Result<(), String> {
|
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()
|
.build()
|
||||||
.map_err(|error| error.to_string())?;
|
.map_err(|error| error.to_string())?;
|
||||||
parser
|
parser
|
||||||
@@ -74,10 +104,15 @@ pub(crate) fn validate_liquid_template_syntax(template_source: &str) -> Result<(
|
|||||||
.map_err(|error| error.to_string())
|
.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()
|
ParserBuilder::with_stdlib()
|
||||||
.filter(I18n)
|
.filter(I18n)
|
||||||
.filter(Markdown { host })
|
.filter(Markdown {
|
||||||
|
host,
|
||||||
|
cache: markdown_cache,
|
||||||
|
})
|
||||||
.filter(Slugify)
|
.filter(Slugify)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +197,7 @@ struct MarkdownArgs {
|
|||||||
)]
|
)]
|
||||||
struct Markdown {
|
struct Markdown {
|
||||||
host: Arc<dyn HostApi>,
|
host: Arc<dyn HostApi>,
|
||||||
|
cache: MarkdownCache,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ParseFilter for Markdown {
|
impl ParseFilter for Markdown {
|
||||||
@@ -169,6 +205,7 @@ impl ParseFilter for Markdown {
|
|||||||
Ok(Box::new(MarkdownFilter {
|
Ok(Box::new(MarkdownFilter {
|
||||||
args: MarkdownArgs::from_args(args)?,
|
args: MarkdownArgs::from_args(args)?,
|
||||||
host: Arc::clone(&self.host),
|
host: Arc::clone(&self.host),
|
||||||
|
cache: Arc::clone(&self.cache),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,6 +219,7 @@ impl ParseFilter for Markdown {
|
|||||||
struct MarkdownFilter {
|
struct MarkdownFilter {
|
||||||
args: MarkdownArgs,
|
args: MarkdownArgs,
|
||||||
host: Arc<dyn HostApi>,
|
host: Arc<dyn HostApi>,
|
||||||
|
cache: MarkdownCache,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Debug for MarkdownFilter {
|
impl fmt::Debug for MarkdownFilter {
|
||||||
@@ -208,31 +246,54 @@ impl Filter for MarkdownFilter {
|
|||||||
.map(value_to_string_map)
|
.map(value_to_string_map)
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
};
|
};
|
||||||
let macro_context = MacroRenderContext {
|
let post_id = args
|
||||||
roots: collect_macro_roots(runtime),
|
|
||||||
post_id: args
|
|
||||||
.post_id
|
.post_id
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|value| value.as_scalar().map(|scalar| scalar.to_kstr().to_string()))
|
.and_then(|value| value.as_scalar().map(|scalar| scalar.to_kstr().to_string()))
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
runtime
|
runtime
|
||||||
.try_get(&[ScalarCow::new("post"), ScalarCow::new("id")])
|
.try_get(&[ScalarCow::new("post"), ScalarCow::new("id")])
|
||||||
.and_then(|value| {
|
.and_then(|value| value.as_scalar().map(|scalar| scalar.to_kstr().to_string()))
|
||||||
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: post_id.clone(),
|
||||||
host: Arc::clone(&self.host),
|
host: Arc::clone(&self.host),
|
||||||
};
|
};
|
||||||
|
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 expanded = expand_builtin_macros(markdown.as_str(), ¯o_context);
|
||||||
let rendered = render_markdown_to_html(&expanded);
|
let rendered = render_markdown_to_html(&expanded);
|
||||||
Ok(Value::scalar(rewrite_rendered_html_urls(
|
rewrite_rendered_html_urls(&rendered, &rewrite_context)
|
||||||
&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> {
|
fn collect_macro_roots(runtime: &dyn Runtime) -> JsonMap<String, JsonValue> {
|
||||||
let mut roots = JsonMap::new();
|
let mut roots = JsonMap::new();
|
||||||
|
|
||||||
|
|||||||
@@ -9,17 +9,17 @@ use chrono::{Datelike, Local, TimeZone, Utc};
|
|||||||
use rayon::prelude::*;
|
use rayon::prelude::*;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use super::page_renderer::{CompiledLiquidTemplate, LiquidRenderer};
|
||||||
use crate::db::queries;
|
use crate::db::queries;
|
||||||
use crate::engine::generation::{GenerationSection, classify_generated_path};
|
use crate::engine::generation::{GenerationSection, classify_generated_path};
|
||||||
use crate::engine::menu::{self, MenuItemKind};
|
use crate::engine::menu::{self, MenuItemKind};
|
||||||
use crate::model::{
|
use crate::model::{
|
||||||
CategorySettings, Media, Post, PostStatus, ProjectMetadata, ScriptKind, Tag, Template,
|
CategorySettings, Media, Post, PostLink, PostMedia, PostStatus, PostTranslation,
|
||||||
TemplateKind, TemplateStatus,
|
ProjectMetadata, ScriptKind, Tag, Template, TemplateKind, TemplateStatus,
|
||||||
};
|
};
|
||||||
use crate::render::{
|
use crate::render::{
|
||||||
PostLanguageVariant, RenderCategorySettings, RenderTemplateLookup, blog_page_title,
|
PostLanguageVariant, RenderCategorySettings, RenderTemplateLookup, blog_page_title,
|
||||||
build_canonical_post_path, render_liquid_template_with_host, resolve_post_template,
|
build_canonical_post_path, resolve_post_template, select_post_language_variant,
|
||||||
select_post_language_variant,
|
|
||||||
};
|
};
|
||||||
use crate::scripting::{CoreHost, HostApi, UnavailableHost};
|
use crate::scripting::{CoreHost, HostApi, UnavailableHost};
|
||||||
use crate::util::frontmatter::{read_script_file, read_template_file, read_translation_file};
|
use crate::util::frontmatter::{read_script_file, read_template_file, read_translation_file};
|
||||||
@@ -59,7 +59,6 @@ pub struct PagefindDocument {
|
|||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct SiteRenderArtifacts {
|
pub struct SiteRenderArtifacts {
|
||||||
pub pages: Vec<SitePage>,
|
pub pages: Vec<SitePage>,
|
||||||
pub pagefind_documents: Vec<PagefindDocument>,
|
|
||||||
pub route_manifest: Vec<SitePage>,
|
pub route_manifest: Vec<SitePage>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,14 +71,13 @@ pub struct PreviewRenderResult {
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct TemplateBundle {
|
struct TemplateBundle {
|
||||||
post_templates: Vec<Template>,
|
post_templates: Vec<Template>,
|
||||||
template_source_by_slug: HashMap<String, String>,
|
post_template_by_slug: HashMap<String, CompiledLiquidTemplate>,
|
||||||
list_template_sources: HashMap<String, String>,
|
list_template_by_slug: HashMap<String, CompiledLiquidTemplate>,
|
||||||
default_list_template: String,
|
default_list_template: CompiledLiquidTemplate,
|
||||||
not_found_template: String,
|
not_found_template: CompiledLiquidTemplate,
|
||||||
partials: HashMap<String, String>,
|
|
||||||
macro_templates: HashMap<String, String>,
|
macro_templates: HashMap<String, String>,
|
||||||
macro_scripts: HashMap<String, Value>,
|
macro_scripts: HashMap<String, Value>,
|
||||||
host: Arc<dyn HostApi>,
|
renderer: LiquidRenderer,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -103,6 +101,59 @@ struct RouteSpec {
|
|||||||
items_per_page: usize,
|
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(
|
pub fn build_site_render_artifacts(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
data_dir: &Path,
|
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(
|
pub fn build_targeted_site_section_render_artifacts(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
data_dir: &Path,
|
data_dir: &Path,
|
||||||
@@ -260,10 +315,47 @@ fn build_site_render_artifacts_with_mode(
|
|||||||
requested_paths: Option<&HashSet<String>>,
|
requested_paths: Option<&HashSet<String>>,
|
||||||
on_page_rendered: &(dyn Fn(&str) + Sync),
|
on_page_rendered: &(dyn Fn(&str) + Sync),
|
||||||
) -> Result<SiteRenderArtifacts, Box<dyn Error + Send + 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 bundle = load_template_bundle(conn, data_dir, project_id)?;
|
||||||
let main_language = main_language(metadata).to_string();
|
let main_language = main_language(metadata).to_string();
|
||||||
let languages = render_languages(metadata);
|
let languages = render_languages(metadata);
|
||||||
let tags = queries::tag::list_tags_by_project(conn, project_id).unwrap_or_default();
|
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 category_settings = queries_category_settings(data_dir)?;
|
||||||
let media_items = queries::media::list_media_by_project(conn, project_id).unwrap_or_default();
|
let media_items = queries::media::list_media_by_project(conn, project_id).unwrap_or_default();
|
||||||
let media_by_id = media_items
|
let media_by_id = media_items
|
||||||
@@ -273,34 +365,84 @@ fn build_site_render_artifacts_with_mode(
|
|||||||
.collect::<HashMap<_, _>>();
|
.collect::<HashMap<_, _>>();
|
||||||
let project_media = media_items.iter().map(media_context).collect::<Vec<_>>();
|
let project_media = media_items.iter().map(media_context).collect::<Vec<_>>();
|
||||||
let canonical_media_map = canonical_media_paths(&media_items);
|
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 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 {
|
for language in languages {
|
||||||
let localized_posts = load_language_posts(
|
let posts = load_language_posts(
|
||||||
conn,
|
|
||||||
data_dir,
|
data_dir,
|
||||||
published_posts,
|
published_posts,
|
||||||
|
&translations,
|
||||||
&language,
|
&language,
|
||||||
&main_language,
|
&main_language,
|
||||||
is_preview,
|
is_preview,
|
||||||
)?;
|
)?;
|
||||||
let localized_list_posts = filter_posts_for_lists(&localized_posts, &category_settings);
|
let list_posts = filter_posts_for_lists(&posts, &category_settings);
|
||||||
let routes = build_language_routes(
|
let routes =
|
||||||
&localized_list_posts,
|
build_language_routes(&list_posts, metadata, &language, &tags, &category_settings);
|
||||||
metadata,
|
let linked_media_by_post_id =
|
||||||
&language,
|
build_linked_media_by_post_id(&posts, &linked_media_by_source_post);
|
||||||
&tags,
|
let post_data_json_by_id = build_post_data_json_by_id(&posts, &linked_media_by_post_id);
|
||||||
&category_settings,
|
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| {
|
let expanded_requested_paths = requested_paths.map(|requested| {
|
||||||
expand_requested_aggregate_paths(
|
expand_requested_aggregate_paths(requested, localized_posts, routes, language, metadata)
|
||||||
requested,
|
|
||||||
&localized_posts,
|
|
||||||
&routes,
|
|
||||||
&language,
|
|
||||||
metadata,
|
|
||||||
)
|
|
||||||
});
|
});
|
||||||
let requested_paths = expanded_requested_paths.as_ref();
|
let requested_paths = expanded_requested_paths.as_ref();
|
||||||
artifacts
|
artifacts
|
||||||
@@ -311,11 +453,6 @@ fn build_site_render_artifacts_with_mode(
|
|||||||
url_path: route.url_path.clone(),
|
url_path: route.url_path.clone(),
|
||||||
html: String::new(),
|
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
|
let rendered_list_pages = routes
|
||||||
.par_iter()
|
.par_iter()
|
||||||
.filter(|route| {
|
.filter(|route| {
|
||||||
@@ -328,17 +465,17 @@ fn build_site_render_artifacts_with_mode(
|
|||||||
render_list_route(
|
render_list_route(
|
||||||
route,
|
route,
|
||||||
metadata,
|
metadata,
|
||||||
&language,
|
language,
|
||||||
&localized_list_posts,
|
&context.category_settings,
|
||||||
&tags,
|
&language_context.menu_items,
|
||||||
&category_settings,
|
&language_context.canonical_post_path_by_slug,
|
||||||
&menu_items,
|
&language_context.taxonomy,
|
||||||
&post_data_json_by_id,
|
&language_context.post_data_json_by_id,
|
||||||
&canonical_media_map,
|
&context.canonical_media_map,
|
||||||
&project_media,
|
&context.project_media,
|
||||||
&project_tags,
|
&context.project_tags,
|
||||||
&bundle,
|
&context.bundle,
|
||||||
is_preview,
|
context.is_preview,
|
||||||
)
|
)
|
||||||
.map(|html| {
|
.map(|html| {
|
||||||
on_page_rendered(&route.url_path);
|
on_page_rendered(&route.url_path);
|
||||||
@@ -352,15 +489,7 @@ fn build_site_render_artifacts_with_mode(
|
|||||||
})
|
})
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
for page in rendered_list_pages {
|
artifacts.pages.extend(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);
|
|
||||||
}
|
|
||||||
|
|
||||||
if section.is_none_or(|section| section == GenerationSection::Core) {
|
if section.is_none_or(|section| section == GenerationSection::Core) {
|
||||||
let relative_path = if language == main_language {
|
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)) {
|
if requested_paths.is_none_or(|requested| requested.contains(&relative_path)) {
|
||||||
let url_path = format!("/{}", relative_path.trim_end_matches(".html"));
|
let url_path = format!("/{}", relative_path.trim_end_matches(".html"));
|
||||||
let html =
|
let html = render_not_found_route(
|
||||||
render_not_found_route(&bundle, metadata, &language, &url_path, &menu_items)?;
|
&context.bundle,
|
||||||
|
metadata,
|
||||||
|
language,
|
||||||
|
&url_path,
|
||||||
|
&language_context.menu_items,
|
||||||
|
)?;
|
||||||
on_page_rendered(&url_path);
|
on_page_rendered(&url_path);
|
||||||
artifacts.pages.push(SitePage {
|
artifacts.pages.push(SitePage {
|
||||||
language: language.clone(),
|
language: language.clone(),
|
||||||
@@ -382,10 +516,9 @@ fn build_site_render_artifacts_with_mode(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let canonical_map =
|
let mut single_routes = Vec::new();
|
||||||
canonical_post_path_by_slug(&localized_posts, &language, &main_language);
|
for record in localized_posts {
|
||||||
for record in &localized_posts {
|
let canonical_path = build_canonical_post_path(&record.post, language, main_language);
|
||||||
let canonical_path = build_canonical_post_path(&record.post, &language, &main_language);
|
|
||||||
let mut post_paths = vec![(canonical_path, GenerationSection::Single)];
|
let mut post_paths = vec![(canonical_path, GenerationSection::Single)];
|
||||||
if record
|
if record
|
||||||
.post
|
.post
|
||||||
@@ -410,45 +543,47 @@ fn build_site_render_artifacts_with_mode(
|
|||||||
url_path: url_path.clone(),
|
url_path: url_path.clone(),
|
||||||
html: String::new(),
|
html: String::new(),
|
||||||
});
|
});
|
||||||
if section.is_some_and(|section| section != route_section)
|
if section.is_none_or(|section| section == route_section)
|
||||||
|| requested_paths.is_some_and(|requested| !requested.contains(&relative_path))
|
&& 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,
|
}
|
||||||
|
let rendered_single_pages = single_routes
|
||||||
|
.par_iter()
|
||||||
|
.map(|(record, relative_path, url_path)| {
|
||||||
|
render_post_route(
|
||||||
metadata,
|
metadata,
|
||||||
&language,
|
language,
|
||||||
&main_language,
|
main_language,
|
||||||
record,
|
record,
|
||||||
&localized_posts,
|
&context.tags,
|
||||||
&tags,
|
&context.render_categories,
|
||||||
&category_settings,
|
&language_context.linked_media_by_post_id,
|
||||||
&linked_media_by_post_id,
|
&language_context.links,
|
||||||
&canonical_map,
|
&language_context.canonical_post_path_by_slug,
|
||||||
&menu_items,
|
&language_context.menu_items,
|
||||||
&post_data_json_by_id,
|
&language_context.taxonomy,
|
||||||
&canonical_media_map,
|
&language_context.post_data_json_by_id,
|
||||||
&project_media,
|
&context.canonical_media_map,
|
||||||
&project_tags,
|
&context.project_media,
|
||||||
&bundle,
|
&context.project_tags,
|
||||||
is_preview,
|
&context.bundle,
|
||||||
)?;
|
context.is_preview,
|
||||||
on_page_rendered(&url_path);
|
)
|
||||||
artifacts.pagefind_documents.push(PagefindDocument {
|
.map(|html| {
|
||||||
|
on_page_rendered(url_path);
|
||||||
|
SitePage {
|
||||||
language: language.clone(),
|
language: language.clone(),
|
||||||
relative_path: relative_path.clone(),
|
relative_path: relative_path.clone(),
|
||||||
url_path: url_path.clone(),
|
url_path: url_path.clone(),
|
||||||
html: html.clone(),
|
|
||||||
});
|
|
||||||
artifacts.pages.push(SitePage {
|
|
||||||
language: language.clone(),
|
|
||||||
relative_path,
|
|
||||||
url_path,
|
|
||||||
html,
|
html,
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
artifacts.pages.extend(rendered_single_pages);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(artifacts)
|
Ok(artifacts)
|
||||||
@@ -611,16 +746,27 @@ fn load_template_bundle(
|
|||||||
.insert("post".to_string(), STARTER_SINGLE_POST_TEMPLATE.to_string());
|
.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 {
|
Ok(TemplateBundle {
|
||||||
post_templates,
|
post_templates,
|
||||||
template_source_by_slug,
|
post_template_by_slug,
|
||||||
list_template_sources,
|
list_template_by_slug,
|
||||||
default_list_template,
|
default_list_template,
|
||||||
not_found_template,
|
not_found_template,
|
||||||
partials,
|
|
||||||
macro_templates,
|
macro_templates,
|
||||||
macro_scripts,
|
macro_scripts,
|
||||||
host,
|
renderer,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -640,21 +786,22 @@ fn load_template_source(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn load_language_posts(
|
fn load_language_posts(
|
||||||
conn: &Connection,
|
|
||||||
data_dir: &Path,
|
data_dir: &Path,
|
||||||
published_posts: &[(Post, String)],
|
published_posts: &[(Post, String)],
|
||||||
|
translations: &HashMap<(String, String), PostTranslation>,
|
||||||
language: &str,
|
language: &str,
|
||||||
main_language: &str,
|
main_language: &str,
|
||||||
is_preview: bool,
|
is_preview: bool,
|
||||||
) -> Result<Vec<RenderPostRecord>, Box<dyn Error + Send + Sync>> {
|
) -> Result<Vec<RenderPostRecord>, Box<dyn Error + Send + Sync>> {
|
||||||
let mut posts = Vec::new();
|
let mut posts = Vec::new();
|
||||||
for (post, body) in published_posts {
|
for (post, body) in published_posts {
|
||||||
let translation = queries::post_translation::get_post_translation_by_post_and_language(
|
let translation = translations
|
||||||
conn, &post.id, language,
|
.get(&(post.id.clone(), language.to_string()))
|
||||||
)
|
.cloned()
|
||||||
.ok()
|
|
||||||
.filter(|translation| {
|
.filter(|translation| {
|
||||||
(is_preview && translation.status == PostStatus::Draft && translation.content.is_some())
|
(is_preview
|
||||||
|
&& translation.status == PostStatus::Draft
|
||||||
|
&& translation.content.is_some())
|
||||||
|| (!translation.file_path.trim().is_empty()
|
|| (!translation.file_path.trim().is_empty()
|
||||||
&& data_dir
|
&& data_dir
|
||||||
.join(translation.file_path.trim_start_matches('/'))
|
.join(translation.file_path.trim_start_matches('/'))
|
||||||
@@ -993,10 +1140,10 @@ fn render_list_route(
|
|||||||
route: &RouteSpec,
|
route: &RouteSpec,
|
||||||
metadata: &ProjectMetadata,
|
metadata: &ProjectMetadata,
|
||||||
language: &str,
|
language: &str,
|
||||||
posts: &[RenderPostRecord],
|
|
||||||
tags: &[Tag],
|
|
||||||
category_settings: &HashMap<String, CategorySettings>,
|
category_settings: &HashMap<String, CategorySettings>,
|
||||||
menu_items: &[Value],
|
menu_items: &[Value],
|
||||||
|
canonical_post_path_by_slug: &HashMap<String, String>,
|
||||||
|
taxonomy: &TaxonomyContext,
|
||||||
post_data_json_by_id: &HashMap<String, Value>,
|
post_data_json_by_id: &HashMap<String, Value>,
|
||||||
canonical_media_path_by_source_path: &HashMap<String, String>,
|
canonical_media_path_by_source_path: &HashMap<String, String>,
|
||||||
project_media: &[Value],
|
project_media: &[Value],
|
||||||
@@ -1005,12 +1152,10 @@ fn render_list_route(
|
|||||||
is_preview: bool,
|
is_preview: bool,
|
||||||
) -> Result<String, Box<dyn Error + Send + Sync>> {
|
) -> Result<String, Box<dyn Error + Send + Sync>> {
|
||||||
let main_language = main_language(metadata);
|
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
|
let list_template = route
|
||||||
.list_template_slug
|
.list_template_slug
|
||||||
.as_deref()
|
.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);
|
.unwrap_or(&bundle.default_list_template);
|
||||||
let context = json!({
|
let context = json!({
|
||||||
"language": language,
|
"language": language,
|
||||||
@@ -1043,40 +1188,35 @@ fn render_list_route(
|
|||||||
"total_pages": route.total_pages,
|
"total_pages": route.total_pages,
|
||||||
"total_items": route.total_items,
|
"total_items": route.total_items,
|
||||||
"items_per_page": route.items_per_page,
|
"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,
|
"canonical_media_path_by_source_path": canonical_media_path_by_source_path,
|
||||||
"post_data_json_by_id": post_data_json_by_id,
|
"post_data_json_by_id": post_data_json_by_id,
|
||||||
"project": { "media": project_media },
|
"project": { "media": project_media },
|
||||||
"Tags": project_tags,
|
"Tags": project_tags,
|
||||||
"post_categories": taxonomy_counts.0,
|
"post_categories": taxonomy.categories,
|
||||||
"post_tags": taxonomy_counts.1,
|
"post_tags": taxonomy.tags,
|
||||||
"tag_color_by_name": taxonomy_counts.2,
|
"tag_color_by_name": taxonomy.tag_colors,
|
||||||
"backlinks": Vec::<Value>::new(),
|
"backlinks": Vec::<Value>::new(),
|
||||||
"not_found_message": serde_json::Value::Null,
|
"not_found_message": serde_json::Value::Null,
|
||||||
"not_found_back_label": serde_json::Value::Null,
|
"not_found_back_label": serde_json::Value::Null,
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(render_liquid_template_with_host(
|
Ok(bundle.renderer.render(list_template, &context)?)
|
||||||
list_template,
|
|
||||||
&bundle.partials,
|
|
||||||
&context,
|
|
||||||
Arc::clone(&bundle.host),
|
|
||||||
)?)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn render_post_route(
|
fn render_post_route(
|
||||||
conn: &Connection,
|
|
||||||
metadata: &ProjectMetadata,
|
metadata: &ProjectMetadata,
|
||||||
language: &str,
|
language: &str,
|
||||||
main_language: &str,
|
main_language: &str,
|
||||||
record: &RenderPostRecord,
|
record: &RenderPostRecord,
|
||||||
all_posts: &[RenderPostRecord],
|
|
||||||
tags: &[Tag],
|
tags: &[Tag],
|
||||||
category_settings: &HashMap<String, CategorySettings>,
|
render_categories: &HashMap<String, RenderCategorySettings>,
|
||||||
linked_media_by_post_id: &HashMap<String, Vec<Value>>,
|
linked_media_by_post_id: &HashMap<String, Vec<Value>>,
|
||||||
|
links: &LinkContext,
|
||||||
canonical_post_path_by_slug: &HashMap<String, String>,
|
canonical_post_path_by_slug: &HashMap<String, String>,
|
||||||
menu_items: &[Value],
|
menu_items: &[Value],
|
||||||
|
taxonomy: &TaxonomyContext,
|
||||||
post_data_json_by_id: &HashMap<String, Value>,
|
post_data_json_by_id: &HashMap<String, Value>,
|
||||||
canonical_media_path_by_source_path: &HashMap<String, String>,
|
canonical_media_path_by_source_path: &HashMap<String, String>,
|
||||||
project_media: &[Value],
|
project_media: &[Value],
|
||||||
@@ -1084,56 +1224,39 @@ fn render_post_route(
|
|||||||
bundle: &TemplateBundle,
|
bundle: &TemplateBundle,
|
||||||
is_preview: bool,
|
is_preview: bool,
|
||||||
) -> Result<String, Box<dyn Error + Send + Sync>> {
|
) -> 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 {
|
let resolved = resolve_post_template(RenderTemplateLookup {
|
||||||
post: &record.post,
|
post: &record.post,
|
||||||
templates: &bundle.post_templates,
|
templates: &bundle.post_templates,
|
||||||
tags,
|
tags,
|
||||||
category_settings: &render_categories,
|
category_settings: render_categories,
|
||||||
})
|
})
|
||||||
.map_err(|error| format!("template lookup failed: {error:?}"))?;
|
.map_err(|error| format!("template lookup failed: {error:?}"))?;
|
||||||
let template_source = bundle
|
let template = bundle
|
||||||
.template_source_by_slug
|
.post_template_by_slug
|
||||||
.get(&resolved.slug)
|
.get(&resolved.slug)
|
||||||
.cloned()
|
.cloned()
|
||||||
.or_else(|| resolved.content.clone())
|
.or_else(|| bundle.post_template_by_slug.get("post").cloned())
|
||||||
.unwrap_or_else(|| STARTER_SINGLE_POST_TEMPLATE.to_string());
|
.ok_or("default post template was not compiled")?;
|
||||||
|
|
||||||
let linked_media = linked_media_by_post_id
|
let linked_media = linked_media_by_post_id
|
||||||
.get(&record.post.id)
|
.get(&record.post.id)
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let outgoing_links =
|
let outgoing_link_context = links
|
||||||
queries::post_link::list_links_by_source(conn, &record.source_post_id).unwrap_or_default();
|
.outgoing_by_post
|
||||||
let incoming_links =
|
.get(&record.source_post_id)
|
||||||
queries::post_link::list_links_by_target(conn, &record.source_post_id).unwrap_or_default();
|
.cloned()
|
||||||
let post_by_id = all_posts
|
.unwrap_or_default();
|
||||||
.iter()
|
let incoming_link_context = links
|
||||||
.map(|item| (item.source_post_id.clone(), item))
|
.incoming_by_post
|
||||||
.collect::<HashMap<_, _>>();
|
.get(&record.source_post_id)
|
||||||
let outgoing_link_context = outgoing_links
|
.cloned()
|
||||||
.iter()
|
.unwrap_or_default();
|
||||||
.map(|link| link_context(link, &post_by_id, language, main_language))
|
let backlinks = links
|
||||||
.collect::<Vec<_>>();
|
.backlinks_by_post
|
||||||
let incoming_link_context = incoming_links
|
.get(&record.source_post_id)
|
||||||
.iter()
|
.cloned()
|
||||||
.map(|link| link_context(link, &post_by_id, language, main_language))
|
.unwrap_or_default();
|
||||||
.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 context = json!({
|
let context = json!({
|
||||||
"language": language,
|
"language": language,
|
||||||
@@ -1151,9 +1274,9 @@ fn render_post_route(
|
|||||||
"calendar_initial_year": calendar_initial_parts(&record.post).0,
|
"calendar_initial_year": calendar_initial_parts(&record.post).0,
|
||||||
"calendar_initial_month": calendar_initial_parts(&record.post).1,
|
"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": 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_categories": taxonomy_items_for_categories(&record.post.categories, taxonomy),
|
||||||
"post_tags": taxonomy_items_for_tags(&record.post.tags, all_posts, tags),
|
"post_tags": taxonomy_items_for_tags(&record.post.tags, taxonomy, tags),
|
||||||
"tag_color_by_name": taxonomy_counts.2,
|
"tag_color_by_name": taxonomy.tag_colors,
|
||||||
"backlinks": backlinks,
|
"backlinks": backlinks,
|
||||||
"canonical_post_path_by_slug": canonical_post_path_by_slug,
|
"canonical_post_path_by_slug": canonical_post_path_by_slug,
|
||||||
"canonical_media_path_by_source_path": canonical_media_path_by_source_path,
|
"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,
|
"not_found_back_label": serde_json::Value::Null,
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(render_liquid_template_with_host(
|
Ok(bundle.renderer.render(&template, &context)?)
|
||||||
&template_source,
|
|
||||||
&bundle.partials,
|
|
||||||
&context,
|
|
||||||
Arc::clone(&bundle.host),
|
|
||||||
)?)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_not_found_route(
|
fn render_not_found_route(
|
||||||
@@ -1225,12 +1343,9 @@ fn render_not_found_route(
|
|||||||
"canonical_media_path_by_source_path": HashMap::<String, String>::new(),
|
"canonical_media_path_by_source_path": HashMap::<String, String>::new(),
|
||||||
"post_data_json_by_id": HashMap::<String, Value>::new(),
|
"post_data_json_by_id": HashMap::<String, Value>::new(),
|
||||||
});
|
});
|
||||||
Ok(render_liquid_template_with_host(
|
Ok(bundle
|
||||||
&bundle.not_found_template,
|
.renderer
|
||||||
&bundle.partials,
|
.render(&bundle.not_found_template, &context)?)
|
||||||
&context,
|
|
||||||
Arc::clone(&bundle.host),
|
|
||||||
)?)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_menu_items(
|
fn build_menu_items(
|
||||||
@@ -1442,24 +1557,40 @@ fn canonical_media_paths(media_items: &[Media]) -> HashMap<String, String> {
|
|||||||
map
|
map
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_linked_media_by_post_id(
|
fn build_linked_media_by_source_post(
|
||||||
conn: &Connection,
|
post_media: &[PostMedia],
|
||||||
posts: &[RenderPostRecord],
|
|
||||||
media_by_id: &HashMap<String, Media>,
|
media_by_id: &HashMap<String, Media>,
|
||||||
) -> HashMap<String, Vec<Value>> {
|
) -> HashMap<String, Vec<Value>> {
|
||||||
let mut result = HashMap::new();
|
let mut result = HashMap::new();
|
||||||
for record in posts {
|
for link in post_media {
|
||||||
let media = queries::post_media::list_post_media_by_post(conn, &record.source_post_id)
|
if let Some(media) = media_by_id.get(&link.media_id) {
|
||||||
.unwrap_or_default()
|
result
|
||||||
.into_iter()
|
.entry(link.post_id.clone())
|
||||||
.filter_map(|link| media_by_id.get(&link.media_id))
|
.or_insert_with(Vec::new)
|
||||||
.map(media_context)
|
.push(media_context(media));
|
||||||
.collect();
|
}
|
||||||
result.insert(record.post.id.clone(), media);
|
|
||||||
}
|
}
|
||||||
result
|
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(
|
fn build_post_data_json_by_id(
|
||||||
posts: &[RenderPostRecord],
|
posts: &[RenderPostRecord],
|
||||||
linked_media_by_post_id: &HashMap<String, Vec<Value>>,
|
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
|
items
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_taxonomy_counts(
|
fn build_taxonomy_context(posts: &[RenderPostRecord], tags: &[Tag]) -> TaxonomyContext {
|
||||||
posts: &[RenderPostRecord],
|
|
||||||
tags: &[Tag],
|
|
||||||
) -> (Vec<Value>, Vec<Value>, HashMap<String, String>) {
|
|
||||||
let mut category_counts: HashMap<String, usize> = HashMap::new();
|
let mut category_counts: HashMap<String, usize> = HashMap::new();
|
||||||
let mut tag_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();
|
let mut tag_colors = HashMap::new();
|
||||||
|
|
||||||
for record in posts {
|
for record in posts {
|
||||||
for category in &record.post.categories {
|
for category in &record.post.categories {
|
||||||
*category_counts.entry(category.clone()).or_default() += 1;
|
*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 {
|
for tag_name in &record.post.tags {
|
||||||
*tag_counts.entry(tag_name.clone()).or_default() += 1;
|
*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<_>>();
|
.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
|
names
|
||||||
.iter()
|
.iter()
|
||||||
.map(|name| {
|
.map(|name| {
|
||||||
let count = posts
|
let count = taxonomy
|
||||||
.iter()
|
.category_counts
|
||||||
.filter(|record| {
|
.get(&name.to_lowercase())
|
||||||
record
|
.copied()
|
||||||
.post
|
.unwrap_or_default();
|
||||||
.categories
|
|
||||||
.iter()
|
|
||||||
.any(|category| category.eq_ignore_ascii_case(name))
|
|
||||||
})
|
|
||||||
.count();
|
|
||||||
json!({
|
json!({
|
||||||
"name": name,
|
"name": name,
|
||||||
"slug": slugify(name),
|
"slug": slugify(name),
|
||||||
@@ -1613,22 +1750,17 @@ fn taxonomy_items_for_categories(names: &[String], posts: &[RenderPostRecord]) -
|
|||||||
|
|
||||||
fn taxonomy_items_for_tags(
|
fn taxonomy_items_for_tags(
|
||||||
names: &[String],
|
names: &[String],
|
||||||
posts: &[RenderPostRecord],
|
taxonomy: &TaxonomyContext,
|
||||||
tags: &[Tag],
|
tags: &[Tag],
|
||||||
) -> Vec<Value> {
|
) -> Vec<Value> {
|
||||||
names
|
names
|
||||||
.iter()
|
.iter()
|
||||||
.map(|name| {
|
.map(|name| {
|
||||||
let count = posts
|
let count = taxonomy
|
||||||
.iter()
|
.tag_counts
|
||||||
.filter(|record| {
|
.get(&name.to_lowercase())
|
||||||
record
|
.copied()
|
||||||
.post
|
.unwrap_or_default();
|
||||||
.tags
|
|
||||||
.iter()
|
|
||||||
.any(|tag| tag.eq_ignore_ascii_case(name))
|
|
||||||
})
|
|
||||||
.count();
|
|
||||||
let color = tags
|
let color = tags
|
||||||
.iter()
|
.iter()
|
||||||
.find(|tag| tag.name.eq_ignore_ascii_case(name))
|
.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(
|
fn link_context(
|
||||||
link: &crate::model::PostLink,
|
link: &crate::model::PostLink,
|
||||||
post_by_id: &HashMap<String, &RenderPostRecord>,
|
post_by_id: &HashMap<String, &RenderPostRecord>,
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ impl BdsApp {
|
|||||||
);
|
);
|
||||||
let mut render_task_ids = Vec::new();
|
let mut render_task_ids = Vec::new();
|
||||||
let mut tasks = Vec::new();
|
let mut tasks = Vec::new();
|
||||||
|
let prepared_generation = Arc::new(std::sync::OnceLock::new());
|
||||||
|
|
||||||
for section in sections {
|
for section in sections {
|
||||||
let label = t(self.ui_locale, generation_section_label_key(section));
|
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_data_dir = data_dir.clone();
|
||||||
let task_group_id = group_id.clone();
|
let task_group_id = group_id.clone();
|
||||||
let task_validation = validation.clone();
|
let task_validation = validation.clone();
|
||||||
|
let task_prepared_generation = Arc::clone(&prepared_generation);
|
||||||
let locale = self.ui_locale;
|
let locale = self.ui_locale;
|
||||||
tasks.push(Task::perform(
|
tasks.push(Task::perform(
|
||||||
async move {
|
async move {
|
||||||
@@ -185,6 +187,7 @@ impl BdsApp {
|
|||||||
task_id,
|
task_id,
|
||||||
section,
|
section,
|
||||||
task_validation,
|
task_validation,
|
||||||
|
task_prepared_generation,
|
||||||
force,
|
force,
|
||||||
locale,
|
locale,
|
||||||
page_work,
|
page_work,
|
||||||
@@ -494,6 +497,9 @@ fn run_site_generation_section(
|
|||||||
task_id: TaskId,
|
task_id: TaskId,
|
||||||
section: engine::generation::GenerationSection,
|
section: engine::generation::GenerationSection,
|
||||||
validation: Option<engine::validate_site::SiteValidationReport>,
|
validation: Option<engine::validate_site::SiteValidationReport>,
|
||||||
|
prepared_generation: Arc<
|
||||||
|
std::sync::OnceLock<Result<Arc<engine::generation::PreparedSiteGeneration>, String>>,
|
||||||
|
>,
|
||||||
force: bool,
|
force: bool,
|
||||||
locale: UiLocale,
|
locale: UiLocale,
|
||||||
expected_pages: usize,
|
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 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 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)
|
let posts = bds_core::db::queries::post::list_posts_by_project(db.conn(), &project_id)
|
||||||
.map_err(|error| error.to_string())?;
|
.map_err(|error| error.to_string())?;
|
||||||
let mut sources = Vec::new();
|
let sources = posts
|
||||||
for post in posts
|
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(engine::generation::has_published_snapshot)
|
.filter(engine::generation::has_published_snapshot)
|
||||||
{
|
.map(|post| engine::generation::load_published_post_source(&data_dir, post))
|
||||||
if task_manager.is_cancelled(task_id) {
|
.collect::<Result<Vec<_>, _>>()
|
||||||
return Err("cancelled".to_string());
|
|
||||||
}
|
|
||||||
if let Some(source) = engine::generation::load_published_post_source(&data_dir, post)
|
|
||||||
.map_err(|error| error.to_string())?
|
.map_err(|error| error.to_string())?
|
||||||
{
|
.into_iter()
|
||||||
sources.push(source);
|
.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");
|
let output_dir = data_dir.join("html");
|
||||||
std::fs::create_dir_all(&output_dir).map_err(|error| error.to_string())?;
|
std::fs::create_dir_all(&output_dir).map_err(|error| error.to_string())?;
|
||||||
let render_manager = Arc::clone(&task_manager);
|
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);
|
let is_cancelled = move || cancel_manager.is_cancelled(task_id);
|
||||||
match validation {
|
match validation {
|
||||||
Some(validation) => engine::generation::apply_validation_section_with_progress(
|
Some(validation) => engine::generation::apply_validation_prepared_section_with_progress(
|
||||||
db.conn(),
|
db.conn(),
|
||||||
&output_dir,
|
&output_dir,
|
||||||
&project_id,
|
&project_id,
|
||||||
&metadata,
|
prepared,
|
||||||
&sources,
|
|
||||||
&validation,
|
&validation,
|
||||||
section,
|
section,
|
||||||
on_page,
|
on_page,
|
||||||
on_rendered,
|
&on_rendered,
|
||||||
is_cancelled,
|
is_cancelled,
|
||||||
),
|
),
|
||||||
None if force => engine::generation::render_site_section_forced_with_progress(
|
None => engine::generation::render_prepared_site_section_with_progress(
|
||||||
db.conn(),
|
db.conn(),
|
||||||
&output_dir,
|
&output_dir,
|
||||||
&project_id,
|
&project_id,
|
||||||
&metadata,
|
prepared,
|
||||||
&sources,
|
|
||||||
section,
|
section,
|
||||||
|
force,
|
||||||
|
&on_rendered,
|
||||||
on_page,
|
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,
|
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(
|
#[expect(
|
||||||
clippy::too_many_arguments,
|
clippy::too_many_arguments,
|
||||||
reason = "arguments are independent panel state slices"
|
reason = "arguments are independent panel state slices"
|
||||||
@@ -459,3 +432,30 @@ fn post_link_button(locale: UiLocale, link: &ResolvedPostLink) -> Element<'stati
|
|||||||
.style(tab_inactive)
|
.style(tab_inactive)
|
||||||
.into()
|
.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