diff --git a/book-example/src/format/config.md b/book-example/src/format/config.md index c1a97aa7..cc5c6845 100644 --- a/book-example/src/format/config.md +++ b/book-example/src/format/config.md @@ -198,6 +198,12 @@ The following configuration options are available: an icon link will be output in the menu bar of the book. - **git-repository-icon:** The FontAwesome icon class to use for the git repository link. Defaults to `fa-github`. +- **redirect:** A subtable used for generating redirects when a page is moved. + The table contains key-value pairs where the key is where the redirect file + needs to be created, as an absolute path from the build directory, (e.g. + `/appendices/bibliography.html`). The value can be any valid URI the + browser should navigate to (e.g. `https://rust-lang.org/`, + `/overview.html`, or `../bibliography.html`). Available configuration options for the `[output.html.fold]` table: @@ -281,6 +287,10 @@ boost-paragraph = 1 expand = true heading-split-level = 3 copy-js = true + +[output.html.redirect] +"/appendices/bibliography.html" = "https://rustc-dev-guide.rust-lang.org/appendix/bibliography.html" +"/other-installation-methods.html" = "../infra/other-installation-methods.html" ``` ### Markdown Renderer @@ -291,7 +301,7 @@ conjunction with `mdbook test` to see the Markdown that `mdbook` is passing to `rustdoc`. The Markdown renderer is included with `mdbook` but disabled by default. -Enable it by adding an emtpy table to your `book.toml` as follows: +Enable it by adding an empty table to your `book.toml` as follows: ```toml [output.markdown] diff --git a/src/config.rs b/src/config.rs index 4fb20be0..dc712bbb 100644 --- a/src/config.rs +++ b/src/config.rs @@ -50,6 +50,7 @@ #![deny(missing_docs)] use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::collections::HashMap; use std::env; use std::fs::File; use std::io::Read; @@ -514,6 +515,9 @@ pub struct HtmlConfig { /// This config item *should not be edited* by the end user. #[doc(hidden)] pub livereload_url: Option, + /// The mapping from old pages to new pages/URLs to use when generating + /// redirects. + pub redirect: HashMap, } impl Default for HtmlConfig { @@ -535,6 +539,7 @@ impl Default for HtmlConfig { git_repository_url: None, git_repository_icon: None, livereload_url: None, + redirect: HashMap::new(), } } } @@ -693,6 +698,10 @@ mod tests { editable = true editor = "ace" + [output.html.redirect] + "index.html" = "overview.html" + "nexted/page.md" = "https://rust-lang.org/" + [preprocessor.first] [preprocessor.second] @@ -731,6 +740,15 @@ mod tests { playpen: playpen_should_be, git_repository_url: Some(String::from("https://foo.com/")), git_repository_icon: Some(String::from("fa-code-fork")), + redirect: vec![ + (String::from("index.html"), String::from("overview.html")), + ( + String::from("nexted/page.md"), + String::from("https://rust-lang.org/"), + ), + ] + .into_iter() + .collect(), ..Default::default() }; diff --git a/src/renderer/html_handlebars/hbs_renderer.rs b/src/renderer/html_handlebars/hbs_renderer.rs index d58bc805..ecb8359e 100644 --- a/src/renderer/html_handlebars/hbs_renderer.rs +++ b/src/renderer/html_handlebars/hbs_renderer.rs @@ -9,7 +9,7 @@ use crate::utils; use std::borrow::Cow; use std::collections::BTreeMap; use std::collections::HashMap; -use std::fs; +use std::fs::{self, File}; use std::path::{Path, PathBuf}; use handlebars::Handlebars; @@ -279,6 +279,68 @@ impl HtmlHandlebars { Ok(()) } + + fn emit_redirects( + &self, + root: &Path, + handlebars: &Handlebars<'_>, + redirects: &HashMap, + ) -> Result<()> { + if redirects.is_empty() { + return Ok(()); + } + + log::debug!("Emitting redirects"); + + for (original, new) in redirects { + log::debug!("Redirecting \"{}\" → \"{}\"", original, new); + // Note: all paths are relative to the build directory, so the + // leading slash in an absolute path means nothing (and would mess + // up `root.join(original)`). + let original = original.trim_start_matches("/"); + let filename = root.join(original); + self.emit_redirect(handlebars, &filename, new)?; + } + + Ok(()) + } + + fn emit_redirect( + &self, + handlebars: &Handlebars<'_>, + original: &Path, + destination: &str, + ) -> Result<()> { + if original.exists() { + // sanity check to avoid accidentally overwriting a real file. + let msg = format!( + "Not redirecting \"{}\" to \"{}\" because it already exists. Are you sure it needs to be redirected?", + original.display(), + destination, + ); + return Err(Error::msg(msg)); + } + + if let Some(parent) = original.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("Unable to ensure \"{}\" exists", parent.display()))?; + } + + let ctx = json!({ + "url": destination, + }); + let f = File::create(original)?; + handlebars + .render_to_write("redirect", &ctx, f) + .with_context(|| { + format!( + "Unable to create a redirect file at \"{}\"", + original.display() + ) + })?; + + Ok(()) + } } // TODO(mattico): Remove some time after the 0.1.8 release @@ -343,6 +405,10 @@ impl Renderer for HtmlHandlebars { debug!("Register the head handlebars template"); handlebars.register_partial("head", String::from_utf8(theme.head.clone())?)?; + debug!("Register the redirect handlebars template"); + handlebars + .register_template_string("redirect", String::from_utf8(theme.redirect.clone())?)?; + debug!("Register the header handlebars template"); handlebars.register_partial("header", String::from_utf8(theme.header.clone())?)?; @@ -401,6 +467,9 @@ impl Renderer for HtmlHandlebars { } } + self.emit_redirects(&ctx.destination, &handlebars, &html_config.redirect) + .context("Unable to emit redirects")?; + // Copy all remaining files, avoid a recursive copy from/to the book build dir utils::fs::copy_files_except_ext(&src_dir, &destination, true, Some(&build_dir), &["md"])?; diff --git a/src/theme/mod.rs b/src/theme/mod.rs index b8c496c3..38aa22e9 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -15,6 +15,7 @@ use crate::errors::*; pub static INDEX: &[u8] = include_bytes!("index.hbs"); pub static HEAD: &[u8] = include_bytes!("head.hbs"); +pub static REDIRECT: &[u8] = include_bytes!("redirect.hbs"); pub static HEADER: &[u8] = include_bytes!("header.hbs"); pub static CHROME_CSS: &[u8] = include_bytes!("css/chrome.css"); pub static GENERAL_CSS: &[u8] = include_bytes!("css/general.css"); @@ -46,6 +47,7 @@ pub static FONT_AWESOME_OTF: &[u8] = include_bytes!("FontAwesome/fonts/FontAweso pub struct Theme { pub index: Vec, pub head: Vec, + pub redirect: Vec, pub header: Vec, pub chrome_css: Vec, pub general_css: Vec, @@ -77,6 +79,7 @@ impl Theme { let files = vec![ (theme_dir.join("index.hbs"), &mut theme.index), (theme_dir.join("head.hbs"), &mut theme.head), + (theme_dir.join("redirect.hbs"), &mut theme.redirect), (theme_dir.join("header.hbs"), &mut theme.header), (theme_dir.join("book.js"), &mut theme.js), (theme_dir.join("css/chrome.css"), &mut theme.chrome_css), @@ -120,6 +123,7 @@ impl Default for Theme { Theme { index: INDEX.to_owned(), head: HEAD.to_owned(), + redirect: REDIRECT.to_owned(), header: HEADER.to_owned(), chrome_css: CHROME_CSS.to_owned(), general_css: GENERAL_CSS.to_owned(), @@ -175,6 +179,7 @@ mod tests { let files = [ "index.hbs", "head.hbs", + "redirect.hbs", "header.hbs", "favicon.png", "css/chrome.css", @@ -203,6 +208,7 @@ mod tests { let empty = Theme { index: Vec::new(), head: Vec::new(), + redirect: Vec::new(), header: Vec::new(), chrome_css: Vec::new(), general_css: Vec::new(), diff --git a/src/theme/redirect.hbs b/src/theme/redirect.hbs new file mode 100644 index 00000000..9f49e6d0 --- /dev/null +++ b/src/theme/redirect.hbs @@ -0,0 +1,12 @@ + + + + + Redirecting... + + + + +

