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