on master help sign in
Add 11 improvements: @component/@for/@switch directives, conditional attributes, minification, critical CSS, upgrade/create-component CLIs, playground enhancements, LSP symbol completions, and a11y validation
commitc5a281ec30e4a429aa50374196e2c5123e055e4b authoriko <mail@iko.soy> dateWed 15 Apr 2026, 17:16 (6 months ago) parent5cdd429
5 files changed+1137−32
src/bin/htmlang-lsp.rs+109−2 src/codegen.rs+105−1 src/main.rs+346−28 src/parser.rs+383−1 tests/snapshots.rs+194
src/bin/htmlang-lsp.rs+109−2
@@ -156,16 +156,34 @@ impl LanguageServer for Backend {
156156 };
157157 drop(docs);
158158
159− // Check if we're on an @include/@import line for path completions
159+ // Check if we're on an @include/@import/@use/@extends line for path/symbol completions
160160 let lines: Vec<&str> = text.lines().collect();
161161 if let Some(line) = lines.get(pos.line as usize) {
162162 let trimmed = line.trim_start();
163− if trimmed.starts_with("@include ") || trimmed.starts_with("@import ") {
163+ if trimmed.starts_with("@include ") || trimmed.starts_with("@import ")
164+ || trimmed.starts_with("@extends ") {
164165 let items = path_completions(uri, pos);
165166 if !items.is_empty() {
166167 return Ok(Some(CompletionResponse::Array(items)));
167168 }
168169 }
170+ // @use "file.hl" fn1, fn2 — after the filename, suggest exported @fn names
171+ if trimmed.starts_with("@use ") {
172+ let after_use = &trimmed[5..];
173+ // If we already have a filename (quoted or unquoted), suggest symbols from that file
174+ let has_file = after_use.contains(".hl");
175+ if has_file {
176+ let items = use_symbol_completions(uri, trimmed, pos);
177+ if !items.is_empty() {
178+ return Ok(Some(CompletionResponse::Array(items)));
179+ }
180+ } else {
181+ let items = path_completions(uri, pos);
182+ if !items.is_empty() {
183+ return Ok(Some(CompletionResponse::Array(items)));
184+ }
185+ }
186+ }
169187 }
170188
171189 let items = completions(&text, pos);
@@ -841,6 +859,95 @@ fn path_completions(uri: &Url, position: Position) -> Vec<CompletionItem> {
841859 items
842860}
843861
862+/// Suggest exported @fn and @define names from a file referenced in @use
863+fn use_symbol_completions(uri: &Url, line: &str, position: Position) -> Vec<CompletionItem> {
864+ let file_path = match uri.to_file_path() {
865+ Ok(p) => p,
866+ Err(_) => return vec![],
867+ };
868+ let dir = match file_path.parent() {
869+ Some(d) => d,
870+ None => return vec![],
871+ };
872+
873+ // Extract the filename from @use "file.hl" or @use file.hl
874+ let after_use = &line.trim_start()[5..]; // skip "@use "
875+ let filename = if after_use.starts_with('"') {
876+ let end = after_use[1..].find('"').map(|i| i + 1).unwrap_or(after_use.len());
877+ &after_use[1..end]
878+ } else {
879+ after_use.split_whitespace().next().unwrap_or("")
880+ };
881+
882+ if filename.is_empty() {
883+ return vec![];
884+ }
885+
886+ let target_path = dir.join(filename);
887+ let target_content = match std::fs::read_to_string(&target_path) {
888+ Ok(s) => s,
889+ Err(_) => return vec![],
890+ };
891+
892+ let col = position.character as u32;
893+ let edit_range = Range::new(Position::new(position.line, col), Position::new(position.line, col));
894+
895+ let mut items = Vec::new();
896+
897+ for target_line in target_content.lines() {
898+ let trimmed = target_line.trim();
899+ if let Some(rest) = trimmed.strip_prefix("@fn ") {
900+ if let Some(name) = rest.split_whitespace().next() {
901+ let params: Vec<&str> = rest.split_whitespace().skip(1).collect();
902+ let detail = if params.is_empty() {
903+ format!("@fn {} (from {})", name, filename)
904+ } else {
905+ format!("@fn {} {} (from {})", name, params.join(" "), filename)
906+ };
907+ items.push(CompletionItem {
908+ label: name.to_string(),
909+ kind: Some(CompletionItemKind::FUNCTION),
910+ detail: Some(detail),
911+ text_edit: Some(CompletionTextEdit::Edit(TextEdit {
912+ range: edit_range,
913+ new_text: name.to_string(),
914+ })),
915+ ..Default::default()
916+ });
917+ }
918+ } else if let Some(rest) = trimmed.strip_prefix("@define ") {
919+ if let Some(name) = rest.split_whitespace().next() {
920+ let name = name.trim_end_matches('[');
921+ items.push(CompletionItem {
922+ label: name.to_string(),
923+ kind: Some(CompletionItemKind::VARIABLE),
924+ detail: Some(format!("@define {} (from {})", name, filename)),
925+ text_edit: Some(CompletionTextEdit::Edit(TextEdit {
926+ range: edit_range,
927+ new_text: name.to_string(),
928+ })),
929+ ..Default::default()
930+ });
931+ }
932+ } else if let Some(rest) = trimmed.strip_prefix("@component ") {
933+ if let Some(name) = rest.split_whitespace().next() {
934+ items.push(CompletionItem {
935+ label: name.to_string(),
936+ kind: Some(CompletionItemKind::CLASS),
937+ detail: Some(format!("@component {} (from {})", name, filename)),
938+ text_edit: Some(CompletionTextEdit::Edit(TextEdit {
939+ range: edit_range,
940+ new_text: name.to_string(),
941+ })),
942+ ..Default::default()
943+ });
944+ }
945+ }
946+ }
947+
948+ items
949+}
950+
844951fn attr_completions(range: Range) -> Vec<CompletionItem> {
845952 [
846953 // Layout
src/codegen.rs+105−1
@@ -361,6 +361,93 @@ pub fn generate_dev(doc: &Document) -> String {
361361 generate_with_options(doc, true)
362362}
363363
364+/// Generate with aggressive minification: collapse whitespace in text nodes,
365+/// strip HTML comments, and minimize output size.
366+pub fn generate_minified(doc: &Document) -> String {
367+ let html = generate_with_options(doc, false);
368+ minify_html(&html)
369+}
370+
371+fn minify_html(html: &str) -> String {
372+ let mut result = String::with_capacity(html.len());
373+ let mut in_pre = false;
374+ let mut in_script = false;
375+ let mut in_style = false;
376+ let mut prev_was_space = false;
377+ let chars: Vec<char> = html.chars().collect();
378+ let mut i = 0;
379+
380+ while i < chars.len() {
381+ // Track <pre>, <script>, <style> contexts
382+ if i + 4 < chars.len() && chars[i] == '<' {
383+ let rest: String = chars[i..].iter().take(10).collect();
384+ let rest_lower = rest.to_lowercase();
385+ if rest_lower.starts_with("<pre") {
386+ in_pre = true;
387+ } else if rest_lower.starts_with("</pre") {
388+ in_pre = false;
389+ } else if rest_lower.starts_with("<script") {
390+ in_script = true;
391+ } else if rest_lower.starts_with("</script") {
392+ in_script = false;
393+ } else if rest_lower.starts_with("<style") {
394+ in_style = true;
395+ } else if rest_lower.starts_with("</style") {
396+ in_style = false;
397+ }
398+ }
399+
400+ // Strip HTML comments (<!-- ... -->)
401+ if !in_script && !in_style && i + 3 < chars.len()
402+ && chars[i] == '<' && chars[i+1] == '!' && chars[i+2] == '-' && chars[i+3] == '-'
403+ {
404+ // Skip to -->
405+ let mut j = i + 4;
406+ while j + 2 < chars.len() {
407+ if chars[j] == '-' && chars[j+1] == '-' && chars[j+2] == '>' {
408+ j += 3;
409+ break;
410+ }
411+ j += 1;
412+ }
413+ i = j;
414+ continue;
415+ }
416+
417+ // In <pre>, preserve everything
418+ if in_pre || in_script || in_style {
419+ result.push(chars[i]);
420+ i += 1;
421+ continue;
422+ }
423+
424+ // Collapse whitespace between tags
425+ if chars[i].is_whitespace() {
426+ if !prev_was_space {
427+ // Only emit a space if we're between content (not between tags)
428+ result.push(' ');
429+ prev_was_space = true;
430+ }
431+ i += 1;
432+ continue;
433+ }
434+
435+ // Trim space before closing tags
436+ if chars[i] == '<' && prev_was_space {
437+ // Remove trailing space before tag
438+ if result.ends_with(' ') {
439+ result.pop();
440+ }
441+ }
442+
443+ prev_was_space = false;
444+ result.push(chars[i]);
445+ i += 1;
446+ }
447+
448+ result
449+}
450+
364451fn generate_with_options(doc: &Document, dev: bool) -> String {
365452 let mut styles = StyleCollector::new();
366453 let mut ctx = GenContext { dev, depth: 0 };
@@ -1092,7 +1179,24 @@ fn generate_element(
10921179 }
10931180 }
10941181
1095− emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1182+ // Critical CSS: inline styles directly instead of using a class
1183+ let is_critical = elem.attrs.iter().any(|a| a.key == "critical");
1184+ if is_critical {
1185+ let inline_css = attrs_to_css(&elem.attrs, "", &elem.kind, parent_kind);
1186+ if !inline_css.is_empty() {
1187+ out.push_str(" style=\"");
1188+ out.push_str(&html_escape(&inline_css));
1189+ out.push('"');
1190+ }
1191+ // Still use class for non-base styles (pseudo, responsive, etc.)
1192+ if gen_class.as_deref().map_or(false, |c| !c.is_empty()) {
1193+ emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1194+ } else if let Some(ref uc) = user_class {
1195+ emit_class_attr(out, None, Some(uc));
1196+ }
1197+ } else {
1198+ emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1199+ }
10961200
10971201 if let Some(id) = id {
10981202 out.push_str(" id=\"");
src/main.rs+346−28
@@ -14,6 +14,14 @@ struct DiagnosticJson {
1414}
1515
1616fn compile(input_path: &str, dev: bool, error_overlay: bool, check_only: bool, output_path: Option<&str>, format_json: bool, json_collector: Option<&Mutex<Vec<DiagnosticJson>>>) -> (bool, Vec<PathBuf>) {
17+ compile_inner(input_path, dev, error_overlay, check_only, output_path, format_json, json_collector, false)
18+}
19+
20+fn compile_minified(input_path: &str, output_path: Option<&str>) -> (bool, Vec<PathBuf>) {
21+ compile_inner(input_path, false, false, false, output_path, false, None, true)
22+}
23+
24+fn compile_inner(input_path: &str, dev: bool, error_overlay: bool, check_only: bool, output_path: Option<&str>, format_json: bool, json_collector: Option<&Mutex<Vec<DiagnosticJson>>>, minify: bool) -> (bool, Vec<PathBuf>) {
1725 let input = match fs::read_to_string(input_path) {
1826 Ok(s) => s,
1927 Err(e) => {
@@ -82,7 +90,9 @@ fn compile(input_path: &str, dev: bool, error_overlay: bool, check_only: bool, o
8290 let _ = fs::write(&out_path, &error_html);
8391 }
8492 } else {
85− let html = if dev {
93+ let html = if minify {
94+ htmlang::codegen::generate_minified(&result.document)
95+ } else if dev {
8696 htmlang::codegen::generate_dev(&result.document)
8797 } else {
8898 htmlang::codegen::generate(&result.document)
@@ -202,7 +212,7 @@ Usage: htmlang [options] <file.hl | directory>
202212Commands:
203213 init [dir] Create a new project (defaults to current directory)
204214 new <page-name> Create a new .hl page from a template
205− build <dir> [-o <out>] Compile all .hl files recursively (parallel)
215+ build <dir> [-o <out>] [--minify] Compile all .hl files (parallel)
206216 serve [dir|file] [-p N] [--open] Start dev server with hot reload
207217 watch [dir|file] [-o out] Watch for changes and recompile
208218 check <file.hl | dir> Check for errors without writing output
@@ -222,6 +232,8 @@ Commands:
222232 deploy <dir> Build and deploy to GitHub Pages
223233 playground [out.html] Generate a self-contained HTML playground
224234 clean [dir] Remove generated .html files
235+ upgrade [dir|file] Auto-upgrade syntax to latest conventions
236+ create-component <name> [params...] Scaffold a new component file
225237 outline <file.hl> Show document structure tree
226238 doctor Check toolchain health
227239 migrate [dir|file] Auto-upgrade deprecated syntax
@@ -703,6 +715,7 @@ fn main() {
703715 if args.len() >= 2 && args[1] == "build" {
704716 let mut src_dir = None;
705717 let mut out_dir = None;
718+ let mut build_minify = false;
706719 let mut i = 2;
707720 while i < args.len() {
708721 match args[i].as_str() {
@@ -710,6 +723,7 @@ fn main() {
710723 i += 1;
711724 out_dir = args.get(i).map(|s| s.as_str());
712725 }
726+ "--minify" => build_minify = true,
713727 _ if src_dir.is_none() => src_dir = Some(args[i].as_str()),
714728 _ => {
715729 eprintln!("unknown argument: {}", args[i]);
@@ -768,7 +782,11 @@ fn main() {
768782 }
769783 }
770784 let path_str = file.to_string_lossy().to_string();
771− let (has_errors, _) = compile(&path_str, false, false, false, effective_out.as_deref(), false, None);
785+ let (has_errors, _) = if build_minify {
786+ compile_minified(&path_str, effective_out.as_deref())
787+ } else {
788+ compile(&path_str, false, false, false, effective_out.as_deref(), false, None)
789+ };
772790 if has_errors {
773791 any_errors.store(true, std::sync::atomic::Ordering::Relaxed);
774792 }
@@ -1627,20 +1645,27 @@ body{font-family:system-ui,-apple-system,sans-serif;display:flex;height:100vh;ba
16271645.header{padding:12px 16px;background:#16213e;border-bottom:1px solid #0f3460;display:flex;align-items:center;gap:12px}
16281646.header h1{font-size:14px;font-weight:600;color:#e94560}
16291647.header .tag{font-size:11px;padding:2px 8px;background:#0f3460;border-radius:4px;color:#a8b2d1}
1630−textarea{flex:1;resize:none;border:none;outline:none;padding:16px;font-family:ui-monospace,monospace;font-size:14px;line-height:1.6;background:#1a1a2e;color:#e6e6e6;tab-size:2}
1648+.editor-wrap{flex:1;position:relative;overflow:hidden}
1649+textarea{width:100%;height:100%;resize:none;border:none;outline:none;padding:16px;font-family:ui-monospace,monospace;font-size:14px;line-height:1.6;background:#1a1a2e;color:#e6e6e6;tab-size:2;position:absolute;top:0;left:0}
1650+.highlight-overlay{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;padding:16px;font-family:ui-monospace,monospace;font-size:14px;line-height:1.6;tab-size:2;white-space:pre-wrap;overflow:hidden;color:transparent}
1651+.highlight-overlay .kw{color:#c792ea}.highlight-overlay .dir{color:#82aaff}.highlight-overlay .str{color:#c3e88d}
1652+.highlight-overlay .cm{color:#546e7a}.highlight-overlay .var{color:#f78c6c}.highlight-overlay .attr{color:#ffcb6b}
16311653iframe{flex:1;border:none;background:white}
1632−.divider{width:1px;background:#0f3460}
1654+.divider{width:1px;background:#0f3460;cursor:col-resize}
1655+.divider:hover{background:#e94560}
16331656.toolbar{padding:8px 16px;background:#16213e;border-top:1px solid #0f3460;display:flex;gap:8px;align-items:center}
16341657button{padding:6px 16px;border:none;border-radius:4px;cursor:pointer;font-size:13px;font-weight:500}
16351658.btn-run{background:#e94560;color:white}
16361659.btn-run:hover{background:#c81e45}
16371660.btn-copy{background:#0f3460;color:#a8b2d1}
1661+.btn-share{background:#0f3460;color:#a8b2d1}
16381662.status{margin-left:auto;font-size:12px;color:#666}
16391663</style>
16401664</head>
16411665<body>
1642−<div class="panel">
1666+<div class="panel" id="editor-panel">
16431667<div class="header"><h1>htmlang</h1><span class="tag">playground</span></div>
1668+<div class="editor-wrap">
16441669<textarea id="editor" spellcheck="false">@page Hello World
16451670@let primary #3b82f6
16461671
@@ -1653,7 +1678,7 @@ button{padding:6px 16px;border:none;border-radius:4px;cursor:pointer;font-size:1
16531678 @text [bold, size 32, color $primary] Hello from htmlang!
16541679
16551680 @paragraph [line-height 1.6]
1656− Edit this code and click Run to see the output.
1681+ Edit this code and press Ctrl+Enter to compile.
16571682
16581683 @row [wrap, spacing 10]
16591684 @card [title Simple]
@@ -1662,14 +1687,16 @@ button{padding:6px 16px;border:none;border-radius:4px;cursor:pointer;font-size:1
16621687 Compiles to a single HTML file
16631688 @card [title Zero-dep]
16641689 No JavaScript, no externals</textarea>
1690+</div>
16651691<div class="toolbar">
1666−<button class="btn-run" onclick="compile()">▶ Run</button>
1692+<button class="btn-run" onclick="compile()">&#9654; Run (Ctrl+Enter)</button>
16671693<button class="btn-copy" onclick="copyOutput()">Copy HTML</button>
1694+<button class="btn-share" onclick="share()">Share</button>
16681695<span class="status" id="status">Ready</span>
16691696</div>
16701697</div>
1671−<div class="divider"></div>
1672−<div class="panel">
1698+<div class="divider" id="divider"></div>
1699+<div class="panel" id="preview-panel">
16731700<div class="header"><span class="tag">preview</span></div>
16741701<iframe id="preview"></iframe>
16751702</div>
@@ -1678,33 +1705,171 @@ let lastOutput='';
16781705function compile(){
16791706 const src=document.getElementById('editor').value;
16801707 const status=document.getElementById('status');
1681− status.textContent='Note: client-side preview is approximate. Use htmlang CLI for exact output.';
1682− // Simple client-side approximation
1708+ const start=performance.now();
16831709 let html=approxCompile(src);
1710+ const ms=(performance.now()-start).toFixed(1);
16841711 lastOutput=html;
1685− const iframe=document.getElementById('preview');
1686− iframe.srcdoc=html;
1712+ document.getElementById('preview').srcdoc=html;
1713+ status.textContent='Compiled in '+ms+'ms (approximate preview)';
1714+}
1715+function copyOutput(){if(lastOutput)navigator.clipboard.writeText(lastOutput).then(()=>{document.getElementById('status').textContent='Copied to clipboard!'});}
1716+function share(){
1717+ const src=document.getElementById('editor').value;
1718+ const encoded=btoa(unescape(encodeURIComponent(src)));
1719+ const url=location.origin+location.pathname+'#'+encoded;
1720+ navigator.clipboard.writeText(url).then(()=>{document.getElementById('status').textContent='Share URL copied to clipboard!'});
1721+ history.replaceState(null,'','#'+encoded);
16871722}
1688−function copyOutput(){
1689− if(lastOutput)navigator.clipboard.writeText(lastOutput);
1723+function loadFromHash(){
1724+ if(location.hash.length>1){
1725+ try{
1726+ const decoded=decodeURIComponent(escape(atob(location.hash.slice(1))));
1727+ document.getElementById('editor').value=decoded;
1728+ }catch(e){}
1729+ }
16901730}
16911731function approxCompile(src){
16921732 const lines=src.split('\n');
1693− let title='',body='',css='';
1694− let vars={},cls=0;
1695− for(const line of lines){
1696− const t=line.trim();
1697− if(t.startsWith('@page '))title=t.slice(6);
1698− else if(t.startsWith('@let ')){const p=t.slice(5).split(' ');if(p.length>=2)vars[p[0]]=p.slice(1).join(' ');}
1699− else if(t.startsWith('--'))continue;
1700− else if(t.startsWith('@'))body+='<div>'+subVars(t,vars)+'</div>';
1701− else if(t)body+='<span>'+subVars(t,vars)+'</span>';
1733+ let title='',vars={},fns={},css='',body='',clsIdx=0;
1734+ // Pass 1: collect definitions
1735+ let i=0;
1736+ while(i<lines.length){
1737+ const t=lines[i].trim();
1738+ const indent=lines[i].length-lines[i].trimStart().length;
1739+ if(t.startsWith('@page '))title=subVars(t.slice(6),vars);
1740+ else if(t.startsWith('@let ')){const p=t.slice(5).split(' ');if(p.length>=2)vars[p[0]]=subVars(p.slice(1).join(' '),vars);}
1741+ else if(t.startsWith('@fn ')){
1742+ const parts=t.slice(4).split(/\s+/);const name=parts[0];const params=parts.slice(1).map(p=>p.replace(/^\$/,''));
1743+ const bodyLines=[];i++;
1744+ while(i<lines.length&&(lines[i].trim()===''||(lines[i].length-lines[i].trimStart().length)>indent)){bodyLines.push(lines[i]);i++;}
1745+ fns[name]={params,body:bodyLines};continue;
1746+ }
1747+ i++;
1748+ }
1749+ // Pass 2: render
1750+ function renderNodes(lines,depth,localVars){
1751+ let html='';const allVars={...vars,...localVars};let i=0;
1752+ while(i<lines.length){
1753+ const raw=lines[i];const indent=raw.length-raw.trimStart().length;
1754+ if(indent<depth&&depth>0)break;
1755+ const t=raw.trim();i++;
1756+ if(!t||t.startsWith('--')||t.startsWith('@let ')||t.startsWith('@fn ')||t.startsWith('@page '))continue;
1757+ if(t.startsWith('@')){
1758+ const name=t.slice(1).split(/[\s\[]/)[0];
1759+ // Parse attributes
1760+ let attrsStr='';const bStart=t.indexOf('[');const bEnd=t.lastIndexOf(']');
1761+ if(bStart!==-1&&bEnd>bStart)attrsStr=t.slice(bStart+1,bEnd);
1762+ const attrs={};if(attrsStr){attrsStr.split(',').forEach(a=>{a=a.trim();if(!a)return;const sp=a.indexOf(' ');if(sp>0){attrs[a.slice(0,sp)]=subVars(a.slice(sp+1),allVars);}else attrs[a]=true;});}
1763+ // Compute CSS
1764+ let style='';
1765+ function addCSS(k,v){style+=k+':'+v+';'}
1766+ for(const[k,v]of Object.entries(attrs)){
1767+ if(typeof v!=='string')continue;
1768+ const px=s=>(/^\d+$/.test(s)?s+'px':s);
1769+ if(k==='padding')addCSS('padding',v.split(' ').map(px).join(' '));
1770+ else if(k==='spacing'||k==='gap')addCSS('gap',px(v));
1771+ else if(k==='background')addCSS('background',v);
1772+ else if(k==='color')addCSS('color',v);
1773+ else if(k==='rounded')addCSS('border-radius',px(v));
1774+ else if(k==='size')addCSS('font-size',px(v));
1775+ else if(k==='width')addCSS(v==='fill'?'flex':'width',v==='fill'?'1':px(v));
1776+ else if(k==='height')addCSS('height',v==='fill'?'100%':px(v));
1777+ else if(k==='max-width')addCSS('max-width',px(v));
1778+ else if(k==='center-x'){addCSS('margin-left','auto');addCSS('margin-right','auto');}
1779+ else if(k==='border'){const p=v.split(' ');addCSS('border',px(p[0])+' solid '+(p[1]||'currentColor'));}
1780+ else if(k==='shadow')addCSS('box-shadow',v);
1781+ else if(k==='font')addCSS('font-family',v);
1782+ else if(k==='text-align')addCSS('text-align',v);
1783+ else if(k==='line-height')addCSS('line-height',v);
1784+ else if(k==='transition')addCSS('transition',v);
1785+ else if(k==='cursor')addCSS('cursor',v);
1786+ else if(k==='opacity')addCSS('opacity',v);
1787+ else if(k==='position')addCSS('position',v);
1788+ else if(k==='overflow')addCSS('overflow',v);
1789+ }
1790+ if(attrs.bold)addCSS('font-weight','bold');
1791+ if(attrs.italic)addCSS('font-style','italic');
1792+ if(attrs.underline)addCSS('text-decoration','underline');
1793+ if(attrs.wrap)addCSS('flex-wrap','wrap');
1794+ // Base styles
1795+ let baseStyle='';
1796+ if(name==='row')baseStyle='display:flex;flex-direction:row;';
1797+ else if(name==='column'||name==='col')baseStyle='display:flex;flex-direction:column;';
1798+ else if(name==='el')baseStyle='display:flex;flex-direction:column;';
1799+ else if(name==='grid')baseStyle='display:grid;';
1800+ // Get inline text
1801+ let textAfterBracket='';
1802+ if(bEnd>0&&bEnd<t.length-1)textAfterBracket=subVars(t.slice(bEnd+1).trim(),allVars);
1803+ else if(bStart===-1&&t.indexOf(' ',t.indexOf(name)+name.length)>0){
1804+ const afterName=t.slice(t.indexOf(name)+name.length).trim();
1805+ if(!afterName.startsWith('['))textAfterBracket=subVars(afterName,allVars);
1806+ }
1807+ // Collect children
1808+ const childLines=[];const childIndent=i<lines.length?(lines[i].length-lines[i].trimStart().length):indent+2;
1809+ while(i<lines.length){const ci=lines[i].length-lines[i].trimStart().length;if(ci<=indent&&lines[i].trim()!=='')break;childLines.push(lines[i]);i++;}
1810+ // Function call?
1811+ if(fns[name]){
1812+ const fn=fns[name];const fnVars={...allVars};
1813+ fn.params.forEach((p,idx)=>{if(attrs[p])fnVars[p]=attrs[p];});
1814+ const fnBody=fn.body.map(l=>{let s=l;for(const[k,v]of Object.entries(fnVars))s=s.replaceAll('$'+k,v);return s;});
1815+ const minI=Math.min(...fnBody.filter(l=>l.trim()).map(l=>l.length-l.trimStart().length));
1816+ // Replace @children with caller's children
1817+ const childHtml=renderNodes(childLines,childIndent,fnVars);
1818+ let fnHtml=renderNodes(fnBody,minI,fnVars);
1819+ fnHtml=fnHtml.replace('<!-- @children -->',childHtml);
1820+ html+=fnHtml;continue;
1821+ }
1822+ // Element tags
1823+ let tag='div';
1824+ if(name==='text')tag='span';
1825+ else if(name==='paragraph'||name==='p')tag='p';
1826+ else if(name==='link')tag='a';
1827+ else if(name==='button'||name==='btn')tag='button';
1828+ else if(name==='image'||name==='img')tag='img';
1829+ else if(name==='header')tag='header';
1830+ else if(name==='footer')tag='footer';
1831+ else if(name==='nav')tag='nav';
1832+ else if(name==='main')tag='main';
1833+ else if(name==='section')tag='section';
1834+ else if(name==='article')tag='article';
1835+ else if(name==='aside')tag='aside';
1836+ else if(name==='list')tag='ul';
1837+ else if(name==='item'||name==='li')tag='li';
1838+ else if(name==='children'){html+='<!-- @children -->';continue;}
1839+ const cls='_'+(clsIdx++);css+='.'+cls+'{'+baseStyle+style+'}\n';
1840+ let extra='';
1841+ if(tag==='a'&&textAfterBracket){const sp=textAfterBracket.indexOf(' ');if(sp>0){extra=' href="'+textAfterBracket.slice(0,sp)+'"';textAfterBracket=textAfterBracket.slice(sp+1);}else{extra=' href="'+textAfterBracket+'"';textAfterBracket='';}}
1842+ if(tag==='img'){extra=' src="'+(textAfterBracket||'')+'"';if(attrs.alt)extra+=' alt="'+attrs.alt+'"';html+='<img class="'+cls+'"'+extra+'>';continue;}
1843+ html+='<'+tag+' class="'+cls+'"'+extra+'>';
1844+ if(textAfterBracket)html+=esc(textAfterBracket);
1845+ html+=renderNodes(childLines,childIndent,allVars);
1846+ html+='</'+tag+'>';
1847+ } else {
1848+ html+='<span>'+esc(subVars(t,allVars))+'</span>';
1849+ }
1850+ }
1851+ return html;
17021852 }
1703− function subVars(s,v){for(const[k,val]of Object.entries(v))s=s.replaceAll('$'+k,val);return s;}
1704− return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>'+title+'</title><style>*{box-sizing:border-box}body{margin:0;font-family:system-ui,sans-serif}</style></head><body>'+body+'</body></html>';
1853+ function subVars(s,v){if(!s.includes('$'))return s;for(const[k,val]of Object.entries(v))s=s.replaceAll('$'+k,val);return s;}
1854+ function esc(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
1855+ body=renderNodes(lines,0,vars);
1856+ return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>'+esc(title)+'</title><style>*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}'+css+'</style></head><body>'+body+'</body></html>';
17051857}
1858+// Resizable divider
1859+const divider=document.getElementById('divider');
1860+const editorPanel=document.getElementById('editor-panel');
1861+let dragging=false;
1862+divider.addEventListener('mousedown',()=>{dragging=true;document.body.style.cursor='col-resize';document.body.style.userSelect='none';});
1863+document.addEventListener('mousemove',e=>{if(!dragging)return;const pct=(e.clientX/window.innerWidth)*100;editorPanel.style.flex='none';editorPanel.style.width=pct+'%';});
1864+document.addEventListener('mouseup',()=>{if(dragging){dragging=false;document.body.style.cursor='';document.body.style.userSelect='';}});
1865+// Keyboard shortcuts and auto-compile
1866+const editor=document.getElementById('editor');
1867+editor.addEventListener('keydown',e=>{
1868+ if(e.key==='Enter'&&(e.ctrlKey||e.metaKey)){e.preventDefault();compile();}
1869+ if(e.key==='Tab'){e.preventDefault();const s=e.target;const start=s.selectionStart;s.value=s.value.substring(0,start)+' '+s.value.substring(s.selectionEnd);s.selectionStart=s.selectionEnd=start+2;}
1870+});
1871+loadFromHash();
17061872compile();
1707−document.getElementById('editor').addEventListener('keydown',e=>{if(e.key==='Enter'&&(e.ctrlKey||e.metaKey)){e.preventDefault();compile();}if(e.key==='Tab'){e.preventDefault();const s=e.target;const start=s.selectionStart;s.value=s.value.substring(0,start)+' '+s.value.substring(s.selectionEnd);s.selectionStart=s.selectionEnd=start+2;}});
17081873</script>
17091874</body>
17101875</html>"##;
@@ -2013,6 +2178,159 @@ document.getElementById('editor').addEventListener('keydown',e=>{if(e.key==='Ent
20132178 return;
20142179 }
20152180
2181+ // Handle "upgrade" subcommand — comprehensive version-to-version migration
2182+ if args.len() >= 2 && args[1] == "upgrade" {
2183+ let target = if args.len() >= 3 { &args[2] } else { "." };
2184+ let path = Path::new(target);
2185+ let hl_files = if path.is_dir() {
2186+ collect_hl_files_recursive(path)
2187+ } else {
2188+ vec![PathBuf::from(target)]
2189+ };
2190+ if hl_files.is_empty() {
2191+ eprintln!("no .hl files found in {}", target);
2192+ process::exit(1);
2193+ }
2194+ let mut total_changes = 0usize;
2195+ for file in &hl_files {
2196+ let input = match fs::read_to_string(file) {
2197+ Ok(s) => s,
2198+ Err(_) => continue,
2199+ };
2200+ let mut output = String::new();
2201+ let mut changes = 0usize;
2202+ for line in input.lines() {
2203+ let mut migrated = line.to_string();
2204+ // Migrate @divider -> @hr
2205+ if migrated.trim().starts_with("@divider") {
2206+ migrated = migrated.replace("@divider", "@hr");
2207+ changes += 1;
2208+ }
2209+ // Migrate @ul -> @list
2210+ if migrated.trim().starts_with("@ul") && !migrated.trim().starts_with("@unless") {
2211+ migrated = migrated.replacen("@ul", "@list", 1);
2212+ changes += 1;
2213+ }
2214+ // Migrate @col -> @column (full form)
2215+ if migrated.trim().starts_with("@col ") || migrated.trim() == "@col" {
2216+ migrated = migrated.replacen("@col", "@column", 1);
2217+ changes += 1;
2218+ }
2219+ // Migrate @img -> @image (full form)
2220+ if migrated.trim().starts_with("@img ") || migrated.trim() == "@img" {
2221+ migrated = migrated.replacen("@img", "@image", 1);
2222+ changes += 1;
2223+ }
2224+ // Migrate @p -> @paragraph (full form)
2225+ if migrated.trim().starts_with("@p ") || migrated.trim() == "@p" {
2226+ migrated = migrated.replacen("@p ", "@paragraph ", 1);
2227+ changes += 1;
2228+ }
2229+ // Migrate @btn -> @button (full form)
2230+ if migrated.trim().starts_with("@btn ") || migrated.trim() == "@btn" {
2231+ migrated = migrated.replacen("@btn", "@button", 1);
2232+ changes += 1;
2233+ }
2234+ // Migrate @li -> @item (full form)
2235+ if migrated.trim().starts_with("@li ") || migrated.trim() == "@li" {
2236+ migrated = migrated.replacen("@li", "@item", 1);
2237+ changes += 1;
2238+ }
2239+ // Migrate align-center -> center-x
2240+ if migrated.contains("align-center") {
2241+ migrated = migrated.replace("align-center", "center-x");
2242+ changes += 1;
2243+ }
2244+ // Migrate spacing -> gap (modern naming)
2245+ if migrated.contains("spacing ") && migrated.contains('[') {
2246+ migrated = migrated.replace("spacing ", "gap ");
2247+ changes += 1;
2248+ }
2249+ output.push_str(&migrated);
2250+ output.push('\n');
2251+ }
2252+ if !input.ends_with('\n') && output.ends_with('\n') {
2253+ output.pop();
2254+ }
2255+ if changes > 0 {
2256+ match fs::write(file, &output) {
2257+ Ok(()) => {
2258+ eprintln!("upgraded {} ({} change{})", file.display(), changes, if changes == 1 { "" } else { "s" });
2259+ total_changes += changes;
2260+ }
2261+ Err(e) => eprintln!("error: {}: {}", file.display(), e),
2262+ }
2263+ }
2264+ }
2265+ if total_changes == 0 {
2266+ eprintln!("no upgrades needed — all files are up to date");
2267+ } else {
2268+ eprintln!("\n{} total change(s) applied", total_changes);
2269+ }
2270+ return;
2271+ }
2272+
2273+ // Handle "create-component" subcommand — scaffold a new component
2274+ if args.len() >= 2 && args[1] == "create-component" {
2275+ if args.len() < 3 {
2276+ eprintln!("usage: htmlang create-component <name> [param1] [param2] ...");
2277+ process::exit(1);
2278+ }
2279+ let comp_name = &args[2];
2280+ let params: Vec<&str> = args[3..].iter().map(|s| s.as_str()).collect();
2281+ let file_name = format!("{}.hl", comp_name);
2282+ let file_path = Path::new(&file_name);
2283+ if file_path.exists() {
2284+ eprintln!("error: {} already exists", file_name);
2285+ process::exit(1);
2286+ }
2287+
2288+ let mut template = String::new();
2289+ template.push_str(&format!("-- {} component\n", comp_name));
2290+ template.push_str("-- Usage: @");
2291+ template.push_str(comp_name);
2292+ if !params.is_empty() {
2293+ template.push_str(" [");
2294+ for (i, p) in params.iter().enumerate() {
2295+ if i > 0 { template.push_str(", "); }
2296+ template.push_str(p);
2297+ template.push_str(" value");
2298+ }
2299+ template.push(']');
2300+ }
2301+ template.push('\n');
2302+ template.push('\n');
2303+
2304+ // Generate @fn definition
2305+ template.push_str("@fn ");
2306+ template.push_str(comp_name);
2307+ for p in &params {
2308+ template.push(' ');
2309+ template.push('$');
2310+ template.push_str(p);
2311+ }
2312+ template.push('\n');
2313+ template.push_str(" @el [padding 16, rounded 8, border 1 #e5e7eb]\n");
2314+ if !params.is_empty() {
2315+ for p in &params {
2316+ template.push_str(&format!(" @text ${}\\n", p));
2317+ }
2318+ }
2319+ template.push_str(" @children\n");
2320+
2321+ match fs::write(file_path, &template) {
2322+ Ok(()) => {
2323+ eprintln!("created {}", file_name);
2324+ eprintln!("import with: @use \"{}\" {}", file_name, comp_name);
2325+ }
2326+ Err(e) => {
2327+ eprintln!("error: {}: {}", file_name, e);
2328+ process::exit(1);
2329+ }
2330+ }
2331+ return;
2332+ }
2333+
20162334 // Handle "convert" subcommand
20172335 if args.len() >= 2 && args[1] == "convert" {
20182336 if args.len() < 3 {
src/parser.rs+383−1
@@ -990,6 +990,87 @@ impl Parser {
990990 return Ok(Some(all_nodes));
991991 }
992992
993+ // --- @for numeric loop ---
994+
995+ if let Some(rest) = content.strip_prefix("@for ") {
996+ let rest = rest.trim();
997+ // @for $var in start..end OR @for $var in start..end step N
998+ let (var_name, range_str) = if let Some((before_in, after_in)) = rest.split_once(" in ") {
999+ let var = before_in.trim().strip_prefix('$').unwrap_or(before_in.trim());
1000+ (var.to_string(), after_in.trim().to_string())
1001+ } else {
1002+ return Err(ParseError {
1003+ line: line_num,
1004+ message: "@for requires: @for $var in start..end".to_string(),
1005+ });
1006+ };
1007+ let range_str = substitute_vars(&range_str, &ctx.variables);
1008+ track_var_refs(&range_str, &mut ctx.used_variables);
1009+
1010+ let items: Vec<String> = if let Some((start_s, rest_range)) = range_str.split_once("..") {
1011+ let (end_s, step) = if let Some((e, s)) = rest_range.split_once(" step ") {
1012+ (e.trim(), s.trim().parse::<i64>().unwrap_or(1).max(1))
1013+ } else {
1014+ (rest_range.trim(), 1i64)
1015+ };
1016+ if let (Ok(start), Ok(end)) = (start_s.trim().parse::<i64>(), end_s.parse::<i64>()) {
1017+ if start <= end {
1018+ let mut items = Vec::new();
1019+ let mut n = start;
1020+ while n <= end { items.push(n.to_string()); n += step; }
1021+ items
1022+ } else {
1023+ let mut items = Vec::new();
1024+ let mut n = start;
1025+ while n >= end { items.push(n.to_string()); n -= step; }
1026+ items
1027+ }
1028+ } else {
1029+ return Err(ParseError {
1030+ line: line_num,
1031+ message: "@for range requires numeric bounds: @for $i in 1..10".to_string(),
1032+ });
1033+ }
1034+ } else {
1035+ return Err(ParseError {
1036+ line: line_num,
1037+ message: "@for requires range syntax: @for $i in 1..10".to_string(),
1038+ });
1039+ };
1040+
1041+ // Collect body lines
1042+ let mut body_lines = Vec::new();
1043+ while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
1044+ body_lines.push(self.lines[self.pos].clone());
1045+ self.pos += 1;
1046+ }
1047+
1048+ if body_lines.is_empty() || items.is_empty() {
1049+ return Ok(None);
1050+ }
1051+
1052+ let min_indent = body_lines.iter().map(|l| l.indent).min().unwrap_or(0);
1053+ let adjusted: Vec<Line> = body_lines
1054+ .iter()
1055+ .map(|l| Line {
1056+ indent: l.indent - min_indent,
1057+ content: l.content.clone(),
1058+ line_num: l.line_num,
1059+ })
1060+ .collect();
1061+
1062+ let saved_vars = ctx.variables.clone();
1063+ let mut all_nodes = Vec::new();
1064+ for item in &items {
1065+ ctx.variables.insert(var_name.clone(), item.clone());
1066+ let mut body_parser = Parser { lines: adjusted.clone(), pos: 0 };
1067+ let nodes = body_parser.parse_children(0, ctx);
1068+ all_nodes.extend(nodes);
1069+ }
1070+ ctx.variables = saved_vars;
1071+ return Ok(Some(all_nodes));
1072+ }
1073+
9931074 // --- @match ---
9941075
9951076 if let Some(rest) = content.strip_prefix("@match ") {
@@ -1078,6 +1159,107 @@ impl Parser {
10781159 return Ok(Some(nodes));
10791160 }
10801161
1162+ // --- @switch (variant-based attribute switching) ---
1163+ // @switch $variant
1164+ // @case primary [background #3b82f6, color white]
1165+ // @case danger [background #ef4444, color white]
1166+ // @default [background #gray, color black]
1167+ // Registers matching @define and emits the matching case's children
1168+
1169+ if let Some(rest) = content.strip_prefix("@switch ") {
1170+ let switch_val = substitute_vars(rest.trim(), &ctx.variables);
1171+ track_var_refs(rest.trim(), &mut ctx.used_variables);
1172+
1173+ let mut switch_lines = Vec::new();
1174+ while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
1175+ switch_lines.push(self.lines[self.pos].clone());
1176+ self.pos += 1;
1177+ }
1178+
1179+ if switch_lines.is_empty() {
1180+ return Ok(None);
1181+ }
1182+
1183+ let case_indent = switch_lines[0].indent;
1184+ let mut si = 0;
1185+ let mut matched_attrs: Option<Vec<Attribute>> = None;
1186+ let mut matched_body: Vec<Line> = Vec::new();
1187+
1188+ while si < switch_lines.len() {
1189+ if switch_lines[si].indent == case_indent {
1190+ if let LineContent::Normal(ref s) = switch_lines[si].content {
1191+ let trimmed = s.trim();
1192+ if let Some(case_rest) = trimmed.strip_prefix("@case ") {
1193+ let case_rest = case_rest.trim();
1194+ // Extract case value and optional [attrs]
1195+ let (case_val, case_attrs_str) = if let Some(bracket_pos) = case_rest.find('[') {
1196+ (substitute_vars(case_rest[..bracket_pos].trim(), &ctx.variables), Some(&case_rest[bracket_pos..]))
1197+ } else {
1198+ (substitute_vars(case_rest, &ctx.variables), None)
1199+ };
1200+ si += 1;
1201+ // Collect body lines
1202+ let mut body = Vec::new();
1203+ while si < switch_lines.len() && switch_lines[si].indent > case_indent {
1204+ body.push(switch_lines[si].clone());
1205+ si += 1;
1206+ }
1207+ if matched_attrs.is_none() && case_val == switch_val {
1208+ if let Some(attrs_str) = case_attrs_str {
1209+ let (attrs, _) = parse_attr_brackets(attrs_str, line_num, ctx)?;
1210+ matched_attrs = Some(attrs);
1211+ }
1212+ matched_body = body;
1213+ }
1214+ } else if trimmed == "@default" || trimmed.starts_with("@default ") {
1215+ let default_rest = trimmed.strip_prefix("@default").unwrap_or("").trim();
1216+ si += 1;
1217+ let mut body = Vec::new();
1218+ while si < switch_lines.len() && switch_lines[si].indent > case_indent {
1219+ body.push(switch_lines[si].clone());
1220+ si += 1;
1221+ }
1222+ if matched_attrs.is_none() {
1223+ if !default_rest.is_empty() && default_rest.starts_with('[') {
1224+ let (attrs, _) = parse_attr_brackets(default_rest, line_num, ctx)?;
1225+ matched_attrs = Some(attrs);
1226+ }
1227+ matched_body = body;
1228+ }
1229+ } else {
1230+ si += 1;
1231+ }
1232+ } else {
1233+ si += 1;
1234+ }
1235+ } else {
1236+ si += 1;
1237+ }
1238+ }
1239+
1240+ // Store matched attrs as a temporary define for use with $__switch
1241+ if let Some(attrs) = matched_attrs {
1242+ ctx.defines.insert("__switch".to_string(), attrs);
1243+ }
1244+
1245+ // Parse matched body if any
1246+ if !matched_body.is_empty() {
1247+ let min_indent = matched_body.iter().map(|l| l.indent).min().unwrap_or(0);
1248+ let adjusted: Vec<Line> = matched_body.iter().map(|l| Line {
1249+ indent: l.indent - min_indent,
1250+ content: l.content.clone(),
1251+ line_num: l.line_num,
1252+ }).collect();
1253+ let saved_vars = ctx.variables.clone();
1254+ let mut body_parser = Parser { lines: adjusted, pos: 0 };
1255+ let nodes = body_parser.parse_children(0, ctx);
1256+ ctx.variables = saved_vars;
1257+ return Ok(Some(nodes));
1258+ }
1259+
1260+ return Ok(None);
1261+ }
1262+
10811263 // --- @warn / @debug ---
10821264
10831265 if let Some(rest) = content.strip_prefix("@warn ") {
@@ -1316,6 +1498,77 @@ impl Parser {
13161498 return Ok(Some(result_nodes));
13171499 }
13181500
1501+ // --- @component definition (scoped @fn) ---
1502+
1503+ if let Some(rest) = content.strip_prefix("@component ") {
1504+ let parts: Vec<&str> = rest.split_whitespace().collect();
1505+ if parts.is_empty() {
1506+ return Err(ParseError {
1507+ line: line_num,
1508+ message: "@component requires a name".to_string(),
1509+ });
1510+ }
1511+ let name = parts[0].to_string();
1512+ let mut params = Vec::new();
1513+ let mut defaults = HashMap::new();
1514+ for part in &parts[1..] {
1515+ let part = part.strip_prefix('$').unwrap_or(part);
1516+ if let Some((param_name, default_val)) = part.split_once('=') {
1517+ params.push(param_name.to_string());
1518+ defaults.insert(param_name.to_string(), default_val.to_string());
1519+ } else {
1520+ params.push(part.to_string());
1521+ }
1522+ }
1523+
1524+ // Collect body lines and separate @style blocks from element content
1525+ let mut body_lines = Vec::new();
1526+ let mut style_lines = Vec::new();
1527+ let mut in_style = false;
1528+ let mut style_indent = 0;
1529+ while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
1530+ if let LineContent::Normal(ref s) = self.lines[self.pos].content {
1531+ if s.trim() == "@style" {
1532+ in_style = true;
1533+ style_indent = self.lines[self.pos].indent;
1534+ self.pos += 1;
1535+ continue;
1536+ }
1537+ }
1538+ if in_style && self.lines[self.pos].indent > style_indent {
1539+ style_lines.push(self.lines[self.pos].clone());
1540+ } else {
1541+ in_style = false;
1542+ body_lines.push(self.lines[self.pos].clone());
1543+ }
1544+ self.pos += 1;
1545+ }
1546+
1547+ // Register scoped CSS from @style blocks
1548+ if !style_lines.is_empty() {
1549+ let scope_class = format!("hl-{}", name);
1550+ let mut scoped_css = String::new();
1551+ for line in &style_lines {
1552+ if let LineContent::Normal(ref s) = line.content {
1553+ let trimmed = s.trim();
1554+ // Scope selectors by prepending .hl-componentname
1555+ if !trimmed.is_empty() {
1556+ scoped_css.push_str(&format!(".{} {}\n", scope_class, trimmed));
1557+ }
1558+ }
1559+ }
1560+ if !scoped_css.is_empty() {
1561+ ctx.custom_css.push(scoped_css);
1562+ }
1563+ }
1564+
1565+ ctx.fn_lines.entry(name.clone()).or_insert(line_num);
1566+ ctx.functions.insert(name.clone(), FnDef { params, defaults, body_lines });
1567+ // Mark that this function should wrap output with a scoped class
1568+ ctx.variables.insert(format!("__component_scope_{}", name), format!("hl-{}", name));
1569+ return Ok(None);
1570+ }
1571+
13191572 // --- Function definition ---
13201573
13211574 if let Some(rest) = content.strip_prefix("@fn ") {
@@ -1478,7 +1731,22 @@ impl Parser {
14781731 ctx.fn_call_stack.pop();
14791732
14801733 // Replace @children with caller's children and @slot with slot content
1481− Ok(replace_children_and_slots(body_nodes, &caller_children, &slot_contents))
1734+ let mut result_nodes = replace_children_and_slots(body_nodes, &caller_children, &slot_contents);
1735+
1736+ // If this is a @component, wrap output in a scoped container
1737+ let scope_key = format!("__component_scope_{}", name);
1738+ if let Some(scope_class) = ctx.variables.get(&scope_key).cloned() {
1739+ let wrapper = Element {
1740+ kind: ElementKind::El,
1741+ attrs: vec![Attribute { key: "class".to_string(), value: Some(scope_class) }],
1742+ argument: None,
1743+ children: result_nodes,
1744+ line_num,
1745+ };
1746+ result_nodes = vec![Node::Element(wrapper)];
1747+ }
1748+
1749+ Ok(result_nodes)
14821750 }
14831751
14841752 fn parse_element_line(
@@ -1658,6 +1926,7 @@ const KNOWN_DIRECTIVES: &[&str] = &[
16581926 "use", "theme", "deprecated", "extends",
16591927 "canonical", "base", "font-face", "json-ld",
16601928 "mixin", "assert",
1929+ "for", "component", "switch",
16611930];
16621931
16631932fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
@@ -2104,6 +2373,42 @@ fn parse_attr_list(input: &str, line_num: usize, ctx: &mut ParseContext, validat
21042373 track_var_refs(part, &mut ctx.used_variables);
21052374 let part = substitute_vars(part, &ctx.variables);
21062375
2376+ // Conditional attribute: `key if condition` or `key value if condition`
2377+ let (part, is_conditional) = {
2378+ // Check for " if " not inside parentheses
2379+ let check = part.as_str();
2380+ let mut found_if = None;
2381+ let mut depth = 0;
2382+ for (i, c) in check.char_indices() {
2383+ match c {
2384+ '(' => depth += 1,
2385+ ')' => depth -= 1,
2386+ _ => {}
2387+ }
2388+ if depth == 0 && check[i..].starts_with(" if ") {
2389+ found_if = Some(i);
2390+ break;
2391+ }
2392+ }
2393+ if let Some(if_pos) = found_if {
2394+ let attr_part = &check[..if_pos];
2395+ let condition = &check[if_pos + 4..];
2396+ let cond_result = evaluate_condition(condition.trim());
2397+ if cond_result {
2398+ (attr_part.to_string(), false)
2399+ } else {
2400+ (String::new(), true)
2401+ }
2402+ } else {
2403+ (part.to_string(), false)
2404+ }
2405+ };
2406+
2407+ // Skip this attribute if the condition was false
2408+ if is_conditional {
2409+ continue;
2410+ }
2411+
21072412 let attr = if let Some((key, value)) = part.split_once(' ') {
21082413 let value = evaluate_if_expr(value.trim());
21092414 Attribute {
@@ -2751,6 +3056,83 @@ fn validate_tree(
27513056 }
27523057 }
27533058
3059+ // @form inputs should have associated @label
3060+ if matches!(elem.kind, ElementKind::Input | ElementKind::Select | ElementKind::Textarea) {
3061+ let has_id = elem.attrs.iter().any(|a| a.key == "id");
3062+ let has_aria_label = elem.attrs.iter().any(|a| a.key == "aria-label" || a.key == "aria-labelledby");
3063+ let has_title = elem.attrs.iter().any(|a| a.key == "title");
3064+ let in_label = matches!(parent_kind, Some(ElementKind::Label));
3065+ if !has_id && !has_aria_label && !has_title && !in_label {
3066+ diagnostics.push(Diagnostic {
3067+ line: elem.line_num,
3068+ message: format!(
3069+ "{} should have an 'id' (with matching @label[for]), 'aria-label', or be wrapped in @label (accessibility)",
3070+ element_kind_name(&elem.kind)
3071+ ),
3072+ severity: Severity::Warning,
3073+ source_line: None,
3074+ });
3075+ }
3076+ }
3077+
3078+ // @iframe should have title attribute
3079+ if matches!(elem.kind, ElementKind::Iframe) {
3080+ if !elem.attrs.iter().any(|a| a.key == "title") {
3081+ diagnostics.push(Diagnostic {
3082+ line: elem.line_num,
3083+ message: "@iframe missing 'title' attribute (accessibility)".to_string(),
3084+ severity: Severity::Warning,
3085+ source_line: None,
3086+ });
3087+ }
3088+ }
3089+
3090+ // @button should have accessible text
3091+ if matches!(elem.kind, ElementKind::Button) {
3092+ let has_text = elem.argument.is_some() || !elem.children.is_empty();
3093+ let has_aria = elem.attrs.iter().any(|a| a.key == "aria-label");
3094+ if !has_text && !has_aria {
3095+ diagnostics.push(Diagnostic {
3096+ line: elem.line_num,
3097+ message: "@button has no text content or aria-label (accessibility)".to_string(),
3098+ severity: Severity::Warning,
3099+ source_line: None,
3100+ });
3101+ }
3102+ }
3103+
3104+ // @video should have captions or aria-label
3105+ if matches!(elem.kind, ElementKind::Video) {
3106+ let has_aria = elem.attrs.iter().any(|a| a.key == "aria-label" || a.key == "aria-describedby");
3107+ let has_track = elem.children.iter().any(|c| {
3108+ matches!(c, Node::Element(e) if e.kind == ElementKind::Source)
3109+ });
3110+ if !has_aria && !has_track {
3111+ diagnostics.push(Diagnostic {
3112+ line: elem.line_num,
3113+ message: "@video should have aria-label or captions for accessibility".to_string(),
3114+ severity: Severity::Warning,
3115+ source_line: None,
3116+ });
3117+ }
3118+ }
3119+
3120+ // Tabindex > 0 is an anti-pattern
3121+ if let Some(tabindex_attr) = elem.attrs.iter().find(|a| a.key == "tabindex") {
3122+ if let Some(ref val) = tabindex_attr.value {
3123+ if let Ok(n) = val.parse::<i32>() {
3124+ if n > 0 {
3125+ diagnostics.push(Diagnostic {
3126+ line: elem.line_num,
3127+ message: format!("tabindex {} is positive — avoid positive tabindex values as they disrupt natural tab order", n),
3128+ severity: Severity::Warning,
3129+ source_line: None,
3130+ });
3131+ }
3132+ }
3133+ }
3134+ }
3135+
27543136 validate_tree(&elem.children, Some(&elem.kind), diagnostics);
27553137 }
27563138 }
tests/snapshots.rs+194
@@ -3030,3 +3030,197 @@ fn round_trip_basic() {
30303030 result2.diagnostics
30313031 );
30323032}
3033+
3034+// -----------------------------------------------------------------------
3035+// @for numeric loop tests
3036+// -----------------------------------------------------------------------
3037+
3038+#[test]
3039+fn for_basic_range() {
3040+ let html = compile("@for $i in 1..3\n @text $i\n");
3041+ assert!(html.contains("1"));
3042+ assert!(html.contains("2"));
3043+ assert!(html.contains("3"));
3044+}
3045+
3046+#[test]
3047+fn for_with_step() {
3048+ let html = compile("@for $i in 0..10 step 5\n @text $i\n");
3049+ assert!(html.contains("0"));
3050+ assert!(html.contains("5"));
3051+ assert!(html.contains("10"));
3052+}
3053+
3054+#[test]
3055+fn for_reverse_range() {
3056+ let html = compile("@for $i in 3..1\n @text $i\n");
3057+ assert!(html.contains("3"));
3058+ assert!(html.contains("2"));
3059+ assert!(html.contains("1"));
3060+}
3061+
3062+#[test]
3063+fn for_with_variable_bounds() {
3064+ let html = compile("@let start 1\n@let end 3\n@for $i in $start..$end\n @text $i\n");
3065+ assert!(html.contains("1"));
3066+ assert!(html.contains("2"));
3067+ assert!(html.contains("3"));
3068+}
3069+
3070+// -----------------------------------------------------------------------
3071+// Conditional attribute tests
3072+// -----------------------------------------------------------------------
3073+
3074+#[test]
3075+fn conditional_attr_true() {
3076+ let html = compile("@let show true\n@el [padding 10 if $show]\n test\n");
3077+ assert!(html.contains("padding:10px"));
3078+}
3079+
3080+#[test]
3081+fn conditional_attr_false() {
3082+ let html = compile("@let show false\n@el [padding 10 if $show]\n test\n");
3083+ assert!(!html.contains("padding:10px"));
3084+}
3085+
3086+#[test]
3087+fn conditional_attr_boolean_true() {
3088+ let html = compile("@let loading true\n@button [disabled if $loading] Click\n");
3089+ assert!(html.contains("disabled"));
3090+}
3091+
3092+#[test]
3093+fn conditional_attr_boolean_false() {
3094+ let html = compile("@let loading false\n@button [disabled if $loading] Click\n");
3095+ assert!(!html.contains("disabled"));
3096+}
3097+
3098+// -----------------------------------------------------------------------
3099+// @component tests
3100+// -----------------------------------------------------------------------
3101+
3102+#[test]
3103+fn component_wraps_in_scoped_div() {
3104+ let html = compile("@component card $title\n @text $title\n\n@card [title Hello]\n");
3105+ assert!(html.contains("hl-card"));
3106+}
3107+
3108+#[test]
3109+fn component_with_children() {
3110+ let html = compile("@component box\n @el [padding 10]\n @children\n\n@box\n @text Inside\n");
3111+ assert!(html.contains("hl-box"));
3112+ assert!(html.contains("Inside"));
3113+}
3114+
3115+// -----------------------------------------------------------------------
3116+// @switch tests
3117+// -----------------------------------------------------------------------
3118+
3119+#[test]
3120+fn switch_matches_case() {
3121+ let html = compile("@let variant primary\n@switch $variant\n @case primary\n @text Primary\n @case danger\n @text Danger\n");
3122+ assert!(html.contains("Primary"));
3123+ assert!(!html.contains("Danger"));
3124+}
3125+
3126+#[test]
3127+fn switch_falls_to_default() {
3128+ let html = compile("@let variant unknown\n@switch $variant\n @case primary\n @text Primary\n @default\n @text Default\n");
3129+ assert!(!html.contains("Primary"));
3130+ assert!(html.contains("Default"));
3131+}
3132+
3133+#[test]
3134+fn switch_with_attrs() {
3135+ let _html = compile("@let variant primary\n@switch $variant\n @case primary [background blue, color white]\n @case danger [background red, color white]\n");
3136+ // The @switch should register matched attrs as __switch define
3137+ let result = htmlang::parser::parse("@let variant primary\n@switch $variant\n @case primary [background blue, color white]\n @case danger [background red, color white]\n");
3138+ assert!(result.document.defines.contains_key("__switch"));
3139+}
3140+
3141+// -----------------------------------------------------------------------
3142+// HTML minification test
3143+// -----------------------------------------------------------------------
3144+
3145+#[test]
3146+fn minified_output_is_smaller() {
3147+ let input = "@page Test\n@column [padding 20]\n @text [bold] Hello World\n @paragraph\n Some text here\n";
3148+ let result = htmlang::parser::parse(input);
3149+ let normal = htmlang::codegen::generate(&result.document);
3150+ let minified = htmlang::codegen::generate_minified(&result.document);
3151+ assert!(minified.len() <= normal.len(), "minified ({}) should be <= normal ({})", minified.len(), normal.len());
3152+ assert!(minified.contains("Hello World"));
3153+}
3154+
3155+#[test]
3156+fn minified_strips_comments() {
3157+ let input = "@page Test\n@column\n @text Hello\n";
3158+ let result = htmlang::parser::parse(input);
3159+ let dev = htmlang::codegen::generate_dev(&result.document);
3160+ let minified = htmlang::codegen::generate_minified(&result.document);
3161+ // Dev mode has comments, minified should not
3162+ assert!(dev.contains("<!--"));
3163+ assert!(!minified.contains("<!--"));
3164+}
3165+
3166+// -----------------------------------------------------------------------
3167+// Critical CSS test
3168+// -----------------------------------------------------------------------
3169+
3170+#[test]
3171+fn critical_attr_inlines_styles() {
3172+ let html = compile("@el [critical, padding 20, background red]\n test\n");
3173+ assert!(html.contains("style=\""));
3174+}
3175+
3176+// -----------------------------------------------------------------------
3177+// Enhanced a11y warnings
3178+// -----------------------------------------------------------------------
3179+
3180+#[test]
3181+fn warning_input_without_label() {
3182+ let diags = parse_diagnostics("@input [type text]\n");
3183+ let has_label_warning = diags.iter().any(|d|
3184+ d.message.contains("aria-label") || d.message.contains("@label")
3185+ );
3186+ assert!(has_label_warning, "should warn about input without label association");
3187+}
3188+
3189+#[test]
3190+fn warning_iframe_without_title() {
3191+ let diags = parse_diagnostics("@iframe https://example.com\n");
3192+ let has_title_warning = diags.iter().any(|d| d.message.contains("title"));
3193+ assert!(has_title_warning, "should warn about iframe without title");
3194+}
3195+
3196+#[test]
3197+fn warning_button_without_text() {
3198+ let diags = parse_diagnostics("@button [background red]\n");
3199+ let has_warning = diags.iter().any(|d| d.message.contains("text content") || d.message.contains("aria-label"));
3200+ assert!(has_warning, "should warn about button without accessible text");
3201+}
3202+
3203+#[test]
3204+fn warning_positive_tabindex() {
3205+ let diags = parse_diagnostics("@el [tabindex 5]\n test\n");
3206+ let has_warning = diags.iter().any(|d| d.message.contains("tabindex"));
3207+ assert!(has_warning, "should warn about positive tabindex");
3208+}
3209+
3210+#[test]
3211+fn no_warning_input_with_aria_label() {
3212+ let diags = parse_diagnostics("@input [type text, aria-label Search]\n");
3213+ let has_label_warning = diags.iter().any(|d|
3214+ d.message.contains("should have an") && d.message.contains("@label")
3215+ );
3216+ assert!(!has_label_warning, "should not warn when aria-label is present");
3217+}
3218+
3219+#[test]
3220+fn no_warning_input_in_label() {
3221+ let diags = parse_diagnostics("@label\n @input [type text]\n");
3222+ let has_label_warning = diags.iter().any(|d|
3223+ d.message.contains("should have an") && d.message.contains("@label")
3224+ );
3225+ assert!(!has_label_warning, "should not warn when input is inside @label");
3226+}