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