Fix project macro rendering in preview and generation
This commit is contained in:
@@ -654,7 +654,9 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::db::queries;
|
||||
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};
|
||||
|
||||
fn preview_port_guard() -> &'static Mutex<()> {
|
||||
@@ -810,6 +812,167 @@ mod tests {
|
||||
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(""text":"Rust""));
|
||||
assert!(rendered.contains(""color":"#ff6600""));
|
||||
assert!(rendered.contains(""size":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]
|
||||
fn preview_renders_page_category_post_at_flat_path() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
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;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct MacroRenderContext {
|
||||
pub roots: Map<String, JsonValue>,
|
||||
@@ -60,8 +63,8 @@ fn render_macro(invocation: &str, context: &MacroRenderContext) -> Option<String
|
||||
|
||||
match name {
|
||||
"gallery" => Some(render_gallery(&args, context)),
|
||||
"youtube" => Some(render_youtube(&args)),
|
||||
"vimeo" => Some(render_vimeo(&args)),
|
||||
"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),
|
||||
@@ -189,41 +192,40 @@ fn render_gallery(args: &HashMap<String, JsonValue>, context: &MacroRenderContex
|
||||
.and_then(value_as_u64)
|
||||
.map(|value| value.clamp(1, 6) as usize)
|
||||
.unwrap_or(3);
|
||||
let images = args
|
||||
let mut images = args
|
||||
.get("images")
|
||||
.and_then(JsonValue::as_array)
|
||||
.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() {
|
||||
return empty_block(
|
||||
&format!("macro-gallery gallery-cols-{columns}"),
|
||||
"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!(
|
||||
"<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 {
|
||||
let Some(path) = image_path(&image) else {
|
||||
continue;
|
||||
};
|
||||
let title = image_title(&image);
|
||||
let alt = image_alt(&image, title.as_deref());
|
||||
let alt = image_alt(&image);
|
||||
html.push_str(&format!(
|
||||
"<a class=\"gallery-item\" href=\"{}\" data-lightbox=\"{}\"{}><img src=\"{}\" alt=\"{}\" loading=\"lazy\" /></a>",
|
||||
escape_html_attr(&path),
|
||||
escape_html_attr(gallery_id),
|
||||
escape_html_attr(&gallery_id),
|
||||
title
|
||||
.as_deref()
|
||||
.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),
|
||||
));
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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();
|
||||
if video_id.is_empty() {
|
||||
return empty_block(
|
||||
@@ -245,53 +258,73 @@ fn render_youtube(args: &HashMap<String, JsonValue>) -> String {
|
||||
"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/{}\" 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(&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();
|
||||
if video_id.is_empty() {
|
||||
return empty_block("macro-vimeo", "gallery-empty", "Missing Vimeo video id.");
|
||||
}
|
||||
let title = macro_title(args, context, "render.video.vimeoTitle");
|
||||
format!(
|
||||
"<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(&title),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_photo_archive(args: &HashMap<String, JsonValue>, context: &MacroRenderContext) -> String {
|
||||
let media = args
|
||||
let mut media = args
|
||||
.get("media")
|
||||
.and_then(JsonValue::as_array)
|
||||
.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() {
|
||||
return empty_block(
|
||||
"macro-photo-archive",
|
||||
"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 {
|
||||
let bucket = 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);
|
||||
if let Some(bucket) = media_archive_month(&item) {
|
||||
grouped.entry(bucket).or_default().push(item);
|
||||
}
|
||||
}
|
||||
if grouped.is_empty() {
|
||||
return empty_block(
|
||||
"macro-photo-archive",
|
||||
"photo-archive-empty",
|
||||
&render_translation(context, "render.photoArchive.empty"),
|
||||
);
|
||||
}
|
||||
|
||||
let single_month = grouped.len() == 1;
|
||||
@@ -303,21 +336,28 @@ fn render_photo_archive(args: &HashMap<String, JsonValue>, context: &MacroRender
|
||||
""
|
||||
}
|
||||
);
|
||||
for (bucket, items) in grouped.into_iter().rev() {
|
||||
html.push_str("<section class=\"photo-archive-month\">");
|
||||
let month_limit = if year.is_none() { 10 } else { usize::MAX };
|
||||
for ((year, month), items) in grouped.into_iter().rev().take(month_limit) {
|
||||
let label = format!(
|
||||
"{} {year}",
|
||||
render_translation(context, &format!("render.month.{month}"))
|
||||
);
|
||||
html.push_str(
|
||||
"<section class=\"photo-archive-month-wrapper\"><div class=\"photo-archive-month\">",
|
||||
);
|
||||
html.push_str(&format!(
|
||||
"<header class=\"photo-archive-month-label\"><span>{}</span></header>",
|
||||
escape_html(&bucket),
|
||||
escape_html(&label),
|
||||
));
|
||||
html.push_str("<div class=\"photo-archive-gallery\">");
|
||||
for item in items {
|
||||
let Some(path) = image_path(&item) else {
|
||||
continue;
|
||||
};
|
||||
let title = image_title(&item);
|
||||
let alt = image_alt(&item, title.as_deref());
|
||||
let title = image_archive_title(&item);
|
||||
let alt = image_alt(&item);
|
||||
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),
|
||||
title
|
||||
.as_deref()
|
||||
@@ -327,7 +367,7 @@ fn render_photo_archive(args: &HashMap<String, JsonValue>, context: &MacroRender
|
||||
escape_html_attr(&alt),
|
||||
));
|
||||
}
|
||||
html.push_str("</div></section>");
|
||||
html.push_str("</div></div></section>");
|
||||
}
|
||||
html.push_str("</div></section>");
|
||||
html
|
||||
@@ -341,21 +381,37 @@ fn render_tag_cloud(args: &HashMap<String, JsonValue>, context: &MacroRenderCont
|
||||
.or_else(|| tag_items(context));
|
||||
|
||||
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() {
|
||||
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);
|
||||
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());
|
||||
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(&render_translation(context, "render.tagCloud.ariaLabel")),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -371,7 +427,8 @@ fn build_tag_cloud_words(tags: &[JsonValue], context: &MacroRenderContext) -> Ve
|
||||
.max()
|
||||
.unwrap_or(min_count.max(1));
|
||||
|
||||
tags.iter()
|
||||
let mut words = tags
|
||||
.iter()
|
||||
.filter_map(|tag| {
|
||||
let name = tag.get("name").and_then(JsonValue::as_str)?;
|
||||
let slug = tag
|
||||
@@ -381,9 +438,9 @@ fn build_tag_cloud_words(tags: &[JsonValue], context: &MacroRenderContext) -> Ve
|
||||
.unwrap_or_else(|| name.to_lowercase().replace(' ', "-"));
|
||||
let count = tag.get("post_count").and_then(value_as_u64).unwrap_or(1);
|
||||
let size = if max_count == min_count {
|
||||
36.0
|
||||
35.0
|
||||
} 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
|
||||
.get("color")
|
||||
@@ -398,25 +455,130 @@ fn build_tag_cloud_words(tags: &[JsonValue], context: &MacroRenderContext) -> Ve
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
});
|
||||
Some(serde_json::json!({
|
||||
"text": name,
|
||||
"count": count,
|
||||
"url": format!("/tag/{slug}"),
|
||||
"size": size.round(),
|
||||
"color": color,
|
||||
}))
|
||||
let mut word = Map::from_iter([
|
||||
("text".into(), JsonValue::String(name.into())),
|
||||
("count".into(), JsonValue::from(count)),
|
||||
("url".into(), JsonValue::String(format!("/tag/{slug}/"))),
|
||||
("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()
|
||||
.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>> {
|
||||
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>> {
|
||||
lookup_path("post_tags", context)
|
||||
lookup_path("Tags", context)
|
||||
.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> {
|
||||
@@ -439,22 +601,62 @@ fn image_title(image: &JsonValue) -> Option<String> {
|
||||
.filter(|value| !value.is_empty())
|
||||
.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
|
||||
.get("alt")
|
||||
.and_then(JsonValue::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.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()
|
||||
}
|
||||
|
||||
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().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 {
|
||||
@@ -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<_>>();
|
||||
match segments.as_slice() {
|
||||
["media", year, month, ..] if year.len() == 4 && month.len() == 2 => {
|
||||
Some(format!("{year}-{month}"))
|
||||
Some((year.parse().ok()?, month.parse().ok()?))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
@@ -537,7 +739,7 @@ mod tests {
|
||||
);
|
||||
|
||||
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("data-tag-cloud=\"true\""));
|
||||
}
|
||||
|
||||
@@ -196,9 +196,17 @@ impl Filter for MarkdownFilter {
|
||||
};
|
||||
let macro_context = MacroRenderContext {
|
||||
roots: collect_macro_roots(runtime),
|
||||
post_id: runtime
|
||||
.try_get(&[ScalarCow::new("post"), ScalarCow::new("id")])
|
||||
.and_then(|value| value.as_scalar().map(|scalar| scalar.to_kstr().to_string())),
|
||||
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")])
|
||||
.and_then(|value| {
|
||||
value.as_scalar().map(|scalar| scalar.to_kstr().to_string())
|
||||
})
|
||||
}),
|
||||
host: Arc::clone(&self.host),
|
||||
};
|
||||
|
||||
@@ -216,6 +224,7 @@ fn collect_macro_roots(runtime: &dyn Runtime) -> JsonMap<String, JsonValue> {
|
||||
|
||||
for key in [
|
||||
"post",
|
||||
"post_data_json_by_id",
|
||||
"post_tags",
|
||||
"tag_color_by_name",
|
||||
"project",
|
||||
|
||||
@@ -182,9 +182,12 @@ fn build_site_render_artifacts_with_mode(
|
||||
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_by_id = media_items
|
||||
.into_iter()
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|media| (media.id.clone(), media))
|
||||
.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();
|
||||
for language in languages {
|
||||
@@ -212,7 +215,10 @@ fn build_site_render_artifacts_with_mode(
|
||||
url_path: route.url_path.clone(),
|
||||
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 rendered_list_pages = routes
|
||||
.par_iter()
|
||||
@@ -232,6 +238,8 @@ fn build_site_render_artifacts_with_mode(
|
||||
&category_settings,
|
||||
&menu_items,
|
||||
&post_data_json_by_id,
|
||||
&project_media,
|
||||
&project_tags,
|
||||
&bundle,
|
||||
is_preview,
|
||||
)
|
||||
@@ -319,10 +327,12 @@ fn build_site_render_artifacts_with_mode(
|
||||
&localized_posts,
|
||||
&tags,
|
||||
&category_settings,
|
||||
&media_by_id,
|
||||
&linked_media_by_post_id,
|
||||
&canonical_map,
|
||||
&menu_items,
|
||||
&post_data_json_by_id,
|
||||
&project_media,
|
||||
&project_tags,
|
||||
&bundle,
|
||||
is_preview,
|
||||
)?;
|
||||
@@ -795,6 +805,8 @@ fn render_list_route(
|
||||
category_settings: &HashMap<String, CategorySettings>,
|
||||
menu_items: &[Value],
|
||||
post_data_json_by_id: &HashMap<String, Value>,
|
||||
project_media: &[Value],
|
||||
project_tags: &[Value],
|
||||
bundle: &TemplateBundle,
|
||||
is_preview: bool,
|
||||
) -> Result<String, Box<dyn Error + Send + Sync>> {
|
||||
@@ -839,6 +851,8 @@ fn render_list_route(
|
||||
"canonical_post_path_by_slug": canonical_map,
|
||||
"canonical_media_path_by_source_path": canonical_media_paths(posts),
|
||||
"post_data_json_by_id": post_data_json_by_id,
|
||||
"project": { "media": project_media },
|
||||
"Tags": project_tags,
|
||||
"post_categories": taxonomy_counts.0,
|
||||
"post_tags": taxonomy_counts.1,
|
||||
"tag_color_by_name": taxonomy_counts.2,
|
||||
@@ -865,10 +879,12 @@ fn render_post_route(
|
||||
all_posts: &[RenderPostRecord],
|
||||
tags: &[Tag],
|
||||
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>,
|
||||
menu_items: &[Value],
|
||||
post_data_json_by_id: &HashMap<String, Value>,
|
||||
project_media: &[Value],
|
||||
project_tags: &[Value],
|
||||
bundle: &TemplateBundle,
|
||||
is_preview: bool,
|
||||
) -> Result<String, Box<dyn Error + Send + Sync>> {
|
||||
@@ -897,12 +913,10 @@ fn render_post_route(
|
||||
.or_else(|| resolved.content.clone())
|
||||
.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)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|link| media_by_id.get(&link.media_id))
|
||||
.map(media_context)
|
||||
.collect::<Vec<_>>();
|
||||
let linked_media = linked_media_by_post_id
|
||||
.get(&record.post.id)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let outgoing_links =
|
||||
queries::post_link::list_links_by_source(conn, &record.source_post_id).unwrap_or_default();
|
||||
let incoming_links =
|
||||
@@ -947,6 +961,8 @@ fn render_post_route(
|
||||
"canonical_post_path_by_slug": canonical_post_path_by_slug,
|
||||
"canonical_media_path_by_source_path": canonical_media_paths(all_posts),
|
||||
"post_data_json_by_id": post_data_json_by_id,
|
||||
"project": { "media": project_media },
|
||||
"Tags": project_tags,
|
||||
"day_blocks": Vec::<Value>::new(),
|
||||
"archive_context": serde_json::Value::Null,
|
||||
"show_archive_range_heading": false,
|
||||
@@ -1238,9 +1254,34 @@ fn extract_media_refs(markdown: &str) -> Vec<String> {
|
||||
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()
|
||||
.map(|record| {
|
||||
let linked_media = linked_media_by_post_id
|
||||
.get(&record.post.id)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
(
|
||||
record.post.id.clone(),
|
||||
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),
|
||||
"tags": record.post.tags,
|
||||
"categories": record.post.categories,
|
||||
"linked_media": linked_media,
|
||||
}),
|
||||
)
|
||||
})
|
||||
.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(
|
||||
posts: &[RenderPostRecord],
|
||||
tags: &[Tag],
|
||||
@@ -1411,6 +1497,7 @@ fn media_context(media: &Media) -> Value {
|
||||
"width": media.width,
|
||||
"height": media.height,
|
||||
"file_path": canonical_media_path(media),
|
||||
"created_at": media.created_at,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user