@@ -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()">▶ 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,'&').replace(/</g,'<').replace(/>/g,'>');}
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 ¶ms {
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 ¶ms {
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 {