on master help sign in
Add 10 improvements: include error chains, JSON error positions, explain command, config validation, smart watch mode, watch debounce, port display, and stress tests
9ec4406iko
src/main.rs+235−48
@@ -381,6 +381,7 @@ Commands:
381 bundle <file|dir> [-o <out>] Compile and inline all assets as data URIs
381 bundle <file|dir> [-o <out>] Compile and inline all assets as data URIs
382 size <file|dir> Report output sizes (raw, minified, ~gzip)
382 size <file|dir> Report output sizes (raw, minified, ~gzip)
383 benchmark <file|dir> Measure compile time and output size
383 benchmark <file|dir> Measure compile time and output size
384+ explain <file.hl> Show what CSS each source line produces
384 lsp Start the Language Server Protocol server
385 lsp Start the Language Server Protocol server
385 completions <shell> Generate shell completions (bash, zsh, fish)
386 completions <shell> Generate shell completions (bash, zsh, fish)
386
387
@@ -965,13 +966,19 @@ fn load_config(target: &Path) -> ProjectConfig {
965 };
966 };
966
967
967 let mut section = "";
968 let mut section = "";
968− for line in content.lines() {
969+ for (line_num, line) in content.lines().enumerate() {
969 let trimmed = line.trim();
970 let trimmed = line.trim();
970 if trimmed.is_empty() || trimmed.starts_with('#') {
971 if trimmed.is_empty() || trimmed.starts_with('#') {
971 continue;
972 continue;
972 }
973 }
973 if trimmed.starts_with('[') && trimmed.ends_with(']') {
974 if trimmed.starts_with('[') && trimmed.ends_with(']') {
974 section = &trimmed[1..trimmed.len() - 1];
975 section = &trimmed[1..trimmed.len() - 1];
976+ if !matches!(section, "variables" | "breakpoints") {
977+ eprintln!(
978+ "warning: {}:{}: unknown section '[{}]' (expected: variables, breakpoints)",
979+ config_path.display(), line_num + 1, section
980+ );
981+ }
975 continue;
982 continue;
976 }
983 }
977 if let Some((key, value)) = trimmed.split_once('=') {
984 if let Some((key, value)) = trimmed.split_once('=') {
@@ -981,7 +988,12 @@ fn load_config(target: &Path) -> ProjectConfig {
981 "" => match key {
988 "" => match key {
982 "output" => config.output = Some(value.to_string()),
989 "output" => config.output = Some(value.to_string()),
983 "port" => config.port = value.parse().unwrap_or(3000),
990 "port" => config.port = value.parse().unwrap_or(3000),
984− _ => {}
991+ _ => {
992+ eprintln!(
993+ "warning: {}:{}: unknown key '{}' (expected: output, port)",
994+ config_path.display(), line_num + 1, key
995+ );
996+ }
985 },
997 },
986 "variables" => {
998 "variables" => {
987 config.variables.push((key.to_string(), value.to_string()));
999 config.variables.push((key.to_string(), value.to_string()));
@@ -989,7 +1001,7 @@ fn load_config(target: &Path) -> ProjectConfig {
989 "breakpoints" => {
1001 "breakpoints" => {
990 config.breakpoints.push((key.to_string(), value.to_string()));
1002 config.breakpoints.push((key.to_string(), value.to_string()));
991 }
1003 }
992− _ => {}
1004+ _ => {} // already warned about unknown section
993 }
1005 }
994 }
1006 }
995 }
1007 }
@@ -1632,7 +1644,7 @@ fn main() {
1632 rt.block_on(htmlang::serve::run(effective_port, index_path, server_tx));
1644 rt.block_on(htmlang::serve::run(effective_port, index_path, server_tx));
1633 });
1645 });
1634 if serve_open { open_in_browser(effective_port); }
1646 if serve_open { open_in_browser(effective_port); }
1635− watch_loop(target_path, &hl_files, &[], true, true, Some(tx));
1647+ watch_loop(target_path, &hl_files, &[], true, true, Some(tx), effective_port);
1636 } else {
1648 } else {
1637 let (_, included) = compile(&target, &CompileConfig {
1649 let (_, included) = compile(&target, &CompileConfig {
1638 dev: true, error_overlay: true,
1650 dev: true, error_overlay: true,
@@ -1647,7 +1659,7 @@ fn main() {
1647 });
1659 });
1648 if serve_open { open_in_browser(effective_port); }
1660 if serve_open { open_in_browser(effective_port); }
1649 let files = vec![PathBuf::from(&target)];
1661 let files = vec![PathBuf::from(&target)];
1650− watch_loop(Path::new(&target).parent().unwrap_or(Path::new(".")), &files, &included, true, true, Some(tx));
1662+ watch_loop(Path::new(&target).parent().unwrap_or(Path::new(".")), &files, &included, true, true, Some(tx), effective_port);
1651 }
1663 }
1652 return;
1664 return;
1653 }
1665 }
@@ -1694,14 +1706,14 @@ fn main() {
1694 });
1706 });
1695 all_included.extend(included);
1707 all_included.extend(included);
1696 }
1708 }
1697− watch_loop(target_path, &hl_files, &all_included, false, false, None);
1709+ watch_loop(target_path, &hl_files, &all_included, false, false, None, 0);
1698 } else {
1710 } else {
1699 let (_, included) = compile(&target, &CompileConfig {
1711 let (_, included) = compile(&target, &CompileConfig {
1700 output_path: effective_output.as_deref(),
1712 output_path: effective_output.as_deref(),
1701 ..Default::default()
1713 ..Default::default()
1702 });
1714 });
1703 let files = vec![PathBuf::from(&target)];
1715 let files = vec![PathBuf::from(&target)];
1704− watch_loop(Path::new(&target).parent().unwrap_or(Path::new(".")), &files, &included, false, false, None);
1716+ watch_loop(Path::new(&target).parent().unwrap_or(Path::new(".")), &files, &included, false, false, None, 0);
1705 }
1717 }
1706 return;
1718 return;
1707 }
1719 }
@@ -2828,6 +2840,145 @@ compile();
2828 return;
2840 return;
2829 }
2841 }
2830
2842
2843+ // Handle "explain" subcommand — show CSS mapping for each line
2844+ if args.len() >= 2 && args[1] == "explain" {
2845+ if args.len() < 3 {
2846+ eprintln!("usage: htmlang explain <file.hl>");
2847+ process::exit(1);
2848+ }
2849+ let file = &args[2];
2850+ let input = match fs::read_to_string(file) {
2851+ Ok(s) => s,
2852+ Err(e) => {
2853+ eprintln!("error: {}: {}", file, e);
2854+ process::exit(1);
2855+ }
2856+ };
2857+ let base = Path::new(file).parent();
2858+ let result = htmlang::parser::parse_with_base(&input, base);
2859+ for d in &result.diagnostics {
2860+ let prefix = match d.severity {
2861+ htmlang::parser::Severity::Error => "error",
2862+ htmlang::parser::Severity::Warning => "warning",
2863+ };
2864+ eprintln!("{}: line {}: {}", prefix, d.line, d.message);
2865+ }
2866+ if result.diagnostics.iter().any(|d| d.severity == htmlang::parser::Severity::Error) {
2867+ process::exit(1);
2868+ }
2869+ let html = htmlang::codegen::generate_dev(&result.document);
2870+ // Extract CSS class mappings from <style> block
2871+ let mut class_css: HashMap<String, String> = HashMap::new();
2872+ if let Some(style_start) = html.find("<style>") {
2873+ if let Some(style_end) = html[style_start..].find("</style>") {
2874+ let css_block = &html[style_start + 7..style_start + style_end];
2875+ // Parse each CSS rule: .className { rules }
2876+ let mut pos = 0;
2877+ let css_bytes = css_block.as_bytes();
2878+ while pos < css_bytes.len() {
2879+ if css_bytes[pos] == b'.' {
2880+ let rule_start = pos;
2881+ // Find class name end
2882+ pos += 1;
2883+ while pos < css_bytes.len() && css_bytes[pos] != b' ' && css_bytes[pos] != b'{' && css_bytes[pos] != b':' && css_bytes[pos] != b',' {
2884+ pos += 1;
2885+ }
2886+ let class_name = &css_block[rule_start + 1..pos];
2887+ // Find rule body
2888+ while pos < css_bytes.len() && css_bytes[pos] != b'{' {
2889+ pos += 1;
2890+ }
2891+ if pos < css_bytes.len() {
2892+ pos += 1; // skip '{'
2893+ let body_start = pos;
2894+ let mut depth = 1;
2895+ while pos < css_bytes.len() && depth > 0 {
2896+ if css_bytes[pos] == b'{' { depth += 1; }
2897+ if css_bytes[pos] == b'}' { depth -= 1; }
2898+ pos += 1;
2899+ }
2900+ let body = css_block[body_start..pos.saturating_sub(1)].trim();
2901+ if !body.is_empty() {
2902+ class_css.entry(class_name.to_string())
2903+ .and_modify(|existing| {
2904+ existing.push_str("; ");
2905+ existing.push_str(body);
2906+ })
2907+ .or_insert_with(|| body.to_string());
2908+ }
2909+ }
2910+ } else {
2911+ pos += 1;
2912+ }
2913+ }
2914+ }
2915+ }
2916+ // Extract data-hl-line attributes from dev HTML to map classes to source lines
2917+ let mut line_classes: HashMap<usize, Vec<String>> = HashMap::new();
2918+ let html_body = if let Some(body_start) = html.find("<body") {
2919+ &html[body_start..]
2920+ } else {
2921+ &html
2922+ };
2923+ // Scan for elements with data-hl-line="N" and class="X"
2924+ // Dev mode outputs: <div class="a" data-hl-line="2" data-hl-el="el">
2925+ let mut scan_pos = 0;
2926+ while scan_pos < html_body.len() {
2927+ // Find each opening tag
2928+ if html_body.as_bytes()[scan_pos] == b'<' && scan_pos + 1 < html_body.len() && html_body.as_bytes()[scan_pos + 1].is_ascii_alphabetic() {
2929+ // Find end of tag
2930+ let tag_end = html_body[scan_pos..].find('>').map(|p| scan_pos + p).unwrap_or(html_body.len());
2931+ let tag = &html_body[scan_pos..tag_end];
2932+ // Extract data-hl-line
2933+ if let Some(hl_pos) = tag.find("data-hl-line=\"") {
2934+ let after = &tag[hl_pos + 14..];
2935+ if let Some(end) = after.find('"') {
2936+ if let Ok(ln) = after[..end].parse::<usize>() {
2937+ // Extract class
2938+ if let Some(cls_pos) = tag.find("class=\"") {
2939+ let cls_after = &tag[cls_pos + 7..];
2940+ if let Some(cls_end) = cls_after.find('"') {
2941+ for cls in cls_after[..cls_end].split_whitespace() {
2942+ line_classes.entry(ln).or_default().push(cls.to_string());
2943+ }
2944+ }
2945+ }
2946+ }
2947+ }
2948+ }
2949+ scan_pos = tag_end;
2950+ } else {
2951+ scan_pos += 1;
2952+ }
2953+ }
2954+ // Print explanation
2955+ for (line_num, line) in input.lines().enumerate() {
2956+ let ln = line_num + 1;
2957+ let trimmed = line.trim();
2958+ if trimmed.is_empty() || trimmed.starts_with("--") {
2959+ continue;
2960+ }
2961+ print!("{:>4} | {}", ln, line);
2962+ if let Some(classes) = line_classes.get(&ln) {
2963+ let mut css_parts: Vec<String> = Vec::new();
2964+ for cls in classes {
2965+ if let Some(css) = class_css.get(cls) {
2966+ css_parts.push(css.clone());
2967+ }
2968+ }
2969+ if !css_parts.is_empty() {
2970+ println!();
2971+ println!(" → {}", css_parts.join("; "));
2972+ } else {
2973+ println!();
2974+ }
2975+ } else {
2976+ println!();
2977+ }
2978+ }
2979+ return;
2980+ }
2981+
2831 // Handle "benchmark" subcommand — measure compile time and output size
2982 // Handle "benchmark" subcommand — measure compile time and output size
2832 if args.len() >= 2 && args[1] == "benchmark" {
2983 if args.len() >= 2 && args[1] == "benchmark" {
2833 if args.len() < 3 {
2984 if args.len() < 3 {
@@ -3219,7 +3370,7 @@ compile();
3219 None
3370 None
3220 };
3371 };
3221
3372
3222− watch_loop(dir, &hl_files, &all_included, dev, serve, reload_tx);
3373+ watch_loop(dir, &hl_files, &all_included, dev, serve, reload_tx, port);
3223 return;
3374 return;
3224 }
3375 }
3225
3376
@@ -3273,6 +3424,7 @@ compile();
3273 dev,
3424 dev,
3274 serve,
3425 serve,
3275 reload_tx,
3426 reload_tx,
3427+ port,
3276 );
3428 );
3277}
3429}
3278
3430
@@ -3336,7 +3488,7 @@ fn print_bash_completions() {
3336 local cur prev commands
3488 local cur prev commands
3337 cur="${{COMP_WORDS[COMP_CWORD]}}"
3489 cur="${{COMP_WORDS[COMP_CWORD]}}"
3338 prev="${{COMP_WORDS[COMP_CWORD-1]}}"
3490 prev="${{COMP_WORDS[COMP_CWORD-1]}}"
3339− commands="init new build serve watch check convert fmt sitemap lint stats preview diff export repl feed components deps dead-code deploy playground clean upgrade create-component outline doctor migrate bundle size benchmark test lsp completions"
3491+ commands="init new build serve watch check convert fmt sitemap lint stats preview diff export repl feed components deps dead-code deploy playground clean upgrade create-component outline doctor migrate bundle size benchmark explain test lsp completions"
3340
3492
3341 if [ "$COMP_CWORD" -eq 1 ]; then
3493 if [ "$COMP_CWORD" -eq 1 ]; then
3342 COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
3494 COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
@@ -3397,6 +3549,7 @@ _htmlang() {{
3397 'bundle:Inline all assets'
3549 'bundle:Inline all assets'
3398 'size:Report output sizes'
3550 'size:Report output sizes'
3399 'benchmark:Measure compile time'
3551 'benchmark:Measure compile time'
3552+ 'explain:Show CSS mapping per source line'
3400 'test:Run assertions'
3553 'test:Run assertions'
3401 'lsp:Start LSP server'
3554 'lsp:Start LSP server'
3402 'completions:Generate shell completions'
3555 'completions:Generate shell completions'
@@ -3469,6 +3622,7 @@ fn print_fish_completions() {
3469 ("bundle", "Inline all assets as data URIs"),
3622 ("bundle", "Inline all assets as data URIs"),
3470 ("size", "Report output sizes"),
3623 ("size", "Report output sizes"),
3471 ("benchmark", "Measure compile time"),
3624 ("benchmark", "Measure compile time"),
3625+ ("explain", "Show CSS mapping per source line"),
3472 ("test", "Run assertions"),
3626 ("test", "Run assertions"),
3473 ("lsp", "Start LSP server"),
3627 ("lsp", "Start LSP server"),
3474 ("completions", "Generate shell completions"),
3628 ("completions", "Generate shell completions"),
@@ -3526,6 +3680,7 @@ fn watch_loop(
3526 dev: bool,
3680 dev: bool,
3527 serve: bool,
3681 serve: bool,
3528 reload_tx: Option<tokio::sync::broadcast::Sender<()>>,
3682 reload_tx: Option<tokio::sync::broadcast::Sender<()>>,
3683+ serve_port: u16,
3529) {
3684) {
3530 use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
3685 use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
3531 use std::sync::mpsc;
3686 use std::sync::mpsc;
@@ -3556,13 +3711,15 @@ fn watch_loop(
3556 let _ = watcher.watch(watch_dir, RecursiveMode::NonRecursive);
3711 let _ = watcher.watch(watch_dir, RecursiveMode::NonRecursive);
3557
3712
3558 if serve {
3713 if serve {
3559− eprintln!("watching for changes at http://127.0.0.1:3000");
3714+ eprintln!("watching for changes at http://127.0.0.1:{}", serve_port);
3560 } else {
3715 } else {
3561 eprintln!("watching for changes...");
3716 eprintln!("watching for changes...");
3562 }
3717 }
3563
3718
3564 // Track content hashes for incremental rebuilds
3719 // Track content hashes for incremental rebuilds
3565 let mut content_hashes: HashMap<PathBuf, u64> = HashMap::new();
3720 let mut content_hashes: HashMap<PathBuf, u64> = HashMap::new();
3721+ // Dependency map: source file -> list of included/imported files
3722+ let mut dep_map: HashMap<PathBuf, Vec<PathBuf>> = HashMap::new();
3566
3723
3567 fn hash_file(path: &Path) -> Option<u64> {
3724 fn hash_file(path: &Path) -> Option<u64> {
3568 let content = fs::read(path).ok()?;
3725 let content = fs::read(path).ok()?;
@@ -3571,7 +3728,7 @@ fn watch_loop(
3571 Some(hasher.finish())
3728 Some(hasher.finish())
3572 }
3729 }
3573
3730
3574− // Seed initial hashes
3731+ // Seed initial hashes and dependency map
3575 for file in source_files {
3732 for file in source_files {
3576 let canonical = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
3733 let canonical = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
3577 if let Some(h) = hash_file(&canonical) {
3734 if let Some(h) = hash_file(&canonical) {
@@ -3587,11 +3744,12 @@ fn watch_loop(
3587 loop {
3744 loop {
3588 match rx.recv() {
3745 match rx.recv() {
3589 Ok(_) => {
3746 Ok(_) => {
3590− // Drain additional events (debounce)
3747+ // Drain additional events (debounce) with short delay for rapid saves
3748+ std::thread::sleep(std::time::Duration::from_millis(50));
3591 while rx.try_recv().is_ok() {}
3749 while rx.try_recv().is_ok() {}
3592
3750
3593− // Check if any file content actually changed
3751+ // Collect which files actually changed
3594− let mut any_changed = false;
3752+ let mut changed_files: Vec<PathBuf> = Vec::new();
3595 let check_path = |path: &Path,
3753 let check_path = |path: &Path,
3596 hashes: &mut HashMap<PathBuf, u64>|
3754 hashes: &mut HashMap<PathBuf, u64>|
3597 -> bool {
3755 -> bool {
@@ -3607,7 +3765,18 @@ fn watch_loop(
3607 for file in source_files {
3765 for file in source_files {
3608 let canonical = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
3766 let canonical = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
3609 if check_path(&canonical, &mut content_hashes) {
3767 if check_path(&canonical, &mut content_hashes) {
3610− any_changed = true;
3768+ changed_files.push(canonical);
3769+ }
3770+ }
3771+
3772+ // Check included files for changes
3773+ for (_, deps) in &dep_map {
3774+ for dep in deps {
3775+ if check_path(dep, &mut content_hashes) {
3776+ if !changed_files.contains(dep) {
3777+ changed_files.push(dep.clone());
3778+ }
3779+ }
3611 }
3780 }
3612 }
3781 }
3613
3782
@@ -3617,63 +3786,81 @@ fn watch_loop(
3617 for file in &current_files {
3786 for file in &current_files {
3618 let canonical = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
3787 let canonical = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
3619 if check_path(&canonical, &mut content_hashes) {
3788 if check_path(&canonical, &mut content_hashes) {
3620− any_changed = true;
3789+ changed_files.push(canonical.clone());
3621 let _ = watcher.watch(&canonical, RecursiveMode::NonRecursive);
3790 let _ = watcher.watch(&canonical, RecursiveMode::NonRecursive);
3622 }
3791 }
3623 }
3792 }
3624 }
3793 }
3625
3794
3626− if !any_changed {
3795+ if changed_files.is_empty() {
3627 continue;
3796 continue;
3628 }
3797 }
3629
3798
3630 eprintln!("\nrecompiling...");
3799 eprintln!("\nrecompiling...");
3631
3800
3632− // Dependency-graph incremental: track which source files
3801+ // Determine which source files need recompilation:
3633− // depend on which included files, only recompile affected ones
3802+ // 1. Source files that changed directly
3803+ // 2. Source files whose dependencies changed
3804+ let mut files_to_compile: Vec<PathBuf> = Vec::new();
3805+ let all_sources: Vec<PathBuf> = {
3634− let changed_paths: Vec<PathBuf> = source_files.iter()
3806+ let mut s: Vec<PathBuf> = source_files.iter()
3635− .filter_map(|f| {
3636− let c = fs::canonicalize(f).unwrap_or_else(|_| f.clone());
3807+ .map(|f| fs::canonicalize(f).unwrap_or_else(|_| f.clone()))
3637− if content_hashes.get(&c).is_some() { Some(c) } else { None }
3638− })
3639− .collect();
3808+ .collect();
3640−
3809+ if watch_dir.is_dir() {
3641− // Build dependency map: source -> [deps] from previous compile
3810+ for file in &collect_hl_files(watch_dir) {
3642− // (tracked via included_files returned by compile)
3811+ let c = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
3643− // For now, recompile source files whose deps changed
3812+ if !s.contains(&c) {
3813+ s.push(c);
3814+ }
3815+ }
3816+ }
3817+ s
3818+ };
3819+
3820+ for source in &all_sources {
3821+ // Recompile if the source itself changed
3822+ if changed_files.contains(source) {
3823+ if !files_to_compile.contains(source) {
3824+ files_to_compile.push(source.clone());
3825+ }
3826+ continue;
3827+ }
3828+ // Recompile if any of its dependencies changed
3829+ if let Some(deps) = dep_map.get(source) {
3830+ if deps.iter().any(|d| changed_files.contains(d)) {
3831+ if !files_to_compile.contains(source) {
3832+ files_to_compile.push(source.clone());
3833+ }
3834+ }
3835+ }
3836+ }
3837+
3838+ // If no specific files identified (e.g., first run), compile all
3839+ if files_to_compile.is_empty() {
3840+ files_to_compile = all_sources;
3841+ }
3842+
3644 let mut recompiled = 0usize;
3843 let mut recompiled = 0usize;
3645− for file in source_files {
3844+ for file in &files_to_compile {
3646 let path_str = file.to_string_lossy().to_string();
3845 let path_str = file.to_string_lossy().to_string();
3647 let (_, new_includes) = compile(&path_str, &CompileConfig {
3846 let (_, new_includes) = compile(&path_str, &CompileConfig {
3648 dev, error_overlay: serve,
3847 dev, error_overlay: serve,
3649 ..Default::default()
3848 ..Default::default()
3650 });
3849 });
3651 recompiled += 1;
3850 recompiled += 1;
3652− for inc in &new_includes {
3851+ // Update dependency map
3852+ let canonical_deps: Vec<PathBuf> = new_includes.iter()
3853+ .map(|p| fs::canonicalize(p).unwrap_or_else(|_| p.clone()))
3854+ .collect();
3855+ for inc in &canonical_deps {
3653 let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
3856 let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
3654 if let Some(h) = hash_file(inc) {
3857 if let Some(h) = hash_file(inc) {
3655 content_hashes.insert(inc.clone(), h);
3858 content_hashes.insert(inc.clone(), h);
3656 }
3859 }
3657 }
3860 }
3861+ dep_map.insert(file.clone(), canonical_deps);
3658 }
3862 }
3659
3863
3660− // Also compile any new .hl files in directory mode
3661− if watch_dir.is_dir() {
3662− for file in &collect_hl_files(watch_dir) {
3663− if !source_files.contains(file) {
3664− let path_str = file.to_string_lossy().to_string();
3665− let (_, new_includes) = compile(&path_str, &CompileConfig {
3666− dev, error_overlay: serve,
3667− ..Default::default()
3668− });
3669− recompiled += 1;
3670− for inc in &new_includes {
3671− let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
3672− }
3673− }
3674− }
3675− }
3676− let _ = changed_paths;
3677 eprintln!("recompiled {} file(s)", recompiled);
3864 eprintln!("recompiled {} file(s)", recompiled);
3678
3865
3679 if let Some(ref tx) = reload_tx {
3866 if let Some(ref tx) = reload_tx {
src/parser.rs+71−4
@@ -766,6 +766,7 @@ impl Parser {
766 let saved_base = ctx.base_path.clone();
766 let saved_base = ctx.base_path.clone();
767 ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
767 ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
768
768
769+ let diag_count_before = ctx.diagnostics.len();
769 let included_lines = preprocess(&included_text);
770 let included_lines = preprocess(&included_text);
770 let mut included_parser = Parser {
771 let mut included_parser = Parser {
771 lines: included_lines,
772 lines: included_lines,
@@ -773,6 +774,12 @@ impl Parser {
773 };
774 };
774 let nodes = included_parser.parse_children(0, ctx);
775 let nodes = included_parser.parse_children(0, ctx);
775
776
777+ // Annotate new diagnostics with include chain
778+ let include_chain = format_include_chain(&ctx.include_stack);
779+ for d in &mut ctx.diagnostics[diag_count_before..] {
780+ d.message = format!("{}\n in {}", d.message, include_chain);
781+ }
782+
776 ctx.base_path = saved_base;
783 ctx.base_path = saved_base;
777 ctx.include_stack.pop();
784 ctx.include_stack.pop();
778 return Ok(Some(nodes));
785 return Ok(Some(nodes));
@@ -900,6 +907,8 @@ impl Parser {
900 let saved_base = ctx.base_path.clone();
907 let saved_base = ctx.base_path.clone();
901 ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
908 ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
902
909
910+ let diag_count_before = ctx.diagnostics.len();
911+
903 if let Some(ref prefix) = alias {
912 if let Some(ref prefix) = alias {
904 // Parse into a temporary context, then copy definitions with prefix
913 // Parse into a temporary context, then copy definitions with prefix
905 let saved_fns = ctx.functions.clone();
914 let saved_fns = ctx.functions.clone();
@@ -962,6 +971,12 @@ impl Parser {
962 let _discarded_nodes = imported_parser.parse_children(0, ctx);
971 let _discarded_nodes = imported_parser.parse_children(0, ctx);
963 }
972 }
964
973
974+ // Annotate new diagnostics with import chain
975+ let import_chain = format_include_chain(&ctx.include_stack);
976+ for d in &mut ctx.diagnostics[diag_count_before..] {
977+ d.message = format!("{}\n in {}", d.message, import_chain);
978+ }
979+
965 ctx.base_path = saved_base;
980 ctx.base_path = saved_base;
966 ctx.include_stack.pop();
981 ctx.include_stack.pop();
967 ctx.import_stack.pop();
982 ctx.import_stack.pop();
@@ -1220,8 +1235,8 @@ impl Parser {
1220 }
1235 }
1221 };
1236 };
1222
1237
1223− match parse_json(&json_text) {
1238+ match parse_json_with_error(&json_text) {
1224− Some(json) => {
1239+ Ok(json) => {
1225 if prefix.is_empty() {
1240 if prefix.is_empty() {
1226 // No prefix: top-level object keys become variables directly
1241 // No prefix: top-level object keys become variables directly
1227 if let JsonValue::Object(pairs) = &json {
1242 if let JsonValue::Object(pairs) = &json {
@@ -1248,10 +1263,10 @@ impl Parser {
1248 }
1263 }
1249 }
1264 }
1250 }
1265 }
1251− None => {
1266+ Err(detail) => {
1252 ctx.diagnostics.push(Diagnostic {
1267 ctx.diagnostics.push(Diagnostic {
1253 line: line_num,
1268 line: line_num,
1254− message: format!("invalid JSON in '{}'", filename),
1269+ message: format!("invalid JSON in '{}': {}", filename, detail),
1255 severity: Severity::Error,
1270 severity: Severity::Error,
1256 source_line: Some(content.clone()),
1271 source_line: Some(content.clone()),
1257 });
1272 });
@@ -2988,6 +3003,15 @@ fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseErro
2988 }
3003 }
2989}
3004}
2990
3005
3006+/// Format include/import stack as a readable chain for error messages.
3007+fn format_include_chain(stack: &[PathBuf]) -> String {
3008+ stack
3009+ .iter()
3010+ .map(|p| p.file_name().unwrap_or_default().to_string_lossy().to_string())
3011+ .collect::<Vec<_>>()
3012+ .join(" → ")
3013+}
3014+
2991fn levenshtein(a: &str, b: &str) -> usize {
3015fn levenshtein(a: &str, b: &str) -> usize {
2992 let a: Vec<char> = a.chars().collect();
3016 let a: Vec<char> = a.chars().collect();
2993 let b: Vec<char> = b.chars().collect();
3017 let b: Vec<char> = b.chars().collect();
@@ -4521,6 +4545,49 @@ fn parse_json(input: &str) -> Option<JsonValue> {
4521 Some(val)
4545 Some(val)
4522}
4546}
4523
4547
4548+/// Parse JSON with an error message indicating where parsing failed.
4549+fn parse_json_with_error(input: &str) -> Result<JsonValue, String> {
4550+ let trimmed = input.trim();
4551+ if trimmed.is_empty() {
4552+ return Err("empty input".to_string());
4553+ }
4554+ let chars: Vec<char> = trimmed.chars().collect();
4555+ match parse_json_value(&chars, 0) {
4556+ Some((val, _)) => Ok(val),
4557+ None => {
4558+ // Find approximate error position by parsing as far as possible
4559+ let mut deepest = 0usize;
4560+ fn probe(chars: &[char], pos: usize, deepest: &mut usize) {
4561+ let mut p = pos;
4562+ while p < chars.len() {
4563+ if p > *deepest { *deepest = p; }
4564+ match chars[p] {
4565+ '{' | '[' => { p += 1; probe(chars, p, deepest); return; }
4566+ '"' => {
4567+ p += 1;
4568+ while p < chars.len() && chars[p] != '"' {
4569+ if chars[p] == '\\' { p += 1; }
4570+ p += 1;
4571+ }
4572+ if p < chars.len() { p += 1; }
4573+ if p > *deepest { *deepest = p; }
4574+ return;
4575+ }
4576+ _ => { p += 1; }
4577+ }
4578+ }
4579+ }
4580+ probe(&chars, 0, &mut deepest);
4581+ // Convert char position to line:col
4582+ let prefix: String = chars[..deepest.min(chars.len())].iter().collect();
4583+ let line = prefix.chars().filter(|&c| c == '\n').count() + 1;
4584+ let col = prefix.rfind('\n').map_or(prefix.len(), |p| prefix.len() - p - 1) + 1;
4585+ let context: String = chars[deepest.saturating_sub(20)..deepest.min(chars.len())].iter().collect();
4586+ Err(format!("at line {}:{} near \"{}\"", line, col, context.trim()))
4587+ }
4588+ }
4589+}
4590+
4524fn parse_json_value(chars: &[char], mut pos: usize) -> Option<(JsonValue, usize)> {
4591fn parse_json_value(chars: &[char], mut pos: usize) -> Option<(JsonValue, usize)> {
4525 pos = skip_ws(chars, pos);
4592 pos = skip_ws(chars, pos);
4526 if pos >= chars.len() {
4593 if pos >= chars.len() {
tests/snapshots.rs+25
@@ -3897,3 +3897,28 @@ fn auto_image_dimensions_respects_explicit() {
3897
3897
3898 let _ = std::fs::remove_dir_all(&dir);
3898 let _ = std::fs::remove_dir_all(&dir);
3899}
3899}
3900+
3901+#[test]
3902+fn snapshot_stress_large() {
3903+ snapshot_test("stress_large");
3904+}
3905+
3906+#[test]
3907+fn snapshot_stress_deeply_nested() {
3908+ snapshot_test("stress_deeply_nested");
3909+}
3910+
3911+#[test]
3912+fn perf_large_document() {
3913+ let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/snapshots");
3914+ let input = std::fs::read_to_string(dir.join("stress_large.hl")).unwrap();
3915+ let start = std::time::Instant::now();
3916+ let result = htmlang::parser::parse(&input);
3917+ let _ = htmlang::codegen::generate(&result.document);
3918+ let elapsed = start.elapsed();
3919+ assert!(
3920+ elapsed.as_millis() < 5000,
3921+ "compilation took {}ms, expected < 5000ms",
3922+ elapsed.as_millis()
3923+ );
3924+}
tests/snapshots/stress_deeply_nested.hlnew file+184
@@ -0,0 +1,184 @@
1+@page Deeply Nested Stress Test
2+
3+@let --primary #3b82f6
4+@let --border #e2e8f0
5+@let --radius 8
6+
7+@fn deep-card $title
8+ @el [background white, dark:background #1e293b, rounded $--radius, padding 16, border 1 $--border]
9+ @text [bold, size 16] $title
10+ @children
11+
12+@fn inner-panel $label
13+ @el [background #f8fafc, dark:background #0f172a, rounded 6, padding 12, margin-top 8]
14+ @text [size 13, color #64748b] $label
15+ @children
16+
17+@column [spacing 20, padding 24]
18+
19+ @el [background #f0f9ff, rounded 12, padding 20, border 1 #bae6fd]
20+ @text [bold, size 20, color #0369a1] Level 1 - App Shell
21+ @row [spacing 16, margin-top 16]
22+ @el [background white, rounded 8, padding 16, border 1 $--border, width fill]
23+ @text [bold, size 16, color #1e293b] Level 2 - Sidebar
24+ @column [spacing 8, margin-top 12]
25+ @el [background #f1f5f9, rounded 6, padding 12]
26+ @text [size 14, color #475569] Level 3 - Nav Group
27+ @column [spacing 4, margin-top 8]
28+ @el [padding 8 12, hover:background #e2e8f0, rounded 4, transition all 0.15s]
29+ @text [size 13, color #64748b] Level 4 - Nav Item
30+ @row [spacing 6, margin-top 4, align-items center]
31+ @el [width 6, height 6, rounded 50%, background #10b981]
32+ @el [padding 4 8, background #dbeafe, rounded 4]
33+ @text [size 11, color #1d4ed8] Level 5 - Badge
34+ @el [display inline-block, margin-left 4]
35+ @text [size 10, color #3b82f6] Level 6 - Badge Detail
36+ @el [margin-top 2]
37+ @text [size 9, color #93c5fd] Level 7 - Sub Detail
38+ @el [padding 2 4, background #eff6ff, rounded 2, margin-top 1]
39+ @text [size 8, color #bfdbfe] Level 8 - Deepest Badge
40+
41+ @el [padding 8 12, hover:background #e2e8f0, rounded 4]
42+ @text [size 13, color #64748b] Level 4 - Second Nav Item
43+ @el [margin-top 4, padding 6, background #f0fdf4, rounded 4]
44+ @text [size 12, color #166534] Level 5 - Status
45+ @row [spacing 4, margin-top 2]
46+ @el [padding 2 6, background #bbf7d0, rounded 2]
47+ @text [size 10, color #15803d] Level 6 - Online
48+ @el [padding 2 6, background #fef3c7, rounded 2]
49+ @text [size 10, color #92400e] Level 6 - Away
50+
51+ @el [background #f1f5f9, rounded 6, padding 12]
52+ @text [size 14, color #475569] Level 3 - Second Group
53+ @column [spacing 4, margin-top 8]
54+ @each $item in Settings,Profile,Security
55+ @el [padding 8 12, hover:background #e2e8f0, rounded 4]
56+ @text [size 13, color #64748b] $item
57+
58+ @el [background white, rounded 8, padding 16, border 1 $--border, width fill]
59+ @text [bold, size 16, color #1e293b] Level 2 - Main Content
60+ @column [spacing 12, margin-top 12]
61+ @deep-card [title Level 3 - Dashboard Card]
62+ @inner-panel [label Level 4 - Chart Area]
63+ @row [spacing 8, margin-top 8]
64+ @el [width fill, background #eff6ff, rounded 4, padding 8]
65+ @text [size 12, color #1d4ed8, bold] Level 5 - Metric A
66+ @el [margin-top 4, height 60, background #dbeafe, rounded 4, padding 8]
67+ @text [size 20, bold, color #1e40af] Level 6 - Value
68+ @el [margin-top 2]
69+ @text [size 10, color #3b82f6] Level 7 - Trend
70+ @row [spacing 2, margin-top 2]
71+ @el [width 4, height 12, background #93c5fd, rounded 1]
72+ @el [width 4, height 18, background #60a5fa, rounded 1]
73+ @el [width 4, height 8, background #93c5fd, rounded 1]
74+ @el [width 4, height 22, background #3b82f6, rounded 1]
75+ @el [width 2, height 2, rounded 50%, background white, margin-top 1]
76+ @text [size 6, color white] Level 8 - Spark Dot
77+ @el [width fill, background #f0fdf4, rounded 4, padding 8]
78+ @text [size 12, color #166534, bold] Level 5 - Metric B
79+ @el [margin-top 4, height 60, background #dcfce7, rounded 4, padding 8]
80+ @text [size 20, bold, color #166534] Level 6 - Value
81+ @el [margin-top 2]
82+ @text [size 10, color #16a34a] Level 7 - Change
83+ @el [padding 1 4, background #bbf7d0, rounded 2, display inline-block]
84+ @text [size 8, color #15803d] Level 8 - Delta
85+
86+ @deep-card [title Level 3 - Form Card]
87+ @inner-panel [label Level 4 - Form Section]
88+ @column [spacing 8, margin-top 8]
89+ @el [padding 8, border 1 $--border, rounded 4]
90+ @text [size 12, color #64748b, margin-bottom 4] Level 5 - Field Group
91+ @input [type text, placeholder Name, padding 6 10, rounded 4, border 1 $--border, width fill]
92+ @el [margin-top 4, padding 4 8, background #fef2f2, rounded 4]
93+ @text [size 11, color #991b1b] Level 6 - Validation Error
94+ @el [margin-top 2]
95+ @text [size 10, color #dc2626] Level 7 - Error Detail
96+ @el [margin-top 1, padding 2 4, background #fee2e2, rounded 2]
97+ @text [size 9, color #b91c1c] Level 8 - Error Code
98+
99+ @el [padding 8, border 1 $--border, rounded 4]
100+ @text [size 12, color #64748b, margin-bottom 4] Level 5 - Select Group
101+ @select [padding 6 10, rounded 4, border 1 $--border, width fill]
102+ @option [value a] Option A
103+ @option [value b] Option B
104+ @option [value c] Option C
105+
106+ @deep-card [title Level 3 - Nested Lists Card]
107+ @inner-panel [label Level 4 - Category Browser]
108+ @column [spacing 6, margin-top 8]
109+ @let top-categories Electronics,Clothing,Books
110+ @each $cat in $top-categories
111+ @el [padding 8, border 1 $--border, rounded 4, hover:background #f8fafc]
112+ @text [size 14, bold, color #1e293b] Level 5 - $cat
113+ @column [spacing 4, margin-top 6, margin-left 12]
114+ @el [padding 6, background #f8fafc, rounded 4]
115+ @row [spacing 8, align-items center]
116+ @el [width 8, height 8, rounded 2, background $--primary]
117+ @text [size 12, color #475569] Level 6 - Subcategory
118+ @el [margin-top 4, margin-left 16]
119+ @text [size 11, color #94a3b8] Level 7 - Item
120+ @el [margin-left 12, margin-top 2, padding 2 6, background #eff6ff, rounded 2]
121+ @text [size 10, color #3b82f6] Level 8 - Variant
122+
123+ @el [background #fefce8, rounded 12, padding 20, border 1 #fde68a, margin-top 20]
124+ @text [bold, size 20, color #854d0e] Level 1 - Settings Panel
125+ @column [spacing 12, margin-top 16]
126+ @el [background white, rounded 8, padding 16, border 1 $--border]
127+ @text [bold, size 16, color #1e293b] Level 2 - Account Settings
128+ @column [spacing 8, margin-top 12]
129+ @el [background #f8fafc, rounded 6, padding 12]
130+ @text [size 14, color #475569] Level 3 - Profile Section
131+ @el [margin-top 8, padding 10, border 1 $--border, rounded 6]
132+ @text [size 13, color #1e293b] Level 4 - Avatar Upload
133+ @el [margin-top 6, width 80, height 80, rounded 50%, background #e2e8f0, display flex, align-items center, justify-content center]
134+ @text [size 24, color #94a3b8, bold] Level 5 - Initials
135+ @el [position absolute, bottom 0, right 0, width 24, height 24, rounded 50%, background $--primary, display flex, align-items center, justify-content center]
136+ @text [size 10, color white] Level 6 - Edit Icon
137+ @el [margin-top 1]
138+ @text [size 8, color #bfdbfe] Level 7 - Tooltip
139+ @el [padding 1 3, background #1e3a5f, rounded 2, margin-top 1]
140+ @text [size 7, color white] Level 8 - Tooltip Arrow
141+
142+ @el [background #f8fafc, rounded 6, padding 12]
143+ @text [size 14, color #475569] Level 3 - Preferences
144+ @column [spacing 6, margin-top 8]
145+ @each $pref in Language,Timezone,Currency
146+ @row [spacing 8, align-items center, padding 6 8, border 1 $--border, rounded 4]
147+ @text [size 13, color #1e293b, width fill] Level 4 - $pref
148+ @el [padding 4 12, background #f1f5f9, rounded 4]
149+ @text [size 12, color #64748b] Level 5 - Current Value
150+
151+ @el [background white, rounded 8, padding 16, border 1 $--border]
152+ @text [bold, size 16, color #1e293b] Level 2 - Notification Preferences
153+ @column [spacing 6, margin-top 12]
154+ @let channels Email,Push,SMS,Slack
155+ @each $channel in $channels
156+ @row [spacing 8, align-items center, padding 8, border-bottom 1 $--border]
157+ @text [size 14, color #1e293b, width fill] $channel Notifications
158+ @el [width 36, height 20, rounded 10, background $--primary, padding 2]
159+ @el [width 16, height 16, rounded 50%, background white, margin-left 16]
160+
161+ @el [background #fdf2f8, rounded 12, padding 20, border 1 #fbcfe8, margin-top 20]
162+ @text [bold, size 20, color #9d174d] Level 1 - Monitoring
163+ @row [spacing 16, margin-top 16]
164+ @each $service in API,Database,Cache,Queue
165+ @el [background white, rounded 8, padding 12, border 1 $--border, width fill]
166+ @text [bold, size 14, color #1e293b] Level 2 - $service
167+ @el [margin-top 8, background #f0fdf4, rounded 6, padding 8]
168+ @text [size 12, color #166534] Level 3 - Healthy
169+ @el [margin-top 4]
170+ @row [spacing 4]
171+ @el [width fill, height 4, background #86efac, rounded 2]
172+ @el [width fill, height 4, background #4ade80, rounded 2]
173+ @el [width fill, height 4, background #22c55e, rounded 2]
174+ @el [width fill, height 4, background #86efac, rounded 2]
175+ @el [margin-top 4]
176+ @text [size 11, color #16a34a] Level 4 - Uptime 99.9%
177+ @el [margin-top 2]
178+ @text [size 10, color #22c55e] Level 5 - Response 42ms
179+ @el [margin-top 2, padding 2 6, background #dcfce7, rounded 2]
180+ @text [size 9, color #15803d] Level 6 - p99 120ms
181+ @el [margin-top 1]
182+ @text [size 8, color #4ade80] Level 7 - Throughput 1.2k/s
183+ @el [padding 1 3, background #bbf7d0, rounded 1, margin-top 1]
184+ @text [size 7, color #166534] Level 8 - Peak 2.4k/s
tests/snapshots/stress_large.hlnew file+392
@@ -0,0 +1,392 @@
1+@page Stress Test Large Document
2+
3+@let --primary #3b82f6
4+@let --secondary #10b981
5+@let --accent #f59e0b
6+@let --danger #ef4444
7+@let --dark #1e293b
8+@let --light #f8fafc
9+@let --gray #64748b
10+@let --border #e2e8f0
11+@let --radius 8
12+@let --gap 16
13+@let --gap-sm 8
14+@let --gap-lg 24
15+
16+@keyframes fade-in
17+ from{opacity:0}to{opacity:1}
18+
19+@keyframes slide-down
20+ 0%{transform:translateY(-10px);opacity:0}100%{transform:translateY(0);opacity:1}
21+
22+@fn icon-button $label $color=var(--primary)
23+ @button [padding 8 16, background $color, hover:background $--dark, color white, rounded 6, border none, cursor pointer, transition all 0.2s, font-size 14]
24+ $label
25+
26+@fn badge $label $variant=blue
27+ @el [display inline-block, padding 2 8, background $variant, color white, rounded 12, font-size 12]
28+ @text $label
29+
30+@fn card $title
31+ @el [background white, dark:background #1e293b, rounded $--radius, padding $--gap, border 1 $--border, dark:border 1 #334155, hover:shadow 0 4 12 rgba(0,0,0,0.1), transition all 0.2s, animation fade-in 0.3s ease]
32+ @text [bold, size 18, color $--dark, dark:color white] $title
33+ @el [margin-top 8]
34+ @children
35+
36+@fn stat-card $label $value $change
37+ @el [background white, dark:background #1e293b, rounded $--radius, padding $--gap, border 1 $--border, dark:border 1 #334155]
38+ @text [size 14, color $--gray] $label
39+ @text [bold, size 28, color $--dark, dark:color white, margin-top 4] $value
40+ @text [size 12, color $--secondary, margin-top 2] $change
41+
42+@fn nav-link $url $label $active=false
43+ @if $active == true
44+ @link [color $--primary, bold, padding 8 12, background #eff6ff, dark:background #1e3a5f, rounded 6] $url $label
45+ @else
46+ @link [color $--gray, hover:color $--primary, padding 8 12, hover:background #f1f5f9, dark:hover:background #334155, rounded 6, transition all 0.15s] $url $label
47+
48+@fn sidebar-section $title
49+ @el [margin-bottom $--gap-lg]
50+ @text [bold, size 12, color $--gray, text-transform uppercase, letter-spacing 1, margin-bottom $--gap-sm] $title
51+ @children
52+
53+@fn avatar $initials $size=32
54+ @el [width $size, height $size, rounded 50%, background $--primary, color white, display flex, align-items center, justify-content center, font-size 14, bold]
55+ @text $initials
56+
57+@fn table-row $col1 $col2 $col3 $col4
58+ @row [spacing 0, padding 12 16, border-bottom 1 $--border, dark:border-bottom 1 #334155, hover:background #f8fafc, dark:hover:background #1e293b]
59+ @el [width 200]
60+ @text [size 14] $col1
61+ @el [width 150]
62+ @text [size 14, color $--gray] $col2
63+ @el [width 120]
64+ @text [size 14] $col3
65+ @el [width fill]
66+ @text [size 14, color $--gray] $col4
67+
68+@fn breadcrumb-item $url $label $is-last=false
69+ @if $is-last == true
70+ @text [color $--dark, dark:color white, size 14] $label
71+ @else
72+ @row [spacing 8]
73+ @link [color $--gray, hover:color $--primary, size 14] $url $label
74+ @text [color $--border, size 14] /
75+
76+@fn alert $message $type=info
77+ @if $type == info
78+ @el [background #eff6ff, border 1 #bfdbfe, rounded $--radius, padding 12 16]
79+ @text [color #1e40af, size 14] $message
80+ @else if $type == success
81+ @el [background #f0fdf4, border 1 #bbf7d0, rounded $--radius, padding 12 16]
82+ @text [color #166534, size 14] $message
83+ @else if $type == warning
84+ @el [background #fffbeb, border 1 #fde68a, rounded $--radius, padding 12 16]
85+ @text [color #92400e, size 14] $message
86+ @else
87+ @el [background #fef2f2, border 1 #fecaca, rounded $--radius, padding 12 16]
88+ @text [color #991b1b, size 14] $message
89+
90+@fn progress-bar $percent $color=var(--primary)
91+ @el [width fill, height 8, background #e2e8f0, dark:background #334155, rounded 4, overflow hidden]
92+ @el [width $percent%, height fill, background $color, rounded 4]
93+
94+@fn tag $label
95+ @el [display inline-block, padding 4 10, background #f1f5f9, dark:background #334155, color $--gray, dark:color #94a3b8, rounded 4, font-size 12, margin 2]
96+ @text $label
97+
98+@fn notification-dot
99+ @el [width 8, height 8, rounded 50%, background $--danger]
100+
101+@fn divider
102+ @el [width fill, height 1, background $--border, dark:background #334155, margin 8 0]
103+
104+@fn toggle $checked=false
105+ @if $checked == true
106+ @el [width 40, height 22, rounded 11, background $--primary, padding 2, cursor pointer, transition all 0.2s]
107+ @el [width 18, height 18, rounded 50%, background white, margin-left 18, transition all 0.2s]
108+ @else
109+ @el [width 40, height 22, rounded 11, background #cbd5e1, dark:background #475569, padding 2, cursor pointer, transition all 0.2s]
110+ @el [width 18, height 18, rounded 50%, background white, transition all 0.2s]
111+
112+@let nav-items home,products,team,blog,docs,pricing
113+@let categories all,design,development,marketing,analytics
114+@let team-members Alice,Bob,Carol,Dave,Eve,Frank,Grace,Hank
115+@let statuses active,pending,completed,archived
116+@let projects Alpha,Beta,Gamma,Delta,Epsilon
117+
118+@row [spacing 0, min-height 100vh]
119+
120+ @column [width 260, background white, dark:background #0f172a, border-right 1 $--border, dark:border-right 1 #1e293b, padding $--gap, spacing $--gap-sm]
121+
122+ @row [spacing 12, padding 8, align-items center]
123+ @avatar [initials HL, size 36]
124+ @column [spacing 2]
125+ @text [bold, size 16, color $--dark, dark:color white] htmlang
126+ @text [size 12, color $--gray] Dashboard v2.0
127+
128+ @divider
129+
130+ @sidebar-section [title Navigation]
131+ @column [spacing 2]
132+ @nav-link [url /dashboard, label Dashboard, active true]
133+ @nav-link [url /analytics, label Analytics]
134+ @nav-link [url /projects, label Projects]
135+ @nav-link [url /team, label Team]
136+ @nav-link [url /messages, label Messages]
137+ @nav-link [url /calendar, label Calendar]
138+ @nav-link [url /documents, label Documents]
139+ @nav-link [url /settings, label Settings]
140+
141+ @sidebar-section [title Favorites]
142+ @column [spacing 2]
143+ @nav-link [url /project/alpha, label Project Alpha]
144+ @nav-link [url /project/beta, label Project Beta]
145+ @nav-link [url /reports/q4, label Q4 Reports]
146+
147+ @sidebar-section [title Shortcuts]
148+ @column [spacing 4]
149+ @row [spacing 8, align-items center]
150+ @el [width 6, height 6, rounded 50%, background $--secondary]
151+ @text [size 13, color $--gray] Online (12)
152+ @row [spacing 8, align-items center]
153+ @el [width 6, height 6, rounded 50%, background $--accent]
154+ @text [size 13, color $--gray] Away (3)
155+ @row [spacing 8, align-items center]
156+ @el [width 6, height 6, rounded 50%, background #cbd5e1]
157+ @text [size 13, color $--gray] Offline (5)
158+
159+ @el [margin-top auto, padding 12, background #f1f5f9, dark:background #1e293b, rounded $--radius]
160+ @row [spacing 8, align-items center]
161+ @avatar [initials JD, size 28]
162+ @column [spacing 0]
163+ @text [size 13, bold, color $--dark, dark:color white] Jane Doe
164+ @text [size 11, color $--gray] jane@example.com
165+
166+ @column [width fill, background $--light, dark:background #0f172a]
167+
168+ @header [padding 12 24, background white, dark:background #0f172a, border-bottom 1 $--border, dark:border-bottom 1 #1e293b]
169+ @row [spacing 16, align-items center]
170+ @column [spacing 2]
171+ @row [spacing 8]
172+ @breadcrumb-item [url /, label Home]
173+ @breadcrumb-item [url /dashboard, label Dashboard, is-last true]
174+ @text [bold, size 22, color $--dark, dark:color white] Dashboard Overview
175+
176+ @el [width fill]
177+
178+ @row [spacing 12, align-items center]
179+ @input [type search, placeholder Search..., padding 8 12, rounded 6, border 1 $--border, dark:border 1 #334155, dark:background #1e293b, dark:color white, width 240, focus:border 1 $--primary, transition all 0.2s]
180+ @el [position relative]
181+ @icon-button [label Alerts]
182+ @el [position absolute, top -2, right -2]
183+ @notification-dot
184+ @icon-button [label Settings, color $--gray]
185+
186+ @el [padding $--gap-lg, overflow-y auto]
187+
188+ @alert [message Welcome back! You have 3 new notifications and 2 pending tasks., type info]
189+
190+ @row [spacing $--gap, margin-top $--gap-lg, sm:flex-direction column, lg:flex-direction row]
191+ @stat-card [label Total Revenue, value $48,295, change +12.5% from last month]
192+ @stat-card [label Active Users, value 2,847, change +8.2% from last week]
193+ @stat-card [label Conversion Rate, value 3.24%, change +0.4% from yesterday]
194+ @stat-card [label Open Tickets, value 142, change -5.1% from last month]
195+
196+ @row [spacing $--gap, margin-top $--gap-lg]
197+
198+ @card [title Revenue Overview]
199+ @el [height 200, background #f8fafc, dark:background #0f172a, rounded 6, margin-top 12, display flex, align-items flex-end, padding 16, gap 8]
200+ @each $height in 40,65,45,80,55,70,90,60,75,85,50,95
201+ @el [width fill, height $height%, background $--primary, hover:background #2563eb, rounded 4 4 0 0, transition all 0.2s, min-width 20]
202+
203+ @card [title Activity Feed]
204+ @column [spacing 12, margin-top 12]
205+ @each $member in $team-members
206+ @row [spacing 12, align-items center, padding 8, hover:background #f8fafc, dark:hover:background #1e293b, rounded 6]
207+ @avatar [initials $member, size 32]
208+ @column [spacing 2, width fill]
209+ @text [size 14, bold, color $--dark, dark:color white] $member
210+ @text [size 12, color $--gray] Updated project files
211+ @text [size 11, color $--gray] 2h ago
212+
213+ @row [spacing $--gap, margin-top $--gap-lg, sm:flex-direction column, lg:flex-direction row]
214+
215+ @column [width fill, spacing $--gap]
216+
217+ @card [title Project Status]
218+ @column [spacing 0, margin-top 12]
219+ @row [spacing 0, padding 8 16, background #f8fafc, dark:background #0f172a, border-bottom 1 $--border]
220+ @el [width 200]
221+ @text [bold, size 12, color $--gray, text-transform uppercase] Project
222+ @el [width 150]
223+ @text [bold, size 12, color $--gray, text-transform uppercase] Status
224+ @el [width 120]
225+ @text [bold, size 12, color $--gray, text-transform uppercase] Progress
226+ @el [width fill]
227+ @text [bold, size 12, color $--gray, text-transform uppercase] Due Date
228+
229+ @table-row [col1 Project Alpha, col2 In Progress, col3 75%, col4 Dec 15 2025]
230+ @table-row [col1 Project Beta, col2 Review, col3 90%, col4 Nov 30 2025]
231+ @table-row [col1 Project Gamma, col2 Planning, col3 20%, col4 Jan 20 2026]
232+ @table-row [col1 Project Delta, col2 Completed, col3 100%, col4 Oct 10 2025]
233+ @table-row [col1 Project Epsilon, col2 On Hold, col3 45%, col4 Feb 28 2026]
234+
235+ @card [title Progress Breakdown]
236+ @column [spacing 12, margin-top 12]
237+ @each $project in $projects
238+ @column [spacing 4]
239+ @row [spacing 0]
240+ @text [size 13, color $--dark, dark:color white, width fill] $project
241+ @text [size 13, color $--gray] 67%
242+ @progress-bar [percent 67, color var(--primary)]
243+
244+ @card [title Recent Activity Log]
245+ @column [spacing 8, margin-top 12]
246+ @let log-entries Deployed v2.3.1 to production,Merged feature/auth-redesign,Closed issue #482: Login timeout,Updated CI/CD pipeline config,Added unit tests for payment module,Reviewed PR #156: API refactor,Fixed memory leak in worker pool,Released hotfix 2.3.2
247+ @each $entry, $i in $log-entries
248+ @row [spacing 12, padding 8 12, align-items center]
249+ @el [width 24, height 24, rounded 50%, background #eff6ff, dark:background #1e3a5f, display flex, align-items center, justify-content center]
250+ @text [size 10, color $--primary, bold] $i
251+ @text [size 14, color $--dark, dark:color white, width fill] $entry
252+ @text [size 11, color $--gray] just now
253+
254+ @column [width 340, sm:width fill, spacing $--gap]
255+
256+ @card [title Quick Actions]
257+ @column [spacing 8, margin-top 12]
258+ @icon-button [label New Project, color var(--primary)]
259+ @icon-button [label Create Report, color var(--secondary)]
260+ @icon-button [label Invite Member, color var(--accent)]
261+ @icon-button [label View Backlog, color var(--gray)]
262+
263+ @card [title Team Members]
264+ @column [spacing 8, margin-top 12]
265+ @each $member in $team-members
266+ @row [spacing 10, align-items center, padding 6 8, hover:background #f1f5f9, dark:hover:background #1e293b, rounded 6, transition all 0.15s]
267+ @avatar [initials $member, size 28]
268+ @column [spacing 0, width fill]
269+ @text [size 13, bold, color $--dark, dark:color white] $member
270+ @text [size 11, color $--gray] Developer
271+ @el [width 8, height 8, rounded 50%, background $--secondary]
272+
273+ @card [title Tags]
274+ @row [spacing 0, wrap, margin-top 8]
275+ @tag [label frontend]
276+ @tag [label backend]
277+ @tag [label devops]
278+ @tag [label design]
279+ @tag [label mobile]
280+ @tag [label security]
281+ @tag [label performance]
282+ @tag [label testing]
283+ @tag [label docs]
284+ @tag [label infra]
285+ @tag [label ux]
286+ @tag [label api]
287+
288+ @card [title Notifications]
289+ @column [spacing 0, margin-top 12]
290+ @let notifications New deployment completed,PR review requested,Build failed on staging,Meeting in 30 minutes,Subscription renewal due
291+ @each $note in $notifications
292+ @row [spacing 10, padding 10 12, border-bottom 1 $--border, dark:border-bottom 1 #334155, align-items center]
293+ @notification-dot
294+ @text [size 13, color $--dark, dark:color white, width fill] $note
295+ @text [size 11, color $--gray] now
296+
297+ @card [title Settings]
298+ @column [spacing 12, margin-top 12]
299+ @row [spacing 0, align-items center]
300+ @text [size 14, color $--dark, dark:color white, width fill] Dark Mode
301+ @toggle [checked true]
302+ @row [spacing 0, align-items center]
303+ @text [size 14, color $--dark, dark:color white, width fill] Notifications
304+ @toggle [checked true]
305+ @row [spacing 0, align-items center]
306+ @text [size 14, color $--dark, dark:color white, width fill] Auto-save
307+ @toggle [checked false]
308+ @row [spacing 0, align-items center]
309+ @text [size 14, color $--dark, dark:color white, width fill] Compact View
310+ @toggle [checked false]
311+
312+ @el [margin-top $--gap-lg]
313+ @card [title Data Table]
314+ @column [spacing 0, margin-top 12]
315+ @row [spacing 16, margin-bottom 12, align-items center]
316+ @input [type search, placeholder Filter results..., padding 8 12, rounded 6, border 1 $--border, width 240]
317+ @el [width fill]
318+ @row [spacing 8]
319+ @each $cat in $categories
320+ @button [padding 6 12, rounded 4, border 1 $--border, background white, dark:background #1e293b, hover:background #f1f5f9, color $--gray, hover:color $--dark, font-size 13, cursor pointer, transition all 0.15s] $cat
321+
322+ @row [spacing 0, padding 8 16, background #f8fafc, dark:background #0f172a, border-bottom 2 $--border, rounded 6 6 0 0]
323+ @el [width 60]
324+ @text [bold, size 12, color $--gray, text-transform uppercase] ID
325+ @el [width 200]
326+ @text [bold, size 12, color $--gray, text-transform uppercase] Name
327+ @el [width 150]
328+ @text [bold, size 12, color $--gray, text-transform uppercase] Category
329+ @el [width 120]
330+ @text [bold, size 12, color $--gray, text-transform uppercase] Status
331+ @el [width fill]
332+ @text [bold, size 12, color $--gray, text-transform uppercase] Updated
333+
334+ @each $status in $statuses
335+ @each $project in $projects
336+ @row [spacing 0, padding 10 16, border-bottom 1 $--border, dark:border-bottom 1 #334155, hover:background #f8fafc, dark:hover:background #1e293b, cursor pointer, transition all 0.1s]
337+ @el [width 60]
338+ @text [size 13, color $--gray] #42
339+ @el [width 200]
340+ @row [spacing 8, align-items center]
341+ @avatar [initials $project, size 24]
342+ @text [size 14, color $--dark, dark:color white] $project
343+ @el [width 150]
344+ @text [size 13, color $--gray] $status
345+ @el [width 120]
346+ @badge [label $status, variant var(--secondary)]
347+ @el [width fill]
348+ @text [size 13, color $--gray] 2 hours ago
349+
350+ @row [spacing 16, padding 12 16, align-items center, border-top 1 $--border]
351+ @text [size 13, color $--gray] Showing 1-20 of 142 results
352+ @el [width fill]
353+ @row [spacing 4]
354+ @button [padding 6 10, rounded 4, border 1 $--border, background white, dark:background #1e293b, color $--gray, font-size 13, cursor pointer] Prev
355+ @button [padding 6 10, rounded 4, border 1 $--primary, background $--primary, color white, font-size 13, cursor pointer] 1
356+ @button [padding 6 10, rounded 4, border 1 $--border, background white, dark:background #1e293b, color $--gray, font-size 13, cursor pointer] 2
357+ @button [padding 6 10, rounded 4, border 1 $--border, background white, dark:background #1e293b, color $--gray, font-size 13, cursor pointer] 3
358+ @button [padding 6 10, rounded 4, border 1 $--border, background white, dark:background #1e293b, color $--gray, font-size 13, cursor pointer] Next
359+
360+ @footer [padding $--gap-lg 24, background white, dark:background #0f172a, border-top 1 $--border, dark:border-top 1 #1e293b]
361+ @row [spacing $--gap-lg, sm:flex-direction column, lg:flex-direction row]
362+ @column [spacing 8, width fill]
363+ @text [bold, size 14, color $--dark, dark:color white] htmlang Dashboard
364+ @text [size 12, color $--gray] Built with htmlang - a declarative HTML language
365+ @text [size 11, color $--gray] Copyright 2025 htmlang contributors
366+
367+ @column [spacing 4]
368+ @text [bold, size 12, color $--gray, text-transform uppercase, margin-bottom 4] Product
369+ @link [color $--gray, hover:color $--primary, size 13] /features Features
370+ @link [color $--gray, hover:color $--primary, size 13] /pricing Pricing
371+ @link [color $--gray, hover:color $--primary, size 13] /changelog Changelog
372+ @link [color $--gray, hover:color $--primary, size 13] /roadmap Roadmap
373+
374+ @column [spacing 4]
375+ @text [bold, size 12, color $--gray, text-transform uppercase, margin-bottom 4] Resources
376+ @link [color $--gray, hover:color $--primary, size 13] /docs Documentation
377+ @link [color $--gray, hover:color $--primary, size 13] /api API Reference
378+ @link [color $--gray, hover:color $--primary, size 13] /guides Guides
379+ @link [color $--gray, hover:color $--primary, size 13] /examples Examples
380+
381+ @column [spacing 4]
382+ @text [bold, size 12, color $--gray, text-transform uppercase, margin-bottom 4] Company
383+ @link [color $--gray, hover:color $--primary, size 13] /about About
384+ @link [color $--gray, hover:color $--primary, size 13] /blog Blog
385+ @link [color $--gray, hover:color $--primary, size 13] /careers Careers
386+ @link [color $--gray, hover:color $--primary, size 13] /contact Contact
387+
388+ @column [spacing 4]
389+ @text [bold, size 12, color $--gray, text-transform uppercase, margin-bottom 4] Legal
390+ @link [color $--gray, hover:color $--primary, size 13] /privacy Privacy Policy
391+ @link [color $--gray, hover:color $--primary, size 13] /terms Terms of Service
392+ @link [color $--gray, hover:color $--primary, size 13] /cookies Cookie Policy