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, 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
13/// Central blog registry holding all parsed content.
14///
15/// Created via [`BlogConfig`] builder and typically stored in a `LazyLock<BlogRegistry>` static.
16pub struct BlogRegistry {
17    /// All parsed blog posts, sorted by date (newest first).
18    posts: Vec<BlogPost>,
19    /// Author definitions from `_blog.json`.
20    authors: HashMap<String, Author>,
21    /// All unique tags across all posts, sorted alphabetically.
22    all_tags: Vec<String>,
23    /// Prebuilt search index.
24    search_index: Vec<BlogSearchEntry>,
25    /// Indices into `posts` for featured posts, preserving date order.
26    featured_indices: Vec<usize>,
27    /// Posts per page for pagination.
28    pub posts_per_page: usize,
29    /// Date display format string.
30    pub date_format: String,
31    /// Optional theme configuration.
32    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                // Blog post views render the frontmatter title in their own
58                // <h1>; strip a duplicate body H1 so each page emits exactly one.
59                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    // ── Post access ──────────────────────────────────────────────────────
109
110    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    /// Get all featured/pinned posts, sorted by date (newest first).
119    pub fn featured_posts(&self) -> Vec<&BlogPost> {
120        self.featured_indices
121            .iter()
122            .map(|&i| &self.posts[i])
123            .collect()
124    }
125
126    /// Check if there are any featured posts.
127    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    /// Get a page of non-featured posts for the main blog index.
139    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    /// Total number of pages for the main blog index, excluding featured posts.
154    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    /// Find posts related to the given slug by tag overlap.
167    ///
168    /// Returns up to `max` posts sorted by number of overlapping tags (descending),
169    /// then by date (newest first). Excludes the current post.
170    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    // ── Navigation ───────────────────────────────────────────────────────
247
248    /// Get the previous post (older) relative to the given slug.
249    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    /// Get the next post (newer) relative to the given slug.
259    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    // ── Metadata ─────────────────────────────────────────────────────────
269
270    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    // ── Search ───────────────────────────────────────────────────────────
286
287    /// Search posts by query string.
288    ///
289    /// Same multi-term AND / tier scoring as docs search (title > description >
290    /// body); posts are indexed whole (no sections). See [`crate::search`].
291    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    // ── RSS ──────────────────────────────────────────────────────────────
326
327    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    // ── Sitemap ──────────────────────────────────────────────────────────
377
378    /// Generate a sitemap.xml for all blog posts.
379    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        // Blog index page
386        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        // Individual posts
391        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    // ── Date formatting ──────────────────────────────────────────────────
404
405    pub fn format_date(&self, date: &str) -> String {
406        format_date_with(date, &self.date_format)
407    }
408}
409
410/// Format an ISO 8601 date string (YYYY-MM-DD) with a simple format pattern.
411pub 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        // Single term: both Rust posts match on title, newest first.
589        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        // Multi-term AND: only "Rust New" contains both words.
597        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}