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#[derive(Deserialize, Debug)]
19#[serde(deny_unknown_fields)]
20pub struct Files {
21 pub path: Utf8PathBuf,
23
24 #[serde(default)]
30 pub files: Vec<String>,
31
32 #[serde(default)]
34 pub default_file: Option<Utf8PathBuf>,
35
36 #[serde(default)]
38 pub ignore_case_insensitive: bool,
39
40 #[serde(default)]
45 pub same_file_system: bool,
46
47 #[serde(default)]
49 pub types: Vec<String>,
50
51 #[serde(default)]
53 pub hidden: bool,
54
55 #[serde(default)]
57 pub follow_links: bool,
58
59 #[serde(default)]
64 pub dot_ignore: bool,
65
66 #[serde(default)]
69 pub git_global: bool,
70
71 #[serde(default)]
73 pub git_exclude: bool,
74
75 #[serde(default)]
77 pub git_ignore: bool,
78
79 #[serde(default)]
82 pub require_git: bool,
83
84 #[serde(default)]
86 pub git_ignore_parents: bool,
87
88 #[serde(default)]
90 pub max_depth: Option<usize>,
91
92 #[serde(default)]
94 pub max_filesize: Option<u64>,
95
96 #[serde(default)]
97 pub height: Option<String>,
98}
99
100#[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}