1use itertools::Itertools;
18use markdown_it::{
19 parser::inline::{InlineRule, InlineState},
20 MarkdownIt, Node, NodeValue, Renderer,
21};
22
23#[derive(Debug)]
24pub struct Ruby {
25 pub base_text: String,
26 pub ruby_text: String,
27}
28
29impl NodeValue for Ruby {
30 fn render(&self, node: &Node, fmt: &mut dyn Renderer) {
31 fmt.open("ruby", &node.attrs);
32 fmt.text(self.base_text.trim());
33
34 fmt.open("rp", &[]);
35 fmt.text("(");
36 fmt.close("rp");
37
38 fmt.open("rt", &[]);
39 fmt.text(self.ruby_text.trim());
40 fmt.close("rt");
41
42 fmt.open("rp", &[]);
43 fmt.text(")");
44 fmt.close("rp");
45
46 fmt.close("ruby");
47 }
48}
49
50fn find_text_end_index<const BREAK_CHAR: char>(
51 char_indices: &mut impl Iterator<Item = (usize, char)>,
52) -> Option<usize> {
53 let mut prev_char_escaped = false;
54
55 char_indices.find_map(|(i, c)| {
56 if c == '\\' {
57 prev_char_escaped = true;
58 return None;
59 }
60
61 let index = (c == BREAK_CHAR && !prev_char_escaped).then_some(i);
62 prev_char_escaped = false;
63
64 index
65 })
66}
67
68fn prepare_text(text: &str) -> String {
69 #[allow(unstable_name_collisions)]
71 text.split_whitespace()
72 .intersperse(" ") .collect::<String>()
74 .replace('\\', "")
75}
76
77struct RubyScanner;
78
79impl InlineRule for RubyScanner {
80 const MARKER: char = '{';
81
82 fn run(state: &mut InlineState) -> Option<(Node, usize)> {
83 let mut char_indices = state.src[state.pos..state.pos_max].char_indices();
84 if char_indices.next()?.1 != Self::MARKER {
85 return None;
86 }
87
88 let base_end_pos = find_text_end_index::<'|'>(&mut char_indices)? + state.pos;
89 let base_text = &state.src[state.pos + 1..base_end_pos];
90
91 let end_pos = find_text_end_index::<'}'>(&mut char_indices.skip(2))? + state.pos;
92 let ruby_text = &state.src[base_end_pos + 1..end_pos];
93
94 Some((
95 Node::new(Ruby {
96 base_text: prepare_text(base_text),
97 ruby_text: prepare_text(ruby_text),
98 }),
99 (end_pos - state.pos) + 1,
100 ))
101 }
102}
103
104pub fn add(md: &mut MarkdownIt) {
105 md.inline.add_rule::<RubyScanner>();
106}
107
108#[cfg(test)]
109mod test {
110 use super::add;
111 use markdown_it::{
112 plugins::{cmark, extra},
113 MarkdownIt,
114 };
115 use rstest::rstest;
116 use std::sync::LazyLock;
117
118 static MARKDOWN_PARSER: LazyLock<MarkdownIt> = LazyLock::new(|| {
119 let mut parser = MarkdownIt::new();
120 cmark::add(&mut parser);
121 extra::add(&mut parser);
122 add(&mut parser);
123
124 parser
125 });
126
127 #[rstest]
128 #[case("{漢|Kan}{字|ji}", "<p><ruby>漢<rp>(</rp><rt>Kan</rt><rp>)</rp></ruby><ruby>字<rp>(</rp><rt>ji</rt><rp>)</rp></ruby></p>\n")]
129 #[case(
130 "\\{foo|bar}{baz|qux}",
131 "<p>{foo|bar}<ruby>baz<rp>(</rp><rt>qux</rt><rp>)</rp></ruby></p>\n"
132 )]
133 #[case(
134 "{foo|bar}{baz\\|qux}",
135 "<p><ruby>foo<rp>(</rp><rt>bar</rt><rp>)</rp></ruby>{baz|qux}</p>\n"
136 )]
137 #[case(
138 "{foo\\|bar\\}{baz|qux}",
139 "<p><ruby>foo|bar}{baz<rp>(</rp><rt>qux</rt><rp>)</rp></ruby></p>\n"
140 )]
141 #[case(
142 "{foo\\|bar}\\{baz|qux}",
143 "<p><ruby>foo|bar}{baz<rp>(</rp><rt>qux</rt><rp>)</rp></ruby></p>\n"
144 )]
145 #[case(
146 "Some stuff before {foo doo|bar hello} mid {baz|qux} after words",
147 "<p>Some stuff before <ruby>foo doo<rp>(</rp><rt>bar hello</rt><rp>)</rp></ruby> mid <ruby>baz<rp>(</rp><rt>qux</rt><rp>)</rp></ruby> after words</p>\n"
148 )]
149 fn test(#[case] md_str: &str, #[case] expected: &str) {
150 let result = MARKDOWN_PARSER.parse(md_str).xrender();
151
152 assert_eq!(result, String::from(expected));
153 }
154}