Skip to main content

textlane_html/actions/hast/
stringify.rs

1use std::borrow::Cow;
2
3use textlane_core::Error;
4use textlane_core::hast::{Element, Node, PropertyItem, PropertyValue};
5
6use crate::helpers::omission::{Siblings, is_closing_omitted, is_opening_omitted};
7use crate::helpers::property::find_property;
8use crate::helpers::schema::{
9    BOOLEAN, COMMA_SEPARATED, HTML, Info, OVERLOADED_BOOLEAN, SVG,
10};
11use crate::models::options::Stringify;
12
13// html-void-elements
14pub(crate) const VOID_ELEMENTS: [&str; 20] = [
15    "area", "base", "basefont", "bgsound", "br", "col", "command", "embed", "frame",
16    "hr", "image", "img", "input", "keygen", "link", "meta", "param", "source", "track",
17    "wbr",
18];
19
20// What to escape in attribute names, unquoted, single- and double-quoted values: indexed
21// by whether parse errors are not allowed, then whether dangerous characters are not
22const NAME: [[&str; 2]; 2] = [
23    ["\t\n\x0C\r &/=>", "\t\n\x0C\r \"&'/=>`"],
24    ["\0\t\n\x0C\r \"&'/<=>", "\0\t\n\x0C\r \"&'/<=>`"],
25];
26const UNQUOTED: [[&str; 2]; 2] = [
27    ["\t\n\x0C\r &>", "\0\t\n\x0C\r \"&'<=>`"],
28    ["\0\t\n\x0C\r \"&'<=>`", "\0\t\n\x0C\r \"&'<=>`"],
29];
30const SINGLE: [[&str; 2]; 2] = [["&'", "\"&'`"], ["\0&'", "\0\"&'`"]];
31const DOUBLE: [[&str; 2]; 2] = [["\"&", "\"&'`"], ["\0\"&", "\0\"&'`"]];
32
33/// Serialize hast as HTML, as hast-util-to-html does.
34///
35/// Walks with an explicit stack instead of recursing, so a deeply nested tree cannot
36/// overflow the call stack.
37pub fn stringify_hast(node: &Node<'_>, options: &Stringify) -> Result<String, Error> {
38    let quote = match options.quote.as_deref() {
39        None | Some("" | "\"") => '"',
40        Some("'") => '\'',
41        Some(quote) => {
42            return Err(Error::new(
43                "invalid-options",
44                format!("Invalid quote `{quote}`, expected `'` or `\"`"),
45            ));
46        }
47    };
48    let mut state = State {
49        options,
50        output: String::new(),
51        schema: if options.space.as_deref() == Some("svg") {
52            &SVG
53        } else {
54            &HTML
55        },
56        quote,
57        alternative: if quote == '"' { '\'' } else { '"' },
58    };
59    let mut stack = vec![];
60    state.open(node, 0, None, &mut stack)?;
61    while let Some(step) = stack.last_mut() {
62        match step {
63            Step::Children { siblings, next } => {
64                let siblings = *siblings;
65                let index = *next;
66                match siblings.children.get(index) {
67                    Some(child) => {
68                        *next += 1;
69                        state.open(child, index, Some(siblings), &mut stack)?;
70                    }
71                    None => {
72                        stack.pop();
73                    }
74                }
75            }
76            Step::Close(_) => {
77                if let Some(Step::Close(close)) = stack.pop() {
78                    state.close(close);
79                }
80            }
81        }
82    }
83    Ok(state.output)
84}
85
86struct State<'o> {
87    options: &'o Stringify,
88    output: String,
89    schema: &'static [Info],
90    quote: char,
91    alternative: char,
92}
93
94enum Step<'n, 'a> {
95    Children {
96        siblings: Siblings<'n, 'a>,
97        next: usize,
98    },
99    Close(Close<'n, 'a>),
100}
101
102struct Close<'n, 'a> {
103    element: &'n Element<'a>,
104    index: usize,
105    parent: Option<Siblings<'n, 'a>>,
106    schema: &'static [Info],
107    omit: bool,
108    self_closing: bool,
109    // Where the content starts, to tell an empty element, which closes itself
110    content_start: usize,
111    wrote_start_tag: bool,
112    tight_slash: bool,
113}
114
115fn is_svg(schema: &'static [Info]) -> bool {
116    std::ptr::eq(schema, &SVG[..])
117}
118
119impl<'o> State<'o> {
120    fn open<'n, 'a>(
121        &mut self,
122        node: &'n Node<'a>,
123        index: usize,
124        parent: Option<Siblings<'n, 'a>>,
125        stack: &mut Vec<Step<'n, 'a>>,
126    ) -> Result<(), Error> {
127        match node {
128            Node::Root(root) => {
129                stack.push(Step::Children {
130                    siblings: Siblings {
131                        tag_name: None,
132                        children: &root.children,
133                    },
134                    next: 0,
135                });
136            }
137            Node::Element(element) => self.open_element(element, index, parent, stack),
138            Node::Text(text) => self.write_text(text.value, parent),
139            Node::Raw(raw) => {
140                // Unlike upstream, raw is escaped even in `script` and `style` unless allowed:
141                // its value is HTML from elsewhere, which could close the element early
142                if self.options.allow_dangerous_html {
143                    self.output.push_str(raw.value);
144                } else {
145                    push_escaped(&mut self.output, raw.value, "<&");
146                }
147            }
148            Node::Comment(comment) => self.write_comment(comment.value),
149            Node::Doctype(_) => {
150                self.output.push_str("<!");
151                self.output.push_str(if self.options.upper_doctype {
152                    "DOCTYPE"
153                } else {
154                    "doctype"
155                });
156                if !self.options.tight_doctype {
157                    self.output.push(' ');
158                }
159                self.output.push_str("html>");
160            }
161            Node::Unknown(unknown) => {
162                return Err(Error::new(
163                    "unknown-node",
164                    format!("Cannot compile unknown node `{}`", unknown.kind),
165                ));
166            }
167            Node::MdxJsxFlowElement(_)
168            | Node::MdxJsxTextElement(_)
169            | Node::MdxFlowExpression(_)
170            | Node::MdxTextExpression(_)
171            | Node::MdxjsEsm(_) => {
172                let kind = match node {
173                    Node::MdxJsxFlowElement(_) => "mdxJsxFlowElement",
174                    Node::MdxJsxTextElement(_) => "mdxJsxTextElement",
175                    Node::MdxFlowExpression(_) => "mdxFlowExpression",
176                    Node::MdxTextExpression(_) => "mdxTextExpression",
177                    _ => "mdxjsEsm",
178                };
179                return Err(Error::new(
180                    "unknown-node",
181                    format!("Cannot compile unknown node `{kind}`"),
182                ));
183            }
184        }
185        Ok(())
186    }
187
188    fn open_element<'n, 'a>(
189        &mut self,
190        element: &'n Element<'a>,
191        index: usize,
192        parent: Option<Siblings<'n, 'a>>,
193        stack: &mut Vec<Step<'n, 'a>>,
194    ) {
195        let schema = self.schema;
196        let svg = is_svg(schema);
197        let omit = !svg && self.options.omit_optional_tags;
198        let self_closing = if svg {
199            self.options.close_empty_elements
200        } else {
201            let name = if element
202                .tag_name
203                .bytes()
204                .any(|byte| byte.is_ascii_uppercase() || !byte.is_ascii())
205            {
206                Cow::Owned(element.tag_name.to_lowercase())
207            } else {
208                Cow::Borrowed(element.tag_name)
209            };
210            match &self.options.voids {
211                Some(voids) => voids.iter().any(|void| *void == name),
212                None => VOID_ELEMENTS.contains(&&*name),
213            }
214        };
215        if !svg && element.tag_name == "svg" {
216            self.schema = &SVG;
217        }
218        let start = self.output.len();
219        self.output.push('<');
220        self.output.push_str(element.tag_name);
221        let attributes = self.output.len();
222        self.write_attributes(element);
223        let mut wrote_start_tag = false;
224        let mut tight_slash = false;
225        if self.output.len() == attributes
226            && omit
227            && is_opening_omitted(element, index, parent)
228        {
229            self.output.truncate(start);
230        } else {
231            let last = self.output[attributes..].chars().next_back();
232            tight_slash = self.options.tight_self_closing
233                && last != Some('/')
234                && last.is_none_or(|last| last == '"' || last == '\'');
235            self.output.push('>');
236            wrote_start_tag = true;
237        }
238        let children = match element.content {
239            Some(content) if !svg && element.tag_name == "template" => &content.children,
240            _ => &element.children,
241        };
242        stack.push(Step::Close(Close {
243            element,
244            index,
245            parent,
246            schema,
247            omit,
248            self_closing,
249            content_start: self.output.len(),
250            wrote_start_tag,
251            tight_slash,
252        }));
253        stack.push(Step::Children {
254            siblings: Siblings {
255                tag_name: Some(element.tag_name),
256                children,
257            },
258            next: 0,
259        });
260    }
261
262    fn close(&mut self, close: Close) {
263        let self_closing = close.self_closing && self.output.len() == close.content_start;
264        if self_closing
265            && close.wrote_start_tag
266            && (is_svg(close.schema) || self.options.close_self_closing)
267        {
268            self.output.pop();
269            if !close.tight_slash {
270                self.output.push(' ');
271            }
272            self.output.push_str("/>");
273        }
274        self.schema = close.schema;
275        if !self_closing
276            && (!close.omit
277                || !is_closing_omitted(close.element, close.index, close.parent))
278        {
279            self.output.push_str("</");
280            self.output.push_str(close.element.tag_name);
281            self.output.push('>');
282        }
283    }
284
285    fn write_text(&mut self, value: &str, parent: Option<Siblings>) {
286        if parent
287            .and_then(|parent| parent.tag_name)
288            .is_some_and(|name| name == "script" || name == "style")
289        {
290            self.output.push_str(value);
291        } else {
292            push_escaped(&mut self.output, value, "<&");
293        }
294    }
295
296    fn write_comment(&mut self, value: &str) {
297        if self.options.bogus_comments {
298            self.output.push_str("<?");
299            push_escaped(&mut self.output, value, ">");
300            self.output.push('>');
301            return;
302        }
303        self.output.push_str("<!--");
304        // `/^>|^->|<!--|-->|--!>|<!-$/g`
305        let mut rest = value;
306        let mut at_start = true;
307        while !rest.is_empty() {
308            let found = if at_start && rest.starts_with('>') {
309                Some(1)
310            } else if at_start && rest.starts_with("->") {
311                Some(2)
312            } else if rest.starts_with("<!--") || rest.starts_with("--!>") {
313                Some(4)
314            } else if rest.starts_with("-->") || rest == "<!-" {
315                Some(3)
316            } else {
317                None
318            };
319            match found {
320                Some(size) => {
321                    push_escaped(&mut self.output, &rest[..size], "<>");
322                    rest = &rest[size..];
323                }
324                None => {
325                    let character = rest.chars().next().unwrap_or_default();
326                    self.output.push(character);
327                    rest = &rest[character.len_utf8()..];
328                }
329            }
330            at_start = false;
331        }
332        self.output.push_str("-->");
333    }
334
335    // Each attribute after a space, or right after a quoted one with `tight_attributes`
336    fn write_attributes(&mut self, element: &Element) {
337        let mut tight = false;
338        for property in element.properties.iter() {
339            if matches!(property.value, PropertyValue::Null) {
340                continue;
341            }
342            let mark = self.output.len();
343            if !tight {
344                self.output.push(' ');
345            }
346            let value = self.output.len();
347            self.write_attribute(property.name, &property.value);
348            if self.output.len() == value {
349                self.output.truncate(mark);
350                continue;
351            }
352            tight = self.options.tight_attributes
353                && matches!(self.output.as_bytes().last(), Some(b'"' | b'\''));
354        }
355    }
356
357    fn write_attribute(&mut self, key: &str, value: &PropertyValue) {
358        let found = find_property(self.schema, key);
359        let x = usize::from(!(self.options.allow_parse_errors && !is_svg(self.schema)));
360        let y = usize::from(!self.options.allow_dangerous_characters);
361        let attribute = found.attribute.as_ref();
362        let is_bool = found.kinds & (BOOLEAN | OVERLOADED_BOOLEAN) != 0;
363        let value: Value = match value {
364            PropertyValue::String(text)
365                if found.kinds & OVERLOADED_BOOLEAN != 0
366                    && (*text == attribute || text.is_empty()) =>
367            {
368                Value::Boolean(true)
369            }
370            PropertyValue::String(text)
371                if is_bool && (*text == attribute || text.is_empty()) =>
372            {
373                Value::Boolean(!text.is_empty())
374            }
375            PropertyValue::Number(number) if is_bool => {
376                Value::Boolean(*number != 0.0 && !number.is_nan())
377            }
378            PropertyValue::List(_) if is_bool => Value::Boolean(true),
379            PropertyValue::Null => return,
380            PropertyValue::Boolean(boolean) => Value::Boolean(*boolean),
381            PropertyValue::Number(number) if number.is_nan() => return,
382            PropertyValue::Number(number) => {
383                Value::Text(Cow::Owned(write_js_number(*number)))
384            }
385            PropertyValue::String(text) => Value::Text(Cow::Borrowed(text)),
386            PropertyValue::List(items) => {
387                let items: Vec<String> = items
388                    .iter()
389                    .map(|item| match item {
390                        PropertyItem::Number(number) => write_js_number(*number),
391                        PropertyItem::String(text) => (*text).to_string(),
392                    })
393                    .collect();
394                Value::Text(Cow::Owned(if found.kinds & COMMA_SEPARATED != 0 {
395                    join_commas(&items, !self.options.tight_comma_separated_lists)
396                } else {
397                    items.join(" ").trim_matches(is_js_space).to_string()
398                }))
399            }
400        };
401        let value = match value {
402            Value::Boolean(false) => return,
403            Value::Boolean(true) => {
404                push_escaped(&mut self.output, attribute, NAME[x][y]);
405                return;
406            }
407            Value::Text(value) => value,
408        };
409        push_escaped(&mut self.output, attribute, NAME[x][y]);
410        if self.options.collapse_empty_attributes && value.is_empty() {
411            return;
412        }
413        if self.options.prefer_unquoted && !has_any(&value, UNQUOTED[x][y]) {
414            if !value.is_empty() {
415                self.output.push('=');
416                self.output.push_str(&value);
417            }
418            return;
419        }
420        let mut quote = self.quote;
421        if self.options.quote_smart
422            && value.matches(quote).count() > value.matches(self.alternative).count()
423        {
424            quote = self.alternative;
425        }
426        let subset = if quote == '\'' {
427            SINGLE[x][y]
428        } else {
429            DOUBLE[x][y]
430        };
431        self.output.push('=');
432        self.output.push(quote);
433        push_escaped(&mut self.output, &value, subset);
434        self.output.push(quote);
435    }
436}
437
438enum Value<'v> {
439    Boolean(bool),
440    Text(Cow<'v, str>),
441}
442
443// comma-separated-tokens' `stringify`: a trailing empty item keeps its comma
444fn join_commas(items: &[String], pad_left: bool) -> String {
445    let separator = if pad_left { ", " } else { "," };
446    let mut joined = items.join(separator);
447    if items.last().is_some_and(String::is_empty) {
448        joined.push_str(separator);
449    }
450    joined.trim_matches(is_js_space).to_string()
451}
452
453fn is_js_space(character: char) -> bool {
454    character.is_whitespace() || character == '\u{FEFF}'
455}
456
457fn has_any(value: &str, subset: &str) -> bool {
458    find_any(value.as_bytes(), subset.as_bytes(), 0).is_some()
459}
460
461// stringify-entities with a subset: each listed character as a hexadecimal reference. The
462// subsets are ASCII, so a byte never matches inside a multibyte character
463fn push_escaped(output: &mut String, value: &str, subset: &str) {
464    let bytes = value.as_bytes();
465    let mut start = 0;
466    while let Some(index) = find_any(bytes, subset.as_bytes(), start) {
467        let byte = bytes[index];
468        output.push_str(&value[start..index]);
469        output.push_str("&#x");
470        if byte >= 16 {
471            output.push(char::from(HEX[usize::from(byte >> 4)]));
472        }
473        output.push(char::from(HEX[usize::from(byte & 15)]));
474        output.push(';');
475        start = index + 1;
476    }
477    output.push_str(&value[start..]);
478}
479
480fn find_any(bytes: &[u8], subset: &[u8], from: usize) -> Option<usize> {
481    let rest = &bytes[from..];
482    let found = match *subset {
483        [] => None,
484        [a] => memchr::memchr(a, rest),
485        [a, b] => memchr::memchr2(a, b, rest),
486        [a, b, c] => memchr::memchr3(a, b, c, rest),
487        _ => {
488            let mut mask = [false; 256];
489            for &byte in subset {
490                mask[usize::from(byte)] = true;
491            }
492            rest.iter().position(|&byte| mask[usize::from(byte)])
493        }
494    };
495    found.map(|index| from + index)
496}
497
498const HEX: &[u8; 16] = b"0123456789ABCDEF";
499
500/// A number as JavaScript's `String(number)` writes it.
501pub(crate) fn write_js_number(number: f64) -> String {
502    if number.is_infinite() {
503        return if number > 0.0 {
504            "Infinity"
505        } else {
506            "-Infinity"
507        }
508        .to_string();
509    }
510    if number == 0.0 {
511        return "0".to_string();
512    }
513    let magnitude = number.abs();
514    if (1e-6..1e21).contains(&magnitude) {
515        return format!("{number}");
516    }
517    // JavaScript writes the shortest digits with an exponent: `1e+21`, `1.5e-7`
518    let formatted = format!("{number:e}");
519    match formatted.split_once('e') {
520        Some((mantissa, exponent)) if !exponent.starts_with('-') => {
521            format!("{mantissa}e+{exponent}")
522        }
523        _ => formatted,
524    }
525}
526
527#[cfg(test)]
528#[path = "stringify_unit.rs"]
529mod unit;