diff --git a/README.md b/README.md
index e5a1207..3541c49 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,7 @@ The project is under active development. Core blogging workflows are broadly ava
- Local MCP automation over stdio or a localhost-only stateless HTTP endpoint, with project resources, read/search/count tools, inert write proposals, explicit desktop approval, and opt-in Claude Code/Copilot configuration.
- A fully localized Ratatui terminal workspace, available locally through `bds-cli tui`/`BDS_MODE=tui` and remotely through authenticated SSH shell sessions, with shared post/template/script editing and publishing, project/search/command overlays, settings, tags, Git, reports, task progress, live multi-client locale updates, and airplane-mode AI gating.
- `bds-cli server` hosting the shared application engines over a loopback-by-default, public-key-only SSH service, with restrictive private key material, live authorization updates, terminal-session transport, CLI-change synchronization, ordered domain/task events, and native desktop remote-project selection.
-- Markdown/Liquid rendering with native macros, multilingual routes, feeds, sitemap, Pagefind, and incremental site generation through cancellable section task groups.
+- bDS2-compatible Markdown/Liquid rendering with native macros, canonical multilingual and flat page routes, recursive menus, calendar archives, feeds, a root hreflang sitemap, Pagefind, and incremental site generation through cancellable section task groups.
- Navigable generated-route preview in the app or system browser, with draft database overlays and published filesystem content.
- Optional one-shot AI translation, description, analysis, taxonomy, and language-detection operations using independent online and local OpenAI-compatible profiles. Each profile has secure credentials, persistently discovered chat/title/image model selections, explicit tool/vision overrides, chat testing, and status-bar airplane-mode routing.
- Persistent conversational AI with safe Markdown, streamed and cancellable responses, model/session/token tracking, bounded project-aware blog tools, and localized conversation management in the Chat workspace. Allowlisted render tools add persistent native cards, charts, forms, lists, metrics, mind maps, tables, and tabs without executing assistant-provided HTML or JavaScript.
diff --git a/assets/starter-templates/partials/language-switcher.liquid b/assets/starter-templates/partials/language-switcher.liquid
index 1fa9bb3..7b0b604 100644
--- a/assets/starter-templates/partials/language-switcher.liquid
+++ b/assets/starter-templates/partials/language-switcher.liquid
@@ -15,7 +15,7 @@
@@ -35,7 +35,7 @@
{% endif %}
diff --git a/crates/bds-core/src/engine/generation.rs b/crates/bds-core/src/engine/generation.rs
index d3060c0..c0b2cbf 100644
--- a/crates/bds-core/src/engine/generation.rs
+++ b/crates/bds-core/src/engine/generation.rs
@@ -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>,
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::>(),
@@ -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::>()
+ };
+ 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 {
{
Some(GenerationSection::Single)
}
+ [_slug, "index.html"] => Some(GenerationSection::Core),
_ => None,
}
}
@@ -782,9 +808,7 @@ pub(crate) fn classify_generated_path(path: &str) -> Option {
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 {
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![
- "".to_string(),
- "".to_string(),
- " ".to_string(),
- format!(" {} ", escape_xml(&metadata.name)),
- format!(" {base_url}/"),
- format!(" {} ", escape_xml(metadata.description.as_deref().unwrap_or(""))),
- format!(" {} ", last_build.format("%a, %d %b %Y %H:%M:%S GMT")),
- " bDS ".to_string(),
- ];
+ let mut xml = format!(
+ "{} ({}) ",
+ 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(" - ".to_string());
- xml.push(format!(
- "
{} ",
+ let url = post_absolute_url(base_url, metadata, source, language);
+ xml.push_str(&format!(
+ "{} {url} ",
escape_xml(&source.post.title)
));
- xml.push(format!(" {url}"));
- xml.push(format!(" {url} "));
- xml.push(format!(
- " {} ",
- published.format("%a, %d %b %Y %H:%M:%S GMT")
- ));
- if let Some(author) = &source.post.author {
- xml.push(format!(" {} ", escape_xml(author)));
- }
- xml.push(format!(
- " {} ",
- escape_xml(language)
- ));
- let html = render_markdown_to_html(&source.body_markdown);
- xml.push(format!(
- " "
- ));
- xml.push(format!(
- " "
- ));
- for category in &source.post.categories {
- xml.push(format!(
- " {} ",
- escape_xml(category)
- ));
- }
- for tag in &source.post.tags {
- xml.push(format!(" {} ", escape_xml(tag)));
- }
- xml.push(" ".to_string());
}
-
- xml.push(" ".to_string());
- xml.push(" ".to_string());
- xml.join("\n")
+ xml.push_str(" ");
+ 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![
- "".to_string(),
- "".to_string(),
- format!(" {} ", escape_xml(&metadata.name)),
- format!(
- " {} ",
- escape_xml(metadata.description.as_deref().unwrap_or(""))
- ),
- format!(" {base_url}/ "),
- format!(" "),
- format!(" "),
- format!(
- " {} ",
- updated.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
- ),
- ];
+ let mut xml = format!(
+ "{} ({}) ",
+ 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!(" ", escape_xml(language)));
- xml.push(format!(
- " {} ",
+ let url = post_absolute_url(base_url, metadata, source, language);
+ xml.push_str(&format!(
+ "{} {url} ",
escape_xml(&source.post.title)
));
- xml.push(format!(" {url} "));
- xml.push(format!(" "));
- xml.push(format!(
- " {} ",
- published.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
- ));
- xml.push(format!(
- " {} ",
- published.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
- ));
- if let Some(author) = &source.post.author {
- xml.push(format!(
- " {} ",
- escape_xml(author)
- ));
- }
- xml.push(format!(" {html}
"));
- xml.push(format!(" {html}
"));
- for category in &source.post.categories {
- xml.push(format!(
- " ",
- escape_xml(category)
- ));
- }
- for tag in &source.post.tags {
- xml.push(format!(" ", escape_xml(tag)));
- }
- xml.push(" ".to_string());
}
+ xml.push_str(" ");
+ xml
+}
- xml.push(" ".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::>();
+ 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(
"".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::>();
+ 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(" ".to_string());
- xml.push(format!(" {url} "));
+ xml.push(format!(" {} ", escape_xml(&url)));
xml.push(format!(" {lastmod} "));
- xml.push(format!(
- " {} ",
- if is_home { "daily" } else { "weekly" }
- ));
- xml.push(format!(
- " {} ",
- if is_home { "1.0" } else { "0.8" }
- ));
- if let Some(alternates) = alternates {
- for alternate in alternates {
+ xml.push(format!(" {} ", changefreq));
+ xml.push(format!(" {} ", 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!(
- " ",
+ " ",
+ escape_xml(alternate_language),
+ escape_xml(&href),
+ ));
+ }
+ let href = format!("{base_url}{}", sitemap_url_path(&page.url_path));
+ xml.push(format!(
+ " ",
+ 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!(
+ " ",
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!(
- " ",
- default_page.url_path,
+ " ",
+ escape_xml(&href),
));
}
}
@@ -1157,7 +1166,65 @@ fn build_sitemap_xml(
}
xml.push(" ".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::>();
+ 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::>();
+ 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 {
diff --git a/crates/bds-core/src/engine/preview.rs b/crates/bds-core/src/engine/preview.rs
index 4dac8a9..4a98603 100644
--- a/crates/bds-core/src/engine/preview.rs
+++ b/crates/bds-core/src/engine/preview.rs
@@ -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::>();
@@ -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 {
- 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> {
- 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("world "));
}
+ #[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("Hello "));
+ }
+
#[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 Entwurf "));
assert!(calendar_json.contains("2024"));
assert!(rss_xml.contains("body"));
- assert!(rss_xml.contains("Filesystem body "));
- assert!(!rss_xml.contains("Stale database body"));
+ assert!(rss_xml.contains("Hello "));
+ assert!(rss_xml.contains("Published from file "));
assert!(translated_atom_xml.contains("Entwurf"));
+ assert!(translated_atom_xml.contains("Hallo "));
}
#[test]
diff --git a/crates/bds-core/src/engine/validate_site.rs b/crates/bds-core/src/engine/validate_site.rs
index 976dff6..3e785f7 100644
--- a/crates/bds-core/src/engine/validate_site.rs
+++ b/crates/bds-core/src/engine/validate_site.rs
@@ -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() {
diff --git a/crates/bds-core/src/render/generation.rs b/crates/bds-core/src/render/generation.rs
index e1cf741..f0c948b 100644
--- a/crates/bds-core/src/render/generation.rs
+++ b/crates/bds-core/src/render/generation.rs
@@ -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 {
- serde_json::to_string_pretty(&build_calendar_archive_data(posts))
+ serde_json::to_string(&build_calendar_archive_data(posts))
}
diff --git a/crates/bds-core/src/render/mod.rs b/crates/bds-core/src/render/mod.rs
index a4c8889..4c211bd 100644
--- a/crates/bds-core/src/render/mod.rs
+++ b/crates/bds-core/src/render/mod.rs
@@ -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,
diff --git a/crates/bds-core/src/render/routes.rs b/crates/bds-core/src/render/routes.rs
index 3edc0c0..282c0a5 100644
--- a/crates/bds-core/src/render/routes.rs
+++ b/crates/bds-core/src/render/routes.rs
@@ -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 {
+ 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 {
),
(
"partials/menu-items".to_string(),
- "{% for item in items %}{{ item.title }} {% 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 {
let mut current_key: Option = 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 {
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\""));
}
}
diff --git a/crates/bds-core/src/render/site.rs b/crates/bds-core/src/render/site.rs
index 3a200a9..5bfb2c5 100644
--- a/crates/bds-core/src/render/site.rs
+++ b/crates/bds-core/src/render/site.rs
@@ -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, Box> {
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::>();
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::>();
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 {
),
(
"partials/menu-items".to_string(),
- "{% for item in items %}{{ item.title }} {% endfor %}"
- .to_string(),
+ STARTER_MENU_ITEMS_PARTIAL.to_string(),
),
])
}
@@ -1644,8 +1668,8 @@ fn pico_stylesheet_href(metadata: &ProjectMetadata) -> Option {
}
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");
diff --git a/crates/bds-core/tests/fixture_readability.rs b/crates/bds-core/tests/fixture_readability.rs
index fd4642b..dc08438 100644
--- a/crates/bds-core/tests/fixture_readability.rs
+++ b/crates/bds-core/tests/fixture_readability.rs
@@ -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("2026-07-17 "));
+ assert!(rss.starts_with("rfc1437 (de) "));
+ assert!(atom.starts_with("rfc1437 (de) "));
+ assert!(sitemap.contains("https://www.rfc1437.de/ "));
+ assert!(sitemap.contains("hreflang=\"en\" href=\"https://www.rfc1437.de/en/\""));
+ assert!(sitemap.contains("https://www.rfc1437.de/bilderarchiv-2004/ "));
+ assert_eq!(calendar["days"]["2026-07-17"], 3);
+}
diff --git a/crates/bds-core/tests/m4_generation_engine.rs b/crates/bds-core/tests/m4_generation_engine.rs
index eae8d7e..93caa40 100644
--- a/crates/bds-core/tests/m4_generation_engine.rs
+++ b/crates/bds-core/tests/m4_generation_engine.rs
@@ -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("Blog (en) next https://example.com/2024/03/10/next/hello https://example.com/2024/03/09/hello/ "
+ );
+ let atom = std::fs::read_to_string(dir.path().join("atom.xml")).unwrap();
+ assert_eq!(
+ atom,
+ "Blog (en) next https://example.com/2024/03/10/next/ hello https://example.com/2024/03/09/hello/ "
+ );
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("english-source "));
+ assert!(!main_rss.contains("Deutsche Übersetzung "));
+ assert!(english_rss.contains("english-source "));
+ assert!(english_rss.contains("English translation "));
+ assert!(!english_rss.contains("Deutscher Fallback "));
+}
+
#[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(" ")
- || atom.contains(" ")
- );
- assert!(atom.contains(" "));
+ assert!(atom.starts_with("Blog (en) "));
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("https://example.com/about/ "));
+}
+
+#[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);
diff --git a/crates/bds-core/tests/m4_generation_primitives.rs b/crates/bds-core/tests/m4_generation_primitives.rs
index 312eaba..6017516 100644
--- a/crates/bds-core/tests/m4_generation_primitives.rs
+++ b/crates/bds-core/tests/m4_generation_primitives.rs
@@ -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());
+}
diff --git a/fixtures/golden-generated-sites/rfc1437-sample/2005/11/13/esmeralda/index.html b/fixtures/golden-generated-sites/rfc1437-sample/2005/11/13/esmeralda/index.html
index 687eb93..bd72668 100644
--- a/fixtures/golden-generated-sites/rfc1437-sample/2005/11/13/esmeralda/index.html
+++ b/fixtures/golden-generated-sites/rfc1437-sample/2005/11/13/esmeralda/index.html
@@ -3,19 +3,23 @@
- Content-type: matter-transport/sentient-life-form
-
+ Esmeralda
+
-
+
-
-
-
+
+
+
+
+
+
+
@@ -27,20 +31,19 @@
-
-
+
-
-
+
+
🇩🇪
-
-
-
+
+
+
🇬🇧
-
-
+
+
@@ -64,167 +67,167 @@
Esmeralda
-
-
-
-
picture
-
-
-
-
fotografie
-
-
-
makro
-
-
-
natur
-
-
-
spinne
-
-
-
tiere
-
-
-
-
+
+
-
-
+
-
+
-