mirror of
https://github.com/rust-lang/mdBook
synced 2024-11-15 01:07:13 +00:00
Add an option to convert to curly quotes when rendering to HTML
This commit is contained in:
parent
29708db467
commit
193f014a5b
11 changed files with 208 additions and 22 deletions
|
@ -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"]
|
||||
```
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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>,
|
||||
}
|
||||
|
||||
|
|
|
@ -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>>,
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
108
src/utils/mod.rs
108
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), "<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'"), "\t‘one’");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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]
|
||||
|
|
Loading…
Reference in a new issue