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 @@
+
+
+ {%- if items.size > 0 -%}
+ {%- for item in items -%}
+
+
+
+ {%- endfor -%}
+ {%- else -%}
+
{{ no_items_label | escape }}
+ {%- endif -%}
+
+ {%- if caption -%}
+
{{ caption | escape }}
+ {%- endif -%}
+
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 }}
+
+
+ {%- for item in month.items -%}
+
+
+
+ {%- endfor -%}
+
+
+
+ {%- 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 @@
+
+ VIDEO
+
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!(
- "",
- escape_html_attr(context.post_id.as_deref().unwrap_or_default()),
- );
- for image in images {
- let Some(path) = image_path(&image) else {
- continue;
- };
- let title = image_title(&image);
- let alt = image_alt(&image);
- html.push_str(&format!(
- "
",
- escape_html_attr(&path),
- escape_html_attr(&gallery_id),
- 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("
");
- if let Some(caption) = args
- .get("caption")
- .map(stringify_scalar)
- .filter(|caption| !caption.is_empty())
- {
- html.push_str(&format!(
- "{} ",
- escape_html(&caption)
- ));
- }
- html.push_str(" ");
- 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(
+ " VIDEO
"
+ )
+ );
+
+ 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(
+ ""
+ )
+ );
+
+ 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 }}";