Skip to main content

mdbook_content_loader/
lib.rs

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