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 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"]
```

View file

@ -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<Error>> {
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<Error>> {
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<Error>> {
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");

View file

@ -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<String> {
if let Some(htmlconfig) = self.config.get_html_config() {
return htmlconfig.get_google_analytics_id();

View file

@ -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
}

View file

@ -6,6 +6,7 @@ use super::tomlconfig::TomlHtmlConfig;
pub struct HtmlConfig {
destination: PathBuf,
theme: Option<PathBuf>,
curly_quotes: bool,
google_analytics: Option<String>,
additional_css: Vec<PathBuf>,
additional_js: Vec<PathBuf>,
@ -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<String> {
self.google_analytics.clone()
}

View file

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

View file

@ -24,6 +24,7 @@ pub struct TomlHtmlConfig {
pub destination: Option<PathBuf>,
pub theme: Option<PathBuf>,
pub google_analytics: Option<String>,
pub curly_quotes: Option<bool>,
pub additional_css: 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
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

View file

@ -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), "<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;
// 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"));
}
}
// 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;
// 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]