mdbook_content_loader/
lib.rs

1use anyhow::{bail, Context};
2use chrono::Utc;
3use mdbook_preprocessor::{
4    book::{Book, BookItem},
5    errors::Error,
6    Preprocessor, PreprocessorContext,
7};
8use serde_json::{json, Map, Value};
9use std::cmp::Reverse;
10use std::fs;
11use std::path::Path;
12
13pub struct ContentLoader;
14
15impl ContentLoader {
16    pub fn new() -> ContentLoader {
17        ContentLoader
18    }
19}
20
21impl Default for ContentLoader {
22    fn default() -> Self {
23        Self::new()
24    }
25}
26
27impl Preprocessor for ContentLoader {
28    fn name(&self) -> &str {
29        "content-loader"
30    }
31
32    fn run(&self, ctx: &PreprocessorContext, mut book: Book) -> Result<Book, Error> {
33        // mdBook 0.5.1: Config has a typed book.src (PathBuf)
34        let src = ctx.config.book.src.to_str().unwrap_or("src");
35        let src_dir = ctx.root.join(src);
36        let index_path = src_dir.join("content-collections.json");
37
38        let payload: Value = match load_collections(&index_path) {
39            Ok(data) => data,
40            Err(e) => {
41                log::warn!("content-loader: {}", e);
42                return Ok(book);
43            }
44        };
45
46        let script = format!(
47            r#"<script>window.CONTENT_COLLECTIONS = {};</script>"#,
48            serde_json::to_string(&payload)?
49        );
50
51        book.for_each_mut(|item| {
52            if let BookItem::Chapter(chapter) = item {
53                chapter.content = format!("{}\n{}", script, chapter.content);
54            }
55        });
56
57        Ok(book)
58    }
59
60    fn supports_renderer(&self, renderer: &str) -> Result<bool, Error> {
61        Ok(renderer == "html")
62    }
63}
64
65fn load_collections(path: &Path) -> anyhow::Result<Value> {
66    if !path.exists() {
67        bail!("content-collections.json not found at {:?}", path);
68    }
69
70    let content = fs::read_to_string(path).context("Failed to read content-collections.json")?;
71    let json_val: Value = serde_json::from_str(&content).context("Failed to parse JSON")?;
72
73    let entries: Vec<Value> = json_val
74        .get("entries")
75        .and_then(|v| v.as_array())
76        .map(|a| a.to_vec())
77        .unwrap_or_default();
78
79    let published: Vec<_> = entries
80        .into_iter()
81        .filter(|e| !e.get("draft").and_then(|v| v.as_bool()).unwrap_or(false))
82        .collect();
83
84    let mut collections: Map<String, Value> = Map::new();
85    let mut default_collection = vec![];
86
87    for entry in &published {
88        let coll = entry
89            .get("collection")
90            .and_then(|v| v.as_str())
91            .unwrap_or("posts")
92            .to_string();
93        if coll == "posts" {
94            default_collection.push(entry.clone());
95        } else {
96            let entry_arr = collections
97                .entry(coll)
98                .or_insert_with(|| json!([]))
99                .as_array_mut()
100                .expect("Failed to convert to array");
101            entry_arr.push(entry.clone());
102        }
103    }
104
105    if !default_collection.is_empty() {
106        sort_by_date_desc(&mut default_collection);
107        collections.insert("posts".to_string(), json!(default_collection));
108    }
109
110    for coll in collections.values_mut() {
111        if let Value::Array(arr) = coll {
112            sort_by_date_desc(arr);
113        }
114    }
115
116    Ok(json!({
117        "entries": published,
118        "collections": collections,
119        "generated_at": Utc::now().to_rfc3339(),
120    }))
121}
122
123fn sort_by_date_desc(arr: &mut [Value]) {
124    arr.sort_by_key(|e| {
125        let date = e.get("date").and_then(|v| v.as_str()).unwrap_or("");
126        Reverse(date.to_string())
127    });
128}