]> Untitled Git - lemmy.git/commitdiff
Fixes #1884 - Support Spoiler Tags (#3018)
authorNina Blanson <nina.m.blanson@gmail.com>
Wed, 14 Jun 2023 11:15:59 +0000 (06:15 -0500)
committerGitHub <noreply@github.com>
Wed, 14 Jun 2023 11:15:59 +0000 (13:15 +0200)
* Fixes #1884 - Switches markdown libraries and creates a custom rule to manage spoiler blocks

* Add tests to cover invalid spoiler input

* Consolidate tests, add comments

* Make immutable, static instance of markdown parser

---------

Co-authored-by: Nutomic <me@nutomic.com>
Cargo.lock
crates/utils/Cargo.toml
crates/utils/src/utils/markdown.rs
crates/utils/src/utils/markdown/spoiler_rule.rs [new file with mode: 0644]

index e70471712d079f10807ec26a62ffd80365af634a..6bc9e014ad5162c0c5ba178c6e51e39bcd1199f5 100644 (file)
@@ -377,6 +377,12 @@ version = "1.0.71"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9c7d0618f0e0b7e8ff11427422b64564d5fb0be1940354bfe2e0529b18a9d9b8"
 
+[[package]]
+name = "argparse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f8ebf5827e4ac4fd5946560e6a99776ea73b596d80898f357007317a7141e47"
+
 [[package]]
 name = "arrayvec"
 version = "0.5.2"
@@ -650,6 +656,15 @@ dependencies = [
  "scoped-tls",
 ]
 
+[[package]]
+name = "bincode"
+version = "1.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
+dependencies = [
+ "serde",
+]
+
 [[package]]
 name = "bit-set"
 version = "0.5.3"
@@ -898,24 +913,6 @@ dependencies = [
  "memchr",
 ]
 
-[[package]]
-name = "comrak"
-version = "0.14.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "15bf1e432b302dc6236dd0db580d182ce520bb24af82d6462e2d7a5e0a31c50d"
-dependencies = [
- "entities",
- "lazy_static",
- "memchr",
- "pest",
- "pest_derive",
- "regex",
- "shell-words",
- "typed-arena 1.7.0",
- "unicode_categories",
- "xdg",
-]
-
 [[package]]
 name = "config"
 version = "0.13.3"
@@ -971,6 +968,26 @@ dependencies = [
  "tracing-subscriber",
 ]
 
+[[package]]
+name = "const_format"
+version = "0.2.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c990efc7a285731f9a4378d81aff2f0e85a2c8781a05ef0f8baa8dac54d0ff48"
+dependencies = [
+ "const_format_proc_macros",
+]
+
+[[package]]
+name = "const_format_proc_macros"
+version = "0.2.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e026b6ce194a874cb9cf32cd5772d1ef9767cc8fcb5765948d74f37a9d8b2bf6"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-xid",
+]
+
 [[package]]
 name = "constant_time_eq"
 version = "0.2.4"
@@ -1278,6 +1295,17 @@ dependencies = [
  "text_lines",
 ]
 
+[[package]]
+name = "derivative"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.103",
+]
+
 [[package]]
 name = "derive_builder"
 version = "0.10.2"
@@ -1477,26 +1505,6 @@ dependencies = [
  "chrono",
 ]
 
-[[package]]
-name = "dirs"
-version = "4.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059"
-dependencies = [
- "dirs-sys",
-]
-
-[[package]]
-name = "dirs-sys"
-version = "0.3.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6"
-dependencies = [
- "libc",
- "redox_users",
- "winapi",
-]
-
 [[package]]
 name = "displaydoc"
 version = "0.2.4"
@@ -1538,6 +1546,12 @@ dependencies = [
  "syn 1.0.103",
 ]
 
+[[package]]
+name = "downcast-rs"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ea835d29036a4087793836fa931b08837ad5e957da9e23886b29586fb9b6650"
+
 [[package]]
 name = "dprint-core"
 version = "0.59.0"
@@ -1775,6 +1789,16 @@ dependencies = [
  "hashbrown",
 ]
 
+[[package]]
+name = "fancy-regex"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d6b8560a05112eb52f04b00e5d3790c0dd75d9d980eb8a122fb23b92a623ccf"
+dependencies = [
+ "bit-set",
+ "regex",
+]
+
 [[package]]
 name = "fastrand"
 version = "1.8.0"
@@ -2116,6 +2140,15 @@ version = "3.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "4d13cdbd5dbb29f9c88095bbdc2590c9cba0d0a1269b983fef6b2cdd7e9f4db1"
 
