1 | use crate::book::{Book, BookItem}; |
2 | use crate::config::{BookConfig, Code, Config, HtmlConfig, Playground, RustEdition}; |
3 | use crate::errors::*; |
4 | use crate::renderer::html_handlebars::helpers; |
5 | use crate::renderer::{RenderContext, Renderer}; |
6 | use crate::theme::{self, playground_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::{self, File}; |
13 | use std::path::{Path, PathBuf}; |
14 | |
15 | use crate::utils::fs::get_404_output_file; |
16 | use handlebars::Handlebars; |
17 | use log::{debug, trace, warn}; |
18 | use once_cell::sync::Lazy; |
19 | use regex::{Captures, Regex}; |
20 | use serde_json::json; |
21 | |
22 | #[derive (Default)] |
23 | pub struct HtmlHandlebars; |
24 | |
25 | impl 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 | // FIXME: This should be made DRY-er and rely less on mutable state |
37 | |
38 | 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 | // Add page break between chapters |
64 | // See https://developer.mozilla.org/en-US/docs/Web/CSS/break-before and https://developer.mozilla.org/en-US/docs/Web/CSS/page-break-before |
65 | // Add both two CSS properties because of the compatibility issue |
66 | 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 | // Update the context with data for this file |
72 | 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 | // "print.html" is used for the print page. |
78 | 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 | // Render the handlebars template with the data |
110 | debug!("Render template" ); |
111 | let rendered = ctx.handlebars.render("index" , &ctx.data)?; |
112 | |
113 | let rendered = self.post_process( |
114 | rendered, |
115 | &ctx.html_config.playground, |
116 | &ctx.html_config.code, |
117 | ctx.edition, |
118 | ); |
119 | |
120 | // Write to file |
121 | debug!("Creating {}" , filepath.display()); |
122 | utils::fs::write_file(&ctx.destination, &filepath, rendered.as_bytes())?; |
123 | |
124 | if ctx.is_index { |
125 | ctx.data.insert("path" .to_owned(), json!("index.md" )); |
126 | ctx.data.insert("path_to_root" .to_owned(), json!("" )); |
127 | ctx.data.insert("is_index" .to_owned(), json!(true)); |
128 | let rendered_index = ctx.handlebars.render("index" , &ctx.data)?; |
129 | let rendered_index = self.post_process( |
130 | rendered_index, |
131 | &ctx.html_config.playground, |
132 | &ctx.html_config.code, |
133 | ctx.edition, |
134 | ); |
135 | debug!("Creating index.html from {}" , ctx_path); |
136 | utils::fs::write_file(&ctx.destination, "index.html" , rendered_index.as_bytes())?; |
137 | } |
138 | |
139 | Ok(()) |
140 | } |
141 | |
142 | fn render_404( |
143 | &self, |
144 | ctx: &RenderContext, |
145 | html_config: &HtmlConfig, |
146 | src_dir: &Path, |
147 | handlebars: &mut Handlebars<'_>, |
148 | data: &mut serde_json::Map<String, serde_json::Value>, |
149 | ) -> Result<()> { |
150 | let destination = &ctx.destination; |
151 | let content_404 = if let Some(ref filename) = html_config.input_404 { |
152 | let path = src_dir.join(filename); |
153 | std::fs::read_to_string(&path) |
154 | .with_context(|| format!("unable to open 404 input file {:?}" , path))? |
155 | } else { |
156 | // 404 input not explicitly configured try the default file 404.md |
157 | let default_404_location = src_dir.join("404.md" ); |
158 | if default_404_location.exists() { |
159 | std::fs::read_to_string(&default_404_location).with_context(|| { |
160 | format!("unable to open 404 input file {:?}" , default_404_location) |
161 | })? |
162 | } else { |
163 | "# Document not found (404) \n\nThis URL is invalid, sorry. Please use the \ |
164 | navigation bar or search to continue." |
165 | .to_string() |
166 | } |
167 | }; |
168 | let html_content_404 = utils::render_markdown(&content_404, html_config.curly_quotes); |
169 | |
170 | let mut data_404 = data.clone(); |
171 | let base_url = if let Some(site_url) = &html_config.site_url { |
172 | site_url |
173 | } else { |
174 | debug!( |
175 | "HTML 'site-url' parameter not set, defaulting to '/'. Please configure \ |
176 | this to ensure the 404 page work correctly, especially if your site is hosted in a \ |
177 | subdirectory on the HTTP server." |
178 | ); |
179 | "/" |
180 | }; |
181 | data_404.insert("base_url" .to_owned(), json!(base_url)); |
182 | // Set a dummy path to ensure other paths (e.g. in the TOC) are generated correctly |
183 | data_404.insert("path" .to_owned(), json!("404.md" )); |
184 | data_404.insert("content" .to_owned(), json!(html_content_404)); |
185 | |
186 | let mut title = String::from("Page not found" ); |
187 | if let Some(book_title) = &ctx.config.book.title { |
188 | title.push_str(" - " ); |
189 | title.push_str(book_title); |
190 | } |
191 | data_404.insert("title" .to_owned(), json!(title)); |
192 | let rendered = handlebars.render("index" , &data_404)?; |
193 | |
194 | let rendered = self.post_process( |
195 | rendered, |
196 | &html_config.playground, |
197 | &html_config.code, |
198 | ctx.config.rust.edition, |
199 | ); |
200 | let output_file = get_404_output_file(&html_config.input_404); |
201 | utils::fs::write_file(destination, output_file, rendered.as_bytes())?; |
202 | debug!("Creating 404.html ✓" ); |
203 | Ok(()) |
204 | } |
205 | |
206 | #[cfg_attr (feature = "cargo-clippy" , allow(clippy::let_and_return))] |
207 | fn post_process( |
208 | &self, |
209 | rendered: String, |
210 | playground_config: &Playground, |
211 | code_config: &Code, |
212 | edition: Option<RustEdition>, |
213 | ) -> String { |
214 | let rendered = build_header_links(&rendered); |
215 | let rendered = fix_code_blocks(&rendered); |
216 | let rendered = add_playground_pre(&rendered, playground_config, edition); |
217 | let rendered = hide_lines(&rendered, code_config); |
218 | |
219 | rendered |
220 | } |
221 | |
222 | fn copy_static_files( |
223 | &self, |
224 | destination: &Path, |
225 | theme: &Theme, |
226 | html_config: &HtmlConfig, |
227 | ) -> Result<()> { |
228 | use crate::utils::fs::write_file; |
229 | |
230 | write_file( |
231 | destination, |
232 | ".nojekyll" , |
233 | b"This file makes sure that Github Pages doesn't process mdBook's output. \n" , |
234 | )?; |
235 | |
236 | if let Some(cname) = &html_config.cname { |
237 | write_file(destination, "CNAME" , format!(" {}\n" , cname).as_bytes())?; |
238 | } |
239 | |
240 | write_file(destination, "book.js" , &theme.js)?; |
241 | write_file(destination, "css/general.css" , &theme.general_css)?; |
242 | write_file(destination, "css/chrome.css" , &theme.chrome_css)?; |
243 | if html_config.print.enable { |
244 | write_file(destination, "css/print.css" , &theme.print_css)?; |
245 | } |
246 | write_file(destination, "css/variables.css" , &theme.variables_css)?; |
247 | if let Some(contents) = &theme.favicon_png { |
248 | write_file(destination, "favicon.png" , contents)?; |
249 | } |
250 | if let Some(contents) = &theme.favicon_svg { |
251 | write_file(destination, "favicon.svg" , contents)?; |
252 | } |
253 | write_file(destination, "highlight.css" , &theme.highlight_css)?; |
254 | write_file(destination, "tomorrow-night.css" , &theme.tomorrow_night_css)?; |
255 | write_file(destination, "ayu-highlight.css" , &theme.ayu_highlight_css)?; |
256 | write_file(destination, "highlight.js" , &theme.highlight_js)?; |
257 | write_file(destination, "clipboard.min.js" , &theme.clipboard_js)?; |
258 | write_file( |
259 | destination, |
260 | "FontAwesome/css/font-awesome.css" , |
261 | theme::FONT_AWESOME, |
262 | )?; |
263 | write_file( |
264 | destination, |
265 | "FontAwesome/fonts/fontawesome-webfont.eot" , |
266 | theme::FONT_AWESOME_EOT, |
267 | )?; |
268 | write_file( |
269 | destination, |
270 | "FontAwesome/fonts/fontawesome-webfont.svg" , |
271 | theme::FONT_AWESOME_SVG, |
272 | )?; |
273 | write_file( |
274 | destination, |
275 | "FontAwesome/fonts/fontawesome-webfont.ttf" , |
276 | theme::FONT_AWESOME_TTF, |
277 | )?; |
278 | write_file( |
279 | destination, |
280 | "FontAwesome/fonts/fontawesome-webfont.woff" , |
281 | theme::FONT_AWESOME_WOFF, |
282 | )?; |
283 | write_file( |
284 | destination, |
285 | "FontAwesome/fonts/fontawesome-webfont.woff2" , |
286 | theme::FONT_AWESOME_WOFF2, |
287 | )?; |
288 | write_file( |
289 | destination, |
290 | "FontAwesome/fonts/FontAwesome.ttf" , |
291 | theme::FONT_AWESOME_TTF, |
292 | )?; |
293 | // Don't copy the stock fonts if the user has specified their own fonts to use. |
294 | if html_config.copy_fonts && theme.fonts_css.is_none() { |
295 | write_file(destination, "fonts/fonts.css" , theme::fonts::CSS)?; |
296 | for (file_name, contents) in theme::fonts::LICENSES.iter() { |
297 | write_file(destination, file_name, contents)?; |
298 | } |
299 | for (file_name, contents) in theme::fonts::OPEN_SANS.iter() { |
300 | write_file(destination, file_name, contents)?; |
301 | } |
302 | write_file( |
303 | destination, |
304 | theme::fonts::SOURCE_CODE_PRO.0, |
305 | theme::fonts::SOURCE_CODE_PRO.1, |
306 | )?; |
307 | } |
308 | if let Some(fonts_css) = &theme.fonts_css { |
309 | if !fonts_css.is_empty() { |
310 | write_file(destination, "fonts/fonts.css" , fonts_css)?; |
311 | } |
312 | } |
313 | if !html_config.copy_fonts && theme.fonts_css.is_none() { |
314 | warn!( |
315 | "output.html.copy-fonts is deprecated. \n\ |
316 | This book appears to have copy-fonts=false in book.toml without a fonts.css file. \n\ |
317 | Add an empty `theme/fonts/fonts.css` file to squelch this warning." |
318 | ); |
319 | } |
320 | for font_file in &theme.font_files { |
321 | let contents = fs::read(font_file)?; |
322 | let filename = font_file.file_name().unwrap(); |
323 | let filename = Path::new("fonts" ).join(filename); |
324 | write_file(destination, filename, &contents)?; |
325 | } |
326 | |
327 | let playground_config = &html_config.playground; |
328 | |
329 | // Ace is a very large dependency, so only load it when requested |
330 | if playground_config.editable && playground_config.copy_js { |
331 | // Load the editor |
332 | write_file(destination, "editor.js" , playground_editor::JS)?; |
333 | write_file(destination, "ace.js" , playground_editor::ACE_JS)?; |
334 | write_file(destination, "mode-rust.js" , playground_editor::MODE_RUST_JS)?; |
335 | write_file( |
336 | destination, |
337 | "theme-dawn.js" , |
338 | playground_editor::THEME_DAWN_JS, |
339 | )?; |
340 | write_file( |
341 | destination, |
342 | "theme-tomorrow_night.js" , |
343 | playground_editor::THEME_TOMORROW_NIGHT_JS, |
344 | )?; |
345 | } |
346 | |
347 | Ok(()) |
348 | } |
349 | |
350 | /// Update the context with data for this file |
351 | fn configure_print_version( |
352 | &self, |
353 | data: &mut serde_json::Map<String, serde_json::Value>, |
354 | print_content: &str, |
355 | ) { |
356 | // Make sure that the Print chapter does not display the title from |
357 | // the last rendered chapter by removing it from its context |
358 | data.remove("title" ); |
359 | data.insert("is_print" .to_owned(), json!(true)); |
360 | data.insert("path" .to_owned(), json!("print.md" )); |
361 | data.insert("content" .to_owned(), json!(print_content)); |
362 | data.insert( |
363 | "path_to_root" .to_owned(), |
364 | json!(utils::fs::path_to_root(Path::new("print.md" ))), |
365 | ); |
366 | } |
367 | |
368 | fn register_hbs_helpers(&self, handlebars: &mut Handlebars<'_>, html_config: &HtmlConfig) { |
369 | handlebars.register_helper( |
370 | "toc" , |
371 | Box::new(helpers::toc::RenderToc { |
372 | no_section_label: html_config.no_section_label, |
373 | }), |
374 | ); |
375 | handlebars.register_helper("previous" , Box::new(helpers::navigation::previous)); |
376 | handlebars.register_helper("next" , Box::new(helpers::navigation::next)); |
377 | // TODO: remove theme_option in 0.5, it is not needed. |
378 | handlebars.register_helper("theme_option" , Box::new(helpers::theme::theme_option)); |
379 | } |
380 | |
381 | /// Copy across any additional CSS and JavaScript files which the book |
382 | /// has been configured to use. |
383 | fn copy_additional_css_and_js( |
384 | &self, |
385 | html: &HtmlConfig, |
386 | root: &Path, |
387 | destination: &Path, |
388 | ) -> Result<()> { |
389 | let custom_files = html.additional_css.iter().chain(html.additional_js.iter()); |
390 | |
391 | debug!("Copying additional CSS and JS" ); |
392 | |
393 | for custom_file in custom_files { |
394 | let input_location = root.join(custom_file); |
395 | let output_location = destination.join(custom_file); |
396 | if let Some(parent) = output_location.parent() { |
397 | fs::create_dir_all(parent) |
398 | .with_context(|| format!("Unable to create {}" , parent.display()))?; |
399 | } |
400 | debug!( |
401 | "Copying {} -> {}" , |
402 | input_location.display(), |
403 | output_location.display() |
404 | ); |
405 | |
406 | fs::copy(&input_location, &output_location).with_context(|| { |
407 | format!( |
408 | "Unable to copy {} to {}" , |
409 | input_location.display(), |
410 | output_location.display() |
411 | ) |
412 | })?; |
413 | } |
414 | |
415 | Ok(()) |
416 | } |
417 | |
418 | fn emit_redirects( |
419 | &self, |
420 | root: &Path, |
421 | handlebars: &Handlebars<'_>, |
422 | redirects: &HashMap<String, String>, |
423 | ) -> Result<()> { |
424 | if redirects.is_empty() { |
425 | return Ok(()); |
426 | } |
427 | |
428 | log::debug!("Emitting redirects" ); |
429 | |
430 | for (original, new) in redirects { |
431 | log::debug!("Redirecting \"{}\" → \"{}\"" , original, new); |
432 | // Note: all paths are relative to the build directory, so the |
433 | // leading slash in an absolute path means nothing (and would mess |
434 | // up `root.join(original)`). |
435 | let original = original.trim_start_matches('/' ); |
436 | let filename = root.join(original); |
437 | self.emit_redirect(handlebars, &filename, new)?; |
438 | } |
439 | |
440 | Ok(()) |
441 | } |
442 | |
443 | fn emit_redirect( |
444 | &self, |
445 | handlebars: &Handlebars<'_>, |
446 | original: &Path, |
447 | destination: &str, |
448 | ) -> Result<()> { |
449 | if original.exists() { |
450 | // sanity check to avoid accidentally overwriting a real file. |
451 | let msg = format!( |
452 | "Not redirecting \"{}\" to \"{}\" because it already exists. Are you sure it needs to be redirected?" , |
453 | original.display(), |
454 | destination, |
455 | ); |
456 | return Err(Error::msg(msg)); |
457 | } |
458 | |
459 | if let Some(parent) = original.parent() { |
460 | std::fs::create_dir_all(parent) |
461 | .with_context(|| format!("Unable to ensure \"{}\" exists" , parent.display()))?; |
462 | } |
463 | |
464 | let ctx = json!({ |
465 | "url" : destination, |
466 | }); |
467 | let f = File::create(original)?; |
468 | handlebars |
469 | .render_to_write("redirect" , &ctx, f) |
470 | .with_context(|| { |
471 | format!( |
472 | "Unable to create a redirect file at \"{}\"" , |
473 | original.display() |
474 | ) |
475 | })?; |
476 | |
477 | Ok(()) |
478 | } |
479 | } |
480 | |
481 | // TODO(mattico): Remove some time after the 0.1.8 release |
482 | fn maybe_wrong_theme_dir(dir: &Path) -> Result<bool> { |
483 | fn entry_is_maybe_book_file(entry: fs::DirEntry) -> Result<bool> { |
484 | Ok(entry.file_type()?.is_file() |
485 | && entry.path().extension().map_or(default:false, |ext: &OsStr| ext == "md" )) |
486 | } |
487 | |
488 | if dir.is_dir() { |
489 | for entry: Result in fs::read_dir(path:dir)? { |
490 | if entry_is_maybe_book_file(entry?).unwrap_or(default:false) { |
491 | return Ok(false); |
492 | } |
493 | } |
494 | Ok(true) |
495 | } else { |
496 | Ok(false) |
497 | } |
498 | } |
499 | |
500 | impl Renderer for HtmlHandlebars { |
501 | fn name(&self) -> &str { |
502 | "html" |
503 | } |
504 | |
505 | fn render(&self, ctx: &RenderContext) -> Result<()> { |
506 | let book_config = &ctx.config.book; |
507 | let html_config = ctx.config.html_config().unwrap_or_default(); |
508 | let src_dir = ctx.root.join(&ctx.config.book.src); |
509 | let destination = &ctx.destination; |
510 | let book = &ctx.book; |
511 | let build_dir = ctx.root.join(&ctx.config.build.build_dir); |
512 | |
513 | if destination.exists() { |
514 | utils::fs::remove_dir_content(destination) |
515 | .with_context(|| "Unable to remove stale HTML output" )?; |
516 | } |
517 | |
518 | trace!("render" ); |
519 | let mut handlebars = Handlebars::new(); |
520 | |
521 | let theme_dir = match html_config.theme { |
522 | Some(ref theme) => { |
523 | let dir = ctx.root.join(theme); |
524 | if !dir.is_dir() { |
525 | bail!("theme dir {} does not exist" , dir.display()); |
526 | } |
527 | dir |
528 | } |
529 | None => ctx.root.join("theme" ), |
530 | }; |
531 | |
532 | if html_config.theme.is_none() |
533 | && maybe_wrong_theme_dir(&src_dir.join("theme" )).unwrap_or(false) |
534 | { |
535 | warn!( |
536 | "Previous versions of mdBook erroneously accepted `./src/theme` as an automatic \ |
537 | theme directory" |
538 | ); |
539 | warn!("Please move your theme files to `./theme` for them to continue being used" ); |
540 | } |
541 | |
542 | let theme = theme::Theme::new(theme_dir); |
543 | |
544 | debug!("Register the index handlebars template" ); |
545 | handlebars.register_template_string("index" , String::from_utf8(theme.index.clone())?)?; |
546 | |
547 | debug!("Register the head handlebars template" ); |
548 | handlebars.register_partial("head" , String::from_utf8(theme.head.clone())?)?; |
549 | |
550 | debug!("Register the redirect handlebars template" ); |
551 | handlebars |
552 | .register_template_string("redirect" , String::from_utf8(theme.redirect.clone())?)?; |
553 | |
554 | debug!("Register the header handlebars template" ); |
555 | handlebars.register_partial("header" , String::from_utf8(theme.header.clone())?)?; |
556 | |
557 | debug!("Register handlebars helpers" ); |
558 | self.register_hbs_helpers(&mut handlebars, &html_config); |
559 | |
560 | let mut data = make_data(&ctx.root, book, &ctx.config, &html_config, &theme)?; |
561 | |
562 | // Print version |
563 | let mut print_content = String::new(); |
564 | |
565 | fs::create_dir_all(destination) |
566 | .with_context(|| "Unexpected error when constructing destination path" )?; |
567 | |
568 | let mut is_index = true; |
569 | for item in book.iter() { |
570 | let ctx = RenderItemContext { |
571 | handlebars: &handlebars, |
572 | destination: destination.to_path_buf(), |
573 | data: data.clone(), |
574 | is_index, |
575 | book_config: book_config.clone(), |
576 | html_config: html_config.clone(), |
577 | edition: ctx.config.rust.edition, |
578 | chapter_titles: &ctx.chapter_titles, |
579 | }; |
580 | self.render_item(item, ctx, &mut print_content)?; |
581 | // Only the first non-draft chapter item should be treated as the "index" |
582 | is_index &= !matches!(item, BookItem::Chapter(ch) if !ch.is_draft_chapter()); |
583 | } |
584 | |
585 | // Render 404 page |
586 | if html_config.input_404 != Some("" .to_string()) { |
587 | self.render_404(ctx, &html_config, &src_dir, &mut handlebars, &mut data)?; |
588 | } |
589 | |
590 | // Print version |
591 | self.configure_print_version(&mut data, &print_content); |
592 | if let Some(ref title) = ctx.config.book.title { |
593 | data.insert("title" .to_owned(), json!(title)); |
594 | } |
595 | |
596 | // Render the handlebars template with the data |
597 | if html_config.print.enable { |
598 | debug!("Render template" ); |
599 | let rendered = handlebars.render("index" , &data)?; |
600 | |
601 | let rendered = self.post_process( |
602 | rendered, |
603 | &html_config.playground, |
604 | &html_config.code, |
605 | ctx.config.rust.edition, |
606 | ); |
607 | |
608 | utils::fs::write_file(destination, "print.html" , rendered.as_bytes())?; |
609 | debug!("Creating print.html ✓" ); |
610 | } |
611 | |
612 | debug!("Copy static files" ); |
613 | self.copy_static_files(destination, &theme, &html_config) |
614 | .with_context(|| "Unable to copy across static files" )?; |
615 | self.copy_additional_css_and_js(&html_config, &ctx.root, destination) |
616 | .with_context(|| "Unable to copy across additional CSS and JS" )?; |
617 | |
618 | // Render search index |
619 | #[cfg (feature = "search" )] |
620 | { |
621 | let search = html_config.search.unwrap_or_default(); |
622 | if search.enable { |
623 | super::search::create_files(&search, destination, book)?; |
624 | } |
625 | } |
626 | |
627 | self.emit_redirects(&ctx.destination, &handlebars, &html_config.redirect) |
628 | .context("Unable to emit redirects" )?; |
629 | |
630 | // Copy all remaining files, avoid a recursive copy from/to the book build dir |
631 | utils::fs::copy_files_except_ext(&src_dir, destination, true, Some(&build_dir), &["md" ])?; |
632 | |
633 | Ok(()) |
634 | } |
635 | } |
636 | |
637 | fn make_data( |
638 | root: &Path, |
639 | book: &Book, |
640 | config: &Config, |
641 | html_config: &HtmlConfig, |
642 | theme: &Theme, |
643 | ) -> Result<serde_json::Map<String, serde_json::Value>> { |
644 | trace!("make_data" ); |
645 | |
646 | let mut data = serde_json::Map::new(); |
647 | data.insert( |
648 | "language" .to_owned(), |
649 | json!(config.book.language.clone().unwrap_or_default()), |
650 | ); |
651 | data.insert( |
652 | "text_direction" .to_owned(), |
653 | json!(config.book.realized_text_direction()), |
654 | ); |
655 | data.insert( |
656 | "book_title" .to_owned(), |
657 | json!(config.book.title.clone().unwrap_or_default()), |
658 | ); |
659 | data.insert( |
660 | "description" .to_owned(), |
661 | json!(config.book.description.clone().unwrap_or_default()), |
662 | ); |
663 | if theme.favicon_png.is_some() { |
664 | data.insert("favicon_png" .to_owned(), json!("favicon.png" )); |
665 | } |
666 | if theme.favicon_svg.is_some() { |
667 | data.insert("favicon_svg" .to_owned(), json!("favicon.svg" )); |
668 | } |
669 | if let Some(ref live_reload_endpoint) = html_config.live_reload_endpoint { |
670 | data.insert( |
671 | "live_reload_endpoint" .to_owned(), |
672 | json!(live_reload_endpoint), |
673 | ); |
674 | } |
675 | |
676 | // TODO: remove default_theme in 0.5, it is not needed. |
677 | let default_theme = match html_config.default_theme { |
678 | Some(ref theme) => theme.to_lowercase(), |
679 | None => "light" .to_string(), |
680 | }; |
681 | data.insert("default_theme" .to_owned(), json!(default_theme)); |
682 | |
683 | let preferred_dark_theme = match html_config.preferred_dark_theme { |
684 | Some(ref theme) => theme.to_lowercase(), |
685 | None => "navy" .to_string(), |
686 | }; |
687 | data.insert( |
688 | "preferred_dark_theme" .to_owned(), |
689 | json!(preferred_dark_theme), |
690 | ); |
691 | |
692 | // Add google analytics tag |
693 | if let Some(ref ga) = html_config.google_analytics { |
694 | data.insert("google_analytics" .to_owned(), json!(ga)); |
695 | } |
696 | |
697 | if html_config.mathjax_support { |
698 | data.insert("mathjax_support" .to_owned(), json!(true)); |
699 | } |
700 | |
701 | // This `matches!` checks for a non-empty file. |
702 | if html_config.copy_fonts || matches!(theme.fonts_css.as_deref(), Some([_, ..])) { |
703 | data.insert("copy_fonts" .to_owned(), json!(true)); |
704 | } |
705 | |
706 | // Add check to see if there is an additional style |
707 | if !html_config.additional_css.is_empty() { |
708 | let mut css = Vec::new(); |
709 | for style in &html_config.additional_css { |
710 | match style.strip_prefix(root) { |
711 | Ok(p) => css.push(p.to_str().expect("Could not convert to str" )), |
712 | Err(_) => css.push(style.to_str().expect("Could not convert to str" )), |
713 | } |
714 | } |
715 | data.insert("additional_css" .to_owned(), json!(css)); |
716 | } |
717 | |
718 | // Add check to see if there is an additional script |
719 | if !html_config.additional_js.is_empty() { |
720 | let mut js = Vec::new(); |
721 | for script in &html_config.additional_js { |
722 | match script.strip_prefix(root) { |
723 | Ok(p) => js.push(p.to_str().expect("Could not convert to str" )), |
724 | Err(_) => js.push(script.to_str().expect("Could not convert to str" )), |
725 | } |
726 | } |
727 | data.insert("additional_js" .to_owned(), json!(js)); |
728 | } |
729 | |
730 | if html_config.playground.editable && html_config.playground.copy_js { |
731 | data.insert("playground_js" .to_owned(), json!(true)); |
732 | if html_config.playground.line_numbers { |
733 | data.insert("playground_line_numbers" .to_owned(), json!(true)); |
734 | } |
735 | } |
736 | if html_config.playground.copyable { |
737 | data.insert("playground_copyable" .to_owned(), json!(true)); |
738 | } |
739 | |
740 | data.insert("print_enable" .to_owned(), json!(html_config.print.enable)); |
741 | data.insert("fold_enable" .to_owned(), json!(html_config.fold.enable)); |
742 | data.insert("fold_level" .to_owned(), json!(html_config.fold.level)); |
743 | |
744 | let search = html_config.search.clone(); |
745 | if cfg!(feature = "search" ) { |
746 | let search = search.unwrap_or_default(); |
747 | data.insert("search_enabled" .to_owned(), json!(search.enable)); |
748 | data.insert( |
749 | "search_js" .to_owned(), |
750 | json!(search.enable && search.copy_js), |
751 | ); |
752 | } else if search.is_some() { |
753 | warn!("mdBook compiled without search support, ignoring `output.html.search` table" ); |
754 | warn!( |
755 | "please reinstall with `cargo install mdbook --force --features search`to use the \ |
756 | search feature" |
757 | ) |
758 | } |
759 | |
760 | if let Some(ref git_repository_url) = html_config.git_repository_url { |
761 | data.insert("git_repository_url" .to_owned(), json!(git_repository_url)); |
762 | } |
763 | |
764 | let git_repository_icon = match html_config.git_repository_icon { |
765 | Some(ref git_repository_icon) => git_repository_icon, |
766 | None => "fa-github" , |
767 | }; |
768 | data.insert("git_repository_icon" .to_owned(), json!(git_repository_icon)); |
769 | |
770 | let mut chapters = vec![]; |
771 | |
772 | for item in book.iter() { |
773 | // Create the data to inject in the template |
774 | let mut chapter = BTreeMap::new(); |
775 | |
776 | match *item { |
777 | BookItem::PartTitle(ref title) => { |
778 | chapter.insert("part" .to_owned(), json!(title)); |
779 | } |
780 | BookItem::Chapter(ref ch) => { |
781 | if let Some(ref section) = ch.number { |
782 | chapter.insert("section" .to_owned(), json!(section.to_string())); |
783 | } |
784 | |
785 | chapter.insert( |
786 | "has_sub_items" .to_owned(), |
787 | json!((!ch.sub_items.is_empty()).to_string()), |
788 | ); |
789 | |
790 | chapter.insert("name" .to_owned(), json!(ch.name)); |
791 | if let Some(ref path) = ch.path { |
792 | let p = path |
793 | .to_str() |
794 | .with_context(|| "Could not convert path to str" )?; |
795 | chapter.insert("path" .to_owned(), json!(p)); |
796 | } |
797 | } |
798 | BookItem::Separator => { |
799 | chapter.insert("spacer" .to_owned(), json!("_spacer_" )); |
800 | } |
801 | } |
802 | |
803 | chapters.push(chapter); |
804 | } |
805 | |
806 | data.insert("chapters" .to_owned(), json!(chapters)); |
807 | |
808 | debug!("[*]: JSON constructed" ); |
809 | Ok(data) |
810 | } |
811 | |
812 | /// Goes through the rendered HTML, making sure all header tags have |
813 | /// an anchor respectively so people can link to sections directly. |
814 | fn build_header_links(html: &str) -> String { |
815 | static BUILD_HEADER_LINKS: Lazy<Regex> = Lazy::new(|| { |
816 | Regex::new(r#"<h(\d)(?: id="([^"]+)")?(?: class="([^"]+)")?>(.*?)</h\d>"# ).unwrap() |
817 | }); |
818 | static IGNORE_CLASS: &[&str] = &["menu-title" ]; |
819 | |
820 | let mut id_counter = HashMap::new(); |
821 | |
822 | BUILD_HEADER_LINKS |
823 | .replace_all(html, |caps: &Captures<'_>| { |
824 | let level = caps[1] |
825 | .parse() |
826 | .expect("Regex should ensure we only ever get numbers here" ); |
827 | |
828 | // Ignore .menu-title because now it's getting detected by the regex. |
829 | if let Some(classes) = caps.get(3) { |
830 | for class in classes.as_str().split(" " ) { |
831 | if IGNORE_CLASS.contains(&class) { |
832 | return caps[0].to_string(); |
833 | } |
834 | } |
835 | } |
836 | |
837 | insert_link_into_header( |
838 | level, |
839 | &caps[4], |
840 | caps.get(2).map(|x| x.as_str().to_string()), |
841 | caps.get(3).map(|x| x.as_str().to_string()), |
842 | &mut id_counter, |
843 | ) |
844 | }) |
845 | .into_owned() |
846 | } |
847 | |
848 | /// Insert a sinle link into a header, making sure each link gets its own |
849 | /// unique ID by appending an auto-incremented number (if necessary). |
850 | fn insert_link_into_header( |
851 | level: usize, |
852 | content: &str, |
853 | id: Option<String>, |
854 | classes: Option<String>, |
855 | id_counter: &mut HashMap<String, usize>, |
856 | ) -> String { |
857 | let id: String = id.unwrap_or_else(|| utils::unique_id_from_content(content, id_counter)); |
858 | let classes: String = classesOption |
859 | .map(|s: String| format!(" class= \"{s}\"" )) |
860 | .unwrap_or_default(); |
861 | |
862 | format!( |
863 | r##"<h {level} id=" {id}" {classes}><a class="header" href="# {id}"> {text}</a></h {level}>"## , |
864 | level = level, |
865 | id = id, |
866 | text = content, |
867 | classes = classes |
868 | ) |
869 | } |
870 | |
871 | // The rust book uses annotations for rustdoc to test code snippets, |
872 | // like the following: |
873 | // ```rust,should_panic |
874 | // fn main() { |
875 | // // Code here |
876 | // } |
877 | // ``` |
878 | // This function replaces all commas by spaces in the code block classes |
879 | fn fix_code_blocks(html: &str) -> String { |
880 | static FIX_CODE_BLOCKS: Lazy<Regex> = |
881 | Lazy::new(|| Regex::new(re:r##"<code([^>]+)class="([^"]+)"([^>]*)>"## ).unwrap()); |
882 | |
883 | FIX_CODE_BLOCKSCow<'_, str> |
884 | .replace_all(text:html, |caps: &Captures<'_>| { |
885 | let before: &str = &caps[1]; |
886 | let classes: &String = &caps[2].replace(from:',' , to:" " ); |
887 | let after: &str = &caps[3]; |
888 | |
889 | format!( |
890 | r#"<code {before}class=" {classes}" {after}>"# , |
891 | before = before, |
892 | classes = classes, |
893 | after = after |
894 | ) |
895 | }) |
896 | .into_owned() |
897 | } |
898 | |
899 | static CODE_BLOCK_RE: Lazy<Regex> = |
900 | Lazy::new(|| Regex::new(re:r##"((?s)<code[^>]?class="([^"]+)".*?>(.*?)</code>)"## ).unwrap()); |
901 | |
902 | fn add_playground_pre( |
903 | html: &str, |
904 | playground_config: &Playground, |
905 | edition: Option<RustEdition>, |
906 | ) -> String { |
907 | CODE_BLOCK_RE |
908 | .replace_all(html, |caps: &Captures<'_>| { |
909 | let text = &caps[1]; |
910 | let classes = &caps[2]; |
911 | let code = &caps[3]; |
912 | |
913 | if classes.contains("language-rust" ) |
914 | && ((!classes.contains("ignore" ) |
915 | && !classes.contains("noplayground" ) |
916 | && !classes.contains("noplaypen" ) |
917 | && playground_config.runnable) |
918 | || classes.contains("mdbook-runnable" )) |
919 | { |
920 | let contains_e2015 = classes.contains("edition2015" ); |
921 | let contains_e2018 = classes.contains("edition2018" ); |
922 | let contains_e2021 = classes.contains("edition2021" ); |
923 | let edition_class = if contains_e2015 || contains_e2018 || contains_e2021 { |
924 | // the user forced edition, we should not overwrite it |
925 | "" |
926 | } else { |
927 | match edition { |
928 | Some(RustEdition::E2015) => " edition2015" , |
929 | Some(RustEdition::E2018) => " edition2018" , |
930 | Some(RustEdition::E2021) => " edition2021" , |
931 | None => "" , |
932 | } |
933 | }; |
934 | |
935 | // wrap the contents in an external pre block |
936 | format!( |
937 | "<pre class= \"playground \"><code class= \"{}{}\"> {}</code></pre>" , |
938 | classes, |
939 | edition_class, |
940 | { |
941 | let content: Cow<'_, str> = if playground_config.editable |
942 | && classes.contains("editable" ) |
943 | || text.contains("fn main" ) |
944 | || text.contains("quick_main!" ) |
945 | { |
946 | code.into() |
947 | } else { |
948 | // we need to inject our own main |
949 | let (attrs, code) = partition_source(code); |
950 | |
951 | format!("# #![allow(unused)] \n{}#fn main() {{\n{}# }}" , attrs, code) |
952 | .into() |
953 | }; |
954 | content |
955 | } |
956 | ) |
957 | } else { |
958 | // not language-rust, so no-op |
959 | text.to_owned() |
960 | } |
961 | }) |
962 | .into_owned() |
963 | } |
964 | |
965 | /// Modifies all `<code>` blocks to convert "hidden" lines and to wrap them in |
966 | /// a `<span class="boring">`. |
967 | fn hide_lines(html: &str, code_config: &Code) -> String { |
968 | let language_regex = Regex::new(r"\blanguage-(\w+)\b" ).unwrap(); |
969 | let hidelines_regex = Regex::new(r"\bhidelines=(\S+)" ).unwrap(); |
970 | CODE_BLOCK_RE |
971 | .replace_all(html, |caps: &Captures<'_>| { |
972 | let text = &caps[1]; |
973 | let classes = &caps[2]; |
974 | let code = &caps[3]; |
975 | |
976 | if classes.contains("language-rust" ) { |
977 | format!( |
978 | "<code class= \"{}\"> {}</code>" , |
979 | classes, |
980 | hide_lines_rust(code) |
981 | ) |
982 | } else { |
983 | // First try to get the prefix from the code block |
984 | let hidelines_capture = hidelines_regex.captures(classes); |
985 | let hidelines_prefix = match &hidelines_capture { |
986 | Some(capture) => Some(&capture[1]), |
987 | None => { |
988 | // Then look up the prefix by language |
989 | language_regex.captures(classes).and_then(|capture| { |
990 | code_config.hidelines.get(&capture[1]).map(|p| p.as_str()) |
991 | }) |
992 | } |
993 | }; |
994 | |
995 | match hidelines_prefix { |
996 | Some(prefix) => format!( |
997 | "<code class= \"{}\"> {}</code>" , |
998 | classes, |
999 | hide_lines_with_prefix(code, prefix) |
1000 | ), |
1001 | None => text.to_owned(), |
1002 | } |
1003 | } |
1004 | }) |
1005 | .into_owned() |
1006 | } |
1007 | |
1008 | fn hide_lines_rust(content: &str) -> String { |
1009 | static BORING_LINES_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"^(\s*)#(.?)(.*)$" ).unwrap()); |
1010 | |
1011 | let mut result = String::with_capacity(content.len()); |
1012 | let mut lines = content.lines().peekable(); |
1013 | while let Some(line) = lines.next() { |
1014 | // Don't include newline on the last line. |
1015 | let newline = if lines.peek().is_none() { "" } else { " \n" }; |
1016 | if let Some(caps) = BORING_LINES_REGEX.captures(line) { |
1017 | if &caps[2] == "#" { |
1018 | result += &caps[1]; |
1019 | result += &caps[2]; |
1020 | result += &caps[3]; |
1021 | result += newline; |
1022 | continue; |
1023 | } else if &caps[2] != "!" && &caps[2] != "[" { |
1024 | result += "<span class= \"boring \">" ; |
1025 | result += &caps[1]; |
1026 | if &caps[2] != " " { |
1027 | result += &caps[2]; |
1028 | } |
1029 | result += &caps[3]; |
1030 | result += newline; |
1031 | result += "</span>" ; |
1032 | continue; |
1033 | } |
1034 | } |
1035 | result += line; |
1036 | result += newline; |
1037 | } |
1038 | result |
1039 | } |
1040 | |
1041 | fn hide_lines_with_prefix(content: &str, prefix: &str) -> String { |
1042 | let mut result: String = String::with_capacity(content.len()); |
1043 | for line: &str in content.lines() { |
1044 | if line.trim_start().starts_with(prefix) { |
1045 | let pos: usize = line.find(prefix).unwrap(); |
1046 | let (ws: &str, rest: &str) = (&line[..pos], &line[pos + prefix.len()..]); |
1047 | |
1048 | result += "<span class= \"boring \">" ; |
1049 | result += ws; |
1050 | result += rest; |
1051 | result += " \n" ; |
1052 | result += "</span>" ; |
1053 | continue; |
1054 | } |
1055 | result += line; |
1056 | result += " \n" ; |
1057 | } |
1058 | result |
1059 | } |
1060 | |
1061 | fn partition_source(s: &str) -> (String, String) { |
1062 | let mut after_header: bool = false; |
1063 | let mut before: String = String::new(); |
1064 | let mut after: String = String::new(); |
1065 | |
1066 | for line: &str in s.lines() { |
1067 | let trimline: &str = line.trim(); |
1068 | let header: bool = trimline.chars().all(char::is_whitespace) || trimline.starts_with("#![" ); |
1069 | if !header || after_header { |
1070 | after_header = true; |
1071 | after.push_str(string:line); |
1072 | after.push(ch:' \n' ); |
1073 | } else { |
1074 | before.push_str(string:line); |
1075 | before.push(ch:' \n' ); |
1076 | } |
1077 | } |
1078 | |
1079 | (before, after) |
1080 | } |
1081 | |
1082 | struct RenderItemContext<'a> { |
1083 | handlebars: &'a Handlebars<'a>, |
1084 | destination: PathBuf, |
1085 | data: serde_json::Map<String, serde_json::Value>, |
1086 | is_index: bool, |
1087 | book_config: BookConfig, |
1088 | html_config: HtmlConfig, |
1089 | edition: Option<RustEdition>, |
1090 | chapter_titles: &'a HashMap<PathBuf, String>, |
1091 | } |
1092 | |
1093 | #[cfg (test)] |
1094 | mod tests { |
1095 | use crate::config::TextDirection; |
1096 | |
1097 | use super::*; |
1098 | use pretty_assertions::assert_eq; |
1099 | |
1100 | #[test ] |
1101 | fn original_build_header_links() { |
1102 | let inputs = vec![ |
1103 | ( |
1104 | "blah blah <h1>Foo</h1>" , |
1105 | r##"blah blah <h1 id="foo"><a class="header" href="#foo">Foo</a></h1>"## , |
1106 | ), |
1107 | ( |
1108 | "<h1>Foo</h1>" , |
1109 | r##"<h1 id="foo"><a class="header" href="#foo">Foo</a></h1>"## , |
1110 | ), |
1111 | ( |
1112 | "<h3>Foo^bar</h3>" , |
1113 | r##"<h3 id="foobar"><a class="header" href="#foobar">Foo^bar</a></h3>"## , |
1114 | ), |
1115 | ( |
1116 | "<h4></h4>" , |
1117 | r##"<h4 id=""><a class="header" href="#"></a></h4>"## , |
1118 | ), |
1119 | ( |
1120 | "<h4><em>Hï</em></h4>" , |
1121 | r##"<h4 id="hï"><a class="header" href="#hï"><em>Hï</em></a></h4>"## , |
1122 | ), |
1123 | ( |
1124 | "<h1>Foo</h1><h3>Foo</h3>" , |
1125 | 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>"## , |
1126 | ), |
1127 | // id only |
1128 | ( |
1129 | r##"<h1 id="foobar">Foo</h1>"## , |
1130 | r##"<h1 id="foobar"><a class="header" href="#foobar">Foo</a></h1>"## , |
1131 | ), |
1132 | // class only |
1133 | ( |
1134 | r##"<h1 class="class1 class2">Foo</h1>"## , |
1135 | r##"<h1 id="foo" class="class1 class2"><a class="header" href="#foo">Foo</a></h1>"## , |
1136 | ), |
1137 | // both id and class |
1138 | ( |
1139 | r##"<h1 id="foobar" class="class1 class2">Foo</h1>"## , |
1140 | r##"<h1 id="foobar" class="class1 class2"><a class="header" href="#foobar">Foo</a></h1>"## , |
1141 | ), |
1142 | ]; |
1143 | |
1144 | for (src, should_be) in inputs { |
1145 | let got = build_header_links(src); |
1146 | assert_eq!(got, should_be); |
1147 | } |
1148 | } |
1149 | |
1150 | #[test ] |
1151 | fn add_playground() { |
1152 | let inputs = [ |
1153 | ("<code class= \"language-rust \">x()</code>" , |
1154 | "<pre class= \"playground \"><code class= \"language-rust \"># #![allow(unused)] \n#fn main() { \nx() \n#}</code></pre>" ), |
1155 | ("<code class= \"language-rust \">fn main() {}</code>" , |
1156 | "<pre class= \"playground \"><code class= \"language-rust \">fn main() {}</code></pre>" ), |
1157 | ("<code class= \"language-rust editable \">let s = \"foo \n # bar \n\";</code>" , |
1158 | "<pre class= \"playground \"><code class= \"language-rust editable \">let s = \"foo \n # bar \n\";</code></pre>" ), |
1159 | ("<code class= \"language-rust editable \">let s = \"foo \n ## bar \n\";</code>" , |
1160 | "<pre class= \"playground \"><code class= \"language-rust editable \">let s = \"foo \n ## bar \n\";</code></pre>" ), |
1161 | ("<code class= \"language-rust editable \">let s = \"foo \n # bar \n# \n\";</code>" , |
1162 | "<pre class= \"playground \"><code class= \"language-rust editable \">let s = \"foo \n # bar \n# \n\";</code></pre>" ), |
1163 | ("<code class= \"language-rust ignore \">let s = \"foo \n # bar \n\";</code>" , |
1164 | "<code class= \"language-rust ignore \">let s = \"foo \n # bar \n\";</code>" ), |
1165 | ("<code class= \"language-rust editable \">#![no_std] \nlet s = \"foo \"; \n #[some_attr]</code>" , |
1166 | "<pre class= \"playground \"><code class= \"language-rust editable \">#![no_std] \nlet s = \"foo \"; \n #[some_attr]</code></pre>" ), |
1167 | ]; |
1168 | for (src, should_be) in &inputs { |
1169 | let got = add_playground_pre( |
1170 | src, |
1171 | &Playground { |
1172 | editable: true, |
1173 | ..Playground::default() |
1174 | }, |
1175 | None, |
1176 | ); |
1177 | assert_eq!(&*got, *should_be); |
1178 | } |
1179 | } |
1180 | #[test ] |
1181 | fn add_playground_edition2015() { |
1182 | let inputs = [ |
1183 | ("<code class= \"language-rust \">x()</code>" , |
1184 | "<pre class= \"playground \"><code class= \"language-rust edition2015 \"># #![allow(unused)] \n#fn main() { \nx() \n#}</code></pre>" ), |
1185 | ("<code class= \"language-rust \">fn main() {}</code>" , |
1186 | "<pre class= \"playground \"><code class= \"language-rust edition2015 \">fn main() {}</code></pre>" ), |
1187 | ("<code class= \"language-rust edition2015 \">fn main() {}</code>" , |
1188 | "<pre class= \"playground \"><code class= \"language-rust edition2015 \">fn main() {}</code></pre>" ), |
1189 | ("<code class= \"language-rust edition2018 \">fn main() {}</code>" , |
1190 | "<pre class= \"playground \"><code class= \"language-rust edition2018 \">fn main() {}</code></pre>" ), |
1191 | ]; |
1192 | for (src, should_be) in &inputs { |
1193 | let got = add_playground_pre( |
1194 | src, |
1195 | &Playground { |
1196 | editable: true, |
1197 | ..Playground::default() |
1198 | }, |
1199 | Some(RustEdition::E2015), |
1200 | ); |
1201 | assert_eq!(&*got, *should_be); |
1202 | } |
1203 | } |
1204 | #[test ] |
1205 | fn add_playground_edition2018() { |
1206 | let inputs = [ |
1207 | ("<code class= \"language-rust \">x()</code>" , |
1208 | "<pre class= \"playground \"><code class= \"language-rust edition2018 \"># #![allow(unused)] \n#fn main() { \nx() \n#}</code></pre>" ), |
1209 | ("<code class= \"language-rust \">fn main() {}</code>" , |
1210 | "<pre class= \"playground \"><code class= \"language-rust edition2018 \">fn main() {}</code></pre>" ), |
1211 | ("<code class= \"language-rust edition2015 \">fn main() {}</code>" , |
1212 | "<pre class= \"playground \"><code class= \"language-rust edition2015 \">fn main() {}</code></pre>" ), |
1213 | ("<code class= \"language-rust edition2018 \">fn main() {}</code>" , |
1214 | "<pre class= \"playground \"><code class= \"language-rust edition2018 \">fn main() {}</code></pre>" ), |
1215 | ]; |
1216 | for (src, should_be) in &inputs { |
1217 | let got = add_playground_pre( |
1218 | src, |
1219 | &Playground { |
1220 | editable: true, |
1221 | ..Playground::default() |
1222 | }, |
1223 | Some(RustEdition::E2018), |
1224 | ); |
1225 | assert_eq!(&*got, *should_be); |
1226 | } |
1227 | } |
1228 | #[test ] |
1229 | fn add_playground_edition2021() { |
1230 | let inputs = [ |
1231 | ("<code class= \"language-rust \">x()</code>" , |
1232 | "<pre class= \"playground \"><code class= \"language-rust edition2021 \"># #![allow(unused)] \n#fn main() { \nx() \n#}</code></pre>" ), |
1233 | ("<code class= \"language-rust \">fn main() {}</code>" , |
1234 | "<pre class= \"playground \"><code class= \"language-rust edition2021 \">fn main() {}</code></pre>" ), |
1235 | ("<code class= \"language-rust edition2015 \">fn main() {}</code>" , |
1236 | "<pre class= \"playground \"><code class= \"language-rust edition2015 \">fn main() {}</code></pre>" ), |
1237 | ("<code class= \"language-rust edition2018 \">fn main() {}</code>" , |
1238 | "<pre class= \"playground \"><code class= \"language-rust edition2018 \">fn main() {}</code></pre>" ), |
1239 | ]; |
1240 | for (src, should_be) in &inputs { |
1241 | let got = add_playground_pre( |
1242 | src, |
1243 | &Playground { |
1244 | editable: true, |
1245 | ..Playground::default() |
1246 | }, |
1247 | Some(RustEdition::E2021), |
1248 | ); |
1249 | assert_eq!(&*got, *should_be); |
1250 | } |
1251 | } |
1252 | |
1253 | #[test ] |
1254 | fn hide_lines_language_rust() { |
1255 | let inputs = [ |
1256 | ( |
1257 | "<pre class= \"playground \"><code class= \"language-rust \"> \n# #![allow(unused)] \n#fn main() { \nx() \n#}</code></pre>" , |
1258 | "<pre class= \"playground \"><code class= \"language-rust \"> \n<span class= \"boring \">#![allow(unused)] \n</span><span class= \"boring \">fn main() { \n</span>x() \n<span class= \"boring \">}</span></code></pre>" ,), |
1259 | ( |
1260 | "<pre class= \"playground \"><code class= \"language-rust \">fn main() {}</code></pre>" , |
1261 | "<pre class= \"playground \"><code class= \"language-rust \">fn main() {}</code></pre>" ,), |
1262 | ( |
1263 | "<pre class= \"playground \"><code class= \"language-rust editable \">let s = \"foo \n # bar \n\";</code></pre>" , |
1264 | "<pre class= \"playground \"><code class= \"language-rust editable \">let s = \"foo \n<span class= \"boring \"> bar \n</span> \";</code></pre>" ,), |
1265 | ( |
1266 | "<pre class= \"playground \"><code class= \"language-rust editable \">let s = \"foo \n ## bar \n\";</code></pre>" , |
1267 | "<pre class= \"playground \"><code class= \"language-rust editable \">let s = \"foo \n # bar \n\";</code></pre>" ,), |
1268 | ( |
1269 | "<pre class= \"playground \"><code class= \"language-rust editable \">let s = \"foo \n # bar \n# \n\";</code></pre>" , |
1270 | "<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>" ,), |
1271 | ( |
1272 | "<code class= \"language-rust ignore \">let s = \"foo \n # bar \n\";</code>" , |
1273 | "<code class= \"language-rust ignore \">let s = \"foo \n<span class= \"boring \"> bar \n</span> \";</code>" ,), |
1274 | ( |
1275 | "<pre class= \"playground \"><code class= \"language-rust editable \">#![no_std] \nlet s = \"foo \"; \n #[some_attr]</code></pre>" , |
1276 | "<pre class= \"playground \"><code class= \"language-rust editable \">#![no_std] \nlet s = \"foo \"; \n #[some_attr]</code></pre>" ,), |
1277 | ]; |
1278 | for (src, should_be) in &inputs { |
1279 | let got = hide_lines(src, &Code::default()); |
1280 | assert_eq!(&*got, *should_be); |
1281 | } |
1282 | } |
1283 | |
1284 | #[test ] |
1285 | fn hide_lines_language_other() { |
1286 | let inputs = [ |
1287 | ( |
1288 | "<code class= \"language-python \">~hidden() \nnothidden(): \n~ hidden() \n ~hidden() \n nothidden()</code>" , |
1289 | "<code class= \"language-python \"><span class= \"boring \">hidden() \n</span>nothidden(): \n<span class= \"boring \"> hidden() \n</span><span class= \"boring \"> hidden() \n</span> nothidden() \n</code>" ,), |
1290 | ( |
1291 | "<code class= \"language-python hidelines=!!! \">!!!hidden() \nnothidden(): \n!!! hidden() \n !!!hidden() \n nothidden()</code>" , |
1292 | "<code class= \"language-python hidelines=!!! \"><span class= \"boring \">hidden() \n</span>nothidden(): \n<span class= \"boring \"> hidden() \n</span><span class= \"boring \"> hidden() \n</span> nothidden() \n</code>" ,), |
1293 | ]; |
1294 | for (src, should_be) in &inputs { |
1295 | let got = hide_lines( |
1296 | src, |
1297 | &Code { |
1298 | hidelines: { |
1299 | let mut map = HashMap::new(); |
1300 | map.insert("python" .to_string(), "~" .to_string()); |
1301 | map |
1302 | }, |
1303 | }, |
1304 | ); |
1305 | assert_eq!(&*got, *should_be); |
1306 | } |
1307 | } |
1308 | |
1309 | #[test ] |
1310 | fn test_json_direction() { |
1311 | assert_eq!(json!(TextDirection::RightToLeft), json!("rtl" )); |
1312 | assert_eq!(json!(TextDirection::LeftToRight), json!("ltr" )); |
1313 | } |
1314 | } |
1315 | |