blitz_dom/
form.rs

1use markup5ever::{LocalName, local_name};
2
3use crate::{
4    BaseDocument, ElementData,
5    traversal::{AncestorTraverser, TreeTraverser},
6};
7use blitz_traits::{
8    navigation::NavigationOptions,
9    net::{Body, Entry, EntryValue, FormData, Method},
10};
11use core::str::FromStr;
12use std::fmt::Display;
13
14/// https://url.spec.whatwg.org/#default-encode-set
15const DEFAULT_ENCODE_SET: percent_encoding::AsciiSet = percent_encoding::CONTROLS
16    // Query Set
17    .add(b' ')
18    .add(b'"')
19    .add(b'#')
20    .add(b'<')
21    .add(b'>')
22    // Path Set
23    .add(b'?')
24    .add(b'`')
25    .add(b'{')
26    .add(b'}');
27
28impl BaseDocument {
29    /// Resets the form owner for a given node by either using an explicit form attribute
30    /// or finding the nearest ancestor form element
31    ///
32    /// # Arguments
33    /// * `node_id` - The ID of the node whose form owner needs to be reset
34    ///
35    /// <https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#reset-the-form-owner>
36    pub fn reset_form_owner(&mut self, node_id: usize) {
37        let node = &self.nodes[node_id];
38        let Some(element) = node.element_data() else {
39            return;
40        };
41
42        // First try explicit form attribute
43        let final_owner_id = element
44            .attr(local_name!("form"))
45            .and_then(|owner| self.nodes_to_id.get(owner))
46            .copied()
47            .filter(|owner_id| {
48                self.get_node(*owner_id)
49                    .is_some_and(|node| node.data.is_element_with_tag_name(&local_name!("form")))
50            })
51            .or_else(|| {
52                AncestorTraverser::new(self, node_id).find(|ancestor_id| {
53                    self.nodes[*ancestor_id]
54                        .data
55                        .is_element_with_tag_name(&local_name!("form"))
56                })
57            });
58
59        if let Some(final_owner_id) = final_owner_id {
60            self.controls_to_form.insert(node_id, final_owner_id);
61        }
62    }
63
64    /// Submits a form with the given form node ID and submitter node ID
65    ///
66    /// # Arguments
67    /// * `node_id` - The ID of the form node to submit
68    /// * `submitter_id` - The ID of the node that triggered the submission
69    ///
70    /// <https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#form-submission-algorithm>
71    pub fn submit_form(&self, node_id: usize, submitter_id: usize) {
72        let node = &self.nodes[node_id];
73        let Some(element) = node.element_data() else {
74            return;
75        };
76
77        let entry = construct_entry_list(self, node_id, submitter_id);
78
79        let method = get_form_attr(
80            self,
81            element,
82            local_name!("method"),
83            submitter_id,
84            local_name!("formmethod"),
85        )
86        .and_then(|method| method.parse::<FormMethod>().ok())
87        .unwrap_or(FormMethod::Get);
88
89        let action = get_form_attr(
90            self,
91            element,
92            local_name!("action"),
93            submitter_id,
94            local_name!("formaction"),
95        )
96        .unwrap_or_default();
97
98        let mut parsed_action = self.resolve_url(action);
99
100        let scheme = parsed_action.scheme();
101
102        let enctype = get_form_attr(
103            self,
104            element,
105            local_name!("enctype"),
106            submitter_id,
107            local_name!("formenctype"),
108        )
109        .and_then(|enctype| enctype.parse::<RequestContentType>().ok())
110        .unwrap_or(RequestContentType::FormUrlEncoded);
111
112        let mut post_resource = Body::Empty;
113
114        match (scheme, method) {
115            ("http" | "https" | "data", FormMethod::Get) => {
116                let pairs = convert_to_list_of_name_value_pairs(entry);
117                let mut query = String::new();
118                url::form_urlencoded::Serializer::new(&mut query).extend_pairs(pairs);
119                parsed_action.set_query(Some(&query));
120            }
121            ("http" | "https", FormMethod::Post) => post_resource = Body::Form(entry),
122            ("mailto", FormMethod::Get) => {
123                let pairs = convert_to_list_of_name_value_pairs(entry);
124                parsed_action.query_pairs_mut().extend_pairs(pairs);
125            }
126            ("mailto", FormMethod::Post) => {
127                let pairs = convert_to_list_of_name_value_pairs(entry);
128                let body = match enctype {
129                    RequestContentType::TextPlain => {
130                        let body = encode_text_plain(&pairs);
131                        percent_encoding::utf8_percent_encode(&body, &DEFAULT_ENCODE_SET)
132                            .to_string()
133                    }
134                    _ => {
135                        let mut body = String::new();
136                        url::form_urlencoded::Serializer::new(&mut body).extend_pairs(pairs);
137                        body
138                    }
139                };
140                let mut query = if let Some(query) = parsed_action.query() {
141                    let mut query = query.to_string();
142                    query.push('&');
143                    query
144                } else {
145                    String::new()
146                };
147                query.push_str("body=");
148                query.push_str(&body);
149                parsed_action.set_query(Some(&query));
150            }
151            _ => {
152                #[cfg(feature = "tracing")]
153                tracing::warn!(
154                    "Scheme {} with method {:?} is not implemented",
155                    scheme,
156                    method
157                );
158                return;
159            }
160        }
161
162        let method = method.try_into().unwrap_or_default();
163
164        let navigation_options =
165            NavigationOptions::new(parsed_action, enctype.to_string(), self.id())
166                .set_document_resource(post_resource)
167                .set_method(method);
168
169        self.navigation_provider.navigate_to(navigation_options)
170    }
171}
172
173/// Constructs a list of form entries from form controls
174///
175/// # Arguments
176/// * `doc` - Reference to the base document
177/// * `form_id` - ID of the form element
178/// * `submitter_id` - ID of the element that triggered form submission
179///
180/// # Returns
181/// Returns an EntryList containing all valid form control entries
182///
183/// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#constructing-the-form-data-set
184fn construct_entry_list(doc: &BaseDocument, form_id: usize, submitter_id: usize) -> FormData {
185    let mut entry_list = FormData::new();
186
187    let mut create_entry = |name: &str, value: EntryValue| {
188        entry_list.0.push(Entry {
189            name: name.to_string(),
190            value,
191        });
192    };
193
194    fn datalist_ancestor(doc: &BaseDocument, node_id: usize) -> bool {
195        AncestorTraverser::new(doc, node_id).any(|node_id| {
196            doc.nodes[node_id]
197                .data
198                .is_element_with_tag_name(&local_name!("datalist"))
199        })
200    }
201
202    // For each element field in controls, in tree order:
203    for control_id in TreeTraverser::new(doc) {
204        let Some(node) = doc.get_node(control_id) else {
205            continue;
206        };
207        let Some(element) = node.element_data() else {
208            continue;
209        };
210
211        // Check if the form owner is same as form_id
212        if doc
213            .controls_to_form
214            .get(&control_id)
215            .map(|owner_id| *owner_id != form_id)
216            .unwrap_or(true)
217        {
218            continue;
219        }
220
221        let element_type = element.attr(local_name!("type"));
222
223        //  If any of the following are true:
224        //   field has a datalist element ancestor;
225        //   field is disabled;
226        //   field is a button but it is not submitter;
227        //   field is an input element whose type attribute is in the Checkbox state and whose checkedness is false; or
228        //   field is an input element whose type attribute is in the Radio Button state and whose checkedness is false,
229        //  then continue.
230        if datalist_ancestor(doc, node.id)
231            || element.attr(local_name!("disabled")).is_some()
232            || (element.name.local == local_name!("button") && node.id != submitter_id)
233            || element.name.local == local_name!("input")
234                && ((matches!(element_type, Some("checkbox" | "radio"))
235                    && !element.checkbox_input_checked().unwrap_or(false))
236                    || matches!(element_type, Some("submit" | "button")))
237        {
238            continue;
239        }
240
241        // If the field element is an input element whose type attribute is in the Image Button state, then:
242        if element_type == Some("image") {
243            // If the field element is not submitter, then continue.
244            if node.id != submitter_id {
245                continue;
246            }
247            // TODO: If the field element has a name attribute specified and its value is not the empty string, let name be that value followed by U+002E (.). Otherwise, let name be the empty string.
248            //   Let namex be the concatenation of name and U+0078 (x).
249            //   Let namey be the concatenation of name and U+0079 (y).
250            //   Let (x, y) be the selected coordinate.
251            //   Create an entry with namex and x, and append it to entry list.
252            //   Create an entry with namey and y, and append it to entry list.
253            //   Continue.
254            continue;
255        }
256
257        // TODO: If the field is a form-associated custom element,
258        //  then perform the entry construction algorithm given field and entry list,
259        //  then continue.
260
261        // If either the field element does not have a name attribute specified, or its name attribute's value is the empty string, then continue.
262        // Let name be the value of the field element's name attribute.
263        let Some(name) = element
264            .attr(local_name!("name"))
265            .filter(|str| !str.is_empty())
266        else {
267            continue;
268        };
269
270        // TODO: If the field element is a select element,
271        //  then for each option element in the select element's
272        //  list of options whose selectedness is true and that is not disabled,
273        //  create an entry with name and the value of the option element,
274        //  and append it to entry list.
275
276        // Otherwise, if the field element is an input element whose type attribute is in the Checkbox state or the Radio Button state, then:
277        if element.name.local == local_name!("input")
278            && matches!(element_type, Some("checkbox" | "radio"))
279        {
280            // If the field element has a value attribute specified, then let value be the value of that attribute; otherwise, let value be the string "on".
281            let value = element.attr(local_name!("value")).unwrap_or("on");
282            // Create an entry with name and value, and append it to entry list.
283            create_entry(name, value.into());
284            continue;
285        }
286        // Otherwise, if the field element is an input element whose type attribute is in the File Upload state, then:
287        #[cfg(feature = "file_input")]
288        if element.name.local == local_name!("input") && matches!(element_type, Some("file")) {
289            // If there are no selected files, then create an entry with name and a new File object with an empty name, application/octet-stream as type, and an empty body, and append it to entry list.
290            let Some(files) = element.file_data() else {
291                create_entry(name, EntryValue::EmptyFile);
292                continue;
293            };
294            if files.is_empty() {
295                create_entry(name, EntryValue::EmptyFile);
296            }
297            // Otherwise, for each file in selected files, create an entry with name and a File object representing the file, and append it to entry list.
298            else {
299                for path_buf in files.iter() {
300                    create_entry(name, path_buf.clone().into());
301                }
302            }
303            continue;
304        }
305        //Otherwise, if the field element is an input element whose type attribute is in the Hidden state and name is an ASCII case-insensitive match for "_charset_":
306        if element.name.local == local_name!("input")
307            && element_type == Some("hidden")
308            && name.eq_ignore_ascii_case("_charset_")
309        {
310            // Let charset be the name of encoding.
311            let charset = "UTF-8"; // TODO: Support multiple encodings.
312            // Create an entry with name and charset, and append it to entry list.
313            create_entry(name, charset.into());
314        }
315        // Otherwise, create an entry with name and the value of the field element, and append it to entry list.
316        else if let Some(text) = element.text_input_data() {
317            create_entry(name, text.editor.text().to_string().as_str().into());
318        } else if let Some(value) = element.attr(local_name!("value")) {
319            create_entry(name, value.into());
320        }
321    }
322    entry_list
323}
324
325fn get_form_attr<'a>(
326    doc: &'a BaseDocument,
327    form: &'a ElementData,
328    form_local: impl PartialEq<LocalName>,
329    submitter_id: usize,
330    submitter_local: impl PartialEq<LocalName>,
331) -> Option<&'a str> {
332    get_submitter_attr(doc, submitter_id, submitter_local).or_else(|| form.attr(form_local))
333}
334
335fn get_submitter_attr(
336    doc: &BaseDocument,
337    submitter_id: usize,
338    local_name: impl PartialEq<LocalName>,
339) -> Option<&str> {
340    doc.get_node(submitter_id)
341        .and_then(|node| node.element_data())
342        .and_then(|element_data| {
343            if element_data.name.local == local_name!("button")
344                && element_data.attr(local_name!("type")) == Some("submit")
345            {
346                element_data.attr(local_name)
347            } else {
348                None
349            }
350        })
351}
352
353#[derive(Debug, Copy, Clone, PartialEq, Eq)]
354enum FormMethod {
355    Get,
356    Post,
357    Dialog,
358}
359impl FromStr for FormMethod {
360    type Err = ();
361    fn from_str(s: &str) -> Result<Self, Self::Err> {
362        Ok(match s.to_lowercase().as_str() {
363            "get" => FormMethod::Get,
364            "post" => FormMethod::Post,
365            "dialog" => FormMethod::Dialog,
366            _ => return Err(()),
367        })
368    }
369}
370impl TryFrom<FormMethod> for Method {
371    type Error = &'static str;
372    fn try_from(method: FormMethod) -> Result<Self, Self::Error> {
373        Ok(match method {
374            FormMethod::Get => Method::GET,
375            FormMethod::Post => Method::POST,
376            FormMethod::Dialog => return Err("Dialog is not an HTTP method"),
377        })
378    }
379}
380/// Supported content types for HTTP requests
381#[derive(Debug, Clone)]
382pub enum RequestContentType {
383    /// application/x-www-form-urlencoded
384    FormUrlEncoded,
385    /// multipart/form-data
386    MultipartFormData,
387    /// text/plain
388    TextPlain,
389}
390
391impl FromStr for RequestContentType {
392    type Err = ();
393    fn from_str(s: &str) -> Result<Self, Self::Err> {
394        Ok(match s {
395            "application/x-www-form-urlencoded" => RequestContentType::FormUrlEncoded,
396            "multipart/form-data" => RequestContentType::MultipartFormData,
397            "text/plain" => RequestContentType::TextPlain,
398            _ => return Err(()),
399        })
400    }
401}
402
403impl Display for RequestContentType {
404    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
405        match self {
406            RequestContentType::FormUrlEncoded => write!(f, "application/x-www-form-urlencoded"),
407            RequestContentType::MultipartFormData => write!(f, "multipart/form-data"),
408            RequestContentType::TextPlain => write!(f, "text/plain"),
409        }
410    }
411}
412
413/// Converts the entry list to a vector of name-value pairs with normalized line endings
414/// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#converting-an-entry-list-to-a-list-of-name-value-pairs
415fn convert_to_list_of_name_value_pairs(form_data: FormData) -> Vec<(String, String)> {
416    form_data
417        .iter()
418        .map(|Entry { name, value }| {
419            let name = normalize_line_endings(name.as_ref());
420            let value = normalize_line_endings(value.as_ref());
421            (name, value)
422        })
423        .collect()
424}
425
426/// Normalizes line endings in a string according to HTML spec
427/// Converts single CR or LF to CRLF pairs according to HTML form submission requirements
428fn normalize_line_endings(input: &str) -> String {
429    // Replace every occurrence of U+000D (CR) not followed by U+000A (LF),
430    // and every occurrence of U+000A (LF) not preceded by U+000D (CR),
431    // in value, by a string consisting of U+000D (CR) and U+000A (LF).
432
433    let mut result = String::with_capacity(input.len());
434    let mut chars = input.chars().peekable();
435
436    while let Some(current) = chars.next() {
437        match (current, chars.peek()) {
438            ('\r', Some('\n')) => {
439                result.push_str("\r\n");
440                chars.next();
441            }
442            ('\r' | '\n', _) => {
443                result.push_str("\r\n");
444            }
445            _ => result.push(current),
446        }
447    }
448
449    result
450}
451
452/// Encodes form data as text/plain according to HTML spec given an slice of name-value pairs
453/// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#text/plain-encoding-algorithm
454fn encode_text_plain<T: AsRef<str>, U: AsRef<str>>(input: &[(T, U)]) -> String {
455    let mut out = String::new();
456    for (name, value) in input {
457        out.push_str(name.as_ref());
458        out.push('=');
459        out.push_str(value.as_ref());
460        out.push_str("\r\n");
461    }
462    out
463}