Skip to main content

max / makenotwork

3.9 KB · 124 lines History Blame Raw
1 use std::collections::HashMap;
2
3 use crate::escape::html_escape;
4
5 /// Quote author info for attribution rendering.
6 pub struct QuoteAuthor {
7 pub username: String,
8 pub display_name: String,
9 pub is_removed: bool,
10 }
11
12 /// Post-process rendered HTML to replace `[quote:POST_ID:HASH]` markers with
13 /// clickable author attribution.
14 pub fn post_process_quotes(
15 html: &str,
16 quote_authors: &HashMap<uuid::Uuid, QuoteAuthor>,
17 ) -> String {
18 static QUOTE_RE: std::sync::LazyLock<regex_lite::Regex> = std::sync::LazyLock::new(|| {
19 regex_lite::Regex::new(r"\[quote:([0-9a-f\-]{36}):([0-9a-f]{8})\]").unwrap()
20 });
21 QUOTE_RE
22 .replace_all(html, |caps: &regex_lite::Captures| {
23 let post_id_str = &caps[1];
24 let resolved = uuid::Uuid::parse_str(post_id_str)
25 .ok()
26 .and_then(|post_id| quote_authors.get(&post_id));
27
28 if let Some(author) = resolved {
29 if author.is_removed {
30 format!(
31 "<cite class=\"quote-attribution\"><a href=\"#post-{}\">(original post removed)</a></cite>",
32 post_id_str
33 )
34 } else {
35 format!(
36 "<cite class=\"quote-attribution\"><a href=\"#post-{}\">— {} (@{})</a></cite>",
37 post_id_str,
38 html_escape(&author.display_name),
39 html_escape(&author.username),
40 )
41 }
42 } else {
43 caps[0].to_string()
44 }
45 })
46 .to_string()
47 }
48
49 #[cfg(test)]
50 mod tests {
51 use super::*;
52
53 #[test]
54 fn replaces_quote_marker_with_attribution() {
55 let post_id = uuid::Uuid::new_v4();
56 let mut authors = HashMap::new();
57 authors.insert(
58 post_id,
59 QuoteAuthor {
60 username: "alice".to_string(),
61 display_name: "Alice Smith".to_string(),
62 is_removed: false,
63 },
64 );
65 let input = format!("[quote:{}:abcd1234]", post_id);
66 let result = post_process_quotes(&input, &authors);
67 assert!(result.contains("Alice Smith"));
68 assert!(result.contains("@alice"));
69 assert!(result.contains("quote-attribution"));
70 }
71
72 #[test]
73 fn removed_post_shows_removed_text() {
74 let post_id = uuid::Uuid::new_v4();
75 let mut authors = HashMap::new();
76 authors.insert(
77 post_id,
78 QuoteAuthor {
79 username: "bob".to_string(),
80 display_name: "Bob".to_string(),
81 is_removed: true,
82 },
83 );
84 let input = format!("[quote:{}:abcd1234]", post_id);
85 let result = post_process_quotes(&input, &authors);
86 assert!(result.contains("original post removed"));
87 assert!(!result.contains("Bob"));
88 }
89
90 #[test]
91 fn unknown_post_id_left_unchanged() {
92 let authors = HashMap::new();
93 let input = "[quote:00000000-0000-0000-0000-000000000000:abcd1234]";
94 let result = post_process_quotes(input, &authors);
95 assert_eq!(result, input);
96 }
97
98 #[test]
99 fn non_quote_text_unchanged() {
100 let authors = HashMap::new();
101 let input = "<p>Hello world</p>";
102 let result = post_process_quotes(input, &authors);
103 assert_eq!(result, input);
104 }
105
106 #[test]
107 fn html_escapes_display_name() {
108 let post_id = uuid::Uuid::new_v4();
109 let mut authors = HashMap::new();
110 authors.insert(
111 post_id,
112 QuoteAuthor {
113 username: "user".to_string(),
114 display_name: "A <B> & C".to_string(),
115 is_removed: false,
116 },
117 );
118 let input = format!("[quote:{}:abcd1234]", post_id);
119 let result = post_process_quotes(&input, &authors);
120 assert!(result.contains("A &lt;B&gt; &amp; C"));
121 assert!(!result.contains("<B>"));
122 }
123 }
124