From 8170076e9b6e1fbe62398d5dde197afb173a94b5 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Tue, 21 Jul 2026 20:44:03 +0200 Subject: [PATCH] Render built-in macros from Liquid templates. --- README.md | 2 +- .../starter-templates/macros/gallery.liquid | 30 + .../macros/photo-archive.liquid | 33 ++ .../starter-templates/macros/tag-cloud.liquid | 19 + assets/starter-templates/macros/vimeo.liquid | 9 + .../starter-templates/macros/youtube.liquid | 9 + crates/bds-core/src/engine/template.rs | 20 + crates/bds-core/src/render/macros.rs | 536 ++++++++++++------ crates/bds-core/src/render/page_renderer.rs | 1 + crates/bds-core/src/render/site.rs | 53 +- crates/bds-core/tests/m4_page_renderer.rs | 20 + 11 files changed, 552 insertions(+), 180 deletions(-) create mode 100644 assets/starter-templates/macros/gallery.liquid create mode 100644 assets/starter-templates/macros/photo-archive.liquid create mode 100644 assets/starter-templates/macros/tag-cloud.liquid create mode 100644 assets/starter-templates/macros/vimeo.liquid create mode 100644 assets/starter-templates/macros/youtube.liquid diff --git a/README.md b/README.md index 1461b12..9301536 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. -- bDS2-compatible Markdown/Liquid rendering with native macros, descriptive category archive titles, 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. +- bDS2-compatible Markdown/Liquid rendering with built-in macros rendered from bundled Liquid templates in isolated scopes (customizable with `macros/*` partial-template slugs), descriptive category archive titles, 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 run in background tasks with editor-level waiting indicators, using provider-portable JSON-only requests through 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 restart-persistent 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/macros/gallery.liquid b/assets/starter-templates/macros/gallery.liquid new file mode 100644 index 0000000..74eed00 --- /dev/null +++ b/assets/starter-templates/macros/gallery.liquid @@ -0,0 +1,30 @@ + diff --git a/assets/starter-templates/macros/photo-archive.liquid b/assets/starter-templates/macros/photo-archive.liquid new file mode 100644 index 0000000..3e0a622 --- /dev/null +++ b/assets/starter-templates/macros/photo-archive.liquid @@ -0,0 +1,33 @@ +
+
+ {%- if months.size > 0 -%} + {%- for month in months -%} +
+
+
+ {{ month.label | escape }} +
+ +
+
+ {%- endfor -%} + {%- else -%} +
{{ no_items_label | escape }}
+ {%- endif -%} +
+
diff --git a/assets/starter-templates/macros/tag-cloud.liquid b/assets/starter-templates/macros/tag-cloud.liquid new file mode 100644 index 0000000..a9f4680 --- /dev/null +++ b/assets/starter-templates/macros/tag-cloud.liquid @@ -0,0 +1,19 @@ +
+ {%- if words_json -%} + + {%- else -%} +
{{ no_items_label | escape }}
+ {%- endif -%} +
diff --git a/assets/starter-templates/macros/vimeo.liquid b/assets/starter-templates/macros/vimeo.liquid new file mode 100644 index 0000000..f93a8fa --- /dev/null +++ b/assets/starter-templates/macros/vimeo.liquid @@ -0,0 +1,9 @@ +
+ +
diff --git a/assets/starter-templates/macros/youtube.liquid b/assets/starter-templates/macros/youtube.liquid new file mode 100644 index 0000000..eaf293e --- /dev/null +++ b/assets/starter-templates/macros/youtube.liquid @@ -0,0 +1,9 @@ +
+ +
diff --git a/crates/bds-core/src/engine/template.rs b/crates/bds-core/src/engine/template.rs index c6590ca..436034f 100644 --- a/crates/bds-core/src/engine/template.rs +++ b/crates/bds-core/src/engine/template.rs @@ -761,6 +761,26 @@ mod tests { "menu", include_str!("../../../../assets/starter-templates/partials/menu.liquid"), ), + ( + "macro-gallery", + include_str!("../../../../assets/starter-templates/macros/gallery.liquid"), + ), + ( + "macro-youtube", + include_str!("../../../../assets/starter-templates/macros/youtube.liquid"), + ), + ( + "macro-vimeo", + include_str!("../../../../assets/starter-templates/macros/vimeo.liquid"), + ), + ( + "macro-photo-archive", + include_str!("../../../../assets/starter-templates/macros/photo-archive.liquid"), + ), + ( + "macro-tag-cloud", + include_str!("../../../../assets/starter-templates/macros/tag-cloud.liquid"), + ), ] { let result = validate_template(content); assert!(result.is_ok(), "starter template {name}: {result:?}"); diff --git a/crates/bds-core/src/render/macros.rs b/crates/bds-core/src/render/macros.rs index e2f0d69..aaf8748 100644 --- a/crates/bds-core/src/render/macros.rs +++ b/crates/bds-core/src/render/macros.rs @@ -6,6 +6,17 @@ 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, @@ -202,80 +213,48 @@ fn render_gallery(args: &HashMap, context: &MacroRenderContex .filter(is_image) .collect::>(); images.sort_by(media_newest_first); - - if images.is_empty() { - return empty_block( - &format!("macro-gallery gallery-cols-{columns}"), - "gallery-empty", - &render_translation(context, "render.gallery.empty"), - ); - } - let gallery_id = format!("gallery-{}", context.post_id.as_deref().unwrap_or_default()); - let mut html = format!( - ""); - html + 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 { - 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.", - ); - } - let title = macro_title(args, context, "render.video.youtubeTitle"); - format!( - "
", - escape_html_attr(&video_id), - escape_html_attr(&title), + 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 { - 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."); - } - let title = macro_title(args, context, "render.video.vimeoTitle"); - format!( - "
", - escape_html_attr(&video_id), - escape_html_attr(&title), + 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"), + }), ) } @@ -305,72 +284,48 @@ fn render_photo_archive(args: &HashMap, context: &MacroRender media.truncate(200); } - if media.is_empty() { - return empty_block( - "macro-photo-archive", - "photo-archive-empty", - &render_translation(context, "render.photoArchive.empty"), - ); - } - 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); } } - if grouped.is_empty() { - return empty_block( - "macro-photo-archive", - "photo-archive-empty", - &render_translation(context, "render.photoArchive.empty"), - ); - } - - let single_month = grouped.len() == 1; - let mut html = format!( - "
", - if single_month { - " photo-archive-single-month" - } else { - "" - } - ); let month_limit = if year.is_none() { 10 } else { usize::MAX }; - for ((year, month), items) in grouped.into_iter().rev().take(month_limit) { - let label = format!( - "{} {year}", - render_translation(context, &format!("render.month.{month}")) - ); - html.push_str( - "
", - ); - html.push_str(&format!( - "
{}
", - escape_html(&label), - )); - html.push_str("
"); - for item in items { - let Some(path) = image_path(&item) else { - continue; - }; - let title = image_archive_title(&item); - let alt = image_alt(&item); - 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 + 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 { @@ -380,38 +335,26 @@ fn render_tag_cloud(args: &HashMap, context: &MacroRenderCont .cloned() .or_else(|| tag_items(context)); - let Some(tags) = tags else { - return empty_block( - "macro-tag-cloud", - "tag-cloud-empty", - &render_translation(context, "render.tagCloud.empty"), - ); - }; - if tags.is_empty() { - return empty_block( - "macro-tag-cloud", - "tag-cloud-empty", - &render_translation(context, "render.tagCloud.empty"), - ); - } - - let words = build_tag_cloud_words(&tags, context); - if words.is_empty() { - return empty_block( - "macro-tag-cloud", - "tag-cloud-empty", - &render_translation(context, "render.tagCloud.empty"), - ); - } - 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 = serde_json::to_string(&words).unwrap_or_else(|_| "[]".to_string()); - format!( - "
", - escape_html_attr(&words_json), - escape_html_attr(&render_translation(context, "render.tagCloud.ariaLabel")), + 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"), + }), ) } @@ -431,11 +374,7 @@ fn build_tag_cloud_words(tags: &[JsonValue], context: &MacroRenderContext) -> Ve .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 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 @@ -458,7 +397,10 @@ fn build_tag_cloud_words(tags: &[JsonValue], context: &MacroRenderContext) -> Ve let mut word = Map::from_iter([ ("text".into(), JsonValue::String(name.into())), ("count".into(), JsonValue::from(count)), - ("url".into(), JsonValue::String(format!("/tag/{slug}/"))), + ( + "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()) { @@ -482,6 +424,43 @@ fn build_tag_cloud_words(tags: &[JsonValue], context: &MacroRenderContext) -> Ve 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()) @@ -588,6 +567,14 @@ fn image_path(image: &JsonValue) -> Option { .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") @@ -679,31 +666,57 @@ fn month_bucket(path: &str) -> Option<(i32, u32)> { } } -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 { +fn escape_html_attr(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}; + 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() { @@ -744,6 +757,179 @@ mod tests { 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() { + let rendered = + expand_builtin_macros("[[youtube]] [[vimeo]]", &MacroRenderContext::default()); + + assert!(rendered.contains("https://www.youtube.com/embed/?rel=0")); + assert!(rendered.contains("https://player.vimeo.com/video/")); + assert!(rendered.contains("title=\"YouTube video\"")); + assert!(rendered.contains("title=\"Vimeo video\"")); + 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"; diff --git a/crates/bds-core/src/render/page_renderer.rs b/crates/bds-core/src/render/page_renderer.rs index f755391..bcb9ece 100644 --- a/crates/bds-core/src/render/page_renderer.rs +++ b/crates/bds-core/src/render/page_renderer.rs @@ -244,6 +244,7 @@ fn collect_macro_roots(runtime: &dyn Runtime) -> JsonMap { "project", "Tags", "macro_scripts", + "macro_templates", "language", "language_prefix", "main_language", diff --git a/crates/bds-core/src/render/site.rs b/crates/bds-core/src/render/site.rs index 119aedf..d1b1de3 100644 --- a/crates/bds-core/src/render/site.rs +++ b/crates/bds-core/src/render/site.rs @@ -77,6 +77,7 @@ struct TemplateBundle { default_list_template: String, not_found_template: String, partials: HashMap, + macro_templates: HashMap, macro_scripts: HashMap, host: Arc, } @@ -411,6 +412,7 @@ fn load_template_bundle( let mut post_templates = Vec::new(); let mut list_template_sources = HashMap::new(); let mut partials = starter_partials(); + let mut macro_templates = crate::render::macros::bundled_macro_templates(); let mut default_list_template = STARTER_POST_LIST_TEMPLATE.to_string(); let mut not_found_template = STARTER_NOT_FOUND_TEMPLATE.to_string(); let mut macro_scripts = HashMap::new(); @@ -472,10 +474,14 @@ fn load_template_bundle( } } TemplateKind::Partial => { - let key = normalize_partial_slug(&template.slug); - partials.insert(key.clone(), source.clone()); - if !key.starts_with("partials/") { - partials.insert(format!("partials/{key}"), source); + if let Some(name) = normalize_macro_template_slug(&template.slug) { + macro_templates.insert(name.to_string(), source); + } else { + let key = normalize_partial_slug(&template.slug); + partials.insert(key.clone(), source.clone()); + if !key.starts_with("partials/") { + partials.insert(format!("partials/{key}"), source); + } } } } @@ -517,6 +523,7 @@ fn load_template_bundle( default_list_template, not_found_template, partials, + macro_templates, macro_scripts, host, }) @@ -836,6 +843,7 @@ fn render_list_route( "main_language": main_language, "is_preview": is_preview, "macro_scripts": bundle.macro_scripts, + "macro_templates": bundle.macro_templates, "html_theme_attribute": serde_json::Value::Null, "page_title": route.page_title, "pico_stylesheet_href": pico_stylesheet_href(metadata), @@ -958,6 +966,7 @@ fn render_post_route( "main_language": main_language, "is_preview": is_preview, "macro_scripts": bundle.macro_scripts, + "macro_templates": bundle.macro_templates, "page_title": record.post.title, "pico_stylesheet_href": pico_stylesheet_href(metadata), "html_theme_attribute": serde_json::Value::Null, @@ -1731,6 +1740,20 @@ fn normalize_partial_slug(slug: &str) -> String { } } +fn normalize_macro_template_slug(slug: &str) -> Option<&'static str> { + let normalized = slug.trim().trim_matches('/'); + let normalized = normalized.strip_prefix("partials/").unwrap_or(normalized); + let normalized = normalized.strip_suffix(".liquid").unwrap_or(normalized); + match normalized { + "macros/gallery" => Some("gallery"), + "macros/youtube" => Some("youtube"), + "macros/vimeo" => Some("vimeo"), + "macros/photo-archive" | "macros/photo_archive" => Some("photo-archive"), + "macros/tag-cloud" | "macros/tag_cloud" => Some("tag-cloud"), + _ => None, + } +} + fn starter_partials() -> HashMap { HashMap::from([ ( @@ -1801,6 +1824,28 @@ mod menu_tests { ); } + #[test] + fn macro_partial_slugs_select_customizable_bundled_templates() { + let templates = crate::render::macros::bundled_macro_templates(); + assert_eq!(templates.len(), 5); + assert!(templates.contains_key("photo-archive")); + assert!(templates.contains_key("tag-cloud")); + + assert_eq!( + normalize_macro_template_slug("macros/gallery"), + Some("gallery") + ); + assert_eq!( + normalize_macro_template_slug("partials/macros/photo-archive.liquid"), + Some("photo-archive") + ); + assert_eq!( + normalize_macro_template_slug("macros/tag_cloud"), + Some("tag-cloud") + ); + assert_eq!(normalize_macro_template_slug("partials/card"), None); + } + #[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/m4_page_renderer.rs b/crates/bds-core/tests/m4_page_renderer.rs index f2ef19d..f9c494f 100644 --- a/crates/bds-core/tests/m4_page_renderer.rs +++ b/crates/bds-core/tests/m4_page_renderer.rs @@ -91,6 +91,26 @@ fn markdown_filter_expands_builtin_macros_from_runtime_context() { assert!(rendered.contains("data-tag-cloud=\"true\"")); } +#[test] +fn markdown_filter_uses_project_macro_template_overrides() { + let template = "{{ body | markdown: nil, nil, canonical_post_path_by_slug, canonical_media_path_by_source_path, language, language_prefix }}"; + let context = serde_json::json!({ + "body": "[[youtube id=custom-id]]", + "macro_templates": { + "youtube": "
custom
" + }, + "canonical_post_path_by_slug": {}, + "canonical_media_path_by_source_path": {}, + "language": "en", + "language_prefix": "" + }); + + let rendered = render_liquid_template(template, &HashMap::new(), &context).unwrap(); + + assert!(rendered.contains("
custom
")); + assert!(!rendered.contains("macro-youtube")); +} + #[test] fn markdown_filter_leaves_unknown_macros_verbatim() { let template = "{{ body | markdown: nil, nil, canonical_post_path_by_slug, canonical_media_path_by_source_path, language, language_prefix }}";