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