@@ -159,13 +159,6 @@ fn json_escape_string(s: &str) -> String {
162−fn xml_escape(s: &str) -> String {
163− s.replace('&', "&")
164− .replace('<', "<")
165− .replace('>', ">")
166− .replace('"', """)
169fn json_array(items: impl Iterator<Item = String>) -> String {
162fn json_array(items: impl Iterator<Item = String>) -> String {
170 let inner: Vec<String> = items.collect();
163 let inner: Vec<String> = items.collect();
171 format!("[{}]", inner.join(","))
164 format!("[{}]", inner.join(","))
@@ -191,23 +184,6 @@ fn print_json_diagnostics(diagnostics: &[DiagnosticJson]) {
191 println!("{}", json_object(&[("diagnostics", arr)]));
184 println!("{}", json_object(&[("diagnostics", arr)]));
194−fn kebab_to_title(s: &str) -> String {
197− let mut chars = word.chars();
198− match chars.next() {
200− let mut title = c.to_uppercase().to_string();
201− title.extend(chars);
204− None => String::new(),
207− .collect::<Vec<_>>()
211fn format_bytes(bytes: usize) -> String {
187fn format_bytes(bytes: usize) -> String {
212 if bytes >= 1_048_576 {
188 if bytes >= 1_048_576 {
213 format!("{:.1}MB", bytes as f64 / 1_048_576.0)
189 format!("{:.1}MB", bytes as f64 / 1_048_576.0)
@@ -218,97 +194,6 @@ fn format_bytes(bytes: usize) -> String {
221−fn bundle_assets(html: &str, base_dir: &Path) -> String {
223− let mut result = html.to_string();
225− // Inline images referenced as src="..." in <img> tags
226− let img_re_patterns = ["src=\"", "url("];
227− for pattern in &img_re_patterns {
228− let mut output = String::new();
229− let mut remaining = result.as_str();
230− while let Some(pos) = remaining.find(pattern) {
231− output.push_str(&remaining[..pos + pattern.len()]);
232− remaining = &remaining[pos + pattern.len()..];
234− // Find the closing delimiter
235− let close_char = if *pattern == "src=\"" { '"' } else { ')' };
236− if let Some(end) = remaining.find(close_char) {
237− let path_str = remaining[..end].trim_matches(|c| c == '\'' || c == '"');
239− // Skip data URIs, remote URLs, and anchors
240− if !path_str.starts_with("data:")
241− && !path_str.starts_with("http://")
242− && !path_str.starts_with("https://")
243− && !path_str.starts_with('#')
244− && !path_str.is_empty()
246− let asset_path = base_dir.join(path_str);
247− if asset_path.exists()
248− && let Ok(mut file) = std::fs::File::open(&asset_path)
250− let mut buf = Vec::new();
251− if file.read_to_end(&mut buf).is_ok() {
252− let mime = match asset_path.extension().and_then(|e| e.to_str()) {
253− Some("png") => "image/png",
254− Some("jpg") | Some("jpeg") => "image/jpeg",
255− Some("gif") => "image/gif",
256− Some("svg") => "image/svg+xml",
257− Some("webp") => "image/webp",
258− Some("ico") => "image/x-icon",
259− Some("woff2") => "font/woff2",
260− Some("woff") => "font/woff",
261− Some("ttf") => "font/ttf",
262− Some("otf") => "font/otf",
263− Some("avif") => "image/avif",
264− _ => "application/octet-stream",
266− use std::fmt::Write as FmtWrite;
267− let mut b64 = String::new();
268− // Simple base64 encoding
269− let encoded = base64_encode(&buf);
270− let _ = write!(b64, "data:{};base64,{}", mime, encoded);
271− output.push_str(&b64);
272− remaining = &remaining[end..];
277− // If we couldn't inline, keep original path
278− output.push_str(&remaining[..end]);
279− remaining = &remaining[end..];
282− output.push_str(remaining);
288−fn base64_encode(data: &[u8]) -> String {
289− const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
290− let mut result = String::with_capacity(data.len().div_ceil(3) * 4);
291− for chunk in data.chunks(3) {
292− let b0 = chunk[0] as u32;
293− let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
294− let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
295− let n = (b0 << 16) | (b1 << 8) | b2;
296− result.push(CHARS[((n >> 18) & 63) as usize] as char);
297− result.push(CHARS[((n >> 12) & 63) as usize] as char);
298− if chunk.len() > 1 {
299− result.push(CHARS[((n >> 6) & 63) as usize] as char);
303− if chunk.len() > 2 {
304− result.push(CHARS[(n & 63) as usize] as char);
312fn copy_non_hl_files(src_dir: &Path, out_dir: &Path) {
197fn copy_non_hl_files(src_dir: &Path, out_dir: &Path) {
313 let skip = out_dir.canonicalize().ok();
198 let skip = out_dir.canonicalize().ok();
314 // Building in place: the assets are already where they belong, and
199 // Building in place: the assets are already where they belong, and
@@ -427,142 +312,6 @@ h1{{color:#ff6b6b;margin-bottom:1rem;font-size:1.5rem}}
430−fn init_project(dir: &str, template_name: Option<&str>) {
431− let dir = Path::new(dir);
432− if dir.to_str() != Some(".")
433− && let Err(e) = fs::create_dir_all(dir)
435− eprintln!("error: cannot create directory '{}': {}", dir.display(), e);
439− let index_path = dir.join("index.hl");
440− if index_path.exists() {
441− eprintln!("error: {} already exists", index_path.display());
445− let template = match template_name {
451−@column [max-width 720, center-x, padding 40, spacing 30]
452− @header [spacing 10]
453− @text [bold, size 36] My Blog
454− @paragraph [color #666, line-height 1.6] Thoughts and ideas.
457− @article [spacing 12, padding-bottom 30, border-bottom 1 #eee]
458− @text [bold, size 24] First Post
459− @text [color #888, size 14] 2024-01-15
460− @paragraph [line-height 1.8]
461− Welcome to my blog. This is the first post.
463− @article [spacing 12, padding-bottom 30, border-bottom 1 #eee]
464− @text [bold, size 24] Another Post
465− @text [color #888, size 14] 2024-01-10
466− @paragraph [line-height 1.8]
467− Here is another post with more content.
469− @footer [padding-top 20, border-top 1 #eee]
470− @text [color #888, size 14, text-align center] Built with htmlang
474− r#"@page Documentation
476−@let sidebar-width 250
478−@row [min-height 100vh]
479− @aside [width $sidebar-width, padding 20, background #f8f9fa, border-right 1 #e0e0e0, spacing 8]
480− @text [bold, size 18, padding-bottom 10] Docs
482− @link # Getting Started
483− @link # Installation
484− @link # Configuration
485− @link # API Reference
487− @main [width fill, padding 40, max-width 800, spacing 20]
488− @text [bold, size 32] Getting Started
489− @paragraph [line-height 1.8]
490− Welcome to the documentation. Use the sidebar to navigate.
492− @text [bold, size 24] Installation
493− @code [padding 16, background #f5f5f5, rounded 8]
494− npm install my-package
496− @text [bold, size 24] Usage
497− @paragraph [line-height 1.8]
498− Import and use the library in your project.
501− Some("portfolio") => {
506−@column [min-height 100vh, spacing 0]
507− @header [padding 20 40, background white, border-bottom 1 #eee]
508− @row [max-width 1200, center-x, width fill, justify-content space-between, align-items center]
509− @text [bold, size 20] Jane Doe
516− @main [max-width 1200, center-x, padding 60 40, spacing 60]
517− @column [spacing 10, center-x, text-align center, max-width 600]
518− @text [bold, size 48] Designer & Developer
519− @paragraph [color #666, size 18, line-height 1.6]
520− I create beautiful, functional digital experiences.
522− @grid [grid-cols 3, gap 20]
523− @el [aspect-ratio 1, background #f0f0f0, rounded 12, padding 20, spacing 10]
524− @text [bold] Project One
525− @text [color #666, size 14] Web Design
527− @el [aspect-ratio 1, background #f0f0f0, rounded 12, padding 20, spacing 10]
528− @text [bold] Project Two
529− @text [color #666, size 14] Branding
531− @el [aspect-ratio 1, background #f0f0f0, rounded 12, padding 20, spacing 10]
532− @text [bold] Project Three
533− @text [color #666, size 14] Development
535− @footer [padding 30 40, background #1a1a2e, color white, text-align center]
536− @text [size 14] Built with htmlang
543−@column [max-width 800, center-x, padding 40, spacing 20]
544− @text [bold, size 32] Hello, htmlang!
546− @paragraph [line-height 1.6]
547− Edit {@text [bold, color $primary] index.hl} and run
548− {@text [font monospace, size 14] htmlang -s .} to get started.
551− @el [padding 12 24, background $primary, rounded 8, cursor pointer, hover:background #2563eb, transition all 0.15s ease] > @link https://github.com/nicholasgasior/htmlang
552− @text [color white, bold] Documentation
557− match fs::write(&index_path, template) {
558− Ok(()) => eprintln!("created {}", index_path.display()),
560− eprintln!("error: {}: {}", index_path.display(), e);
566fn collect_hl_files_recursive(dir: &Path) -> Vec<PathBuf> {
315fn collect_hl_files_recursive(dir: &Path) -> Vec<PathBuf> {
567 let mut files = Vec::new();
316 let mut files = Vec::new();
568 collect_hl_recursive_inner(dir, &mut files);
317 collect_hl_recursive_inner(dir, &mut files);
@@ -590,106 +339,6 @@ fn collect_hl_recursive_inner(dir: &Path, files: &mut Vec<PathBuf>) {
593−fn collect_all_files_recursive(dir: &Path) -> Vec<PathBuf> {
594− let mut files = Vec::new();
595− fn walk(dir: &Path, files: &mut Vec<PathBuf>) {
596− if let Ok(entries) = fs::read_dir(dir) {
597− for entry in entries.flatten() {
598− let path = entry.path();
601− } else if path.is_file() {
607− walk(dir, &mut files);
612−fn generate_sitemap(dir: &str, base_url: &str) {
613− let dir = Path::new(dir);
614− let hl_files = collect_hl_files_recursive(dir);
615− if hl_files.is_empty() {
616− eprintln!("no .hl files found in {}", dir.display());
619− let mut xml = String::from(
620− "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n",
622− for file in &hl_files {
623− let rel = file.strip_prefix(dir).unwrap_or(file);
625− .with_extension("html")
627− .replace('\\', "/");
628− let url = if url_path == "index.html" {
629− format!("{}/", base_url.trim_end_matches('/'))
631− format!("{}/{}", base_url.trim_end_matches('/'), url_path)
634− // Get file modification time for <lastmod>
638− .and_then(|m| m.modified().ok())
641− .duration_since(std::time::UNIX_EPOCH)
642− .unwrap_or_default()
644− let days = secs / 86400;
645− // Simple date calculation from epoch days
646− let (y, m, d) = epoch_days_to_date(days);
647− format!("{:04}-{:02}-{:02}", y, m, d)
650− // Heuristic priority: index pages get higher priority
651− let priority = if url_path == "index.html" {
653− } else if rel.components().count() <= 2 {
659− xml.push_str(" <url>\n");
660− xml.push_str(&format!(" <loc>{}</loc>\n", xml_escape(&url)));
661− if let Some(ref date) = lastmod {
662− xml.push_str(&format!(" <lastmod>{}</lastmod>\n", date));
664− xml.push_str(&format!(" <priority>{}</priority>\n", priority));
665− xml.push_str(" </url>\n");
667− xml.push_str("</urlset>\n");
668− let out_path = dir.join("sitemap.xml");
669− match fs::write(&out_path, &xml) {
670− Ok(()) => eprintln!("wrote {} ({} URLs)", out_path.display(), hl_files.len()),
672− eprintln!("error: {}: {}", out_path.display(), e);
678−fn epoch_days_to_date(days: u64) -> (u64, u64, u64) {
679− // Algorithm from http://howardhinnant.github.io/date_algorithms.html
680− let z = days + 719468;
681− let era = z / 146097;
682− let doe = z - era * 146097;
683− let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
684− let y = yoe + era * 400;
685− let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
686− let mp = (5 * doy + 2) / 153;
687− let d = doy - (153 * mp + 2) / 5 + 1;
688− let m = if mp < 10 { mp + 3 } else { mp - 9 };
689− let y = if m <= 2 { y + 1 } else { y };
693fn lint_file(path: &str) -> Vec<String> {
342fn lint_file(path: &str) -> Vec<String> {
694 let input = match fs::read_to_string(path) {
343 let input = match fs::read_to_string(path) {
695 Ok(s) => s,
344 Ok(s) => s,
@@ -789,122 +438,6 @@ fn lint_nodes(nodes: &[htmlang::ast::Node], path: &str, depth: usize, warnings:
792−fn stats_file(path: &str) {
793− let input = match fs::read_to_string(path) {
796− eprintln!("error: {}: {}", path, e);
800− let base = Path::new(path).parent();
801− let result = htmlang::parser::parse_with_base(&input, base);
802− let html = htmlang::codegen::generate(&result.document);
804− let mut element_count = 0;
805− let mut colors = std::collections::HashSet::new();
806− let mut fonts = std::collections::HashSet::new();
808− &result.document.nodes,
814− // Count CSS rules (approximate from generated style block)
815− let css_rules = html.matches('{').count().saturating_sub(1); // subtract the html/head/body structure
817− let source_bytes = input.len();
818− let output_bytes = html.len();
820− eprintln!("--- {} ---", path);
822− " source size: {} bytes ({} lines)",
824− input.lines().count()
826− eprintln!(" output size: {} bytes", output_bytes);
827− eprintln!(" elements: {}", element_count);
828− eprintln!(" CSS rules: ~{}", css_rules);
829− eprintln!(" unique colors: {}", colors.len());
830− if !colors.is_empty() {
831− let mut sorted: Vec<_> = colors.iter().collect();
837− .map(|s| s.as_str())
838− .collect::<Vec<_>>()
842− eprintln!(" unique fonts: {}", fonts.len());
843− if !fonts.is_empty() {
844− let mut sorted: Vec<_> = fonts.iter().collect();
850− .map(|s| s.as_str())
851− .collect::<Vec<_>>()
858− .any(|d| d.severity == htmlang::parser::Severity::Error)
865− .filter(|d| d.severity == htmlang::parser::Severity::Error)
869− let warn_count = result
872− .filter(|d| d.severity == htmlang::parser::Severity::Warning)
875− eprintln!(" warnings: {}", warn_count);
880− nodes: &[htmlang::ast::Node],
882− colors: &mut std::collections::HashSet<String>,
883− fonts: &mut std::collections::HashSet<String>,
886− if let htmlang::ast::Node::Element(elem) = node {
888− for attr in &elem.attrs {
889− let key = attr.key.as_str();
890− // Strip pseudo/media prefixes for color/font detection
891− let base_key = key.split(':').next_back().unwrap_or(key);
892− if matches!(base_key, "color" | "background")
893− && let Some(ref v) = attr.value
895− colors.insert(v.clone());
897− if base_key == "font"
898− && let Some(ref v) = attr.value
900− fonts.insert(v.clone());
903− count_elements(&elem.children, count, colors, fonts);
908/// Whether the build cache record at `path` matches `key` and every
441/// Whether the build cache record at `path` matches `key` and every
909/// dependency it lists still has the recorded content hash.
442/// dependency it lists still has the recorded content hash.
910fn build_cache_is_fresh(path: &Path, key: u64) -> bool {
443fn build_cache_is_fresh(path: &Path, key: u64) -> bool {
@@ -1177,26 +710,6 @@ fn load_config(target: &Path) -> ProjectConfig {
1180−fn find_wasm_pkg() -> Option<(Vec<u8>, String)> {
1181− // Look for pre-built WASM binary and JS glue in known locations
1183− // Relative to current executable
1184− std::env::current_exe()
1186− .and_then(|p| p.parent().map(|d| d.join("..").join("wasm-pkg"))),
1187− // In the project build directory (development)
1188− Some(PathBuf::from("target/wasm-pkg")),
1190− for dir in candidates.iter().flatten() {
1191− let wasm = dir.join("htmlang_wasm_bg.wasm");
1192− let js = dir.join("htmlang_wasm.js");
1193− if let (Ok(wasm_bytes), Ok(js_str)) = (fs::read(&wasm), fs::read_to_string(&js)) {
1194− return Some((wasm_bytes, js_str));
1200fn main() {
713fn main() {
1201 let args: Vec<String> = env::args().collect();
714 let args: Vec<String> = env::args().collect();
1202 let mut watch = false;
715 let mut watch = false;
@@ -1212,38 +725,28 @@ fn main() {
1212 let mut output_path: Option<String> = None;
725 let mut output_path: Option<String> = None;
1213 let mut input_path = None;
726 let mut input_path = None;
728+ // A bare word that isn't a path is a mistyped (or removed) subcommand.
729+ if let Some(first) = args.get(1)
730+ && !first.starts_with('-')
731+ && !cli::COMMANDS.contains(&first.as_str())
732+ && !first.ends_with(".hl")
733+ && !Path::new(first).exists()
736+ "error: unknown command '{}'\ncommands: {}",
738+ cli::COMMANDS.join(", ")
1215 // Handle "lsp" subcommand — launch the LSP server from the main binary
743 // Handle "lsp" subcommand — launch the LSP server from the main binary
1216 if args.len() >= 2 && args[1] == "lsp" {
744 if args.len() >= 2 && args[1] == "lsp" {
1217 cli::run_lsp();
745 cli::run_lsp();
1221− // Handle "completions" subcommand — generate shell completions
1222− if args.len() >= 2 && args[1] == "completions" {
1223− let shell = args.get(2).map(|s| s.as_str()).unwrap_or("bash");
1224− cli::print_shell_completions(shell);
1228− // Handle "init" subcommand
1229− if args.len() >= 2 && args[1] == "init" {
1230− let mut init_dir = ".";
1231− let mut init_template: Option<&str> = None;
1233− while i < args.len() {
1234− match args[i].as_str() {
1235− "--template" | "-t" => {
1237− init_template = args.get(i).map(|s| s.as_str());
1239− _ if init_dir == "." => init_dir = &args[i],
1244− init_project(init_dir, init_template);
1248 // Handle "fmt" subcommand
751 // Handle "fmt" subcommand
1249 if args.len() >= 3 && args[1] == "fmt" {
752 if args.len() >= 3 && args[1] == "fmt" {
@@ -1439,18 +942,6 @@ fn main() {
1442− // Handle "sitemap" subcommand
1443− if args.len() >= 2 && args[1] == "sitemap" {
1444− let dir = if args.len() >= 3 { &args[2] } else { "." };
1445− let base_url = args
1447− .position(|a| a == "--base-url" || a == "-b")
1448− .and_then(|i| args.get(i + 1))
1449− .map(|s| s.as_str())
1450− .unwrap_or("https://example.com");
1451− generate_sitemap(dir, base_url);
1455 // Handle "lint" subcommand
946 // Handle "lint" subcommand
1456 if args.len() >= 2 && args[1] == "lint" {
947 if args.len() >= 2 && args[1] == "lint" {
@@ -1504,24 +995,6 @@ fn main() {
1507− // Handle "stats" subcommand
1508− if args.len() >= 2 && args[1] == "stats" {
1509− let target = if args.len() >= 3 { &args[2] } else { "." };
1510− let path = Path::new(target);
1512− let hl_files = collect_hl_files_recursive(path);
1513− if hl_files.is_empty() {
1514− eprintln!("no .hl files found in {}", target);
1517− for file in &hl_files {
1518− stats_file(&file.to_string_lossy());
1521− stats_file(target);
1526 // Handle "check" subcommand
999 // Handle "check" subcommand
1527 if args.len() >= 2 && args[1] == "check" {
1000 if args.len() >= 2 && args[1] == "check" {
@@ -1596,301 +1069,32 @@ fn main() {
1599− // Handle "test" subcommand (run @assert directives across a project)
1600− if args.len() >= 2 && args[1] == "test" {
1601− let target = if args.len() >= 3 { &args[2] } else { "." };
1602− let path = Path::new(target);
1603− let hl_files = if path.is_dir() {
1604− collect_hl_files_recursive(path)
1606− vec![PathBuf::from(target)]
1608− if hl_files.is_empty() {
1609− eprintln!("no .hl files found in {}", target);
1612− let mut total_asserts = 0usize;
1613− let mut failed_asserts = 0usize;
1614− let mut files_with_errors = 0usize;
1615− for file in &hl_files {
1616− let input = match fs::read_to_string(file) {
1619− eprintln!("error: {}: {}", file.display(), e);
1620− files_with_errors += 1;
1624− let base = file.parent();
1625− let result = htmlang::parser::parse_with_base(&input, base);
1626− let file_errors: Vec<_> = result
1629− .filter(|d| d.severity == htmlang::parser::Severity::Error)
1631− let assert_failures: Vec<_> = file_errors
1633− .filter(|d| d.message.starts_with("assertion failed:"))
1635− total_asserts += assert_failures.len();
1636− // Count @assert lines (passes + failures)
1637− let assert_count = input
1639− .filter(|l| l.trim().starts_with("@assert "))
1641− total_asserts += assert_count.saturating_sub(assert_failures.len());
1642− if !assert_failures.is_empty() {
1643− files_with_errors += 1;
1644− failed_asserts += assert_failures.len();
1645− for d in &assert_failures {
1646− eprintln!("FAIL {}: line {}: {}", file.display(), d.line, d.message);
1650− let passed = total_asserts.saturating_sub(failed_asserts);
1652− "\n{} assertions: {} passed, {} failed ({} files)",
1658− if failed_asserts > 0 || files_with_errors > 0 {
1664− // Handle "new" subcommand
1665− if args.len() >= 2 && args[1] == "new" {
1666− if args.len() < 3 {
1667− eprintln!("usage: htmlang new <page-name>");
1670− let page_name = &args[2];
1671− let file_name = format!("{}.hl", page_name);
1672− let file_path = Path::new(&file_name);
1673− if file_path.exists() {
1674− eprintln!("error: {} already exists", file_name);
1677− let title = kebab_to_title(page_name);
1678− let template = format!(
1680− @let primary #3b82f6\n\
1682− @column [max-width 800, center-x, padding 40, spacing 20]\n\
1683− \x20\x20@text [bold, size 32] {title}\n\
1684− \x20\x20@paragraph [line-height 1.6]\n\
1685− \x20\x20\x20\x20Content goes here.\n",
1688− match fs::write(file_path, &template) {
1689− Ok(()) => eprintln!("created {}", file_name),
1691− eprintln!("error: {}: {}", file_name, e);
1698− // Handle "preview" subcommand
1699− if args.len() >= 2 && args[1] == "preview" {
1700− if args.len() < 3 {
1701− eprintln!("usage: htmlang preview <file.hl>");
1704− let file = &args[2];
1705− let tmp_dir = env::temp_dir().join("htmlang-preview");
1706− let _ = fs::create_dir_all(&tmp_dir);
1707− let out_path = tmp_dir.join("preview.html");
1708− let (has_errors, _) = compile(
1712− output_path: Some(&out_path.to_string_lossy()),
1713− ..Default::default()
1719− let url = format!("file://{}", out_path.display());
1720− let cmd = if cfg!(target_os = "macos") {
1725− let _ = std::process::Command::new(cmd).arg(&url).spawn();
1726− eprintln!("opened {}", out_path.display());
1730− // Handle "diff" subcommand
1077+ // Handle "serve" standalone subcommand
1731− if args.len() >= 2 && args[1] == "diff" {
1078+ if args.len() >= 2 && args[1] == "serve" {
1732− if args.len() < 4 {
1079+ let mut serve_target = None;
1733− eprintln!("usage: htmlang diff <file1.hl> <file2.hl>");
1080+ let mut serve_port: u16 = 3000;
1734− process::exit(1);
1081+ let mut serve_open = false;
1735− }
1082+ let mut serve_https = false;
1736− let file1 = &args[2];
1083+ let mut cert_path: Option<String> = None;
1737− let file2 = &args[3];
1084+ let mut key_path: Option<String> = None;
1738− let compile_to_string = |path: &str| -> Result<String, String> {
1085+ let mut _proxy_routes: Vec<(String, String)> = Vec::new();
1739− let input = fs::read_to_string(path).map_err(|e| format!("error: {}: {}", path, e))?;
1086+ let mut si = 2;
1740− let base = Path::new(path).parent();
1087+ while si < args.len() {
1741− let result = htmlang::parser::parse_with_base(&input, base);
1088+ match args[si].as_str() {
1742− if result
1089+ "-p" | "--port" => {
1743− .diagnostics
1090+ si += 1;
1744− .iter()
1091+ serve_port = args.get(si).and_then(|p| p.parse().ok()).unwrap_or(3000);
1745− .any(|d| d.severity == htmlang::parser::Severity::Error)
1747− return Err(format!("error: {} has parse errors", path));
1749− Ok(htmlang::codegen::generate_dev(&result.document))
1751− let html1 = match compile_to_string(file1) {
1754− eprintln!("{}", e);
1758− let html2 = match compile_to_string(file2) {
1761− eprintln!("{}", e);
1765− let lines1: Vec<&str> = html1.lines().collect();
1766− let lines2: Vec<&str> = html2.lines().collect();
1767− let mut has_diff = false;
1768− let max_len = lines1.len().max(lines2.len());
1769− for i in 0..max_len {
1770− let l1 = lines1.get(i).unwrap_or(&"");
1771− let l2 = lines2.get(i).unwrap_or(&"");
1774− if !l1.is_empty() {
1775− println!("- {}", l1);
1777− if !l2.is_empty() {
1778− println!("+ {}", l2);
1783− eprintln!("no differences");
1788− // Handle "export" subcommand
1789− if args.len() >= 2 && args[1] == "export" {
1790− let mut src_dir = None;
1791− let mut out_file = None;
1793− while ei < args.len() {
1794− match args[ei].as_str() {
1795− "-o" | "--output" => {
1797− out_file = args.get(ei).map(|s| s.as_str());
1799− _ if src_dir.is_none() => src_dir = Some(args[ei].as_str()),
1801− eprintln!("unknown argument: {}", args[ei]);
1807− let src = src_dir.unwrap_or(".");
1808− let dir = Path::new(src);
1810− eprintln!("error: '{}' is not a directory", src);
1813− let hl_files = collect_hl_files_recursive(dir);
1814− if hl_files.is_empty() {
1815− eprintln!("no .hl files found in {}", src);
1818− let zip_path = out_file.unwrap_or("site.zip");
1819− // Build to temp directory, then create zip
1820− let tmp_dir = env::temp_dir().join("htmlang-export");
1821− let _ = fs::remove_dir_all(&tmp_dir);
1822− let _ = fs::create_dir_all(&tmp_dir);
1823− let mut any_errors = false;
1824− for file in &hl_files {
1825− let rel = file.strip_prefix(dir).unwrap_or(file);
1826− let out_path = tmp_dir.join(rel).with_extension("html");
1827− if let Some(parent) = out_path.parent() {
1828− let _ = fs::create_dir_all(parent);
1830− let path_str = file.to_string_lossy().to_string();
1831− let (has_errors, _) = compile(
1834− output_path: Some(&out_path.to_string_lossy()),
1835− ..Default::default()
1842− // Copy non-.hl assets
1843− copy_non_hl_files(dir, &tmp_dir);
1845− eprintln!("export completed with errors");
1847− // Create a simple tar-like zip (concatenated files with headers)
1848− // Since we can't use external crates, we'll create a self-contained directory listing
1849− let export_files = collect_all_files_recursive(&tmp_dir);
1850− let zip_file = std::fs::File::create(zip_path);
1853− use std::io::Write;
1854− // Write a simple archive format: filename\0length\0content
1855− for file in &export_files {
1856− let rel = file.strip_prefix(&tmp_dir).unwrap_or(file);
1857− if let Ok(data) = fs::read(file) {
1858− let _ = write!(f, "FILE:{}:{}:", rel.display(), data.len());
1859− let _ = f.write_all(&data);
1862− eprintln!("exported {} files to {}", export_files.len(), zip_path);
1865− eprintln!("error: {}: {}", zip_path, e);
1866− // Fallback: just report the built files in tmp_dir
1867− eprintln!("files available in {}", tmp_dir.display());
1873− // Handle "serve" standalone subcommand
1874− if args.len() >= 2 && args[1] == "serve" {
1875− let mut serve_target = None;
1876− let mut serve_port: u16 = 3000;
1877− let mut serve_open = false;
1878− let mut serve_https = false;
1879− let mut cert_path: Option<String> = None;
1880− let mut key_path: Option<String> = None;
1881− let mut _proxy_routes: Vec<(String, String)> = Vec::new();
1883− while si < args.len() {
1884− match args[si].as_str() {
1885− "-p" | "--port" => {
1887− serve_port = args.get(si).and_then(|p| p.parse().ok()).unwrap_or(3000);
1889− "--open" => serve_open = true,
1093+ "--open" => serve_open = true,
1890− "--https" => serve_https = true,
1094+ "--https" => serve_https = true,
1891− "--cert" => {
1095+ "--cert" => {
1892− si += 1;
1096+ si += 1;
1893− cert_path = args.get(si).cloned();
1097+ cert_path = args.get(si).cloned();
1895 "--key" => {
1099 "--key" => {
1896 si += 1;
1100 si += 1;
@@ -2171,206 +1375,23 @@ fn main() {
2174− // Handle "repl" subcommand
2175− if args.len() >= 2 && args[1] == "repl" {
2176− use std::io::{self, BufRead, Write};
2177− eprintln!("htmlang repl — type .hl code, then a blank line to compile (Ctrl+D to exit)");
2178− let stdin = io::stdin();
2179− let stdout = io::stdout();
2182− let _ = io::stderr().flush();
2183− let mut buffer = String::new();
2185− let mut line = String::new();
2186− match stdin.lock().read_line(&mut line) {
2188− if buffer.is_empty() {
2194− if line.trim().is_empty() && !buffer.is_empty() {
2197− buffer.push_str(&line);
2202− if buffer.trim().is_empty() {
2205− let result = htmlang::parser::parse(&buffer);
2206− for d in &result.diagnostics {
2207− let prefix = severity_label(d.severity);
2208− eprintln!("{}: line {}: {}", prefix, d.line, d.message);
2213− .any(|d| d.severity == htmlang::parser::Severity::Error)
2215− let html = htmlang::codegen::generate_dev(&result.document);
2216− let _ = stdout.lock().write_all(html.as_bytes());
2217− let _ = stdout.lock().write_all(b"\n");
2222− // Handle "feed" subcommand
2223− if args.len() >= 2 && args[1] == "feed" {
2224− let dir = if args.len() >= 3 && !args[2].starts_with('-') {
2229− let base_url = args
2231− .position(|a| a == "--base-url" || a == "-b")
2232− .and_then(|i| args.get(i + 1))
2233− .map(|s| s.as_str())
2234− .unwrap_or("https://example.com");
2235− let dir_path = Path::new(dir);
2236− let hl_files = collect_hl_files_recursive(dir_path);
2237− if hl_files.is_empty() {
2238− eprintln!("no .hl files found in {}", dir);
2241− let mut items = Vec::new();
2242− for file in &hl_files {
2243− let input = match fs::read_to_string(file) {
2245− Err(_) => continue,
2247− let base = file.parent();
2248− let result = htmlang::parser::parse_with_base(&input, base);
2249− if let Some(ref title) = result.document.page_title {
2250− let rel = file.strip_prefix(dir_path).unwrap_or(file);
2252− .with_extension("html")
2254− .replace('\\', "/");
2255− let url = if url_path == "index.html" {
2256− format!("{}/", base_url.trim_end_matches('/'))
2258− format!("{}/{}", base_url.trim_end_matches('/'), url_path)
2260− // Get description from meta tags if available
2261− let description = result
2265− .find(|(k, _)| k == "description")
2266− .map(|(_, v)| v.clone())
2267− .unwrap_or_default();
2268− items.push((title.clone(), url, description));
2271− // Generate RSS 2.0 feed
2272− let site_title = items
2274− .map(|(t, _, _)| t.clone())
2275− .unwrap_or_else(|| "My Site".to_string());
2276− let mut rss = format!(
2277− "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
2278− <rss version=\"2.0\">\n\
2280− <title>{}</title>\n\
2282− <description>RSS feed generated by htmlang</description>\n",
2283− xml_escape(&site_title),
2284− xml_escape(base_url)
2286− for (title, url, description) in &items {
2287− rss.push_str(&format!(
2288− "<item>\n<title>{}</title>\n<link>{}</link>\n<description>{}</description>\n</item>\n",
2291− xml_escape(description)
2294− rss.push_str("</channel>\n</rss>\n");
2295− let out_path = dir_path.join("feed.xml");
2296− match fs::write(&out_path, &rss) {
2297− Ok(()) => eprintln!("wrote {} ({} items)", out_path.display(), items.len()),
2299− eprintln!("error: {}: {}", out_path.display(), e);
2306− // Handle "components" subcommand
2307− if args.len() >= 2 && args[1] == "components" {
2308− let target = if args.len() >= 3 { &args[2] } else { "." };
2309− let path = Path::new(target);
2310− let hl_files = if path.is_dir() {
2311− collect_hl_files_recursive(path)
2313− vec![PathBuf::from(target)]
2315− if hl_files.is_empty() {
2316− eprintln!("no .hl files found in {}", target);
2319− let mut total = 0usize;
2320− for file in &hl_files {
2321− let input = match fs::read_to_string(file) {
2323− Err(_) => continue,
2325− let mut found = Vec::new();
2326− for (line_num, line) in input.lines().enumerate() {
2327− let trimmed = line.trim();
2328− if let Some(rest) = trimmed.strip_prefix("@let ") {
2329− // Detect function definitions: @let name with indented body
2330− let parts: Vec<&str> = rest.split_whitespace().collect();
2331− if let Some(&name) = parts.first() {
2332− // Check if next line is indented (indicating a function body)
2333− let next_line = input.lines().nth(line_num + 1);
2334− let is_fn = next_line
2335− .map(|l| l.starts_with(" ") || l.starts_with('\t'))
2337− // Also include if it has $params (even without checking body)
2339− parts.len() > 1 && parts[1..].iter().any(|p| p.starts_with('$'));
2340− if is_fn || has_params {
2341− let params: Vec<&str> = parts[1..]
2343− .filter(|p| p.starts_with('$'))
2344− .map(|p| p.strip_prefix('$').unwrap_or(p))
2346− let param_str = if params.is_empty() {
2349− format!(" ({})", params.join(", "))
2351− found.push((line_num + 1, name.to_string(), param_str));
2356− if !found.is_empty() {
2357− let rel = file.strip_prefix(path).unwrap_or(file);
2358− for (line, name, params) in &found {
2359− println!(" @{}{} — {}:{}", name, params, rel.display(), line);
2365− eprintln!("no component definitions found");
2367− eprintln!("\n{} component(s) found", total);
2372− // Handle "deps" subcommand — show dependency graph
1381+
1392+ // Handle "upgrade" subcommand: rewrite removed or renamed syntax to its
2373− if args.len() >= 2 && args[1] == "deps" {
1394+ if args.len() >= 2 && args[1] == "upgrade" {
2374 let target = if args.len() >= 3 { &args[2] } else { "." };
1395 let target = if args.len() >= 3 { &args[2] } else { "." };
2375 let path = Path::new(target);
1396 let path = Path::new(target);
2376 let hl_files = if path.is_dir() {
1397 let hl_files = if path.is_dir() {
@@ -2382,1174 +1403,44 @@ fn main() {
2382 eprintln!("no .hl files found in {}", target);
1403 eprintln!("no .hl files found in {}", target);
2383 process::exit(1);
1404 process::exit(1);
2385− let mut graph: Vec<(String, Vec<String>)> = Vec::new();
1406+ let mut total_changes = 0usize;
1407+ let mut needs_manual = false;
2386 for file in &hl_files {
1408 for file in &hl_files {
2387 let input = match fs::read_to_string(file) {
1409 let input = match fs::read_to_string(file) {
2388 Ok(s) => s,
1410 Ok(s) => s,
2389 Err(_) => continue,
1411 Err(_) => continue,
2391− let rel = file.strip_prefix(path).unwrap_or(file);
1413+ let result = htmlang::upgrade::upgrade(&input);
2392− let rel_str = rel.display().to_string();
1414+ for (line, message) in &result.manual {
2393− let mut deps = Vec::new();
1415+ eprintln!("{}:{}: manual change needed: {}", file.display(), line, message);
2394− for line in input.lines() {
1416+ needs_manual = true;
2395− let trimmed = line.trim();
2396− for directive in &["@include ", "@import ", "@extends "] {
2397− if let Some(rest) = trimmed.strip_prefix(directive) {
2398− let dep = rest.split_whitespace().next().unwrap_or("").to_string();
2399− if !dep.is_empty() {
2405− graph.push((rel_str, deps));
2407− for (file, deps) in &graph {
2408− if deps.is_empty() {
2409− println!("{} (no dependencies)", file);
2411− println!("{}", file);
2412− for (i, dep) in deps.iter().enumerate() {
2413− let prefix = if i == deps.len() - 1 {
2418− println!(" {}{}", prefix, dep);
2425− // Handle "dead-code" subcommand — project-wide unused definitions
2426− if args.len() >= 2 && args[1] == "dead-code" {
2427− let mut dc_json = false;
2428− let mut dc_target = ".".to_string();
2430− while i < args.len() {
2431− match args[i].as_str() {
2434− if args.get(i).map(|s| s.as_str()) == Some("json") {
2438− _ if dc_target == "." => dc_target = args[i].clone(),
2441− i += 1;
1418+ if result.changes > 0 {
2442− }
1419+ match fs::write(file, &result.output) {
2443− let target = dc_target.as_str();
2444− let path = Path::new(target);
2445− let hl_files = if path.is_dir() {
2446− collect_hl_files_recursive(path)
2448− vec![PathBuf::from(target)]
2450− if hl_files.is_empty() {
2451− eprintln!("no .hl files found in {}", target);
2454− // Pass 1: collect all definitions and usages across all files
2455− let mut all_let_defs: Vec<(String, String, usize)> = Vec::new(); // (name, file, line)
2456− let mut all_refs: std::collections::HashSet<String> = std::collections::HashSet::new();
2457− for file in &hl_files {
2458− let input = match fs::read_to_string(file) {
2459− Ok(s) => s,
1420+ Ok(()) => {
2460− Err(_) => continue,
1421+ eprintln!(
2461− };
1422+ "upgraded {} ({} change{})",
2463− .strip_prefix(path)
2465− .display()
1423+ file.display(),
2466− .to_string();
1424+ result.changes,
2467− for (line_num, line) in input.lines().enumerate() {
1425+ if result.changes == 1 { "" } else { "s" }
2468− let trimmed = line.trim();
1426+ );
2469− if let Some(rest) = trimmed.strip_prefix("@let ")
1427+ total_changes += result.changes;
2470− && let Some(name) = rest.split_whitespace().next()
2472− let name = name.trim_end_matches('[');
2473− all_let_defs.push((name.to_string(), rel.clone(), line_num + 1));
2475− // Collect references: @name calls and $name usages
2476− if trimmed.starts_with('@')
2477− && !trimmed.starts_with("@let ")
2478− && !trimmed.starts_with("@include ")
2479− && !trimmed.starts_with("@import ")
2480− && !trimmed.starts_with("@extends ")
2481− && !trimmed.starts_with("@page")
2482− && !trimmed.starts_with("@meta ")
2483− && !trimmed.starts_with("@og ")
2484− && !trimmed.starts_with("@head")
2485− && !trimmed.starts_with("@style")
2486− && !trimmed.starts_with("@keyframes ")
2487− && !trimmed.starts_with("@theme")
2488− && !trimmed.starts_with("@deprecated ")
2489− && !trimmed.starts_with("@breakpoint ")
2490− && !trimmed.starts_with("@slot ")
2491− && !trimmed.starts_with("@lang ")
2492− && !trimmed.starts_with("@favicon ")
2493− && !trimmed.starts_with("--")
2494− && let Some(name) = trimmed[1..].split([' ', '[']).next()
2496− all_refs.insert(name.to_string());
2498− // Collect $var references
2499− let mut rest = trimmed;
2500− while let Some(pos) = rest.find('$') {
2501− let after = &rest[pos + 1..];
2503− .find(|c: char| !c.is_alphanumeric() && c != '-' && c != '_')
2504− .unwrap_or(after.len());
2506− all_refs.insert(after[..end].to_string());
2508− rest = if end < after.len() { &after[end..] } else { "" };
1429+ Err(e) => eprintln!("error: {}: {}", file.display(), e),
2512− // Pass 2: report unused
1433+ if total_changes == 0 && !needs_manual {
2513− let mut unused: Vec<(String, String, String, usize)> = Vec::new(); // (kind, name, file, line)
1434+ eprintln!("no upgrades needed — all files are up to date");
2514− for (name, file, line) in &all_let_defs {
1435+ } else if total_changes > 0 {
2515− if !all_refs.contains(name) && !name.starts_with("--") {
1436+ eprintln!("\n{} total change(s) applied", total_changes);
2516− unused.push(("@let".to_string(), name.clone(), file.clone(), *line));
2519− if dc_json {
1438+ if needs_manual {
2520− let arr = json_array(unused.iter().map(|(kind, name, file, line)| {
1439+ process::exit(1);
2522− ("kind", json_escape_string(kind)),
2523− ("name", json_escape_string(name)),
2524− ("file", json_escape_string(file)),
2525− ("line", line.to_string()),
2528− println!("{}", json_object(&[("unused", arr)]));
2529− } else if unused.is_empty() {
2530− eprintln!("no unused definitions found");
2532− for (kind, name, file, line) in &unused {
2533− println!("unused {} '{}' — {}:{}", kind, name, file, line);
2535− eprintln!("\n{} unused definition(s) found", unused.len());
2540− // Handle "deploy" subcommand — deploy to GitHub Pages or other providers
2541− if args.len() >= 2 && args[1] == "deploy" {
2542− let mut deploy_target = ".";
2543− let mut provider = "github-pages";
2545− while di < args.len() {
2546− match args[di].as_str() {
2547− "--provider" | "-P" => {
2549− if let Some(p) = args.get(di) {
2550− provider = match p.as_str() {
2551− "netlify" | "vercel" | "cloudflare" | "github-pages" => {
2552− // Leak to get &str lifetime — fine for CLI
2557− "unknown provider: {} (supported: github-pages, netlify, vercel, cloudflare)",
2565− _ if deploy_target == "." => deploy_target = &args[di],
2570− let target = deploy_target;
2571− let path = Path::new(target);
2572− if !path.is_dir() {
2573− eprintln!("error: '{}' is not a directory", target);
2577− let hl_files = collect_hl_files_recursive(path);
2578− if hl_files.is_empty() {
2579− eprintln!("no .hl files found in {}", target);
2582− let deploy_dir = path.join("_deploy");
2583− let _ = fs::create_dir_all(&deploy_dir);
2584− let any_errors = std::sync::atomic::AtomicBool::new(false);
2585− std::thread::scope(|s| {
2586− for file in &hl_files {
2587− let any_errors = &any_errors;
2588− let deploy_dir = &deploy_dir;
2590− let rel = file.strip_prefix(path).unwrap_or(file);
2591− let out_path = deploy_dir.join(rel).with_extension("html");
2592− if let Some(parent) = out_path.parent() {
2593− let _ = fs::create_dir_all(parent);
2595− let out_str = out_path.to_string_lossy().to_string();
2596− let path_str = file.to_string_lossy().to_string();
2597− let (has_errors, _) = compile(
2600− output_path: Some(&out_str),
2601− ..Default::default()
2605− any_errors.store(true, std::sync::atomic::Ordering::Relaxed);
2610− if any_errors.load(std::sync::atomic::Ordering::Relaxed) {
2611− eprintln!("error: build failed, not deploying");
2614− copy_non_hl_files(path, &deploy_dir);
2615− eprintln!("built {} files to {}", hl_files.len(), deploy_dir.display());
2617− // Provider-specific deploy
2620− eprintln!("deploying to Netlify...");
2621− let status = process::Command::new("npx")
2627− &deploy_dir.to_string_lossy(),
2631− Ok(s) if s.success() => eprintln!("deployed to Netlify!"),
2634− "netlify deploy failed — ensure netlify-cli is installed (npx netlify-cli deploy --prod --dir {})",
2635− deploy_dir.display()
2643− eprintln!("deploying to Vercel...");
2644− let status = process::Command::new("npx")
2645− .args(["vercel", "--prod", deploy_dir.to_string_lossy().as_ref()])
2648− Ok(s) if s.success() => eprintln!("deployed to Vercel!"),
2651− "vercel deploy failed — ensure vercel CLI is installed (npx vercel --prod {})",
2652− deploy_dir.display()
2660− eprintln!("deploying to Cloudflare Pages...");
2661− let status = process::Command::new("npx")
2666− deploy_dir.to_string_lossy().as_ref(),
2670− Ok(s) if s.success() => eprintln!("deployed to Cloudflare Pages!"),
2673− "cloudflare deploy failed — ensure wrangler is installed (npx wrangler pages deploy {})",
2674− deploy_dir.display()
2681− _ => {} // fall through to GitHub Pages
2684− // Deploy via gh-pages push
2685− let init_ok = matches!(
2686− process::Command::new("git")
2688− .current_dir(&deploy_dir)
2690− Ok(s) if s.success()
2693− eprintln!("error: git init failed in deploy directory");
2696− let _ = process::Command::new("git")
2697− .args(["checkout", "-b", "gh-pages"])
2698− .current_dir(&deploy_dir)
2700− let _ = process::Command::new("git")
2701− .args(["add", "."])
2702− .current_dir(&deploy_dir)
2704− let commit_ok = matches!(
2705− process::Command::new("git")
2706− .args(["commit", "-m", "deploy"])
2707− .current_dir(&deploy_dir)
2709− Ok(s) if s.success()
2712− eprintln!("error: git commit failed");
2715− // Check if remote origin exists in parent
2716− let remote_output = process::Command::new("git")
2717− .args(["remote", "get-url", "origin"])
2719− if let Ok(output) = remote_output {
2720− if output.status.success() {
2721− let remote_url = String::from_utf8_lossy(&output.stdout).trim().to_string();
2722− let _ = process::Command::new("git")
2723− .args(["remote", "add", "origin", &remote_url])
2724− .current_dir(&deploy_dir)
2726− eprintln!("pushing to gh-pages branch at {}...", remote_url);
2727− let push_ok = matches!(
2728− process::Command::new("git")
2729− .args(["push", "-f", "origin", "gh-pages"])
2730− .current_dir(&deploy_dir)
2732− Ok(s) if s.success()
2736− "error: push failed — run 'git push -f origin gh-pages' manually from {}",
2737− deploy_dir.display()
2741− eprintln!("deployed to GitHub Pages!");
2744− "no git remote found — deploy directory ready at {}",
2745− deploy_dir.display()
2748− "push manually: cd {} && git remote add origin <url> && git push -f origin gh-pages",
2749− deploy_dir.display()
2756− // Handle "playground" subcommand — generate self-contained HTML playground
2757− if args.len() >= 2 && args[1] == "playground" {
2758− let out = if args.len() >= 3 {
2764− // Load pre-built WASM binary and JS glue
2765− let (wasm_bytes, js_glue) = match find_wasm_pkg() {
2768− eprintln!("error: WASM module not found. Build it first:");
2770− " cargo build --release --target wasm32-unknown-unknown -p htmlang-wasm"
2773− " wasm-bindgen --target no-modules --no-typescript --out-dir target/wasm-pkg \\"
2775− eprintln!(" target/wasm32-unknown-unknown/release/htmlang_wasm.wasm");
2779− let b64 = base64_encode(&wasm_bytes);
2780− let wasm_section = format!(
2781− "<script>\n{}\n</script>\n<script>\n\
2784− var raw=atob(b64);\n\
2785− var bytes=new Uint8Array(raw.length);\n\
2786− for(var i=0;i<raw.length;i++)bytes[i]=raw.charCodeAt(i);\n\
2787− wasm_bindgen.initSync(bytes);\n\
2788− window._wasmCompile=wasm_bindgen.compile;\n\
2793− let compile_fn = "function compileSource(src){return window._wasmCompile(src);}";
2794− let editor_content = include_str!("playground_default.hl");
2796− let playground_html = format!(
2797− r##"<!DOCTYPE html>
2800−<meta charset="utf-8">
2801−<meta name="viewport" content="width=device-width, initial-scale=1">
2802−<title>htmlang Playground</title>
2804−*{{box-sizing:border-box;margin:0}}
2805−body{{font-family:system-ui,-apple-system,sans-serif;display:flex;height:100vh;background:#1a1a2e;color:#eee}}
2806−.panel{{flex:1;display:flex;flex-direction:column;min-width:0}}
2807−.header{{padding:12px 16px;background:#16213e;border-bottom:1px solid #0f3460;display:flex;align-items:center;gap:12px}}
2808−.header h1{{font-size:14px;font-weight:600;color:#e94560}}
2809−.header .tag{{font-size:11px;padding:2px 8px;background:#0f3460;border-radius:4px;color:#a8b2d1}}
2810−.editor-wrap{{flex:1;position:relative;overflow:hidden}}
2811−textarea{{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}}
2812−iframe{{flex:1;border:none;background:white}}
2813−.divider{{width:1px;background:#0f3460;cursor:col-resize}}
2814−.divider:hover{{background:#e94560}}
2815−.toolbar{{padding:8px 16px;background:#16213e;border-top:1px solid #0f3460;display:flex;gap:8px;align-items:center}}
2816−button{{padding:6px 16px;border:none;border-radius:4px;cursor:pointer;font-size:13px;font-weight:500}}
2817−.btn-run{{background:#e94560;color:white}}
2818−.btn-run:hover{{background:#c81e45}}
2819−.btn-copy{{background:#0f3460;color:#a8b2d1}}
2820−.btn-share{{background:#0f3460;color:#a8b2d1}}
2821−.status{{margin-left:auto;font-size:12px;color:#666}}
2825−<div class="panel" id="editor-panel">
2826−<div class="header"><h1>htmlang</h1><span class="tag">playground</span></div>
2827−<div class="editor-wrap">
2828−<textarea id="editor" spellcheck="false">{editor_content}</textarea>
2830−<div class="toolbar">
2831−<button class="btn-run" onclick="compile()">▶ Run (Ctrl+Enter)</button>
2832−<button class="btn-copy" onclick="copyOutput()">Copy HTML</button>
2833−<button class="btn-share" onclick="share()">Share</button>
2834−<span class="status" id="status">Ready</span>
2837−<div class="divider" id="divider"></div>
2838−<div class="panel" id="preview-panel">
2839−<div class="header"><span class="tag">preview</span></div>
2840−<iframe id="preview"></iframe>
2846−function compile(){{
2847− var src=document.getElementById('editor').value;
2848− var status=document.getElementById('status');
2849− var start=performance.now();
2850− var html=compileSource(src);
2851− var ms=(performance.now()-start).toFixed(1);
2853− document.getElementById('preview').srcdoc=html;
2854− status.textContent='Compiled in '+ms+'ms';
2856−function copyOutput(){{if(lastOutput)navigator.clipboard.writeText(lastOutput).then(function(){{document.getElementById('status').textContent='Copied to clipboard!'}});}}
2858− var src=document.getElementById('editor').value;
2859− var encoded=btoa(unescape(encodeURIComponent(src)));
2860− var url=location.origin+location.pathname+'#'+encoded;
2861− navigator.clipboard.writeText(url).then(function(){{document.getElementById('status').textContent='Share URL copied to clipboard!'}});
2862− history.replaceState(null,'','#'+encoded);
2864−function loadFromHash(){{
2865− if(location.hash.length>1){{
2867− var decoded=decodeURIComponent(escape(atob(location.hash.slice(1))));
2868− document.getElementById('editor').value=decoded;
2872−// Resizable divider
2873−var divider=document.getElementById('divider');
2874−var editorPanel=document.getElementById('editor-panel');
2876−divider.addEventListener('mousedown',function(){{dragging=true;document.body.style.cursor='col-resize';document.body.style.userSelect='none';}});
2877−document.addEventListener('mousemove',function(e){{if(!dragging)return;var pct=(e.clientX/window.innerWidth)*100;editorPanel.style.flex='none';editorPanel.style.width=pct+'%';}});
2878−document.addEventListener('mouseup',function(){{if(dragging){{dragging=false;document.body.style.cursor='';document.body.style.userSelect='';}}}});
2879−// Keyboard shortcuts and auto-compile
2880−var editor=document.getElementById('editor');
2881−editor.addEventListener('keydown',function(e){{
2882− if(e.key==='Enter'&&(e.ctrlKey||e.metaKey)){{e.preventDefault();compile();}}
2883− 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;}}
2890− wasm_section = wasm_section,
2891− compile_fn = compile_fn
2893− if let Err(e) = fs::write(out, playground_html) {
2894− eprintln!("error writing {}: {}", out, e);
2897− eprintln!("open in browser: open {}", out);
2901− // Handle "clean" subcommand — remove generated .html files
2902− if args.len() >= 2 && args[1] == "clean" {
2903− let target = if args.len() >= 3 { &args[2] } else { "." };
2904− let dir = Path::new(target);
2906− eprintln!("error: '{}' is not a directory", target);
2909− let hl_files = collect_hl_files_recursive(dir);
2910− let mut removed = 0;
2911− for hl_file in &hl_files {
2912− let html_file = hl_file.with_extension("html");
2913− if html_file.exists() {
2914− match fs::remove_file(&html_file) {
2916− eprintln!("removed {}", html_file.display());
2919− Err(e) => eprintln!("error: {}: {}", html_file.display(), e),
2923− // Also remove sitemap.xml if present
2924− let sitemap = dir.join("sitemap.xml");
2925− if sitemap.exists()
2926− && let Ok(()) = fs::remove_file(&sitemap)
2928− eprintln!("removed {}", sitemap.display());
2932− "cleaned {} file{}",
2934− if removed == 1 { "" } else { "s" }
2939− // Handle "outline" subcommand — show document structure
2940− if args.len() >= 2 && args[1] == "outline" {
2941− if args.len() < 3 {
2942− eprintln!("usage: htmlang outline <file.hl>");
2945− let path = &args[2];
2946− let input = match fs::read_to_string(path) {
2949− eprintln!("error: {}: {}", path, e);
2953− let base = Path::new(path).parent();
2954− let result = htmlang::parser::parse_with_base(&input, base);
2955− if let Some(title) = &result.document.page_title {
2956− eprintln!("@page {}", title);
2958− fn print_outline(nodes: &[htmlang::ast::Node], depth: usize) {
2959− let indent = " ".repeat(depth);
2960− for node in nodes {
2962− htmlang::ast::Node::Element(elem) => {
2963− let kind = format!("{:?}", elem.kind);
2964− let kind = kind.split('(').next().unwrap_or(&kind);
2965− let label = if let Some(arg) = &elem.argument {
2966− format!("@{} {}", kind.to_lowercase(), arg)
2968− format!("@{}", kind.to_lowercase())
2970− eprintln!("{}{}", indent, label);
2971− print_outline(&elem.children, depth + 1);
2973− htmlang::ast::Node::Text(segs) => {
2974− let text: String = segs
2976− .filter_map(|s| match s {
2977− htmlang::ast::TextSegment::Plain(t) => Some(t.as_str()),
2981− if !text.trim().is_empty() {
2982− let display = if text.chars().count() > 40 {
2983− format!("{}...", text.chars().take(37).collect::<String>())
2987− eprintln!("{}\"{}\"", indent, display.trim());
2990− htmlang::ast::Node::Raw(_) => {
2991− eprintln!("{}@raw ...", indent);
2996− print_outline(&result.document.nodes, 0);
3000− // Handle "doctor" subcommand — check toolchain health
3001− if args.len() >= 2 && args[1] == "doctor" {
3002− eprintln!("htmlang doctor — checking toolchain health\n");
3005− // Check htmlang version
3006− eprintln!(" htmlang: v{}", env!("CARGO_PKG_VERSION"));
3008− // Check if htmlang-lsp binary is available
3009− let lsp_status = process::Command::new("htmlang-lsp")
3013− Ok(output) if output.status.success() => {
3014− let ver = String::from_utf8_lossy(&output.stdout);
3015− eprintln!(" htmlang-lsp: {}", ver.trim());
3018− eprintln!(" htmlang-lsp: not found (optional — needed for editor integration)");
3022− // Check if cargo is available (for building from source)
3023− let cargo_status = process::Command::new("cargo").arg("--version").output();
3024− match cargo_status {
3025− Ok(output) if output.status.success() => {
3026− let ver = String::from_utf8_lossy(&output.stdout);
3027− eprintln!(" cargo: {}", ver.trim());
3030− eprintln!(" cargo: not found");
3035− // Check if git is available (for deploy)
3036− let git_status = process::Command::new("git").arg("--version").output();
3038− Ok(output) if output.status.success() => {
3039− let ver = String::from_utf8_lossy(&output.stdout);
3040− eprintln!(" git: {}", ver.trim());
3043− eprintln!(" git: not found (optional — needed for deploy)");
3047− // Check for htmlang.toml in current directory
3048− if Path::new("htmlang.toml").exists() {
3049− eprintln!(" config: htmlang.toml found");
3051− eprintln!(" config: no htmlang.toml (using defaults)");
3054− // Check for .hl files
3055− let hl_count = collect_hl_files_recursive(Path::new(".")).len();
3056− eprintln!(" .hl files: {} found in current directory", hl_count);
3060− eprintln!("all checks passed");
3062− eprintln!("some checks failed — see above");
3068− // Handle "bundle" subcommand — inline images/fonts as data URIs
3069− if args.len() >= 2 && args[1] == "bundle" {
3070− if args.len() < 3 {
3071− eprintln!("usage: htmlang bundle <file.hl | dir> [-o <out>]");
3074− let mut bundle_target = None;
3075− let mut bundle_out = None;
3077− while bi < args.len() {
3078− match args[bi].as_str() {
3079− "-o" | "--output" => {
3081− bundle_out = args.get(bi).map(|s| s.as_str());
3083− _ if bundle_target.is_none() => bundle_target = Some(args[bi].as_str()),
3085− eprintln!("unknown argument: {}", args[bi]);
3091− let target = bundle_target.unwrap_or(".");
3092− let path = Path::new(target);
3093− let hl_files = if path.is_dir() {
3094− collect_hl_files_recursive(path)
3096− vec![PathBuf::from(target)]
3098− if hl_files.is_empty() {
3099− eprintln!("no .hl files found in {}", target);
3102− for file in &hl_files {
3103− let input = match fs::read_to_string(file) {
3106− eprintln!("error: {}: {}", file.display(), e);
3110− let base = file.parent();
3111− let result = htmlang::parser::parse_with_base(&input, base);
3115− .any(|d| d.severity == htmlang::parser::Severity::Error)
3117− for d in &result.diagnostics {
3118− if d.severity == htmlang::parser::Severity::Error {
3119− eprintln!("error: line {}: {}", d.line, d.message);
3124− let html = htmlang::codegen::generate(&result.document);
3125− // Inline external assets: images and fonts referenced in the HTML
3126− let bundled = bundle_assets(&html, base.unwrap_or(Path::new(".")));
3127− let out_path = match bundle_out {
3129− let p = Path::new(o);
3130− if p.is_dir() || hl_files.len() > 1 {
3131− let _ = fs::create_dir_all(p);
3132− let rel = file.strip_prefix(path).unwrap_or(file);
3133− p.join(rel).with_extension("html")
3138− None => file.with_extension("html"),
3140− match fs::write(&out_path, &bundled) {
3141− Ok(()) => eprintln!("bundled {}", out_path.display()),
3142− Err(e) => eprintln!("error: {}: {}", out_path.display(), e),
3148− // Handle "size" subcommand — report output sizes with gzip estimates
3149− if args.len() >= 2 && args[1] == "size" {
3150− let target = if args.len() >= 3 { &args[2] } else { "." };
3151− let path = Path::new(target);
3152− let hl_files = if path.is_dir() {
3153− collect_hl_files_recursive(path)
3155− vec![PathBuf::from(target)]
3157− if hl_files.is_empty() {
3158− eprintln!("no .hl files found in {}", target);
3161− let mut total_source = 0usize;
3162− let mut total_output = 0usize;
3163− let mut total_minified = 0usize;
3164− for file in &hl_files {
3165− let input = match fs::read_to_string(file) {
3167− Err(_) => continue,
3169− total_source += input.len();
3170− let base = file.parent();
3171− let result = htmlang::parser::parse_with_base(&input, base);
3175− .any(|d| d.severity == htmlang::parser::Severity::Error)
3179− let html = htmlang::codegen::generate(&result.document);
3180− let minified = htmlang::codegen::generate_minified(&result.document);
3181− let html_len = html.len();
3182− let min_len = minified.len();
3183− // Estimate gzip size as ~30% of minified (rough heuristic for HTML)
3184− let gzip_est = min_len * 30 / 100;
3185− total_output += html_len;
3186− total_minified += min_len;
3188− " {}: {} → {} (minified: {}, ~gzip: {})",
3190− format_bytes(input.len()),
3191− format_bytes(html_len),
3192− format_bytes(min_len),
3193− format_bytes(gzip_est),
3197− let total_gzip = total_minified * 30 / 100;
3199− " total: {} source → {} output (minified: {}, ~gzip: {})",
3200− format_bytes(total_source),
3201− format_bytes(total_output),
3202− format_bytes(total_minified),
3203− format_bytes(total_gzip),
3208− // Handle "explain" subcommand — show CSS mapping for each line
3209− if args.len() >= 2 && args[1] == "explain" {
3210− if args.len() < 3 {
3211− eprintln!("usage: htmlang explain <file.hl>");
3214− let file = &args[2];
3215− let input = match fs::read_to_string(file) {
3218− eprintln!("error: {}: {}", file, e);
3222− let base = Path::new(file).parent();
3223− let result = htmlang::parser::parse_with_base(&input, base);
3224− for d in &result.diagnostics {
3225− let prefix = severity_label(d.severity);
3226− eprintln!("{}: line {}: {}", prefix, d.line, d.message);
3231− .any(|d| d.severity == htmlang::parser::Severity::Error)
3235− let html = htmlang::codegen::generate_dev(&result.document);
3236− // Extract CSS class mappings from <style> block
3237− let mut class_css: HashMap<String, String> = HashMap::new();
3238− if let Some(style_start) = html.find("<style>")
3239− && let Some(style_end) = html[style_start..].find("</style>")
3241− let css_block = &html[style_start + 7..style_start + style_end];
3242− // Parse each CSS rule: .className { rules }
3244− let css_bytes = css_block.as_bytes();
3245− while pos < css_bytes.len() {
3246− if css_bytes[pos] == b'.' {
3247− let rule_start = pos;
3248− // Find class name end
3250− while pos < css_bytes.len()
3251− && css_bytes[pos] != b' '
3252− && css_bytes[pos] != b'{'
3253− && css_bytes[pos] != b':'
3254− && css_bytes[pos] != b','
3258− let class_name = &css_block[rule_start + 1..pos];
3260− while pos < css_bytes.len() && css_bytes[pos] != b'{' {
3263− if pos < css_bytes.len() {
3264− pos += 1; // skip '{'
3265− let body_start = pos;
3267− while pos < css_bytes.len() && depth > 0 {
3268− if css_bytes[pos] == b'{' {
3271− if css_bytes[pos] == b'}' {
3276− let body = css_block[body_start..pos.saturating_sub(1)].trim();
3277− if !body.is_empty() {
3279− .entry(class_name.to_string())
3280− .and_modify(|existing| {
3281− existing.push_str("; ");
3282− existing.push_str(body);
3284− .or_insert_with(|| body.to_string());
3292− // Extract data-hl-line attributes from dev HTML to map classes to source lines
3293− let mut line_classes: HashMap<usize, Vec<String>> = HashMap::new();
3294− let html_body = if let Some(body_start) = html.find("<body") {
3295− &html[body_start..]
3299− // Scan for elements with data-hl-line="N" and class="X"
3300− // Dev mode outputs: <div class="a" data-hl-line="2" data-hl-el="el">
3301− let mut scan_pos = 0;
3302− while scan_pos < html_body.len() {
3303− // Find each opening tag
3304− if html_body.as_bytes()[scan_pos] == b'<'
3305− && scan_pos + 1 < html_body.len()
3306− && html_body.as_bytes()[scan_pos + 1].is_ascii_alphabetic()
3309− let tag_end = html_body[scan_pos..]
3311− .map(|p| scan_pos + p)
3312− .unwrap_or(html_body.len());
3313− let tag = &html_body[scan_pos..tag_end];
3314− // Extract data-hl-line
3315− if let Some(hl_pos) = tag.find("data-hl-line=\"") {
3316− let after = &tag[hl_pos + 14..];
3317− if let Some(end) = after.find('"')
3318− && let Ok(ln) = after[..end].parse::<usize>()
3321− if let Some(cls_pos) = tag.find("class=\"") {
3322− let cls_after = &tag[cls_pos + 7..];
3323− if let Some(cls_end) = cls_after.find('"') {
3324− for cls in cls_after[..cls_end].split_whitespace() {
3325− line_classes.entry(ln).or_default().push(cls.to_string());
3331− scan_pos = tag_end;
3336− // Print explanation
3337− for (line_num, line) in input.lines().enumerate() {
3338− let ln = line_num + 1;
3339− let trimmed = line.trim();
3340− if trimmed.is_empty() || trimmed.starts_with("--") {
3343− print!("{:>4} | {}", ln, line);
3344− if let Some(classes) = line_classes.get(&ln) {
3345− let mut css_parts: Vec<String> = Vec::new();
3346− for cls in classes {
3347− if let Some(css) = class_css.get(cls) {
3348− css_parts.push(css.clone());
3351− if !css_parts.is_empty() {
3353− println!(" → {}", css_parts.join("; "));
3364− // Handle "benchmark" subcommand — measure compile time and output size
3365− if args.len() >= 2 && args[1] == "benchmark" {
3366− if args.len() < 3 {
3367− eprintln!("usage: htmlang benchmark <file.hl | dir>");
3370− let target = &args[2];
3371− let path = Path::new(target);
3372− let hl_files = if path.is_dir() {
3373− collect_hl_files_recursive(path)
3375− vec![PathBuf::from(target)]
3377− if hl_files.is_empty() {
3378− eprintln!("no .hl files found in {}", target);
3382− let mut total_source = 0usize;
3383− let mut total_output = 0usize;
3384− let mut total_css_rules = 0usize;
3385− let mut total_elements = 0usize;
3386− let mut total_errors = 0usize;
3388− let start = std::time::Instant::now();
3389− for file in &hl_files {
3390− let input = match fs::read_to_string(file) {
3392− Err(_) => continue,
3394− total_source += input.len();
3395− let base = file.parent();
3396− let result = htmlang::parser::parse_with_base(&input, base);
3397− let has_errors = result
3400− .any(|d| d.severity == htmlang::parser::Severity::Error);
3405− let html = htmlang::codegen::generate(&result.document);
3406− total_output += html.len();
3407− total_css_rules += html.matches('{').count().saturating_sub(1);
3409− let mut colors = std::collections::HashSet::new();
3410− let mut fonts = std::collections::HashSet::new();
3411− count_elements(&result.document.nodes, &mut ec, &mut colors, &mut fonts);
3412− total_elements += ec;
3414− let elapsed = start.elapsed();
3416− eprintln!("--- benchmark results ---");
3417− eprintln!(" files: {}", hl_files.len());
3418− eprintln!(" compile time: {:.1}ms", elapsed.as_secs_f64() * 1000.0);
3419− eprintln!(" source size: {} bytes", total_source);
3420− eprintln!(" output size: {} bytes", total_output);
3421− if total_source > 0 {
3424− total_output as f64 / total_source as f64
3427− eprintln!(" elements: {}", total_elements);
3428− eprintln!(" CSS rules: ~{}", total_css_rules);
3429− if total_errors > 0 {
3430− eprintln!(" errors: {} file(s) had errors", total_errors);
3432− if hl_files.len() > 1 {
3433− let per_file = elapsed.as_secs_f64() * 1000.0 / hl_files.len() as f64;
3434− eprintln!(" per file: {:.2}ms", per_file);
3439− // Handle "upgrade" subcommand (and its old name "migrate"): rewrite
3440− // removed or renamed syntax to its current form.
3441− if args.len() >= 2 && (args[1] == "upgrade" || args[1] == "migrate") {
3442− let target = if args.len() >= 3 { &args[2] } else { "." };
3443− let path = Path::new(target);
3444− let hl_files = if path.is_dir() {
3445− collect_hl_files_recursive(path)
3447− vec![PathBuf::from(target)]
3449− if hl_files.is_empty() {
3450− eprintln!("no .hl files found in {}", target);
3453− let mut total_changes = 0usize;
3454− let mut needs_manual = false;
3455− for file in &hl_files {
3456− let input = match fs::read_to_string(file) {
3458− Err(_) => continue,
3460− let result = htmlang::upgrade::upgrade(&input);
3461− for (line, message) in &result.manual {
3462− eprintln!("{}:{}: manual change needed: {}", file.display(), line, message);
3463− needs_manual = true;
3465− if result.changes > 0 {
3466− match fs::write(file, &result.output) {
3469− "upgraded {} ({} change{})",
3472− if result.changes == 1 { "" } else { "s" }
3474− total_changes += result.changes;
3476− Err(e) => eprintln!("error: {}: {}", file.display(), e),
3480− if total_changes == 0 && !needs_manual {
3481− eprintln!("no upgrades needed — all files are up to date");
3482− } else if total_changes > 0 {
3483− eprintln!("\n{} total change(s) applied", total_changes);
3491− // Handle "create-component" subcommand — scaffold a new component
3492− if args.len() >= 2 && args[1] == "create-component" {
3493− if args.len() < 3 {
3494− eprintln!("usage: htmlang create-component <name> [param1] [param2] ...");
3497− let comp_name = &args[2];
3498− let params: Vec<&str> = args[3..].iter().map(|s| s.as_str()).collect();
3499− let file_name = format!("{}.hl", comp_name);
3500− let file_path = Path::new(&file_name);
3501− if file_path.exists() {
3502− eprintln!("error: {} already exists", file_name);
3506− let mut template = String::new();
3507− template.push_str(&format!("-- {} component\n", comp_name));
3508− template.push_str("-- Usage: @");
3509− template.push_str(comp_name);
3510− if !params.is_empty() {
3511− template.push_str(" [");
3512− for (i, p) in params.iter().enumerate() {
3514− template.push_str(", ");
3516− template.push_str(p);
3517− template.push_str(" value");
3519− template.push(']');
3521− template.push('\n');
3522− template.push('\n');
3524− // Generate @let component definition
3525− template.push_str("@let ");
3526− template.push_str(comp_name);
3528− template.push(' ');
3529− template.push('$');
3530− template.push_str(p);
3532− template.push('\n');
3533− template.push_str(" @el [padding 16, rounded 8, border 1 #e5e7eb]\n");
3534− if !params.is_empty() {
3536− template.push_str(&format!(" @text ${}\n", p));
3539− template.push_str(" @children\n");
3541− match fs::write(file_path, &template) {
3543− eprintln!("created {}", file_name);
3544− eprintln!("import with: @import {} (then call @{})", file_name, comp_name);
3547− eprintln!("error: {}: {}", file_name, e);
3554 // Handle "convert" subcommand
1445 // Handle "convert" subcommand
3555 if args.len() >= 2 && args[1] == "convert" {
1446 if args.len() >= 2 && args[1] == "convert" {