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.

546 lines
22KB

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