Skip to main content

dioxus_docs_kit/blog/
registry.rs

1//! Blog content registry.
2
3use 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
14/// Central blog registry holding all parsed content.
15///
16/// Created via [`BlogConfig`] builder and typically stored in a `LazyLock<BlogRegistry>` static.
17pub struct BlogRegistry {
18    /// All parsed blog posts, sorted by date (newest first).
19    posts: Vec<BlogPost>,
20    /// Author definitions from `_blog.json`.
21    authors: HashMap<String, Author>,
22    /// All unique tags across all posts, sorted alphabetically.
23    all_tags: Vec<String>,
24    categories: Vec<BlogCategory>,
25    category_base_path: Option<String>,
26    /// Prebuilt search index.
27    search_index: Vec<BlogSearchEntry>,
28    /// Indices into `posts` for featured posts, preserving date order.
29    featured_indices: Vec<usize>,
30    /// Posts per page for pagination.
31    pub posts_per_page: usize,
32    /// Date display format string.
33    pub date_format: String,
34    /// Optional theme configuration.
35    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                // Blog post views render the frontmatter title in their own
69                // <h1>; strip a duplicate body H1 so each page emits exactly one.
70                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    // ── Post access ──────────────────────────────────────────────────────
134
135    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    /// Get all featured/pinned posts, sorted by date (newest first).
144    pub fn featured_posts(&self) -> Vec<&BlogPost> {
145        self.featured_indices
146            .iter()
147            .map(|&i| &self.posts[i])
148            .collect()
149    }
150
151    /// Check if there are any featured posts.
152    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    /// Published categories, in tag order. Empty when category routes are disabled.
164    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    /// Category pagination uses one-based page numbers and includes featured posts.
183    /// Unknown categories and out-of-range pages return `None` (including page zero).
184    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    /// Canonical root-relative URL; page one has no pagination suffix.
193    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    /// Get a page of non-featured posts for the main blog index.
212    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    /// Total number of pages for the main blog index, excluding featured posts.
227    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    /// Find posts related to the given slug by tag overlap.
240    ///
241    /// Returns up to `max` posts sorted by number of overlapping tags (descending),
242    /// then by date (newest first). Excludes the current post.
243    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    // ── Navigation ───────────────────────────────────────────────────────
320
321    /// Get the previous post (older) relative to the given slug.
322    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    /// Get the next post (newer) relative to the given slug.
332    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    // ── Metadata ─────────────────────────────────────────────────────────
342
343    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    // ── Search ───────────────────────────────────────────────────────────
359
360    /// Search posts by query string.
361    ///
362    /// Same multi-term AND / tier scoring as docs search (title > description >
363    /// body); posts are indexed whole (no sections).
364    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    // ── RSS ──────────────────────────────────────────────────────────────
399
400    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    // ── Sitemap ──────────────────────────────────────────────────────────
452
453    /// Generate a sitemap.xml for posts and enabled category pages.
454    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        // Blog index page
461        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        // Individual posts
467        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    // ── Date formatting ──────────────────────────────────────────────────
491
492    pub fn format_date(&self, date: &str) -> String {
493        format_date_with(date, &self.date_format)
494    }
495}
496
497/// Format an ISO 8601 date string (YYYY-MM-DD) with a simple format pattern.
498pub 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        // Single term: both Rust posts match on title, newest first.
796        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        // Multi-term AND: only "Rust New" contains both words.
804        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 &amp; WASM: &lt;T&gt; generics"),
844            "got: {rss}"
845        );
846        assert!(rss.contains("Site &amp; Co"), "got: {rss}");
847        // No bare `&` survives: every one must start an entity.
848        for (idx, _) in rss.match_indices('&') {
849            let tail = &rss[idx..];
850            assert!(
851                tail.starts_with("&amp;")
852                    || tail.starts_with("&lt;")
853                    || tail.starts_with("&gt;")
854                    || tail.starts_with("&quot;")
855                    || tail.starts_with("&apos;"),
856                "unescaped `&` at {idx} makes the whole feed unparseable: {:?}",
857                &tail[..tail.len().min(40)]
858            );
859        }
860    }
861}