on master help sign in
Add 17 improvements: semantic HTML elements, lists, tables, media, pattern matching, formatters, and new CSS attributes
commitb68ee4157094f1122cb2ebd39567ee0718c012ed authoriko <mail@iko.soy> dateWed 15 Apr 2026, 12:16 (6 months ago) parentcd9c463
15 files changed+1026−24
DESIGN.md+17 src/ast.rs+21 src/bin/htmlang-lsp.rs+96 src/codegen.rs+147−3 src/fmt.rs+51 src/lib.rs+1 src/main.rs+51−19 src/parser.rs+192−2 tests/snapshots.rs+355 tests/snapshots/list_elements.hl+15 tests/snapshots/match_directive.hl+14 tests/snapshots/media_elements.hl+8 tests/snapshots/new_css_attrs.hl+17 tests/snapshots/semantic_elements.hl+24 tests/snapshots/table_elements.hl+17
DESIGN.md+17
@@ -21,6 +21,23 @@ A minimalist layout language inspired by elm-ui that compiles to static HTML.
2121| `@image` | img | Image |
2222| `@link` | a | Anchor wrapping children |
2323| `@raw` | verbatim HTML | Escape hatch |
24+| `@nav` | nav | Navigation landmark |
25+| `@header` | header | Page/section header |
26+| `@footer` | footer | Page/section footer |
27+| `@main` | main | Main content area |
28+| `@section` | section | Thematic section |
29+| `@article` | article | Self-contained content |
30+| `@aside` | aside | Sidebar/tangential content |
31+| `@list` | ul/ol | List (`[ordered]` for ol) |
32+| `@item` | li | List item |
33+| `@table` | table | Table |
34+| `@thead` | thead | Table head group |
35+| `@tbody` | tbody | Table body group |
36+| `@tr` | tr | Table row |
37+| `@td` | td | Table cell |
38+| `@th` | th | Table header cell |
39+| `@video` | video | Video element |
40+| `@audio` | audio | Audio element |
2441
2542Bare lines (not starting with `@` or `[`) are text nodes.
2643
src/ast.rs+21
@@ -55,6 +55,27 @@ pub enum ElementKind {
5555 Label,
5656 // Slots
5757 Slot(String),
58+ // Semantic elements
59+ Nav,
60+ Header,
61+ Footer,
62+ Main,
63+ Section,
64+ Article,
65+ Aside,
66+ // List elements
67+ List,
68+ ListItem,
69+ // Table elements
70+ Table,
71+ TableHead,
72+ TableBody,
73+ TableRow,
74+ TableCell,
75+ TableHeaderCell,
76+ // Media elements
77+ Video,
78+ Audio,
5879}
5980
6081#[derive(Debug, Clone)]
src/bin/htmlang-lsp.rs+96
@@ -354,6 +354,28 @@ fn element_completions(range: Range) -> Vec<CompletionItem> {
354354 ("@raw", "Raw HTML escape hatch"),
355355 ("@children", "Slot for caller's children (inside @fn)"),
356356 ("@slot", "Named slot inside @fn (e.g., @slot header)"),
357+ // Semantic elements
358+ ("@nav", "Navigation container (nav)"),
359+ ("@header", "Page/section header (header)"),
360+ ("@footer", "Page/section footer (footer)"),
361+ ("@main", "Main content area (main)"),
362+ ("@section", "Thematic section (section)"),
363+ ("@article", "Self-contained content (article)"),
364+ ("@aside", "Sidebar/tangential content (aside)"),
365+ // List elements
366+ ("@list", "List container (ul/ol, use [ordered] for ol)"),
367+ ("@item", "List item (li)"),
368+ ("@li", "List item (short for @item)"),
369+ // Table elements
370+ ("@table", "Table element"),
371+ ("@thead", "Table head group"),
372+ ("@tbody", "Table body group"),
373+ ("@tr", "Table row"),
374+ ("@td", "Table cell"),
375+ ("@th", "Table header cell"),
376+ // Media elements
377+ ("@video", "Video element"),
378+ ("@audio", "Audio element"),
357379 ]
358380 .iter()
359381 .map(|(name, detail)| item(name, CompletionItemKind::KEYWORD, detail, name, range))
@@ -377,6 +399,11 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
377399 ("@head", "Add raw content to <head>", "@head"),
378400 ("@style", "Add raw CSS to stylesheet", "@style"),
379401 ("@slot", "Named slot in @fn for caller content", "@slot "),
402+ ("@match", "Pattern matching on a value", "@match "),
403+ ("@case", "Match case (inside @match)", "@case "),
404+ ("@default", "Default case (inside @match)", "@default"),
405+ ("@warn", "Emit a compile-time warning", "@warn "),
406+ ("@debug", "Print debug message during compilation", "@debug "),
380407 ]
381408 .iter()
382409 .map(|(name, detail, insert)| {
@@ -488,6 +515,28 @@ fn attr_completions(range: Range) -> Vec<CompletionItem> {
488515 ("aria-label", "Accessible label", true),
489516 ("aria-hidden", "Hide from assistive tech (true/false)", true),
490517 ("data-", "Custom data attribute", true),
518+ // CSS: aspect-ratio, outline, logical properties, scroll-snap
519+ ("aspect-ratio", "CSS aspect ratio (e.g., 16/9, 1)", true),
520+ ("outline", "Outline (width [color])", true),
521+ ("padding-inline", "Inline (horizontal) padding for i18n", true),
522+ ("padding-block", "Block (vertical) padding for i18n", true),
523+ ("margin-inline", "Inline (horizontal) margin for i18n", true),
524+ ("margin-block", "Block (vertical) margin for i18n", true),
525+ ("scroll-snap-type", "Scroll snap behavior (x/y mandatory/proximity)", true),
526+ ("scroll-snap-align", "Snap alignment (start/center/end)", true),
527+ // Media attributes
528+ ("controls", "Show media controls (for @video, @audio)", false),
529+ ("autoplay", "Auto-play media", false),
530+ ("loop", "Loop media playback", false),
531+ ("muted", "Mute media", false),
532+ ("poster", "Video poster image URL", true),
533+ ("preload", "Media preload hint (auto/metadata/none)", true),
534+ ("loading", "Loading behavior (lazy/eager)", true),
535+ ("decoding", "Image decoding (async/sync/auto)", true),
536+ // List
537+ ("ordered", "Use ordered list (ol instead of ul)", false),
538+ // Media src
539+ ("src", "Source URL for media elements", true),
491540 // State prefixes
492541 ("hover:", "Style on hover", false),
493542 ("active:", "Style on active/click", false),
@@ -761,6 +810,33 @@ fn hover_builtin(word: &str) -> Option<String> {
761810 "@head" => "**@head** \u{2014} Head content\n\nAdds raw content to `<head>` (fonts, icons, etc.).\n\n```\n@head\n <link rel=\"icon\" href=\"favicon.ico\">\n```",
762811 "@style" => "**@style** \u{2014} Custom CSS\n\nAdds raw CSS to the stylesheet.\n\n```\n@style\n .custom { border: 1px solid red; }\n @container sidebar (min-width: 400px) { ... }\n```",
763812 "@slot" => "**@slot** \u{2014} Named slot\n\nDefines a named insertion point inside `@fn`. Callers fill it with `@slot name` + children.\n\n```\n@fn layout\n @slot header\n @children\n @slot footer\n```",
813+ // Semantic elements
814+ "@nav" => "**@nav** \u{2014} Navigation\n\nRenders as `<nav>`. Semantic landmark for navigation links.",
815+ "@header" => "**@header** \u{2014} Header\n\nRenders as `<header>`. Page or section header.",
816+ "@footer" => "**@footer** \u{2014} Footer\n\nRenders as `<footer>`. Page or section footer.",
817+ "@main" => "**@main** \u{2014} Main content\n\nRenders as `<main>`. Primary content of the page.",
818+ "@section" => "**@section** \u{2014} Section\n\nRenders as `<section>`. Thematic grouping of content.",
819+ "@article" => "**@article** \u{2014} Article\n\nRenders as `<article>`. Self-contained, independently distributable content.",
820+ "@aside" => "**@aside** \u{2014} Aside\n\nRenders as `<aside>`. Content tangentially related to surrounding content.",
821+ // List elements
822+ "@list" => "**@list** \u{2014} List\n\nRenders as `<ul>` (or `<ol>` with `[ordered]`).\n\nUsage:\n```\n@list [ordered]\n @item First\n @item Second\n```",
823+ "@item" | "@li" => "**@item** \u{2014} List item\n\nRenders as `<li>`. Use inside `@list`.",
824+ // Table elements
825+ "@table" => "**@table** \u{2014} Table\n\nRenders as `<table>`.\n\n```\n@table\n @thead\n @tr\n @th Name\n @th Age\n @tbody\n @tr\n @td Alice\n @td 30\n```",
826+ "@thead" => "**@thead** \u{2014} Table head\n\nRenders as `<thead>`. Groups header rows.",
827+ "@tbody" => "**@tbody** \u{2014} Table body\n\nRenders as `<tbody>`. Groups body rows.",
828+ "@tr" => "**@tr** \u{2014} Table row\n\nRenders as `<tr>`.",
829+ "@td" => "**@td** \u{2014} Table cell\n\nRenders as `<td>`. Regular table data cell.",
830+ "@th" => "**@th** \u{2014} Table header cell\n\nRenders as `<th>`. Header cell (typically bold/centered).",
831+ // Media elements
832+ "@video" => "**@video** \u{2014} Video\n\nRenders as `<video>`.\n\nUsage: `@video [controls] demo.mp4`",
833+ "@audio" => "**@audio** \u{2014} Audio\n\nRenders as `<audio>`.\n\nUsage: `@audio [controls] song.mp3`",
834+ // Directives
835+ "@match" => "**@match** \u{2014} Pattern matching\n\nMatch a value against cases.\n\n```\n@match $theme\n @case dark\n @el [background #333]\n @case light\n @el [background white]\n @default\n @el [background gray]\n```",
836+ "@case" => "**@case** \u{2014} Match case\n\nA case inside `@match`. Matches when the value equals the case value.",
837+ "@default" => "**@default** \u{2014} Default case\n\nFallback case inside `@match` when no other case matches.",
838+ "@warn" => "**@warn** \u{2014} Compile warning\n\nEmit a custom warning during compilation.\n\nUsage: `@warn This value is deprecated`",
839+ "@debug" => "**@debug** \u{2014} Debug message\n\nPrint a debug message to stderr during compilation.\n\nUsage: `@debug Theme is $theme`",
764840 // Attributes
765841 "spacing" | "gap" => "**spacing** `<value>`\n\nGap between children. Supports CSS units (px, rem, em, %).\nMaps to CSS `gap`.",
766842 "padding" => "**padding** `<value>` | `<y> <x>` | `<t> <h> <b>` | `<t> <r> <b> <l>`\n\nInner padding. Supports CSS units. Accepts 1\u{2013}4 values.",
@@ -842,6 +918,26 @@ fn hover_builtin(word: &str) -> Option<String> {
842918 "role" => "**role** `<value>` \u{2014} ARIA role (e.g., `navigation`, `banner`, `main`).",
843919 "tabindex" => "**tabindex** `<value>` \u{2014} Tab order. `0` = natural order, `-1` = skip.",
844920 "title" => "**title** `<value>` \u{2014} Tooltip text.",
921+ // New CSS attributes
922+ "aspect-ratio" => "**aspect-ratio** `<value>` \u{2014} CSS aspect ratio (e.g., `16/9`, `1`, `4/3`).",
923+ "outline" => "**outline** `<width> [color]` \u{2014} Outline (like border but doesn't affect layout).",
924+ "padding-inline" => "**padding-inline** `<value>` \u{2014} Horizontal padding (logical property, i18n-aware).",
925+ "padding-block" => "**padding-block** `<value>` \u{2014} Vertical padding (logical property, i18n-aware).",
926+ "margin-inline" => "**margin-inline** `<value>` \u{2014} Horizontal margin (logical property, i18n-aware).",
927+ "margin-block" => "**margin-block** `<value>` \u{2014} Vertical margin (logical property, i18n-aware).",
928+ "scroll-snap-type" => "**scroll-snap-type** `<value>` \u{2014} Scroll snap type (`x mandatory`, `y proximity`).",
929+ "scroll-snap-align" => "**scroll-snap-align** `<value>` \u{2014} Scroll snap alignment (`start`, `center`, `end`).",
930+ // Media/image attributes
931+ "loading" => "**loading** `<value>` \u{2014} Loading behavior for images (`lazy`, `eager`).",
932+ "decoding" => "**decoding** `<value>` \u{2014} Image decoding mode (`async`, `sync`, `auto`).",
933+ "controls" => "**controls** \u{2014} Show media controls (for @video, @audio).",
934+ "autoplay" => "**autoplay** \u{2014} Auto-play media.",
935+ "loop" => "**loop** \u{2014} Loop media playback.",
936+ "muted" => "**muted** \u{2014} Mute media.",
937+ "poster" => "**poster** `<url>` \u{2014} Poster image for video.",
938+ "preload" => "**preload** `<value>` \u{2014} Media preload hint (`auto`, `metadata`, `none`).",
939+ "ordered" => "**ordered** \u{2014} Use ordered list (`<ol>` instead of `<ul>`).",
940+ "src" => "**src** `<url>` \u{2014} Source URL for media elements.",
845941 _ => return None,
846942 };
847943
src/codegen.rs+147−3
@@ -328,6 +328,9 @@ fn generate_node(
328328 let needs_wrap = matches!(
329329 parent_kind,
330330 Some(ElementKind::Row) | Some(ElementKind::Column) | Some(ElementKind::El)
331+ | Some(ElementKind::Nav) | Some(ElementKind::Header) | Some(ElementKind::Footer)
332+ | Some(ElementKind::Main) | Some(ElementKind::Section) | Some(ElementKind::Article)
333+ | Some(ElementKind::Aside) | Some(ElementKind::ListItem)
331334 );
332335 if needs_wrap {
333336 out.push_str(&ctx.indent());
@@ -353,10 +356,19 @@ const HTML_PASSTHROUGH_ATTRS: &[&str] = &[
353356 "for", "action", "method", "autocomplete",
354357 "min", "max", "step", "pattern", "maxlength", "rows", "cols", "multiple",
355358 "alt", "role", "tabindex", "title",
359+ // Media
360+ "controls", "autoplay", "loop", "muted", "poster", "preload",
361+ // Image optimization
362+ "loading", "decoding",
363+ // Media src (explicit attribute form)
364+ "src",
356365];
357366
358367/// Boolean HTML attributes (rendered without a value, e.g., `<input disabled>`).
359−const BOOLEAN_HTML_ATTRS: &[&str] = &["disabled", "required", "checked", "multiple"];
368+const BOOLEAN_HTML_ATTRS: &[&str] = &[
369+ "disabled", "required", "checked", "multiple",
370+ "controls", "autoplay", "loop", "muted",
371+];
360372
361373fn emit_html_passthrough_attrs(out: &mut String, attrs: &[Attribute]) {
362374 for attr in attrs {
@@ -399,7 +411,7 @@ fn generate_element(
399411 return;
400412 }
401413
402− let tag = match elem.kind {
414+ let tag = match &elem.kind {
403415 ElementKind::Row | ElementKind::Column | ElementKind::El => "div",
404416 ElementKind::Text => "span",
405417 ElementKind::Paragraph => "p",
@@ -409,6 +421,29 @@ fn generate_element(
409421 ElementKind::Textarea => "textarea",
410422 ElementKind::Option => "option",
411423 ElementKind::Label => "label",
424+ // Semantic elements
425+ ElementKind::Nav => "nav",
426+ ElementKind::Header => "header",
427+ ElementKind::Footer => "footer",
428+ ElementKind::Main => "main",
429+ ElementKind::Section => "section",
430+ ElementKind::Article => "article",
431+ ElementKind::Aside => "aside",
432+ // List
433+ ElementKind::List => {
434+ if elem.attrs.iter().any(|a| a.key == "ordered") { "ol" } else { "ul" }
435+ }
436+ ElementKind::ListItem => "li",
437+ // Table
438+ ElementKind::Table => "table",
439+ ElementKind::TableHead => "thead",
440+ ElementKind::TableBody => "tbody",
441+ ElementKind::TableRow => "tr",
442+ ElementKind::TableCell => "td",
443+ ElementKind::TableHeaderCell => "th",
444+ // Media
445+ ElementKind::Video => "video",
446+ ElementKind::Audio => "audio",
412447 ElementKind::Image | ElementKind::Input | ElementKind::Children | ElementKind::Slot(_) => unreachable!(),
413448 };
414449
@@ -424,6 +459,23 @@ fn generate_element(
424459 ElementKind::Textarea => "textarea",
425460 ElementKind::Option => "option",
426461 ElementKind::Label => "label",
462+ ElementKind::Nav => "nav",
463+ ElementKind::Header => "header",
464+ ElementKind::Footer => "footer",
465+ ElementKind::Main => "main",
466+ ElementKind::Section => "section",
467+ ElementKind::Article => "article",
468+ ElementKind::Aside => "aside",
469+ ElementKind::List => "list",
470+ ElementKind::ListItem => "item",
471+ ElementKind::Table => "table",
472+ ElementKind::TableHead => "thead",
473+ ElementKind::TableBody => "tbody",
474+ ElementKind::TableRow => "tr",
475+ ElementKind::TableCell => "td",
476+ ElementKind::TableHeaderCell => "th",
477+ ElementKind::Video => "video",
478+ ElementKind::Audio => "audio",
427479 _ => "",
428480 };
429481
@@ -447,6 +499,15 @@ fn generate_element(
447499 }
448500 }
449501
502+ // Video/Audio src
503+ if matches!(elem.kind, ElementKind::Video | ElementKind::Audio) {
504+ if let Some(src) = &elem.argument {
505+ out.push_str(" src=\"");
506+ out.push_str(&html_escape(src));
507+ out.push('"');
508+ }
509+ }
510+
450511 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
451512
452513 if let Some(id) = id {
@@ -468,6 +529,9 @@ fn generate_element(
468529 | ElementKind::Label
469530 | ElementKind::Option
470531 | ElementKind::Textarea
532+ | ElementKind::ListItem
533+ | ElementKind::TableCell
534+ | ElementKind::TableHeaderCell
471535 ) {
472536 if let Some(text) = &elem.argument {
473537 out.push_str(&html_escape(text));
@@ -534,6 +598,16 @@ fn generate_self_closing(
534598
535599 emit_html_passthrough_attrs(out, &elem.attrs);
536600
601+ // Image optimization: auto-add loading="lazy" and decoding="async"
602+ if elem.kind == ElementKind::Image {
603+ if !elem.attrs.iter().any(|a| a.key == "loading") {
604+ out.push_str(" loading=\"lazy\"");
605+ }
606+ if !elem.attrs.iter().any(|a| a.key == "decoding") {
607+ out.push_str(" decoding=\"async\"");
608+ }
609+ }
610+
537611 out.push('>');
538612 out.push_str(ctx.nl());
539613}
@@ -634,6 +708,13 @@ fn attrs_to_css(
634708 ElementKind::Column => css.push_str("display:flex;flex-direction:column;"),
635709 ElementKind::El => css.push_str("display:flex;flex-direction:column;"),
636710 ElementKind::Paragraph => css.push_str("margin:0;"),
711+ // Semantic elements get flex column layout like @el
712+ ElementKind::Nav | ElementKind::Header | ElementKind::Footer
713+ | ElementKind::Main | ElementKind::Section | ElementKind::Article
714+ | ElementKind::Aside => css.push_str("display:flex;flex-direction:column;"),
715+ // Lists: reset browser defaults
716+ ElementKind::List => css.push_str("margin:0;padding-left:0;list-style:none;"),
717+ ElementKind::ListItem => css.push_str("display:flex;flex-direction:column;"),
637718 _ => {}
638719 }
639720 }
@@ -998,6 +1079,67 @@ fn attrs_to_css(
9981079 }
9991080 }
10001081
1082+ // Aspect ratio
1083+ "aspect-ratio" => {
1084+ if let Some(v) = val {
1085+ push_css(&mut css, "aspect-ratio", v);
1086+ }
1087+ }
1088+
1089+ // Outline (like border but doesn't affect layout)
1090+ "outline" => {
1091+ if let Some(v) = val {
1092+ let parts: Vec<&str> = v.splitn(2, ' ').collect();
1093+ if parts.len() == 2 {
1094+ push_css(
1095+ &mut css,
1096+ "outline",
1097+ &format!("{} solid {}", css_px(parts[0]), parts[1]),
1098+ );
1099+ } else {
1100+ push_css(
1101+ &mut css,
1102+ "outline",
1103+ &format!("{} solid currentColor", css_px(parts[0])),
1104+ );
1105+ }
1106+ }
1107+ }
1108+
1109+ // Logical properties (i18n-aware)
1110+ "padding-inline" => {
1111+ if let Some(v) = val {
1112+ push_css(&mut css, "padding-inline", &css_px_multi(v));
1113+ }
1114+ }
1115+ "padding-block" => {
1116+ if let Some(v) = val {
1117+ push_css(&mut css, "padding-block", &css_px_multi(v));
1118+ }
1119+ }
1120+ "margin-inline" => {
1121+ if let Some(v) = val {
1122+ push_css(&mut css, "margin-inline", &css_px_multi(v));
1123+ }
1124+ }
1125+ "margin-block" => {
1126+ if let Some(v) = val {
1127+ push_css(&mut css, "margin-block", &css_px_multi(v));
1128+ }
1129+ }
1130+
1131+ // Scroll snap
1132+ "scroll-snap-type" => {
1133+ if let Some(v) = val {
1134+ push_css(&mut css, "scroll-snap-type", v);
1135+ }
1136+ }
1137+ "scroll-snap-align" => {
1138+ if let Some(v) = val {
1139+ push_css(&mut css, "scroll-snap-align", v);
1140+ }
1141+ }
1142+
10011143 // Container queries
10021144 "container" => {
10031145 push_css(&mut css, "container-type", "inline-size");
@@ -1018,7 +1160,9 @@ fn attrs_to_css(
10181160 "type" | "placeholder" | "name" | "value" | "disabled" | "required"
10191161 | "checked" | "for" | "action" | "method" | "autocomplete" | "min"
10201162 | "max" | "step" | "pattern" | "maxlength" | "rows" | "cols"
1021− | "multiple" | "alt" | "role" | "tabindex" | "title" => {}
1163+ | "multiple" | "alt" | "role" | "tabindex" | "title"
1164+ | "controls" | "autoplay" | "loop" | "muted" | "poster" | "preload"
1165+ | "loading" | "decoding" | "ordered" | "src" => {}
10221166
10231167 _ => {}
10241168 }
src/fmt.rsnew file+51
@@ -0,0 +1,51 @@
1+/// Format an htmlang source file with normalized indentation (2 spaces per level).
2+pub fn format(input: &str) -> String {
3+ let mut output = String::new();
4+ let mut indent_stack: Vec<i32> = vec![-1]; // sentinel
5+ let mut bracket_depth: i32 = 0;
6+ let mut bracket_base_level: usize = 0;
7+
8+ for line in input.lines() {
9+ let trimmed = line.trim();
10+
11+ // Preserve blank lines
12+ if trimmed.is_empty() {
13+ output.push('\n');
14+ continue;
15+ }
16+
17+ let open = trimmed.chars().filter(|&c| c == '[').count() as i32;
18+ let close = trimmed.chars().filter(|&c| c == ']').count() as i32;
19+
20+ // Inside multi-line bracket continuation
21+ if bracket_depth > 0 {
22+ let level = bracket_base_level + 1;
23+ output.push_str(&" ".repeat(level));
24+ output.push_str(trimmed);
25+ output.push('\n');
26+ bracket_depth += open - close;
27+ continue;
28+ }
29+
30+ let raw_indent = (line.len() - line.trim_start().len()) as i32;
31+
32+ // Pop stack to find parent
33+ while indent_stack.len() > 1 && *indent_stack.last().unwrap() >= raw_indent {
34+ indent_stack.pop();
35+ }
36+
37+ let level = indent_stack.len() - 1;
38+ output.push_str(&" ".repeat(level));
39+ output.push_str(trimmed);
40+ output.push('\n');
41+
42+ indent_stack.push(raw_indent);
43+
44+ bracket_depth = open - close;
45+ if bracket_depth > 0 {
46+ bracket_base_level = level;
47+ }
48+ }
49+
50+ output
51+}
src/lib.rs+1
@@ -1,4 +1,5 @@
11pub mod ast;
22pub mod codegen;
3+pub mod fmt;
34pub mod parser;
45pub mod serve;
src/main.rs+51−19
@@ -5,7 +5,7 @@ use std::hash::{Hash, Hasher};
55use std::path::{Path, PathBuf};
66use std::process;
77
8−fn compile(input_path: &str, dev: bool, error_overlay: bool) -> (bool, Vec<PathBuf>) {
8+fn compile(input_path: &str, dev: bool, error_overlay: bool, check_only: bool) -> (bool, Vec<PathBuf>) {
99 let input = match fs::read_to_string(input_path) {
1010 Ok(s) => s,
1111 Err(e) => {
@@ -35,20 +35,22 @@ fn compile(input_path: &str, dev: bool, error_overlay: bool) -> (bool, Vec<PathB
3535
3636 let out_path = Path::new(input_path).with_extension("html");
3737
38− if has_errors {
39− if error_overlay {
40− let error_html = generate_error_overlay(&result.diagnostics, input_path);
41− let _ = fs::write(&out_path, &error_html);
42− }
43− } else {
44− let html = if dev {
45− htmlang::codegen::generate_dev(&result.document)
38+ if !check_only {
39+ if has_errors {
40+ if error_overlay {
41+ let error_html = generate_error_overlay(&result.diagnostics, input_path);
42+ let _ = fs::write(&out_path, &error_html);
43+ }
4644 } else {
47− htmlang::codegen::generate(&result.document)
48− };
49− match fs::write(&out_path, &html) {
50− Ok(()) => eprintln!("wrote {}", out_path.display()),
51− Err(e) => eprintln!("error: {}: {}", out_path.display(), e),
45+ let html = if dev {
46+ htmlang::codegen::generate_dev(&result.document)
47+ } else {
48+ htmlang::codegen::generate(&result.document)
49+ };
50+ match fs::write(&out_path, &html) {
51+ Ok(()) => eprintln!("wrote {}", out_path.display()),
52+ Err(e) => eprintln!("error: {}: {}", out_path.display(), e),
53+ }
5254 }
5355 }
5456
@@ -103,16 +105,22 @@ Options:
103105 -s, --serve Start dev server with hot reload (implies --watch)
104106 -p, --port <N> Port for dev server (default: 3000)
105107 -d, --dev Development mode
108+ -c, --check Check for errors without writing output
106109 -h, --help Show this help
107110 -V, --version Show version
108111
112+Commands:
113+ fmt <file.hl> Format a .hl file (normalizes indentation)
114+
109115Examples:
110116 htmlang page.hl Compile page.hl to page.html
111117 htmlang site/ Compile all .hl files in directory
112118 htmlang -w page.hl Recompile on file changes
113119 htmlang -s page.hl Start dev server with hot reload
114120 htmlang -s site/ Serve a multi-page site
115− htmlang -s -p 8080 page.hl",
121+ htmlang -s -p 8080 page.hl
122+ htmlang -c page.hl Lint without writing output
123+ htmlang fmt page.hl Format a file",
116124 env!("CARGO_PKG_VERSION")
117125 );
118126}
@@ -122,9 +130,32 @@ fn main() {
122130 let mut watch = false;
123131 let mut serve = false;
124132 let mut dev = false;
133+ let mut check = false;
125134 let mut port: u16 = 3000;
126135 let mut input_path = None;
127136
137+ // Handle "fmt" subcommand
138+ if args.len() >= 3 && args[1] == "fmt" {
139+ let file = &args[2];
140+ match fs::read_to_string(file) {
141+ Ok(input) => {
142+ let formatted = htmlang::fmt::format(&input);
143+ match fs::write(file, &formatted) {
144+ Ok(()) => eprintln!("formatted {}", file),
145+ Err(e) => {
146+ eprintln!("error: {}: {}", file, e);
147+ process::exit(1);
148+ }
149+ }
150+ }
151+ Err(e) => {
152+ eprintln!("error: {}: {}", file, e);
153+ process::exit(1);
154+ }
155+ }
156+ return;
157+ }
158+
128159 let mut i = 1;
129160 while i < args.len() {
130161 match args[i].as_str() {
@@ -138,6 +169,7 @@ fn main() {
138169 }
139170 "--watch" | "-w" => watch = true,
140171 "--dev" | "-d" => dev = true,
172+ "--check" | "-c" => check = true,
141173 "--serve" | "-s" => {
142174 serve = true;
143175 watch = true;
@@ -189,7 +221,7 @@ fn main() {
189221 let mut all_included: Vec<PathBuf> = Vec::new();
190222 for file in &hl_files {
191223 let path_str = file.to_string_lossy().to_string();
192− let (has_errors, included) = compile(&path_str, dev, serve);
224+ let (has_errors, included) = compile(&path_str, dev, serve, check);
193225 if has_errors {
194226 any_errors = true;
195227 }
@@ -222,7 +254,7 @@ fn main() {
222254 }
223255
224256 // --- Single file mode ---
225− let (has_errors, included_files) = compile(&input_path, dev, serve);
257+ let (has_errors, included_files) = compile(&input_path, dev, serve, check);
226258
227259 if !watch {
228260 if has_errors {
@@ -383,7 +415,7 @@ fn watch_loop(
383415 // Recompile all source files
384416 for file in source_files {
385417 let path_str = file.to_string_lossy().to_string();
386− let (_, new_includes) = compile(&path_str, dev, serve);
418+ let (_, new_includes) = compile(&path_str, dev, serve, false);
387419 for inc in &new_includes {
388420 let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
389421 if let Some(h) = hash_file(inc) {
@@ -397,7 +429,7 @@ fn watch_loop(
397429 for file in &collect_hl_files(watch_dir) {
398430 if !source_files.contains(file) {
399431 let path_str = file.to_string_lossy().to_string();
400− let (_, new_includes) = compile(&path_str, dev, serve);
432+ let (_, new_includes) = compile(&path_str, dev, serve, false);
401433 for inc in &new_includes {
402434 let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
403435 }
src/parser.rs+192−2
@@ -336,7 +336,7 @@ impl Parser {
336336 return Ok(None);
337337 }
338338
339− if content.strip_prefix("@head").is_some() {
339+ if content == "@head" || content.starts_with("@head ") {
340340 // Collect indented body lines as raw head content
341341 let mut head_content = String::new();
342342 while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
@@ -682,6 +682,113 @@ impl Parser {
682682 return Ok(Some(all_nodes));
683683 }
684684
685+ // --- @match ---
686+
687+ if let Some(rest) = content.strip_prefix("@match ") {
688+ let match_val = substitute_vars(rest.trim(), &ctx.variables);
689+ track_var_refs(rest.trim(), &mut ctx.used_variables);
690+
691+ // Collect all child lines
692+ let mut match_lines = Vec::new();
693+ while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
694+ match_lines.push(self.lines[self.pos].clone());
695+ self.pos += 1;
696+ }
697+
698+ if match_lines.is_empty() {
699+ return Ok(None);
700+ }
701+
702+ let case_indent = match_lines[0].indent;
703+
704+ // Group into cases: (Some(value), body) or (None, body) for @default
705+ let mut cases: Vec<(Option<String>, Vec<Line>)> = Vec::new();
706+ let mut mi = 0;
707+ while mi < match_lines.len() {
708+ if match_lines[mi].indent == case_indent {
709+ if let LineContent::Normal(ref s) = match_lines[mi].content {
710+ let trimmed = s.trim();
711+ if let Some(case_val) = trimmed.strip_prefix("@case ") {
712+ let case_val = substitute_vars(case_val.trim(), &ctx.variables);
713+ mi += 1;
714+ let mut body = Vec::new();
715+ while mi < match_lines.len() && match_lines[mi].indent > case_indent {
716+ body.push(match_lines[mi].clone());
717+ mi += 1;
718+ }
719+ cases.push((Some(case_val), body));
720+ } else if trimmed == "@default" {
721+ mi += 1;
722+ let mut body = Vec::new();
723+ while mi < match_lines.len() && match_lines[mi].indent > case_indent {
724+ body.push(match_lines[mi].clone());
725+ mi += 1;
726+ }
727+ cases.push((None, body));
728+ } else {
729+ mi += 1;
730+ }
731+ } else {
732+ mi += 1;
733+ }
734+ } else {
735+ mi += 1;
736+ }
737+ }
738+
739+ // Find first matching case or @default
740+ let body_lines = cases
741+ .into_iter()
742+ .find(|(case_val, _)| match case_val {
743+ Some(v) => *v == match_val,
744+ None => true,
745+ })
746+ .map(|(_, lines)| lines)
747+ .unwrap_or_default();
748+
749+ if body_lines.is_empty() {
750+ return Ok(None);
751+ }
752+
753+ let min_indent = body_lines.iter().map(|l| l.indent).min().unwrap_or(0);
754+ let adjusted: Vec<Line> = body_lines
755+ .iter()
756+ .map(|l| Line {
757+ indent: l.indent - min_indent,
758+ content: l.content.clone(),
759+ line_num: l.line_num,
760+ })
761+ .collect();
762+
763+ let saved_vars = ctx.variables.clone();
764+ let mut body_parser = Parser {
765+ lines: adjusted,
766+ pos: 0,
767+ };
768+ let nodes = body_parser.parse_children(0, ctx);
769+ ctx.variables = saved_vars;
770+ return Ok(Some(nodes));
771+ }
772+
773+ // --- @warn / @debug ---
774+
775+ if let Some(rest) = content.strip_prefix("@warn ") {
776+ let msg = substitute_vars(rest.trim(), &ctx.variables);
777+ ctx.diagnostics.push(Diagnostic {
778+ line: line_num,
779+ message: msg,
780+ severity: Severity::Warning,
781+ source_line: Some(content.clone()),
782+ });
783+ return Ok(None);
784+ }
785+
786+ if let Some(rest) = content.strip_prefix("@debug ") {
787+ let msg = substitute_vars(rest.trim(), &ctx.variables);
788+ eprintln!("debug: line {}: {}", line_num, msg);
789+ return Ok(None);
790+ }
791+
685792 // --- @keyframes directive ---
686793
687794 if let Some(rest) = content.strip_prefix("@keyframes ") {
@@ -1015,11 +1122,16 @@ fn parse_single_element(
10151122const KNOWN_ELEMENTS: &[&str] = &[
10161123 "row", "column", "col", "el", "text", "paragraph", "p", "image", "img", "link", "children",
10171124 "input", "button", "select", "textarea", "option", "label", "slot",
1125+ "nav", "header", "footer", "main", "section", "article", "aside",
1126+ "list", "item", "li",
1127+ "table", "thead", "tbody", "tr", "td", "th",
1128+ "video", "audio",
10181129];
10191130
10201131const KNOWN_DIRECTIVES: &[&str] = &[
10211132 "page", "let", "define", "fn", "include", "import", "raw", "keyframes",
10221133 "if", "else", "each", "meta", "head", "style",
1134+ "match", "case", "default", "warn", "debug",
10231135];
10241136
10251137fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
@@ -1039,6 +1151,23 @@ fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseErro
10391151 "option" | "opt" => Ok(ElementKind::Option),
10401152 "label" => Ok(ElementKind::Label),
10411153 "slot" => Ok(ElementKind::Slot(String::new())), // slot name filled in by parse_single_element
1154+ "nav" => Ok(ElementKind::Nav),
1155+ "header" => Ok(ElementKind::Header),
1156+ "footer" => Ok(ElementKind::Footer),
1157+ "main" => Ok(ElementKind::Main),
1158+ "section" => Ok(ElementKind::Section),
1159+ "article" => Ok(ElementKind::Article),
1160+ "aside" => Ok(ElementKind::Aside),
1161+ "list" => Ok(ElementKind::List),
1162+ "item" | "li" => Ok(ElementKind::ListItem),
1163+ "table" => Ok(ElementKind::Table),
1164+ "thead" => Ok(ElementKind::TableHead),
1165+ "tbody" => Ok(ElementKind::TableBody),
1166+ "tr" => Ok(ElementKind::TableRow),
1167+ "td" => Ok(ElementKind::TableCell),
1168+ "th" => Ok(ElementKind::TableHeaderCell),
1169+ "video" => Ok(ElementKind::Video),
1170+ "audio" => Ok(ElementKind::Audio),
10421171 _ => {
10431172 let all_known: Vec<&str> = KNOWN_ELEMENTS
10441173 .iter()
@@ -1126,6 +1255,17 @@ const KNOWN_ATTRS: &[&str] = &[
11261255 "min", "max", "step", "pattern", "maxlength", "rows", "cols", "multiple",
11271256 // Accessibility
11281257 "alt", "role", "tabindex", "title",
1258+ // CSS: aspect-ratio, outline, logical properties, scroll-snap
1259+ "aspect-ratio", "outline",
1260+ "padding-inline", "padding-block", "margin-inline", "margin-block",
1261+ "scroll-snap-type", "scroll-snap-align",
1262+ // Media attributes
1263+ "controls", "autoplay", "loop", "muted", "poster", "preload",
1264+ "loading", "decoding",
1265+ // List
1266+ "ordered",
1267+ // Media src (explicit attribute form)
1268+ "src",
11291269];
11301270
11311271/// Attributes that expect purely numeric values (px-based) or values with CSS units.
@@ -1556,11 +1696,33 @@ fn element_kind_name(kind: &ElementKind) -> &'static str {
15561696 ElementKind::Option => "@option",
15571697 ElementKind::Label => "@label",
15581698 ElementKind::Slot(_) => "@slot",
1699+ ElementKind::Nav => "@nav",
1700+ ElementKind::Header => "@header",
1701+ ElementKind::Footer => "@footer",
1702+ ElementKind::Main => "@main",
1703+ ElementKind::Section => "@section",
1704+ ElementKind::Article => "@article",
1705+ ElementKind::Aside => "@aside",
1706+ ElementKind::List => "@list",
1707+ ElementKind::ListItem => "@item",
1708+ ElementKind::Table => "@table",
1709+ ElementKind::TableHead => "@thead",
1710+ ElementKind::TableBody => "@tbody",
1711+ ElementKind::TableRow => "@tr",
1712+ ElementKind::TableCell => "@td",
1713+ ElementKind::TableHeaderCell => "@th",
1714+ ElementKind::Video => "@video",
1715+ ElementKind::Audio => "@audio",
15591716 }
15601717}
15611718
15621719fn is_container(kind: &ElementKind) -> bool {
1563− matches!(kind, ElementKind::Row | ElementKind::Column | ElementKind::El)
1720+ matches!(kind,
1721+ ElementKind::Row | ElementKind::Column | ElementKind::El
1722+ | ElementKind::Nav | ElementKind::Header | ElementKind::Footer
1723+ | ElementKind::Main | ElementKind::Section | ElementKind::Article
1724+ | ElementKind::Aside | ElementKind::List | ElementKind::ListItem
1725+ )
15641726}
15651727
15661728fn validate_tree(
@@ -1651,6 +1813,34 @@ fn validate_tree(
16511813 source_line: None,
16521814 });
16531815 }
1816+
1817+ // 'ordered' only on @list
1818+ if base == "ordered" && !matches!(elem.kind, ElementKind::List) {
1819+ diagnostics.push(Diagnostic {
1820+ line: elem.line_num,
1821+ message: format!(
1822+ "'ordered' has no effect on {} (only works on @list)",
1823+ element_kind_name(&elem.kind)
1824+ ),
1825+ severity: Severity::Warning,
1826+ source_line: None,
1827+ });
1828+ }
1829+
1830+ // Media-specific attributes only on @video/@audio
1831+ if matches!(base, "controls" | "autoplay" | "loop" | "muted" | "poster" | "preload")
1832+ && !matches!(elem.kind, ElementKind::Video | ElementKind::Audio)
1833+ {
1834+ diagnostics.push(Diagnostic {
1835+ line: elem.line_num,
1836+ message: format!(
1837+ "'{}' has no effect on {} (only works on @video, @audio)",
1838+ base, element_kind_name(&elem.kind)
1839+ ),
1840+ severity: Severity::Warning,
1841+ source_line: None,
1842+ });
1843+ }
16541844 }
16551845 validate_tree(&elem.children, Some(&elem.kind), diagnostics);
16561846 }
tests/snapshots.rs+355
@@ -588,3 +588,358 @@ fn css_var_not_warned_unused() {
588588 diags
589589 );
590590}
591+
592+// ---------------------------------------------------------------------------
593+// Snapshot tests for new features
594+// ---------------------------------------------------------------------------
595+
596+#[test]
597+fn snapshot_semantic_elements() {
598+ snapshot_test("semantic_elements");
599+}
600+
601+#[test]
602+fn snapshot_list_elements() {
603+ snapshot_test("list_elements");
604+}
605+
606+#[test]
607+fn snapshot_table_elements() {
608+ snapshot_test("table_elements");
609+}
610+
611+#[test]
612+fn snapshot_media_elements() {
613+ snapshot_test("media_elements");
614+}
615+
616+#[test]
617+fn snapshot_match_directive() {
618+ snapshot_test("match_directive");
619+}
620+
621+#[test]
622+fn snapshot_new_css_attrs() {
623+ snapshot_test("new_css_attrs");
624+}
625+
626+// ---------------------------------------------------------------------------
627+// Feature tests: semantic elements
628+// ---------------------------------------------------------------------------
629+
630+#[test]
631+fn semantic_nav_renders_nav_tag() {
632+ let output = compile("@page T\n@nav\n @text Links");
633+ assert!(output.contains("<nav"));
634+ assert!(output.contains("</nav>"));
635+}
636+
637+#[test]
638+fn semantic_header_renders_header_tag() {
639+ let output = compile("@page T\n@header\n @text Title");
640+ assert!(output.contains("<header"));
641+ assert!(output.contains("</header>"));
642+}
643+
644+#[test]
645+fn semantic_footer_renders_footer_tag() {
646+ let output = compile("@page T\n@footer\n @text Footer");
647+ assert!(output.contains("<footer"));
648+ assert!(output.contains("</footer>"));
649+}
650+
651+#[test]
652+fn semantic_main_renders_main_tag() {
653+ let output = compile("@page T\n@main\n @text Content");
654+ assert!(output.contains("<main"));
655+ assert!(output.contains("</main>"));
656+}
657+
658+#[test]
659+fn semantic_section_renders_section_tag() {
660+ let output = compile("@page T\n@section\n @text Section");
661+ assert!(output.contains("<section"));
662+ assert!(output.contains("</section>"));
663+}
664+
665+#[test]
666+fn semantic_article_renders_article_tag() {
667+ let output = compile("@page T\n@article\n @text Article");
668+ assert!(output.contains("<article"));
669+ assert!(output.contains("</article>"));
670+}
671+
672+#[test]
673+fn semantic_aside_renders_aside_tag() {
674+ let output = compile("@page T\n@aside\n @text Sidebar");
675+ assert!(output.contains("<aside"));
676+ assert!(output.contains("</aside>"));
677+}
678+
679+// ---------------------------------------------------------------------------
680+// Feature tests: list elements
681+// ---------------------------------------------------------------------------
682+
683+#[test]
684+fn list_renders_ul_by_default() {
685+ let output = compile("@page T\n@list\n @item Hello");
686+ assert!(output.contains("<ul"));
687+ assert!(output.contains("<li"));
688+ assert!(output.contains("Hello"));
689+}
690+
691+#[test]
692+fn list_renders_ol_with_ordered() {
693+ let output = compile("@page T\n@list [ordered]\n @item First\n @item Second");
694+ assert!(output.contains("<ol"));
695+ assert!(output.contains("<li"));
696+}
697+
698+#[test]
699+fn item_alias_li() {
700+ let output = compile("@page T\n@list\n @li Works");
701+ assert!(output.contains("<li"));
702+ assert!(output.contains("Works"));
703+}
704+
705+// ---------------------------------------------------------------------------
706+// Feature tests: table elements
707+// ---------------------------------------------------------------------------
708+
709+#[test]
710+fn table_renders_proper_tags() {
711+ let output = compile("@page T\n@table\n @thead\n @tr\n @th Header\n @tbody\n @tr\n @td Cell");
712+ assert!(output.contains("<table"));
713+ assert!(output.contains("<thead"));
714+ assert!(output.contains("<tbody"));
715+ assert!(output.contains("<tr"));
716+ assert!(output.contains("<th"));
717+ assert!(output.contains("<td"));
718+ assert!(output.contains("Header"));
719+ assert!(output.contains("Cell"));
720+}
721+
722+// ---------------------------------------------------------------------------
723+// Feature tests: media elements
724+// ---------------------------------------------------------------------------
725+
726+#[test]
727+fn video_renders_with_src() {
728+ let output = compile("@page T\n@video [controls] demo.mp4");
729+ assert!(output.contains("<video"));
730+ assert!(output.contains("src=\"demo.mp4\""));
731+ assert!(output.contains("controls"));
732+ assert!(output.contains("</video>"));
733+}
734+
735+#[test]
736+fn audio_renders_with_src() {
737+ let output = compile("@page T\n@audio [controls] song.mp3");
738+ assert!(output.contains("<audio"));
739+ assert!(output.contains("src=\"song.mp3\""));
740+ assert!(output.contains("controls"));
741+}
742+
743+#[test]
744+fn video_with_multiple_attrs() {
745+ let output = compile("@page T\n@video [controls, muted, autoplay, loop] clip.mp4");
746+ assert!(output.contains("controls"));
747+ assert!(output.contains("muted"));
748+ assert!(output.contains("autoplay"));
749+ assert!(output.contains("loop"));
750+}
751+
752+// ---------------------------------------------------------------------------
753+// Feature tests: @match directive
754+// ---------------------------------------------------------------------------
755+
756+#[test]
757+fn match_selects_correct_case() {
758+ let output = compile("@let x b\n@match $x\n @case a\n @text A\n @case b\n @text B\n @default\n @text D");
759+ assert!(output.contains("B"));
760+ assert!(!output.contains(">A<"));
761+ assert!(!output.contains(">D<"));
762+}
763+
764+#[test]
765+fn match_falls_to_default() {
766+ let output = compile("@let x z\n@match $x\n @case a\n @text A\n @default\n @text Default");
767+ assert!(output.contains("Default"));
768+ assert!(!output.contains(">A<"));
769+}
770+
771+#[test]
772+fn match_no_match_no_default() {
773+ let output = compile("@let x z\n@match $x\n @case a\n @text A\n @case b\n @text B");
774+ assert!(!output.contains(">A<"));
775+ assert!(!output.contains(">B<"));
776+}
777+
778+// ---------------------------------------------------------------------------
779+// Feature tests: new CSS attributes
780+// ---------------------------------------------------------------------------
781+
782+#[test]
783+fn css_aspect_ratio() {
784+ let output = compile("@page T\n@el [aspect-ratio 16/9]");
785+ assert!(output.contains("aspect-ratio:16/9"));
786+}
787+
788+#[test]
789+fn css_outline() {
790+ let output = compile("@page T\n@el [outline 2 red]");
791+ assert!(output.contains("outline:2px solid red"));
792+}
793+
794+#[test]
795+fn css_outline_no_color() {
796+ let output = compile("@page T\n@el [outline 3]");
797+ assert!(output.contains("outline:3px solid currentColor"));
798+}
799+
800+#[test]
801+fn css_padding_inline() {
802+ let output = compile("@page T\n@el [padding-inline 20]");
803+ assert!(output.contains("padding-inline:20px"));
804+}
805+
806+#[test]
807+fn css_padding_block() {
808+ let output = compile("@page T\n@el [padding-block 10]");
809+ assert!(output.contains("padding-block:10px"));
810+}
811+
812+#[test]
813+fn css_margin_inline() {
814+ let output = compile("@page T\n@el [margin-inline 20]");
815+ assert!(output.contains("margin-inline:20px"));
816+}
817+
818+#[test]
819+fn css_margin_block() {
820+ let output = compile("@page T\n@el [margin-block 10]");
821+ assert!(output.contains("margin-block:10px"));
822+}
823+
824+#[test]
825+fn css_scroll_snap_type() {
826+ let output = compile("@page T\n@el [scroll-snap-type x mandatory]");
827+ assert!(output.contains("scroll-snap-type:x mandatory"));
828+}
829+
830+#[test]
831+fn css_scroll_snap_align() {
832+ let output = compile("@page T\n@el [scroll-snap-align center]");
833+ assert!(output.contains("scroll-snap-align:center"));
834+}
835+
836+// ---------------------------------------------------------------------------
837+// Feature tests: @warn / @debug
838+// ---------------------------------------------------------------------------
839+
840+#[test]
841+fn warn_produces_diagnostic() {
842+ let diags = parse_diagnostics("@warn Something is wrong");
843+ assert!(
844+ diags.iter().any(|d| d.message == "Something is wrong"
845+ && d.severity == htmlang::parser::Severity::Warning),
846+ "expected @warn diagnostic, got: {:?}",
847+ diags
848+ );
849+}
850+
851+#[test]
852+fn warn_substitutes_variables() {
853+ let diags = parse_diagnostics("@let name test\n@warn Missing $name value");
854+ assert!(
855+ diags.iter().any(|d| d.message.contains("Missing test value")),
856+ "expected substituted @warn, got: {:?}",
857+ diags
858+ );
859+}
860+
861+// ---------------------------------------------------------------------------
862+// Feature tests: image optimization hints
863+// ---------------------------------------------------------------------------
864+
865+#[test]
866+fn image_auto_lazy_loading() {
867+ let output = compile("@page T\n@image https://example.com/photo.jpg");
868+ assert!(output.contains("loading=\"lazy\""));
869+ assert!(output.contains("decoding=\"async\""));
870+}
871+
872+#[test]
873+fn image_explicit_loading_not_doubled() {
874+ let output = compile("@page T\n@image [loading eager] https://example.com/photo.jpg");
875+ assert!(output.contains("loading=\"eager\""));
876+ assert!(!output.contains("loading=\"lazy\""));
877+}
878+
879+// ---------------------------------------------------------------------------
880+// Feature tests: element-specific attribute validation
881+// ---------------------------------------------------------------------------
882+
883+#[test]
884+fn warning_ordered_on_non_list() {
885+ let diags = parse_diagnostics("@el [ordered]");
886+ assert!(
887+ diags.iter().any(|d| d.message.contains("ordered") && d.message.contains("@list")),
888+ "expected ordered on non-list warning, got: {:?}",
889+ diags
890+ );
891+}
892+
893+#[test]
894+fn no_warning_ordered_on_list() {
895+ let diags = parse_diagnostics("@list [ordered]\n @item x");
896+ assert!(
897+ !diags.iter().any(|d| d.message.contains("ordered") && d.message.contains("no effect")),
898+ "should not warn about ordered on @list, got: {:?}",
899+ diags
900+ );
901+}
902+
903+#[test]
904+fn warning_controls_on_non_media() {
905+ let diags = parse_diagnostics("@el [controls]");
906+ assert!(
907+ diags.iter().any(|d| d.message.contains("controls") && d.message.contains("@video")),
908+ "expected controls on non-media warning, got: {:?}",
909+ diags
910+ );
911+}
912+
913+// ---------------------------------------------------------------------------
914+// Feature tests: formatter
915+// ---------------------------------------------------------------------------
916+
917+#[test]
918+fn fmt_normalizes_indentation() {
919+ let input = "@row\n @col\n @text hello\n @col\n @text world";
920+ let formatted = htmlang::fmt::format(input);
921+ assert_eq!(formatted, "@row\n @col\n @text hello\n @col\n @text world\n");
922+}
923+
924+#[test]
925+fn fmt_preserves_blank_lines() {
926+ let input = "@page Test\n\n@row\n @text hello";
927+ let formatted = htmlang::fmt::format(input);
928+ assert_eq!(formatted, "@page Test\n\n@row\n @text hello\n");
929+}
930+
931+// ---------------------------------------------------------------------------
932+// Feature tests: string interpolation (already exists, verify)
933+// ---------------------------------------------------------------------------
934+
935+#[test]
936+fn string_interpolation_in_text() {
937+ let output = compile("@let name World\n@text Hello $name!");
938+ assert!(output.contains("Hello World!"));
939+}
940+
941+#[test]
942+fn string_interpolation_in_bare_text() {
943+ let output = compile("@let greeting Hi\n@el\n $greeting there");
944+ assert!(output.contains("Hi there"));
945+}
tests/snapshots/list_elements.hlnew file+15
@@ -0,0 +1,15 @@
1+@page List Elements
2+
3+@list [spacing 8]
4+ @item First item
5+ @item Second item
6+ @item Third item
7+
8+@list [ordered, spacing 4]
9+ @item Step one
10+ @item Step two
11+ @item Step three
12+
13+@list
14+ @item [padding 10, background #f0f0f0, rounded 4]
15+ @text [bold] Styled item
tests/snapshots/match_directive.hlnew file+14
@@ -0,0 +1,14 @@
1+@page Match
2+
3+@let theme dark
4+
5+@match $theme
6+ @case light
7+ @el [background white, color black]
8+ @text Light theme
9+ @case dark
10+ @el [background #333, color white]
11+ @text Dark theme
12+ @default
13+ @el [background gray]
14+ @text Default theme
tests/snapshots/media_elements.hlnew file+8
@@ -0,0 +1,8 @@
1+@page Media Elements
2+
3+@video [controls] demo.mp4
4+
5+@audio [controls, autoplay, loop] music.mp3
6+
7+@video [controls, muted, poster poster.jpg, width 640]
8+ @text Your browser does not support video
tests/snapshots/new_css_attrs.hlnew file+17
@@ -0,0 +1,17 @@
1+@page CSS Attrs
2+
3+@el [aspect-ratio 16/9, width 400, background #eee]
4+ @text Widescreen
5+
6+@el [outline 2 red, padding 10]
7+ @text Outlined
8+
9+@el [padding-inline 20, padding-block 10]
10+ @text Logical padding
11+
12+@el [margin-inline 20, margin-block 10]
13+ @text Logical margin
14+
15+@el [scroll-snap-type x mandatory, overflow scroll]
16+ @el [scroll-snap-align center, width 300]
17+ @text Snap target
tests/snapshots/semantic_elements.hlnew file+24
@@ -0,0 +1,24 @@
1+@page Semantic Elements
2+
3+@header [padding 20, background #f0f0f0]
4+ @text [bold, size 24] My Site
5+
6+@nav [spacing 10]
7+ @link / Home
8+ @link /about About
9+ @link /contact Contact
10+
11+@main [padding 20]
12+ @article [padding 16, border 1 #ddd, rounded 8]
13+ @text [bold] Article Title
14+ @paragraph Some article content here
15+
16+ @section [padding 16]
17+ @text [bold] Section Title
18+ @paragraph Section content
19+
20+@aside [padding 16, background #f8f8f8]
21+ @text Related links
22+
23+@footer [padding 20, background #333, color white]
24+ @text Copyright 2024
tests/snapshots/table_elements.hlnew file+17
@@ -0,0 +1,17 @@
1+@page Table Elements
2+
3+@table [width fill]
4+ @thead
5+ @tr
6+ @th Name
7+ @th Age
8+ @th City
9+ @tbody
10+ @tr
11+ @td Alice
12+ @td 30
13+ @td New York
14+ @tr
15+ @td Bob
16+ @td 25
17+ @td London