diff --git a/crates/bds-core/src/engine/preview.rs b/crates/bds-core/src/engine/preview.rs index 1e1d973..472dfbd 100644 --- a/crates/bds-core/src/engine/preview.rs +++ b/crates/bds-core/src/engine/preview.rs @@ -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("world")); } + #[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(); diff --git a/crates/bds-core/src/render/macros.rs b/crates/bds-core/src/render/macros.rs index 2d776f5..e2f0d69 100644 --- a/crates/bds-core/src/render/macros.rs +++ b/crates/bds-core/src/render/macros.rs @@ -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, @@ -60,8 +63,8 @@ fn render_macro(invocation: &str, context: &MacroRenderContext) -> Option 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, 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::>(); + 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!( - ""); html } -fn render_youtube(args: &HashMap) -> String { +fn render_youtube(args: &HashMap, 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 { "Missing YouTube video id.", ); } + let title = macro_title(args, context, "render.video.youtubeTitle"); format!( - "
", + "
", escape_html_attr(&video_id), + escape_html_attr(&title), ) } -fn render_vimeo(args: &HashMap) -> String { +fn render_vimeo(args: &HashMap, 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!( - "
", + "
", escape_html_attr(&video_id), + escape_html_attr(&title), ) } fn render_photo_archive(args: &HashMap, 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::>(); + 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::>::new(); + let mut grouped = BTreeMap::<(i32, u32), Vec>::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, context: &MacroRender "" } ); - for (bucket, items) in grouped.into_iter().rev() { - html.push_str("
"); + 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( + "
", + ); html.push_str(&format!( "
{}
", - escape_html(&bucket), + escape_html(&label), )); html.push_str("
"); 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!( - "\"{}\"", + "\"{}\"", escape_html_attr(&path), title .as_deref() @@ -327,7 +367,7 @@ fn render_photo_archive(args: &HashMap, context: &MacroRender escape_html_attr(&alt), )); } - html.push_str("
"); + html.push_str("
"); } html.push_str(""); html @@ -341,21 +381,37 @@ fn render_tag_cloud(args: &HashMap, 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!( - "
", + "
", 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::>(); + 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> { - 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> { + lookup_path("project.media", context).and_then(|value| value.as_array().cloned()) } fn tag_items(context: &MacroRenderContext) -> Option> { - 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, + 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 { @@ -439,22 +601,62 @@ fn image_title(image: &JsonValue) -> Option { .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 { + 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 { + 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 { 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 { + value_as_u64(value) + .and_then(|number| number.try_into().ok()) + .filter(|month| (1..=12).contains(month)) +} + +fn value_as_i32(value: &JsonValue) -> Option { + 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 { +fn month_bucket(path: &str) -> Option<(i32, u32)> { let segments = path.trim_matches('/').split('/').collect::>(); 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\"")); } diff --git a/crates/bds-core/src/render/page_renderer.rs b/crates/bds-core/src/render/page_renderer.rs index dd4c3f2..71491e3 100644 --- a/crates/bds-core/src/render/page_renderer.rs +++ b/crates/bds-core/src/render/page_renderer.rs @@ -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 { for key in [ "post", + "post_data_json_by_id", "post_tags", "tag_color_by_name", "project", diff --git a/crates/bds-core/src/render/site.rs b/crates/bds-core/src/render/site.rs index 5bfb2c5..6c490cb 100644 --- a/crates/bds-core/src/render/site.rs +++ b/crates/bds-core/src/render/site.rs @@ -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::>(); + let project_media = media_items.iter().map(media_context).collect::>(); + 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, menu_items: &[Value], post_data_json_by_id: &HashMap, + project_media: &[Value], + project_tags: &[Value], bundle: &TemplateBundle, is_preview: bool, ) -> Result> { @@ -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, - media_by_id: &HashMap, + linked_media_by_post_id: &HashMap>, canonical_post_path_by_slug: &HashMap, menu_items: &[Value], post_data_json_by_id: &HashMap, + project_media: &[Value], + project_tags: &[Value], bundle: &TemplateBundle, is_preview: bool, ) -> Result> { @@ -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::>(); + 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::::new(), "archive_context": serde_json::Value::Null, "show_archive_range_heading": false, @@ -1238,9 +1254,34 @@ fn extract_media_refs(markdown: &str) -> Vec { refs } -fn build_post_data_json_by_id(posts: &[RenderPostRecord]) -> HashMap { +fn build_linked_media_by_post_id( + conn: &Connection, + posts: &[RenderPostRecord], + media_by_id: &HashMap, +) -> HashMap> { + 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>, +) -> HashMap { 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 Vec { + let mut counts = HashMap::::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::>(); + 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, }) }