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