use markdown_it::{
parser::{
block::{BlockRule, BlockState},
inline::InlineRoot,
},
MarkdownIt,
Node,
NodeValue,
Renderer,
};
use regex::Regex;
use std::sync::LazyLock;
#[derive(Debug)]
struct SpoilerBlock {
visible_text: String,
}
const SPOILER_PREFIX: &str = "::: spoiler ";
const SPOILER_SUFFIX: &str = ":::";
const SPOILER_SUFFIX_NEWLINE: &str = ":::\n";
static SPOILER_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^::: spoiler .*$").expect("compile spoiler markdown regex."));
impl NodeValue for SpoilerBlock {
fn render(&self, node: &Node, fmt: &mut dyn Renderer) {
fmt.cr();
fmt.open("details", &node.attrs);
fmt.open("summary", &[]);
fmt.text(&self.visible_text);
fmt.close("summary");
fmt.open("p", &[]);
fmt.contents(&node.children);
fmt.close("p");
fmt.close("details");
fmt.cr();
}
}
struct SpoilerBlockScanner;
impl BlockRule for SpoilerBlockScanner {
fn run(state: &mut BlockState) -> Option<(Node, usize)> {
let first_line: &str = state.get_line(state.line).trim();
if !SPOILER_REGEX.is_match(first_line) {
return None;
}
let begin_spoiler_line_idx: usize = state.line + 1;
let mut end_fence_line_idx: usize = begin_spoiler_line_idx;
let mut has_end_fence: bool = false;
while end_fence_line_idx < state.line_max && !has_end_fence {
let next_line: &str = state.get_line(end_fence_line_idx).trim();
if next_line.eq(SPOILER_SUFFIX) || next_line.eq(SPOILER_SUFFIX_NEWLINE) {
has_end_fence = true;
break;
}
end_fence_line_idx += 1;
}
if has_end_fence {
let (spoiler_content, mapping) = state.get_lines(
begin_spoiler_line_idx,
end_fence_line_idx,
state.blk_indent,
true,
);
let mut node = Node::new(SpoilerBlock {
visible_text: String::from(first_line.replace(SPOILER_PREFIX, "").trim()),
});
node
.children
.push(Node::new(InlineRoot::new(spoiler_content, mapping)));
Some((node, end_fence_line_idx - state.line + 1))
} else {
None
}
}
}
pub fn add(markdown_parser: &mut MarkdownIt) {
markdown_parser.block.add_rule::<SpoilerBlockScanner>();
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
#[allow(clippy::indexing_slicing)]
mod tests {
use crate::utils::markdown::spoiler_rule::add;
use markdown_it::MarkdownIt;
use pretty_assertions::assert_eq;
#[test]
fn test_spoiler_markdown() {
let tests: Vec<_> = vec![
(
"invalid spoiler",
"::: spoiler click to see more\nbut I never finished",
"<p>::: spoiler click to see more\nbut I never finished</p>\n",
),
(
"another invalid spoiler",
"::: spoiler\nnever added the lead in\n:::",
"<p>::: spoiler\nnever added the lead in\n:::</p>\n",
),
(
"basic spoiler, but no newline at the end",
"::: spoiler click to see more\nhow spicy!\n:::",
"<details><summary>click to see more</summary><p>how spicy!\n</p></details>\n"
),
(
"basic spoiler with a newline at the end",
"::: spoiler click to see more\nhow spicy!\n:::\n",
"<details><summary>click to see more</summary><p>how spicy!\n</p></details>\n"
),
(
"spoiler with extra markdown on the call to action (no extra parsing)",
"::: spoiler _click to see more_\nhow spicy!\n:::\n",
"<details><summary>_click to see more_</summary><p>how spicy!\n</p></details>\n"
),
(
"spoiler with extra markdown in the fenced spoiler block",
"::: spoiler click to see more\n**how spicy!**\n*i have many lines*\n:::\n",
"<details><summary>click to see more</summary><p><strong>how spicy!</strong>\n<em>i have many lines</em>\n</p></details>\n"
),
(
"spoiler mixed with other content",
"hey you\npsst, wanna hear a secret?\n::: spoiler lean in and i'll tell you\n**you are breathtaking!**\n:::\nwhatcha think about that?",
"<p>hey you\npsst, wanna hear a secret?</p>\n<details><summary>lean in and i'll tell you</summary><p><strong>you are breathtaking!</strong>\n</p></details>\n<p>whatcha think about that?</p>\n"
),
(
"spoiler mixed with indented content",
"- did you know that\n::: spoiler the call was\n***coming from inside the house!***\n:::\n - crazy, right?",
"<ul>\n<li>did you know that</li>\n</ul>\n<details><summary>the call was</summary><p><em><strong>coming from inside the house!</strong></em>\n</p></details>\n<ul>\n<li>crazy, right?</li>\n</ul>\n"
)
];
tests.iter().for_each(|&(msg, input, expected)| {
let md = &mut MarkdownIt::new();
markdown_it::plugins::cmark::add(md);
add(md);
assert_eq!(
md.parse(input).xrender(),
expected,
"Testing {}, with original input '{}'",
msg,
input
);
});
}
}