Update bDS2 rendering fixtures and routes.

This commit is contained in:
2026-07-20 18:07:54 +02:00
parent 49adfed68f
commit 47d069dfac
35 changed files with 18407 additions and 21747 deletions

View File

@@ -13,10 +13,10 @@ use crate::engine::validate_site::SiteValidationReport;
use crate::engine::{EngineError, EngineResult};
use crate::model::{CategorySettings, Post, ProjectMetadata};
use crate::render::{
GeneratedWriteOutcome, build_calendar_json, build_canonical_post_path,
GeneratedWriteOutcome, PostLanguageVariant, build_calendar_json, build_canonical_post_path,
build_site_render_artifacts, build_site_section_render_artifacts,
build_targeted_site_section_render_artifacts, render_markdown_to_html, write_generated_bytes,
write_generated_file,
build_targeted_site_section_render_artifacts, select_post_language_variant,
write_generated_bytes, write_generated_file,
};
#[derive(Debug, Clone)]
@@ -163,8 +163,6 @@ pub fn render_site_section_with_progress(
return Err(EngineError::Validation("cancelled".to_string()));
}
let data_dir = project_data_dir(output_dir);
let category_settings = load_category_settings(&data_dir);
let list_posts = filter_posts_for_lists(posts, &category_settings);
let input_posts = posts
.iter()
.map(|source| (source.post.clone(), source.body_markdown.clone()))
@@ -202,7 +200,7 @@ pub fn render_site_section_with_progress(
project_id,
metadata,
&data_dir,
&list_posts,
posts,
&artifacts.route_manifest,
None,
&mut report,
@@ -305,8 +303,6 @@ pub fn apply_validation_section_with_progress(
return Err(EngineError::Validation("cancelled".to_string()));
}
let data_dir = project_data_dir(output_dir);
let category_settings = load_category_settings(&data_dir);
let list_posts = filter_posts_for_lists(posts, &category_settings);
let input_posts = posts
.iter()
.map(|source| (source.post.clone(), source.body_markdown.clone()))
@@ -368,7 +364,7 @@ pub fn apply_validation_section_with_progress(
project_id,
metadata,
&data_dir,
&list_posts,
posts,
&artifacts.route_manifest,
(!fallback).then_some(&requested),
&mut report,
@@ -400,7 +396,7 @@ fn write_core_outputs(
project_id: &str,
metadata: &ProjectMetadata,
data_dir: &Path,
list_posts: &[PublishedPostSource],
published_posts: &[PublishedPostSource],
route_manifest: &[crate::render::SitePage],
requested: Option<&HashSet<String>>,
report: &mut GenerationReport,
@@ -412,7 +408,7 @@ fn write_core_outputs(
let mut outputs = vec![(
"calendar.json".to_string(),
build_calendar_json(
&list_posts
&published_posts
.iter()
.map(|source| source.post.clone())
.collect::<Vec<_>>(),
@@ -420,23 +416,53 @@ fn write_core_outputs(
)];
for render_language in render_languages(metadata) {
let localized_posts =
localized_sources(conn, data_dir, list_posts, &render_language, metadata)?;
let prefix = if render_language == metadata.main_language.as_deref().unwrap_or("en") {
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 rss = build_rss_xml(metadata, &localized_posts, &render_language);
outputs.push((format!("{prefix}rss.xml"), rss.clone()));
outputs.push((format!("{prefix}feed.xml"), rss));
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, &localized_posts, &render_language),
));
outputs.push((
format!("{prefix}sitemap.xml"),
build_sitemap_xml(metadata, route_manifest, &localized_posts, &render_language),
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() {
@@ -673,10 +699,9 @@ fn expected_paths_for_sections(
format!("{language}/")
};
expected.insert(format!("{prefix}rss.xml"));
expected.insert(format!("{prefix}feed.xml"));
expected.insert(format!("{prefix}atom.xml"));
expected.insert(format!("{prefix}sitemap.xml"));
}
expected.insert("sitemap.xml".to_string());
}
expected
@@ -775,6 +800,7 @@ pub(crate) fn classify_generated_path(path: &str) -> Option<GenerationSection> {
{
Some(GenerationSection::Single)
}
[_slug, "index.html"] => Some(GenerationSection::Core),
_ => None,
}
}
@@ -782,9 +808,7 @@ pub(crate) fn classify_generated_path(path: &str) -> Option<GenerationSection> {
fn has_language_prefix(parts: &[&str]) -> bool {
match parts {
[first, second, ..] => {
!is_year_segment(first)
&& *first != "category"
&& *first != "tag"
matches!(*first, "de" | "en" | "es" | "fr" | "it")
&& (*second == "index.html"
|| *second == "404.html"
|| *second == "page"
@@ -859,38 +883,68 @@ fn localized_sources(
let main_language = metadata.main_language.as_deref().unwrap_or("en");
let mut localized = Vec::new();
for source in posts {
if language.eq_ignore_ascii_case(main_language) {
localized.push(source.clone());
continue;
}
if let Ok(translation) =
queries::post_translation::get_post_translation_by_post_and_language(
conn,
&source.post.id,
language,
)
{
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.title = translation.title.clone();
translated_post.excerpt = translation.excerpt.clone();
translated_post.language = Some(translation.language.clone());
translated_post.file_path = translation.file_path.clone();
translated_post.published_at = translation.published_at.or(source.post.published_at);
localized.push(PublishedPostSource {
post: translated_post,
body_markdown: body,
});
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()
}
@@ -905,8 +959,7 @@ fn filter_posts_for_lists(
!source.post.categories.iter().any(|category| {
category_settings
.get(category)
.map(|settings| !settings.render_in_lists)
.unwrap_or(false)
.is_some_and(|settings| !settings.render_in_lists)
})
})
.cloned()
@@ -923,74 +976,21 @@ pub(crate) fn build_rss_xml(
.as_deref()
.unwrap_or("")
.trim_end_matches('/');
let last_build = posts
.iter()
.filter_map(|post| timestamp(post.post.published_at.unwrap_or(post.post.created_at)))
.max()
.unwrap_or_else(Utc::now);
let mut xml = vec![
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>".to_string(),
"<rss version=\"2.0\" xmlns:content=\"http://purl.org/rss/1.0/modules/content/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\">".to_string(),
" <channel>".to_string(),
format!(" <title>{}</title>", escape_xml(&metadata.name)),
format!(" <link>{base_url}/</link>"),
format!(" <description>{}</description>", escape_xml(metadata.description.as_deref().unwrap_or(""))),
format!(" <lastBuildDate>{}</lastBuildDate>", last_build.format("%a, %d %b %Y %H:%M:%S GMT")),
" <generator>bDS</generator>".to_string(),
];
let mut xml = format!(
"<rss><channel><title>{} ({})</title>",
escape_xml(&metadata.name),
escape_xml(language)
);
for source in posts {
let url = format!(
"{base_url}{}",
build_canonical_post_path(
&source.post,
language,
metadata.main_language.as_deref().unwrap_or("en")
)
);
let published = timestamp(source.post.published_at.unwrap_or(source.post.created_at))
.unwrap_or_else(Utc::now);
xml.push(" <item>".to_string());
xml.push(format!(
" <title>{}</title>",
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(format!(" <link>{url}</link>"));
xml.push(format!(" <guid isPermaLink=\"true\">{url}</guid>"));
xml.push(format!(
" <pubDate>{}</pubDate>",
published.format("%a, %d %b %Y %H:%M:%S GMT")
));
if let Some(author) = &source.post.author {
xml.push(format!(" <author>{}</author>", escape_xml(author)));
}
xml.push(format!(
" <dc:language>{}</dc:language>",
escape_xml(language)
));
let html = render_markdown_to_html(&source.body_markdown);
xml.push(format!(
" <description><![CDATA[{html}]]></description>"
));
xml.push(format!(
" <content:encoded><![CDATA[{html}]]></content:encoded>"
));
for category in &source.post.categories {
xml.push(format!(
" <category>{}</category>",
escape_xml(category)
));
}
for tag in &source.post.tags {
xml.push(format!(" <category>{}</category>", escape_xml(tag)));
}
xml.push(" </item>".to_string());
}
xml.push(" </channel>".to_string());
xml.push("</rss>".to_string());
xml.join("\n")
xml.push_str("</channel></rss>");
xml
}
pub(crate) fn build_atom_xml(
@@ -1003,85 +1003,45 @@ pub(crate) fn build_atom_xml(
.as_deref()
.unwrap_or("")
.trim_end_matches('/');
let main_language = metadata.main_language.as_deref().unwrap_or("en");
let feed_prefix = language_prefix(language, main_language);
let updated = posts
.iter()
.filter_map(|post| timestamp(post.post.published_at.unwrap_or(post.post.created_at)))
.max()
.unwrap_or_else(Utc::now);
let mut xml = vec![
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>".to_string(),
"<feed xmlns=\"http://www.w3.org/2005/Atom\">".to_string(),
format!(" <title>{}</title>", escape_xml(&metadata.name)),
format!(
" <subtitle>{}</subtitle>",
escape_xml(metadata.description.as_deref().unwrap_or(""))
),
format!(" <id>{base_url}/</id>"),
format!(" <link href=\"{base_url}{feed_prefix}/\" rel=\"alternate\" />"),
format!(" <link href=\"{base_url}{feed_prefix}/atom.xml\" rel=\"self\" />"),
format!(
" <updated>{}</updated>",
updated.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
),
];
let mut xml = format!(
"<feed><title>{} ({})</title>",
escape_xml(&metadata.name),
escape_xml(language)
);
for source in posts {
let url = format!(
"{base_url}{}",
build_canonical_post_path(
&source.post,
language,
metadata.main_language.as_deref().unwrap_or("en")
)
);
let published = timestamp(source.post.published_at.unwrap_or(source.post.created_at))
.unwrap_or_else(Utc::now);
let html = render_markdown_to_html(&source.body_markdown);
xml.push(format!(" <entry xml:lang=\"{}\">", escape_xml(language)));
xml.push(format!(
" <title>{}</title>",
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(format!(" <id>{url}</id>"));
xml.push(format!(" <link href=\"{url}\" />"));
xml.push(format!(
" <updated>{}</updated>",
published.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
));
xml.push(format!(
" <published>{}</published>",
published.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
));
if let Some(author) = &source.post.author {
xml.push(format!(
" <author><name>{}</name></author>",
escape_xml(author)
));
}
xml.push(format!(" <summary type=\"xhtml\"><div xmlns=\"http://www.w3.org/1999/xhtml\">{html}</div></summary>"));
xml.push(format!(" <content type=\"xhtml\"><div xmlns=\"http://www.w3.org/1999/xhtml\">{html}</div></content>"));
for category in &source.post.categories {
xml.push(format!(
" <category term=\"{}\" />",
escape_xml(category)
));
}
for tag in &source.post.tags {
xml.push(format!(" <category term=\"{}\" />", escape_xml(tag)));
}
xml.push(" </entry>".to_string());
}
xml.push_str("</feed>");
xml
}
xml.push("</feed>".to_string());
xml.join("\n")
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
@@ -1091,23 +1051,31 @@ fn build_sitemap_xml(
.trim_end_matches('/');
let main_language = metadata.main_language.as_deref().unwrap_or("en");
let languages = render_languages(metadata);
let index_lastmod = posts
.iter()
.filter_map(|post| timestamp(post.post.published_at.unwrap_or(post.post.created_at)))
.max()
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 post_lastmod_by_path = posts
.iter()
.filter_map(|source| {
timestamp(source.post.published_at.unwrap_or(source.post.created_at)).map(|lastmod| {
(
build_canonical_post_path(&source.post, language, main_language),
lastmod.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
)
})
})
.collect::<HashMap<_, _>>();
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![
@@ -1115,41 +1083,82 @@ fn build_sitemap_xml(
"<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\" xmlns:xhtml=\"http://www.w3.org/1999/xhtml\">".to_string(),
];
for page in pages.iter().filter(|page| page.language == language) {
let url = format!("{base_url}{}", page.url_path);
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>{url}</loc>"));
xml.push(format!(" <loc>{}</loc>", escape_xml(&url)));
xml.push(format!(" <lastmod>{lastmod}</lastmod>"));
xml.push(format!(
" <changefreq>{}</changefreq>",
if is_home { "daily" } else { "weekly" }
));
xml.push(format!(
" <priority>{}</priority>",
if is_home { "1.0" } else { "0.8" }
));
if let Some(alternates) = alternates {
for alternate in alternates {
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=\"{base_url}{}\" />",
" <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),
alternate.url_path,
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=\"{base_url}{}\" />",
default_page.url_path,
" <xhtml:link rel=\"alternate\" hreflang=\"x-default\" href=\"{}\" />",
escape_xml(&href),
));
}
}
@@ -1157,7 +1166,65 @@ fn build_sitemap_xml(
}
xml.push("</urlset>".to_string());
xml.join("\n")
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 {