Update bDS2 rendering fixtures and routes.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -14,7 +14,7 @@ use crate::db::{Database, queries};
|
||||
use crate::engine::generation::PublishedPostSource;
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
use crate::model::{Post, PostStatus, pico_stylesheet_href};
|
||||
use crate::render::build_preview_response;
|
||||
use crate::render::{PostLanguageVariant, build_preview_response, select_post_language_variant};
|
||||
use crate::util::frontmatter::{read_post_file, read_translation_file};
|
||||
|
||||
pub const PREVIEW_HOST: &str = "127.0.0.1";
|
||||
@@ -204,9 +204,8 @@ fn render_preview_response(
|
||||
let metadata = crate::engine::meta::read_project_json(&state.data_dir)?;
|
||||
let db = Database::open(&state.db_path)?;
|
||||
let preview_posts = collect_preview_posts(state)?;
|
||||
let list_posts = filter_preview_list_posts(&state.data_dir, &preview_posts);
|
||||
if path == "/calendar.json" {
|
||||
let posts = list_posts
|
||||
let posts = preview_posts
|
||||
.iter()
|
||||
.map(|source| source.post.clone())
|
||||
.collect::<Vec<_>>();
|
||||
@@ -220,13 +219,27 @@ fn render_preview_response(
|
||||
.into_response());
|
||||
}
|
||||
if let Some((language, kind)) = preview_feed_request(path, &metadata) {
|
||||
let localized_posts = localized_preview_posts(
|
||||
db.conn(),
|
||||
&state.data_dir,
|
||||
&list_posts,
|
||||
&language,
|
||||
metadata.main_language.as_deref().unwrap_or("en"),
|
||||
)?;
|
||||
let main_language = metadata.main_language.as_deref().unwrap_or("en");
|
||||
let localized_posts = if language.eq_ignore_ascii_case(main_language) {
|
||||
preview_posts.clone()
|
||||
} else {
|
||||
localized_preview_posts(
|
||||
db.conn(),
|
||||
&state.data_dir,
|
||||
&preview_posts,
|
||||
&language,
|
||||
main_language,
|
||||
)?
|
||||
.into_iter()
|
||||
.filter(|source| {
|
||||
source
|
||||
.post
|
||||
.language
|
||||
.as_deref()
|
||||
.is_some_and(|post_language| post_language.eq_ignore_ascii_case(&language))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let (content_type, xml) = match kind {
|
||||
PreviewFeedKind::Rss => (
|
||||
"application/rss+xml; charset=utf-8",
|
||||
@@ -414,28 +427,17 @@ fn collect_preview_posts(state: &PreviewServerState) -> EngineResult<Vec<Publish
|
||||
post,
|
||||
});
|
||||
}
|
||||
preview_posts.sort_by_key(|source| source.post.published_at.unwrap_or(source.post.created_at));
|
||||
preview_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))
|
||||
});
|
||||
Ok(preview_posts)
|
||||
}
|
||||
|
||||
fn filter_preview_list_posts(
|
||||
data_dir: &Path,
|
||||
posts: &[PublishedPostSource],
|
||||
) -> Vec<PublishedPostSource> {
|
||||
let settings = crate::engine::meta::read_category_meta_json(data_dir).unwrap_or_default();
|
||||
posts
|
||||
.iter()
|
||||
.filter(|source| {
|
||||
!source.post.categories.iter().any(|category| {
|
||||
settings
|
||||
.get(category)
|
||||
.is_some_and(|setting| !setting.render_in_lists)
|
||||
})
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn preview_feed_request(
|
||||
path: &str,
|
||||
metadata: &crate::model::ProjectMetadata,
|
||||
@@ -460,7 +462,7 @@ fn preview_feed_request(
|
||||
_ => return None,
|
||||
};
|
||||
let kind = match filename {
|
||||
"rss.xml" | "feed.xml" => PreviewFeedKind::Rss,
|
||||
"rss.xml" => PreviewFeedKind::Rss,
|
||||
"atom.xml" => PreviewFeedKind::Atom,
|
||||
_ => return None,
|
||||
};
|
||||
@@ -474,30 +476,49 @@ fn localized_preview_posts(
|
||||
language: &str,
|
||||
main_language: &str,
|
||||
) -> EngineResult<Vec<PublishedPostSource>> {
|
||||
if language.eq_ignore_ascii_case(main_language) {
|
||||
return Ok(posts.to_vec());
|
||||
}
|
||||
|
||||
let mut localized = Vec::new();
|
||||
for source in posts {
|
||||
let Ok(translation) = queries::post_translation::get_post_translation_by_post_and_language(
|
||||
let translation = queries::post_translation::get_post_translation_by_post_and_language(
|
||||
conn,
|
||||
&source.post.id,
|
||||
language,
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
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.status = translation.status.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: load_translation_body(data_dir, &translation)?,
|
||||
)
|
||||
.ok()
|
||||
.filter(|translation| {
|
||||
(translation.status == PostStatus::Draft && translation.content.is_some())
|
||||
|| (!translation.file_path.trim().is_empty()
|
||||
&& data_dir
|
||||
.join(translation.file_path.trim_start_matches('/'))
|
||||
.is_file())
|
||||
});
|
||||
match select_post_language_variant(
|
||||
&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 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: load_translation_body(data_dir, &translation)?,
|
||||
});
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
Ok(localized)
|
||||
}
|
||||
@@ -789,6 +810,25 @@ mod tests {
|
||||
assert!(html.contains("<strong>world</strong>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_renders_page_category_post_at_flat_path() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let mut source = make_post();
|
||||
source.post.categories = vec!["page".into()];
|
||||
let response = build_preview_response(
|
||||
db.conn(),
|
||||
Path::new("."),
|
||||
"project-1",
|
||||
&make_metadata(),
|
||||
&[(source.post, source.body_markdown)],
|
||||
"/hello",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status_code, 200);
|
||||
assert!(response.html.contains("<h1>Hello</h1>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_renders_language_prefixed_single_post() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -923,11 +963,10 @@ mod tests {
|
||||
assert!(translated_draft_html.contains("Deutscher <strong>Entwurf</strong>"));
|
||||
assert!(calendar_json.contains("2024"));
|
||||
assert!(rss_xml.contains("<rss"));
|
||||
assert!(rss_xml.contains("Draft <strong>body</strong>"));
|
||||
assert!(rss_xml.contains("Filesystem <strong>body</strong>"));
|
||||
assert!(!rss_xml.contains("Stale database body"));
|
||||
assert!(rss_xml.contains("<title>Hello</title>"));
|
||||
assert!(rss_xml.contains("<title>Published from file</title>"));
|
||||
assert!(translated_atom_xml.contains("<feed"));
|
||||
assert!(translated_atom_xml.contains("Deutscher <strong>Entwurf</strong>"));
|
||||
assert!(translated_atom_xml.contains("<title>Hallo</title>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -47,10 +47,9 @@ pub fn validate_site(
|
||||
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());
|
||||
|
||||
let mut actual = HashSet::new();
|
||||
if output_dir.exists() {
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::db::DbConnection as Connection;
|
||||
use chrono::{Datelike, TimeZone, Utc};
|
||||
use chrono::{Datelike, Local, TimeZone};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::db::queries::generated_file_hash as qhash;
|
||||
@@ -99,7 +99,6 @@ pub fn build_core_generation_paths(main_language: &str, blog_languages: &[String
|
||||
"404.html".to_string(),
|
||||
"sitemap.xml".to_string(),
|
||||
"rss.xml".to_string(),
|
||||
"feed.xml".to_string(),
|
||||
"atom.xml".to_string(),
|
||||
"calendar.json".to_string(),
|
||||
];
|
||||
@@ -108,9 +107,7 @@ pub fn build_core_generation_paths(main_language: &str, blog_languages: &[String
|
||||
if language != main_language {
|
||||
paths.push(format!("{language}/index.html"));
|
||||
paths.push(format!("{language}/404.html"));
|
||||
paths.push(format!("{language}/sitemap.xml"));
|
||||
paths.push(format!("{language}/rss.xml"));
|
||||
paths.push(format!("{language}/feed.xml"));
|
||||
paths.push(format!("{language}/atom.xml"));
|
||||
}
|
||||
}
|
||||
@@ -124,8 +121,7 @@ pub fn build_calendar_archive_data(posts: &[Post]) -> CalendarArchiveData {
|
||||
let mut days = BTreeMap::new();
|
||||
|
||||
for post in posts {
|
||||
let timestamp_ms = post.published_at.unwrap_or(post.created_at);
|
||||
let Some(created_at) = Utc.timestamp_millis_opt(timestamp_ms).single() else {
|
||||
let Some(created_at) = Local.timestamp_millis_opt(post.created_at).single() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -146,5 +142,5 @@ pub fn build_calendar_archive_data(posts: &[Post]) -> CalendarArchiveData {
|
||||
}
|
||||
|
||||
pub fn build_calendar_json(posts: &[Post]) -> serde_json::Result<String> {
|
||||
serde_json::to_string_pretty(&build_calendar_archive_data(posts))
|
||||
serde_json::to_string(&build_calendar_archive_data(posts))
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ pub use generation::{
|
||||
pub use markdown::render_markdown_to_html;
|
||||
pub(crate) use page_renderer::render_liquid_template_with_host;
|
||||
pub use page_renderer::{RenderError, render_liquid_template};
|
||||
pub(crate) use routes::{PostLanguageVariant, select_post_language_variant};
|
||||
pub use routes::{
|
||||
RenderedPage, build_canonical_post_path, render_starter_list_page,
|
||||
render_starter_list_page_with_media_map, render_starter_single_post_page,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::{Datelike, TimeZone, Utc};
|
||||
use chrono::{Datelike, Local, TimeZone};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::i18n::normalize_language;
|
||||
@@ -17,6 +17,40 @@ const STARTER_MENU_PARTIAL: &str =
|
||||
include_str!("../../../../assets/starter-templates/partials/menu.liquid");
|
||||
const STARTER_LANGUAGE_SWITCHER_PARTIAL: &str =
|
||||
include_str!("../../../../assets/starter-templates/partials/language-switcher.liquid");
|
||||
const STARTER_MENU_ITEMS_PARTIAL: &str =
|
||||
include_str!("../../../../assets/starter-templates/partials/menu-items.liquid");
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum PostLanguageVariant {
|
||||
Base,
|
||||
Translation,
|
||||
}
|
||||
|
||||
pub(crate) fn select_post_language_variant(
|
||||
post: &Post,
|
||||
target_language: &str,
|
||||
main_language: &str,
|
||||
has_translation: bool,
|
||||
) -> Option<PostLanguageVariant> {
|
||||
let source_language = post.language.as_deref().unwrap_or(main_language);
|
||||
if target_language.eq_ignore_ascii_case(main_language) {
|
||||
return Some(if has_translation {
|
||||
PostLanguageVariant::Translation
|
||||
} else {
|
||||
PostLanguageVariant::Base
|
||||
});
|
||||
}
|
||||
if post.do_not_translate {
|
||||
return None;
|
||||
}
|
||||
if source_language.eq_ignore_ascii_case(target_language) {
|
||||
Some(PostLanguageVariant::Base)
|
||||
} else if has_translation {
|
||||
Some(PostLanguageVariant::Translation)
|
||||
} else {
|
||||
Some(PostLanguageVariant::Base)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RenderedPage {
|
||||
@@ -99,8 +133,7 @@ struct PostTemplateContext<'a> {
|
||||
}
|
||||
|
||||
pub fn build_canonical_post_path(post: &Post, language: &str, main_language: &str) -> String {
|
||||
let timestamp_ms = post.published_at.unwrap_or(post.created_at);
|
||||
let Some(timestamp) = Utc.timestamp_millis_opt(timestamp_ms).single() else {
|
||||
let Some(timestamp) = Local.timestamp_millis_opt(post.created_at).single() else {
|
||||
return fallback_language_path(post, language, main_language);
|
||||
};
|
||||
|
||||
@@ -276,8 +309,7 @@ fn starter_partials() -> HashMap<String, String> {
|
||||
),
|
||||
(
|
||||
"partials/menu-items".to_string(),
|
||||
"{% for item in items %}<a href=\"{{ item.href }}\">{{ item.title }}</a>{% endfor %}"
|
||||
.to_string(),
|
||||
STARTER_MENU_ITEMS_PARTIAL.to_string(),
|
||||
),
|
||||
])
|
||||
}
|
||||
@@ -310,8 +342,8 @@ fn fallback_language_path(post: &Post, language: &str, main_language: &str) -> S
|
||||
}
|
||||
|
||||
fn calendar_initial_parts(post: &Post) -> (i32, u32) {
|
||||
let timestamp_ms = post.published_at.unwrap_or(post.created_at);
|
||||
Utc.timestamp_millis_opt(timestamp_ms)
|
||||
Local
|
||||
.timestamp_millis_opt(post.created_at)
|
||||
.single()
|
||||
.map(|timestamp| (timestamp.year(), timestamp.month()))
|
||||
.unwrap_or((1970, 1))
|
||||
@@ -406,8 +438,7 @@ fn build_day_blocks(posts: &[(Post, String)]) -> Vec<DayBlockContext> {
|
||||
let mut current_key: Option<String> = None;
|
||||
|
||||
for (post, body) in posts {
|
||||
let timestamp_ms = post.published_at.unwrap_or(post.created_at);
|
||||
let Some(timestamp) = Utc.timestamp_millis_opt(timestamp_ms).single() else {
|
||||
let Some(timestamp) = Local.timestamp_millis_opt(post.created_at).single() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -425,10 +456,10 @@ fn build_day_blocks(posts: &[(Post, String)]) -> Vec<DayBlockContext> {
|
||||
blocks.push(DayBlockContext {
|
||||
show_date_marker: true,
|
||||
date_label: format!(
|
||||
"{:02}.{:02}.{:04}",
|
||||
timestamp.day(),
|
||||
"{:04}-{:02}-{:02}",
|
||||
timestamp.year(),
|
||||
timestamp.month(),
|
||||
timestamp.year()
|
||||
timestamp.day()
|
||||
),
|
||||
posts: Vec::new(),
|
||||
show_separator: false,
|
||||
@@ -500,7 +531,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn canonical_post_paths_follow_language_prefix_rule() {
|
||||
let post = make_post();
|
||||
let mut post = make_post();
|
||||
post.published_at = Some(1_712_678_400_000);
|
||||
assert_eq!(
|
||||
build_canonical_post_path(&post, "en", "en"),
|
||||
"/2024/03/09/hello"
|
||||
@@ -511,6 +543,52 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_post_paths_use_the_bds2_local_created_date() {
|
||||
let mut post = make_post();
|
||||
post.created_at = 1_711_927_800_000;
|
||||
let local = Local
|
||||
.timestamp_millis_opt(post.created_at)
|
||||
.single()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
build_canonical_post_path(&post, "en", "en"),
|
||||
format!(
|
||||
"/{:04}/{:02}/{:02}/hello",
|
||||
local.year(),
|
||||
local.month(),
|
||||
local.day()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_variants_match_bds2_canonical_and_fallback_rules() {
|
||||
let mut post = make_post();
|
||||
post.language = Some("en".into());
|
||||
|
||||
assert_eq!(
|
||||
select_post_language_variant(&post, "de", "de", true),
|
||||
Some(PostLanguageVariant::Translation)
|
||||
);
|
||||
assert_eq!(
|
||||
select_post_language_variant(&post, "en", "de", false),
|
||||
Some(PostLanguageVariant::Base)
|
||||
);
|
||||
assert_eq!(
|
||||
select_post_language_variant(&post, "fr", "de", true),
|
||||
Some(PostLanguageVariant::Translation)
|
||||
);
|
||||
assert_eq!(
|
||||
select_post_language_variant(&post, "fr", "de", false),
|
||||
Some(PostLanguageVariant::Base)
|
||||
);
|
||||
|
||||
post.do_not_translate = true;
|
||||
assert_eq!(select_post_language_variant(&post, "fr", "de", true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starter_single_post_renderer_uses_canonical_route_and_language_links() {
|
||||
let post = make_post();
|
||||
@@ -591,8 +669,8 @@ mod tests {
|
||||
|
||||
assert_eq!(rendered.relative_path, "de/index.html");
|
||||
assert!(rendered.html.contains("archive-day-group"));
|
||||
assert!(rendered.html.contains("09.03.2024"));
|
||||
assert!(rendered.html.contains("10.03.2024"));
|
||||
assert!(rendered.html.contains("2024-03-09"));
|
||||
assert!(rendered.html.contains("2024-03-10"));
|
||||
assert!(rendered.html.contains("href=\"/de/2024/03/10/next\""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::db::DbConnection as Connection;
|
||||
use chrono::{Datelike, TimeZone, Utc};
|
||||
use chrono::{Datelike, Local, TimeZone, Utc};
|
||||
use rayon::prelude::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -17,8 +17,8 @@ use crate::model::{
|
||||
TemplateKind, TemplateStatus,
|
||||
};
|
||||
use crate::render::{
|
||||
RenderCategorySettings, RenderTemplateLookup, build_canonical_post_path,
|
||||
render_liquid_template_with_host, resolve_post_template,
|
||||
PostLanguageVariant, RenderCategorySettings, RenderTemplateLookup, build_canonical_post_path,
|
||||
render_liquid_template_with_host, resolve_post_template, select_post_language_variant,
|
||||
};
|
||||
use crate::scripting::{CoreHost, HostApi, UnavailableHost};
|
||||
use crate::util::frontmatter::{read_script_file, read_template_file, read_translation_file};
|
||||
@@ -36,6 +36,8 @@ const STARTER_MENU_PARTIAL: &str =
|
||||
include_str!("../../../../assets/starter-templates/partials/menu.liquid");
|
||||
const STARTER_LANGUAGE_SWITCHER_PARTIAL: &str =
|
||||
include_str!("../../../../assets/starter-templates/partials/language-switcher.liquid");
|
||||
const STARTER_MENU_ITEMS_PARTIAL: &str =
|
||||
include_str!("../../../../assets/starter-templates/partials/menu-items.liquid");
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SitePage {
|
||||
@@ -81,6 +83,7 @@ struct TemplateBundle {
|
||||
#[derive(Debug, Clone)]
|
||||
struct RenderPostRecord {
|
||||
post: Post,
|
||||
source_post_id: String,
|
||||
body_markdown: String,
|
||||
}
|
||||
|
||||
@@ -278,47 +281,64 @@ fn build_site_render_artifacts_with_mode(
|
||||
canonical_post_path_by_slug(&localized_posts, &language, &main_language);
|
||||
for record in &localized_posts {
|
||||
let canonical_path = build_canonical_post_path(&record.post, &language, &main_language);
|
||||
let relative_path = format!("{}/index.html", canonical_path.trim_start_matches('/'));
|
||||
artifacts.route_manifest.push(SitePage {
|
||||
language: language.clone(),
|
||||
relative_path: relative_path.clone(),
|
||||
url_path: canonical_path.clone(),
|
||||
html: String::new(),
|
||||
});
|
||||
if section.is_some_and(|section| section != GenerationSection::Single) {
|
||||
continue;
|
||||
let mut post_paths = vec![(canonical_path, GenerationSection::Single)];
|
||||
if record
|
||||
.post
|
||||
.categories
|
||||
.iter()
|
||||
.any(|category| category == "page")
|
||||
{
|
||||
post_paths.push((
|
||||
if language == main_language {
|
||||
format!("/{}", record.post.slug)
|
||||
} else {
|
||||
format!("/{language}/{}", record.post.slug)
|
||||
},
|
||||
GenerationSection::Core,
|
||||
));
|
||||
}
|
||||
if requested_paths.is_some_and(|requested| !requested.contains(&relative_path)) {
|
||||
continue;
|
||||
for (url_path, route_section) in post_paths {
|
||||
let relative_path = format!("{}/index.html", url_path.trim_start_matches('/'));
|
||||
artifacts.route_manifest.push(SitePage {
|
||||
language: language.clone(),
|
||||
relative_path: relative_path.clone(),
|
||||
url_path: url_path.clone(),
|
||||
html: String::new(),
|
||||
});
|
||||
if section.is_some_and(|section| section != route_section)
|
||||
|| requested_paths.is_some_and(|requested| !requested.contains(&relative_path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let html = render_post_route(
|
||||
conn,
|
||||
metadata,
|
||||
&language,
|
||||
&main_language,
|
||||
record,
|
||||
&localized_posts,
|
||||
&tags,
|
||||
&category_settings,
|
||||
&media_by_id,
|
||||
&canonical_map,
|
||||
&menu_items,
|
||||
&post_data_json_by_id,
|
||||
&bundle,
|
||||
is_preview,
|
||||
)?;
|
||||
artifacts.pagefind_documents.push(PagefindDocument {
|
||||
language: language.clone(),
|
||||
relative_path: relative_path.clone(),
|
||||
url_path: url_path.clone(),
|
||||
html: html.clone(),
|
||||
});
|
||||
artifacts.pages.push(SitePage {
|
||||
language: language.clone(),
|
||||
relative_path,
|
||||
url_path,
|
||||
html,
|
||||
});
|
||||
}
|
||||
let html = render_post_route(
|
||||
conn,
|
||||
metadata,
|
||||
&language,
|
||||
&main_language,
|
||||
record,
|
||||
&localized_posts,
|
||||
&tags,
|
||||
&category_settings,
|
||||
&media_by_id,
|
||||
&canonical_map,
|
||||
&menu_items,
|
||||
&post_data_json_by_id,
|
||||
&bundle,
|
||||
is_preview,
|
||||
)?;
|
||||
artifacts.pagefind_documents.push(PagefindDocument {
|
||||
language: language.clone(),
|
||||
relative_path: relative_path.clone(),
|
||||
url_path: canonical_path.clone(),
|
||||
html: html.clone(),
|
||||
});
|
||||
artifacts.pages.push(SitePage {
|
||||
language: language.clone(),
|
||||
relative_path,
|
||||
url_path: canonical_path,
|
||||
html,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,47 +533,61 @@ fn load_language_posts(
|
||||
) -> Result<Vec<RenderPostRecord>, Box<dyn Error + Send + Sync>> {
|
||||
let mut posts = Vec::new();
|
||||
for (post, body) in published_posts {
|
||||
if language.eq_ignore_ascii_case(main_language) {
|
||||
posts.push(RenderPostRecord {
|
||||
let translation = queries::post_translation::get_post_translation_by_post_and_language(
|
||||
conn, &post.id, language,
|
||||
)
|
||||
.ok()
|
||||
.filter(|translation| {
|
||||
(is_preview && translation.status == PostStatus::Draft && translation.content.is_some())
|
||||
|| (!translation.file_path.trim().is_empty()
|
||||
&& data_dir
|
||||
.join(translation.file_path.trim_start_matches('/'))
|
||||
.is_file())
|
||||
});
|
||||
match select_post_language_variant(post, language, main_language, translation.is_some()) {
|
||||
Some(PostLanguageVariant::Base) => posts.push(RenderPostRecord {
|
||||
post: post.clone(),
|
||||
source_post_id: post.id.clone(),
|
||||
body_markdown: body.clone(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(translation) =
|
||||
queries::post_translation::get_post_translation_by_post_and_language(
|
||||
conn, &post.id, language,
|
||||
)
|
||||
{
|
||||
let translated_body = if is_preview && translation.status == PostStatus::Draft {
|
||||
match &translation.content {
|
||||
Some(content) => content.clone(),
|
||||
None => read_translation_body(data_dir, &translation.file_path)?,
|
||||
}
|
||||
} else {
|
||||
read_translation_body(data_dir, &translation.file_path)?
|
||||
};
|
||||
let mut translated_post = post.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.published_at = translation.published_at.or(post.published_at);
|
||||
posts.push(RenderPostRecord {
|
||||
post: translated_post,
|
||||
body_markdown: translated_body,
|
||||
});
|
||||
}),
|
||||
Some(PostLanguageVariant::Translation) => {
|
||||
let Some(translation) = translation else {
|
||||
continue;
|
||||
};
|
||||
let translated_body = if is_preview && translation.status == PostStatus::Draft {
|
||||
match &translation.content {
|
||||
Some(content) => content.clone(),
|
||||
None => read_translation_body(data_dir, &translation.file_path)?,
|
||||
}
|
||||
} else {
|
||||
read_translation_body(data_dir, &translation.file_path)?
|
||||
};
|
||||
let mut translated_post = 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(post.published_at);
|
||||
posts.push(RenderPostRecord {
|
||||
post: translated_post,
|
||||
source_post_id: post.id.clone(),
|
||||
body_markdown: translated_body,
|
||||
});
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
posts.sort_by(|left, right| {
|
||||
right
|
||||
.post
|
||||
.published_at
|
||||
.unwrap_or(right.post.created_at)
|
||||
.cmp(&left.post.published_at.unwrap_or(left.post.created_at))
|
||||
.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))
|
||||
});
|
||||
Ok(posts)
|
||||
}
|
||||
@@ -607,10 +641,7 @@ fn build_language_routes(
|
||||
.or_default()
|
||||
.push(record.clone());
|
||||
}
|
||||
if let Some(timestamp) = Utc
|
||||
.timestamp_millis_opt(record.post.published_at.unwrap_or(record.post.created_at))
|
||||
.single()
|
||||
{
|
||||
if let Some(timestamp) = Local.timestamp_millis_opt(record.post.created_at).single() {
|
||||
year_posts
|
||||
.entry(timestamp.year())
|
||||
.or_default()
|
||||
@@ -791,8 +822,8 @@ fn render_list_route(
|
||||
"calendar_initial_month": route.posts.first().map(|post| calendar_initial_parts(&post.post).1).unwrap_or(1),
|
||||
"archive_context": route.archive_context,
|
||||
"show_archive_range_heading": false,
|
||||
"min_date": route.posts.last().map(|record| timestamp_parts(record.post.published_at.unwrap_or(record.post.created_at))),
|
||||
"max_date": route.posts.first().map(|record| timestamp_parts(record.post.published_at.unwrap_or(record.post.created_at))),
|
||||
"min_date": route.posts.last().map(|record| timestamp_parts(record.post.created_at)),
|
||||
"max_date": route.posts.first().map(|record| timestamp_parts(record.post.created_at)),
|
||||
"day_blocks": build_day_blocks(&route.posts, category_settings),
|
||||
"is_list_page": route.current_page > 1,
|
||||
"is_first_page": route.current_page == 1,
|
||||
@@ -866,19 +897,19 @@ fn render_post_route(
|
||||
.or_else(|| resolved.content.clone())
|
||||
.unwrap_or_else(|| STARTER_SINGLE_POST_TEMPLATE.to_string());
|
||||
|
||||
let linked_media = queries::post_media::list_post_media_by_post(conn, &record.post.id)
|
||||
let linked_media = queries::post_media::list_post_media_by_post(conn, &record.source_post_id)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|link| media_by_id.get(&link.media_id))
|
||||
.map(media_context)
|
||||
.collect::<Vec<_>>();
|
||||
let outgoing_links =
|
||||
queries::post_link::list_links_by_source(conn, &record.post.id).unwrap_or_default();
|
||||
queries::post_link::list_links_by_source(conn, &record.source_post_id).unwrap_or_default();
|
||||
let incoming_links =
|
||||
queries::post_link::list_links_by_target(conn, &record.post.id).unwrap_or_default();
|
||||
queries::post_link::list_links_by_target(conn, &record.source_post_id).unwrap_or_default();
|
||||
let post_by_id = all_posts
|
||||
.iter()
|
||||
.map(|item| (item.post.id.clone(), item))
|
||||
.map(|item| (item.source_post_id.clone(), item))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let outgoing_link_context = outgoing_links
|
||||
.iter()
|
||||
@@ -1072,8 +1103,7 @@ fn build_day_blocks(
|
||||
let mut current_label = String::new();
|
||||
|
||||
for record in posts {
|
||||
let timestamp_ms = record.post.published_at.unwrap_or(record.post.created_at);
|
||||
let Some(timestamp) = Utc.timestamp_millis_opt(timestamp_ms).single() else {
|
||||
let Some(timestamp) = Local.timestamp_millis_opt(record.post.created_at).single() else {
|
||||
continue;
|
||||
};
|
||||
let key = format!(
|
||||
@@ -1091,13 +1121,8 @@ fn build_day_blocks(
|
||||
}));
|
||||
current_posts = Vec::new();
|
||||
}
|
||||
current_label = key.clone();
|
||||
current_key = key;
|
||||
current_label = format!(
|
||||
"{:02}.{:02}.{:04}",
|
||||
timestamp.day(),
|
||||
timestamp.month(),
|
||||
timestamp.year()
|
||||
);
|
||||
let show_title = should_show_list_title(&record.post, category_settings);
|
||||
current_posts.push(json!({
|
||||
"id": record.post.id,
|
||||
@@ -1631,8 +1656,7 @@ fn starter_partials() -> HashMap<String, String> {
|
||||
),
|
||||
(
|
||||
"partials/menu-items".to_string(),
|
||||
"{% for item in items %}<a href=\"{{ item.href }}\">{{ item.title }}</a>{% endfor %}"
|
||||
.to_string(),
|
||||
STARTER_MENU_ITEMS_PARTIAL.to_string(),
|
||||
),
|
||||
])
|
||||
}
|
||||
@@ -1644,8 +1668,8 @@ fn pico_stylesheet_href(metadata: &ProjectMetadata) -> Option<String> {
|
||||
}
|
||||
|
||||
fn calendar_initial_parts(post: &Post) -> (i32, u32) {
|
||||
let timestamp_ms = post.published_at.unwrap_or(post.created_at);
|
||||
Utc.timestamp_millis_opt(timestamp_ms)
|
||||
Local
|
||||
.timestamp_millis_opt(post.created_at)
|
||||
.single()
|
||||
.map(|timestamp| (timestamp.year(), timestamp.month()))
|
||||
.unwrap_or((1970, 1))
|
||||
@@ -1670,6 +1694,22 @@ mod menu_tests {
|
||||
use super::*;
|
||||
use crate::engine::menu::{MenuItem, MenuItemKind};
|
||||
|
||||
#[test]
|
||||
fn starter_menu_partial_keeps_nested_submenus_and_calendar_navigation() {
|
||||
let partials = starter_partials();
|
||||
let menu_items = partials.get("partials/menu-items").unwrap();
|
||||
|
||||
assert!(menu_items.contains("blog-menu-submenu"));
|
||||
assert!(menu_items.contains("data-blog-calendar-toggle"));
|
||||
assert!(menu_items.contains("data-blog-calendar-root"));
|
||||
assert!(
|
||||
partials
|
||||
.get("partials/language-switcher")
|
||||
.unwrap()
|
||||
.contains("data-search-no-results")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_request_paths_select_only_the_matching_generated_page() {
|
||||
assert_eq!(preview_relative_path("/"), "index.html");
|
||||
|
||||
@@ -247,3 +247,26 @@ fn relationships_reference_existing_entities() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_site_golden_tracks_current_bds2_rendering() {
|
||||
let directory = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../fixtures/golden-generated-sites/rfc1437-sample");
|
||||
let index = fs::read_to_string(directory.join("index.html")).unwrap();
|
||||
let rss = fs::read_to_string(directory.join("rss.xml")).unwrap();
|
||||
let atom = fs::read_to_string(directory.join("atom.xml")).unwrap();
|
||||
let sitemap = fs::read_to_string(directory.join("sitemap.xml")).unwrap();
|
||||
let calendar: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(directory.join("calendar.json")).unwrap())
|
||||
.unwrap();
|
||||
|
||||
assert!(index.contains("class=\"blog-menu-submenu\""));
|
||||
assert!(index.contains("data-search-no-results=\"Keine Ergebnisse gefunden\""));
|
||||
assert!(index.contains("<span>2026-07-17</span>"));
|
||||
assert!(rss.starts_with("<rss><channel><title>rfc1437 (de)</title>"));
|
||||
assert!(atom.starts_with("<feed><title>rfc1437 (de)</title>"));
|
||||
assert!(sitemap.contains("<loc>https://www.rfc1437.de/</loc>"));
|
||||
assert!(sitemap.contains("hreflang=\"en\" href=\"https://www.rfc1437.de/en/\""));
|
||||
assert!(sitemap.contains("<loc>https://www.rfc1437.de/bilderarchiv-2004/</loc>"));
|
||||
assert_eq!(calendar["days"]["2026-07-17"], 3);
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ use bds_core::engine::generation::{
|
||||
use bds_core::engine::meta::write_category_meta_json;
|
||||
use bds_core::engine::validate_site::validate_site;
|
||||
use bds_core::model::{
|
||||
CategorySettings, Post, PostStatus, Project, ProjectMetadata, Template, TemplateKind,
|
||||
TemplateStatus,
|
||||
CategorySettings, Post, PostStatus, PostTranslation, Project, ProjectMetadata, Template,
|
||||
TemplateKind, TemplateStatus,
|
||||
};
|
||||
use tempfile::TempDir;
|
||||
|
||||
@@ -177,7 +177,7 @@ fn generation_engine_writes_core_and_single_post_artifacts() {
|
||||
assert!(report.written_paths.contains(&"index.html".to_string()));
|
||||
assert!(report.written_paths.contains(&"calendar.json".to_string()));
|
||||
assert!(report.written_paths.contains(&"rss.xml".to_string()));
|
||||
assert!(report.written_paths.contains(&"feed.xml".to_string()));
|
||||
assert!(!report.written_paths.contains(&"feed.xml".to_string()));
|
||||
assert!(report.written_paths.contains(&"atom.xml".to_string()));
|
||||
assert!(report.written_paths.contains(&"sitemap.xml".to_string()));
|
||||
assert!(report.written_paths.contains(&"404.html".to_string()));
|
||||
@@ -209,7 +209,7 @@ fn generation_engine_writes_core_and_single_post_artifacts() {
|
||||
|
||||
assert!(dir.path().join("index.html").exists());
|
||||
assert!(dir.path().join("rss.xml").exists());
|
||||
assert!(dir.path().join("feed.xml").exists());
|
||||
assert!(!dir.path().join("feed.xml").exists());
|
||||
assert!(dir.path().join("atom.xml").exists());
|
||||
assert!(dir.path().join("sitemap.xml").exists());
|
||||
assert!(dir.path().join("assets/pico.min.css").exists());
|
||||
@@ -217,14 +217,188 @@ fn generation_engine_writes_core_and_single_post_artifacts() {
|
||||
assert!(dir.path().join("2024/03/09/hello/index.html").exists());
|
||||
|
||||
let rss = std::fs::read_to_string(dir.path().join("rss.xml")).unwrap();
|
||||
assert!(rss.contains("<rss version=\"2.0\""));
|
||||
assert!(rss.contains("https://example.com/2024/03/09/hello"));
|
||||
assert_eq!(
|
||||
rss,
|
||||
"<rss><channel><title>Blog (en)</title><item><title>next</title><link>https://example.com/2024/03/10/next/</link></item><item><title>hello</title><link>https://example.com/2024/03/09/hello/</link></item></channel></rss>"
|
||||
);
|
||||
let atom = std::fs::read_to_string(dir.path().join("atom.xml")).unwrap();
|
||||
assert_eq!(
|
||||
atom,
|
||||
"<feed><title>Blog (en)</title><entry><title>next</title><id>https://example.com/2024/03/10/next/</id></entry><entry><title>hello</title><id>https://example.com/2024/03/09/hello/</id></entry></feed>"
|
||||
);
|
||||
|
||||
let sitemap = std::fs::read_to_string(dir.path().join("sitemap.xml")).unwrap();
|
||||
assert!(sitemap.contains("https://example.com/2024/03/09/hello"));
|
||||
assert!(sitemap.contains("https://example.com/category/article"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_source_languages_use_the_main_language_at_canonical_urls() {
|
||||
let (db, dir) = setup();
|
||||
let mut metadata = make_metadata();
|
||||
metadata.main_language = Some("de".into());
|
||||
metadata.blog_languages = vec!["de".into(), "en".into()];
|
||||
bds_core::engine::meta::write_project_json(dir.path(), &metadata).unwrap();
|
||||
insert_template(
|
||||
db.conn(),
|
||||
&Template {
|
||||
id: "template-post".into(),
|
||||
project_id: "p1".into(),
|
||||
slug: "post".into(),
|
||||
title: "Post".into(),
|
||||
kind: TemplateKind::Post,
|
||||
enabled: true,
|
||||
version: 1,
|
||||
file_path: String::new(),
|
||||
status: TemplateStatus::Published,
|
||||
content: Some("ID={{ post.id }} {{ post.content }}".into()),
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut english_source = make_post("english-source", 1_710_000_000_000);
|
||||
english_source.language = Some("en".into());
|
||||
write_published_snapshot(&dir, &mut english_source, "English source body");
|
||||
bds_core::db::queries::post::insert_post(db.conn(), &english_source).unwrap();
|
||||
let german_translation = PostTranslation {
|
||||
id: "translation-english-de".into(),
|
||||
project_id: "p1".into(),
|
||||
translation_for: english_source.id.clone(),
|
||||
language: "de".into(),
|
||||
title: "Deutsche Übersetzung".into(),
|
||||
excerpt: None,
|
||||
content: None,
|
||||
status: PostStatus::Published,
|
||||
file_path: "posts/english-source.de.md".into(),
|
||||
checksum: None,
|
||||
created_at: english_source.created_at,
|
||||
updated_at: english_source.updated_at,
|
||||
published_at: english_source.published_at,
|
||||
};
|
||||
bds_core::db::queries::post_translation::insert_post_translation(
|
||||
db.conn(),
|
||||
&german_translation,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join(&german_translation.file_path),
|
||||
bds_core::util::frontmatter::write_translation_file(
|
||||
&german_translation,
|
||||
"Deutscher Übersetzungstext",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut german_without_translation = make_post("german-fallback", 1_710_172_800_000);
|
||||
german_without_translation.language = Some("de".into());
|
||||
german_without_translation.title = "Deutscher Fallback".into();
|
||||
|
||||
let mut german_source = make_post("german-source", 1_710_086_400_000);
|
||||
german_source.language = Some("de".into());
|
||||
german_source.title = "Deutsche Quelle".into();
|
||||
write_published_snapshot(&dir, &mut german_source, "Deutscher Quelltext");
|
||||
bds_core::db::queries::post::insert_post(db.conn(), &german_source).unwrap();
|
||||
let english_translation = PostTranslation {
|
||||
id: "translation-german-en".into(),
|
||||
project_id: "p1".into(),
|
||||
translation_for: german_source.id.clone(),
|
||||
language: "en".into(),
|
||||
title: "English translation".into(),
|
||||
excerpt: None,
|
||||
content: None,
|
||||
status: PostStatus::Published,
|
||||
file_path: "posts/german-source.en.md".into(),
|
||||
checksum: None,
|
||||
created_at: german_source.created_at,
|
||||
updated_at: german_source.updated_at,
|
||||
published_at: german_source.published_at,
|
||||
};
|
||||
bds_core::db::queries::post_translation::insert_post_translation(
|
||||
db.conn(),
|
||||
&english_translation,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join(&english_translation.file_path),
|
||||
bds_core::util::frontmatter::write_translation_file(
|
||||
&english_translation,
|
||||
"English translation body",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut german_private = make_post("german-private", 1_710_259_200_000);
|
||||
german_private.language = Some("de".into());
|
||||
german_private.do_not_translate = true;
|
||||
|
||||
let output = dir.path().join("html");
|
||||
let sources = vec![
|
||||
PublishedPostSource {
|
||||
post: english_source,
|
||||
body_markdown: "English source body".into(),
|
||||
},
|
||||
PublishedPostSource {
|
||||
post: german_source,
|
||||
body_markdown: "Deutscher Quelltext".into(),
|
||||
},
|
||||
PublishedPostSource {
|
||||
post: german_without_translation,
|
||||
body_markdown: "Unübersetzter Quelltext".into(),
|
||||
},
|
||||
PublishedPostSource {
|
||||
post: german_private,
|
||||
body_markdown: "Nur auf Deutsch".into(),
|
||||
},
|
||||
];
|
||||
generate_starter_site(db.conn(), &output, "p1", &metadata, &sources, "de").unwrap();
|
||||
|
||||
let canonical_english_source =
|
||||
std::fs::read_to_string(output.join("2024/03/09/english-source/index.html")).unwrap();
|
||||
let localized_english_source =
|
||||
std::fs::read_to_string(output.join("en/2024/03/09/english-source/index.html")).unwrap();
|
||||
let canonical_german_source =
|
||||
std::fs::read_to_string(output.join("2024/03/10/german-source/index.html")).unwrap();
|
||||
let localized_german_source =
|
||||
std::fs::read_to_string(output.join("en/2024/03/10/german-source/index.html")).unwrap();
|
||||
let localized_fallback =
|
||||
std::fs::read_to_string(output.join("en/2024/03/11/german-fallback/index.html")).unwrap();
|
||||
|
||||
assert!(canonical_english_source.contains("Deutscher Übersetzungstext"));
|
||||
assert!(canonical_english_source.contains("ID=translation-english-de"));
|
||||
assert!(localized_english_source.contains("English source body"));
|
||||
assert!(localized_english_source.contains("ID=post-english-source"));
|
||||
assert!(canonical_german_source.contains("Deutscher Quelltext"));
|
||||
assert!(localized_german_source.contains("English translation body"));
|
||||
assert!(localized_fallback.contains("Unübersetzter Quelltext"));
|
||||
assert!(
|
||||
output
|
||||
.join("2024/03/12/german-private/index.html")
|
||||
.is_file()
|
||||
);
|
||||
assert!(
|
||||
!output
|
||||
.join("en/2024/03/12/german-private/index.html")
|
||||
.exists()
|
||||
);
|
||||
|
||||
let sitemap = std::fs::read_to_string(output.join("sitemap.xml")).unwrap();
|
||||
assert!(sitemap.contains("https://example.com/2024/03/09/english-source"));
|
||||
assert!(sitemap.contains("https://example.com/en/2024/03/09/english-source"));
|
||||
assert!(sitemap.contains("https://example.com/2024/03/10/german-source"));
|
||||
assert!(sitemap.contains("https://example.com/en/2024/03/10/german-source"));
|
||||
assert!(sitemap.contains("https://example.com/2024/03/12/german-private"));
|
||||
assert!(!sitemap.contains("https://example.com/en/2024/03/12/german-private"));
|
||||
let main_rss = std::fs::read_to_string(output.join("rss.xml")).unwrap();
|
||||
let english_rss = std::fs::read_to_string(output.join("en/rss.xml")).unwrap();
|
||||
assert!(main_rss.contains("<title>english-source</title>"));
|
||||
assert!(!main_rss.contains("<title>Deutsche Übersetzung</title>"));
|
||||
assert!(english_rss.contains("<title>english-source</title>"));
|
||||
assert!(english_rss.contains("<title>English translation</title>"));
|
||||
assert!(!english_rss.contains("<title>Deutscher Fallback</title>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn section_generation_reports_its_urls_and_defers_pagefind() {
|
||||
let (db, dir) = setup();
|
||||
@@ -345,7 +519,8 @@ fn multilingual_generation_writes_language_aware_atom_and_sitemap_routes() {
|
||||
|
||||
assert!(report.written_paths.contains(&"en/atom.xml".to_string()));
|
||||
assert!(report.written_paths.contains(&"en/rss.xml".to_string()));
|
||||
assert!(report.written_paths.contains(&"en/sitemap.xml".to_string()));
|
||||
assert!(!report.written_paths.contains(&"en/sitemap.xml".to_string()));
|
||||
assert!(!report.written_paths.contains(&"feed.xml".to_string()));
|
||||
assert!(report.written_paths.contains(&"en/404.html".to_string()));
|
||||
assert!(
|
||||
report
|
||||
@@ -354,19 +529,79 @@ fn multilingual_generation_writes_language_aware_atom_and_sitemap_routes() {
|
||||
);
|
||||
|
||||
let atom = std::fs::read_to_string(dir.path().join("en/atom.xml")).unwrap();
|
||||
assert!(
|
||||
atom.contains("<link href=\"https://example.com/en/\" rel=\"alternate\" />")
|
||||
|| atom.contains("<link href=\"https://example.com/en\" rel=\"alternate\" />")
|
||||
);
|
||||
assert!(atom.contains("<link href=\"https://example.com/en/atom.xml\" rel=\"self\" />"));
|
||||
assert!(atom.starts_with("<feed><title>Blog (en)</title>"));
|
||||
|
||||
let sitemap = std::fs::read_to_string(dir.path().join("sitemap.xml")).unwrap();
|
||||
assert!(sitemap.contains("hreflang=\"de\" href=\"https://example.com/\""));
|
||||
assert!(sitemap.contains("hreflang=\"en\" href=\"https://example.com/en\""));
|
||||
assert!(sitemap.contains("hreflang=\"en\" href=\"https://example.com/en/\""));
|
||||
assert!(sitemap.contains("hreflang=\"x-default\" href=\"https://example.com/\""));
|
||||
assert!(sitemap.contains("https://example.com/category/article"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_category_posts_also_generate_flat_routes() {
|
||||
let (db, dir) = setup();
|
||||
let metadata = make_metadata();
|
||||
let mut page = make_post("about", 1_710_000_000_000);
|
||||
page.categories = vec!["page".into()];
|
||||
let posts = vec![PublishedPostSource {
|
||||
post: page,
|
||||
body_markdown: "About this site".into(),
|
||||
}];
|
||||
|
||||
let report =
|
||||
generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
|
||||
|
||||
assert!(
|
||||
report
|
||||
.written_paths
|
||||
.contains(&"about/index.html".to_string())
|
||||
);
|
||||
assert!(dir.path().join("about/index.html").is_file());
|
||||
let sitemap = std::fs::read_to_string(dir.path().join("sitemap.xml")).unwrap();
|
||||
assert!(sitemap.contains("<loc>https://example.com/about/</loc>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_aliases_belong_to_core_while_dated_posts_belong_to_single() {
|
||||
let (db, dir) = setup();
|
||||
let metadata = make_metadata();
|
||||
let mut page = make_post("about", 1_710_000_000_000);
|
||||
page.categories = vec!["page".into()];
|
||||
let posts = vec![PublishedPostSource {
|
||||
post: page,
|
||||
body_markdown: "About this site".into(),
|
||||
}];
|
||||
|
||||
let single = render_site_section_with_progress(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
"p1",
|
||||
&metadata,
|
||||
&posts,
|
||||
GenerationSection::Single,
|
||||
|_current, _total, _url| {},
|
||||
|| false,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(single.written_paths, vec!["2024/03/09/about/index.html"]);
|
||||
assert!(!dir.path().join("about/index.html").exists());
|
||||
|
||||
let core = render_site_section_with_progress(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
"p1",
|
||||
&metadata,
|
||||
&posts,
|
||||
GenerationSection::Core,
|
||||
|_current, _total, _url| {},
|
||||
|| false,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(core.written_paths.contains(&"about/index.html".to_string()));
|
||||
assert!(dir.path().join("about/index.html").is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generation_respects_category_list_settings_and_writes_bundled_images() {
|
||||
let (db, dir) = setup();
|
||||
@@ -457,12 +692,12 @@ fn generation_respects_category_list_settings_and_writes_bundled_images() {
|
||||
std::fs::read_to_string(dir.path().join("category/featured/index.html")).unwrap();
|
||||
assert!(featured_html.contains("FEATURED:[Featured Post|false]"));
|
||||
|
||||
let feed = std::fs::read_to_string(dir.path().join("feed.xml")).unwrap();
|
||||
assert!(!feed.contains("hidden-post"));
|
||||
assert!(feed.contains("featured-post"));
|
||||
let rss = std::fs::read_to_string(dir.path().join("rss.xml")).unwrap();
|
||||
assert!(rss.contains("hidden-post"));
|
||||
assert!(rss.contains("featured-post"));
|
||||
|
||||
let calendar = std::fs::read_to_string(dir.path().join("calendar.json")).unwrap();
|
||||
assert!(!calendar.contains("2024-03-09"));
|
||||
assert!(calendar.contains("2024-03-09"));
|
||||
assert!(calendar.contains("2024-03-10"));
|
||||
}
|
||||
|
||||
@@ -484,7 +719,7 @@ fn generation_engine_skips_unchanged_outputs_on_second_run() {
|
||||
assert!(second.skipped_paths.contains(&"index.html".to_string()));
|
||||
assert!(second.skipped_paths.contains(&"calendar.json".to_string()));
|
||||
assert!(second.skipped_paths.contains(&"rss.xml".to_string()));
|
||||
assert!(second.skipped_paths.contains(&"feed.xml".to_string()));
|
||||
assert!(!second.skipped_paths.contains(&"feed.xml".to_string()));
|
||||
assert!(
|
||||
second
|
||||
.skipped_paths
|
||||
@@ -506,12 +741,12 @@ fn site_validation_detects_stale_and_missing_outputs() {
|
||||
|
||||
generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
|
||||
std::fs::write(dir.path().join("index.html"), "tampered").unwrap();
|
||||
std::fs::remove_file(dir.path().join("feed.xml")).unwrap();
|
||||
std::fs::remove_file(dir.path().join("atom.xml")).unwrap();
|
||||
|
||||
let report = validate_site(db.conn(), dir.path(), "p1").unwrap();
|
||||
|
||||
assert!(report.stale_pages.contains(&"index.html".to_string()));
|
||||
assert!(report.missing_pages.contains(&"feed.xml".to_string()));
|
||||
assert!(report.missing_pages.contains(&"atom.xml".to_string()));
|
||||
assert!(report.extra_pages.is_empty());
|
||||
}
|
||||
|
||||
@@ -529,7 +764,7 @@ fn apply_validation_repairs_core_section_outputs() {
|
||||
|
||||
generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "en").unwrap();
|
||||
std::fs::write(dir.path().join("index.html"), "tampered").unwrap();
|
||||
std::fs::remove_file(dir.path().join("feed.xml")).unwrap();
|
||||
std::fs::remove_file(dir.path().join("atom.xml")).unwrap();
|
||||
|
||||
let report = validate_site(db.conn(), dir.path(), "p1").unwrap();
|
||||
let sections = sections_from_validation_report(&report);
|
||||
|
||||
@@ -111,20 +111,20 @@ fn generated_write_rewrites_stale_file_even_when_db_hash_matches() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_generation_paths_include_language_prefixed_variants() {
|
||||
fn core_generation_paths_match_bds2_outputs() {
|
||||
let paths = build_core_generation_paths("en", &["en".into(), "de".into(), "fr".into()]);
|
||||
assert!(paths.contains(&"index.html".to_string()));
|
||||
assert!(paths.contains(&"404.html".to_string()));
|
||||
assert!(paths.contains(&"sitemap.xml".to_string()));
|
||||
assert!(paths.contains(&"rss.xml".to_string()));
|
||||
assert!(paths.contains(&"feed.xml".to_string()));
|
||||
assert!(!paths.contains(&"feed.xml".to_string()));
|
||||
assert!(paths.contains(&"atom.xml".to_string()));
|
||||
assert!(paths.contains(&"calendar.json".to_string()));
|
||||
assert!(paths.contains(&"de/index.html".to_string()));
|
||||
assert!(paths.contains(&"de/404.html".to_string()));
|
||||
assert!(paths.contains(&"de/sitemap.xml".to_string()));
|
||||
assert!(!paths.contains(&"de/sitemap.xml".to_string()));
|
||||
assert!(paths.contains(&"de/rss.xml".to_string()));
|
||||
assert!(paths.contains(&"de/feed.xml".to_string()));
|
||||
assert!(!paths.contains(&"de/feed.xml".to_string()));
|
||||
assert!(paths.contains(&"de/atom.xml".to_string()));
|
||||
assert!(paths.contains(&"fr/index.html".to_string()));
|
||||
}
|
||||
@@ -138,6 +138,7 @@ fn calendar_json_groups_posts_by_year_month_day() {
|
||||
];
|
||||
|
||||
let json = build_calendar_json(&posts).unwrap();
|
||||
assert!(!json.contains('\n'));
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(parsed["years"]["2024"], 3);
|
||||
@@ -146,3 +147,15 @@ fn calendar_json_groups_posts_by_year_month_day() {
|
||||
assert_eq!(parsed["days"]["2024-03-09"], 2);
|
||||
assert_eq!(parsed["days"]["2024-04-09"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calendar_json_uses_created_at_instead_of_published_at() {
|
||||
let mut post = make_post("a", 1_710_000_000_000);
|
||||
post.published_at = Some(1_712_678_400_000);
|
||||
|
||||
let json = build_calendar_json(&[post]).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(parsed["days"]["2024-03-09"], 1);
|
||||
assert!(parsed["days"].get("2024-04-09").is_none());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user