979 lines
34 KiB
Rust
979 lines
34 KiB
Rust
use std::collections::{BTreeMap, HashMap};
|
|
use std::sync::Arc;
|
|
|
|
use chrono::{Datelike, TimeZone, Utc};
|
|
use serde_json::{Map, Value as JsonValue};
|
|
|
|
use crate::i18n::translate_render;
|
|
|
|
const GALLERY_TEMPLATE: &str =
|
|
include_str!("../../../../assets/starter-templates/macros/gallery.liquid");
|
|
const YOUTUBE_TEMPLATE: &str =
|
|
include_str!("../../../../assets/starter-templates/macros/youtube.liquid");
|
|
const VIMEO_TEMPLATE: &str =
|
|
include_str!("../../../../assets/starter-templates/macros/vimeo.liquid");
|
|
const PHOTO_ARCHIVE_TEMPLATE: &str =
|
|
include_str!("../../../../assets/starter-templates/macros/photo-archive.liquid");
|
|
const TAG_CLOUD_TEMPLATE: &str =
|
|
include_str!("../../../../assets/starter-templates/macros/tag-cloud.liquid");
|
|
|
|
#[derive(Clone)]
|
|
pub(crate) struct MacroRenderContext {
|
|
pub roots: Map<String, JsonValue>,
|
|
pub post_id: Option<String>,
|
|
pub host: Arc<dyn crate::scripting::HostApi>,
|
|
}
|
|
|
|
impl Default for MacroRenderContext {
|
|
fn default() -> Self {
|
|
Self {
|
|
roots: Map::new(),
|
|
post_id: None,
|
|
host: Arc::new(crate::scripting::UnavailableHost),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn expand_builtin_macros(markdown: &str, context: &MacroRenderContext) -> String {
|
|
let mut expanded = String::with_capacity(markdown.len());
|
|
let mut cursor = 0;
|
|
|
|
while let Some(offset) = markdown[cursor..].find("[[") {
|
|
let start = cursor + offset;
|
|
expanded.push_str(&markdown[cursor..start]);
|
|
|
|
let body_start = start + 2;
|
|
let Some(end_offset) = markdown[body_start..].find("]]") else {
|
|
expanded.push_str(&markdown[start..]);
|
|
return expanded;
|
|
};
|
|
let end = body_start + end_offset;
|
|
let invocation = &markdown[body_start..end];
|
|
let raw = &markdown[start..end + 2];
|
|
|
|
if let Some(rendered) = render_macro(invocation, context) {
|
|
expanded.push_str(&rendered);
|
|
} else {
|
|
expanded.push_str(raw);
|
|
}
|
|
cursor = end + 2;
|
|
}
|
|
|
|
expanded.push_str(&markdown[cursor..]);
|
|
expanded
|
|
}
|
|
|
|
fn render_macro(invocation: &str, context: &MacroRenderContext) -> Option<String> {
|
|
let tokens = tokenize_invocation(invocation);
|
|
let name = tokens.first()?.as_str();
|
|
let mut args = HashMap::new();
|
|
for token in tokens.iter().skip(1) {
|
|
let (key, raw_value) = token.split_once('=')?;
|
|
args.insert(key.to_string(), resolve_token(raw_value, context));
|
|
}
|
|
|
|
match name {
|
|
"gallery" => Some(render_gallery(&args, context)),
|
|
"youtube" => Some(render_youtube(&args, context)),
|
|
"vimeo" => Some(render_vimeo(&args, context)),
|
|
"photo_archive" => Some(render_photo_archive(&args, context)),
|
|
"tag_cloud" => Some(render_tag_cloud(&args, context)),
|
|
_ => render_script_macro(name, &args, context),
|
|
}
|
|
}
|
|
|
|
fn render_script_macro(
|
|
name: &str,
|
|
args: &HashMap<String, JsonValue>,
|
|
context: &MacroRenderContext,
|
|
) -> Option<String> {
|
|
let definition = context.roots.get("macro_scripts")?.get(name)?;
|
|
let source = definition.get("source")?.as_str()?;
|
|
let entrypoint = definition
|
|
.get("entrypoint")
|
|
.and_then(JsonValue::as_str)
|
|
.unwrap_or("render");
|
|
let env = serde_json::json!({
|
|
"isPreview": context.roots.get("is_preview").and_then(JsonValue::as_bool).unwrap_or(false),
|
|
"mainLanguage": context.roots.get("main_language").and_then(JsonValue::as_str).unwrap_or("en"),
|
|
"language": context.roots.get("language").and_then(JsonValue::as_str).unwrap_or("en"),
|
|
"languagePrefix": context.roots.get("language_prefix").and_then(JsonValue::as_str).unwrap_or(""),
|
|
"hook": "markdown",
|
|
"source": { "kind": if context.post_id.is_some() { "post" } else { "page" } },
|
|
"translations": context.roots.get("translations").cloned().unwrap_or(JsonValue::Array(Vec::new())),
|
|
});
|
|
let params = serde_json::to_value(args).ok()?;
|
|
match crate::scripting::execute_many_with_host(
|
|
source,
|
|
entrypoint,
|
|
&[params, env],
|
|
crate::scripting::ExecutionKind::Macro,
|
|
&crate::scripting::ExecutionControl::default(),
|
|
Arc::clone(&context.host),
|
|
) {
|
|
Ok(result) => Some(match result.value {
|
|
JsonValue::Null => String::new(),
|
|
JsonValue::String(value) => value,
|
|
value => value.to_string(),
|
|
}),
|
|
Err(_) => Some(String::new()),
|
|
}
|
|
}
|
|
|
|
fn tokenize_invocation(invocation: &str) -> Vec<String> {
|
|
let mut tokens = Vec::new();
|
|
let mut current = String::new();
|
|
let mut quote = None;
|
|
|
|
for ch in invocation.chars() {
|
|
if let Some(active_quote) = quote {
|
|
current.push(ch);
|
|
if ch == active_quote {
|
|
quote = None;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
match ch {
|
|
'"' | '\'' => {
|
|
quote = Some(ch);
|
|
current.push(ch);
|
|
}
|
|
' ' | '\n' | '\t' | '\r' => {
|
|
if !current.is_empty() {
|
|
tokens.push(std::mem::take(&mut current));
|
|
}
|
|
}
|
|
_ => current.push(ch),
|
|
}
|
|
}
|
|
|
|
if !current.is_empty() {
|
|
tokens.push(current);
|
|
}
|
|
|
|
tokens
|
|
}
|
|
|
|
fn resolve_token(raw: &str, context: &MacroRenderContext) -> JsonValue {
|
|
let trimmed = raw.trim();
|
|
if trimmed.len() >= 2
|
|
&& ((trimmed.starts_with('"') && trimmed.ends_with('"'))
|
|
|| (trimmed.starts_with('\'') && trimmed.ends_with('\'')))
|
|
{
|
|
return JsonValue::String(trimmed[1..trimmed.len() - 1].to_string());
|
|
}
|
|
|
|
match trimmed {
|
|
"true" => return JsonValue::Bool(true),
|
|
"false" => return JsonValue::Bool(false),
|
|
"null" => return JsonValue::Null,
|
|
_ => {}
|
|
}
|
|
|
|
if let Ok(value) = trimmed.parse::<i64>() {
|
|
return JsonValue::from(value);
|
|
}
|
|
if let Ok(value) = trimmed.parse::<f64>() {
|
|
return JsonValue::from(value);
|
|
}
|
|
|
|
lookup_path(trimmed, context).unwrap_or_else(|| JsonValue::String(trimmed.to_string()))
|
|
}
|
|
|
|
fn lookup_path(path: &str, context: &MacroRenderContext) -> Option<JsonValue> {
|
|
let mut segments = path.split('.');
|
|
let first = segments.next()?;
|
|
let mut current = context.roots.get(first)?.clone();
|
|
|
|
for segment in segments {
|
|
current = match current {
|
|
JsonValue::Object(map) => map.get(segment)?.clone(),
|
|
JsonValue::Array(values) => values.get(segment.parse::<usize>().ok()?)?.clone(),
|
|
_ => return None,
|
|
};
|
|
}
|
|
|
|
Some(current)
|
|
}
|
|
|
|
fn render_gallery(args: &HashMap<String, JsonValue>, context: &MacroRenderContext) -> String {
|
|
let columns = args
|
|
.get("columns")
|
|
.and_then(value_as_u64)
|
|
.map(|value| value.clamp(1, 6) as usize)
|
|
.unwrap_or(3);
|
|
let mut images = args
|
|
.get("images")
|
|
.and_then(JsonValue::as_array)
|
|
.cloned()
|
|
.or_else(|| linked_media(context))
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.filter(is_image)
|
|
.collect::<Vec<_>>();
|
|
images.sort_by(media_newest_first);
|
|
let gallery_id = format!("gallery-{}", context.post_id.as_deref().unwrap_or_default());
|
|
let items = images
|
|
.into_iter()
|
|
.filter_map(|image| {
|
|
Some(serde_json::json!({
|
|
"media_path": ensure_leading_slash(&image_path(&image)?),
|
|
"title": image_title(&image).unwrap_or_default(),
|
|
"alt": image_alt(&image),
|
|
"group_name": gallery_id,
|
|
}))
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
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<String, JsonValue>, context: &MacroRenderContext) -> String {
|
|
render_macro_template(
|
|
macro_template(context, "youtube", YOUTUBE_TEMPLATE),
|
|
serde_json::json!({
|
|
"id": args.get("id").map(stringify_scalar).unwrap_or_default(),
|
|
"title": macro_title(args, context, "render.video.youtubeTitle"),
|
|
}),
|
|
)
|
|
}
|
|
|
|
fn render_vimeo(args: &HashMap<String, JsonValue>, context: &MacroRenderContext) -> String {
|
|
render_macro_template(
|
|
macro_template(context, "vimeo", VIMEO_TEMPLATE),
|
|
serde_json::json!({
|
|
"id": args.get("id").map(stringify_scalar).unwrap_or_default(),
|
|
"title": macro_title(args, context, "render.video.vimeoTitle"),
|
|
}),
|
|
)
|
|
}
|
|
|
|
fn render_photo_archive(args: &HashMap<String, JsonValue>, context: &MacroRenderContext) -> String {
|
|
let mut media = args
|
|
.get("media")
|
|
.and_then(JsonValue::as_array)
|
|
.cloned()
|
|
.or_else(|| project_media(context))
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.filter(is_image)
|
|
.collect::<Vec<_>>();
|
|
media.sort_by(media_newest_first);
|
|
|
|
let year = args.get("year").and_then(value_as_i32);
|
|
let month = args.get("month").and_then(value_as_u32);
|
|
if args.contains_key("year") && year.is_none() {
|
|
media.clear();
|
|
} else if let Some(year) = year {
|
|
media.retain(|item| {
|
|
media_archive_month(item).is_some_and(|(item_year, item_month)| {
|
|
item_year == year && month.is_none_or(|month| item_month == month)
|
|
})
|
|
});
|
|
} else {
|
|
media.truncate(200);
|
|
}
|
|
|
|
let mut grouped = BTreeMap::<(i32, u32), Vec<JsonValue>>::new();
|
|
for item in media {
|
|
if let Some(bucket) = media_archive_month(&item) {
|
|
grouped.entry(bucket).or_default().push(item);
|
|
}
|
|
}
|
|
let month_limit = if year.is_none() { 10 } else { usize::MAX };
|
|
let months = grouped
|
|
.into_iter()
|
|
.rev()
|
|
.take(month_limit)
|
|
.map(|((year, month), items)| {
|
|
let items = items
|
|
.into_iter()
|
|
.filter_map(|item| {
|
|
Some(serde_json::json!({
|
|
"media_path": ensure_leading_slash(&image_path(&item)?),
|
|
"title": image_archive_title(&item).unwrap_or_default(),
|
|
"alt": image_alt(&item),
|
|
"group_name": format!("{year}-{month}"),
|
|
}))
|
|
})
|
|
.collect::<Vec<_>>();
|
|
serde_json::json!({
|
|
"label": format!(
|
|
"{} {year}",
|
|
render_translation(context, &format!("render.month.{month}"))
|
|
),
|
|
"items": items,
|
|
})
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
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<String, JsonValue>, context: &MacroRenderContext) -> String {
|
|
let tags = args
|
|
.get("tags")
|
|
.and_then(JsonValue::as_array)
|
|
.cloned()
|
|
.or_else(|| tag_items(context));
|
|
|
|
let orientation = normalize_tag_cloud_orientation(args.get("orientation"));
|
|
let width = tag_cloud_dimension(args.get("width"), 320, 1600, 900);
|
|
let height = tag_cloud_dimension(args.get("height"), 180, 900, 420);
|
|
let words_json = tags
|
|
.as_deref()
|
|
.map(|tags| build_tag_cloud_words(tags, context))
|
|
.filter(|words| !words.is_empty())
|
|
.and_then(|words| serde_json::to_string(&words).ok())
|
|
.map(|words| escape_html_attr(&words));
|
|
|
|
render_macro_template(
|
|
macro_template(context, "tag-cloud", TAG_CLOUD_TEMPLATE),
|
|
serde_json::json!({
|
|
"orientation": orientation,
|
|
"words_json": words_json,
|
|
"width": width,
|
|
"height": height,
|
|
"aria_label": render_translation(context, "render.tagCloud.ariaLabel"),
|
|
"no_items_label": render_translation(context, "render.tagCloud.empty"),
|
|
}),
|
|
)
|
|
}
|
|
|
|
fn build_tag_cloud_words(tags: &[JsonValue], context: &MacroRenderContext) -> Vec<JsonValue> {
|
|
let min_count = tags
|
|
.iter()
|
|
.filter_map(|tag| tag.get("post_count").and_then(value_as_u64))
|
|
.min()
|
|
.unwrap_or(1);
|
|
let max_count = tags
|
|
.iter()
|
|
.filter_map(|tag| tag.get("post_count").and_then(value_as_u64))
|
|
.max()
|
|
.unwrap_or(min_count.max(1));
|
|
|
|
let mut words = tags
|
|
.iter()
|
|
.filter_map(|tag| {
|
|
let name = tag.get("name").and_then(JsonValue::as_str)?;
|
|
let encoded_name = encode_path_segment(name);
|
|
let count = tag.get("post_count").and_then(value_as_u64).unwrap_or(1);
|
|
let size = if max_count == min_count {
|
|
35.0
|
|
} else {
|
|
14.0 + (((count - min_count) as f64 / (max_count - min_count) as f64) * 42.0)
|
|
};
|
|
let color = tag
|
|
.get("color")
|
|
.and_then(JsonValue::as_str)
|
|
.map(ToOwned::to_owned)
|
|
.or_else(|| {
|
|
context
|
|
.roots
|
|
.get("tag_color_by_name")
|
|
.and_then(JsonValue::as_object)
|
|
.and_then(|colors| colors.get(name))
|
|
.and_then(JsonValue::as_str)
|
|
.map(ToOwned::to_owned)
|
|
});
|
|
let mut word = Map::from_iter([
|
|
("text".into(), JsonValue::String(name.into())),
|
|
("count".into(), JsonValue::from(count)),
|
|
(
|
|
"url".into(),
|
|
JsonValue::String(format!("/tag/{encoded_name}/")),
|
|
),
|
|
("size".into(), JsonValue::from(size.round() as u64)),
|
|
]);
|
|
if let Some(color) = color.filter(|color| !color.is_empty()) {
|
|
word.insert("color".into(), JsonValue::String(color));
|
|
}
|
|
Some(JsonValue::Object(word))
|
|
})
|
|
.collect::<Vec<_>>();
|
|
words.sort_by(|left, right| {
|
|
right["count"]
|
|
.as_u64()
|
|
.cmp(&left["count"].as_u64())
|
|
.then_with(|| {
|
|
left["text"]
|
|
.as_str()
|
|
.unwrap_or_default()
|
|
.to_lowercase()
|
|
.cmp(&right["text"].as_str().unwrap_or_default().to_lowercase())
|
|
})
|
|
});
|
|
words
|
|
}
|
|
|
|
fn render_macro_template(template: &str, assigns: JsonValue) -> String {
|
|
crate::render::render_liquid_template(template, &HashMap::new(), &assigns).unwrap_or_default()
|
|
}
|
|
|
|
fn macro_template<'a>(context: &'a MacroRenderContext, name: &str, bundled: &'a str) -> &'a str {
|
|
context
|
|
.roots
|
|
.get("macro_templates")
|
|
.and_then(JsonValue::as_object)
|
|
.and_then(|templates| templates.get(name))
|
|
.and_then(JsonValue::as_str)
|
|
.unwrap_or(bundled)
|
|
}
|
|
|
|
pub(super) fn bundled_macro_templates() -> HashMap<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>> {
|
|
lookup_path("post.linked_media", context)
|
|
.and_then(|value| value.as_array().cloned())
|
|
.or_else(|| {
|
|
let post_id = context.post_id.as_deref()?;
|
|
context
|
|
.roots
|
|
.get("post_data_json_by_id")?
|
|
.get(post_id)?
|
|
.get("linked_media")?
|
|
.as_array()
|
|
.cloned()
|
|
})
|
|
}
|
|
|
|
fn project_media(context: &MacroRenderContext) -> Option<Vec<JsonValue>> {
|
|
lookup_path("project.media", context).and_then(|value| value.as_array().cloned())
|
|
}
|
|
|
|
fn tag_items(context: &MacroRenderContext) -> Option<Vec<JsonValue>> {
|
|
lookup_path("Tags", context)
|
|
.and_then(|value| value.as_array().cloned())
|
|
.or_else(|| lookup_path("post_tags", context).and_then(|value| value.as_array().cloned()))
|
|
}
|
|
|
|
fn is_image(media: &JsonValue) -> bool {
|
|
media
|
|
.get("mime_type")
|
|
.and_then(JsonValue::as_str)
|
|
.is_none_or(|mime_type| mime_type.starts_with("image/"))
|
|
}
|
|
|
|
fn media_newest_first(left: &JsonValue, right: &JsonValue) -> std::cmp::Ordering {
|
|
right
|
|
.get("created_at")
|
|
.and_then(JsonValue::as_i64)
|
|
.cmp(&left.get("created_at").and_then(JsonValue::as_i64))
|
|
.then_with(|| {
|
|
left.get("id")
|
|
.and_then(JsonValue::as_str)
|
|
.cmp(&right.get("id").and_then(JsonValue::as_str))
|
|
})
|
|
}
|
|
|
|
fn media_archive_month(media: &JsonValue) -> Option<(i32, u32)> {
|
|
media
|
|
.get("created_at")
|
|
.and_then(JsonValue::as_i64)
|
|
.and_then(|timestamp| Utc.timestamp_millis_opt(timestamp).single())
|
|
.map(|timestamp| (timestamp.year(), timestamp.month()))
|
|
.or_else(|| {
|
|
media
|
|
.get("file_path")
|
|
.and_then(JsonValue::as_str)
|
|
.and_then(month_bucket)
|
|
})
|
|
}
|
|
|
|
fn normalize_tag_cloud_orientation(value: Option<&JsonValue>) -> &'static str {
|
|
match value
|
|
.map(stringify_scalar)
|
|
.unwrap_or_default()
|
|
.trim()
|
|
.to_lowercase()
|
|
.as_str()
|
|
{
|
|
"mixed_hv" | "mixed-hv" | "hv" | "horizontal_vertical" => "mixed-hv",
|
|
"mixed_diagonal" | "mixed-diagonal" | "diagonal" | "diag" => "mixed-diagonal",
|
|
_ => "horizontal",
|
|
}
|
|
}
|
|
|
|
fn tag_cloud_dimension(value: Option<&JsonValue>, min: u64, max: u64, default: u64) -> u64 {
|
|
value
|
|
.and_then(value_as_u64)
|
|
.filter(|value| (min..=max).contains(value))
|
|
.unwrap_or(default)
|
|
}
|
|
|
|
fn macro_title(
|
|
args: &HashMap<String, JsonValue>,
|
|
context: &MacroRenderContext,
|
|
translation_key: &str,
|
|
) -> String {
|
|
args.get("title")
|
|
.map(stringify_scalar)
|
|
.filter(|title| !title.trim().is_empty())
|
|
.unwrap_or_else(|| render_translation(context, translation_key))
|
|
}
|
|
|
|
fn render_translation(context: &MacroRenderContext, key: &str) -> String {
|
|
let language = context
|
|
.roots
|
|
.get("language")
|
|
.and_then(JsonValue::as_str)
|
|
.unwrap_or("en");
|
|
translate_render(language, key)
|
|
}
|
|
|
|
fn image_path(image: &JsonValue) -> Option<String> {
|
|
image
|
|
.get("file_path")
|
|
.and_then(JsonValue::as_str)
|
|
.map(ToOwned::to_owned)
|
|
}
|
|
|
|
fn ensure_leading_slash(path: &str) -> String {
|
|
if path.starts_with('/') {
|
|
path.to_string()
|
|
} else {
|
|
format!("/{path}")
|
|
}
|
|
}
|
|
|
|
fn image_title(image: &JsonValue) -> Option<String> {
|
|
image
|
|
.get("caption")
|
|
.and_then(JsonValue::as_str)
|
|
.filter(|value| !value.is_empty())
|
|
.map(ToOwned::to_owned)
|
|
.or_else(|| {
|
|
image
|
|
.get("title")
|
|
.and_then(JsonValue::as_str)
|
|
.filter(|value| !value.is_empty())
|
|
.map(ToOwned::to_owned)
|
|
})
|
|
.or_else(|| image_name(image))
|
|
}
|
|
|
|
fn image_archive_title(image: &JsonValue) -> Option<String> {
|
|
image
|
|
.get("title")
|
|
.and_then(JsonValue::as_str)
|
|
.filter(|value| !value.is_empty())
|
|
.map(ToOwned::to_owned)
|
|
.or_else(|| image_name(image))
|
|
}
|
|
|
|
fn image_alt(image: &JsonValue) -> String {
|
|
image
|
|
.get("alt")
|
|
.and_then(JsonValue::as_str)
|
|
.filter(|value| !value.is_empty())
|
|
.map(ToOwned::to_owned)
|
|
.or_else(|| {
|
|
image
|
|
.get("title")
|
|
.and_then(JsonValue::as_str)
|
|
.filter(|value| !value.is_empty())
|
|
.map(ToOwned::to_owned)
|
|
})
|
|
.or_else(|| image_name(image))
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn image_name(image: &JsonValue) -> Option<String> {
|
|
image
|
|
.get("original_name")
|
|
.or_else(|| image.get("filename"))
|
|
.and_then(JsonValue::as_str)
|
|
.filter(|value| !value.is_empty())
|
|
.map(ToOwned::to_owned)
|
|
}
|
|
|
|
fn value_as_u64(value: &JsonValue) -> Option<u64> {
|
|
value
|
|
.as_u64()
|
|
.or_else(|| value.as_i64().and_then(|number| number.try_into().ok()))
|
|
.or_else(|| value.as_str().and_then(|number| number.parse().ok()))
|
|
}
|
|
|
|
fn value_as_u32(value: &JsonValue) -> Option<u32> {
|
|
value_as_u64(value)
|
|
.and_then(|number| number.try_into().ok())
|
|
.filter(|month| (1..=12).contains(month))
|
|
}
|
|
|
|
fn value_as_i32(value: &JsonValue) -> Option<i32> {
|
|
value
|
|
.as_i64()
|
|
.and_then(|number| number.try_into().ok())
|
|
.or_else(|| value.as_str().and_then(|number| number.parse().ok()))
|
|
}
|
|
|
|
fn stringify_scalar(value: &JsonValue) -> String {
|
|
match value {
|
|
JsonValue::Null => String::new(),
|
|
JsonValue::Bool(boolean) => boolean.to_string(),
|
|
JsonValue::Number(number) => number.to_string(),
|
|
JsonValue::String(text) => text.clone(),
|
|
JsonValue::Array(_) | JsonValue::Object(_) => value.to_string(),
|
|
}
|
|
}
|
|
|
|
fn month_bucket(path: &str) -> Option<(i32, u32)> {
|
|
let segments = path.trim_matches('/').split('/').collect::<Vec<_>>();
|
|
match segments.as_slice() {
|
|
["media", year, month, ..] if year.len() == 4 && month.len() == 2 => {
|
|
Some((year.parse().ok()?, month.parse().ok()?))
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn escape_html_attr(value: &str) -> String {
|
|
value
|
|
.replace('&', "&")
|
|
.replace('<', "<")
|
|
.replace('>', ">")
|
|
.replace('"', """)
|
|
.replace('\'', "'")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
GALLERY_TEMPLATE, MacroRenderContext, PHOTO_ARCHIVE_TEMPLATE, TAG_CLOUD_TEMPLATE,
|
|
VIMEO_TEMPLATE, YOUTUBE_TEMPLATE, expand_builtin_macros, render_macro_template,
|
|
};
|
|
|
|
fn compact_html(html: &str) -> String {
|
|
html.split_whitespace()
|
|
.collect::<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]
|
|
fn expands_gallery_and_tag_cloud_macros() {
|
|
let mut roots = serde_json::Map::new();
|
|
roots.insert(
|
|
"post".to_string(),
|
|
serde_json::json!({
|
|
"linked_media": [
|
|
{"file_path": "/media/2026/04/one.jpg", "title": "One", "alt": "Alt one"},
|
|
{"file_path": "/media/2026/04/two.jpg", "caption": "Two"}
|
|
]
|
|
}),
|
|
);
|
|
roots.insert(
|
|
"post_tags".to_string(),
|
|
serde_json::json!([
|
|
{"name": "Rust", "slug": "rust", "post_count": 4, "color": "#ff6600"},
|
|
{"name": "Iced", "slug": "iced", "post_count": 2}
|
|
]),
|
|
);
|
|
roots.insert(
|
|
"tag_color_by_name".to_string(),
|
|
serde_json::json!({"Iced": "#0088cc"}),
|
|
);
|
|
|
|
let html = expand_builtin_macros(
|
|
"[[gallery images=post.linked_media columns=2]]\n\n[[tag_cloud tags=post_tags]]",
|
|
&MacroRenderContext {
|
|
roots,
|
|
post_id: Some("post-1".to_string()),
|
|
..MacroRenderContext::default()
|
|
},
|
|
);
|
|
|
|
assert!(html.contains("macro-gallery gallery-cols-2"));
|
|
assert!(html.contains("data-lightbox=\"gallery-post-1\""));
|
|
assert!(html.contains("macro-tag-cloud"));
|
|
assert!(html.contains("data-tag-cloud=\"true\""));
|
|
}
|
|
|
|
#[test]
|
|
fn built_in_macros_match_the_bds2_template_markup() {
|
|
let mut roots = serde_json::Map::new();
|
|
roots.insert("language".into(), serde_json::json!("en"));
|
|
roots.insert(
|
|
"post".into(),
|
|
serde_json::json!({
|
|
"linked_media": [{
|
|
"id": "image-1",
|
|
"file_path": "/media/2026/04/one.jpg",
|
|
"mime_type": "image/jpeg",
|
|
"title": "One & only",
|
|
"alt": "Alt <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 & only\" > <img src=\"/media/2026/04/one.jpg\" alt=\"Alt <one>\" 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 & only\" > <img src=\"/media/2026/04/one.jpg\" alt=\"Alt <one>\" 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(""url":"/tag/Rust%20%26%20SQLite/""));
|
|
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() {
|
|
for (language, youtube_title, vimeo_title) in [
|
|
("en", "YouTube video", "Vimeo video"),
|
|
("de", "YouTube-Video", "Vimeo-Video"),
|
|
("fr", "Vidéo YouTube", "Vidéo Vimeo"),
|
|
("it", "Video YouTube", "Video Vimeo"),
|
|
("es", "Vídeo de YouTube", "Vídeo de Vimeo"),
|
|
] {
|
|
let mut roots = serde_json::Map::new();
|
|
roots.insert("language".into(), serde_json::json!(language));
|
|
let context = MacroRenderContext {
|
|
roots,
|
|
..MacroRenderContext::default()
|
|
};
|
|
let rendered = expand_builtin_macros("[[youtube]] [[vimeo]]", &context);
|
|
|
|
assert!(rendered.contains("https://www.youtube.com/embed/?rel=0"));
|
|
assert!(rendered.contains("https://player.vimeo.com/video/"));
|
|
assert!(rendered.contains(&format!("title=\"{youtube_title}\"")));
|
|
assert!(rendered.contains(&format!("title=\"{vimeo_title}\"")));
|
|
assert!(!rendered.contains("Missing"));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn empty_macro_states_use_render_domain_translations() {
|
|
let mut roots = serde_json::Map::new();
|
|
roots.insert("language".into(), serde_json::json!("de"));
|
|
let context = MacroRenderContext {
|
|
roots,
|
|
..MacroRenderContext::default()
|
|
};
|
|
|
|
let rendered =
|
|
expand_builtin_macros("[[gallery]] [[photo_archive]] [[tag_cloud]]", &context);
|
|
|
|
assert!(rendered.contains("Keine verknüpften Bilder gefunden."));
|
|
assert!(rendered.contains("Keine Fotos für dieses Archiv gefunden."));
|
|
assert!(rendered.contains("Keine Tags gefunden."));
|
|
}
|
|
|
|
#[test]
|
|
fn project_macro_templates_override_the_bundled_defaults() {
|
|
let mut roots = serde_json::Map::new();
|
|
roots.insert(
|
|
"macro_templates".into(),
|
|
serde_json::json!({
|
|
"gallery": "<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]
|
|
fn leaves_unknown_macros_verbatim() {
|
|
let markdown = "Before [[future_macro value='x']] after";
|
|
assert_eq!(
|
|
expand_builtin_macros(markdown, &MacroRenderContext::default()),
|
|
markdown
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn script_macro_receives_named_params_and_environment() {
|
|
let mut roots = serde_json::Map::new();
|
|
roots.insert(
|
|
"macro_scripts".into(),
|
|
serde_json::json!({
|
|
"notice": {
|
|
"source": "function render(params, env) return '<aside>' .. params.text .. ':' .. env.language .. '</aside>' end",
|
|
"entrypoint": "render"
|
|
}
|
|
}),
|
|
);
|
|
roots.insert("language".into(), serde_json::Value::String("de".into()));
|
|
let rendered = expand_builtin_macros(
|
|
"[[notice text=Hallo]]",
|
|
&MacroRenderContext {
|
|
roots,
|
|
post_id: Some("post-1".into()),
|
|
..MacroRenderContext::default()
|
|
},
|
|
);
|
|
assert_eq!(rendered, "<aside>Hallo:de</aside>");
|
|
}
|
|
}
|