You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

420 lines
16KB

  1. use std::borrow::Cow::Owned;
  2. use std::collections::HashMap;
  3. use pulldown_cmark as cmark;
  4. use self::cmark::{Parser, Event, Tag};
  5. use regex::Regex;
  6. use syntect::dumps::from_binary;
  7. use syntect::easy::HighlightLines;
  8. use syntect::parsing::SyntaxSet;
  9. use syntect::highlighting::ThemeSet;
  10. use syntect::html::{start_coloured_html_snippet, styles_to_coloured_html, IncludeBackground};
  11. use tera::{Tera, Context};
  12. use config::Config;
  13. use errors::{Result, ResultExt};
  14. // We need to put those in a struct to impl Send and sync
  15. pub struct Setup {
  16. syntax_set: SyntaxSet,
  17. pub theme_set: ThemeSet,
  18. }
  19. unsafe impl Send for Setup {}
  20. unsafe impl Sync for Setup {}
  21. lazy_static!{
  22. static ref SHORTCODE_RE: Regex = Regex::new(r#"\{(?:%|\{)\s+([[:alnum:]]+?)\(([[:alnum:]]+?="?.+?"?)\)\s+(?:%|\})\}"#).unwrap();
  23. pub static ref SETUP: Setup = Setup {
  24. syntax_set: SyntaxSet::load_defaults_newlines(),
  25. theme_set: from_binary(include_bytes!("../sublime_themes/all.themedump"))
  26. };
  27. }
  28. /// A ShortCode that has a body
  29. /// Called by having some content like {% ... %} body {% end %}
  30. /// We need the struct to hold the data while we're processing the markdown
  31. #[derive(Debug)]
  32. struct ShortCode {
  33. name: String,
  34. args: HashMap<String, String>,
  35. body: String,
  36. }
  37. impl ShortCode {
  38. pub fn new(name: &str, args: HashMap<String, String>) -> ShortCode {
  39. ShortCode {
  40. name: name.to_string(),
  41. args: args,
  42. body: String::new(),
  43. }
  44. }
  45. pub fn append(&mut self, text: &str) {
  46. self.body.push_str(text)
  47. }
  48. pub fn render(&self, tera: &Tera) -> Result<String> {
  49. let mut context = Context::new();
  50. for (key, value) in self.args.iter() {
  51. context.add(key, value);
  52. }
  53. context.add("body", &self.body);
  54. let tpl_name = format!("shortcodes/{}.html", self.name);
  55. tera.render(&tpl_name, &context)
  56. .chain_err(|| format!("Failed to render {} shortcode", self.name))
  57. }
  58. }
  59. /// Parse a shortcode without a body
  60. fn parse_shortcode(input: &str) -> (String, HashMap<String, String>) {
  61. let mut args = HashMap::new();
  62. let caps = SHORTCODE_RE.captures(input).unwrap();
  63. // caps[0] is the full match
  64. let name = &caps[1];
  65. let arg_list = &caps[2];
  66. for arg in arg_list.split(',') {
  67. let bits = arg.split('=').collect::<Vec<_>>();
  68. args.insert(bits[0].trim().to_string(), bits[1].replace("\"", ""));
  69. }
  70. (name.to_string(), args)
  71. }
  72. /// Renders a shortcode or return an error
  73. fn render_simple_shortcode(tera: &Tera, name: &str, args: &HashMap<String, String>) -> Result<String> {
  74. let mut context = Context::new();
  75. for (key, value) in args.iter() {
  76. context.add(key, value);
  77. }
  78. let tpl_name = format!("shortcodes/{}.html", name);
  79. tera.render(&tpl_name, &context).chain_err(|| format!("Failed to render {} shortcode", name))
  80. }
  81. pub fn markdown_to_html(content: &str, permalinks: &HashMap<String, String>, tera: &Tera, config: &Config) -> Result<String> {
  82. // We try to be smart about highlighting code as it can be time-consuming
  83. // If the global config disables it, then we do nothing. However,
  84. // if we see a code block in the content, we assume that this page needs
  85. // to be highlighted. It could potentially have false positive if the content
  86. // has ``` in it but that seems kind of unlikely
  87. let should_highlight = if config.highlight_code.unwrap() {
  88. content.contains("```")
  89. } else {
  90. false
  91. };
  92. let highlight_theme = config.highlight_theme.clone().unwrap();
  93. // Set while parsing
  94. let mut error = None;
  95. let mut highlighter: Option<HighlightLines> = None;
  96. let mut shortcode_block = None;
  97. // shortcodes live outside of paragraph so we need to ensure we don't close
  98. // a paragraph that has already been closed
  99. let mut added_shortcode = false;
  100. // Don't transform things that look like shortcodes in code blocks
  101. let mut in_code_block = false;
  102. // the rendered html
  103. let mut html = String::new();
  104. {
  105. let parser = Parser::new(content).map(|event| match event {
  106. Event::Text(text) => {
  107. // if we are in the middle of a code block
  108. if let Some(ref mut highlighter) = highlighter {
  109. let highlighted = &highlighter.highlight(&text);
  110. let html = styles_to_coloured_html(highlighted, IncludeBackground::Yes);
  111. return Event::Html(Owned(html));
  112. }
  113. if in_code_block {
  114. return Event::Text(text);
  115. }
  116. // Shortcode without body
  117. if shortcode_block.is_none() && text.starts_with("{{") && text.ends_with("}}") {
  118. if SHORTCODE_RE.is_match(&text) {
  119. let (name, args) = parse_shortcode(&text);
  120. added_shortcode = true;
  121. match render_simple_shortcode(tera, &name, &args) {
  122. Ok(s) => return Event::Html(Owned(format!("</p>{}", s))),
  123. Err(e) => {
  124. error = Some(e);
  125. return Event::Html(Owned("".to_string()));
  126. }
  127. }
  128. }
  129. // non-matching will be returned normally below
  130. }
  131. // Shortcode with a body
  132. if shortcode_block.is_none() && text.starts_with("{%") && text.ends_with("%}") {
  133. if SHORTCODE_RE.is_match(&text) {
  134. let (name, args) = parse_shortcode(&text);
  135. shortcode_block = Some(ShortCode::new(&name, args));
  136. }
  137. // Don't return anything
  138. return Event::Text(Owned("".to_string()));
  139. }
  140. // If we have some text while in a shortcode, it's either the body
  141. // or the end tag
  142. if shortcode_block.is_some() {
  143. if let Some(ref mut shortcode) = shortcode_block {
  144. if text.trim() == "{% end %}" {
  145. added_shortcode = true;
  146. match shortcode.render(tera) {
  147. Ok(s) => return Event::Html(Owned(format!("</p>{}", s))),
  148. Err(e) => {
  149. error = Some(e);
  150. return Event::Html(Owned("".to_string()));
  151. }
  152. }
  153. } else {
  154. shortcode.append(&text);
  155. return Event::Html(Owned("".to_string()));
  156. }
  157. }
  158. }
  159. // Business as usual
  160. Event::Text(text)
  161. },
  162. Event::Start(Tag::CodeBlock(ref info)) => {
  163. in_code_block = true;
  164. if !should_highlight {
  165. return Event::Html(Owned("<pre><code>".to_owned()));
  166. }
  167. let theme = &SETUP.theme_set.themes[&highlight_theme];
  168. let syntax = info
  169. .split(' ')
  170. .next()
  171. .and_then(|lang| SETUP.syntax_set.find_syntax_by_token(lang))
  172. .unwrap_or_else(|| SETUP.syntax_set.find_syntax_plain_text());
  173. highlighter = Some(HighlightLines::new(syntax, theme));
  174. let snippet = start_coloured_html_snippet(theme);
  175. Event::Html(Owned(snippet))
  176. },
  177. Event::End(Tag::CodeBlock(_)) => {
  178. in_code_block = false;
  179. if !should_highlight{
  180. return Event::Html(Owned("</code></pre>\n".to_owned()))
  181. }
  182. // reset highlight and close the code block
  183. highlighter = None;
  184. Event::Html(Owned("</pre>".to_owned()))
  185. },
  186. // Need to handle relative links
  187. Event::Start(Tag::Link(ref link, ref title)) => {
  188. if link.starts_with("./") {
  189. let permalink = match permalinks.get(&link.replacen("./", "", 1)) {
  190. Some(p) => p,
  191. None => {
  192. error = Some(format!("Relative link {} not found.", link).into());
  193. return Event::Html(Owned("".to_string()));
  194. }
  195. };
  196. return Event::Start(Tag::Link(Owned(permalink.clone()), title.clone()));
  197. }
  198. return Event::Start(Tag::Link(link.clone(), title.clone()));
  199. },
  200. // need to know when we are in a code block to disable shortcodes in them
  201. Event::Start(Tag::Code) => {
  202. in_code_block = true;
  203. event
  204. },
  205. Event::End(Tag::Code) => {
  206. in_code_block = false;
  207. event
  208. },
  209. // If we added shortcodes, don't close a paragraph since there's none
  210. Event::End(Tag::Paragraph) => {
  211. if added_shortcode {
  212. added_shortcode = false;
  213. return Event::Html(Owned("".to_owned()));
  214. }
  215. event
  216. },
  217. // Ignore softbreaks inside shortcodes
  218. Event::SoftBreak => {
  219. if shortcode_block.is_some() {
  220. return Event::Html(Owned("".to_owned()));
  221. }
  222. event
  223. },
  224. _ => {
  225. // println!("event = {:?}", event);
  226. event
  227. },
  228. });
  229. cmark::html::push_html(&mut html, parser);
  230. }
  231. match error {
  232. Some(e) => Err(e),
  233. None => Ok(html.replace("<p></p>", "")),
  234. }
  235. }
  236. #[cfg(test)]
  237. mod tests {
  238. use std::collections::HashMap;
  239. use site::GUTENBERG_TERA;
  240. use tera::Tera;
  241. use config::Config;
  242. use super::{markdown_to_html, parse_shortcode};
  243. #[test]
  244. fn test_parse_simple_shortcode_one_arg() {
  245. let (name, args) = parse_shortcode(r#"{{ youtube(id="w7Ft2ymGmfc") }}"#);
  246. assert_eq!(name, "youtube");
  247. assert_eq!(args["id"], "w7Ft2ymGmfc");
  248. }
  249. #[test]
  250. fn test_parse_simple_shortcode_several_arg() {
  251. let (name, args) = parse_shortcode(r#"{{ youtube(id="w7Ft2ymGmfc", autoplay=true) }}"#);
  252. assert_eq!(name, "youtube");
  253. assert_eq!(args["id"], "w7Ft2ymGmfc");
  254. assert_eq!(args["autoplay"], "true");
  255. }
  256. #[test]
  257. fn test_parse_block_shortcode_several_arg() {
  258. let (name, args) = parse_shortcode(r#"{% youtube(id="w7Ft2ymGmfc", autoplay=true) %}"#);
  259. assert_eq!(name, "youtube");
  260. assert_eq!(args["id"], "w7Ft2ymGmfc");
  261. assert_eq!(args["autoplay"], "true");
  262. }
  263. #[test]
  264. fn test_markdown_to_html_simple() {
  265. let res = markdown_to_html("# hello", &HashMap::new(), &Tera::default(), &Config::default()).unwrap();
  266. assert_eq!(res, "<h1>hello</h1>\n");
  267. }
  268. #[test]
  269. fn test_markdown_to_html_code_block_highlighting_off() {
  270. let mut config = Config::default();
  271. config.highlight_code = Some(false);
  272. let res = markdown_to_html("```\n$ gutenberg server\n```", &HashMap::new(), &Tera::default(), &config).unwrap();
  273. assert_eq!(
  274. res,
  275. "<pre><code>$ gutenberg server\n</code></pre>\n"
  276. );
  277. }
  278. #[test]
  279. fn test_markdown_to_html_code_block_no_lang() {
  280. let res = markdown_to_html("```\n$ gutenberg server\n$ ping\n```", &HashMap::new(), &Tera::default(), &Config::default()).unwrap();
  281. assert_eq!(
  282. res,
  283. "<pre style=\"background-color:#2b303b\">\n<span style=\"background-color:#2b303b;color:#c0c5ce;\">$ gutenberg server\n</span><span style=\"background-color:#2b303b;color:#c0c5ce;\">$ ping\n</span></pre>"
  284. );
  285. }
  286. #[test]
  287. fn test_markdown_to_html_code_block_with_lang() {
  288. let res = markdown_to_html("```python\nlist.append(1)\n```", &HashMap::new(), &Tera::default(), &Config::default()).unwrap();
  289. assert_eq!(
  290. res,
  291. "<pre style=\"background-color:#2b303b\">\n<span style=\"background-color:#2b303b;color:#c0c5ce;\">list</span><span style=\"background-color:#2b303b;color:#c0c5ce;\">.</span><span style=\"background-color:#2b303b;color:#bf616a;\">append</span><span style=\"background-color:#2b303b;color:#c0c5ce;\">(</span><span style=\"background-color:#2b303b;color:#d08770;\">1</span><span style=\"background-color:#2b303b;color:#c0c5ce;\">)</span><span style=\"background-color:#2b303b;color:#c0c5ce;\">\n</span></pre>"
  292. );
  293. }
  294. #[test]
  295. fn test_markdown_to_html_code_block_with_unknown_lang() {
  296. let res = markdown_to_html("```yolo\nlist.append(1)\n```", &HashMap::new(), &Tera::default(), &Config::default()).unwrap();
  297. // defaults to plain text
  298. assert_eq!(
  299. res,
  300. "<pre style=\"background-color:#2b303b\">\n<span style=\"background-color:#2b303b;color:#c0c5ce;\">list.append(1)\n</span></pre>"
  301. );
  302. }
  303. #[test]
  304. fn test_markdown_to_html_with_shortcode() {
  305. let res = markdown_to_html(r#"
  306. Hello
  307. {{ youtube(id="ub36ffWAqgQ") }}
  308. "#, &HashMap::new(), &GUTENBERG_TERA, &Config::default()).unwrap();
  309. assert!(res.contains("<p>Hello</p>\n<div >"));
  310. assert!(res.contains(r#"<iframe src="https://www.youtube.com/embed/ub36ffWAqgQ""#));
  311. }
  312. #[test]
  313. fn test_markdown_to_html_with_several_shortcode_in_row() {
  314. let res = markdown_to_html(r#"
  315. Hello
  316. {{ youtube(id="ub36ffWAqgQ") }}
  317. {{ youtube(id="ub36ffWAqgQ", autoplay=true) }}
  318. {{ vimeo(id="210073083") }}
  319. {{ gist(url="https://gist.github.com/Keats/32d26f699dcc13ebd41b") }}
  320. "#, &HashMap::new(), &GUTENBERG_TERA, &Config::default()).unwrap();
  321. assert!(res.contains("<p>Hello</p>\n<div >"));
  322. assert!(res.contains(r#"<iframe src="https://www.youtube.com/embed/ub36ffWAqgQ""#));
  323. assert!(res.contains(r#"<iframe src="https://www.youtube.com/embed/ub36ffWAqgQ?autoplay=1""#));
  324. assert!(res.contains(r#"//player.vimeo.com/video/210073083""#));
  325. }
  326. #[test]
  327. fn test_markdown_to_html_shortcode_in_code_block() {
  328. let res = markdown_to_html(r#"```{{ youtube(id="w7Ft2ymGmfc") }}```"#, &HashMap::new(), &GUTENBERG_TERA, &Config::default()).unwrap();
  329. assert_eq!(res, "<p><code>{{ youtube(id=&quot;w7Ft2ymGmfc&quot;) }}</code></p>\n");
  330. }
  331. #[test]
  332. fn test_markdown_to_html_shortcode_with_body() {
  333. let mut tera = Tera::default();
  334. tera.extend(&GUTENBERG_TERA).unwrap();
  335. tera.add_raw_template("shortcodes/quote.html", "<blockquote>{{ body }} - {{ author}}</blockquote>").unwrap();
  336. let res = markdown_to_html(r#"
  337. Hello
  338. {% quote(author="Keats") %}
  339. A quote
  340. {% end %}
  341. "#, &HashMap::new(), &tera, &Config::default()).unwrap();
  342. assert_eq!(res, "<p>Hello\n</p><blockquote>A quote - Keats</blockquote>");
  343. }
  344. #[test]
  345. fn test_markdown_to_html_unknown_shortcode() {
  346. let res = markdown_to_html("{{ hello(flash=true) }}", &HashMap::new(), &Tera::default(), &Config::default());
  347. assert!(res.is_err());
  348. }
  349. #[test]
  350. fn test_markdown_to_html_relative_link_exists() {
  351. let mut permalinks = HashMap::new();
  352. permalinks.insert("pages/about.md".to_string(), "https://vincent.is/about".to_string());
  353. let res = markdown_to_html(
  354. r#"[rel link](./pages/about.md), [abs link](https://vincent.is/about)"#,
  355. &permalinks,
  356. &GUTENBERG_TERA,
  357. &Config::default()
  358. ).unwrap();
  359. assert!(
  360. res.contains(r#"<p><a href="https://vincent.is/about">rel link</a>, <a href="https://vincent.is/about">abs link</a></p>"#)
  361. );
  362. }
  363. #[test]
  364. fn test_markdown_to_html_relative_link_inexistant() {
  365. let res = markdown_to_html("[rel link](./pages/about.md)", &HashMap::new(), &Tera::default(), &Config::default());
  366. assert!(res.is_err());
  367. }
  368. }