Fix project macro rendering in preview and generation
This commit is contained in:
@@ -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(""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]
|
#[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();
|
||||||
|
|||||||
@@ -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,53 +258,73 @@ 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")
|
grouped.entry(bucket).or_default().push(item);
|
||||||
.and_then(JsonValue::as_str)
|
}
|
||||||
.and_then(month_bucket)
|
}
|
||||||
.unwrap_or_else(|| "Archive".to_string());
|
if grouped.is_empty() {
|
||||||
grouped.entry(bucket).or_default().push(item);
|
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;
|
||||||
@@ -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\""));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
.try_get(&[ScalarCow::new("post"), ScalarCow::new("id")])
|
.post_id
|
||||||
.and_then(|value| value.as_scalar().map(|scalar| scalar.to_kstr().to_string())),
|
.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),
|
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",
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user