Skip to main content

mdbook_files/
lib.rs

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