@@ -59,6 +59,8 @@ fn compile(input_path: &str, cfg: &CompileConfig) -> (bool, Vec<PathBuf>) {
5959 let severity = match d.severity {
6060 htmlang::parser::Severity::Error => "error",
6161 htmlang::parser::Severity::Warning => "warning",
62+ htmlang::parser::Severity::Info => "info",
63+ htmlang::parser::Severity::Help => "help",
6264 };
6365 collected.push(DiagnosticJson {
6466 file: input_path.to_string(),
@@ -73,6 +75,8 @@ fn compile(input_path: &str, cfg: &CompileConfig) -> (bool, Vec<PathBuf>) {
7375 let prefix = match d.severity {
7476 htmlang::parser::Severity::Error => "error",
7577 htmlang::parser::Severity::Warning => "warning",
78+ htmlang::parser::Severity::Info => "info",
79+ htmlang::parser::Severity::Help => "help",
7680 };
7781 if let Some(col) = d.column {
7882 eprintln!("{}: line {}:{}: {}", prefix, d.line, col, d.message);
@@ -312,15 +316,31 @@ fn generate_error_overlay(diagnostics: &[htmlang::parser::Diagnostic], file: &st
312316 let prefix = match d.severity {
313317 htmlang::parser::Severity::Error => "error",
314318 htmlang::parser::Severity::Warning => "warning",
319+ htmlang::parser::Severity::Info => "info",
320+ htmlang::parser::Severity::Help => "help",
315321 };
316322 let escaped = d.message
317323 .replace('&', "&")
318324 .replace('<', "<")
319325 .replace('>', ">");
326+ let location = match d.column {
327+ Some(col) => format!("line {}:{}", d.line, col),
328+ None => format!("line {}", d.line),
329+ };
320330 errors.push_str(&format!(
321− "<div class=\"entry\"><span class=\"badge {}\">{}</span> line {}: {}</div>",
322− prefix, prefix, d.line, escaped
331+ "<div class=\"entry\"><span class=\"badge {}\">{}</span> <span class=\"loc\">{}</span> {}",
332+ prefix, prefix, location, escaped
323333 ));
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));
341+ }
342+ }
343+ errors.push_str("</div>");
324344 }
325345 format!(
326346 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
328348body{{background:#1a1a2e;color:#eee;font-family:ui-monospace,monospace;padding:2rem}}
329349h1{{color:#ff6b6b;margin-bottom:1rem;font-size:1.5rem}}
330350.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}}
332353.badge{{display:inline-block;padding:2px 8px;border-radius:4px;font-size:0.8rem;margin-right:8px}}
333354.badge.error{{background:#c0392b;color:white}}
334355.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}}
335360</style></head><body>
336361<h1>Build Error</h1>
337362<div class="file">{file}</div>
@@ -594,6 +619,8 @@ fn lint_file(path: &str) -> Vec<String> {
594619 let prefix = match d.severity {
595620 htmlang::parser::Severity::Error => "error",
596621 htmlang::parser::Severity::Warning => "warning",
622+ htmlang::parser::Severity::Info => "info",
623+ htmlang::parser::Severity::Help => "help",
597624 };
598625 warnings.push(format!("{}:{}:{}: {}", path, d.line, prefix, d.message));
599626 }
@@ -1578,6 +1605,9 @@ fn main() {
15781605 let mut serve_target = None;
15791606 let mut serve_port: u16 = 3000;
15801607 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;
15811611 let mut _proxy_routes: Vec<(String, String)> = Vec::new();
15821612 let mut si = 2;
15831613 while si < args.len() {
@@ -1587,6 +1617,15 @@ fn main() {
15871617 serve_port = args.get(si).and_then(|p| p.parse().ok()).unwrap_or(3000);
15881618 }
15891619 "--open" => serve_open = true,
1620+ "--https" => serve_https = true,
1621+ "--cert" => {
1622+ si += 1;
1623+ cert_path = args.get(si).cloned();
1624+ }
1625+ "--key" => {
1626+ si += 1;
1627+ key_path = args.get(si).cloned();
1628+ }
15901629 "--proxy" => {
15911630 // --proxy /api http://localhost:3001
15921631 si += 1;
@@ -1612,6 +1651,32 @@ fn main() {
16121651 let config = load_config(target_path);
16131652 let effective_port = if serve_port != 3000 { serve_port } else { config.port };
16141653
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");
1661+ process::exit(1);
1662+ });
1663+ let key = key_path.clone().unwrap_or_else(|| {
1664+ eprintln!("error: --https requires --cert <path> and --key <path>");
1665+ process::exit(1);
1666+ });
1667+ match htmlang::serve::load_tls_config(Path::new(&cert), Path::new(&key)) {
1668+ Ok(cfg) => Some(cfg),
1669+ Err(e) => {
1670+ eprintln!("error: failed to load TLS config: {}", e);
1671+ process::exit(1);
1672+ }
1673+ }
1674+ } else {
1675+ None
1676+ };
1677+ let scheme = if tls_config.is_some() { "https" } else { "http" };
1678+ let _ = scheme; // announced by watch_loop / open_in_browser downstream
1679+
16151680 // Do initial compile
16161681 if target_path.is_dir() {
16171682 let hl_files = collect_hl_files_recursive(target_path);
@@ -1633,9 +1698,13 @@ fn main() {
16331698 let serve_dir = config.output.as_ref().map(|o| PathBuf::from(o)).unwrap_or_else(|| target_path.to_path_buf());
16341699 let index_path = serve_dir.join("index.html");
16351700 let server_tx = tx.clone();
1701+ let tls_for_thread = tls_config;
16361702 std::thread::spawn(move || {
16371703 let rt = tokio::runtime::Runtime::new().expect("failed to create runtime");
1638− rt.block_on(htmlang::serve::run(effective_port, index_path, server_tx));
1704+ match tls_for_thread {
1705+ Some(tls) => rt.block_on(htmlang::serve::run_https(effective_port, index_path, server_tx, tls)),
1706+ None => rt.block_on(htmlang::serve::run(effective_port, index_path, server_tx)),
1707+ }
16391708 });
16401709 if serve_open { open_in_browser(effective_port); }
16411710 watch_loop(target_path, &hl_files, &[], true, true, Some(tx), effective_port, config.debounce_ms);
@@ -1647,9 +1716,13 @@ fn main() {
16471716 let (tx, _) = tokio::sync::broadcast::channel::<()>(16);
16481717 let out_path = Path::new(&target).with_extension("html");
16491718 let server_tx = tx.clone();
1719+ let tls_for_thread = tls_config;
16501720 std::thread::spawn(move || {
16511721 let rt = tokio::runtime::Runtime::new().expect("failed to create runtime");
1652− rt.block_on(htmlang::serve::run(effective_port, out_path, server_tx));
1722+ match tls_for_thread {
1723+ Some(tls) => rt.block_on(htmlang::serve::run_https(effective_port, out_path, server_tx, tls)),
1724+ None => rt.block_on(htmlang::serve::run(effective_port, out_path, server_tx)),
1725+ }
16531726 });
16541727 if serve_open { open_in_browser(effective_port); }
16551728 let files = vec![PathBuf::from(&target)];
@@ -1748,6 +1821,8 @@ fn main() {
17481821 let prefix = match d.severity {
17491822 htmlang::parser::Severity::Error => "error",
17501823 htmlang::parser::Severity::Warning => "warning",
1824+ htmlang::parser::Severity::Info => "info",
1825+ htmlang::parser::Severity::Help => "help",
17511826 };
17521827 eprintln!("{}: line {}: {}", prefix, d.line, d.message);
17531828 }
@@ -2753,6 +2828,8 @@ compile();
27532828 let prefix = match d.severity {
27542829 htmlang::parser::Severity::Error => "error",
27552830 htmlang::parser::Severity::Warning => "warning",
2831+ htmlang::parser::Severity::Info => "info",
2832+ htmlang::parser::Severity::Help => "help",
27562833 };
27572834 eprintln!("{}: line {}: {}", prefix, d.line, d.message);
27582835 }
@@ -3384,8 +3461,12 @@ fn watch_loop(
33843461
33853462 // Track content hashes for incremental rebuilds
33863463 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.
33883466 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();
33893470
33903471 fn hash_file(path: &Path) -> Option<u64> {
33913472 let content = fs::read(path).ok()?;
@@ -3401,10 +3482,27 @@ fn watch_loop(
34013482 content_hashes.insert(canonical, h);
34023483 }
34033484 }
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
3489+ .iter()
3490+ .map(|p| fs::canonicalize(p).unwrap_or_else(|_| p.clone()))
3491+ .collect();
3492+ for inc in &initial_includes_canonical {
34053493 if let Some(h) = hash_file(inc) {
34063494 content_hashes.insert(inc.clone(), h);
34073495 }
3496+ watched_includes.insert(inc.clone());
3497+ }
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());
3505+ }
34083506 }
34093507
34103508 loop {
@@ -3517,7 +3615,9 @@ fn watch_loop(
35173615 .map(|p| fs::canonicalize(p).unwrap_or_else(|_| p.clone()))
35183616 .collect();
35193617 for inc in &canonical_deps {
3520− let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
3618+ if watched_includes.insert(inc.clone()) {
3619+ let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
3620+ }
35213621 if let Some(h) = hash_file(inc) {
35223622 content_hashes.insert(inc.clone(), h);
35233623 }
@@ -3525,6 +3625,24 @@ fn watch_loop(
35253625 dep_map.insert(file.clone(), canonical_deps);
35263626 }
35273627
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
3632+ .values()
3633+ .flatten()
3634+ .cloned()
3635+ .collect();
3636+ let to_drop: Vec<PathBuf> = watched_includes
3637+ .difference(&still_needed)
3638+ .cloned()
3639+ .collect();
3640+ for path in to_drop {
3641+ let _ = watcher.unwatch(&path);
3642+ watched_includes.remove(&path);
3643+ content_hashes.remove(&path);
3644+ }
3645+
35283646 eprintln!("recompiled {} file(s)", recompiled);
35293647
35303648 if let Some(ref tx) = reload_tx {