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.

593 lines
24KB

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