blitz_dom/
form.rs

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