// Custom Markdown plugin to manage spoilers. // // Matches the capability described in Lemmy UI: // https://github.com/LemmyNet/lemmy-ui/blob/main/src/shared/utils.ts#L159 // that is based off of: // https://github.com/markdown-it/markdown-it-container/tree/master#example // // FORMAT: // Input Markdown: ::: spoiler VISIBLE_TEXT\nHIDDEN_SPOILER\n:::\n // Output HTML:
VISIBLE_TEXT

nHIDDEN_SPOILER

// // Anatomy of a spoiler: // keyword // ^ // ::: spoiler VISIBLE_HINT // ^ ^ // begin fence visible text // // HIDDEN_SPOILER // ^ // hidden text // // ::: // ^ // end fence use markdown_it::{ parser::{ block::{BlockRule, BlockState}, inline::InlineRoot, }, MarkdownIt, Node, NodeValue, Renderer, }; use once_cell::sync::Lazy; use regex::Regex; #[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: Lazy = Lazy::new(|| Regex::new(r"^::: spoiler .*$").expect("compile spoiler markdown regex.")); impl NodeValue for SpoilerBlock { // Formats any node marked as a 'SpoilerBlock' into HTML. // See the SpoilerBlockScanner#run implementation to see how these nodes get added to the tree. fn render(&self, node: &Node, fmt: &mut dyn Renderer) { fmt.cr(); fmt.open("details", &node.attrs); fmt.open("summary", &[]); // Not allowing special styling to the visible text to keep it simple. // If allowed, would need to parse the child nodes to assign to visible vs hidden text sections. 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 { // Invoked on every line in the provided Markdown text to check if the BlockRule applies. // // NOTE: This does NOT support nested spoilers at this time. fn run(state: &mut BlockState) -> Option<(Node, usize)> { let first_line: &str = state.get_line(state.line).trim(); // 1. Check if the first line contains the spoiler syntax... 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; // 2. Search for the end of the spoiler and find the index of the last line of the spoiler. // There could potentially be multiple lines between the beginning and end of the block. // // Block ends with a line with ':::' or ':::\n'; it must be isolated from other markdown. 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; } // 3. If available, construct and return the spoiler node to add to the tree. 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()), }); // Add the spoiler content as children; marking as a child tells the tree to process the // node again, which means other Markdown syntax (ex: emphasis, links) can be rendered. node .children .push(Node::new(InlineRoot::new(spoiler_content, mapping))); // NOTE: Not using begin_spoiler_line_idx here because of incorrect results when // state.line == 0 (subtracts an idx) vs the expected correct result (adds an idx). Some((node, end_fence_line_idx - state.line + 1)) } else { None } } } pub fn add(markdown_parser: &mut MarkdownIt) { markdown_parser.block.add_rule::(); } #[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", "

::: spoiler click to see more\nbut I never finished

\n", ), ( "another invalid spoiler", "::: spoiler\nnever added the lead in\n:::", "

::: spoiler\nnever added the lead in\n:::

\n", ), ( "basic spoiler, but no newline at the end", "::: spoiler click to see more\nhow spicy!\n:::", "
click to see more

how spicy!\n

\n" ), ( "basic spoiler with a newline at the end", "::: spoiler click to see more\nhow spicy!\n:::\n", "
click to see more

how spicy!\n

\n" ), ( "spoiler with extra markdown on the call to action (no extra parsing)", "::: spoiler _click to see more_\nhow spicy!\n:::\n", "
_click to see more_

how spicy!\n

\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", "
click to see more

how spicy!\ni have many lines\n

\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?", "

hey you\npsst, wanna hear a secret?

\n
lean in and i'll tell you

you are breathtaking!\n

\n

whatcha think about that?

\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?", "\n
the call was

coming from inside the house!\n

\n\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 ); }); } }