Skip to content

Commit 15c93b5

Browse files
committed
Add support for fragment redirects
This adds the ability to redirect URLs with `#` fragments. This is useful when section headers get renamed or moved to other pages. This works both for deleted pages and existing pages. The implementation requires the use of JavaScript in order to manipulate the location. (Ideally this would be handled on the server side.) This also makes it so that deleted page redirects preserve the fragment ID. Previously if you had a deleted page redirect, and the user went to something like `page.html#foo`, it would redirect to `bar.html` without the fragment. I think preserving the fragment is probably a better behavior. If the new page doesn't have the fragment ID, then no harm is really done. This is technically an open redirect, but I don't think that there is too much danger with preserving a fragment ID?
1 parent e6315bf commit 15c93b5

File tree

18 files changed

+306
-15
lines changed

18 files changed

+306
-15
lines changed

guide/src/format/configuration/renderers.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,13 +310,21 @@ This is useful when you move, rename, or remove a page to ensure that links to t
310310
[output.html.redirect]
311311
"/appendices/bibliography.html" = "https://rustc-dev-guide.rust-lang.org/appendix/bibliography.html"
312312
"/other-installation-methods.html" = "../infra/other-installation-methods.html"
313+
314+
# Fragment redirects also work.
315+
"/some-existing-page.html#old-fragment" = "some-existing-page.html#new-fragment"
316+
317+
# Fragment redirects also work for deleted pages.
318+
"/old-page.html" = "new-page.html"
319+
"/old-page.html#old-fragment" = "new-page.html#new-fragment"
313320
```
314321

315322
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`).
316323
The value can be any valid URI the browser should navigate to (e.g. `https://rust-lang.org/`, `/overview.html`, or `../bibliography.html`).
317324

318325
This will generate an HTML page which will automatically redirect to the given location.
319-
Note that the source location does not support `#` anchor redirects.
326+
327+
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.
320328

321329
## Markdown Renderer
322330

src/front-end/templates/index.hbs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,21 @@
341341
{{/if}}
342342
{{/if}}
343343

