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
13pub(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
20const 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
33pub 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 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 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 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 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
443fn 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
461fn 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
500pub(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 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;