on master help sign in
Add 17 improvements: semantic HTML elements, lists, tables, media, pattern matching, formatters, and new CSS attributes
b68ee41iko
DESIGN.md+17
@@ -21,6 +21,23 @@ A minimalist layout language inspired by elm-ui that compiles to static HTML.
21| `@image` | img | Image |
21| `@image` | img | Image |
22| `@link` | a | Anchor wrapping children |
22| `@link` | a | Anchor wrapping children |
23| `@raw` | verbatim HTML | Escape hatch |
23| `@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 |
24
41
25Bare lines (not starting with `@` or `[`) are text nodes.
42Bare lines (not starting with `@` or `[`) are text nodes.
26
43
src/ast.rs+21
@@ -55,6 +55,27 @@ pub enum ElementKind {
55 Label,
55 Label,
56 // Slots
56 // Slots
57 Slot(String),
57 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,
58}
79}
59
80
60#[derive(Debug, Clone)]
81#[derive(Debug, Clone)]
src/bin/htmlang-lsp.rs+96
@@ -354,6 +354,28 @@ fn element_completions(range: Range) -> Vec<CompletionItem> {
354 ("@raw", "Raw HTML escape hatch"),
354 ("@raw", "Raw HTML escape hatch"),
355 ("@children", "Slot for caller's children (inside @fn)"),
355 ("@children", "Slot for caller's children (inside @fn)"),
356 ("@slot", "Named slot inside @fn (e.g., @slot header)"),
356 ("@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"),
357 ]
379 ]
358 .iter()
380 .iter()
359 .map(|(name, detail)| item(name, CompletionItemKind::KEYWORD, detail, name, range))
381 .map(|(name, detail)| item(name, CompletionItemKind::KEYWORD, detail, name, range))
@@ -377,6 +399,11 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
377 ("@head", "Add raw content to <head>", "@head"),
399 ("@head", "Add raw content to <head>", "@head"),
378 ("@style", "Add raw CSS to stylesheet", "@style"),
400 ("@style", "Add raw CSS to stylesheet", "@style"),
379 ("@slot", "Named slot in @fn for caller content", "@slot "),
401 ("@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 "),
380 ]
407 ]
381 .iter()
408 .iter()
382 .map(|(name, detail, insert)| {
409 .map(|(name, detail, insert)| {
@@ -488,6 +515,28 @@ fn attr_completions(range: Range) -> Vec<CompletionItem> {
488 ("aria-label", "Accessible label", true),
515 ("aria-label", "Accessible label", true),
489 ("aria-hidden", "Hide from assistive tech (true/false)", true),
516 ("aria-hidden", "Hide from assistive tech (true/false)", true),
490 ("data-", "Custom data attribute", true),
517 ("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),
491 // State prefixes
540 // State prefixes
492 ("hover:", "Style on hover", false),
541 ("hover:", "Style on hover", false),
493 ("active:", "Style on active/click", false),
542 ("active:", "Style on active/click", false),
@@ -761,6 +810,33 @@ fn hover_builtin(word: &str) -> Option<String> {
761 "@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```",
810 "@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```",
762 "@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```",
811 "@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```",
763 "@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```",
812 "@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`",
764 // Attributes
840 // Attributes
765 "spacing" | "gap" => "**spacing** `<value>`\n\nGap between children. Supports CSS units (px, rem, em, %).\nMaps to CSS `gap`.",
841 "spacing" | "gap" => "**spacing** `<value>`\n\nGap between children. Supports CSS units (px, rem, em, %).\nMaps to CSS `gap`.",
766 "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 "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> {
842 "role" => "**role** `<value>` \u{2014} ARIA role (e.g., `navigation`, `banner`, `main`).",
918 "role" => "**role** `<value>` \u{2014} ARIA role (e.g., `navigation`, `banner`, `main`).",
843 "tabindex" => "**tabindex** `<value>` \u{2014} Tab order. `0` = natural order, `-1` = skip.",
919 "tabindex" => "**tabindex** `<value>` \u{2014} Tab order. `0` = natural order, `-1` = skip.",
844 "title" => "**title** `<value>` \u{2014} Tooltip text.",
920 "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.",
845 _ => return None,
941 _ => return None,
846 };
942 };
847
943
src/codegen.rs+147−3
@@ -328,6 +328,9 @@ fn generate_node(
328 let needs_wrap = matches!(
328 let needs_wrap = matches!(
329 parent_kind,
329 parent_kind,
330 Some(ElementKind::Row) | Some(ElementKind::Column) | Some(ElementKind::El)
330 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)
331 );
334 );
332 if needs_wrap {
335 if needs_wrap {
333 out.push_str(&ctx.indent());
336 out.push_str(&ctx.indent());
@@ -353,10 +356,19 @@ const HTML_PASSTHROUGH_ATTRS: &[&str] = &[
353 "for", "action", "method", "autocomplete",
356 "for", "action", "method", "autocomplete",
354 "min", "max", "step", "pattern", "maxlength", "rows", "cols", "multiple",
357 "min", "max", "step", "pattern", "maxlength", "rows", "cols", "multiple",
355 "alt", "role", "tabindex", "title",
358 "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",
356];
365];
357
366
358/// Boolean HTML attributes (rendered without a value, e.g., `<input disabled>`).
367/// Boolean HTML attributes (rendered without a value, e.g., `<input disabled>`).
368+const BOOLEAN_HTML_ATTRS: &[&str] = &[
359−const BOOLEAN_HTML_ATTRS: &[&str] = &["disabled", "required", "checked", "multiple"];
369+ "disabled", "required", "checked", "multiple",
370+ "controls", "autoplay", "loop", "muted",
371+];
360
372
361fn emit_html_passthrough_attrs(out: &mut String, attrs: &[Attribute]) {
373fn emit_html_passthrough_attrs(out: &mut String, attrs: &[Attribute]) {
362 for attr in attrs {
374 for attr in attrs {
@@ -399,7 +411,7 @@ fn generate_element(
399 return;
411 return;
400 }
412 }
401
413
402− let tag = match elem.kind {
414+ let tag = match &elem.kind {
403 ElementKind::Row | ElementKind::Column | ElementKind::El => "div",
415 ElementKind::Row | ElementKind::Column | ElementKind::El => "div",
404 ElementKind::Text => "span",
416 ElementKind::Text => "span",
405 ElementKind::Paragraph => "p",
417 ElementKind::Paragraph => "p",
@@ -409,6 +421,29 @@ fn generate_element(
409 ElementKind::Textarea => "textarea",
421 ElementKind::Textarea => "textarea",
410 ElementKind::Option => "option",
422 ElementKind::Option => "option",
411 ElementKind::Label => "label",
423 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",
412 ElementKind::Image | ElementKind::Input | ElementKind::Children | ElementKind::Slot(_) => unreachable!(),
447 ElementKind::Image | ElementKind::Input | ElementKind::Children | ElementKind::Slot(_) => unreachable!(),
413 };
448 };
414
449
@@ -424,6 +459,23 @@ fn generate_element(
424 ElementKind::Textarea => "textarea",
459 ElementKind::Textarea => "textarea",
425 ElementKind::Option => "option",
460 ElementKind::Option => "option",
426 ElementKind::Label => "label",
461 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",
427 _ => "",
479 _ => "",
428 };
480 };
429
481
@@ -447,6 +499,15 @@ fn generate_element(
447 }
499 }
448 }
500 }
449
501
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+
450 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
511 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
451
512
452 if let Some(id) = id {
513 if let Some(id) = id {
@@ -468,6 +529,9 @@ fn generate_element(
468 | ElementKind::Label
529 | ElementKind::Label
469 | ElementKind::Option
530 | ElementKind::Option
470 | ElementKind::Textarea
531 | ElementKind::Textarea
532+ | ElementKind::ListItem
533+ | ElementKind::TableCell
534+ | ElementKind::TableHeaderCell
471 ) {
535 ) {
472 if let Some(text) = &elem.argument {
536 if let Some(text) = &elem.argument {
473 out.push_str(&html_escape(text));
537 out.push_str(&html_escape(text));
@@ -534,6 +598,16 @@ fn generate_self_closing(
534
598
535 emit_html_passthrough_attrs(out, &elem.attrs);
599 emit_html_passthrough_attrs(out, &elem.attrs);
536
600
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+
537 out.push('>');
611 out.push('>');
538 out.push_str(ctx.nl());
612 out.push_str(ctx.nl());
539}
613}
@@ -634,6 +708,13 @@ fn attrs_to_css(
634 ElementKind::Column => css.push_str("display:flex;flex-direction:column;"),
708 ElementKind::Column => css.push_str("display:flex;flex-direction:column;"),
635 ElementKind::El => css.push_str("display:flex;flex-direction:column;"),
709 ElementKind::El => css.push_str("display:flex;flex-direction:column;"),
636 ElementKind::Paragraph => css.push_str("margin:0;"),
710 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;"),
637 _ => {}
718 _ => {}
638 }
719 }
639 }
720 }
@@ -998,6 +1079,67 @@ fn attrs_to_css(
998 }
1079 }
999 }
1080 }
1000
1081
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+
1001 // Container queries
1143 // Container queries
1002 "container" => {
1144 "container" => {
1003 push_css(&mut css, "container-type", "inline-size");
1145 push_css(&mut css, "container-type", "inline-size");
@@ -1018,7 +1160,9 @@ fn attrs_to_css(
1018 "type" | "placeholder" | "name" | "value" | "disabled" | "required"
1160 "type" | "placeholder" | "name" | "value" | "disabled" | "required"
1019 | "checked" | "for" | "action" | "method" | "autocomplete" | "min"
1161 | "checked" | "for" | "action" | "method" | "autocomplete" | "min"
1020 | "max" | "step" | "pattern" | "maxlength" | "rows" | "cols"
1162 | "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" => {}
1022
1166
1023 _ => {}
1167 _ => {}
1024 }
1168 }
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 @@
1pub mod ast;
1pub mod ast;
2pub mod codegen;
2pub mod codegen;
3+pub mod fmt;
3pub mod parser;
4pub mod parser;
4pub mod serve;
5pub mod serve;
src/main.rs+51−19
@@ -5,7 +5,7 @@ use std::hash::{Hash, Hasher};
5use std::path::{Path, PathBuf};
5use std::path::{Path, PathBuf};
6use std::process;
6use std::process;
7
7
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>) {
9 let input = match fs::read_to_string(input_path) {
9 let input = match fs::read_to_string(input_path) {
10 Ok(s) => s,
10 Ok(s) => s,
11 Err(e) => {
11 Err(e) => {
@@ -35,20 +35,22 @@ fn compile(input_path: &str, dev: bool, error_overlay: bool) -> (bool, Vec<PathB
35
35
36 let out_path = Path::new(input_path).with_extension("html");
36 let out_path = Path::new(input_path).with_extension("html");
37
37
38+ if !check_only {
38− if has_errors {
39+ if has_errors {
39− if error_overlay {
40+ if error_overlay {
40− let error_html = generate_error_overlay(&result.diagnostics, input_path);
41+ let error_html = generate_error_overlay(&result.diagnostics, input_path);
41− let _ = fs::write(&out_path, &error_html);
42+ let _ = fs::write(&out_path, &error_html);
42− }
43+ }
43− } else {
44− let html = if dev {
45− htmlang::codegen::generate_dev(&result.document)
46 } else {
44 } else {
45+ let html = if dev {
46+ htmlang::codegen::generate_dev(&result.document)
47+ } else {
47− htmlang::codegen::generate(&result.document)
48+ htmlang::codegen::generate(&result.document)
48− };
49+ };
49− match fs::write(&out_path, &html) {
50+ match fs::write(&out_path, &html) {
50− Ok(()) => eprintln!("wrote {}", out_path.display()),
51+ Ok(()) => eprintln!("wrote {}", out_path.display()),
51− Err(e) => eprintln!("error: {}: {}", out_path.display(), e),
52+ Err(e) => eprintln!("error: {}: {}", out_path.display(), e),
53+ }
52 }
54 }
53 }
55 }
54
56
@@ -103,16 +105,22 @@ Options:
103 -s, --serve Start dev server with hot reload (implies --watch)
105 -s, --serve Start dev server with hot reload (implies --watch)
104 -p, --port <N> Port for dev server (default: 3000)
106 -p, --port <N> Port for dev server (default: 3000)
105 -d, --dev Development mode
107 -d, --dev Development mode
108+ -c, --check Check for errors without writing output
106 -h, --help Show this help
109 -h, --help Show this help
107 -V, --version Show version
110 -V, --version Show version
108
111
112+Commands:
113+ fmt <file.hl> Format a .hl file (normalizes indentation)
114+
109Examples:
115Examples:
110 htmlang page.hl Compile page.hl to page.html
116 htmlang page.hl Compile page.hl to page.html
111 htmlang site/ Compile all .hl files in directory
117 htmlang site/ Compile all .hl files in directory
112 htmlang -w page.hl Recompile on file changes
118 htmlang -w page.hl Recompile on file changes
113 htmlang -s page.hl Start dev server with hot reload
119 htmlang -s page.hl Start dev server with hot reload
114 htmlang -s site/ Serve a multi-page site
120 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",
116 env!("CARGO_PKG_VERSION")
124 env!("CARGO_PKG_VERSION")
117 );
125 );
118}
126}
@@ -122,9 +130,32 @@ fn main() {
122 let mut watch = false;
130 let mut watch = false;
123 let mut serve = false;
131 let mut serve = false;
124 let mut dev = false;
132 let mut dev = false;
133+ let mut check = false;
125 let mut port: u16 = 3000;
134 let mut port: u16 = 3000;
126 let mut input_path = None;
135 let mut input_path = None;
127
136
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+
128 let mut i = 1;
159 let mut i = 1;
129 while i < args.len() {
160 while i < args.len() {
130 match args[i].as_str() {
161 match args[i].as_str() {
@@ -138,6 +169,7 @@ fn main() {
138 }
169 }
139 "--watch" | "-w" => watch = true,
170 "--watch" | "-w" => watch = true,
140 "--dev" | "-d" => dev = true,
171 "--dev" | "-d" => dev = true,
172+ "--check" | "-c" => check = true,
141 "--serve" | "-s" => {
173 "--serve" | "-s" => {
142 serve = true;
174 serve = true;
143 watch = true;
175 watch = true;
@@ -189,7 +221,7 @@ fn main() {
189 let mut all_included: Vec<PathBuf> = Vec::new();
221 let mut all_included: Vec<PathBuf> = Vec::new();
190 for file in &hl_files {
222 for file in &hl_files {
191 let path_str = file.to_string_lossy().to_string();
223 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);
193 if has_errors {
225 if has_errors {
194 any_errors = true;
226 any_errors = true;
195 }
227 }
@@ -222,7 +254,7 @@ fn main() {
222 }
254 }
223
255
224 // --- Single file mode ---
256 // --- 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);
226
258
227 if !watch {
259 if !watch {
228 if has_errors {
260 if has_errors {
@@ -383,7 +415,7 @@ fn watch_loop(
383 // Recompile all source files
415 // Recompile all source files
384 for file in source_files {
416 for file in source_files {
385 let path_str = file.to_string_lossy().to_string();
417 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);
387 for inc in &new_includes {
419 for inc in &new_includes {
388 let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
420 let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
389 if let Some(h) = hash_file(inc) {
421 if let Some(h) = hash_file(inc) {
@@ -397,7 +429,7 @@ fn watch_loop(
397 for file in &collect_hl_files(watch_dir) {
429 for file in &collect_hl_files(watch_dir) {
398 if !source_files.contains(file) {
430 if !source_files.contains(file) {
399 let path_str = file.to_string_lossy().to_string();
431 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);
401 for inc in &new_includes {
433 for inc in &new_includes {
402 let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
434 let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
403 }
435 }
src/parser.rs+192−2
@@ -336,7 +336,7 @@ impl Parser {
336 return Ok(None);
336 return Ok(None);
337 }
337 }
338
338
339− if content.strip_prefix("@head").is_some() {
339+ if content == "@head" || content.starts_with("@head ") {
340 // Collect indented body lines as raw head content
340 // Collect indented body lines as raw head content
341 let mut head_content = String::new();
341 let mut head_content = String::new();
342 while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
342 while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
@@ -682,6 +682,113 @@ impl Parser {
682 return Ok(Some(all_nodes));
682 return Ok(Some(all_nodes));
683 }
683 }
684
684
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+
685 // --- @keyframes directive ---
792 // --- @keyframes directive ---
686
793
687 if let Some(rest) = content.strip_prefix("@keyframes ") {
794 if let Some(rest) = content.strip_prefix("@keyframes ") {
@@ -1015,11 +1122,16 @@ fn parse_single_element(
1015const KNOWN_ELEMENTS: &[&str] = &[
1122const KNOWN_ELEMENTS: &[&str] = &[
1016 "row", "column", "col", "el", "text", "paragraph", "p", "image", "img", "link", "children",
1123 "row", "column", "col", "el", "text", "paragraph", "p", "image", "img", "link", "children",
1017 "input", "button", "select", "textarea", "option", "label", "slot",
1124 "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",
1018];
1129];
1019
1130
1020const KNOWN_DIRECTIVES: &[&str] = &[
1131const KNOWN_DIRECTIVES: &[&str] = &[
1021 "page", "let", "define", "fn", "include", "import", "raw", "keyframes",
1132 "page", "let", "define", "fn", "include", "import", "raw", "keyframes",
1022 "if", "else", "each", "meta", "head", "style",
1133 "if", "else", "each", "meta", "head", "style",
1134+ "match", "case", "default", "warn", "debug",
1023];
1135];
1024
1136
1025fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
1137fn 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
1039 "option" | "opt" => Ok(ElementKind::Option),
1151 "option" | "opt" => Ok(ElementKind::Option),
1040 "label" => Ok(ElementKind::Label),
1152 "label" => Ok(ElementKind::Label),
1041 "slot" => Ok(ElementKind::Slot(String::new())), // slot name filled in by parse_single_element
1153 "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),
1042 _ => {
1171 _ => {
1043 let all_known: Vec<&str> = KNOWN_ELEMENTS
1172 let all_known: Vec<&str> = KNOWN_ELEMENTS
1044 .iter()
1173 .iter()
@@ -1126,6 +1255,17 @@ const KNOWN_ATTRS: &[&str] = &[
1126 "min", "max", "step", "pattern", "maxlength", "rows", "cols", "multiple",
1255 "min", "max", "step", "pattern", "maxlength", "rows", "cols", "multiple",
1127 // Accessibility
1256 // Accessibility
1128 "alt", "role", "tabindex", "title",
1257 "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",
1129];
1269];
1130
1270
1131/// Attributes that expect purely numeric values (px-based) or values with CSS units.
1271/// 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 {
1556 ElementKind::Option => "@option",
1696 ElementKind::Option => "@option",
1557 ElementKind::Label => "@label",
1697 ElementKind::Label => "@label",
1558 ElementKind::Slot(_) => "@slot",
1698 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",
1559 }
1716 }
1560}
1717}
1561
1718
1562fn is_container(kind: &ElementKind) -> bool {
1719fn is_container(kind: &ElementKind) -> bool {
1720+ matches!(kind,
1563− matches!(kind, ElementKind::Row | ElementKind::Column | ElementKind::El)
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+ )
1564}
1726}
1565
1727
1566fn validate_tree(
1728fn validate_tree(
@@ -1651,6 +1813,34 @@ fn validate_tree(
1651 source_line: None,
1813 source_line: None,
1652 });
1814 });
1653 }
1815 }
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+ }
1654 }
1844 }
1655 validate_tree(&elem.children, Some(&elem.kind), diagnostics);
1845 validate_tree(&elem.children, Some(&elem.kind), diagnostics);
1656 }
1846 }
tests/snapshots.rs+355
@@ -588,3 +588,358 @@ fn css_var_not_warned_unused() {
588 diags
588 diags
589 );
589 );
590}
590}
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