Render built-in macros from Liquid templates.

This commit is contained in:
2026-07-21 20:44:03 +02:00
parent 3ac7a8bfc4
commit 8170076e9b
11 changed files with 552 additions and 180 deletions

View File

@@ -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. - 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. - 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. - `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. - 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. - 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. - 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.

View File

@@ -0,0 +1,30 @@
<div
class="macro-gallery gallery-cols-{{ columns }}"
data-post-id="{{ post_id | escape }}"
data-columns="{{ columns }}"
data-lightbox="true"
>
<div class="gallery-container gallery-lightbox">
{%- if items.size > 0 -%}
{%- for item in items -%}
<a
class="gallery-item"
href="{{ item.media_path | escape }}"
data-lightbox="{{ item.group_name | escape }}"
data-title="{{ item.title | escape }}"
>
<img
src="{{ item.media_path | escape }}"
alt="{{ item.alt | escape }}"
loading="lazy"
/>
</a>
{%- endfor -%}
{%- else -%}
<div class="gallery-empty">{{ no_items_label | escape }}</div>
{%- endif -%}
</div>
{%- if caption -%}
<figcaption class="gallery-caption">{{ caption | escape }}</figcaption>
{%- endif -%}
</div>

View File

@@ -0,0 +1,33 @@
<div class="{{ root_classes }}"{% for attr in data_attrs %} {{ attr.name }}="{{ attr.value | escape }}"{% endfor %}>
<div class="photo-archive-container">
{%- if months.size > 0 -%}
{%- for month in months -%}
<div class="photo-archive-month-wrapper">
<div class="photo-archive-month">
<div class="photo-archive-month-label">
<span>{{ month.label | escape }}</span>
</div>
<div class="photo-archive-gallery">
{%- for item in month.items -%}
<a
class="photo-archive-item"
href="{{ item.media_path | escape }}"
data-lightbox="{{ item.group_name | escape }}"
data-title="{{ item.title | escape }}"
>
<img
src="{{ item.media_path | escape }}"
alt="{{ item.alt | escape }}"
loading="lazy"
/>
</a>
{%- endfor -%}
</div>
</div>
</div>
{%- endfor -%}
{%- else -%}
<div class="photo-archive-empty">{{ no_items_label | escape }}</div>
{%- endif -%}
</div>
</div>

View File

@@ -0,0 +1,19 @@
<div
class="macro-tag-cloud"
data-tag-cloud="true"
data-orientation="{{ orientation }}"
data-color-distribution="quantile"
data-color-easing="0.7"
data-color-theme="pico"{%- if words_json %} data-tag-cloud-words="{{ words_json }}" data-width="{{ width }}" data-height="{{ height }}"{%- endif -%}
>
{%- if words_json -%}
<svg
class="tag-cloud-canvas"
viewBox="0 0 {{ width }} {{ height }}"
preserveAspectRatio="xMidYMid meet"
aria-label="{{ aria_label | escape }}"
></svg>
{%- else -%}
<div class="tag-cloud-empty">{{ no_items_label | escape }}</div>
{%- endif -%}
</div>

View File

@@ -0,0 +1,9 @@
<div class="macro-vimeo">
<iframe
src="https://player.vimeo.com/video/{{ id | escape }}"
title="{{ title | escape }}"
frameborder="0"
allow="autoplay; fullscreen; picture-in-picture"
allowfullscreen
></iframe>
</div>

View File

@@ -0,0 +1,9 @@
<div class="macro-youtube">
<iframe
src="https://www.youtube.com/embed/{{ id | escape }}?rel=0"
title="{{ title | escape }}"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
></iframe>
</div>

View File

@@ -761,6 +761,26 @@ mod tests {
"menu", "menu",
include_str!("../../../../assets/starter-templates/partials/menu.liquid"), 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); let result = validate_template(content);
assert!(result.is_ok(), "starter template {name}: {result:?}"); assert!(result.is_ok(), "starter template {name}: {result:?}");

View File

@@ -6,6 +6,17 @@ use serde_json::{Map, Value as JsonValue};
use crate::i18n::translate_render; 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)] #[derive(Clone)]
pub(crate) struct MacroRenderContext { pub(crate) struct MacroRenderContext {
pub roots: Map<String, JsonValue>, pub roots: Map<String, JsonValue>,
@@ -202,80 +213,48 @@ fn render_gallery(args: &HashMap<String, JsonValue>, context: &MacroRenderContex
.filter(is_image) .filter(is_image)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
images.sort_by(media_newest_first); 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 gallery_id = format!("gallery-{}", context.post_id.as_deref().unwrap_or_default());
let mut html = format!( let items = images
"<section class=\"macro-gallery gallery-cols-{columns}\" data-post-id=\"{}\" data-columns=\"{columns}\" data-lightbox=\"true\"><div class=\"gallery-container gallery-lightbox\">", .into_iter()
escape_html_attr(context.post_id.as_deref().unwrap_or_default()), .filter_map(|image| {
); Some(serde_json::json!({
for image in images { "media_path": ensure_leading_slash(&image_path(&image)?),
let Some(path) = image_path(&image) else { "title": image_title(&image).unwrap_or_default(),
continue; "alt": image_alt(&image),
}; "group_name": gallery_id,
let title = image_title(&image); }))
let alt = image_alt(&image); })
html.push_str(&format!( .collect::<Vec<_>>();
"<a class=\"gallery-item\" href=\"{}\" data-lightbox=\"{}\"{}><img src=\"{}\" alt=\"{}\" loading=\"lazy\" /></a>",
escape_html_attr(&path), render_macro_template(
escape_html_attr(&gallery_id), macro_template(context, "gallery", GALLERY_TEMPLATE),
title serde_json::json!({
.as_deref() "columns": columns,
.map(|value| format!(" data-title=\"{}\"", escape_html_attr(value))) "post_id": context.post_id.as_deref().unwrap_or_default(),
.unwrap_or_default(), "items": items,
escape_html_attr(&path), "caption": args.get("caption").map(stringify_scalar),
escape_html_attr(&alt), "no_items_label": render_translation(context, "render.gallery.empty"),
)); }),
} )
html.push_str("</div>");
if let Some(caption) = args
.get("caption")
.map(stringify_scalar)
.filter(|caption| !caption.is_empty())
{
html.push_str(&format!(
"<figcaption class=\"gallery-caption\">{}</figcaption>",
escape_html(&caption)
));
}
html.push_str("</section>");
html
} }
fn render_youtube(args: &HashMap<String, JsonValue>, context: &MacroRenderContext) -> String { fn render_youtube(args: &HashMap<String, JsonValue>, context: &MacroRenderContext) -> String {
let video_id = args.get("id").map(stringify_scalar).unwrap_or_default(); render_macro_template(
if video_id.is_empty() { macro_template(context, "youtube", YOUTUBE_TEMPLATE),
return empty_block( serde_json::json!({
"macro-youtube", "id": args.get("id").map(stringify_scalar).unwrap_or_default(),
"gallery-empty", "title": macro_title(args, context, "render.video.youtubeTitle"),
"Missing YouTube video id.", }),
);
}
let title = macro_title(args, context, "render.video.youtubeTitle");
format!(
"<section class=\"macro-youtube\"><iframe src=\"https://www.youtube.com/embed/{}?rel=0\" title=\"{}\" loading=\"lazy\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture\" allowfullscreen></iframe></section>",
escape_html_attr(&video_id),
escape_html_attr(&title),
) )
} }
fn render_vimeo(args: &HashMap<String, JsonValue>, context: &MacroRenderContext) -> String { fn render_vimeo(args: &HashMap<String, JsonValue>, context: &MacroRenderContext) -> String {
let video_id = args.get("id").map(stringify_scalar).unwrap_or_default(); render_macro_template(
if video_id.is_empty() { macro_template(context, "vimeo", VIMEO_TEMPLATE),
return empty_block("macro-vimeo", "gallery-empty", "Missing Vimeo video id."); serde_json::json!({
} "id": args.get("id").map(stringify_scalar).unwrap_or_default(),
let title = macro_title(args, context, "render.video.vimeoTitle"); "title": macro_title(args, context, "render.video.vimeoTitle"),
format!( }),
"<section class=\"macro-vimeo\"><iframe src=\"https://player.vimeo.com/video/{}\" title=\"{}\" loading=\"lazy\" allow=\"autoplay; fullscreen; picture-in-picture\" allowfullscreen></iframe></section>",
escape_html_attr(&video_id),
escape_html_attr(&title),
) )
} }
@@ -305,72 +284,48 @@ fn render_photo_archive(args: &HashMap<String, JsonValue>, context: &MacroRender
media.truncate(200); 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<JsonValue>>::new(); let mut grouped = BTreeMap::<(i32, u32), Vec<JsonValue>>::new();
for item in media { for item in media {
if let Some(bucket) = media_archive_month(&item) { if let Some(bucket) = media_archive_month(&item) {
grouped.entry(bucket).or_default().push(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!(
"<section class=\"macro-photo-archive{}\"><div class=\"photo-archive-container\">",
if single_month {
" photo-archive-single-month"
} else {
""
}
);
let month_limit = if year.is_none() { 10 } else { usize::MAX }; let month_limit = if year.is_none() { 10 } else { usize::MAX };
for ((year, month), items) in grouped.into_iter().rev().take(month_limit) { let months = grouped
let label = format!( .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::<Vec<_>>();
serde_json::json!({
"label": format!(
"{} {year}", "{} {year}",
render_translation(context, &format!("render.month.{month}")) render_translation(context, &format!("render.month.{month}"))
); ),
html.push_str( "items": items,
"<section class=\"photo-archive-month-wrapper\"><div class=\"photo-archive-month\">", })
); })
html.push_str(&format!( .collect::<Vec<_>>();
"<header class=\"photo-archive-month-label\"><span>{}</span></header>",
escape_html(&label), render_macro_template(
)); macro_template(context, "photo-archive", PHOTO_ARCHIVE_TEMPLATE),
html.push_str("<div class=\"photo-archive-gallery\">"); serde_json::json!({
for item in items { "root_classes": "macro-photo-archive",
let Some(path) = image_path(&item) else { "data_attrs": [],
continue; "months": months,
}; "no_items_label": render_translation(context, "render.photoArchive.empty"),
let title = image_archive_title(&item); }),
let alt = image_alt(&item); )
html.push_str(&format!(
"<a class=\"photo-archive-item\" href=\"{}\" data-lightbox=\"{year}-{month}\"{}><img src=\"{}\" alt=\"{}\" loading=\"lazy\" /></a>",
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("</div></div></section>");
}
html.push_str("</div></section>");
html
} }
fn render_tag_cloud(args: &HashMap<String, JsonValue>, context: &MacroRenderContext) -> String { fn render_tag_cloud(args: &HashMap<String, JsonValue>, context: &MacroRenderContext) -> String {
@@ -380,38 +335,26 @@ fn render_tag_cloud(args: &HashMap<String, JsonValue>, context: &MacroRenderCont
.cloned() .cloned()
.or_else(|| tag_items(context)); .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 orientation = normalize_tag_cloud_orientation(args.get("orientation"));
let width = tag_cloud_dimension(args.get("width"), 320, 1600, 900); let width = tag_cloud_dimension(args.get("width"), 320, 1600, 900);
let height = tag_cloud_dimension(args.get("height"), 180, 900, 420); let height = tag_cloud_dimension(args.get("height"), 180, 900, 420);
let words_json = serde_json::to_string(&words).unwrap_or_else(|_| "[]".to_string()); let words_json = tags
format!( .as_deref()
"<section class=\"macro-tag-cloud\" data-tag-cloud=\"true\" data-color-distribution=\"quantile\" data-color-theme=\"pico\" data-color-easing=\"0.7\" data-width=\"{width}\" data-height=\"{height}\" data-orientation=\"{orientation}\" data-tag-cloud-words=\"{}\"><svg class=\"tag-cloud-canvas\" viewBox=\"0 0 {width} {height}\" preserveAspectRatio=\"xMidYMid meet\" aria-label=\"{}\"></svg></section>", .map(|tags| build_tag_cloud_words(tags, context))
escape_html_attr(&words_json), .filter(|words| !words.is_empty())
escape_html_attr(&render_translation(context, "render.tagCloud.ariaLabel")), .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() .iter()
.filter_map(|tag| { .filter_map(|tag| {
let name = tag.get("name").and_then(JsonValue::as_str)?; let name = tag.get("name").and_then(JsonValue::as_str)?;
let slug = tag let encoded_name = encode_path_segment(name);
.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 count = tag.get("post_count").and_then(value_as_u64).unwrap_or(1);
let size = if max_count == min_count { let size = if max_count == min_count {
35.0 35.0
@@ -458,7 +397,10 @@ fn build_tag_cloud_words(tags: &[JsonValue], context: &MacroRenderContext) -> Ve
let mut word = Map::from_iter([ let mut word = Map::from_iter([
("text".into(), JsonValue::String(name.into())), ("text".into(), JsonValue::String(name.into())),
("count".into(), JsonValue::from(count)), ("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)), ("size".into(), JsonValue::from(size.round() as u64)),
]); ]);
if let Some(color) = color.filter(|color| !color.is_empty()) { 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 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<String, String> {
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<Vec<JsonValue>> { fn linked_media(context: &MacroRenderContext) -> Option<Vec<JsonValue>> {
lookup_path("post.linked_media", context) lookup_path("post.linked_media", context)
.and_then(|value| value.as_array().cloned()) .and_then(|value| value.as_array().cloned())
@@ -588,6 +567,14 @@ fn image_path(image: &JsonValue) -> Option<String> {
.map(ToOwned::to_owned) .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<String> { fn image_title(image: &JsonValue) -> Option<String> {
image image
.get("caption") .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 { fn escape_html_attr(value: &str) -> String {
format!(
"<section class=\"{}\"><p class=\"{}\">{}</p></section>",
wrapper_class,
message_class,
escape_html(message),
)
}
fn escape_html(value: &str) -> String {
value value
.replace('&', "&amp;") .replace('&', "&amp;")
.replace('<', "&lt;") .replace('<', "&lt;")
.replace('>', "&gt;") .replace('>', "&gt;")
}
fn escape_html_attr(value: &str) -> String {
escape_html(value)
.replace('"', "&quot;") .replace('"', "&quot;")
.replace('\'', "&#39;") .replace('\'', "&#39;")
} }
#[cfg(test)] #[cfg(test)]
mod tests { 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::<Vec<_>>()
.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] #[test]
fn expands_gallery_and_tag_cloud_macros() { fn expands_gallery_and_tag_cloud_macros() {
@@ -744,6 +757,179 @@ mod tests {
assert!(html.contains("data-tag-cloud=\"true\"")); 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 <one>"
}]
}),
);
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 <one>"
}]
}),
);
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(
"<div class=\"macro-youtube\"> <iframe src=\"https://www.youtube.com/embed/abc123?rel=0\" title=\"YouTube video\" frameborder=\"0\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture\" allowfullscreen ></iframe> </div>"
)
);
let vimeo = compact_html(&expand_builtin_macros("[[vimeo id=98765]]", &context));
assert_eq!(
vimeo,
compact_html(
"<div class=\"macro-vimeo\"> <iframe src=\"https://player.vimeo.com/video/98765\" title=\"Vimeo video\" frameborder=\"0\" allow=\"autoplay; fullscreen; picture-in-picture\" allowfullscreen ></iframe> </div>"
)
);
let gallery = compact_html(&expand_builtin_macros(
"[[gallery columns=2 caption='A caption']]",
&context,
));
assert_eq!(
gallery,
compact_html(
"<div class=\"macro-gallery gallery-cols-2\" data-post-id=\"post-1\" data-columns=\"2\" data-lightbox=\"true\" > <div class=\"gallery-container gallery-lightbox\"><a class=\"gallery-item\" href=\"/media/2026/04/one.jpg\" data-lightbox=\"gallery-post-1\" data-title=\"One &amp; only\" > <img src=\"/media/2026/04/one.jpg\" alt=\"Alt &lt;one&gt;\" loading=\"lazy\" /> </a></div><figcaption class=\"gallery-caption\">A caption</figcaption></div>"
)
);
let archive = compact_html(&expand_builtin_macros("[[photo_archive]]", &context));
assert_eq!(
archive,
compact_html(
"<div class=\"macro-photo-archive\"> <div class=\"photo-archive-container\"><div class=\"photo-archive-month-wrapper\"> <div class=\"photo-archive-month\"> <div class=\"photo-archive-month-label\"> <span>April 2026</span> </div> <div class=\"photo-archive-gallery\"><a class=\"photo-archive-item\" href=\"/media/2026/04/one.jpg\" data-lightbox=\"2026-4\" data-title=\"One &amp; only\" > <img src=\"/media/2026/04/one.jpg\" alt=\"Alt &lt;one&gt;\" loading=\"lazy\" /> </a></div> </div> </div></div> </div>"
)
);
let tag_cloud = compact_html(&expand_builtin_macros(
"[[tag_cloud orientation=diagonal width=640 height=300]]",
&context,
));
assert!(tag_cloud.starts_with(
"<div class=\"macro-tag-cloud\" data-tag-cloud=\"true\" data-orientation=\"mixed-diagonal\" data-color-distribution=\"quantile\" data-color-easing=\"0.7\" data-color-theme=\"pico\" data-tag-cloud-words=\""
), "{tag_cloud}");
assert!(tag_cloud.contains("&quot;url&quot;:&quot;/tag/Rust%20%26%20SQLite/&quot;"));
assert!(tag_cloud.contains("data-width=\"640\" data-height=\"300\""));
assert!(tag_cloud.ends_with(
&compact_html("> <svg class=\"tag-cloud-canvas\" viewBox=\"0 0 640 300\" preserveAspectRatio=\"xMidYMid meet\" aria-label=\"Tag cloud\" ></svg></div>")
));
}
#[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": "<aside data-columns=\"{{ columns }}\">custom gallery</aside>"
}),
);
let rendered = expand_builtin_macros(
"[[gallery columns=4]]",
&MacroRenderContext {
roots,
..MacroRenderContext::default()
},
);
assert_eq!(rendered, "<aside data-columns=\"4\">custom gallery</aside>");
}
#[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] #[test]
fn leaves_unknown_macros_verbatim() { fn leaves_unknown_macros_verbatim() {
let markdown = "Before [[future_macro value='x']] after"; let markdown = "Before [[future_macro value='x']] after";

View File

@@ -244,6 +244,7 @@ fn collect_macro_roots(runtime: &dyn Runtime) -> JsonMap<String, JsonValue> {
"project", "project",
"Tags", "Tags",
"macro_scripts", "macro_scripts",
"macro_templates",
"language", "language",
"language_prefix", "language_prefix",
"main_language", "main_language",

View File

@@ -77,6 +77,7 @@ struct TemplateBundle {
default_list_template: String, default_list_template: String,
not_found_template: String, not_found_template: String,
partials: HashMap<String, String>, partials: HashMap<String, String>,
macro_templates: HashMap<String, String>,
macro_scripts: HashMap<String, Value>, macro_scripts: HashMap<String, Value>,
host: Arc<dyn HostApi>, host: Arc<dyn HostApi>,
} }
@@ -411,6 +412,7 @@ fn load_template_bundle(
let mut post_templates = Vec::new(); let mut post_templates = Vec::new();
let mut list_template_sources = HashMap::new(); let mut list_template_sources = HashMap::new();
let mut partials = starter_partials(); 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 default_list_template = STARTER_POST_LIST_TEMPLATE.to_string();
let mut not_found_template = STARTER_NOT_FOUND_TEMPLATE.to_string(); let mut not_found_template = STARTER_NOT_FOUND_TEMPLATE.to_string();
let mut macro_scripts = HashMap::new(); let mut macro_scripts = HashMap::new();
@@ -472,6 +474,9 @@ fn load_template_bundle(
} }
} }
TemplateKind::Partial => { TemplateKind::Partial => {
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); let key = normalize_partial_slug(&template.slug);
partials.insert(key.clone(), source.clone()); partials.insert(key.clone(), source.clone());
if !key.starts_with("partials/") { if !key.starts_with("partials/") {
@@ -480,6 +485,7 @@ fn load_template_bundle(
} }
} }
} }
}
list_template_sources list_template_sources
.entry("post-list".to_string()) .entry("post-list".to_string())
@@ -517,6 +523,7 @@ fn load_template_bundle(
default_list_template, default_list_template,
not_found_template, not_found_template,
partials, partials,
macro_templates,
macro_scripts, macro_scripts,
host, host,
}) })
@@ -836,6 +843,7 @@ fn render_list_route(
"main_language": main_language, "main_language": main_language,
"is_preview": is_preview, "is_preview": is_preview,
"macro_scripts": bundle.macro_scripts, "macro_scripts": bundle.macro_scripts,
"macro_templates": bundle.macro_templates,
"html_theme_attribute": serde_json::Value::Null, "html_theme_attribute": serde_json::Value::Null,
"page_title": route.page_title, "page_title": route.page_title,
"pico_stylesheet_href": pico_stylesheet_href(metadata), "pico_stylesheet_href": pico_stylesheet_href(metadata),
@@ -958,6 +966,7 @@ fn render_post_route(
"main_language": main_language, "main_language": main_language,
"is_preview": is_preview, "is_preview": is_preview,
"macro_scripts": bundle.macro_scripts, "macro_scripts": bundle.macro_scripts,
"macro_templates": bundle.macro_templates,
"page_title": record.post.title, "page_title": record.post.title,
"pico_stylesheet_href": pico_stylesheet_href(metadata), "pico_stylesheet_href": pico_stylesheet_href(metadata),
"html_theme_attribute": serde_json::Value::Null, "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<String, String> { fn starter_partials() -> HashMap<String, String> {
HashMap::from([ 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] #[test]
fn preview_request_paths_select_only_the_matching_generated_page() { fn preview_request_paths_select_only_the_matching_generated_page() {
assert_eq!(preview_relative_path("/"), "index.html"); assert_eq!(preview_relative_path("/"), "index.html");

View File

@@ -91,6 +91,26 @@ fn markdown_filter_expands_builtin_macros_from_runtime_context() {
assert!(rendered.contains("data-tag-cloud=\"true\"")); 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": "<figure data-video=\"{{ id | escape }}\">custom</figure>"
},
"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("<figure data-video=\"custom-id\">custom</figure>"));
assert!(!rendered.contains("macro-youtube"));
}
#[test] #[test]
fn markdown_filter_leaves_unknown_macros_verbatim() { 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 }}"; let template = "{{ body | markdown: nil, nil, canonical_post_path_by_slug, canonical_media_path_by_source_path, language, language_prefix }}";