use std::collections::{BTreeMap, HashMap}; use std::sync::Arc; use chrono::{Datelike, TimeZone, Utc}; use serde_json::{Map, Value as JsonValue}; use crate::i18n::translate_render; const GALLERY_TEMPLATE: &str = include_str!("../../../../assets/starter-templates/macros/gallery.liquid"); const YOUTUBE_TEMPLATE: &str = include_str!("../../../../assets/starter-templates/macros/youtube.liquid"); const VIMEO_TEMPLATE: &str = include_str!("../../../../assets/starter-templates/macros/vimeo.liquid"); const PHOTO_ARCHIVE_TEMPLATE: &str = include_str!("../../../../assets/starter-templates/macros/photo-archive.liquid"); const TAG_CLOUD_TEMPLATE: &str = include_str!("../../../../assets/starter-templates/macros/tag-cloud.liquid"); #[derive(Clone)] pub(crate) struct MacroRenderContext { pub roots: Map, pub post_id: Option, pub host: Arc, } impl Default for MacroRenderContext { fn default() -> Self { Self { roots: Map::new(), post_id: None, host: Arc::new(crate::scripting::UnavailableHost), } } } 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, context)), "vimeo" => Some(render_vimeo(&args, context)), "photo_archive" => Some(render_photo_archive(&args, context)), "tag_cloud" => Some(render_tag_cloud(&args, context)), _ => render_script_macro(name, &args, context), } } fn render_script_macro( name: &str, args: &HashMap, context: &MacroRenderContext, ) -> Option { let definition = context.roots.get("macro_scripts")?.get(name)?; let source = definition.get("source")?.as_str()?; let entrypoint = definition .get("entrypoint") .and_then(JsonValue::as_str) .unwrap_or("render"); let env = serde_json::json!({ "isPreview": context.roots.get("is_preview").and_then(JsonValue::as_bool).unwrap_or(false), "mainLanguage": context.roots.get("main_language").and_then(JsonValue::as_str).unwrap_or("en"), "language": context.roots.get("language").and_then(JsonValue::as_str).unwrap_or("en"), "languagePrefix": context.roots.get("language_prefix").and_then(JsonValue::as_str).unwrap_or(""), "hook": "markdown", "source": { "kind": if context.post_id.is_some() { "post" } else { "page" } }, "translations": context.roots.get("translations").cloned().unwrap_or(JsonValue::Array(Vec::new())), }); let params = serde_json::to_value(args).ok()?; match crate::scripting::execute_many_with_host( source, entrypoint, &[params, env], crate::scripting::ExecutionKind::Macro, &crate::scripting::ExecutionControl::default(), Arc::clone(&context.host), ) { Ok(result) => Some(match result.value { JsonValue::Null => String::new(), JsonValue::String(value) => value, value => value.to_string(), }), Err(_) => Some(String::new()), } } 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 && ((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 mut images = args .get("images") .and_then(JsonValue::as_array) .cloned() .or_else(|| linked_media(context)) .unwrap_or_default() .into_iter() .filter(is_image) .collect::>(); images.sort_by(media_newest_first); let gallery_id = format!("gallery-{}", context.post_id.as_deref().unwrap_or_default()); let items = images .into_iter() .filter_map(|image| { Some(serde_json::json!({ "media_path": ensure_leading_slash(&image_path(&image)?), "title": image_title(&image).unwrap_or_default(), "alt": image_alt(&image), "group_name": gallery_id, })) }) .collect::>(); render_macro_template( macro_template(context, "gallery", GALLERY_TEMPLATE), serde_json::json!({ "columns": columns, "post_id": context.post_id.as_deref().unwrap_or_default(), "items": items, "caption": args.get("caption").map(stringify_scalar), "no_items_label": render_translation(context, "render.gallery.empty"), }), ) } fn render_youtube(args: &HashMap, context: &MacroRenderContext) -> String { render_macro_template( macro_template(context, "youtube", YOUTUBE_TEMPLATE), serde_json::json!({ "id": args.get("id").map(stringify_scalar).unwrap_or_default(), "title": macro_title(args, context, "render.video.youtubeTitle"), }), ) } fn render_vimeo(args: &HashMap, context: &MacroRenderContext) -> String { render_macro_template( macro_template(context, "vimeo", VIMEO_TEMPLATE), serde_json::json!({ "id": args.get("id").map(stringify_scalar).unwrap_or_default(), "title": macro_title(args, context, "render.video.vimeoTitle"), }), ) } fn render_photo_archive(args: &HashMap, context: &MacroRenderContext) -> String { let mut media = args .get("media") .and_then(JsonValue::as_array) .cloned() .or_else(|| project_media(context)) .unwrap_or_default() .into_iter() .filter(is_image) .collect::>(); media.sort_by(media_newest_first); let year = args.get("year").and_then(value_as_i32); let month = args.get("month").and_then(value_as_u32); if args.contains_key("year") && year.is_none() { media.clear(); } else if let Some(year) = year { media.retain(|item| { media_archive_month(item).is_some_and(|(item_year, item_month)| { item_year == year && month.is_none_or(|month| item_month == month) }) }); } else { media.truncate(200); } let mut grouped = BTreeMap::<(i32, u32), Vec>::new(); for item in media { if let Some(bucket) = media_archive_month(&item) { grouped.entry(bucket).or_default().push(item); } } let month_limit = if year.is_none() { 10 } else { usize::MAX }; let months = grouped .into_iter() .rev() .take(month_limit) .map(|((year, month), items)| { let items = items .into_iter() .filter_map(|item| { Some(serde_json::json!({ "media_path": ensure_leading_slash(&image_path(&item)?), "title": image_archive_title(&item).unwrap_or_default(), "alt": image_alt(&item), "group_name": format!("{year}-{month}"), })) }) .collect::>(); serde_json::json!({ "label": format!( "{} {year}", render_translation(context, &format!("render.month.{month}")) ), "items": items, }) }) .collect::>(); render_macro_template( macro_template(context, "photo-archive", PHOTO_ARCHIVE_TEMPLATE), serde_json::json!({ "root_classes": "macro-photo-archive", "data_attrs": [], "months": months, "no_items_label": render_translation(context, "render.photoArchive.empty"), }), ) } 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 orientation = normalize_tag_cloud_orientation(args.get("orientation")); let width = tag_cloud_dimension(args.get("width"), 320, 1600, 900); let height = tag_cloud_dimension(args.get("height"), 180, 900, 420); let words_json = tags .as_deref() .map(|tags| build_tag_cloud_words(tags, context)) .filter(|words| !words.is_empty()) .and_then(|words| serde_json::to_string(&words).ok()) .map(|words| escape_html_attr(&words)); render_macro_template( macro_template(context, "tag-cloud", TAG_CLOUD_TEMPLATE), serde_json::json!({ "orientation": orientation, "words_json": words_json, "width": width, "height": height, "aria_label": render_translation(context, "render.tagCloud.ariaLabel"), "no_items_label": render_translation(context, "render.tagCloud.empty"), }), ) } 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)); let mut words = tags .iter() .filter_map(|tag| { let name = tag.get("name").and_then(JsonValue::as_str)?; let encoded_name = encode_path_segment(name); let count = tag.get("post_count").and_then(value_as_u64).unwrap_or(1); let size = if max_count == min_count { 35.0 } else { 14.0 + (((count - min_count) as f64 / (max_count - min_count) as f64) * 42.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) }); let mut word = Map::from_iter([ ("text".into(), JsonValue::String(name.into())), ("count".into(), JsonValue::from(count)), ( "url".into(), JsonValue::String(format!("/tag/{encoded_name}/")), ), ("size".into(), JsonValue::from(size.round() as u64)), ]); if let Some(color) = color.filter(|color| !color.is_empty()) { word.insert("color".into(), JsonValue::String(color)); } Some(JsonValue::Object(word)) }) .collect::>(); words.sort_by(|left, right| { right["count"] .as_u64() .cmp(&left["count"].as_u64()) .then_with(|| { left["text"] .as_str() .unwrap_or_default() .to_lowercase() .cmp(&right["text"].as_str().unwrap_or_default().to_lowercase()) }) }); words } fn render_macro_template(template: &str, assigns: JsonValue) -> String { crate::render::render_liquid_template(template, &HashMap::new(), &assigns).unwrap_or_default() } fn macro_template<'a>(context: &'a MacroRenderContext, name: &str, bundled: &'a str) -> &'a str { context .roots .get("macro_templates") .and_then(JsonValue::as_object) .and_then(|templates| templates.get(name)) .and_then(JsonValue::as_str) .unwrap_or(bundled) } pub(super) fn bundled_macro_templates() -> HashMap { HashMap::from([ ("gallery".into(), GALLERY_TEMPLATE.into()), ("youtube".into(), YOUTUBE_TEMPLATE.into()), ("vimeo".into(), VIMEO_TEMPLATE.into()), ("photo-archive".into(), PHOTO_ARCHIVE_TEMPLATE.into()), ("tag-cloud".into(), TAG_CLOUD_TEMPLATE.into()), ]) } fn encode_path_segment(value: &str) -> String { use std::fmt::Write; value.bytes().fold(String::new(), |mut encoded, byte| { if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { encoded.push(char::from(byte)); } else { write!(encoded, "%{byte:02X}").expect("writing to a String cannot fail"); } encoded }) } fn linked_media(context: &MacroRenderContext) -> Option> { lookup_path("post.linked_media", context) .and_then(|value| value.as_array().cloned()) .or_else(|| { let post_id = context.post_id.as_deref()?; context .roots .get("post_data_json_by_id")? .get(post_id)? .get("linked_media")? .as_array() .cloned() }) } fn project_media(context: &MacroRenderContext) -> Option> { lookup_path("project.media", context).and_then(|value| value.as_array().cloned()) } fn tag_items(context: &MacroRenderContext) -> Option> { lookup_path("Tags", context) .and_then(|value| value.as_array().cloned()) .or_else(|| lookup_path("post_tags", context).and_then(|value| value.as_array().cloned())) } fn is_image(media: &JsonValue) -> bool { media .get("mime_type") .and_then(JsonValue::as_str) .is_none_or(|mime_type| mime_type.starts_with("image/")) } fn media_newest_first(left: &JsonValue, right: &JsonValue) -> std::cmp::Ordering { right .get("created_at") .and_then(JsonValue::as_i64) .cmp(&left.get("created_at").and_then(JsonValue::as_i64)) .then_with(|| { left.get("id") .and_then(JsonValue::as_str) .cmp(&right.get("id").and_then(JsonValue::as_str)) }) } fn media_archive_month(media: &JsonValue) -> Option<(i32, u32)> { media .get("created_at") .and_then(JsonValue::as_i64) .and_then(|timestamp| Utc.timestamp_millis_opt(timestamp).single()) .map(|timestamp| (timestamp.year(), timestamp.month())) .or_else(|| { media .get("file_path") .and_then(JsonValue::as_str) .and_then(month_bucket) }) } fn normalize_tag_cloud_orientation(value: Option<&JsonValue>) -> &'static str { match value .map(stringify_scalar) .unwrap_or_default() .trim() .to_lowercase() .as_str() { "mixed_hv" | "mixed-hv" | "hv" | "horizontal_vertical" => "mixed-hv", "mixed_diagonal" | "mixed-diagonal" | "diagonal" | "diag" => "mixed-diagonal", _ => "horizontal", } } fn tag_cloud_dimension(value: Option<&JsonValue>, min: u64, max: u64, default: u64) -> u64 { value .and_then(value_as_u64) .filter(|value| (min..=max).contains(value)) .unwrap_or(default) } fn macro_title( args: &HashMap, context: &MacroRenderContext, translation_key: &str, ) -> String { args.get("title") .map(stringify_scalar) .filter(|title| !title.trim().is_empty()) .unwrap_or_else(|| render_translation(context, translation_key)) } fn render_translation(context: &MacroRenderContext, key: &str) -> String { let language = context .roots .get("language") .and_then(JsonValue::as_str) .unwrap_or("en"); translate_render(language, key) } fn image_path(image: &JsonValue) -> Option { image .get("file_path") .and_then(JsonValue::as_str) .map(ToOwned::to_owned) } fn ensure_leading_slash(path: &str) -> String { if path.starts_with('/') { path.to_string() } else { format!("/{path}") } } 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) }) .or_else(|| image_name(image)) } fn image_archive_title(image: &JsonValue) -> Option { image .get("title") .and_then(JsonValue::as_str) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .or_else(|| image_name(image)) } fn image_alt(image: &JsonValue) -> String { image .get("alt") .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) }) .or_else(|| image_name(image)) .unwrap_or_default() } fn image_name(image: &JsonValue) -> Option { image .get("original_name") .or_else(|| image.get("filename")) .and_then(JsonValue::as_str) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) } fn value_as_u64(value: &JsonValue) -> Option { value .as_u64() .or_else(|| value.as_i64().and_then(|number| number.try_into().ok())) .or_else(|| value.as_str().and_then(|number| number.parse().ok())) } fn value_as_u32(value: &JsonValue) -> Option { value_as_u64(value) .and_then(|number| number.try_into().ok()) .filter(|month| (1..=12).contains(month)) } fn value_as_i32(value: &JsonValue) -> Option { value .as_i64() .and_then(|number| number.try_into().ok()) .or_else(|| value.as_str().and_then(|number| number.parse().ok())) } 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<(i32, u32)> { let segments = path.trim_matches('/').split('/').collect::>(); match segments.as_slice() { ["media", year, month, ..] if year.len() == 4 && month.len() == 2 => { Some((year.parse().ok()?, month.parse().ok()?)) } _ => None, } } fn escape_html_attr(value: &str) -> String { value .replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") } #[cfg(test)] mod tests { use super::{ GALLERY_TEMPLATE, MacroRenderContext, PHOTO_ARCHIVE_TEMPLATE, TAG_CLOUD_TEMPLATE, VIMEO_TEMPLATE, YOUTUBE_TEMPLATE, expand_builtin_macros, render_macro_template, }; fn compact_html(html: &str) -> String { html.split_whitespace() .collect::>() .join(" ") .replace("> <", "><") } #[test] fn bundled_macro_templates_use_supported_liquid_syntax() { for (name, template) in [ ("gallery", GALLERY_TEMPLATE), ("youtube", YOUTUBE_TEMPLATE), ("vimeo", VIMEO_TEMPLATE), ("photo-archive", PHOTO_ARCHIVE_TEMPLATE), ("tag-cloud", TAG_CLOUD_TEMPLATE), ] { let result = crate::render::page_renderer::validate_liquid_template_syntax(template); assert!( result.is_ok(), "{name} macro template must parse: {result:?}" ); } } #[test] fn macro_template_assignments_do_not_escape_the_isolated_scope() { let assigns = serde_json::json!({"visible": "outside"}); let rendered = render_macro_template( "{{ visible }}|{% assign visible = 'inside' %}{{ visible }}", assigns.clone(), ); assert_eq!(rendered, "outside|inside"); assert_eq!(assigns["visible"], "outside"); } #[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()), ..MacroRenderContext::default() }, ); assert!(html.contains("macro-gallery gallery-cols-2")); assert!(html.contains("data-lightbox=\"gallery-post-1\"")); assert!(html.contains("macro-tag-cloud")); assert!(html.contains("data-tag-cloud=\"true\"")); } #[test] fn built_in_macros_match_the_bds2_template_markup() { let mut roots = serde_json::Map::new(); roots.insert("language".into(), serde_json::json!("en")); roots.insert( "post".into(), serde_json::json!({ "linked_media": [{ "id": "image-1", "file_path": "/media/2026/04/one.jpg", "mime_type": "image/jpeg", "title": "One & only", "alt": "Alt " }] }), ); roots.insert( "project".into(), serde_json::json!({ "media": [{ "id": "image-1", "file_path": "/media/2026/04/one.jpg", "mime_type": "image/jpeg", "title": "One & only", "alt": "Alt " }] }), ); roots.insert( "post_tags".into(), serde_json::json!([ {"name": "Rust & SQLite", "slug": "ignored", "post_count": 4, "color": "#ff6600"}, {"name": "Iced", "slug": "also-ignored", "post_count": 2} ]), ); let context = MacroRenderContext { roots, post_id: Some("post-1".into()), ..MacroRenderContext::default() }; let youtube = compact_html(&expand_builtin_macros("[[youtube id=abc123]]", &context)); assert_eq!( youtube, compact_html( "
" ) ); let vimeo = compact_html(&expand_builtin_macros("[[vimeo id=98765]]", &context)); assert_eq!( vimeo, compact_html( "
" ) ); let gallery = compact_html(&expand_builtin_macros( "[[gallery columns=2 caption='A caption']]", &context, )); assert_eq!( gallery, compact_html( "" ) ); let archive = compact_html(&expand_builtin_macros("[[photo_archive]]", &context)); assert_eq!( archive, compact_html( "
April 2026
" ) ); let tag_cloud = compact_html(&expand_builtin_macros( "[[tag_cloud orientation=diagonal width=640 height=300]]", &context, )); assert!(tag_cloud.starts_with( "
") )); } #[test] fn video_macros_with_missing_ids_still_use_the_localized_templates() { for (language, youtube_title, vimeo_title) in [ ("en", "YouTube video", "Vimeo video"), ("de", "YouTube-Video", "Vimeo-Video"), ("fr", "Vidéo YouTube", "Vidéo Vimeo"), ("it", "Video YouTube", "Video Vimeo"), ("es", "Vídeo de YouTube", "Vídeo de Vimeo"), ] { let mut roots = serde_json::Map::new(); roots.insert("language".into(), serde_json::json!(language)); let context = MacroRenderContext { roots, ..MacroRenderContext::default() }; let rendered = expand_builtin_macros("[[youtube]] [[vimeo]]", &context); assert!(rendered.contains("https://www.youtube.com/embed/?rel=0")); assert!(rendered.contains("https://player.vimeo.com/video/")); assert!(rendered.contains(&format!("title=\"{youtube_title}\""))); assert!(rendered.contains(&format!("title=\"{vimeo_title}\""))); assert!(!rendered.contains("Missing")); } } #[test] fn empty_macro_states_use_render_domain_translations() { let mut roots = serde_json::Map::new(); roots.insert("language".into(), serde_json::json!("de")); let context = MacroRenderContext { roots, ..MacroRenderContext::default() }; let rendered = expand_builtin_macros("[[gallery]] [[photo_archive]] [[tag_cloud]]", &context); assert!(rendered.contains("Keine verknüpften Bilder gefunden.")); assert!(rendered.contains("Keine Fotos für dieses Archiv gefunden.")); assert!(rendered.contains("Keine Tags gefunden.")); } #[test] fn project_macro_templates_override_the_bundled_defaults() { let mut roots = serde_json::Map::new(); roots.insert( "macro_templates".into(), serde_json::json!({ "gallery": "" }), ); let rendered = expand_builtin_macros( "[[gallery columns=4]]", &MacroRenderContext { roots, ..MacroRenderContext::default() }, ); assert_eq!(rendered, ""); } #[test] fn template_rendering_preserves_macro_clamps_aliases_and_archive_filters() { let mut roots = serde_json::Map::new(); roots.insert( "project".into(), serde_json::json!({ "media": [ {"file_path": "/media/2026/04/new.jpg", "mime_type": "image/jpeg"}, {"file_path": "/media/2025/03/old.jpg", "mime_type": "image/jpeg"} ] }), ); roots.insert( "post_tags".into(), serde_json::json!([{"name": "Rust", "post_count": 1}]), ); let context = MacroRenderContext { roots, ..MacroRenderContext::default() }; let rendered = expand_builtin_macros( "[[gallery columns=99]] [[tag_cloud orientation=hv width=99999 height=1]] [[photo_archive year=2025 month=3]]", &context, ); assert!(rendered.contains("gallery-cols-6")); assert!(rendered.contains("data-orientation=\"mixed-hv\"")); assert!(rendered.contains("data-width=\"900\" data-height=\"420\"")); assert!(rendered.contains("/media/2025/03/old.jpg")); assert!(!rendered.contains("/media/2026/04/new.jpg")); } #[test] fn leaves_unknown_macros_verbatim() { let markdown = "Before [[future_macro value='x']] after"; assert_eq!( expand_builtin_macros(markdown, &MacroRenderContext::default()), markdown ); } #[test] fn script_macro_receives_named_params_and_environment() { let mut roots = serde_json::Map::new(); roots.insert( "macro_scripts".into(), serde_json::json!({ "notice": { "source": "function render(params, env) return '' end", "entrypoint": "render" } }), ); roots.insert("language".into(), serde_json::Value::String("de".into())); let rendered = expand_builtin_macros( "[[notice text=Hallo]]", &MacroRenderContext { roots, post_id: Some("post-1".into()), ..MacroRenderContext::default() }, ); assert_eq!(rendered, ""); } }