344+
{{#if fragment_map}}
345+
<script>
346+
document.addEventListener('DOMContentLoaded', function() {
347+
const fragmentMap =
348+
{{{fragment_map}}}
349+
;
350+
const target = fragmentMap[window.location.hash];
351+
if (target) {
352+
let url = new URL(target, window.location.href);
353+
window.location.replace(url.href);
354+
}
355+
});
356+
</script>
357+
{{/if}}
358+
344359
</div>
345360
</body>
346361
</html>

src/front-end/templates/redirect.hbs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,29 @@
88
</head>
99
<body>
1010
<p>Redirecting to... <a href="{{url}}">{{url}}</a>.</p>
11+
12+
<script>
13+
// This handles redirects that involve fragments.
14+
document.addEventListener('DOMContentLoaded', function() {
15+
const fragmentMap =
16+
{{{fragment_map}}}
17+
;
18+
const fragment = window.location.hash;
19+
if (fragment) {
20+
let redirectUrl = "{{url}}";
21+
const target = fragmentMap[fragment];
22+
if (target) {
23+
let url = new URL(target, window.location.href);
24+
redirectUrl = url.href;
25+
} else {
26+
let url = new URL(redirectUrl, window.location.href);
27+
url.hash = window.location.hash;
28+
redirectUrl = url.href;
29+
}
30+
window.location.replace(redirectUrl);
31+
}
32+
// else redirect handled by http-equiv
33+
});
34+
</script>
1135
</body>
1236
</html>

src/renderer/html_handlebars/hbs_renderer.rs

Lines changed: 83 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,14 @@ impl HtmlHandlebars {
111111
.insert("section".to_owned(), json!(section.to_string()));
112112
}
113113

114+
let redirects = collect_redirects_for_path(&filepath, &ctx.html_config.redirect)?;
115+
if !redirects.is_empty() {
116+
ctx.data.insert(
117+
"fragment_map".to_owned(),
118+
json!(serde_json::to_string(&redirects)?),
119+
);
120+
}
121+
114122
// Render the handlebars template with the data
115123
debug!("Render template");
116124
let rendered = ctx.handlebars.render("index", &ctx.data)?;
@@ -266,15 +274,27 @@ impl HtmlHandlebars {
266274
}
267275

268276
log::debug!("Emitting redirects");
277+
let redirects = combine_fragment_redirects(redirects);
269278

270-
for (original, new) in redirects {
271-
log::debug!("Redirecting \"{}\"\"{}\"", original, new);
279+
for (original, (dest, fragment_map)) in redirects {
272280
// Note: all paths are relative to the build directory, so the
273281
// leading slash in an absolute path means nothing (and would mess
274282
// up `root.join(original)`).
275283
let original = original.trim_start_matches('/');
276284
let filename = root.join(original);
277-
self.emit_redirect(handlebars, &filename, new)?;
285+
if filename.exists() {
286+
// This redirect is handled by the in-page fragment mapper.
287+
continue;
288+
}
289+
if dest.is_empty() {
290+
bail!(
291+
"redirect entry for `{original}` only has source paths with `#` fragments\n\
292+
There must be an entry without the `#` fragment to determine the default \
293+
destination."
294+
);
295+
}
296+
log::debug!("Redirecting \"{}\"\"{}\"", original, dest);
297+
self.emit_redirect(handlebars, &filename, &dest, &fragment_map)?;
278298
}
279299

280300
Ok(())
@@ -285,23 +305,17 @@ impl HtmlHandlebars {
285305
handlebars: &Handlebars<'_>,
286306
original: &Path,
287307
destination: &str,
308+
fragment_map: &BTreeMap<String, String>,
288309
) -> Result<()> {
289-
if original.exists() {
290-
// sanity check to avoid accidentally overwriting a real file.
291-
let msg = format!(
292-
"Not redirecting \"{}\" to \"{}\" because it already exists. Are you sure it needs to be redirected?",
293-
original.display(),
294-
destination,
295-
);
296-
return Err(Error::msg(msg));
297-
}
298-
299310
if let Some(parent) = original.parent() {
300311
std::fs::create_dir_all(parent)
301312
.with_context(|| format!("Unable to ensure \"{}\" exists", parent.display()))?;
302313
}
303314

315+
let js_map = serde_json::to_string(fragment_map)?;
316+
304317
let ctx = json!({
318+
"fragment_map": js_map,
305319
"url": destination,
306320
});
307321
let f = File::create(original)?;
@@ -934,6 +948,62 @@ struct RenderItemContext<'a> {
934948
chapter_titles: &'a HashMap<PathBuf, String>,
935949
}
936950

951+
/// Redirect mapping.
952+
///
953+
/// The key is the source path (like `foo/bar.html`). The value is a tuple
954+
/// `(destination_path, fragment_map)`. The `destination_path` is the page to
955+
/// redirect to. `fragment_map` is the map of fragments that override the
956+
/// destination. For example, a fragment `#foo` could redirect to any other
957+
/// page or site.
958+
type CombinedRedirects = BTreeMap<String, (String, BTreeMap<String, String>)>;
959+
fn combine_fragment_redirects(redirects: &HashMap<String, String>) -> CombinedRedirects {
960+
let mut combined: CombinedRedirects = BTreeMap::new();
961+
// This needs to extract the fragments to generate the fragment map.
962+
for (original, new) in redirects {
963+
if let Some((source_path, source_fragment)) = original.rsplit_once('#') {
964+
let e = combined.entry(source_path.to_string()).or_default();
965+
if let Some(old) = e.1.insert(format!("#{source_fragment}"), new.clone()) {
966+
log::error!(
967+
"internal error: found duplicate fragment redirect \
968+
{old} for {source_path}#{source_fragment}"
969+
);
970+
}
971+
} else {
972+
let e = combined.entry(original.to_string()).or_default();
973+
e.0 = new.clone();
974+
}
975+
}
976+
combined
977+
}
978+
979+
/// Collects fragment redirects for an existing page.
980+
///
981+
/// The returned map has keys like `#foo` and the value is the new destination
982+
/// path or URL.
983+
fn collect_redirects_for_path(
984+
path: &Path,
985+
redirects: &HashMap<String, String>,
986+
) -> Result<BTreeMap<String, String>> {
987+
let path = format!("/{}", path.display().to_string().replace('\\', "/"));
988+
if redirects.contains_key(&path) {
989+
bail!(
990+
"redirect found for existing chapter at `{path}`\n\
991+
Either delete the redirect or remove the chapter."
992+
);
993+
}
994+
995+
let key_prefix = format!("{path}#");
996+
let map = redirects
997+
.iter()
998+
.filter_map(|(source, dest)| {
999+
source
1000+
.strip_prefix(&key_prefix)
1001+
.map(|fragment| (format!("#{fragment}"), dest.to_string()))
1002+
})
1003+
.collect();
1004+
Ok(map)
1005+
}
1006+
9371007
#[cfg(test)]
9381008
mod tests {
9391009
use crate::config::TextDirection;

test_book/book.toml

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,27 @@ expand = true
2525
heading-split-level = 2
2626

2727
[output.html.redirect]
28-
"/format/config.html" = "configuration/index.html"
28+
"/format/config.html" = "../prefix.html"
29+
30+
# This is a source without a fragment, and one with a fragment that goes to
31+
# the same place. The redirect with the fragment is not necessary, since that
32+
# is the default behavior.
33+
"/pointless-fragment.html" = "prefix.html"
34+
"/pointless-fragment.html#foo" = "prefix.html#foo"
35+
36+
"/rename-page-and-fragment.html" = "prefix.html"
37+
"/rename-page-and-fragment.html#orig" = "prefix.html#new"
38+
39+
"/rename-page-fragment-elsewhere.html" = "prefix.html"
40+
"/rename-page-fragment-elsewhere.html#orig" = "suffix.html#new"
41+
42+
# Rename fragment on an existing page.
43+
"/prefix.html#orig" = "prefix.html#new"
44+
# Rename fragment on an existing page to another page.
45+
"/prefix.html#orig-new-page" = "suffix.html#new"
46+
47+
"/full-url-with-fragment.html" = "https://www.rust-lang.org/#fragment"
48+
49+
"/full-url-with-fragment-map.html" = "https://www.rust-lang.org/"
50+
"/full-url-with-fragment-map.html#a" = "https://www.rust-lang.org/#new1"
51+
"/full-url-with-fragment-map.html#b" = "https://www.rust-lang.org/#new2"

tests/gui/redirect.goml

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
go-to: |DOC_PATH| + "format/config.html"
2+
assert-window-property: ({"location": |DOC_PATH| + "prefix.html"})
3+
4+
// Check that it preserves fragments when redirecting.
5+
go-to: |DOC_PATH| + "format/config.html#fragment"
6+
assert-window-property: ({"location": |DOC_PATH| + "prefix.html#fragment"})
7+
8+
// The fragment one here isn't necessary, but should still work.
9+
go-to: |DOC_PATH| + "pointless-fragment.html"
10+
assert-window-property: ({"location": |DOC_PATH| + "prefix.html"})
11+
go-to: |DOC_PATH| + "pointless-fragment.html#foo"
12+
assert-window-property: ({"location": |DOC_PATH| + "prefix.html#foo"})
13+
14+
// Page rename, and a fragment rename.
15+
go-to: |DOC_PATH| + "rename-page-and-fragment.html"
16+
assert-window-property: ({"location": |DOC_PATH| + "prefix.html"})
17+
go-to: |DOC_PATH| + "rename-page-and-fragment.html#orig"
18+
assert-window-property: ({"location": |DOC_PATH| + "prefix.html#new"})
19+
20+
// Page rename, and the fragment goes to a *different* page from the default.
21+
go-to: |DOC_PATH| + "rename-page-fragment-elsewhere.html"
22+
assert-window-property: ({"location": |DOC_PATH| + "prefix.html"})
23+
go-to: |DOC_PATH| + "rename-page-fragment-elsewhere.html#orig"
24+
assert-window-property: ({"location": |DOC_PATH| + "suffix.html#new"})
25+
26+
// Goes to an external site.
27+
go-to: |DOC_PATH| + "full-url-with-fragment.html"
28+
assert-window-property: ({"location": "https://www.rust-lang.org/#fragment"})
29+
30+
// External site with fragment renames.
31+
go-to: |DOC_PATH| + "full-url-with-fragment-map.html#a"
32+
assert-window-property: ({"location": "https://www.rust-lang.org/#new1"})
33+
go-to: |DOC_PATH| + "full-url-with-fragment-map.html#b"
34+
assert-window-property: ({"location": "https://www.rust-lang.org/#new2"})
35+
36+
// Rename fragment on an existing page.
37+
go-to: |DOC_PATH| + "prefix.html#orig"
38+
assert-window-property: ({"location": |DOC_PATH| + "prefix.html#new"})
39+
40+
// Other fragments aren't affected.
41+
go-to: |DOC_PATH| + "index.html" // Reset page since redirects are processed on load.
42+
go-to: |DOC_PATH| + "prefix.html"
43+
assert-window-property: ({"location": |DOC_PATH| + "prefix.html"})
44+
go-to: |DOC_PATH| + "index.html" // Reset page since redirects are processed on load.
45+
go-to: |DOC_PATH| + "prefix.html#dont-change"
46+
assert-window-property: ({"location": |DOC_PATH| + "prefix.html#dont-change"})
47+
48+
// Rename fragment on an existing page to another page.
49+
go-to: |DOC_PATH| + "index.html" // Reset page since redirects are processed on load.
50+
go-to: |DOC_PATH| + "prefix.html#orig-new-page"
51+
assert-window-property: ({"location": |DOC_PATH| + "suffix.html#new"})

tests/testsuite/redirects.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,34 @@ fn redirects_are_emitted_correctly() {
1616
file!["redirects/redirects_are_emitted_correctly/expected/nested/page.html"],
1717
);
1818
}
19+
20+
// Invalid redirect with only fragments.
21+
#[test]
22+
fn redirect_removed_with_fragments_only() {
23+
BookTest::from_dir("redirects/redirect_removed_with_fragments_only").run("build", |cmd| {
24+
cmd.expect_failure().expect_stderr(str![[r#"
25+
[TIMESTAMP] [INFO] (mdbook::book): Book building has started
26+
[TIMESTAMP] [INFO] (mdbook::book): Running the html backend
27+
[TIMESTAMP] [ERROR] (mdbook::utils): Error: Rendering failed
28+
[TIMESTAMP] [ERROR] (mdbook::utils): [TAB]Caused By: Unable to emit redirects
29+
[TIMESTAMP] [ERROR] (mdbook::utils): [TAB]Caused By: redirect entry for `old-file.html` only has source paths with `#` fragments
30+
There must be an entry without the `#` fragment to determine the default destination.
31+
32+
"#]]);
33+
});
34+
}
35+
36+
// Invalid redirect for an existing page.
37+
#[test]
38+
fn redirect_existing_page() {
39+
BookTest::from_dir("redirects/redirect_existing_page").run("build", |cmd| {
40+
cmd.expect_failure().expect_stderr(str![[r#"
41+
[TIMESTAMP] [INFO] (mdbook::book): Book building has started
42+
[TIMESTAMP] [INFO] (mdbook::book): Running the html backend
43+
[TIMESTAMP] [ERROR] (mdbook::utils): Error: Rendering failed
44+
[TIMESTAMP] [ERROR] (mdbook::utils): [TAB]Caused By: redirect found for existing chapter at `/chapter_1.html`
45+
Either delete the redirect or remove the chapter.
46+
47+
"#]]);
48+
});
49+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
[book]
2+
title = "redirect_existing_page"
3+
4+
[output.html.redirect]
5+
"/chapter_1.html" = "other-page.html"
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Summary
2+
3+
- [Chapter 1](./chapter_1.md)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Chapter 1

0 commit comments

Comments
 (0)