Skip to content

Fix heading links in nested pages #419

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Sep 7, 2017
Merged
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
112 changes: 73 additions & 39 deletions src/renderer/html_handlebars/hbs_renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,16 @@ impl HtmlHandlebars {
debug!("[*]: Render template");
let rendered = ctx.handlebars.render("index", &ctx.data)?;

let filename = Path::new(&ch.path).with_extension("html");
let filepath = Path::new(&ch.path).with_extension("html");
let rendered = self.post_process(rendered,
filename.file_name().unwrap().to_str().unwrap_or(""),
ctx.book.get_html_config().get_playpen_config());
&normalize_path(filepath.to_str()
.ok_or(Error::from(format!("Bad file name: {}", filepath.display())))?),
ctx.book.get_html_config().get_playpen_config()
);

// Write to file
info!("[*] Creating {:?} ✓", filename.display());
ctx.book.write_file(filename, &rendered.into_bytes())?;
info!("[*] Creating {:?} ✓", filepath.display());
ctx.book.write_file(filepath, &rendered.into_bytes())?;

if ctx.is_index {
self.render_index(ctx.book, ch, &ctx.destination)?;
Expand Down Expand Up @@ -111,9 +113,9 @@ impl HtmlHandlebars {
Ok(())
}

fn post_process(&self, rendered: String, filename: &str, playpen_config: &PlaypenConfig) -> String {
let rendered = build_header_links(&rendered, filename);
let rendered = fix_anchor_links(&rendered, filename);
fn post_process(&self, rendered: String, filepath: &str, playpen_config: &PlaypenConfig) -> String {
let rendered = build_header_links(&rendered, &filepath);
let rendered = fix_anchor_links(&rendered, &filepath);
let rendered = fix_code_blocks(&rendered);
let rendered = add_playpen_pre(&rendered, playpen_config);

Expand Down Expand Up @@ -182,7 +184,7 @@ impl HtmlHandlebars {
Ok(())
}

/// Helper function to write a file to the build directory, normalizing
/// Helper function to write a file to the build directory, normalizing
/// the path to be relative to the book root.
fn write_custom_file(&self, custom_file: &Path, book: &MDBook) -> Result<()> {
let mut data = Vec::new();
Expand Down Expand Up @@ -284,7 +286,7 @@ impl Renderer for HtmlHandlebars {

let rendered = self.post_process(rendered, "print.html",
book.get_html_config().get_playpen_config());

book.write_file(
Path::new("print").with_extension("html"),
&rendered.into_bytes(),
Expand Down Expand Up @@ -412,7 +414,7 @@ fn make_data(book: &MDBook) -> Result<serde_json::Map<String, serde_json::Value>

/// Goes through the rendered HTML, making sure all header tags are wrapped in
/// an anchor so people can link to sections directly.
fn build_header_links(html: &str, filename: &str) -> String {
fn build_header_links(html: &str, filepath: &str) -> String {
let regex = Regex::new(r"<h(\d)>(.*?)</h\d>").unwrap();
let mut id_counter = HashMap::new();

Expand All @@ -422,14 +424,14 @@ fn build_header_links(html: &str, filename: &str) -> String {
"Regex should ensure we only ever get numbers here",
);

wrap_header_with_link(level, &caps[2], &mut id_counter, filename)
wrap_header_with_link(level, &caps[2], &mut id_counter, filepath)
})
.into_owned()
}

/// Wraps a single header tag with a link, making sure each tag gets its own
/// unique ID by appending an auto-incremented number (if necessary).
fn wrap_header_with_link(level: usize, content: &str, id_counter: &mut HashMap<String, usize>, filename: &str)
fn wrap_header_with_link(level: usize, content: &str, id_counter: &mut HashMap<String, usize>, filepath: &str)
-> String {
let raw_id = id_from_content(content);

Expand All @@ -443,11 +445,11 @@ fn wrap_header_with_link(level: usize, content: &str, id_counter: &mut HashMap<S
*id_count += 1;

format!(
r#"<a class="header" href="{filename}#{id}" id="{id}"><h{level}>{text}</h{level}></a>"#,
r##"<a class="header" href="{filepath}#{id}" id="{id}"><h{level}>{text}</h{level}></a>"##,
level = level,
id = id,
text = content,
filename = filename
filepath = filepath
)
}

Expand All @@ -457,7 +459,7 @@ fn id_from_content(content: &str) -> String {
let mut content = content.to_string();

// Skip any tags or html-encoded stuff
let repl_sub = vec![
const REPL_SUB: &[&str] = &[
"<em>",
"</em>",
"<code>",
Expand All @@ -470,27 +472,17 @@ fn id_from_content(content: &str) -> String {
"&#39;",
"&quot;",
];
for sub in repl_sub {
for sub in REPL_SUB {
content = content.replace(sub, "");
}

let mut id = String::new();

for c in content.chars() {
if c.is_alphanumeric() || c == '-' || c == '_' {
id.push(c.to_ascii_lowercase());
} else if c.is_whitespace() {
id.push(c);
}
}

id
normalize_id(&content)
}

// anchors to the same page (href="#anchor") do not work because of
// <base href="../"> pointing to the root folder. This function *fixes*
// that in a very inelegant way
fn fix_anchor_links(html: &str, filename: &str) -> String {
fn fix_anchor_links(html: &str, filepath: &str) -> String {
let regex = Regex::new(r##"<a([^>]+)href="#([^"]+)"([^>]*)>"##).unwrap();
regex
.replace_all(html, |caps: &Captures| {
Expand All @@ -499,9 +491,9 @@ fn fix_anchor_links(html: &str, filename: &str) -> String {
let after = &caps[3];

format!(
"<a{before}href=\"{filename}#{anchor}\"{after}>",
"<a{before}href=\"{filepath}#{anchor}\"{after}>",
before = before,
filename = filename,
filepath = filepath,
anchor = anchor,
after = after
)
Expand Down Expand Up @@ -592,6 +584,26 @@ struct RenderItemContext<'a> {
is_index: bool,
}

pub fn normalize_path(path: &str) -> String {
use std::path::is_separator;
path.chars()
.map(|ch| if is_separator(ch) { '/' } else { ch })
.collect::<String>()
}

pub fn normalize_id(content: &str) -> String {
content.chars()
.filter_map(|ch|
if ch.is_alphanumeric() || ch == '_' {
Some(ch.to_ascii_lowercase())
} else if ch.is_whitespace() {
Some('-')
} else {
None
}
)
.collect::<String>()
}


#[cfg(test)]
Expand All @@ -601,17 +613,39 @@ mod tests {
#[test]
fn original_build_header_links() {
let inputs = vec![
("blah blah <h1>Foo</h1>", r#"blah blah <a class="header" href="bar.rs#foo" id="foo"><h1>Foo</h1></a>"#),
("<h1>Foo</h1>", r#"<a class="header" href="bar.rs#foo" id="foo"><h1>Foo</h1></a>"#),
("<h3>Foo^bar</h3>", r#"<a class="header" href="bar.rs#foobar" id="foobar"><h3>Foo^bar</h3></a>"#),
("<h4></h4>", r#"<a class="header" href="bar.rs#" id=""><h4></h4></a>"#),
("<h4><em>Hï</em></h4>", r#"<a class="header" href="bar.rs#hï" id="hï"><h4><em>Hï</em></h4></a>"#),
("<h1>Foo</h1><h3>Foo</h3>",
r#"<a class="header" href="bar.rs#foo" id="foo"><h1>Foo</h1></a><a class="header" href="bar.rs#foo-1" id="foo-1"><h3>Foo</h3></a>"#),
(
"blah blah <h1>Foo</h1>",
r##"blah blah <a class="header" href="./some_chapter/some_section.html#foo" id="foo"><h1>Foo</h1></a>"##,
),
(
"<h1>Foo</h1>",
r##"<a class="header" href="./some_chapter/some_section.html#foo" id="foo"><h1>Foo</h1></a>"##,
),
(
"<h3>Foo^bar</h3>",
r##"<a class="header" href="./some_chapter/some_section.html#foobar" id="foobar"><h3>Foo^bar</h3></a>"##,
),
(
"<h4></h4>",
r##"<a class="header" href="./some_chapter/some_section.html#" id=""><h4></h4></a>"##
),
(
"<h4><em>Hï</em></h4>",
r##"<a class="header" href="./some_chapter/some_section.html#hï" id="hï"><h4><em>Hï</em></h4></a>"##
),
(
"<h1>Foo</h1><h3>Foo</h3>",
r##"<a class="header" href="./some_chapter/some_section.html#foo" id="foo"><h1>Foo</h1></a><a class="header" href="./some_chapter/some_section.html#foo-1" id="foo-1"><h3>Foo</h3></a>"##
),
];

for (src, should_be) in inputs {
let got = build_header_links(src, "bar.rs");
let filepath = "./some_chapter/some_section.html";
let got = build_header_links(&src, filepath);
assert_eq!(got, should_be);

// This is redundant for most cases
let got = fix_anchor_links(&got, filepath);
assert_eq!(got, should_be);
}
}
Expand Down
3 changes: 0 additions & 3 deletions tests/dummy-book/first/index.md

This file was deleted.

File renamed without changes.
File renamed without changes.
5 changes: 5 additions & 0 deletions tests/dummy/book/first/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# First Chapter

more text.

## Some Section
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@ This file has some testable code.

```rust
assert!($TEST_STATUS);
```
```

## Some Section
File renamed without changes.
File renamed without changes.
44 changes: 12 additions & 32 deletions tests/helpers.rs → tests/dummy/mod.rs
Original file line number Diff line number Diff line change
@@ -1,26 +1,23 @@
//! Helpers for tests which exercise the overall application, in particular
//! the `MDBook` initialization and build/rendering process.
//!
//! This will create an entire book in a temporary directory using some
//! dummy contents from the `tests/dummy-book/` directory.

// Not all features are used in all test crates, so...
#![allow(dead_code, unused_extern_crates)]

#![allow(dead_code, unused_variables, unused_imports)]
extern crate tempdir;

use std::path::Path;
use std::fs::{self, File};
use std::io::{Read, Write};
use std::fs::{create_dir_all, File};
use std::io::Write;

use tempdir::TempDir;


const SUMMARY_MD: &'static str = include_str!("dummy-book/SUMMARY.md");
const INTRO: &'static str = include_str!("dummy-book/intro.md");
const FIRST: &'static str = include_str!("dummy-book/first/index.md");
const NESTED: &'static str = include_str!("dummy-book/first/nested.md");
const SECOND: &'static str = include_str!("dummy-book/second.md");
const CONCLUSION: &'static str = include_str!("dummy-book/conclusion.md");
const SUMMARY_MD: &'static str = include_str!("book/SUMMARY.md");
const INTRO: &'static str = include_str!("book/intro.md");
const FIRST: &'static str = include_str!("book/first/index.md");
const NESTED: &'static str = include_str!("book/first/nested.md");
const SECOND: &'static str = include_str!("book/second.md");
const CONCLUSION: &'static str = include_str!("book/conclusion.md");


/// Create a dummy book in a temporary directory, using the contents of
Expand Down Expand Up @@ -58,10 +55,10 @@ impl DummyBook {
let temp = TempDir::new("dummy_book").unwrap();

let src = temp.path().join("src");
fs::create_dir_all(&src).unwrap();
create_dir_all(&src).unwrap();

let first = src.join("first");
fs::create_dir_all(&first).unwrap();
create_dir_all(&first).unwrap();

let to_substitute = if self.passing_test { "true" } else { "false" };
let nested_text = NESTED.replace("$TEST_STATUS", to_substitute);
Expand Down Expand Up @@ -91,20 +88,3 @@ impl Default for DummyBook {
DummyBook { passing_test: true }
}
}


/// Read the contents of the provided file into memory and then iterate through
/// the list of strings asserting that the file contains all of them.
pub fn assert_contains_strings<P: AsRef<Path>>(filename: P, strings: &[&str]) {
let filename = filename.as_ref();

let mut content = String::new();
File::open(&filename)
.expect("Couldn't open the provided file")
.read_to_string(&mut content)
.expect("Couldn't read the file's contents");

for s in strings {
assert!(content.contains(s), "Searching for {:?} in {}\n\n{}", s, filename.display(), content);
}
}
24 changes: 24 additions & 0 deletions tests/helpers/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//! Helpers for tests which exercise the overall application, in particular
//! the `MDBook` initialization and build/rendering process.


use std::path::Path;
use std::fs::File;
use std::io::Read;


/// Read the contents of the provided file into memory and then iterate through
/// the list of strings asserting that the file contains all of them.
pub fn assert_contains_strings<P: AsRef<Path>>(filename: P, strings: &[&str]) {
let filename = filename.as_ref();

let mut content = String::new();
File::open(&filename)
.expect("Couldn't open the provided file")
.read_to_string(&mut content)
.expect("Couldn't read the file's contents");

for s in strings {
assert!(content.contains(s), "Searching for {:?} in {}\n\n{}", s, filename.display(), content);
}
}
Loading