@@ -336,7 +336,7 @@ impl Parser {
336 return Ok(None);
336 return Ok(None);
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));
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);
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());
698+ if match_lines.is_empty() {
702+ let case_indent = match_lines[0].indent;
704+ // Group into cases: (Some(value), body) or (None, body) for @default
705+ let mut cases: Vec<(Option<String>, Vec<Line>)> = Vec::new();
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);
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());
719+ cases.push((Some(case_val), body));
720+ } else if trimmed == "@default" {
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());
727+ cases.push((None, body));
739+ // Find first matching case or @default
740+ let body_lines = cases
742+ .find(|(case_val, _)| match case_val {
743+ Some(v) => *v == match_val,
746+ .map(|(_, lines)| lines)
747+ .unwrap_or_default();
749+ if body_lines.is_empty() {
753+ let min_indent = body_lines.iter().map(|l| l.indent).min().unwrap_or(0);
754+ let adjusted: Vec<Line> = body_lines
757+ indent: l.indent - min_indent,
758+ content: l.content.clone(),
759+ line_num: l.line_num,
763+ let saved_vars = ctx.variables.clone();
764+ let mut body_parser = Parser {
768+ let nodes = body_parser.parse_children(0, ctx);
769+ ctx.variables = saved_vars;
770+ return Ok(Some(nodes));
773+ // --- @warn / @debug ---
775+ if let Some(rest) = content.strip_prefix("@warn ") {
776+ let msg = substitute_vars(rest.trim(), &ctx.variables);
777+ ctx.diagnostics.push(Diagnostic {
780+ severity: Severity::Warning,
781+ source_line: Some(content.clone()),
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);
685 // --- @keyframes directive ---
792 // --- @keyframes directive ---
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",
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",
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),
1043 let all_known: Vec<&str> = KNOWN_ELEMENTS
1172 let all_known: Vec<&str> = KNOWN_ELEMENTS
@@ -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",
1267+ // Media src (explicit attribute form)
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",
1562fn is_container(kind: &ElementKind) -> bool {
1719fn is_container(kind: &ElementKind) -> bool {
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
1566fn validate_tree(
1728fn validate_tree(
@@ -1651,6 +1813,34 @@ fn validate_tree(
1651 source_line: None,
1813 source_line: None,
1817+ // 'ordered' only on @list
1818+ if base == "ordered" && !matches!(elem.kind, ElementKind::List) {
1819+ diagnostics.push(Diagnostic {
1820+ line: elem.line_num,
1822+ "'ordered' has no effect on {} (only works on @list)",
1823+ element_kind_name(&elem.kind)
1825+ severity: Severity::Warning,
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)
1834+ diagnostics.push(Diagnostic {
1835+ line: elem.line_num,
1837+ "'{}' has no effect on {} (only works on @video, @audio)",
1838+ base, element_kind_name(&elem.kind)
1840+ severity: Severity::Warning,
1655 validate_tree(&elem.children, Some(&elem.kind), diagnostics);
1845 validate_tree(&elem.children, Some(&elem.kind), diagnostics);