Skip to main content

mdbook_embedify/
embed.rs

1use crate::assets::scripts::include;
2use crate::parser;
3use crate::utils;
4
5use mdbook_core::{
6    book::{Book, Chapter},
7    errors::Error,
8};
9use mdbook_preprocessor::{Preprocessor, PreprocessorContext};
10use regex::Regex;
11use rust_embed::RustEmbed;
12
13// Compile regex patterns once for reuse
14lazy_static::lazy_static! {
15    static ref RE_EMBED_MACRO: Regex = Regex::new(r"\{%\s+.*?\s+%\}").unwrap();
16    static ref RE_IGNORE: Regex = Regex::new(r"(?si)<!--\s*embed\s+ignore\s+begin\s*-->(.*?)<!--\s*embed\s+ignore\s+end\s*-->").unwrap();
17    static ref RE_EMBED_IGNORE: Regex = Regex::new(r"\{%\s+embed-ignore\s+(.*?)\s+%\}").unwrap();
18}
19
20#[derive(RustEmbed)]
21#[folder = "src/assets/templates"]
22struct Assets;
23
24pub struct Embed;
25
26impl Embed {
27    pub fn new() -> Embed {
28        Embed
29    }
30}
31
32fn render_script_app(
33    ctx: &PreprocessorContext,
34    app: parser::EmbedApp,
35) -> Result<Option<String>, String> {
36    match app.name.as_str() {
37        "include" => include::include_script(ctx, app.options).map(Some),
38        _ => Ok(None),
39    }
40}
41
42fn render_template_app(
43    ctx: &PreprocessorContext,
44    app: parser::EmbedApp,
45) -> Result<Option<String>, String> {
46    let mut template = String::new();
47    let app_path = format!("{}.html", app.name);
48
49    // check custom template first
50    let templates_folder =
51        utils::get_config_string(&ctx.config, "custom-templates-folder", "assets/templates");
52    if !templates_folder.is_empty() {
53        let joined_folder = ctx.root.join(templates_folder);
54        let joined_folder = joined_folder.to_string_lossy().to_string();
55        let template_path = format!("{}/{}", joined_folder, app_path);
56        if std::path::Path::new(&template_path).exists() {
57            template = std::fs::read_to_string(&template_path).unwrap_or_else(|_| String::new());
58        }
59    }
60
61    // if custom template not found, use the default template
62    if template.is_empty() && Assets::iter().any(|name| name == app_path) {
63        // get the template from the embedded files
64        let file = Assets::get(&app_path).unwrap();
65        template = String::from_utf8(file.data.to_vec()).unwrap_or_else(|_| String::new());
66    }
67
68    // No template found
69    if template.is_empty() {
70        return Ok(None);
71    }
72
73    // Use a mutable flag to track if we need to exit early
74    let mut should_exit = false;
75
76    let result = RE_EMBED_MACRO
77        .replace_all(&template, |caps: &regex::Captures| {
78            if should_exit {
79                return "".to_string(); // Short-circuit further replacements
80            }
81
82            let input = caps.get(0).map_or("", |m| m.as_str());
83            let placeholder = parser::parse_placeholder(input);
84
85            if placeholder.is_none() {
86                return input.to_string();
87            }
88
89            // find the value in the options
90            let placeholder = placeholder.unwrap();
91            let found = app
92                .options
93                .iter()
94                .find(|option| option.name == placeholder.key);
95
96            // check if the option is required and not set
97            if placeholder.default.is_empty() {
98                if found.is_none() || found.unwrap().value.is_empty() {
99                    should_exit = true;
100                    return input.to_string();
101                }
102            }
103
104            // when the option value is set, use it, otherwise use the default value
105            let mut value = if found.is_some() && !found.unwrap().value.is_empty() {
106                found.unwrap().value.clone()
107            } else {
108                placeholder.default.clone()
109            };
110
111            // render the value with the method
112            if placeholder.method == "markdown" {
113                value = utils::render_to_markdown(value.clone());
114            }
115
116            value
117        })
118        .to_string();
119
120    // If the flag is set, return error
121    if should_exit {
122        return Err("Missing required options".to_string());
123    }
124
125    Ok(Some(utils::minify_html(result)))
126}
127
128fn render_embeds(ctx: &PreprocessorContext, chapter: Chapter, content: String) -> String {
129    let mut content = content;
130    if chapter.is_draft_chapter() {
131        return content; // Skip processing if the chapter is a draft
132    }
133
134    let chapter_path = chapter.path.unwrap().clone(); // Clone chapter path to avoid consuming it
135
136    // Collect and replace all ignored sections in a single pass
137    let mut ignored_sections: Vec<(String, String)> = Vec::new();
138
139    content = RE_IGNORE
140        .replace_all(&content, |caps: &regex::Captures| {
141            // Use a highly unique placeholder to avoid any possible conflicts
142            let placeholder = format!(
143                "__MDBOOK_EMBEDIFY_IGNORE_{}_{:x}__",
144                ignored_sections.len(),
145                std::ptr::addr_of!(ignored_sections) as usize
146            );
147            let ignored_content = caps.get(0).unwrap().as_str();
148
149            ignored_sections.push((placeholder.clone(), ignored_content.to_string()));
150            placeholder
151        })
152        .to_string();
153
154    content = RE_EMBED_MACRO
155        .replace_all(&content, |caps: &regex::Captures| {
156            let input = caps.get(0).map_or("", |m| m.as_str());
157            let app = parser::parse_app(input);
158            if app.is_none() {
159                return input.to_string();
160            }
161            let app = app.unwrap();
162
163            // render template app first
164            let mut rendered = render_template_app(ctx, app.clone());
165
166            // when is ok, but not rendered, try to render script app
167            if rendered.is_ok() && rendered.as_ref().unwrap().is_none() {
168                rendered = render_script_app(ctx, app.clone());
169            }
170
171            // if failed, print the error and return the input
172            if !rendered.is_ok() {
173                let err = rendered.err().unwrap();
174                eprintln!(
175                    "(mdbook-embedify): Error while rendering app \"{}\" in {:?}. {}",
176                    app.name, chapter_path, err
177                );
178                return input.to_string();
179            }
180
181            // if the app is not rendered, return the input
182            if rendered.as_ref().unwrap().is_none() {
183                return input.to_string();
184            }
185
186            // unwrap the result
187            // Convert app.options to JSON string for data attribute
188            let options_string = {
189                let mut json_parts = Vec::new();
190                for option in &app.options {
191                    json_parts.push(format!(
192                        "data-option-{}=\"{}\"",
193                        option.name,
194                        option.value
195                    ));
196                }
197                format!("{}", json_parts.join(" "))
198            };
199
200            format!(
201                "<!-- {} -->\n\n<div data-embedify data-app=\"{}\" {} style=\"display:none\"></div>\n\n{}",
202                input,
203                app.name,
204                options_string,
205                rendered.unwrap().unwrap()
206            )
207        })
208        .to_string();
209
210    // Handle embed-ignore syntax - replace {% embed-ignore %} with {% embed %}
211    // This allows the content to be processed as a regular embed without rendering
212    content = RE_EMBED_IGNORE
213        .replace_all(&content, |caps: &regex::Captures| {
214            let content_part = caps.get(1).map_or("", |m| m.as_str());
215            format!("{{% embed {} %}}", content_part)
216        })
217        .to_string();
218
219    // Restore ignored sections efficiently with exact matching
220    // Process in reverse order of creation to avoid index conflicts
221    for (placeholder, ignored_content) in ignored_sections.into_iter().rev() {
222        // Use exact string replacement to avoid partial matches
223        if let Some(pos) = content.find(&placeholder) {
224            content.replace_range(pos..pos + placeholder.len(), &ignored_content);
225        }
226    }
227
228    content
229}
230
231impl Preprocessor for Embed {
232    fn name(&self) -> &str {
233        "mdbook-embedify"
234    }
235
236    fn run(&self, ctx: &PreprocessorContext, mut book: Book) -> Result<Book, Error> {
237        let config = &ctx.config;
238
239        let footer = utils::get_config_bool(config, "footer.enable");
240        let giscus = utils::get_config_bool(config, "giscus.enable");
241        let scroll_to_top = utils::get_config_bool(config, "scroll-to-top.enable");
242        let announcement_banner = utils::get_config_bool(config, "announcement-banner.enable");
243
244        book.for_each_mut(|item| {
245            if let mdbook_core::book::BookItem::Chapter(chapter) = item {
246                let mut content = chapter.content.clone();
247                // create the global scroll to top button
248                if scroll_to_top {
249                    content.push_str(&utils::create_scroll_to_top());
250                }
251                // create the global announcement banner
252                if announcement_banner {
253                    content.push_str(&utils::create_announcement_banner(config));
254                }
255                // create the global giscus comments
256                if giscus {
257                    content.push_str(&utils::create_giscus(config));
258                }
259                // create the global footer
260                if footer {
261                    content.push_str(&utils::create_footer(config));
262                }
263                // render the embeds in the content
264                chapter.content = render_embeds(ctx, chapter.clone(), content);
265            }
266        });
267
268        // return the book
269        Ok(book)
270    }
271}