1use crate::blog::config::BlogConfig;
4use crate::blog::types::{
5 Author, BlogManifest, BlogPost, BlogSearchEntry, calculate_reading_time,
6 extract_blog_frontmatter,
7};
8use crate::config::ThemeConfig;
9use dioxus_mdx::{get_raw_markdown, parse_mdx};
10use std::collections::HashMap;
11
12pub struct BlogRegistry {
16 posts: Vec<BlogPost>,
18 authors: HashMap<String, Author>,
20 all_tags: Vec<String>,
22 search_index: Vec<BlogSearchEntry>,
24 featured_indices: Vec<usize>,
26 pub posts_per_page: usize,
28 pub date_format: String,
30 pub theme: Option<ThemeConfig>,
32}
33
34impl BlogRegistry {
35 pub(crate) fn from_config(config: BlogConfig) -> Self {
36 let manifest: BlogManifest =
37 serde_json::from_str(config.manifest_json()).expect("Failed to parse _blog.json");
38
39 let mut posts: Vec<BlogPost> = config
40 .content_map()
41 .iter()
42 .filter(|(key, _)| **key != "__manifest__")
43 .filter_map(|(&slug, &content)| {
44 let (frontmatter, remaining) = extract_blog_frontmatter(content)?;
45
46 if frontmatter.draft {
47 return None;
48 }
49
50 let nodes = parse_mdx(remaining);
51 let raw_markdown = get_raw_markdown(&nodes);
52 let reading_time_minutes = calculate_reading_time(&raw_markdown);
53
54 Some(BlogPost {
55 slug: slug.to_string(),
56 frontmatter,
57 content: nodes,
58 raw_markdown,
59 reading_time_minutes,
60 })
61 })
62 .collect();
63
64 posts.sort_by(|a, b| b.frontmatter.date.cmp(&a.frontmatter.date));
65
66 let mut tag_set: Vec<String> = posts
67 .iter()
68 .flat_map(|p| p.frontmatter.tags.iter().cloned())
69 .collect();
70 tag_set.sort();
71 tag_set.dedup();
72
73 let featured_indices: Vec<usize> = posts
74 .iter()
75 .enumerate()
76 .filter(|(_, p)| p.frontmatter.featured)
77 .map(|(i, _)| i)
78 .collect();
79
80 let search_index = Self::build_search_index(&posts);
81
82 let posts_per_page = config.posts_per_page();
83 let date_format = config.date_format().to_string();
84 let theme = config.theme_config().cloned();
85
86 Self {
87 posts,
88 authors: manifest.authors,
89 all_tags: tag_set,
90 featured_indices,
91 search_index,
92 posts_per_page,
93 date_format,
94 theme,
95 }
96 }
97
98 pub fn get_post(&self, slug: &str) -> Option<&BlogPost> {
101 self.posts.iter().find(|p| p.slug == slug)
102 }
103
104 pub fn all_posts(&self) -> &[BlogPost] {
105 &self.posts
106 }
107
108 pub fn featured_posts(&self) -> Vec<&BlogPost> {
110 self.featured_indices
111 .iter()
112 .map(|&i| &self.posts[i])
113 .collect()
114 }
115
116 pub fn has_featured(&self) -> bool {
118 !self.featured_indices.is_empty()
119 }
120
121 pub fn posts_by_tag(&self, tag: &str) -> Vec<&BlogPost> {
122 self.posts
123 .iter()
124 .filter(|p| p.frontmatter.tags.iter().any(|t| t == tag))
125 .collect()
126 }
127
128 pub fn non_featured_posts_page(&self, page: usize) -> Vec<&BlogPost> {
130 let filtered: Vec<&BlogPost> = self
131 .posts
132 .iter()
133 .filter(|p| !p.frontmatter.featured)
134 .collect();
135 let start = page * self.posts_per_page;
136 let end = (start + self.posts_per_page).min(filtered.len());
137 if start >= filtered.len() {
138 return Vec::new();
139 }
140 filtered[start..end].to_vec()
141 }
142
143 pub fn non_featured_total_pages(&self) -> usize {
145 let count = self
146 .posts
147 .iter()
148 .filter(|p| !p.frontmatter.featured)
149 .count();
150 if count == 0 {
151 return 1;
152 }
153 count.div_ceil(self.posts_per_page)
154 }
155
156 pub fn related_posts(&self, slug: &str, max: usize) -> Vec<&BlogPost> {
161 let current = match self.get_post(slug) {
162 Some(p) => p,
163 None => return Vec::new(),
164 };
165 let current_tags: std::collections::HashSet<&str> = current
166 .frontmatter
167 .tags
168 .iter()
169 .map(|t| t.as_str())
170 .collect();
171
172 if current_tags.is_empty() {
173 return Vec::new();
174 }
175
176 let mut scored: Vec<(usize, &BlogPost)> = self
177 .posts
178 .iter()
179 .filter(|p| p.slug != slug)
180 .filter_map(|p| {
181 let overlap = p
182 .frontmatter
183 .tags
184 .iter()
185 .filter(|t| current_tags.contains(t.as_str()))
186 .count();
187 if overlap > 0 {
188 Some((overlap, p))
189 } else {
190 None
191 }
192 })
193 .collect();
194
195 scored.sort_by(|a, b| {
196 b.0.cmp(&a.0)
197 .then_with(|| b.1.frontmatter.date.cmp(&a.1.frontmatter.date))
198 });
199 scored.into_iter().take(max).map(|(_, p)| p).collect()
200 }
201
202 pub fn posts_page(&self, page: usize) -> &[BlogPost] {
203 let start = page * self.posts_per_page;
204 let end = (start + self.posts_per_page).min(self.posts.len());
205 if start >= self.posts.len() {
206 return &[];
207 }
208 &self.posts[start..end]
209 }
210
211 pub fn posts_page_by_tag(&self, tag: &str, page: usize) -> Vec<&BlogPost> {
212 let filtered = self.posts_by_tag(tag);
213 let start = page * self.posts_per_page;
214 let end = (start + self.posts_per_page).min(filtered.len());
215 if start >= filtered.len() {
216 return Vec::new();
217 }
218 filtered[start..end].to_vec()
219 }
220
221 pub fn total_pages(&self) -> usize {
222 if self.posts.is_empty() {
223 return 1;
224 }
225 self.posts.len().div_ceil(self.posts_per_page)
226 }
227
228 pub fn total_pages_for_tag(&self, tag: &str) -> usize {
229 let count = self.posts_by_tag(tag).len();
230 if count == 0 {
231 return 1;
232 }
233 count.div_ceil(self.posts_per_page)
234 }
235
236 pub fn prev_post(&self, slug: &str) -> Option<&BlogPost> {
240 let idx = self.posts.iter().position(|p| p.slug == slug)?;
241 if idx + 1 < self.posts.len() {
242 Some(&self.posts[idx + 1])
243 } else {
244 None
245 }
246 }
247
248 pub fn next_post(&self, slug: &str) -> Option<&BlogPost> {
250 let idx = self.posts.iter().position(|p| p.slug == slug)?;
251 if idx > 0 {
252 Some(&self.posts[idx - 1])
253 } else {
254 None
255 }
256 }
257
258 pub fn all_tags(&self) -> &[String] {
261 &self.all_tags
262 }
263
264 pub fn tag_count(&self, tag: &str) -> usize {
265 self.posts
266 .iter()
267 .filter(|p| p.frontmatter.tags.iter().any(|t| t == tag))
268 .count()
269 }
270
271 pub fn get_author(&self, id: &str) -> Option<&Author> {
272 self.authors.get(id)
273 }
274
275 pub fn search_posts(&self, query: &str) -> Vec<&BlogSearchEntry> {
278 let query = query.trim();
279 if query.is_empty() {
280 return Vec::new();
281 }
282 let q = query.to_lowercase();
283
284 let mut title_matches: Vec<&BlogSearchEntry> = Vec::new();
285 let mut desc_matches: Vec<&BlogSearchEntry> = Vec::new();
286 let mut content_matches: Vec<&BlogSearchEntry> = Vec::new();
287
288 for entry in &self.search_index {
289 if entry.title.to_lowercase().contains(&q) {
290 title_matches.push(entry);
291 } else if entry.description.to_lowercase().contains(&q) {
292 desc_matches.push(entry);
293 } else if entry.content_preview.to_lowercase().contains(&q) {
294 content_matches.push(entry);
295 }
296 }
297
298 title_matches.extend(desc_matches);
299 title_matches.extend(content_matches);
300 title_matches
301 }
302
303 fn build_search_index(posts: &[BlogPost]) -> Vec<BlogSearchEntry> {
304 posts
305 .iter()
306 .map(|post| {
307 let preview: String = post.raw_markdown.chars().take(200).collect();
308 BlogSearchEntry {
309 slug: post.slug.clone(),
310 title: post.frontmatter.title.clone(),
311 description: post.frontmatter.description.clone().unwrap_or_default(),
312 content_preview: preview,
313 date: post.frontmatter.date.clone(),
314 tags: post.frontmatter.tags.clone(),
315 }
316 })
317 .collect()
318 }
319
320 pub fn generate_rss(&self, site_title: &str, site_url: &str, blog_path: &str) -> String {
323 let mut rss = format!(
324 r#"<?xml version="1.0" encoding="UTF-8"?>
325<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
326<channel>
327<title>{site_title}</title>
328<link>{site_url}{blog_path}</link>
329<description>{site_title} RSS Feed</description>
330<atom:link href="{site_url}{blog_path}/rss.xml" rel="self" type="application/rss+xml"/>
331"#
332 );
333
334 for post in &self.posts {
335 let title = &post.frontmatter.title;
336 let desc = post.frontmatter.description.as_deref().unwrap_or_default();
337 let link = format!("{site_url}{blog_path}/{}", post.slug);
338 rss.push_str(&format!(
339 "<item>\n<title>{title}</title>\n<link>{link}</link>\n<description>{desc}</description>\n<pubDate>{}</pubDate>\n<guid>{link}</guid>\n</item>\n",
340 post.frontmatter.date
341 ));
342 }
343
344 rss.push_str("</channel>\n</rss>\n");
345 rss
346 }
347
348 pub fn generate_llms_txt(
349 &self,
350 site_title: &str,
351 site_description: &str,
352 base_url: &str,
353 blog_path: &str,
354 ) -> String {
355 let mut out = format!("# {site_title}\n\n> {site_description}\n\n");
356
357 for post in &self.posts {
358 let title = &post.frontmatter.title;
359 let desc = post.frontmatter.description.as_deref().unwrap_or_default();
360 let url = format!("{base_url}{blog_path}/{}", post.slug);
361 if desc.is_empty() {
362 out.push_str(&format!("- [{title}]({url})\n"));
363 } else {
364 out.push_str(&format!("- [{title}]({url}): {desc}\n"));
365 }
366 }
367
368 out
369 }
370
371 pub fn generate_sitemap(&self, site_url: &str, blog_path: &str) -> String {
375 let mut xml = String::from(
376 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
377 <urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n",
378 );
379
380 xml.push_str(&format!(
382 "<url>\n<loc>{site_url}{blog_path}</loc>\n<changefreq>weekly</changefreq>\n<priority>0.8</priority>\n</url>\n"
383 ));
384
385 for post in &self.posts {
387 let loc = format!("{site_url}{blog_path}/{}", post.slug);
388 let lastmod = &post.frontmatter.date;
389 xml.push_str(&format!(
390 "<url>\n<loc>{loc}</loc>\n<lastmod>{lastmod}</lastmod>\n<changefreq>monthly</changefreq>\n<priority>0.6</priority>\n</url>\n"
391 ));
392 }
393
394 xml.push_str("</urlset>\n");
395 xml
396 }
397
398 pub fn format_date(&self, date: &str) -> String {
401 format_date_with(date, &self.date_format)
402 }
403}
404
405pub fn format_date_with(date: &str, fmt: &str) -> String {
407 let parts: Vec<&str> = date.split('-').collect();
408 if parts.len() != 3 {
409 return date.to_string();
410 }
411
412 let year = parts[0];
413 let month = parts[1];
414 let day = parts[2];
415
416 let month_name = match month {
417 "01" => "January",
418 "02" => "February",
419 "03" => "March",
420 "04" => "April",
421 "05" => "May",
422 "06" => "June",
423 "07" => "July",
424 "08" => "August",
425 "09" => "September",
426 "10" => "October",
427 "11" => "November",
428 "12" => "December",
429 _ => month,
430 };
431
432 fmt.replace("%Y", year)
433 .replace("%m", month)
434 .replace("%d", day)
435 .replace("%B", month_name)
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441 use crate::blog::config::BlogConfig;
442 use std::collections::HashMap;
443
444 fn build_registry(posts_per_page: usize) -> BlogRegistry {
445 let manifest = r#"{
446 "authors": {
447 "author": { "name": "Author" }
448 },
449 "posts": ["featured", "regular-1", "regular-2", "regular-3", "rust-new", "rust-old", "misc"]
450 }"#;
451
452 let mut content_map = HashMap::new();
453 content_map.insert(
454 "featured",
455 r#"---
456title: "Featured"
457date: "2026-03-21"
458author: "author"
459tags: ["announcement"]
460featured: true
461---
462Featured post
463"#,
464 );
465 content_map.insert(
466 "regular-1",
467 r#"---
468title: "Regular 1"
469date: "2026-03-20"
470author: "author"
471tags: ["announcement"]
472---
473Regular one
474"#,
475 );
476 content_map.insert(
477 "regular-2",
478 r#"---
479title: "Regular 2"
480date: "2026-03-19"
481author: "author"
482tags: ["announcement"]
483---
484Regular two
485"#,
486 );
487 content_map.insert(
488 "regular-3",
489 r#"---
490title: "Regular 3"
491date: "2026-03-18"
492author: "author"
493tags: ["announcement"]
494---
495Regular three
496"#,
497 );
498 content_map.insert(
499 "rust-new",
500 r#"---
501title: "Rust New"
502date: "2026-03-17"
503author: "author"
504tags: ["rust", "web", "async"]
505---
506Rust new
507"#,
508 );
509 content_map.insert(
510 "rust-old",
511 r#"---
512title: "Rust Old"
513date: "2026-03-16"
514author: "author"
515tags: ["rust", "web"]
516---
517Rust old
518"#,
519 );
520 content_map.insert(
521 "misc",
522 r#"---
523title: "Misc"
524date: "2026-03-15"
525author: "author"
526tags: ["rust"]
527---
528Misc
529"#,
530 );
531
532 BlogConfig::new(manifest, content_map)
533 .with_posts_per_page(posts_per_page)
534 .build()
535 }
536
537 #[test]
538 fn unfiltered_pagination_excludes_featured_posts() {
539 let registry = build_registry(2);
540
541 let page_1: Vec<_> = registry
542 .non_featured_posts_page(0)
543 .into_iter()
544 .map(|post| post.slug.as_str())
545 .collect();
546 let page_2: Vec<_> = registry
547 .non_featured_posts_page(1)
548 .into_iter()
549 .map(|post| post.slug.as_str())
550 .collect();
551 let page_3: Vec<_> = registry
552 .non_featured_posts_page(2)
553 .into_iter()
554 .map(|post| post.slug.as_str())
555 .collect();
556 let page_4 = registry.non_featured_posts_page(3);
557
558 assert_eq!(page_1, vec!["regular-1", "regular-2"]);
559 assert_eq!(page_2, vec!["regular-3", "rust-new"]);
560 assert_eq!(page_3, vec!["rust-old", "misc"]);
561 assert!(page_4.is_empty());
562 assert_eq!(registry.non_featured_total_pages(), 3);
563 }
564
565 #[test]
566 fn tag_pagination_still_includes_featured_posts() {
567 let registry = build_registry(2);
568
569 let page: Vec<_> = registry
570 .posts_page_by_tag("announcement", 0)
571 .into_iter()
572 .map(|post| post.slug.as_str())
573 .collect();
574
575 assert_eq!(page, vec!["featured", "regular-1"]);
576 assert_eq!(registry.total_pages_for_tag("announcement"), 2);
577 }
578
579 #[test]
580 fn related_posts_tie_break_on_date() {
581 let registry = build_registry(10);
582
583 let related: Vec<_> = registry
584 .related_posts("misc", 3)
585 .into_iter()
586 .map(|post| post.slug.as_str())
587 .collect();
588
589 assert_eq!(related, vec!["rust-new", "rust-old"]);
590 }
591}