@@ -217,6 +217,10 @@ Commands:
217217 repl Interactive REPL (type .hl, get HTML)
218218 feed <dir> [-b URL] Generate RSS feed from @page metadata
219219 components <dir> List all @fn definitions across a project
220+ deps <dir> Show file dependency graph (@include/@import)
221+ dead-code <dir> Find unused @fn, @define, @let across project
222+ deploy <dir> Build and deploy to GitHub Pages
223+ playground [out.html] Generate a self-contained HTML playground
220224
221225Options:
222226 -w, --watch Watch for changes and recompile
@@ -519,6 +523,63 @@ fn count_elements(
519523 }
520524}
521525
526+/// Extract shared CSS rules across multiple HTML files and write shared.css
527+fn extract_shared_css(html_files: &[PathBuf], out_dir: &Path) {
528+ // Parse <style> blocks from each HTML file and count rule occurrences
529+ let mut rule_counts: HashMap<String, usize> = HashMap::new();
530+ let total = html_files.len();
531+ for file in html_files {
532+ if let Ok(html) = fs::read_to_string(file) {
533+ // Extract CSS between <style> and </style>
534+ if let Some(start) = html.find("<style>") {
535+ if let Some(end) = html[start..].find("</style>") {
536+ let css = &html[start + 7..start + end];
537+ // Extract individual rules (class-based)
538+ let mut seen_in_file = std::collections::HashSet::new();
539+ let mut i = 0;
540+ let bytes = css.as_bytes();
541+ while i < bytes.len() {
542+ if bytes[i] == b'.' || bytes[i] == b'@' {
543+ // Find end of rule block
544+ let start_pos = i;
545+ let mut depth = 0;
546+ let mut found_open = false;
547+ while i < bytes.len() {
548+ if bytes[i] == b'{' { depth += 1; found_open = true; }
549+ else if bytes[i] == b'}' {
550+ depth -= 1;
551+ if found_open && depth == 0 { i += 1; break; }
552+ }
553+ i += 1;
554+ }
555+ let rule = &css[start_pos..i];
556+ if !rule.is_empty() && !seen_in_file.contains(rule) {
557+ seen_in_file.insert(rule.to_string());
558+ *rule_counts.entry(rule.to_string()).or_insert(0) += 1;
559+ }
560+ } else {
561+ i += 1;
562+ }
563+ }
564+ }
565+ }
566+ }
567+ }
568+ // Rules appearing in ALL files are shared
569+ let shared_rules: Vec<&String> = rule_counts.iter()
570+ .filter(|(_, count)| **count == total)
571+ .map(|(rule, _)| rule)
572+ .collect();
573+ if shared_rules.is_empty() {
574+ return;
575+ }
576+ let shared_css_path = out_dir.join("shared.css");
577+ let shared_css: String = shared_rules.iter().map(|r| r.as_str()).collect::<Vec<_>>().join("\n");
578+ if fs::write(&shared_css_path, &shared_css).is_ok() {
579+ eprintln!("extracted {} shared CSS rules to {}", shared_rules.len(), shared_css_path.display());
580+ }
581+}
582+
522583fn open_in_browser(port: u16) {
523584 let url = format!("http://127.0.0.1:{}", port);
524585 let cmd = if cfg!(target_os = "macos") {
@@ -718,6 +779,19 @@ fn main() {
718779 // Copy non-.hl static assets to output directory
719780 if let Some(out) = out_dir {
720781 copy_non_hl_files(dir, Path::new(out));
782+
783+ // Shared CSS extraction: find duplicate CSS rules across pages
784+ let out_path = Path::new(out);
785+ let html_files: Vec<PathBuf> = hl_files.iter()
786+ .map(|f| {
787+ let rel = f.strip_prefix(dir).unwrap_or(f);
788+ out_path.join(rel).with_extension("html")
789+ })
790+ .filter(|p| p.exists())
791+ .collect();
792+ if html_files.len() > 1 {
793+ extract_shared_css(&html_files, out_path);
794+ }
721795 }
722796 return;
723797 }
@@ -1290,6 +1364,354 @@ fn main() {
12901364 return;
12911365 }
12921366
1367+ // Handle "deps" subcommand — show dependency graph
1368+ if args.len() >= 2 && args[1] == "deps" {
1369+ let target = if args.len() >= 3 { &args[2] } else { "." };
1370+ let path = Path::new(target);
1371+ let hl_files = if path.is_dir() {
1372+ collect_hl_files_recursive(path)
1373+ } else {
1374+ vec![PathBuf::from(target)]
1375+ };
1376+ if hl_files.is_empty() {
1377+ eprintln!("no .hl files found in {}", target);
1378+ process::exit(1);
1379+ }
1380+ let mut graph: Vec<(String, Vec<String>)> = Vec::new();
1381+ for file in &hl_files {
1382+ let input = match fs::read_to_string(file) {
1383+ Ok(s) => s,
1384+ Err(_) => continue,
1385+ };
1386+ let rel = file.strip_prefix(path).unwrap_or(file);
1387+ let rel_str = rel.display().to_string();
1388+ let mut deps = Vec::new();
1389+ for line in input.lines() {
1390+ let trimmed = line.trim();
1391+ for directive in &["@include ", "@import ", "@extends ", "@use "] {
1392+ if let Some(rest) = trimmed.strip_prefix(directive) {
1393+ let dep = rest.split_whitespace().next().unwrap_or("").to_string();
1394+ if !dep.is_empty() {
1395+ deps.push(dep);
1396+ }
1397+ }
1398+ }
1399+ }
1400+ graph.push((rel_str, deps));
1401+ }
1402+ for (file, deps) in &graph {
1403+ if deps.is_empty() {
1404+ println!("{} (no dependencies)", file);
1405+ } else {
1406+ println!("{}", file);
1407+ for (i, dep) in deps.iter().enumerate() {
1408+ let prefix = if i == deps.len() - 1 { "└── " } else { "├── " };
1409+ println!(" {}{}", prefix, dep);
1410+ }
1411+ }
1412+ }
1413+ return;
1414+ }
1415+
1416+ // Handle "dead-code" subcommand — project-wide unused definitions
1417+ if args.len() >= 2 && args[1] == "dead-code" {
1418+ let target = if args.len() >= 3 { &args[2] } else { "." };
1419+ let path = Path::new(target);
1420+ let hl_files = if path.is_dir() {
1421+ collect_hl_files_recursive(path)
1422+ } else {
1423+ vec![PathBuf::from(target)]
1424+ };
1425+ if hl_files.is_empty() {
1426+ eprintln!("no .hl files found in {}", target);
1427+ process::exit(1);
1428+ }
1429+ // Pass 1: collect all definitions and usages across all files
1430+ let mut all_fn_defs: Vec<(String, String, usize)> = Vec::new(); // (name, file, line)
1431+ let mut all_def_defs: Vec<(String, String, usize)> = Vec::new();
1432+ let mut all_let_defs: Vec<(String, String, usize)> = Vec::new();
1433+ let mut all_refs: std::collections::HashSet<String> = std::collections::HashSet::new();
1434+ for file in &hl_files {
1435+ let input = match fs::read_to_string(file) {
1436+ Ok(s) => s,
1437+ Err(_) => continue,
1438+ };
1439+ let rel = file.strip_prefix(path).unwrap_or(file).display().to_string();
1440+ for (line_num, line) in input.lines().enumerate() {
1441+ let trimmed = line.trim();
1442+ if let Some(rest) = trimmed.strip_prefix("@fn ") {
1443+ if let Some(name) = rest.split_whitespace().next() {
1444+ all_fn_defs.push((name.to_string(), rel.clone(), line_num + 1));
1445+ }
1446+ } else if let Some(rest) = trimmed.strip_prefix("@define ") {
1447+ if let Some(name) = rest.split_whitespace().next() {
1448+ let name = name.trim_end_matches('[');
1449+ all_def_defs.push((name.to_string(), rel.clone(), line_num + 1));
1450+ }
1451+ } else if let Some(rest) = trimmed.strip_prefix("@let ") {
1452+ if let Some(name) = rest.split_whitespace().next() {
1453+ all_let_defs.push((name.to_string(), rel.clone(), line_num + 1));
1454+ }
1455+ }
1456+ // Collect references: @name calls and $name usages
1457+ if trimmed.starts_with('@') && !trimmed.starts_with("@fn ")
1458+ && !trimmed.starts_with("@let ") && !trimmed.starts_with("@define ")
1459+ && !trimmed.starts_with("@include ") && !trimmed.starts_with("@import ")
1460+ && !trimmed.starts_with("@extends ") && !trimmed.starts_with("@page")
1461+ && !trimmed.starts_with("@meta ") && !trimmed.starts_with("@og ")
1462+ && !trimmed.starts_with("@head") && !trimmed.starts_with("@style")
1463+ && !trimmed.starts_with("@keyframes ") && !trimmed.starts_with("@theme")
1464+ && !trimmed.starts_with("@deprecated ") && !trimmed.starts_with("@breakpoint ")
1465+ && !trimmed.starts_with("@use ") && !trimmed.starts_with("@slot ")
1466+ && !trimmed.starts_with("@lang ") && !trimmed.starts_with("@favicon ")
1467+ && !trimmed.starts_with("--")
1468+ {
1469+ if let Some(name) = trimmed[1..].split(|c: char| c == ' ' || c == '[').next() {
1470+ all_refs.insert(name.to_string());
1471+ }
1472+ }
1473+ // Collect $var references
1474+ let mut rest = trimmed;
1475+ while let Some(pos) = rest.find('$') {
1476+ let after = &rest[pos + 1..];
1477+ let end = after.find(|c: char| !c.is_alphanumeric() && c != '-' && c != '_').unwrap_or(after.len());
1478+ if end > 0 {
1479+ all_refs.insert(after[..end].to_string());
1480+ }
1481+ rest = if end < after.len() { &after[end..] } else { "" };
1482+ }
1483+ }
1484+ }
1485+ // Pass 2: report unused
1486+ let mut count = 0;
1487+ for (name, file, line) in &all_fn_defs {
1488+ if !all_refs.contains(name) {
1489+ println!("unused @fn '{}' — {}:{}", name, file, line);
1490+ count += 1;
1491+ }
1492+ }
1493+ for (name, file, line) in &all_def_defs {
1494+ if !all_refs.contains(name) {
1495+ println!("unused @define '{}' — {}:{}", name, file, line);
1496+ count += 1;
1497+ }
1498+ }
1499+ for (name, file, line) in &all_let_defs {
1500+ if !all_refs.contains(name) && !name.starts_with("--") {
1501+ println!("unused @let '{}' — {}:{}", name, file, line);
1502+ count += 1;
1503+ }
1504+ }
1505+ if count == 0 {
1506+ eprintln!("no unused definitions found");
1507+ } else {
1508+ eprintln!("\n{} unused definition(s) found", count);
1509+ }
1510+ return;
1511+ }
1512+
1513+ // Handle "deploy" subcommand — deploy to GitHub Pages
1514+ if args.len() >= 2 && args[1] == "deploy" {
1515+ let target = if args.len() >= 3 { &args[2] } else { "." };
1516+ let path = Path::new(target);
1517+ if !path.is_dir() {
1518+ eprintln!("error: '{}' is not a directory", target);
1519+ process::exit(1);
1520+ }
1521+ // Build first
1522+ let hl_files = collect_hl_files_recursive(path);
1523+ if hl_files.is_empty() {
1524+ eprintln!("no .hl files found in {}", target);
1525+ process::exit(1);
1526+ }
1527+ let deploy_dir = path.join("_deploy");
1528+ let _ = fs::create_dir_all(&deploy_dir);
1529+ let any_errors = std::sync::atomic::AtomicBool::new(false);
1530+ std::thread::scope(|s| {
1531+ for file in &hl_files {
1532+ let any_errors = &any_errors;
1533+ let deploy_dir = &deploy_dir;
1534+ s.spawn(move || {
1535+ let rel = file.strip_prefix(path).unwrap_or(file);
1536+ let out_path = deploy_dir.join(rel).with_extension("html");
1537+ if let Some(parent) = out_path.parent() {
1538+ let _ = fs::create_dir_all(parent);
1539+ }
1540+ let out_str = out_path.to_string_lossy().to_string();
1541+ let path_str = file.to_string_lossy().to_string();
1542+ let (has_errors, _) = compile(&path_str, false, false, false, Some(&out_str), false, None);
1543+ if has_errors {
1544+ any_errors.store(true, std::sync::atomic::Ordering::Relaxed);
1545+ }
1546+ });
1547+ }
1548+ });
1549+ if any_errors.load(std::sync::atomic::Ordering::Relaxed) {
1550+ eprintln!("error: build failed, not deploying");
1551+ process::exit(1);
1552+ }
1553+ copy_non_hl_files(path, &deploy_dir);
1554+ // Deploy via gh-pages push
1555+ eprintln!("built {} files to {}", hl_files.len(), deploy_dir.display());
1556+ let status = process::Command::new("git")
1557+ .args(&["init"])
1558+ .current_dir(&deploy_dir)
1559+ .status();
1560+ if status.is_err() || !status.unwrap().success() {
1561+ eprintln!("error: git init failed in deploy directory");
1562+ process::exit(1);
1563+ }
1564+ let _ = process::Command::new("git")
1565+ .args(&["checkout", "-b", "gh-pages"])
1566+ .current_dir(&deploy_dir)
1567+ .status();
1568+ let _ = process::Command::new("git")
1569+ .args(&["add", "."])
1570+ .current_dir(&deploy_dir)
1571+ .status();
1572+ let status = process::Command::new("git")
1573+ .args(&["commit", "-m", "deploy"])
1574+ .current_dir(&deploy_dir)
1575+ .status();
1576+ if status.is_err() || !status.unwrap().success() {
1577+ eprintln!("error: git commit failed");
1578+ process::exit(1);
1579+ }
1580+ // Check if remote origin exists in parent
1581+ let remote_output = process::Command::new("git")
1582+ .args(&["remote", "get-url", "origin"])
1583+ .output();
1584+ if let Ok(output) = remote_output {
1585+ if output.status.success() {
1586+ let remote_url = String::from_utf8_lossy(&output.stdout).trim().to_string();
1587+ let _ = process::Command::new("git")
1588+ .args(&["remote", "add", "origin", &remote_url])
1589+ .current_dir(&deploy_dir)
1590+ .status();
1591+ eprintln!("pushing to gh-pages branch at {}...", remote_url);
1592+ let push_status = process::Command::new("git")
1593+ .args(&["push", "-f", "origin", "gh-pages"])
1594+ .current_dir(&deploy_dir)
1595+ .status();
1596+ if push_status.is_err() || !push_status.unwrap().success() {
1597+ eprintln!("error: push failed — run 'git push -f origin gh-pages' manually from {}", deploy_dir.display());
1598+ process::exit(1);
1599+ }
1600+ eprintln!("deployed to GitHub Pages!");
1601+ } else {
1602+ eprintln!("no git remote found — deploy directory ready at {}", deploy_dir.display());
1603+ eprintln!("push manually: cd {} && git remote add origin <url> && git push -f origin gh-pages", deploy_dir.display());
1604+ }
1605+ }
1606+ return;
1607+ }
1608+
1609+ // Handle "playground" subcommand — generate self-contained HTML playground
1610+ if args.len() >= 2 && args[1] == "playground" {
1611+ let out = if args.len() >= 3 { &args[2] } else { "playground.html" };
1612+ let playground_html = r##"<!DOCTYPE html>
1613+<html lang="en">
1614+<head>
1615+<meta charset="utf-8">
1616+<meta name="viewport" content="width=device-width, initial-scale=1">
1617+<title>htmlang Playground</title>
1618+<style>
1619+*{box-sizing:border-box;margin:0}
1620+body{font-family:system-ui,-apple-system,sans-serif;display:flex;height:100vh;background:#1a1a2e;color:#eee}
1621+.panel{flex:1;display:flex;flex-direction:column;min-width:0}
1622+.header{padding:12px 16px;background:#16213e;border-bottom:1px solid #0f3460;display:flex;align-items:center;gap:12px}
1623+.header h1{font-size:14px;font-weight:600;color:#e94560}
1624+.header .tag{font-size:11px;padding:2px 8px;background:#0f3460;border-radius:4px;color:#a8b2d1}
1625+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}
1626+iframe{flex:1;border:none;background:white}
1627+.divider{width:1px;background:#0f3460}
1628+.toolbar{padding:8px 16px;background:#16213e;border-top:1px solid #0f3460;display:flex;gap:8px;align-items:center}
1629+button{padding:6px 16px;border:none;border-radius:4px;cursor:pointer;font-size:13px;font-weight:500}
1630+.btn-run{background:#e94560;color:white}
1631+.btn-run:hover{background:#c81e45}
1632+.btn-copy{background:#0f3460;color:#a8b2d1}
1633+.status{margin-left:auto;font-size:12px;color:#666}
1634+</style>
1635+</head>
1636+<body>
1637+<div class="panel">
1638+<div class="header"><h1>htmlang</h1><span class="tag">playground</span></div>
1639+<textarea id="editor" spellcheck="false">@page Hello World
1640+@let primary #3b82f6
1641+
1642+@fn card $title
1643+ @el [padding 20, background white, rounded 8, border 1 #e5e7eb, hover:border 1 $primary, transition all 0.15s ease]
1644+ @text [bold, size 18] $title
1645+ @children
1646+
1647+@column [max-width 800, center-x, padding 40, spacing 20]
1648+ @text [bold, size 32, color $primary] Hello from htmlang!
1649+
1650+ @paragraph [line-height 1.6]
1651+ Edit this code and click Run to see the output.
1652+
1653+ @row [wrap, spacing 10]
1654+ @card [title Simple]
1655+ Write layouts without CSS
1656+ @card [title Fast]
1657+ Compiles to a single HTML file
1658+ @card [title Zero-dep]
1659+ No JavaScript, no externals</textarea>
1660+<div class="toolbar">
1661+<button class="btn-run" onclick="compile()">▶ Run</button>
1662+<button class="btn-copy" onclick="copyOutput()">Copy HTML</button>
1663+<span class="status" id="status">Ready</span>
1664+</div>
1665+</div>
1666+<div class="divider"></div>
1667+<div class="panel">
1668+<div class="header"><span class="tag">preview</span></div>
1669+<iframe id="preview"></iframe>
1670+</div>
1671+<script>
1672+let lastOutput='';
1673+function compile(){
1674+ const src=document.getElementById('editor').value;
1675+ const status=document.getElementById('status');
1676+ status.textContent='Note: client-side preview is approximate. Use htmlang CLI for exact output.';
1677+ // Simple client-side approximation
1678+ let html=approxCompile(src);
1679+ lastOutput=html;
1680+ const iframe=document.getElementById('preview');
1681+ iframe.srcdoc=html;
1682+}
1683+function copyOutput(){
1684+ if(lastOutput)navigator.clipboard.writeText(lastOutput);
1685+}
1686+function approxCompile(src){
1687+ const lines=src.split('\n');
1688+ let title='',body='',css='';
1689+ let vars={},cls=0;
1690+ for(const line of lines){
1691+ const t=line.trim();
1692+ if(t.startsWith('@page '))title=t.slice(6);
1693+ else if(t.startsWith('@let ')){const p=t.slice(5).split(' ');if(p.length>=2)vars[p[0]]=p.slice(1).join(' ');}
1694+ else if(t.startsWith('--'))continue;
1695+ else if(t.startsWith('@'))body+='<div>'+subVars(t,vars)+'</div>';
1696+ else if(t)body+='<span>'+subVars(t,vars)+'</span>';
1697+ }
1698+ function subVars(s,v){for(const[k,val]of Object.entries(v))s=s.replaceAll('$'+k,val);return s;}
1699+ 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>';
1700+}
1701+compile();
1702+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;}});
1703+</script>
1704+</body>
1705+</html>"##;
1706+ if let Err(e) = fs::write(out, playground_html) {
1707+ eprintln!("error writing {}: {}", out, e);
1708+ process::exit(1);
1709+ }
1710+ eprintln!("playground written to {}", out);
1711+ eprintln!("open in browser: open {}", out);
1712+ return;
1713+ }
1714+
12931715 // Handle "convert" subcommand
12941716 if args.len() >= 2 && args[1] == "convert" {
12951717 if args.len() < 3 {