1type Attrs = Vec<(String, Option<String>)>;
12type Element = (String, Attrs, String);
13
14#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct Row {
17 pub select_value: Option<String>,
19 pub cells: Vec<String>,
21 pub actions: RowActions,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct RowActions {
28 pub view: Option<String>,
30 pub edit: Option<String>,
32 pub delete_action: Option<String>,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct FilterOption {
40 pub value: String,
42 pub label: String,
44 pub selected: bool,
46}
47
48pub fn rows(html: &str) -> Vec<Row> {
50 let mut out = Vec::new();
51 for (_, attrs, inner) in elements(html, "tr") {
52 let Some(id) = attr_value(&attrs, "id") else {
53 continue;
54 };
55 if !id.starts_with("row-") {
56 continue;
57 }
58 out.push(row(&inner));
59 }
60 out
61}
62
63pub fn row_actions(html: &str, key: &str) -> Option<RowActions> {
66 rows(html).into_iter().find_map(|row| {
67 let matches = row.select_value.as_deref() == Some(key)
68 || row
69 .actions
70 .view
71 .as_deref()
72 .is_some_and(|href| href_contains_key(href, key))
73 || row
74 .actions
75 .edit
76 .as_deref()
77 .is_some_and(|href| href_contains_key(href, key))
78 || row
79 .actions
80 .delete_action
81 .as_deref()
82 .is_some_and(|action| href_contains_key(action, key));
83 matches.then_some(row.actions)
84 })
85}
86
87pub fn field_error(html: &str, name: &str) -> Option<String> {
90 let target = format!("{name}-error");
91 for (_, attrs, inner) in tags(html) {
92 if attr_value(&attrs, "id").as_deref() == Some(target.as_str()) {
93 let text = strip_text(&inner);
94 if text.is_empty() {
95 return None;
96 }
97 return Some(text);
98 }
99 }
100 None
101}
102
103pub fn filter_options(html: &str, name: &str) -> Option<Vec<FilterOption>> {
106 for (tag, attrs, inner) in tags(html) {
107 if attr_value(&attrs, "data-filter-name").as_deref() != Some(name) {
108 continue;
109 }
110 if tag != "select" {
111 return None;
112 }
113 let mut options = Vec::new();
114 for (_, option_attrs, option_inner) in elements(&inner, "option") {
115 options.push(FilterOption {
116 value: attr_value(&option_attrs, "value").unwrap_or_default(),
117 label: strip_text(&option_inner),
118 selected: is_selected(&option_attrs),
119 });
120 }
121 return Some(options);
122 }
123 None
124}
125
126fn row(row_html: &str) -> Row {
127 let mut select_value = None;
128 for (tag, attrs, _) in tags(row_html) {
129 if tag == "input" && attr_value(&attrs, "aria-label").as_deref() == Some("Select row") {
130 select_value = attr_value(&attrs, "value");
131 break;
132 }
133 }
134 let mut cells = Vec::new();
135 for (_, _, inner) in elements(row_html, "td") {
136 cells.push(strip_text(&inner));
137 }
138 let mut view_href = None;
139 let mut edit_href = None;
140 let mut delete_action = None;
141 for (tag, attrs, _) in tags(row_html) {
142 match (tag.as_str(), attr_value(&attrs, "aria-label").as_deref()) {
143 ("a", Some("View")) => view_href = attr_value(&attrs, "href"),
144 ("a", Some("Edit")) => edit_href = attr_value(&attrs, "href"),
145 ("button", Some("Delete")) => delete_action = attr_value(&attrs, "formaction"),
146 _ => {}
147 }
148 }
149 Row {
150 select_value,
151 cells,
152 actions: RowActions {
153 view: view_href,
154 edit: edit_href,
155 delete_action,
156 },
157 }
158}
159
160fn href_contains_key(href: &str, key: &str) -> bool {
161 href.split(['?', '#', '/', '&'])
162 .any(|segment| segment == key)
163}
164
165fn is_selected(attrs: &Attrs) -> bool {
166 attrs.iter().any(|(name, _)| name == "selected")
167}
168
169fn attr_value(attrs: &Attrs, name: &str) -> Option<String> {
170 attrs
171 .iter()
172 .find(|(attr, _)| attr == name)
173 .and_then(|(_, value)| value.clone())
174}
175
176fn tags(html: &str) -> Vec<Element> {
177 open_tags(html)
178 .into_iter()
179 .map(|(tag, attrs, open_end)| {
180 if is_void(&tag) {
181 return (tag, attrs, String::new());
182 }
183 let close = format!("</{tag}>");
184 let after = &html[open_end + 1..];
185 match after.find(close.as_str()) {
186 Some(close_at) => (tag, attrs, after[..close_at].to_string()),
187 None => (tag, attrs, String::new()),
188 }
189 })
190 .collect()
191}
192
193fn elements(html: &str, tag: &str) -> Vec<Element> {
194 tags(html)
195 .into_iter()
196 .filter(|(name, _, _)| name == tag)
197 .collect()
198}
199
200fn open_tags(html: &str) -> Vec<(String, Attrs, usize)> {
201 let mut out = Vec::new();
202 let mut pos = 0;
203 while let Some(start) = html[pos..].find('<') {
204 let abs_start = pos + start;
205 let Some(end) = tag_end(html, abs_start) else {
206 break;
207 };
208 let raw = &html[abs_start + 1..end];
209 pos = end + 1;
210 if raw.starts_with('!') || raw.starts_with('?') || raw.starts_with('/') {
211 continue;
212 }
213 let (tag, attrs) = parse_tag(raw);
214 if tag.is_empty() {
215 continue;
216 }
217 out.push((tag, attrs, end));
218 }
219 out
220}
221
222fn parse_tag(raw: &str) -> (String, Attrs) {
223 let mut attrs = Vec::new();
224 let bytes = raw.as_bytes();
225 let mut pos = 0;
226 while pos < bytes.len() && !bytes[pos].is_ascii_whitespace() && bytes[pos] != b'/' {
227 pos += 1;
228 }
229 let tag = raw[..pos].to_ascii_lowercase();
230 while pos < bytes.len() {
231 while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
232 pos += 1;
233 }
234 if pos >= bytes.len() || bytes[pos] == b'/' {
235 break;
236 }
237 let name_start = pos;
238 while pos < bytes.len()
239 && (bytes[pos].is_ascii_alphanumeric()
240 || matches!(bytes[pos], b'-' | b'_' | b':' | b'.' | b'@'))
241 {
242 pos += 1;
243 }
244 if name_start == pos {
245 pos += 1;
246 while pos < bytes.len() && !raw.is_char_boundary(pos) {
247 pos += 1;
248 }
249 continue;
250 }
251 let name = raw[name_start..pos].to_ascii_lowercase();
252 while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
253 pos += 1;
254 }
255 if pos >= bytes.len() || bytes[pos] != b'=' {
256 attrs.push((name, None));
257 continue;
258 }
259 pos += 1;
260 while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
261 pos += 1;
262 }
263 if pos >= bytes.len() {
264 attrs.push((name, Some(String::new())));
265 break;
266 }
267 let quote = bytes[pos];
268 if quote == b'"' || quote == b'\'' {
269 pos += 1;
270 let value_start = pos;
271 while pos < bytes.len() && bytes[pos] != quote {
272 pos += 1;
273 }
274 attrs.push((
275 name,
276 Some(decode_entities(&raw[value_start..pos.min(bytes.len())])),
277 ));
278 pos = (pos + 1).min(bytes.len());
279 } else {
280 let value_start = pos;
281 while pos < bytes.len() && !bytes[pos].is_ascii_whitespace() {
282 pos += 1;
283 }
284 attrs.push((name, Some(decode_entities(&raw[value_start..pos]))));
285 }
286 }
287 (tag, attrs)
288}
289
290fn tag_end(html: &str, start: usize) -> Option<usize> {
291 let bytes = html.as_bytes();
292 let mut quote = None;
293 let mut pos = start + 1;
294 while pos < bytes.len() {
295 let byte = bytes[pos];
296 if let Some(q) = quote {
297 if byte == q {
298 quote = None;
299 }
300 } else if byte == b'"' || byte == b'\'' {
301 quote = Some(byte);
302 } else if byte == b'>' {
303 return Some(pos);
304 }
305 pos += 1;
306 }
307 None
308}
309
310fn is_void(tag: &str) -> bool {
311 matches!(
312 tag,
313 "area"
314 | "base"
315 | "br"
316 | "col"
317 | "embed"
318 | "hr"
319 | "img"
320 | "input"
321 | "link"
322 | "meta"
323 | "param"
324 | "source"
325 | "track"
326 | "wbr"
327 )
328}
329
330fn strip_text(fragment: &str) -> String {
331 let mut text = String::new();
332 let mut pos = 0;
333 while pos < fragment.len() {
334 if let Some(start) = fragment[pos..].find('<') {
335 text.push_str(&fragment[pos..pos + start]);
336 let abs = pos + start;
337 match tag_end(fragment, abs) {
338 Some(end) => pos = end + 1,
339 None => break,
340 }
341 } else {
342 text.push_str(&fragment[pos..]);
343 break;
344 }
345 }
346 decode_entities(&text).trim().to_string()
347}
348
349fn decode_entities(text: &str) -> String {
350 text.replace("<", "<")
351 .replace(">", ">")
352 .replace(""", "\"")
353 .replace("'", "'")
354 .replace("'", "'")
355 .replace("&", "&")
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361
362 const TABLE: &str = r#"<table><thead><tr><th>Name</th></tr></thead><tbody>
363<tr id="row-Ada-12345678"><td><input type="checkbox" value="Ada" aria-label="Select row"></td><td>Ada</td><td><div class="flex"><a href="/admin/dummies/Ada" aria-label="View">V</a><a href="/admin/dummies/Ada/edit" aria-label="Edit">E</a><button type="button" formaction="/admin/dummies/Ada/delete" aria-label="Delete">D</button></div></td></tr>
364<tr id="group-Active-87654321"><td colspan="3">Active (1 on this page)</td></tr>
365<tr id="row-Ken-abcdef12"><td></td><td>Ken</td><td></td></tr>
366<tr><td colspan="3">No records yet</td></tr>
367<tr id="row-NoBox-99999999"><td></td><td>NoBox</td><td><div class="flex"><a href="/admin/dummies/NoBox" aria-label="View">V</a></div></td></tr>
368<tr id="row-Prefixed-aaaaaaaa"><td></td><td>Prefixed</td><td><div class="flex"><button type="button" formaction="/admin/rel/Prefixed/delete?return=%2Fadmin%2Fposts" aria-label="Delete">D</button></div></td></tr>
369</tbody></table>"#;
370
371 #[test]
372 fn rows_skips_group_headers_and_reads_actions() {
373 let found = rows(TABLE);
374 assert_eq!(found.len(), 4);
375 assert_eq!(found[0].select_value.as_deref(), Some("Ada"));
376 assert!(found[0].cells.iter().any(|cell| cell == "Ada"));
377 assert_eq!(found[0].actions.view.as_deref(), Some("/admin/dummies/Ada"));
378 assert_eq!(
379 found[0].actions.edit.as_deref(),
380 Some("/admin/dummies/Ada/edit")
381 );
382 assert_eq!(found[1].select_value, None);
383 assert!(found[1].cells.iter().any(|cell| cell == "Ken"));
384 assert_eq!(found[1].actions.view, None);
385 }
386
387 #[test]
388 fn row_actions_finds_by_key() {
389 let actions = row_actions(TABLE, "Ada").expect("Ada has actions");
390 assert_eq!(actions.view.as_deref(), Some("/admin/dummies/Ada"));
391 assert_eq!(actions.edit.as_deref(), Some("/admin/dummies/Ada/edit"));
392 assert_eq!(
393 actions.delete_action.as_deref(),
394 Some("/admin/dummies/Ada/delete")
395 );
396 let without_checkbox =
397 row_actions(TABLE, "NoBox").expect("a row without a checkbox matches by URL");
398 assert_eq!(
399 without_checkbox.view.as_deref(),
400 Some("/admin/dummies/NoBox")
401 );
402 assert_eq!(without_checkbox.edit, None);
403 let prefixed = row_actions(TABLE, "Prefixed").expect("a delete action matches by URL");
404 assert_eq!(
405 prefixed.delete_action.as_deref(),
406 Some("/admin/rel/Prefixed/delete?return=%2Fadmin%2Fposts")
407 );
408 assert_eq!(row_actions(TABLE, "Ad"), None);
409 assert_eq!(row_actions(TABLE, "Nobody"), None);
410 }
411
412 #[test]
413 fn field_error_reads_the_error_slot() {
414 let html = r#"<div class="ac-field ac-field--error"><input name="name" aria-invalid="true" aria-describedby="name-error"><div id="name-error" class="ac-error">name is required</div></div>"#;
415 assert_eq!(
416 field_error(html, "name").as_deref(),
417 Some("name is required")
418 );
419 assert_eq!(field_error(html, "email"), None);
420 }
421
422 #[test]
423 fn filter_options_reads_values_and_selection() {
424 let html = r#"<label>Status<select name="f.status" data-filter-name="status"><option value="">All</option><option value="draft">draft</option><option value="published" selected="">published</option></select></label><label>Created<input type="date" name="f.created_at" data-filter-name="created_at" value="2024-01-15"></label>"#;
425 let options = filter_options(html, "status").expect("status has options");
426 assert_eq!(options.len(), 3);
427 assert!(options.iter().any(|option| option.value == "draft"));
428 assert_eq!(
429 options
430 .iter()
431 .find(|option| option.value == "published")
432 .map(|option| option.selected),
433 Some(true)
434 );
435 assert_eq!(filter_options(html, "created_at"), None);
436 assert_eq!(filter_options(html, "other"), None);
437 }
438
439 #[test]
440 fn single_quotes_and_entities_decode() {
441 let html =
442 "<select data-filter-name='status'><option value='a&b'>A & B</option></select>";
443 let options = filter_options(html, "status").expect("options");
444 assert_eq!(options[0].value, "a&b");
445 assert_eq!(options[0].label, "A & B");
446 }
447}