1use crate::blog::config::BlogConfig;
4use crate::blog::types::{
5 Author, BlogCategory, 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 categories: Vec<BlogCategory>,
25 category_base_path: Option<String>,
26 search_index: Vec<BlogSearchEntry>,
28 featured_indices: Vec<usize>,
30 pub posts_per_page: usize,
32 pub date_format: String,
34 pub theme: Option<ThemeConfig>,
36}
37
38impl BlogRegistry {
39 pub(crate) fn try_from_config(config: BlogConfig) -> Result<Self, DocsKitError> {
40 let manifest: BlogManifest = serde_json::from_str(config.manifest_json())
41 .map_err(DocsKitError::BlogManifestParse)?;
42 if config.posts_per_page() == 0 {
43 return Err(DocsKitError::BlogConfig(
44 "posts_per_page must be greater than zero".into(),
45 ));
46 }
47 if let Some(path) = config.category_base_path() {
48 super::categories::validate_base_path(path)?;
49 }
50
51 let mut posts: Vec<BlogPost> = config
52 .content_map()
53 .iter()
54 .filter(|(key, _)| **key != "__manifest__")
55 .filter_map(|(&slug, &content)| {
56 let (frontmatter, remaining) = match extract_blog_frontmatter(content) {
57 Ok(parsed) => parsed,
58 Err(e) => {
59 tracing::warn!("dioxus-docs-kit: skipping blog post \"{slug}\": {e}");
60 return None;
61 }
62 };
63
64 if frontmatter.draft {
65 return None;
66 }
67
68 let body = strip_leading_h1(remaining);
71 let nodes = parse_mdx(body);
72 let raw_markdown = get_raw_markdown(&nodes);
73 let reading_time_minutes = calculate_reading_time(&raw_markdown);
74
75 Some(BlogPost {
76 slug: slug.to_string(),
77 frontmatter,
78 content: nodes,
79 raw_markdown,
80 reading_time_minutes,
81 })
82 })
83 .collect();
84
85 posts.sort_by(|a, b| {
86 b.frontmatter
87 .date
88 .cmp(&a.frontmatter.date)
89 .then_with(|| a.slug.cmp(&b.slug))
90 });
91
92 let mut tag_set: Vec<String> = posts
93 .iter()
94 .flat_map(|p| p.frontmatter.tags.iter().cloned())
95 .collect();
96 tag_set.sort();
97 tag_set.dedup();
98
99 let categories = if config.category_base_path().is_some() {
100 super::categories::build_categories(&tag_set, &manifest.categories)?
101 } else {
102 Vec::new()
103 };
104 let category_base_path = config.category_base_path().map(str::to_string);
105
106 let featured_indices: Vec<usize> = posts
107 .iter()
108 .enumerate()
109 .filter(|(_, p)| p.frontmatter.featured)
110 .map(|(i, _)| i)
111 .collect();
112
113 let search_index = Self::build_search_index(&posts);
114
115 let posts_per_page = config.posts_per_page();
116 let date_format = config.date_format().to_string();
117 let theme = config.theme_config().cloned();
118
119 Ok(Self {
120 posts,
121 authors: manifest.authors,
122 all_tags: tag_set,
123 categories,
124 category_base_path,
125 featured_indices,
126 search_index,
127 posts_per_page,
128 date_format,
129 theme,
130 })
131 }
132
133 pub fn get_post(&self, slug: &str) -> Option<&BlogPost> {
136 self.posts.iter().find(|p| p.slug == slug)
137 }
138
139 pub fn all_posts(&self) -> &[BlogPost] {
140 &self.posts
141 }
142
143 pub fn featured_posts(&self) -> Vec<&BlogPost> {
145 self.featured_indices
146 .iter()
147 .map(|&i| &self.posts[i])
148 .collect()
149 }
150
151 pub fn has_featured(&self) -> bool {
153 !self.featured_indices.is_empty()
154 }
155
156 pub fn posts_by_tag(&self, tag: &str) -> Vec<&BlogPost> {
157 self.posts
158 .iter()
159 .filter(|p| p.frontmatter.tags.iter().any(|t| t == tag))
160 .collect()
161 }
162
163 pub fn categories(&self) -> &[BlogCategory] {
165 &self.categories
166 }
167
168 pub fn category_base_path(&self) -> Option<&str> {
169 self.category_base_path.as_deref()
170 }
171
172 pub fn get_category(&self, slug: &str) -> Option<&BlogCategory> {
173 self.categories
174 .iter()
175 .find(|category| category.slug == slug)
176 }
177
178 pub fn category_for_tag(&self, tag: &str) -> Option<&BlogCategory> {
179 self.categories.iter().find(|category| category.tag == tag)
180 }
181
182 pub fn category_posts_page(&self, slug: &str, page: usize) -> Option<Vec<&BlogPost>> {
185 let category = self.get_category(slug)?;
186 if page == 0 || page > self.total_pages_for_tag(&category.tag) {
187 return None;
188 }
189 Some(self.posts_page_by_tag(&category.tag, page - 1))
190 }
191
192 pub fn category_url(&self, slug: &str, page: usize) -> Option<String> {
194 let category = self.get_category(slug)?;
195 let base = self.category_base_path()?;
196 if page == 0 || page > self.total_pages_for_tag(&category.tag) {
197 return None;
198 }
199 let slug = super::categories::encode_path_segment(&category.slug);
200 Some(if page == 1 {
201 format!("{base}/{slug}")
202 } else {
203 format!("{base}/{slug}/page/{page}")
204 })
205 }
206
207 pub fn category_url_for_tag(&self, tag: &str) -> Option<String> {
208 self.category_url(&self.category_for_tag(tag)?.slug, 1)
209 }
210
211 pub fn non_featured_posts_page(&self, page: usize) -> Vec<&BlogPost> {
213 let filtered: Vec<&BlogPost> = self
214 .posts
215 .iter()
216 .filter(|p| !p.frontmatter.featured)
217 .collect();
218 let start = page * self.posts_per_page;
219 let end = (start + self.posts_per_page).min(filtered.len());
220 if start >= filtered.len() {
221 return Vec::new();
222 }
223 filtered[start..end].to_vec()
224 }
225
226 pub fn non_featured_total_pages(&self) -> usize {
228 let count = self
229 .posts
230 .iter()
231 .filter(|p| !p.frontmatter.featured)
232 .count();
233 if count == 0 {
234 return 1;
235 }
236 count.div_ceil(self.posts_per_page)
237 }
238
239 pub fn related_posts(&self, slug: &str, max: usize) -> Vec<&BlogPost> {
244 let current = match self.get_post(slug) {
245 Some(p) => p,
246 None => return Vec::new(),
247 };
248 let current_tags: std::collections::HashSet<&str> = current
249 .frontmatter
250 .tags
251 .iter()
252 .map(|t| t.as_str())
253 .collect();
254
255 if current_tags.is_empty() {
256 return Vec::new();
257 }
258
259 let mut scored: Vec<(usize, &BlogPost)> = self
260 .posts
261 .iter()
262 .filter(|p| p.slug != slug)
263 .filter_map(|p| {
264 let overlap = p
265 .frontmatter
266 .tags
267 .iter()
268 .filter(|t| current_tags.contains(t.as_str()))
269 .count();
270 if overlap > 0 {
271 Some((overlap, p))
272 } else {
273 None
274 }
275 })
276 .collect();
277
278 scored.sort_by(|a, b| {
279 b.0.cmp(&a.0)
280 .then_with(|| b.1.frontmatter.date.cmp(&a.1.frontmatter.date))
281 });
282 scored.into_iter().take(max).map(|(_, p)| p).collect()
283 }
284
285 pub fn posts_page(&self, page: usize) -> &[BlogPost] {
286 let start = page * self.posts_per_page;
287 let end = (start + self.posts_per_page).min(self.posts.len());
288 if start >= self.posts.len() {
289 return &[];
290 }
291 &self.posts[start..end]
292 }
293
294 pub fn posts_page_by_tag(&self, tag: &str, page: usize) -> Vec<&BlogPost> {
295 let filtered = self.posts_by_tag(tag);
296 let start = page * self.posts_per_page;
297 let end = (start + self.posts_per_page).min(filtered.len());
298 if start >= filtered.len() {
299 return Vec::new();
300 }
301 filtered[start..end].to_vec()
302 }
303
304 pub fn total_pages(&self) -> usize {
305 if self.posts.is_empty() {
306 return 1;
307 }
308 self.posts.len().div_ceil(self.posts_per_page)
309 }
310
311 pub fn total_pages_for_tag(&self, tag: &str) -> usize {
312 let count = self.posts_by_tag(tag).len();
313 if count == 0 {
314 return 1;
315 }
316 count.div_ceil(self.posts_per_page)
317 }
318
319 pub fn prev_post(&self, slug: &str) -> Option<&BlogPost> {
323 let idx = self.posts.iter().position(|p| p.slug == slug)?;
324 if idx + 1 < self.posts.len() {
325 Some(&self.posts[idx + 1])
326 } else {
327 None
328 }
329 }
330
331 pub fn next_post(&self, slug: &str) -> Option<&BlogPost> {
333 let idx = self.posts.iter().position(|p| p.slug == slug)?;
334 if idx > 0 {
335 Some(&self.posts[idx - 1])
336 } else {
337 None
338 }
339 }
340
341 pub fn all_tags(&self) -> &[String] {
344 &self.all_tags
345 }
346
347 pub fn tag_count(&self, tag: &str) -> usize {
348 self.posts
349 .iter()
350 .filter(|p| p.frontmatter.tags.iter().any(|t| t == tag))
351 .count()
352 }
353
354 pub fn get_author(&self, id: &str) -> Option<&Author> {
355 self.authors.get(id)
356 }
357
358 pub fn search_posts(&self, query: &str) -> Vec<&BlogSearchEntry> {
365 crate::search::rank(&self.search_index, query, |e, buf| {
366 buf.push(crate::search::Field::title(&e.title_lower));
367 if !e.description_lower.is_empty() {
368 buf.push(crate::search::Field::description(&e.description_lower));
369 }
370 if !e.body_lower.is_empty() {
371 buf.push(crate::search::Field::body(&e.body_lower));
372 }
373 })
374 }
375
376 fn build_search_index(posts: &[BlogPost]) -> Vec<BlogSearchEntry> {
377 posts
378 .iter()
379 .map(|post| {
380 let title = post.frontmatter.title.clone();
381 let description = post.frontmatter.description.clone().unwrap_or_default();
382 let body = crate::search::clean_markdown(&post.raw_markdown);
383 BlogSearchEntry {
384 slug: post.slug.clone(),
385 title_lower: crate::search::search_lower(&title),
386 description_lower: crate::search::search_lower(&description),
387 body_lower: crate::search::search_lower(&body),
388 title,
389 description,
390 body,
391 date: post.frontmatter.date.clone(),
392 tags: post.frontmatter.tags.clone(),
393 }
394 })
395 .collect()
396 }
397
398 pub fn generate_rss(&self, site_title: &str, site_url: &str, blog_path: &str) -> String {
401 let channel_title = xml_escape(site_title);
402 let self_link = xml_escape(&format!("{site_url}{blog_path}"));
403 let mut rss = format!(
404 r#"<?xml version="1.0" encoding="UTF-8"?>
405<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
406<channel>
407<title>{channel_title}</title>
408<link>{self_link}</link>
409<description>{channel_title} RSS Feed</description>
410<atom:link href="{self_link}/rss.xml" rel="self" type="application/rss+xml"/>
411"#
412 );
413
414 for post in &self.posts {
415 let title = xml_escape(&post.frontmatter.title);
416 let desc = xml_escape(post.frontmatter.description.as_deref().unwrap_or_default());
417 let link = xml_escape(&format!("{site_url}{blog_path}/{}", post.slug));
418 rss.push_str(&format!(
419 "<item>\n<title>{title}</title>\n<link>{link}</link>\n<description>{desc}</description>\n<pubDate>{}</pubDate>\n<guid>{link}</guid>\n</item>\n",
420 xml_escape(&post.frontmatter.date)
421 ));
422 }
423
424 rss.push_str("</channel>\n</rss>\n");
425 rss
426 }
427
428 pub fn generate_llms_txt(
429 &self,
430 site_title: &str,
431 site_description: &str,
432 base_url: &str,
433 blog_path: &str,
434 ) -> String {
435 let mut out = format!("# {site_title}\n\n> {site_description}\n\n");
436
437 for post in &self.posts {
438 let title = &post.frontmatter.title;
439 let desc = post.frontmatter.description.as_deref().unwrap_or_default();
440 let url = format!("{base_url}{blog_path}/{}", post.slug);
441 if desc.is_empty() {
442 out.push_str(&format!("- [{title}]({url})\n"));
443 } else {
444 out.push_str(&format!("- [{title}]({url}): {desc}\n"));
445 }
446 }
447
448 out
449 }
450
451 pub fn generate_sitemap(&self, site_url: &str, blog_path: &str) -> String {
455 let mut xml = String::from(
456 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
457 <urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n",
458 );
459
460 let index_loc = xml_escape(&format!("{site_url}{blog_path}"));
462 xml.push_str(&format!(
463 "<url>\n<loc>{index_loc}</loc>\n<changefreq>weekly</changefreq>\n<priority>0.8</priority>\n</url>\n"
464 ));
465
466 for post in &self.posts {
468 let loc = xml_escape(&format!("{site_url}{blog_path}/{}", post.slug));
469 let lastmod = xml_escape(&post.frontmatter.date);
470 xml.push_str(&format!(
471 "<url>\n<loc>{loc}</loc>\n<lastmod>{lastmod}</lastmod>\n<changefreq>monthly</changefreq>\n<priority>0.6</priority>\n</url>\n"
472 ));
473 }
474
475 for category in &self.categories {
476 for page in 1..=self.total_pages_for_tag(&category.tag) {
477 if let Some(path) = self.category_url(&category.slug, page) {
478 let loc =
479 xml_escape(&crate::components::seo::join_site_url(site_url, &path, ""));
480 xml.push_str(&format!(
481 "<url>\n<loc>{loc}</loc>\n<changefreq>weekly</changefreq>\n</url>\n"
482 ));
483 }
484 }
485 }
486 xml.push_str("</urlset>\n");
487 xml
488 }
489
490 pub fn format_date(&self, date: &str) -> String {
493 format_date_with(date, &self.date_format)
494 }
495}
496
497pub fn format_date_with(date: &str, fmt: &str) -> String {
499 let parts: Vec<&str> = date.split('-').collect();
500 if parts.len() != 3 {
501 return date.to_string();
502 }
503
504 let year = parts[0];
505 let month = parts[1];
506 let day = parts[2];
507
508 let month_name = match month {
509 "01" => "January",
510 "02" => "February",
511 "03" => "March",
512 "04" => "April",
513 "05" => "May",
514 "06" => "June",
515 "07" => "July",
516 "08" => "August",
517 "09" => "September",
518 "10" => "October",
519 "11" => "November",
520 "12" => "December",
521 _ => month,
522 };
523
524 fmt.replace("%Y", year)
525 .replace("%m", month)
526 .replace("%d", day)
527 .replace("%B", month_name)
528}
529
530#[cfg(test)]
531mod tests {
532 use super::*;
533 use crate::blog::config::BlogConfig;
534 use std::collections::HashMap;
535
536 fn build_registry(posts_per_page: usize) -> BlogRegistry {
537 let manifest = r#"{
538 "authors": {
539 "author": { "name": "Author" }
540 },
541 "posts": ["featured", "regular-1", "regular-2", "regular-3", "rust-new", "rust-old", "misc"]
542 }"#;
543
544 let mut content_map = HashMap::new();
545 content_map.insert(
546 "featured",
547 r#"---
548title: "Featured"
549date: "2026-03-21"
550author: "author"
551tags: ["announcement"]
552featured: true
553---
554Featured post
555"#,
556 );
557 content_map.insert(
558 "regular-1",
559 r#"---
560title: "Regular 1"
561date: "2026-03-20"
562author: "author"
563tags: ["announcement"]
564---
565Regular one
566"#,
567 );
568 content_map.insert(
569 "regular-2",
570 r#"---
571title: "Regular 2"
572date: "2026-03-19"
573author: "author"
574tags: ["announcement"]
575---
576Regular two
577"#,
578 );
579 content_map.insert(
580 "regular-3",
581 r#"---
582title: "Regular 3"
583date: "2026-03-18"
584author: "author"
585tags: ["announcement"]
586---
587Regular three
588"#,
589 );
590 content_map.insert(
591 "rust-new",
592 r#"---
593title: "Rust New"
594date: "2026-03-17"
595author: "author"
596tags: ["rust", "web", "async"]
597---
598Rust new
599"#,
600 );
601 content_map.insert(
602 "rust-old",
603 r#"---
604title: "Rust Old"
605date: "2026-03-16"
606author: "author"
607tags: ["rust", "web"]
608---
609Rust old
610"#,
611 );
612 content_map.insert(
613 "misc",
614 r#"---
615title: "Misc"
616date: "2026-03-15"
617author: "author"
618tags: ["rust"]
619---
620Misc
621"#,
622 );
623
624 BlogConfig::new(manifest, content_map)
625 .with_posts_per_page(posts_per_page)
626 .build()
627 }
628
629 fn category_config() -> BlogConfig {
630 BlogConfig::new(r#"{"authors":{}, "posts":[], "categories":{
631 "Rust": {"slug":"rust-lang", "title":"Rust programming", "description":"Learn Rust.", "image":"/rust.png"},
632 "unused": {"title":"Unused"}
633 }}"#, HashMap::from([
634 ("a", "---\ntitle: A\ndate: '2026-01-02'\nauthor: a\ntags: [Rust]\nfeatured: true\n---\nA"),
635 ("b", "---\ntitle: B\ndate: '2026-01-02'\nauthor: a\ntags: [Rust, Web]\n---\nB"),
636 ("c", "---\ntitle: C\ndate: '2026-01-01'\nauthor: a\ntags: [Rust]\n---\nC"),
637 ("draft", "---\ntitle: Draft\ndate: '2026-01-03'\nauthor: a\ntags: [Rust, Secret]\ndraft: true\n---\nDraft"),
638 ]))
639 .with_category_base_path("/topics/")
640 .with_posts_per_page(2)
641 }
642
643 #[test]
644 fn categories_use_published_tags_and_optional_metadata() {
645 let registry = category_config().build();
646 assert_eq!(registry.categories().len(), 2);
647 let rust = registry.get_category("rust-lang").unwrap();
648 assert_eq!(rust.title, "Rust programming");
649 assert_eq!(rust.description, "Learn Rust.");
650 assert_eq!(rust.image.as_deref(), Some("/rust.png"));
651 assert_eq!(registry.category_for_tag("Rust"), Some(rust));
652 assert!(registry.get_category("secret").is_none());
653 assert!(registry.get_category("unused").is_none());
654 assert_eq!(
655 registry.get_category("web").unwrap().description,
656 "Browse articles about Web."
657 );
658 }
659
660 #[test]
661 fn category_urls_percent_encode_non_ascii_slugs() {
662 let registry = BlogConfig::new(
663 r#"{"authors":{}, "posts":[], "categories":{}}"#,
664 HashMap::from([(
665 "a",
666 "---\ntitle: A\ndate: '2026-01-02'\nauthor: a\ntags: [Café]\n---\nA",
667 )]),
668 )
669 .with_category_base_path("/topics")
670 .build();
671 let category = registry.category_for_tag("Café").unwrap();
672 assert_eq!(category.slug, "café");
673 assert_eq!(
674 registry.category_url("café", 1).as_deref(),
675 Some("/topics/caf%C3%A9")
676 );
677 assert!(
678 registry
679 .generate_sitemap("https://example.com", "/blog")
680 .contains("<loc>https://example.com/topics/caf%C3%A9</loc>")
681 );
682 }
683
684 #[test]
685 fn category_pages_include_featured_and_have_stable_order() {
686 let registry = category_config().build();
687 let slugs = |page| {
688 registry
689 .category_posts_page("rust-lang", page)
690 .unwrap()
691 .iter()
692 .map(|post| post.slug.clone())
693 .collect::<Vec<_>>()
694 };
695 assert_eq!(slugs(1), ["a", "b"]);
696 assert_eq!(slugs(2), ["c"]);
697 for page in [0, 3, usize::MAX] {
698 assert!(registry.category_posts_page("rust-lang", page).is_none());
699 assert!(registry.category_url("rust-lang", page).is_none());
700 }
701 assert!(registry.category_posts_page("missing", 1).is_none());
702 assert_eq!(
703 registry.category_url_for_tag("Rust").as_deref(),
704 Some("/topics/rust-lang")
705 );
706 assert_eq!(
707 registry.category_url("rust-lang", 2).as_deref(),
708 Some("/topics/rust-lang/page/2")
709 );
710 }
711
712 #[test]
713 fn sitemap_contains_only_valid_category_pages() {
714 let registry = category_config().build();
715 let xml = registry.generate_sitemap("https://example.com", "/blog");
716 for path in [
717 "/topics/rust-lang",
718 "/topics/rust-lang/page/2",
719 "/topics/web",
720 ] {
721 assert_eq!(
722 xml.matches(&format!("<loc>https://example.com{path}</loc>"))
723 .count(),
724 1
725 );
726 }
727 assert!(!xml.contains("/page/1"));
728 assert!(!xml.contains("/page/3"));
729 assert!(!xml.contains("secret"));
730 assert!(!xml.contains("unused"));
731 }
732
733 #[test]
734 fn category_routes_are_opt_in_and_zero_page_size_is_rejected() {
735 let registry = build_registry(2);
736 assert!(registry.categories().is_empty());
737 assert!(registry.category_url_for_tag("rust").is_none());
738 assert!(
739 !registry
740 .generate_sitemap("https://example.com", "/blog")
741 .contains("/categories/")
742 );
743 assert!(matches!(
744 category_config().with_posts_per_page(0).try_build(),
745 Err(DocsKitError::BlogConfig(_))
746 ));
747 }
748
749 #[test]
750 fn unfiltered_pagination_excludes_featured_posts() {
751 let registry = build_registry(2);
752
753 let page_1: Vec<_> = registry
754 .non_featured_posts_page(0)
755 .into_iter()
756 .map(|post| post.slug.as_str())
757 .collect();
758 let page_2: Vec<_> = registry
759 .non_featured_posts_page(1)
760 .into_iter()
761 .map(|post| post.slug.as_str())
762 .collect();
763 let page_3: Vec<_> = registry
764 .non_featured_posts_page(2)
765 .into_iter()
766 .map(|post| post.slug.as_str())
767 .collect();
768 let page_4 = registry.non_featured_posts_page(3);
769
770 assert_eq!(page_1, vec!["regular-1", "regular-2"]);
771 assert_eq!(page_2, vec!["regular-3", "rust-new"]);
772 assert_eq!(page_3, vec!["rust-old", "misc"]);
773 assert!(page_4.is_empty());
774 assert_eq!(registry.non_featured_total_pages(), 3);
775 }
776
777 #[test]
778 fn tag_pagination_still_includes_featured_posts() {
779 let registry = build_registry(2);
780
781 let page: Vec<_> = registry
782 .posts_page_by_tag("announcement", 0)
783 .into_iter()
784 .map(|post| post.slug.as_str())
785 .collect();
786
787 assert_eq!(page, vec!["featured", "regular-1"]);
788 assert_eq!(registry.total_pages_for_tag("announcement"), 2);
789 }
790
791 #[test]
792 fn blog_search_matches_title_and_requires_all_terms() {
793 let registry = build_registry(10);
794
795 let single: Vec<&str> = registry
797 .search_posts("rust")
798 .iter()
799 .map(|e| e.slug.as_str())
800 .collect();
801 assert_eq!(single, vec!["rust-new", "rust-old"]);
802
803 let multi: Vec<&str> = registry
805 .search_posts("rust new")
806 .iter()
807 .map(|e| e.slug.as_str())
808 .collect();
809 assert_eq!(multi, vec!["rust-new"]);
810
811 assert!(registry.search_posts(" ").is_empty());
812 }
813
814 #[test]
815 fn related_posts_tie_break_on_date() {
816 let registry = build_registry(10);
817
818 let related: Vec<_> = registry
819 .related_posts("misc", 3)
820 .into_iter()
821 .map(|post| post.slug.as_str())
822 .collect();
823
824 assert_eq!(related, vec!["rust-new", "rust-old"]);
825 }
826
827 #[test]
828 fn rss_escapes_xml_metacharacters() {
829 let manifest = r#"{
830 "authors": { "author": { "name": "Author" } },
831 "posts": ["ampersand"]
832 }"#;
833 let mut content_map = HashMap::new();
834 content_map.insert(
835 "ampersand",
836 "---\ntitle: \"Rust & WASM: <T> generics\"\ndate: \"2026-03-21\"\nauthor: \"author\"\ndescription: \"a \\\"quoted\\\" & thing\"\n---\nBody\n",
837 );
838 let registry = BlogConfig::new(manifest, content_map).build();
839
840 let rss = registry.generate_rss("Site & Co", "https://example.com", "/blog");
841
842 assert!(
843 rss.contains("Rust & WASM: <T> generics"),
844 "got: {rss}"
845 );
846 assert!(rss.contains("Site & Co"), "got: {rss}");
847 for (idx, _) in rss.match_indices('&') {
849 let tail = &rss[idx..];
850 assert!(
851 tail.starts_with("&")
852 || tail.starts_with("<")
853 || tail.starts_with(">")
854 || tail.starts_with(""")
855 || tail.starts_with("'"),
856 "unescaped `&` at {idx} makes the whole feed unparseable: {:?}",
857 &tail[..tail.len().min(40)]
858 );
859 }
860 }
861}