diff --git a/Cargo.toml b/Cargo.toml index d05d3737ac..94cc096773 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 } diff --git a/book-example/src/SUMMARY.md b/book-example/src/SUMMARY.md index aba9ab5d45..15531178b5 100644 --- a/book-example/src/SUMMARY.md +++ b/book-example/src/SUMMARY.md @@ -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) diff --git a/book-example/src/cli/init.md b/book-example/src/cli/init.md index 43d1ae02d3..781153cd83 100644 --- a/book-example/src/cli/init.md +++ b/book-example/src/cli/init.md @@ -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. diff --git a/book-example/src/for_developers/index.md b/book-example/src/for_developers/index.md index 4f173c904d..ae1069fc8b 100644 --- a/book-example/src/for_developers/index.md +++ b/book-example/src/for_developers/index.md @@ -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 diff --git a/book-example/src/test.md b/book-example/src/test.md new file mode 100644 index 0000000000..9e7c5a1ec5 --- /dev/null +++ b/book-example/src/test.md @@ -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) diff --git a/src/lib.rs b/src/lib.rs index 559cec303a..8326cee2cb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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] @@ -115,6 +117,7 @@ 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 { @@ -122,6 +125,7 @@ pub mod errors { 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 { diff --git a/src/renderer/html_handlebars/hbs_renderer.rs b/src/renderer/html_handlebars/hbs_renderer.rs index 6535a4a3dd..ec47b558b5 100644 --- a/src/renderer/html_handlebars/hbs_renderer.rs +++ b/src/renderer/html_handlebars/hbs_renderer.rs @@ -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; @@ -41,15 +43,39 @@ impl HtmlHandlebars { fn render_item( &self, - item: &BookItem, - mut ctx: RenderItemContext, + item: &BookItem, + targets: &HashSet, + 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 @@ -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, @@ -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 diff --git a/src/utils/link_filter.rs b/src/utils/link_filter.rs new file mode 100644 index 0000000000..e53f6e19b4 --- /dev/null +++ b/src/utils/link_filter.rs @@ -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 { + 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; +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 56291aeb10..4e307d545d 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,6 +1,7 @@ #![allow(missing_docs)] // FIXME: Document this pub mod fs; +mod link_filter; mod string; use errors::Error; @@ -9,9 +10,14 @@ 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(); @@ -19,10 +25,19 @@ pub fn render_markdown(text: &str, curly_quotes: bool) -> String { 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> = 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 } @@ -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)) => { @@ -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), "

'one'

\n"); + assert_eq!(render_markdown("'one'", None, false), "

'one'

\n"); } #[test] @@ -137,7 +179,7 @@ mod tests {

'three' ‘four’

"#; - assert_eq!(render_markdown(input, true), expected); + assert_eq!(render_markdown(input, None, true), expected); } #[test] @@ -159,8 +201,8 @@ more text with spaces

more text with spaces

"#; - 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] @@ -173,8 +215,8 @@ more text with spaces let expected = r#"
"#; - 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] @@ -187,8 +229,8 @@ more text with spaces let expected = r#"
"#; - 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] @@ -200,15 +242,37 @@ more text with spaces let expected = r#"
"#; - 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 = "

foo\nfoo

\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); } }