@@ -927,6 +927,26 @@ fn load_config(target: &Path) -> ProjectConfig {
927927 config
928928}
929929
930+fn find_wasm_pkg() -> Option<(Vec<u8>, String)> {
931+ // Look for pre-built WASM binary and JS glue in known locations
932+ let candidates = [
933+ // Relative to current executable
934+ std::env::current_exe()
935+ .ok()
936+ .and_then(|p| p.parent().map(|d| d.join("..").join("wasm-pkg"))),
937+ // In the project build directory (development)
938+ Some(PathBuf::from("target/wasm-pkg")),
939+ ];
940+ for dir in candidates.iter().flatten() {
941+ let wasm = dir.join("htmlang_wasm_bg.wasm");
942+ let js = dir.join("htmlang_wasm.js");
943+ if let (Ok(wasm_bytes), Ok(js_str)) = (fs::read(&wasm), fs::read_to_string(&js)) {
944+ return Some((wasm_bytes, js_str));
945+ }
946+ }
947+ None
948+}
949+
930950fn main() {
931951 let args: Vec<String> = env::args().collect();
932952 let mut watch = false;
@@ -2149,34 +2169,59 @@ fn main() {
21492169 // Handle "playground" subcommand — generate self-contained HTML playground
21502170 if args.len() >= 2 && args[1] == "playground" {
21512171 let out = if args.len() >= 3 { &args[2] } else { "playground.html" };
2152− let playground_html = r##"<!DOCTYPE html>
2172+
2173+ // Load pre-built WASM binary and JS glue
2174+ let (wasm_bytes, js_glue) = match find_wasm_pkg() {
2175+ Some(pkg) => pkg,
2176+ None => {
2177+ eprintln!("error: WASM module not found. Build it first:");
2178+ eprintln!(" cargo build --release --target wasm32-unknown-unknown -p htmlang-wasm");
2179+ eprintln!(" wasm-bindgen --target no-modules --no-typescript --out-dir target/wasm-pkg \\");
2180+ eprintln!(" target/wasm32-unknown-unknown/release/htmlang_wasm.wasm");
2181+ process::exit(1);
2182+ }
2183+ };
2184+ let b64 = base64_encode(&wasm_bytes);
2185+ let wasm_section = format!(
2186+ "<script>\n{}\n</script>\n<script>\n\
2187+ (function(){{\n\
2188+ var b64=\"{}\";\n\
2189+ var raw=atob(b64);\n\
2190+ var bytes=new Uint8Array(raw.length);\n\
2191+ for(var i=0;i<raw.length;i++)bytes[i]=raw.charCodeAt(i);\n\
2192+ wasm_bindgen.initSync(bytes);\n\
2193+ window._wasmCompile=wasm_bindgen.compile;\n\
2194+ }})();\n\
2195+ </script>",
2196+ js_glue, b64,
2197+ );
2198+ let compile_fn = "function compileSource(src){return window._wasmCompile(src);}";
2199+
2200+ let playground_html = format!(r##"<!DOCTYPE html>
21532201<html lang="en">
21542202<head>
21552203<meta charset="utf-8">
21562204<meta name="viewport" content="width=device-width, initial-scale=1">
21572205<title>htmlang Playground</title>
21582206<style>
2159−*{box-sizing:border-box;margin:0}
2160−body{font-family:system-ui,-apple-system,sans-serif;display:flex;height:100vh;background:#1a1a2e;color:#eee}
2161−.panel{flex:1;display:flex;flex-direction:column;min-width:0}
2162−.header{padding:12px 16px;background:#16213e;border-bottom:1px solid #0f3460;display:flex;align-items:center;gap:12px}
2163−.header h1{font-size:14px;font-weight:600;color:#e94560}
2164−.header .tag{font-size:11px;padding:2px 8px;background:#0f3460;border-radius:4px;color:#a8b2d1}
2165−.editor-wrap{flex:1;position:relative;overflow:hidden}
2166−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}
2167−.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}
2168−.highlight-overlay .kw{color:#c792ea}.highlight-overlay .dir{color:#82aaff}.highlight-overlay .str{color:#c3e88d}
2169−.highlight-overlay .cm{color:#546e7a}.highlight-overlay .var{color:#f78c6c}.highlight-overlay .attr{color:#ffcb6b}
2170−iframe{flex:1;border:none;background:white}
2171−.divider{width:1px;background:#0f3460;cursor:col-resize}
2172−.divider:hover{background:#e94560}
2173−.toolbar{padding:8px 16px;background:#16213e;border-top:1px solid #0f3460;display:flex;gap:8px;align-items:center}
2174−button{padding:6px 16px;border:none;border-radius:4px;cursor:pointer;font-size:13px;font-weight:500}
2175−.btn-run{background:#e94560;color:white}
2176−.btn-run:hover{background:#c81e45}
2177−.btn-copy{background:#0f3460;color:#a8b2d1}
2178−.btn-share{background:#0f3460;color:#a8b2d1}
2179−.status{margin-left:auto;font-size:12px;color:#666}
2207+*{{box-sizing:border-box;margin:0}}
2208+body{{font-family:system-ui,-apple-system,sans-serif;display:flex;height:100vh;background:#1a1a2e;color:#eee}}
2209+.panel{{flex:1;display:flex;flex-direction:column;min-width:0}}
2210+.header{{padding:12px 16px;background:#16213e;border-bottom:1px solid #0f3460;display:flex;align-items:center;gap:12px}}
2211+.header h1{{font-size:14px;font-weight:600;color:#e94560}}
2212+.header .tag{{font-size:11px;padding:2px 8px;background:#0f3460;border-radius:4px;color:#a8b2d1}}
2213+.editor-wrap{{flex:1;position:relative;overflow:hidden}}
2214+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}}
2215+iframe{{flex:1;border:none;background:white}}
2216+.divider{{width:1px;background:#0f3460;cursor:col-resize}}
2217+.divider:hover{{background:#e94560}}
2218+.toolbar{{padding:8px 16px;background:#16213e;border-top:1px solid #0f3460;display:flex;gap:8px;align-items:center}}
2219+button{{padding:6px 16px;border:none;border-radius:4px;cursor:pointer;font-size:13px;font-weight:500}}
2220+.btn-run{{background:#e94560;color:white}}
2221+.btn-run:hover{{background:#c81e45}}
2222+.btn-copy{{background:#0f3460;color:#a8b2d1}}
2223+.btn-share{{background:#0f3460;color:#a8b2d1}}
2224+.status{{margin-left:auto;font-size:12px;color:#666}}
21802225</style>
21812226</head>
21822227<body>
@@ -2217,184 +2262,58 @@ button{padding:6px 16px;border:none;border-radius:4px;cursor:pointer;font-size:1
22172262<div class="header"><span class="tag">preview</span></div>
22182263<iframe id="preview"></iframe>
22192264</div>
2265+{wasm_section}
22202266<script>
2221−let lastOutput='';
2222−function compile(){
2223− const src=document.getElementById('editor').value;
2224− const status=document.getElementById('status');
2225− const start=performance.now();
2226− let html=approxCompile(src);
2227− const ms=(performance.now()-start).toFixed(1);
2267+{compile_fn}
2268+var lastOutput='';
2269+function compile(){{
2270+ var src=document.getElementById('editor').value;
2271+ var status=document.getElementById('status');
2272+ var start=performance.now();
2273+ var html=compileSource(src);
2274+ var ms=(performance.now()-start).toFixed(1);
22282275 lastOutput=html;
22292276 document.getElementById('preview').srcdoc=html;
2230− status.textContent='Compiled in '+ms+'ms (approximate preview)';
2231−}
2232−function copyOutput(){if(lastOutput)navigator.clipboard.writeText(lastOutput).then(()=>{document.getElementById('status').textContent='Copied to clipboard!'});}
2233−function share(){
2234− const src=document.getElementById('editor').value;
2235− const encoded=btoa(unescape(encodeURIComponent(src)));
2236− const url=location.origin+location.pathname+'#'+encoded;
2237− navigator.clipboard.writeText(url).then(()=>{document.getElementById('status').textContent='Share URL copied to clipboard!'});
2277+ status.textContent='Compiled in '+ms+'ms';
2278+}}
2279+function copyOutput(){{if(lastOutput)navigator.clipboard.writeText(lastOutput).then(function(){{document.getElementById('status').textContent='Copied to clipboard!'}});}}
2280+function share(){{
2281+ var src=document.getElementById('editor').value;
2282+ var encoded=btoa(unescape(encodeURIComponent(src)));
2283+ var url=location.origin+location.pathname+'#'+encoded;
2284+ navigator.clipboard.writeText(url).then(function(){{document.getElementById('status').textContent='Share URL copied to clipboard!'}});
22382285 history.replaceState(null,'','#'+encoded);
2239−}
2240−function loadFromHash(){
2241− if(location.hash.length>1){
2242− try{
2243− const decoded=decodeURIComponent(escape(atob(location.hash.slice(1))));
2286+}}
2287+function loadFromHash(){{
2288+ if(location.hash.length>1){{
2289+ try{{
2290+ var decoded=decodeURIComponent(escape(atob(location.hash.slice(1))));
22442291 document.getElementById('editor').value=decoded;
2245− }catch(e){}
2246− }
2247−}
2248−function approxCompile(src){
2249− const lines=src.split('\n');
2250− let title='',vars={},fns={},css='',body='',clsIdx=0;
2251− // Pass 1: collect definitions
2252− let i=0;
2253− while(i<lines.length){
2254− const t=lines[i].trim();
2255− const indent=lines[i].length-lines[i].trimStart().length;
2256− if(t.startsWith('@page '))title=subVars(t.slice(6),vars);
2257− else if(t.startsWith('@let ')){const p=t.slice(5).split(' ');if(p.length>=2)vars[p[0]]=subVars(p.slice(1).join(' '),vars);}
2258− else if(t.startsWith('@fn ')){
2259− const parts=t.slice(4).split(/\s+/);const name=parts[0];const params=parts.slice(1).map(p=>p.replace(/^\$/,''));
2260− const bodyLines=[];i++;
2261− while(i<lines.length&&(lines[i].trim()===''||(lines[i].length-lines[i].trimStart().length)>indent)){bodyLines.push(lines[i]);i++;}
2262− fns[name]={params,body:bodyLines};continue;
2263− }
2264− i++;
2265− }
2266− // Pass 2: render
2267− function renderNodes(lines,depth,localVars){
2268− let html='';const allVars={...vars,...localVars};let i=0;
2269− while(i<lines.length){
2270− const raw=lines[i];const indent=raw.length-raw.trimStart().length;
2271− if(indent<depth&&depth>0)break;
2272− const t=raw.trim();i++;
2273− if(!t||t.startsWith('--')||t.startsWith('@let ')||t.startsWith('@fn ')||t.startsWith('@page '))continue;
2274− if(t.startsWith('@')){
2275− const name=t.slice(1).split(/[\s\[]/)[0];
2276− // Parse attributes
2277− let attrsStr='';const bStart=t.indexOf('[');const bEnd=t.lastIndexOf(']');
2278− if(bStart!==-1&&bEnd>bStart)attrsStr=t.slice(bStart+1,bEnd);
2279− 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;});}
2280− // Compute CSS
2281− let style='';
2282− function addCSS(k,v){style+=k+':'+v+';'}
2283− for(const[k,v]of Object.entries(attrs)){
2284− if(typeof v!=='string')continue;
2285− const px=s=>(/^\d+$/.test(s)?s+'px':s);
2286− if(k==='padding')addCSS('padding',v.split(' ').map(px).join(' '));
2287− else if(k==='spacing'||k==='gap')addCSS('gap',px(v));
2288− else if(k==='background')addCSS('background',v);
2289− else if(k==='color')addCSS('color',v);
2290− else if(k==='rounded')addCSS('border-radius',px(v));
2291− else if(k==='size')addCSS('font-size',px(v));
2292− else if(k==='width')addCSS(v==='fill'?'flex':'width',v==='fill'?'1':px(v));
2293− else if(k==='height')addCSS('height',v==='fill'?'100%':px(v));
2294− else if(k==='max-width')addCSS('max-width',px(v));
2295− else if(k==='center-x'){addCSS('margin-left','auto');addCSS('margin-right','auto');}
2296− else if(k==='border'){const p=v.split(' ');addCSS('border',px(p[0])+' solid '+(p[1]||'currentColor'));}
2297− else if(k==='shadow')addCSS('box-shadow',v);
2298− else if(k==='font')addCSS('font-family',v);
2299− else if(k==='text-align')addCSS('text-align',v);
2300− else if(k==='line-height')addCSS('line-height',v);
2301− else if(k==='transition')addCSS('transition',v);
2302− else if(k==='cursor')addCSS('cursor',v);
2303− else if(k==='opacity')addCSS('opacity',v);
2304− else if(k==='position')addCSS('position',v);
2305− else if(k==='overflow')addCSS('overflow',v);
2306− }
2307− if(attrs.bold)addCSS('font-weight','bold');
2308− if(attrs.italic)addCSS('font-style','italic');
2309− if(attrs.underline)addCSS('text-decoration','underline');
2310− if(attrs.wrap)addCSS('flex-wrap','wrap');
2311− // Base styles
2312− let baseStyle='';
2313− if(name==='row')baseStyle='display:flex;flex-direction:row;';
2314− else if(name==='column'||name==='col')baseStyle='display:flex;flex-direction:column;';
2315− else if(name==='el')baseStyle='display:flex;flex-direction:column;';
2316− else if(name==='grid')baseStyle='display:grid;';
2317− // Get inline text
2318− let textAfterBracket='';
2319− if(bEnd>0&&bEnd<t.length-1)textAfterBracket=subVars(t.slice(bEnd+1).trim(),allVars);
2320− else if(bStart===-1&&t.indexOf(' ',t.indexOf(name)+name.length)>0){
2321− const afterName=t.slice(t.indexOf(name)+name.length).trim();
2322− if(!afterName.startsWith('['))textAfterBracket=subVars(afterName,allVars);
2323− }
2324− // Collect children
2325− const childLines=[];const childIndent=i<lines.length?(lines[i].length-lines[i].trimStart().length):indent+2;
2326− 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++;}
2327− // Function call?
2328− if(fns[name]){
2329− const fn=fns[name];const fnVars={...allVars};
2330− fn.params.forEach((p,idx)=>{if(attrs[p])fnVars[p]=attrs[p];});
2331− const fnBody=fn.body.map(l=>{let s=l;for(const[k,v]of Object.entries(fnVars))s=s.replaceAll('$'+k,v);return s;});
2332− const minI=Math.min(...fnBody.filter(l=>l.trim()).map(l=>l.length-l.trimStart().length));
2333− // Replace @children with caller's children
2334− const childHtml=renderNodes(childLines,childIndent,fnVars);
2335− let fnHtml=renderNodes(fnBody,minI,fnVars);
2336− fnHtml=fnHtml.replace('<!-- @children -->',childHtml);
2337− html+=fnHtml;continue;
2338− }
2339− // Element tags
2340− let tag='div';
2341− if(name==='text')tag='span';
2342− else if(name==='paragraph'||name==='p')tag='p';
2343− else if(name==='link')tag='a';
2344− else if(name==='button'||name==='btn')tag='button';
2345− else if(name==='image'||name==='img')tag='img';
2346− else if(name==='header')tag='header';
2347− else if(name==='footer')tag='footer';
2348− else if(name==='nav')tag='nav';
2349− else if(name==='main')tag='main';
2350− else if(name==='section')tag='section';
2351− else if(name==='article')tag='article';
2352− else if(name==='aside')tag='aside';
2353− else if(name==='list')tag='ul';
2354− else if(name==='item'||name==='li')tag='li';
2355− else if(name==='children'){html+='<!-- @children -->';continue;}
2356− const cls='_'+(clsIdx++);css+='.'+cls+'{'+baseStyle+style+'}\n';
2357− let extra='';
2358− 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='';}}
2359− if(tag==='img'){extra=' src="'+(textAfterBracket||'')+'"';if(attrs.alt)extra+=' alt="'+attrs.alt+'"';html+='<img class="'+cls+'"'+extra+'>';continue;}
2360− html+='<'+tag+' class="'+cls+'"'+extra+'>';
2361− if(textAfterBracket)html+=esc(textAfterBracket);
2362− html+=renderNodes(childLines,childIndent,allVars);
2363− html+='</'+tag+'>';
2364− } else {
2365− html+='<span>'+esc(subVars(t,allVars))+'</span>';
2366− }
2367− }
2368− return html;
2369− }
2370− function subVars(s,v){if(!s.includes('$'))return s;for(const[k,val]of Object.entries(v))s=s.replaceAll('$'+k,val);return s;}
2371− function esc(s){return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
2372− body=renderNodes(lines,0,vars);
2373− 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>';
2374−}
2292+ }}catch(e){{}}
2293+ }}
2294+}}
23752295// Resizable divider
2376−const divider=document.getElementById('divider');
2377−const editorPanel=document.getElementById('editor-panel');
2378−let dragging=false;
2379−divider.addEventListener('mousedown',()=>{dragging=true;document.body.style.cursor='col-resize';document.body.style.userSelect='none';});
2380−document.addEventListener('mousemove',e=>{if(!dragging)return;const pct=(e.clientX/window.innerWidth)*100;editorPanel.style.flex='none';editorPanel.style.width=pct+'%';});
2381−document.addEventListener('mouseup',()=>{if(dragging){dragging=false;document.body.style.cursor='';document.body.style.userSelect='';}});
2296+var divider=document.getElementById('divider');
2297+var editorPanel=document.getElementById('editor-panel');
2298+var dragging=false;
2299+divider.addEventListener('mousedown',function(){{dragging=true;document.body.style.cursor='col-resize';document.body.style.userSelect='none';}});
2300+document.addEventListener('mousemove',function(e){{if(!dragging)return;var pct=(e.clientX/window.innerWidth)*100;editorPanel.style.flex='none';editorPanel.style.width=pct+'%';}});
2301+document.addEventListener('mouseup',function(){{if(dragging){{dragging=false;document.body.style.cursor='';document.body.style.userSelect='';}}}});
23822302// Keyboard shortcuts and auto-compile
2383−const editor=document.getElementById('editor');
2384−editor.addEventListener('keydown',e=>{
2385− if(e.key==='Enter'&&(e.ctrlKey||e.metaKey)){e.preventDefault();compile();}
2386− 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;}
2387−});
2303+var editor=document.getElementById('editor');
2304+editor.addEventListener('keydown',function(e){{
2305+ if(e.key==='Enter'&&(e.ctrlKey||e.metaKey)){{e.preventDefault();compile();}}
2306+ if(e.key==='Tab'){{e.preventDefault();var s=e.target;var start=s.selectionStart;s.value=s.value.substring(0,start)+' '+s.value.substring(s.selectionEnd);s.selectionStart=s.selectionEnd=start+2;}}
2307+}});
23882308loadFromHash();
23892309compile();
23902310</script>
23912311</body>
2392−</html>"##;
2312+</html>"##, wasm_section = wasm_section, compile_fn = compile_fn);
23932313 if let Err(e) = fs::write(out, playground_html) {
23942314 eprintln!("error writing {}: {}", out, e);
23952315 process::exit(1);
23962316 }
2397− eprintln!("playground written to {}", out);
23982317 eprintln!("open in browser: open {}", out);
23992318 return;
24002319 }