Merge pull request #2747 from ehuss/fragment-redirect

Add support for fragment redirects
This commit is contained in:
Eric Huss 2025-07-14 21:55:08 +00:00 committed by GitHub
commit a918910a52
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 306 additions and 15 deletions

View file

@ -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

View file

@ -347,6 +347,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>

View file

@ -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>

View file

@ -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)?;
@ -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(())
@ -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)?;
@ -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;

View file

@ -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
tests/gui/redirect.goml Normal file
View file

@ -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"})

View file

@ -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.
"#]]);
});
}

View file

@ -0,0 +1,5 @@
[book]
title = "redirect_existing_page"
[output.html.redirect]
"/chapter_1.html" = "other-page.html"

View file

@ -0,0 +1,3 @@
# Summary
- [Chapter 1](./chapter_1.md)

View file

@ -0,0 +1 @@
# Chapter 1

View file

@ -0,0 +1,5 @@
[book]
title = "redirect_removed_with_fragments_only"
[output.html.redirect]
"/old-file.html#foo" = "chapter_1.html"

View file

@ -0,0 +1,3 @@
# Summary
- [Chapter 1](./chapter_1.md)

View file

@ -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/"

View file

@ -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>

View file

@ -8,5 +8,29 @@
</head>
<body>
<p>Redirecting to... <a href="index.html">index.html</a>.</p>
<script>
// This handles redirects that involve fragments.
document.addEventListener('DOMContentLoaded', function() {
const fragmentMap =
{"#old":"index.html#new"}
;
const fragment = window.location.hash;
if (fragment) {
let redirectUrl = "index.html";
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>

View file

@ -1,3 +1,4 @@
# Redirects
- [Chapter 1](chapter_1.md)
- [Chapter 2](chapter_2.md)

View file

@ -0,0 +1 @@
# Chapter 2