]> Untitled Git - lemmy.git/blob - crates/utils/src/utils/markdown/spoiler_rule.rs
Cache & Optimize Woodpecker CI (#3450)
[lemmy.git] / crates / utils / src / utils / markdown / spoiler_rule.rs
1 // Custom Markdown plugin to manage spoilers.
2 //
3 // Matches the capability described in Lemmy UI:
4 // https://github.com/LemmyNet/lemmy-ui/blob/main/src/shared/utils.ts#L159
5 // that is based off of:
6 // https://github.com/markdown-it/markdown-it-container/tree/master#example
7 //
8 // FORMAT:
9 // Input Markdown: ::: spoiler VISIBLE_TEXT\nHIDDEN_SPOILER\n:::\n
10 // Output HTML: <details><summary>VISIBLE_TEXT</summary><p>nHIDDEN_SPOILER</p></details>
11 //
12 // Anatomy of a spoiler:
13 //     keyword
14 //        ^
15 // ::: spoiler VISIBLE_HINT
16 //  ^                ^
17 // begin fence   visible text
18 //
19 // HIDDEN_SPOILER
20 //      ^
21 //  hidden text
22 //
23 // :::
24 //  ^
25 // end fence
26
27 use markdown_it::{
28   parser::{
29     block::{BlockRule, BlockState},
30     inline::InlineRoot,
31   },
32   MarkdownIt,
33   Node,
34   NodeValue,
35   Renderer,
36 };
37 use once_cell::sync::Lazy;
38 use regex::Regex;
39
40 #[derive(Debug)]
41 struct SpoilerBlock {
42   visible_text: String,
43 }
44
45 const SPOILER_PREFIX: &str = "::: spoiler ";
46 const SPOILER_SUFFIX: &str = ":::";
47 const SPOILER_SUFFIX_NEWLINE: &str = ":::\n";
48
49 static SPOILER_REGEX: Lazy<Regex> =
50   Lazy::new(|| Regex::new(r"^::: spoiler .*$").expect("compile spoiler markdown regex."));
51
52 impl NodeValue for SpoilerBlock {
53   // Formats any node marked as a 'SpoilerBlock' into HTML.
54   // See the SpoilerBlockScanner#run implementation to see how these nodes get added to the tree.
55   fn render(&self, node: &Node, fmt: &mut dyn Renderer) {
56     fmt.cr();
57     fmt.open("details", &node.attrs);
58     fmt.open("summary", &[]);
59     // Not allowing special styling to the visible text to keep it simple.
60     // If allowed, would need to parse the child nodes to assign to visible vs hidden text sections.
61     fmt.text(&self.visible_text);
62     fmt.close("summary");
63     fmt.open("p", &[]);
64     fmt.contents(&node.children);
65     fmt.close("p");
66     fmt.close("details");
67     fmt.cr();
68   }
69 }
70
71 struct SpoilerBlockScanner;
72
73 impl BlockRule for SpoilerBlockScanner {
74   // Invoked on every line in the provided Markdown text to check if the BlockRule applies.
75   //
76   // NOTE: This does NOT support nested spoilers at this time.
77   fn run(state: &mut BlockState) -> Option<(Node, usize)> {
78     let first_line: &str = state.get_line(state.line).trim();
79
80     // 1. Check if the first line contains the spoiler syntax...
81     if !SPOILER_REGEX.is_match(first_line) {
82       return None;
83     }
84
85     let begin_spoiler_line_idx: usize = state.line + 1;
86     let mut end_fence_line_idx: usize = begin_spoiler_line_idx;
87     let mut has_end_fence: bool = false;
88
89     // 2. Search for the end of the spoiler and find the index of the last line of the spoiler.
90     // There could potentially be multiple lines between the beginning and end of the block.
91     //
92     // Block ends with a line with ':::' or ':::\n'; it must be isolated from other markdown.
93     while end_fence_line_idx < state.line_max && !has_end_fence {
94       let next_line: &str = state.get_line(end_fence_line_idx).trim();
95
96       if next_line.eq(SPOILER_SUFFIX) || next_line.eq(SPOILER_SUFFIX_NEWLINE) {
97         has_end_fence = true;
98         break;
99       }
100
101       end_fence_line_idx += 1;
102     }
103
104     // 3. If available, construct and return the spoiler node to add to the tree.
105     if has_end_fence {
106       let (spoiler_content, mapping) = state.get_lines(
107         begin_spoiler_line_idx,
108         end_fence_line_idx,
109         state.blk_indent,
110         true,
111       );
112
113       let mut node = Node::new(SpoilerBlock {
114         visible_text: String::from(first_line.replace(SPOILER_PREFIX, "").trim()),
115       });
116
117       // Add the spoiler content as children; marking as a child tells the tree to process the
118       // node again, which means other Markdown syntax (ex: emphasis, links) can be rendered.
119       node
120         .children
121         .push(Node::new(InlineRoot::new(spoiler_content, mapping)));
122
123       // NOTE: Not using begin_spoiler_line_idx here because of incorrect results when
124       //       state.line == 0 (subtracts an idx) vs the expected correct result (adds an idx).
125       Some((node, end_fence_line_idx - state.line + 1))
126     } else {
127       None
128     }
129   }
130 }
131
132 pub fn add(markdown_parser: &mut MarkdownIt) {
133   markdown_parser.block.add_rule::<SpoilerBlockScanner>();
134 }
135
136 #[cfg(test)]
137 mod tests {
138   #![allow(clippy::unwrap_used)]
139   #![allow(clippy::indexing_slicing)]
140
141   use crate::utils::markdown::spoiler_rule::add;
142   use markdown_it::MarkdownIt;
143
144   #[test]
145   fn test_spoiler_markdown() {
146     let tests: Vec<_> = vec![
147       (
148         "invalid spoiler",
149         "::: spoiler click to see more\nbut I never finished",
150         "<p>::: spoiler click to see more\nbut I never finished</p>\n",
151       ),
152       (
153         "another invalid spoiler",
154         "::: spoiler\nnever added the lead in\n:::",
155         "<p>::: spoiler\nnever added the lead in\n:::</p>\n",
156       ),
157       (
158         "basic spoiler, but no newline at the end",
159         "::: spoiler click to see more\nhow spicy!\n:::",
160         "<details><summary>click to see more</summary><p>how spicy!\n</p></details>\n"
161       ),
162       (
163         "basic spoiler with a newline at the end",
164         "::: spoiler click to see more\nhow spicy!\n:::\n",
165         "<details><summary>click to see more</summary><p>how spicy!\n</p></details>\n"
166       ),
167       (
168         "spoiler with extra markdown on the call to action (no extra parsing)",
169         "::: spoiler _click to see more_\nhow spicy!\n:::\n",
170         "<details><summary>_click to see more_</summary><p>how spicy!\n</p></details>\n"
171       ),
172       (
173         "spoiler with extra markdown in the fenced spoiler block",
174         "::: spoiler click to see more\n**how spicy!**\n*i have many lines*\n:::\n",
175         "<details><summary>click to see more</summary><p><strong>how spicy!</strong>\n<em>i have many lines</em>\n</p></details>\n"
176       ),
177       (
178         "spoiler mixed with other content",
179         "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?",
180         "<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"
181       ),
182       (
183         "spoiler mixed with indented content",
184         "- did you know that\n::: spoiler the call was\n***coming from inside the house!***\n:::\n - crazy, right?",
185         "<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"
186       )
187     ];
188
189     tests.iter().for_each(|&(msg, input, expected)| {
190       let md = &mut MarkdownIt::new();
191       markdown_it::plugins::cmark::add(md);
192       add(md);
193
194       assert_eq!(
195         md.parse(input).xrender(),
196         expected,
197         "Testing {}, with original input '{}'",
198         msg,
199         input
200       );
201     });
202   }
203 }