3use std::collections::{HashMap, HashSet};
6use std::hash::{Hash, Hasher};
7use std::path::{Path, PathBuf};
11struct DiagnosticJson {
18fn severity_label(s: htmlang::parser::Severity) -> &'static str {
20 htmlang::parser::Severity::Error => "error",
21 htmlang::parser::Severity::Warning => "warning",
22 htmlang::parser::Severity::Info => "info",
23 htmlang::parser::Severity::Help => "help",
28struct CompileConfig<'a> {
32 output_path: Option<&'a str>,
34 json_collector: Option<&'a Mutex<Vec<DiagnosticJson>>>,
41fn compile(input_path: &str, cfg: &CompileConfig) -> (bool, Vec<PathBuf>) {
42 let input = match fs::read_to_string(input_path) {
46 if let Some(collector) = cfg.json_collector {
47 collector.lock().unwrap().push(DiagnosticJson {
48 file: input_path.to_string(),
50 severity: "error".to_string(),
51 message: format!("{}", e),
55 eprintln!("error: {}: {}", input_path, e);
57 return (true, vec![]);
61 let base = Path::new(input_path).parent();
62 let result = htmlang::parser::parse_with_base(&input, base);
65 if let Some(collector) = cfg.json_collector {
66 let mut collected = collector.lock().unwrap();
67 for d in &result.diagnostics {
68 collected.push(DiagnosticJson {
69 file: input_path.to_string(),
71 severity: severity_label(d.severity).to_string(),
72 message: d.message.clone(),
77 for d in &result.diagnostics {
78 let prefix = severity_label(d.severity);
79 if let Some(col) = d.column {
80 eprintln!("{}: line {}:{}: {}", prefix, d.line, col, d.message);
82 eprintln!("{}: line {}: {}", prefix, d.line, d.message);
84 if let Some(ref src) = d.source_line {
85 eprintln!(" | {}", src);
86 if let Some(col) = d.column {
87 eprintln!(" | {}^", " ".repeat(col));
93 let has_errors = result.diagnostics.iter().any(|d| {
94 d.severity == htmlang::parser::Severity::Error
95 || (cfg.strict && d.severity == htmlang::parser::Severity::Warning)
98 let out_path = match cfg.output_path {
99 Some(p) => PathBuf::from(p),
100 None => Path::new(input_path).with_extension("html"),
105 if cfg.error_overlay {
106 let error_html = generate_error_overlay(&result.diagnostics, input_path);
107 let _ = fs::write(&out_path, &error_html);
110 let html = htmlang::codegen::generate_with(
112 &htmlang::codegen::CodegenOptions {
114 partial: cfg.partial,
119 match fs::write(&out_path, &html) {
120 Ok(()) => eprintln!("wrote {}", out_path.display()),
121 Err(e) => eprintln!("error: {}: {}", out_path.display(), e),
123 // Generate source map alongside HTML
125 let map_path = out_path.with_extension("html.map");
126 let source_map = htmlang::codegen::generate_source_map(
128 &Path::new(input_path)
133 let _ = fs::write(&map_path, &source_map);
138 (has_errors, result.included_files)
141fn json_escape_string(s: &str) -> String {
143 .replace('\\', "\\\\")
144 .replace('"', "\\\"")
145 .replace('\n', "\\n");
146 format!("\"{}\"", escaped)
149fn json_array(items: impl Iterator<Item = String>) -> String {
150 let inner: Vec<String> = items.collect();
151 format!("[{}]", inner.join(","))
154fn json_object(fields: &[(&str, String)]) -> String {
155 let inner: Vec<String> = fields
157 .map(|(k, v)| format!("{}:{}", json_escape_string(k), v))
159 format!("{{{}}}", inner.join(","))
162fn print_json_diagnostics(diagnostics: &[DiagnosticJson]) {
163 let arr = json_array(diagnostics.iter().map(|d| {
165 ("file", json_escape_string(&d.file)),
166 ("line", d.line.to_string()),
167 ("severity", json_escape_string(&d.severity)),
168 ("message", json_escape_string(&d.message)),
171 println!("{}", json_object(&[("diagnostics", arr)]));
174fn kebab_to_title(s: &str) -> String {
177 let mut chars = word.chars();
180 let mut title = c.to_uppercase().to_string();
184 None => String::new(),
191fn format_bytes(bytes: usize) -> String {
192 if bytes >= 1_048_576 {
193 format!("{:.1}MB", bytes as f64 / 1_048_576.0)
194 } else if bytes >= 1024 {
195 format!("{:.1}KB", bytes as f64 / 1024.0)
197 format!("{}B", bytes)
201fn bundle_assets(html: &str, base_dir: &Path) -> String {
203 let mut result = html.to_string();
205 // Inline images referenced as src="..." in <img> tags
206 let img_re_patterns = ["src=\"", "url("];
207 for pattern in &img_re_patterns {
208 let mut output = String::new();
209 let mut remaining = result.as_str();
210 while let Some(pos) = remaining.find(pattern) {
211 output.push_str(&remaining[..pos + pattern.len()]);
212 remaining = &remaining[pos + pattern.len()..];
214 // Find the closing delimiter
215 let close_char = if *pattern == "src=\"" { '"' } else { ')' };
216 if let Some(end) = remaining.find(close_char) {
217 let path_str = remaining[..end].trim_matches(|c| c == '\'' || c == '"');
219 // Skip data URIs, remote URLs, and anchors
220 if !path_str.starts_with("data:")
221 && !path_str.starts_with("http://")
222 && !path_str.starts_with("https://")
223 && !path_str.starts_with('#')
224 && !path_str.is_empty()
226 let asset_path = base_dir.join(path_str);
227 if asset_path.exists()
228 && let Ok(mut file) = std::fs::File::open(&asset_path)
230 let mut buf = Vec::new();
231 if file.read_to_end(&mut buf).is_ok() {
232 let mime = match asset_path.extension().and_then(|e| e.to_str()) {
233 Some("png") => "image/png",
234 Some("jpg") | Some("jpeg") => "image/jpeg",
235 Some("gif") => "image/gif",
236 Some("svg") => "image/svg+xml",
237 Some("webp") => "image/webp",
238 Some("ico") => "image/x-icon",
239 Some("woff2") => "font/woff2",
240 Some("woff") => "font/woff",
241 Some("ttf") => "font/ttf",
242 Some("otf") => "font/otf",
243 Some("avif") => "image/avif",
244 _ => "application/octet-stream",
246 use std::fmt::Write as FmtWrite;
247 let mut b64 = String::new();
248 // Simple base64 encoding
249 let encoded = base64_encode(&buf);
250 let _ = write!(b64, "data:{};base64,{}", mime, encoded);
251 output.push_str(&b64);
252 remaining = &remaining[end..];
257 // If we couldn't inline, keep original path
258 output.push_str(&remaining[..end]);
259 remaining = &remaining[end..];
262 output.push_str(remaining);
268fn base64_encode(data: &[u8]) -> String {
269 const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
270 let mut result = String::with_capacity(data.len().div_ceil(3) * 4);
271 for chunk in data.chunks(3) {
272 let b0 = chunk[0] as u32;
273 let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
274 let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
275 let n = (b0 << 16) | (b1 << 8) | b2;
276 result.push(CHARS[((n >> 18) & 63) as usize] as char);
277 result.push(CHARS[((n >> 12) & 63) as usize] as char);
279 result.push(CHARS[((n >> 6) & 63) as usize] as char);
284 result.push(CHARS[(n & 63) as usize] as char);
292fn copy_non_hl_files(src_dir: &Path, out_dir: &Path) {
293 let skip = out_dir.canonicalize().ok();
294 copy_non_hl_recursive(src_dir, src_dir, out_dir, skip.as_deref());
297fn copy_non_hl_recursive(base: &Path, dir: &Path, out_dir: &Path, skip_canonical: Option<&Path>) {
298 if let Ok(entries) = fs::read_dir(dir) {
299 for entry in entries.flatten() {
300 let path = entry.path();
302 // Skip hidden directories (.htmlang-cache, .git, etc.)
305 .is_some_and(|n| n.to_str().is_some_and(|s| s.starts_with('.')))
309 // Skip the output directory to avoid copying it into itself
310 if let Some(skip) = skip_canonical {
311 if path.canonicalize().ok().as_deref() == Some(skip) {
315 copy_non_hl_recursive(base, &path, out_dir, skip_canonical);
316 } else if path.is_file() && path.extension().is_none_or(|e| e != "hl") {
317 let rel = path.strip_prefix(base).unwrap_or(&path);
318 let dest = out_dir.join(rel);
319 if let Some(parent) = dest.parent() {
320 let _ = fs::create_dir_all(parent);
322 match fs::copy(&path, &dest) {
323 Ok(_) => eprintln!("copied {}", dest.display()),
324 Err(e) => eprintln!("error: copy {}: {}", dest.display(), e),
331fn generate_error_overlay(diagnostics: &[htmlang::parser::Diagnostic], file: &str) -> String {
332 let mut errors = String::new();
333 for d in diagnostics {
334 let prefix = severity_label(d.severity);
337 .replace('&', "&")
338 .replace('<', "<")
339 .replace('>', ">");
340 let location = match d.column {
341 Some(col) => format!("line {}:{}", d.line, col),
342 None => format!("line {}", d.line),
344 errors.push_str(&format!(
345 "<div class=\"entry\"><span class=\"badge {}\">{}</span> <span class=\"loc\">{}</span> {}",
346 prefix, prefix, location, escaped
348 if let Some(ref src) = d.source_line {
350 .replace('&', "&")
351 .replace('<', "<")
352 .replace('>', ">");
353 errors.push_str(&format!("<pre class=\"src\">{}</pre>", src_esc));
354 if let Some(col) = d.column {
355 // Render a caret indicator underneath the source line.
356 let caret = format!("{}^", " ".repeat(col));
357 errors.push_str(&format!("<pre class=\"caret\">{}</pre>", caret));
360 errors.push_str("</div>");
363 r#"<!DOCTYPE html><html><head><meta charset="utf-8"><title>Build Error</title><style>
364*{{margin:0;box-sizing:border-box}}
365body{{background:#1a1a2e;color:#eee;font-family:ui-monospace,monospace;padding:2rem}}
366h1{{color:#ff6b6b;margin-bottom:1rem;font-size:1.5rem}}
367.file{{color:#888;margin-bottom:1.5rem;font-size:0.9rem}}
368.entry{{padding:0.75rem 0;border-bottom:1px solid #333}}
369.loc{{color:#9ca3af;margin-right:6px}}
370.badge{{display:inline-block;padding:2px 8px;border-radius:4px;font-size:0.8rem;margin-right:8px}}
371.badge.error{{background:#c0392b;color:white}}
372.badge.warning{{background:#f39c12;color:white}}
373.badge.info{{background:#2563eb;color:white}}
374.badge.help{{background:#16a34a;color:white}}
375.src{{margin-top:0.5rem;padding:0.4rem 0.6rem;background:#0f0f1e;border-radius:4px;color:#ddd;white-space:pre-wrap}}
376.caret{{margin:0;padding:0 0.6rem;color:#ff6b6b;white-space:pre}}
379<div class="file">{file}</div>
387fn init_project(dir: &str, template_name: Option<&str>) {
388 let dir = Path::new(dir);
389 if dir.to_str() != Some(".")
390 && let Err(e) = fs::create_dir_all(dir)
392 eprintln!("error: cannot create directory '{}': {}", dir.display(), e);
396 let index_path = dir.join("index.hl");
397 if index_path.exists() {
398 eprintln!("error: {} already exists", index_path.display());
402 let template = match template_name {
408@column [max-width 720, center-x, padding 40, spacing 30]
410 @text [bold, size 36] My Blog
411 @paragraph [color #666, line-height 1.6] Thoughts and ideas.
414 @article [spacing 12, padding-bottom 30, border-bottom 1 #eee]
415 @text [bold, size 24] First Post
416 @text [color #888, size 14] 2024-01-15
417 @paragraph [line-height 1.8]
418 Welcome to my blog. This is the first post.
420 @article [spacing 12, padding-bottom 30, border-bottom 1 #eee]
421 @text [bold, size 24] Another Post
422 @text [color #888, size 14] 2024-01-10
423 @paragraph [line-height 1.8]
424 Here is another post with more content.
426 @footer [padding-top 20, border-top 1 #eee]
427 @text [color #888, size 14, text-align center] Built with htmlang
431 r#"@page Documentation
433@let sidebar-width 250
435@row [min-height 100vh]
436 @aside [width $sidebar-width, padding 20, background #f8f9fa, border-right 1 #e0e0e0, spacing 8]
437 @text [bold, size 18, padding-bottom 10] Docs
439 @link # Getting Started
441 @link # Configuration
442 @link # API Reference
444 @main [width fill, padding 40, max-width 800, spacing 20]
445 @text [bold, size 32] Getting Started
446 @paragraph [line-height 1.8]
447 Welcome to the documentation. Use the sidebar to navigate.
449 @text [bold, size 24] Installation
450 @code [padding 16, background #f5f5f5, rounded 8]
451 npm install my-package
453 @text [bold, size 24] Usage
454 @paragraph [line-height 1.8]
455 Import and use the library in your project.
458 Some("portfolio") => {
463@column [min-height 100vh, spacing 0]
464 @header [padding 20 40, background white, border-bottom 1 #eee]
465 @row [max-width 1200, center-x, width fill, justify-content space-between, align-items center]
466 @text [bold, size 20] Jane Doe
473 @main [max-width 1200, center-x, padding 60 40, spacing 60]
474 @column [spacing 10, center-x, text-align center, max-width 600]
475 @text [bold, size 48] Designer & Developer
476 @paragraph [color #666, size 18, line-height 1.6]
477 I create beautiful, functional digital experiences.
479 @grid [grid-cols 3, gap 20]
480 @el [aspect-ratio 1, background #f0f0f0, rounded 12, padding 20, spacing 10]
481 @text [bold] Project One
482 @text [color #666, size 14] Web Design
484 @el [aspect-ratio 1, background #f0f0f0, rounded 12, padding 20, spacing 10]
485 @text [bold] Project Two
486 @text [color #666, size 14] Branding
488 @el [aspect-ratio 1, background #f0f0f0, rounded 12, padding 20, spacing 10]
489 @text [bold] Project Three
490 @text [color #666, size 14] Development
492 @footer [padding 30 40, background #1a1a2e, color white, text-align center]
493 @text [size 14] Built with htmlang
500@column [max-width 800, center-x, padding 40, spacing 20]
501 @text [bold, size 32] Hello, htmlang!
503 @paragraph [line-height 1.6]
504 Edit {@text [bold, color $primary] index.hl} and run
505 {@text [font monospace, size 14] htmlang -s .} to get started.
508 @el [padding 12 24, background $primary, rounded 8, cursor pointer, hover:background #2563eb, transition all 0.15s ease] > @link https://github.com/nicholasgasior/htmlang
509 @text [color white, bold] Documentation
514 match fs::write(&index_path, template) {
515 Ok(()) => eprintln!("created {}", index_path.display()),
517 eprintln!("error: {}: {}", index_path.display(), e);
523fn collect_hl_files_recursive(dir: &Path) -> Vec<PathBuf> {
524 let mut files = Vec::new();
525 collect_hl_recursive_inner(dir, &mut files);
530fn collect_hl_recursive_inner(dir: &Path, files: &mut Vec<PathBuf>) {
531 if let Ok(entries) = fs::read_dir(dir) {
532 for entry in entries.flatten() {
533 let path = entry.path();
535 // Skip hidden directories (.htmlang-cache, .git, etc.)
538 .is_some_and(|n| n.to_str().is_some_and(|s| s.starts_with('.')))
542 collect_hl_recursive_inner(&path, files);
543 } else if path.is_file() && path.extension().is_some_and(|e| e == "hl") {
550fn collect_all_files_recursive(dir: &Path) -> Vec<PathBuf> {
551 let mut files = Vec::new();
552 fn walk(dir: &Path, files: &mut Vec<PathBuf>) {
553 if let Ok(entries) = fs::read_dir(dir) {
554 for entry in entries.flatten() {
555 let path = entry.path();
558 } else if path.is_file() {
564 walk(dir, &mut files);
569fn generate_sitemap(dir: &str, base_url: &str) {
570 let dir = Path::new(dir);
571 let hl_files = collect_hl_files_recursive(dir);
572 if hl_files.is_empty() {
573 eprintln!("no .hl files found in {}", dir.display());
576 let mut xml = String::from(
577 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n",
579 for file in &hl_files {
580 let rel = file.strip_prefix(dir).unwrap_or(file);
582 .with_extension("html")
585 let url = if url_path == "index.html" {
586 format!("{}/", base_url.trim_end_matches('/'))
588 format!("{}/{}", base_url.trim_end_matches('/'), url_path)
591 // Get file modification time for <lastmod>
595 .and_then(|m| m.modified().ok())
598 .duration_since(std::time::UNIX_EPOCH)
601 let days = secs / 86400;
602 // Simple date calculation from epoch days
603 let (y, m, d) = epoch_days_to_date(days);
604 format!("{:04}-{:02}-{:02}", y, m, d)
607 // Heuristic priority: index pages get higher priority
608 let priority = if url_path == "index.html" {
610 } else if rel.components().count() <= 2 {
616 xml.push_str(" <url>\n");
617 xml.push_str(&format!(" <loc>{}</loc>\n", url));
618 if let Some(ref date) = lastmod {
619 xml.push_str(&format!(" <lastmod>{}</lastmod>\n", date));
621 xml.push_str(&format!(" <priority>{}</priority>\n", priority));
622 xml.push_str(" </url>\n");
624 xml.push_str("</urlset>\n");
625 let out_path = dir.join("sitemap.xml");
626 match fs::write(&out_path, &xml) {
627 Ok(()) => eprintln!("wrote {} ({} URLs)", out_path.display(), hl_files.len()),
629 eprintln!("error: {}: {}", out_path.display(), e);
635fn epoch_days_to_date(days: u64) -> (u64, u64, u64) {
636 // Algorithm from http://howardhinnant.github.io/date_algorithms.html
637 let z = days + 719468;
638 let era = z / 146097;
639 let doe = z - era * 146097;
640 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
641 let y = yoe + era * 400;
642 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
643 let mp = (5 * doy + 2) / 153;
644 let d = doy - (153 * mp + 2) / 5 + 1;
645 let m = if mp < 10 { mp + 3 } else { mp - 9 };
646 let y = if m <= 2 { y + 1 } else { y };
650fn lint_file(path: &str) -> Vec<String> {
651 let input = match fs::read_to_string(path) {
653 Err(e) => return vec![format!("error: {}: {}", path, e)],
655 let base = Path::new(path).parent();
656 let result = htmlang::parser::parse_with_base(&input, base);
657 let mut warnings = Vec::new();
659 // Report parser diagnostics
660 for d in &result.diagnostics {
661 let prefix = severity_label(d.severity);
662 warnings.push(format!("{}:{}:{}: {}", path, d.line, prefix, d.message));
665 // Additional lint checks on the AST
666 lint_nodes(&result.document.nodes, path, 0, &mut warnings);
670fn lint_nodes(nodes: &[htmlang::ast::Node], path: &str, depth: usize, warnings: &mut Vec<String>) {
672 if let htmlang::ast::Node::Element(elem) = node {
673 // Deeply nested elements (>10 levels)
675 warnings.push(format!(
676 "{}:{}:lint: deeply nested element ({} levels) — consider simplifying",
677 path, elem.line_num, depth
681 // @image without alt
682 if elem.kind == htmlang::ast::ElementKind::Image
683 && !elem.attrs.iter().any(|a| a.key == "alt")
685 warnings.push(format!(
686 "{}:{}:lint: @image missing 'alt' attribute (accessibility)",
691 // @link without content or aria-label
692 if elem.kind == htmlang::ast::ElementKind::Link {
693 let has_aria = elem.attrs.iter().any(|a| a.key == "aria-label");
694 let has_children = !elem.children.is_empty();
695 let has_arg_text = elem.argument.as_ref().is_some_and(|_| false);
696 if !has_aria && !has_children && !has_arg_text {
697 warnings.push(format!(
698 "{}:{}:lint: @link has no visible text or aria-label (accessibility)",
704 // @input without type
705 if elem.kind == htmlang::ast::ElementKind::Input
706 && !elem.attrs.iter().any(|a| a.key == "type")
708 warnings.push(format!(
709 "{}:{}:lint: @input missing 'type' attribute",
714 // Empty containers (no children, no text)
717 htmlang::ast::ElementKind::Row
718 | htmlang::ast::ElementKind::Column
719 | htmlang::ast::ElementKind::El
720 ) && elem.children.is_empty()
722 warnings.push(format!(
723 "{}:{}:lint: empty container (@{}) has no children",
727 htmlang::ast::ElementKind::Row => "row",
728 htmlang::ast::ElementKind::Column => "column",
734 // @button without type
735 if elem.kind == htmlang::ast::ElementKind::Button
736 && !elem.attrs.iter().any(|a| a.key == "type")
738 warnings.push(format!(
739 "{}:{}:lint: @button missing 'type' attribute (defaults to submit)",
744 lint_nodes(&elem.children, path, depth + 1, warnings);
749fn stats_file(path: &str) {
750 let input = match fs::read_to_string(path) {
753 eprintln!("error: {}: {}", path, e);
757 let base = Path::new(path).parent();
758 let result = htmlang::parser::parse_with_base(&input, base);
759 let html = htmlang::codegen::generate(&result.document);
761 let mut element_count = 0;
762 let mut colors = std::collections::HashSet::new();
763 let mut fonts = std::collections::HashSet::new();
765 &result.document.nodes,
771 // Count CSS rules (approximate from generated style block)
772 let css_rules = html.matches('{').count().saturating_sub(1); // subtract the html/head/body structure
774 let source_bytes = input.len();
775 let output_bytes = html.len();
777 eprintln!("--- {} ---", path);
779 " source size: {} bytes ({} lines)",
781 input.lines().count()
783 eprintln!(" output size: {} bytes", output_bytes);
784 eprintln!(" elements: {}", element_count);
785 eprintln!(" CSS rules: ~{}", css_rules);
786 eprintln!(" unique colors: {}", colors.len());
787 if !colors.is_empty() {
788 let mut sorted: Vec<_> = colors.iter().collect();
799 eprintln!(" unique fonts: {}", fonts.len());
800 if !fonts.is_empty() {
801 let mut sorted: Vec<_> = fonts.iter().collect();
815 .any(|d| d.severity == htmlang::parser::Severity::Error)
822 .filter(|d| d.severity == htmlang::parser::Severity::Error)
826 let warn_count = result
829 .filter(|d| d.severity == htmlang::parser::Severity::Warning)
832 eprintln!(" warnings: {}", warn_count);
837 nodes: &[htmlang::ast::Node],
839 colors: &mut std::collections::HashSet<String>,
840 fonts: &mut std::collections::HashSet<String>,
843 if let htmlang::ast::Node::Element(elem) = node {
845 for attr in &elem.attrs {
846 let key = attr.key.as_str();
847 // Strip pseudo/media prefixes for color/font detection
848 let base_key = key.split(':').next_back().unwrap_or(key);
849 if matches!(base_key, "color" | "background")
850 && let Some(ref v) = attr.value
852 colors.insert(v.clone());
854 if base_key == "font"
855 && let Some(ref v) = attr.value
857 fonts.insert(v.clone());
860 count_elements(&elem.children, count, colors, fonts);
865/// Extract shared CSS rules across multiple HTML files and write shared.css
866fn extract_shared_css(html_files: &[PathBuf], out_dir: &Path) {
867 // Parse <style> blocks from each HTML file and count rule occurrences
868 let mut rule_counts: HashMap<String, usize> = HashMap::new();
869 let total = html_files.len();
870 for file in html_files {
871 if let Ok(html) = fs::read_to_string(file) {
872 // Extract CSS between <style> and </style>
873 if let Some(start) = html.find("<style>")
874 && let Some(end) = html[start..].find("</style>")
876 let css = &html[start + 7..start + end];
877 // Extract individual rules (class-based)
878 let mut seen_in_file = std::collections::HashSet::new();
880 let bytes = css.as_bytes();
881 while i < bytes.len() {
882 if bytes[i] == b'.' || bytes[i] == b'@' {
883 // Find end of rule block
886 let mut found_open = false;
887 while i < bytes.len() {
888 if bytes[i] == b'{' {
891 } else if bytes[i] == b'}' {
893 if found_open && depth == 0 {
900 let rule = &css[start_pos..i];
901 if !rule.is_empty() && !seen_in_file.contains(rule) {
902 seen_in_file.insert(rule.to_string());
903 *rule_counts.entry(rule.to_string()).or_insert(0) += 1;
912 // Rules appearing in ALL files are shared
913 let shared_rules: Vec<&String> = rule_counts
915 .filter(|(_, count)| **count == total)
916 .map(|(rule, _)| rule)
918 if shared_rules.is_empty() {
921 let shared_set: std::collections::HashSet<&String> = shared_rules.iter().copied().collect();
922 let shared_css_path = out_dir.join("shared.css");
923 let shared_css: String = shared_rules
928 if fs::write(&shared_css_path, &shared_css).is_err() {
932 "extracted {} shared CSS rules to {}",
934 shared_css_path.display()
937 // Remove shared rules from individual files and inject <link> tag
938 for file in html_files {
939 if let Ok(html) = fs::read_to_string(file)
940 && let Some(style_start) = html.find("<style>")
941 && let Some(style_end_rel) = html[style_start..].find("</style>")
943 let css = &html[style_start + 7..style_start + style_end_rel];
944 // Rebuild CSS without shared rules
945 let mut filtered = String::new();
947 let bytes = css.as_bytes();
948 while i < bytes.len() {
949 if bytes[i] == b'.' || bytes[i] == b'@' {
952 let mut found_open = false;
953 while i < bytes.len() {
954 if bytes[i] == b'{' {
957 } else if bytes[i] == b'}' {
959 if found_open && depth == 0 {
966 let rule = &css[start_pos..i];
967 if !shared_set.contains(&rule.to_string()) {
968 filtered.push_str(rule);
971 filtered.push(css.as_bytes()[i] as char);
975 let link_tag = "<link rel=\"stylesheet\" href=\"shared.css\">";
976 let new_html = format!(
977 "{}{}<style>{}</style>{}",
978 &html[..style_start],
981 &html[style_start + style_end_rel + 8..],
983 let _ = fs::write(file, new_html);
988fn open_in_browser(port: u16) {
989 let url = format!("http://127.0.0.1:{}", port);
990 let cmd = if cfg!(target_os = "macos") {
995 let _ = std::process::Command::new(cmd).arg(&url).spawn();
998// ---------------------------------------------------------------------------
999// Config file support (htmlang.toml)
1000// ---------------------------------------------------------------------------
1002struct ProjectConfig {
1003 output: Option<String>,
1005 variables: Vec<(String, String)>,
1006 breakpoints: Vec<(String, String)>,
1007 // Build options (can be overridden by CLI flags)
1009 minify: Option<bool>,
1010 compat: Option<bool>,
1011 strict: Option<bool>,
1016fn load_config(target: &Path) -> ProjectConfig {
1017 let mut config = ProjectConfig {
1020 variables: Vec::new(),
1021 breakpoints: Vec::new(),
1029 let config_path = if target.is_dir() {
1030 target.join("htmlang.toml")
1034 .unwrap_or(Path::new("."))
1035 .join("htmlang.toml")
1038 let content = match fs::read_to_string(&config_path) {
1040 Err(_) => return config,
1043 let mut section = "";
1044 for (line_num, line) in content.lines().enumerate() {
1045 let trimmed = line.trim();
1046 if trimmed.is_empty() || trimmed.starts_with('#') {
1049 if trimmed.starts_with('[') && trimmed.ends_with(']') {
1050 section = &trimmed[1..trimmed.len() - 1];
1051 if !matches!(section, "variables" | "breakpoints" | "build" | "watch") {
1053 "warning: {}:{}: unknown section '[{}]' (expected: variables, breakpoints, build, watch)",
1054 config_path.display(),
1061 if let Some((key, value)) = trimmed.split_once('=') {
1062 let key = key.trim();
1063 let value = value.trim().trim_matches('"');
1066 "output" => config.output = Some(value.to_string()),
1067 "port" => config.port = value.parse().unwrap_or(3000),
1070 "warning: {}:{}: unknown key '{}' (expected: output, port)",
1071 config_path.display(),
1077 "build" => match key {
1078 "dev" => config.dev = Some(value == "true"),
1079 "minify" => config.minify = Some(value == "true"),
1080 "compat" => config.compat = Some(value == "true"),
1081 "strict" => config.strict = Some(value == "true"),
1084 "warning: {}:{}: unknown build key '{}' (expected: dev, minify, compat, strict)",
1085 config_path.display(),
1091 "watch" => match key {
1092 "debounce_ms" => config.debounce_ms = value.parse().unwrap_or(50),
1095 "warning: {}:{}: unknown watch key '{}' (expected: debounce_ms)",
1096 config_path.display(),
1103 config.variables.push((key.to_string(), value.to_string()));
1108 .push((key.to_string(), value.to_string()));
1110 _ => {} // already warned about unknown section
1118fn find_wasm_pkg() -> Option<(Vec<u8>, String)> {
1119 // Look for pre-built WASM binary and JS glue in known locations
1121 // Relative to current executable
1122 std::env::current_exe()
1124 .and_then(|p| p.parent().map(|d| d.join("..").join("wasm-pkg"))),
1125 // In the project build directory (development)
1126 Some(PathBuf::from("target/wasm-pkg")),
1128 for dir in candidates.iter().flatten() {
1129 let wasm = dir.join("htmlang_wasm_bg.wasm");
1130 let js = dir.join("htmlang_wasm.js");
1131 if let (Ok(wasm_bytes), Ok(js_str)) = (fs::read(&wasm), fs::read_to_string(&js)) {
1132 return Some((wasm_bytes, js_str));
1139 let args: Vec<String> = env::args().collect();
1140 let mut watch = false;
1141 let mut serve = false;
1142 let mut dev = false;
1143 let mut check = false;
1144 let mut format_json = false;
1145 let mut compat = false;
1146 let mut strict = false;
1147 let mut open_browser = false;
1148 let mut partial = false;
1149 let mut port: u16 = 3000;
1150 let mut output_path: Option<String> = None;
1151 let mut input_path = None;
1153 // Handle "lsp" subcommand — launch the LSP server from the main binary
1154 if args.len() >= 2 && args[1] == "lsp" {
1159 // Handle "completions" subcommand — generate shell completions
1160 if args.len() >= 2 && args[1] == "completions" {
1161 let shell = args.get(2).map(|s| s.as_str()).unwrap_or("bash");
1162 cli::print_shell_completions(shell);
1166 // Handle "init" subcommand
1167 if args.len() >= 2 && args[1] == "init" {
1168 let mut init_dir = ".";
1169 let mut init_template: Option<&str> = None;
1171 while i < args.len() {
1172 match args[i].as_str() {
1173 "--template" | "-t" => {
1175 init_template = args.get(i).map(|s| s.as_str());
1177 _ if init_dir == "." => init_dir = &args[i],
1182 init_project(init_dir, init_template);
1186 // Handle "fmt" subcommand
1187 if args.len() >= 3 && args[1] == "fmt" {
1188 let file = &args[2];
1189 match fs::read_to_string(file) {
1191 let formatted = htmlang::fmt::format(&input);
1192 match fs::write(file, &formatted) {
1193 Ok(()) => eprintln!("formatted {}", file),
1195 eprintln!("error: {}: {}", file, e);
1201 eprintln!("error: {}: {}", file, e);
1208 // Handle "build" subcommand
1209 if args.len() >= 2 && args[1] == "build" {
1210 let mut src_dir = None;
1211 let mut out_dir = None;
1212 let mut build_minify = false;
1213 let mut build_compat = false;
1214 let mut build_strict = false;
1216 while i < args.len() {
1217 match args[i].as_str() {
1218 "-o" | "--output" => {
1220 out_dir = args.get(i).map(|s| s.as_str());
1222 "--minify" => build_minify = true,
1223 "--compat" => build_compat = true,
1224 "--strict" => build_strict = true,
1225 _ if src_dir.is_none() => src_dir = Some(args[i].as_str()),
1227 eprintln!("unknown argument: {}", args[i]);
1233 let src = src_dir.unwrap_or(".");
1234 let dir = Path::new(src);
1236 eprintln!("error: '{}' is not a directory", src);
1239 // Load project config — CLI flags override config file
1240 let config = load_config(dir);
1241 let build_minify = build_minify || config.minify.unwrap_or(false);
1242 let build_compat = build_compat || config.compat.unwrap_or(false);
1243 let build_strict = build_strict || config.strict.unwrap_or(false);
1244 let out_dir = out_dir.or(config.output.as_deref()).or(Some("out"));
1245 let hl_files = collect_hl_files_recursive(dir);
1246 if hl_files.is_empty() {
1247 eprintln!("no .hl files found in {}", src);
1250 // Create output dir if needed
1251 if let Some(out) = out_dir {
1252 let _ = fs::create_dir_all(out);
1254 // Pre-create output directories for each file (must be done before parallel compilation)
1255 let effective_outs: Vec<Option<String>> = hl_files
1259 let rel = file.strip_prefix(dir).unwrap_or(file);
1260 let out_path = Path::new(o).join(rel).with_extension("html");
1261 if let Some(parent) = out_path.parent() {
1262 let _ = fs::create_dir_all(parent);
1264 out_path.to_string_lossy().to_string()
1269 // Build content hash cache for incremental compilation
1270 let cache_dir = dir.join(".htmlang-cache");
1271 let _ = fs::create_dir_all(&cache_dir);
1273 // Compile files in parallel (with incremental skip for unchanged files)
1274 let build_start = std::time::Instant::now();
1275 let any_errors = std::sync::atomic::AtomicBool::new(false);
1276 let skipped = std::sync::atomic::AtomicUsize::new(0);
1277 std::thread::scope(|s| {
1278 for (file, effective_out) in hl_files.iter().zip(effective_outs.iter()) {
1279 let any_errors = &any_errors;
1280 let skipped = &skipped;
1281 let cache_dir = &cache_dir;
1283 // Content hash-based caching: skip if file content hasn't changed
1284 let hash_file_name = file
1286 .unwrap_or_default()
1290 let hash_path = cache_dir.join(&hash_file_name);
1291 if let Ok(content) = fs::read(file) {
1292 let mut hasher = std::collections::hash_map::DefaultHasher::new();
1293 content.hash(&mut hasher);
1294 let current_hash = hasher.finish().to_string();
1295 if let Ok(cached_hash) = fs::read_to_string(&hash_path)
1296 && cached_hash.trim() == current_hash
1298 // Also verify output exists
1299 let out_exists = effective_out
1301 .map_or(file.with_extension("html").exists(), |p| {
1302 Path::new(p.as_str()).exists()
1305 skipped.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1309 // Update hash cache after compilation
1310 let _ = fs::write(&hash_path, ¤t_hash);
1312 let path_str = file.to_string_lossy().to_string();
1313 let (has_errors, _) = compile(
1316 output_path: effective_out.as_deref(),
1317 minify: build_minify,
1318 compat: build_compat,
1319 strict: build_strict,
1320 ..Default::default()
1324 any_errors.store(true, std::sync::atomic::Ordering::Relaxed);
1329 let build_elapsed = build_start.elapsed();
1330 let skipped_count = skipped.load(std::sync::atomic::Ordering::Relaxed);
1331 let compiled_count = hl_files.len() - skipped_count;
1333 // Report build performance
1334 let total_output_size: usize = effective_outs
1336 .filter_map(|p| p.as_ref())
1337 .filter_map(|p| fs::metadata(p).ok())
1338 .map(|m| m.len() as usize)
1341 "built {} files in {:.2}s ({}){}",
1343 build_elapsed.as_secs_f64(),
1344 format_bytes(total_output_size),
1345 if skipped_count > 0 {
1346 format!(", {} skipped", skipped_count)
1351 if any_errors.load(std::sync::atomic::Ordering::Relaxed) {
1355 // Copy non-.hl static assets to output directory
1356 if let Some(out) = out_dir {
1357 copy_non_hl_files(dir, Path::new(out));
1359 // Shared CSS extraction: find duplicate CSS rules across pages
1360 let out_path = Path::new(out);
1361 let html_files: Vec<PathBuf> = hl_files
1364 let rel = f.strip_prefix(dir).unwrap_or(f);
1365 out_path.join(rel).with_extension("html")
1367 .filter(|p| p.exists())
1369 if html_files.len() > 1 {
1370 extract_shared_css(&html_files, out_path);
1376 // Handle "sitemap" subcommand
1377 if args.len() >= 2 && args[1] == "sitemap" {
1378 let dir = if args.len() >= 3 { &args[2] } else { "." };
1381 .position(|a| a == "--base-url" || a == "-b")
1382 .and_then(|i| args.get(i + 1))
1383 .map(|s| s.as_str())
1384 .unwrap_or("https://example.com");
1385 generate_sitemap(dir, base_url);
1389 // Handle "lint" subcommand
1390 if args.len() >= 2 && args[1] == "lint" {
1391 let mut lint_target = ".";
1392 let mut lint_json = false;
1394 while i < args.len() {
1395 match args[i].as_str() {
1398 if args.get(i).map(|s| s.as_str()) == Some("json") {
1402 _ if lint_target == "." => lint_target = &args[i],
1407 let path = Path::new(lint_target);
1408 let mut all_warnings = Vec::new();
1410 let hl_files = collect_hl_files_recursive(path);
1411 if hl_files.is_empty() {
1412 eprintln!("no .hl files found in {}", lint_target);
1415 for file in &hl_files {
1416 let path_str = file.to_string_lossy().to_string();
1417 all_warnings.extend(lint_file(&path_str));
1420 all_warnings.extend(lint_file(lint_target));
1423 let arr = json_array(all_warnings.iter().map(|w| {
1425 ("severity", json_escape_string("warning")),
1426 ("message", json_escape_string(w)),
1429 println!("{}", json_object(&[("diagnostics", arr)]));
1430 } else if all_warnings.is_empty() {
1431 eprintln!("no issues found");
1433 for w in &all_warnings {
1441 // Handle "stats" subcommand
1442 if args.len() >= 2 && args[1] == "stats" {
1443 let target = if args.len() >= 3 { &args[2] } else { "." };
1444 let path = Path::new(target);
1446 let hl_files = collect_hl_files_recursive(path);
1447 if hl_files.is_empty() {
1448 eprintln!("no .hl files found in {}", target);
1451 for file in &hl_files {
1452 stats_file(&file.to_string_lossy());
1460 // Handle "check" subcommand
1461 if args.len() >= 2 && args[1] == "check" {
1462 let mut check_target = None;
1463 let mut check_format_json = false;
1465 while ci < args.len() {
1466 match args[ci].as_str() {
1469 if args.get(ci).is_some_and(|v| v == "json") {
1470 check_format_json = true;
1473 _ if check_target.is_none() => check_target = Some(args[ci].as_str()),
1475 eprintln!("unknown argument: {}", args[ci]);
1481 let target = check_target.unwrap_or(".");
1482 let json_collector = if check_format_json {
1483 Some(Mutex::new(Vec::new()))
1487 let path = Path::new(target);
1488 let mut any_errors = false;
1490 let hl_files = collect_hl_files_recursive(path);
1491 if hl_files.is_empty() {
1492 eprintln!("no .hl files found in {}", target);
1495 for file in &hl_files {
1496 let path_str = file.to_string_lossy().to_string();
1497 let (has_errors, _) = compile(
1501 format_json: check_format_json,
1502 json_collector: json_collector.as_ref(),
1503 ..Default::default()
1511 let (has_errors, _) = compile(
1515 format_json: check_format_json,
1516 json_collector: json_collector.as_ref(),
1517 ..Default::default()
1524 if check_format_json && let Some(collector) = json_collector {
1525 print_json_diagnostics(&collector.lock().unwrap());
1533 // Handle "test" subcommand (run @assert directives across a project)
1534 if args.len() >= 2 && args[1] == "test" {
1535 let target = if args.len() >= 3 { &args[2] } else { "." };
1536 let path = Path::new(target);
1537 let hl_files = if path.is_dir() {
1538 collect_hl_files_recursive(path)
1540 vec![PathBuf::from(target)]
1542 if hl_files.is_empty() {
1543 eprintln!("no .hl files found in {}", target);
1546 let mut total_asserts = 0usize;
1547 let mut failed_asserts = 0usize;
1548 let mut files_with_errors = 0usize;
1549 for file in &hl_files {
1550 let input = match fs::read_to_string(file) {
1553 eprintln!("error: {}: {}", file.display(), e);
1554 files_with_errors += 1;
1558 let base = file.parent();
1559 let result = htmlang::parser::parse_with_base(&input, base);
1560 let file_errors: Vec<_> = result
1563 .filter(|d| d.severity == htmlang::parser::Severity::Error)
1565 let assert_failures: Vec<_> = file_errors
1567 .filter(|d| d.message.starts_with("assertion failed:"))
1569 total_asserts += assert_failures.len();
1570 // Count @assert lines (passes + failures)
1571 let assert_count = input
1573 .filter(|l| l.trim().starts_with("@assert "))
1575 total_asserts += assert_count.saturating_sub(assert_failures.len());
1576 if !assert_failures.is_empty() {
1577 files_with_errors += 1;
1578 failed_asserts += assert_failures.len();
1579 for d in &assert_failures {
1580 eprintln!("FAIL {}: line {}: {}", file.display(), d.line, d.message);
1584 let passed = total_asserts.saturating_sub(failed_asserts);
1586 "\n{} assertions: {} passed, {} failed ({} files)",
1592 if failed_asserts > 0 || files_with_errors > 0 {
1598 // Handle "new" subcommand
1599 if args.len() >= 2 && args[1] == "new" {
1601 eprintln!("usage: htmlang new <page-name>");
1604 let page_name = &args[2];
1605 let file_name = format!("{}.hl", page_name);
1606 let file_path = Path::new(&file_name);
1607 if file_path.exists() {
1608 eprintln!("error: {} already exists", file_name);
1611 let title = kebab_to_title(page_name);
1612 let template = format!(
1614 @let primary #3b82f6\n\
1616 @column [max-width 800, center-x, padding 40, spacing 20]\n\
1617 \x20\x20@text [bold, size 32] {title}\n\
1618 \x20\x20@paragraph [line-height 1.6]\n\
1619 \x20\x20\x20\x20Content goes here.\n",
1622 match fs::write(file_path, &template) {
1623 Ok(()) => eprintln!("created {}", file_name),
1625 eprintln!("error: {}: {}", file_name, e);
1632 // Handle "preview" subcommand
1633 if args.len() >= 2 && args[1] == "preview" {
1635 eprintln!("usage: htmlang preview <file.hl>");
1638 let file = &args[2];
1639 let tmp_dir = env::temp_dir().join("htmlang-preview");
1640 let _ = fs::create_dir_all(&tmp_dir);
1641 let out_path = tmp_dir.join("preview.html");
1642 let (has_errors, _) = compile(
1646 output_path: Some(&out_path.to_string_lossy()),
1647 ..Default::default()
1653 let url = format!("file://{}", out_path.display());
1654 let cmd = if cfg!(target_os = "macos") {
1659 let _ = std::process::Command::new(cmd).arg(&url).spawn();
1660 eprintln!("opened {}", out_path.display());
1664 // Handle "diff" subcommand
1665 if args.len() >= 2 && args[1] == "diff" {
1667 eprintln!("usage: htmlang diff <file1.hl> <file2.hl>");
1670 let file1 = &args[2];
1671 let file2 = &args[3];
1672 let compile_to_string = |path: &str| -> Result<String, String> {
1673 let input = fs::read_to_string(path).map_err(|e| format!("error: {}: {}", path, e))?;
1674 let base = Path::new(path).parent();
1675 let result = htmlang::parser::parse_with_base(&input, base);
1679 .any(|d| d.severity == htmlang::parser::Severity::Error)
1681 return Err(format!("error: {} has parse errors", path));
1683 Ok(htmlang::codegen::generate_dev(&result.document))
1685 let html1 = match compile_to_string(file1) {
1692 let html2 = match compile_to_string(file2) {
1699 let lines1: Vec<&str> = html1.lines().collect();
1700 let lines2: Vec<&str> = html2.lines().collect();
1701 let mut has_diff = false;
1702 let max_len = lines1.len().max(lines2.len());
1703 for i in 0..max_len {
1704 let l1 = lines1.get(i).unwrap_or(&"");
1705 let l2 = lines2.get(i).unwrap_or(&"");
1709 println!("- {}", l1);
1712 println!("+ {}", l2);
1717 eprintln!("no differences");
1722 // Handle "export" subcommand
1723 if args.len() >= 2 && args[1] == "export" {
1724 let mut src_dir = None;
1725 let mut out_file = None;
1727 while ei < args.len() {
1728 match args[ei].as_str() {
1729 "-o" | "--output" => {
1731 out_file = args.get(ei).map(|s| s.as_str());
1733 _ if src_dir.is_none() => src_dir = Some(args[ei].as_str()),
1735 eprintln!("unknown argument: {}", args[ei]);
1741 let src = src_dir.unwrap_or(".");
1742 let dir = Path::new(src);
1744 eprintln!("error: '{}' is not a directory", src);
1747 let hl_files = collect_hl_files_recursive(dir);
1748 if hl_files.is_empty() {
1749 eprintln!("no .hl files found in {}", src);
1752 let zip_path = out_file.unwrap_or("site.zip");
1753 // Build to temp directory, then create zip
1754 let tmp_dir = env::temp_dir().join("htmlang-export");
1755 let _ = fs::remove_dir_all(&tmp_dir);
1756 let _ = fs::create_dir_all(&tmp_dir);
1757 let mut any_errors = false;
1758 for file in &hl_files {
1759 let rel = file.strip_prefix(dir).unwrap_or(file);
1760 let out_path = tmp_dir.join(rel).with_extension("html");
1761 if let Some(parent) = out_path.parent() {
1762 let _ = fs::create_dir_all(parent);
1764 let path_str = file.to_string_lossy().to_string();
1765 let (has_errors, _) = compile(
1768 output_path: Some(&out_path.to_string_lossy()),
1769 ..Default::default()
1776 // Copy non-.hl assets
1777 copy_non_hl_files(dir, &tmp_dir);
1779 eprintln!("export completed with errors");
1781 // Create a simple tar-like zip (concatenated files with headers)
1782 // Since we can't use external crates, we'll create a self-contained directory listing
1783 let export_files = collect_all_files_recursive(&tmp_dir);
1784 let zip_file = std::fs::File::create(zip_path);
1788 // Write a simple archive format: filename\0length\0content
1789 for file in &export_files {
1790 let rel = file.strip_prefix(&tmp_dir).unwrap_or(file);
1791 if let Ok(data) = fs::read(file) {
1792 let _ = write!(f, "FILE:{}:{}:", rel.display(), data.len());
1793 let _ = f.write_all(&data);
1796 eprintln!("exported {} files to {}", export_files.len(), zip_path);
1799 eprintln!("error: {}: {}", zip_path, e);
1800 // Fallback: just report the built files in tmp_dir
1801 eprintln!("files available in {}", tmp_dir.display());
1807 // Handle "serve" standalone subcommand
1808 if args.len() >= 2 && args[1] == "serve" {
1809 let mut serve_target = None;
1810 let mut serve_port: u16 = 3000;
1811 let mut serve_open = false;
1812 let mut serve_https = false;
1813 let mut cert_path: Option<String> = None;
1814 let mut key_path: Option<String> = None;
1815 let mut _proxy_routes: Vec<(String, String)> = Vec::new();
1817 while si < args.len() {
1818 match args[si].as_str() {
1819 "-p" | "--port" => {
1821 serve_port = args.get(si).and_then(|p| p.parse().ok()).unwrap_or(3000);
1823 "--open" => serve_open = true,
1824 "--https" => serve_https = true,
1827 cert_path = args.get(si).cloned();
1831 key_path = args.get(si).cloned();
1834 // --proxy /api http://localhost:3001
1836 let prefix = args.get(si).cloned().unwrap_or_default();
1838 let target_url = args.get(si).cloned().unwrap_or_default();
1839 if !prefix.is_empty() && !target_url.is_empty() {
1840 _proxy_routes.push((prefix, target_url));
1843 _proxy_routes.last().unwrap().0,
1844 _proxy_routes.last().unwrap().1
1848 _ if serve_target.is_none() => serve_target = Some(args[si].clone()),
1850 eprintln!("unknown argument: {}", args[si]);
1856 let target = serve_target.unwrap_or_else(|| ".".to_string());
1857 let target_path = Path::new(&target);
1859 let config = load_config(target_path);
1860 let effective_port = if serve_port != 3000 {
1866 // Resolve optional TLS config. Without both --cert and --key, --https is
1867 // an error — we intentionally do not generate self-signed certificates
1868 // to avoid surprising the user with unpinned trust anchors.
1869 let tls_config = if serve_https {
1870 let cert = cert_path.clone().unwrap_or_else(|| {
1871 eprintln!("error: --https requires --cert <path> and --key <path>");
1872 eprintln!(" generate a dev cert with: mkcert localhost 127.0.0.1");
1875 let key = key_path.clone().unwrap_or_else(|| {
1876 eprintln!("error: --https requires --cert <path> and --key <path>");
1879 match htmlang::serve::load_tls_config(Path::new(&cert), Path::new(&key)) {
1880 Ok(cfg) => Some(cfg),
1882 eprintln!("error: failed to load TLS config: {}", e);
1889 let scheme = if tls_config.is_some() {
1894 let _ = scheme; // announced by watch_loop / open_in_browser downstream
1896 // Do initial compile
1897 if target_path.is_dir() {
1898 let hl_files = collect_hl_files_recursive(target_path);
1899 let mut all_included: Vec<PathBuf> = Vec::new();
1900 for file in &hl_files {
1901 let path_str = file.to_string_lossy().to_string();
1902 let effective_out = config.output.as_ref().map(|o| {
1903 let rel = file.strip_prefix(target_path).unwrap_or(file);
1904 let out_p = Path::new(o).join(rel).with_extension("html");
1905 if let Some(parent) = out_p.parent() {
1906 let _ = fs::create_dir_all(parent);
1908 out_p.to_string_lossy().to_string()
1910 let (_, included) = compile(
1914 error_overlay: true,
1915 output_path: effective_out.as_deref(),
1916 ..Default::default()
1919 all_included.extend(included);
1921 let (tx, _) = tokio::sync::broadcast::channel::<()>(16);
1922 let serve_dir = config
1926 .unwrap_or_else(|| target_path.to_path_buf());
1927 let index_path = serve_dir.join("index.html");
1928 let server_tx = tx.clone();
1929 let tls_for_thread = tls_config;
1930 std::thread::spawn(move || {
1931 let rt = tokio::runtime::Runtime::new().expect("failed to create runtime");
1932 match tls_for_thread {
1933 Some(tls) => rt.block_on(htmlang::serve::run_https(
1939 None => rt.block_on(htmlang::serve::run(effective_port, index_path, server_tx)),
1943 open_in_browser(effective_port);
1956 let (_, included) = compile(
1960 error_overlay: true,
1961 ..Default::default()
1964 let (tx, _) = tokio::sync::broadcast::channel::<()>(16);
1965 let out_path = Path::new(&target).with_extension("html");
1966 let server_tx = tx.clone();
1967 let tls_for_thread = tls_config;
1968 std::thread::spawn(move || {
1969 let rt = tokio::runtime::Runtime::new().expect("failed to create runtime");
1970 match tls_for_thread {
1971 Some(tls) => rt.block_on(htmlang::serve::run_https(
1977 None => rt.block_on(htmlang::serve::run(effective_port, out_path, server_tx)),
1981 open_in_browser(effective_port);
1983 let files = vec![PathBuf::from(&target)];
1985 Path::new(&target).parent().unwrap_or(Path::new(".")),
1998 // Handle "watch" standalone subcommand
1999 if args.len() >= 2 && args[1] == "watch" {
2000 let mut watch_target = None;
2001 let mut watch_output = None;
2003 while wi < args.len() {
2004 match args[wi].as_str() {
2005 "-o" | "--output" => {
2007 watch_output = args.get(wi).cloned();
2009 _ if watch_target.is_none() => watch_target = Some(args[wi].clone()),
2011 eprintln!("unknown argument: {}", args[wi]);
2017 let target = watch_target.unwrap_or_else(|| ".".to_string());
2018 let target_path = Path::new(&target);
2019 let config = load_config(target_path);
2020 let effective_output = watch_output.or(config.output);
2022 if target_path.is_dir() {
2023 let hl_files = collect_hl_files_recursive(target_path);
2024 if let Some(ref out) = effective_output {
2025 let _ = fs::create_dir_all(out);
2027 let mut all_included = Vec::new();
2028 for file in &hl_files {
2029 let path_str = file.to_string_lossy().to_string();
2030 let effective_out = effective_output.as_ref().map(|o| {
2031 let rel = file.strip_prefix(target_path).unwrap_or(file);
2032 let out_p = Path::new(o).join(rel).with_extension("html");
2033 if let Some(parent) = out_p.parent() {
2034 let _ = fs::create_dir_all(parent);
2036 out_p.to_string_lossy().to_string()
2038 let (_, included) = compile(
2041 output_path: effective_out.as_deref(),
2042 ..Default::default()
2045 all_included.extend(included);
2058 let (_, included) = compile(
2061 output_path: effective_output.as_deref(),
2062 ..Default::default()
2065 let files = vec![PathBuf::from(&target)];
2067 Path::new(&target).parent().unwrap_or(Path::new(".")),
2080 // Handle "repl" subcommand
2081 if args.len() >= 2 && args[1] == "repl" {
2082 use std::io::{self, BufRead, Write};
2083 eprintln!("htmlang repl — type .hl code, then a blank line to compile (Ctrl+D to exit)");
2084 let stdin = io::stdin();
2085 let stdout = io::stdout();
2088 let _ = io::stderr().flush();
2089 let mut buffer = String::new();
2091 let mut line = String::new();
2092 match stdin.lock().read_line(&mut line) {
2094 if buffer.is_empty() {
2100 if line.trim().is_empty() && !buffer.is_empty() {
2103 buffer.push_str(&line);
2108 if buffer.trim().is_empty() {
2111 let result = htmlang::parser::parse(&buffer);
2112 for d in &result.diagnostics {
2113 let prefix = severity_label(d.severity);
2114 eprintln!("{}: line {}: {}", prefix, d.line, d.message);
2119 .any(|d| d.severity == htmlang::parser::Severity::Error)
2121 let html = htmlang::codegen::generate_dev(&result.document);
2122 let _ = stdout.lock().write_all(html.as_bytes());
2123 let _ = stdout.lock().write_all(b"\n");
2128 // Handle "feed" subcommand
2129 if args.len() >= 2 && args[1] == "feed" {
2130 let dir = if args.len() >= 3 && !args[2].starts_with('-') {
2137 .position(|a| a == "--base-url" || a == "-b")
2138 .and_then(|i| args.get(i + 1))
2139 .map(|s| s.as_str())
2140 .unwrap_or("https://example.com");
2141 let dir_path = Path::new(dir);
2142 let hl_files = collect_hl_files_recursive(dir_path);
2143 if hl_files.is_empty() {
2144 eprintln!("no .hl files found in {}", dir);
2147 let mut items = Vec::new();
2148 for file in &hl_files {
2149 let input = match fs::read_to_string(file) {
2153 let base = file.parent();
2154 let result = htmlang::parser::parse_with_base(&input, base);
2155 if let Some(ref title) = result.document.page_title {
2156 let rel = file.strip_prefix(dir_path).unwrap_or(file);
2158 .with_extension("html")
2160 .replace('\\', "/");
2161 let url = if url_path == "index.html" {
2162 format!("{}/", base_url.trim_end_matches('/'))
2164 format!("{}/{}", base_url.trim_end_matches('/'), url_path)
2166 // Get description from meta tags if available
2167 let description = result
2171 .find(|(k, _)| k == "description")
2172 .map(|(_, v)| v.clone())
2173 .unwrap_or_default();
2174 items.push((title.clone(), url, description));
2177 // Generate RSS 2.0 feed
2178 let site_title = items
2180 .map(|(t, _, _)| t.clone())
2181 .unwrap_or_else(|| "My Site".to_string());
2182 let mut rss = format!(
2183 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
2184 <rss version=\"2.0\">\n\
2186 <title>{}</title>\n\
2188 <description>RSS feed generated by htmlang</description>\n",
2189 site_title, base_url
2191 for (title, url, description) in &items {
2192 rss.push_str(&format!(
2193 "<item>\n<title>{}</title>\n<link>{}</link>\n<description>{}</description>\n</item>\n",
2194 title, url, description
2197 rss.push_str("</channel>\n</rss>\n");
2198 let out_path = dir_path.join("feed.xml");
2199 match fs::write(&out_path, &rss) {
2200 Ok(()) => eprintln!("wrote {} ({} items)", out_path.display(), items.len()),
2202 eprintln!("error: {}: {}", out_path.display(), e);
2209 // Handle "components" subcommand
2210 if args.len() >= 2 && args[1] == "components" {
2211 let target = if args.len() >= 3 { &args[2] } else { "." };
2212 let path = Path::new(target);
2213 let hl_files = if path.is_dir() {
2214 collect_hl_files_recursive(path)
2216 vec![PathBuf::from(target)]
2218 if hl_files.is_empty() {
2219 eprintln!("no .hl files found in {}", target);
2222 let mut total = 0usize;
2223 for file in &hl_files {
2224 let input = match fs::read_to_string(file) {
2228 let mut found = Vec::new();
2229 for (line_num, line) in input.lines().enumerate() {
2230 let trimmed = line.trim();
2231 if let Some(rest) = trimmed.strip_prefix("@let ") {
2232 // Detect function definitions: @let name with indented body
2233 let parts: Vec<&str> = rest.split_whitespace().collect();
2234 if let Some(&name) = parts.first() {
2235 // Check if next line is indented (indicating a function body)
2236 let next_line = input.lines().nth(line_num + 1);
2237 let is_fn = next_line
2238 .map(|l| l.starts_with(" ") || l.starts_with('\t'))
2240 // Also include if it has $params (even without checking body)
2242 parts.len() > 1 && parts[1..].iter().any(|p| p.starts_with('$'));
2243 if is_fn || has_params {
2244 let params: Vec<&str> = parts[1..]
2246 .filter(|p| p.starts_with('$'))
2247 .map(|p| p.strip_prefix('$').unwrap_or(p))
2249 let param_str = if params.is_empty() {
2252 format!(" ({})", params.join(", "))
2254 found.push((line_num + 1, name.to_string(), param_str));
2259 if !found.is_empty() {
2260 let rel = file.strip_prefix(path).unwrap_or(file);
2261 for (line, name, params) in &found {
2262 println!(" @{}{} — {}:{}", name, params, rel.display(), line);
2268 eprintln!("no component definitions found");
2270 eprintln!("\n{} component(s) found", total);
2275 // Handle "deps" subcommand — show dependency graph
2276 if args.len() >= 2 && args[1] == "deps" {
2277 let target = if args.len() >= 3 { &args[2] } else { "." };
2278 let path = Path::new(target);
2279 let hl_files = if path.is_dir() {
2280 collect_hl_files_recursive(path)
2282 vec![PathBuf::from(target)]
2284 if hl_files.is_empty() {
2285 eprintln!("no .hl files found in {}", target);
2288 let mut graph: Vec<(String, Vec<String>)> = Vec::new();
2289 for file in &hl_files {
2290 let input = match fs::read_to_string(file) {
2294 let rel = file.strip_prefix(path).unwrap_or(file);
2295 let rel_str = rel.display().to_string();
2296 let mut deps = Vec::new();
2297 for line in input.lines() {
2298 let trimmed = line.trim();
2299 for directive in &["@include ", "@import ", "@extends ", "@use "] {
2300 if let Some(rest) = trimmed.strip_prefix(directive) {
2301 let dep = rest.split_whitespace().next().unwrap_or("").to_string();
2302 if !dep.is_empty() {
2308 graph.push((rel_str, deps));
2310 for (file, deps) in &graph {
2311 if deps.is_empty() {
2312 println!("{} (no dependencies)", file);
2314 println!("{}", file);
2315 for (i, dep) in deps.iter().enumerate() {
2316 let prefix = if i == deps.len() - 1 {
2321 println!(" {}{}", prefix, dep);
2328 // Handle "dead-code" subcommand — project-wide unused definitions
2329 if args.len() >= 2 && args[1] == "dead-code" {
2330 let mut dc_json = false;
2331 let mut dc_target = ".".to_string();
2333 while i < args.len() {
2334 match args[i].as_str() {
2337 if args.get(i).map(|s| s.as_str()) == Some("json") {
2341 _ if dc_target == "." => dc_target = args[i].clone(),
2346 let target = dc_target.as_str();
2347 let path = Path::new(target);
2348 let hl_files = if path.is_dir() {
2349 collect_hl_files_recursive(path)
2351 vec![PathBuf::from(target)]
2353 if hl_files.is_empty() {
2354 eprintln!("no .hl files found in {}", target);
2357 // Pass 1: collect all definitions and usages across all files
2358 let mut all_let_defs: Vec<(String, String, usize)> = Vec::new(); // (name, file, line)
2359 let mut all_refs: std::collections::HashSet<String> = std::collections::HashSet::new();
2360 for file in &hl_files {
2361 let input = match fs::read_to_string(file) {
2370 for (line_num, line) in input.lines().enumerate() {
2371 let trimmed = line.trim();
2372 if let Some(rest) = trimmed.strip_prefix("@let ")
2373 && let Some(name) = rest.split_whitespace().next()
2375 let name = name.trim_end_matches('[');
2376 all_let_defs.push((name.to_string(), rel.clone(), line_num + 1));
2378 // Collect references: @name calls and $name usages
2379 if trimmed.starts_with('@')
2380 && !trimmed.starts_with("@let ")
2381 && !trimmed.starts_with("@include ")
2382 && !trimmed.starts_with("@import ")
2383 && !trimmed.starts_with("@extends ")
2384 && !trimmed.starts_with("@page")
2385 && !trimmed.starts_with("@meta ")
2386 && !trimmed.starts_with("@og ")
2387 && !trimmed.starts_with("@head")
2388 && !trimmed.starts_with("@style")
2389 && !trimmed.starts_with("@keyframes ")
2390 && !trimmed.starts_with("@theme")
2391 && !trimmed.starts_with("@deprecated ")
2392 && !trimmed.starts_with("@breakpoint ")
2393 && !trimmed.starts_with("@use ")
2394 && !trimmed.starts_with("@slot ")
2395 && !trimmed.starts_with("@lang ")
2396 && !trimmed.starts_with("@favicon ")
2397 && !trimmed.starts_with("--")
2398 && let Some(name) = trimmed[1..].split([' ', '[']).next()
2400 all_refs.insert(name.to_string());
2402 // Collect $var references
2403 let mut rest = trimmed;
2404 while let Some(pos) = rest.find('$') {
2405 let after = &rest[pos + 1..];
2407 .find(|c: char| !c.is_alphanumeric() && c != '-' && c != '_')
2408 .unwrap_or(after.len());
2410 all_refs.insert(after[..end].to_string());
2412 rest = if end < after.len() { &after[end..] } else { "" };
2416 // Pass 2: report unused
2417 let mut unused: Vec<(String, String, String, usize)> = Vec::new(); // (kind, name, file, line)
2418 for (name, file, line) in &all_let_defs {
2419 if !all_refs.contains(name) && !name.starts_with("--") {
2420 unused.push(("@let".to_string(), name.clone(), file.clone(), *line));
2424 let arr = json_array(unused.iter().map(|(kind, name, file, line)| {
2426 ("kind", json_escape_string(kind)),
2427 ("name", json_escape_string(name)),
2428 ("file", json_escape_string(file)),
2429 ("line", line.to_string()),
2432 println!("{}", json_object(&[("unused", arr)]));
2433 } else if unused.is_empty() {
2434 eprintln!("no unused definitions found");
2436 for (kind, name, file, line) in &unused {
2437 println!("unused {} '{}' — {}:{}", kind, name, file, line);
2439 eprintln!("\n{} unused definition(s) found", unused.len());
2444 // Handle "deploy" subcommand — deploy to GitHub Pages or other providers
2445 if args.len() >= 2 && args[1] == "deploy" {
2446 let mut deploy_target = ".";
2447 let mut provider = "github-pages";
2449 while di < args.len() {
2450 match args[di].as_str() {
2451 "--provider" | "-P" => {
2453 if let Some(p) = args.get(di) {
2454 provider = match p.as_str() {
2455 "netlify" | "vercel" | "cloudflare" | "github-pages" => {
2456 // Leak to get &str lifetime — fine for CLI
2461 "unknown provider: {} (supported: github-pages, netlify, vercel, cloudflare)",
2469 _ if deploy_target == "." => deploy_target = &args[di],
2474 let target = deploy_target;
2475 let path = Path::new(target);
2477 eprintln!("error: '{}' is not a directory", target);
2481 let hl_files = collect_hl_files_recursive(path);
2482 if hl_files.is_empty() {
2483 eprintln!("no .hl files found in {}", target);
2486 let deploy_dir = path.join("_deploy");
2487 let _ = fs::create_dir_all(&deploy_dir);
2488 let any_errors = std::sync::atomic::AtomicBool::new(false);
2489 std::thread::scope(|s| {
2490 for file in &hl_files {
2491 let any_errors = &any_errors;
2492 let deploy_dir = &deploy_dir;
2494 let rel = file.strip_prefix(path).unwrap_or(file);
2495 let out_path = deploy_dir.join(rel).with_extension("html");
2496 if let Some(parent) = out_path.parent() {
2497 let _ = fs::create_dir_all(parent);
2499 let out_str = out_path.to_string_lossy().to_string();
2500 let path_str = file.to_string_lossy().to_string();
2501 let (has_errors, _) = compile(
2504 output_path: Some(&out_str),
2505 ..Default::default()
2509 any_errors.store(true, std::sync::atomic::Ordering::Relaxed);
2514 if any_errors.load(std::sync::atomic::Ordering::Relaxed) {
2515 eprintln!("error: build failed, not deploying");
2518 copy_non_hl_files(path, &deploy_dir);
2519 eprintln!("built {} files to {}", hl_files.len(), deploy_dir.display());
2521 // Provider-specific deploy
2524 eprintln!("deploying to Netlify...");
2525 let status = process::Command::new("npx")
2531 &deploy_dir.to_string_lossy(),
2535 Ok(s) if s.success() => eprintln!("deployed to Netlify!"),
2538 "netlify deploy failed — ensure netlify-cli is installed (npx netlify-cli deploy --prod --dir {})",
2539 deploy_dir.display()
2547 eprintln!("deploying to Vercel...");
2548 let status = process::Command::new("npx")
2549 .args(["vercel", "--prod", deploy_dir.to_string_lossy().as_ref()])
2552 Ok(s) if s.success() => eprintln!("deployed to Vercel!"),
2555 "vercel deploy failed — ensure vercel CLI is installed (npx vercel --prod {})",
2556 deploy_dir.display()
2564 eprintln!("deploying to Cloudflare Pages...");
2565 let status = process::Command::new("npx")
2570 deploy_dir.to_string_lossy().as_ref(),
2574 Ok(s) if s.success() => eprintln!("deployed to Cloudflare Pages!"),
2577 "cloudflare deploy failed — ensure wrangler is installed (npx wrangler pages deploy {})",
2578 deploy_dir.display()
2585 _ => {} // fall through to GitHub Pages
2588 // Deploy via gh-pages push
2589 let init_ok = matches!(
2590 process::Command::new("git")
2592 .current_dir(&deploy_dir)
2594 Ok(s) if s.success()
2597 eprintln!("error: git init failed in deploy directory");
2600 let _ = process::Command::new("git")
2601 .args(["checkout", "-b", "gh-pages"])
2602 .current_dir(&deploy_dir)
2604 let _ = process::Command::new("git")
2606 .current_dir(&deploy_dir)
2608 let commit_ok = matches!(
2609 process::Command::new("git")
2610 .args(["commit", "-m", "deploy"])
2611 .current_dir(&deploy_dir)
2613 Ok(s) if s.success()
2616 eprintln!("error: git commit failed");
2619 // Check if remote origin exists in parent
2620 let remote_output = process::Command::new("git")
2621 .args(["remote", "get-url", "origin"])
2623 if let Ok(output) = remote_output {
2624 if output.status.success() {
2625 let remote_url = String::from_utf8_lossy(&output.stdout).trim().to_string();
2626 let _ = process::Command::new("git")
2627 .args(["remote", "add", "origin", &remote_url])
2628 .current_dir(&deploy_dir)
2630 eprintln!("pushing to gh-pages branch at {}...", remote_url);
2631 let push_ok = matches!(
2632 process::Command::new("git")
2633 .args(["push", "-f", "origin", "gh-pages"])
2634 .current_dir(&deploy_dir)
2636 Ok(s) if s.success()
2640 "error: push failed — run 'git push -f origin gh-pages' manually from {}",
2641 deploy_dir.display()
2645 eprintln!("deployed to GitHub Pages!");
2648 "no git remote found — deploy directory ready at {}",
2649 deploy_dir.display()
2652 "push manually: cd {} && git remote add origin <url> && git push -f origin gh-pages",
2653 deploy_dir.display()
2660 // Handle "playground" subcommand — generate self-contained HTML playground
2661 if args.len() >= 2 && args[1] == "playground" {
2662 let out = if args.len() >= 3 {
2668 // Load pre-built WASM binary and JS glue
2669 let (wasm_bytes, js_glue) = match find_wasm_pkg() {
2672 eprintln!("error: WASM module not found. Build it first:");
2674 " cargo build --release --target wasm32-unknown-unknown -p htmlang-wasm"
2677 " wasm-bindgen --target no-modules --no-typescript --out-dir target/wasm-pkg \\"
2679 eprintln!(" target/wasm32-unknown-unknown/release/htmlang_wasm.wasm");
2683 let b64 = base64_encode(&wasm_bytes);
2684 let wasm_section = format!(
2685 "<script>\n{}\n</script>\n<script>\n\
2688 var raw=atob(b64);\n\
2689 var bytes=new Uint8Array(raw.length);\n\
2690 for(var i=0;i<raw.length;i++)bytes[i]=raw.charCodeAt(i);\n\
2691 wasm_bindgen.initSync(bytes);\n\
2692 window._wasmCompile=wasm_bindgen.compile;\n\
2697 let compile_fn = "function compileSource(src){return window._wasmCompile(src);}";
2698 let editor_content = include_str!("playground_default.hl");
2700 let playground_html = format!(
2704<meta charset="utf-8">
2705<meta name="viewport" content="width=device-width, initial-scale=1">
2706<title>htmlang Playground</title>
2708*{{box-sizing:border-box;margin:0}}
2709body{{font-family:system-ui,-apple-system,sans-serif;display:flex;height:100vh;background:#1a1a2e;color:#eee}}
2710.panel{{flex:1;display:flex;flex-direction:column;min-width:0}}
2711.header{{padding:12px 16px;background:#16213e;border-bottom:1px solid #0f3460;display:flex;align-items:center;gap:12px}}
2712.header h1{{font-size:14px;font-weight:600;color:#e94560}}
2713.header .tag{{font-size:11px;padding:2px 8px;background:#0f3460;border-radius:4px;color:#a8b2d1}}
2714.editor-wrap{{flex:1;position:relative;overflow:hidden}}
2715textarea{{width:100%;height:100%;resize:none;border:none;outline:none;padding:16px;font-family:ui-monospace,monospace;font-size:14px;line-height:1.6;background:#1a1a2e;color:#e6e6e6;tab-size:2;position:absolute;top:0;left:0}}
2716iframe{{flex:1;border:none;background:white}}
2717.divider{{width:1px;background:#0f3460;cursor:col-resize}}
2718.divider:hover{{background:#e94560}}
2719.toolbar{{padding:8px 16px;background:#16213e;border-top:1px solid #0f3460;display:flex;gap:8px;align-items:center}}
2720button{{padding:6px 16px;border:none;border-radius:4px;cursor:pointer;font-size:13px;font-weight:500}}
2721.btn-run{{background:#e94560;color:white}}
2722.btn-run:hover{{background:#c81e45}}
2723.btn-copy{{background:#0f3460;color:#a8b2d1}}
2724.btn-share{{background:#0f3460;color:#a8b2d1}}
2725.status{{margin-left:auto;font-size:12px;color:#666}}
2729<div class="panel" id="editor-panel">
2730<div class="header"><h1>htmlang</h1><span class="tag">playground</span></div>
2731<div class="editor-wrap">
2732<textarea id="editor" spellcheck="false">{editor_content}</textarea>
2734<div class="toolbar">
2735<button class="btn-run" onclick="compile()">▶ Run (Ctrl+Enter)</button>
2736<button class="btn-copy" onclick="copyOutput()">Copy HTML</button>
2737<button class="btn-share" onclick="share()">Share</button>
2738<span class="status" id="status">Ready</span>
2741<div class="divider" id="divider"></div>
2742<div class="panel" id="preview-panel">
2743<div class="header"><span class="tag">preview</span></div>
2744<iframe id="preview"></iframe>
2751 var src=document.getElementById('editor').value;
2752 var status=document.getElementById('status');
2753 var start=performance.now();
2754 var html=compileSource(src);
2755 var ms=(performance.now()-start).toFixed(1);
2757 document.getElementById('preview').srcdoc=html;
2758 status.textContent='Compiled in '+ms+'ms';
2760function copyOutput(){{if(lastOutput)navigator.clipboard.writeText(lastOutput).then(function(){{document.getElementById('status').textContent='Copied to clipboard!'}});}}
2762 var src=document.getElementById('editor').value;
2763 var encoded=btoa(unescape(encodeURIComponent(src)));
2764 var url=location.origin+location.pathname+'#'+encoded;
2765 navigator.clipboard.writeText(url).then(function(){{document.getElementById('status').textContent='Share URL copied to clipboard!'}});
2766 history.replaceState(null,'','#'+encoded);
2768function loadFromHash(){{
2769 if(location.hash.length>1){{
2771 var decoded=decodeURIComponent(escape(atob(location.hash.slice(1))));
2772 document.getElementById('editor').value=decoded;
2777var divider=document.getElementById('divider');
2778var editorPanel=document.getElementById('editor-panel');
2780divider.addEventListener('mousedown',function(){{dragging=true;document.body.style.cursor='col-resize';document.body.style.userSelect='none';}});
2781document.addEventListener('mousemove',function(e){{if(!dragging)return;var pct=(e.clientX/window.innerWidth)*100;editorPanel.style.flex='none';editorPanel.style.width=pct+'%';}});
2782document.addEventListener('mouseup',function(){{if(dragging){{dragging=false;document.body.style.cursor='';document.body.style.userSelect='';}}}});
2783// Keyboard shortcuts and auto-compile
2784var editor=document.getElementById('editor');
2785editor.addEventListener('keydown',function(e){{
2786 if(e.key==='Enter'&&(e.ctrlKey||e.metaKey)){{e.preventDefault();compile();}}
2787 if(e.key==='Tab'){{e.preventDefault();var s=e.target;var start=s.selectionStart;s.value=s.value.substring(0,start)+' '+s.value.substring(s.selectionEnd);s.selectionStart=s.selectionEnd=start+2;}}
2794 wasm_section = wasm_section,
2795 compile_fn = compile_fn
2797 if let Err(e) = fs::write(out, playground_html) {
2798 eprintln!("error writing {}: {}", out, e);
2801 eprintln!("open in browser: open {}", out);
2805 // Handle "clean" subcommand — remove generated .html files
2806 if args.len() >= 2 && args[1] == "clean" {
2807 let target = if args.len() >= 3 { &args[2] } else { "." };
2808 let dir = Path::new(target);
2810 eprintln!("error: '{}' is not a directory", target);
2813 let hl_files = collect_hl_files_recursive(dir);
2814 let mut removed = 0;
2815 for hl_file in &hl_files {
2816 let html_file = hl_file.with_extension("html");
2817 if html_file.exists() {
2818 match fs::remove_file(&html_file) {
2820 eprintln!("removed {}", html_file.display());
2823 Err(e) => eprintln!("error: {}: {}", html_file.display(), e),
2827 // Also remove sitemap.xml if present
2828 let sitemap = dir.join("sitemap.xml");
2830 && let Ok(()) = fs::remove_file(&sitemap)
2832 eprintln!("removed {}", sitemap.display());
2836 "cleaned {} file{}",
2838 if removed == 1 { "" } else { "s" }
2843 // Handle "outline" subcommand — show document structure
2844 if args.len() >= 2 && args[1] == "outline" {
2846 eprintln!("usage: htmlang outline <file.hl>");
2849 let path = &args[2];
2850 let input = match fs::read_to_string(path) {
2853 eprintln!("error: {}: {}", path, e);
2857 let base = Path::new(path).parent();
2858 let result = htmlang::parser::parse_with_base(&input, base);
2859 if let Some(title) = &result.document.page_title {
2860 eprintln!("@page {}", title);
2862 fn print_outline(nodes: &[htmlang::ast::Node], depth: usize) {
2863 let indent = " ".repeat(depth);
2866 htmlang::ast::Node::Element(elem) => {
2867 let kind = format!("{:?}", elem.kind);
2868 let kind = kind.split('(').next().unwrap_or(&kind);
2869 let label = if let Some(arg) = &elem.argument {
2870 format!("@{} {}", kind.to_lowercase(), arg)
2872 format!("@{}", kind.to_lowercase())
2874 eprintln!("{}{}", indent, label);
2875 print_outline(&elem.children, depth + 1);
2877 htmlang::ast::Node::Text(segs) => {
2878 let text: String = segs
2880 .filter_map(|s| match s {
2881 htmlang::ast::TextSegment::Plain(t) => Some(t.as_str()),
2885 if !text.trim().is_empty() {
2886 let display = if text.len() > 40 {
2887 format!("{}...", &text[..37])
2891 eprintln!("{}\"{}\"", indent, display.trim());
2894 htmlang::ast::Node::Raw(_) => {
2895 eprintln!("{}@raw ...", indent);
2900 print_outline(&result.document.nodes, 0);
2904 // Handle "doctor" subcommand — check toolchain health
2905 if args.len() >= 2 && args[1] == "doctor" {
2906 eprintln!("htmlang doctor — checking toolchain health\n");
2909 // Check htmlang version
2910 eprintln!(" htmlang: v{}", env!("CARGO_PKG_VERSION"));
2912 // Check if htmlang-lsp binary is available
2913 let lsp_status = process::Command::new("htmlang-lsp")
2917 Ok(output) if output.status.success() => {
2918 let ver = String::from_utf8_lossy(&output.stdout);
2919 eprintln!(" htmlang-lsp: {}", ver.trim());
2922 eprintln!(" htmlang-lsp: not found (optional — needed for editor integration)");
2926 // Check if cargo is available (for building from source)
2927 let cargo_status = process::Command::new("cargo").arg("--version").output();
2928 match cargo_status {
2929 Ok(output) if output.status.success() => {
2930 let ver = String::from_utf8_lossy(&output.stdout);
2931 eprintln!(" cargo: {}", ver.trim());
2934 eprintln!(" cargo: not found");
2939 // Check if git is available (for deploy)
2940 let git_status = process::Command::new("git").arg("--version").output();
2942 Ok(output) if output.status.success() => {
2943 let ver = String::from_utf8_lossy(&output.stdout);
2944 eprintln!(" git: {}", ver.trim());
2947 eprintln!(" git: not found (optional — needed for deploy)");
2951 // Check for htmlang.toml in current directory
2952 if Path::new("htmlang.toml").exists() {
2953 eprintln!(" config: htmlang.toml found");
2955 eprintln!(" config: no htmlang.toml (using defaults)");
2958 // Check for .hl files
2959 let hl_count = collect_hl_files_recursive(Path::new(".")).len();
2960 eprintln!(" .hl files: {} found in current directory", hl_count);
2964 eprintln!("all checks passed");
2966 eprintln!("some checks failed — see above");
2972 // Handle "migrate" subcommand — auto-upgrade deprecated syntax
2973 if args.len() >= 2 && args[1] == "migrate" {
2974 let target = if args.len() >= 3 { &args[2] } else { "." };
2975 let path = Path::new(target);
2976 let hl_files = if path.is_dir() {
2977 collect_hl_files_recursive(path)
2979 vec![PathBuf::from(target)]
2981 if hl_files.is_empty() {
2982 eprintln!("no .hl files found in {}", target);
2985 let mut total_changes = 0usize;
2986 for file in &hl_files {
2987 let input = match fs::read_to_string(file) {
2991 let mut output = String::new();
2992 let mut changes = 0usize;
2993 for line in input.lines() {
2994 let mut migrated = line.to_string();
2995 // Migrate @divider -> @hr
2996 if migrated.trim().starts_with("@divider") {
2997 migrated = migrated.replace("@divider", "@hr");
3000 // Migrate @ul -> @list
3001 if migrated.trim().starts_with("@ul") && !migrated.trim().starts_with("@unless") {
3002 migrated = migrated.replacen("@ul", "@list", 1);
3005 // Migrate align-center -> center-x (common mistake)
3006 if migrated.contains("align-center") {
3007 migrated = migrated.replace("align-center", "center-x");
3010 output.push_str(&migrated);
3013 // Remove trailing extra newline if original didn't end with one
3014 if !input.ends_with('\n') && output.ends_with('\n') {
3018 match fs::write(file, &output) {
3021 "migrated {} ({} change{})",
3024 if changes == 1 { "" } else { "s" }
3026 total_changes += changes;
3028 Err(e) => eprintln!("error: {}: {}", file.display(), e),
3032 if total_changes == 0 {
3033 eprintln!("no migrations needed");
3036 "\n{} total change(s) across {} file(s)",
3044 // Handle "bundle" subcommand — inline images/fonts as data URIs
3045 if args.len() >= 2 && args[1] == "bundle" {
3047 eprintln!("usage: htmlang bundle <file.hl | dir> [-o <out>]");
3050 let mut bundle_target = None;
3051 let mut bundle_out = None;
3053 while bi < args.len() {
3054 match args[bi].as_str() {
3055 "-o" | "--output" => {
3057 bundle_out = args.get(bi).map(|s| s.as_str());
3059 _ if bundle_target.is_none() => bundle_target = Some(args[bi].as_str()),
3061 eprintln!("unknown argument: {}", args[bi]);
3067 let target = bundle_target.unwrap_or(".");
3068 let path = Path::new(target);
3069 let hl_files = if path.is_dir() {
3070 collect_hl_files_recursive(path)
3072 vec![PathBuf::from(target)]
3074 if hl_files.is_empty() {
3075 eprintln!("no .hl files found in {}", target);
3078 for file in &hl_files {
3079 let input = match fs::read_to_string(file) {
3082 eprintln!("error: {}: {}", file.display(), e);
3086 let base = file.parent();
3087 let result = htmlang::parser::parse_with_base(&input, base);
3091 .any(|d| d.severity == htmlang::parser::Severity::Error)
3093 for d in &result.diagnostics {
3094 if d.severity == htmlang::parser::Severity::Error {
3095 eprintln!("error: line {}: {}", d.line, d.message);
3100 let html = htmlang::codegen::generate(&result.document);
3101 // Inline external assets: images and fonts referenced in the HTML
3102 let bundled = bundle_assets(&html, base.unwrap_or(Path::new(".")));
3103 let out_path = match bundle_out {
3105 let p = Path::new(o);
3106 if p.is_dir() || hl_files.len() > 1 {
3107 let _ = fs::create_dir_all(p);
3108 let rel = file.strip_prefix(path).unwrap_or(file);
3109 p.join(rel).with_extension("html")
3114 None => file.with_extension("html"),
3116 match fs::write(&out_path, &bundled) {
3117 Ok(()) => eprintln!("bundled {}", out_path.display()),
3118 Err(e) => eprintln!("error: {}: {}", out_path.display(), e),
3124 // Handle "size" subcommand — report output sizes with gzip estimates
3125 if args.len() >= 2 && args[1] == "size" {
3126 let target = if args.len() >= 3 { &args[2] } else { "." };
3127 let path = Path::new(target);
3128 let hl_files = if path.is_dir() {
3129 collect_hl_files_recursive(path)
3131 vec![PathBuf::from(target)]
3133 if hl_files.is_empty() {
3134 eprintln!("no .hl files found in {}", target);
3137 let mut total_source = 0usize;
3138 let mut total_output = 0usize;
3139 let mut total_minified = 0usize;
3140 for file in &hl_files {
3141 let input = match fs::read_to_string(file) {
3145 total_source += input.len();
3146 let base = file.parent();
3147 let result = htmlang::parser::parse_with_base(&input, base);
3151 .any(|d| d.severity == htmlang::parser::Severity::Error)
3155 let html = htmlang::codegen::generate(&result.document);
3156 let minified = htmlang::codegen::generate_minified(&result.document);
3157 let html_len = html.len();
3158 let min_len = minified.len();
3159 // Estimate gzip size as ~30% of minified (rough heuristic for HTML)
3160 let gzip_est = min_len * 30 / 100;
3161 total_output += html_len;
3162 total_minified += min_len;
3164 " {}: {} → {} (minified: {}, ~gzip: {})",
3166 format_bytes(input.len()),
3167 format_bytes(html_len),
3168 format_bytes(min_len),
3169 format_bytes(gzip_est),
3173 let total_gzip = total_minified * 30 / 100;
3175 " total: {} source → {} output (minified: {}, ~gzip: {})",
3176 format_bytes(total_source),
3177 format_bytes(total_output),
3178 format_bytes(total_minified),
3179 format_bytes(total_gzip),
3184 // Handle "explain" subcommand — show CSS mapping for each line
3185 if args.len() >= 2 && args[1] == "explain" {
3187 eprintln!("usage: htmlang explain <file.hl>");
3190 let file = &args[2];
3191 let input = match fs::read_to_string(file) {
3194 eprintln!("error: {}: {}", file, e);
3198 let base = Path::new(file).parent();
3199 let result = htmlang::parser::parse_with_base(&input, base);
3200 for d in &result.diagnostics {
3201 let prefix = severity_label(d.severity);
3202 eprintln!("{}: line {}: {}", prefix, d.line, d.message);
3207 .any(|d| d.severity == htmlang::parser::Severity::Error)
3211 let html = htmlang::codegen::generate_dev(&result.document);
3212 // Extract CSS class mappings from <style> block
3213 let mut class_css: HashMap<String, String> = HashMap::new();
3214 if let Some(style_start) = html.find("<style>")
3215 && let Some(style_end) = html[style_start..].find("</style>")
3217 let css_block = &html[style_start + 7..style_start + style_end];
3218 // Parse each CSS rule: .className { rules }
3220 let css_bytes = css_block.as_bytes();
3221 while pos < css_bytes.len() {
3222 if css_bytes[pos] == b'.' {
3223 let rule_start = pos;
3224 // Find class name end
3226 while pos < css_bytes.len()
3227 && css_bytes[pos] != b' '
3228 && css_bytes[pos] != b'{'
3229 && css_bytes[pos] != b':'
3230 && css_bytes[pos] != b','
3234 let class_name = &css_block[rule_start + 1..pos];
3236 while pos < css_bytes.len() && css_bytes[pos] != b'{' {
3239 if pos < css_bytes.len() {
3240 pos += 1; // skip '{'
3241 let body_start = pos;
3243 while pos < css_bytes.len() && depth > 0 {
3244 if css_bytes[pos] == b'{' {
3247 if css_bytes[pos] == b'}' {
3252 let body = css_block[body_start..pos.saturating_sub(1)].trim();
3253 if !body.is_empty() {
3255 .entry(class_name.to_string())
3256 .and_modify(|existing| {
3257 existing.push_str("; ");
3258 existing.push_str(body);
3260 .or_insert_with(|| body.to_string());
3268 // Extract data-hl-line attributes from dev HTML to map classes to source lines
3269 let mut line_classes: HashMap<usize, Vec<String>> = HashMap::new();
3270 let html_body = if let Some(body_start) = html.find("<body") {
3275 // Scan for elements with data-hl-line="N" and class="X"
3276 // Dev mode outputs: <div class="a" data-hl-line="2" data-hl-el="el">
3277 let mut scan_pos = 0;
3278 while scan_pos < html_body.len() {
3279 // Find each opening tag
3280 if html_body.as_bytes()[scan_pos] == b'<'
3281 && scan_pos + 1 < html_body.len()
3282 && html_body.as_bytes()[scan_pos + 1].is_ascii_alphabetic()
3285 let tag_end = html_body[scan_pos..]
3287 .map(|p| scan_pos + p)
3288 .unwrap_or(html_body.len());
3289 let tag = &html_body[scan_pos..tag_end];
3290 // Extract data-hl-line
3291 if let Some(hl_pos) = tag.find("data-hl-line=\"") {
3292 let after = &tag[hl_pos + 14..];
3293 if let Some(end) = after.find('"')
3294 && let Ok(ln) = after[..end].parse::<usize>()
3297 if let Some(cls_pos) = tag.find("class=\"") {
3298 let cls_after = &tag[cls_pos + 7..];
3299 if let Some(cls_end) = cls_after.find('"') {
3300 for cls in cls_after[..cls_end].split_whitespace() {
3301 line_classes.entry(ln).or_default().push(cls.to_string());
3312 // Print explanation
3313 for (line_num, line) in input.lines().enumerate() {
3314 let ln = line_num + 1;
3315 let trimmed = line.trim();
3316 if trimmed.is_empty() || trimmed.starts_with("--") {
3319 print!("{:>4} | {}", ln, line);
3320 if let Some(classes) = line_classes.get(&ln) {
3321 let mut css_parts: Vec<String> = Vec::new();
3322 for cls in classes {
3323 if let Some(css) = class_css.get(cls) {
3324 css_parts.push(css.clone());
3327 if !css_parts.is_empty() {
3329 println!(" → {}", css_parts.join("; "));
3340 // Handle "benchmark" subcommand — measure compile time and output size
3341 if args.len() >= 2 && args[1] == "benchmark" {
3343 eprintln!("usage: htmlang benchmark <file.hl | dir>");
3346 let target = &args[2];
3347 let path = Path::new(target);
3348 let hl_files = if path.is_dir() {
3349 collect_hl_files_recursive(path)
3351 vec![PathBuf::from(target)]
3353 if hl_files.is_empty() {
3354 eprintln!("no .hl files found in {}", target);
3358 let mut total_source = 0usize;
3359 let mut total_output = 0usize;
3360 let mut total_css_rules = 0usize;
3361 let mut total_elements = 0usize;
3362 let mut total_errors = 0usize;
3364 let start = std::time::Instant::now();
3365 for file in &hl_files {
3366 let input = match fs::read_to_string(file) {
3370 total_source += input.len();
3371 let base = file.parent();
3372 let result = htmlang::parser::parse_with_base(&input, base);
3373 let has_errors = result
3376 .any(|d| d.severity == htmlang::parser::Severity::Error);
3381 let html = htmlang::codegen::generate(&result.document);
3382 total_output += html.len();
3383 total_css_rules += html.matches('{').count().saturating_sub(1);
3385 let mut colors = std::collections::HashSet::new();
3386 let mut fonts = std::collections::HashSet::new();
3387 count_elements(&result.document.nodes, &mut ec, &mut colors, &mut fonts);
3388 total_elements += ec;
3390 let elapsed = start.elapsed();
3392 eprintln!("--- benchmark results ---");
3393 eprintln!(" files: {}", hl_files.len());
3394 eprintln!(" compile time: {:.1}ms", elapsed.as_secs_f64() * 1000.0);
3395 eprintln!(" source size: {} bytes", total_source);
3396 eprintln!(" output size: {} bytes", total_output);
3397 if total_source > 0 {
3400 total_output as f64 / total_source as f64
3403 eprintln!(" elements: {}", total_elements);
3404 eprintln!(" CSS rules: ~{}", total_css_rules);
3405 if total_errors > 0 {
3406 eprintln!(" errors: {} file(s) had errors", total_errors);
3408 if hl_files.len() > 1 {
3409 let per_file = elapsed.as_secs_f64() * 1000.0 / hl_files.len() as f64;
3410 eprintln!(" per file: {:.2}ms", per_file);
3415 // Handle "upgrade" subcommand — comprehensive version-to-version migration
3416 if args.len() >= 2 && args[1] == "upgrade" {
3417 let target = if args.len() >= 3 { &args[2] } else { "." };
3418 let path = Path::new(target);
3419 let hl_files = if path.is_dir() {
3420 collect_hl_files_recursive(path)
3422 vec![PathBuf::from(target)]
3424 if hl_files.is_empty() {
3425 eprintln!("no .hl files found in {}", target);
3428 let mut total_changes = 0usize;
3429 for file in &hl_files {
3430 let input = match fs::read_to_string(file) {
3434 let mut output = String::new();
3435 let mut changes = 0usize;
3436 for line in input.lines() {
3437 let mut migrated = line.to_string();
3438 // Migrate @divider -> @hr
3439 if migrated.trim().starts_with("@divider") {
3440 migrated = migrated.replace("@divider", "@hr");
3443 // Migrate @ul -> @list
3444 if migrated.trim().starts_with("@ul") && !migrated.trim().starts_with("@unless") {
3445 migrated = migrated.replacen("@ul", "@list", 1);
3448 // Migrate @col -> @column (full form)
3449 if migrated.trim().starts_with("@col ") || migrated.trim() == "@col" {
3450 migrated = migrated.replacen("@col", "@column", 1);
3453 // Migrate @img -> @image (full form)
3454 if migrated.trim().starts_with("@img ") || migrated.trim() == "@img" {
3455 migrated = migrated.replacen("@img", "@image", 1);
3458 // Migrate @p -> @paragraph (full form)
3459 if migrated.trim().starts_with("@p ") || migrated.trim() == "@p" {
3460 migrated = migrated.replacen("@p ", "@paragraph ", 1);
3463 // Migrate @btn -> @button (full form)
3464 if migrated.trim().starts_with("@btn ") || migrated.trim() == "@btn" {
3465 migrated = migrated.replacen("@btn", "@button", 1);
3468 // Migrate @li -> @item (full form)
3469 if migrated.trim().starts_with("@li ") || migrated.trim() == "@li" {
3470 migrated = migrated.replacen("@li", "@item", 1);
3473 // Migrate align-center -> center-x
3474 if migrated.contains("align-center") {
3475 migrated = migrated.replace("align-center", "center-x");
3478 // Migrate spacing -> gap (modern naming)
3479 if migrated.contains("spacing ") && migrated.contains('[') {
3480 migrated = migrated.replace("spacing ", "gap ");
3483 output.push_str(&migrated);
3486 if !input.ends_with('\n') && output.ends_with('\n') {
3490 match fs::write(file, &output) {
3493 "upgraded {} ({} change{})",
3496 if changes == 1 { "" } else { "s" }
3498 total_changes += changes;
3500 Err(e) => eprintln!("error: {}: {}", file.display(), e),
3504 if total_changes == 0 {
3505 eprintln!("no upgrades needed — all files are up to date");
3507 eprintln!("\n{} total change(s) applied", total_changes);
3512 // Handle "create-component" subcommand — scaffold a new component
3513 if args.len() >= 2 && args[1] == "create-component" {
3515 eprintln!("usage: htmlang create-component <name> [param1] [param2] ...");
3518 let comp_name = &args[2];
3519 let params: Vec<&str> = args[3..].iter().map(|s| s.as_str()).collect();
3520 let file_name = format!("{}.hl", comp_name);
3521 let file_path = Path::new(&file_name);
3522 if file_path.exists() {
3523 eprintln!("error: {} already exists", file_name);
3527 let mut template = String::new();
3528 template.push_str(&format!("-- {} component\n", comp_name));
3529 template.push_str("-- Usage: @");
3530 template.push_str(comp_name);
3531 if !params.is_empty() {
3532 template.push_str(" [");
3533 for (i, p) in params.iter().enumerate() {
3535 template.push_str(", ");
3537 template.push_str(p);
3538 template.push_str(" value");
3542 template.push('\n');
3543 template.push('\n');
3545 // Generate @let component definition
3546 template.push_str("@let ");
3547 template.push_str(comp_name);
3551 template.push_str(p);
3553 template.push('\n');
3554 template.push_str(" @el [padding 16, rounded 8, border 1 #e5e7eb]\n");
3555 if !params.is_empty() {
3557 template.push_str(&format!(" @text ${}\\n", p));
3560 template.push_str(" @children\n");
3562 match fs::write(file_path, &template) {
3564 eprintln!("created {}", file_name);
3565 eprintln!("import with: @use \"{}\" {}", file_name, comp_name);
3568 eprintln!("error: {}: {}", file_name, e);
3575 // Handle "convert" subcommand
3576 if args.len() >= 2 && args[1] == "convert" {
3578 eprintln!("usage: htmlang convert <file.html>");
3581 let html_file = &args[2];
3582 let html = match fs::read_to_string(html_file) {
3585 eprintln!("error: {}: {}", html_file, e);
3589 let hl_output = htmlang::convert::convert(&html);
3590 print!("{}", hl_output);
3595 while i < args.len() {
3596 match args[i].as_str() {
3597 "--help" | "-h" => {
3601 "--version" | "-V" => {
3602 println!("htmlang {}", env!("CARGO_PKG_VERSION"));
3605 "--watch" | "-w" => watch = true,
3606 "--dev" | "-d" => dev = true,
3607 "--check" | "-c" => check = true,
3608 "--compat" => compat = true,
3609 "--strict" => strict = true,
3610 "--open" => open_browser = true,
3611 "--partial" => partial = true,
3615 Some(f) if f == "json" => format_json = true,
3617 eprintln!("unknown format: {}", f);
3621 eprintln!("--format requires a value");
3626 "--serve" | "-s" => {
3630 "--port" | "-p" => {
3634 port = p.parse().unwrap_or_else(|_| {
3635 eprintln!("invalid port: {}", p);
3640 eprintln!("--port requires a value");
3645 "--output" | "-o" => {
3648 Some(p) => output_path = Some(p.clone()),
3650 eprintln!("--output requires a value");
3655 _ if input_path.is_none() => input_path = Some(args[i].clone()),
3657 eprintln!("unknown argument: {}", args[i]);
3664 let input_path = match input_path {
3672 let is_dir = Path::new(&input_path).is_dir();
3674 // --- Directory mode: compile all .hl files ---
3676 let dir = Path::new(&input_path);
3677 let hl_files = collect_hl_files(dir);
3678 if hl_files.is_empty() {
3679 eprintln!("no .hl files found in {}", input_path);
3683 // Create output dir if needed
3684 if let Some(ref out) = output_path {
3685 let _ = fs::create_dir_all(out);
3688 let json_collector = if format_json {
3689 Some(Mutex::new(Vec::new()))
3693 let mut any_errors = false;
3694 let mut all_included: Vec<PathBuf> = Vec::new();
3695 for file in &hl_files {
3696 let path_str = file.to_string_lossy().to_string();
3697 let effective_out = output_path.as_ref().map(|o| {
3698 let rel = file.strip_prefix(dir).unwrap_or(file);
3699 let out_p = Path::new(o).join(rel).with_extension("html");
3700 if let Some(parent) = out_p.parent() {
3701 let _ = fs::create_dir_all(parent);
3703 out_p.to_string_lossy().to_string()
3705 let (has_errors, included) = compile(
3709 error_overlay: serve,
3711 output_path: effective_out.as_deref(),
3713 json_collector: json_collector.as_ref(),
3717 ..Default::default()
3723 all_included.extend(included);
3726 if format_json && let Some(collector) = json_collector {
3727 print_json_diagnostics(&collector.lock().unwrap());
3737 // For directory serve mode, serve the directory with route mapping
3738 let reload_tx = if serve {
3739 let (tx, _) = tokio::sync::broadcast::channel::<()>(16);
3740 let serve_dir = dir.to_path_buf();
3741 let server_tx = tx.clone();
3742 std::thread::spawn(move || {
3743 let rt = tokio::runtime::Runtime::new().expect("failed to create runtime");
3744 rt.block_on(htmlang::serve::run_dir(port, serve_dir, server_tx));
3747 open_in_browser(port);
3767 // --- Single file mode ---
3768 let json_collector_single = if format_json {
3769 Some(Mutex::new(Vec::new()))
3773 let (has_errors, included_files) = compile(
3777 error_overlay: serve,
3779 output_path: output_path.as_deref(),
3781 json_collector: json_collector_single.as_ref(),
3785 ..Default::default()
3788 if format_json && let Some(ref collector) = json_collector_single {
3789 print_json_diagnostics(&collector.lock().unwrap());
3799 // Start dev server if requested
3800 let reload_tx = if serve {
3801 let (tx, _) = tokio::sync::broadcast::channel::<()>(16);
3802 let out_path = match output_path {
3803 Some(ref p) => PathBuf::from(p),
3804 None => Path::new(&input_path).with_extension("html"),
3806 let server_tx = tx.clone();
3807 std::thread::spawn(move || {
3808 let rt = tokio::runtime::Runtime::new().expect("failed to create runtime");
3809 rt.block_on(htmlang::serve::run(port, out_path, server_tx));
3812 open_in_browser(port);
3819 let files = vec![PathBuf::from(&input_path)];
3821 Path::new(&input_path).parent().unwrap_or(Path::new(".")),
3832// (CLI help, LSP launcher, and shell completions moved to cli.rs)
3834fn collect_hl_files(dir: &Path) -> Vec<PathBuf> {
3835 let mut files = Vec::new();
3836 if let Ok(entries) = fs::read_dir(dir) {
3837 for entry in entries.flatten() {
3838 let path = entry.path();
3839 if path.is_file() && path.extension().is_some_and(|e| e == "hl") {
3848#[allow(clippy::too_many_arguments)]
3851 source_files: &[PathBuf],
3852 included_files: &[PathBuf],
3855 reload_tx: Option<tokio::sync::broadcast::Sender<()>>,
3859 use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
3860 use std::sync::mpsc;
3862 let (tx, rx) = mpsc::channel();
3863 let mut watcher = RecommendedWatcher::new(
3865 if let Ok(event) = res {
3866 let _ = tx.send(event);
3871 .expect("failed to create file watcher");
3873 // Watch all source files
3874 for file in source_files {
3875 let canonical = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
3877 .watch(&canonical, RecursiveMode::NonRecursive)
3878 .unwrap_or_else(|_| eprintln!("warning: could not watch {}", file.display()));
3880 for inc in included_files {
3881 let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
3884 // Also watch the directory itself for new files
3885 let _ = watcher.watch(watch_dir, RecursiveMode::NonRecursive);
3888 eprintln!("watching for changes at http://127.0.0.1:{}", serve_port);
3890 eprintln!("watching for changes...");
3893 // Track content hashes for incremental rebuilds
3894 let mut content_hashes: HashMap<PathBuf, u64> = HashMap::new();
3895 // Dependency map: source file -> list of included/imported files. Maintained
3896 // across rebuilds so we always know the current dep graph.
3897 let mut dep_map: HashMap<PathBuf, Vec<PathBuf>> = HashMap::new();
3898 // Set of currently-watched include paths. Used to unwatch files that are
3899 // no longer referenced after a rebuild changes the dep graph.
3900 let mut watched_includes: HashSet<PathBuf> = HashSet::new();
3902 fn hash_file(path: &Path) -> Option<u64> {
3903 let content = fs::read(path).ok()?;
3904 let mut hasher = std::collections::hash_map::DefaultHasher::new();
3905 content.hash(&mut hasher);
3906 Some(hasher.finish())
3909 // Seed initial hashes and dependency map
3910 for file in source_files {
3911 let canonical = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
3912 if let Some(h) = hash_file(&canonical) {
3913 content_hashes.insert(canonical, h);
3916 // Seed dep_map from the initial compile's include list so that the first
3917 // change event correctly picks up include-file modifications even before
3918 // the first recompile replaces the entry.
3919 let initial_includes_canonical: Vec<PathBuf> = included_files
3921 .map(|p| fs::canonicalize(p).unwrap_or_else(|_| p.clone()))
3923 for inc in &initial_includes_canonical {
3924 if let Some(h) = hash_file(inc) {
3925 content_hashes.insert(inc.clone(), h);
3927 watched_includes.insert(inc.clone());
3929 // Attach the initial include list to every source file so a change to any
3930 // of them triggers a rebuild of any source. Per-file dep refinement happens
3931 // on the next recompile.
3932 if !initial_includes_canonical.is_empty() {
3933 for file in source_files {
3934 let canonical = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
3935 dep_map.insert(canonical, initial_includes_canonical.clone());
3939 while rx.recv().is_ok() {
3940 // Drain additional events (debounce) with configurable delay
3941 std::thread::sleep(std::time::Duration::from_millis(debounce_ms));
3942 while rx.try_recv().is_ok() {}
3944 // Collect which files actually changed (HashSet for O(1) lookups)
3945 let mut changed_files: HashSet<PathBuf> = HashSet::new();
3946 let check_path = |path: &Path, hashes: &mut HashMap<PathBuf, u64>| -> bool {
3947 if let Some(h) = hash_file(path)
3948 && hashes.get(path) != Some(&h)
3950 hashes.insert(path.to_path_buf(), h);
3956 for file in source_files {
3957 let canonical = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
3958 if check_path(&canonical, &mut content_hashes) {
3959 changed_files.insert(canonical);
3963 // Check included files for changes
3964 for deps in dep_map.values() {
3966 if check_path(dep, &mut content_hashes) {
3967 changed_files.insert(dep.clone());
3972 // Check for new .hl files in directory
3973 if watch_dir.is_dir() {
3974 let current_files = collect_hl_files(watch_dir);
3975 for file in ¤t_files {
3976 let canonical = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
3977 if check_path(&canonical, &mut content_hashes) {
3978 changed_files.insert(canonical.clone());
3979 let _ = watcher.watch(&canonical, RecursiveMode::NonRecursive);
3984 if changed_files.is_empty() {
3988 eprintln!("\nrecompiling...");
3990 // Determine which source files need recompilation:
3991 // 1. Source files that changed directly
3992 // 2. Source files whose dependencies changed
3993 let mut files_to_compile: Vec<PathBuf> = Vec::new();
3994 let all_sources: Vec<PathBuf> = {
3995 let mut s: Vec<PathBuf> = source_files
3997 .map(|f| fs::canonicalize(f).unwrap_or_else(|_| f.clone()))
3999 if watch_dir.is_dir() {
4000 for file in &collect_hl_files(watch_dir) {
4001 let c = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
4002 if !s.contains(&c) {
4010 for source in &all_sources {
4011 // Recompile if the source itself changed
4012 if changed_files.contains(source) {
4013 if !files_to_compile.contains(source) {
4014 files_to_compile.push(source.clone());
4018 // Recompile if any of its dependencies changed
4019 if let Some(deps) = dep_map.get(source)
4020 && deps.iter().any(|d| changed_files.contains(d))
4021 && !files_to_compile.contains(source)
4023 files_to_compile.push(source.clone());
4027 // If no specific files identified (e.g., first run), compile all
4028 if files_to_compile.is_empty() {
4029 files_to_compile = all_sources;
4032 let mut recompiled = 0usize;
4033 for file in &files_to_compile {
4034 let path_str = file.to_string_lossy().to_string();
4035 let (_, new_includes) = compile(
4039 error_overlay: serve,
4040 ..Default::default()
4044 // Update dependency map
4045 let canonical_deps: Vec<PathBuf> = new_includes
4047 .map(|p| fs::canonicalize(p).unwrap_or_else(|_| p.clone()))
4049 for inc in &canonical_deps {
4050 if watched_includes.insert(inc.clone()) {
4051 let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
4053 if let Some(h) = hash_file(inc) {
4054 content_hashes.insert(inc.clone(), h);
4057 dep_map.insert(file.clone(), canonical_deps);
4060 // Unwatch include files that are no longer referenced by any
4061 // source. Prevents file-descriptor leaks when @include edges
4062 // are removed mid-session.
4063 let still_needed: HashSet<PathBuf> = dep_map.values().flatten().cloned().collect();
4064 let to_drop: Vec<PathBuf> = watched_includes
4065 .difference(&still_needed)
4068 for path in to_drop {
4069 let _ = watcher.unwatch(&path);
4070 watched_includes.remove(&path);
4071 content_hashes.remove(&path);
4074 eprintln!("recompiled {} file(s)", recompiled);
4076 if let Some(ref tx) = reload_tx {
4077 let _ = tx.send(());
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list