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        // [preprocessor.content-loader]
47        // inject_all = true  # optional, default = false
48        let inject_all = match ctx
49            .config
50            .get::<bool>("preprocessor.content-loader.inject_all")
51        {
52            Ok(Some(b)) => b,
53            Ok(None) => false, // key not set
54            Err(e) => {
55                log::warn!(
56                    "content-loader: expected bool for preprocessor.content-loader.inject_all: {e}"
57                );
58                false
59            }
60        };
61
62        let script = format!(
63            r#"<script>window.CONTENT_COLLECTIONS = {};</script>"#,
64            serde_json::to_string(&payload)?
65        );
66
67        book.for_each_mut(|item| {
68            if let BookItem::Chapter(chapter) = item {
69                let is_index =
70                    chapter.path.as_ref().and_then(|p| p.file_stem()) == Some("index".as_ref());
71
72                if inject_all || is_index {
73                    chapter.content = format!("{}\n{}", script, chapter.content);
74                }
75            }
76        });
77
78        Ok(book)
79    }
80
81    fn supports_renderer(&self, renderer: &str) -> Result<bool, Error> {
82        Ok(renderer == "html")
83    }
84}
85
86fn load_collections(path: &Path) -> anyhow::Result<Value> {
87    if !path.exists() {
88        bail!("content-collections.json not found at {:?}", path);
89    }
90
91    let content = fs::read_to_string(path).context("Failed to read content-collections.json")?;
92    let json_val: Value = serde_json::from_str(&content).context("Failed to parse JSON")?;
93
94    let entries: Vec<Value> = json_val
95        .get("entries")
96        .and_then(|v| v.as_array())
97        .map(|a| a.to_vec())
98        .unwrap_or_default();
99
100    let published: Vec<_> = entries
101        .into_iter()
102        .filter(|e| !e.get("draft").and_then(|v| v.as_bool()).unwrap_or(false))
103        .collect();
104
105    let mut collections: Map<String, Value> = Map::new();
106    let mut default_collection = vec![];
107
108    for entry in &published {
109        let coll = entry
110            .get("collection")
111            .and_then(|v| v.as_str())
112            .unwrap_or("posts")
113            .to_string();
114        if coll == "posts" {
115            default_collection.push(entry.clone());
116        } else {
117            let entry_arr = collections
118                .entry(coll)
119                .or_insert_with(|| json!([]))
120                .as_array_mut()
121                .expect("Failed to convert to array");
122            entry_arr.push(entry.clone());
123        }
124    }
125
126    if !default_collection.is_empty() {
127        sort_by_date_desc(&mut default_collection);
128        collections.insert("posts".to_string(), json!(default_collection));
129    }
130
131    for coll in collections.values_mut() {
132        if let Value::Array(arr) = coll {
133            sort_by_date_desc(arr);
134        }
135    }
136
137    Ok(json!({
138        "entries": published,
139        "collections": collections,
140        "generated_at": Utc::now().to_rfc3339(),
141    }))
142}
143
144fn sort_by_date_desc(arr: &mut [Value]) {
145    arr.sort_by_key(|e| {
146        let date = e.get("date").and_then(|v| v.as_str()).unwrap_or("");
147        Reverse(date.to_string())
148    });
149}