mdbook_content_loader/
lib.rs1use 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 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 inject_all = match ctx
49 .config
50 .get::<bool>("preprocessor.content-loader.inject_all")
51 {
52 Ok(Some(b)) => b,
53 Ok(None) => false, 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}