use std::collections::HashMap; use chrono::{Datelike, TimeZone, Utc}; use serde::Serialize; use crate::i18n::normalize_language; use crate::model::{Post, ProjectMetadata}; use crate::render::{RenderError, render_liquid_template}; const STARTER_SINGLE_POST_TEMPLATE: &str = include_str!("../../../../assets/starter-templates/single-post.liquid"); const STARTER_POST_LIST_TEMPLATE: &str = include_str!("../../../../assets/starter-templates/post-list.liquid"); const STARTER_HEAD_PARTIAL: &str = include_str!("../../../../assets/starter-templates/partials/head.liquid"); 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"); #[derive(Debug, Clone, PartialEq, Eq)] pub struct RenderedPage { pub relative_path: String, pub html: String, } #[derive(Debug, Clone, Serialize)] struct AlternateLinkContext { href: String, hreflang: String, } #[derive(Debug, Clone, Serialize)] struct BlogLanguageContext { is_current: bool, code: String, flag: String, href: String, href_prefix: String, } #[derive(Debug, Clone, Serialize)] struct DayBlockContext { show_date_marker: bool, date_label: String, posts: Vec, show_separator: bool, } #[derive(Debug, Clone, Serialize)] struct ListTemplateContext { language: String, language_prefix: String, page_title: String, pico_stylesheet_href: Option, html_theme_attribute: Option, alternate_links: Vec, blog_languages: Vec, menu_items: Vec, calendar_initial_year: i32, calendar_initial_month: u32, archive_context: Option, show_archive_range_heading: bool, min_date: Option, max_date: Option, day_blocks: Vec, is_list_page: bool, is_first_page: bool, is_last_page: bool, has_prev_page: bool, has_next_page: bool, prev_page_href: Option, next_page_href: Option, canonical_post_path_by_slug: HashMap, canonical_media_path_by_source_path: HashMap, post_data_json_by_id: HashMap, } #[derive(Debug, Clone, Serialize)] struct PostTemplateContext<'a> { language: &'a str, language_prefix: String, page_title: &'a str, pico_stylesheet_href: Option, html_theme_attribute: Option, alternate_links: Vec, blog_languages: Vec, menu_items: Vec, calendar_initial_year: i32, calendar_initial_month: u32, post: serde_json::Value, post_categories: Vec, post_tags: Vec, tag_color_by_name: HashMap, backlinks: Vec, canonical_post_path_by_slug: HashMap, canonical_media_path_by_source_path: HashMap, post_data_json_by_id: HashMap, } 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 { return fallback_language_path(post, language, main_language); }; let base = format!( "/{:04}/{:02}/{:02}/{}", timestamp.year(), timestamp.month(), timestamp.day(), post.slug ); if language.eq_ignore_ascii_case(main_language) { base } else { format!("/{language}{base}") } } pub fn render_starter_single_post_page( post: &Post, body_markdown: &str, metadata: &ProjectMetadata, language: &str, ) -> Result { render_starter_single_post_page_with_media_map( post, body_markdown, metadata, language, HashMap::new(), ) } pub fn render_starter_single_post_page_with_media_map( post: &Post, body_markdown: &str, metadata: &ProjectMetadata, language: &str, canonical_media_path_by_source_path: HashMap, ) -> Result { let relative_path = format!("{}/index.html", build_canonical_post_path(post, language, main_language(metadata)).trim_start_matches('/')); let canonical_path = build_canonical_post_path(post, language, main_language(metadata)); let (calendar_initial_year, calendar_initial_month) = calendar_initial_parts(post); let context = PostTemplateContext { language, language_prefix: language_prefix(language, main_language(metadata)), page_title: &post.title, pico_stylesheet_href: metadata .pico_theme .as_ref() .map(|_| "/assets/pico.min.css".to_string()), html_theme_attribute: None, alternate_links: build_alternate_links(post, metadata, language), blog_languages: build_blog_languages(post, metadata, language), menu_items: vec![], calendar_initial_year, calendar_initial_month, post: serde_json::json!({ "id": post.id, "title": post.title, "content": body_markdown, }), post_categories: post.categories.clone(), post_tags: post.tags.clone(), tag_color_by_name: post .tags .iter() .map(|tag| (tag.clone(), String::new())) .collect(), backlinks: vec![], canonical_post_path_by_slug: HashMap::from([(post.slug.clone(), canonical_path)]), canonical_media_path_by_source_path, post_data_json_by_id: HashMap::new(), }; let html = render_liquid_template( STARTER_SINGLE_POST_TEMPLATE, &starter_partials(), &context, )?; Ok(RenderedPage { relative_path, html }) } pub fn render_starter_list_page( posts: &[(Post, String)], metadata: &ProjectMetadata, language: &str, ) -> Result { render_starter_list_page_with_media_map(posts, metadata, language, HashMap::new()) } pub fn render_starter_list_page_with_media_map( posts: &[(Post, String)], metadata: &ProjectMetadata, language: &str, canonical_media_path_by_source_path: HashMap, ) -> Result { let relative_path = if language.eq_ignore_ascii_case(main_language(metadata)) { "index.html".to_string() } else { format!("{language}/index.html") }; let canonical_paths = posts .iter() .map(|(post, _)| { ( post.slug.clone(), build_canonical_post_path(post, language, main_language(metadata)), ) }) .collect::>(); let (calendar_initial_year, calendar_initial_month) = posts .first() .map(|(post, _)| calendar_initial_parts(post)) .unwrap_or((1970, 1)); let context = ListTemplateContext { language: language.to_string(), language_prefix: language_prefix(language, main_language(metadata)), page_title: metadata.name.clone(), pico_stylesheet_href: metadata .pico_theme .as_ref() .map(|_| "/assets/pico.min.css".to_string()), html_theme_attribute: None, alternate_links: vec![], blog_languages: build_blog_languages_for_index(metadata, language), menu_items: vec![], calendar_initial_year, calendar_initial_month, archive_context: None, show_archive_range_heading: false, min_date: None, max_date: None, day_blocks: build_day_blocks(posts), is_list_page: false, is_first_page: true, is_last_page: true, has_prev_page: false, has_next_page: false, prev_page_href: None, next_page_href: None, canonical_post_path_by_slug: canonical_paths, canonical_media_path_by_source_path, post_data_json_by_id: HashMap::new(), }; let html = render_liquid_template( &starter_post_list_template(), &starter_partials(), &context, )?; Ok(RenderedPage { relative_path, html }) } fn starter_partials() -> HashMap { HashMap::from([ ("partials/head".to_string(), STARTER_HEAD_PARTIAL.to_string()), ("partials/menu".to_string(), STARTER_MENU_PARTIAL.to_string()), ( "partials/language-switcher".to_string(), STARTER_LANGUAGE_SWITCHER_PARTIAL.to_string(), ), ( "partials/menu-items".to_string(), "{% for item in items %}{{ item.title }}{% endfor %}".to_string(), ), ]) } fn starter_post_list_template() -> String { STARTER_POST_LIST_TEMPLATE.replace( "{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href, language_prefix: language_prefix %}", "{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href, language_prefix: language_prefix, alternate_links: alternate_links %}", ) } fn main_language(metadata: &ProjectMetadata) -> &str { metadata.main_language.as_deref().unwrap_or("en") } fn language_prefix(language: &str, main_language: &str) -> String { if language.eq_ignore_ascii_case(main_language) { String::new() } else { format!("/{language}") } } fn fallback_language_path(post: &Post, language: &str, main_language: &str) -> String { if language.eq_ignore_ascii_case(main_language) { format!("/posts/{}", post.slug) } else { format!("/{language}/posts/{}", post.slug) } } 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) .single() .map(|timestamp| (timestamp.year(), timestamp.month())) .unwrap_or((1970, 1)) } fn build_alternate_links(post: &Post, metadata: &ProjectMetadata, current_language: &str) -> Vec { metadata .blog_languages .iter() .map(|language| AlternateLinkContext { href: build_absolute_post_url(post, metadata, language), hreflang: language.clone(), }) .chain(std::iter::once(AlternateLinkContext { href: build_absolute_post_url(post, metadata, current_language), hreflang: "x-default".to_string(), })) .collect() } fn build_blog_languages(post: &Post, metadata: &ProjectMetadata, current_language: &str) -> Vec { metadata .blog_languages .iter() .map(|language| BlogLanguageContext { is_current: language.eq_ignore_ascii_case(current_language), code: language.clone(), flag: render_flag(language), href: build_absolute_post_url(post, metadata, language), href_prefix: language_prefix(language, main_language(metadata)), }) .collect() } fn build_blog_languages_for_index(metadata: &ProjectMetadata, current_language: &str) -> Vec { metadata .blog_languages .iter() .map(|language| BlogLanguageContext { is_current: language.eq_ignore_ascii_case(current_language), code: language.clone(), flag: render_flag(language), href: build_absolute_index_url(metadata, language), href_prefix: language_prefix(language, main_language(metadata)), }) .collect() } fn build_absolute_post_url(post: &Post, metadata: &ProjectMetadata, language: &str) -> String { let base_url = metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/'); format!("{base_url}{}", build_canonical_post_path(post, language, main_language(metadata))) } fn build_absolute_index_url(metadata: &ProjectMetadata, language: &str) -> String { let base_url = metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/'); let suffix = if language.eq_ignore_ascii_case(main_language(metadata)) { "/".to_string() } else { format!("/{language}/") }; format!("{base_url}{suffix}") } fn render_flag(language: &str) -> String { normalize_language(language).flag_emoji().to_string() } fn build_day_blocks(posts: &[(Post, String)]) -> Vec { let mut blocks: Vec = Vec::new(); 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 { continue; }; let key = format!("{:04}-{:02}-{:02}", timestamp.year(), timestamp.month(), timestamp.day()); if current_key.as_deref() != Some(key.as_str()) { if let Some(last) = blocks.last_mut() { last.show_separator = true; } current_key = Some(key); blocks.push(DayBlockContext { show_date_marker: true, date_label: format!("{:02}.{:02}.{:04}", timestamp.day(), timestamp.month(), timestamp.year()), posts: Vec::new(), show_separator: false, }); } if let Some(block) = blocks.last_mut() { block.posts.push(serde_json::json!({ "id": post.id, "slug": post.slug, "title": post.title, "content": post.excerpt.clone().unwrap_or_else(|| body.clone()), "show_title": true, })); } } blocks } #[cfg(test)] mod tests { use super::*; use crate::model::PostStatus; fn make_post() -> Post { Post { id: "post-1".into(), project_id: "project-1".into(), title: "Hello".into(), slug: "hello".into(), excerpt: None, content: Some("Body".into()), status: PostStatus::Published, author: None, language: Some("en".into()), do_not_translate: false, template_slug: None, file_path: String::new(), checksum: None, tags: vec![], categories: vec![], published_title: None, published_content: None, published_tags: None, published_categories: None, published_excerpt: None, created_at: 1_710_000_000_000, updated_at: 1_710_000_000_000, published_at: Some(1_710_000_000_000), } } fn make_metadata() -> ProjectMetadata { ProjectMetadata { name: "Blog".into(), description: None, public_url: Some("https://example.com".into()), main_language: Some("en".into()), default_author: None, max_posts_per_page: 50, blogmark_category: None, pico_theme: None, semantic_similarity_enabled: false, blog_languages: vec!["en".into(), "de".into()], } } #[test] fn canonical_post_paths_follow_language_prefix_rule() { let post = make_post(); assert_eq!(build_canonical_post_path(&post, "en", "en"), "/2024/03/09/hello"); assert_eq!(build_canonical_post_path(&post, "de", "en"), "/de/2024/03/09/hello"); } #[test] fn starter_single_post_renderer_uses_canonical_route_and_language_links() { let post = make_post(); let metadata = make_metadata(); let rendered = render_starter_single_post_page(&post, "Body with [link](/posts/hello)", &metadata, "en").unwrap(); assert_eq!(rendered.relative_path, "2024/03/09/hello/index.html"); assert!(rendered.html.contains("https://example.com/2024/03/09/hello")); assert!(rendered.html.contains("https://example.com/de/2024/03/09/hello")); assert!(rendered.html.contains("href=\"/2024/03/09/hello\"")); } #[test] fn starter_single_post_renderer_rewrites_bds_media_image_links() { let post = make_post(); let metadata = make_metadata(); let rendered = render_starter_single_post_page_with_media_map( &post, "![](bds-media://media-1)", &metadata, "en", HashMap::from([( "bds-media://media-1".to_string(), "/media/2026/04/media-1.png".to_string(), )]), ) .unwrap(); assert!(rendered.html.contains("src=\"/media/2026/04/media-1.png\"")); } #[test] fn starter_list_renderer_groups_posts_and_uses_language_specific_index_path() { let metadata = make_metadata(); let first = make_post(); let mut second = make_post(); second.id = "post-2".into(); second.slug = "next".into(); second.title = "Next".into(); second.published_at = Some(1_710_086_400_000); second.created_at = 1_710_086_400_000; let rendered = render_starter_list_page( &[(first, "First body".into()), (second, "Second body".into())], &metadata, "de", ) .unwrap(); 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("href=\"/de/2024/03/10/next\"")); } }