1use crate::book::{Book, BookItem};
2use crate::config::{BookConfig, Config, HtmlConfig, Playground, PlaygroundLanguage, RustEdition};
3use crate::errors::*;
4use crate::renderer::html_handlebars::helpers;
5use crate::renderer::{RenderContext, Renderer};
6use crate::theme::{self, playground_editor, Theme};
7use crate::utils;
8
9use std::borrow::Cow;
10use std::collections::BTreeMap;
11use std::collections::HashMap;
12use std::fs::{self, File};
13use std::path::{Path, PathBuf};
14
15use crate::utils::fs::get_404_output_file;
16use handlebars::Handlebars;
17use log::{debug, trace, warn};
18use once_cell::sync::Lazy;
19use regex::{escape, Captures, Regex};
20use serde_json::json;
21
22#[derive(Default)]
23pub struct HtmlHandlebars;
24
25impl HtmlHandlebars {
26 pub fn new() -> Self {
27 HtmlHandlebars
28 }
29
30 fn render_item(
31 &self,
32 item: &BookItem,
33 mut ctx: RenderItemContext<'_>,
34 print_content: &mut String,
35 ) -> Result<()> {
36 let (ch, path) = match item {
39 BookItem::Chapter(ch) if !ch.is_draft_chapter() => (ch, ch.path.as_ref().unwrap()),
40 _ => return Ok(()),
41 };
42
43 if let Some(ref edit_url_template) = ctx.html_config.edit_url_template {
44 let full_path = ctx.book_config.src.to_str().unwrap_or_default().to_owned()
45 + "/"
46 + ch.source_path
47 .clone()
48 .unwrap_or_default()
49 .to_str()
50 .unwrap_or_default();
51
52 let edit_url = edit_url_template.replace("{path}", &full_path);
53 ctx.data
54 .insert("git_repository_edit_url".to_owned(), json!(edit_url));
55 }
56
57 let content = ch.content.clone();
58 let content = utils::render_markdown(&content, ctx.html_config.curly_quotes);
59
60 let fixed_content =
61 utils::render_markdown_with_path(&ch.content, ctx.html_config.curly_quotes, Some(path));
62 if !ctx.is_index && ctx.html_config.print.page_break {
63 print_content
67 .push_str(r#"<div style="break-before: page; page-break-before: always;"></div>"#);
68 }
69 print_content.push_str(&fixed_content);
70
71 let ctx_path = path
73 .to_str()
74 .with_context(|| "Could not convert path to str")?;
75 let filepath = Path::new(&ctx_path).with_extension("html");
76
77 if path == Path::new("print.md") {
79 bail!("{} is reserved for internal use", path.display());
80 };
81
82 let book_title = ctx
83 .data
84 .get("book_title")
85 .and_then(serde_json::Value::as_str)
86 .unwrap_or("");
87
88 let title = if let Some(title) = ctx.chapter_titles.get(path) {
89 title.clone()
90 } else if book_title.is_empty() {
91 ch.name.clone()
92 } else {
93 ch.name.clone() + " - " + book_title
94 };
95
96 ctx.data.insert("path".to_owned(), json!(path));
97 ctx.data.insert("content".to_owned(), json!(content));
98 ctx.data.insert("chapter_title".to_owned(), json!(ch.name));
99 ctx.data.insert("title".to_owned(), json!(title));
100 ctx.data.insert(
101 "path_to_root".to_owned(),
102 json!(utils::fs::path_to_root(&path)),
103 );
104 if let Some(ref section) = ch.number {
105 ctx.data
106 .insert("section".to_owned(), json!(section.to_string()));
107 }
108
109 debug!("Render template");
111 let rendered = ctx.handlebars.render("index", &ctx.data)?;
112
113 let rendered = self.post_process(rendered, &ctx.html_config.playground, ctx.edition);
114
115 debug!("Creating {}", filepath.display());
117 utils::fs::write_file(&ctx.destination, &filepath, rendered.as_bytes())?;
118
119 if ctx.is_index {
120 ctx.data.insert("path".to_owned(), json!("index.md"));
121 ctx.data.insert("path_to_root".to_owned(), json!(""));
122 ctx.data.insert("is_index".to_owned(), json!(true));
123 let rendered_index = ctx.handlebars.render("index", &ctx.data)?;
124 let rendered_index =
125 self.post_process(rendered_index, &ctx.html_config.playground, ctx.edition);
126 debug!("Creating index.html from {}", ctx_path);
127 utils::fs::write_file(&ctx.destination, "index.html", rendered_index.as_bytes())?;
128 }
129
130 Ok(())
131 }
132
133 fn render_404(
134 &self,
135 ctx: &RenderContext,
136 html_config: &HtmlConfig,
137 src_dir: &Path,
138 handlebars: &mut Handlebars<'_>,
139 data: &mut serde_json::Map<String, serde_json::Value>,
140 ) -> Result<()> {
141 let destination = &ctx.destination;
142 let content_404 = if let Some(ref filename) = html_config.input_404 {
143 let path = src_dir.join(filename);
144 std::fs::read_to_string(&path)
145 .with_context(|| format!("unable to open 404 input file {:?}", path))?
146 } else {
147 let default_404_location = src_dir.join("404.md");
149 if default_404_location.exists() {
150 std::fs::read_to_string(&default_404_location).with_context(|| {
151 format!("unable to open 404 input file {:?}", default_404_location)
152 })?
153 } else {
154 "# Document not found (404)\n\nThis URL is invalid, sorry. Please use the \
155 navigation bar or search to continue."
156 .to_string()
157 }
158 };
159 let html_content_404 = utils::render_markdown(&content_404, html_config.curly_quotes);
160
161 let mut data_404 = data.clone();
162 let base_url = if let Some(site_url) = &html_config.site_url {
163 site_url
164 } else {
165 debug!(
166 "HTML 'site-url' parameter not set, defaulting to '/'. Please configure \
167 this to ensure the 404 page work correctly, especially if your site is hosted in a \
168 subdirectory on the HTTP server."
169 );
170 "/"
171 };
172 data_404.insert("base_url".to_owned(), json!(base_url));
173 data_404.insert("path".to_owned(), json!("404.md"));
175 data_404.insert("content".to_owned(), json!(html_content_404));
176
177 let mut title = String::from("Page not found");
178 if let Some(book_title) = &ctx.config.book.title {
179 title.push_str(" - ");
180 title.push_str(book_title);
181 }
182 data_404.insert("title".to_owned(), json!(title));
183 let rendered = handlebars.render("index", &data_404)?;
184
185 let rendered =
186 self.post_process(rendered, &html_config.playground, ctx.config.rust.edition);
187 let output_file = get_404_output_file(&html_config.input_404);
188 utils::fs::write_file(destination, output_file, rendered.as_bytes())?;
189 debug!("Creating 404.html ✓");
190 Ok(())
191 }
192
193 #[cfg_attr(feature = "cargo-clippy", allow(clippy::let_and_return))]
194 fn post_process(
195 &self,
196 rendered: String,
197 playground_config: &Playground,
198 edition: Option<RustEdition>,
199 ) -> String {
200 let rendered = build_header_links(&rendered);
201 let rendered = fix_code_blocks(&rendered);
202 let rendered = add_playground_pre(&rendered, playground_config, edition);
203
204 rendered
205 }
206
207 fn copy_static_files(
208 &self,
209 destination: &Path,
210 theme: &Theme,
211 html_config: &HtmlConfig,
212 ) -> Result<()> {
213 use crate::utils::fs::write_file;
214
215 write_file(
216 destination,
217 ".nojekyll",
218 b"This file makes sure that Github Pages doesn't process mdBook's output.\n",
219 )?;
220
221 if let Some(cname) = &html_config.cname {
222 write_file(destination, "CNAME", format!("{}\n", cname).as_bytes())?;
223 }
224
225 write_file(destination, "book.js", &theme.js)?;
226 write_file(destination, "css/general.css", &theme.general_css)?;
227 write_file(destination, "css/chrome.css", &theme.chrome_css)?;
228 if html_config.print.enable {
229 write_file(destination, "css/print.css", &theme.print_css)?;
230 }
231 write_file(destination, "css/variables.css", &theme.variables_css)?;
232 if let Some(contents) = &theme.favicon_png {
233 write_file(destination, "favicon.png", contents)?;
234 }
235 if let Some(contents) = &theme.favicon_svg {
236 write_file(destination, "favicon.svg", contents)?;
237 }
238 write_file(destination, "highlight.css", &theme.highlight_css)?;
239 write_file(destination, "tomorrow-night.css", &theme.tomorrow_night_css)?;
240 write_file(destination, "ayu-highlight.css", &theme.ayu_highlight_css)?;
241 write_file(destination, "highlight.js", &theme.highlight_js)?;
242 write_file(destination, "clipboard.min.js", &theme.clipboard_js)?;
243 write_file(
244 destination,
245 "FontAwesome/css/font-awesome.css",
246 theme::FONT_AWESOME,
247 )?;
248 write_file(
249 destination,
250 "FontAwesome/fonts/fontawesome-webfont.eot",
251 theme::FONT_AWESOME_EOT,
252 )?;
253 write_file(
254 destination,
255 "FontAwesome/fonts/fontawesome-webfont.svg",
256 theme::FONT_AWESOME_SVG,
257 )?;
258 write_file(
259 destination,
260 "FontAwesome/fonts/fontawesome-webfont.ttf",
261 theme::FONT_AWESOME_TTF,
262 )?;
263 write_file(
264 destination,
265 "FontAwesome/fonts/fontawesome-webfont.woff",
266 theme::FONT_AWESOME_WOFF,
267 )?;
268 write_file(
269 destination,
270 "FontAwesome/fonts/fontawesome-webfont.woff2",
271 theme::FONT_AWESOME_WOFF2,
272 )?;
273 write_file(
274 destination,
275 "FontAwesome/fonts/FontAwesome.ttf",
276 theme::FONT_AWESOME_TTF,
277 )?;
278 if html_config.copy_fonts {
279 write_file(destination, "fonts/fonts.css", theme::fonts::CSS)?;
280 for (file_name, contents) in theme::fonts::LICENSES.iter() {
281 write_file(destination, file_name, contents)?;
282 }
283 for (file_name, contents) in theme::fonts::OPEN_SANS.iter() {
284 write_file(destination, file_name, contents)?;
285 }
286 write_file(
287 destination,
288 theme::fonts::SOURCE_CODE_PRO.0,
289 theme::fonts::SOURCE_CODE_PRO.1,
290 )?;
291 }
292
293 let playground_config = &html_config.playground;
294
295 if playground_config.editable && playground_config.copy_js {
297 write_file(destination, "editor.js", playground_editor::JS)?;
299 write_file(destination, "ace.js", playground_editor::ACE_JS)?;
300 write_file(destination, "mode-rust.js", playground_editor::MODE_RUST_JS)?;
301 write_file(
302 destination,
303 "mode-c_cpp.js",
304 playground_editor::MODE_C_CPP_JS,
305 )?;
306 write_file(
307 destination,
308 "theme-dawn.js",
309 playground_editor::THEME_DAWN_JS,
310 )?;
311 write_file(
312 destination,
313 "theme-monokai.js",
314 playground_editor::THEME_MONOKAI_JS,
315 )?;
316 write_file(
317 destination,
318 "theme-tomorrow_night.js",
319 playground_editor::THEME_TOMORROW_NIGHT_JS,
320 )?;
321 }
322
323 Ok(())
324 }
325
326 fn configure_print_version(
328 &self,
329 data: &mut serde_json::Map<String, serde_json::Value>,
330 print_content: &str,
331 ) {
332 data.remove("title");
335 data.insert("is_print".to_owned(), json!(true));
336 data.insert("path".to_owned(), json!("print.md"));
337 data.insert("content".to_owned(), json!(print_content));
338 data.insert(
339 "path_to_root".to_owned(),
340 json!(utils::fs::path_to_root(Path::new("print.md"))),
341 );
342 }
343
344 fn register_hbs_helpers(&self, handlebars: &mut Handlebars<'_>, html_config: &HtmlConfig) {
345 handlebars.register_helper(
346 "toc",
347 Box::new(helpers::toc::RenderToc {
348 no_section_label: html_config.no_section_label,
349 }),
350 );
351 handlebars.register_helper("previous", Box::new(helpers::navigation::previous));
352 handlebars.register_helper("next", Box::new(helpers::navigation::next));
353 handlebars.register_helper("theme_option", Box::new(helpers::theme::theme_option));
355 }
356
357 fn copy_additional_css_and_js(
360 &self,
361 html: &HtmlConfig,
362 root: &Path,
363 destination: &Path,
364 ) -> Result<()> {
365 let custom_files = html.additional_css.iter().chain(html.additional_js.iter());
366
367 debug!("Copying additional CSS and JS");
368
369 for custom_file in custom_files {
370 let input_location = root.join(custom_file);
371 let output_location = destination.join(custom_file);
372 if let Some(parent) = output_location.parent() {
373 fs::create_dir_all(parent)
374 .with_context(|| format!("Unable to create {}", parent.display()))?;
375 }
376 debug!(
377 "Copying {} -> {}",
378 input_location.display(),
379 output_location.display()
380 );
381
382 fs::copy(&input_location, &output_location).with_context(|| {
383 format!(
384 "Unable to copy {} to {}",
385 input_location.display(),
386 output_location.display()
387 )
388 })?;
389 }
390
391 Ok(())
392 }
393
394 fn emit_redirects(
395 &self,
396 root: &Path,
397 handlebars: &Handlebars<'_>,
398 redirects: &HashMap<String, String>,
399 ) -> Result<()> {
400 if redirects.is_empty() {
401 return Ok(());
402 }
403
404 log::debug!("Emitting redirects");
405
406 for (original, new) in redirects {
407 log::debug!("Redirecting \"{}\" → \"{}\"", original, new);
408 let original = original.trim_start_matches('/');
412 let filename = root.join(original);
413 self.emit_redirect(handlebars, &filename, new)?;
414 }
415
416 Ok(())
417 }
418
419 fn emit_redirect(
420 &self,
421 handlebars: &Handlebars<'_>,
422 original: &Path,
423 destination: &str,
424 ) -> Result<()> {
425 if original.exists() {
426 let msg = format!(
428 "Not redirecting \"{}\" to \"{}\" because it already exists. Are you sure it needs to be redirected?",
429 original.display(),
430 destination,
431 );
432 return Err(Error::msg(msg));
433 }
434
435 if let Some(parent) = original.parent() {
436 std::fs::create_dir_all(parent)
437 .with_context(|| format!("Unable to ensure \"{}\" exists", parent.display()))?;
438 }
439
440 let ctx = json!({
441 "url": destination,
442 });
443 let f = File::create(original)?;
444 handlebars
445 .render_to_write("redirect", &ctx, f)
446 .with_context(|| {
447 format!(
448 "Unable to create a redirect file at \"{}\"",
449 original.display()
450 )
451 })?;
452
453 Ok(())
454 }
455}
456
457fn maybe_wrong_theme_dir(dir: &Path) -> Result<bool> {
459 fn entry_is_maybe_book_file(entry: fs::DirEntry) -> Result<bool> {
460 Ok(entry.file_type()?.is_file()
461 && entry.path().extension().map_or(false, |ext| ext == "md"))
462 }
463
464 if dir.is_dir() {
465 for entry in fs::read_dir(dir)? {
466 if entry_is_maybe_book_file(entry?).unwrap_or(false) {
467 return Ok(false);
468 }
469 }
470 Ok(true)
471 } else {
472 Ok(false)
473 }
474}
475
476impl Renderer for HtmlHandlebars {
477 fn name(&self) -> &str {
478 "html"
479 }
480
481 fn render(&self, ctx: &RenderContext) -> Result<()> {
482 let book_config = &ctx.config.book;
483 let html_config = ctx.config.html_config().unwrap_or_default();
484 let src_dir = ctx.root.join(&ctx.config.book.src);
485 let destination = &ctx.destination;
486 let book = &ctx.book;
487 let build_dir = ctx.root.join(&ctx.config.build.build_dir);
488
489 if destination.exists() {
490 utils::fs::remove_dir_content(destination)
491 .with_context(|| "Unable to remove stale HTML output")?;
492 }
493
494 trace!("render");
495 let mut handlebars = Handlebars::new();
496
497 let theme_dir = match html_config.theme {
498 Some(ref theme) => {
499 let dir = ctx.root.join(theme);
500 if !dir.is_dir() {
501 bail!("theme dir {} does not exist", dir.display());
502 }
503 dir
504 }
505 None => ctx.root.join("theme"),
506 };
507
508 if html_config.theme.is_none()
509 && maybe_wrong_theme_dir(&src_dir.join("theme")).unwrap_or(false)
510 {
511 warn!(
512 "Previous versions of mdBook erroneously accepted `./src/theme` as an automatic \
513 theme directory"
514 );
515 warn!("Please move your theme files to `./theme` for them to continue being used");
516 }
517
518 let theme = theme::Theme::new(theme_dir);
519
520 debug!("Register the index handlebars template");
521 handlebars.register_template_string("index", String::from_utf8(theme.index.clone())?)?;
522
523 debug!("Register the head handlebars template");
524 handlebars.register_partial("head", String::from_utf8(theme.head.clone())?)?;
525
526 debug!("Register the redirect handlebars template");
527 handlebars
528 .register_template_string("redirect", String::from_utf8(theme.redirect.clone())?)?;
529
530 debug!("Register the header handlebars template");
531 handlebars.register_partial("header", String::from_utf8(theme.header.clone())?)?;
532
533 debug!("Register handlebars helpers");
534 self.register_hbs_helpers(&mut handlebars, &html_config);
535
536 let mut data = make_data(&ctx.root, book, &ctx.config, &html_config, &theme)?;
537
538 let mut print_content = String::new();
540
541 fs::create_dir_all(&destination)
542 .with_context(|| "Unexpected error when constructing destination path")?;
543
544 let mut is_index = true;
545 for item in book.iter() {
546 let ctx = RenderItemContext {
547 handlebars: &handlebars,
548 destination: destination.to_path_buf(),
549 data: data.clone(),
550 is_index,
551 book_config: book_config.clone(),
552 html_config: html_config.clone(),
553 edition: ctx.config.rust.edition,
554 chapter_titles: &ctx.chapter_titles,
555 };
556 self.render_item(item, ctx, &mut print_content)?;
557 is_index &= !matches!(item, BookItem::Chapter(ch) if !ch.is_draft_chapter());
559 }
560
561 if html_config.input_404 != Some("".to_string()) {
563 self.render_404(ctx, &html_config, &src_dir, &mut handlebars, &mut data)?;
564 }
565
566 self.configure_print_version(&mut data, &print_content);
568 if let Some(ref title) = ctx.config.book.title {
569 data.insert("title".to_owned(), json!(title));
570 }
571
572 if html_config.print.enable {
574 debug!("Render template");
575 let rendered = handlebars.render("index", &data)?;
576
577 let rendered =
578 self.post_process(rendered, &html_config.playground, ctx.config.rust.edition);
579
580 utils::fs::write_file(destination, "print.html", rendered.as_bytes())?;
581 debug!("Creating print.html ✓");
582 }
583
584 debug!("Copy static files");
585 self.copy_static_files(destination, &theme, &html_config)
586 .with_context(|| "Unable to copy across static files")?;
587 self.copy_additional_css_and_js(&html_config, &ctx.root, destination)
588 .with_context(|| "Unable to copy across additional CSS and JS")?;
589
590 #[cfg(feature = "search")]
592 {
593 let search = html_config.search.unwrap_or_default();
594 if search.enable {
595 super::search::create_files(&search, destination, book)?;
596 }
597 }
598
599 self.emit_redirects(&ctx.destination, &handlebars, &html_config.redirect)
600 .context("Unable to emit redirects")?;
601
602 utils::fs::copy_files_except_ext(&src_dir, destination, true, Some(&build_dir), &["md"])?;
604
605 Ok(())
606 }
607}
608
609fn make_data(
610 root: &Path,
611 book: &Book,
612 config: &Config,
613 html_config: &HtmlConfig,
614 theme: &Theme,
615) -> Result<serde_json::Map<String, serde_json::Value>> {
616 trace!("make_data");
617
618 let mut data = serde_json::Map::new();
619 data.insert(
620 "language".to_owned(),
621 json!(config.book.language.clone().unwrap_or_default()),
622 );
623 data.insert(
624 "book_title".to_owned(),
625 json!(config.book.title.clone().unwrap_or_default()),
626 );
627 data.insert(
628 "description".to_owned(),
629 json!(config.book.description.clone().unwrap_or_default()),
630 );
631 if theme.favicon_png.is_some() {
632 data.insert("favicon_png".to_owned(), json!("favicon.png"));
633 }
634 if theme.favicon_svg.is_some() {
635 data.insert("favicon_svg".to_owned(), json!("favicon.svg"));
636 }
637 if let Some(ref live_reload_endpoint) = html_config.live_reload_endpoint {
638 data.insert(
639 "live_reload_endpoint".to_owned(),
640 json!(live_reload_endpoint),
641 );
642 }
643
644 let default_theme = match html_config.default_theme {
646 Some(ref theme) => theme.to_lowercase(),
647 None => "light".to_string(),
648 };
649 data.insert("default_theme".to_owned(), json!(default_theme));
650
651 let preferred_dark_theme = match html_config.preferred_dark_theme {
652 Some(ref theme) => theme.to_lowercase(),
653 None => "navy".to_string(),
654 };
655 data.insert(
656 "preferred_dark_theme".to_owned(),
657 json!(preferred_dark_theme),
658 );
659
660 if let Some(ref ga) = html_config.google_analytics {
662 data.insert("google_analytics".to_owned(), json!(ga));
663 }
664
665 if html_config.mathjax_support {
666 data.insert("mathjax_support".to_owned(), json!(true));
667 }
668
669 if html_config.copy_fonts {
670 data.insert("copy_fonts".to_owned(), json!(true));
671 }
672
673 if !html_config.additional_css.is_empty() {
675 let mut css = Vec::new();
676 for style in &html_config.additional_css {
677 match style.strip_prefix(root) {
678 Ok(p) => css.push(p.to_str().expect("Could not convert to str")),
679 Err(_) => css.push(style.to_str().expect("Could not convert to str")),
680 }
681 }
682 data.insert("additional_css".to_owned(), json!(css));
683 }
684
685 if !html_config.additional_js.is_empty() {
687 let mut js = Vec::new();
688 for script in &html_config.additional_js {
689 match script.strip_prefix(root) {
690 Ok(p) => js.push(p.to_str().expect("Could not convert to str")),
691 Err(_) => js.push(script.to_str().expect("Could not convert to str")),
692 }
693 }
694 data.insert("additional_js".to_owned(), json!(js));
695 }
696
697 if html_config.playground.editable && html_config.playground.copy_js {
698 data.insert("playground_js".to_owned(), json!(true));
699 if html_config.playground.line_numbers {
700 data.insert("playground_line_numbers".to_owned(), json!(true));
701 }
702 }
703 if html_config.playground.copyable {
704 data.insert("playground_copyable".to_owned(), json!(true));
705 }
706
707 data.insert("print_enable".to_owned(), json!(html_config.print.enable));
708 data.insert("fold_enable".to_owned(), json!(html_config.fold.enable));
709 data.insert("fold_level".to_owned(), json!(html_config.fold.level));
710
711 let search = html_config.search.clone();
712 if cfg!(feature = "search") {
713 let search = search.unwrap_or_default();
714 data.insert("search_enabled".to_owned(), json!(search.enable));
715 data.insert(
716 "search_js".to_owned(),
717 json!(search.enable && search.copy_js),
718 );
719 } else if search.is_some() {
720 warn!("mdBook compiled without search support, ignoring `output.html.search` table");
721 warn!(
722 "please reinstall with `cargo install mdbook --force --features search`to use the \
723 search feature"
724 )
725 }
726
727 if let Some(ref git_repository_url) = html_config.git_repository_url {
728 data.insert("git_repository_url".to_owned(), json!(git_repository_url));
729 }
730
731 let git_repository_icon = match html_config.git_repository_icon {
732 Some(ref git_repository_icon) => git_repository_icon,
733 None => "fa-github",
734 };
735 data.insert("git_repository_icon".to_owned(), json!(git_repository_icon));
736
737 let mut chapters = vec![];
738
739 for item in book.iter() {
740 let mut chapter = BTreeMap::new();
742
743 match *item {
744 BookItem::PartTitle(ref title) => {
745 chapter.insert("part".to_owned(), json!(title));
746 }
747 BookItem::Chapter(ref ch) => {
748 if let Some(ref section) = ch.number {
749 chapter.insert("section".to_owned(), json!(section.to_string()));
750 }
751
752 chapter.insert(
753 "has_sub_items".to_owned(),
754 json!((!ch.sub_items.is_empty()).to_string()),
755 );
756
757 chapter.insert("name".to_owned(), json!(ch.name));
758 if let Some(ref path) = ch.path {
759 let p = path
760 .to_str()
761 .with_context(|| "Could not convert path to str")?;
762 chapter.insert("path".to_owned(), json!(p));
763 }
764 }
765 BookItem::Separator => {
766 chapter.insert("spacer".to_owned(), json!("_spacer_"));
767 }
768 }
769
770 chapters.push(chapter);
771 }
772
773 data.insert("chapters".to_owned(), json!(chapters));
774
775 debug!("[*]: JSON constructed");
776 Ok(data)
777}
778
779fn build_header_links(html: &str) -> String {
782 static BUILD_HEADER_LINKS: Lazy<Regex> =
783 Lazy::new(|| Regex::new(r"<h(\d)>(.*?)</h\d>").unwrap());
784
785 let mut id_counter = HashMap::new();
786
787 BUILD_HEADER_LINKS
788 .replace_all(html, |caps: &Captures<'_>| {
789 let level = caps[1]
790 .parse()
791 .expect("Regex should ensure we only ever get numbers here");
792
793 insert_link_into_header(level, &caps[2], &mut id_counter)
794 })
795 .into_owned()
796}
797
798fn insert_link_into_header(
801 level: usize,
802 content: &str,
803 id_counter: &mut HashMap<String, usize>,
804) -> String {
805 let id = utils::unique_id_from_content(content, id_counter);
806
807 format!(
808 r##"<h{level} id="{id}"><a class="header" href="#{id}">{text}</a></h{level}>"##,
809 level = level,
810 id = id,
811 text = content
812 )
813}
814
815fn fix_code_blocks(html: &str) -> String {
824 static FIX_CODE_BLOCKS: Lazy<Regex> =
825 Lazy::new(|| Regex::new(r##"<code([^>]+)class="([^"]+)"([^>]*)>"##).unwrap());
826
827 FIX_CODE_BLOCKS
828 .replace_all(html, |caps: &Captures<'_>| {
829 let before = &caps[1];
830 let classes = &caps[2].replace(',', " ");
831 let after = &caps[3];
832
833 format!(
834 r#"<code{before}class="{classes}"{after}>"#,
835 before = before,
836 classes = classes,
837 after = after
838 )
839 })
840 .into_owned()
841}
842
843struct PlaygroundConfig {
844 language: Option<String>,
845 runnable: bool,
846 editable: bool,
847 rust_edition: String,
848}
849
850fn add_playground_pre(
851 html: &str,
852 playground_config: &Playground,
853 edition: Option<RustEdition>,
854) -> String {
855 static ADD_PLAYGROUND_PRE: Lazy<Regex> =
856 Lazy::new(|| Regex::new(r##"((?s)<code[^>]?class="([^"]+)".*?>(.*?)</code>)"##).unwrap());
857
858 ADD_PLAYGROUND_PRE
859 .replace_all(html, |caps: &Captures<'_>| {
860 let text = &caps[1];
861 let classes = &caps[2];
862 let code = &caps[3];
863
864 let mut playground_languages: HashMap<String, PlaygroundLanguage> = playground_config.languages.iter().map(|lang| (lang.language.to_owned(), lang.to_owned())).collect();
865
866 let contains_rust = playground_languages.get("rust").is_some();
867 if !contains_rust {
868 playground_languages.insert(String::from("rust"), PlaygroundLanguage::default());
869 }
870
871 let language = classes.split_ascii_whitespace().find_map(|class| if class.contains("language-") { match class.get(9..) {
872 Some(l) => Some(l.to_string()),
873 None => None,
874 }} else { None });
875 let playground_config = PlaygroundConfig {
876 language,
877 runnable: (!classes.contains("ignore")
878 && !classes.contains("noplayground")
879 && !classes.contains("noplaypen")
880 && playground_config.runnable)
881 || classes.contains("mdbook-runnable"),
882 editable: classes.contains("editable"),
883 rust_edition: if classes.contains("edition2015") || classes.contains("edition2018") || classes.contains("edition2021") { String::from("") } else {
884 match edition {
885 Some(RustEdition::E2015) => String::from("edition2015"),
886 Some(RustEdition::E2018) => String::from("edition2018"),
887 Some(RustEdition::E2021) => String::from("edition2021"),
888 None => String::from(""),
889 }
890 },
891 };
892 let rust_lang = playground_languages.get("rust").unwrap();
893 if playground_config.language == Some(String::from("rust")) {
894 if playground_config.runnable {
895 format!(
897 "<pre class=\"playground\"><code class=\"{}{}{}\">{}</code></pre>",
898 classes,
899 if playground_config.rust_edition != "" { " " } else { "" },
900 playground_config.rust_edition,
901 {
902 let content: Cow<'_, str> = if playground_config.editable
903 && classes.contains("editable")
904 || text.contains("fn main")
905 || text.contains("quick_main!")
906 {
907 code.into()
908 } else {
909 let (attrs, code) = partition_source(code);
911
912 format!("# #![allow(unused)]\n{}#fn main() {{\n{}#}}", attrs, code)
913 .into()
914 };
915 hide_lines(&content, &rust_lang.hidelines)
916 }
917 )
918 } else {
919 format!("<code class=\"{}\">{}</code>", classes, hide_lines(code, &rust_lang.hidelines))
920 }
921 } else if playground_config.language.is_some() {
922 if playground_config.runnable {
923 let language = playground_config.language.unwrap();
924 let play_lang = playground_languages.get(&language);
925 match play_lang {
926 Some(play_lang) => {
927 format!(
928 "<pre data-endpoint={} class=\"playground\"><code class=\"{}\">{}</code></pre>",
929 play_lang.endpoint,
930 classes,
931 {
932 let content: Cow<'_, str> = code.into();
933 hide_lines(&content, &play_lang.hidelines)
934 }
935 )
936 },
937 None => {
938 format!("<code class=\"{}\">{}</code>", classes, hide_lines(code, &rust_lang.hidelines))
939 }
940 }
941
942 } else {
943 format!("<code class=\"{}\">{}</code>", classes, hide_lines(code, &rust_lang.hidelines))
944 }
945 } else {
946 text.to_owned()
948 }
949 })
950 .into_owned()
951}
952
953fn hide_lines(content: &str, hidden: &str) -> String {
954 let hidden_regex = format!(r"^(\s*){}(.?)(.*)$", escape(hidden));
955 let boring_lines_regex: Regex = Regex::new(&hidden_regex).unwrap();
956
957 let mut result = String::with_capacity(content.len());
958 let mut lines = content.lines().peekable();
959 while let Some(line) = lines.next() {
960 let newline = if lines.peek().is_none() { "" } else { "\n" };
962 if let Some(caps) = boring_lines_regex.captures(line) {
963 if &caps[2] == hidden {
964 result += &caps[1];
965 result += &caps[2];
966 result += &caps[3];
967 result += newline;
968 continue;
969 } else if &caps[2] != "!" && &caps[2] != "[" {
970 result += "<span class=\"boring\">";
971 result += &caps[1];
972 if &caps[2] != " " {
973 result += &caps[2];
974 }
975 result += &caps[3];
976 result += newline;
977 result += "</span>";
978 continue;
979 }
980 }
981 result += line;
982 result += newline;
983 }
984 result
985}
986
987fn partition_source(s: &str) -> (String, String) {
988 let mut after_header = false;
989 let mut before = String::new();
990 let mut after = String::new();
991
992 for line in s.lines() {
993 let trimline = line.trim();
994 let header = trimline.chars().all(char::is_whitespace) || trimline.starts_with("#![");
995 if !header || after_header {
996 after_header = true;
997 after.push_str(line);
998 after.push('\n');
999 } else {
1000 before.push_str(line);
1001 before.push('\n');
1002 }
1003 }
1004
1005 (before, after)
1006}
1007
1008struct RenderItemContext<'a> {
1009 handlebars: &'a Handlebars<'a>,
1010 destination: PathBuf,
1011 data: serde_json::Map<String, serde_json::Value>,
1012 is_index: bool,
1013 book_config: BookConfig,
1014 html_config: HtmlConfig,
1015 edition: Option<RustEdition>,
1016 chapter_titles: &'a HashMap<PathBuf, String>,
1017}
1018
1019#[cfg(test)]
1020mod tests {
1021 use super::*;
1022
1023 #[test]
1024 fn original_build_header_links() {
1025 let inputs = vec![
1026 (
1027 "blah blah <h1>Foo</h1>",
1028 r##"blah blah <h1 id="foo"><a class="header" href="#foo">Foo</a></h1>"##,
1029 ),
1030 (
1031 "<h1>Foo</h1>",
1032 r##"<h1 id="foo"><a class="header" href="#foo">Foo</a></h1>"##,
1033 ),
1034 (
1035 "<h3>Foo^bar</h3>",
1036 r##"<h3 id="foobar"><a class="header" href="#foobar">Foo^bar</a></h3>"##,
1037 ),
1038 (
1039 "<h4></h4>",
1040 r##"<h4 id=""><a class="header" href="#"></a></h4>"##,
1041 ),
1042 (
1043 "<h4><em>Hï</em></h4>",
1044 r##"<h4 id="hï"><a class="header" href="#hï"><em>Hï</em></a></h4>"##,
1045 ),
1046 (
1047 "<h1>Foo</h1><h3>Foo</h3>",
1048 r##"<h1 id="foo"><a class="header" href="#foo">Foo</a></h1><h3 id="foo-1"><a class="header" href="#foo-1">Foo</a></h3>"##,
1049 ),
1050 ];
1051
1052 for (src, should_be) in inputs {
1053 let got = build_header_links(src);
1054 assert_eq!(got, should_be);
1055 }
1056 }
1057
1058 #[test]
1059 fn add_playground() {
1060 let inputs = [
1061 ("<code class=\"language-rust\">x()</code>",
1062 "<pre class=\"playground\"><code class=\"language-rust\"><span class=\"boring\">#![allow(unused)]\n</span><span class=\"boring\">fn main() {\n</span>x()\n<span class=\"boring\">}</span></code></pre>"),
1063 ("<code class=\"language-rust\">fn main() {}</code>",
1064 "<pre class=\"playground\"><code class=\"language-rust\">fn main() {}</code></pre>"),
1065 ("<code class=\"language-rust editable\">let s = \"foo\n # bar\n\";</code>",
1066 "<pre class=\"playground\"><code class=\"language-rust editable\">let s = \"foo\n<span class=\"boring\"> bar\n</span>\";</code></pre>"),
1067 ("<code class=\"language-rust editable\">let s = \"foo\n ## bar\n\";</code>",
1068 "<pre class=\"playground\"><code class=\"language-rust editable\">let s = \"foo\n # bar\n\";</code></pre>"),
1069 ("<code class=\"language-rust editable\">let s = \"foo\n # bar\n#\n\";</code>",
1070 "<pre class=\"playground\"><code class=\"language-rust editable\">let s = \"foo\n<span class=\"boring\"> bar\n</span><span class=\"boring\">\n</span>\";</code></pre>"),
1071 ("<code class=\"language-rust ignore\">let s = \"foo\n # bar\n\";</code>",
1072 "<code class=\"language-rust ignore\">let s = \"foo\n<span class=\"boring\"> bar\n</span>\";</code>"),
1073 ("<code class=\"language-rust editable\">#![no_std]\nlet s = \"foo\";\n #[some_attr]</code>",
1074 "<pre class=\"playground\"><code class=\"language-rust editable\">#![no_std]\nlet s = \"foo\";\n #[some_attr]</code></pre>"),
1075 ];
1076 for (src, should_be) in &inputs {
1077 let got = add_playground_pre(
1078 src,
1079 &Playground {
1080 editable: true,
1081 ..Playground::default()
1082 },
1083 None,
1084 );
1085 assert_eq!(&*got, *should_be);
1086 }
1087 }
1088 #[test]
1089 fn add_playground_edition2015() {
1090 let inputs = [
1091 ("<code class=\"language-rust\">x()</code>",
1092 "<pre class=\"playground\"><code class=\"language-rust edition2015\"><span class=\"boring\">#![allow(unused)]\n</span><span class=\"boring\">fn main() {\n</span>x()\n<span class=\"boring\">}</span></code></pre>"),
1093 ("<code class=\"language-rust\">fn main() {}</code>",
1094 "<pre class=\"playground\"><code class=\"language-rust edition2015\">fn main() {}</code></pre>"),
1095 ("<code class=\"language-rust edition2015\">fn main() {}</code>",
1096 "<pre class=\"playground\"><code class=\"language-rust edition2015\">fn main() {}</code></pre>"),
1097 ("<code class=\"language-rust edition2018\">fn main() {}</code>",
1098 "<pre class=\"playground\"><code class=\"language-rust edition2018\">fn main() {}</code></pre>"),
1099 ];
1100 for (src, should_be) in &inputs {
1101 let got = add_playground_pre(
1102 src,
1103 &Playground {
1104 editable: true,
1105 ..Playground::default()
1106 },
1107 Some(RustEdition::E2015),
1108 );
1109 assert_eq!(&*got, *should_be);
1110 }
1111 }
1112 #[test]
1113 fn add_playground_edition2018() {
1114 let inputs = [
1115 ("<code class=\"language-rust\">x()</code>",
1116 "<pre class=\"playground\"><code class=\"language-rust edition2018\"><span class=\"boring\">#![allow(unused)]\n</span><span class=\"boring\">fn main() {\n</span>x()\n<span class=\"boring\">}</span></code></pre>"),
1117 ("<code class=\"language-rust\">fn main() {}</code>",
1118 "<pre class=\"playground\"><code class=\"language-rust edition2018\">fn main() {}</code></pre>"),
1119 ("<code class=\"language-rust edition2015\">fn main() {}</code>",
1120 "<pre class=\"playground\"><code class=\"language-rust edition2015\">fn main() {}</code></pre>"),
1121 ("<code class=\"language-rust edition2018\">fn main() {}</code>",
1122 "<pre class=\"playground\"><code class=\"language-rust edition2018\">fn main() {}</code></pre>"),
1123 ];
1124 for (src, should_be) in &inputs {
1125 let got = add_playground_pre(
1126 src,
1127 &Playground {
1128 editable: true,
1129 ..Playground::default()
1130 },
1131 Some(RustEdition::E2018),
1132 );
1133 assert_eq!(&*got, *should_be);
1134 }
1135 }
1136 #[test]
1137 fn add_playground_edition2021() {
1138 let inputs = [
1139 ("<code class=\"language-rust\">x()</code>",
1140 "<pre class=\"playground\"><code class=\"language-rust edition2021\"><span class=\"boring\">#![allow(unused)]\n</span><span class=\"boring\">fn main() {\n</span>x()\n<span class=\"boring\">}</span></code></pre>"),
1141 ("<code class=\"language-rust\">fn main() {}</code>",
1142 "<pre class=\"playground\"><code class=\"language-rust edition2021\">fn main() {}</code></pre>"),
1143 ("<code class=\"language-rust edition2015\">fn main() {}</code>",
1144 "<pre class=\"playground\"><code class=\"language-rust edition2015\">fn main() {}</code></pre>"),
1145 ("<code class=\"language-rust edition2018\">fn main() {}</code>",
1146 "<pre class=\"playground\"><code class=\"language-rust edition2018\">fn main() {}</code></pre>"),
1147 ];
1148 for (src, should_be) in &inputs {
1149 let got = add_playground_pre(
1150 src,
1151 &Playground {
1152 editable: true,
1153 ..Playground::default()
1154 },
1155 Some(RustEdition::E2021),
1156 );
1157 assert_eq!(&*got, *should_be);
1158 }
1159 }
1160}