use mdbook::utils::new_cmark_parser;
use mdbook::BookItem;
use mdbook::{book::Chapter, preprocess::Preprocessor};
use pulldown_cmark::Event;
use pulldown_cmark_to_cmark::cmark;
use toml::Value;
use crate::config::Configuration;
const ESCAPE_CHAR: char = '\\';
const LANG_SPEC_START: char = '[';
const LANG_SPEC_END: char = ']';
#[derive(Default)]
pub(crate) struct InlineHighlighterPreprocessor;
impl Preprocessor for InlineHighlighterPreprocessor {
fn name(&self) -> &str {
"inline-highlighting"
}
fn supports_renderer(&self, renderer: &str) -> bool {
renderer == "html"
}
fn run(
&self,
ctx: &mdbook::preprocess::PreprocessorContext,
mut book: mdbook::book::Book,
) -> mdbook::errors::Result<mdbook::book::Book> {
book.for_each_mut(|item: &mut BookItem| {
if let BookItem::Chapter(chapter) = item {
let config: Configuration = match ctx.config.get_preprocessor(self.name()) {
Some(c) => c.try_into().unwrap(),
None => Configuration::default(),
};
let default_language = config.default_language;
let mut buf = String::new();
let html_options = ctx.config.get_renderer("html");
let smart_quotes = match html_options {
Some(options) => match options.get("smart-punctuation") {
Some(Value::Boolean(value)) => *value,
_ => false,
},
None => false,
};
let parser = new_cmark_parser(&chapter.content, smart_quotes);
let mut events = vec![];
for event in parser {
events.push(if let Event::Code(code) = event {
let (c, is_html) =
parse_inline_code(code.as_ref(), default_language.as_deref(), chapter)
.clone()
.to_owned();
if is_html {
Event::Html(c.into())
} else {
Event::Code(c.into())
}
} else {
event
});
}
match cmark(events.iter(), &mut buf).map(|_| buf) {
Ok(result) => chapter.content = result,
Err(error) => {
log::error!("Markdown serialization failed: {}", error);
}
};
};
});
Ok(book)
}
}
fn parse_inline_code(
code: &str,
default_language: Option<&str>,
chapter: &Chapter,
) -> (String, bool) {
let mut chars = code.chars();
match chars.next() {
Some(LANG_SPEC_START) => {}
Some(ch) => {
let result: &str = if ch == ESCAPE_CHAR {
chars.as_str()
} else {
code
};
return if let Some(l) = default_language {
(inline_with_highlighting(result, l), true)
} else {
(result.to_string(), false)
};
}
None => return (String::new(), false),
};
let mut lang = String::new();
loop {
let maybe_ch = chars.next();
match maybe_ch {
Some(LANG_SPEC_END) => break,
Some(ch) => lang.push(ch),
None => {
log::error!(
"missing closing character `{}` in chapter `{}`",
LANG_SPEC_END,
chapter
);
return if let Some(l) = default_language {
(inline_with_highlighting(code, l), true)
} else {
(code.into(), false)
};
}
};
}
let language: Option<&str> = if lang == "none" {
default_language
} else {
Some(&lang)
};
if chars.next().is_none_or(|ch| ch != ' ') {
log::error!(
"missing space after language identifier in chapter `{}`",
chapter
);
return if let Some(l) = default_language {
(inline_with_highlighting(code, l), true)
} else {
(code.into(), false)
};
};
let actual_code = chars.as_str();
match language {
Some(l) => (inline_with_highlighting(actual_code, l), true),
None => (actual_code.to_string(), false),
}
}
fn inline_with_highlighting(code: &str, language: &str) -> String {
format!("<code class=\"hljs language-{}\">{}</code>", language, code)
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn html_with_language() {
assert_eq!(
"<code class=\"hljs language-javascript\">Hello</code>",
inline_with_highlighting("Hello", "javascript"),
);
}
#[test]
fn invalid_inline() {
assert_eq!(
("[forgot-to-close oops".to_string(), false),
parse_inline_code("[forgot-to-close oops", None, &Chapter::default())
);
assert_eq!(
(
"<code class=\"hljs language-javascript\">[forgot-to-close oops</code>".to_string(),
true
),
parse_inline_code(
"[forgot-to-close oops",
Some("javascript"),
&Chapter::default()
)
);
assert_eq!(
("[js]var missingSpace;".to_string(), false),
parse_inline_code("[js]var missingSpace;", None, &Chapter::default()),
);
assert_eq!(
(
"<code class=\"hljs language-typescript\">[js]var missingSpace;</code>".to_string(),
true
),
parse_inline_code(
"[js]var missingSpace;",
Some("typescript"),
&Chapter::default()
)
)
}
#[test]
fn escaped_inline() {
assert_eq!(
("[python] x = 1".to_string(), false),
parse_inline_code("\\[python] x = 1", None, &Chapter::default())
);
assert_eq!(
(
"<code class=\"hljs language-python\">[Hello</code>".to_string(),
true
),
parse_inline_code("\\[Hello", Some("python"), &Chapter::default())
);
}
#[test]
fn markdown_without_default_without_language() {
let expect = String::from("Hello");
assert_eq!(
(expect.clone(), false),
parse_inline_code("[none] Hello", None, &Chapter::default()),
);
assert_eq!(
(expect.clone(), false),
parse_inline_code("Hello", None, &Chapter::default()),
)
}
#[test]
fn markdown_with_default_without_language() {
let expect = String::from("<code class=\"hljs language-javascript\">Hello</code>");
assert_eq!(
(expect.clone(), true),
parse_inline_code("[none] Hello", Some("javascript"), &Chapter::default()),
);
assert_eq!(
(expect.clone(), true),
parse_inline_code("Hello", Some("javascript"), &Chapter::default()),
);
}
#[test]
fn markdown_without_default_with_language() {
assert_eq!(
(
"<code class=\"hljs language-javascript\">Hello</code>".to_string(),
true
),
parse_inline_code("[javascript] Hello", None, &Chapter::default()),
)
}
#[test]
fn markdown_with_default_with_language() {
assert_eq!(
(
"<code class=\"hljs language-javascript\">Hello</code>".to_string(),
true
),
parse_inline_code("[javascript] Hello", Some("python"), &Chapter::default()),
)
}
}