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