From ffcf3d9878bbfd6988e86d29cc4c5090cb7094c5 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Mon, 13 Apr 2026 20:04:56 +0200 Subject: [PATCH] feat: more M4 closing --- crates/bds-core/src/engine/generation.rs | 130 ++++- crates/bds-core/src/engine/mod.rs | 1 + crates/bds-core/src/engine/project.rs | 7 +- crates/bds-core/src/engine/site_assets.rs | 79 +++ crates/bds-core/src/render/macros.rs | 466 ++++++++++++++++++ crates/bds-core/src/render/mod.rs | 1 + crates/bds-core/src/render/page_renderer.rs | 63 ++- crates/bds-core/tests/m4_generation_engine.rs | 33 ++ crates/bds-core/tests/m4_page_renderer.rs | 35 ++ 9 files changed, 795 insertions(+), 20 deletions(-) create mode 100644 crates/bds-core/src/engine/site_assets.rs create mode 100644 crates/bds-core/src/render/macros.rs diff --git a/crates/bds-core/src/engine/generation.rs b/crates/bds-core/src/engine/generation.rs index 16795d5..8243c40 100644 --- a/crates/bds-core/src/engine/generation.rs +++ b/crates/bds-core/src/engine/generation.rs @@ -7,6 +7,7 @@ use pagefind::options::PagefindServiceConfig; use rusqlite::Connection; use crate::db::queries; +use crate::engine::site_assets::write_bundled_site_assets; use crate::engine::{EngineError, EngineResult}; use crate::model::{Post, ProjectMetadata}; use crate::render::{ @@ -33,7 +34,7 @@ pub fn generate_starter_site( project_id: &str, metadata: &ProjectMetadata, posts: &[PublishedPostSource], - language: &str, + _language: &str, ) -> EngineResult { let mut report = GenerationReport::default(); let input_posts = posts @@ -47,6 +48,8 @@ pub fn generate_starter_site( write_out(conn, output_dir, project_id, &page.relative_path, &page.html, &mut report)?; } + write_bundled_site_assets(conn, output_dir, project_id, &mut report)?; + write_out( conn, output_dir, @@ -69,7 +72,14 @@ pub fn generate_starter_site( } write_out(conn, output_dir, project_id, &format!("{prefix}feed.xml"), &rss, &mut report)?; write_out(conn, output_dir, project_id, &format!("{prefix}atom.xml"), &build_atom_xml(metadata, &localized_posts, &render_language), &mut report)?; - write_out(conn, output_dir, project_id, &format!("{prefix}sitemap.xml"), &build_sitemap_xml(metadata, &localized_posts, &render_language), &mut report)?; + write_out( + conn, + output_dir, + project_id, + &format!("{prefix}sitemap.xml"), + &build_sitemap_xml(metadata, &artifacts.pages, &localized_posts, &render_language), + &mut report, + )?; } write_pagefind_indexes(conn, output_dir, project_id, &artifacts.pagefind_documents, &mut report)?; @@ -259,6 +269,8 @@ fn build_rss_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], lang fn build_atom_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], language: &str) -> String { let base_url = metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/'); + let 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))) @@ -270,8 +282,8 @@ fn build_atom_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], lan format!(" {}", escape_xml(&metadata.name)), format!(" {}", escape_xml(metadata.description.as_deref().unwrap_or(""))), format!(" {base_url}/"), - format!(" "), - format!(" "), + format!(" "), + format!(" "), format!(" {}", updated.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)), ]; @@ -303,36 +315,74 @@ fn build_atom_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], lan xml.join("\n") } -fn build_sitemap_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], language: &str) -> String { +fn build_sitemap_xml( + metadata: &ProjectMetadata, + pages: &[crate::render::SitePage], + posts: &[PublishedPostSource], + language: &str, +) -> String { let base_url = metadata.public_url.as_deref().unwrap_or("").trim_end_matches('/'); + let main_language = metadata.main_language.as_deref().unwrap_or("en"); + let languages = render_languages(metadata); let index_lastmod = posts .iter() .filter_map(|post| timestamp(post.post.published_at.unwrap_or(post.post.created_at))) .max() .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 page_groups = group_pages_by_logical_path(pages, &languages, main_language); let mut xml = vec![ "".to_string(), "".to_string(), - " ".to_string(), - format!(" {base_url}/"), - format!(" {index_lastmod}"), - " daily".to_string(), - " 1.0".to_string(), - " ".to_string(), ]; - 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 lastmod = timestamp(source.post.published_at.unwrap_or(source.post.created_at)) - .unwrap_or_else(Utc::now) - .to_rfc3339_opts(chrono::SecondsFormat::Millis, true); + for page in pages.iter().filter(|page| page.language == language) { + let url = format!("{base_url}{}", page.url_path); + let logical_key = logical_page_key(&page.relative_path, &languages, main_language); + 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); xml.push(" ".to_string()); xml.push(format!(" {url}")); xml.push(format!(" {lastmod}")); - xml.push(" weekly".to_string()); - xml.push(" 0.8".to_string()); + 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!( + " ", + escape_xml(&alternate.language), + alternate.url_path, + )); + } + if let Some(default_page) = alternates.iter().find(|alternate| alternate.language == main_language) { + xml.push(format!( + " ", + default_page.url_path, + )); + } + } xml.push(" ".to_string()); } @@ -340,6 +390,50 @@ fn build_sitemap_xml(metadata: &ProjectMetadata, posts: &[PublishedPostSource], xml.join("\n") } +fn language_prefix(language: &str, main_language: &str) -> String { + if language.eq_ignore_ascii_case(main_language) { + String::new() + } else { + format!("/{language}") + } +} + +fn language_root_url_path(language: &str, main_language: &str) -> String { + let prefix = language_prefix(language, main_language); + if prefix.is_empty() { + "/".to_string() + } else { + format!("{prefix}/") + } +} + +fn group_pages_by_logical_path<'a>( + pages: &'a [crate::render::SitePage], + languages: &[String], + main_language: &str, +) -> HashMap> { + let mut grouped = HashMap::>::new(); + for page in pages { + let key = logical_page_key(&page.relative_path, languages, main_language); + grouped.entry(key).or_default().push(page); + } + grouped +} + +fn logical_page_key(relative_path: &str, languages: &[String], main_language: &str) -> String { + let mut parts = relative_path.split('/'); + let Some(first) = parts.next() else { + return relative_path.to_string(); + }; + if first.eq_ignore_ascii_case(main_language) { + return parts.collect::>().join("/"); + } + if languages.iter().any(|language| language.eq_ignore_ascii_case(first) && !language.eq_ignore_ascii_case(main_language)) { + return parts.collect::>().join("/"); + } + relative_path.to_string() +} + fn timestamp(timestamp_ms: i64) -> Option> { chrono::Utc.timestamp_millis_opt(timestamp_ms).single() } diff --git a/crates/bds-core/src/engine/mod.rs b/crates/bds-core/src/engine/mod.rs index ddef658..d4a5650 100644 --- a/crates/bds-core/src/engine/mod.rs +++ b/crates/bds-core/src/engine/mod.rs @@ -13,6 +13,7 @@ pub mod script; pub mod script_rebuild; pub mod task; pub mod metadata_diff; +pub mod site_assets; pub mod rebuild; pub mod search; pub mod calendar; diff --git a/crates/bds-core/src/engine/project.rs b/crates/bds-core/src/engine/project.rs index aff0729..885e19d 100644 --- a/crates/bds-core/src/engine/project.rs +++ b/crates/bds-core/src/engine/project.rs @@ -6,6 +6,7 @@ use rusqlite::Connection; use uuid::Uuid; use crate::db::queries::project as q; +use crate::engine::site_assets::copy_bundled_site_assets; use crate::engine::{EngineError, EngineResult}; use crate::model::Project; use crate::model::metadata::ProjectMetadata; @@ -152,7 +153,7 @@ pub fn delete_project(conn: &Connection, project_id: &str, internal_data_dir: Op // ── helpers ────────────────────────────────────────────────────────── fn create_directory_structure(data_dir: &Path) -> EngineResult<()> { - let subdirs = ["posts", "media", "meta", "thumbnails", "templates", "scripts"]; + let subdirs = ["posts", "media", "meta", "thumbnails", "templates", "scripts", "assets"]; for sub in &subdirs { fs::create_dir_all(data_dir.join(sub))?; } @@ -200,6 +201,7 @@ fn write_default_meta_files(data_dir: &Path, project_name: &str) -> EngineResult // Starter templates — per project.allium StarterTemplatesCopied copy_starter_templates(data_dir)?; + copy_bundled_site_assets(data_dir)?; Ok(()) } @@ -274,6 +276,9 @@ mod tests { assert!(data_path.join("thumbnails").is_dir()); assert!(data_path.join("templates").is_dir()); assert!(data_path.join("scripts").is_dir()); + assert!(data_path.join("assets").is_dir()); + assert!(data_path.join("assets/pico.min.css").is_file()); + assert!(data_path.join("assets/tag-cloud.js").is_file()); // Verify meta files let project_json: serde_json::Value = diff --git a/crates/bds-core/src/engine/site_assets.rs b/crates/bds-core/src/engine/site_assets.rs new file mode 100644 index 0000000..d470b43 --- /dev/null +++ b/crates/bds-core/src/engine/site_assets.rs @@ -0,0 +1,79 @@ +use std::fs; +use std::path::Path; + +use rusqlite::Connection; + +use crate::engine::{EngineError, EngineResult}; +use crate::render::{GeneratedWriteOutcome, write_generated_bytes}; + +#[derive(Debug, Clone, Copy)] +pub(crate) struct BundledSiteAsset { + pub relative_path: &'static str, + pub bytes: &'static [u8], +} + +const BUNDLED_SITE_ASSETS: &[BundledSiteAsset] = &[ + BundledSiteAsset { relative_path: "assets/bds.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/bds.css") }, + BundledSiteAsset { relative_path: "assets/calendar-runtime.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/calendar-runtime.js") }, + BundledSiteAsset { relative_path: "assets/code-enhancements.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/code-enhancements.js") }, + BundledSiteAsset { relative_path: "assets/d3.layout.cloud.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/d3.layout.cloud.js") }, + BundledSiteAsset { relative_path: "assets/highlight.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/highlight.min.css") }, + BundledSiteAsset { relative_path: "assets/highlight.min.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/highlight.min.js") }, + BundledSiteAsset { relative_path: "assets/lightbox.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/lightbox.min.css") }, + BundledSiteAsset { relative_path: "assets/lightbox.min.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/lightbox.min.js") }, + BundledSiteAsset { relative_path: "assets/pico.amber.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.amber.min.css") }, + BundledSiteAsset { relative_path: "assets/pico.blue.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.blue.min.css") }, + BundledSiteAsset { relative_path: "assets/pico.cyan.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.cyan.min.css") }, + BundledSiteAsset { relative_path: "assets/pico.fuchsia.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.fuchsia.min.css") }, + BundledSiteAsset { relative_path: "assets/pico.green.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.green.min.css") }, + BundledSiteAsset { relative_path: "assets/pico.grey.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.grey.min.css") }, + BundledSiteAsset { relative_path: "assets/pico.indigo.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.indigo.min.css") }, + BundledSiteAsset { relative_path: "assets/pico.jade.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.jade.min.css") }, + BundledSiteAsset { relative_path: "assets/pico.lime.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.lime.min.css") }, + BundledSiteAsset { relative_path: "assets/pico.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.min.css") }, + BundledSiteAsset { relative_path: "assets/pico.orange.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.orange.min.css") }, + BundledSiteAsset { relative_path: "assets/pico.pink.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.pink.min.css") }, + BundledSiteAsset { relative_path: "assets/pico.pumpkin.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.pumpkin.min.css") }, + BundledSiteAsset { relative_path: "assets/pico.purple.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.purple.min.css") }, + BundledSiteAsset { relative_path: "assets/pico.red.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.red.min.css") }, + BundledSiteAsset { relative_path: "assets/pico.sand.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.sand.min.css") }, + BundledSiteAsset { relative_path: "assets/pico.slate.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.slate.min.css") }, + BundledSiteAsset { relative_path: "assets/pico.violet.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.violet.min.css") }, + BundledSiteAsset { relative_path: "assets/pico.yellow.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.yellow.min.css") }, + BundledSiteAsset { relative_path: "assets/pico.zinc.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/pico.zinc.min.css") }, + BundledSiteAsset { relative_path: "assets/search-runtime.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/search-runtime.js") }, + BundledSiteAsset { relative_path: "assets/tag-cloud.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/tag-cloud.js") }, + BundledSiteAsset { relative_path: "assets/vanilla-calendar.min.css", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/vanilla-calendar.min.css") }, + BundledSiteAsset { relative_path: "assets/vanilla-calendar.min.js", bytes: include_bytes!("../../../../fixtures/golden-generated-sites/rfc1437-sample/assets/vanilla-calendar.min.js") }, +]; + +pub(crate) fn copy_bundled_site_assets(project_dir: &Path) -> EngineResult<()> { + for asset in BUNDLED_SITE_ASSETS { + let target = project_dir.join(asset.relative_path); + if target.exists() { + continue; + } + if let Some(parent) = target.parent() { + fs::create_dir_all(parent)?; + } + fs::write(target, asset.bytes)?; + } + Ok(()) +} + +pub(crate) fn write_bundled_site_assets( + conn: &Connection, + output_dir: &Path, + project_id: &str, + report: &mut crate::engine::generation::GenerationReport, +) -> EngineResult<()> { + for asset in BUNDLED_SITE_ASSETS { + match write_generated_bytes(conn, output_dir, project_id, asset.relative_path, asset.bytes) + .map_err(|error| EngineError::Parse(error.to_string()))? + { + GeneratedWriteOutcome::Written => report.written_paths.push(asset.relative_path.to_string()), + GeneratedWriteOutcome::SkippedUnchanged => report.skipped_paths.push(asset.relative_path.to_string()), + } + } + Ok(()) +} \ No newline at end of file diff --git a/crates/bds-core/src/render/macros.rs b/crates/bds-core/src/render/macros.rs new file mode 100644 index 0000000..8bc2927 --- /dev/null +++ b/crates/bds-core/src/render/macros.rs @@ -0,0 +1,466 @@ +use std::collections::{BTreeMap, HashMap}; + +use serde_json::{Map, Value as JsonValue}; + +#[derive(Debug, Clone, Default)] +pub(crate) struct MacroRenderContext { + pub roots: Map, + pub post_id: Option, +} + +pub(crate) fn expand_builtin_macros(markdown: &str, context: &MacroRenderContext) -> String { + let mut expanded = String::with_capacity(markdown.len()); + let mut cursor = 0; + + while let Some(offset) = markdown[cursor..].find("[[") { + let start = cursor + offset; + expanded.push_str(&markdown[cursor..start]); + + let body_start = start + 2; + let Some(end_offset) = markdown[body_start..].find("]]") else { + expanded.push_str(&markdown[start..]); + return expanded; + }; + let end = body_start + end_offset; + let invocation = &markdown[body_start..end]; + let raw = &markdown[start..end + 2]; + + if let Some(rendered) = render_macro(invocation, context) { + expanded.push_str(&rendered); + } else { + expanded.push_str(raw); + } + cursor = end + 2; + } + + expanded.push_str(&markdown[cursor..]); + expanded +} + +fn render_macro(invocation: &str, context: &MacroRenderContext) -> Option { + let tokens = tokenize_invocation(invocation); + let name = tokens.first()?.as_str(); + let mut args = HashMap::new(); + for token in tokens.iter().skip(1) { + let (key, raw_value) = token.split_once('=')?; + args.insert(key.to_string(), resolve_token(raw_value, context)); + } + + match name { + "gallery" => Some(render_gallery(&args, context)), + "youtube" => Some(render_youtube(&args)), + "vimeo" => Some(render_vimeo(&args)), + "photo_archive" => Some(render_photo_archive(&args, context)), + "tag_cloud" => Some(render_tag_cloud(&args, context)), + _ => None, + } +} + +fn tokenize_invocation(invocation: &str) -> Vec { + let mut tokens = Vec::new(); + let mut current = String::new(); + let mut quote = None; + + for ch in invocation.chars() { + if let Some(active_quote) = quote { + current.push(ch); + if ch == active_quote { + quote = None; + } + continue; + } + + match ch { + '"' | '\'' => { + quote = Some(ch); + current.push(ch); + } + ' ' | '\n' | '\t' | '\r' => { + if !current.is_empty() { + tokens.push(std::mem::take(&mut current)); + } + } + _ => current.push(ch), + } + } + + if !current.is_empty() { + tokens.push(current); + } + + tokens +} + +fn resolve_token(raw: &str, context: &MacroRenderContext) -> JsonValue { + let trimmed = raw.trim(); + if trimmed.len() >= 2 { + if (trimmed.starts_with('"') && trimmed.ends_with('"')) + || (trimmed.starts_with('\'') && trimmed.ends_with('\'')) + { + return JsonValue::String(trimmed[1..trimmed.len() - 1].to_string()); + } + } + + match trimmed { + "true" => return JsonValue::Bool(true), + "false" => return JsonValue::Bool(false), + "null" => return JsonValue::Null, + _ => {} + } + + if let Ok(value) = trimmed.parse::() { + return JsonValue::from(value); + } + if let Ok(value) = trimmed.parse::() { + return JsonValue::from(value); + } + + lookup_path(trimmed, context).unwrap_or_else(|| JsonValue::String(trimmed.to_string())) +} + +fn lookup_path(path: &str, context: &MacroRenderContext) -> Option { + let mut segments = path.split('.'); + let first = segments.next()?; + let mut current = context.roots.get(first)?.clone(); + + for segment in segments { + current = match current { + JsonValue::Object(map) => map.get(segment)?.clone(), + JsonValue::Array(values) => values.get(segment.parse::().ok()?)?.clone(), + _ => return None, + }; + } + + Some(current) +} + +fn render_gallery(args: &HashMap, context: &MacroRenderContext) -> String { + let columns = args + .get("columns") + .and_then(value_as_u64) + .map(|value| value.clamp(1, 6) as usize) + .unwrap_or(3); + let images = args + .get("images") + .and_then(JsonValue::as_array) + .cloned() + .or_else(|| linked_media(context)); + + let Some(images) = images else { + return empty_block( + &format!("macro-gallery gallery-cols-{columns}"), + "gallery-empty", + "No media available.", + ); + }; + if images.is_empty() { + return empty_block( + &format!("macro-gallery gallery-cols-{columns}"), + "gallery-empty", + "No media available.", + ); + } + + let gallery_id = context.post_id.as_deref().unwrap_or("gallery"); + let mut html = format!( + ""); + html +} + +fn render_youtube(args: &HashMap) -> String { + let video_id = args.get("id").map(stringify_scalar).unwrap_or_default(); + if video_id.is_empty() { + return empty_block("macro-youtube", "gallery-empty", "Missing YouTube video id."); + } + format!( + "
", + escape_html_attr(&video_id), + ) +} + +fn render_vimeo(args: &HashMap) -> String { + let video_id = args.get("id").map(stringify_scalar).unwrap_or_default(); + if video_id.is_empty() { + return empty_block("macro-vimeo", "gallery-empty", "Missing Vimeo video id."); + } + format!( + "
", + escape_html_attr(&video_id), + ) +} + +fn render_photo_archive(args: &HashMap, context: &MacroRenderContext) -> String { + let media = args + .get("media") + .and_then(JsonValue::as_array) + .cloned() + .or_else(|| linked_media(context)); + + let Some(media) = media else { + return empty_block("macro-photo-archive", "photo-archive-empty", "No photos available."); + }; + if media.is_empty() { + return empty_block("macro-photo-archive", "photo-archive-empty", "No photos available."); + } + + let mut grouped = BTreeMap::>::new(); + for item in media { + let bucket = item + .get("file_path") + .and_then(JsonValue::as_str) + .and_then(month_bucket) + .unwrap_or_else(|| "Archive".to_string()); + grouped.entry(bucket).or_default().push(item); + } + + let single_month = grouped.len() == 1; + let mut html = format!( + "
", + if single_month { " photo-archive-single-month" } else { "" } + ); + for (bucket, items) in grouped.into_iter().rev() { + html.push_str("
"); + html.push_str(&format!( + "
{}
", + escape_html(&bucket), + )); + html.push_str("
"); + for item in items { + let Some(path) = image_path(&item) else { + continue; + }; + let title = image_title(&item); + let alt = image_alt(&item, title.as_deref()); + html.push_str(&format!( + "\"{}\"", + escape_html_attr(&path), + title + .as_deref() + .map(|value| format!(" data-title=\"{}\"", escape_html_attr(value))) + .unwrap_or_default(), + escape_html_attr(&path), + escape_html_attr(&alt), + )); + } + html.push_str("
"); + } + html.push_str("
"); + html +} + +fn render_tag_cloud(args: &HashMap, context: &MacroRenderContext) -> String { + let tags = args + .get("tags") + .and_then(JsonValue::as_array) + .cloned() + .or_else(|| tag_items(context)); + + let Some(tags) = tags else { + return empty_block("macro-tag-cloud", "tag-cloud-empty", "No tags available."); + }; + if tags.is_empty() { + return empty_block("macro-tag-cloud", "tag-cloud-empty", "No tags available."); + } + + let words = build_tag_cloud_words(&tags, context); + if words.is_empty() { + return empty_block("macro-tag-cloud", "tag-cloud-empty", "No tags available."); + } + + let words_json = serde_json::to_string(&words).unwrap_or_else(|_| "[]".to_string()); + format!( + "
", + escape_html_attr(&words_json), + ) +} + +fn build_tag_cloud_words(tags: &[JsonValue], context: &MacroRenderContext) -> Vec { + let min_count = tags + .iter() + .filter_map(|tag| tag.get("post_count").and_then(value_as_u64)) + .min() + .unwrap_or(1); + let max_count = tags + .iter() + .filter_map(|tag| tag.get("post_count").and_then(value_as_u64)) + .max() + .unwrap_or(min_count.max(1)); + + tags.iter() + .filter_map(|tag| { + let name = tag.get("name").and_then(JsonValue::as_str)?; + let slug = tag + .get("slug") + .and_then(JsonValue::as_str) + .map(ToOwned::to_owned) + .unwrap_or_else(|| name.to_lowercase().replace(' ', "-")); + let count = tag.get("post_count").and_then(value_as_u64).unwrap_or(1); + let size = if max_count == min_count { + 36.0 + } else { + 18.0 + (((count - min_count) as f64 / (max_count - min_count) as f64) * 38.0) + }; + let color = tag + .get("color") + .and_then(JsonValue::as_str) + .map(ToOwned::to_owned) + .or_else(|| { + context + .roots + .get("tag_color_by_name") + .and_then(JsonValue::as_object) + .and_then(|colors| colors.get(name)) + .and_then(JsonValue::as_str) + .map(ToOwned::to_owned) + }); + Some(serde_json::json!({ + "text": name, + "count": count, + "url": format!("/tag/{slug}"), + "size": size.round(), + "color": color, + })) + }) + .collect() +} + +fn linked_media(context: &MacroRenderContext) -> Option> { + lookup_path("post.linked_media", context).and_then(|value| value.as_array().cloned()) +} + +fn tag_items(context: &MacroRenderContext) -> Option> { + lookup_path("post_tags", context) + .and_then(|value| value.as_array().cloned()) + .or_else(|| lookup_path("Tags", context).and_then(|value| value.as_array().cloned())) +} + +fn image_path(image: &JsonValue) -> Option { + image.get("file_path").and_then(JsonValue::as_str).map(ToOwned::to_owned) +} + +fn image_title(image: &JsonValue) -> Option { + image + .get("caption") + .and_then(JsonValue::as_str) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| { + image + .get("title") + .and_then(JsonValue::as_str) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + }) +} + +fn image_alt(image: &JsonValue, fallback: Option<&str>) -> String { + image + .get("alt") + .and_then(JsonValue::as_str) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| fallback.map(ToOwned::to_owned)) + .unwrap_or_default() +} + +fn value_as_u64(value: &JsonValue) -> Option { + value.as_u64().or_else(|| value.as_i64().map(|number| number.max(0) as u64)) +} + +fn stringify_scalar(value: &JsonValue) -> String { + match value { + JsonValue::Null => String::new(), + JsonValue::Bool(boolean) => boolean.to_string(), + JsonValue::Number(number) => number.to_string(), + JsonValue::String(text) => text.clone(), + JsonValue::Array(_) | JsonValue::Object(_) => value.to_string(), + } +} + +fn month_bucket(path: &str) -> Option { + let segments = path.trim_matches('/').split('/').collect::>(); + match segments.as_slice() { + ["media", year, month, ..] if year.len() == 4 && month.len() == 2 => Some(format!("{year}-{month}")), + _ => None, + } +} + +fn empty_block(wrapper_class: &str, message_class: &str, message: &str) -> String { + format!( + "

{}

", + wrapper_class, + message_class, + escape_html(message), + ) +} + +fn escape_html(value: &str) -> String { + value.replace('&', "&").replace('<', "<").replace('>', ">") +} + +fn escape_html_attr(value: &str) -> String { + escape_html(value).replace('"', """).replace('\'', "'") +} + +#[cfg(test)] +mod tests { + use super::{MacroRenderContext, expand_builtin_macros}; + + #[test] + fn expands_gallery_and_tag_cloud_macros() { + let mut roots = serde_json::Map::new(); + roots.insert( + "post".to_string(), + serde_json::json!({ + "linked_media": [ + {"file_path": "/media/2026/04/one.jpg", "title": "One", "alt": "Alt one"}, + {"file_path": "/media/2026/04/two.jpg", "caption": "Two"} + ] + }), + ); + roots.insert( + "post_tags".to_string(), + serde_json::json!([ + {"name": "Rust", "slug": "rust", "post_count": 4, "color": "#ff6600"}, + {"name": "Iced", "slug": "iced", "post_count": 2} + ]), + ); + roots.insert( + "tag_color_by_name".to_string(), + serde_json::json!({"Iced": "#0088cc"}), + ); + + let html = expand_builtin_macros( + "[[gallery images=post.linked_media columns=2]]\n\n[[tag_cloud tags=post_tags]]", + &MacroRenderContext { + roots, + post_id: Some("post-1".to_string()), + }, + ); + + assert!(html.contains("macro-gallery gallery-cols-2")); + assert!(html.contains("data-lightbox=\"post-1\"")); + assert!(html.contains("macro-tag-cloud")); + assert!(html.contains("data-tag-cloud=\"true\"")); + } +} \ No newline at end of file diff --git a/crates/bds-core/src/render/mod.rs b/crates/bds-core/src/render/mod.rs index 63be7f3..6bde2ac 100644 --- a/crates/bds-core/src/render/mod.rs +++ b/crates/bds-core/src/render/mod.rs @@ -1,4 +1,5 @@ mod markdown; +mod macros; mod generation; mod page_renderer; mod routes; diff --git a/crates/bds-core/src/render/page_renderer.rs b/crates/bds-core/src/render/page_renderer.rs index 889ec2b..215d57e 100644 --- a/crates/bds-core/src/render/page_renderer.rs +++ b/crates/bds-core/src/render/page_renderer.rs @@ -6,10 +6,13 @@ use liquid_core::{ Display_filter, Expression, Filter, FilterParameters, FilterReflection, FromFilterParameters, ParseFilter, Runtime, Value, ValueView, }; +use liquid_core::model::ScalarCow; use serde::Serialize; +use serde_json::{Map as JsonMap, Value as JsonValue}; use thiserror::Error; use crate::i18n::translate_render; +use crate::render::macros::{MacroRenderContext, expand_builtin_macros}; use crate::render::render_markdown_to_html; #[derive(Debug, Error)] @@ -123,12 +126,70 @@ impl Filter for MarkdownFilter { .map(value_to_string_map) .unwrap_or_default(), }; + let macro_context = MacroRenderContext { + roots: collect_macro_roots(runtime), + post_id: runtime + .try_get(&[ScalarCow::new("post"), ScalarCow::new("id")]) + .and_then(|value| value.as_scalar().map(|scalar| scalar.to_kstr().to_string())), + }; - let rendered = render_markdown_to_html(markdown.as_str()); + let expanded = expand_builtin_macros(markdown.as_str(), ¯o_context); + let rendered = render_markdown_to_html(&expanded); Ok(Value::scalar(rewrite_rendered_html_urls(&rendered, &rewrite_context))) } } +fn collect_macro_roots(runtime: &dyn Runtime) -> JsonMap { + let mut roots = JsonMap::new(); + + for key in ["post", "post_tags", "tag_color_by_name", "project", "Tags"] { + if let Some(value) = runtime.try_get(&[ScalarCow::new(key)]) { + roots.insert(key.to_string(), liquid_value_to_json(value.as_view())); + } + } + + if !roots.contains_key("Tags") { + if let Some(tags) = roots.get("post_tags").cloned() { + roots.insert("Tags".to_string(), tags); + } + } + + roots +} + +fn liquid_value_to_json(value: &dyn ValueView) -> JsonValue { + if value.is_nil() { + return JsonValue::Null; + } + + if let Some(scalar) = value.as_scalar() { + if let Some(boolean) = scalar.to_bool() { + return JsonValue::Bool(boolean); + } + if let Some(integer) = scalar.to_integer() { + return JsonValue::from(integer); + } + if let Some(float) = scalar.to_float() { + return JsonValue::from(float); + } + return JsonValue::String(scalar.to_kstr().to_string()); + } + + if let Some(array) = value.as_array() { + return JsonValue::Array(array.values().map(liquid_value_to_json).collect()); + } + + if let Some(object) = value.as_object() { + let mapped = object + .iter() + .map(|(key, value)| (key.to_string(), liquid_value_to_json(value))) + .collect(); + return JsonValue::Object(mapped); + } + + JsonValue::String(value.to_kstr().to_string()) +} + fn value_to_string_map(value: &impl ValueView) -> HashMap { value .as_object() diff --git a/crates/bds-core/tests/m4_generation_engine.rs b/crates/bds-core/tests/m4_generation_engine.rs index 116630b..dd2d48e 100644 --- a/crates/bds-core/tests/m4_generation_engine.rs +++ b/crates/bds-core/tests/m4_generation_engine.rs @@ -94,6 +94,8 @@ fn generation_engine_writes_core_and_single_post_artifacts() { 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(&"assets/pico.min.css".to_string())); + assert!(report.written_paths.contains(&"assets/tag-cloud.js".to_string())); assert!(report.written_paths.contains(&"2024/03/09/hello/index.html".to_string())); assert!(report.written_paths.contains(&"2024/03/10/next/index.html".to_string())); @@ -102,6 +104,8 @@ fn generation_engine_writes_core_and_single_post_artifacts() { 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()); + assert!(dir.path().join("assets/tag-cloud.js").exists()); assert!(dir.path().join("2024/03/09/hello/index.html").exists()); let rss = std::fs::read_to_string(dir.path().join("rss.xml")).unwrap(); @@ -110,6 +114,34 @@ fn generation_engine_writes_core_and_single_post_artifacts() { 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 multilingual_generation_writes_language_aware_atom_and_sitemap_routes() { + let (db, dir) = setup(); + let mut metadata = make_metadata(); + metadata.main_language = Some("de".into()); + metadata.blog_languages = vec!["de".into(), "en".into()]; + let posts = vec![PublishedPostSource { + post: make_post("hallo", 1_710_000_000_000), + body_markdown: "Hallo Welt".into(), + }]; + + let report = generate_starter_site(db.conn(), dir.path(), "p1", &metadata, &posts, "de").unwrap(); + + assert!(report.written_paths.contains(&"en/atom.xml".to_string())); + assert!(report.written_paths.contains(&"en/sitemap.xml".to_string())); + + let atom = std::fs::read_to_string(dir.path().join("en/atom.xml")).unwrap(); + assert!(atom.contains("") || atom.contains("")); + assert!(atom.contains("")); + + 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=\"x-default\" href=\"https://example.com/\"")); + assert!(sitemap.contains("https://example.com/category/article")); } #[test] @@ -129,6 +161,7 @@ fn generation_engine_skips_unchanged_outputs_on_second_run() { 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(&"assets/pico.min.css".to_string())); } #[test] diff --git a/crates/bds-core/tests/m4_page_renderer.rs b/crates/bds-core/tests/m4_page_renderer.rs index 8b65000..acf9bd2 100644 --- a/crates/bds-core/tests/m4_page_renderer.rs +++ b/crates/bds-core/tests/m4_page_renderer.rs @@ -56,6 +56,41 @@ fn markdown_filter_rewrites_post_and_media_urls() { assert!(rendered.contains("src=\"/assets/pic.png\"")); } +#[test] +fn markdown_filter_expands_builtin_macros_from_runtime_context() { + let template = "{{ body | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language, language_prefix }}"; + let partials = HashMap::new(); + let context = serde_json::json!({ + "body": "[[gallery images=post.linked_media columns=2]]\n\n[[youtube id=dQw4w9WgXcQ]]\n\n[[vimeo id=123456]]\n\n[[photo_archive media=post.linked_media]]\n\n[[tag_cloud tags=post_tags]]", + "post": { + "id": "post-1", + "linked_media": [ + {"file_path": "/media/2026/04/one.jpg", "title": "One", "alt": "Image one"}, + {"file_path": "/media/2026/04/two.jpg", "caption": "Two"} + ] + }, + "post_data_json_by_id": { + "post-1": {"id": "post-1", "title": "Post 1"} + }, + "post_tags": [ + {"name": "Rust", "slug": "rust", "post_count": 4, "color": "#ff6600"}, + {"name": "Iced", "slug": "iced", "post_count": 2} + ], + "tag_color_by_name": {"Iced": "#0088cc"}, + "canonical_post_path_by_slug": {}, + "canonical_media_path_by_source_path": {}, + "language": "en", + "language_prefix": "" + }); + + let rendered = render_liquid_template(template, &partials, &context).unwrap(); + assert!(rendered.contains("macro-gallery gallery-cols-2")); + assert!(rendered.contains("https://www.youtube.com/embed/dQw4w9WgXcQ")); + assert!(rendered.contains("https://player.vimeo.com/video/123456")); + assert!(rendered.contains("macro-photo-archive")); + assert!(rendered.contains("data-tag-cloud=\"true\"")); +} + #[test] fn starter_single_post_template_renders_with_partials() { let template = include_str!("../../../assets/starter-templates/single-post.liquid");