char/phoebe

git clone https://git.t4t.associates/char/phoebe

videogame hackerStrip <mx-reply> from matrix HTML conversion0ddd756

legacy
11.5 KiB291 linesraw
1use html5ever::{local_name, namespace_url, ns, LocalName, QualName};
2use kuchiki::{iter::NodeEdge, traits::*, NodeData};
3
4use super::{MessageComponent, MessageContent};
5
6pub fn convert_matrix(message: &str) -> MessageContent {
7    let dom = kuchiki::parse_fragment(
8        QualName::new(None, ns!(html), LocalName::from("div")),
9        vec![],
10    )
11    .one(message);
12
13    let mut parents = vec![];
14    let mut components = vec![];
15    let mut skip_text = 0;
16    let mut skip_all = 0;
17
18    for edge in dom.traverse() {
19        match edge {
20            NodeEdge::Start(node) => {
21                if let NodeData::Element(element) = node.data() {
22                    if element.name.local == *"mx-reply" {
23                        skip_all += 1;
24                    }
25
26                    if skip_all > 0 {
27                        continue;
28                    }
29
30                    if element.name.ns == ns!(html) {
31                        match element.name.local {
32                            local_name!("strong")
33                            | local_name!("b")
34                            | local_name!("em")
35                            | local_name!("i")
36                            | local_name!("s")
37                            | local_name!("u")
38                            | local_name!("a")
39                            | local_name!("blockquote") => {
40                                parents.push(components);
41                                components = vec![];
42                            }
43
44                            local_name!("span") => {
45                                let attrs = element.attributes.borrow();
46                                if attrs.get("data-mx-spoiler").is_some() {
47                                    parents.push(components);
48                                    components = vec![];
49                                }
50                            }
51
52                            local_name!("code") => {
53                                skip_text += 1;
54                            }
55
56                            _ => {}
57                        }
58                    }
59                }
60            }
61
62            NodeEdge::End(node) => match node.data() {
63                NodeData::Text(text) => {
64                    if skip_text <= 0 && skip_all <= 0 {
65                        let text = text.borrow().lines().collect::<Vec<_>>().join(" ");
66                        components.push(MessageComponent::Plain(text));
67                    }
68                }
69                NodeData::Element(element) => {
70                    if element.name.local == *"mx-reply" {
71                        skip_all -= 1;
72                    }
73
74                    if skip_all > 0 {
75                        continue;
76                    }
77
78                    macro_rules! construct_component {
79                        ($f:expr) => {{
80                            let component_type = $f;
81                            if let Some(mut parent_components) = parents.pop() {
82                                parent_components.push((component_type)(components));
83                                components = parent_components;
84                            }
85                        }};
86                    }
87
88                    if element.name.ns == ns!(html) {
89                        match element.name.local {
90                            local_name!("strong") | local_name!("b") => {
91                                construct_component!(MessageComponent::Bold)
92                            }
93                            local_name!("em") | local_name!("i") => {
94                                construct_component!(MessageComponent::Italic)
95                            }
96                            local_name!("s") => {
97                                construct_component!(MessageComponent::Strikethrough)
98                            }
99                            local_name!("u") => {
100                                construct_component!(MessageComponent::Underline)
101                            }
102                            local_name!("a") => {
103                                if let Some(mut parent_components) = parents.pop() {
104                                    let attrs = element.attributes.borrow();
105                                    if let Some(href) = attrs.get(local_name!("href")) {
106                                        parent_components.push(MessageComponent::Link {
107                                            target: href.to_string(),
108                                            text: components,
109                                        });
110                                    } else {
111                                        parent_components.append(&mut components);
112                                    }
113
114                                    components = parent_components;
115                                }
116                            }
117                            local_name!("br") | local_name!("p") => {
118                                components.push(MessageComponent::HardBreak);
119                            }
120                            local_name!("blockquote") => {
121                                construct_component!(MessageComponent::BlockQuote)
122                            }
123                            local_name!("span") => {
124                                let attrs = element.attributes.borrow();
125                                if let Some(spoiler_reason) = attrs.get("data-mx-spoiler") {
126                                    construct_component!(|inner| MessageComponent::Spoiler {
127                                        reason: (!spoiler_reason.is_empty())
128                                            .then(|| spoiler_reason.to_string()),
129                                        content: inner,
130                                    })
131                                }
132                            }
133                            local_name!("code") => {
134                                // is_code_block = whether we are the child of a <pre> tag
135                                let is_code_block = node
136                                    .parent()
137                                    .as_ref()
138                                    .map(|p| p.data())
139                                    .and_then(|d| match d {
140                                        NodeData::Element(e) => {
141                                            Some(e.name.local == local_name!("pre"))
142                                        }
143                                        _ => None,
144                                    })
145                                    .unwrap_or(false);
146
147                                components.push(if is_code_block {
148                                    let attrs = element.attributes.borrow();
149                                    let lang = attrs
150                                        .get(local_name!("class"))
151                                        .and_then(|lang| lang.strip_prefix("language-"))
152                                        .map(|s| s.to_string());
153
154                                    MessageComponent::CodeBlock {
155                                        lang,
156                                        source: node.text_contents(),
157                                    }
158                                } else {
159                                    MessageComponent::Code(node.text_contents())
160                                });
161
162                                skip_text -= 1;
163                            }
164                            _ => {}
165                        }
166                    }
167                }
168                _ => {}
169            },
170        };
171    }
172
173    components
174}
175
176pub fn format_matrix(message_content: &[MessageComponent]) -> String {
177    message_content
178        .iter()
179        .map(|component| match component {
180            MessageComponent::Plain(text) => html_escape::encode_text(text).to_string(),
181            MessageComponent::Link { target, text } => format!(
182                r#"<a href="{}">{}</a>"#,
183                html_escape::encode_quoted_attribute(target),
184                format_matrix(text)
185            ),
186
187            MessageComponent::Italic(inner) => format!("<em>{}</em>", format_matrix(inner)),
188            MessageComponent::Bold(inner) => format!("<strong>{}</strong>", format_matrix(inner)),
189            MessageComponent::Strikethrough(inner) => {
190                format!("<del>{}</del>", format_matrix(inner))
191            }
192            MessageComponent::Underline(inner) => format!("<u>{}</u>", format_matrix(inner)),
193
194            MessageComponent::Code(code) => {
195                format!("<code>{}</code>", html_escape::encode_text(code))
196            }
197            MessageComponent::CodeBlock { lang, source } => {
198                format!(
199                    r#"<pre><code{}>{}</code></pre>"#,
200                    lang.as_ref()
201                        .map(|lang| format!(
202                            r#" class="language-{}""#,
203                            html_escape::encode_quoted_attribute(lang)
204                        ))
205                        .unwrap_or_else(|| "".to_string()),
206                    source,
207                )
208            }
209
210            MessageComponent::Spoiler { reason, content } => format!(
211                "<span data-mx-spoiler{}>{}</span>",
212                reason
213                    .as_ref()
214                    .map(|reason| format!(r#"="{}""#, html_escape::encode_quoted_attribute(reason)))
215                    .unwrap_or_else(|| "".to_string()),
216                format_matrix(content)
217            ),
218            MessageComponent::HardBreak => "<br>".to_string(),
219            MessageComponent::BlockQuote(inner) => {
220                format!("<blockquote>{}</blockquote>", format_matrix(inner))
221            }
222        })
223        .collect()
224}
225
226#[test]
227fn simple_parsing() {
228    use MessageComponent::*;
229
230    let html =
231        r#"<strong>hello! <i>&lt;&gt;</i></strong> <a href="https://example.com/">example</a>"#;
232
233    assert_eq!(
234        convert_matrix(html),
235        vec![
236            Bold(vec![
237                Plain("hello! ".to_string(),),
238                Italic(vec![Plain("<>".to_string())]),
239            ]),
240            Plain(" ".to_string()),
241            Link {
242                target: "https://example.com/".to_string(),
243                text: vec![Plain("example".to_string())]
244            },
245        ]
246    )
247}
248
249#[test]
250fn spoiler_parsing() {
251    use MessageComponent::*;
252
253    let html = r#"<span data-mx-spoiler>the <em>whole</em> island is populated by lesbians</span>"#;
254
255    assert_eq!(
256        convert_matrix(html),
257        vec![Spoiler {
258            reason: None,
259            content: vec![
260                Plain("the ".to_string()),
261                Italic(vec![Plain("whole".to_string())]),
262                Plain(" island is populated by lesbians".to_string())
263            ]
264        }]
265    );
266}
267
268#[test]
269fn code_parsing() {
270    use MessageComponent::*;
271
272    let html = r#"<code>hello_world();</code>"#;
273
274    assert_eq!(
275        convert_matrix(html),
276        vec![Code("hello_world();".to_string())]
277    );
278
279    let html = r#"<pre><code class="language-javascript">console.log("hello, world!");
280console.table({ a: 1, b: 2, c: 3 });
281</code></pre>"#;
282
283    assert_eq!(
284        convert_matrix(html),
285        vec![CodeBlock {
286            lang: Some("javascript".to_string()),
287            source: "console.log(\"hello, world!\");\nconsole.table({ a: 1, b: 2, c: 3 });\n"
288                .to_string(),
289        }]
290    );
291}