1297 lines
41 KiB
Rust
1297 lines
41 KiB
Rust
use std::collections::{BTreeMap, HashMap, HashSet};
|
|
use std::path::Path;
|
|
|
|
use crate::db::DbConnection as Connection;
|
|
use chrono::{DateTime, TimeZone, Utc};
|
|
use pagefind::api::PagefindIndex;
|
|
use pagefind::options::PagefindServiceConfig;
|
|
use walkdir::WalkDir;
|
|
|
|
use crate::db::queries;
|
|
use crate::engine::site_assets::write_bundled_site_assets;
|
|
use crate::engine::validate_site::SiteValidationReport;
|
|
use crate::engine::{EngineError, EngineResult};
|
|
use crate::model::{CategorySettings, Post, ProjectMetadata};
|
|
use crate::render::{
|
|
GeneratedWriteOutcome, PostLanguageVariant, build_calendar_json, build_canonical_post_path,
|
|
build_site_render_artifacts, build_site_section_render_artifacts,
|
|
build_targeted_site_section_render_artifacts, select_post_language_variant,
|
|
write_generated_bytes, write_generated_file,
|
|
};
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct PublishedPostSource {
|
|
pub post: Post,
|
|
pub body_markdown: String,
|
|
}
|
|
|
|
/// Whether a post has a published snapshot eligible for site generation.
|
|
pub fn has_published_snapshot(post: &Post) -> bool {
|
|
matches!(
|
|
post.status,
|
|
crate::model::PostStatus::Published | crate::model::PostStatus::Draft
|
|
) && !post.file_path.trim().is_empty()
|
|
}
|
|
|
|
/// Load the last-published body from disk, never from draft database content.
|
|
pub fn load_published_post_source(
|
|
data_dir: &Path,
|
|
post: Post,
|
|
) -> EngineResult<Option<PublishedPostSource>> {
|
|
if !has_published_snapshot(&post) {
|
|
return Ok(None);
|
|
}
|
|
let raw = std::fs::read_to_string(data_dir.join(&post.file_path))?;
|
|
let (_, body_markdown) =
|
|
crate::util::frontmatter::read_post_file(&raw).map_err(EngineError::Parse)?;
|
|
Ok(Some(PublishedPostSource {
|
|
post,
|
|
body_markdown,
|
|
}))
|
|
}
|
|
|
|
#[derive(Debug, Default, Clone)]
|
|
pub struct GenerationReport {
|
|
pub written_paths: Vec<String>,
|
|
pub skipped_paths: Vec<String>,
|
|
pub deleted_paths: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub enum GenerationSection {
|
|
Core,
|
|
Single,
|
|
Category,
|
|
Tag,
|
|
Date,
|
|
}
|
|
|
|
impl GenerationSection {
|
|
pub const ALL: [Self; 5] = [
|
|
Self::Core,
|
|
Self::Single,
|
|
Self::Category,
|
|
Self::Tag,
|
|
Self::Date,
|
|
];
|
|
}
|
|
|
|
impl GenerationReport {
|
|
pub fn append(&mut self, mut other: Self) {
|
|
self.written_paths.append(&mut other.written_paths);
|
|
self.skipped_paths.append(&mut other.skipped_paths);
|
|
self.deleted_paths.append(&mut other.deleted_paths);
|
|
}
|
|
}
|
|
|
|
pub fn generate_starter_site(
|
|
conn: &Connection,
|
|
output_dir: &Path,
|
|
project_id: &str,
|
|
metadata: &ProjectMetadata,
|
|
posts: &[PublishedPostSource],
|
|
_language: &str,
|
|
) -> EngineResult<GenerationReport> {
|
|
generate_starter_site_with_progress(
|
|
conn,
|
|
output_dir,
|
|
project_id,
|
|
metadata,
|
|
posts,
|
|
_language,
|
|
|_current, _total, _path| {},
|
|
)
|
|
}
|
|
|
|
/// Forget stored generated-file hashes so the next render writes every
|
|
/// artifact while repopulating the cache with its current content hash.
|
|
pub fn clear_generation_cache(conn: &Connection, project_id: &str) -> EngineResult<usize> {
|
|
use crate::db::schema::generated_file_hashes;
|
|
use diesel::prelude::*;
|
|
|
|
Ok(conn.with(|connection| {
|
|
diesel::delete(
|
|
generated_file_hashes::table.filter(generated_file_hashes::project_id.eq(project_id)),
|
|
)
|
|
.execute(connection)
|
|
})?)
|
|
}
|
|
|
|
pub fn generate_starter_site_with_progress(
|
|
conn: &Connection,
|
|
output_dir: &Path,
|
|
project_id: &str,
|
|
metadata: &ProjectMetadata,
|
|
posts: &[PublishedPostSource],
|
|
_language: &str,
|
|
mut on_page: impl FnMut(usize, usize, &str),
|
|
) -> EngineResult<GenerationReport> {
|
|
let mut report = GenerationReport::default();
|
|
for section in GenerationSection::ALL {
|
|
report.append(render_site_section_with_progress(
|
|
conn,
|
|
output_dir,
|
|
project_id,
|
|
metadata,
|
|
posts,
|
|
section,
|
|
&mut on_page,
|
|
|| false,
|
|
)?);
|
|
}
|
|
report.append(build_site_search_index(
|
|
conn, output_dir, project_id, metadata,
|
|
)?);
|
|
Ok(report)
|
|
}
|
|
|
|
#[expect(
|
|
clippy::too_many_arguments,
|
|
reason = "section rendering uses the existing generation context and two callbacks"
|
|
)]
|
|
pub fn render_site_section_with_progress(
|
|
conn: &Connection,
|
|
output_dir: &Path,
|
|
project_id: &str,
|
|
metadata: &ProjectMetadata,
|
|
posts: &[PublishedPostSource],
|
|
section: GenerationSection,
|
|
mut on_page: impl FnMut(usize, usize, &str),
|
|
mut is_cancelled: impl FnMut() -> bool,
|
|
) -> EngineResult<GenerationReport> {
|
|
if is_cancelled() {
|
|
return Err(EngineError::Validation("cancelled".to_string()));
|
|
}
|
|
let data_dir = project_data_dir(output_dir);
|
|
let input_posts = posts
|
|
.iter()
|
|
.map(|source| (source.post.clone(), source.body_markdown.clone()))
|
|
.collect::<Vec<_>>();
|
|
let artifacts = build_site_section_render_artifacts(
|
|
conn,
|
|
&data_dir,
|
|
project_id,
|
|
metadata,
|
|
&input_posts,
|
|
section,
|
|
)
|
|
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
|
let mut report = GenerationReport::default();
|
|
let total_pages = artifacts.pages.len();
|
|
for (index, page) in artifacts.pages.iter().enumerate() {
|
|
if is_cancelled() {
|
|
return Err(EngineError::Validation("cancelled".to_string()));
|
|
}
|
|
write_out(
|
|
conn,
|
|
output_dir,
|
|
project_id,
|
|
&page.relative_path,
|
|
&page.html,
|
|
&mut report,
|
|
)?;
|
|
on_page(index + 1, total_pages, &page.url_path);
|
|
}
|
|
|
|
if section == GenerationSection::Core {
|
|
write_core_outputs(
|
|
conn,
|
|
output_dir,
|
|
project_id,
|
|
metadata,
|
|
&data_dir,
|
|
posts,
|
|
&artifacts.route_manifest,
|
|
None,
|
|
&mut report,
|
|
&mut is_cancelled,
|
|
)?;
|
|
}
|
|
Ok(report)
|
|
}
|
|
|
|
pub fn sections_from_validation_report(
|
|
report: &SiteValidationReport,
|
|
metadata: &ProjectMetadata,
|
|
) -> Vec<GenerationSection> {
|
|
let mut sections = HashSet::new();
|
|
let mut saw_unknown = false;
|
|
|
|
for path in report
|
|
.missing_pages
|
|
.iter()
|
|
.chain(report.extra_pages.iter())
|
|
.chain(report.stale_pages.iter())
|
|
{
|
|
match classify_generated_path(path, metadata) {
|
|
Some(section) => {
|
|
sections.insert(section);
|
|
}
|
|
None => {
|
|
saw_unknown = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if saw_unknown && !report_is_empty(report) {
|
|
return all_sections();
|
|
}
|
|
|
|
let mut ordered = sections.into_iter().collect::<Vec<_>>();
|
|
ordered.sort_by_key(section_sort_key);
|
|
ordered
|
|
}
|
|
|
|
pub fn apply_validation_sections(
|
|
conn: &Connection,
|
|
output_dir: &Path,
|
|
project_id: &str,
|
|
metadata: &ProjectMetadata,
|
|
posts: &[PublishedPostSource],
|
|
sections: &[GenerationSection],
|
|
) -> EngineResult<GenerationReport> {
|
|
if sections.is_empty() {
|
|
return Ok(GenerationReport::default());
|
|
}
|
|
|
|
let section_set = sections.iter().copied().collect::<HashSet<_>>();
|
|
let data_dir = project_data_dir(output_dir);
|
|
let input_posts = posts
|
|
.iter()
|
|
.map(|source| (source.post.clone(), source.body_markdown.clone()))
|
|
.collect::<Vec<_>>();
|
|
let artifacts =
|
|
build_site_render_artifacts(conn, &data_dir, project_id, metadata, &input_posts)
|
|
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
|
let mut report = GenerationReport::default();
|
|
let expected_paths = expected_paths_for_sections(metadata, &artifacts.pages, §ion_set);
|
|
|
|
for section in sections {
|
|
report.append(render_site_section_with_progress(
|
|
conn,
|
|
output_dir,
|
|
project_id,
|
|
metadata,
|
|
posts,
|
|
*section,
|
|
|_current, _total, _url| {},
|
|
|| false,
|
|
)?);
|
|
}
|
|
|
|
remove_extra_section_paths(
|
|
output_dir,
|
|
&expected_paths,
|
|
metadata,
|
|
§ion_set,
|
|
&mut report,
|
|
)?;
|
|
report.append(build_site_search_index(
|
|
conn, output_dir, project_id, metadata,
|
|
)?);
|
|
|
|
Ok(report)
|
|
}
|
|
|
|
#[expect(
|
|
clippy::too_many_arguments,
|
|
reason = "targeted apply adds its validation report to the existing generation context"
|
|
)]
|
|
pub fn apply_validation_section_with_progress(
|
|
conn: &Connection,
|
|
output_dir: &Path,
|
|
project_id: &str,
|
|
metadata: &ProjectMetadata,
|
|
posts: &[PublishedPostSource],
|
|
validation: &SiteValidationReport,
|
|
section: GenerationSection,
|
|
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 data_dir = project_data_dir(output_dir);
|
|
let input_posts = posts
|
|
.iter()
|
|
.map(|source| (source.post.clone(), source.body_markdown.clone()))
|
|
.collect::<Vec<_>>();
|
|
let requested = validation
|
|
.missing_pages
|
|
.iter()
|
|
.chain(validation.stale_pages.iter())
|
|
.cloned()
|
|
.collect::<HashSet<_>>();
|
|
let fallback = validation
|
|
.missing_pages
|
|
.iter()
|
|
.chain(validation.extra_pages.iter())
|
|
.chain(validation.stale_pages.iter())
|
|
.any(|path| classify_generated_path(path, metadata).is_none());
|
|
let artifacts = if fallback {
|
|
build_site_section_render_artifacts(
|
|
conn,
|
|
&data_dir,
|
|
project_id,
|
|
metadata,
|
|
&input_posts,
|
|
section,
|
|
)
|
|
} else {
|
|
build_targeted_site_section_render_artifacts(
|
|
conn,
|
|
&data_dir,
|
|
project_id,
|
|
metadata,
|
|
&input_posts,
|
|
section,
|
|
&requested,
|
|
)
|
|
}
|
|
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
|
let mut report = GenerationReport::default();
|
|
let total_pages = artifacts.pages.len();
|
|
for (index, page) in artifacts.pages.iter().enumerate() {
|
|
if is_cancelled() {
|
|
return Err(EngineError::Validation("cancelled".to_string()));
|
|
}
|
|
write_out(
|
|
conn,
|
|
output_dir,
|
|
project_id,
|
|
&page.relative_path,
|
|
&page.html,
|
|
&mut report,
|
|
)?;
|
|
on_page(index + 1, total_pages, &page.url_path);
|
|
}
|
|
|
|
if section == GenerationSection::Core {
|
|
write_core_outputs(
|
|
conn,
|
|
output_dir,
|
|
project_id,
|
|
metadata,
|
|
&data_dir,
|
|
posts,
|
|
&artifacts.route_manifest,
|
|
(!fallback).then_some(&requested),
|
|
&mut report,
|
|
&mut is_cancelled,
|
|
)?;
|
|
}
|
|
|
|
for path in &validation.extra_pages {
|
|
if is_cancelled() {
|
|
return Err(EngineError::Validation("cancelled".to_string()));
|
|
}
|
|
let owned_by_section = classify_generated_path(path, metadata)
|
|
.map_or(section == GenerationSection::Core, |owner| owner == section);
|
|
if owned_by_section && output_dir.join(path).is_file() {
|
|
std::fs::remove_file(output_dir.join(path)).map_err(EngineError::Io)?;
|
|
report.deleted_paths.push(path.clone());
|
|
}
|
|
}
|
|
Ok(report)
|
|
}
|
|
|
|
#[expect(
|
|
clippy::too_many_arguments,
|
|
reason = "generation context is existing domain data"
|
|
)]
|
|
fn write_core_outputs(
|
|
conn: &Connection,
|
|
output_dir: &Path,
|
|
project_id: &str,
|
|
metadata: &ProjectMetadata,
|
|
data_dir: &Path,
|
|
published_posts: &[PublishedPostSource],
|
|
route_manifest: &[crate::render::SitePage],
|
|
requested: Option<&HashSet<String>>,
|
|
report: &mut GenerationReport,
|
|
is_cancelled: &mut impl FnMut() -> bool,
|
|
) -> EngineResult<()> {
|
|
if requested.is_none() {
|
|
write_bundled_site_assets(conn, output_dir, project_id, report)?;
|
|
}
|
|
let mut outputs = vec![(
|
|
"calendar.json".to_string(),
|
|
build_calendar_json(
|
|
&published_posts
|
|
.iter()
|
|
.map(|source| source.post.clone())
|
|
.collect::<Vec<_>>(),
|
|
)?,
|
|
)];
|
|
for render_language in render_languages(metadata) {
|
|
let localized_posts =
|
|
localized_sources(conn, data_dir, published_posts, &render_language, metadata)?;
|
|
let is_main = render_language == metadata.main_language.as_deref().unwrap_or("en");
|
|
let prefix = if is_main {
|
|
String::new()
|
|
} else {
|
|
format!("{render_language}/")
|
|
};
|
|
let mut feed_posts = if is_main {
|
|
published_posts.to_vec()
|
|
} else {
|
|
localized_posts
|
|
.iter()
|
|
.filter(|source| {
|
|
source
|
|
.post
|
|
.language
|
|
.as_deref()
|
|
.is_some_and(|language| language.eq_ignore_ascii_case(&render_language))
|
|
})
|
|
.cloned()
|
|
.collect::<Vec<_>>()
|
|
};
|
|
sort_published_sources(&mut feed_posts);
|
|
outputs.push((
|
|
format!("{prefix}rss.xml"),
|
|
build_rss_xml(metadata, &feed_posts, &render_language),
|
|
));
|
|
outputs.push((
|
|
format!("{prefix}atom.xml"),
|
|
build_atom_xml(metadata, &feed_posts, &render_language),
|
|
));
|
|
if is_main {
|
|
let category_settings = load_category_settings(data_dir);
|
|
let mut sitemap_posts = published_posts.to_vec();
|
|
sort_published_sources(&mut sitemap_posts);
|
|
let sitemap_list_posts = filter_posts_for_lists(&sitemap_posts, &category_settings);
|
|
outputs.push((
|
|
"sitemap.xml".to_string(),
|
|
build_sitemap_xml(
|
|
metadata,
|
|
route_manifest,
|
|
&sitemap_posts,
|
|
&sitemap_list_posts,
|
|
&render_language,
|
|
),
|
|
));
|
|
}
|
|
}
|
|
for (path, content) in outputs {
|
|
if is_cancelled() {
|
|
return Err(EngineError::Validation("cancelled".to_string()));
|
|
}
|
|
if requested.is_none_or(|requested| requested.contains(&path)) {
|
|
write_out(conn, output_dir, project_id, &path, &content, report)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn write_out(
|
|
conn: &Connection,
|
|
output_dir: &Path,
|
|
project_id: &str,
|
|
relative_path: &str,
|
|
content: &str,
|
|
report: &mut GenerationReport,
|
|
) -> EngineResult<()> {
|
|
match write_generated_file(conn, output_dir, project_id, relative_path, content)
|
|
.map_err(|error| EngineError::Parse(error.to_string()))?
|
|
{
|
|
GeneratedWriteOutcome::Written => report.written_paths.push(relative_path.to_string()),
|
|
GeneratedWriteOutcome::SkippedUnchanged => {
|
|
report.skipped_paths.push(relative_path.to_string())
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn build_site_search_index(
|
|
conn: &Connection,
|
|
output_dir: &Path,
|
|
project_id: &str,
|
|
metadata: &ProjectMetadata,
|
|
) -> EngineResult<GenerationReport> {
|
|
build_site_search_index_with_progress(
|
|
conn,
|
|
output_dir,
|
|
project_id,
|
|
metadata,
|
|
|_current, _total, _path| {},
|
|
|| false,
|
|
)
|
|
}
|
|
|
|
pub fn build_site_search_index_with_progress(
|
|
conn: &Connection,
|
|
output_dir: &Path,
|
|
project_id: &str,
|
|
metadata: &ProjectMetadata,
|
|
mut on_file: impl FnMut(usize, usize, &str),
|
|
mut is_cancelled: impl FnMut() -> bool,
|
|
) -> EngineResult<GenerationReport> {
|
|
let mut documents = Vec::new();
|
|
if output_dir.exists() {
|
|
for entry in WalkDir::new(output_dir).into_iter().filter_map(Result::ok) {
|
|
if !entry.file_type().is_file() {
|
|
continue;
|
|
}
|
|
let relative_path = entry
|
|
.path()
|
|
.strip_prefix(output_dir)
|
|
.unwrap_or(entry.path())
|
|
.to_string_lossy()
|
|
.replace('\\', "/");
|
|
if !relative_path.ends_with(".html")
|
|
|| relative_path.starts_with("pagefind/")
|
|
|| relative_path.contains("/pagefind/")
|
|
{
|
|
continue;
|
|
}
|
|
let language = render_languages(metadata)
|
|
.into_iter()
|
|
.find(|language| relative_path.starts_with(&format!("{language}/")))
|
|
.unwrap_or_else(|| {
|
|
metadata
|
|
.main_language
|
|
.clone()
|
|
.unwrap_or_else(|| "en".into())
|
|
});
|
|
documents.push(crate::render::PagefindDocument {
|
|
language,
|
|
url_path: String::new(),
|
|
html: std::fs::read_to_string(entry.path()).map_err(EngineError::Io)?,
|
|
relative_path,
|
|
});
|
|
}
|
|
}
|
|
documents.sort_by(|left, right| left.relative_path.cmp(&right.relative_path));
|
|
|
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
.map_err(EngineError::Io)?;
|
|
|
|
let grouped = documents.iter().fold(
|
|
BTreeMap::<String, Vec<&crate::render::PagefindDocument>>::new(),
|
|
|mut acc, doc| {
|
|
acc.entry(doc.language.clone()).or_default().push(doc);
|
|
acc
|
|
},
|
|
);
|
|
let mut outputs = Vec::new();
|
|
for (language, docs) in grouped {
|
|
if is_cancelled() {
|
|
return Err(EngineError::Validation("cancelled".to_string()));
|
|
}
|
|
let config = PagefindServiceConfig::builder()
|
|
.keep_index_url(true)
|
|
.force_language(language.clone())
|
|
.build();
|
|
let mut index = PagefindIndex::new(Some(config))
|
|
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
|
let output_prefix = if language == metadata.main_language.as_deref().unwrap_or("en") {
|
|
"pagefind".to_string()
|
|
} else {
|
|
format!("{language}/pagefind")
|
|
};
|
|
runtime.block_on(async {
|
|
for doc in docs {
|
|
if is_cancelled() {
|
|
return Err(EngineError::Validation("cancelled".to_string()));
|
|
}
|
|
index
|
|
.add_html_file(Some(doc.relative_path.clone()), None, doc.html.clone())
|
|
.await
|
|
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
|
}
|
|
let files = index
|
|
.get_files()
|
|
.await
|
|
.map_err(|error| EngineError::Parse(error.to_string()))?;
|
|
for file in files {
|
|
outputs.push((
|
|
format!(
|
|
"{output_prefix}/{}",
|
|
file.filename.to_string_lossy().trim_start_matches('/')
|
|
),
|
|
file.contents,
|
|
));
|
|
}
|
|
Ok::<(), EngineError>(())
|
|
})?;
|
|
}
|
|
outputs.sort_by(|left, right| {
|
|
left.0
|
|
.ends_with("pagefind-entry.json")
|
|
.cmp(&right.0.ends_with("pagefind-entry.json"))
|
|
.then_with(|| left.0.cmp(&right.0))
|
|
});
|
|
let total = outputs.len();
|
|
let mut report = GenerationReport::default();
|
|
let expected = outputs
|
|
.iter()
|
|
.map(|(relative, _)| relative.clone())
|
|
.collect::<HashSet<_>>();
|
|
for (index, (relative, contents)) in outputs.into_iter().enumerate() {
|
|
if is_cancelled() {
|
|
return Err(EngineError::Validation("cancelled".to_string()));
|
|
}
|
|
match write_generated_bytes(conn, output_dir, project_id, &relative, &contents)
|
|
.map_err(|error| EngineError::Parse(error.to_string()))?
|
|
{
|
|
GeneratedWriteOutcome::Written => report.written_paths.push(relative.clone()),
|
|
GeneratedWriteOutcome::SkippedUnchanged => report.skipped_paths.push(relative.clone()),
|
|
}
|
|
on_file(index + 1, total, &relative);
|
|
}
|
|
for language in render_languages(metadata) {
|
|
let prefix = if language == metadata.main_language.as_deref().unwrap_or("en") {
|
|
"pagefind".to_string()
|
|
} else {
|
|
format!("{language}/pagefind")
|
|
};
|
|
let index_dir = output_dir.join(&prefix);
|
|
if !index_dir.exists() {
|
|
continue;
|
|
}
|
|
for entry in WalkDir::new(&index_dir).into_iter().filter_map(Result::ok) {
|
|
if is_cancelled() {
|
|
return Err(EngineError::Validation("cancelled".to_string()));
|
|
}
|
|
if !entry.file_type().is_file() {
|
|
continue;
|
|
}
|
|
let relative = entry
|
|
.path()
|
|
.strip_prefix(output_dir)
|
|
.unwrap_or(entry.path())
|
|
.to_string_lossy()
|
|
.replace('\\', "/");
|
|
if !expected.contains(&relative) {
|
|
std::fs::remove_file(entry.path()).map_err(EngineError::Io)?;
|
|
report.deleted_paths.push(relative);
|
|
}
|
|
}
|
|
}
|
|
Ok(report)
|
|
}
|
|
|
|
fn project_data_dir(output_dir: &Path) -> std::path::PathBuf {
|
|
if output_dir.join("meta").exists() {
|
|
output_dir.to_path_buf()
|
|
} else {
|
|
output_dir.parent().unwrap_or(output_dir).to_path_buf()
|
|
}
|
|
}
|
|
|
|
fn expected_paths_for_sections(
|
|
metadata: &ProjectMetadata,
|
|
pages: &[crate::render::SitePage],
|
|
sections: &HashSet<GenerationSection>,
|
|
) -> HashSet<String> {
|
|
let mut expected = pages
|
|
.iter()
|
|
.filter(|page| path_matches_sections(&page.relative_path, metadata, sections))
|
|
.map(|page| page.relative_path.clone())
|
|
.collect::<HashSet<_>>();
|
|
|
|
if sections.contains(&GenerationSection::Core) {
|
|
expected.insert("calendar.json".to_string());
|
|
expected.insert("rss.xml".to_string());
|
|
for language in render_languages(metadata) {
|
|
let prefix = if language
|
|
== metadata
|
|
.main_language
|
|
.clone()
|
|
.unwrap_or_else(|| "en".to_string())
|
|
{
|
|
String::new()
|
|
} else {
|
|
format!("{language}/")
|
|
};
|
|
expected.insert(format!("{prefix}rss.xml"));
|
|
expected.insert(format!("{prefix}atom.xml"));
|
|
}
|
|
expected.insert("sitemap.xml".to_string());
|
|
}
|
|
|
|
expected
|
|
}
|
|
|
|
fn remove_extra_section_paths(
|
|
output_dir: &Path,
|
|
expected: &HashSet<String>,
|
|
metadata: &ProjectMetadata,
|
|
sections: &HashSet<GenerationSection>,
|
|
report: &mut GenerationReport,
|
|
) -> EngineResult<()> {
|
|
if !output_dir.exists() {
|
|
return Ok(());
|
|
}
|
|
|
|
let mut deleted = Vec::new();
|
|
for entry in WalkDir::new(output_dir).into_iter().filter_map(Result::ok) {
|
|
if !entry.file_type().is_file() {
|
|
continue;
|
|
}
|
|
let rel = entry
|
|
.path()
|
|
.strip_prefix(output_dir)
|
|
.unwrap_or(entry.path())
|
|
.to_string_lossy()
|
|
.replace('\\', "/");
|
|
if rel.starts_with("meta/")
|
|
|| rel.starts_with("posts/")
|
|
|| rel.starts_with("media/")
|
|
|| rel.starts_with("assets/")
|
|
|| rel.starts_with("pagefind")
|
|
|| rel.contains("/pagefind/")
|
|
{
|
|
continue;
|
|
}
|
|
if !matches_generated_extension(&rel)
|
|
|| !path_matches_sections(&rel, metadata, sections)
|
|
|| expected.contains(&rel)
|
|
{
|
|
continue;
|
|
}
|
|
std::fs::remove_file(entry.path()).map_err(EngineError::Io)?;
|
|
deleted.push(rel);
|
|
}
|
|
|
|
deleted.sort();
|
|
report.deleted_paths.extend(deleted);
|
|
Ok(())
|
|
}
|
|
|
|
fn path_matches_sections(
|
|
path: &str,
|
|
metadata: &ProjectMetadata,
|
|
sections: &HashSet<GenerationSection>,
|
|
) -> bool {
|
|
classify_generated_path(path, metadata)
|
|
.map(|section| sections.contains(§ion))
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
pub(crate) fn classify_generated_path(
|
|
path: &str,
|
|
metadata: &ProjectMetadata,
|
|
) -> Option<GenerationSection> {
|
|
if path.ends_with(".xml") || path.ends_with(".json") {
|
|
return Some(GenerationSection::Core);
|
|
}
|
|
|
|
let mut parts = path.split('/').collect::<Vec<_>>();
|
|
if parts.is_empty() {
|
|
return None;
|
|
}
|
|
if has_language_prefix(&parts, metadata) {
|
|
parts.remove(0);
|
|
}
|
|
|
|
match parts.as_slice() {
|
|
["index.html"] | ["404.html"] | ["page", _, "index.html"] => Some(GenerationSection::Core),
|
|
["category", ..] => Some(GenerationSection::Category),
|
|
["tag", ..] => Some(GenerationSection::Tag),
|
|
[year, "index.html"] if is_year_segment(year) => Some(GenerationSection::Date),
|
|
[year, "page", _, "index.html"] if is_year_segment(year) => Some(GenerationSection::Date),
|
|
[year, month, "index.html"] if is_year_segment(year) && is_month_segment(month) => {
|
|
Some(GenerationSection::Date)
|
|
}
|
|
[year, month, "page", _, "index.html"]
|
|
if is_year_segment(year) && is_month_segment(month) =>
|
|
{
|
|
Some(GenerationSection::Date)
|
|
}
|
|
[year, month, day, "index.html"]
|
|
if is_year_segment(year) && is_month_segment(month) && is_day_segment(day) =>
|
|
{
|
|
Some(GenerationSection::Date)
|
|
}
|
|
[year, month, day, "page", _, "index.html"]
|
|
if is_year_segment(year) && is_month_segment(month) && is_day_segment(day) =>
|
|
{
|
|
Some(GenerationSection::Date)
|
|
}
|
|
[year, month, day, _slug, "index.html"]
|
|
if is_year_segment(year) && is_month_segment(month) && is_day_segment(day) =>
|
|
{
|
|
Some(GenerationSection::Single)
|
|
}
|
|
[_slug, "index.html"] => Some(GenerationSection::Core),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn has_language_prefix(parts: &[&str], metadata: &ProjectMetadata) -> bool {
|
|
parts.len() > 1
|
|
&& render_languages(metadata)
|
|
.iter()
|
|
.any(|language| language.eq_ignore_ascii_case(parts[0]))
|
|
}
|
|
|
|
fn is_year_segment(value: &str) -> bool {
|
|
value.len() == 4 && value.chars().all(|ch| ch.is_ascii_digit())
|
|
}
|
|
|
|
fn is_month_segment(value: &str) -> bool {
|
|
value.len() == 2 && value.chars().all(|ch| ch.is_ascii_digit())
|
|
}
|
|
|
|
fn is_day_segment(value: &str) -> bool {
|
|
is_month_segment(value)
|
|
}
|
|
|
|
fn matches_generated_extension(path: &str) -> bool {
|
|
path.ends_with(".html") || path.ends_with(".xml") || path.ends_with(".json")
|
|
}
|
|
|
|
fn all_sections() -> Vec<GenerationSection> {
|
|
GenerationSection::ALL.to_vec()
|
|
}
|
|
|
|
fn section_sort_key(section: &GenerationSection) -> u8 {
|
|
match section {
|
|
GenerationSection::Core => 0,
|
|
GenerationSection::Single => 1,
|
|
GenerationSection::Category => 2,
|
|
GenerationSection::Tag => 3,
|
|
GenerationSection::Date => 4,
|
|
}
|
|
}
|
|
|
|
fn report_is_empty(report: &SiteValidationReport) -> bool {
|
|
report.missing_pages.is_empty()
|
|
&& report.extra_pages.is_empty()
|
|
&& report.stale_pages.is_empty()
|
|
}
|
|
|
|
fn render_languages(metadata: &ProjectMetadata) -> Vec<String> {
|
|
let main = metadata
|
|
.main_language
|
|
.clone()
|
|
.unwrap_or_else(|| "en".to_string());
|
|
let mut languages = vec![main.clone()];
|
|
for language in &metadata.blog_languages {
|
|
if !languages
|
|
.iter()
|
|
.any(|existing| existing.eq_ignore_ascii_case(language))
|
|
{
|
|
languages.push(language.clone());
|
|
}
|
|
}
|
|
languages
|
|
}
|
|
|
|
fn localized_sources(
|
|
conn: &Connection,
|
|
data_dir: &Path,
|
|
posts: &[PublishedPostSource],
|
|
language: &str,
|
|
metadata: &ProjectMetadata,
|
|
) -> EngineResult<Vec<PublishedPostSource>> {
|
|
let main_language = metadata.main_language.as_deref().unwrap_or("en");
|
|
let mut localized = Vec::new();
|
|
for source in posts {
|
|
let translation = queries::post_translation::get_post_translation_by_post_and_language(
|
|
conn,
|
|
&source.post.id,
|
|
language,
|
|
)
|
|
.ok()
|
|
.filter(|translation| {
|
|
!translation.file_path.trim().is_empty()
|
|
&& data_dir
|
|
.join(translation.file_path.trim_start_matches('/'))
|
|
.is_file()
|
|
});
|
|
match select_post_language_variant(
|
|
&source.post,
|
|
language,
|
|
main_language,
|
|
translation.is_some(),
|
|
) {
|
|
Some(PostLanguageVariant::Base) => localized.push(source.clone()),
|
|
Some(PostLanguageVariant::Translation) => {
|
|
let Some(translation) = translation else {
|
|
continue;
|
|
};
|
|
let raw = std::fs::read_to_string(
|
|
data_dir.join(translation.file_path.trim_start_matches('/')),
|
|
)
|
|
.map_err(EngineError::Io)?;
|
|
let (_, body) = crate::util::frontmatter::read_translation_file(&raw)
|
|
.map_err(EngineError::Parse)?;
|
|
let mut translated_post = source.post.clone();
|
|
translated_post.id = translation.id.clone();
|
|
translated_post.title = translation.title.clone();
|
|
translated_post.excerpt = translation.excerpt.clone();
|
|
translated_post.language = Some(translation.language.clone());
|
|
translated_post.status = translation.status.clone();
|
|
translated_post.file_path = translation.file_path.clone();
|
|
translated_post.updated_at = translation.updated_at;
|
|
translated_post.published_at =
|
|
translation.published_at.or(source.post.published_at);
|
|
localized.push(PublishedPostSource {
|
|
post: translated_post,
|
|
body_markdown: body,
|
|
});
|
|
}
|
|
None => {}
|
|
}
|
|
}
|
|
sort_published_sources(&mut localized);
|
|
Ok(localized)
|
|
}
|
|
|
|
fn sort_published_sources(posts: &mut [PublishedPostSource]) {
|
|
posts.sort_by(|left, right| {
|
|
right
|
|
.post
|
|
.created_at
|
|
.cmp(&left.post.created_at)
|
|
.then_with(|| right.post.published_at.cmp(&left.post.published_at))
|
|
.then_with(|| left.post.slug.cmp(&right.post.slug))
|
|
});
|
|
}
|
|
|
|
fn load_category_settings(data_dir: &Path) -> HashMap<String, CategorySettings> {
|
|
crate::engine::meta::read_category_meta_json(data_dir).unwrap_or_default()
|
|
}
|
|
|
|
fn filter_posts_for_lists(
|
|
posts: &[PublishedPostSource],
|
|
category_settings: &HashMap<String, CategorySettings>,
|
|
) -> Vec<PublishedPostSource> {
|
|
posts
|
|
.iter()
|
|
.filter(|source| {
|
|
!source.post.categories.iter().any(|category| {
|
|
category_settings
|
|
.get(category)
|
|
.is_some_and(|settings| !settings.render_in_lists)
|
|
})
|
|
})
|
|
.cloned()
|
|
.collect()
|
|
}
|
|
|
|
pub(crate) fn build_rss_xml(
|
|
metadata: &ProjectMetadata,
|
|
posts: &[PublishedPostSource],
|
|
language: &str,
|
|
) -> String {
|
|
let base_url = metadata
|
|
.public_url
|
|
.as_deref()
|
|
.unwrap_or("")
|
|
.trim_end_matches('/');
|
|
let mut xml = format!(
|
|
"<rss><channel><title>{} ({})</title>",
|
|
escape_xml(&metadata.name),
|
|
escape_xml(language)
|
|
);
|
|
|
|
for source in posts {
|
|
let url = post_absolute_url(base_url, metadata, source, language);
|
|
xml.push_str(&format!(
|
|
"<item><title>{}</title><link>{url}</link></item>",
|
|
escape_xml(&source.post.title)
|
|
));
|
|
}
|
|
xml.push_str("</channel></rss>");
|
|
xml
|
|
}
|
|
|
|
pub(crate) fn build_atom_xml(
|
|
metadata: &ProjectMetadata,
|
|
posts: &[PublishedPostSource],
|
|
language: &str,
|
|
) -> String {
|
|
let base_url = metadata
|
|
.public_url
|
|
.as_deref()
|
|
.unwrap_or("")
|
|
.trim_end_matches('/');
|
|
let mut xml = format!(
|
|
"<feed><title>{} ({})</title>",
|
|
escape_xml(&metadata.name),
|
|
escape_xml(language)
|
|
);
|
|
|
|
for source in posts {
|
|
let url = post_absolute_url(base_url, metadata, source, language);
|
|
xml.push_str(&format!(
|
|
"<entry><title>{}</title><id>{url}</id></entry>",
|
|
escape_xml(&source.post.title)
|
|
));
|
|
}
|
|
xml.push_str("</feed>");
|
|
xml
|
|
}
|
|
|
|
fn post_absolute_url(
|
|
base_url: &str,
|
|
metadata: &ProjectMetadata,
|
|
source: &PublishedPostSource,
|
|
language: &str,
|
|
) -> String {
|
|
format!(
|
|
"{base_url}{}/",
|
|
build_canonical_post_path(
|
|
&source.post,
|
|
language,
|
|
metadata.main_language.as_deref().unwrap_or("en")
|
|
)
|
|
.trim_end_matches('/')
|
|
)
|
|
}
|
|
|
|
fn build_sitemap_xml(
|
|
metadata: &ProjectMetadata,
|
|
pages: &[crate::render::SitePage],
|
|
posts: &[PublishedPostSource],
|
|
list_posts: &[PublishedPostSource],
|
|
language: &str,
|
|
) -> String {
|
|
let base_url = metadata
|
|
.public_url
|
|
.as_deref()
|
|
.unwrap_or("")
|
|
.trim_end_matches('/');
|
|
let main_language = metadata.main_language.as_deref().unwrap_or("en");
|
|
let languages = render_languages(metadata);
|
|
let index_lastmod = list_posts
|
|
.first()
|
|
.and_then(|post| timestamp(post.post.updated_at))
|
|
.unwrap_or_else(Utc::now)
|
|
.to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
|
|
let mut post_lastmod_by_path = HashMap::new();
|
|
for source in posts {
|
|
let Some(lastmod) = timestamp(source.post.updated_at) else {
|
|
continue;
|
|
};
|
|
let lastmod = lastmod.to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
|
|
post_lastmod_by_path.insert(
|
|
build_canonical_post_path(&source.post, language, main_language),
|
|
lastmod.clone(),
|
|
);
|
|
if source
|
|
.post
|
|
.categories
|
|
.iter()
|
|
.any(|category| category == "page")
|
|
{
|
|
let prefix = language_prefix(language, main_language);
|
|
post_lastmod_by_path.insert(format!("{prefix}/{}", source.post.slug), lastmod.clone());
|
|
}
|
|
}
|
|
let page_groups = group_pages_by_logical_path(pages, &languages, main_language);
|
|
|
|
let mut xml = vec![
|
|
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>".to_string(),
|
|
"<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\" xmlns:xhtml=\"http://www.w3.org/1999/xhtml\">".to_string(),
|
|
];
|
|
|
|
let mut language_pages = pages
|
|
.iter()
|
|
.filter(|page| page.language == language)
|
|
.collect::<Vec<_>>();
|
|
language_pages.sort_by(|left, right| {
|
|
let left_key = logical_page_key(&left.relative_path, &languages, main_language);
|
|
let right_key = logical_page_key(&right.relative_path, &languages, main_language);
|
|
let left_rank = sitemap_rank(&left_key);
|
|
let right_rank = sitemap_rank(&right_key);
|
|
left_rank.cmp(&right_rank).then_with(|| {
|
|
if (4..=6).contains(&left_rank) {
|
|
right_key.cmp(&left_key)
|
|
} else {
|
|
std::cmp::Ordering::Equal
|
|
}
|
|
})
|
|
});
|
|
|
|
for page in language_pages {
|
|
let logical_key = logical_page_key(&page.relative_path, &languages, main_language);
|
|
if logical_key.contains("/page/") && !logical_key.starts_with("page/") {
|
|
continue;
|
|
}
|
|
let url_path = sitemap_url_path(&page.url_path);
|
|
let url = format!("{base_url}{url_path}");
|
|
let alternates = page_groups.get(&logical_key);
|
|
let lastmod = post_lastmod_by_path
|
|
.get(&page.url_path)
|
|
.cloned()
|
|
.unwrap_or_else(|| index_lastmod.clone());
|
|
let is_home = page.url_path == language_root_url_path(language, main_language);
|
|
let (changefreq, priority) = sitemap_metadata(&logical_key, is_home);
|
|
let rank = sitemap_rank(&logical_key);
|
|
xml.push(" <url>".to_string());
|
|
xml.push(format!(" <loc>{}</loc>", escape_xml(&url)));
|
|
xml.push(format!(" <lastmod>{lastmod}</lastmod>"));
|
|
xml.push(format!(" <changefreq>{}</changefreq>", changefreq));
|
|
xml.push(format!(" <priority>{}</priority>", priority));
|
|
if !matches!(rank, 2 | 3) {
|
|
for alternate_language in &languages {
|
|
let alternate_path = if alternate_language == main_language {
|
|
page.url_path.clone()
|
|
} else if page.url_path == "/" {
|
|
format!("/{alternate_language}")
|
|
} else {
|
|
format!("/{alternate_language}{}", page.url_path)
|
|
};
|
|
let href = format!("{base_url}{}", sitemap_url_path(&alternate_path));
|
|
xml.push(format!(
|
|
" <xhtml:link rel=\"alternate\" hreflang=\"{}\" href=\"{}\" />",
|
|
escape_xml(alternate_language),
|
|
escape_xml(&href),
|
|
));
|
|
}
|
|
let href = format!("{base_url}{}", sitemap_url_path(&page.url_path));
|
|
xml.push(format!(
|
|
" <xhtml:link rel=\"alternate\" hreflang=\"x-default\" href=\"{}\" />",
|
|
escape_xml(&href),
|
|
));
|
|
} else if let Some(alternates) = alternates {
|
|
for alternate in alternates {
|
|
let href = format!("{base_url}{}", sitemap_url_path(&alternate.url_path));
|
|
xml.push(format!(
|
|
" <xhtml:link rel=\"alternate\" hreflang=\"{}\" href=\"{}\" />",
|
|
escape_xml(&alternate.language),
|
|
escape_xml(&href),
|
|
));
|
|
}
|
|
if let Some(default_page) = alternates
|
|
.iter()
|
|
.find(|alternate| alternate.language == main_language)
|
|
{
|
|
let href = format!("{base_url}{}", sitemap_url_path(&default_page.url_path));
|
|
xml.push(format!(
|
|
" <xhtml:link rel=\"alternate\" hreflang=\"x-default\" href=\"{}\" />",
|
|
escape_xml(&href),
|
|
));
|
|
}
|
|
}
|
|
xml.push(" </url>".to_string());
|
|
}
|
|
|
|
xml.push("</urlset>".to_string());
|
|
format!("{}\n", xml.join("\n"))
|
|
}
|
|
|
|
fn sitemap_url_path(path: &str) -> String {
|
|
if path == "/" {
|
|
path.to_string()
|
|
} else {
|
|
format!("{}/", path.trim_end_matches('/'))
|
|
}
|
|
}
|
|
|
|
fn sitemap_metadata(logical_path: &str, is_home: bool) -> (&'static str, &'static str) {
|
|
if is_home {
|
|
return ("daily", "1.0");
|
|
}
|
|
if logical_path.starts_with("page/") {
|
|
return ("daily", "0.9");
|
|
}
|
|
let parts = logical_path.split('/').collect::<Vec<_>>();
|
|
match parts.as_slice() {
|
|
[year, month, day, _slug, "index.html"]
|
|
if is_year_segment(year) && is_month_segment(month) && is_day_segment(day) =>
|
|
{
|
|
("monthly", "0.8")
|
|
}
|
|
["category" | "tag", ..] => ("weekly", "0.6"),
|
|
[year, month, day, "index.html"]
|
|
if is_year_segment(year) && is_month_segment(month) && is_day_segment(day) =>
|
|
{
|
|
("monthly", "0.4")
|
|
}
|
|
[year, ..] if is_year_segment(year) => ("monthly", "0.5"),
|
|
[_slug, "index.html"] => ("weekly", "0.7"),
|
|
_ => ("weekly", "0.6"),
|
|
}
|
|
}
|
|
|
|
fn sitemap_rank(logical_path: &str) -> u8 {
|
|
let parts = logical_path.split('/').collect::<Vec<_>>();
|
|
match parts.as_slice() {
|
|
["index.html"] => 0,
|
|
["page", _, "index.html"] => 1,
|
|
[year, month, day, _slug, "index.html"]
|
|
if is_year_segment(year) && is_month_segment(month) && is_day_segment(day) =>
|
|
{
|
|
2
|
|
}
|
|
[year, "index.html"] if is_year_segment(year) => 4,
|
|
[year, month, "index.html"] if is_year_segment(year) && is_month_segment(month) => 5,
|
|
[year, month, day, "index.html"]
|
|
if is_year_segment(year) && is_month_segment(month) && is_day_segment(day) =>
|
|
{
|
|
6
|
|
}
|
|
[_slug, "index.html"] => 3,
|
|
["category", ..] => 7,
|
|
["tag", ..] => 8,
|
|
_ => 9,
|
|
}
|
|
}
|
|
|
|
fn language_prefix(language: &str, main_language: &str) -> String {
|
|
if language.eq_ignore_ascii_case(main_language) {
|
|
String::new()
|
|
} else {
|
|
format!("/{language}")
|
|
}
|
|
}
|
|
|
|
fn language_root_url_path(language: &str, main_language: &str) -> String {
|
|
let prefix = language_prefix(language, main_language);
|
|
if prefix.is_empty() {
|
|
"/".to_string()
|
|
} else {
|
|
format!("{prefix}/")
|
|
}
|
|
}
|
|
|
|
fn group_pages_by_logical_path<'a>(
|
|
pages: &'a [crate::render::SitePage],
|
|
languages: &[String],
|
|
main_language: &str,
|
|
) -> HashMap<String, Vec<&'a crate::render::SitePage>> {
|
|
let mut grouped = HashMap::<String, Vec<&crate::render::SitePage>>::new();
|
|
for page in pages {
|
|
let key = logical_page_key(&page.relative_path, languages, main_language);
|
|
grouped.entry(key).or_default().push(page);
|
|
}
|
|
grouped
|
|
}
|
|
|
|
fn logical_page_key(relative_path: &str, languages: &[String], main_language: &str) -> String {
|
|
let mut parts = relative_path.split('/');
|
|
let Some(first) = parts.next() else {
|
|
return relative_path.to_string();
|
|
};
|
|
if first.eq_ignore_ascii_case(main_language) {
|
|
return parts.collect::<Vec<_>>().join("/");
|
|
}
|
|
if languages.iter().any(|language| {
|
|
language.eq_ignore_ascii_case(first) && !language.eq_ignore_ascii_case(main_language)
|
|
}) {
|
|
return parts.collect::<Vec<_>>().join("/");
|
|
}
|
|
relative_path.to_string()
|
|
}
|
|
|
|
fn timestamp(timestamp_ms: i64) -> Option<DateTime<Utc>> {
|
|
chrono::Utc.timestamp_millis_opt(timestamp_ms).single()
|
|
}
|
|
|
|
fn escape_xml(value: &str) -> String {
|
|
value
|
|
.replace('&', "&")
|
|
.replace('<', "<")
|
|
.replace('>', ">")
|
|
.replace('"', """)
|
|
.replace('\'', "'")
|
|
}
|