Fixes #1884 - Support Spoiler Tags (#3018)

* 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>
This commit is contained in:
Nina Blanson 2023-06-14 06:15:59 -05:00 committed by GitHub
parent f3f95e5d2f
commit 1c7bfd6be8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 507 additions and 77 deletions

300
Cargo.lock generated
View file

@ -377,6 +377,12 @@ version = "1.0.71"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c7d0618f0e0b7e8ff11427422b64564d5fb0be1940354bfe2e0529b18a9d9b8" checksum = "9c7d0618f0e0b7e8ff11427422b64564d5fb0be1940354bfe2e0529b18a9d9b8"
[[package]]
name = "argparse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f8ebf5827e4ac4fd5946560e6a99776ea73b596d80898f357007317a7141e47"
[[package]] [[package]]
name = "arrayvec" name = "arrayvec"
version = "0.5.2" version = "0.5.2"
@ -650,6 +656,15 @@ dependencies = [
"scoped-tls", "scoped-tls",
] ]
[[package]]
name = "bincode"
version = "1.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "bit-set" name = "bit-set"
version = "0.5.3" version = "0.5.3"
@ -898,24 +913,6 @@ dependencies = [
"memchr", "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]] [[package]]
name = "config" name = "config"
version = "0.13.3" version = "0.13.3"
@ -971,6 +968,26 @@ dependencies = [
"tracing-subscriber", "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]] [[package]]
name = "constant_time_eq" name = "constant_time_eq"
version = "0.2.4" version = "0.2.4"
@ -1278,6 +1295,17 @@ dependencies = [
"text_lines", "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]] [[package]]
name = "derive_builder" name = "derive_builder"
version = "0.10.2" version = "0.10.2"
@ -1477,26 +1505,6 @@ dependencies = [
"chrono", "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]] [[package]]
name = "displaydoc" name = "displaydoc"
version = "0.2.4" version = "0.2.4"
@ -1538,6 +1546,12 @@ dependencies = [
"syn 1.0.103", "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]] [[package]]
name = "dprint-core" name = "dprint-core"
version = "0.59.0" version = "0.59.0"
@ -1775,6 +1789,16 @@ dependencies = [
"hashbrown", "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]] [[package]]
name = "fastrand" name = "fastrand"
version = "1.8.0" version = "1.8.0"
@ -2116,6 +2140,15 @@ version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d13cdbd5dbb29f9c88095bbdc2590c9cba0d0a1269b983fef6b2cdd7e9f4db1" 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]] [[package]]
name = "html2md" name = "html2md"
version = "0.2.13" version = "0.2.13"
@ -2794,7 +2827,6 @@ dependencies = [
"actix-web", "actix-web",
"anyhow", "anyhow",
"chrono", "chrono",
"comrak",
"deser-hjson", "deser-hjson",
"diesel", "diesel",
"doku", "doku",
@ -2804,6 +2836,7 @@ dependencies = [
"itertools", "itertools",
"jsonwebtoken", "jsonwebtoken",
"lettre", "lettre",
"markdown-it",
"once_cell", "once_cell",
"openssl", "openssl",
"percent-encoding", "percent-encoding",
@ -2940,6 +2973,15 @@ version = "0.2.135"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68783febc7782c6c5cb401fbda4de5a9898be1762314da0bb2c10ced61f18b0c" 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]] [[package]]
name = "link-cplusplus" name = "link-cplusplus"
version = "1.0.7" version = "1.0.7"
@ -2955,6 +2997,15 @@ version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
[[package]]
name = "linkify"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96dd5884008358112bc66093362197c7248ece00d46624e2cf71e50029f8cff5"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.1.4" version = "0.1.4"
@ -3017,6 +3068,29 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" 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]] [[package]]
name = "markup5ever" name = "markup5ever"
version = "0.10.1" version = "0.10.1"
@ -3093,6 +3167,17 @@ dependencies = [
"digest", "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]] [[package]]
name = "memchr" name = "memchr"
version = "2.5.0" version = "2.5.0"
@ -3849,6 +3934,20 @@ version = "0.3.25"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1df8c4ec4b0627e53bdf214615ad287367e482558cf84b109250b37464dc03ae" 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]] [[package]]
name = "pmutil" name = "pmutil"
version = "0.5.3" version = "0.5.3"
@ -4073,6 +4172,15 @@ dependencies = [
"prost 0.11.0", "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]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.22.0" version = "0.22.0"
@ -4103,6 +4211,15 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "quick-xml"
version = "0.28.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ce5e73202a820a31f8a0ee32ada5e21029c81fd9e3ebf668a40832e4219d9d1"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.28" version = "1.0.28"
@ -4205,6 +4322,17 @@ dependencies = [
"rand_core 0.5.1", "rand_core 0.5.1",
] ]
[[package]]
name = "readonly"
version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb656d27c22b5c47154452686cae5e096f12e124daacb36a0bfcb32dbebb39e3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.18",
]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.2.16" version = "0.2.16"
@ -4214,17 +4342,6 @@ dependencies = [
"bitflags 1.3.2", "bitflags 1.3.2",
] ]
[[package]]
name = "redox_users"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b"
dependencies = [
"getrandom 0.2.8",
"redox_syscall",
"thiserror",
]
[[package]] [[package]]
name = "regex" name = "regex"
version = "1.8.4" version = "1.8.4"
@ -4497,6 +4614,12 @@ version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4501abdff3ae82a1c1b477a17252eb69cee9e66eb915c1abaa4f44d873df9f09" checksum = "4501abdff3ae82a1c1b477a17252eb69cee9e66eb915c1abaa4f44d873df9f09"
[[package]]
name = "safemem"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef703b7cb59335eae2eb93ceb664c0eb7ea6bf567079d843e09420219668e072"
[[package]] [[package]]
name = "same-file" name = "same-file"
version = "1.0.6" version = "1.0.6"
@ -4736,12 +4859,6 @@ dependencies = [
"lazy_static", "lazy_static",
] ]
[[package]]
name = "shell-words"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde"
[[package]] [[package]]
name = "signal-hook-registry" name = "signal-hook-registry"
version = "1.4.0" version = "1.4.0"
@ -4833,6 +4950,19 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" 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]] [[package]]
name = "static_assertions" name = "static_assertions"
version = "1.1.0" version = "1.1.0"
@ -4999,7 +5129,7 @@ dependencies = [
"swc_common", "swc_common",
"swc_ecma_ast", "swc_ecma_ast",
"tracing", "tracing",
"typed-arena 2.0.2", "typed-arena",
] ]
[[package]] [[package]]
@ -5078,6 +5208,29 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20518fe4a4c9acf048008599e464deb21beeae3d3578418951a189c235a7a9a8" 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]] [[package]]
name = "tap" name = "tap"
version = "1.0.1" version = "1.0.1"
@ -5771,12 +5924,6 @@ dependencies = [
"unchecked-index", "unchecked-index",
] ]
[[package]]
name = "typed-arena"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9b2228007eba4120145f785df0f6c92ea538f5a3635a612ecf4e334c8c1446d"
[[package]] [[package]]
name = "typed-arena" name = "typed-arena"
version = "2.0.2" version = "2.0.2"
@ -5827,6 +5974,12 @@ version = "0.3.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460" checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460"
[[package]]
name = "unicode-general-category"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2281c8c1d221438e373249e065ca4989c4c36952c211ff21a0ee91c44a3869e7"
[[package]] [[package]]
name = "unicode-id" name = "unicode-id"
version = "0.3.3" version = "0.3.3"
@ -5861,10 +6014,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b"
[[package]] [[package]]
name = "unicode_categories" name = "unicode-xid"
version = "0.1.1" version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c"
[[package]] [[package]]
name = "unreachable" name = "unreachable"
@ -5905,6 +6058,12 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8-width"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5190c9442dcdaf0ddd50f37420417d219ae5261bbf5db120d0f9bab996c9cba1"
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.2.1" version = "1.2.1"
@ -6303,15 +6462,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85e60b0d1b5f99db2556934e21937020776a5d31520bf169e851ac44e6420214" checksum = "85e60b0d1b5f99db2556934e21937020776a5d31520bf169e851ac44e6420214"
[[package]]
name = "xdg"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c4583db5cbd4c4c0303df2d15af80f0539db703fa1c68802d4cbbd2dd0f88f6"
dependencies = [
"dirs",
]
[[package]] [[package]]
name = "xml5ever" name = "xml5ever"
version = "0.16.2" version = "0.16.2"

View file

@ -43,7 +43,7 @@ deser-hjson = "1.0.2"
smart-default = "0.6.0" smart-default = "0.6.0"
jsonwebtoken = "8.1.1" jsonwebtoken = "8.1.1"
lettre = "0.10.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"] } totp-rs = { version = "4.2.0", features = ["gen_secret", "otpauth"] }
[dev-dependencies] [dev-dependencies]

View 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 { 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
);
});
}
} }

View file

@ -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
);
});
}
}