markdown_it_ruby/
lib.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
//! A [`markdown-it`](https://crates.io/crates/markdown-it) plugin to process [ruby text](https://en.wikipedia.org/wiki/Ruby_character).
//!
//! To load the plugin:
//!
//! ```rust
//! # use markdown_it;
//! # use markdown_it_ruby;
//! let mut parser = markdown_it::MarkdownIt::new();
//! markdown_it::plugins::cmark::add(&mut parser);
//!
//! markdown_it_ruby::add(&mut parser);
//!
//! let html = parser.parse("{漢|Kan}{字|ji}").xrender();
//! 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"));
//! ```

use itertools::Itertools;
use markdown_it::{
    parser::inline::{InlineRule, InlineState},
    MarkdownIt, Node, NodeValue, Renderer,
};

#[derive(Debug)]
pub struct Ruby {
    pub base_text: String,
    pub ruby_text: String,
}

impl NodeValue for Ruby {
    fn render(&self, node: &Node, fmt: &mut dyn Renderer) {
        fmt.open("ruby", &node.attrs);
        fmt.text(self.base_text.trim());

        fmt.open("rp", &[]);
        fmt.text("(");
        fmt.close("rp");

        fmt.open("rt", &[]);
        fmt.text(self.ruby_text.trim());
        fmt.close("rt");

        fmt.open("rp", &[]);
        fmt.text(")");
        fmt.close("rp");

        fmt.close("ruby");
    }
}

fn find_text_end_index<const BREAK_CHAR: char>(
    char_indices: &mut impl Iterator<Item = (usize, char)>,
) -> Option<usize> {
    let mut prev_char_escaped = false;

    char_indices.find_map(|(i, c)| {
        if c == '\\' {
            prev_char_escaped = true;
            return None;
        }

        let index = (c == BREAK_CHAR && !prev_char_escaped).then_some(i);
        prev_char_escaped = false;

        index
    })
}

fn prepare_text(text: &str) -> String {
    // Intersperse guarantees there are still spaces between visible text words.
    #[allow(unstable_name_collisions)]
    text.split_whitespace()
        .intersperse(" ") // TODO: Use intersperse function from std once it makes it to a stable version: https://github.com/rust-lang/rust/issues/79524
        .collect::<String>()
        .replace('\\', "")
}

struct RubyScanner;

impl InlineRule for RubyScanner {
    const MARKER: char = '{';

    fn run(state: &mut InlineState) -> Option<(Node, usize)> {
        let mut char_indices = state.src[state.pos..state.pos_max].char_indices();
        if char_indices.next()?.1 != Self::MARKER {
            return None;
        }

        let base_end_pos = find_text_end_index::<'|'>(&mut char_indices)? + state.pos;
        let base_text = &state.src[state.pos + 1..base_end_pos];

        let end_pos = find_text_end_index::<'}'>(&mut char_indices.skip(2))? + state.pos;
        let ruby_text = &state.src[base_end_pos + 1..end_pos];

        Some((
            Node::new(Ruby {
                base_text: prepare_text(base_text),
                ruby_text: prepare_text(ruby_text),
            }),
            (end_pos - state.pos) + 1,
        ))
    }
}

pub fn add(md: &mut MarkdownIt) {
    md.inline.add_rule::<RubyScanner>();
}

#[cfg(test)]
mod test {
    use super::add;
    use markdown_it::{
        plugins::{cmark, extra},
        MarkdownIt,
    };
    use rstest::rstest;
    use std::sync::LazyLock;

    static MARKDOWN_PARSER: LazyLock<MarkdownIt> = LazyLock::new(|| {
        let mut parser = MarkdownIt::new();
        cmark::add(&mut parser);
        extra::add(&mut parser);
        add(&mut parser);

        parser
    });

    #[rstest]
    #[case("{漢|Kan}{字|ji}", "<p><ruby>漢<rp>(</rp><rt>Kan</rt><rp>)</rp></ruby><ruby>字<rp>(</rp><rt>ji</rt><rp>)</rp></ruby></p>\n")]
    #[case(
        "\\{foo|bar}{baz|qux}",
        "<p>{foo|bar}<ruby>baz<rp>(</rp><rt>qux</rt><rp>)</rp></ruby></p>\n"
    )]
    #[case(
        "{foo|bar}{baz\\|qux}",
        "<p><ruby>foo<rp>(</rp><rt>bar</rt><rp>)</rp></ruby>{baz|qux}</p>\n"
    )]
    #[case(
        "{foo\\|bar\\}{baz|qux}",
        "<p><ruby>foo|bar}{baz<rp>(</rp><rt>qux</rt><rp>)</rp></ruby></p>\n"
    )]
    #[case(
        "{foo\\|bar}\\{baz|qux}",
        "<p><ruby>foo|bar}{baz<rp>(</rp><rt>qux</rt><rp>)</rp></ruby></p>\n"
    )]
    #[case(
        "Some stuff before      {foo      doo|bar            hello}  mid {baz|qux} after      words",
        "<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"
    )]
    fn test(#[case] md_str: &str, #[case] expected: &str) {
        let result = MARKDOWN_PARSER.parse(md_str).xrender();

        assert_eq!(result, String::from(expected));
    }
}