Skip to content

Add support for fragment redirects #2747

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

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion guide/src/format/configuration/renderers.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,13 +310,21 @@ This is useful when you move, rename, or remove a page to ensure that links to t
[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"

# Fragment redirects also work.
"/some-existing-page.html#old-fragment" = "some-existing-page.html#new-fragment"

# Fragment redirects also work for deleted pages.
"/old-page.html" = "new-page.html"
"/old-page.html#old-fragment" = "new-page.html#new-fragment"
```

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`).

This will generate an HTML page which will automatically redirect to the given location.
Note that the source location does not support `#` anchor redirects.

When fragment redirects are specified, the page must use JavaScript to redirect to the correct location. This is useful if you rename or move a section header. Fragment redirects work with existing pages and deleted pages.

## Markdown Renderer

Expand Down
15 changes: 15 additions & 0 deletions src/front-end/templates/index.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,21 @@
{{/if}}
{{/if}}

{{#if fragment_map}}
<script>
document.addEventListener('DOMContentLoaded', function() {
const fragmentMap =
{{{fragment_map}}}
;
const target = fragmentMap[window.location.hash];
if (target) {
let url = new URL(target, window.location.href);
window.location.replace(url.href);
}
});
</script>
{{/if}}

</div>
</body>
</html>
24 changes: 24 additions & 0 deletions src/front-end/templates/redirect.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,29 @@
</head>
<body>
<p>Redirecting to... <a href="{{url}}">{{url}}</a>.</p>

<script>
// This handles redirects that involve fragments.
document.addEventListener('DOMContentLoaded', function() {
const fragmentMap =
{{{fragment_map}}}
;
const fragment = window.location.hash;
if (fragment) {
let redirectUrl = "{{url}}";
const target = fragmentMap[fragment];
if (target) {
let url = new URL(target, window.location.href);
redirectUrl = url.href;
} else {
let url = new URL(redirectUrl, window.location.href);
url.hash = window.location.hash;
redirectUrl = url.href;
}
window.location.replace(redirectUrl);
}
// else redirect handled by http-equiv
});
</script>
</body>
</html>
96 changes: 83 additions & 13 deletions src/renderer/html_handlebars/hbs_renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,14 @@ impl HtmlHandlebars {
.insert("section".to_owned(), json!(section.to_string()));
}

let redirects = collect_redirects_for_path(&filepath, &ctx.html_config.redirect)?;
if !redirects.is_empty() {
ctx.data.insert(
"fragment_map".to_owned(),
json!(serde_json::to_string(&redirects)?),
);
}

// Render the handlebars template with the data
debug!("Render template");
let rendered = ctx.handlebars.render("index", &ctx.data)?;
Expand Down Expand Up @@ -266,15 +274,27 @@ impl HtmlHandlebars {
}

log::debug!("Emitting redirects");
let redirects = combine_fragment_redirects(redirects);

for (original, new) in redirects {
log::debug!("Redirecting \"{}\" → \"{}\"", original, new);
for (original, (dest, fragment_map)) in redirects {
// 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)?;
if filename.exists() {
// This redirect is handled by the in-page fragment mapper.
continue;
}
if dest.is_empty() {
bail!(
"redirect entry for `{original}` only has source paths with `#` fragments\n\
There must be an entry without the `#` fragment to determine the default \
destination."
);
}
log::debug!("Redirecting \"{}\" → \"{}\"", original, dest);
self.emit_redirect(handlebars, &filename, &dest, &fragment_map)?;
}

Ok(())
Expand All @@ -285,23 +305,17 @@ impl HtmlHandlebars {
handlebars: &Handlebars<'_>,
original: &Path,
destination: &str,
fragment_map: &BTreeMap<String, String>,
) -> 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 js_map = serde_json::to_string(fragment_map)?;

let ctx = json!({
"fragment_map": js_map,
"url": destination,
});
let f = File::create(original)?;
Expand Down Expand Up @@ -934,6 +948,62 @@ struct RenderItemContext<'a> {
chapter_titles: &'a HashMap<PathBuf, String>,
}

/// Redirect mapping.
///
/// The key is the source path (like `foo/bar.html`). The value is a tuple
/// `(destination_path, fragment_map)`. The `destination_path` is the page to
/// redirect to. `fragment_map` is the map of fragments that override the
/// destination. For example, a fragment `#foo` could redirect to any other
/// page or site.
type CombinedRedirects = BTreeMap<String, (String, BTreeMap<String, String>)>;
fn combine_fragment_redirects(redirects: &HashMap<String, String>) -> CombinedRedirects {
let mut combined: CombinedRedirects = BTreeMap::new();
// This needs to extract the fragments to generate the fragment map.
for (original, new) in redirects {
if let Some((source_path, source_fragment)) = original.rsplit_once('#') {
let e = combined.entry(source_path.to_string()).or_default();
if let Some(old) = e.1.insert(format!("#{source_fragment}"), new.clone()) {
log::error!(
"internal error: found duplicate fragment redirect \
{old} for {source_path}#{source_fragment}"
);
}
} else {
let e = combined.entry(original.to_string()).or_default();
e.0 = new.clone();
}
}
combined
}

/// Collects fragment redirects for an existing page.
///
/// The returned map has keys like `#foo` and the value is the new destination
/// path or URL.
fn collect_redirects_for_path(
path: &Path,
redirects: &HashMap<String, String>,
) -> Result<BTreeMap<String, String>> {
let path = format!("/{}", path.display().to_string().replace('\\', "/"));
if redirects.contains_key(&path) {
bail!(
"redirect found for existing chapter at `{path}`\n\
Either delete the redirect or remove the chapter."
);
}

let key_prefix = format!("{path}#");
let map = redirects
.iter()
.filter_map(|(source, dest)| {
source
.strip_prefix(&key_prefix)
.map(|fragment| (format!("#{fragment}"), dest.to_string()))
})
.collect();
Ok(map)
}

#[cfg(test)]
mod tests {
use crate::config::TextDirection;
Expand Down
25 changes: 24 additions & 1 deletion test_book/book.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,27 @@ expand = true
heading-split-level = 2

[output.html.redirect]
"/format/config.html" = "configuration/index.html"
"/format/config.html" = "../prefix.html"

# This is a source without a fragment, and one with a fragment that goes to
# the same place. The redirect with the fragment is not necessary, since that
# is the default behavior.
"/pointless-fragment.html" = "prefix.html"
"/pointless-fragment.html#foo" = "prefix.html#foo"

"/rename-page-and-fragment.html" = "prefix.html"
"/rename-page-and-fragment.html#orig" = "prefix.html#new"

"/rename-page-fragment-elsewhere.html" = "prefix.html"
"/rename-page-fragment-elsewhere.html#orig" = "suffix.html#new"

# Rename fragment on an existing page.
"/prefix.html#orig" = "prefix.html#new"
# Rename fragment on an existing page to another page.
"/prefix.html#orig-new-page" = "suffix.html#new"

"/full-url-with-fragment.html" = "https://www.rust-lang.org/#fragment"

"/full-url-with-fragment-map.html" = "https://www.rust-lang.org/"
"/full-url-with-fragment-map.html#a" = "https://www.rust-lang.org/#new1"
"/full-url-with-fragment-map.html#b" = "https://www.rust-lang.org/#new2"
51 changes: 51 additions & 0 deletions tests/gui/redirect.goml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
go-to: |DOC_PATH| + "format/config.html"
assert-window-property: ({"location": |DOC_PATH| + "prefix.html"})

// Check that it preserves fragments when redirecting.
go-to: |DOC_PATH| + "format/config.html#fragment"
assert-window-property: ({"location": |DOC_PATH| + "prefix.html#fragment"})

// The fragment one here isn't necessary, but should still work.
go-to: |DOC_PATH| + "pointless-fragment.html"
assert-window-property: ({"location": |DOC_PATH| + "prefix.html"})
go-to: |DOC_PATH| + "pointless-fragment.html#foo"
assert-window-property: ({"location": |DOC_PATH| + "prefix.html#foo"})

// Page rename, and a fragment rename.
go-to: |DOC_PATH| + "rename-page-and-fragment.html"
assert-window-property: ({"location": |DOC_PATH| + "prefix.html"})
go-to: |DOC_PATH| + "rename-page-and-fragment.html#orig"
assert-window-property: ({"location": |DOC_PATH| + "prefix.html#new"})

// Page rename, and the fragment goes to a *different* page from the default.
go-to: |DOC_PATH| + "rename-page-fragment-elsewhere.html"
assert-window-property: ({"location": |DOC_PATH| + "prefix.html"})
go-to: |DOC_PATH| + "rename-page-fragment-elsewhere.html#orig"
assert-window-property: ({"location": |DOC_PATH| + "suffix.html#new"})

// Goes to an external site.
go-to: |DOC_PATH| + "full-url-with-fragment.html"
assert-window-property: ({"location": "https://www.rust-lang.org/#fragment"})

// External site with fragment renames.
go-to: |DOC_PATH| + "full-url-with-fragment-map.html#a"
assert-window-property: ({"location": "https://www.rust-lang.org/#new1"})
go-to: |DOC_PATH| + "full-url-with-fragment-map.html#b"
assert-window-property: ({"location": "https://www.rust-lang.org/#new2"})

// Rename fragment on an existing page.
go-to: |DOC_PATH| + "prefix.html#orig"
assert-window-property: ({"location": |DOC_PATH| + "prefix.html#new"})

// Other fragments aren't affected.
go-to: |DOC_PATH| + "index.html" // Reset page since redirects are processed on load.
go-to: |DOC_PATH| + "prefix.html"
assert-window-property: ({"location": |DOC_PATH| + "prefix.html"})
go-to: |DOC_PATH| + "index.html" // Reset page since redirects are processed on load.
go-to: |DOC_PATH| + "prefix.html#dont-change"
assert-window-property: ({"location": |DOC_PATH| + "prefix.html#dont-change"})

// Rename fragment on an existing page to another page.
go-to: |DOC_PATH| + "index.html" // Reset page since redirects are processed on load.
go-to: |DOC_PATH| + "prefix.html#orig-new-page"
assert-window-property: ({"location": |DOC_PATH| + "suffix.html#new"})
31 changes: 31 additions & 0 deletions tests/testsuite/redirects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,34 @@ fn redirects_are_emitted_correctly() {
file!["redirects/redirects_are_emitted_correctly/expected/nested/page.html"],
);
}

// Invalid redirect with only fragments.
#[test]
fn redirect_removed_with_fragments_only() {
BookTest::from_dir("redirects/redirect_removed_with_fragments_only").run("build", |cmd| {
cmd.expect_failure().expect_stderr(str![[r#"
[TIMESTAMP] [INFO] (mdbook::book): Book building has started
[TIMESTAMP] [INFO] (mdbook::book): Running the html backend
[TIMESTAMP] [ERROR] (mdbook::utils): Error: Rendering failed
[TIMESTAMP] [ERROR] (mdbook::utils): [TAB]Caused By: Unable to emit redirects
[TIMESTAMP] [ERROR] (mdbook::utils): [TAB]Caused By: redirect entry for `old-file.html` only has source paths with `#` fragments
There must be an entry without the `#` fragment to determine the default destination.

"#]]);
});
}

// Invalid redirect for an existing page.
#[test]
fn redirect_existing_page() {
BookTest::from_dir("redirects/redirect_existing_page").run("build", |cmd| {
cmd.expect_failure().expect_stderr(str![[r#"
[TIMESTAMP] [INFO] (mdbook::book): Book building has started
[TIMESTAMP] [INFO] (mdbook::book): Running the html backend
[TIMESTAMP] [ERROR] (mdbook::utils): Error: Rendering failed
[TIMESTAMP] [ERROR] (mdbook::utils): [TAB]Caused By: redirect found for existing chapter at `/chapter_1.html`
Either delete the redirect or remove the chapter.

"#]]);
});
}
5 changes: 5 additions & 0 deletions tests/testsuite/redirects/redirect_existing_page/book.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[book]
title = "redirect_existing_page"

[output.html.redirect]
"/chapter_1.html" = "other-page.html"
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Summary

- [Chapter 1](./chapter_1.md)
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Chapter 1
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[book]
title = "redirect_removed_with_fragments_only"

[output.html.redirect]
"/old-file.html#foo" = "chapter_1.html"
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Summary

- [Chapter 1](./chapter_1.md)
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Chapter 1
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ title = "redirects_are_emitted_correctly"

[output.html.redirect]
"/overview.html" = "index.html"
"/overview.html#old" = "index.html#new"
"/nested/page.html" = "https://rust-lang.org/"
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,29 @@
</head>
<body>
<p>Redirecting to... <a href="https://rust-lang.org/">https://rust-lang.org/</a>.</p>

<script>
// This handles redirects that involve fragments.
document.addEventListener('DOMContentLoaded', function() {
const fragmentMap =
{}
;
const fragment = window.location.hash;
if (fragment) {
let redirectUrl = "https://rust-lang.org/";
const target = fragmentMap[fragment];
if (target) {
let url = new URL(target, window.location.href);
redirectUrl = url.href;
} else {
let url = new URL(redirectUrl, window.location.href);
url.hash = window.location.hash;
redirectUrl = url.href;
}
window.location.replace(redirectUrl);
}
// else redirect handled by http-equiv
});
</script>
</body>
</html>
Loading