+[[package]]
+name = "html-escape"
+version = "0.2.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476"
+dependencies = [
+ "utf8-width",
+]
+
 [[package]]
 name = "html2md"
 version = "0.2.13"
@@ -2794,7 +2827,6 @@ dependencies = [
  "actix-web",
  "anyhow",
  "chrono",
- "comrak",
  "deser-hjson",
  "diesel",
  "doku",
@@ -2804,6 +2836,7 @@ dependencies = [
  "itertools",
  "jsonwebtoken",
  "lettre",
+ "markdown-it",
  "once_cell",
  "openssl",
  "percent-encoding",
@@ -2940,6 +2973,15 @@ version = "0.2.135"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "68783febc7782c6c5cb401fbda4de5a9898be1762314da0bb2c10ced61f18b0c"
 
+[[package]]
+name = "line-wrap"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f30344350a2a51da54c1d53be93fade8a237e545dbcc4bdbe635413f2117cab9"
+dependencies = [
+ "safemem",
+]
+
 [[package]]
 name = "link-cplusplus"
 version = "1.0.7"
@@ -2955,6 +2997,15 @@ version = "0.5.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
 
+[[package]]
+name = "linkify"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96dd5884008358112bc66093362197c7248ece00d46624e2cf71e50029f8cff5"
+dependencies = [
+ "memchr",
+]
+
 [[package]]
 name = "linux-raw-sys"
 version = "0.1.4"
@@ -3017,6 +3068,29 @@ version = "0.1.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
 
+[[package]]
+name = "markdown-it"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53107ab22a09ae3b2eaedccf1d1c6aa58c1aa77e15689a799e0d8eda2b1a7d54"
+dependencies = [
+ "argparse",
+ "const_format",
+ "derivative",
+ "derive_more",
+ "downcast-rs",
+ "entities",
+ "html-escape",
+ "linkify",
+ "mdurl",
+ "once_cell",
+ "readonly",
+ "regex",
+ "stacker",
+ "syntect",
+ "unicode-general-category",
+]
+
 [[package]]
 name = "markup5ever"
 version = "0.10.1"
@@ -3093,6 +3167,17 @@ dependencies = [
  "digest",
 ]
 
+[[package]]
+name = "mdurl"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5736ba45bbac8f7ccc99a897f88ce85e508a18baec973a040f2514e6cdbff0d2"
+dependencies = [
+ "idna 0.2.3",
+ "once_cell",
+ "regex",
+]
+
 [[package]]
 name = "memchr"
 version = "2.5.0"
@@ -3849,6 +3934,20 @@ version = "0.3.25"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1df8c4ec4b0627e53bdf214615ad287367e482558cf84b109250b37464dc03ae"
 
+[[package]]
+name = "plist"
+version = "1.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9bd9647b268a3d3e14ff09c23201133a62589c658db02bb7388c7246aafe0590"
+dependencies = [
+ "base64 0.21.2",
+ "indexmap",
+ "line-wrap",
+ "quick-xml 0.28.2",
+ "serde",
+ "time 0.3.15",
+]
+
 [[package]]
 name = "pmutil"
 version = "0.5.3"
@@ -4073,6 +4172,15 @@ dependencies = [
  "prost 0.11.0",
 ]
 
+[[package]]
+name = "psm"
+version = "0.1.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5787f7cda34e3033a72192c018bc5883100330f362ef279a8cbccfce8bb4e874"
+dependencies = [
+ "cc",
+]
+
 [[package]]
 name = "quick-xml"
 version = "0.22.0"
@@ -4103,6 +4211,15 @@ dependencies = [
  "serde",
 ]
 
+[[package]]
+name = "quick-xml"
+version = "0.28.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ce5e73202a820a31f8a0ee32ada5e21029c81fd9e3ebf668a40832e4219d9d1"
+dependencies = [
+ "memchr",
+]
+
 [[package]]
 name = "quote"
 version = "1.0.28"
@@ -4206,23 +4323,23 @@ dependencies = [
 ]
 
 [[package]]
-name = "redox_syscall"
-version = "0.2.16"
+name = "readonly"
+version = "0.2.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a"
+checksum = "eb656d27c22b5c47154452686cae5e096f12e124daacb36a0bfcb32dbebb39e3"
 dependencies = [
- "bitflags 1.3.2",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.18",
 ]
 
 [[package]]
-name = "redox_users"
-version = "0.4.3"
+name = "redox_syscall"
+version = "0.2.16"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b"
+checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a"
 dependencies = [
- "getrandom 0.2.8",
- "redox_syscall",
- "thiserror",
+ "bitflags 1.3.2",
 ]
 
 [[package]]
@@ -4497,6 +4614,12 @@ version = "1.0.11"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "4501abdff3ae82a1c1b477a17252eb69cee9e66eb915c1abaa4f44d873df9f09"
 
+[[package]]
+name = "safemem"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef703b7cb59335eae2eb93ceb664c0eb7ea6bf567079d843e09420219668e072"
+
 [[package]]
 name = "same-file"
 version = "1.0.6"
@@ -4736,12 +4859,6 @@ dependencies = [
  "lazy_static",
 ]
 
-[[package]]
-name = "shell-words"
-version = "1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde"
-
 [[package]]
 name = "signal-hook-registry"
 version = "1.4.0"
@@ -4833,6 +4950,19 @@ version = "1.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
 
+[[package]]
+name = "stacker"
+version = "0.1.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c886bd4480155fd3ef527d45e9ac8dd7118a898a46530b7b94c3e21866259fce"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "libc",
+ "psm",
+ "winapi",
+]
+
 [[package]]
 name = "static_assertions"
 version = "1.1.0"
@@ -4999,7 +5129,7 @@ dependencies = [
  "swc_common",
  "swc_ecma_ast",
  "tracing",
- "typed-arena 2.0.2",
+ "typed-arena",
 ]
 
 [[package]]
@@ -5078,6 +5208,29 @@ version = "0.1.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "20518fe4a4c9acf048008599e464deb21beeae3d3578418951a189c235a7a9a8"
 
+[[package]]
+name = "syntect"
+version = "5.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6c454c27d9d7d9a84c7803aaa3c50cd088d2906fe3c6e42da3209aa623576a8"
+dependencies = [
+ "bincode",
+ "bitflags 1.3.2",
+ "fancy-regex",
+ "flate2",
+ "fnv",
+ "lazy_static",
+ "once_cell",
+ "plist",
+ "regex-syntax 0.6.27",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "thiserror",
+ "walkdir",
+ "yaml-rust",
+]
+
 [[package]]
 name = "tap"
 version = "1.0.1"
@@ -5771,12 +5924,6 @@ dependencies = [
  "unchecked-index",
 ]
 
-[[package]]
-name = "typed-arena"
-version = "1.7.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a9b2228007eba4120145f785df0f6c92ea538f5a3635a612ecf4e334c8c1446d"
-
 [[package]]
 name = "typed-arena"
 version = "2.0.2"
@@ -5827,6 +5974,12 @@ version = "0.3.13"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460"
 
+[[package]]
+name = "unicode-general-category"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2281c8c1d221438e373249e065ca4989c4c36952c211ff21a0ee91c44a3869e7"
+
 [[package]]
 name = "unicode-id"
 version = "0.3.3"
@@ -5861,10 +6014,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b"
 
 [[package]]
-name = "unicode_categories"
-version = "0.1.1"
+name = "unicode-xid"
+version = "0.2.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
+checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c"
 
 [[package]]
 name = "unreachable"
@@ -5905,6 +6058,12 @@ version = "0.7.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
 
+[[package]]
+name = "utf8-width"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5190c9442dcdaf0ddd50f37420417d219ae5261bbf5db120d0f9bab996c9cba1"
+
 [[package]]
 name = "uuid"
 version = "1.2.1"
@@ -6303,15 +6462,6 @@ version = "0.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "85e60b0d1b5f99db2556934e21937020776a5d31520bf169e851ac44e6420214"
 
-[[package]]
-name = "xdg"
-version = "2.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0c4583db5cbd4c4c0303df2d15af80f0539db703fa1c68802d4cbbd2dd0f88f6"
-dependencies = [
- "dirs",
-]
-
 [[package]]
 name = "xml5ever"
 version = "0.16.2"
index 4e80c5df3fb00786508c7339cf1f0a28c5620c7f..917e88bf13f144ddf3db56503f37abf554f91d0c 100644 (file)
@@ -43,7 +43,7 @@ deser-hjson = "1.0.2"
 smart-default = "0.6.0"
 jsonwebtoken = "8.1.1"
 lettre = "0.10.1"
-comrak = { version = "0.14.0", default-features = false }
+markdown-it = "0.5.0"
 totp-rs = { version = "4.2.0", features = ["gen_secret", "otpauth"] }
 
 [dev-dependencies]
index a5ee535718fb87cdadfc0cedf97f6445ad599cf6..451c86bc73621f3ff008e500d79dd44fadb43c20 100644 (file)
@@ -1,3 +1,83 @@
+use markdown_it::MarkdownIt;
+use once_cell::sync::Lazy;
+
+mod spoiler_rule;
+
+static MARKDOWN_PARSER: Lazy<MarkdownIt> = Lazy::new(|| {
+  let mut parser = MarkdownIt::new();
+  markdown_it::plugins::cmark::add(&mut parser);
+  markdown_it::plugins::extra::add(&mut parser);
+  spoiler_rule::add(&mut parser);
+
+  parser
+});
+
 pub fn markdown_to_html(text: &str) -> String {
-  comrak::markdown_to_html(text, &comrak::ComrakOptions::default())
+  MARKDOWN_PARSER.parse(text).xrender()
+}
+
+#[cfg(test)]
+mod tests {
+  use crate::utils::markdown::markdown_to_html;
+
+  #[test]
+  fn test_basic_markdown() {
+    let tests: Vec<_> = vec![
+      (
+        "headings",
+        "# h1\n## h2\n### h3\n#### h4\n##### h5\n###### h6",
+        "<h1>h1</h1>\n<h2>h2</h2>\n<h3>h3</h3>\n<h4>h4</h4>\n<h5>h5</h5>\n<h6>h6</h6>\n"
+      ),
+      (
+        "line breaks",
+        "First\rSecond",
+        "<p>First\nSecond</p>\n"),
+      (
+        "emphasis",
+        "__bold__ **bold** *italic* ***bold+italic***",
+        "<p><strong>bold</strong> <strong>bold</strong> <em>italic</em> <em><strong>bold+italic</strong></em></p>\n"
+      ),
+      (
+        "blockquotes",
+        "> #### Hello\n > \n > - Hola\n > - 안영 \n>> Goodbye\n",
+        "<blockquote>\n<h4>Hello</h4>\n<ul>\n<li>Hola</li>\n<li>안영</li>\n</ul>\n<blockquote>\n<p>Goodbye</p>\n</blockquote>\n</blockquote>\n"
+      ),
+      (
+        "lists (ordered, unordered)",
+        "1. pen\n2. apple\n3. apple pen\n- pen\n- pineapple\n- pineapple pen",
+        "<ol>\n<li>pen</li>\n<li>apple</li>\n<li>apple pen</li>\n</ol>\n<ul>\n<li>pen</li>\n<li>pineapple</li>\n<li>pineapple pen</li>\n</ul>\n"
+      ),
+      (
+        "code and code blocks",
+        "this is my amazing `code snippet` and my amazing ```code block```",
+        "<p>this is my amazing <code>code snippet</code> and my amazing <code>code block</code></p>\n"
+      ),
+      (
+        "links",
+        "[Lemmy](https://join-lemmy.org/ \"Join Lemmy!\")",
+        "<p><a href=\"https://join-lemmy.org/\" title=\"Join Lemmy!\">Lemmy</a></p>\n"
+      ),
+      (
+        "images",
+        "![My linked image](https://image.com \"image alt text\")",
+        "<p><img src=\"https://image.com\" alt=\"My linked image\" title=\"image alt text\" /></p>\n"
+      ),
+      // Ensure any custom plugins are added to 'MARKDOWN_PARSER' implementation.
+      (
+        "basic spoiler",
+        "::: spoiler click to see more\nhow spicy!\n:::\n",
+        "<details><summary>click to see more</summary><p>how spicy!\n</p></details>\n"
+      ),
+    ];
+
+    tests.iter().for_each(|&(msg, input, expected)| {
+      let result = markdown_to_html(input);
+
+      assert_eq!(
+        result, expected,
+        "Testing {}, with original input '{}'",
+        msg, input
+      );
+    });
+  }
 }
diff --git a/crates/utils/src/utils/markdown/spoiler_rule.rs b/crates/utils/src/utils/markdown/spoiler_rule.rs
new file mode 100644 (file)
index 0000000..1a564f0
--- /dev/null
@@ -0,0 +1,200 @@
+// 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: <details><summary>VISIBLE_TEXT</summary><p>nHIDDEN_SPOILER</p></details>
+//
+// 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<Regex> =
+  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::<SpoilerBlockScanner>();
+}
+
+#[cfg(test)]
+mod tests {
+  use crate::utils::markdown::spoiler_rule::add;
+  use markdown_it::MarkdownIt;
+
+  #[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
+      );
+    });
+  }
+}