Rollup merge of #117662 - GuillaumeGomez:links-in-headings, r=notriddle

[rustdoc] Allows links in headings

Reopening of https://github.com/rust-lang/rust/pull/94360.

# Explanations

Rustdoc currently doesn't follow the markdown spec on headings: we don't allow links in them. So instead of having headings linking to themselves, this PR generates an anchor on the left side like this:

![image](https://github.com/rust-lang/rust/assets/3050060/a118a7e9-5ef8-4d07-914f-46defc3245c3)

<details>
<summary>previous version</summary>

![image](https://github.com/rust-lang/rust/assets/3050060/c34fa844-9cd4-47dc-bb51-b37f5f66afee)

</details>

Having the anchor always displayed allows for mobile devices users to be able to have a link to the anchor. The different color used for the anchor itself is the same as links so people notice when looking at it that they can click on it.

You can test it [here](https://rustdoc.crud.net/imperio/links-in-headings/std/index.html).

cc `@camelid`
r? `@notriddle`
This commit is contained in:
Matthias Krüger 2024-01-19 19:26:59 +01:00 committed by GitHub
commit cad609d9e3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 211 additions and 99 deletions

View File

@ -530,7 +530,6 @@ impl<'a, 'b, 'ids, I: Iterator<Item = SpannedEvent<'a>>> Iterator
for event in &mut self.inner {
match &event.0 {
Event::End(Tag::Heading(..)) => break,
Event::Start(Tag::Link(_, _, _)) | Event::End(Tag::Link(..)) => {}
Event::Text(text) | Event::Code(text) => {
id.extend(text.chars().filter_map(slugify));
self.buf.push_back(event);
@ -549,12 +548,10 @@ impl<'a, 'b, 'ids, I: Iterator<Item = SpannedEvent<'a>>> Iterator
let level =
std::cmp::min(level as u32 + (self.heading_offset as u32), MAX_HEADER_LEVEL);
self.buf.push_back((Event::Html(format!("</a></h{level}>").into()), 0..0));
self.buf.push_back((Event::Html(format!("</h{level}>").into()), 0..0));
let start_tags = format!(
"<h{level} id=\"{id}\">\
<a href=\"#{id}\">",
);
let start_tags =
format!("<h{level} id=\"{id}\"><a class=\"doc-anchor\" href=\"#{id}\">§</a>");
return Some((Event::Html(start_tags.into()), 0..0));
}
event

View File

@ -311,26 +311,38 @@ fn test_header() {
assert_eq!(output, expect, "original: {}", input);
}
t("# Foo bar", "<h2 id=\"foo-bar\"><a href=\"#foo-bar\">Foo bar</a></h2>");
t(
"# Foo bar",
"<h2 id=\"foo-bar\"><a class=\"doc-anchor\" href=\"#foo-bar\">§</a>Foo bar</h2>",
);
t(
"## Foo-bar_baz qux",
"<h3 id=\"foo-bar_baz-qux\">\
<a href=\"#foo-bar_baz-qux\">Foo-bar_baz qux</a></h3>",
<a class=\"doc-anchor\" href=\"#foo-bar_baz-qux\">§</a>\
Foo-bar_baz qux\
</h3>",
);
t(
"### **Foo** *bar* baz!?!& -_qux_-%",
"<h4 id=\"foo-bar-baz--qux-\">\
<a href=\"#foo-bar-baz--qux-\"><strong>Foo</strong> \
<em>bar</em> baz!?!&amp; -<em>qux</em>-%</a>\
<a class=\"doc-anchor\" href=\"#foo-bar-baz--qux-\">§</a>\
<strong>Foo</strong> <em>bar</em> baz!?!&amp; -<em>qux</em>-%\
</h4>",
);
t(
"#### **Foo?** & \\*bar?!* _`baz`_ ❤ #qux",
"<h5 id=\"foo--bar--baz--qux\">\
<a href=\"#foo--bar--baz--qux\"><strong>Foo?</strong> &amp; *bar?!* \
<em><code>baz</code></em> #qux</a>\
<a class=\"doc-anchor\" href=\"#foo--bar--baz--qux\">§</a>\
<strong>Foo?</strong> &amp; *bar?!* <em><code>baz</code></em> #qux\
</h5>",
);
t(
"# Foo [bar](https://hello.yo)",
"<h2 id=\"foo-bar\">\
<a class=\"doc-anchor\" href=\"#foo-bar\">§</a>\
Foo <a href=\"https://hello.yo\">bar</a>\
</h2>",
);
}
#[test]
@ -351,12 +363,36 @@ fn test_header_ids_multiple_blocks() {
assert_eq!(output, expect, "original: {}", input);
}
t(&mut map, "# Example", "<h2 id=\"example\"><a href=\"#example\">Example</a></h2>");
t(&mut map, "# Panics", "<h2 id=\"panics\"><a href=\"#panics\">Panics</a></h2>");
t(&mut map, "# Example", "<h2 id=\"example-1\"><a href=\"#example-1\">Example</a></h2>");
t(&mut map, "# Search", "<h2 id=\"search-1\"><a href=\"#search-1\">Search</a></h2>");
t(&mut map, "# Example", "<h2 id=\"example-2\"><a href=\"#example-2\">Example</a></h2>");
t(&mut map, "# Panics", "<h2 id=\"panics-1\"><a href=\"#panics-1\">Panics</a></h2>");
t(
&mut map,
"# Example",
"<h2 id=\"example\"><a class=\"doc-anchor\" href=\"#example\">§</a>Example</h2>",
);
t(
&mut map,
"# Panics",
"<h2 id=\"panics\"><a class=\"doc-anchor\" href=\"#panics\">§</a>Panics</h2>",
);
t(
&mut map,
"# Example",
"<h2 id=\"example-1\"><a class=\"doc-anchor\" href=\"#example-1\">§</a>Example</h2>",
);
t(
&mut map,
"# Search",
"<h2 id=\"search-1\"><a class=\"doc-anchor\" href=\"#search-1\">§</a>Search</h2>",
);
t(
&mut map,
"# Example",
"<h2 id=\"example-2\"><a class=\"doc-anchor\" href=\"#example-2\">§</a>Example</h2>",
);
t(
&mut map,
"# Panics",
"<h2 id=\"panics-1\"><a class=\"doc-anchor\" href=\"#panics-1\">§</a>Panics</h2>",
);
}
#[test]

View File

@ -1207,17 +1207,31 @@ impl<'a> AssocItemLink<'a> {
}
}
fn write_impl_section_heading(mut w: impl fmt::Write, title: &str, id: &str) {
pub fn write_section_heading(
w: &mut impl fmt::Write,
title: &str,
id: &str,
extra_class: Option<&str>,
extra: impl fmt::Display,
) {
let (extra_class, whitespace) = match extra_class {
Some(extra) => (extra, " "),
None => ("", ""),
};
write!(
w,
"<h2 id=\"{id}\" class=\"section-header\">\
"<h2 id=\"{id}\" class=\"{extra_class}{whitespace}section-header\">\
{title}\
<a href=\"#{id}\" class=\"anchor\">§</a>\
</h2>"
</h2>{extra}",
)
.unwrap();
}
fn write_impl_section_heading(w: &mut impl fmt::Write, title: &str, id: &str) {
write_section_heading(w, title, id, None, "")
}
pub(crate) fn render_all_impls(
mut w: impl Write,
cx: &mut Context<'_>,

View File

@ -19,8 +19,8 @@ use super::{
item_ty_to_section, notable_traits_button, notable_traits_json, render_all_impls,
render_assoc_item, render_assoc_items, render_attributes_in_code, render_attributes_in_pre,
render_impl, render_rightside, render_stability_since_raw,
render_stability_since_raw_with_extra, AssocItemLink, AssocItemRender, Context,
ImplRenderingParameters, RenderMode,
render_stability_since_raw_with_extra, write_section_heading, AssocItemLink, AssocItemRender,
Context, ImplRenderingParameters, RenderMode,
};
use crate::clean;
use crate::config::ModuleSorting;
@ -425,13 +425,12 @@ fn item_module(w: &mut Buffer, cx: &mut Context<'_>, item: &clean::Item, items:
w.write_str(ITEM_TABLE_CLOSE);
}
last_section = Some(my_section);
write!(
write_section_heading(
w,
"<h2 id=\"{id}\" class=\"section-header\">\
<a href=\"#{id}\">{name}</a>\
</h2>{ITEM_TABLE_OPEN}",
id = cx.derive_id(my_section.id()),
name = my_section.name(),
my_section.name(),
&cx.derive_id(my_section.id()),
None,
ITEM_TABLE_OPEN,
);
}
@ -814,16 +813,6 @@ fn item_trait(w: &mut Buffer, cx: &mut Context<'_>, it: &clean::Item, t: &clean:
// Trait documentation
write!(w, "{}", document(cx, it, None, HeadingOffset::H2));
fn write_small_section_header(w: &mut Buffer, id: &str, title: &str, extra_content: &str) {
write!(
w,
"<h2 id=\"{0}\" class=\"section-header\">\
{1}<a href=\"#{0}\" class=\"anchor\">§</a>\
</h2>{2}",
id, title, extra_content
)
}
fn trait_item(w: &mut Buffer, cx: &mut Context<'_>, m: &clean::Item, t: &clean::Item) {
let name = m.name.unwrap();
info!("Documenting {name} on {ty_name:?}", ty_name = t.name);
@ -857,10 +846,11 @@ fn item_trait(w: &mut Buffer, cx: &mut Context<'_>, it: &clean::Item, t: &clean:
}
if !required_types.is_empty() {
write_small_section_header(
write_section_heading(
w,
"required-associated-types",
"Required Associated Types",
"required-associated-types",
None,
"<div class=\"methods\">",
);
for t in required_types {
@ -869,10 +859,11 @@ fn item_trait(w: &mut Buffer, cx: &mut Context<'_>, it: &clean::Item, t: &clean:
w.write_str("</div>");
}
if !provided_types.is_empty() {
write_small_section_header(
write_section_heading(
w,
"provided-associated-types",
"Provided Associated Types",
"provided-associated-types",
None,
"<div class=\"methods\">",
);
for t in provided_types {
@ -882,10 +873,11 @@ fn item_trait(w: &mut Buffer, cx: &mut Context<'_>, it: &clean::Item, t: &clean:
}
if !required_consts.is_empty() {
write_small_section_header(
write_section_heading(
w,
"required-associated-consts",
"Required Associated Constants",
"required-associated-consts",
None,
"<div class=\"methods\">",
);
for t in required_consts {
@ -894,10 +886,11 @@ fn item_trait(w: &mut Buffer, cx: &mut Context<'_>, it: &clean::Item, t: &clean:
w.write_str("</div>");
}
if !provided_consts.is_empty() {
write_small_section_header(
write_section_heading(
w,
"provided-associated-consts",
"Provided Associated Constants",
"provided-associated-consts",
None,
"<div class=\"methods\">",
);
for t in provided_consts {
@ -908,10 +901,11 @@ fn item_trait(w: &mut Buffer, cx: &mut Context<'_>, it: &clean::Item, t: &clean:
// Output the documentation for each function individually
if !required_methods.is_empty() || must_implement_one_of_functions.is_some() {
write_small_section_header(
write_section_heading(
w,
"required-methods",
"Required Methods",
"required-methods",
None,
"<div class=\"methods\">",
);
@ -929,10 +923,11 @@ fn item_trait(w: &mut Buffer, cx: &mut Context<'_>, it: &clean::Item, t: &clean:
w.write_str("</div>");
}
if !provided_methods.is_empty() {
write_small_section_header(
write_section_heading(
w,
"provided-methods",
"Provided Methods",
"provided-methods",
None,
"<div class=\"methods\">",
);
for m in provided_methods {
@ -949,10 +944,11 @@ fn item_trait(w: &mut Buffer, cx: &mut Context<'_>, it: &clean::Item, t: &clean:
let mut extern_crates = FxHashSet::default();
if !t.is_object_safe(cx.tcx()) {
write_small_section_header(
write_section_heading(
w,
"object-safety",
"Object Safety",
"object-safety",
None,
&format!(
"<div class=\"object-safety-info\">This trait is <b>not</b> \
<a href=\"{base}/reference/items/traits.html#object-safety\">\
@ -996,7 +992,7 @@ fn item_trait(w: &mut Buffer, cx: &mut Context<'_>, it: &clean::Item, t: &clean:
foreign.sort_by_cached_key(|i| ImplString::new(i, cx));
if !foreign.is_empty() {
write_small_section_header(w, "foreign-impls", "Implementations on Foreign Types", "");
write_section_heading(w, "Implementations on Foreign Types", "foreign-impls", None, "");
for implementor in foreign {
let provided_methods = implementor.inner_impl().provided_trait_methods(tcx);
@ -1021,10 +1017,11 @@ fn item_trait(w: &mut Buffer, cx: &mut Context<'_>, it: &clean::Item, t: &clean:
}
}
write_small_section_header(
write_section_heading(
w,
"implementors",
"Implementors",
"implementors",
None,
"<div id=\"implementors-list\">",
);
for implementor in concrete {
@ -1033,10 +1030,11 @@ fn item_trait(w: &mut Buffer, cx: &mut Context<'_>, it: &clean::Item, t: &clean:
w.write_str("</div>");
if t.is_auto(tcx) {
write_small_section_header(
write_section_heading(
w,
"synthetic-implementors",
"Auto implementors",
"synthetic-implementors",
None,
"<div id=\"synthetic-implementors-list\">",
);
for implementor in synthetic {
@ -1054,18 +1052,20 @@ fn item_trait(w: &mut Buffer, cx: &mut Context<'_>, it: &clean::Item, t: &clean:
} else {
// even without any implementations to write in, we still want the heading and list, so the
// implementors javascript file pulled in below has somewhere to write the impls into
write_small_section_header(
write_section_heading(
w,
"implementors",
"Implementors",
"implementors",
None,
"<div id=\"implementors-list\"></div>",
);
if t.is_auto(tcx) {
write_small_section_header(
write_section_heading(
w,
"synthetic-implementors",
"Auto implementors",
"synthetic-implementors",
None,
"<div id=\"synthetic-implementors-list\"></div>",
);
}
@ -1248,11 +1248,7 @@ fn item_type_alias(w: &mut Buffer, cx: &mut Context<'_>, it: &clean::Item, t: &c
write!(w, "{}", document(cx, it, None, HeadingOffset::H2));
if let Some(inner_type) = &t.inner_type {
write!(
w,
"<h2 id=\"aliased-type\" class=\"section-header\">\
Aliased Type<a href=\"#aliased-type\" class=\"anchor\">§</a></h2>"
);
write_section_heading(w, "Aliased Type", "aliased-type", None, "");
match inner_type {
clean::TypeAliasInnerType::Enum { variants, is_non_exhaustive } => {
@ -1673,16 +1669,14 @@ fn item_variants(
enum_def_id: DefId,
) {
let tcx = cx.tcx();
write!(
write_section_heading(
w,
"<h2 id=\"variants\" class=\"variants section-header\">\
Variants{}<a href=\"#variants\" class=\"anchor\">§</a>\
</h2>\
{}\
<div class=\"variants\">",
document_non_exhaustive_header(it),
document_non_exhaustive(it)
&format!("Variants{}", document_non_exhaustive_header(it)),
"variants",
Some("variants"),
format!("{}<div class=\"variants\">", document_non_exhaustive(it)),
);
let should_show_enum_discriminant = should_show_enum_discriminant(cx, enum_def_id, variants);
for (index, variant) in variants.iter_enumerated() {
if variant.is_stripped() {
@ -1930,16 +1924,12 @@ fn item_fields(
.peekable();
if let None | Some(CtorKind::Fn) = ctor_kind {
if fields.peek().is_some() {
write!(
w,
"<h2 id=\"fields\" class=\"fields section-header\">\
{}{}<a href=\"#fields\" class=\"anchor\">§</a>\
</h2>\
{}",
let title = format!(
"{}{}",
if ctor_kind.is_none() { "Fields" } else { "Tuple Fields" },
document_non_exhaustive_header(it),
document_non_exhaustive(it)
);
write_section_heading(w, &title, "fields", Some("fields"), document_non_exhaustive(it));
for (index, (field, ty)) in fields.enumerate() {
let field_name =
field.name.map_or_else(|| index.to_string(), |sym| sym.as_str().to_string());

View File

@ -849,11 +849,30 @@ nav.sub {
h2.section-header > .anchor {
padding-right: 6px;
}
a.doc-anchor {
color: var(--main-color);
display: none;
position: absolute;
left: -17px;
/* We add this padding so that when the cursor moves from the heading's text to the anchor,
the anchor doesn't disappear. */
padding-right: 5px;
/* And this padding is used to make the anchor larger and easier to click on. */
padding-left: 3px;
}
*:hover > .doc-anchor {
display: block;
}
/* If the first element of the top doc block is a heading, we don't want to ever display its anchor
because of the `[-]` element which would overlap with it. */
.top-doc > .docblock > *:first-child > .doc-anchor {
display: none !important;
}
.main-heading a:hover,
.example-wrap .rust a:hover,
.all-items a:hover,
.docblock a:not(.test-arrow):not(.scrape-help):not(.tooltip):hover,
.docblock a:not(.test-arrow):not(.scrape-help):not(.tooltip):hover:not(.doc-anchor),
.docblock-short a:not(.test-arrow):not(.scrape-help):not(.tooltip):hover,
.item-info a {
text-decoration: underline;

View File

@ -6,7 +6,7 @@ reload:
// We first check that the headers in the `.top-doc` doc block still have their
// bottom border.
assert-text: (".top-doc .docblock > h3", "Hello")
assert-text: (".top-doc .docblock > h3", "§Hello")
assert-css: (
".top-doc .docblock > h3",
{"border-bottom": "1px solid #d2d2d2"},

View File

@ -1,4 +1,4 @@
// This test check for headers text and background colors for the different themes.
// This test check for headings text and background colors for the different themes.
define-function: (
"check-colors",
@ -45,7 +45,7 @@ call-function: (
"color": "#c5c5c5",
"code_header_color": "#e6e1cf",
"focus_background_color": "rgba(255, 236, 164, 0.06)",
"headings_color": "#39afd7",
"headings_color": "#c5c5c5",
},
)
call-function: (
@ -55,7 +55,7 @@ call-function: (
"color": "#ddd",
"code_header_color": "#ddd",
"focus_background_color": "#494a3d",
"headings_color": "#d2991d",
"headings_color": "#ddd",
},
)
call-function: (
@ -65,6 +65,6 @@ call-function: (
"color": "black",
"code_header_color": "black",
"focus_background_color": "#fdffd3",
"headings_color": "#3873ad",
"headings_color": "black",
},
)

View File

@ -0,0 +1,32 @@
// Test to ensure that the headings anchor behave as expected.
go-to: "file://" + |DOC_PATH| + "/test_docs/struct.HeavilyDocumentedStruct.html"
show-text: true
define-function: (
"check-heading-anchor",
(heading_id),
block {
// The anchor should not be displayed by default.
assert-css: ("#" + |heading_id| + " .doc-anchor", { "display": "none" })
// We ensure that hovering the heading makes the anchor visible.
move-cursor-to: "#" + |heading_id|
assert-css: ("#" + |heading_id| + ":hover .doc-anchor", { "display": "block" })
// We then ensure that moving from the heading to the anchor doesn't make the anchor
// disappear.
move-cursor-to: "#" + |heading_id| + " .doc-anchor"
assert-css: ("#" + |heading_id| + " .doc-anchor:hover", {
"display": "block",
// We also ensure that there is no underline decoration.
"text-decoration-line": "none",
})
}
)
move-cursor-to: "#top-doc-prose-title"
// If the top documentation block first element is a heading, we should never display its anchor
// to prevent it from overlapping with the `[-]` element.
assert-css: ("#top-doc-prose-title:hover .doc-anchor", { "display": "none" })
call-function: ("check-heading-anchor", ("top-doc-prose-sub-heading"))
call-function: ("check-heading-anchor", ("top-doc-prose-sub-sub-heading"))
call-function: ("check-heading-anchor", ("you-know-the-drill"))

View File

@ -5,18 +5,23 @@
pub struct Foo;
impl Foo {
// @has - '//*[@id="examples"]//a' 'Examples'
// @has - '//*[@id="panics"]//a' 'Panics'
// @has - '//*[@id="examples"]' 'Examples'
// @has - '//*[@id="examples"]/a[@href="#examples"]' '§'
// @has - '//*[@id="panics"]' 'Panics'
// @has - '//*[@id="panics"]/a[@href="#panics"]' '§'
/// # Examples
/// # Panics
pub fn bar() {}
// @has - '//*[@id="examples-1"]//a' 'Examples'
// @has - '//*[@id="examples-1"]' 'Examples'
// @has - '//*[@id="examples-1"]/a[@href="#examples-1"]' '§'
/// # Examples
pub fn bar_1() {}
// @has - '//*[@id="examples-2"]//a' 'Examples'
// @has - '//*[@id="panics-1"]//a' 'Panics'
// @has - '//*[@id="examples-2"]' 'Examples'
// @has - '//*[@id="examples-2"]/a[@href="#examples-2"]' '§'
// @has - '//*[@id="panics-1"]' 'Panics'
// @has - '//*[@id="panics-1"]/a[@href="#panics-1"]' '§'
/// # Examples
/// # Panics
pub fn bar_2() {}

View File

@ -0,0 +1,14 @@
#![crate_name = "foo"]
//! # Heading with [a link](https://a.com) inside
//!
//! And even with
//!
//! ## [multiple](https://b.com) [links](https://c.com)
//!
//! !
// @has 'foo/index.html'
// @has - '//h2/a[@href="https://a.com"]' 'a link'
// @has - '//h3/a[@href="https://b.com"]' 'multiple'
// @has - '//h3/a[@href="https://c.com"]' 'links'

View File

@ -1,9 +1,12 @@
// It actually checks that the link is kept in the headings as expected now.
#![crate_name = "foo"]
// @has foo/fn.foo.html
// @!has - '//a[@href="http://a.a"]' ''
// @has - '//a[@href="#implementing-stuff-somewhere"]' 'Implementing stuff somewhere'
// @has - '//a[@href="#another-one-urg"]' 'Another one urg'
// @has - '//a[@href="http://a.a"]' 'stuff'
// @has - '//*[@id="implementing-stuff-somewhere"]' 'Implementing stuff somewhere'
// @has - '//a[@href="http://b.b"]' 'one'
// @has - '//*[@id="another-one-urg"]' 'Another one urg'
/// fooo
///
@ -13,5 +16,5 @@
///
/// # Another [one][two] urg
///
/// [two]: http://a.a
/// [two]: http://b.b
pub fn foo() {}

View File

@ -2,8 +2,9 @@
// @has foo/index.html '//*[@class="desc docblock-short"]' 'fooo'
// @!has foo/index.html '//*[@class="desc docblock-short"]/h1' 'fooo'
// @has foo/fn.foo.html '//h2[@id="fooo"]/a[@href="#fooo"]' 'fooo'
// @has foo/fn.foo.html '//h2[@id="fooo"]' 'fooo'
// @has foo/fn.foo.html '//h2[@id="fooo"]/a[@href="#fooo"]' '§'
/// # fooo
///
/// foo
@ -11,8 +12,9 @@ pub fn foo() {}
// @has foo/index.html '//*[@class="desc docblock-short"]' 'mooood'
// @!has foo/index.html '//*[@class="desc docblock-short"]/h2' 'mooood'
// @has foo/foo/index.html '//h3[@id="mooood"]/a[@href="#mooood"]' 'mooood'
// @has foo/foo/index.html '//h3[@id="mooood"]' 'mooood'
// @has foo/foo/index.html '//h3[@id="mooood"]/a[@href="#mooood"]' '§'
/// ## mooood
///
/// foo mod