diff --git a/crates/mdbook-core/src/utils/mod.rs b/crates/mdbook-core/src/utils/mod.rs index c36b16e960..1fb4abe9ec 100644 --- a/crates/mdbook-core/src/utils/mod.rs +++ b/crates/mdbook-core/src/utils/mod.rs @@ -43,7 +43,7 @@ pub fn normalize_id(content: &str) -> String { /// Generate an ID for use with anchors which is derived from a "normalised" /// string. -fn id_from_content(content: &str) -> String { +pub fn id_from_content(content: &str) -> String { let mut content = content.to_string(); // Skip any tags or html-encoded stuff diff --git a/crates/mdbook-html/src/html_handlebars/hbs_renderer.rs b/crates/mdbook-html/src/html_handlebars/hbs_renderer.rs index e703752a7e..a2d8853208 100644 --- a/crates/mdbook-html/src/html_handlebars/hbs_renderer.rs +++ b/crates/mdbook-html/src/html_handlebars/hbs_renderer.rs @@ -67,7 +67,25 @@ impl HtmlHandlebars { print_content .push_str(r#"
"#); } - print_content.push_str(&fixed_content); + let print_page_id = { + let mut base = path.display().to_string(); + if base.ends_with(".md") { + base.truncate(base.len() - 3); + } + &base + .replace("/", "-") + .replace("\\", "-") + .to_ascii_lowercase() + }; + + // We have to build header links in advance so that we can know the ranges + // for the headers in one page. + // Insert a dummy div to make sure that we can locate the specific page. + print_content.push_str(&(format!(r#"
"#))); + print_content.push_str(&build_header_links( + &build_print_element_id(&fixed_content, &print_page_id), + Some(print_page_id), + )); // Update the context with data for this file let ctx_path = path @@ -239,7 +257,23 @@ impl HtmlHandlebars { code_config: &Code, edition: Option, ) -> String { - let rendered = build_header_links(&rendered); + let rendered = build_header_links(&rendered, None); + let rendered = self.post_process_common(rendered, &playground_config, code_config, edition); + + rendered + } + + /// Applies some post-processing to the HTML to apply some adjustments. + /// + /// This common function is used for both normal chapters (via + /// `post_process`) and the combined print page. + fn post_process_common( + &self, + rendered: String, + playground_config: &Playground, + code_config: &Code, + edition: Option, + ) -> String { let rendered = fix_code_blocks(&rendered); let rendered = add_playground_pre(&rendered, playground_config, edition); let rendered = hide_lines(&rendered, code_config); @@ -497,7 +531,7 @@ impl Renderer for HtmlHandlebars { debug!("Render template"); let rendered = handlebars.render("index", &data)?; - let rendered = self.post_process( + let rendered = self.post_process_common( rendered, &html_config.playground, &html_config.code, @@ -695,9 +729,35 @@ fn make_data( Ok(data) } +/// Go through the rendered print page HTML, +/// add path id prefix to all the elements id as well as footnote links. +fn build_print_element_id(html: &str, print_page_id: &str) -> String { + static ALL_ID: LazyLock = + LazyLock::new(|| Regex::new(r#"(<[^>]*?id=")([^"]+?)""#).unwrap()); + static FOOTNOTE_ID: LazyLock = LazyLock::new(|| { + Regex::new( + r##"(]*?class="footnote-reference"[^>]*?>[^<]*?]*?href="#)([^"]+?)""##, + ) + .unwrap() + }); + + let temp_html = ALL_ID.replace_all(html, |caps: &Captures<'_>| { + format!("{}{}-{}\"", &caps[1], print_page_id, &caps[2]) + }); + + FOOTNOTE_ID + .replace_all(&temp_html, |caps: &Captures<'_>| { + format!("{}{}-{}\"", &caps[1], print_page_id, &caps[2]) + }) + .into_owned() +} + /// Goes through the rendered HTML, making sure all header tags have /// an anchor respectively so people can link to sections directly. -fn build_header_links(html: &str) -> String { +/// +/// `print_page_id` should be set to the print page ID prefix when adjusting the +/// print page. +fn build_header_links(html: &str, print_page_id: Option<&str>) -> String { static BUILD_HEADER_LINKS: LazyLock = LazyLock::new(|| { Regex::new(r#"(.*?)"#).unwrap() }); @@ -726,6 +786,7 @@ fn build_header_links(html: &str) -> String { caps.get(2).map(|x| x.as_str().to_string()), caps.get(3).map(|x| x.as_str().to_string()), &mut id_counter, + print_page_id, ) }) .into_owned() @@ -733,14 +794,26 @@ fn build_header_links(html: &str) -> String { /// Insert a single link into a header, making sure each link gets its own /// unique ID by appending an auto-incremented number (if necessary). +/// +/// For `print.html`, we will add a path id prefix. fn insert_link_into_header( level: usize, content: &str, id: Option, classes: Option, id_counter: &mut HashMap, + print_page_id: Option<&str>, ) -> String { - let id = id.unwrap_or_else(|| utils::unique_id_from_content(content, id_counter)); + let id = if let Some(print_page_id) = print_page_id { + let content_id = { + #[allow(deprecated)] + utils::id_from_content(content) + }; + let with_prefix = format!("{} {}", print_page_id, content_id); + id.unwrap_or_else(|| utils::unique_id_from_content(&with_prefix, id_counter)) + } else { + id.unwrap_or_else(|| utils::unique_id_from_content(content, id_counter)) + }; let classes = classes .map(|s| format!(" class=\"{s}\"")) .unwrap_or_default(); @@ -1123,7 +1196,7 @@ mod tests { ]; for (src, should_be) in inputs { - let got = build_header_links(src); + let got = build_header_links(src, None); assert_eq!(got, should_be); } } diff --git a/crates/mdbook-html/src/html_handlebars/search.rs b/crates/mdbook-html/src/html_handlebars/search.rs index a424f80132..be61eedde3 100644 --- a/crates/mdbook-html/src/html_handlebars/search.rs +++ b/crates/mdbook-html/src/html_handlebars/search.rs @@ -134,7 +134,8 @@ fn render_item( .with_context(|| "Could not convert HTML path to str")?; let anchor_base = utils::fs::normalize_path(filepath); - let options = HtmlRenderOptions::new(&chapter_path); + let redirect = HashMap::new(); + let options = HtmlRenderOptions::new(&chapter_path, &redirect); let mut p = new_cmark_parser(&chapter.content, &options.markdown_options).peekable(); let mut in_heading = false; diff --git a/crates/mdbook-html/src/lib.rs b/crates/mdbook-html/src/lib.rs index d3025e426d..fd17d13552 100644 --- a/crates/mdbook-html/src/lib.rs +++ b/crates/mdbook-html/src/lib.rs @@ -11,9 +11,9 @@ use std::path::Path; /// Creates an [`HtmlRenderOptions`] from the given config. pub fn html_render_options_from_config<'a>( path: &'a Path, - config: &HtmlConfig, + config: &'a HtmlConfig, ) -> HtmlRenderOptions<'a> { - let mut options = HtmlRenderOptions::new(path); + let mut options = HtmlRenderOptions::new(path, &config.redirect); options.markdown_options.smart_punctuation = config.smart_punctuation; options } diff --git a/crates/mdbook-markdown/src/lib.rs b/crates/mdbook-markdown/src/lib.rs index dda97757bf..d10c68ea94 100644 --- a/crates/mdbook-markdown/src/lib.rs +++ b/crates/mdbook-markdown/src/lib.rs @@ -9,11 +9,11 @@ //! [`pulldown_cmark`] event stream. For example, it adjusts some links, //! modifies the behavior of footnotes, and adds various HTML wrappers. -use pulldown_cmark::{CodeBlockKind, CowStr, Event, Options, Parser, Tag, TagEnd, html}; +use pulldown_cmark::{CodeBlockKind, CowStr, Event, LinkType, Options, Parser, Tag, TagEnd, html}; use regex::Regex; use std::collections::HashMap; use std::fmt::Write; -use std::path::Path; +use std::path::{Component, Path, PathBuf}; use std::sync::LazyLock; #[doc(inline)] @@ -51,19 +51,68 @@ pub struct HtmlRenderOptions<'a> { pub path: &'a Path, /// If true, render for the print page. pub for_print: bool, + /// The path to the page being rendered. + pub redirect: &'a HashMap, } impl<'a> HtmlRenderOptions<'a> { /// Creates a new [`HtmlRenderOptions`]. - pub fn new(path: &'a Path) -> HtmlRenderOptions<'a> { + pub fn new(path: &'a Path, redirect: &'a HashMap) -> HtmlRenderOptions<'a> { HtmlRenderOptions { markdown_options: MarkdownOptions::default(), path, for_print: false, + redirect, } } } +/// Improve the path to try remove and solve .. token, +/// This assumes that `a/b/../c` is `a/c`. +/// +/// This function ensures a given path ending with '/' will also +/// end with '/' after normalization. +/// +fn normalize_path>(path: P) -> String { + let ends_with_slash = path.as_ref().to_str().map_or(false, |s| s.ends_with('/')); + let mut normalized = PathBuf::new(); + for component in path.as_ref().components() { + match &component { + Component::ParentDir => { + if !normalized.pop() { + normalized.push(component); + } + } + Component::CurDir => {} + _ => { + normalized.push(component); + } + } + } + if ends_with_slash { + normalized.push(""); + } + normalized + .to_str() + .unwrap() + .replace("\\", "/") + .trim_start_matches('/') + .to_string() +} + +/// Converts a relative URL path to a reference ID for the print page. +fn normalize_print_page_id(mut path: String) -> String { + path = path + .replace("/", "-") + .replace(".html#", "-") + .replace("#", "-") + .to_ascii_lowercase(); + if path.ends_with(".html") { + path.truncate(path.len() - 5); + } + path +} + /// Creates a new pulldown-cmark parser of the given text. pub fn new_cmark_parser<'text>(text: &'text str, options: &MarkdownOptions) -> Parser<'text> { let mut opts = Options::empty(); @@ -79,6 +128,14 @@ pub fn new_cmark_parser<'text>(text: &'text str, options: &MarkdownOptions) -> P } /// Renders markdown to HTML. +/// +/// `path` is the path to the page being rendered relative to the root of the +/// book. This is used for the `print.html` page so that links on the print +/// page go to the anchors that has a path id prefix. Normal page rendering +/// sets `path` to None. +/// +/// `redirects` is also only for the print page. It's for adjusting links to +/// a redirected location to go to the correct spot on the `print.html` page. pub fn render_markdown(text: &str, options: &HtmlRenderOptions<'_>) -> String { let mut body = String::with_capacity(text.len() * 3 / 2); @@ -222,6 +279,22 @@ fn add_footnote_defs( } }); + let prefix = if options.for_print { + let mut base = options.path.display().to_string(); + if base.ends_with(".md") { + base.truncate(base.len() - 3); + } + base = normalize_print_page_id(normalize_path(base)); + + if base.is_empty() { + String::new() + } else { + format!("{}-", base) + } + } else { + String::new() + }; + defs.sort_by_cached_key(|(name, _)| numbers[name].0); body.push_str( @@ -244,7 +317,7 @@ fn add_footnote_defs( usage.to_string() }; let backlink = - Event::Html(format!(" ↩{nth}").into()); + Event::Html(format!(" ↩{nth}").into()); if matches!(fn_events.last(), Some(Event::End(TagEnd::Paragraph))) { // Put the linkback at the end of the last paragraph instead // of on a line by itself. @@ -304,50 +377,171 @@ fn clean_codeblock_headers(event: Event<'_>) -> Event<'_> { fn adjust_links<'a>(event: Event<'a>, options: &HtmlRenderOptions<'_>) -> Event<'a> { static SCHEME_LINK: LazyLock = LazyLock::new(|| Regex::new(r"^[a-z][a-z0-9+.-]*:").unwrap()); - static MD_LINK: LazyLock = - LazyLock::new(|| Regex::new(r"(?P.*)\.md(?P#.*)?").unwrap()); + static HTML_MD_LINK: LazyLock = + LazyLock::new(|| Regex::new(r"(?P.*)\.(html|md)(?P#.*)?").unwrap()); + + fn add_base(options: &HtmlRenderOptions<'_>) -> String { + let mut fixed_link = String::new(); + if options.for_print { + let base = options + .path + .parent() + .expect("path can't be empty") + .to_str() + .expect("utf-8 paths only"); + if !base.is_empty() { + write!(fixed_link, "{base}/").unwrap(); + } + } + fixed_link.to_string() + } + + fn fix_print_page_link<'a>( + mut normalized_path: String, + redirects: &HashMap, + ) -> CowStr<'a> { + // Fix redirect links + let (path_no_fragment, fragment) = match normalized_path.split_once('#') { + Some((a, b)) => (a, Some(b)), + None => (normalized_path.as_str(), None), + }; + for (original, redirect) in redirects { + if !normalize_path(original.trim_start_matches('/')) + .eq_ignore_ascii_case(&normalized_path) + && !normalize_path(original.trim_start_matches('/')) + .eq_ignore_ascii_case(&path_no_fragment) + { + continue; + } + + let mut unnormalized_path = String::new(); + if SCHEME_LINK.is_match(&redirect) { + unnormalized_path = redirect.to_string(); + } else { + let base = PathBuf::from(path_no_fragment) + .parent() + .expect("path can't be empty") + .to_str() + .expect("utf-8 paths only") + .to_owned(); + + let normalized_base = normalize_path(base).trim_matches('/').to_owned(); + if !normalized_base.is_empty() { + write!(unnormalized_path, "{normalized_base}/{redirect}").unwrap(); + } else { + unnormalized_path = redirect.to_string().trim_start_matches('/').to_string(); + } + } + + // original without anchors, need to append link anchors + if !original.contains("#") { + if let Some(fragment) = fragment { + if !unnormalized_path.contains("#") { + unnormalized_path.push('#'); + } else { + unnormalized_path.push('-'); + } + unnormalized_path.push_str(fragment); + } + } + + if SCHEME_LINK.is_match(&redirect) { + return CowStr::from(unnormalized_path); + } else { + normalized_path = normalize_path(unnormalized_path); + } + break; + } - fn fix<'a>(dest: CowStr<'a>, options: &HtmlRenderOptions<'_>) -> CowStr<'a> { + // Check again to make sure anchors are the html links inside the book. + if normalized_path.starts_with("../") || normalized_path.contains("/../") { + return CowStr::from(normalized_path); + } + + let mut fixed_anchor_for_print = String::new(); + fixed_anchor_for_print.push_str("#"); + fixed_anchor_for_print.push_str(&normalize_print_page_id(normalized_path)); + CowStr::from(fixed_anchor_for_print) + } + + /// Fix resource links like img to the correct location. + fn fix_resource_links<'a>(dest: CowStr<'a>, options: &HtmlRenderOptions<'_>) -> CowStr<'a> { + // Don't modify links with schemes like `https`. + // Only fix relative links + if SCHEME_LINK.is_match(&dest) || dest.starts_with('/') { + return dest; + } + + // This is a relative link, adjust it as necessary. + let mut fixed_link = add_base(options); + fixed_link.push_str(&dest); + CowStr::from(fixed_link) + } + + fn fix_a_links_with_type<'a>( + dest: CowStr<'a>, + options: &HtmlRenderOptions<'_>, + link_type: LinkType, + ) -> CowStr<'a> { + if link_type == LinkType::Email { + return dest; + } + fix_a_links(dest, options) + } + + /// Adjust markdown file to correct point in the html file. + fn fix_a_links<'a>(dest: CowStr<'a>, options: &HtmlRenderOptions<'_>) -> CowStr<'a> { if dest.starts_with('#') { // Fragment-only link. if options.for_print { let mut base = options.path.display().to_string(); if base.ends_with(".md") { - base.replace_range(base.len() - 3.., ".html"); + base.truncate(base.len() - 3); } - return format!("{base}{dest}").into(); + return format!( + "#{}{}", + normalize_print_page_id(normalize_path(base)), + dest.replace("#", "-") + ) + .into(); } else { return dest; - } + }; } + // Don't modify links with schemes like `https`. - if !SCHEME_LINK.is_match(&dest) { + if SCHEME_LINK.is_match(&dest) { + return dest; + } + + let mut fixed_link = if dest.starts_with('/') { + String::new() + } else { // This is a relative link, adjust it as necessary. - let mut fixed_link = String::new(); - if options.for_print { - let base = options - .path - .parent() - .expect("path can't be empty") - .to_str() - .expect("utf-8 paths only"); - if !base.is_empty() { - write!(fixed_link, "{base}/").unwrap(); - } + add_base(options) + }; + + if let Some(caps) = HTML_MD_LINK.captures(&dest) { + fixed_link.push_str(&caps["link"]); + fixed_link.push_str(".html"); + if let Some(anchor) = caps.name("anchor") { + fixed_link.push_str(anchor.as_str()); } + } else { + fixed_link.push_str(&dest); + }; - if let Some(caps) = MD_LINK.captures(&dest) { - fixed_link.push_str(&caps["link"]); - fixed_link.push_str(".html"); - if let Some(anchor) = caps.name("anchor") { - fixed_link.push_str(anchor.as_str()); - } - } else { - fixed_link.push_str(&dest); - }; - return CowStr::from(fixed_link); + let normalized_path = normalize_path(&fixed_link); + + // Judge if the html link is inside the book. + if !normalized_path.starts_with("../") && !normalized_path.contains("/../") { + // In `print.html`, print page links would all link to anchors on the print page. + if options.for_print { + return fix_print_page_link(normalized_path, options.redirect); + } } - dest + // In normal page rendering, links to anchors on another page. + CowStr::from(fixed_link) } fn fix_html<'a>(html: CowStr<'a>, options: &HtmlRenderOptions<'_>) -> CowStr<'a> { @@ -359,12 +553,44 @@ fn adjust_links<'a>(event: Event<'a>, options: &HtmlRenderOptions<'_>) -> Event< // There are dozens of HTML tags/attributes that contain paths, so // feel free to add more tags if desired; these are the only ones I // care about right now. - static HTML_LINK: LazyLock = - LazyLock::new(|| Regex::new(r#"(<(?:a|img) [^>]*?(?:src|href)=")([^"]+?)""#).unwrap()); + static A_LINK: LazyLock = + LazyLock::new(|| Regex::new(r#"(]*?href=")([^"]+?)""#).unwrap()); + static A_NAME: LazyLock = + LazyLock::new(|| Regex::new(r#"(]*?name=")([^"]+?)""#).unwrap()); + static IMG_LINK: LazyLock = + LazyLock::new(|| Regex::new(r#"(]*?src=")([^"]+?)""#).unwrap()); + + let img_link_fixed_html = IMG_LINK.replace_all(&html, |caps: ®ex::Captures<'_>| { + let fixed = fix_resource_links(caps[2].into(), options); + format!("{}{}\"", &caps[1], fixed) + }); - HTML_LINK - .replace_all(&html, |caps: ®ex::Captures<'_>| { - let fixed = fix(caps[2].into(), options); + let a_name_fixed_html = + A_NAME.replace_all(&img_link_fixed_html, |caps: ®ex::Captures<'_>| { + // This is a relative link, adjust it as necessary. + let origin_name = &caps[2].to_string(); + format!( + "{}{}\"", + &caps[1], + CowStr::from(if options.for_print { + let mut base = options.path.display().to_string(); + if base.ends_with(".md") { + base.truncate(base.len() - 3); + } + format!( + "{}-{}", + normalize_print_page_id(normalize_path(base)), + origin_name.to_string() + ) + } else { + origin_name.to_string() + }) + ) + }); + + A_LINK + .replace_all(&a_name_fixed_html, |caps: ®ex::Captures<'_>| { + let fixed = fix_a_links(caps[2].into(), options); format!("{}{}\"", &caps[1], fixed) }) .into_owned() @@ -379,7 +605,7 @@ fn adjust_links<'a>(event: Event<'a>, options: &HtmlRenderOptions<'_>) -> Event< id, }) => Event::Start(Tag::Link { link_type, - dest_url: fix(dest_url, options), + dest_url: fix_a_links_with_type(dest_url, options, link_type), title, id, }), @@ -390,7 +616,7 @@ fn adjust_links<'a>(event: Event<'a>, options: &HtmlRenderOptions<'_>) -> Event< id, }) => Event::Start(Tag::Image { link_type, - dest_url: fix(dest_url, options), + dest_url: fix_resource_links(dest_url, options), title, id, }), diff --git a/crates/mdbook-markdown/src/tests.rs b/crates/mdbook-markdown/src/tests.rs index 223eade311..756811f486 100644 --- a/crates/mdbook-markdown/src/tests.rs +++ b/crates/mdbook-markdown/src/tests.rs @@ -16,7 +16,8 @@ fn escaped_special() { #[test] fn preserves_external_links() { - let options = HtmlRenderOptions::new(&Path::new("")); + let redirect = HashMap::new(); + let options = HtmlRenderOptions::new(&Path::new(""), &redirect); assert_eq!( render_markdown("[example](https://www.rust-lang.org/)", &options), "

