on master help sign in
Add form elements, conditionals, loops, accessibility, error handling, caching, and LSP improvements
15d080aiko
src/ast.rs+8
@@ -30,6 +30,7 @@ pub struct Element {
30 pub attrs: Vec<Attribute>,
30 pub attrs: Vec<Attribute>,
31 pub argument: Option<String>,
31 pub argument: Option<String>,
32 pub children: Vec<Node>,
32 pub children: Vec<Node>,
33+ pub line_num: usize,
33}
34}
34
35
35#[derive(Debug, Clone, PartialEq)]
36#[derive(Debug, Clone, PartialEq)]
@@ -42,6 +43,13 @@ pub enum ElementKind {
42 Image,
43 Image,
43 Link,
44 Link,
44 Children,
45 Children,
46+ // Form elements
47+ Input,
48+ Button,
49+ Select,
50+ Textarea,
51+ Option,
52+ Label,
45}
53}
46
54
47#[derive(Debug, Clone)]
55#[derive(Debug, Clone)]
src/bin/htmlang-lsp.rs+60
@@ -301,6 +301,14 @@ fn element_completions(range: Range) -> Vec<CompletionItem> {
301 ("@image", "Image element"),
301 ("@image", "Image element"),
302 ("@img", "Image element (short for @image)"),
302 ("@img", "Image element (short for @image)"),
303 ("@link", "Anchor/link element"),
303 ("@link", "Anchor/link element"),
304+ ("@input", "Form input element (self-closing)"),
305+ ("@button", "Button element"),
306+ ("@btn", "Button element (short for @button)"),
307+ ("@select", "Select dropdown element"),
308+ ("@textarea", "Multi-line text input"),
309+ ("@option", "Option inside @select"),
310+ ("@opt", "Option (short for @option)"),
311+ ("@label", "Label element"),
304 ("@raw", "Raw HTML escape hatch"),
312 ("@raw", "Raw HTML escape hatch"),
305 ("@children", "Slot for caller's children (inside @fn)"),
313 ("@children", "Slot for caller's children (inside @fn)"),
306 ]
314 ]
@@ -316,6 +324,9 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
316 ("@define", "Define an attribute bundle", "@define "),
324 ("@define", "Define an attribute bundle", "@define "),
317 ("@fn", "Define a reusable function", "@fn "),
325 ("@fn", "Define a reusable function", "@fn "),
318 ("@keyframes", "Define a CSS animation", "@keyframes "),
326 ("@keyframes", "Define a CSS animation", "@keyframes "),
327+ ("@if", "Conditional rendering", "@if "),
328+ ("@each", "Loop over comma-separated values", "@each "),
329+ ("@include", "Include another .hl file", "@include "),
319 ]
330 ]
320 .iter()
331 .iter()
321 .map(|(name, detail, insert)| {
332 .map(|(name, detail, insert)| {
@@ -376,6 +387,29 @@ fn attr_completions(range: Range) -> Vec<CompletionItem> {
376 ("class", "HTML class attribute", true),
387 ("class", "HTML class attribute", true),
377 // Animation
388 // Animation
378 ("animation", "CSS animation (e.g., name 0.3s ease)", true),
389 ("animation", "CSS animation (e.g., name 0.3s ease)", true),
390+ // Form
391+ ("type", "Input type (text/email/password/submit/...)", true),
392+ ("placeholder", "Placeholder text", true),
393+ ("name", "Form field name", true),
394+ ("value", "Form field value", true),
395+ ("disabled", "Disable the element", false),
396+ ("required", "Mark field as required", false),
397+ ("checked", "Checkbox/radio checked state", false),
398+ ("for", "Label target (id of associated input)", true),
399+ ("action", "Form action URL", true),
400+ ("method", "Form method (get/post)", true),
401+ ("rows", "Textarea rows", true),
402+ ("cols", "Textarea columns", true),
403+ ("maxlength", "Maximum input length", true),
404+ ("multiple", "Allow multiple selections", false),
405+ // Accessibility
406+ ("alt", "Alternative text (for images)", true),
407+ ("role", "ARIA role", true),
408+ ("tabindex", "Tab order", true),
409+ ("title", "Tooltip text", true),
410+ ("aria-label", "Accessible label", true),
411+ ("aria-hidden", "Hide from assistive tech (true/false)", true),
412+ ("data-", "Custom data attribute", true),
379 // State prefixes
413 // State prefixes
380 ("hover:", "Style on hover", false),
414 ("hover:", "Style on hover", false),
381 ("active:", "Style on active/click", false),
415 ("active:", "Style on active/click", false),
@@ -630,6 +664,15 @@ fn hover_builtin(word: &str) -> Option<String> {
630 "@fn" => "**@fn** \u{2014} Function\n\nDefines a reusable component.\n\n```\n@fn card $title\n @el [padding 20]\n @text [bold] $title\n @children\n```",
664 "@fn" => "**@fn** \u{2014} Function\n\nDefines a reusable component.\n\n```\n@fn card $title\n @el [padding 20]\n @text [bold] $title\n @children\n```",
631 "@keyframes" => "**@keyframes** \u{2014} CSS Animation\n\nDefines keyframes for CSS animations.\n\n```\n@keyframes fade-in\n from{opacity:0}to{opacity:1}\n```\n\nUse with `animation` attribute: `[animation fade-in 0.3s ease]`",
665 "@keyframes" => "**@keyframes** \u{2014} CSS Animation\n\nDefines keyframes for CSS animations.\n\n```\n@keyframes fade-in\n from{opacity:0}to{opacity:1}\n```\n\nUse with `animation` attribute: `[animation fade-in 0.3s ease]`",
632 "@children" => "**@children** \u{2014} Children slot\n\nPlaceholder inside `@fn` body replaced with the caller's children.",
666 "@children" => "**@children** \u{2014} Children slot\n\nPlaceholder inside `@fn` body replaced with the caller's children.",
667+ "@input" => "**@input** \u{2014} Form input\n\nRenders as self-closing `<input>`.\n\nUsage: `@input [type text, placeholder Name, name user]`",
668+ "@button" | "@btn" => "**@button** \u{2014} Button\n\nRenders as `<button>`.\n\nUsage: `@button [type submit] Click me`",
669+ "@select" => "**@select** \u{2014} Select dropdown\n\nRenders as `<select>`. Use `@option` children.\n\nUsage: `@select [name color]`",
670+ "@textarea" => "**@textarea** \u{2014} Multi-line text input\n\nRenders as `<textarea>`.\n\nUsage: `@textarea [name bio, rows 4] Default text`",
671+ "@option" | "@opt" => "**@option** \u{2014} Select option\n\nRenders as `<option>`.\n\nUsage: `@option [value red] Red`",
672+ "@label" => "**@label** \u{2014} Form label\n\nRenders as `<label>`.\n\nUsage: `@label [for email] Email Address`",
673+ "@if" => "**@if** \u{2014} Conditional\n\nConditionally includes children at compile time.\n\n```\n@if $theme == dark\n @el [background #333]\n@else\n @el [background white]\n```",
674+ "@each" => "**@each** \u{2014} Loop\n\nRepeat children for each item in a comma-separated list.\n\n```\n@each $color in red,green,blue\n @el [background $color]\n```",
675+ "@include" => "**@include** \u{2014} Include file\n\nIncludes another .hl file.\n\nUsage: `@include header.hl`",
633 // Attributes
676 // Attributes
634 "spacing" => "**spacing** `<value>`\n\nGap between children in pixels. Maps to CSS `gap`.",
677 "spacing" => "**spacing** `<value>`\n\nGap between children in pixels. Maps to CSS `gap`.",
635 "padding" => "**padding** `<value>` | `<y> <x>` | `<t> <h> <b>` | `<t> <r> <b> <l>`\n\nInner padding in pixels. Accepts 1, 2, 3, or 4 values.",
678 "padding" => "**padding** `<value>` | `<y> <x>` | `<t> <h> <b>` | `<t> <r> <b> <l>`\n\nInner padding in pixels. Accepts 1, 2, 3, or 4 values.",
@@ -671,6 +714,23 @@ fn hover_builtin(word: &str) -> Option<String> {
671 "id" => "**id** `<value>` \u{2014} HTML id attribute.",
714 "id" => "**id** `<value>` \u{2014} HTML id attribute.",
672 "class" => "**class** `<value>` \u{2014} HTML class attribute.",
715 "class" => "**class** `<value>` \u{2014} HTML class attribute.",
673 "animation" => "**animation** `<value>` \u{2014} CSS animation shorthand (e.g., `fade-in 0.3s ease`).\n\nDefine animations with `@keyframes`.",
716 "animation" => "**animation** `<value>` \u{2014} CSS animation shorthand (e.g., `fade-in 0.3s ease`).\n\nDefine animations with `@keyframes`.",
717+ // Form attributes
718+ "type" => "**type** `<value>` \u{2014} Input type (`text`, `email`, `password`, `submit`, etc.).",
719+ "placeholder" => "**placeholder** `<value>` \u{2014} Placeholder text for inputs.",
720+ "name" => "**name** `<value>` \u{2014} Form field name.",
721+ "value" => "**value** `<value>` \u{2014} Form field value.",
722+ "disabled" => "**disabled** \u{2014} Disable the element.",
723+ "required" => "**required** \u{2014} Mark field as required.",
724+ "checked" => "**checked** \u{2014} Checkbox/radio checked state.",
725+ "for" => "**for** `<id>` \u{2014} Label target (id of the associated input).",
726+ "rows" => "**rows** `<value>` \u{2014} Number of visible rows for textarea.",
727+ "cols" => "**cols** `<value>` \u{2014} Number of visible columns for textarea.",
728+ "maxlength" => "**maxlength** `<value>` \u{2014} Maximum input length.",
729+ // Accessibility
730+ "alt" => "**alt** `<value>` \u{2014} Alternative text for images.",
731+ "role" => "**role** `<value>` \u{2014} ARIA role (e.g., `navigation`, `banner`, `main`).",
732+ "tabindex" => "**tabindex** `<value>` \u{2014} Tab order. `0` = natural order, `-1` = skip.",
733+ "title" => "**title** `<value>` \u{2014} Tooltip text.",
674 _ => return None,
734 _ => return None,
675 };
735 };
676
736
src/codegen.rs+88−15
@@ -289,6 +289,39 @@ fn generate_node(
289 }
289 }
290}
290}
291
291
292+/// HTML attributes that are passed through to the HTML tag rather than converted to CSS.
293+const HTML_PASSTHROUGH_ATTRS: &[&str] = &[
294+ "type", "placeholder", "name", "value", "disabled", "required", "checked",
295+ "for", "action", "method", "autocomplete",
296+ "min", "max", "step", "pattern", "maxlength", "rows", "cols", "multiple",
297+ "alt", "role", "tabindex", "title",
298+];
299+
300+/// Boolean HTML attributes (rendered without a value, e.g., `<input disabled>`).
301+const BOOLEAN_HTML_ATTRS: &[&str] = &["disabled", "required", "checked", "multiple"];
302+
303+fn emit_html_passthrough_attrs(out: &mut String, attrs: &[Attribute]) {
304+ for attr in attrs {
305+ let key = attr.key.as_str();
306+ let is_passthrough = HTML_PASSTHROUGH_ATTRS.contains(&key)
307+ || key.starts_with("aria-")
308+ || key.starts_with("data-");
309+ if !is_passthrough {
310+ continue;
311+ }
312+ if BOOLEAN_HTML_ATTRS.contains(&key) && attr.value.is_none() {
313+ out.push(' ');
314+ out.push_str(key);
315+ } else if let Some(val) = &attr.value {
316+ out.push(' ');
317+ out.push_str(key);
318+ out.push_str("=\"");
319+ out.push_str(&html_escape(val));
320+ out.push('"');
321+ }
322+ }
323+}
324+
292fn generate_element(
325fn generate_element(
293 elem: &Element,
326 elem: &Element,
294 parent_kind: Option<&ElementKind>,
327 parent_kind: Option<&ElementKind>,
@@ -296,8 +329,9 @@ fn generate_element(
296 styles: &mut StyleCollector,
329 styles: &mut StyleCollector,
297 ctx: &mut GenContext,
330 ctx: &mut GenContext,
298) {
331) {
332+ // Self-closing elements
299− if elem.kind == ElementKind::Image {
333+ if matches!(elem.kind, ElementKind::Image | ElementKind::Input) {
300− generate_image(elem, parent_kind, out, styles, ctx);
334+ generate_self_closing(elem, parent_kind, out, styles, ctx);
301 return;
335 return;
302 }
336 }
303 if elem.kind == ElementKind::Children {
337 if elem.kind == ElementKind::Children {
@@ -309,7 +343,12 @@ fn generate_element(
309 ElementKind::Text => "span",
343 ElementKind::Text => "span",
310 ElementKind::Paragraph => "p",
344 ElementKind::Paragraph => "p",
311 ElementKind::Link => "a",
345 ElementKind::Link => "a",
346+ ElementKind::Button => "button",
347+ ElementKind::Select => "select",
348+ ElementKind::Textarea => "textarea",
349+ ElementKind::Option => "option",
350+ ElementKind::Label => "label",
312− ElementKind::Image | ElementKind::Children => unreachable!(),
351+ ElementKind::Image | ElementKind::Input | ElementKind::Children => unreachable!(),
313 };
352 };
314
353
315 let kind_label = match elem.kind {
354 let kind_label = match elem.kind {
@@ -319,6 +358,11 @@ fn generate_element(
319 ElementKind::Text => "text",
358 ElementKind::Text => "text",
320 ElementKind::Paragraph => "paragraph",
359 ElementKind::Paragraph => "paragraph",
321 ElementKind::Link => "link",
360 ElementKind::Link => "link",
361+ ElementKind::Button => "button",
362+ ElementKind::Select => "select",
363+ ElementKind::Textarea => "textarea",
364+ ElementKind::Option => "option",
365+ ElementKind::Label => "label",
322 _ => "",
366 _ => "",
323 };
367 };
324
368
@@ -326,9 +370,9 @@ fn generate_element(
326 let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles);
370 let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles);
327 let (id, user_class) = extract_id_class(&elem.attrs);
371 let (id, user_class) = extract_id_class(&elem.attrs);
328
372
329− if ctx.dev {
373+ if ctx.dev && elem.line_num > 0 {
330 out.push_str(&ctx.indent());
374 out.push_str(&ctx.indent());
331− out.push_str(&format!("<!-- @{} -->\n", kind_label));
375+ out.push_str(&format!("<!-- @{} line {} -->\n", kind_label, elem.line_num));
332 }
376 }
333 out.push_str(&ctx.indent());
377 out.push_str(&ctx.indent());
334 out.push('<');
378 out.push('<');
@@ -349,11 +393,21 @@ fn generate_element(
349 out.push_str(&html_escape(&id));
393 out.push_str(&html_escape(&id));
350 out.push('"');
394 out.push('"');
351 }
395 }
396+
397+ emit_html_passthrough_attrs(out, &elem.attrs);
398+
352 out.push('>');
399 out.push('>');
353 out.push_str(ctx.nl());
400 out.push_str(ctx.nl());
354
401
355− // Inline text argument (@text content)
402+ // Inline text argument
403+ if matches!(
404+ elem.kind,
356− if elem.kind == ElementKind::Text {
405+ ElementKind::Text
406+ | ElementKind::Button
407+ | ElementKind::Label
408+ | ElementKind::Option
409+ | ElementKind::Textarea
410+ ) {
357 if let Some(text) = &elem.argument {
411 if let Some(text) = &elem.argument {
358 out.push_str(&html_escape(text));
412 out.push_str(&html_escape(text));
359 }
413 }
@@ -377,25 +431,37 @@ fn generate_element(
377 out.push_str(ctx.nl());
431 out.push_str(ctx.nl());
378}
432}
379
433
380−fn generate_image(
434+fn generate_self_closing(
381 elem: &Element,
435 elem: &Element,
382 parent_kind: Option<&ElementKind>,
436 parent_kind: Option<&ElementKind>,
383 out: &mut String,
437 out: &mut String,
384 styles: &mut StyleCollector,
438 styles: &mut StyleCollector,
385 ctx: &GenContext,
439 ctx: &GenContext,
386) {
440) {
387− let src = elem.argument.as_deref().unwrap_or("");
388 let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles);
441 let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles);
389 let (id, user_class) = extract_id_class(&elem.attrs);
442 let (id, user_class) = extract_id_class(&elem.attrs);
390
443
444+ let (tag, kind_label) = match elem.kind {
445+ ElementKind::Image => ("img", "image"),
446+ ElementKind::Input => ("input", "input"),
447+ _ => unreachable!(),
448+ };
449+
391− if ctx.dev {
450+ if ctx.dev && elem.line_num > 0 {
392 out.push_str(&ctx.indent());
451 out.push_str(&ctx.indent());
393− out.push_str("<!-- @image -->\n");
452+ out.push_str(&format!("<!-- @{} line {} -->\n", kind_label, elem.line_num));
394 }
453 }
395 out.push_str(&ctx.indent());
454 out.push_str(&ctx.indent());
396− out.push_str("<img src=\"");
455+ out.push('<');
456+ out.push_str(tag);
457+
458+ // Image src
459+ if elem.kind == ElementKind::Image {
460+ let src = elem.argument.as_deref().unwrap_or("");
461+ out.push_str(" src=\"");
397− out.push_str(&html_escape(src));
462+ out.push_str(&html_escape(src));
398− out.push('"');
463+ out.push('"');
464+ }
399
465
400 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
466 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
401
467
@@ -404,6 +470,9 @@ fn generate_image(
404 out.push_str(&html_escape(&id));
470 out.push_str(&html_escape(&id));
405 out.push('"');
471 out.push('"');
406 }
472 }
473+
474+ emit_html_passthrough_attrs(out, &elem.attrs);
475+
407 out.push('>');
476 out.push('>');
408 out.push_str(ctx.nl());
477 out.push_str(ctx.nl());
409}
478}
@@ -783,8 +852,12 @@ fn attrs_to_css(
783 }
852 }
784 }
853 }
785
854
786− // Identity — not CSS
855+ // Identity and HTML passthrough — not CSS
787 "id" | "class" => {}
856 "id" | "class" => {}
857+ "type" | "placeholder" | "name" | "value" | "disabled" | "required"
858+ | "checked" | "for" | "action" | "method" | "autocomplete" | "min"
859+ | "max" | "step" | "pattern" | "maxlength" | "rows" | "cols"
860+ | "multiple" | "alt" | "role" | "tabindex" | "title" => {}
788
861
789 _ => {}
862 _ => {}
790 }
863 }
src/main.rs+55
@@ -1,5 +1,7 @@
1+use std::collections::HashMap;
1use std::env;
2use std::env;
2use std::fs;
3use std::fs;
4+use std::hash::{Hash, Hasher};
3use std::path::{Path, PathBuf};
5use std::path::{Path, PathBuf};
4use std::process;
6use std::process;
5
7
@@ -21,6 +23,9 @@ fn compile(input_path: &str, dev: bool) -> (bool, Vec<PathBuf>) {
21 htmlang::parser::Severity::Warning => "warning",
23 htmlang::parser::Severity::Warning => "warning",
22 };
24 };
23 eprintln!("{}: line {}: {}", prefix, d.line, d.message);
25 eprintln!("{}: line {}: {}", prefix, d.line, d.message);
26+ if let Some(ref src) = d.source_line {
27+ eprintln!(" | {}", src);
28+ }
24 }
29 }
25
30
26 let has_errors = result
31 let has_errors = result
@@ -143,17 +148,67 @@ fn main() {
143
148
144 eprintln!("watching for changes...");
149 eprintln!("watching for changes...");
145
150
151+ // Track content hashes for incremental rebuilds
152+ let mut content_hashes: HashMap<PathBuf, u64> = HashMap::new();
153+
154+ fn hash_file(path: &Path) -> Option<u64> {
155+ let content = fs::read(path).ok()?;
156+ let mut hasher = std::collections::hash_map::DefaultHasher::new();
157+ content.hash(&mut hasher);
158+ Some(hasher.finish())
159+ }
160+
161+ // Seed initial hashes
162+ if let Some(h) = hash_file(&input_canonical) {
163+ content_hashes.insert(input_canonical.clone(), h);
164+ }
165+ for inc in &included_files {
166+ if let Some(h) = hash_file(inc) {
167+ content_hashes.insert(inc.clone(), h);
168+ }
169+ }
170+
146 loop {
171 loop {
147 match rx.recv() {
172 match rx.recv() {
148 Ok(_) => {
173 Ok(_) => {
149 // Drain additional events (debounce)
174 // Drain additional events (debounce)
150 while rx.try_recv().is_ok() {}
175 while rx.try_recv().is_ok() {}
151
176
177+ // Check if any file content actually changed
178+ let mut any_changed = false;
179+ let check_path = |path: &Path,
180+ hashes: &mut HashMap<PathBuf, u64>|
181+ -> bool {
182+ if let Some(h) = hash_file(path) {
183+ if hashes.get(path) != Some(&h) {
184+ hashes.insert(path.to_path_buf(), h);
185+ return true;
186+ }
187+ }
188+ false
189+ };
190+
191+ if check_path(&input_canonical, &mut content_hashes) {
192+ any_changed = true;
193+ }
194+ for inc in &included_files {
195+ if check_path(inc, &mut content_hashes) {
196+ any_changed = true;
197+ }
198+ }
199+
200+ if !any_changed {
201+ continue;
202+ }
203+
152 eprintln!("\nrecompiling...");
204 eprintln!("\nrecompiling...");
153 let (_, new_includes) = compile(&input_path, dev);
205 let (_, new_includes) = compile(&input_path, dev);
154
206
155 for inc in &new_includes {
207 for inc in &new_includes {
156 let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
208 let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
209+ if let Some(h) = hash_file(inc) {
210+ content_hashes.insert(inc.clone(), h);
211+ }
157 }
212 }
158
213
159 if let Some(ref tx) = reload_tx {
214 if let Some(ref tx) = reload_tx {
src/parser.rs+279−27
@@ -19,6 +19,7 @@ pub struct Diagnostic {
19 pub line: usize,
19 pub line: usize,
20 pub message: String,
20 pub message: String,
21 pub severity: Severity,
21 pub severity: Severity,
22+ pub source_line: Option<String>,
22}
23}
23
24
24pub struct ParseResult {
25pub struct ParseResult {
@@ -73,6 +74,8 @@ struct ParseContext {
73 base_path: Option<PathBuf>,
74 base_path: Option<PathBuf>,
74 included_files: Vec<PathBuf>,
75 included_files: Vec<PathBuf>,
75 include_stack: Vec<PathBuf>,
76 include_stack: Vec<PathBuf>,
77+ fn_call_stack: Vec<String>,
78+ file_cache: HashMap<PathBuf, String>,
76}
79}
77
80
78struct Parser {
81struct Parser {
@@ -98,8 +101,11 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
98 base_path: base_path.map(|p| p.to_path_buf()),
101 base_path: base_path.map(|p| p.to_path_buf()),
99 included_files: Vec::new(),
102 included_files: Vec::new(),
100 include_stack: Vec::new(),
103 include_stack: Vec::new(),
104+ fn_call_stack: Vec::new(),
105+ file_cache: HashMap::new(),
101 };
106 };
102 let nodes = parser.parse_children(0, &mut ctx);
107 let nodes = parser.parse_children(0, &mut ctx);
108+ validate_tree(&nodes, None, &mut ctx.diagnostics);
103 ParseResult {
109 ParseResult {
104 document: Document {
110 document: Document {
105 page_title: ctx.page_title,
111 page_title: ctx.page_title,
@@ -234,6 +240,7 @@ impl Parser {
234 line: e.line,
240 line: e.line,
235 message: e.message,
241 message: e.message,
236 severity: Severity::Error,
242 severity: Severity::Error,
243+ source_line: None,
237 });
244 });
238 // Skip forward past any deeper-indented children of the errored line
245 // Skip forward past any deeper-indented children of the errored line
239 while self.pos < self.lines.len()
246 while self.pos < self.lines.len()
@@ -313,37 +320,164 @@ impl Parser {
313 line: line_num,
320 line: line_num,
314 message: format!("circular include '{}'", filename),
321 message: format!("circular include '{}'", filename),
315 severity: Severity::Error,
322 severity: Severity::Error,
323+ source_line: Some(content.clone()),
316 });
324 });
317 return Ok(None);
325 return Ok(None);
318 }
326 }
319
327
328+ // Use file cache to avoid redundant reads
329+ let included_text = if let Some(cached) = ctx.file_cache.get(&resolved) {
330+ cached.clone()
331+ } else {
320− match std::fs::read_to_string(&resolved) {
332+ match std::fs::read_to_string(&resolved) {
321− Ok(included_text) => {
333+ Ok(text) => {
322− ctx.included_files.push(resolved.clone());
334+ ctx.file_cache.insert(resolved.clone(), text.clone());
323− ctx.include_stack.push(resolved.clone());
335+ text
324− let saved_base = ctx.base_path.clone();
336+ }
325− ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
337+ Err(e) => {
326−
338+ ctx.diagnostics.push(Diagnostic {
327− let included_lines = preprocess(&included_text);
339+ line: line_num,
328− let mut included_parser = Parser {
340+ message: format!("cannot include '{}': {}", filename, e),
329− lines: included_lines,
341+ severity: Severity::Error,
330− pos: 0,
342+ source_line: Some(content.clone()),
331− };
343+ });
332− let nodes = included_parser.parse_children(0, ctx);
333−
334− ctx.base_path = saved_base;
335− ctx.include_stack.pop();
336− return Ok(Some(nodes));
344+ return Ok(None);
345+ }
337 }
346 }
338− Err(e) => {
347+ };
339− ctx.diagnostics.push(Diagnostic {
348+
340− line: line_num,
349+ ctx.included_files.push(resolved.clone());
341− message: format!("cannot include '{}': {}", filename, e),
350+ ctx.include_stack.push(resolved.clone());
342− severity: Severity::Error,
351+ let saved_base = ctx.base_path.clone();
343− });
352+ ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
353+
354+ let included_lines = preprocess(&included_text);
355+ let mut included_parser = Parser {
356+ lines: included_lines,
357+ pos: 0,
358+ };
359+ let nodes = included_parser.parse_children(0, ctx);
360+
361+ ctx.base_path = saved_base;
362+ ctx.include_stack.pop();
344− return Ok(None);
363+ return Ok(Some(nodes));
364+ }
365+
366+ // --- @if / @else ---
367+
368+ if let Some(rest) = content.strip_prefix("@if ") {
369+ let rest = rest.trim();
370+ let condition = substitute_vars(rest, &ctx.variables);
371+ let result = evaluate_condition(&condition);
372+
373+ // Collect then-body lines
374+ let mut then_lines = Vec::new();
375+ while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
376+ then_lines.push(self.lines[self.pos].clone());
377+ self.pos += 1;
378+ }
379+
380+ // Check for @else at same indent
381+ let mut else_lines = Vec::new();
382+ if self.pos < self.lines.len() && self.lines[self.pos].indent == current_indent {
383+ if let LineContent::Normal(ref s) = self.lines[self.pos].content {
384+ if s.trim() == "@else" {
385+ self.pos += 1; // consume @else
386+ while self.pos < self.lines.len()
387+ && self.lines[self.pos].indent > current_indent
388+ {
389+ else_lines.push(self.lines[self.pos].clone());
390+ self.pos += 1;
391+ }
392+ }
345 }
393 }
346 }
394 }
395+
396+ let body_lines = if result { then_lines } else { else_lines };
397+ if body_lines.is_empty() {
398+ return Ok(None);
399+ }
400+
401+ let min_indent = body_lines.iter().map(|l| l.indent).min().unwrap_or(0);
402+ let adjusted: Vec<Line> = body_lines
403+ .iter()
404+ .map(|l| Line {
405+ indent: l.indent - min_indent,
406+ content: l.content.clone(),
407+ line_num: l.line_num,
408+ })
409+ .collect();
410+
411+ let mut body_parser = Parser {
412+ lines: adjusted,
413+ pos: 0,
414+ };
415+ let nodes = body_parser.parse_children(0, ctx);
416+ return Ok(Some(nodes));
417+ }
418+
419+ if content.trim() == "@else" {
420+ return Err(ParseError {
421+ line: line_num,
422+ message: "@else without matching @if".to_string(),
423+ });
424+ }
425+
426+ // --- @each loop ---
427+
428+ if let Some(rest) = content.strip_prefix("@each ") {
429+ let rest = rest.trim();
430+ let parts: Vec<&str> = rest.splitn(3, ' ').collect();
431+ if parts.len() < 3 || parts[1] != "in" {
432+ return Err(ParseError {
433+ line: line_num,
434+ message: "@each requires: @each $var in list".to_string(),
435+ });
436+ }
437+ let var_name = parts[0].strip_prefix('$').unwrap_or(parts[0]).to_string();
438+ let list_str = substitute_vars(parts[2], &ctx.variables);
439+ let items: Vec<String> = list_str
440+ .split(',')
441+ .map(|s| s.trim().to_string())
442+ .filter(|s| !s.is_empty())
443+ .collect();
444+
445+ // Collect body lines
446+ let mut body_lines = Vec::new();
447+ while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
448+ body_lines.push(self.lines[self.pos].clone());
449+ self.pos += 1;
450+ }
451+
452+ if body_lines.is_empty() || items.is_empty() {
453+ return Ok(None);
454+ }
455+
456+ let min_indent = body_lines.iter().map(|l| l.indent).min().unwrap_or(0);
457+ let adjusted: Vec<Line> = body_lines
458+ .iter()
459+ .map(|l| Line {
460+ indent: l.indent - min_indent,
461+ content: l.content.clone(),
462+ line_num: l.line_num,
463+ })
464+ .collect();
465+
466+ let saved_vars = ctx.variables.clone();
467+ let mut all_nodes = Vec::new();
468+
469+ for item in &items {
470+ ctx.variables.insert(var_name.clone(), item.clone());
471+ let mut body_parser = Parser {
472+ lines: adjusted.clone(),
473+ pos: 0,
474+ };
475+ let nodes = body_parser.parse_children(0, ctx);
476+ all_nodes.extend(nodes);
477+ }
478+
479+ ctx.variables = saved_vars;
480+ return Ok(Some(all_nodes));
347 }
481 }
348
482
349 // --- @keyframes directive ---
483 // --- @keyframes directive ---
@@ -428,6 +562,19 @@ impl Parser {
428 line_num: usize,
562 line_num: usize,
429 ctx: &mut ParseContext,
563 ctx: &mut ParseContext,
430 ) -> Result<Vec<Node>, ParseError> {
564 ) -> Result<Vec<Node>, ParseError> {
565+ // Recursive function cycle detection
566+ if ctx.fn_call_stack.contains(&name.to_string()) {
567+ return Err(ParseError {
568+ line: line_num,
569+ message: format!(
570+ "recursive function call to @{} (call stack: {})",
571+ name,
572+ ctx.fn_call_stack.join(" -> ")
573+ ),
574+ });
575+ }
576+ ctx.fn_call_stack.push(name.to_string());
577+
431 // Parse [param value, ...] arguments
578 // Parse [param value, ...] arguments
432 let rest = &content[1 + name.len()..];
579 let rest = &content[1 + name.len()..];
433 let rest = rest.trim_start();
580 let rest = rest.trim_start();
@@ -481,8 +628,9 @@ impl Parser {
481 };
628 };
482 let body_nodes = body_parser.parse_children(0, ctx);
629 let body_nodes = body_parser.parse_children(0, ctx);
483
630
484− // Restore variables
631+ // Restore variables and call stack
485 ctx.variables = saved_vars;
632 ctx.variables = saved_vars;
633+ ctx.fn_call_stack.pop();
486
634
487 // Replace @children with caller's children
635 // Replace @children with caller's children
488 Ok(replace_children_nodes(body_nodes, &caller_children))
636 Ok(replace_children_nodes(body_nodes, &caller_children))
@@ -613,14 +761,18 @@ fn parse_single_element(
613 attrs,
761 attrs,
614 argument,
762 argument,
615 children,
763 children,
764+ line_num,
616 })
765 })
617}
766}
618
767
619const KNOWN_ELEMENTS: &[&str] = &[
768const KNOWN_ELEMENTS: &[&str] = &[
620 "row", "column", "col", "el", "text", "paragraph", "p", "image", "img", "link", "children",
769 "row", "column", "col", "el", "text", "paragraph", "p", "image", "img", "link", "children",
770+ "input", "button", "select", "textarea", "option", "label",
621];
771];
622
772
773+const KNOWN_DIRECTIVES: &[&str] = &[
623−const KNOWN_DIRECTIVES: &[&str] = &["page", "let", "define", "fn", "include", "raw", "keyframes"];
774+ "page", "let", "define", "fn", "include", "raw", "keyframes", "if", "else", "each",
775+];
624
776
625fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
777fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
626 match s {
778 match s {
@@ -632,6 +784,12 @@ fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseErro
632 "image" | "img" => Ok(ElementKind::Image),
784 "image" | "img" => Ok(ElementKind::Image),
633 "link" => Ok(ElementKind::Link),
785 "link" => Ok(ElementKind::Link),
634 "children" => Ok(ElementKind::Children),
786 "children" => Ok(ElementKind::Children),
787+ "input" => Ok(ElementKind::Input),
788+ "button" | "btn" => Ok(ElementKind::Button),
789+ "select" => Ok(ElementKind::Select),
790+ "textarea" => Ok(ElementKind::Textarea),
791+ "option" | "opt" => Ok(ElementKind::Option),
792+ "label" => Ok(ElementKind::Label),
635 _ => {
793 _ => {
636 let all_known: Vec<&str> = KNOWN_ELEMENTS
794 let all_known: Vec<&str> = KNOWN_ELEMENTS
637 .iter()
795 .iter()
@@ -690,16 +848,26 @@ fn suggest_closest<'a>(input: &str, candidates: &[&'a str]) -> Option<&'a str> {
690// ---------------------------------------------------------------------------
848// ---------------------------------------------------------------------------
691
849
692const KNOWN_ATTRS: &[&str] = &[
850const KNOWN_ATTRS: &[&str] = &[
851+ // Layout
693 "spacing", "padding", "padding-x", "padding-y",
852 "spacing", "padding", "padding-x", "padding-y",
694 "width", "height", "min-width", "max-width", "min-height", "max-height",
853 "width", "height", "min-width", "max-width", "min-height", "max-height",
695 "center-x", "center-y", "align-left", "align-right", "align-top", "align-bottom",
854 "center-x", "center-y", "align-left", "align-right", "align-top", "align-bottom",
855+ // Style
696 "background", "color", "border", "rounded",
856 "background", "color", "border", "rounded",
697 "bold", "italic", "underline",
857 "bold", "italic", "underline",
698 "size", "font", "transition", "cursor", "opacity",
858 "size", "font", "transition", "cursor", "opacity",
699 "text-align", "line-height", "overflow", "position", "z-index", "shadow",
859 "text-align", "line-height", "overflow", "position", "z-index", "shadow",
700 "wrap", "gap-x", "gap-y",
860 "wrap", "gap-x", "gap-y",
861+ // Identity
701 "id", "class",
862 "id", "class",
863+ // Animation
702 "animation",
864 "animation",
865+ // Form
866+ "type", "placeholder", "name", "value", "disabled", "required", "checked",
867+ "for", "action", "method", "autocomplete",
868+ "min", "max", "step", "pattern", "maxlength", "rows", "cols", "multiple",
869+ // Accessibility
870+ "alt", "role", "tabindex", "title",
703];
871];
704
872
705/// Attributes that expect purely numeric values (px-based).
873/// Attributes that expect purely numeric values (px-based).
@@ -739,6 +907,7 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext
739 attr.key, val
907 attr.key, val
740 ),
908 ),
741 severity: Severity::Warning,
909 severity: Severity::Warning,
910+ source_line: None,
742 });
911 });
743 return;
912 return;
744 }
913 }
@@ -756,6 +925,7 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext
756 val
925 val
757 ),
926 ),
758 severity: Severity::Warning,
927 severity: Severity::Warning,
928+ source_line: None,
759 });
929 });
760 }
930 }
761 } else if base_key == "opacity" {
931 } else if base_key == "opacity" {
@@ -765,6 +935,7 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext
765 line: line_num,
935 line: line_num,
766 message: format!("'opacity' should be between 0 and 1, got '{}'", val),
936 message: format!("'opacity' should be between 0 and 1, got '{}'", val),
767 severity: Severity::Warning,
937 severity: Severity::Warning,
938+ source_line: None,
768 });
939 });
769 }
940 }
770 } else {
941 } else {
@@ -772,6 +943,7 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext
772 line: line_num,
943 line: line_num,
773 message: format!("'opacity' expects a numeric value, got '{}'", val),
944 message: format!("'opacity' expects a numeric value, got '{}'", val),
774 severity: Severity::Warning,
945 severity: Severity::Warning,
946+ source_line: None,
775 });
947 });
776 }
948 }
777 } else if base_key == "z-index" {
949 } else if base_key == "z-index" {
@@ -780,6 +952,7 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext
780 line: line_num,
952 line: line_num,
781 message: format!("'z-index' expects an integer, got '{}'", val),
953 message: format!("'z-index' expects an integer, got '{}'", val),
782 severity: Severity::Warning,
954 severity: Severity::Warning,
955+ source_line: None,
783 });
956 });
784 }
957 }
785 }
958 }
@@ -888,7 +1061,10 @@ fn parse_attr_list(input: &str, line_num: usize, ctx: &mut ParseContext, validat
888 .or_else(|| base_key.strip_prefix("lg:"))
1061 .or_else(|| base_key.strip_prefix("lg:"))
889 .or_else(|| base_key.strip_prefix("xl:"))
1062 .or_else(|| base_key.strip_prefix("xl:"))
890 .unwrap_or(base_key);
1063 .unwrap_or(base_key);
891− if !KNOWN_ATTRS.contains(&base_key) {
1064+ if !KNOWN_ATTRS.contains(&base_key)
1065+ && !base_key.starts_with("aria-")
1066+ && !base_key.starts_with("data-")
1067+ {
892 let suggestion = suggest_closest(base_key, KNOWN_ATTRS);
1068 let suggestion = suggest_closest(base_key, KNOWN_ATTRS);
893 let msg = match suggestion {
1069 let msg = match suggestion {
894 Some(closest) => {
1070 Some(closest) => {
@@ -900,6 +1076,7 @@ fn parse_attr_list(input: &str, line_num: usize, ctx: &mut ParseContext, validat
900 line: line_num,
1076 line: line_num,
901 message: msg,
1077 message: msg,
902 severity: Severity::Warning,
1078 severity: Severity::Warning,
1079+ source_line: None,
903 });
1080 });
904 } else {
1081 } else {
905 validate_attr_value(&attr, line_num, ctx);
1082 validate_attr_value(&attr, line_num, ctx);
@@ -1049,6 +1226,81 @@ fn parse_text_segments(input: &str, ctx: &mut ParseContext) -> Vec<TextSegment>
1049// Variable substitution
1226// Variable substitution
1050// ---------------------------------------------------------------------------
1227// ---------------------------------------------------------------------------
1051
1228
1229+// ---------------------------------------------------------------------------
1230+// Condition evaluation for @if
1231+// ---------------------------------------------------------------------------
1232+
1233+fn evaluate_condition(condition: &str) -> bool {
1234+ if let Some((left, right)) = condition.split_once("!=") {
1235+ left.trim() != right.trim()
1236+ } else if let Some((left, right)) = condition.split_once("==") {
1237+ left.trim() == right.trim()
1238+ } else {
1239+ // Truthy check: non-empty, not "false", not "0"
1240+ let trimmed = condition.trim();
1241+ !trimmed.is_empty() && trimmed != "false" && trimmed != "0"
1242+ }
1243+}
1244+
1245+// ---------------------------------------------------------------------------
1246+// Post-parse validation (context-dependent warnings)
1247+// ---------------------------------------------------------------------------
1248+
1249+fn strip_all_prefixes(key: &str) -> &str {
1250+ let key = key
1251+ .strip_prefix("hover:")
1252+ .or_else(|| key.strip_prefix("active:"))
1253+ .or_else(|| key.strip_prefix("focus:"))
1254+ .unwrap_or(key);
1255+ key.strip_prefix("sm:")
1256+ .or_else(|| key.strip_prefix("md:"))
1257+ .or_else(|| key.strip_prefix("lg:"))
1258+ .or_else(|| key.strip_prefix("xl:"))
1259+ .unwrap_or(key)
1260+}
1261+
1262+fn validate_tree(
1263+ nodes: &[Node],
1264+ parent_kind: Option<&ElementKind>,
1265+ diagnostics: &mut Vec<Diagnostic>,
1266+) {
1267+ for node in nodes {
1268+ if let Node::Element(elem) = node {
1269+ for attr in &elem.attrs {
1270+ let base = strip_all_prefixes(&attr.key);
1271+ if base == "width" && attr.value.as_deref() == Some("fill") {
1272+ if !matches!(parent_kind, Some(ElementKind::Row)) {
1273+ diagnostics.push(Diagnostic {
1274+ line: elem.line_num,
1275+ message: "'width fill' works best inside @row; using 100% as fallback"
1276+ .to_string(),
1277+ severity: Severity::Warning,
1278+ source_line: None,
1279+ });
1280+ }
1281+ }
1282+ if base == "height" && attr.value.as_deref() == Some("fill") {
1283+ if !matches!(parent_kind, Some(ElementKind::Column)) {
1284+ diagnostics.push(Diagnostic {
1285+ line: elem.line_num,
1286+ message:
1287+ "'height fill' works best inside @column; using 100% as fallback"
1288+ .to_string(),
1289+ severity: Severity::Warning,
1290+ source_line: None,
1291+ });
1292+ }
1293+ }
1294+ }
1295+ validate_tree(&elem.children, Some(&elem.kind), diagnostics);
1296+ }
1297+ }
1298+}
1299+
1300+// ---------------------------------------------------------------------------
1301+// Variable substitution
1302+// ---------------------------------------------------------------------------
1303+
1052fn substitute_vars(input: &str, vars: &HashMap<String, String>) -> String {
1304fn substitute_vars(input: &str, vars: &HashMap<String, String>) -> String {
1053 if !input.contains('$') {
1305 if !input.contains('$') {
1054 return input.to_string();
1306 return input.to_string();
tests/snapshots.rs+187
@@ -14,6 +14,10 @@ fn compile(input: &str) -> String {
14 htmlang::codegen::generate(&result.document)
14 htmlang::codegen::generate(&result.document)
15}
15}
16
16
17+fn parse_diagnostics(input: &str) -> Vec<htmlang::parser::Diagnostic> {
18+ htmlang::parser::parse(input).diagnostics
19+}
20+
17fn snapshot_test(name: &str) {
21fn snapshot_test(name: &str) {
18 let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/snapshots");
22 let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/snapshots");
19 let hl_path = dir.join(format!("{}.hl", name));
23 let hl_path = dir.join(format!("{}.hl", name));
@@ -112,3 +116,186 @@ fn snapshot_animations() {
112fn snapshot_css_vars() {
116fn snapshot_css_vars() {
113 snapshot_test("css_vars");
117 snapshot_test("css_vars");
114}
118}
119+
120+#[test]
121+fn snapshot_form_elements() {
122+ snapshot_test("form_elements");
123+}
124+
125+#[test]
126+fn snapshot_conditionals() {
127+ snapshot_test("conditionals");
128+}
129+
130+#[test]
131+fn snapshot_loops() {
132+ snapshot_test("loops");
133+}
134+
135+#[test]
136+fn snapshot_accessibility() {
137+ snapshot_test("accessibility");
138+}
139+
140+// ---------------------------------------------------------------------------
141+// Error case tests
142+// ---------------------------------------------------------------------------
143+
144+#[test]
145+fn error_unknown_element() {
146+ let diags = parse_diagnostics("@unknown");
147+ assert!(
148+ diags
149+ .iter()
150+ .any(|d| d.message.contains("unknown element @unknown")),
151+ "expected unknown element error, got: {:?}",
152+ diags
153+ );
154+}
155+
156+#[test]
157+fn error_unknown_element_suggestion() {
158+ let diags = parse_diagnostics("@ro");
159+ assert!(
160+ diags
161+ .iter()
162+ .any(|d| d.message.contains("did you mean @row")),
163+ "expected suggestion, got: {:?}",
164+ diags
165+ );
166+}
167+
168+#[test]
169+fn error_unknown_attribute() {
170+ let diags = parse_diagnostics("@el [bakground red]");
171+ assert!(
172+ diags
173+ .iter()
174+ .any(|d| d.message.contains("unknown attribute") && d.message.contains("background")),
175+ "expected unknown attribute with suggestion, got: {:?}",
176+ diags
177+ );
178+}
179+
180+#[test]
181+fn error_unclosed_bracket() {
182+ let diags = parse_diagnostics("@el [padding 10");
183+ assert!(
184+ diags.iter().any(|d| d.message.contains("unclosed")),
185+ "expected unclosed bracket error, got: {:?}",
186+ diags
187+ );
188+}
189+
190+#[test]
191+fn error_recursive_function() {
192+ let input = "@fn loop\n @loop\n@loop";
193+ let diags = parse_diagnostics(input);
194+ assert!(
195+ diags
196+ .iter()
197+ .any(|d| d.message.contains("recursive function call")),
198+ "expected recursive function error, got: {:?}",
199+ diags
200+ );
201+}
202+
203+#[test]
204+fn error_else_without_if() {
205+ let diags = parse_diagnostics("@else");
206+ assert!(
207+ diags
208+ .iter()
209+ .any(|d| d.message.contains("@else without matching @if")),
210+ "expected @else error, got: {:?}",
211+ diags
212+ );
213+}
214+
215+#[test]
216+fn error_each_bad_syntax() {
217+ let diags = parse_diagnostics("@each $x");
218+ assert!(
219+ diags
220+ .iter()
221+ .any(|d| d.message.contains("@each requires")),
222+ "expected @each syntax error, got: {:?}",
223+ diags
224+ );
225+}
226+
227+#[test]
228+fn error_numeric_validation() {
229+ let diags = parse_diagnostics("@el [padding abc]");
230+ assert!(
231+ diags
232+ .iter()
233+ .any(|d| d.message.contains("expects a numeric value")),
234+ "expected numeric validation warning, got: {:?}",
235+ diags
236+ );
237+}
238+
239+#[test]
240+fn error_opacity_range() {
241+ let diags = parse_diagnostics("@el [opacity 2.0]");
242+ assert!(
243+ diags
244+ .iter()
245+ .any(|d| d.message.contains("between 0 and 1")),
246+ "expected opacity range warning, got: {:?}",
247+ diags
248+ );
249+}
250+
251+#[test]
252+fn warning_fill_outside_row() {
253+ let diags = parse_diagnostics("@column\n @el [width fill]");
254+ assert!(
255+ diags
256+ .iter()
257+ .any(|d| d.message.contains("width fill") && d.message.contains("@row")),
258+ "expected fill context warning, got: {:?}",
259+ diags
260+ );
261+}
262+
263+#[test]
264+fn warning_fill_inside_row_ok() {
265+ let diags = parse_diagnostics("@row\n @el [width fill]");
266+ assert!(
267+ !diags.iter().any(|d| d.message.contains("width fill")),
268+ "should not warn about width fill inside @row, got: {:?}",
269+ diags
270+ );
271+}
272+
273+#[test]
274+fn if_condition_truthy() {
275+ let output = compile("@let x hello\n@if $x\n @text visible");
276+ assert!(output.contains("visible"));
277+}
278+
279+#[test]
280+fn if_condition_falsy() {
281+ let output = compile("@let x false\n@if $x\n @text hidden");
282+ assert!(!output.contains("hidden"));
283+}
284+
285+#[test]
286+fn each_loop_expansion() {
287+ let output = compile("@each $n in a,b,c\n @text $n");
288+ assert!(output.contains("a"));
289+ assert!(output.contains("b"));
290+ assert!(output.contains("c"));
291+}
292+
293+#[test]
294+fn aria_data_attrs_accepted() {
295+ let diags = parse_diagnostics("@el [aria-label Test, data-id 42]");
296+ assert!(
297+ !diags.iter().any(|d| d.message.contains("unknown attribute")),
298+ "aria-*/data-* should not produce warnings, got: {:?}",
299+ diags
300+ );
301+}
tests/snapshots/accessibility.hlnew file+6
@@ -0,0 +1,6 @@
1+@page Accessibility
2+@image [alt A photo of sunset, width 200] https://example.com/sunset.jpg
3+@el [role navigation, aria-label Main menu]
4+ @link [tabindex 0, aria-current page] / Home
5+ @link [tabindex 0] /about About
6+@button [aria-pressed false, data-action toggle] Toggle
tests/snapshots/conditionals.hlnew file+21
@@ -0,0 +1,21 @@
1+@page Conditionals
2+@let theme dark
3+@if $theme == dark
4+ @el [background #333, color white, padding 20]
5+ @text Dark mode
6+@else
7+ @el [background white, color #333, padding 20]
8+ @text Light mode
9+
10+@let show true
11+@if $show
12+ @text Visible
13+
14+@let hidden false
15+@if $hidden
16+ @text Should not appear
17+
18+@let a hello
19+@let b world
20+@if $a != $b
21+ @text They differ
tests/snapshots/form_elements.hlnew file+12
@@ -0,0 +1,12 @@
1+@page Form Elements
2+@row [spacing 10]
3+ @button [type submit] Click me
4+ @input [type text, placeholder Enter name, name username]
5+ @input [type email, required, disabled]
6+@column [spacing 5]
7+ @label [for bio] Biography
8+ @textarea [name bio, rows 4] Default text
9+@select [name color]
10+ @option [value red] Red
11+ @option [value green] Green
12+ @option Blue
tests/snapshots/loops.hlnew file+8
@@ -0,0 +1,8 @@
1+@page Loops
2+@let colors red,green,blue
3+@row [spacing 10]
4+ @each $color in $colors
5+ @el [background $color, width 50, height 50]
6+
7+@each $item in apple,banana,cherry
8+ @text $item