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