example

\n" @@ -25,7 +26,8 @@ fn preserves_external_links() { #[test] fn it_can_adjust_markdown_links() { - let options = HtmlRenderOptions::new(&Path::new("")); + let redirect = HashMap::new(); + let options = HtmlRenderOptions::new(&Path::new(""), &redirect); assert_eq!( render_markdown("[example](example.md)", &options), "

example

\n" @@ -55,13 +57,15 @@ fn it_can_wrap_tables() { "#.trim(); - let options = HtmlRenderOptions::new(&Path::new("")); + let redirect = HashMap::new(); + let options = HtmlRenderOptions::new(&Path::new(""), &redirect); assert_eq!(render_markdown(src, &options), out); } #[test] fn it_can_keep_quotes_straight() { - let mut options = HtmlRenderOptions::new(&Path::new("")); + let redirect = HashMap::new(); + let mut options = HtmlRenderOptions::new(&Path::new(""), &redirect); options.markdown_options.smart_punctuation = false; assert_eq!(render_markdown("'one'", &options), "

'one'

\n"); } @@ -79,7 +83,8 @@ fn it_can_make_quotes_curly_except_when_they_are_in_code() {

'three' ‘four’

"#; - let options = HtmlRenderOptions::new(&Path::new("")); + let redirect = HashMap::new(); + let options = HtmlRenderOptions::new(&Path::new(""), &redirect); assert_eq!(render_markdown(input, &options), expected); } @@ -102,7 +107,8 @@ more text with spaces

