]> git.proxmox.com Git - rustc.git/blob - vendor/mdbook/src/renderer/html_handlebars/hbs_renderer.rs
New upstream version 1.45.0+dfsg1
[rustc.git] / vendor / mdbook / src / renderer / html_handlebars / hbs_renderer.rs
1 use crate::book::{Book, BookItem};
2 use crate::config::{Config, HtmlConfig, Playpen};
3 use crate::errors::*;
4 use crate::renderer::html_handlebars::helpers;
5 use crate::renderer::{RenderContext, Renderer};
6 use crate::theme::{self, playpen_editor, Theme};
7 use crate::utils;
8
9 use std::borrow::Cow;
10 use std::collections::BTreeMap;
11 use std::collections::HashMap;
12 use std::fs;
13 use std::path::{Path, PathBuf};
14
15 use handlebars::Handlebars;
16 use regex::{Captures, Regex};
17
18 #[derive(Default)]
19 pub struct HtmlHandlebars;
20
21 impl HtmlHandlebars {
22 pub fn new() -> Self {
23 HtmlHandlebars
24 }
25
26 fn render_item(
27 &self,
28 item: &BookItem,
29 mut ctx: RenderItemContext<'_>,
30 print_content: &mut String,
31 ) -> Result<()> {
32 // FIXME: This should be made DRY-er and rely less on mutable state
33 if let BookItem::Chapter(ref ch) = *item {
34 let content = ch.content.clone();
35 let content = utils::render_markdown(&content, ctx.html_config.curly_quotes);
36
37 let fixed_content = utils::render_markdown_with_path(
38 &ch.content,
39 ctx.html_config.curly_quotes,
40 Some(&ch.path),
41 );
42 print_content.push_str(&fixed_content);
43
44 // Update the context with data for this file
45 let path = ch
46 .path
47 .to_str()
48 .chain_err(|| "Could not convert path to str")?;
49 let filepath = Path::new(&ch.path).with_extension("html");
50
51 // "print.html" is used for the print page.
52 if ch.path == Path::new("print.md") {
53 bail!(ErrorKind::ReservedFilenameError(ch.path.clone()));
54 };
55
56 // Non-lexical lifetimes needed :'(
57 let title: String;
58 {
59 let book_title = ctx
60 .data
61 .get("book_title")
62 .and_then(serde_json::Value::as_str)
63 .unwrap_or("");
64
65 title = match book_title {
66 "" => ch.name.clone(),
67 _ => ch.name.clone() + " - " + book_title,
68 }
69 }
70
71 ctx.data.insert("path".to_owned(), json!(path));
72 ctx.data.insert("content".to_owned(), json!(content));
73 ctx.data.insert("chapter_title".to_owned(), json!(ch.name));
74 ctx.data.insert("title".to_owned(), json!(title));
75 ctx.data.insert(
76 "path_to_root".to_owned(),
77 json!(utils::fs::path_to_root(&ch.path)),
78 );
79 if let Some(ref section) = ch.number {
80 ctx.data
81 .insert("section".to_owned(), json!(section.to_string()));
82 }
83
84 // Render the handlebars template with the data
85 debug!("Render template");
86 let rendered = ctx.handlebars.render("index", &ctx.data)?;
87
88 let rendered = self.post_process(rendered, &ctx.html_config.playpen);
89
90 // Write to file
91 debug!("Creating {}", filepath.display());
92 utils::fs::write_file(&ctx.destination, &filepath, rendered.as_bytes())?;
93
94 if ctx.is_index {
95 ctx.data.insert("path".to_owned(), json!("index.md"));
96 ctx.data.insert("path_to_root".to_owned(), json!(""));
97 ctx.data.insert("is_index".to_owned(), json!("true"));
98 let rendered_index = ctx.handlebars.render("index", &ctx.data)?;
99 let rendered_index = self.post_process(rendered_index, &ctx.html_config.playpen);
100 debug!("Creating index.html from {}", path);
101 utils::fs::write_file(&ctx.destination, "index.html", rendered_index.as_bytes())?;
102 }
103 }
104
105 Ok(())
106 }
107
108 #[cfg_attr(feature = "cargo-clippy", allow(clippy::let_and_return))]
109 fn post_process(&self, rendered: String, playpen_config: &Playpen) -> String {
110 let rendered = build_header_links(&rendered);
111 let rendered = fix_code_blocks(&rendered);
112 let rendered = add_playpen_pre(&rendered, playpen_config);
113
114 rendered
115 }
116
117 fn copy_static_files(
118 &self,
119 destination: &Path,
120 theme: &Theme,
121 html_config: &HtmlConfig,
122 ) -> Result<()> {
123 use crate::utils::fs::write_file;
124
125 write_file(
126 destination,
127 ".nojekyll",
128 b"This file makes sure that Github Pages doesn't process mdBook's output.",
129 )?;
130
131 write_file(destination, "book.js", &theme.js)?;
132 write_file(destination, "css/general.css", &theme.general_css)?;
133 write_file(destination, "css/chrome.css", &theme.chrome_css)?;
134 write_file(destination, "css/print.css", &theme.print_css)?;
135 write_file(destination, "css/variables.css", &theme.variables_css)?;
136 write_file(destination, "favicon.png", &theme.favicon)?;
137 write_file(destination, "highlight.css", &theme.highlight_css)?;
138 write_file(destination, "tomorrow-night.css", &theme.tomorrow_night_css)?;
139 write_file(destination, "ayu-highlight.css", &theme.ayu_highlight_css)?;
140 write_file(destination, "highlight.js", &theme.highlight_js)?;
141 write_file(destination, "clipboard.min.js", &theme.clipboard_js)?;
142 write_file(
143 destination,
144 "FontAwesome/css/font-awesome.css",
145 theme::FONT_AWESOME,
146 )?;
147 write_file(
148 destination,
149 "FontAwesome/fonts/fontawesome-webfont.eot",
150 theme::FONT_AWESOME_EOT,
151 )?;
152 write_file(
153 destination,
154 "FontAwesome/fonts/fontawesome-webfont.svg",
155 theme::FONT_AWESOME_SVG,
156 )?;
157 write_file(
158 destination,
159 "FontAwesome/fonts/fontawesome-webfont.ttf",
160 theme::FONT_AWESOME_TTF,
161 )?;
162 write_file(
163 destination,
164 "FontAwesome/fonts/fontawesome-webfont.woff",
165 theme::FONT_AWESOME_WOFF,
166 )?;
167 write_file(
168 destination,
169 "FontAwesome/fonts/fontawesome-webfont.woff2",
170 theme::FONT_AWESOME_WOFF2,
171 )?;
172 write_file(
173 destination,
174 "FontAwesome/fonts/FontAwesome.ttf",
175 theme::FONT_AWESOME_TTF,
176 )?;
177
178 let playpen_config = &html_config.playpen;
179
180 // Ace is a very large dependency, so only load it when requested
181 if playpen_config.editable && playpen_config.copy_js {
182 // Load the editor
183 write_file(destination, "editor.js", playpen_editor::JS)?;
184 write_file(destination, "ace.js", playpen_editor::ACE_JS)?;
185 write_file(destination, "mode-rust.js", playpen_editor::MODE_RUST_JS)?;
186 write_file(destination, "theme-dawn.js", playpen_editor::THEME_DAWN_JS)?;
187 write_file(
188 destination,
189 "theme-tomorrow_night.js",
190 playpen_editor::THEME_TOMORROW_NIGHT_JS,
191 )?;
192 }
193
194 Ok(())
195 }
196
197 /// Update the context with data for this file
198 fn configure_print_version(
199 &self,
200 data: &mut serde_json::Map<String, serde_json::Value>,
201 print_content: &str,
202 ) {
203 // Make sure that the Print chapter does not display the title from
204 // the last rendered chapter by removing it from its context
205 data.remove("title");
206 data.insert("is_print".to_owned(), json!(true));
207 data.insert("path".to_owned(), json!("print.md"));
208 data.insert("content".to_owned(), json!(print_content));
209 data.insert(
210 "path_to_root".to_owned(),
211 json!(utils::fs::path_to_root(Path::new("print.md"))),
212 );
213 }
214
215 fn register_hbs_helpers(&self, handlebars: &mut Handlebars<'_>, html_config: &HtmlConfig) {
216 handlebars.register_helper(
217 "toc",
218 Box::new(helpers::toc::RenderToc {
219 no_section_label: html_config.no_section_label,
220 }),
221 );
222 handlebars.register_helper("previous", Box::new(helpers::navigation::previous));
223 handlebars.register_helper("next", Box::new(helpers::navigation::next));
224 handlebars.register_helper("theme_option", Box::new(helpers::theme::theme_option));
225 }
226
227 /// Copy across any additional CSS and JavaScript files which the book
228 /// has been configured to use.
229 fn copy_additional_css_and_js(
230 &self,
231 html: &HtmlConfig,
232 root: &Path,
233 destination: &Path,
234 ) -> Result<()> {
235 let custom_files = html.additional_css.iter().chain(html.additional_js.iter());
236
237 debug!("Copying additional CSS and JS");
238
239 for custom_file in custom_files {
240 let input_location = root.join(custom_file);
241 let output_location = destination.join(custom_file);
242 if let Some(parent) = output_location.parent() {
243 fs::create_dir_all(parent)
244 .chain_err(|| format!("Unable to create {}", parent.display()))?;
245 }
246 debug!(
247 "Copying {} -> {}",
248 input_location.display(),
249 output_location.display()
250 );
251
252 fs::copy(&input_location, &output_location).chain_err(|| {
253 format!(
254 "Unable to copy {} to {}",
255 input_location.display(),
256 output_location.display()
257 )
258 })?;
259 }
260
261 Ok(())
262 }
263 }
264
265 // TODO(mattico): Remove some time after the 0.1.8 release
266 fn maybe_wrong_theme_dir(dir: &Path) -> Result<bool> {
267 fn entry_is_maybe_book_file(entry: fs::DirEntry) -> Result<bool> {
268 Ok(entry.file_type()?.is_file()
269 && entry.path().extension().map_or(false, |ext| ext == "md"))
270 }
271
272 if dir.is_dir() {
273 for entry in fs::read_dir(dir)? {
274 if entry_is_maybe_book_file(entry?).unwrap_or(false) {
275 return Ok(false);
276 }
277 }
278 Ok(true)
279 } else {
280 Ok(false)
281 }
282 }
283
284 impl Renderer for HtmlHandlebars {
285 fn name(&self) -> &str {
286 "html"
287 }
288
289 fn render(&self, ctx: &RenderContext) -> Result<()> {
290 let html_config = ctx.config.html_config().unwrap_or_default();
291 let src_dir = ctx.root.join(&ctx.config.book.src);
292 let destination = &ctx.destination;
293 let book = &ctx.book;
294 let build_dir = ctx.root.join(&ctx.config.build.build_dir);
295
296 if destination.exists() {
297 utils::fs::remove_dir_content(destination)
298 .chain_err(|| "Unable to remove stale HTML output")?;
299 }
300
301 trace!("render");
302 let mut handlebars = Handlebars::new();
303
304 let theme_dir = match html_config.theme {
305 Some(ref theme) => theme.to_path_buf(),
306 None => ctx.root.join("theme"),
307 };
308
309 if html_config.theme.is_none()
310 && maybe_wrong_theme_dir(&src_dir.join("theme")).unwrap_or(false)
311 {
312 warn!(
313 "Previous versions of mdBook erroneously accepted `./src/theme` as an automatic \
314 theme directory"
315 );
316 warn!("Please move your theme files to `./theme` for them to continue being used");
317 }
318
319 let theme = theme::Theme::new(theme_dir);
320
321 debug!("Register the index handlebars template");
322 handlebars.register_template_string("index", String::from_utf8(theme.index.clone())?)?;
323
324 debug!("Register the header handlebars template");
325 handlebars.register_partial("header", String::from_utf8(theme.header.clone())?)?;
326
327 debug!("Register handlebars helpers");
328 self.register_hbs_helpers(&mut handlebars, &html_config);
329
330 let mut data = make_data(&ctx.root, &book, &ctx.config, &html_config)?;
331
332 // Print version
333 let mut print_content = String::new();
334
335 fs::create_dir_all(&destination)
336 .chain_err(|| "Unexpected error when constructing destination path")?;
337
338 let mut is_index = true;
339 for item in book.iter() {
340 let ctx = RenderItemContext {
341 handlebars: &handlebars,
342 destination: destination.to_path_buf(),
343 data: data.clone(),
344 is_index,
345 html_config: html_config.clone(),
346 };
347 self.render_item(item, ctx, &mut print_content)?;
348 is_index = false;
349 }
350
351 // Print version
352 self.configure_print_version(&mut data, &print_content);
353 if let Some(ref title) = ctx.config.book.title {
354 data.insert("title".to_owned(), json!(title));
355 }
356
357 // Render the handlebars template with the data
358 debug!("Render template");
359 let rendered = handlebars.render("index", &data)?;
360
361 let rendered = self.post_process(rendered, &html_config.playpen);
362
363 utils::fs::write_file(&destination, "print.html", rendered.as_bytes())?;
364 debug!("Creating print.html ✓");
365
366 debug!("Copy static files");
367 self.copy_static_files(&destination, &theme, &html_config)
368 .chain_err(|| "Unable to copy across static files")?;
369 self.copy_additional_css_and_js(&html_config, &ctx.root, &destination)
370 .chain_err(|| "Unable to copy across additional CSS and JS")?;
371
372 // Render search index
373 #[cfg(feature = "search")]
374 {
375 let search = html_config.search.unwrap_or_default();
376 if search.enable {
377 super::search::create_files(&search, &destination, &book)?;
378 }
379 }
380
381 // Copy all remaining files, avoid a recursive copy from/to the book build dir
382 utils::fs::copy_files_except_ext(&src_dir, &destination, true, Some(&build_dir), &["md"])?;
383
384 Ok(())
385 }
386 }
387
388 fn make_data(
389 root: &Path,
390 book: &Book,
391 config: &Config,
392 html_config: &HtmlConfig,
393 ) -> Result<serde_json::Map<String, serde_json::Value>> {
394 trace!("make_data");
395
396 let mut data = serde_json::Map::new();
397 data.insert(
398 "language".to_owned(),
399 json!(config.book.language.clone().unwrap_or_default()),
400 );
401 data.insert(
402 "book_title".to_owned(),
403 json!(config.book.title.clone().unwrap_or_default()),
404 );
405 data.insert(
406 "description".to_owned(),
407 json!(config.book.description.clone().unwrap_or_default()),
408 );
409 data.insert("favicon".to_owned(), json!("favicon.png"));
410 if let Some(ref livereload) = html_config.livereload_url {
411 data.insert("livereload".to_owned(), json!(livereload));
412 }
413
414 let default_theme = match html_config.default_theme {
415 Some(ref theme) => theme.to_lowercase(),
416 None => "light".to_string(),
417 };
418 data.insert("default_theme".to_owned(), json!(default_theme));
419
420 let preferred_dark_theme = match html_config.preferred_dark_theme {
421 Some(ref theme) => theme.to_lowercase(),
422 None => default_theme,
423 };
424 data.insert(
425 "preferred_dark_theme".to_owned(),
426 json!(preferred_dark_theme),
427 );
428
429 // Add google analytics tag
430 if let Some(ref ga) = html_config.google_analytics {
431 data.insert("google_analytics".to_owned(), json!(ga));
432 }
433
434 if html_config.mathjax_support {
435 data.insert("mathjax_support".to_owned(), json!(true));
436 }
437
438 // Add check to see if there is an additional style
439 if !html_config.additional_css.is_empty() {
440 let mut css = Vec::new();
441 for style in &html_config.additional_css {
442 match style.strip_prefix(root) {
443 Ok(p) => css.push(p.to_str().expect("Could not convert to str")),
444 Err(_) => css.push(style.to_str().expect("Could not convert to str")),
445 }
446 }
447 data.insert("additional_css".to_owned(), json!(css));
448 }
449
450 // Add check to see if there is an additional script
451 if !html_config.additional_js.is_empty() {
452 let mut js = Vec::new();
453 for script in &html_config.additional_js {
454 match script.strip_prefix(root) {
455 Ok(p) => js.push(p.to_str().expect("Could not convert to str")),
456 Err(_) => js.push(script.to_str().expect("Could not convert to str")),
457 }
458 }
459 data.insert("additional_js".to_owned(), json!(js));
460 }
461
462 if html_config.playpen.editable && html_config.playpen.copy_js {
463 data.insert("playpen_js".to_owned(), json!(true));
464 if html_config.playpen.line_numbers {
465 data.insert("playpen_line_numbers".to_owned(), json!(true));
466 }
467 }
468 if html_config.playpen.copyable {
469 data.insert("playpen_copyable".to_owned(), json!(true));
470 }
471
472 data.insert("fold_enable".to_owned(), json!((html_config.fold.enable)));
473 data.insert("fold_level".to_owned(), json!((html_config.fold.level)));
474
475 let search = html_config.search.clone();
476 if cfg!(feature = "search") {
477 let search = search.unwrap_or_default();
478 data.insert("search_enabled".to_owned(), json!(search.enable));
479 data.insert(
480 "search_js".to_owned(),
481 json!(search.enable && search.copy_js),
482 );
483 } else if search.is_some() {
484 warn!("mdBook compiled without search support, ignoring `output.html.search` table");
485 warn!(
486 "please reinstall with `cargo install mdbook --force --features search`to use the \
487 search feature"
488 )
489 }
490
491 if let Some(ref git_repository_url) = html_config.git_repository_url {
492 data.insert("git_repository_url".to_owned(), json!(git_repository_url));
493 }
494
495 let git_repository_icon = match html_config.git_repository_icon {
496 Some(ref git_repository_icon) => git_repository_icon,
497 None => "fa-github",
498 };
499 data.insert("git_repository_icon".to_owned(), json!(git_repository_icon));
500
501 let mut chapters = vec![];
502
503 for item in book.iter() {
504 // Create the data to inject in the template
505 let mut chapter = BTreeMap::new();
506
507 match *item {
508 BookItem::Chapter(ref ch) => {
509 if let Some(ref section) = ch.number {
510 chapter.insert("section".to_owned(), json!(section.to_string()));
511 }
512
513 chapter.insert(
514 "has_sub_items".to_owned(),
515 json!((!ch.sub_items.is_empty()).to_string()),
516 );
517
518 chapter.insert("name".to_owned(), json!(ch.name));
519 let path = ch
520 .path
521 .to_str()
522 .chain_err(|| "Could not convert path to str")?;
523 chapter.insert("path".to_owned(), json!(path));
524 }
525 BookItem::Separator => {
526 chapter.insert("spacer".to_owned(), json!("_spacer_"));
527 }
528 }
529
530 chapters.push(chapter);
531 }
532
533 data.insert("chapters".to_owned(), json!(chapters));
534
535 debug!("[*]: JSON constructed");
536 Ok(data)
537 }
538
539 /// Goes through the rendered HTML, making sure all header tags have
540 /// an anchor respectively so people can link to sections directly.
541 fn build_header_links(html: &str) -> String {
542 let regex = Regex::new(r"<h(\d)>(.*?)</h\d>").unwrap();
543 let mut id_counter = HashMap::new();
544
545 regex
546 .replace_all(html, |caps: &Captures<'_>| {
547 let level = caps[1]
548 .parse()
549 .expect("Regex should ensure we only ever get numbers here");
550
551 insert_link_into_header(level, &caps[2], &mut id_counter)
552 })
553 .into_owned()
554 }
555
556 /// Insert a sinle link into a header, making sure each link gets its own
557 /// unique ID by appending an auto-incremented number (if necessary).
558 fn insert_link_into_header(
559 level: usize,
560 content: &str,
561 id_counter: &mut HashMap<String, usize>,
562 ) -> String {
563 let raw_id = utils::id_from_content(content);
564
565 let id_count = id_counter.entry(raw_id.clone()).or_insert(0);
566
567 let id = match *id_count {
568 0 => raw_id,
569 other => format!("{}-{}", raw_id, other),
570 };
571
572 *id_count += 1;
573
574 format!(
575 r##"<h{level}><a class="header" href="#{id}" id="{id}">{text}</a></h{level}>"##,
576 level = level,
577 id = id,
578 text = content
579 )
580 }
581
582 // The rust book uses annotations for rustdoc to test code snippets,
583 // like the following:
584 // ```rust,should_panic
585 // fn main() {
586 // // Code here
587 // }
588 // ```
589 // This function replaces all commas by spaces in the code block classes
590 fn fix_code_blocks(html: &str) -> String {
591 let regex = Regex::new(r##"<code([^>]+)class="([^"]+)"([^>]*)>"##).unwrap();
592 regex
593 .replace_all(html, |caps: &Captures<'_>| {
594 let before = &caps[1];
595 let classes = &caps[2].replace(",", " ");
596 let after = &caps[3];
597
598 format!(
599 r#"<code{before}class="{classes}"{after}>"#,
600 before = before,
601 classes = classes,
602 after = after
603 )
604 })
605 .into_owned()
606 }
607
608 fn add_playpen_pre(html: &str, playpen_config: &Playpen) -> String {
609 let regex = Regex::new(r##"((?s)<code[^>]?class="([^"]+)".*?>(.*?)</code>)"##).unwrap();
610 regex
611 .replace_all(html, |caps: &Captures<'_>| {
612 let text = &caps[1];
613 let classes = &caps[2];
614 let code = &caps[3];
615
616 if classes.contains("language-rust") {
617 if (!classes.contains("ignore") && !classes.contains("noplaypen"))
618 || classes.contains("mdbook-runnable")
619 {
620 // wrap the contents in an external pre block
621 format!(
622 "<pre class=\"playpen\"><code class=\"{}\">{}</code></pre>",
623 classes,
624 {
625 let content: Cow<'_, str> = if playpen_config.editable
626 && classes.contains("editable")
627 || text.contains("fn main")
628 || text.contains("quick_main!")
629 {
630 code.into()
631 } else {
632 // we need to inject our own main
633 let (attrs, code) = partition_source(code);
634
635 format!(
636 "\n# #![allow(unused_variables)]\n{}#fn main() {{\n{}#}}",
637 attrs, code
638 )
639 .into()
640 };
641 hide_lines(&content)
642 }
643 )
644 } else {
645 format!("<code class=\"{}\">{}</code>", classes, hide_lines(code))
646 }
647 } else {
648 // not language-rust, so no-op
649 text.to_owned()
650 }
651 })
652 .into_owned()
653 }
654
655 lazy_static! {
656 static ref BORING_LINES_REGEX: Regex = Regex::new(r"^(\s*)#(.?)(.*)$").unwrap();
657 }
658
659 fn hide_lines(content: &str) -> String {
660 let mut result = String::with_capacity(content.len());
661 for line in content.lines() {
662 if let Some(caps) = BORING_LINES_REGEX.captures(line) {
663 if &caps[2] == "#" {
664 result += &caps[1];
665 result += &caps[2];
666 result += &caps[3];
667 result += "\n";
668 continue;
669 } else if &caps[2] != "!" && &caps[2] != "[" {
670 result += "<span class=\"boring\">";
671 result += &caps[1];
672 if &caps[2] != " " {
673 result += &caps[2];
674 }
675 result += &caps[3];
676 result += "\n";
677 result += "</span>";
678 continue;
679 }
680 }
681 result += line;
682 result += "\n";
683 }
684 result
685 }
686
687 fn partition_source(s: &str) -> (String, String) {
688 let mut after_header = false;
689 let mut before = String::new();
690 let mut after = String::new();
691
692 for line in s.lines() {
693 let trimline = line.trim();
694 let header = trimline.chars().all(char::is_whitespace) || trimline.starts_with("#![");
695 if !header || after_header {
696 after_header = true;
697 after.push_str(line);
698 after.push_str("\n");
699 } else {
700 before.push_str(line);
701 before.push_str("\n");
702 }
703 }
704
705 (before, after)
706 }
707
708 struct RenderItemContext<'a> {
709 handlebars: &'a Handlebars<'a>,
710 destination: PathBuf,
711 data: serde_json::Map<String, serde_json::Value>,
712 is_index: bool,
713 html_config: HtmlConfig,
714 }
715
716 #[cfg(test)]
717 mod tests {
718 use super::*;
719
720 #[test]
721 fn original_build_header_links() {
722 let inputs = vec![
723 (
724 "blah blah <h1>Foo</h1>",
725 r##"blah blah <h1><a class="header" href="#foo" id="foo">Foo</a></h1>"##,
726 ),
727 (
728 "<h1>Foo</h1>",
729 r##"<h1><a class="header" href="#foo" id="foo">Foo</a></h1>"##,
730 ),
731 (
732 "<h3>Foo^bar</h3>",
733 r##"<h3><a class="header" href="#foobar" id="foobar">Foo^bar</a></h3>"##,
734 ),
735 (
736 "<h4></h4>",
737 r##"<h4><a class="header" href="#" id=""></a></h4>"##,
738 ),
739 (
740 "<h4><em>Hï</em></h4>",
741 r##"<h4><a class="header" href="#hï" id=""><em>Hï</em></a></h4>"##,
742 ),
743 (
744 "<h1>Foo</h1><h3>Foo</h3>",
745 r##"<h1><a class="header" href="#foo" id="foo">Foo</a></h1><h3><a class="header" href="#foo-1" id="foo-1">Foo</a></h3>"##,
746 ),
747 ];
748
749 for (src, should_be) in inputs {
750 let got = build_header_links(&src);
751 assert_eq!(got, should_be);
752 }
753 }
754
755 #[test]
756 fn add_playpen() {
757 let inputs = [
758 ("<code class=\"language-rust\">x()</code>",
759 "<pre class=\"playpen\"><code class=\"language-rust\">\n<span class=\"boring\">#![allow(unused_variables)]\n</span><span class=\"boring\">fn main() {\n</span>x()\n<span class=\"boring\">}\n</span></code></pre>"),
760 ("<code class=\"language-rust\">fn main() {}</code>",
761 "<pre class=\"playpen\"><code class=\"language-rust\">fn main() {}\n</code></pre>"),
762 ("<code class=\"language-rust editable\">let s = \"foo\n # bar\n\";</code>",
763 "<pre class=\"playpen\"><code class=\"language-rust editable\">let s = \"foo\n<span class=\"boring\"> bar\n</span>\";\n</code></pre>"),
764 ("<code class=\"language-rust editable\">let s = \"foo\n ## bar\n\";</code>",
765 "<pre class=\"playpen\"><code class=\"language-rust editable\">let s = \"foo\n # bar\n\";\n</code></pre>"),
766 ("<code class=\"language-rust editable\">let s = \"foo\n # bar\n#\n\";</code>",
767 "<pre class=\"playpen\"><code class=\"language-rust editable\">let s = \"foo\n<span class=\"boring\"> bar\n</span><span class=\"boring\">\n</span>\";\n</code></pre>"),
768 ("<code class=\"language-rust ignore\">let s = \"foo\n # bar\n\";</code>",
769 "<code class=\"language-rust ignore\">let s = \"foo\n<span class=\"boring\"> bar\n</span>\";\n</code>"),
770 ("<code class=\"language-rust editable\">#![no_std]\nlet s = \"foo\";\n #[some_attr]</code>",
771 "<pre class=\"playpen\"><code class=\"language-rust editable\">#![no_std]\nlet s = \"foo\";\n #[some_attr]\n</code></pre>"),
772 ];
773 for (src, should_be) in &inputs {
774 let got = add_playpen_pre(
775 src,
776 &Playpen {
777 editable: true,
778 ..Playpen::default()
779 },
780 );
781 assert_eq!(&*got, *should_be);
782 }
783 }
784 }