Redirecting to... {{url}}.

+ + diff --git a/tests/rendered_output.rs b/tests/rendered_output.rs index 82b07045..db0badd7 100644 --- a/tests/rendered_output.rs +++ b/tests/rendered_output.rs @@ -12,10 +12,11 @@ use mdbook::utils::fs::write_file; use mdbook::MDBook; use select::document::Document; use select::predicate::{Class, Name, Predicate}; +use std::collections::HashMap; use std::ffi::OsStr; use std::fs; use std::io::Write; -use std::path::Path; +use std::path::{Component, Path, PathBuf}; use tempfile::Builder as TempFileBuilder; use walkdir::{DirEntry, WalkDir}; @@ -511,6 +512,42 @@ fn markdown_options() { ); } +#[test] +fn redirects_are_emitted_correctly() { + let temp = DummyBook::new().build().unwrap(); + let mut md = MDBook::load(temp.path()).unwrap(); + + // override the "outputs.html.redirect" table + let redirects: HashMap = vec![ + (PathBuf::from("/overview.html"), String::from("index.html")), + ( + PathBuf::from("/nexted/page.md"), + String::from("https://rust-lang.org/"), + ), + ] + .into_iter() + .collect(); + md.config.set("output.html.redirect", &redirects).unwrap(); + + md.build().unwrap(); + + for (original, redirect) in &redirects { + let mut redirect_file = md.build_dir_for("html"); + // append everything except the bits that make it absolute + // (e.g. "/" or "C:\") + redirect_file.extend(remove_absolute_components(&original)); + let contents = fs::read_to_string(&redirect_file).unwrap(); + assert!(contents.contains(redirect)); + } +} + +fn remove_absolute_components(path: &Path) -> impl Iterator + '_ { + path.components().skip_while(|c| match c { + Component::Prefix(_) | Component::RootDir => true, + _ => false, + }) +} + #[cfg(feature = "search")] mod search { use crate::dummy_book::DummyBook;