more text with spaces

"#; - let options = HtmlRenderOptions::new(&Path::new("")); + let redirect = HashMap::new(); + let options = HtmlRenderOptions::new(&Path::new(""), &redirect); assert_eq!(render_markdown(input, &options), expected); } @@ -115,7 +121,8 @@ fn rust_code_block_properties_are_passed_as_space_delimited_class() { let expected = r#"
"#; - let options = HtmlRenderOptions::new(&Path::new("")); + let redirect = HashMap::new(); + let options = HtmlRenderOptions::new(&Path::new(""), &redirect); assert_eq!(render_markdown(input, &options), expected); } @@ -128,7 +135,8 @@ fn rust_code_block_properties_with_whitespace_are_passed_as_space_delimited_clas let expected = r#"
"#; - let options = HtmlRenderOptions::new(&Path::new("")); + let redirect = HashMap::new(); + let options = HtmlRenderOptions::new(&Path::new(""), &redirect); assert_eq!(render_markdown(input, &options), expected); } @@ -141,13 +149,15 @@ fn rust_code_block_without_properties_has_proper_html_class() { let expected = r#"
"#; - let options = HtmlRenderOptions::new(&Path::new("")); + let redirect = HashMap::new(); + let options = HtmlRenderOptions::new(&Path::new(""), &redirect); assert_eq!(render_markdown(input, &options), expected); let input = r#" ```rust ``` "#; - let options = HtmlRenderOptions::new(&Path::new("")); + let redirect = HashMap::new(); + let options = HtmlRenderOptions::new(&Path::new(""), &redirect); assert_eq!(render_markdown(input, &options), expected); } diff --git a/guide/src/misc/contributors.md b/guide/src/misc/contributors.md index 362a21fe4f..ff3549091f 100644 --- a/guide/src/misc/contributors.md +++ b/guide/src/misc/contributors.md @@ -20,5 +20,6 @@ shout-out to them! - Vivek Akupatni ([apatniv](https://github.com/apatniv)) - Eric Huss ([ehuss](https://github.com/ehuss)) - Josh Rotenberg ([joshrotenberg](https://github.com/joshrotenberg)) +- Songlin Jiang ([HollowMan6](https://github.com/HollowMan6)) If you feel you're missing from this list, feel free to add yourself in a PR. diff --git a/tests/testsuite/print.rs b/tests/testsuite/print.rs index 0e0cdfe05c..57f02501db 100644 --- a/tests/testsuite/print.rs +++ b/tests/testsuite/print.rs @@ -8,17 +8,17 @@ fn relative_links() { BookTest::from_dir("print/relative_links") .check_main_file("book/print.html", str![[r##" -

First Chapter

-

First Nested

-

Testing relative links for the print page

-

When we link to the first section, it should work on +

First Chapter

+

First Nested

+

Testing relative links for the print page

+

When we link to the first section, it should work on both the print page and the non-print page.

-

A fragment link should work.

+

A fragment link should work.

Link outside.

Some image

-

HTML Link

+

HTML Link

raw html -

Some section

+

Some section

"##]]); }