on master help sign in
Add 11 improvements: if() conditionals, parallel build, check/convert/new subcommands, JSON diagnostics, --open flag, and LSP enhancements
commitb63259ae7ff4a21cdae8026a563da0340cf4e817 authoriko <mail@iko.soy> dateWed 15 Apr 2026, 14:21 (6 months ago) parentdb89732
9 files changed+1933−28
DESIGN.md+24 src/bin/htmlang-lsp.rs+119 src/convert.rs+1295 src/lib.rs+1 src/main.rs+297−27 src/parser.rs+43−1 tests/snapshots.rs+138 tests/snapshots/convert_basic.hl+4 tests/snapshots/if_expr.hl+12
DESIGN.md+24
@@ -422,6 +422,23 @@ When `@each` has more than one variable and items contain spaces, values are des
422422
423423`var()` and `calc()` expressions are also passed through as-is.
424424
425+## Conditional attribute values
426+
427+Use `if(condition, true_value, false_value)` inside attribute values for conditional styling:
428+
429+```
430+@let active true
431+@let theme dark
432+
433+@el [background if($active, blue, gray)]
434+ Conditionally styled
435+
436+@el [color if($theme == dark, white, black)]
437+ Theme-aware text
438+```
439+
440+Supports equality (`==`), inequality (`!=`), and truthy checks.
441+
425442## Attributes reference
426443
427444### Layout (set on parent)
@@ -599,11 +616,18 @@ This reads the SVG file and embeds its content directly, keeping the output self
599616```
600617htmlang init # scaffold a new project
601618htmlang init my-site # scaffold in a new directory
619+htmlang new about-us # create about-us.hl from template
602620htmlang page.hl # compile page.hl → page.html
603621htmlang site/ # compile all .hl files in directory
604622htmlang --watch page.hl # compile and watch for changes
605623htmlang -w page.hl # short form
606624htmlang --serve site/ # serve a multi-page site with hot reload
625+htmlang -s --open site/ # serve and open browser
626+htmlang check page.hl # check for errors without writing output
627+htmlang check src/ # check all .hl files in a directory
628+htmlang convert page.html # convert HTML to .hl format (stdout)
629+htmlang build src/ -o dist/ # parallel compile + copy static assets
630+htmlang --format json page.hl # output diagnostics as JSON
607631```
608632
609633Watch mode recompiles automatically when the source file or any `@include`d/`@import`ed files change.
src/bin/htmlang-lsp.rs+119
@@ -139,6 +139,18 @@ impl LanguageServer for Backend {
139139 };
140140 drop(docs);
141141
142+ // Check if we're on an @include/@import line for path completions
143+ let lines: Vec<&str> = text.lines().collect();
144+ if let Some(line) = lines.get(pos.line as usize) {
145+ let trimmed = line.trim_start();
146+ if trimmed.starts_with("@include ") || trimmed.starts_with("@import ") {
147+ let items = path_completions(uri, pos);
148+ if !items.is_empty() {
149+ return Ok(Some(CompletionResponse::Array(items)));
150+ }
151+ }
152+ }
153+
142154 let items = completions(&text, pos);
143155 Ok(if items.is_empty() {
144156 None
@@ -427,6 +439,7 @@ fn completions(text: &str, position: Position) -> Vec<CompletionItem> {
427439 let mut items = element_completions(edit_range);
428440 items.extend(directive_completions(edit_range));
429441 items.extend(function_completions(text, edit_range));
442+ items.extend(snippet_completions(edit_range));
430443 return items;
431444 }
432445
@@ -604,6 +617,90 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
604617 .collect()
605618}
606619
620+fn snippet_completions(range: Range) -> Vec<CompletionItem> {
621+ let snippets: &[(&str, &str, &str)] = &[
622+ (
623+ "card component",
624+ "Reusable card with title and content",
625+ "@fn card \\$title\n @el [padding 20, background white, rounded 8]\n @text [bold] \\$title\n @children",
626+ ),
627+ (
628+ "responsive layout",
629+ "Centered responsive column layout",
630+ "@column [max-width 800, center-x, padding 40, spacing 20]",
631+ ),
632+ (
633+ "nav bar",
634+ "Navigation bar with horizontal items",
635+ "@nav [padding 16, background #1a1a2e]\n @row [spacing 20, align-items center]",
636+ ),
637+ (
638+ "hero section",
639+ "Hero section with title and subtitle",
640+ "@column [padding 80, center-x, center-y, min-height 60vh]\n @text [bold, size 48] ${1:Title}\n @paragraph [size 18, color #666] ${2:Subtitle}",
641+ ),
642+ ];
643+
644+ snippets
645+ .iter()
646+ .map(|(label, detail, insert)| CompletionItem {
647+ label: label.to_string(),
648+ kind: Some(CompletionItemKind::SNIPPET),
649+ detail: Some(detail.to_string()),
650+ insert_text_format: Some(InsertTextFormat::SNIPPET),
651+ text_edit: Some(CompletionTextEdit::Edit(TextEdit {
652+ range,
653+ new_text: insert.to_string(),
654+ })),
655+ sort_text: Some(format!("zz_{}", label)),
656+ ..Default::default()
657+ })
658+ .collect()
659+}
660+
661+fn path_completions(uri: &Url, position: Position) -> Vec<CompletionItem> {
662+ let file_path = match uri.to_file_path() {
663+ Ok(p) => p,
664+ Err(_) => return vec![],
665+ };
666+ let dir = match file_path.parent() {
667+ Some(d) => d,
668+ None => return vec![],
669+ };
670+
671+ let col = position.character as u32;
672+ let edit_range = Range::new(Position::new(position.line, col), Position::new(position.line, col));
673+
674+ let entries = match std::fs::read_dir(dir) {
675+ Ok(e) => e,
676+ Err(_) => return vec![],
677+ };
678+
679+ let mut items = Vec::new();
680+ for entry in entries.flatten() {
681+ let path = entry.path();
682+ if path.extension().and_then(|e| e.to_str()) == Some("hl") {
683+ if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
684+ // Skip the current file itself
685+ if Some(name) == file_path.file_name().and_then(|n| n.to_str()) {
686+ continue;
687+ }
688+ items.push(CompletionItem {
689+ label: name.to_string(),
690+ kind: Some(CompletionItemKind::FILE),
691+ detail: Some("htmlang file".to_string()),
692+ text_edit: Some(CompletionTextEdit::Edit(TextEdit {
693+ range: edit_range,
694+ new_text: name.to_string(),
695+ })),
696+ ..Default::default()
697+ });
698+ }
699+ }
700+ }
701+ items
702+}
703+
607704fn attr_completions(range: Range) -> Vec<CompletionItem> {
608705 [
609706 // Layout
@@ -1402,6 +1499,28 @@ fn hover_builtin(word: &str) -> Option<String> {
14021499fn definition_at(text: &str, position: Position, uri: &Url) -> Option<GotoDefinitionResponse> {
14031500 let lines: Vec<&str> = text.lines().collect();
14041501 let line = lines.get(position.line as usize)?;
1502+
1503+ // Check for @include/@import file path navigation
1504+ let trimmed = line.trim();
1505+ if let Some(filename) = trimmed
1506+ .strip_prefix("@include ")
1507+ .or_else(|| trimmed.strip_prefix("@import "))
1508+ {
1509+ let filename = filename.trim();
1510+ if !filename.is_empty() {
1511+ let file_path = uri.to_file_path().ok()?;
1512+ let dir = file_path.parent()?;
1513+ let target = dir.join(filename);
1514+ if target.exists() {
1515+ let target_uri = Url::from_file_path(&target).ok()?;
1516+ return Some(GotoDefinitionResponse::Scalar(Location {
1517+ uri: target_uri,
1518+ range: Range::new(Position::new(0, 0), Position::new(0, 0)),
1519+ }));
1520+ }
1521+ }
1522+ }
1523+
14051524 let col = (position.character as usize).min(line.len());
14061525 let word = word_at(line, col)?;
14071526
src/convert.rsnew file+1295
@@ -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("&amp;", "&")
314+ .replace("&lt;", "<")
315+ .replace("&gt;", ">")
316+ .replace("&quot;", "\"")
317+ .replace("&#39;", "'")
318+ .replace("&apos;", "'")
319+ .replace("&nbsp;", " ")
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('&', "&amp;")
1137+ .replace('<', "&lt;")
1138+ .replace('>', "&gt;")
1139+ .replace('"', "&quot;")
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+}
src/lib.rs+1
@@ -1,5 +1,6 @@
11pub mod ast;
22pub mod codegen;
3+pub mod convert;
34pub mod fmt;
45pub mod parser;
56pub mod serve;
src/main.rs+297−27
@@ -4,12 +4,31 @@ use std::fs;
44use std::hash::{Hash, Hasher};
55use std::path::{Path, PathBuf};
66use std::process;
7+use std::sync::Mutex;
78
8−fn compile(input_path: &str, dev: bool, error_overlay: bool, check_only: bool, output_path: Option<&str>) -> (bool, Vec<PathBuf>) {
9+struct DiagnosticJson {
10+ file: String,
11+ line: usize,
12+ severity: String,
13+ message: String,
14+}
15+
16+fn compile(input_path: &str, dev: bool, error_overlay: bool, check_only: bool, output_path: Option<&str>, format_json: bool, json_collector: Option<&Mutex<Vec<DiagnosticJson>>>) -> (bool, Vec<PathBuf>) {
917 let input = match fs::read_to_string(input_path) {
1018 Ok(s) => s,
1119 Err(e) => {
12− eprintln!("error: {}: {}", input_path, e);
20+ if format_json {
21+ if let Some(collector) = json_collector {
22+ collector.lock().unwrap().push(DiagnosticJson {
23+ file: input_path.to_string(),
24+ line: 0,
25+ severity: "error".to_string(),
26+ message: format!("{}", e),
27+ });
28+ }
29+ } else {
30+ eprintln!("error: {}: {}", input_path, e);
31+ }
1332 return (true, vec![]);
1433 }
1534 };
@@ -17,14 +36,32 @@ fn compile(input_path: &str, dev: bool, error_overlay: bool, check_only: bool, o
1736 let base = Path::new(input_path).parent();
1837 let result = htmlang::parser::parse_with_base(&input, base);
1938
20− for d in &result.diagnostics {
21− let prefix = match d.severity {
22− htmlang::parser::Severity::Error => "error",
23− htmlang::parser::Severity::Warning => "warning",
24− };
25− eprintln!("{}: line {}: {}", prefix, d.line, d.message);
26− if let Some(ref src) = d.source_line {
27− eprintln!(" | {}", src);
39+ if format_json {
40+ if let Some(collector) = json_collector {
41+ let mut collected = collector.lock().unwrap();
42+ for d in &result.diagnostics {
43+ let severity = match d.severity {
44+ htmlang::parser::Severity::Error => "error",
45+ htmlang::parser::Severity::Warning => "warning",
46+ };
47+ collected.push(DiagnosticJson {
48+ file: input_path.to_string(),
49+ line: d.line,
50+ severity: severity.to_string(),
51+ message: d.message.clone(),
52+ });
53+ }
54+ }
55+ } else {
56+ for d in &result.diagnostics {
57+ let prefix = match d.severity {
58+ htmlang::parser::Severity::Error => "error",
59+ htmlang::parser::Severity::Warning => "warning",
60+ };
61+ eprintln!("{}: line {}: {}", prefix, d.line, d.message);
62+ if let Some(ref src) = d.source_line {
63+ eprintln!(" | {}", src);
64+ }
2865 }
2966 }
3067
@@ -60,6 +97,65 @@ fn compile(input_path: &str, dev: bool, error_overlay: bool, check_only: bool, o
6097 (has_errors, result.included_files)
6198}
6299
100+fn print_json_diagnostics(diagnostics: &[DiagnosticJson]) {
101+ let mut json = String::from("{\"diagnostics\":[");
102+ for (i, d) in diagnostics.iter().enumerate() {
103+ if i > 0 {
104+ json.push(',');
105+ }
106+ let escaped_file = d.file.replace('\\', "\\\\").replace('"', "\\\"");
107+ let escaped_msg = d.message.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n");
108+ json.push_str(&format!(
109+ "{{\"file\":\"{}\",\"line\":{},\"severity\":\"{}\",\"message\":\"{}\"}}",
110+ escaped_file, d.line, d.severity, escaped_msg
111+ ));
112+ }
113+ json.push_str("]}");
114+ println!("{}", json);
115+}
116+
117+fn kebab_to_title(s: &str) -> String {
118+ s.split('-')
119+ .map(|word| {
120+ let mut chars = word.chars();
121+ match chars.next() {
122+ Some(c) => {
123+ let mut title = c.to_uppercase().to_string();
124+ title.extend(chars);
125+ title
126+ }
127+ None => String::new(),
128+ }
129+ })
130+ .collect::<Vec<_>>()
131+ .join(" ")
132+}
133+
134+fn copy_non_hl_files(src_dir: &Path, out_dir: &Path) {
135+ copy_non_hl_recursive(src_dir, src_dir, out_dir);
136+}
137+
138+fn copy_non_hl_recursive(base: &Path, dir: &Path, out_dir: &Path) {
139+ if let Ok(entries) = fs::read_dir(dir) {
140+ for entry in entries.flatten() {
141+ let path = entry.path();
142+ if path.is_dir() {
143+ copy_non_hl_recursive(base, &path, out_dir);
144+ } else if path.is_file() && path.extension().map_or(true, |e| e != "hl") {
145+ let rel = path.strip_prefix(base).unwrap_or(&path);
146+ let dest = out_dir.join(rel);
147+ if let Some(parent) = dest.parent() {
148+ let _ = fs::create_dir_all(parent);
149+ }
150+ match fs::copy(&path, &dest) {
151+ Ok(_) => eprintln!("copied {}", dest.display()),
152+ Err(e) => eprintln!("error: copy {}: {}", dest.display(), e),
153+ }
154+ }
155+ }
156+ }
157+}
158+
63159fn generate_error_overlay(diagnostics: &[htmlang::parser::Diagnostic], file: &str) -> String {
64160 let mut errors = String::new();
65161 for d in diagnostics {
@@ -104,31 +200,41 @@ htmlang {} - a minimalist layout language that compiles to static HTML
104200Usage: htmlang [options] <file.hl | directory>
105201
106202Commands:
107− init [dir] Create a new project (defaults to current directory)
108− build <dir> [-o <out>] Compile all .hl files recursively
109− fmt <file.hl> Format a .hl file (normalizes indentation)
110− sitemap <dir> Generate sitemap.xml from .hl files
203+ init [dir] Create a new project (defaults to current directory)
204+ new <page-name> Create a new .hl page from a template
205+ build <dir> [-o <out>] Compile all .hl files recursively (parallel)
206+ check <file.hl | dir> Check for errors without writing output
207+ convert <file.html> Convert an HTML file to .hl format (stdout)
208+ fmt <file.hl> Format a .hl file (normalizes indentation)
209+ sitemap <dir> Generate sitemap.xml from .hl files
111210
112211Options:
113212 -w, --watch Watch for changes and recompile
114213 -s, --serve Start dev server with hot reload (implies --watch)
115214 -p, --port <N> Port for dev server (default: 3000)
215+ --open Open browser automatically (with --serve)
116216 -o, --output <path> Output file/directory path
117217 -d, --dev Development mode
118218 -c, --check Check for errors without writing output
219+ --format json Output diagnostics as JSON to stdout
119220 -h, --help Show this help
120221 -V, --version Show version
121222
122223Examples:
123224 htmlang init Scaffold a new project
124225 htmlang init my-site Scaffold in a new directory
226+ htmlang new about-us Create about-us.hl from template
125227 htmlang page.hl Compile page.hl to page.html
126228 htmlang site/ Compile all .hl files in directory
127229 htmlang -w page.hl Recompile on file changes
128230 htmlang -s page.hl Start dev server with hot reload
231+ htmlang -s --open site/ Serve and open browser
129232 htmlang -s site/ Serve a multi-page site
130233 htmlang -s -p 8080 page.hl
131234 htmlang -c page.hl Lint without writing output
235+ htmlang check src/ Check all files in a directory
236+ htmlang convert page.html Convert HTML to .hl format
237+ htmlang --format json page.hl Get diagnostics as JSON
132238 htmlang fmt page.hl Format a file
133239 htmlang build src/ -o dist/ Compile all .hl files to dist/
134240 htmlang sitemap src/ Generate sitemap.xml",
@@ -224,12 +330,24 @@ fn generate_sitemap(dir: &str, base_url: &str) {
224330 }
225331}
226332
333+fn open_in_browser(port: u16) {
334+ let url = format!("http://127.0.0.1:{}", port);
335+ let cmd = if cfg!(target_os = "macos") {
336+ "open"
337+ } else {
338+ "xdg-open"
339+ };
340+ let _ = std::process::Command::new(cmd).arg(&url).spawn();
341+}
342+
227343fn main() {
228344 let args: Vec<String> = env::args().collect();
229345 let mut watch = false;
230346 let mut serve = false;
231347 let mut dev = false;
232348 let mut check = false;
349+ let mut format_json = false;
350+ let mut open_browser = false;
233351 let mut port: u16 = 3000;
234352 let mut output_path: Option<String> = None;
235353 let mut input_path = None;
@@ -297,22 +415,38 @@ fn main() {
297415 if let Some(out) = out_dir {
298416 let _ = fs::create_dir_all(out);
299417 }
300− let mut any_errors = false;
301− for file in &hl_files {
302− let path_str = file.to_string_lossy().to_string();
303− let effective_out = out_dir.map(|o| {
304− // Mirror directory structure
418+ // Pre-create output directories for each file (must be done before parallel compilation)
419+ let effective_outs: Vec<Option<String>> = hl_files.iter().map(|file| {
420+ out_dir.map(|o| {
305421 let rel = file.strip_prefix(dir).unwrap_or(file);
306422 let out_path = Path::new(o).join(rel).with_extension("html");
307423 if let Some(parent) = out_path.parent() {
308424 let _ = fs::create_dir_all(parent);
309425 }
310426 out_path.to_string_lossy().to_string()
311− });
312− let (has_errors, _) = compile(&path_str, false, false, false, effective_out.as_deref());
313− if has_errors { any_errors = true; }
427+ })
428+ }).collect();
429+
430+ // Compile files in parallel
431+ let any_errors = std::sync::atomic::AtomicBool::new(false);
432+ std::thread::scope(|s| {
433+ for (file, effective_out) in hl_files.iter().zip(effective_outs.iter()) {
434+ let any_errors = &any_errors;
435+ s.spawn(move || {
436+ let path_str = file.to_string_lossy().to_string();
437+ let (has_errors, _) = compile(&path_str, false, false, false, effective_out.as_deref(), false, None);
438+ if has_errors {
439+ any_errors.store(true, std::sync::atomic::Ordering::Relaxed);
440+ }
441+ });
442+ }
443+ });
444+ if any_errors.load(std::sync::atomic::Ordering::Relaxed) { process::exit(1); }
445+
446+ // Copy non-.hl static assets to output directory
447+ if let Some(out) = out_dir {
448+ copy_non_hl_files(dir, Path::new(out));
314449 }
315− if any_errors { process::exit(1); }
316450 return;
317451 }
318452
@@ -327,6 +461,108 @@ fn main() {
327461 return;
328462 }
329463
464+ // Handle "check" subcommand
465+ if args.len() >= 2 && args[1] == "check" {
466+ let mut check_target = None;
467+ let mut check_format_json = false;
468+ let mut ci = 2;
469+ while ci < args.len() {
470+ match args[ci].as_str() {
471+ "--format" => {
472+ ci += 1;
473+ if args.get(ci).map_or(false, |v| v == "json") {
474+ check_format_json = true;
475+ }
476+ }
477+ _ if check_target.is_none() => check_target = Some(args[ci].as_str()),
478+ _ => {
479+ eprintln!("unknown argument: {}", args[ci]);
480+ process::exit(1);
481+ }
482+ }
483+ ci += 1;
484+ }
485+ let target = check_target.unwrap_or(".");
486+ let json_collector = if check_format_json { Some(Mutex::new(Vec::new())) } else { None };
487+ let path = Path::new(target);
488+ let mut any_errors = false;
489+ if path.is_dir() {
490+ let hl_files = collect_hl_files_recursive(path);
491+ if hl_files.is_empty() {
492+ eprintln!("no .hl files found in {}", target);
493+ process::exit(1);
494+ }
495+ for file in &hl_files {
496+ let path_str = file.to_string_lossy().to_string();
497+ let (has_errors, _) = compile(&path_str, false, false, true, None, check_format_json, json_collector.as_ref());
498+ if has_errors { any_errors = true; }
499+ }
500+ } else {
501+ let (has_errors, _) = compile(target, false, false, true, None, check_format_json, json_collector.as_ref());
502+ if has_errors { any_errors = true; }
503+ }
504+ if check_format_json {
505+ if let Some(collector) = json_collector {
506+ print_json_diagnostics(&collector.lock().unwrap());
507+ }
508+ }
509+ if any_errors { process::exit(1); }
510+ return;
511+ }
512+
513+ // Handle "new" subcommand
514+ if args.len() >= 2 && args[1] == "new" {
515+ if args.len() < 3 {
516+ eprintln!("usage: htmlang new <page-name>");
517+ process::exit(1);
518+ }
519+ let page_name = &args[2];
520+ let file_name = format!("{}.hl", page_name);
521+ let file_path = Path::new(&file_name);
522+ if file_path.exists() {
523+ eprintln!("error: {} already exists", file_name);
524+ process::exit(1);
525+ }
526+ let title = kebab_to_title(page_name);
527+ let template = format!(
528+ "@page {title}\n\
529+ @let primary #3b82f6\n\
530+ \n\
531+ @column [max-width 800, center-x, padding 40, spacing 20]\n\
532+ \x20\x20@text [bold, size 32] {title}\n\
533+ \x20\x20@paragraph [line-height 1.6]\n\
534+ \x20\x20\x20\x20Content goes here.\n",
535+ title = title,
536+ );
537+ match fs::write(file_path, &template) {
538+ Ok(()) => eprintln!("created {}", file_name),
539+ Err(e) => {
540+ eprintln!("error: {}: {}", file_name, e);
541+ process::exit(1);
542+ }
543+ }
544+ return;
545+ }
546+
547+ // Handle "convert" subcommand
548+ if args.len() >= 2 && args[1] == "convert" {
549+ if args.len() < 3 {
550+ eprintln!("usage: htmlang convert <file.html>");
551+ process::exit(1);
552+ }
553+ let html_file = &args[2];
554+ let html = match fs::read_to_string(html_file) {
555+ Ok(s) => s,
556+ Err(e) => {
557+ eprintln!("error: {}: {}", html_file, e);
558+ process::exit(1);
559+ }
560+ };
561+ let hl_output = htmlang::convert::convert(&html);
562+ print!("{}", hl_output);
563+ return;
564+ }
565+
330566 let mut i = 1;
331567 while i < args.len() {
332568 match args[i].as_str() {
@@ -341,6 +577,21 @@ fn main() {
341577 "--watch" | "-w" => watch = true,
342578 "--dev" | "-d" => dev = true,
343579 "--check" | "-c" => check = true,
580+ "--open" => open_browser = true,
581+ "--format" => {
582+ i += 1;
583+ match args.get(i) {
584+ Some(f) if f == "json" => format_json = true,
585+ Some(f) => {
586+ eprintln!("unknown format: {}", f);
587+ process::exit(1);
588+ }
589+ None => {
590+ eprintln!("--format requires a value");
591+ process::exit(1);
592+ }
593+ }
594+ }
344595 "--serve" | "-s" => {
345596 serve = true;
346597 watch = true;
@@ -403,6 +654,7 @@ fn main() {
403654 let _ = fs::create_dir_all(out);
404655 }
405656
657+ let json_collector = if format_json { Some(Mutex::new(Vec::new())) } else { None };
406658 let mut any_errors = false;
407659 let mut all_included: Vec<PathBuf> = Vec::new();
408660 for file in &hl_files {
@@ -415,13 +667,19 @@ fn main() {
415667 }
416668 out_p.to_string_lossy().to_string()
417669 });
418− let (has_errors, included) = compile(&path_str, dev, serve, check, effective_out.as_deref());
670+ let (has_errors, included) = compile(&path_str, dev, serve, check, effective_out.as_deref(), format_json, json_collector.as_ref());
419671 if has_errors {
420672 any_errors = true;
421673 }
422674 all_included.extend(included);
423675 }
424676
677+ if format_json {
678+ if let Some(collector) = json_collector {
679+ print_json_diagnostics(&collector.lock().unwrap());
680+ }
681+ }
682+
425683 if !watch {
426684 if any_errors {
427685 process::exit(1);
@@ -438,6 +696,9 @@ fn main() {
438696 let rt = tokio::runtime::Runtime::new().expect("failed to create runtime");
439697 rt.block_on(htmlang::serve::run(port, index_path, server_tx));
440698 });
699+ if open_browser {
700+ open_in_browser(port);
701+ }
441702 Some(tx)
442703 } else {
443704 None
@@ -448,7 +709,13 @@ fn main() {
448709 }
449710
450711 // --- Single file mode ---
451− let (has_errors, included_files) = compile(&input_path, dev, serve, check, output_path.as_deref());
712+ let json_collector_single = if format_json { Some(Mutex::new(Vec::new())) } else { None };
713+ let (has_errors, included_files) = compile(&input_path, dev, serve, check, output_path.as_deref(), format_json, json_collector_single.as_ref());
714+ if format_json {
715+ if let Some(ref collector) = json_collector_single {
716+ print_json_diagnostics(&collector.lock().unwrap());
717+ }
718+ }
452719
453720 if !watch {
454721 if has_errors {
@@ -469,6 +736,9 @@ fn main() {
469736 let rt = tokio::runtime::Runtime::new().expect("failed to create runtime");
470737 rt.block_on(htmlang::serve::run(port, out_path, server_tx));
471738 });
739+ if open_browser {
740+ open_in_browser(port);
741+ }
472742 Some(tx)
473743 } else {
474744 None
@@ -612,7 +882,7 @@ fn watch_loop(
612882 // Recompile all source files
613883 for file in source_files {
614884 let path_str = file.to_string_lossy().to_string();
615− let (_, new_includes) = compile(&path_str, dev, serve, false, None);
885+ let (_, new_includes) = compile(&path_str, dev, serve, false, None, false, None);
616886 for inc in &new_includes {
617887 let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
618888 if let Some(h) = hash_file(inc) {
@@ -626,7 +896,7 @@ fn watch_loop(
626896 for file in &collect_hl_files(watch_dir) {
627897 if !source_files.contains(file) {
628898 let path_str = file.to_string_lossy().to_string();
629− let (_, new_includes) = compile(&path_str, dev, serve, false, None);
899+ let (_, new_includes) = compile(&path_str, dev, serve, false, None, false, None);
630900 for inc in &new_includes {
631901 let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
632902 }
src/parser.rs+43−1
@@ -1613,9 +1613,10 @@ fn parse_attr_list(input: &str, line_num: usize, ctx: &mut ParseContext, validat
16131613 let part = substitute_vars(part, &ctx.variables);
16141614
16151615 let attr = if let Some((key, value)) = part.split_once(' ') {
1616+ let value = evaluate_if_expr(value.trim());
16161617 Attribute {
16171618 key: key.trim().to_string(),
1618− value: Some(value.trim().to_string()),
1619+ value: Some(value),
16191620 }
16201621 } else {
16211622 Attribute {
@@ -1837,6 +1838,47 @@ fn evaluate_condition(condition: &str) -> bool {
18371838 }
18381839}
18391840
1841+/// Evaluate `if(condition, true_val, false_val)` expressions in attribute values.
1842+fn evaluate_if_expr(input: &str) -> String {
1843+ let trimmed = input.trim();
1844+ if !trimmed.starts_with("if(") || !trimmed.ends_with(')') {
1845+ return input.to_string();
1846+ }
1847+ let inner = &trimmed[3..trimmed.len() - 1];
1848+ let parts = split_if_args(inner);
1849+ if parts.len() != 3 {
1850+ return input.to_string();
1851+ }
1852+ let condition = parts[0].trim();
1853+ let true_val = parts[1].trim();
1854+ let false_val = parts[2].trim();
1855+
1856+ if evaluate_condition(condition) {
1857+ true_val.to_string()
1858+ } else {
1859+ false_val.to_string()
1860+ }
1861+}
1862+
1863+fn split_if_args(input: &str) -> Vec<&str> {
1864+ let mut parts = Vec::new();
1865+ let mut start = 0;
1866+ let mut depth = 0;
1867+ for (i, c) in input.char_indices() {
1868+ match c {
1869+ '(' => depth += 1,
1870+ ')' => depth -= 1,
1871+ ',' if depth == 0 => {
1872+ parts.push(&input[start..i]);
1873+ start = i + 1;
1874+ }
1875+ _ => {}
1876+ }
1877+ }
1878+ parts.push(&input[start..]);
1879+ parts
1880+}
1881+
18401882fn evaluate_arithmetic(input: &str) -> String {
18411883 let input = input.trim();
18421884 for op in &[" * ", " / ", " + ", " - "] {
tests/snapshots.rs+138
@@ -1893,3 +1893,141 @@ fn no_warning_new_media_prefixes() {
18931893 diags
18941894 );
18951895}
1896+
1897+// ---------------------------------------------------------------------------
1898+// Feature: if() conditional attribute values
1899+// ---------------------------------------------------------------------------
1900+
1901+#[test]
1902+fn snapshot_if_expr() {
1903+ snapshot_test("if_expr");
1904+}
1905+
1906+#[test]
1907+fn if_expr_true_branch() {
1908+ let output = compile("@let x true\n@el [background if($x, blue, gray)]\n test");
1909+ assert!(output.contains("blue"), "should use true branch, got: {}", output);
1910+ assert!(!output.contains("gray"), "should not contain false branch");
1911+}
1912+
1913+#[test]
1914+fn if_expr_false_branch() {
1915+ let output = compile("@let x false\n@el [background if($x, blue, gray)]\n test");
1916+ assert!(output.contains("gray"), "should use false branch, got: {}", output);
1917+ assert!(!output.contains("blue"), "should not contain true branch");
1918+}
1919+
1920+#[test]
1921+fn if_expr_equality_condition() {
1922+ let output = compile("@let theme dark\n@el [color if($theme == dark, white, black)]\n test");
1923+ assert!(output.contains("white"), "should match equality, got: {}", output);
1924+}
1925+
1926+#[test]
1927+fn if_expr_inequality_condition() {
1928+ let output = compile("@let mode light\n@el [color if($mode != dark, green, red)]\n test");
1929+ assert!(output.contains("green"), "should match inequality, got: {}", output);
1930+}
1931+
1932+#[test]
1933+fn if_expr_passthrough_non_if() {
1934+ // Regular values should not be affected
1935+ let output = compile("@el [background blue]\n test");
1936+ assert!(output.contains("blue"));
1937+}
1938+
1939+// ---------------------------------------------------------------------------
1940+// Feature: HTML-to-HL converter
1941+// ---------------------------------------------------------------------------
1942+
1943+#[test]
1944+fn convert_basic_div() {
1945+ let hl = htmlang::convert::convert("<div>Hello</div>");
1946+ assert!(hl.contains("@el"), "div should become @el: {}", hl);
1947+ assert!(hl.contains("Hello"), "text should be preserved: {}", hl);
1948+}
1949+
1950+#[test]
1951+fn convert_paragraph() {
1952+ let hl = htmlang::convert::convert("<p>Some text</p>");
1953+ assert!(hl.contains("@paragraph"), "p should become @paragraph: {}", hl);
1954+}
1955+
1956+#[test]
1957+fn convert_link() {
1958+ let hl = htmlang::convert::convert("<a href=\"https://example.com\">Click</a>");
1959+ assert!(hl.contains("@link"), "a should become @link: {}", hl);
1960+ assert!(hl.contains("https://example.com"), "href preserved: {}", hl);
1961+}
1962+
1963+#[test]
1964+fn convert_image() {
1965+ let hl = htmlang::convert::convert("<img src=\"photo.jpg\" alt=\"A photo\">");
1966+ assert!(hl.contains("@image"), "img should become @image: {}", hl);
1967+ assert!(hl.contains("photo.jpg"), "src preserved: {}", hl);
1968+ assert!(hl.contains("alt A photo"), "alt preserved: {}", hl);
1969+}
1970+
1971+#[test]
1972+fn convert_nested() {
1973+ let hl = htmlang::convert::convert("<div><span>inner</span></div>");
1974+ assert!(hl.contains("@el"), "outer div: {}", hl);
1975+ assert!(hl.contains("@text"), "span becomes @text: {}", hl);
1976+ assert!(hl.contains("inner"), "text preserved: {}", hl);
1977+}
1978+
1979+#[test]
1980+fn convert_semantic_elements() {
1981+ let hl = htmlang::convert::convert("<nav><header>H</header></nav>");
1982+ assert!(hl.contains("@nav"), "nav preserved: {}", hl);
1983+ assert!(hl.contains("@header"), "header preserved: {}", hl);
1984+}
1985+
1986+#[test]
1987+fn convert_list() {
1988+ let hl = htmlang::convert::convert("<ul><li>A</li><li>B</li></ul>");
1989+ assert!(hl.contains("@list"), "ul becomes @list: {}", hl);
1990+ assert!(hl.contains("@item"), "li becomes @item: {}", hl);
1991+}
1992+
1993+#[test]
1994+fn convert_ordered_list() {
1995+ let hl = htmlang::convert::convert("<ol><li>First</li></ol>");
1996+ assert!(hl.contains("@list [ordered]"), "ol becomes @list [ordered]: {}", hl);
1997+}
1998+
1999+#[test]
2000+fn convert_form_elements() {
2001+ let hl = htmlang::convert::convert("<form><input type=\"text\"><button>Go</button></form>");
2002+ assert!(hl.contains("@form"), "form preserved: {}", hl);
2003+ assert!(hl.contains("@input"), "input preserved: {}", hl);
2004+ assert!(hl.contains("@button"), "button preserved: {}", hl);
2005+}
2006+
2007+#[test]
2008+fn convert_table() {
2009+ let hl = htmlang::convert::convert("<table><tr><td>Cell</td></tr></table>");
2010+ assert!(hl.contains("@table"), "table: {}", hl);
2011+ assert!(hl.contains("@tr"), "tr: {}", hl);
2012+ assert!(hl.contains("@td"), "td: {}", hl);
2013+}
2014+
2015+#[test]
2016+fn convert_strips_html_boilerplate() {
2017+ let hl = htmlang::convert::convert("<!DOCTYPE html><html><head><title>T</title></head><body><p>Hi</p></body></html>");
2018+ assert!(hl.contains("@paragraph"), "should extract body content: {}", hl);
2019+ assert!(!hl.contains("DOCTYPE"), "should strip doctype: {}", hl);
2020+}
2021+
2022+#[test]
2023+fn convert_inline_style() {
2024+ let hl = htmlang::convert::convert("<div style=\"padding: 20px; background: red;\">X</div>");
2025+ assert!(hl.contains("padding 20"), "padding converted: {}", hl);
2026+ assert!(hl.contains("background red"), "background converted: {}", hl);
2027+}
2028+
2029+#[test]
2030+fn convert_script_to_raw() {
2031+ let hl = htmlang::convert::convert("<script>alert('hi')</script>");
2032+ assert!(hl.contains("@raw"), "script becomes @raw: {}", hl);
2033+}
tests/snapshots/convert_basic.hlnew file+4
@@ -0,0 +1,4 @@
1+@page Test
2+@column
3+ @paragraph
4+ Hello world
tests/snapshots/if_expr.hlnew file+12
@@ -0,0 +1,12 @@
1+@page If Expression
2+@let active true
3+@let theme dark
4+
5+@el [background if($active, blue, gray)]
6+ Active element
7+
8+@el [background if($active, #3b82f6, #999), color if($theme == dark, white, black)]
9+ Themed element
10+
11+@el [padding if($active != true, 10, 20)]
12+ Inactive padding