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.

505 lines
19KB

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