mirror of
https://github.com/LemmyNet/lemmy.git
synced 2024-11-22 09:21:01 +00:00
Support markdown sub/superscript, use external crate for spoilers (#5135)
* Use external crate for spoiler tags * Also add other plugins * fix test
This commit is contained in:
parent
859dfb3f81
commit
322538b0ce
4 changed files with 62 additions and 206 deletions
44
Cargo.lock
generated
44
Cargo.lock
generated
|
@ -2809,6 +2809,10 @@ dependencies = [
|
||||||
"itertools 0.13.0",
|
"itertools 0.13.0",
|
||||||
"lettre",
|
"lettre",
|
||||||
"markdown-it",
|
"markdown-it",
|
||||||
|
"markdown-it-block-spoiler",
|
||||||
|
"markdown-it-ruby",
|
||||||
|
"markdown-it-sub",
|
||||||
|
"markdown-it-sup",
|
||||||
"pretty_assertions",
|
"pretty_assertions",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest 0.12.8",
|
"reqwest 0.12.8",
|
||||||
|
@ -2980,6 +2984,44 @@ dependencies = [
|
||||||
"unicode-general-category",
|
"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]]
|
[[package]]
|
||||||
name = "markup5ever"
|
name = "markup5ever"
|
||||||
version = "0.11.0"
|
version = "0.11.0"
|
||||||
|
@ -5579,7 +5621,7 @@ version = "0.1.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
|
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.48.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
@ -82,6 +82,10 @@ ts-rs = { workspace = true, optional = true }
|
||||||
enum-map = { workspace = true, optional = true }
|
enum-map = { workspace = true, optional = true }
|
||||||
cfg-if = "1"
|
cfg-if = "1"
|
||||||
clearurls = { version = "0.0.4", features = ["linkify"] }
|
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]
|
[dev-dependencies]
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
|
|
|
@ -5,13 +5,15 @@ use std::sync::LazyLock;
|
||||||
|
|
||||||
pub mod image_links;
|
pub mod image_links;
|
||||||
mod link_rule;
|
mod link_rule;
|
||||||
mod spoiler_rule;
|
|
||||||
|
|
||||||
static MARKDOWN_PARSER: LazyLock<MarkdownIt> = LazyLock::new(|| {
|
static MARKDOWN_PARSER: LazyLock<MarkdownIt> = LazyLock::new(|| {
|
||||||
let mut parser = MarkdownIt::new();
|
let mut parser = MarkdownIt::new();
|
||||||
markdown_it::plugins::cmark::add(&mut parser);
|
markdown_it::plugins::cmark::add(&mut parser);
|
||||||
markdown_it::plugins::extra::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);
|
link_rule::add(&mut parser);
|
||||||
|
|
||||||
parser
|
parser
|
||||||
|
@ -102,12 +104,22 @@ mod tests {
|
||||||
(
|
(
|
||||||
"basic spoiler",
|
"basic spoiler",
|
||||||
"::: spoiler click to see more\nhow spicy!\n:::\n",
|
"::: spoiler click to see more\nhow spicy!\n:::\n",
|
||||||
"<details><summary>click to see more</summary><p>how spicy!\n</p></details>\n"
|
"<details><summary>click to see more</summary>how spicy!\n</details>\n"
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"escape html special chars",
|
"escape html special chars",
|
||||||
"<script>alert('xss');</script> hello &\"",
|
"<script>alert('xss');</script> hello &\"",
|
||||||
"<p><script>alert(‘xss’);</script> hello &"</p>\n"
|
"<p><script>alert(‘xss’);</script> hello &"</p>\n"
|
||||||
|
),("subscript","log~2~(a)","<p>log<sub>2</sub>(a)</p>\n"),
|
||||||
|
(
|
||||||
|
"superscript",
|
||||||
|
"Markdown^TM^",
|
||||||
|
"<p>Markdown<sup>TM</sup></p>\n"
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ruby text",
|
||||||
|
"{漢|Kan}{字|ji}",
|
||||||
|
"<p><ruby>漢<rp>(</rp><rt>Kan</rt><rp>)</rp></ruby><ruby>字<rp>(</rp><rt>ji</rt><rp>)</rp></ruby></p>\n"
|
||||||
)
|
)
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
@ -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: <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 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<Regex> =
|
|
||||||
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::<SpoilerBlockScanner>();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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",
|
|
||||||
"<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
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
Reference in a new issue