1use anyhow::{bail, Context as _, Result};
2use camino::Utf8PathBuf;
3use ignore::{overrides::OverrideBuilder, WalkBuilder};
4use log::*;
5use mdbook::{
6 book::{Book, Chapter},
7 errors::Result as MdbookResult,
8 preprocess::{Preprocessor, PreprocessorContext},
9 BookItem,
10};
11use pulldown_cmark::{CodeBlockKind, CowStr, Event, Options, Parser, Tag};
12use pulldown_cmark_to_cmark::cmark;
13use serde::Deserialize;
14use std::{collections::BTreeMap, fmt::Write};
15use tera::Tera;
16use toml::value::Value;
17use uuid::Uuid;
18
19#[derive(Deserialize, Debug)]
21#[serde(deny_unknown_fields)]
22pub struct Files {
23 pub path: Utf8PathBuf,
25
26 #[serde(default)]
32 pub files: Vec<String>,
33
34 #[serde(default)]
36 pub default_file: Option<Utf8PathBuf>,
37
38 #[serde(default)]
40 pub ignore_case_insensitive: bool,
41
42 #[serde(default)]
47 pub same_file_system: bool,
48
49 #[serde(default)]
51 pub types: Vec<String>,
52
53 #[serde(default)]
55 pub hidden: bool,
56
57 #[serde(default)]
59 pub follow_links: bool,
60
61 #[serde(default)]
66 pub dot_ignore: bool,
67
68 #[serde(default)]
71 pub git_global: bool,
72
73 #[serde(default)]
75 pub git_exclude: bool,
76
77 #[serde(default)]
79 pub git_ignore: bool,
80
81 #[serde(default)]
84 pub require_git: bool,
85
86 #[serde(default)]
88 pub git_ignore_parents: bool,
89
90 #[serde(default)]
92 pub max_depth: Option<usize>,
93
94 #[serde(default)]
96 pub max_filesize: Option<u64>,
97
98 #[serde(default)]
99 pub height: Option<String>,
100}
101
102#[derive(Deserialize)]
104pub struct Config {
105 pub prefix: Utf8PathBuf,
106}
107
108#[derive(Clone, Debug, Copy)]
109pub struct Context<'a> {
110 prefix: &'a Utf8PathBuf,
111 tera: &'a Tera,
112}
113
114pub struct Instance<'a> {
115 context: Context<'a>,
116 data: Files,
117 uuid: Uuid,
118}
119
120#[derive(Clone, Debug)]
121pub enum TreeNode {
122 Directory(BTreeMap<String, TreeNode>),
123 File(Uuid),
124}
125
126impl Default for TreeNode {
127 fn default() -> Self {
128 TreeNode::Directory(Default::default())
129 }
130}
131
132impl TreeNode {
133 fn insert(&mut self, path: &[&str], uuid: Uuid) {
134 match self {
135 TreeNode::Directory(files) if path.len() == 1 => {
136 files.insert(path[0].into(), TreeNode::File(uuid));
137 }
138 TreeNode::Directory(files) => {
139 files
140 .entry(path[0].into())
141 .or_default()
142 .insert(&path[1..], uuid);
143 }
144 TreeNode::File(_file) => panic!("entry exists"),
145 }
146 }
147
148 pub fn render(&self) -> Result<String> {
149 let mut output = String::new();
150 match self {
151 TreeNode::File(_) => bail!("root node cannot be file"),
152 TreeNode::Directory(files) => Self::render_files(&mut output, files)?,
153 }
154 Ok(output)
155 }
156
157 fn render_files(output: &mut dyn Write, files: &BTreeMap<String, TreeNode>) -> Result<()> {
158 write!(output, "<ul>")?;
159 for (path, node) in files {
160 node.render_inner(output, path)?;
161 }
162 write!(output, "</ul>")?;
163 Ok(())
164 }
165
166 fn render_inner(&self, output: &mut dyn Write, name: &str) -> Result<()> {
167 match self {
168 TreeNode::File(uuid) => {
169 write!(
170 output,
171 r#"<li id="button-{uuid}" class="mdbook-files-button">{name}</li>"#
172 )?;
173 }
174 TreeNode::Directory(files) => {
175 write!(
176 output,
177 r#"<li class="mdbook-files-folder"><span>{name}/</span>"#
178 )?;
179 Self::render_files(output, files)?;
180 write!(output, "</li>")?;
181 }
182 }
183 Ok(())
184 }
185}
186
187pub type FilesMap = BTreeMap<Utf8PathBuf, Uuid>;
188
189impl<'a> Instance<'a> {
190 fn parent(&self) -> Utf8PathBuf {
191 self.context.prefix.join(&self.data.path)
192 }
193
194 fn files(&self) -> Result<FilesMap> {
195 let mut paths: FilesMap = Default::default();
196 let parent = self.parent();
197 let mut overrides = OverrideBuilder::new(&parent);
198 for item in &self.data.files {
199 overrides.add(item)?;
200 }
201 let overrides = overrides.build()?;
202 let mut walker = WalkBuilder::new(&parent);
203 walker
204 .standard_filters(false)
205 .ignore_case_insensitive(self.data.ignore_case_insensitive)
206 .same_file_system(self.data.same_file_system)
207 .require_git(self.data.require_git)
208 .hidden(self.data.hidden)
209 .ignore(self.data.dot_ignore)
210 .git_ignore(self.data.git_ignore)
211 .git_exclude(self.data.git_exclude)
212 .git_global(self.data.git_global)
213 .parents(self.data.git_ignore_parents)
214 .follow_links(self.data.follow_links)
215 .max_depth(self.data.max_depth)
216 .overrides(overrides)
217 .max_filesize(self.data.max_filesize);
218
219 let walker = walker.build();
220
221 for path in walker {
222 let path = path?;
223 if path.file_type().unwrap().is_file() {
224 paths.insert(path.path().to_path_buf().try_into()?, Uuid::new_v4());
225 }
226 }
227
228 info!("Found {} matching files", paths.len());
229 if paths.is_empty() {
230 bail!("No files matched");
231 }
232
233 Ok(paths)
234 }
235
236 fn left(&self, files: &FilesMap) -> Result<String> {
237 let mut output = String::new();
238 let parent = self.parent();
239 output.push_str(r#"<div class="mdbook-files-left">"#);
240
241 let mut root = TreeNode::default();
242 for (path, uuid) in files.iter() {
243 let path = path.strip_prefix(&parent)?;
244 let path: Vec<_> = path.components().map(|c| c.as_str()).collect();
245 root.insert(&path[..], *uuid);
246 }
247
248 let list = root.render()?;
249 output.push_str(&list);
250 output.push_str("</div>");
251 Ok(output)
252 }
253
254 fn right(&self, files: &FilesMap) -> Result<Vec<Event<'static>>> {
255 let mut events = vec![];
256 events.push(Event::Html(CowStr::Boxed(
257 r#"<div class="mdbook-files-right">"#.to_string().into(),
258 )));
259
260 for (path, uuid) in files {
261 info!("Reading {path}");
262 let contents = std::fs::read_to_string(path)?;
263 let extension = path.extension().unwrap_or("");
264 let tag = Tag::CodeBlock(CodeBlockKind::Fenced(CowStr::Boxed(extension.into())));
265
266 events.push(Event::Html(CowStr::Boxed(
267 format!(r#"<div id="file-{uuid}" class="mdbook-file visible">"#).into(),
268 )));
269
270 events.push(Event::Start(tag.clone()));
271 events.push(Event::Text(CowStr::Boxed(contents.into())));
272 events.push(Event::End(tag));
273
274 events.push(Event::Html(CowStr::Boxed("</div>".to_string().into())));
275 }
276
277 events.push(Event::Html(CowStr::Boxed("</div>".to_string().into())));
278 Ok(events)
279 }
280
281 fn events(&self) -> Result<Vec<Event<'static>>> {
282 let paths = self.files()?;
283
284 let mut events = vec![];
285
286 let height = self.data.height.as_deref().unwrap_or("300px");
287 events.push(Event::Html(CowStr::Boxed(
288 format!(
289 r#"<div id="files-{}" class="mdbook-files" style="height: {height};">"#,
290 self.uuid
291 )
292 .into(),
293 )));
294
295 events.push(Event::Html(CowStr::Boxed(self.left(&paths)?.into())));
296 events.append(&mut self.right(&paths)?);
297 events.push(Event::Html(CowStr::Boxed("</div>".to_string().into())));
298
299 let uuids: Vec<Uuid> = paths.values().copied().collect();
300 let visible = match &self.data.default_file {
301 Some(file) => paths.get(&self.parent().join(file)).unwrap(),
302 None => &uuids[0],
303 };
304
305 let mut context = tera::Context::new();
306 context.insert("uuids", &uuids);
307 context.insert("visible", visible);
308
309 let script = self.context.tera.render("script", &context)?;
310
311 events.push(Event::Html(CowStr::Boxed(
312 format!("<script>{script}</script>").into(),
313 )));
314
315 events.push(Event::HardBreak);
316 Ok(events)
317 }
318}
319
320impl<'b> Context<'b> {
321 fn map(&self, book: Book) -> Result<Book> {
322 let mut book = book;
323 book.sections = std::mem::take(&mut book.sections)
324 .into_iter()
325 .map(|section| self.map_book_item(section))
326 .collect::<Result<_, _>>()?;
327 Ok(book)
328 }
329
330 fn map_book_item(&self, item: BookItem) -> Result<BookItem> {
331 let result = match item {
332 BookItem::Chapter(chapter) => BookItem::Chapter(self.map_chapter(chapter)?),
333 other => other,
334 };
335
336 Ok(result)
337 }
338
339 fn map_code(&self, code: CowStr<'_>) -> Result<Vec<Event<'static>>> {
340 Instance {
341 data: toml::from_str(&code)?,
342 uuid: Uuid::new_v4(),
343 context: *self,
344 }
345 .events()
346 }
347
348 fn label(&self) -> &str {
349 "files"
350 }
351
352 fn map_chapter(&self, mut chapter: Chapter) -> Result<Chapter> {
353 chapter.content = self.map_markdown(&chapter.content)?;
354 chapter.sub_items = std::mem::take(&mut chapter.sub_items)
355 .into_iter()
356 .map(|item| self.map_book_item(item))
357 .collect::<Result<_, _>>()?;
358 Ok(chapter)
359 }
360
361 fn map_markdown(&self, markdown: &str) -> Result<String> {
362 let mut parser = Parser::new_ext(markdown, Options::all());
363 let mut events = vec![];
364
365 loop {
366 let next = parser.next();
367 match next {
368 None => break,
369 Some(Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(label))))
370 if &*label == self.label() =>
371 {
372 let mapped = match parser.next() {
373 Some(Event::Text(code)) => self.map_code(code).context("Mapping code")?,
374 other => unreachable!("Got {other:?}"),
375 };
376
377 for event in mapped.into_iter() {
378 events.push(event);
379 }
380
381 parser.next();
382 }
383 Some(event) => events.push(event),
384 }
385 }
386
387 let mut buf = String::with_capacity(markdown.len());
388 let output = cmark(events.iter(), &mut buf).map(|_| buf)?;
389 Ok(output)
390 }
391}
392
393#[derive(Clone, Debug)]
394pub struct FilesPreprocessor {
395 templates: Tera,
396}
397
398impl Default for FilesPreprocessor {
399 fn default() -> Self {
400 Self::new()
401 }
402}
403
404impl FilesPreprocessor {
405 pub fn new() -> Self {
406 let mut templates = Tera::default();
407 templates
408 .add_raw_template("script", include_str!("script.js.tera"))
409 .unwrap();
410 Self { templates }
411 }
412}
413
414impl Preprocessor for FilesPreprocessor {
415 fn name(&self) -> &str {
416 "files"
417 }
418
419 fn run(&self, ctx: &PreprocessorContext, book: Book) -> MdbookResult<Book> {
420 let config = ctx.config.get_preprocessor(self.name()).unwrap();
421 let config: Config = Value::Table(config.clone()).try_into().unwrap();
422 let instance = Context {
423 prefix: &config.prefix,
424 tera: &self.templates,
425 };
426 instance.map(book)
427 }
428}