Skip to content
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
3 changes: 3 additions & 0 deletions crates/mdbook-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,8 @@ pub struct HtmlConfig {
pub input_404: Option<String>,
/// Absolute url to site, used to emit correct paths for the 404 page, which might be accessed in a deeply nested directory
pub site_url: Option<String>,
/// Canonical site url, used to emit <link rel="canonical"> tags in the HTML.
pub canonical_site_url: Option<String>,
/// The DNS subdomain or apex domain at which your book will be hosted. This
/// string will be written to a file named CNAME in the root of your site,
/// as required by GitHub Pages (see [*Managing a custom domain for your
Expand Down Expand Up @@ -529,6 +531,7 @@ impl Default for HtmlConfig {
git_repository_icon: None,
input_404: None,
site_url: None,
canonical_site_url: None,
cname: None,
edit_url_template: None,
live_reload_endpoint: None,
Expand Down
3 changes: 3 additions & 0 deletions crates/mdbook-html/front-end/templates/index.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
{{#if base_url}}
<base href="{{ base_url }}">
{{/if}}
{{#if canonical_url}}
<link rel="canonical" href="{{ canonical_url }}">

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about adding also the Open Graph URL ?

<meta property="og:url" content="{{ canonical_url }}" />

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my use case where we have versioned copies of the docs and latest, we wouldn't want social media to rewrite the links to point to latest, making it impossible to share this kind of "permalink", so I don't think this is automatically desirable just based on the SEO oriented canonical URL.

IIUC you could add it in the custom html below if it makes sense for your use case.

I guess if you have more equivalent locations in terms of content, you may want social media to rewrite those?
Maybe if your content is published by others too in places where you can't get them to redirect or something, but you can control (a bit) the files that get published. (Sounds almost adversarial, but then it would be pointless to try. Maybe this can happen with some systems/ecosystems that cause duplicates of stuff to be published everywhere? idk - I'll stop speculating)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess the more interesting question is would you want to rewrite social media without also hinting the search engines?
If that's a use case, maybe we should only define canonical_url but leave the socials-vs-search decision up to the custom HTML template, to keep things simple for the mdBook implementation?
If we don't have such a use case, I'd like to keep this built-in behavior right here, because otherwise you have a weird setting without any effect unless you customize your theme. That seems like an step too much for me in terms of UX.
So right back at ya, I guess :)
What do you think of removing <link rel="canonical" .../>?

Copy link

@pinage404 pinage404 Nov 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, i can add the <meta property="og:url" content="{{ canonical_url }}" /> in my own head.hbs

I think i would expect to have the <link rel="canonical" .../> by default when i provide a canonical_url, the UX is better, you are right too !

So, let's change nothing 😅

(I don't know how to close this thread)

{{/if}}


<!-- Custom HTML head -->
Expand Down
18 changes: 18 additions & 0 deletions crates/mdbook-html/src/html_handlebars/hbs_renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ impl HtmlHandlebars {
.to_str()
.with_context(|| "Could not convert path to str")?;
let filepath = Path::new(&ctx_path).with_extension("html");
let filepath_str = filepath
.to_str()
.with_context(|| format!("Could not convert path to str: {}", filepath.display()))?;
let canonical_url = ctx.html_config.canonical_site_url.map(|canon_url| {
let canon_url = canon_url.as_str().trim_end_matches('/');
format!("{}/{}", canon_url, self.clean_path(filepath_str))
});

let book_title = ctx
.data
Expand All @@ -80,6 +87,8 @@ impl HtmlHandlebars {
};

ctx.data.insert("path".to_owned(), json!(path));
ctx.data
.insert("canonical_url".to_owned(), json!(canonical_url));
ctx.data.insert("content".to_owned(), json!(content));
ctx.data.insert("chapter_title".to_owned(), json!(ch.name));
ctx.data.insert("title".to_owned(), json!(title));
Expand Down Expand Up @@ -298,6 +307,15 @@ impl HtmlHandlebars {

Ok(())
}

/// Strips `index.html` from the end of a path, if it exists.
fn clean_path(&self, path: &str) -> String {
if path == "index.html" || path.ends_with("/index.html") {
path[..path.len() - 10].to_string()
} else {
path.to_string()
}
}
}

impl Renderer for HtmlHandlebars {
Expand Down
6 changes: 6 additions & 0 deletions guide/src/format/configuration/renderers.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,12 @@ The following configuration options are available:
navigation links and script/css imports in the 404 file work correctly, even when accessing
urls in subdirectories. Defaults to `/`. If `site-url` is set,
make sure to use document relative links for your assets, meaning they should not start with `/`.
- **canonical-site-url:** Set the canonical URL for the book, which is used by
search engines to determine the primary URL for the content. Use this when
your site is deployed at multiple URLs. For example, when you have site
deployments for a range of versions, you can point all of them to the URL for
the latest version. Without this, your content may be penalized for
duplication, and visitors may be directed to an outdated version of the book.
- **cname:** The DNS subdomain or apex domain at which your book will be hosted.
This string will be written to a file named CNAME in the root of your site, as
required by GitHub Pages (see [*Managing a custom domain for your GitHub Pages
Expand Down
22 changes: 22 additions & 0 deletions tests/testsuite/rendering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -304,3 +304,25 @@ HTML tags must be closed before exiting a markdown element.
str![[r##"<h3 id="option"><a class="header" href="#option">Option<t></t></a></h3>"##]],
);
}

// Checks that a canonical URL is generated correctly.
#[test]
fn canonical_url() {
BookTest::from_dir("rendering/canonical_url")
.check_file_contains(
"book/index.html",
"<link rel=\"canonical\" href=\"https://example.com/test/\">",
)
.check_file_contains(
"book/canonical_url.html",
"<link rel=\"canonical\" href=\"https://example.com/test/canonical_url.html\">",
)
.check_file_contains(
"book/nested/page.html",
"<link rel=\"canonical\" href=\"https://example.com/test/nested/page.html\">",
)
.check_file_contains(
"book/nested/index.html",
"<link rel=\"canonical\" href=\"https://example.com/test/nested/\">",
);
}
6 changes: 6 additions & 0 deletions tests/testsuite/rendering/canonical_url/book.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[book]
title = "canonical_url test"

[output.html]
# trailing slash is not necessary or recommended, but tested here
canonical-site-url = "https://example.com/test/"
4 changes: 4 additions & 0 deletions tests/testsuite/rendering/canonical_url/src/SUMMARY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
- [Intro](README.md)
- [Canonical URL](canonical_url.md)
- [Nested Page](nested/page.md)
- [Nested Index](nested/index.md)