Add an option to convert to curly quotes when rendering to HTML

This commit is contained in:
Jimmy Do 2017-05-31 22:28:08 -07:00
parent 29708db467
commit 193f014a5b
11 changed files with 208 additions and 22 deletions

View File

@ -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:** By default, the HTML book will be rendered in the `root/book` directory, but this option lets you specify another
destination fodler. 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. - **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. - **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. - **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] [output.html]
destination = "my-book" # the output files will be generated in `root/my-book` instead of `root/book` destination = "my-book" # the output files will be generated in `root/my-book` instead of `root/book`
theme = "my-theme" theme = "my-theme"
curly-quotes = true
google-analytics = "123456" google-analytics = "123456"
additional-css = ["custom.css", "custom2.css"] additional-css = ["custom.css", "custom2.css"]
``` ```

View File

@ -64,16 +64,19 @@ fn main() {
.arg_from_usage("-o, --open 'Open the compiled book in a web browser'") .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("-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("--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)'")) .arg_from_usage("[dir] 'A directory for your book{n}(Defaults to Current Directory when omitted)'"))
.subcommand(SubCommand::with_name("watch") .subcommand(SubCommand::with_name("watch")
.about("Watch the files for changes") .about("Watch the files for changes")
.arg_from_usage("-o, --open 'Open the compiled book in a web browser'") .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("-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)'")) .arg_from_usage("[dir] 'A directory for your book{n}(Defaults to Current Directory when omitted)'"))
.subcommand(SubCommand::with_name("serve") .subcommand(SubCommand::with_name("serve")
.about("Serve the book at http://localhost:3000. Rebuild and reload on change.") .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("[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("-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("-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("-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)'") .arg_from_usage("-i, --interface=[interface] 'Interface to listen on{n}(Defaults to localhost)'")
@ -181,6 +184,10 @@ fn build(args: &ArgMatches) -> Result<(), Box<Error>> {
book.create_missing = false; book.create_missing = false;
} }
if args.is_present("curly-quotes") {
book = book.with_curly_quotes(true);
}
book.build()?; book.build()?;
if let Some(d) = book.get_destination() { if let Some(d) = book.get_destination() {
@ -204,6 +211,10 @@ fn watch(args: &ArgMatches) -> Result<(), Box<Error>> {
None => book, None => book,
}; };
if args.is_present("curly-quotes") {
book = book.with_curly_quotes(true);
}
if args.is_present("open") { if args.is_present("open") {
book.build()?; book.build()?;
if let Some(d) = book.get_destination() { if let Some(d) = book.get_destination() {
@ -241,6 +252,10 @@ fn serve(args: &ArgMatches) -> Result<(), Box<Error>> {
std::process::exit(2); 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 port = args.value_of("port").unwrap_or("3000");
let ws_port = args.value_of("websocket-port").unwrap_or("3001"); let ws_port = args.value_of("websocket-port").unwrap_or("3001");
let interface = args.value_of("interface").unwrap_or("localhost"); let interface = args.value_of("interface").unwrap_or("localhost");

View File

@ -479,6 +479,23 @@ impl MDBook {
None 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<String> { pub fn get_google_analytics_id(&self) -> Option<String> {
if let Some(htmlconfig) = self.config.get_html_config() { if let Some(htmlconfig) = self.config.get_html_config() {
return htmlconfig.get_google_analytics_id(); return htmlconfig.get_google_analytics_id();

View File

@ -160,6 +160,12 @@ impl BookConfig {
} }
} }
if let Some(curly_quotes) = jsonconfig.curly_quotes {
if let Some(htmlconfig) = self.get_mut_html_config() {
htmlconfig.set_curly_quotes(curly_quotes);
}
}
self self
} }

View File

@ -6,6 +6,7 @@ use super::tomlconfig::TomlHtmlConfig;
pub struct HtmlConfig { pub struct HtmlConfig {
destination: PathBuf, destination: PathBuf,
theme: Option<PathBuf>, theme: Option<PathBuf>,
curly_quotes: bool,
google_analytics: Option<String>, google_analytics: Option<String>,
additional_css: Vec<PathBuf>, additional_css: Vec<PathBuf>,
additional_js: Vec<PathBuf>, additional_js: Vec<PathBuf>,
@ -27,6 +28,7 @@ impl HtmlConfig {
HtmlConfig { HtmlConfig {
destination: root.into().join("book"), destination: root.into().join("book"),
theme: None, theme: None,
curly_quotes: false,
google_analytics: None, google_analytics: None,
additional_css: Vec::new(), additional_css: Vec::new(),
additional_js: 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() { if tomlconfig.google_analytics.is_some() {
self.google_analytics = tomlconfig.google_analytics; self.google_analytics = tomlconfig.google_analytics;
} }
@ -110,6 +116,14 @@ impl HtmlConfig {
self 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<String> { pub fn get_google_analytics_id(&self) -> Option<String> {
self.google_analytics.clone() self.google_analytics.clone()
} }

View File

@ -13,6 +13,7 @@ pub struct JsonConfig {
pub description: Option<String>, pub description: Option<String>,
pub theme_path: Option<PathBuf>, pub theme_path: Option<PathBuf>,
pub curly_quotes: Option<bool>,
pub google_analytics: Option<String>, pub google_analytics: Option<String>,
} }

View File

@ -24,6 +24,7 @@ pub struct TomlHtmlConfig {
pub destination: Option<PathBuf>, pub destination: Option<PathBuf>,
pub theme: Option<PathBuf>, pub theme: Option<PathBuf>,
pub google_analytics: Option<String>, pub google_analytics: Option<String>,
pub curly_quotes: Option<bool>,
pub additional_css: Option<Vec<PathBuf>>, pub additional_css: Option<Vec<PathBuf>>,
pub additional_js: Option<Vec<PathBuf>>, pub additional_js: Option<Vec<PathBuf>>,
} }

View File

@ -82,7 +82,7 @@ impl Renderer for HtmlHandlebars {
} }
// Render markdown using the pulldown-cmark crate // 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); print_content.push_str(&content);
// Update the context with data for this file // Update the context with data for this file

View File

@ -1,13 +1,14 @@
pub mod fs; 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 /// 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 s = String::with_capacity(text.len() * 3 / 2);
let mut opts = Options::empty(); let mut opts = Options::empty();
@ -15,6 +16,107 @@ pub fn render_markdown(text: &str) -> String {
opts.insert(OPTION_ENABLE_FOOTNOTES); opts.insert(OPTION_ENABLE_FOOTNOTES);
let p = Parser::new_ext(text, opts); 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 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), "<p>'one'</p>\n");
}
#[test]
fn it_can_make_quotes_curly_except_when_they_are_in_code() {
let input = r#"
'one'
```
'two'
```
`'three'` 'four'"#;
let expected = r#"<p>one</p>
<pre><code>'two'
</code></pre>
<p><code>'three'</code> four</p>
"#;
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'"), "\tone");
}
}
}

View File

@ -4,7 +4,7 @@ use mdbook::config::jsonconfig::JsonConfig;
use std::path::PathBuf; 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] #[test]
fn from_json_source() { fn from_json_source() {
let json = r#"{ let json = r#"{
@ -17,7 +17,7 @@ fn from_json_source() {
assert_eq!(config.get_source(), PathBuf::from("root/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] #[test]
fn from_json_title() { fn from_json_title() {
let json = r#"{ let json = r#"{
@ -30,7 +30,7 @@ fn from_json_title() {
assert_eq!(config.get_title(), "Some 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] #[test]
fn from_json_description() { fn from_json_description() {
let json = r#"{ let json = r#"{
@ -43,7 +43,7 @@ fn from_json_description() {
assert_eq!(config.get_description(), "This is a 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] #[test]
fn from_json_author() { fn from_json_author() {
let json = r#"{ let json = r#"{
@ -56,7 +56,7 @@ fn from_json_author() {
assert_eq!(config.get_authors(), &[String::from("John Doe")]); 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] #[test]
fn from_json_destination() { fn from_json_destination() {
let json = r#"{ let json = r#"{
@ -71,7 +71,7 @@ fn from_json_destination() {
assert_eq!(htmlconfig.get_destination(), PathBuf::from("root/htmlbook")); 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] #[test]
fn from_json_output_html_theme() { fn from_json_output_html_theme() {
let json = r#"{ let json = r#"{
@ -85,3 +85,18 @@ fn from_json_output_html_theme() {
assert_eq!(htmlconfig.get_theme().expect("the theme key was provided"), &PathBuf::from("root/theme")); assert_eq!(htmlconfig.get_theme().expect("the theme key was provided"), &PathBuf::from("root/theme"));
} }
// 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);
}

View File

@ -4,7 +4,7 @@ use mdbook::config::tomlconfig::TomlConfig;
use std::path::PathBuf; 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] #[test]
fn from_toml_source() { fn from_toml_source() {
let toml = r#"source = "source""#; let toml = r#"source = "source""#;
@ -15,7 +15,7 @@ fn from_toml_source() {
assert_eq!(config.get_source(), PathBuf::from("root/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] #[test]
fn from_toml_title() { fn from_toml_title() {
let toml = r#"title = "Some title""#; let toml = r#"title = "Some title""#;
@ -26,7 +26,7 @@ fn from_toml_title() {
assert_eq!(config.get_title(), "Some 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] #[test]
fn from_toml_description() { fn from_toml_description() {
let toml = r#"description = "This is a 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"); 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] #[test]
fn from_toml_author() { fn from_toml_author() {
let toml = r#"author = "John Doe""#; let toml = r#"author = "John Doe""#;
@ -48,7 +48,7 @@ fn from_toml_author() {
assert_eq!(config.get_authors(), &[String::from("John Doe")]); 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] #[test]
fn from_toml_authors() { fn from_toml_authors() {
let toml = r#"authors = ["John Doe", "Jane Doe"]"#; 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")]); 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] #[test]
fn from_toml_output_html_destination() { fn from_toml_output_html_destination() {
let toml = r#"[output.html] let toml = r#"[output.html]
@ -73,7 +73,7 @@ fn from_toml_output_html_destination() {
assert_eq!(htmlconfig.get_destination(), PathBuf::from("root/htmlbook")); 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] #[test]
fn from_toml_output_html_theme() { fn from_toml_output_html_theme() {
let toml = r#"[output.html] 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")); 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] #[test]
fn from_toml_output_html_google_analytics() { fn from_toml_output_html_google_analytics() {
let toml = r#"[output.html] 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")); 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 correctly parsed in the TOML config
// Tests that the `output.html.additional-css` key is correcly parsed in the TOML config
#[test] #[test]
fn from_toml_output_html_additional_stylesheet() { fn from_toml_output_html_additional_stylesheet() {
let toml = r#"[output.html] 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")]); 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] #[test]
fn from_toml_output_html_additional_scripts() { fn from_toml_output_html_additional_scripts() {
let toml = r#"[output.html] let toml = r#"[output.html]