@@ -59,6 +59,8 @@ fn compile(input_path: &str, cfg: &CompileConfig) -> (bool, Vec<PathBuf>) {
59 let severity = match d.severity {
59 let severity = match d.severity {
60 htmlang::parser::Severity::Error => "error",
60 htmlang::parser::Severity::Error => "error",
61 htmlang::parser::Severity::Warning => "warning",
61 htmlang::parser::Severity::Warning => "warning",
62+ htmlang::parser::Severity::Info => "info",
63+ htmlang::parser::Severity::Help => "help",
63 collected.push(DiagnosticJson {
65 collected.push(DiagnosticJson {
64 file: input_path.to_string(),
66 file: input_path.to_string(),
@@ -73,6 +75,8 @@ fn compile(input_path: &str, cfg: &CompileConfig) -> (bool, Vec<PathBuf>) {
73 let prefix = match d.severity {
75 let prefix = match d.severity {
74 htmlang::parser::Severity::Error => "error",
76 htmlang::parser::Severity::Error => "error",
75 htmlang::parser::Severity::Warning => "warning",
77 htmlang::parser::Severity::Warning => "warning",
78+ htmlang::parser::Severity::Info => "info",
79+ htmlang::parser::Severity::Help => "help",
77 if let Some(col) = d.column {
81 if let Some(col) = d.column {
78 eprintln!("{}: line {}:{}: {}", prefix, d.line, col, d.message);
82 eprintln!("{}: line {}:{}: {}", prefix, d.line, col, d.message);
@@ -312,15 +316,31 @@ fn generate_error_overlay(diagnostics: &[htmlang::parser::Diagnostic], file: &st
312 let prefix = match d.severity {
316 let prefix = match d.severity {
313 htmlang::parser::Severity::Error => "error",
317 htmlang::parser::Severity::Error => "error",
314 htmlang::parser::Severity::Warning => "warning",
318 htmlang::parser::Severity::Warning => "warning",
319+ htmlang::parser::Severity::Info => "info",
320+ htmlang::parser::Severity::Help => "help",
316 let escaped = d.message
322 let escaped = d.message
317 .replace('&', "&")
323 .replace('&', "&")
318 .replace('<', "<")
324 .replace('<', "<")
319 .replace('>', ">");
325 .replace('>', ">");
326+ let location = match d.column {
327+ Some(col) => format!("line {}:{}", d.line, col),
328+ None => format!("line {}", d.line),
320 errors.push_str(&format!(
330 errors.push_str(&format!(
321− "<div class=\"entry\"><span class=\"badge {}\">{}</span> line {}: {}</div>",
331+ "<div class=\"entry\"><span class=\"badge {}\">{}</span> <span class=\"loc\">{}</span> {}",
322− prefix, prefix, d.line, escaped
332+ prefix, prefix, location, escaped
334+ if let Some(ref src) = d.source_line {
335+ let src_esc = src.replace('&', "&").replace('<', "<").replace('>', ">");
336+ errors.push_str(&format!("<pre class=\"src\">{}</pre>", src_esc));
337+ if let Some(col) = d.column {
338+ // Render a caret indicator underneath the source line.
339+ let caret = format!("{}^", " ".repeat(col));
340+ errors.push_str(&format!("<pre class=\"caret\">{}</pre>", caret));
343+ errors.push_str("</div>");
326 r#"<!DOCTYPE html><html><head><meta charset="utf-8"><title>Build Error</title><style>
346 r#"<!DOCTYPE html><html><head><meta charset="utf-8"><title>Build Error</title><style>
@@ -328,10 +348,15 @@ fn generate_error_overlay(diagnostics: &[htmlang::parser::Diagnostic], file: &st
328body{{background:#1a1a2e;color:#eee;font-family:ui-monospace,monospace;padding:2rem}}
348body{{background:#1a1a2e;color:#eee;font-family:ui-monospace,monospace;padding:2rem}}
329h1{{color:#ff6b6b;margin-bottom:1rem;font-size:1.5rem}}
349h1{{color:#ff6b6b;margin-bottom:1rem;font-size:1.5rem}}
330.file{{color:#888;margin-bottom:1.5rem;font-size:0.9rem}}
350.file{{color:#888;margin-bottom:1.5rem;font-size:0.9rem}}
331−.entry{{padding:0.5rem 0;border-bottom:1px solid #333}}
351+.entry{{padding:0.75rem 0;border-bottom:1px solid #333}}
352+.loc{{color:#9ca3af;margin-right:6px}}
332.badge{{display:inline-block;padding:2px 8px;border-radius:4px;font-size:0.8rem;margin-right:8px}}
353.badge{{display:inline-block;padding:2px 8px;border-radius:4px;font-size:0.8rem;margin-right:8px}}
333.badge.error{{background:#c0392b;color:white}}
354.badge.error{{background:#c0392b;color:white}}
334.badge.warning{{background:#f39c12;color:white}}
355.badge.warning{{background:#f39c12;color:white}}
356+.badge.info{{background:#2563eb;color:white}}
357+.badge.help{{background:#16a34a;color:white}}
358+.src{{margin-top:0.5rem;padding:0.4rem 0.6rem;background:#0f0f1e;border-radius:4px;color:#ddd;white-space:pre-wrap}}
359+.caret{{margin:0;padding:0 0.6rem;color:#ff6b6b;white-space:pre}}
335</style></head><body>
360</style></head><body>
336<h1>Build Error</h1>
361<h1>Build Error</h1>
337<div class="file">{file}</div>
362<div class="file">{file}</div>
@@ -594,6 +619,8 @@ fn lint_file(path: &str) -> Vec<String> {
594 let prefix = match d.severity {
619 let prefix = match d.severity {
595 htmlang::parser::Severity::Error => "error",
620 htmlang::parser::Severity::Error => "error",
596 htmlang::parser::Severity::Warning => "warning",
621 htmlang::parser::Severity::Warning => "warning",
622+ htmlang::parser::Severity::Info => "info",
623+ htmlang::parser::Severity::Help => "help",
598 warnings.push(format!("{}:{}:{}: {}", path, d.line, prefix, d.message));
625 warnings.push(format!("{}:{}:{}: {}", path, d.line, prefix, d.message));
@@ -1578,6 +1605,9 @@ fn main() {
1578 let mut serve_target = None;
1605 let mut serve_target = None;
1579 let mut serve_port: u16 = 3000;
1606 let mut serve_port: u16 = 3000;
1580 let mut serve_open = false;
1607 let mut serve_open = false;
1608+ let mut serve_https = false;
1609+ let mut cert_path: Option<String> = None;
1610+ let mut key_path: Option<String> = None;
1581 let mut _proxy_routes: Vec<(String, String)> = Vec::new();
1611 let mut _proxy_routes: Vec<(String, String)> = Vec::new();
1582 let mut si = 2;
1612 let mut si = 2;
1583 while si < args.len() {
1613 while si < args.len() {
@@ -1587,6 +1617,15 @@ fn main() {
1587 serve_port = args.get(si).and_then(|p| p.parse().ok()).unwrap_or(3000);
1617 serve_port = args.get(si).and_then(|p| p.parse().ok()).unwrap_or(3000);
1589 "--open" => serve_open = true,
1619 "--open" => serve_open = true,
1620+ "--https" => serve_https = true,
1623+ cert_path = args.get(si).cloned();
1627+ key_path = args.get(si).cloned();
1590 "--proxy" => {
1629 "--proxy" => {
1591 // --proxy /api http://localhost:3001
1630 // --proxy /api http://localhost:3001
1592 si += 1;
1631 si += 1;
@@ -1612,6 +1651,32 @@ fn main() {
1612 let config = load_config(target_path);
1651 let config = load_config(target_path);
1613 let effective_port = if serve_port != 3000 { serve_port } else { config.port };
1652 let effective_port = if serve_port != 3000 { serve_port } else { config.port };
1654+ // Resolve optional TLS config. Without both --cert and --key, --https is
1655+ // an error — we intentionally do not generate self-signed certificates
1656+ // to avoid surprising the user with unpinned trust anchors.
1657+ let tls_config = if serve_https {
1658+ let cert = cert_path.clone().unwrap_or_else(|| {
1659+ eprintln!("error: --https requires --cert <path> and --key <path>");
1660+ eprintln!(" generate a dev cert with: mkcert localhost 127.0.0.1");
1663+ let key = key_path.clone().unwrap_or_else(|| {
1664+ eprintln!("error: --https requires --cert <path> and --key <path>");
1667+ match htmlang::serve::load_tls_config(Path::new(&cert), Path::new(&key)) {
1668+ Ok(cfg) => Some(cfg),
1670+ eprintln!("error: failed to load TLS config: {}", e);
1677+ let scheme = if tls_config.is_some() { "https" } else { "http" };
1678+ let _ = scheme; // announced by watch_loop / open_in_browser downstream
1615 // Do initial compile
1680 // Do initial compile
1616 if target_path.is_dir() {
1681 if target_path.is_dir() {
1617 let hl_files = collect_hl_files_recursive(target_path);
1682 let hl_files = collect_hl_files_recursive(target_path);
@@ -1633,9 +1698,13 @@ fn main() {
1633 let serve_dir = config.output.as_ref().map(|o| PathBuf::from(o)).unwrap_or_else(|| target_path.to_path_buf());
1698 let serve_dir = config.output.as_ref().map(|o| PathBuf::from(o)).unwrap_or_else(|| target_path.to_path_buf());
1634 let index_path = serve_dir.join("index.html");
1699 let index_path = serve_dir.join("index.html");
1635 let server_tx = tx.clone();
1700 let server_tx = tx.clone();
1701+ let tls_for_thread = tls_config;
1636 std::thread::spawn(move || {
1702 std::thread::spawn(move || {
1637 let rt = tokio::runtime::Runtime::new().expect("failed to create runtime");
1703 let rt = tokio::runtime::Runtime::new().expect("failed to create runtime");
1704+ match tls_for_thread {
1705+ Some(tls) => rt.block_on(htmlang::serve::run_https(effective_port, index_path, server_tx, tls)),
1638− rt.block_on(htmlang::serve::run(effective_port, index_path, server_tx));
1706+ None => rt.block_on(htmlang::serve::run(effective_port, index_path, server_tx)),
1640 if serve_open { open_in_browser(effective_port); }
1709 if serve_open { open_in_browser(effective_port); }
1641 watch_loop(target_path, &hl_files, &[], true, true, Some(tx), effective_port, config.debounce_ms);
1710 watch_loop(target_path, &hl_files, &[], true, true, Some(tx), effective_port, config.debounce_ms);
@@ -1647,9 +1716,13 @@ fn main() {
1647 let (tx, _) = tokio::sync::broadcast::channel::<()>(16);
1716 let (tx, _) = tokio::sync::broadcast::channel::<()>(16);
1648 let out_path = Path::new(&target).with_extension("html");
1717 let out_path = Path::new(&target).with_extension("html");
1649 let server_tx = tx.clone();
1718 let server_tx = tx.clone();
1719+ let tls_for_thread = tls_config;
1650 std::thread::spawn(move || {
1720 std::thread::spawn(move || {
1651 let rt = tokio::runtime::Runtime::new().expect("failed to create runtime");
1721 let rt = tokio::runtime::Runtime::new().expect("failed to create runtime");
1722+ match tls_for_thread {
1723+ Some(tls) => rt.block_on(htmlang::serve::run_https(effective_port, out_path, server_tx, tls)),
1652− rt.block_on(htmlang::serve::run(effective_port, out_path, server_tx));
1724+ None => rt.block_on(htmlang::serve::run(effective_port, out_path, server_tx)),
1654 if serve_open { open_in_browser(effective_port); }
1727 if serve_open { open_in_browser(effective_port); }
1655 let files = vec![PathBuf::from(&target)];
1728 let files = vec![PathBuf::from(&target)];
@@ -1748,6 +1821,8 @@ fn main() {
1748 let prefix = match d.severity {
1821 let prefix = match d.severity {
1749 htmlang::parser::Severity::Error => "error",
1822 htmlang::parser::Severity::Error => "error",
1750 htmlang::parser::Severity::Warning => "warning",
1823 htmlang::parser::Severity::Warning => "warning",
1824+ htmlang::parser::Severity::Info => "info",
1825+ htmlang::parser::Severity::Help => "help",
1752 eprintln!("{}: line {}: {}", prefix, d.line, d.message);
1827 eprintln!("{}: line {}: {}", prefix, d.line, d.message);
@@ -2753,6 +2828,8 @@ compile();
2753 let prefix = match d.severity {
2828 let prefix = match d.severity {
2754 htmlang::parser::Severity::Error => "error",
2829 htmlang::parser::Severity::Error => "error",
2755 htmlang::parser::Severity::Warning => "warning",
2830 htmlang::parser::Severity::Warning => "warning",
2831+ htmlang::parser::Severity::Info => "info",
2832+ htmlang::parser::Severity::Help => "help",
2757 eprintln!("{}: line {}: {}", prefix, d.line, d.message);
2834 eprintln!("{}: line {}: {}", prefix, d.line, d.message);
@@ -3384,8 +3461,12 @@ fn watch_loop(
3385 // Track content hashes for incremental rebuilds
3462 // Track content hashes for incremental rebuilds
3386 let mut content_hashes: HashMap<PathBuf, u64> = HashMap::new();
3463 let mut content_hashes: HashMap<PathBuf, u64> = HashMap::new();
3387− // Dependency map: source file -> list of included/imported files
3464+ // Dependency map: source file -> list of included/imported files. Maintained
3465+ // across rebuilds so we always know the current dep graph.
3388 let mut dep_map: HashMap<PathBuf, Vec<PathBuf>> = HashMap::new();
3466 let mut dep_map: HashMap<PathBuf, Vec<PathBuf>> = HashMap::new();
3467+ // Set of currently-watched include paths. Used to unwatch files that are
3468+ // no longer referenced after a rebuild changes the dep graph.
3469+ let mut watched_includes: HashSet<PathBuf> = HashSet::new();
3390 fn hash_file(path: &Path) -> Option<u64> {
3471 fn hash_file(path: &Path) -> Option<u64> {
3391 let content = fs::read(path).ok()?;
3472 let content = fs::read(path).ok()?;
@@ -3401,10 +3482,27 @@ fn watch_loop(
3401 content_hashes.insert(canonical, h);
3482 content_hashes.insert(canonical, h);
3404− for inc in included_files {
3485+ // Seed dep_map from the initial compile's include list so that the first
3486+ // change event correctly picks up include-file modifications even before
3487+ // the first recompile replaces the entry.
3488+ let initial_includes_canonical: Vec<PathBuf> = included_files
3490+ .map(|p| fs::canonicalize(p).unwrap_or_else(|_| p.clone()))
3492+ for inc in &initial_includes_canonical {
3405 if let Some(h) = hash_file(inc) {
3493 if let Some(h) = hash_file(inc) {
3406 content_hashes.insert(inc.clone(), h);
3494 content_hashes.insert(inc.clone(), h);
3496+ watched_includes.insert(inc.clone());
3498+ // Attach the initial include list to every source file so a change to any
3499+ // of them triggers a rebuild of any source. Per-file dep refinement happens
3500+ // on the next recompile.
3501+ if !initial_includes_canonical.is_empty() {
3502+ for file in source_files {
3503+ let canonical = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
3504+ dep_map.insert(canonical, initial_includes_canonical.clone());
@@ -3517,7 +3615,9 @@ fn watch_loop(
3517 .map(|p| fs::canonicalize(p).unwrap_or_else(|_| p.clone()))
3615 .map(|p| fs::canonicalize(p).unwrap_or_else(|_| p.clone()))
3518 .collect();
3616 .collect();
3519 for inc in &canonical_deps {
3617 for inc in &canonical_deps {
3618+ if watched_includes.insert(inc.clone()) {
3520− let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
3619+ let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
3521 if let Some(h) = hash_file(inc) {
3621 if let Some(h) = hash_file(inc) {
3522 content_hashes.insert(inc.clone(), h);
3622 content_hashes.insert(inc.clone(), h);
@@ -3525,6 +3625,24 @@ fn watch_loop(
3525 dep_map.insert(file.clone(), canonical_deps);
3625 dep_map.insert(file.clone(), canonical_deps);
3628+ // Unwatch include files that are no longer referenced by any
3629+ // source. Prevents file-descriptor leaks when @include edges
3630+ // are removed mid-session.
3631+ let still_needed: HashSet<PathBuf> = dep_map
3636+ let to_drop: Vec<PathBuf> = watched_includes
3637+ .difference(&still_needed)
3640+ for path in to_drop {
3641+ let _ = watcher.unwatch(&path);
3642+ watched_includes.remove(&path);
3643+ content_hashes.remove(&path);
3528 eprintln!("recompiled {} file(s)", recompiled);
3646 eprintln!("recompiled {} file(s)", recompiled);
3530 if let Some(ref tx) = reload_tx {
3648 if let Some(ref tx) = reload_tx {