1pub struct CsvTableOptions {
9 pub separator: char,
11 pub has_header: bool,
13 pub caption: Option<String>,
15 pub class: String,
17 pub data_type: Option<String>,
20}
21
22pub fn render(content: &str, options: &CsvTableOptions) -> String {
31 let rows = parse_rows(content, options.separator);
32 let table = build_table(&rows, options);
33 format!("<div class=\"moss-table-scroll\" tabindex=\"0\">{table}</div>")
34}
35
36fn parse_rows(content: &str, sep: char) -> Vec<Vec<String>> {
37 let mut rows: Vec<Vec<String>> = Vec::new();
41 let mut row: Vec<String> = Vec::new();
42 let mut field = String::new();
43 let mut in_quotes = false;
44 let mut chars = content.chars().peekable();
45
46 while let Some(c) = chars.next() {
47 match (c, in_quotes) {
48 ('"', true) if chars.peek() == Some(&'"') => {
49 chars.next();
50 field.push('"');
51 }
52 ('"', true) => in_quotes = false,
53 ('"', false) => in_quotes = true,
54 (c, true) => field.push(c),
55 (c, false) if c == sep => {
56 row.push(std::mem::take(&mut field));
57 }
58 ('\n', false) => {
59 row.push(std::mem::take(&mut field));
60 rows.push(std::mem::take(&mut row));
61 }
62 ('\r', false) => { }
63 (c, false) => field.push(c),
64 }
65 }
66 if !field.is_empty() || !row.is_empty() {
67 row.push(field);
68 rows.push(row);
69 }
70 rows
71}
72
73fn build_table(rows: &[Vec<String>], opts: &CsvTableOptions) -> String {
74 let data_type_attr = opts.data_type.as_deref()
75 .map(|t| format!(" data-type=\"{}\"", t))
76 .unwrap_or_default();
77 if rows.is_empty() {
78 return format!("<table class=\"{}\"{}></table>", opts.class, data_type_attr);
79 }
80 let mut out = String::new();
81 out.push_str(&format!("<table class=\"{}\"{}>", opts.class, data_type_attr));
82 if let Some(cap) = &opts.caption {
83 out.push_str(&format!("<caption>{}</caption>", escape(cap)));
84 }
85 let (header, body) = if opts.has_header {
86 (Some(&rows[0]), &rows[1..])
87 } else {
88 (None, rows)
89 };
90 if let Some(h) = header {
91 out.push_str("<thead><tr>");
92 for cell in h {
93 out.push_str(&format!("<th>{}</th>", escape(cell)));
94 }
95 out.push_str("</tr></thead>");
96 }
97 out.push_str("<tbody>");
98 for row in body {
99 out.push_str("<tr>");
100 for cell in row {
101 out.push_str(&format!("<td>{}</td>", escape(cell)));
102 }
103 out.push_str("</tr>");
104 }
105 out.push_str("</tbody></table>");
106 out
107}
108
109fn escape(s: &str) -> String {
110 s.replace('&', "&")
111 .replace('<', "<")
112 .replace('>', ">")
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118
119 fn opts_default() -> CsvTableOptions {
120 CsvTableOptions {
121 separator: ',',
122 has_header: true,
123 caption: None,
124 class: "moss-embed".to_string(),
125 data_type: Some("table".to_string()),
126 }
127 }
128
129 #[test]
130 fn test_csv_basic() {
131 let csv = "name,age\nAlice,30\nBob,25\n";
132 let out = render(csv, &opts_default());
133 assert!(out.contains("<thead><tr><th>name</th><th>age</th></tr></thead>"));
134 assert!(out.contains("<td>Alice</td><td>30</td>"));
135 assert!(out.contains("<td>Bob</td><td>25</td>"));
136 assert!(out.contains("class=\"moss-embed\" data-type=\"table\""));
137 }
138
139 #[test]
140 fn test_csv_no_header() {
141 let csv = "a,1\nb,2\n";
142 let opts = CsvTableOptions {
143 has_header: false,
144 ..opts_default()
145 };
146 let out = render(csv, &opts);
147 assert!(!out.contains("<thead>"), "got: {}", out);
148 assert!(out.contains("<td>a</td>"));
149 }
150
151 #[test]
152 fn test_csv_quoted_field_with_comma() {
153 let csv = "name,note\n\"Smith, J\",\"hi, world\"\n";
154 let out = render(csv, &opts_default());
155 assert!(out.contains("<td>Smith, J</td>"), "got: {}", out);
156 assert!(out.contains("<td>hi, world</td>"), "got: {}", out);
157 }
158
159 #[test]
160 fn test_csv_escaped_quote() {
161 let csv = "name\n\"he said \"\"hi\"\"\"\n";
162 let out = render(csv, &opts_default());
163 assert!(out.contains("<td>he said \"hi\"</td>"), "got: {}", out);
164 }
165
166 #[test]
167 fn test_csv_html_escape() {
168 let csv = "html\n<script>alert(1)</script>\n";
169 let out = render(csv, &opts_default());
170 assert!(!out.contains("<script>"), "raw script leaked: {}", out);
171 assert!(out.contains("<script>"), "got: {}", out);
172 }
173
174 #[test]
175 fn test_tsv_tab_separator() {
176 let tsv = "a\tb\n1\t2\n";
177 let opts = CsvTableOptions {
178 separator: '\t',
179 ..opts_default()
180 };
181 let out = render(tsv, &opts);
182 assert!(out.contains("<th>a</th>"));
183 assert!(out.contains("<th>b</th>"));
184 assert!(out.contains("<td>1</td><td>2</td>"));
185 }
186
187 #[test]
188 fn test_csv_with_caption() {
189 let csv = "a\n1\n";
190 let opts = CsvTableOptions {
191 caption: Some("My Data".to_string()),
192 ..opts_default()
193 };
194 let out = render(csv, &opts);
195 assert!(out.contains("<caption>My Data</caption>"), "got: {}", out);
196 }
197
198 #[test]
199 fn test_csv_empty() {
200 let out = render("", &opts_default());
201 assert!(
203 out.starts_with("<div class=\"moss-table-scroll\" tabindex=\"0\">"),
204 "got: {}",
205 out
206 );
207 assert!(out.contains("<table"), "got: {}", out);
208 assert!(out.ends_with("</table></div>"), "got: {}", out);
209 }
210
211 #[test]
212 fn test_csv_wrapped_in_scroll_container() {
213 let out = render("name,age\nAlice,30\n", &opts_default());
214 assert!(
215 out.starts_with("<div class=\"moss-table-scroll\" tabindex=\"0\">"),
216 "CSV embed must share the scroll wrapper: {out}"
217 );
218 assert!(out.ends_with("</div>"), "got: {}", out);
219 assert!(out.contains("class=\"moss-embed\" data-type=\"table\""), "{out}");
221 }
222
223 #[test]
224 fn test_csv_crlf_line_endings() {
225 let csv = "a,b\r\n1,2\r\n";
226 let out = render(csv, &opts_default());
227 assert!(out.contains("<th>a</th>"), "got: {}", out);
228 assert!(out.contains("<th>b</th>"), "got: {}", out);
229 assert!(out.contains("<td>1</td><td>2</td>"), "got: {}", out);
230 }
231}