Skip to content
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

Translate relative links to other .md files during rendering (fixes #588) #589

Closed
wants to merge 1 commit into from
Closed
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
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ tempdir = "0.3.4"
itertools = "0.7"
shlex = "0.1"
toml-query = "0.6"
relative-path = { version = "0.3", features = ["serde"] }
url = "1.6"

# Watch feature
notify = { version = "4.0", optional = true }
Expand Down
1 change: 1 addition & 0 deletions book-example/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@
- [For Developers](for_developers/index.md)
- [Preprocessors](for_developers/preprocessors.md)
- [Alternate Backends](for_developers/backends.md)
- [Test](test.md)
-----------
[Contributors](misc/contributors.md)
2 changes: 1 addition & 1 deletion book-example/src/cli/init.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ configuration files, etc.
- The `book` directory is where your book is rendered. All the output is ready to be uploaded
to a server to be seen by your audience.

- The `SUMMARY.md` file is the most important file, it's the skeleton of your book and is discussed in more detail in another [chapter](format/summary.html).
- The `SUMMARY.md` file is the most important file, it's the skeleton of your book and is discussed in more detail in another [chapter](../format/summary.md).

#### Tip & Trick: Hidden Feature
When a `SUMMARY.md` file already exists, the `init` command will first parse it and generate the missing files according to the paths used in the `SUMMARY.md`. This allows you to think and create the whole structure of your book and then let mdBook generate it for you.
Expand Down
4 changes: 2 additions & 2 deletions book-example/src/for_developers/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ The *For Developers* chapters are here to show you the more advanced usage of

The two main ways a developer can hook into the book's build process is via,

- [Preprocessors](for_developers/preprocessors.html)
- [Alternate Backends](for_developers/backends.html)
- [Preprocessors](preprocessors.md)
- [Alternate Backends](backends.md)


## The Build Process
Expand Down
5 changes: 5 additions & 0 deletions book-example/src/test.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# This is just a test page

* [Link to format summary](format/summary.md)
* [Link to directory (doesn't behave well)](format)
* [Bad Link :(](format/bad.md)
4 changes: 4 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ extern crate shlex;
extern crate tempdir;
extern crate toml;
extern crate toml_query;
extern crate relative_path;
extern crate url;

#[cfg(test)]
#[macro_use]
Expand All @@ -115,13 +117,15 @@ pub use config::Config;
/// The error types used through out this crate.
pub mod errors {
use std::path::PathBuf;
use relative_path::FromPathError;

error_chain!{
foreign_links {
Io(::std::io::Error) #[doc = "A wrapper around `std::io::Error`"];
HandlebarsRender(::handlebars::RenderError) #[doc = "Handlebars rendering failed"];
HandlebarsTemplate(Box<::handlebars::TemplateError>) #[doc = "Unable to parse the template"];
Utf8(::std::string::FromUtf8Error) #[doc = "Invalid UTF-8"];
FromPathError(FromPathError) #[doc = "Failed to convert to relative path"];
}

links {
Expand Down
43 changes: 39 additions & 4 deletions src/renderer/html_handlebars/hbs_renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ use std::fs::{self, File};
use std::io::{Read, Write};
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::collections::HashSet;
use relative_path::{RelativePathBuf, RelativePath};

use handlebars::Handlebars;

Expand Down Expand Up @@ -41,15 +43,39 @@ impl HtmlHandlebars {

fn render_item(
&self,
item: &BookItem,
mut ctx: RenderItemContext,
item: &BookItem,
targets: &HashSet<RelativePathBuf>,
mut ctx: RenderItemContext,
print_content: &mut String,
) -> Result<()> {
// FIXME: This should be made DRY-er and rely less on mutable state
match *item {
BookItem::Chapter(ref ch) => {
let content = ch.content.clone();
let content = utils::render_markdown(&content, ctx.html_config.curly_quotes);

let path = RelativePathBuf::from_path(&ch.path)?;
let parent = path.parent().unwrap_or(RelativePath::new("."));

let link_filter = utils::ChangeExtLinkFilter::new(
parent,
move |dest| {
let check = parent.join_normalized(dest);

if !targets.contains(&check) {
warn!("link to non-existent destination: {:?}", dest);
return false;
}

true
},
"md",
"html",
);

let content = utils::render_markdown(
&content, Some(&link_filter), ctx.html_config.curly_quotes
);

print_content.push_str(&content);

// Update the context with data for this file
Expand Down Expand Up @@ -307,6 +333,15 @@ impl Renderer for HtmlHandlebars {
fs::create_dir_all(&destination)
.chain_err(|| "Unexpected error when constructing destination path")?;

// valid link targets
let mut targets = HashSet::new();

for item in book.iter() {
if let BookItem::Chapter(ref ch) = *item {
targets.insert(RelativePathBuf::from_path(&ch.path)?);
}
}

for (i, item) in book.iter().enumerate() {
let ctx = RenderItemContext {
handlebars: &handlebars,
Expand All @@ -315,7 +350,7 @@ impl Renderer for HtmlHandlebars {
is_index: i == 0,
html_config: html_config.clone(),
};
self.render_item(item, ctx, &mut print_content)?;
self.render_item(item, &targets, ctx, &mut print_content)?;
}

// Print version
Expand Down
61 changes: 61 additions & 0 deletions src/utils/link_filter.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
use url::Url;
use relative_path::RelativePath;

/// Translate the given destination from a relative link with an '.md' extension, to a link with
/// a '.html' extension.
pub struct ChangeExtLinkFilter<'a, F> {
base: &'a RelativePath,
is_dest: F,
expected: &'a str,
ext: &'a str,
}

impl<'a, F> ChangeExtLinkFilter<'a, F>
where F: Fn(&RelativePath) -> bool
{
pub fn new(base: &'a RelativePath, is_dest: F, expected: &'a str, ext: &'a str) -> ChangeExtLinkFilter<'a, F> {
ChangeExtLinkFilter {
base: base,
is_dest: is_dest,
expected: expected,
ext: ext,
}
}
}

impl<'a, F> LinkFilter for ChangeExtLinkFilter<'a, F>
where F: Fn(&RelativePath) -> bool
{
fn apply(&self, dest: &str) -> Option<String> {
use url::ParseError;

// Verify that specified URL is relative.
if let Err(ParseError::RelativeUrlWithoutBase) = Url::parse(dest) {
// extract fragment.
let mut split = dest.splitn(2, '#');

if let Some(base) = split.next() {
let dest = RelativePath::new(base);

if Some(self.expected) == dest.extension() && (self.is_dest)(dest) {
let dest = self.base.join_normalized(dest).with_extension(self.ext);
let dest = dest.display().to_string();

if let Some(fragment) = split.next() {
return Some(format!("{}#{}", dest, fragment));
}

return Some(dest);
}
}
}

None
}
}

/// A filter to optionally apply to links.
pub trait LinkFilter {
/// Optionally translate the given destination, if applicable.
fn apply(&self, dest: &str) -> Option<String>;
}
90 changes: 77 additions & 13 deletions src/utils/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#![allow(missing_docs)] // FIXME: Document this

pub mod fs;
mod link_filter;
mod string;
use errors::Error;

Expand All @@ -9,20 +10,34 @@ use pulldown_cmark::{html, Event, Options, Parser, Tag, OPTION_ENABLE_FOOTNOTES,
use std::borrow::Cow;

pub use self::string::{RangeArgument, take_lines};
pub use self::link_filter::{LinkFilter, ChangeExtLinkFilter};

/// Wrapper around the pulldown-cmark parser for rendering markdown to HTML.
pub fn render_markdown(text: &str, curly_quotes: bool) -> String {
pub fn render_markdown(
text: &str,
link_filter: Option<&LinkFilter>,
curly_quotes: bool,
) -> String {
let mut s = String::with_capacity(text.len() * 3 / 2);

let mut opts = Options::empty();
opts.insert(OPTION_ENABLE_TABLES);
opts.insert(OPTION_ENABLE_FOOTNOTES);

let p = Parser::new_ext(text, opts);

let mut converter = EventQuoteConverter::new(curly_quotes);

let events = p.map(clean_codeblock_headers)
.map(|event| converter.convert(event));

let events: Box<Iterator<Item = Event>> = if let Some(filter) = link_filter {
let mut link_filter_converter = LinkFilterConverter::new(filter);
Box::new(events.map(move |event| link_filter_converter.convert(event)))
} else {
Box::new(events)
};

html::push_html(&mut s, events);
s
}
Expand Down Expand Up @@ -62,6 +77,31 @@ impl EventQuoteConverter {
}
}

struct LinkFilterConverter<'filter> {
filter: &'filter LinkFilter,
}

impl<'filter> LinkFilterConverter<'filter> {
fn new(filter: &'filter LinkFilter) -> Self {
LinkFilterConverter {
filter: filter,
}
}

fn convert<'a>(&mut self, event: Event<'a>) -> Event<'a> {
match event {
Event::Start(Tag::Link(dest, title)) => {
if let Some(translated) = self.filter.apply(&dest) {
return Event::Start(Tag::Link(Cow::Owned(translated), title));
}

Event::Start(Tag::Link(dest, title))
}
_ => event,
}
}
}

fn clean_codeblock_headers(event: Event) -> Event {
match event {
Event::Start(Tag::CodeBlock(ref info)) => {
Expand Down Expand Up @@ -118,10 +158,12 @@ pub fn log_backtrace(e: &Error) {
mod tests {
mod render_markdown {
use super::super::render_markdown;
use super::super::ChangeExtLinkFilter;
use relative_path::RelativePath;

#[test]
fn it_can_keep_quotes_straight() {
assert_eq!(render_markdown("'one'", false), "<p>'one'</p>\n");
assert_eq!(render_markdown("'one'", None, false), "<p>'one'</p>\n");
}

#[test]
Expand All @@ -137,7 +179,7 @@ mod tests {
</code></pre>
<p><code>'three'</code> ‘four’</p>
"#;
assert_eq!(render_markdown(input, true), expected);
assert_eq!(render_markdown(input, None, true), expected);
}

#[test]
Expand All @@ -159,8 +201,8 @@ more text with spaces
</code></pre>
<p>more text with spaces</p>
"#;
assert_eq!(render_markdown(input, false), expected);
assert_eq!(render_markdown(input, true), expected);
assert_eq!(render_markdown(input, None, false), expected);
assert_eq!(render_markdown(input, None, true), expected);
}

#[test]
Expand All @@ -173,8 +215,8 @@ more text with spaces
let expected =
r#"<pre><code class="language-rust,no_run,should_panic,property_3"></code></pre>
"#;
assert_eq!(render_markdown(input, false), expected);
assert_eq!(render_markdown(input, true), expected);
assert_eq!(render_markdown(input, None, false), expected);
assert_eq!(render_markdown(input, None, true), expected);
}

#[test]
Expand All @@ -187,8 +229,8 @@ more text with spaces
let expected =
r#"<pre><code class="language-rust,no_run,,,should_panic,,property_3"></code></pre>
"#;
assert_eq!(render_markdown(input, false), expected);
assert_eq!(render_markdown(input, true), expected);
assert_eq!(render_markdown(input, None, false), expected);
assert_eq!(render_markdown(input, None, true), expected);
}

#[test]
Expand All @@ -200,15 +242,37 @@ more text with spaces

let expected = r#"<pre><code class="language-rust"></code></pre>
"#;
assert_eq!(render_markdown(input, false), expected);
assert_eq!(render_markdown(input, true), expected);
assert_eq!(render_markdown(input, None, false), expected);
assert_eq!(render_markdown(input, None, true), expected);

let input = r#"
```rust
```
"#;
assert_eq!(render_markdown(input, false), expected);
assert_eq!(render_markdown(input, true), expected);
assert_eq!(render_markdown(input, None, false), expected);
assert_eq!(render_markdown(input, None, true), expected);
}

#[test]
fn test_link_filter() {
let input = r#"
[foo](./bar.md)
[foo](./baz.md)
"#;

let expected = "<p><a href=\"bar.html\">foo</a>\n<a href=\"./baz.md\">foo</a></p>\n";

let bar = RelativePath::new("./bar.md");

let filter = ChangeExtLinkFilter::new(
RelativePath::new("."),
|path| path == bar,
"md",
"html"
);

// only bar is a file.
assert_eq!(render_markdown(input, Some(&filter), false), expected);
}
}

Expand Down