Skip to content

Commit a918910

Browse files
authored
Merge pull request #2747 from ehuss/fragment-redirect
Add support for fragment redirects
2 parents 1eeb0d2 + 15c93b5 commit a918910

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
@@ -347,6 +347,21 @@
347347
{{/if}}
348348
{{/if}}
349349

350+
{{#if fragment_map}}
351+
<script>
352+
document.addEventListener('DOMContentLoaded', function() {
353+
const fragmentMap =
354+
{{{fragment_map}}}
355+
;
356+
const target = fragmentMap[window.location.hash];
357+
if (target) {
358+
let url = new URL(target, window.location.href);
359+
window.location.replace(url.href);
360+
}
361+
});
362+
</script>
363+
{{/if}}
364+
350365
</div>
351366
</body>
352367
</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)