From 193f014a5b6cec9e69b559560742848147550a4a Mon Sep 17 00:00:00 2001 From: Jimmy Do Date: Wed, 31 May 2017 22:28:08 -0700 Subject: [PATCH] Add an option to convert to curly quotes when rendering to HTML --- book-example/src/format/config.md | 2 + src/bin/mdbook.rs | 15 +++ src/book/mod.rs | 17 +++ src/config/bookconfig.rs | 6 ++ src/config/htmlconfig.rs | 14 +++ src/config/jsonconfig.rs | 1 + src/config/tomlconfig.rs | 1 + src/renderer/html_handlebars/hbs_renderer.rs | 2 +- src/utils/mod.rs | 108 ++++++++++++++++++- tests/jsonconfig.rs | 29 +++-- tests/tomlconfig.rs | 35 ++++-- 11 files changed, 208 insertions(+), 22 deletions(-) diff --git a/book-example/src/format/config.md b/book-example/src/format/config.md index 58beb2e7..39aabb67 100644 --- a/book-example/src/format/config.md +++ b/book-example/src/format/config.md @@ -66,6 +66,7 @@ The following configuration options are available: - **destination:** By default, the HTML book will be rendered in the `root/book` directory, but this option lets you specify another destination fodler. - **theme:** mdBook comes with a default theme and all the resource files needed for it. But if this option is set, mdBook will selectively overwrite the theme files with the ones found in the specified folder. +- **curly-quotes:** Convert straight quotes to curly quotes, except for those that occur in code blocks and code spans. Defaults to `false`. - **google-analytics:** If you use Google Analytics, this option lets you enable it by simply specifying your ID in the configuration file. - **additional-css:** If you need to slightly change the appearance of your book without overwriting the whole style, you can specify a set of stylesheets that will be loaded after the default ones where you can surgically change the style. @@ -78,6 +79,7 @@ description = "The example book covers examples." [output.html] destination = "my-book" # the output files will be generated in `root/my-book` instead of `root/book` theme = "my-theme" +curly-quotes = true google-analytics = "123456" additional-css = ["custom.css", "custom2.css"] ``` diff --git a/src/bin/mdbook.rs b/src/bin/mdbook.rs index 416aa081..f7226fbb 100644 --- a/src/bin/mdbook.rs +++ b/src/bin/mdbook.rs @@ -64,16 +64,19 @@ fn main() { .arg_from_usage("-o, --open 'Open the compiled book in a web browser'") .arg_from_usage("-d, --dest-dir=[dest-dir] 'The output directory for your book{n}(Defaults to ./book when omitted)'") .arg_from_usage("--no-create 'Will not create non-existent files linked from SUMMARY.md'") + .arg_from_usage("--curly-quotes 'Convert straight quotes to curly quotes, except for those that occur in code blocks and code spans'") .arg_from_usage("[dir] 'A directory for your book{n}(Defaults to Current Directory when omitted)'")) .subcommand(SubCommand::with_name("watch") .about("Watch the files for changes") .arg_from_usage("-o, --open 'Open the compiled book in a web browser'") .arg_from_usage("-d, --dest-dir=[dest-dir] 'The output directory for your book{n}(Defaults to ./book when omitted)'") + .arg_from_usage("--curly-quotes 'Convert straight quotes to curly quotes, except for those that occur in code blocks and code spans'") .arg_from_usage("[dir] 'A directory for your book{n}(Defaults to Current Directory when omitted)'")) .subcommand(SubCommand::with_name("serve") .about("Serve the book at http://localhost:3000. Rebuild and reload on change.") .arg_from_usage("[dir] 'A directory for your book{n}(Defaults to Current Directory when omitted)'") .arg_from_usage("-d, --dest-dir=[dest-dir] 'The output directory for your book{n}(Defaults to ./book when omitted)'") + .arg_from_usage("--curly-quotes 'Convert straight quotes to curly quotes, except for those that occur in code blocks and code spans'") .arg_from_usage("-p, --port=[port] 'Use another port{n}(Defaults to 3000)'") .arg_from_usage("-w, --websocket-port=[ws-port] 'Use another port for the websocket connection (livereload){n}(Defaults to 3001)'") .arg_from_usage("-i, --interface=[interface] 'Interface to listen on{n}(Defaults to localhost)'") @@ -181,6 +184,10 @@ fn build(args: &ArgMatches) -> Result<(), Box> { book.create_missing = false; } + if args.is_present("curly-quotes") { + book = book.with_curly_quotes(true); + } + book.build()?; if let Some(d) = book.get_destination() { @@ -204,6 +211,10 @@ fn watch(args: &ArgMatches) -> Result<(), Box> { None => book, }; + if args.is_present("curly-quotes") { + book = book.with_curly_quotes(true); + } + if args.is_present("open") { book.build()?; if let Some(d) = book.get_destination() { @@ -241,6 +252,10 @@ fn serve(args: &ArgMatches) -> Result<(), Box> { std::process::exit(2); } + if args.is_present("curly-quotes") { + book = book.with_curly_quotes(true); + } + let port = args.value_of("port").unwrap_or("3000"); let ws_port = args.value_of("websocket-port").unwrap_or("3001"); let interface = args.value_of("interface").unwrap_or("localhost"); diff --git a/src/book/mod.rs b/src/book/mod.rs index 88d0e804..60206c6b 100644 --- a/src/book/mod.rs +++ b/src/book/mod.rs @@ -479,6 +479,23 @@ impl MDBook { None } + pub fn with_curly_quotes(mut self, curly_quotes: bool) -> Self { + if let Some(htmlconfig) = self.config.get_mut_html_config() { + htmlconfig.set_curly_quotes(curly_quotes); + } else { + error!("There is no HTML renderer set..."); + } + self + } + + pub fn get_curly_quotes(&self) -> bool { + if let Some(htmlconfig) = self.config.get_html_config() { + return htmlconfig.get_curly_quotes(); + } + + false + } + pub fn get_google_analytics_id(&self) -> Option { if let Some(htmlconfig) = self.config.get_html_config() { return htmlconfig.get_google_analytics_id(); diff --git a/src/config/bookconfig.rs b/src/config/bookconfig.rs index 94efe9c5..32182854 100644 --- a/src/config/bookconfig.rs +++ b/src/config/bookconfig.rs @@ -159,6 +159,12 @@ impl BookConfig { htmlconfig.set_theme(&root, &d); } } + + if let Some(curly_quotes) = jsonconfig.curly_quotes { + if let Some(htmlconfig) = self.get_mut_html_config() { + htmlconfig.set_curly_quotes(curly_quotes); + } + } self } diff --git a/src/config/htmlconfig.rs b/src/config/htmlconfig.rs index 8ea1f08f..1c42a34a 100644 --- a/src/config/htmlconfig.rs +++ b/src/config/htmlconfig.rs @@ -6,6 +6,7 @@ use super::tomlconfig::TomlHtmlConfig; pub struct HtmlConfig { destination: PathBuf, theme: Option, + curly_quotes: bool, google_analytics: Option, additional_css: Vec, additional_js: Vec, @@ -27,6 +28,7 @@ impl HtmlConfig { HtmlConfig { destination: root.into().join("book"), theme: None, + curly_quotes: false, google_analytics: None, additional_css: Vec::new(), additional_js: Vec::new(), @@ -52,6 +54,10 @@ impl HtmlConfig { } } + if let Some(curly_quotes) = tomlconfig.curly_quotes { + self.curly_quotes = curly_quotes; + } + if tomlconfig.google_analytics.is_some() { self.google_analytics = tomlconfig.google_analytics; } @@ -110,6 +116,14 @@ impl HtmlConfig { self } + pub fn get_curly_quotes(&self) -> bool { + self.curly_quotes + } + + pub fn set_curly_quotes(&mut self, curly_quotes: bool) { + self.curly_quotes = curly_quotes; + } + pub fn get_google_analytics_id(&self) -> Option { self.google_analytics.clone() } diff --git a/src/config/jsonconfig.rs b/src/config/jsonconfig.rs index 9d1e2f3f..3aa264f1 100644 --- a/src/config/jsonconfig.rs +++ b/src/config/jsonconfig.rs @@ -13,6 +13,7 @@ pub struct JsonConfig { pub description: Option, pub theme_path: Option, + pub curly_quotes: Option, pub google_analytics: Option, } diff --git a/src/config/tomlconfig.rs b/src/config/tomlconfig.rs index ca7388bd..90cfa4c6 100644 --- a/src/config/tomlconfig.rs +++ b/src/config/tomlconfig.rs @@ -24,6 +24,7 @@ pub struct TomlHtmlConfig { pub destination: Option, pub theme: Option, pub google_analytics: Option, + pub curly_quotes: Option, pub additional_css: Option>, pub additional_js: Option>, } diff --git a/src/renderer/html_handlebars/hbs_renderer.rs b/src/renderer/html_handlebars/hbs_renderer.rs index b8cdfdbd..87f5709a 100644 --- a/src/renderer/html_handlebars/hbs_renderer.rs +++ b/src/renderer/html_handlebars/hbs_renderer.rs @@ -82,7 +82,7 @@ impl Renderer for HtmlHandlebars { } // Render markdown using the pulldown-cmark crate - content = utils::render_markdown(&content); + content = utils::render_markdown(&content, book.get_curly_quotes()); print_content.push_str(&content); // Update the context with data for this file diff --git a/src/utils/mod.rs b/src/utils/mod.rs index ce98ee50..2dac509e 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,13 +1,14 @@ pub mod fs; -use pulldown_cmark::{Parser, html, Options, OPTION_ENABLE_TABLES, OPTION_ENABLE_FOOTNOTES}; +use pulldown_cmark::{Parser, Event, Tag, html, Options, OPTION_ENABLE_TABLES, OPTION_ENABLE_FOOTNOTES}; +use std::borrow::Cow; /// /// /// Wrapper around the pulldown-cmark parser and renderer to render markdown -pub fn render_markdown(text: &str) -> String { +pub fn render_markdown(text: &str, curly_quotes: bool) -> String { let mut s = String::with_capacity(text.len() * 3 / 2); let mut opts = Options::empty(); @@ -15,6 +16,107 @@ pub fn render_markdown(text: &str) -> String { opts.insert(OPTION_ENABLE_FOOTNOTES); let p = Parser::new_ext(text, opts); - html::push_html(&mut s, p); + let mut converter = EventQuoteConverter::new(curly_quotes); + let events = p.map(|event| converter.convert(event)); + html::push_html(&mut s, events); s } + +struct EventQuoteConverter { + enabled: bool, + convert_text: bool, +} + +impl EventQuoteConverter { + fn new(enabled: bool) -> Self { + EventQuoteConverter { enabled: enabled, convert_text: true } + } + + fn convert<'a>(&mut self, event: Event<'a>) -> Event<'a> { + if !self.enabled { + return event; + } + + match event { + Event::Start(Tag::CodeBlock(_)) | + Event::Start(Tag::Code) => { + self.convert_text = false; + event + }, + Event::End(Tag::CodeBlock(_)) | + Event::End(Tag::Code) => { + self.convert_text = true; + event + }, + Event::Text(ref text) if self.convert_text => Event::Text(Cow::from(convert_quotes_to_curly(text))), + _ => event, + } + } +} + +fn convert_quotes_to_curly(original_text: &str) -> String { + // We'll consider the start to be "whitespace". + let mut preceded_by_whitespace = true; + + original_text + .chars() + .map(|original_char| { + let converted_char = match original_char { + '\'' => if preceded_by_whitespace { '‘' } else { '’' }, + '"' => if preceded_by_whitespace { '“' } else { '”' }, + _ => original_char, + }; + + preceded_by_whitespace = original_char.is_whitespace(); + + converted_char + }) + .collect() +} + +#[cfg(test)] +mod tests { + mod render_markdown { + use super::super::render_markdown; + + #[test] + fn it_can_keep_quotes_straight() { + assert_eq!(render_markdown("'one'", false), "

