Merge pull request #2747 from ehuss/fragment-redirect
Add support for fragment redirects
This commit is contained in:
commit
a918910a52
18 changed files with 306 additions and 15 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
51
tests/gui/redirect.goml
Normal 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"})
|
||||
|
|
@ -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.
|
||||
|
||||
"#]]);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
[book]
|
||||
title = "redirect_existing_page"
|
||||
|
||||
[output.html.redirect]
|
||||
"/chapter_1.html" = "other-page.html"
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# Summary
|
||||
|
||||
- [Chapter 1](./chapter_1.md)
|
||||
|
|
@ -0,0 +1 @@
|
|||
# Chapter 1
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
[book]
|
||||
title = "redirect_removed_with_fragments_only"
|
||||
|
||||
[output.html.redirect]
|
||||
"/old-file.html#foo" = "chapter_1.html"
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# Summary
|
||||
|
||||
- [Chapter 1](./chapter_1.md)
|
||||
|
|
@ -0,0 +1 @@
|
|||
# Chapter 1
|
||||
|
|
@ -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/"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
# Redirects
|
||||
|
||||
- [Chapter 1](chapter_1.md)
|
||||
- [Chapter 2](chapter_2.md)
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
# Chapter 2
|
||||
Loading…
Add table
Reference in a new issue