Fix project macro rendering in preview and generation

This commit is contained in:
2026-07-20 19:31:41 +02:00
parent 5425d83ee1
commit 713575dd99
4 changed files with 545 additions and 84 deletions

View File

@@ -654,7 +654,9 @@ mod tests {
use super::*; use super::*;
use crate::db::queries; use crate::db::queries;
use crate::engine::meta; use crate::engine::meta;
use crate::model::{Post, PostStatus, PostTranslation, Project, ProjectMetadata}; use crate::model::{
Media, Post, PostMedia, PostStatus, PostTranslation, Project, ProjectMetadata, Tag,
};
use std::sync::{Mutex, OnceLock}; use std::sync::{Mutex, OnceLock};
fn preview_port_guard() -> &'static Mutex<()> { fn preview_port_guard() -> &'static Mutex<()> {
@@ -810,6 +812,167 @@ mod tests {
assert!(html.contains("<strong>world</strong>")); assert!(html.contains("<strong>world</strong>"));
} }
#[test]
fn project_macros_render_real_data_in_preview_and_generation() {
let (dir, db) = setup_preview_fixture();
let mut source = make_post();
source.post.tags = vec!["Rust".into(), "Preview".into()];
source.body_markdown = "[[gallery]]\n\n[[photo_archive year=2024 month=4]]\n\n[[tag_cloud orientation=diag width=1000 height=500]]".into();
queries::post::insert_post(db.conn(), &source.post).unwrap();
for tag in ["Rust", "Preview"] {
queries::tag::insert_tag(
db.conn(),
&Tag {
id: format!("tag-{tag}"),
project_id: "project-1".into(),
name: tag.into(),
color: (tag == "Rust").then(|| "#ff6600".into()),
post_template_slug: None,
created_at: 1_710_000_000_000,
updated_at: 1_710_000_000_000,
},
)
.unwrap();
}
let media = [
(
"linked-image",
"image/jpeg",
"media/2024/04/linked.jpg",
1_712_016_000_000,
),
(
"archive-image",
"image/png",
"media/2024/04/archive.png",
1_713_484_800_000,
),
(
"old-image",
"image/jpeg",
"media/2023/12/old.jpg",
1_702_512_000_000,
),
(
"linked-video",
"video/mp4",
"media/2024/04/video.mp4",
1_713_571_200_000,
),
];
for (id, mime_type, file_path, created_at) in media {
queries::media::insert_media(
db.conn(),
&Media {
id: id.into(),
project_id: "project-1".into(),
filename: file_path.rsplit('/').next().unwrap().into(),
original_name: format!("{id}.original"),
mime_type: mime_type.into(),
size: 10,
width: Some(100),
height: Some(80),
title: Some(id.into()),
alt: Some(format!("{id} alt")),
caption: None,
author: None,
language: None,
file_path: file_path.into(),
sidecar_path: format!("{file_path}.yaml"),
checksum: None,
tags: Vec::new(),
created_at,
updated_at: created_at,
},
)
.unwrap();
}
for (index, media_id) in ["linked-image", "linked-video"].into_iter().enumerate() {
queries::post_media::link_media(
db.conn(),
&PostMedia {
id: format!("link-{media_id}"),
project_id: "project-1".into(),
post_id: source.post.id.clone(),
media_id: media_id.into(),
sort_order: index as i32,
created_at: 1_713_571_200_000,
},
)
.unwrap();
}
let posts = vec![(source.post.clone(), source.body_markdown.clone())];
let preview_single = build_preview_response(
db.conn(),
dir.path(),
"project-1",
&make_metadata(),
&posts,
"/2024/03/09/hello",
)
.unwrap()
.html;
let preview_list = build_preview_response(
db.conn(),
dir.path(),
"project-1",
&make_metadata(),
&posts,
"/",
)
.unwrap()
.html;
let generated = crate::render::build_site_render_artifacts(
db.conn(),
dir.path(),
"project-1",
&make_metadata(),
&posts,
)
.unwrap();
let generated_single = &generated
.pages
.iter()
.find(|page| page.url_path == "/2024/03/09/hello")
.unwrap()
.html;
let generated_list = &generated
.pages
.iter()
.find(|page| page.url_path == "/")
.unwrap()
.html;
for rendered in [
&preview_single,
&preview_list,
generated_single,
generated_list,
] {
assert!(rendered.contains("class=\"gallery-item\""));
assert!(rendered.contains("data-lightbox=\"gallery-post-1\""));
assert!(rendered.contains("/media/2024/04/linked.jpg"));
assert!(!rendered.contains("gallery-empty"));
assert!(!rendered.contains("/media/2024/04/video.mp4"));
assert!(rendered.contains("class=\"photo-archive-item\""));
assert!(rendered.contains("/media/2024/04/archive.png"));
assert!(!rendered.contains("/media/2023/12/old.jpg"));
assert!(!rendered.contains("photo-archive-empty"));
assert!(rendered.contains("&quot;text&quot;:&quot;Rust&quot;"));
assert!(rendered.contains("&quot;color&quot;:&quot;#ff6600&quot;"));
assert!(rendered.contains("&quot;size&quot;:35"));
assert!(rendered.contains("data-orientation=\"mixed-diagonal\""));
assert!(rendered.contains("data-width=\"1000\""));
assert!(rendered.contains("data-height=\"500\""));
assert!(!rendered.contains("tag-cloud-empty"));
}
}
#[test] #[test]
fn preview_renders_page_category_post_at_flat_path() { fn preview_renders_page_category_post_at_flat_path() {
let db = Database::open_in_memory().unwrap(); let db = Database::open_in_memory().unwrap();

View File

@@ -1,8 +1,11 @@
use std::collections::{BTreeMap, HashMap}; use std::collections::{BTreeMap, HashMap};
use std::sync::Arc; use std::sync::Arc;
use chrono::{Datelike, TimeZone, Utc};
use serde_json::{Map, Value as JsonValue}; use serde_json::{Map, Value as JsonValue};
use crate::i18n::translate_render;
#[derive(Clone)] #[derive(Clone)]
pub(crate) struct MacroRenderContext { pub(crate) struct MacroRenderContext {
pub roots: Map<String, JsonValue>, pub roots: Map<String, JsonValue>,
@@ -60,8 +63,8 @@ fn render_macro(invocation: &str, context: &MacroRenderContext) -> Option<String
match name { match name {
"gallery" => Some(render_gallery(&args, context)), "gallery" => Some(render_gallery(&args, context)),
"youtube" => Some(render_youtube(&args)), "youtube" => Some(render_youtube(&args, context)),
"vimeo" => Some(render_vimeo(&args)), "vimeo" => Some(render_vimeo(&args, context)),
"photo_archive" => Some(render_photo_archive(&args, context)), "photo_archive" => Some(render_photo_archive(&args, context)),
"tag_cloud" => Some(render_tag_cloud(&args, context)), "tag_cloud" => Some(render_tag_cloud(&args, context)),
_ => render_script_macro(name, &args, context), _ => render_script_macro(name, &args, context),
@@ -189,41 +192,40 @@ fn render_gallery(args: &HashMap<String, JsonValue>, context: &MacroRenderContex
.and_then(value_as_u64) .and_then(value_as_u64)
.map(|value| value.clamp(1, 6) as usize) .map(|value| value.clamp(1, 6) as usize)
.unwrap_or(3); .unwrap_or(3);
let images = args let mut images = args
.get("images") .get("images")
.and_then(JsonValue::as_array) .and_then(JsonValue::as_array)
.cloned() .cloned()
.or_else(|| linked_media(context)); .or_else(|| linked_media(context))
.unwrap_or_default()
.into_iter()
.filter(is_image)
.collect::<Vec<_>>();
images.sort_by(media_newest_first);
let Some(images) = images else {
return empty_block(
&format!("macro-gallery gallery-cols-{columns}"),
"gallery-empty",
"No media available.",
);
};
if images.is_empty() { if images.is_empty() {
return empty_block( return empty_block(
&format!("macro-gallery gallery-cols-{columns}"), &format!("macro-gallery gallery-cols-{columns}"),
"gallery-empty", "gallery-empty",
"No media available.", &render_translation(context, "render.gallery.empty"),
); );
} }
let gallery_id = context.post_id.as_deref().unwrap_or("gallery"); let gallery_id = format!("gallery-{}", context.post_id.as_deref().unwrap_or_default());
let mut html = format!( let mut html = format!(
"<section class=\"macro-gallery gallery-cols-{columns}\"><div class=\"gallery-container\">" "<section class=\"macro-gallery gallery-cols-{columns}\" data-post-id=\"{}\" data-columns=\"{columns}\" data-lightbox=\"true\"><div class=\"gallery-container gallery-lightbox\">",
escape_html_attr(context.post_id.as_deref().unwrap_or_default()),
); );
for image in images { for image in images {
let Some(path) = image_path(&image) else { let Some(path) = image_path(&image) else {
continue; continue;
}; };
let title = image_title(&image); let title = image_title(&image);
let alt = image_alt(&image, title.as_deref()); let alt = image_alt(&image);
html.push_str(&format!( html.push_str(&format!(
"<a class=\"gallery-item\" href=\"{}\" data-lightbox=\"{}\"{}><img src=\"{}\" alt=\"{}\" loading=\"lazy\" /></a>", "<a class=\"gallery-item\" href=\"{}\" data-lightbox=\"{}\"{}><img src=\"{}\" alt=\"{}\" loading=\"lazy\" /></a>",
escape_html_attr(&path), escape_html_attr(&path),
escape_html_attr(gallery_id), escape_html_attr(&gallery_id),
title title
.as_deref() .as_deref()
.map(|value| format!(" data-title=\"{}\"", escape_html_attr(value))) .map(|value| format!(" data-title=\"{}\"", escape_html_attr(value)))
@@ -232,11 +234,22 @@ fn render_gallery(args: &HashMap<String, JsonValue>, context: &MacroRenderContex
escape_html_attr(&alt), escape_html_attr(&alt),
)); ));
} }
html.push_str("</div></section>"); 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 html
} }
fn render_youtube(args: &HashMap<String, JsonValue>) -> String { fn render_youtube(args: &HashMap<String, JsonValue>, context: &MacroRenderContext) -> String {
let video_id = args.get("id").map(stringify_scalar).unwrap_or_default(); let video_id = args.get("id").map(stringify_scalar).unwrap_or_default();
if video_id.is_empty() { if video_id.is_empty() {
return empty_block( return empty_block(
@@ -245,54 +258,74 @@ fn render_youtube(args: &HashMap<String, JsonValue>) -> String {
"Missing YouTube video id.", "Missing YouTube video id.",
); );
} }
let title = macro_title(args, context, "render.video.youtubeTitle");
format!( format!(
"<section class=\"macro-youtube\"><iframe src=\"https://www.youtube.com/embed/{}\" title=\"YouTube video\" loading=\"lazy\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture\" allowfullscreen></iframe></section>", "<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(&video_id),
escape_html_attr(&title),
) )
} }
fn render_vimeo(args: &HashMap<String, JsonValue>) -> String { fn render_vimeo(args: &HashMap<String, JsonValue>, context: &MacroRenderContext) -> String {
let video_id = args.get("id").map(stringify_scalar).unwrap_or_default(); let video_id = args.get("id").map(stringify_scalar).unwrap_or_default();
if video_id.is_empty() { if video_id.is_empty() {
return empty_block("macro-vimeo", "gallery-empty", "Missing Vimeo video id."); return empty_block("macro-vimeo", "gallery-empty", "Missing Vimeo video id.");
} }
let title = macro_title(args, context, "render.video.vimeoTitle");
format!( format!(
"<section class=\"macro-vimeo\"><iframe src=\"https://player.vimeo.com/video/{}\" title=\"Vimeo video\" loading=\"lazy\" allow=\"autoplay; fullscreen; picture-in-picture\" allowfullscreen></iframe></section>", "<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(&video_id),
escape_html_attr(&title),
) )
} }
fn render_photo_archive(args: &HashMap<String, JsonValue>, context: &MacroRenderContext) -> String { fn render_photo_archive(args: &HashMap<String, JsonValue>, context: &MacroRenderContext) -> String {
let media = args let mut media = args
.get("media") .get("media")
.and_then(JsonValue::as_array) .and_then(JsonValue::as_array)
.cloned() .cloned()
.or_else(|| linked_media(context)); .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 Some(media) = media else {
return empty_block(
"macro-photo-archive",
"photo-archive-empty",
"No photos available.",
);
};
if media.is_empty() { if media.is_empty() {
return empty_block( return empty_block(
"macro-photo-archive", "macro-photo-archive",
"photo-archive-empty", "photo-archive-empty",
"No photos available.", &render_translation(context, "render.photoArchive.empty"),
); );
} }
let mut grouped = BTreeMap::<String, Vec<JsonValue>>::new(); let mut grouped = BTreeMap::<(i32, u32), Vec<JsonValue>>::new();
for item in media { for item in media {
let bucket = item if let Some(bucket) = media_archive_month(&item) {
.get("file_path")
.and_then(JsonValue::as_str)
.and_then(month_bucket)
.unwrap_or_else(|| "Archive".to_string());
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 single_month = grouped.len() == 1;
let mut html = format!( let mut html = format!(
@@ -303,21 +336,28 @@ fn render_photo_archive(args: &HashMap<String, JsonValue>, context: &MacroRender
"" ""
} }
); );
for (bucket, items) in grouped.into_iter().rev() { let month_limit = if year.is_none() { 10 } else { usize::MAX };
html.push_str("<section class=\"photo-archive-month\">"); 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(
"<section class=\"photo-archive-month-wrapper\"><div class=\"photo-archive-month\">",
);
html.push_str(&format!( html.push_str(&format!(
"<header class=\"photo-archive-month-label\"><span>{}</span></header>", "<header class=\"photo-archive-month-label\"><span>{}</span></header>",
escape_html(&bucket), escape_html(&label),
)); ));
html.push_str("<div class=\"photo-archive-gallery\">"); html.push_str("<div class=\"photo-archive-gallery\">");
for item in items { for item in items {
let Some(path) = image_path(&item) else { let Some(path) = image_path(&item) else {
continue; continue;
}; };
let title = image_title(&item); let title = image_archive_title(&item);
let alt = image_alt(&item, title.as_deref()); let alt = image_alt(&item);
html.push_str(&format!( html.push_str(&format!(
"<a class=\"photo-archive-item\" href=\"{}\"{}><img src=\"{}\" alt=\"{}\" loading=\"lazy\" /></a>", "<a class=\"photo-archive-item\" href=\"{}\" data-lightbox=\"{year}-{month}\"{}><img src=\"{}\" alt=\"{}\" loading=\"lazy\" /></a>",
escape_html_attr(&path), escape_html_attr(&path),
title title
.as_deref() .as_deref()
@@ -327,7 +367,7 @@ fn render_photo_archive(args: &HashMap<String, JsonValue>, context: &MacroRender
escape_html_attr(&alt), escape_html_attr(&alt),
)); ));
} }
html.push_str("</div></section>"); html.push_str("</div></div></section>");
} }
html.push_str("</div></section>"); html.push_str("</div></section>");
html html
@@ -341,21 +381,37 @@ fn render_tag_cloud(args: &HashMap<String, JsonValue>, context: &MacroRenderCont
.or_else(|| tag_items(context)); .or_else(|| tag_items(context));
let Some(tags) = tags else { let Some(tags) = tags else {
return empty_block("macro-tag-cloud", "tag-cloud-empty", "No tags available."); return empty_block(
"macro-tag-cloud",
"tag-cloud-empty",
&render_translation(context, "render.tagCloud.empty"),
);
}; };
if tags.is_empty() { if tags.is_empty() {
return empty_block("macro-tag-cloud", "tag-cloud-empty", "No tags available."); return empty_block(
"macro-tag-cloud",
"tag-cloud-empty",
&render_translation(context, "render.tagCloud.empty"),
);
} }
let words = build_tag_cloud_words(&tags, context); let words = build_tag_cloud_words(&tags, context);
if words.is_empty() { if words.is_empty() {
return empty_block("macro-tag-cloud", "tag-cloud-empty", "No tags available."); 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()); let words_json = serde_json::to_string(&words).unwrap_or_else(|_| "[]".to_string());
format!( format!(
"<section class=\"macro-tag-cloud\" data-tag-cloud=\"true\" data-color-distribution=\"quantile\" data-color-theme=\"pico\" data-color-easing=\"0.7\" data-width=\"900\" data-height=\"420\" data-orientation=\"mixed-diagonal\" data-tag-cloud-words='{}'><svg class=\"tag-cloud-canvas\" aria-label=\"Tag cloud\"></svg></section>", "<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>",
escape_html_attr(&words_json), escape_html_attr(&words_json),
escape_html_attr(&render_translation(context, "render.tagCloud.ariaLabel")),
) )
} }
@@ -371,7 +427,8 @@ fn build_tag_cloud_words(tags: &[JsonValue], context: &MacroRenderContext) -> Ve
.max() .max()
.unwrap_or(min_count.max(1)); .unwrap_or(min_count.max(1));
tags.iter() let mut words = tags
.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 slug = tag
@@ -381,9 +438,9 @@ fn build_tag_cloud_words(tags: &[JsonValue], context: &MacroRenderContext) -> Ve
.unwrap_or_else(|| name.to_lowercase().replace(' ', "-")); .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 {
36.0 35.0
} else { } else {
18.0 + (((count - min_count) as f64 / (max_count - min_count) as f64) * 38.0) 14.0 + (((count - min_count) as f64 / (max_count - min_count) as f64) * 42.0)
}; };
let color = tag let color = tag
.get("color") .get("color")
@@ -398,25 +455,130 @@ fn build_tag_cloud_words(tags: &[JsonValue], context: &MacroRenderContext) -> Ve
.and_then(JsonValue::as_str) .and_then(JsonValue::as_str)
.map(ToOwned::to_owned) .map(ToOwned::to_owned)
}); });
Some(serde_json::json!({ let mut word = Map::from_iter([
"text": name, ("text".into(), JsonValue::String(name.into())),
"count": count, ("count".into(), JsonValue::from(count)),
"url": format!("/tag/{slug}"), ("url".into(), JsonValue::String(format!("/tag/{slug}/"))),
"size": size.round(), ("size".into(), JsonValue::from(size.round() as u64)),
"color": color, ]);
})) if let Some(color) = color.filter(|color| !color.is_empty()) {
word.insert("color".into(), JsonValue::String(color));
}
Some(JsonValue::Object(word))
}) })
.collect() .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 linked_media(context: &MacroRenderContext) -> Option<Vec<JsonValue>> { fn linked_media(context: &MacroRenderContext) -> Option<Vec<JsonValue>> {
lookup_path("post.linked_media", context).and_then(|value| value.as_array().cloned()) 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>> { fn tag_items(context: &MacroRenderContext) -> Option<Vec<JsonValue>> {
lookup_path("post_tags", context) lookup_path("Tags", context)
.and_then(|value| value.as_array().cloned()) .and_then(|value| value.as_array().cloned())
.or_else(|| 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> { fn image_path(image: &JsonValue) -> Option<String> {
@@ -439,22 +601,62 @@ fn image_title(image: &JsonValue) -> Option<String> {
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.map(ToOwned::to_owned) .map(ToOwned::to_owned)
}) })
.or_else(|| image_name(image))
} }
fn image_alt(image: &JsonValue, fallback: Option<&str>) -> String { 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 image
.get("alt") .get("alt")
.and_then(JsonValue::as_str) .and_then(JsonValue::as_str)
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.map(ToOwned::to_owned) .map(ToOwned::to_owned)
.or_else(|| fallback.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() .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> { fn value_as_u64(value: &JsonValue) -> Option<u64> {
value value
.as_u64() .as_u64()
.or_else(|| value.as_i64().map(|number| number.max(0) 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 { fn stringify_scalar(value: &JsonValue) -> String {
@@ -467,11 +669,11 @@ fn stringify_scalar(value: &JsonValue) -> String {
} }
} }
fn month_bucket(path: &str) -> Option<String> { fn month_bucket(path: &str) -> Option<(i32, u32)> {
let segments = path.trim_matches('/').split('/').collect::<Vec<_>>(); let segments = path.trim_matches('/').split('/').collect::<Vec<_>>();
match segments.as_slice() { match segments.as_slice() {
["media", year, month, ..] if year.len() == 4 && month.len() == 2 => { ["media", year, month, ..] if year.len() == 4 && month.len() == 2 => {
Some(format!("{year}-{month}")) Some((year.parse().ok()?, month.parse().ok()?))
} }
_ => None, _ => None,
} }
@@ -537,7 +739,7 @@ mod tests {
); );
assert!(html.contains("macro-gallery gallery-cols-2")); assert!(html.contains("macro-gallery gallery-cols-2"));
assert!(html.contains("data-lightbox=\"post-1\"")); assert!(html.contains("data-lightbox=\"gallery-post-1\""));
assert!(html.contains("macro-tag-cloud")); assert!(html.contains("macro-tag-cloud"));
assert!(html.contains("data-tag-cloud=\"true\"")); assert!(html.contains("data-tag-cloud=\"true\""));
} }

View File

@@ -196,9 +196,17 @@ impl Filter for MarkdownFilter {
}; };
let macro_context = MacroRenderContext { let macro_context = MacroRenderContext {
roots: collect_macro_roots(runtime), roots: collect_macro_roots(runtime),
post_id: runtime post_id: args
.post_id
.as_ref()
.and_then(|value| value.as_scalar().map(|scalar| scalar.to_kstr().to_string()))
.or_else(|| {
runtime
.try_get(&[ScalarCow::new("post"), ScalarCow::new("id")]) .try_get(&[ScalarCow::new("post"), ScalarCow::new("id")])
.and_then(|value| value.as_scalar().map(|scalar| scalar.to_kstr().to_string())), .and_then(|value| {
value.as_scalar().map(|scalar| scalar.to_kstr().to_string())
})
}),
host: Arc::clone(&self.host), host: Arc::clone(&self.host),
}; };
@@ -216,6 +224,7 @@ fn collect_macro_roots(runtime: &dyn Runtime) -> JsonMap<String, JsonValue> {
for key in [ for key in [
"post", "post",
"post_data_json_by_id",
"post_tags", "post_tags",
"tag_color_by_name", "tag_color_by_name",
"project", "project",

View File

@@ -182,9 +182,12 @@ fn build_site_render_artifacts_with_mode(
let category_settings = queries_category_settings(data_dir)?; let category_settings = queries_category_settings(data_dir)?;
let media_items = queries::media::list_media_by_project(conn, project_id).unwrap_or_default(); let media_items = queries::media::list_media_by_project(conn, project_id).unwrap_or_default();
let media_by_id = media_items let media_by_id = media_items
.into_iter() .iter()
.cloned()
.map(|media| (media.id.clone(), media)) .map(|media| (media.id.clone(), media))
.collect::<HashMap<_, _>>(); .collect::<HashMap<_, _>>();
let project_media = media_items.iter().map(media_context).collect::<Vec<_>>();
let project_tags = build_published_tag_counts(published_posts, &tags);
let mut artifacts = SiteRenderArtifacts::default(); let mut artifacts = SiteRenderArtifacts::default();
for language in languages { for language in languages {
@@ -212,7 +215,10 @@ fn build_site_render_artifacts_with_mode(
url_path: route.url_path.clone(), url_path: route.url_path.clone(),
html: String::new(), html: String::new(),
})); }));
let post_data_json_by_id = build_post_data_json_by_id(&localized_posts); let linked_media_by_post_id =
build_linked_media_by_post_id(conn, &localized_posts, &media_by_id);
let post_data_json_by_id =
build_post_data_json_by_id(&localized_posts, &linked_media_by_post_id);
let menu_items = build_menu_items(data_dir, &language, &main_language)?; let menu_items = build_menu_items(data_dir, &language, &main_language)?;
let rendered_list_pages = routes let rendered_list_pages = routes
.par_iter() .par_iter()
@@ -232,6 +238,8 @@ fn build_site_render_artifacts_with_mode(
&category_settings, &category_settings,
&menu_items, &menu_items,
&post_data_json_by_id, &post_data_json_by_id,
&project_media,
&project_tags,
&bundle, &bundle,
is_preview, is_preview,
) )
@@ -319,10 +327,12 @@ fn build_site_render_artifacts_with_mode(
&localized_posts, &localized_posts,
&tags, &tags,
&category_settings, &category_settings,
&media_by_id, &linked_media_by_post_id,
&canonical_map, &canonical_map,
&menu_items, &menu_items,
&post_data_json_by_id, &post_data_json_by_id,
&project_media,
&project_tags,
&bundle, &bundle,
is_preview, is_preview,
)?; )?;
@@ -795,6 +805,8 @@ fn render_list_route(
category_settings: &HashMap<String, CategorySettings>, category_settings: &HashMap<String, CategorySettings>,
menu_items: &[Value], menu_items: &[Value],
post_data_json_by_id: &HashMap<String, Value>, post_data_json_by_id: &HashMap<String, Value>,
project_media: &[Value],
project_tags: &[Value],
bundle: &TemplateBundle, bundle: &TemplateBundle,
is_preview: bool, is_preview: bool,
) -> Result<String, Box<dyn Error + Send + Sync>> { ) -> Result<String, Box<dyn Error + Send + Sync>> {
@@ -839,6 +851,8 @@ fn render_list_route(
"canonical_post_path_by_slug": canonical_map, "canonical_post_path_by_slug": canonical_map,
"canonical_media_path_by_source_path": canonical_media_paths(posts), "canonical_media_path_by_source_path": canonical_media_paths(posts),
"post_data_json_by_id": post_data_json_by_id, "post_data_json_by_id": post_data_json_by_id,
"project": { "media": project_media },
"Tags": project_tags,
"post_categories": taxonomy_counts.0, "post_categories": taxonomy_counts.0,
"post_tags": taxonomy_counts.1, "post_tags": taxonomy_counts.1,
"tag_color_by_name": taxonomy_counts.2, "tag_color_by_name": taxonomy_counts.2,
@@ -865,10 +879,12 @@ fn render_post_route(
all_posts: &[RenderPostRecord], all_posts: &[RenderPostRecord],
tags: &[Tag], tags: &[Tag],
category_settings: &HashMap<String, CategorySettings>, category_settings: &HashMap<String, CategorySettings>,
media_by_id: &HashMap<String, Media>, linked_media_by_post_id: &HashMap<String, Vec<Value>>,
canonical_post_path_by_slug: &HashMap<String, String>, canonical_post_path_by_slug: &HashMap<String, String>,
menu_items: &[Value], menu_items: &[Value],
post_data_json_by_id: &HashMap<String, Value>, post_data_json_by_id: &HashMap<String, Value>,
project_media: &[Value],
project_tags: &[Value],
bundle: &TemplateBundle, bundle: &TemplateBundle,
is_preview: bool, is_preview: bool,
) -> Result<String, Box<dyn Error + Send + Sync>> { ) -> Result<String, Box<dyn Error + Send + Sync>> {
@@ -897,12 +913,10 @@ fn render_post_route(
.or_else(|| resolved.content.clone()) .or_else(|| resolved.content.clone())
.unwrap_or_else(|| STARTER_SINGLE_POST_TEMPLATE.to_string()); .unwrap_or_else(|| STARTER_SINGLE_POST_TEMPLATE.to_string());
let linked_media = queries::post_media::list_post_media_by_post(conn, &record.source_post_id) let linked_media = linked_media_by_post_id
.unwrap_or_default() .get(&record.post.id)
.into_iter() .cloned()
.filter_map(|link| media_by_id.get(&link.media_id)) .unwrap_or_default();
.map(media_context)
.collect::<Vec<_>>();
let outgoing_links = let outgoing_links =
queries::post_link::list_links_by_source(conn, &record.source_post_id).unwrap_or_default(); queries::post_link::list_links_by_source(conn, &record.source_post_id).unwrap_or_default();
let incoming_links = let incoming_links =
@@ -947,6 +961,8 @@ fn render_post_route(
"canonical_post_path_by_slug": canonical_post_path_by_slug, "canonical_post_path_by_slug": canonical_post_path_by_slug,
"canonical_media_path_by_source_path": canonical_media_paths(all_posts), "canonical_media_path_by_source_path": canonical_media_paths(all_posts),
"post_data_json_by_id": post_data_json_by_id, "post_data_json_by_id": post_data_json_by_id,
"project": { "media": project_media },
"Tags": project_tags,
"day_blocks": Vec::<Value>::new(), "day_blocks": Vec::<Value>::new(),
"archive_context": serde_json::Value::Null, "archive_context": serde_json::Value::Null,
"show_archive_range_heading": false, "show_archive_range_heading": false,
@@ -1238,9 +1254,34 @@ fn extract_media_refs(markdown: &str) -> Vec<String> {
refs refs
} }
fn build_post_data_json_by_id(posts: &[RenderPostRecord]) -> HashMap<String, Value> { fn build_linked_media_by_post_id(
conn: &Connection,
posts: &[RenderPostRecord],
media_by_id: &HashMap<String, Media>,
) -> HashMap<String, Vec<Value>> {
let mut result = HashMap::new();
for record in posts {
let media = queries::post_media::list_post_media_by_post(conn, &record.source_post_id)
.unwrap_or_default()
.into_iter()
.filter_map(|link| media_by_id.get(&link.media_id))
.map(media_context)
.collect();
result.insert(record.post.id.clone(), media);
}
result
}
fn build_post_data_json_by_id(
posts: &[RenderPostRecord],
linked_media_by_post_id: &HashMap<String, Vec<Value>>,
) -> HashMap<String, Value> {
posts.iter() posts.iter()
.map(|record| { .map(|record| {
let linked_media = linked_media_by_post_id
.get(&record.post.id)
.cloned()
.unwrap_or_default();
( (
record.post.id.clone(), record.post.id.clone(),
json!({ json!({
@@ -1255,12 +1296,57 @@ fn build_post_data_json_by_id(posts: &[RenderPostRecord]) -> HashMap<String, Val
"updated_at": timestamp_parts(record.post.updated_at), "updated_at": timestamp_parts(record.post.updated_at),
"tags": record.post.tags, "tags": record.post.tags,
"categories": record.post.categories, "categories": record.post.categories,
"linked_media": linked_media,
}), }),
) )
}) })
.collect() .collect()
} }
fn build_published_tag_counts(posts: &[(Post, String)], tags: &[Tag]) -> Vec<Value> {
let mut counts = HashMap::<String, usize>::new();
for (post, _) in posts {
if post.status != PostStatus::Published {
continue;
}
for name in &post.tags {
if !name.trim().is_empty() {
*counts.entry(name.trim().to_string()).or_default() += 1;
}
}
}
let mut items = counts
.into_iter()
.map(|(name, count)| {
let color = tags
.iter()
.find(|tag| tag.name.eq_ignore_ascii_case(&name))
.and_then(|tag| tag.color.clone())
.filter(|color| !color.is_empty());
json!({
"name": name,
"slug": slugify(&name),
"color": color,
"post_count": count,
})
})
.collect::<Vec<_>>();
items.sort_by(|left, right| {
right["post_count"]
.as_u64()
.cmp(&left["post_count"].as_u64())
.then_with(|| {
left["name"]
.as_str()
.unwrap_or_default()
.to_lowercase()
.cmp(&right["name"].as_str().unwrap_or_default().to_lowercase())
})
});
items
}
fn build_taxonomy_counts( fn build_taxonomy_counts(
posts: &[RenderPostRecord], posts: &[RenderPostRecord],
tags: &[Tag], tags: &[Tag],
@@ -1411,6 +1497,7 @@ fn media_context(media: &Media) -> Value {
"width": media.width, "width": media.width,
"height": media.height, "height": media.height,
"file_path": canonical_media_path(media), "file_path": canonical_media_path(media),
"created_at": media.created_at,
}) })
} }