Skip to main content

markdown_it_ruby/
lib.rs

1//! A [`markdown-it`](https://crates.io/crates/markdown-it) plugin to process [ruby text](https://en.wikipedia.org/wiki/Ruby_character).
2//!
3//! To load the plugin:
4//!
5//! ```rust
6//! # use markdown_it;
7//! # use markdown_it_ruby;
8//! let mut parser = markdown_it::MarkdownIt::new();
9//! markdown_it::plugins::cmark::add(&mut parser);
10//!
11//! markdown_it_ruby::add(&mut parser);
12//!
13//! let html = parser.parse("{漢|Kan}{字|ji}").xrender();
14//! assert_eq!(html, String::from("<p><ruby>漢<rp>(</rp><rt>Kan</rt><rp>)</rp></ruby><ruby>字<rp>(</rp><rt>ji</rt><rp>)</rp></ruby></p>\n"));
15//! ```
16
17use 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    // Intersperse guarantees there are still spaces between visible text words.
70    #[allow(unstable_name_collisions)]
71    text.split_whitespace()
72        .intersperse(" ") // TODO: Use intersperse function from std once it makes it to a stable version: https://github.com/rust-lang/rust/issues/79524
73        .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}