mdbook_files/
lib.rs

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/// Configuration for an invocation of files
20#[derive(Deserialize, Debug)]
21#[serde(deny_unknown_fields)]
22pub struct Files {
23    /// Path to files
24    pub path: Utf8PathBuf,
25
26    /// Add a glob to the set of overrides.
27    ///
28    /// Globs provided here have precisely the same semantics as a single line in a gitignore file,
29    /// where the meaning of `!` is inverted: namely, `!` at the beginning of a glob will ignore a
30    /// file. Without `!`, all matches of the glob provided are treated as whitelist matches.
31    #[serde(default)]
32    pub files: Vec<String>,
33
34    /// When specified, path to the file that is opened by default.
35    #[serde(default)]
36    pub default_file: Option<Utf8PathBuf>,
37
38    /// Process ignores case insensitively
39    #[serde(default)]
40    pub ignore_case_insensitive: bool,
41
42    /// Do not cross file system boundaries.
43    ///
44    /// When this option is enabled, directory traversal will not descend into directories that are
45    /// on a different file system from the root path.
46    #[serde(default)]
47    pub same_file_system: bool,
48
49    /// Select the file type given by name.
50    #[serde(default)]
51    pub types: Vec<String>,
52
53    /// Enables ignoring hidden files.
54    #[serde(default)]
55    pub hidden: bool,
56
57    /// Whether to follow symbolic links or not.
58    #[serde(default)]
59    pub follow_links: bool,
60
61    /// Enables reading `.ignore` files.
62    ///
63    /// `.ignore` files have the same semantics as gitignore files and are supported by search
64    /// tools such as ripgrep and The Silver Searcher.
65    #[serde(default)]
66    pub dot_ignore: bool,
67
68    /// Enables reading a global `gitignore` file, whose path is specified in git’s `core.excludesFile`
69    /// config option.
70    #[serde(default)]
71    pub git_global: bool,
72
73    /// Enables reading `.git/info/exclude` files.
74    #[serde(default)]
75    pub git_exclude: bool,
76
77    /// Enables reading `.gitignore` files.
78    #[serde(default)]
79    pub git_ignore: bool,
80
81    /// Whether a git repository is required to apply git-related ignore rules (global rules,
82    /// .gitignore and local exclude rules).
83    #[serde(default)]
84    pub require_git: bool,
85
86    /// Enables reading ignore files from parent directories.
87    #[serde(default)]
88    pub git_ignore_parents: bool,
89
90    /// The maximum depth to recurse.
91    #[serde(default)]
92    pub max_depth: Option<usize>,
93
94    /// Whether to ignore files above the specified limit.
95    #[serde(default)]
96    pub max_filesize: Option<u64>,
97
98    #[serde(default)]
99    pub height: Option<String>,
100}
101
102/// Configuration for the plugin
103#[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}