mdbook_content_loader/
lib.rs1use 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 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 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, 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}