'one'

\n"); + } + + #[test] + fn it_can_make_quotes_curly_except_when_they_are_in_code() { + let input = r#" +'one' +``` +'two' +``` +`'three'` 'four'"#; + let expected = r#"

‘one’

+
'two'
+
+

'three' ‘four’

+"#; + assert_eq!(render_markdown(input, true), expected); + } + } + + mod convert_quotes_to_curly { + use super::super::convert_quotes_to_curly; + + #[test] + fn it_converts_single_quotes() { + assert_eq!(convert_quotes_to_curly("'one', 'two'"), "‘one’, ‘two’"); + } + + #[test] + fn it_converts_double_quotes() { + assert_eq!(convert_quotes_to_curly(r#""one", "two""#), "“one”, “two”"); + } + + #[test] + fn it_treats_tab_as_whitespace() { + assert_eq!(convert_quotes_to_curly("\t'one'"), "\t‘one’"); + } + } +} diff --git a/tests/jsonconfig.rs b/tests/jsonconfig.rs index ac82bcfd..64029aff 100644 --- a/tests/jsonconfig.rs +++ b/tests/jsonconfig.rs @@ -4,7 +4,7 @@ use mdbook::config::jsonconfig::JsonConfig; use std::path::PathBuf; -// Tests that the `title` key is correcly parsed in the TOML config +// Tests that the `src` key is correctly parsed in the JSON config #[test] fn from_json_source() { let json = r#"{ @@ -17,7 +17,7 @@ fn from_json_source() { assert_eq!(config.get_source(), PathBuf::from("root/source")); } -// Tests that the `title` key is correcly parsed in the TOML config +// Tests that the `title` key is correctly parsed in the JSON config #[test] fn from_json_title() { let json = r#"{ @@ -30,7 +30,7 @@ fn from_json_title() { assert_eq!(config.get_title(), "Some title"); } -// Tests that the `description` key is correcly parsed in the TOML config +// Tests that the `description` key is correctly parsed in the JSON config #[test] fn from_json_description() { let json = r#"{ @@ -43,7 +43,7 @@ fn from_json_description() { assert_eq!(config.get_description(), "This is a description"); } -// Tests that the `author` key is correcly parsed in the TOML config +// Tests that the `author` key is correctly parsed in the JSON config #[test] fn from_json_author() { let json = r#"{ @@ -56,7 +56,7 @@ fn from_json_author() { assert_eq!(config.get_authors(), &[String::from("John Doe")]); } -// Tests that the `output.html.destination` key is correcly parsed in the TOML config +// Tests that the `dest` key is correctly parsed in the JSON config #[test] fn from_json_destination() { let json = r#"{ @@ -71,7 +71,7 @@ fn from_json_destination() { assert_eq!(htmlconfig.get_destination(), PathBuf::from("root/htmlbook")); } -// Tests that the `output.html.theme` key is correcly parsed in the TOML config +// Tests that the `theme_path` key is correctly parsed in the JSON config #[test] fn from_json_output_html_theme() { let json = r#"{ @@ -84,4 +84,19 @@ fn from_json_output_html_theme() { let htmlconfig = config.get_html_config().expect("There should be an HtmlConfig"); assert_eq!(htmlconfig.get_theme().expect("the theme key was provided"), &PathBuf::from("root/theme")); -} \ No newline at end of file +} + +// Tests that the `curly_quotes` key is correctly parsed in the JSON config +#[test] +fn from_json_output_html_curly_quotes() { + let json = r#"{ + "curly_quotes": true + }"#; + + let parsed = JsonConfig::from_json(&json).expect("This should parse"); + let config = BookConfig::from_jsonconfig("root", parsed); + + let htmlconfig = config.get_html_config().expect("There should be an HtmlConfig"); + + assert_eq!(htmlconfig.get_curly_quotes(), true); +} diff --git a/tests/tomlconfig.rs b/tests/tomlconfig.rs index e4398495..84361978 100644 --- a/tests/tomlconfig.rs +++ b/tests/tomlconfig.rs @@ -4,7 +4,7 @@ use mdbook::config::tomlconfig::TomlConfig; use std::path::PathBuf; -// Tests that the `title` key is correcly parsed in the TOML config +// Tests that the `source` key is correctly parsed in the TOML config #[test] fn from_toml_source() { let toml = r#"source = "source""#; @@ -15,7 +15,7 @@ fn from_toml_source() { assert_eq!(config.get_source(), PathBuf::from("root/source")); } -// Tests that the `title` key is correcly parsed in the TOML config +// Tests that the `title` key is correctly parsed in the TOML config #[test] fn from_toml_title() { let toml = r#"title = "Some title""#; @@ -26,7 +26,7 @@ fn from_toml_title() { assert_eq!(config.get_title(), "Some title"); } -// Tests that the `description` key is correcly parsed in the TOML config +// Tests that the `description` key is correctly parsed in the TOML config #[test] fn from_toml_description() { let toml = r#"description = "This is a description""#; @@ -37,7 +37,7 @@ fn from_toml_description() { assert_eq!(config.get_description(), "This is a description"); } -// Tests that the `author` key is correcly parsed in the TOML config +// Tests that the `author` key is correctly parsed in the TOML config #[test] fn from_toml_author() { let toml = r#"author = "John Doe""#; @@ -48,7 +48,7 @@ fn from_toml_author() { assert_eq!(config.get_authors(), &[String::from("John Doe")]); } -// Tests that the `authors` key is correcly parsed in the TOML config +// Tests that the `authors` key is correctly parsed in the TOML config #[test] fn from_toml_authors() { let toml = r#"authors = ["John Doe", "Jane Doe"]"#; @@ -59,7 +59,7 @@ fn from_toml_authors() { assert_eq!(config.get_authors(), &[String::from("John Doe"), String::from("Jane Doe")]); } -// Tests that the `output.html.destination` key is correcly parsed in the TOML config +// Tests that the `output.html.destination` key is correctly parsed in the TOML config #[test] fn from_toml_output_html_destination() { let toml = r#"[output.html] @@ -73,7 +73,7 @@ fn from_toml_output_html_destination() { assert_eq!(htmlconfig.get_destination(), PathBuf::from("root/htmlbook")); } -// Tests that the `output.html.theme` key is correcly parsed in the TOML config +// Tests that the `output.html.theme` key is correctly parsed in the TOML config #[test] fn from_toml_output_html_theme() { let toml = r#"[output.html] @@ -87,7 +87,21 @@ fn from_toml_output_html_theme() { assert_eq!(htmlconfig.get_theme().expect("the theme key was provided"), &PathBuf::from("root/theme")); } -// Tests that the `output.html.google-analytics` key is correcly parsed in the TOML config +// Tests that the `output.html.curly-quotes` key is correctly parsed in the TOML config +#[test] +fn from_toml_output_html_curly_quotes() { + let toml = r#"[output.html] + curly-quotes = true"#; + + let parsed = TomlConfig::from_toml(&toml).expect("This should parse"); + let config = BookConfig::from_tomlconfig("root", parsed); + + let htmlconfig = config.get_html_config().expect("There should be an HtmlConfig"); + + assert_eq!(htmlconfig.get_curly_quotes(), true); +} + +// Tests that the `output.html.google-analytics` key is correctly parsed in the TOML config #[test] fn from_toml_output_html_google_analytics() { let toml = r#"[output.html] @@ -101,8 +115,7 @@ fn from_toml_output_html_google_analytics() { assert_eq!(htmlconfig.get_google_analytics_id().expect("the google-analytics key was provided"), String::from("123456")); } - -// Tests that the `output.html.additional-css` key is correcly parsed in the TOML config +// Tests that the `output.html.additional-css` key is correctly parsed in the TOML config #[test] fn from_toml_output_html_additional_stylesheet() { let toml = r#"[output.html] @@ -116,7 +129,7 @@ fn from_toml_output_html_additional_stylesheet() { assert_eq!(htmlconfig.get_additional_css(), &[PathBuf::from("root/custom.css"), PathBuf::from("root/two/custom.css")]); } -// Tests that the `output.html.additional-js` key is correcly parsed in the TOML config +// Tests that the `output.html.additional-js` key is correctly parsed in the TOML config #[test] fn from_toml_output_html_additional_scripts() { let toml = r#"[output.html]