Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement URL redirecting #1237

Merged
merged 10 commits into from
May 30, 2020
12 changes: 11 additions & 1 deletion book-example/src/format/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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
Expand All @@ -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]
Expand Down
18 changes: 18 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -514,6 +515,9 @@ pub struct HtmlConfig {
/// This config item *should not be edited* by the end user.
#[doc(hidden)]
pub livereload_url: Option<String>,
/// The mapping from old pages to new pages/URLs to use when generating
/// redirects.
pub redirect: HashMap<String, String>,
}

impl Default for HtmlConfig {
Expand All @@ -535,6 +539,7 @@ impl Default for HtmlConfig {
git_repository_url: None,
git_repository_icon: None,
livereload_url: None,
redirect: HashMap::new(),
}
}
}
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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()
};

Expand Down
71 changes: 70 additions & 1 deletion src/renderer/html_handlebars/hbs_renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -279,6 +279,68 @@ impl HtmlHandlebars {

Ok(())
}

fn emit_redirects(
&self,
root: &Path,
handlebars: &Handlebars<'_>,
redirects: &HashMap<String, String>,
) -> 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
Expand Down Expand Up @@ -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())?)?;

Expand Down Expand Up @@ -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"])?;

Expand Down
6 changes: 6 additions & 0 deletions src/theme/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -46,6 +47,7 @@ pub static FONT_AWESOME_OTF: &[u8] = include_bytes!("FontAwesome/fonts/FontAweso
pub struct Theme {
pub index: Vec<u8>,
pub head: Vec<u8>,
pub redirect: Vec<u8>,
pub header: Vec<u8>,
pub chrome_css: Vec<u8>,
pub general_css: Vec<u8>,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -175,6 +179,7 @@ mod tests {
let files = [
"index.hbs",
"head.hbs",
"redirect.hbs",
"header.hbs",
"favicon.png",
"css/chrome.css",
Expand Down Expand Up @@ -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(),
Expand Down
12 changes: 12 additions & 0 deletions src/theme/redirect.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Redirecting...</title>
<meta http-equiv="refresh" content="0;URL='{{url}}'">
<meta rel="canonical" href="{{url}}">
</head>
<body>
<p>Redirecting to... <a href="{{url}}">{{url}}</a>.</p>
</body>
</html>
39 changes: 38 additions & 1 deletion tests/rendered_output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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<PathBuf, String> = 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<Item = Component> + '_ {
path.components().skip_while(|c| match c {
Component::Prefix(_) | Component::RootDir => true,
_ => false,
})
}

#[cfg(feature = "search")]
mod search {
use crate::dummy_book::DummyBook;
Expand Down