Skip to main content

microcad_lang_markdown/
mdbook.rs

1// Copyright © 2026 The µcad authors <info@microcad.xyz>
2// SPDX-License-Identifier: AGPL-3.0-or-later
3
4//! µcad markdown book support.
5
6use std::collections::HashMap;
7
8use thiserror::Error;
9
10use crate::{CodeBlock, Markdown, MarkdownError};
11
12#[derive(Debug, Error)]
13pub enum MdBookError {
14    /// Io Error
15    #[error("IO error: {0}")]
16    IoError(#[from] std::io::Error),
17
18    /// The directory does not contain an mdbook.
19    #[error("No mdbook in directory: {0}")]
20    NoMdBookDirectory(std::path::PathBuf),
21
22    #[error("Error saving markdown file `{file}`: {err}")]
23    Save {
24        file: std::path::PathBuf,
25        err: MarkdownError,
26    },
27}
28
29/// Directory that contains a markdown book.
30pub struct MdBook {
31    pub name: String,
32
33    /// Relative paths to `src` folder in md book folder
34    pub md_files: HashMap<std::path::PathBuf, Markdown>,
35
36    /// Source directory inside book, usually `src`.
37    pub src_path: std::path::PathBuf,
38}
39
40impl MdBook {
41    /// Create a new [`MdBookDirectory`].
42    ///
43    /// Will fail if the directory does not contain a `book.toml` file.
44    /// Scans the directory `src` recursively for markdown files ending with `.md`.
45    pub fn new(path: impl AsRef<std::path::Path>) -> Result<Self, MdBookError> {
46        let root = path.as_ref();
47
48        let root = if root.ends_with("book.toml") {
49            root.parent()
50                .map(|path| path.to_path_buf())
51                .unwrap_or(std::env::current_dir()?)
52        } else {
53            root.to_path_buf()
54        };
55
56        // 1. Validate book.toml existence
57        if !root.join("book.toml").exists() {
58            return Err(MdBookError::NoMdBookDirectory(root));
59        }
60
61        // 2. Identify the src directory
62        let src_path = root.join("src");
63        let mut md_files = Vec::new();
64
65        // 3. Recursively scan src for .md files
66        if src_path.exists() && src_path.is_dir() {
67            Self::visit_dirs(&src_path, &src_path, &mut md_files);
68        }
69
70        let md_files = md_files
71            .iter()
72            .map(|md_file| {
73                (
74                    md_file.clone(),
75                    Markdown::load(src_path.join(md_file))
76                        .unwrap_or_else(|_| panic!("No error: {}", md_file.display())),
77                )
78            })
79            .collect();
80
81        let name = root
82            .file_name()
83            .expect("Some directory name")
84            .to_str()
85            .expect("Valid string")
86            .to_string();
87
88        Ok(Self {
89            name,
90            src_path,
91            md_files,
92        })
93    }
94
95    pub fn abs_md_file(&self, md_file: impl AsRef<std::path::Path>) -> std::path::PathBuf {
96        self.src_path.join(md_file.as_ref())
97    }
98
99    pub fn save_all(&self) -> Result<(), MdBookError> {
100        self.md_files.iter().try_for_each(|(md_file, md)| {
101            md.save(self.abs_md_file(md_file))
102                .map_err(|err| MdBookError::Save {
103                    file: md_file.clone(),
104                    err,
105                })
106        })
107    }
108
109    /// Returns an iterator over all code blocks in the entire document.
110    pub fn code_blocks(&self) -> impl Iterator<Item = (std::path::PathBuf, &CodeBlock)> {
111        self.md_files.iter().flat_map(|(md_file, md)| {
112            md.code_blocks()
113                .map(|code_block| (md_file.clone(), code_block))
114        })
115    }
116
117    /// Returns an iterator over all code blocks in the entire document.
118    pub fn code_blocks_mut(
119        &mut self,
120    ) -> impl Iterator<Item = (std::path::PathBuf, &mut CodeBlock)> {
121        self.md_files.iter_mut().flat_map(|(md_file, md)| {
122            md.code_blocks_mut()
123                .map(|code_block| (md_file.clone(), code_block))
124        })
125    }
126
127    /// Helper to recursively find markdown files.
128    ///
129    /// Stores paths relative to the `src` folder.
130    fn visit_dirs(
131        src_root: &std::path::Path,
132        dir: &std::path::Path,
133        cb: &mut Vec<std::path::PathBuf>,
134    ) {
135        if let Ok(entries) = std::fs::read_dir(dir) {
136            for entry in entries.flatten() {
137                let path = entry.path();
138                if path.is_dir() {
139                    Self::visit_dirs(src_root, &path, cb);
140                } else if path.extension().and_then(|s| s.to_str()) == Some("md") {
141                    // Strip the src_root prefix to keep paths relative to src
142                    if let Ok(relative) = path.strip_prefix(src_root) {
143                        cb.push(relative.to_path_buf());
144                    }
145                }
146            }
147        }
148    }
149}