diff --git a/fuzz/fuzz_targets/all_options.rs b/fuzz/fuzz_targets/all_options.rs index 131c932a..2af39c81 100644 --- a/fuzz/fuzz_targets/all_options.rs +++ b/fuzz/fuzz_targets/all_options.rs @@ -57,6 +57,7 @@ fuzz_target!(|s: &str| { render.ignore_empty_links = true; render.gfm_quirks = true; render.prefer_fenced = true; + render.tasklist_classes = true; markdown_to_html( s, diff --git a/src/html.rs b/src/html.rs index e8ac2303..421458f5 100644 --- a/src/html.rs +++ b/src/html.rs @@ -478,18 +478,27 @@ impl<'o, 'c: 'o> HtmlFormatter<'o, 'c> { NodeValue::List(ref nl) => { if entering { self.cr()?; - if nl.list_type == ListType::Bullet { - self.output.write_all(b"\n")?; - } else if nl.start == 1 { - self.output.write_all(b"\n")?; - } else { - self.output.write_all(b"", nl.start)?; + match nl.list_type { + ListType::Bullet => { + self.output.write_all(b"\n")?; + } + ListType::Ordered => { + self.output.write_all(b"\n")?; + } else { + writeln!(self.output, " start=\"{}\">", nl.start)?; + } + } } } else if nl.list_type == ListType::Bullet { self.output.write_all(b"\n")?; @@ -1043,17 +1052,20 @@ impl<'o, 'c: 'o> HtmlFormatter<'o, 'c> { if entering { self.cr()?; self.output.write_all(b"")?; - write!( - self.output, - " ", - if symbol.is_some() { - "checked=\"\" " - } else { - "" - } - )?; + self.output.write_all(b" ")?; } else { self.output.write_all(b"\n")?; } diff --git a/src/main.rs b/src/main.rs index 41e1a3af..faa98190 100644 --- a/src/main.rs +++ b/src/main.rs @@ -76,6 +76,10 @@ struct Cli { #[arg(long)] relaxed_autolinks: bool, + /// Output classes on tasklist elements so that they can be styled with CSS + #[arg(long)] + tasklist_classes: bool, + /// Default value for fenced code block's info strings if none is given #[arg(long, value_name = "INFO")] default_info_string: Option, @@ -296,6 +300,7 @@ fn main() -> Result<(), Box> { .ignore_setext(cli.ignore_setext) .ignore_empty_links(cli.ignore_empty_links) .gfm_quirks(cli.gfm_quirks || cli.gfm) + .tasklist_classes(cli.tasklist_classes) .build()?; let options = Options { diff --git a/src/nodes.rs b/src/nodes.rs index 95b58a59..2051badd 100644 --- a/src/nodes.rs +++ b/src/nodes.rs @@ -297,6 +297,9 @@ pub struct NodeList { /// Whether the list is [tight](https://github.github.com/gfm/#tight), i.e. whether the /// paragraphs are wrapped in `

` tags when formatted as HTML. pub tight: bool, + + /// Whether the list contains tasks (checkbox items) + pub is_task_list: bool, } /// The metadata of a description list diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 0b8f3171..fb9d4652 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -892,6 +892,23 @@ pub struct RenderOptions { /// "

\"image\"
this is an image

\n"); /// ``` pub figure_with_caption: bool, + + /// Add classes to the output of the tasklist extension. This allows tasklists to be styled. + /// + /// ```rust + /// # use comrak::{markdown_to_html, Options}; + /// let mut options = Options::default(); + /// options.extension.tasklist = true; + /// let input = "- [ ] Foo"; + /// + /// assert_eq!(markdown_to_html(input, &options), + /// "
    \n
  • Foo
  • \n
\n"); + /// + /// options.render.tasklist_classes = true; + /// assert_eq!(markdown_to_html(input, &options), + /// "
    \n
  • Foo
  • \n
\n"); + /// ``` + pub tasklist_classes: bool, } #[non_exhaustive] @@ -2436,7 +2453,13 @@ impl<'a, 'o, 'c: 'o> Parser<'a, 'o, 'c> { return; } - if !node_matches!(parent.parent().unwrap(), NodeValue::Item(..)) { + let grandparent = parent.parent().unwrap(); + if !node_matches!(grandparent, NodeValue::Item(..)) { + return; + } + + let great_grandparent = grandparent.parent().unwrap(); + if !node_matches!(great_grandparent, NodeValue::List(..)) { return; } @@ -2448,8 +2471,12 @@ impl<'a, 'o, 'c: 'o> Parser<'a, 'o, 'c> { sourcepos.start.column += end; parent.data.borrow_mut().sourcepos.start.column += end; - parent.parent().unwrap().data.borrow_mut().value = + grandparent.data.borrow_mut().value = NodeValue::TaskItem(if symbol == ' ' { None } else { Some(symbol) }); + + if let NodeValue::List(ref mut list) = &mut great_grandparent.data.borrow_mut().value { + list.is_task_list = true; + } } fn parse_reference_inline(&mut self, content: &[u8]) -> Option { @@ -2565,6 +2592,7 @@ fn parse_list_marker( delimiter: ListDelimType::Period, bullet_char: c, tight: false, + is_task_list: false, }, )); } else if isdigit(c) { @@ -2620,6 +2648,7 @@ fn parse_list_marker( }, bullet_char: 0, tight: false, + is_task_list: false, }, )); } diff --git a/src/tests/fuzz.rs b/src/tests/fuzz.rs index 21223729..a9ba5b30 100644 --- a/src/tests/fuzz.rs +++ b/src/tests/fuzz.rs @@ -14,6 +14,15 @@ fn tasklist() { ); } +#[test] +fn tasklist_with_classes() { + html_opts!( + [extension.tasklist, render.tasklist_classes, parse.relaxed_tasklist_matching], + "* [*]", + "
    \n
  • \n
\n", + ); +} + #[test] fn table_nul() { html_opts!( diff --git a/src/tests/tasklist.rs b/src/tests/tasklist.rs index dc148519..1404722f 100644 --- a/src/tests/tasklist.rs +++ b/src/tests/tasklist.rs @@ -51,6 +51,58 @@ fn tasklist() { ); } +#[test] +fn tasklist_with_classes() { + html_opts!( + [ + render.unsafe_, + extension.tasklist, + render.tasklist_classes, + parse.relaxed_tasklist_matching + ], + concat!( + "* [ ] Red\n", + "* [x] Green\n", + "* [ ] Blue\n", + "* [!] Papayawhip\n", + "\n", + "1. [ ] Bird\n", + "2. [ ] McHale\n", + "3. [x] Parish\n", + "\n", + "* [ ] Red\n", + " * [x] Green\n", + " * [ ] Blue\n" + ), + concat!( + "
    \n", + "
  • Red
  • \n", + "
  • Green
  • \n", + "
  • Blue
  • \n", + "
  • Papayawhip
  • \n", + "
\n", + "\n", + "
    \n", + "
  1. Bird
  2. \n", + "
  3. McHale
  4. \n", + "
  5. Parish
  6. \n", + "
\n", + "\n", + "
    \n", + "
  • Red\n", + "
      \n", + "
    • Green\n", + "
        \n", + "
      • Blue
      • \n", + "
      \n", + "
    • \n", + "
    \n", + "
  • \n", + "
\n" + ), + ); +} + #[test] fn tasklist_relaxed_regression() { html_opts!( @@ -80,6 +132,35 @@ fn tasklist_relaxed_regression() { ); } +#[test] +fn tasklist_with_classes_relaxed_regression() { + html_opts!( + [extension.tasklist, render.tasklist_classes, parse.relaxed_tasklist_matching], + "* [!] Red\n", + concat!( + "
    \n", + "
  • Red
  • \n", + "
\n" + ), + ); + + html_opts!( + [extension.tasklist, render.tasklist_classes], + "* [!] Red\n", + concat!("
    \n", "
  • [!] Red
  • \n", "
\n"), + ); + + html_opts!( + [extension.tasklist, render.tasklist_classes, parse.relaxed_tasklist_matching], + "* [!] Red\n", + concat!( + "
    \n", + "
  • Red
  • \n", + "
\n" + ), + ); +} + #[test] fn tasklist_32() { html_opts!( @@ -99,6 +180,25 @@ fn tasklist_32() { ); } +#[test] +fn tasklist_32_with_classes() { + html_opts!( + [render.unsafe_, extension.tasklist, render.tasklist_classes], + concat!( + "- [ ] List item 1\n", + "- [ ] This list item is **bold**\n", + "- [x] There is some `code` here\n" + ), + concat!( + "
    \n", + "
  • List item 1
  • \n", + "
  • This list item is bold
  • \n", + "
  • There is some code here
  • \n", + "
\n" + ), + ); +} + #[test] fn sourcepos() { assert_ast_match!( diff --git a/src/xml.rs b/src/xml.rs index cfc4603d..1afa5cf8 100644 --- a/src/xml.rs +++ b/src/xml.rs @@ -161,15 +161,21 @@ impl<'o, 'c> XmlFormatter<'o, 'c> { was_literal = true; } NodeValue::List(ref nl) => { - if nl.list_type == ListType::Bullet { - self.output.write_all(b" type=\"bullet\"")?; - } else { - write!( - self.output, - " type=\"ordered\" start=\"{}\" delim=\"{}\"", - nl.start, - nl.delimiter.xml_name() - )?; + match nl.list_type { + ListType::Bullet => { + self.output.write_all(b" type=\"bullet\"")?; + } + ListType::Ordered => { + write!( + self.output, + " type=\"ordered\" start=\"{}\" delim=\"{}\"", + nl.start, + nl.delimiter.xml_name() + )?; + } + } + if nl.is_task_list { + self.output.write_all(b" tasklist=\"true\"")?; } write!(self.output, " tight=\"{}\"", nl.tight)?; }