@@ -1,1425 +0,0 @@
1−/// Converts HTML to htmlang (.hl) syntax.
2−///
3−/// This is a best-effort converter: it handles well-formed HTML and maps known
4−/// elements and CSS properties to their htmlang equivalents. Unknown elements
5−/// are wrapped in `@raw """..."""`.
6−
7−// ---------------------------------------------------------------------------
8−// Simple recursive-descent HTML parser
9−// ---------------------------------------------------------------------------
10−
11−#[derive(Debug)]
12−enum HtmlNode {
13− Element {
14− tag: String,
15− attrs: Vec<(String, String)>,
16− children: Vec<HtmlNode>,
17− self_closing: bool,
18− },
19− Text(String),
20− Comment(String),
21− /// For DOCTYPE, processing instructions, etc. we just keep the raw text.
22− Raw(String),
23−}
24−
25−struct HtmlParser<'a> {
26− src: &'a [u8],
27− pos: usize,
28−}
29−
30−/// Tags that are self-closing in HTML (void elements).
31−const VOID_TAGS: &[&str] = &[
32− "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source",
33− "track", "wbr",
34−];
35−
36−impl<'a> HtmlParser<'a> {
37− fn new(src: &'a str) -> Self {
38− Self {
39− src: src.as_bytes(),
40− pos: 0,
41− }
42− }
43−
44− fn advance(&mut self) {
45− if self.pos < self.src.len() {
46− self.pos += 1;
47− }
48− }
49−
50− fn skip_whitespace(&mut self) {
51− while self.pos < self.src.len() && self.src[self.pos].is_ascii_whitespace() {
52− self.pos += 1;
53− }
54− }
55−
56− fn starts_with(&self, s: &str) -> bool {
57− self.src[self.pos..].starts_with(s.as_bytes())
58− }
59−
60− /// Whether the `<` at the current position opens markup (a tag, closing
61− /// tag, comment or doctype) rather than being a literal `<` in text.
62− fn at_markup(&self) -> bool {
63− self.src.get(self.pos) == Some(&b'<')
64− && self
65− .src
66− .get(self.pos + 1)
67− .is_some_and(|&c| c.is_ascii_alphabetic() || c == b'/' || c == b'!')
68− }
69−
70− /// Consume up to and including `needle`, returning the text before it.
71− /// Without a match, consumes the rest of the input.
72− fn consume_until(&mut self, needle: &str) -> String {
73− self.consume_until_by(needle, |a, b| a == b)
74− }
75−
76− /// Case-insensitive consume-until.
77− fn consume_until_ci(&mut self, needle: &str) -> String {
78− self.consume_until_by(needle, |a, b| a.eq_ignore_ascii_case(b))
79− }
80−
81− fn consume_until_by(&mut self, needle: &str, eq: impl Fn(&[u8], &[u8]) -> bool) -> String {
82− let start = self.pos;
83− let needle = needle.as_bytes();
84− let found = self.src[start..]
85− .windows(needle.len())
86− .position(|w| eq(w, needle));
87− let end = found.map_or(self.src.len(), |i| start + i);
88− self.pos = found.map_or(self.src.len(), |_| end + needle.len());
89− String::from_utf8_lossy(&self.src[start..end]).into_owned()
90− }
91−
92− fn parse_nodes(&mut self, stop_tag: Option<&str>) -> Vec<HtmlNode> {
93− let mut nodes = Vec::new();
94− loop {
95− if self.pos >= self.src.len() {
96− break;
97− }
98− // Check for closing tag
99− if let Some(stop) = stop_tag
100− && self.starts_with("</")
101− {
102− let saved = self.pos;
103− self.pos += 2;
104− let tag = self.parse_tag_name();
105− if tag.eq_ignore_ascii_case(stop) {
106− // Consume the rest of the closing tag
107− self.consume_until(">");
108− break;
109− }
110− // Not our closing tag, restore and let it be handled
111− self.pos = saved;
112− // This closing tag doesn't match; break out to let the
113− // parent handle it.
114− break;
115− }
116−
117− if self.starts_with("<!--") {
118− self.pos += 4;
119− let text = self.consume_until("-->");
120− nodes.push(HtmlNode::Comment(text));
121− } else if self.starts_with("<!") {
122− // DOCTYPE or similar
123− let raw = self.consume_until(">");
124− nodes.push(HtmlNode::Raw(format!("<!{}>", raw)));
125− } else if self.starts_with("</") {
126− // Stray closing tag (no matching open) -- skip it
127− self.consume_until(">");
128− } else if self.at_markup() {
129− if let Some(node) = self.parse_element() {
130− nodes.push(node);
131− }
132− } else {
133− // Text content
134− let text = self.parse_text();
135− if !text.is_empty() {
136− nodes.push(HtmlNode::Text(text));
137− }
138− }
139− }
140− nodes
141− }
142−
143− fn parse_text(&mut self) -> String {
144− let start = self.pos;
145− // A `<` that doesn't open markup (`a < b`, `1 <2`) is literal text.
146− while self.pos < self.src.len() && (self.pos == start || !self.at_markup()) {
147− self.pos += 1;
148− }
149− let raw = String::from_utf8_lossy(&self.src[start..self.pos]);
150− decode_entities(&raw)
151− }
152−
153− fn parse_tag_name(&mut self) -> String {
154− let start = self.pos;
155− while self.pos < self.src.len() {
156− let ch = self.src[self.pos];
157− if ch.is_ascii_alphanumeric() || ch == b'-' || ch == b'_' || ch == b':' {
158− self.pos += 1;
159− } else {
160− break;
161− }
162− }
163− std::str::from_utf8(&self.src[start..self.pos])
164− .unwrap_or("")
165− .to_ascii_lowercase()
166− }
167−
168− fn parse_element(&mut self) -> Option<HtmlNode> {
169− // Consume '<'
170− self.advance();
171− let tag = self.parse_tag_name();
172− if tag.is_empty() {
173− return None;
174− }
175−
176− let mut attrs = Vec::new();
177− loop {
178− self.skip_whitespace();
179− if self.pos >= self.src.len() {
180− break;
181− }
182− if self.starts_with("/>") {
183− self.pos += 2;
184− return Some(HtmlNode::Element {
185− tag,
186− attrs,
187− children: Vec::new(),
188− self_closing: true,
189− });
190− }
191− if self.starts_with(">") {
192− self.advance();
193− break;
194− }
195− // Parse attribute
196− let key = self.parse_attr_name();
197− if key.is_empty() {
198− self.advance(); // skip unknown char
199− continue;
200− }
201− self.skip_whitespace();
202− let value = if self.starts_with("=") {
203− self.advance();
204− self.skip_whitespace();
205− self.parse_attr_value()
206− } else {
207− String::new()
208− };
209− attrs.push((key, value));
210− }
211−
212− // Void / self-closing tags
213− if VOID_TAGS.contains(&tag.as_str()) {
214− return Some(HtmlNode::Element {
215− tag,
216− attrs,
217− children: Vec::new(),
218− self_closing: true,
219− });
220− }
221−
222− // Raw content tags (script, style) -- consume until closing tag
223− if tag == "script" || tag == "style" {
224− let closer = format!("</{}>", tag);
225− let content = self.consume_until_ci(&closer);
226− let children = if content.trim().is_empty() {
227− Vec::new()
228− } else {
229− vec![HtmlNode::Text(content)]
230− };
231− return Some(HtmlNode::Element {
232− tag,
233− attrs,
234− children,
235− self_closing: false,
236− });
237− }
238−
239− let children = self.parse_nodes(Some(&tag));
240− Some(HtmlNode::Element {
241− tag,
242− attrs,
243− children,
244− self_closing: false,
245− })
246− }
247−
248− fn parse_attr_name(&mut self) -> String {
249− let start = self.pos;
250− while self.pos < self.src.len() {
251− let ch = self.src[self.pos];
252− if ch.is_ascii_alphanumeric()
253− || ch == b'-'
254− || ch == b'_'
255− || ch == b':'
256− || ch == b'.'
257− || ch == b'@'
258− {
259− self.pos += 1;
260− } else {
261− break;
262− }
263− }
264− std::str::from_utf8(&self.src[start..self.pos])
265− .unwrap_or("")
266− .to_string()
267− }
268−
269− fn parse_attr_value(&mut self) -> String {
270− if self.pos >= self.src.len() {
271− return String::new();
272− }
273− let quote = self.src[self.pos];
274− if quote == b'"' || quote == b'\'' {
275− self.advance();
276− let start = self.pos;
277− while self.pos < self.src.len() && self.src[self.pos] != quote {
278− self.pos += 1;
279− }
280− let val = std::str::from_utf8(&self.src[start..self.pos])
281− .unwrap_or("")
282− .to_string();
283− if self.pos < self.src.len() {
284− self.advance(); // closing quote
285− }
286− decode_entities(&val)
287− } else {
288− // Unquoted value
289− let start = self.pos;
290− while self.pos < self.src.len()
291− && !self.src[self.pos].is_ascii_whitespace()
292− && self.src[self.pos] != b'>'
293− {
294− self.pos += 1;
295− }
296− std::str::from_utf8(&self.src[start..self.pos])
297− .unwrap_or("")
298− .to_string()
299− }
300− }
301−
302−}
303−
304−/// Decode HTML character references in one pass (so `&lt;` stays `<`),
305−/// including numeric (`—`, `—`) and common named entities.
306−/// Unknown references are kept as written.
307−fn decode_entities(s: &str) -> String {
308− let mut out = String::with_capacity(s.len());
309− let mut rest = s;
310− while let Some(amp) = rest.find('&') {
311− out.push_str(&rest[..amp]);
312− rest = &rest[amp..];
313− let decoded = rest[1..].find(';').filter(|&end| end <= 32).and_then(|end| {
314− let name = &rest[1..1 + end];
315− decode_entity(name).map(|c| (c, end + 2))
316− });
317− match decoded {
318− Some((c, len)) => {
319− out.push(c);
320− rest = &rest[len..];
321− }
322− None => {
323− out.push('&');
324− rest = &rest[1..];
325− }
326− }
327− }
328− out.push_str(rest);
329− out
330−}
331−
332−fn decode_entity(name: &str) -> Option<char> {
333− if let Some(num) = name.strip_prefix('#') {
334− let code = match num.strip_prefix(['x', 'X']) {
335− Some(hex) => u32::from_str_radix(hex, 16).ok()?,
336− None => num.parse().ok()?,
337− };
338− return char::from_u32(code);
339− }
340− Some(match name {
341− "amp" => '&',
342− "lt" => '<',
343− "gt" => '>',
344− "quot" => '"',
345− "apos" => '\'',
346− "nbsp" => '\u{a0}',
347− "copy" => '©',
348− "reg" => '®',
349− "trade" => '™',
350− "hellip" => '…',
351− "mdash" => '—',
352− "ndash" => '–',
353− "lsquo" => '‘',
354− "rsquo" => '’',
355− "ldquo" => '“',
356− "rdquo" => '”',
357− "laquo" => '«',
358− "raquo" => '»',
359− "bull" => '•',
360− "middot" => '·',
361− "deg" => '°',
362− "times" => '×',
363− "divide" => '÷',
364− "euro" => '€',
365− "pound" => '£',
366− "yen" => '¥',
367− "cent" => '¢',
368− "sect" => '§',
369− "para" => '¶',
370− "larr" => '←',
371− "rarr" => '→',
372− "uarr" => '↑',
373− "darr" => '↓',
374− _ => return None,
375− })
376−}
377−
378−// ---------------------------------------------------------------------------
379−// CSS style parsing (inline style="" attribute)
380−// ---------------------------------------------------------------------------
381−
382−fn parse_inline_style(style: &str) -> Vec<(String, String)> {
383− let mut props = Vec::new();
384− for decl in style.split(';') {
385− let decl = decl.trim();
386− if decl.is_empty() {
387− continue;
388− }
389− if let Some(colon) = decl.find(':') {
390− let key = decl[..colon].trim().to_string();
391− let val = decl[colon + 1..].trim().to_string();
392− props.push((key, val));
393− }
394− }
395− props
396−}
397−
398−/// Strips a trailing "px" and returns just the number, if the value is a plain
399−/// px value. Otherwise returns the value as-is.
400−fn strip_px(v: &str) -> &str {
401− v.strip_suffix("px").unwrap_or(v)
402−}
403−
404−/// Strips px from a multi-value string like "10px 20px".
405−fn strip_px_multi(v: &str) -> String {
406− v.split_whitespace()
407− .map(strip_px)
408− .collect::<Vec<_>>()
409− .join(" ")
410−}
411−
412−/// Converts CSS properties to htmlang attribute key-value pairs.
413−/// Returns (attrs, leftover_css) where leftover_css is anything we
414−/// could not convert to a known htmlang attribute.
415−type HlAttr = (String, Option<String>);
416−type CssDecl = (String, String);
417−
418−fn css_to_hl_attrs(styles: &[CssDecl]) -> (Vec<HlAttr>, Vec<CssDecl>) {
419− let mut attrs: Vec<(String, Option<String>)> = Vec::new();
420− let mut leftover: Vec<(String, String)> = Vec::new();
421−
422− // Collect all styles into a map so we can detect flex patterns
423− let style_map: std::collections::HashMap<&str, &str> = styles
424− .iter()
425− .map(|(k, v)| (k.as_str(), v.as_str()))
426− .collect();
427−
428− // Detect flex row/column -- these affect the element kind, not attributes
429− // We skip display:flex and flex-direction here; the caller checks them.
430−
431− for (key, val) in styles {
432− match key.as_str() {
433− // Skip flex layout props -- handled at element-kind level
434− "display" if val == "flex" => {}
435− "flex-direction" => {}
436−
437− "display" if val == "grid" => {
438− attrs.push(("grid".into(), None));
439− }
440− "display" if val == "none" => {
441− attrs.push(("hidden".into(), None));
442− }
443−
444− "padding" => {
445− attrs.push(("padding".into(), Some(strip_px_multi(val))));
446− }
447− "padding-left" | "padding-right" => {
448− // Check for padding-x pattern
449− if key == "padding-left"
450− && let Some(&pr) = style_map.get("padding-right")
451− && pr == val
452− {
453− attrs.push(("padding-x".into(), Some(strip_px(val).into())));
454− continue;
455− }
456− if key == "padding-right" && style_map.get("padding-left") == Some(&val.as_str()) {
457− // Already emitted as padding-x
458− continue;
459− }
460− leftover.push((key.clone(), val.clone()));
461− }
462− "padding-top" | "padding-bottom" => {
463− if key == "padding-top"
464− && let Some(&pb) = style_map.get("padding-bottom")
465− && pb == val
466− {
467− attrs.push(("padding-y".into(), Some(strip_px(val).into())));
468− continue;
469− }
470− if key == "padding-bottom" && style_map.get("padding-top") == Some(&val.as_str()) {
471− continue;
472− }
473− leftover.push((key.clone(), val.clone()));
474− }
475− "padding-inline" => {
476− attrs.push(("padding-inline".into(), Some(strip_px_multi(val))));
477− }
478− "padding-block" => {
479− attrs.push(("padding-block".into(), Some(strip_px_multi(val))));
480− }
481−
482− "margin" => {
483− attrs.push(("margin".into(), Some(strip_px_multi(val))));
484− }
485− "margin-left" | "margin-right" => {
486− if key == "margin-left"
487− && let Some(&mr) = style_map.get("margin-right")
488− && mr == val
489− {
490− attrs.push(("margin-x".into(), Some(strip_px(val).into())));
491− continue;
492− }
493− if key == "margin-right" && style_map.get("margin-left") == Some(&val.as_str()) {
494− continue;
495− }
496− leftover.push((key.clone(), val.clone()));
497− }
498− "margin-top" | "margin-bottom" => {
499− if key == "margin-top"
500− && let Some(&mb) = style_map.get("margin-bottom")
501− && mb == val
502− {
503− attrs.push(("margin-y".into(), Some(strip_px(val).into())));
504− continue;
505− }
506− if key == "margin-bottom" && style_map.get("margin-top") == Some(&val.as_str()) {
507− continue;
508− }
509− leftover.push((key.clone(), val.clone()));
510− }
511− "margin-inline" => {
512− attrs.push(("margin-inline".into(), Some(strip_px_multi(val))));
513− }
514− "margin-block" => {
515− attrs.push(("margin-block".into(), Some(strip_px_multi(val))));
516− }
517−
518− "width" => attrs.push(("width".into(), Some(strip_px(val).into()))),
519− "height" => attrs.push(("height".into(), Some(strip_px(val).into()))),
520− "min-width" => attrs.push(("min-width".into(), Some(strip_px(val).into()))),
521− "max-width" => attrs.push(("max-width".into(), Some(strip_px(val).into()))),
522− "min-height" => attrs.push(("min-height".into(), Some(strip_px(val).into()))),
523− "max-height" => attrs.push(("max-height".into(), Some(strip_px(val).into()))),
524−
525− "gap" => attrs.push(("spacing".into(), Some(strip_px(val).into()))),
526− "column-gap" => attrs.push(("gap-x".into(), Some(strip_px(val).into()))),
527− "row-gap" => attrs.push(("gap-y".into(), Some(strip_px(val).into()))),
528−
529− "background" | "background-color" => {
530− attrs.push(("background".into(), Some(val.clone())));
531− }
532− "color" => attrs.push(("color".into(), Some(val.clone()))),
533−
534− "border" => {
535− // Try to simplify "1px solid #ccc" -> "1 #ccc"
536− attrs.push(("border".into(), Some(simplify_border(val))));
537− }
538− "border-top" => attrs.push(("border-top".into(), Some(simplify_border(val)))),
539− "border-bottom" => attrs.push(("border-bottom".into(), Some(simplify_border(val)))),
540− "border-left" => attrs.push(("border-left".into(), Some(simplify_border(val)))),
541− "border-right" => attrs.push(("border-right".into(), Some(simplify_border(val)))),
542−
543− "border-radius" => {
544− attrs.push(("rounded".into(), Some(strip_px(val).into())));
545− }
546−
547− "font-weight" if val == "bold" || val == "700" => {
548− attrs.push(("bold".into(), None));
549− }
550− "font-weight" => {
551− attrs.push(("font-weight".into(), Some(val.clone())));
552− }
553− "font-style" if val == "italic" => {
554− attrs.push(("italic".into(), None));
555− }
556− "font-style" => {
557− attrs.push(("font-style".into(), Some(val.clone())));
558− }
559− "text-decoration" if val.contains("underline") => {
560− attrs.push(("underline".into(), None));
561− }
562− "text-decoration" => {
563− leftover.push((key.clone(), val.clone()));
564− }
565− "font-size" => attrs.push(("size".into(), Some(strip_px(val).into()))),
566− "font-family" => attrs.push(("font".into(), Some(val.clone()))),
567−
568− "text-align" => attrs.push(("text-align".into(), Some(val.clone()))),
569− "line-height" => attrs.push(("line-height".into(), Some(val.clone()))),
570− "letter-spacing" => {
571− attrs.push(("letter-spacing".into(), Some(strip_px(val).into())));
572− }
573− "text-transform" => attrs.push(("text-transform".into(), Some(val.clone()))),
574− "white-space" => attrs.push(("white-space".into(), Some(val.clone()))),
575−
576− "overflow" => attrs.push(("overflow".into(), Some(val.clone()))),
577− "position" => attrs.push(("position".into(), Some(val.clone()))),
578− "top" => attrs.push(("top".into(), Some(strip_px(val).into()))),
579− "right" => attrs.push(("right".into(), Some(strip_px(val).into()))),
580− "bottom" => attrs.push(("bottom".into(), Some(strip_px(val).into()))),
581− "left" => attrs.push(("left".into(), Some(strip_px(val).into()))),
582− "z-index" => attrs.push(("z-index".into(), Some(val.clone()))),
583−
584− "display" => attrs.push(("display".into(), Some(val.clone()))),
585− "visibility" => attrs.push(("visibility".into(), Some(val.clone()))),
586−
587− "opacity" => attrs.push(("opacity".into(), Some(val.clone()))),
588− "cursor" => attrs.push(("cursor".into(), Some(val.clone()))),
589− "transition" => attrs.push(("transition".into(), Some(val.clone()))),
590− "transform" => attrs.push(("transform".into(), Some(val.clone()))),
591− "backdrop-filter" => attrs.push(("backdrop-filter".into(), Some(val.clone()))),
592− "box-shadow" => attrs.push(("shadow".into(), Some(val.clone()))),
593− "flex-wrap" if val == "wrap" => attrs.push(("wrap".into(), None)),
594− "animation" => attrs.push(("animation".into(), Some(val.clone()))),
595− "aspect-ratio" => attrs.push(("aspect-ratio".into(), Some(val.clone()))),
596− "outline" => attrs.push(("outline".into(), Some(simplify_border(val)))),
597− "filter" => attrs.push(("filter".into(), Some(val.clone()))),
598− "object-fit" => attrs.push(("object-fit".into(), Some(val.clone()))),
599− "object-position" => attrs.push(("object-position".into(), Some(val.clone()))),
600− "text-shadow" => attrs.push(("text-shadow".into(), Some(val.clone()))),
601− "text-overflow" => attrs.push(("text-overflow".into(), Some(val.clone()))),
602− "pointer-events" => attrs.push(("pointer-events".into(), Some(val.clone()))),
603− "user-select" => attrs.push(("user-select".into(), Some(val.clone()))),
604− "justify-content" => attrs.push(("justify-content".into(), Some(val.clone()))),
605− "align-items" => attrs.push(("align-items".into(), Some(val.clone()))),
606−
607− "grid-template-columns" => {
608− attrs.push(("grid-cols".into(), Some(simplify_grid_repeat(val))));
609− }
610− "grid-template-rows" => {
611− attrs.push(("grid-rows".into(), Some(simplify_grid_repeat(val))));
612− }
613− "grid-column" => {
614− if let Some(rest) = val.strip_prefix("span ") {
615− attrs.push(("col-span".into(), Some(rest.trim().into())));
616− } else {
617− leftover.push((key.clone(), val.clone()));
618− }
619− }
620− "grid-row" => {
621− if let Some(rest) = val.strip_prefix("span ") {
622− attrs.push(("row-span".into(), Some(rest.trim().into())));
623− } else {
624− leftover.push((key.clone(), val.clone()));
625− }
626− }
627−
628− "scroll-snap-type" => attrs.push(("scroll-snap-type".into(), Some(val.clone()))),
629− "scroll-snap-align" => attrs.push(("scroll-snap-align".into(), Some(val.clone()))),
630−
631− "flex" if val == "1" => attrs.push(("width".into(), Some("fill".into()))),
632− "flex-shrink" if val == "0" => attrs.push(("width".into(), Some("shrink".into()))),
633−
634− "text-wrap" => attrs.push(("text-wrap".into(), Some(val.clone()))),
635− "will-change" => attrs.push(("will-change".into(), Some(val.clone()))),
636− "touch-action" => attrs.push(("touch-action".into(), Some(val.clone()))),
637− "vertical-align" => attrs.push(("vertical-align".into(), Some(val.clone()))),
638− "contain" => attrs.push(("contain".into(), Some(val.clone()))),
639− "content-visibility" => attrs.push(("content-visibility".into(), Some(val.clone()))),
640− "scroll-margin" => attrs.push(("scroll-margin".into(), Some(strip_px(val).into()))),
641− "scroll-padding" => attrs.push(("scroll-padding".into(), Some(strip_px(val).into()))),
642−
643− _ => {
644− leftover.push((key.clone(), val.clone()));
645− }
646− }
647− }
648−
649− (attrs, leftover)
650−}
651−
652−/// Simplifies a CSS border value like "1px solid #ccc" to "1 #ccc".
653−fn simplify_border(val: &str) -> String {
654− let parts: Vec<&str> = val.split_whitespace().collect();
655− // Common pattern: "1px solid #ccc"
656− if parts.len() == 3 && parts[1] == "solid" {
657− let width = strip_px(parts[0]);
658− let color = parts[2];
659− if color == "currentColor" {
660− width.to_string()
661− } else {
662− format!("{} {}", width, color)
663− }
664− } else if parts.len() == 2 && parts[0].ends_with("px") {
665− // "1px #ccc" (no style)
666− format!("{} {}", strip_px(parts[0]), parts[1])
667− } else {
668− val.to_string()
669− }
670−}
671−
672−/// Simplifies "repeat(3, 1fr)" to "3".
673−fn simplify_grid_repeat(val: &str) -> String {
674− if let Some(inner) = val
675− .strip_prefix("repeat(")
676− .and_then(|s| s.strip_suffix(')'))
677− {
678− let parts: Vec<&str> = inner.splitn(2, ',').collect();
679− if parts.len() == 2 && parts[1].trim() == "1fr" {
680− return parts[0].trim().to_string();
681− }
682− }
683− val.to_string()
684−}
685−
686−// ---------------------------------------------------------------------------
687−// HTML-to-htmlang conversion
688−// ---------------------------------------------------------------------------
689−
690−/// Converts an HTML string to htmlang (.hl) syntax.
691−pub fn convert(html: &str) -> String {
692− let mut parser = HtmlParser::new(html);
693− let nodes = parser.parse_nodes(None);
694−
695− // Unwrap boilerplate: look inside html > body for the real content.
696− let content_nodes = unwrap_boilerplate(&nodes);
697−
698− let mut out = String::new();
699− for node in content_nodes {
700− emit_node(node, 0, &mut out);
701− }
702−
703− // Trim trailing whitespace but keep a final newline
704− let trimmed = out.trim_end();
705− if trimmed.is_empty() {
706− String::new()
707− } else {
708− format!("{}\n", trimmed)
709− }
710−}
711−
712−/// Walks through html/head/body wrappers and returns references to the
713−/// meaningful content nodes.
714−fn unwrap_boilerplate(nodes: &[HtmlNode]) -> Vec<&HtmlNode> {
715− // First pass: skip DOCTYPE, comments, whitespace-only text at top level
716− let meaningful: Vec<&HtmlNode> = nodes
717− .iter()
718− .filter(|n| match n {
719− HtmlNode::Raw(_) => false,
720− HtmlNode::Comment(_) => false,
721− HtmlNode::Text(t) => !t.trim().is_empty(),
722− HtmlNode::Element { .. } => true,
723− })
724− .collect();
725−
726− // If there's a single <html> element, unwrap it
727− if meaningful.len() == 1
728− && let HtmlNode::Element { tag, children, .. } = meaningful[0]
729− && tag == "html"
730− {
731− return unwrap_html_children(children);
732− }
733−
734− // Otherwise, check if there are any html/head/body tags mixed in
735− let mut result = Vec::new();
736− for node in nodes {
737− match node {
738− HtmlNode::Raw(_) => {}
739− HtmlNode::Comment(_) => {}
740− HtmlNode::Text(t) if t.trim().is_empty() => {}
741− HtmlNode::Element { tag, children, .. }
742− if tag == "html" || tag == "head" || tag == "body" =>
743− {
744− if tag == "body" || tag == "html" {
745− for child in children {
746− match child {
747− HtmlNode::Element { tag: t, .. } if t == "head" => {}
748− HtmlNode::Text(t) if t.trim().is_empty() => {}
749− _ => result.push(child),
750− }
751− }
752− }
753− // Skip <head> entirely
754− }
755− _ => result.push(node),
756− }
757− }
758−
759− if result.is_empty() {
760− // Fallback: return everything that's not boilerplate
761− nodes
762− .iter()
763− .filter(|n| !matches!(n, HtmlNode::Raw(_)))
764− .collect()
765− } else {
766− result
767− }
768−}
769−
770−fn unwrap_html_children(children: &[HtmlNode]) -> Vec<&HtmlNode> {
771− let mut result = Vec::new();
772− for child in children {
773− match child {
774− HtmlNode::Element { tag, children, .. } if tag == "head" => {
775− // Skip <head> entirely
776− let _ = children;
777− }
778− HtmlNode::Element { tag, children, .. } if tag == "body" => {
779− for body_child in children {
780− match body_child {
781− HtmlNode::Text(t) if t.trim().is_empty() => {}
782− _ => result.push(body_child),
783− }
784− }
785− }
786− HtmlNode::Text(t) if t.trim().is_empty() => {}
787− _ => result.push(child),
788− }
789− }
790− result
791−}
792−
793−fn indent_str(depth: usize) -> String {
794− " ".repeat(depth)
795−}
796−
797−fn emit_node(node: &HtmlNode, depth: usize, out: &mut String) {
798− match node {
799− HtmlNode::Text(text) => {
800− let trimmed = text.trim();
801− if !trimmed.is_empty() {
802− for line in trimmed.lines() {
803− let l = line.trim();
804− if !l.is_empty() {
805− out.push_str(&indent_str(depth));
806− // A bare line starting with `@`, `[` or `--` would be
807− // read as an element, attribute list or comment.
808− if l.starts_with('@') || l.starts_with('[') || l.starts_with("--") {
809− out.push_str("@text ");
810− }
811− out.push_str(l);
812− out.push('\n');
813− }
814− }
815− }
816− }
817− HtmlNode::Comment(text) => {
818− for line in text.trim().lines() {
819− out.push_str(&indent_str(depth));
820− out.push_str("-- ");
821− out.push_str(line.trim());
822− out.push('\n');
823− }
824− }
825− HtmlNode::Raw(_) => {}
826− HtmlNode::Element {
827− tag,
828− attrs,
829− children,
830− ..
831− } => {
832− emit_element(tag, attrs, children, depth, out);
833− }
834− }
835−}
836−
837−fn emit_element(
838− tag: &str,
839− attrs: &[(String, String)],
840− children: &[HtmlNode],
841− depth: usize,
842− out: &mut String,
843−) {
844− let indent = indent_str(depth);
845−
846− // Collect HTML attributes
847− let style_str = attr_val(attrs, "style").unwrap_or_default();
848− let class_str = attr_val(attrs, "class").unwrap_or_default();
849− let id_str = attr_val(attrs, "id");
850− let href = attr_val(attrs, "href");
851− let src = attr_val(attrs, "src");
852− let alt = attr_val(attrs, "alt");
853−
854− // Parse inline styles
855− let css_props = parse_inline_style(&style_str);
856−
857− // Detect flex layout from styles
858− let is_flex = css_props.iter().any(|(k, v)| k == "display" && v == "flex");
859− let flex_dir = css_props
860− .iter()
861− .find(|(k, _)| k == "flex-direction")
862− .map(|(_, v)| v.as_str());
863−
864− // Map HTML tag to htmlang element
865− let hl_tag = match tag {
866− "div" => {
867− if is_flex {
868− match flex_dir {
869− Some("column") => "@column",
870− _ => "@row",
871− }
872− } else {
873− "@el"
874− }
875− }
876− "span" => "@text",
877− "p" => "@paragraph",
878− "a" => "@link",
879− "img" => "@image",
880− "nav" => "@nav",
881− "header" => "@header",
882− "footer" => "@footer",
883− "main" => "@main",
884− "section" => "@section",
885− "article" => "@article",
886− "aside" => "@aside",
887− "ul" => "@list",
888− "ol" => "@list",
889− "li" => "@item",
890− "table" => "@table",
891− "thead" => "@thead",
892− "tbody" => "@tbody",
893− "tr" => "@tr",
894− "td" => "@td",
895− "th" => "@th",
896− "form" => "@form",
897− "input" => "@input",
898− "button" => "@button",
899− "select" => "@select",
900− "label" => "@label",
901− "details" => "@details",
902− "summary" => "@summary",
903− "blockquote" => "@blockquote",
904− "cite" => "@cite",
905− "code" => "@code",
906− "hr" => "@hr",
907− "figure" => "@figure",
908− "figcaption" => "@figcaption",
909− "video" => "@video",
910− "audio" => "@audio",
911− "dialog" => "@dialog",
912− "dl" => "@dl",
913− "dt" => "@dt",
914− "dd" => "@dd",
915− "fieldset" => "@fieldset",
916− "legend" => "@legend",
917− "picture" => "@picture",
918− "source" => "@source",
919− "mark" => "@mark",
920− "kbd" => "@kbd",
921− "abbr" => "@abbr",
922− "time" => "@time",
923− "progress" => "@progress",
924− "meter" => "@meter",
925− "h1" => "@h1",
926− "h2" => "@h2",
927− "h3" => "@h3",
928− "h4" => "@h4",
929− "h5" => "@h5",
930− "h6" => "@h6",
931− "b" | "strong" => "@text",
932− "i" | "em" => "@text",
933− "u" => "@text",
934− // Whitespace-sensitive content: htmlang text lines are trimmed and
935− // joined, so keep these as HTML.
936− "br" | "pre" | "textarea" => {
937− emit_raw_element(tag, attrs, children, depth, out);
938− return;
939− }
940− "iframe" => "@iframe",
941− "output" => "@output",
942− "canvas" => "@canvas",
943− "noscript" => "@noscript",
944− "address" => "@address",
945− "search" => "@search",
946− // Unknown / raw elements
947− "script" | "style" | "svg" | "object" | "embed" => {
948− emit_raw_element(tag, attrs, children, depth, out);
949− return;
950− }
951− _ => {
952− // Try to emit as raw
953− emit_raw_element(tag, attrs, children, depth, out);
954− return;
955− }
956− };
957−
958− // Build htmlang attributes from CSS + HTML attrs
959− let (mut hl_attrs, leftover_css) = css_to_hl_attrs(&css_props);
960−
961− // Only a div can become @row/@column. For other flex containers
962− // (`<nav style="display:flex">`) keep the semantic element and lay the
963− // children out in a nested @row/@column that takes the flex attributes.
964− let nested_flex = (is_flex && tag != "div").then(|| {
965− let kind = match flex_dir {
966− Some("column") | Some("column-reverse") => "@column",
967− _ => "@row",
968− };
969− let (flex_attrs, rest): (Vec<HlAttr>, Vec<HlAttr>) =
970− hl_attrs.drain(..).partition(|(k, _)| {
971− matches!(
972− k.as_str(),
973− "spacing" | "gap-x" | "gap-y" | "wrap" | "justify-content" | "align-items"
974− )
975− });
976− hl_attrs = rest;
977− (kind, flex_attrs)
978− });
979−
980− // Add id if present
981− if let Some(id) = &id_str {
982− hl_attrs.insert(0, ("id=".into(), Some(id.clone())));
983− }
984−
985− // Bold/italic for strong/em/b/i/u
986− match tag {
987− "b" | "strong" if !hl_attrs.iter().any(|(k, _)| k == "bold") => {
988− hl_attrs.push(("bold".into(), None));
989− }
990− "i" | "em" if !hl_attrs.iter().any(|(k, _)| k == "italic") => {
991− hl_attrs.push(("italic".into(), None));
992− }
993− "u" if !hl_attrs.iter().any(|(k, _)| k == "underline") => {
994− hl_attrs.push(("underline".into(), None));
995− }
996− _ => {}
997− }
998−
999− // Ordered list
1000− if tag == "ol" {
1001− hl_attrs.push(("ordered".into(), None));
1002− }
1003−
1004− // Alt text for images
1005− if tag == "img"
1006− && let Some(alt_text) = &alt
1007− && !alt_text.is_empty()
1008− {
1009− hl_attrs.push(("alt=".into(), Some(alt_text.clone())));
1010− }
1011−
1012− // Every other HTML attribute carries over as `key=value` (a key ending
1013− // in `=` marks an HTML attribute for `push_attr_block`), or bare for
1014− // booleans like `required`.
1015− for (key, val) in attrs {
1016− match key.as_str() {
1017− "style" | "class" | "id" | "href" | "src" | "alt" => {}
1018− _ if val.is_empty()
1019− && htmlang_core::vocab::BOOLEAN_HTML_ATTRS.contains(&key.as_str()) =>
1020− {
1021− hl_attrs.push((key.clone(), None));
1022− }
1023− _ => hl_attrs.push((format!("{}=", key), Some(val.clone()))),
1024− }
1025− }
1026−
1027− // Build the line
1028− out.push_str(&indent);
1029− out.push_str(hl_tag);
1030−
1031− // Argument (link href, image src)
1032− if tag == "a" {
1033− if let Some(url) = &href {
1034− out.push(' ');
1035− out.push_str(url);
1036− }
1037− } else if tag == "img"
1038− && let Some(src_val) = &src
1039− {
1040− out.push(' ');
1041− out.push_str(src_val);
1042− }
1043−
1044− push_attr_block(&hl_attrs, out);
1045− out.push('\n');
1046−
1047− // Comment with original classes (if any)
1048− if !class_str.is_empty() {
1049− out.push_str(&indent_str(depth + 1));
1050− out.push_str("-- classes: ");
1051− out.push_str(&class_str);
1052− out.push('\n');
1053− }
1054−
1055− // Comment with leftover CSS that couldn't be mapped
1056− if !leftover_css.is_empty() {
1057− let css_text: Vec<String> = leftover_css
1058− .iter()
1059− .map(|(k, v)| format!("{}: {}", k, v))
1060− .collect();
1061− out.push_str(&indent_str(depth + 1));
1062− out.push_str("-- unmapped css: ");
1063− out.push_str(&css_text.join("; "));
1064− out.push('\n');
1065− }
1066−
1067− // Emit children
1068− let child_depth = match &nested_flex {
1069− Some((kind, flex_attrs)) => {
1070− out.push_str(&indent_str(depth + 1));
1071− out.push_str(kind);
1072− push_attr_block(flex_attrs, out);
1073− out.push('\n');
1074− depth + 2
1075− }
1076− None => depth + 1,
1077− };
1078− for child in children {
1079− emit_node(child, child_depth, out);
1080− }
1081−}
1082−
1083−/// Append ` [key value, ...]` for a non-empty attribute list. Values that
1084−/// contain a `,` or `]` are quoted so they stay one attribute.
1085−fn push_attr_block(attrs: &[HlAttr], out: &mut String) {
1086− if attrs.is_empty() {
1087− return;
1088− }
1089− out.push_str(" [");
1090− for (i, (key, val)) in attrs.iter().enumerate() {
1091− if i > 0 {
1092− out.push_str(", ");
1093− }
1094− out.push_str(key);
1095− if let Some(v) = val {
1096− // HTML attributes (`key=`) take the value right after the `=`.
1097− if !key.ends_with('=') {
1098− out.push(' ');
1099− }
1100− if v.contains(',') || v.contains(']') {
1101− out.push('"');
1102− out.push_str(&v.replace('"', "'"));
1103− out.push('"');
1104− } else {
1105− out.push_str(v);
1106− }
1107− }
1108− }
1109− out.push(']');
1110−}
1111−
1112−fn emit_raw_element(
1113− tag: &str,
1114− attrs: &[(String, String)],
1115− children: &[HtmlNode],
1116− depth: usize,
1117− out: &mut String,
1118−) {
1119− let indent = indent_str(depth);
1120−
1121− // Reconstruct the HTML for this element
1122− let mut html = String::new();
1123− html.push('<');
1124− html.push_str(tag);
1125− for (key, val) in attrs {
1126− html.push(' ');
1127− html.push_str(key);
1128− if !val.is_empty() {
1129− html.push_str("=\"");
1130− html.push_str(&html_escape(val));
1131− html.push('"');
1132− }
1133− }
1134− if VOID_TAGS.contains(&tag) {
1135− html.push('>');
1136− } else if children.is_empty() {
1137− html.push_str(&format!("></{}>", tag));
1138− } else {
1139− html.push('>');
1140− reconstruct_html(children, is_raw_text_tag(tag), &mut html);
1141− html.push_str(&format!("</{}>", tag));
1142− }
1143−
1144− let trimmed = html.trim();
1145− if trimmed.contains('\n') || trimmed.len() > 60 {
1146− out.push_str(&indent);
1147− out.push_str("@raw \"\"\"\n");
1148− // Raw content is emitted verbatim by the compiler, so indenting it
1149− // here would change the output (e.g. inside <pre>).
1150− for line in trimmed.lines() {
1151− out.push_str(line);
1152− out.push('\n');
1153− }
1154− out.push_str(&indent);
1155− out.push_str("\"\"\"\n");
1156− } else {
1157− out.push_str(&indent);
1158− out.push_str("@raw \"\"\"");
1159− out.push_str(trimmed);
1160− out.push_str("\"\"\"\n");
1161− }
1162−}
1163−
1164−/// Elements whose text content is not HTML (and was not entity-decoded).
1165−fn is_raw_text_tag(tag: &str) -> bool {
1166− tag == "script" || tag == "style"
1167−}
1168−
1169−fn reconstruct_html(nodes: &[HtmlNode], raw_text: bool, out: &mut String) {
1170− for node in nodes {
1171− match node {
1172− HtmlNode::Text(t) if raw_text => out.push_str(t),
1173− HtmlNode::Text(t) => out.push_str(&html_escape(t)),
1174− HtmlNode::Comment(c) => {
1175− out.push_str("<!--");
1176− out.push_str(c);
1177− out.push_str("-->");
1178− }
1179− HtmlNode::Raw(r) => out.push_str(r),
1180− HtmlNode::Element {
1181− tag,
1182− attrs,
1183− children,
1184− self_closing,
1185− } => {
1186− out.push('<');
1187− out.push_str(tag);
1188− for (key, val) in attrs {
1189− out.push(' ');
1190− out.push_str(key);
1191− if !val.is_empty() {
1192− out.push_str("=\"");
1193− out.push_str(&html_escape(val));
1194− out.push('"');
1195− }
1196− }
1197− if *self_closing {
1198− out.push_str(" />");
1199− } else {
1200− out.push('>');
1201− reconstruct_html(children, is_raw_text_tag(tag), out);
1202− out.push_str(&format!("</{}>", tag));
1203− }
1204− }
1205− }
1206− }
1207−}
1208−
1209−fn html_escape(s: &str) -> String {
1210− s.replace('&', "&")
1211− .replace('<', "<")
1212− .replace('>', ">")
1213− .replace('"', """)
1214−}
1215−
1216−fn attr_val(attrs: &[(String, String)], key: &str) -> Option<String> {
1217− attrs
1218− .iter()
1219− .find(|(k, _)| k == key)
1220− .map(|(_, v)| v.clone())
1221− .filter(|v| !v.is_empty())
1222−}
1223−
1224−// ---------------------------------------------------------------------------
1225−// Tests
1226−// ---------------------------------------------------------------------------
1227−
1228−#[cfg(test)]
1229−mod tests {
1230− use super::*;
1231−
1232− #[test]
1233− fn simple_div() {
1234− let html =
1235− r#"<div style="padding: 20px; background-color: #fff; border-radius: 8px">Hello</div>"#;
1236− let result = convert(html);
1237− assert!(result.contains("@el [padding 20, background #fff, rounded 8]"));
1238− assert!(result.contains("Hello"));
1239− }
1240−
1241− #[test]
1242− fn flex_row() {
1243− let html = r#"<div style="display: flex; flex-direction: row; gap: 10px"><span>A</span><span>B</span></div>"#;
1244− let result = convert(html);
1245− assert!(result.contains("@row [spacing 10]"));
1246− }
1247−
1248− #[test]
1249− fn flex_column() {
1250− let html = r#"<div style="display: flex; flex-direction: column"><p>One</p></div>"#;
1251− let result = convert(html);
1252− assert!(result.contains("@column"));
1253− }
1254−
1255− #[test]
1256− fn link_element() {
1257− let html = r#"<a href="https://example.com">Click me</a>"#;
1258− let result = convert(html);
1259− assert!(result.contains("@link https://example.com"));
1260− assert!(result.contains("Click me"));
1261− }
1262−
1263− #[test]
1264− fn image_element() {
1265− let html = r#"<img src="photo.jpg" alt="A photo">"#;
1266− let result = convert(html);
1267− assert!(result.contains("@image photo.jpg"));
1268− }
1269−
1270− #[test]
1271− fn nested_structure() {
1272− let html = r#"<div><p>Hello</p><p>World</p></div>"#;
1273− let result = convert(html);
1274− assert!(result.contains("@el\n"));
1275− assert!(result.contains(" @paragraph\n"));
1276− assert!(result.contains(" Hello\n"));
1277− }
1278−
1279− #[test]
1280− fn strips_boilerplate() {
1281− let html = r#"<!DOCTYPE html><html><head><title>Test</title></head><body><div>Content</div></body></html>"#;
1282− let result = convert(html);
1283− assert!(!result.contains("html"));
1284− assert!(!result.contains("head"));
1285− assert!(!result.contains("body"));
1286− assert!(result.contains("@el"));
1287− assert!(result.contains("Content"));
1288− }
1289−
1290− #[test]
1291− fn script_to_raw() {
1292− let html = r#"<script>alert("hi");</script>"#;
1293− let result = convert(html);
1294− assert!(result.contains("@raw"));
1295− assert!(result.contains("alert"));
1296− }
1297−
1298− #[test]
1299− fn classes_as_comments() {
1300− let html = r#"<div class="container main-content">Text</div>"#;
1301− let result = convert(html);
1302− assert!(result.contains("-- classes: container main-content"));
1303− }
1304−
1305− #[test]
1306− fn heading_elements() {
1307− let h1 = convert("<h1>Title</h1>");
1308− assert!(h1.contains("@h1"));
1309− let h3 = convert("<h3>Sub</h3>");
1310− assert!(h3.contains("@h3"));
1311− }
1312−
1313− #[test]
1314− fn strong_bold() {
1315− let result = convert("<strong>Important</strong>");
1316− assert!(result.contains("@text [bold]"));
1317− }
1318−
1319− #[test]
1320− fn ordered_list() {
1321− let result = convert("<ol><li>First</li><li>Second</li></ol>");
1322− assert!(result.contains("@list [ordered]"));
1323− assert!(result.contains("@item"));
1324− }
1325−
1326− #[test]
1327− fn semantic_elements() {
1328− let result = convert("<nav><a href=\"/\">Home</a></nav>");
1329− assert!(result.contains("@nav"));
1330− assert!(result.contains("@link /"));
1331− }
1332−
1333− #[test]
1334− fn table_structure() {
1335− let html = "<table><thead><tr><th>Name</th></tr></thead><tbody><tr><td>Alice</td></tr></tbody></table>";
1336− let result = convert(html);
1337− assert!(result.contains("@table"));
1338− assert!(result.contains("@thead"));
1339− assert!(result.contains("@th"));
1340− assert!(result.contains("@tbody"));
1341− assert!(result.contains("@td"));
1342− }
1343−
1344− #[test]
1345− fn form_elements() {
1346− let html = r#"<form action="/submit"><input type="text" placeholder="Name"><button>Go</button></form>"#;
1347− let result = convert(html);
1348− assert!(result.contains("@form [action=/submit]"));
1349− assert!(result.contains("@input [type=text, placeholder=Name]"));
1350− assert!(result.contains("@button"));
1351− }
1352−
1353− #[test]
1354− fn empty_input() {
1355− assert_eq!(convert("").trim(), "");
1356− assert_eq!(convert(" ").trim(), "");
1357− }
1358−
1359− #[test]
1360− fn border_simplification() {
1361− let result = convert(r#"<div style="border: 1px solid #ccc">X</div>"#);
1362− assert!(result.contains("border 1 #ccc"));
1363− }
1364−
1365− #[test]
1366− fn rounded_shorthand() {
1367− let result = convert(r#"<div style="border-radius: 8px">X</div>"#);
1368− assert!(result.contains("rounded 8"));
1369− }
1370−
1371− #[test]
1372− fn bare_less_than_is_text() {
1373− let result = convert("<p>a < b and 1 <2 ok</p><p>after</p>");
1374− assert!(result.contains("a < b and 1 <2 ok"), "{result}");
1375− assert!(result.contains("after"), "{result}");
1376− assert!(!result.contains("@raw"), "{result}");
1377− }
1378−
1379− #[test]
1380− fn script_content_is_not_escaped() {
1381− let result = convert("<script>if (a < b && c > d) {}</script>");
1382− assert!(result.contains("if (a < b && c > d) {}"), "{result}");
1383− }
1384−
1385− #[test]
1386− fn entities_decode_once_and_fully() {
1387− assert_eq!(decode_entities("&lt;tag&gt;"), "<tag>");
1388− assert_eq!(decode_entities("© — — &bogus; a&b"), "© — — &bogus; a&b");
1389− }
1390−
1391− #[test]
1392− fn values_with_commas_are_quoted() {
1393− let result = convert(r#"<input type="text" placeholder="Name, email">"#);
1394− assert!(result.contains(r#"placeholder="Name, email""#), "{result}");
1395− }
1396−
1397− #[test]
1398− fn text_that_looks_like_syntax_is_escaped() {
1399− let result = convert("<p>-- not a comment</p><p>@mention</p>");
1400− assert!(result.contains("@text -- not a comment"), "{result}");
1401− assert!(result.contains("@text @mention"), "{result}");
1402− }
1403−
1404− #[test]
1405− fn whitespace_sensitive_elements_stay_raw() {
1406− let result = convert("<div>line1<br>line2</div><pre> indented\n more</pre>");
1407− assert!(result.contains(r#"@raw """<br>""""#), "{result}");
1408− assert!(result.contains("<pre> indented\n more</pre>"), "{result}");
1409− }
1410−
1411− #[test]
1412− fn flex_on_semantic_element_nests_a_row() {
1413− let result = convert(r#"<nav style="display:flex; gap: 8px"><a href="/">Home</a></nav>"#);
1414− assert!(result.contains("@nav\n @row [spacing 8]\n @link /"), "{result}");
1415− }
1416−
1417− #[test]
1418− fn large_script_converts_quickly() {
1419− let body = "x".repeat(200_000);
1420− let start = std::time::Instant::now();
1421− let result = convert(&format!("<script>{body}</script>"));
1422− assert!(result.contains(&body[..100]));
1423− assert!(start.elapsed() < std::time::Duration::from_secs(2));
1424− }
1425−}