diff --git a/Cargo.lock b/Cargo.lock index c38a00d0c..3cfec53eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2809,6 +2809,10 @@ dependencies = [ "itertools 0.13.0", "lettre", "markdown-it", + "markdown-it-block-spoiler", + "markdown-it-ruby", + "markdown-it-sub", + "markdown-it-sup", "pretty_assertions", "regex", "reqwest 0.12.8", @@ -2980,6 +2984,44 @@ dependencies = [ "unicode-general-category", ] +[[package]] +name = "markdown-it-block-spoiler" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008a8e4184fd08b5dca0f2b5b2ef8f126c1e83ca797c44ee41f8d7765951360c" +dependencies = [ + "itertools 0.13.0", + "markdown-it", +] + +[[package]] +name = "markdown-it-ruby" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3505f4ada7c372e7f5eb4b07850bf5921193bc0bd43cb18991233999c9134d4" +dependencies = [ + "itertools 0.13.0", + "markdown-it", +] + +[[package]] +name = "markdown-it-sub" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8abe3aa8927af2314644b3aae37393241a229e869ff9c95ac640749e08357d2a" +dependencies = [ + "markdown-it", +] + +[[package]] +name = "markdown-it-sup" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ae949e78c7a615f88a47019d51b65962bfc5c4cbc65fa81eae8b9b2506d1cb1" +dependencies = [ + "markdown-it", +] + [[package]] name = "markup5ever" version = "0.11.0" @@ -5579,7 +5621,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/crates/utils/Cargo.toml b/crates/utils/Cargo.toml index f0369f7e9..1c99e89c2 100644 --- a/crates/utils/Cargo.toml +++ b/crates/utils/Cargo.toml @@ -82,6 +82,10 @@ ts-rs = { workspace = true, optional = true } enum-map = { workspace = true, optional = true } cfg-if = "1" clearurls = { version = "0.0.4", features = ["linkify"] } +markdown-it-block-spoiler = "1.0.0" +markdown-it-sub = "1.0.0" +markdown-it-sup = "1.0.0" +markdown-it-ruby = "1.0.0" [dev-dependencies] reqwest = { workspace = true } diff --git a/crates/utils/src/utils/markdown/mod.rs b/crates/utils/src/utils/markdown/mod.rs index a6de096e3..a51b507ce 100644 --- a/crates/utils/src/utils/markdown/mod.rs +++ b/crates/utils/src/utils/markdown/mod.rs @@ -5,13 +5,15 @@ use std::sync::LazyLock; pub mod image_links; mod link_rule; -mod spoiler_rule; static MARKDOWN_PARSER: LazyLock = LazyLock::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); + markdown_it_block_spoiler::add(&mut parser); + markdown_it_sub::add(&mut parser); + markdown_it_sup::add(&mut parser); + markdown_it_ruby::add(&mut parser); link_rule::add(&mut parser); parser @@ -102,12 +104,22 @@ mod tests { ( "basic spoiler", "::: spoiler click to see more\nhow spicy!\n:::\n", - "
click to see more

how spicy!\n

\n" + "
click to see morehow spicy!\n
\n" ), ( "escape html special chars", " hello &\"", "

<script>alert(‘xss’);</script> hello &"

\n" + ),("subscript","log~2~(a)","

log2(a)

\n"), + ( + "superscript", + "Markdown^TM^", + "

MarkdownTM

\n" + ), + ( + "ruby text", + "{漢|Kan}{字|ji}", + "

(Kan)(ji)

\n" ) ]; diff --git a/crates/utils/src/utils/markdown/spoiler_rule.rs b/crates/utils/src/utils/markdown/spoiler_rule.rs deleted file mode 100644 index fd6450b31..000000000 --- a/crates/utils/src/utils/markdown/spoiler_rule.rs +++ /dev/null @@ -1,202 +0,0 @@ -// 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 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 = - LazyLock::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)] -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
  • did you know that
  • \n
\n
the call was

coming from inside the house!\n

\n
    \n
  • crazy, right?
  • \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 - ); - }); - } -}