on master help sign in
Add 10 production-ready improvements: @env/@fetch/@svg/@css-property directives, --partial flag, multi-page dev server, shell completions, lsp subcommand, auto image dimensions, and responsive srcset

Features: - @env: compile-time environment variable access with defaults - @fetch: HTTP data fetching at build time (handles JSON parsing) - @svg: inline SVG files with attribute injection - @css-property: typed CSS custom properties (@property rules) - --partial: output HTML fragments without document wrapper - Multi-page dev server: route mapping, directory listing, live reload - Shell completions: bash/zsh/fish with all commands and flags - htmlang lsp: subcommand to launch LSP server - Auto image dimensions: PNG/JPEG/GIF/WebP dimension detection (prevents CLS) - Responsive srcset: automatic generation for multiple image sizes All 469 existing tests pass + 15 new tests for new features

commitf8e4784972e5bd745d84cc2058388289d6acdfd9 authoriko <mail@iko.soy> dateWed 15 Apr 2026, 23:09 (6 months ago) parenta7dfb52
9 files changed+1273−20
src/bin/htmlang-lsp.rs+5−1 src/codegen.rs+200−1 src/main.rs+270−15 src/parser.rs+364 src/serve.rs+193−3 tests/snapshots.rs+227 tests/snapshots/css_property.hl+8 tests/snapshots/env_directive.hl+3 tests/snapshots/responsive_images.hl+3
src/bin/htmlang-lsp.rs+5−1
@@ -723,6 +723,10 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
723723 ("@mixin", "Define a composable style group (use with ...$name)", "@mixin "),
724724 ("@assert", "Compile-time assertion for variable values", "@assert "),
725725 ("@data", "Load JSON data file into template variables", "@data "),
726+ ("@env", "Access compile-time environment variable", "@env "),
727+ ("@fetch", "Fetch data from URL at compile time", "@fetch "),
728+ ("@svg", "Inline SVG file with optional attributes", "@svg "),
729+ ("@css-property", "Define typed CSS custom property (@property)", "@css-property "),
726730 ]
727731 .iter()
728732 .map(|(name, detail, insert)| {
@@ -3369,7 +3373,7 @@ fn semantic_tokens(text: &str) -> Vec<SemanticToken> {
33693373 | "@unless" | "@og" | "@breakpoint"
33703374 | "@canonical" | "@base" | "@font-face" | "@json-ld"
33713375 | "@mixin" | "@assert" | "@theme" | "@deprecated" | "@extends"
3372− | "@use" | "@data" => 0, // keyword
3376+ | "@use" | "@data" | "@env" | "@fetch" | "@svg" | "@css-property" => 0, // keyword
33733377 _ => {
33743378 // Check if it's a user function call (starts with @ but not a builtin element)
33753379 if is_builtin_element(word) { 0 } else { 2 } // function
src/codegen.rs+200−1
@@ -380,6 +380,16 @@ pub fn generate_dev(doc: &Document) -> String {
380380 generate_with_options(doc, true)
381381}
382382
383+/// Generate an HTML fragment — body content + inline style, no document wrapper.
384+pub fn generate_partial(doc: &Document) -> String {
385+ generate_partial_with_options(doc, false)
386+}
387+
388+/// Generate a dev-mode HTML fragment.
389+pub fn generate_partial_dev(doc: &Document) -> String {
390+ generate_partial_with_options(doc, true)
391+}
392+
383393/// Generate with aggressive minification: collapse whitespace in text nodes,
384394/// strip HTML comments, and minimize output size.
385395pub fn generate_minified(doc: &Document) -> String {
@@ -1008,6 +1018,83 @@ img {{ display: block; }}
10081018 }
10091019}
10101020
1021+/// Generate an HTML fragment: body + optional <style>, no <html>/<head>/<body> wrapper.
1022+fn generate_partial_with_options(doc: &Document, dev: bool) -> String {
1023+ let mut styles = StyleCollector::new();
1024+ let mut ctx = GenContext { dev, depth: 0 };
1025+ let mut body = String::new();
1026+
1027+ for node in &doc.nodes {
1028+ generate_node(node, None, &mut body, &mut styles, &mut ctx);
1029+ }
1030+
1031+ let has_custom_css = !doc.custom_css.is_empty();
1032+ let mut element_css = String::new();
1033+
1034+ // CSS custom properties
1035+ if !doc.css_vars.is_empty() || !doc.theme_tokens.is_empty() {
1036+ if dev {
1037+ element_css.push_str(":root {\n");
1038+ for (name, value) in &doc.css_vars {
1039+ element_css.push_str(&format!(" {}: {};\n", name, value));
1040+ }
1041+ for (name, value) in &doc.theme_tokens {
1042+ let css_name = format!("--{}", name);
1043+ if !doc.css_vars.iter().any(|(n, _)| *n == css_name) {
1044+ element_css.push_str(&format!(" {}: {};\n", css_name, value));
1045+ }
1046+ }
1047+ element_css.push_str("}\n");
1048+ } else {
1049+ element_css.push_str(":root{");
1050+ for (name, value) in &doc.css_vars {
1051+ element_css.push_str(name);
1052+ element_css.push(':');
1053+ element_css.push_str(value);
1054+ element_css.push(';');
1055+ }
1056+ for (name, value) in &doc.theme_tokens {
1057+ let css_name = format!("--{}", name);
1058+ if !doc.css_vars.iter().any(|(n, _)| *n == css_name) {
1059+ element_css.push_str(&css_name);
1060+ element_css.push(':');
1061+ element_css.push_str(value);
1062+ element_css.push(';');
1063+ }
1064+ }
1065+ element_css.push('}');
1066+ }
1067+ }
1068+
1069+ element_css.push_str(&styles.to_css_formatted(dev, !has_custom_css));
1070+
1071+ for (name, kf_body) in &doc.keyframes {
1072+ if dev {
1073+ element_css.push_str(&format!("@keyframes {} {{\n{}\n}}\n", name, kf_body));
1074+ } else {
1075+ element_css.push_str(&format!("@keyframes {}{{{}}}", name, kf_body));
1076+ }
1077+ }
1078+
1079+ for block in &doc.custom_css {
1080+ if dev {
1081+ element_css.push_str(block);
1082+ element_css.push('\n');
1083+ } else {
1084+ let minified: String = block.lines().map(|l| l.trim()).collect::<Vec<_>>().join("");
1085+ element_css.push_str(&minified);
1086+ }
1087+ }
1088+
1089+ if element_css.is_empty() {
1090+ body
1091+ } else if dev {
1092+ format!("<style>\n{}</style>\n{}", element_css, body)
1093+ } else {
1094+ format!("<style>{}</style>{}", element_css, body)
1095+ }
1096+}
1097+
10111098// ---------------------------------------------------------------------------
10121099// Node generation
10131100// ---------------------------------------------------------------------------
@@ -1607,6 +1694,52 @@ fn generate_self_closing(
16071694 }
16081695 }
16091696 }
1697+ // Responsive srcset: @image photo.jpg [responsive 400 800 1200]
1698+ let responsive_attr = elem.attrs.iter().find(|a| a.key == "responsive");
1699+ if let Some(resp) = responsive_attr {
1700+ if let Some(ref sizes_str) = resp.value {
1701+ let widths: Vec<&str> = sizes_str.split_whitespace().collect();
1702+ if !widths.is_empty() {
1703+ let src = elem.argument.as_deref().unwrap_or("");
1704+ if !src.is_empty() {
1705+ // Generate srcset with width descriptors
1706+ // Convention: file-{width}.ext (e.g., photo-400.jpg)
1707+ let dot_pos = src.rfind('.').unwrap_or(src.len());
1708+ let base = &src[..dot_pos];
1709+ let ext = &src[dot_pos..];
1710+ let mut srcset_parts = Vec::new();
1711+ for w in &widths {
1712+ srcset_parts.push(format!("{}-{}{} {}w", base, w, ext, w));
1713+ }
1714+ out.push_str(" srcset=\"");
1715+ out.push_str(&srcset_parts.join(", "));
1716+ out.push('"');
1717+ // Generate sizes attribute
1718+ let max_width = widths.last().unwrap_or(&"100vw");
1719+ out.push_str(&format!(" sizes=\"(max-width: {}px) 100vw, {}px\"", max_width, max_width));
1720+ }
1721+ }
1722+ }
1723+ }
1724+
1725+ // Auto image dimensions: read local image file to inject width/height
1726+ let has_width = elem.attrs.iter().any(|a| a.key == "width");
1727+ let has_height = elem.attrs.iter().any(|a| a.key == "height");
1728+ if !has_width || !has_height {
1729+ if let Some(ref src) = elem.argument {
1730+ if !src.starts_with("http://") && !src.starts_with("https://") && !src.starts_with("data:") {
1731+ if let Some((w, h)) = read_image_dimensions(src) {
1732+ if !has_width {
1733+ out.push_str(&format!(" width=\"{}\"", w));
1734+ }
1735+ if !has_height {
1736+ out.push_str(&format!(" height=\"{}\"", h));
1737+ }
1738+ }
1739+ }
1740+ }
1741+ }
1742+
16101743 if !elem.attrs.iter().any(|a| a.key == "loading") {
16111744 out.push_str(" loading=\"lazy\"");
16121745 }
@@ -2753,7 +2886,7 @@ fn attrs_to_css(
27532886 | "controls" | "autoplay" | "loop" | "muted" | "poster" | "preload"
27542887 | "loading" | "decoding" | "ordered" | "src"
27552888 | "open" | "novalidate" | "low" | "high" | "optimum"
2756− | "colspan" | "rowspan" | "scope" | "inline"
2889+ | "colspan" | "rowspan" | "scope" | "inline" | "responsive"
27572890 | "datetime" | "media" | "sizes" | "srcset" | "cite" | "list"
27582891 | "sandbox" | "allow" | "allowfullscreen" | "referrerpolicy"
27592892 | "formaction" | "formmethod" | "formtarget" | "target"
@@ -2817,6 +2950,72 @@ fn extract_id_class(attrs: &[Attribute]) -> (Option<String>, Option<String>) {
28172950 (id, class)
28182951}
28192952
2953+/// Read image dimensions from a local file by parsing the header bytes.
2954+/// Supports PNG, JPEG, GIF, and WebP.
2955+fn read_image_dimensions(path: &str) -> Option<(u32, u32)> {
2956+ let data = std::fs::read(path).ok()?;
2957+ if data.len() < 24 {
2958+ return None;
2959+ }
2960+
2961+ // PNG: 8-byte signature, then IHDR chunk with width/height at bytes 16-23
2962+ if data.starts_with(b"\x89PNG\r\n\x1a\n") {
2963+ let w = u32::from_be_bytes([data[16], data[17], data[18], data[19]]);
2964+ let h = u32::from_be_bytes([data[20], data[21], data[22], data[23]]);
2965+ return Some((w, h));
2966+ }
2967+
2968+ // GIF: "GIF87a" or "GIF89a", width/height at bytes 6-9 (little-endian)
2969+ if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
2970+ let w = u16::from_le_bytes([data[6], data[7]]) as u32;
2971+ let h = u16::from_le_bytes([data[8], data[9]]) as u32;
2972+ return Some((w, h));
2973+ }
2974+
2975+ // JPEG: scan for SOF0/SOF2 marker (0xFF 0xC0 or 0xFF 0xC2)
2976+ if data.starts_with(b"\xff\xd8") {
2977+ let mut i = 2;
2978+ while i + 9 < data.len() {
2979+ if data[i] != 0xFF {
2980+ i += 1;
2981+ continue;
2982+ }
2983+ let marker = data[i + 1];
2984+ if marker == 0xC0 || marker == 0xC2 {
2985+ let h = u16::from_be_bytes([data[i + 5], data[i + 6]]) as u32;
2986+ let w = u16::from_be_bytes([data[i + 7], data[i + 8]]) as u32;
2987+ return Some((w, h));
2988+ }
2989+ // Skip to next marker
2990+ if i + 3 < data.len() {
2991+ let len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
2992+ i += 2 + len;
2993+ } else {
2994+ break;
2995+ }
2996+ }
2997+ }
2998+
2999+ // WebP: "RIFF" ... "WEBP", VP8 header at byte 20
3000+ if data.len() >= 30 && &data[..4] == b"RIFF" && &data[8..12] == b"WEBP" {
3001+ // VP8 (lossy): dimensions at bytes 26-29
3002+ if &data[12..16] == b"VP8 " && data.len() >= 30 {
3003+ let w = u16::from_le_bytes([data[26], data[27]]) as u32 & 0x3FFF;
3004+ let h = u16::from_le_bytes([data[28], data[29]]) as u32 & 0x3FFF;
3005+ return Some((w, h));
3006+ }
3007+ // VP8L (lossless): signature byte 0x2f at byte 21
3008+ if &data[12..16] == b"VP8L" && data.len() >= 25 && data[21] == 0x2F {
3009+ let bits = u32::from_le_bytes([data[22], data[23], data[24], data[25]]);
3010+ let w = (bits & 0x3FFF) + 1;
3011+ let h = ((bits >> 14) & 0x3FFF) + 1;
3012+ return Some((w, h));
3013+ }
3014+ }
3015+
3016+ None
3017+}
3018+
28203019fn html_escape(s: &str) -> String {
28213020 s.replace('&', "&amp;")
28223021 .replace('<', "&lt;")
src/main.rs+270−15
@@ -14,10 +14,10 @@ struct DiagnosticJson {
1414}
1515
1616fn compile(input_path: &str, dev: bool, error_overlay: bool, check_only: bool, output_path: Option<&str>, format_json: bool, json_collector: Option<&Mutex<Vec<DiagnosticJson>>>) -> (bool, Vec<PathBuf>) {
17− compile_inner(input_path, dev, error_overlay, check_only, output_path, format_json, json_collector, false, false, false)
17+ compile_inner(input_path, dev, error_overlay, check_only, output_path, format_json, json_collector, false, false, false, false)
1818}
1919
20−fn compile_inner(input_path: &str, dev: bool, error_overlay: bool, check_only: bool, output_path: Option<&str>, format_json: bool, json_collector: Option<&Mutex<Vec<DiagnosticJson>>>, minify: bool, compat: bool, strict: bool) -> (bool, Vec<PathBuf>) {
20+fn compile_inner(input_path: &str, dev: bool, error_overlay: bool, check_only: bool, output_path: Option<&str>, format_json: bool, json_collector: Option<&Mutex<Vec<DiagnosticJson>>>, minify: bool, compat: bool, strict: bool, partial: bool) -> (bool, Vec<PathBuf>) {
2121 let input = match fs::read_to_string(input_path) {
2222 Ok(s) => s,
2323 Err(e) => {
@@ -87,13 +87,21 @@ fn compile_inner(input_path: &str, dev: bool, error_overlay: bool, check_only: b
8787 let _ = fs::write(&out_path, &error_html);
8888 }
8989 } else {
90− let html = match (minify, dev, compat) {
91− (true, _, true) => htmlang::codegen::generate_minified_compat(&result.document),
92− (true, _, false) => htmlang::codegen::generate_minified(&result.document),
93− (false, true, true) => htmlang::codegen::generate_dev_compat(&result.document),
94− (false, true, false) => htmlang::codegen::generate_dev(&result.document),
95− (false, false, true) => htmlang::codegen::generate_compat(&result.document),
96− (false, false, false) => htmlang::codegen::generate(&result.document),
90+ let html = if partial {
91+ if dev {
92+ htmlang::codegen::generate_partial_dev(&result.document)
93+ } else {
94+ htmlang::codegen::generate_partial(&result.document)
95+ }
96+ } else {
97+ match (minify, dev, compat) {
98+ (true, _, true) => htmlang::codegen::generate_minified_compat(&result.document),
99+ (true, _, false) => htmlang::codegen::generate_minified(&result.document),
100+ (false, true, true) => htmlang::codegen::generate_dev_compat(&result.document),
101+ (false, true, false) => htmlang::codegen::generate_dev(&result.document),
102+ (false, false, true) => htmlang::codegen::generate_compat(&result.document),
103+ (false, false, false) => htmlang::codegen::generate(&result.document),
104+ }
97105 };
98106 match fs::write(&out_path, &html) {
99107 Ok(()) => eprintln!("wrote {}", out_path.display()),
@@ -356,6 +364,8 @@ Commands:
356364 bundle <file|dir> [-o <out>] Compile and inline all assets as data URIs
357365 size <file|dir> Report output sizes (raw, minified, ~gzip)
358366 benchmark <file|dir> Measure compile time and output size
367+ lsp Start the Language Server Protocol server
368+ completions <shell> Generate shell completions (bash, zsh, fish)
359369
360370Options:
361371 -w, --watch Watch for changes and recompile
@@ -367,6 +377,7 @@ Options:
367377 -c, --check Check for errors without writing output
368378 --compat Add vendor prefixes for broader browser support
369379 --strict Treat warnings as errors (useful for CI)
380+ --partial Output HTML fragment without document wrapper
370381 --format json Output diagnostics as JSON to stdout
371382 -h, --help Show this help
372383 -V, --version Show version
@@ -889,10 +900,24 @@ fn main() {
889900 let mut compat = false;
890901 let mut strict = false;
891902 let mut open_browser = false;
903+ let mut partial = false;
892904 let mut port: u16 = 3000;
893905 let mut output_path: Option<String> = None;
894906 let mut input_path = None;
895907
908+ // Handle "lsp" subcommand — launch the LSP server from the main binary
909+ if args.len() >= 2 && args[1] == "lsp" {
910+ run_lsp();
911+ return;
912+ }
913+
914+ // Handle "completions" subcommand — generate shell completions
915+ if args.len() >= 2 && args[1] == "completions" {
916+ let shell = args.get(2).map(|s| s.as_str()).unwrap_or("bash");
917+ print_shell_completions(shell);
918+ return;
919+ }
920+
896921 // Handle "init" subcommand
897922 if args.len() >= 2 && args[1] == "init" {
898923 let mut init_dir = ".";
@@ -1009,7 +1034,7 @@ fn main() {
10091034 }
10101035 let path_str = file.to_string_lossy().to_string();
10111036 let (has_errors, _) = if build_minify || build_compat {
1012− compile_inner(&path_str, false, false, false, effective_out.as_deref(), false, None, build_minify, build_compat, false)
1037+ compile_inner(&path_str, false, false, false, effective_out.as_deref(), false, None, build_minify, build_compat, false, false)
10131038 } else {
10141039 compile(&path_str, false, false, false, effective_out.as_deref(), false, None)
10151040 };
@@ -2919,6 +2944,7 @@ compile();
29192944 "--compat" => compat = true,
29202945 "--strict" => strict = true,
29212946 "--open" => open_browser = true,
2947+ "--partial" => partial = true,
29222948 "--format" => {
29232949 i += 1;
29242950 match args.get(i) {
@@ -3008,7 +3034,7 @@ compile();
30083034 }
30093035 out_p.to_string_lossy().to_string()
30103036 });
3011− let (has_errors, included) = compile_inner(&path_str, dev, serve, check, effective_out.as_deref(), format_json, json_collector.as_ref(), false, compat, strict);
3037+ let (has_errors, included) = compile_inner(&path_str, dev, serve, check, effective_out.as_deref(), format_json, json_collector.as_ref(), false, compat, strict, partial);
30123038 if has_errors {
30133039 any_errors = true;
30143040 }
@@ -3028,14 +3054,14 @@ compile();
30283054 return;
30293055 }
30303056
3031− // For directory serve mode, serve the directory with index.html
3057+ // For directory serve mode, serve the directory with route mapping
30323058 let reload_tx = if serve {
30333059 let (tx, _) = tokio::sync::broadcast::channel::<()>(16);
3034− let index_path = dir.join("index.html");
3060+ let serve_dir = dir.to_path_buf();
30353061 let server_tx = tx.clone();
30363062 std::thread::spawn(move || {
30373063 let rt = tokio::runtime::Runtime::new().expect("failed to create runtime");
3038− rt.block_on(htmlang::serve::run(port, index_path, server_tx));
3064+ rt.block_on(htmlang::serve::run_dir(port, serve_dir, server_tx));
30393065 });
30403066 if open_browser {
30413067 open_in_browser(port);
@@ -3051,7 +3077,7 @@ compile();
30513077
30523078 // --- Single file mode ---
30533079 let json_collector_single = if format_json { Some(Mutex::new(Vec::new())) } else { None };
3054− let (has_errors, included_files) = compile_inner(&input_path, dev, serve, check, output_path.as_deref(), format_json, json_collector_single.as_ref(), false, compat, strict);
3080+ let (has_errors, included_files) = compile_inner(&input_path, dev, serve, check, output_path.as_deref(), format_json, json_collector_single.as_ref(), false, compat, strict, partial);
30553081 if format_json {
30563082 if let Some(ref collector) = json_collector_single {
30573083 print_json_diagnostics(&collector.lock().unwrap());
@@ -3096,6 +3122,235 @@ compile();
30963122 );
30973123}
30983124
3125+// ---------------------------------------------------------------------------
3126+// LSP subcommand: launch the Language Server Protocol server
3127+// ---------------------------------------------------------------------------
3128+
3129+fn run_lsp() {
3130+ // Try to find the htmlang-lsp binary alongside this binary, or in PATH
3131+ let self_exe = env::current_exe().ok();
3132+ let lsp_name = if cfg!(windows) { "htmlang-lsp.exe" } else { "htmlang-lsp" };
3133+
3134+ let lsp_path = self_exe
3135+ .as_ref()
3136+ .and_then(|exe| exe.parent())
3137+ .map(|dir| dir.join(lsp_name))
3138+ .filter(|p| p.exists());
3139+
3140+ let lsp_cmd = lsp_path
3141+ .map(|p| p.to_string_lossy().to_string())
3142+ .unwrap_or_else(|| lsp_name.to_string());
3143+
3144+ let status = std::process::Command::new(&lsp_cmd)
3145+ .stdin(std::process::Stdio::inherit())
3146+ .stdout(std::process::Stdio::inherit())
3147+ .stderr(std::process::Stdio::inherit())
3148+ .status();
3149+
3150+ match status {
3151+ Ok(s) => {
3152+ if !s.success() {
3153+ process::exit(s.code().unwrap_or(1));
3154+ }
3155+ }
3156+ Err(_) => {
3157+ eprintln!("error: could not find htmlang-lsp binary");
3158+ eprintln!("hint: ensure htmlang-lsp is in the same directory as htmlang, or in your PATH");
3159+ process::exit(1);
3160+ }
3161+ }
3162+}
3163+
3164+// ---------------------------------------------------------------------------
3165+// Shell completion generation
3166+// ---------------------------------------------------------------------------
3167+
3168+fn print_shell_completions(shell: &str) {
3169+ match shell {
3170+ "bash" => print_bash_completions(),
3171+ "zsh" => print_zsh_completions(),
3172+ "fish" => print_fish_completions(),
3173+ _ => {
3174+ eprintln!("unknown shell: {}. Supported: bash, zsh, fish", shell);
3175+ process::exit(1);
3176+ }
3177+ }
3178+}
3179+
3180+fn print_bash_completions() {
3181+ println!(r#"_htmlang() {{
3182+ local cur prev commands
3183+ cur="${{COMP_WORDS[COMP_CWORD]}}"
3184+ prev="${{COMP_WORDS[COMP_CWORD-1]}}"
3185+ commands="init new build serve watch check convert fmt sitemap lint stats preview diff export repl feed components deps dead-code deploy playground clean upgrade create-component outline doctor migrate bundle size benchmark test lsp completions"
3186+
3187+ if [ "$COMP_CWORD" -eq 1 ]; then
3188+ COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
3189+ COMPREPLY+=( $(compgen -f -X '!*.hl' -- "$cur") )
3190+ COMPREPLY+=( $(compgen -d -- "$cur") )
3191+ return
3192+ fi
3193+
3194+ case "$prev" in
3195+ -o|--output) COMPREPLY=( $(compgen -f -- "$cur") ) ;;
3196+ -p|--port) COMPREPLY=() ;;
3197+ --format) COMPREPLY=( $(compgen -W "json" -- "$cur") ) ;;
3198+ --template|-t) COMPREPLY=( $(compgen -W "blog docs portfolio" -- "$cur") ) ;;
3199+ --provider) COMPREPLY=( $(compgen -W "github-pages netlify vercel cloudflare" -- "$cur") ) ;;
3200+ completions) COMPREPLY=( $(compgen -W "bash zsh fish" -- "$cur") ) ;;
3201+ *)
3202+ COMPREPLY=( $(compgen -W "-w --watch -s --serve -d --dev -c --check --compat --strict --partial --open --format --minify -o --output -p --port -h --help -V --version" -- "$cur") )
3203+ COMPREPLY+=( $(compgen -f -X '!*.hl' -- "$cur") )
3204+ COMPREPLY+=( $(compgen -d -- "$cur") )
3205+ ;;
3206+ esac
3207+}}
3208+complete -F _htmlang htmlang"#);
3209+}
3210+
3211+fn print_zsh_completions() {
3212+ println!(r#"#compdef htmlang
3213+
3214+_htmlang() {{
3215+ local -a commands=(
3216+ 'init:Create a new project'
3217+ 'new:Create a new .hl page'
3218+ 'build:Compile all .hl files'
3219+ 'serve:Start dev server'
3220+ 'watch:Watch and recompile'
3221+ 'check:Check for errors'
3222+ 'convert:Convert HTML to .hl'
3223+ 'fmt:Format a .hl file'
3224+ 'sitemap:Generate sitemap.xml'
3225+ 'lint:Lint checks'
3226+ 'stats:Show file statistics'
3227+ 'preview:Compile and open in browser'
3228+ 'diff:Compare two .hl files'
3229+ 'export:Bundle into archive'
3230+ 'repl:Interactive REPL'
3231+ 'feed:Generate RSS feed'
3232+ 'components:List @fn definitions'
3233+ 'deps:Show dependency graph'
3234+ 'dead-code:Find unused definitions'
3235+ 'deploy:Build and deploy'
3236+ 'playground:Generate playground'
3237+ 'clean:Remove generated files'
3238+ 'upgrade:Auto-upgrade syntax'
3239+ 'create-component:Scaffold component'
3240+ 'outline:Show document structure'
3241+ 'doctor:Check toolchain health'
3242+ 'migrate:Upgrade deprecated syntax'
3243+ 'bundle:Inline all assets'
3244+ 'size:Report output sizes'
3245+ 'benchmark:Measure compile time'
3246+ 'test:Run assertions'
3247+ 'lsp:Start LSP server'
3248+ 'completions:Generate shell completions'
3249+ )
3250+
3251+ local -a flags=(
3252+ '-w[Watch for changes]'
3253+ '--watch[Watch for changes]'
3254+ '-s[Start dev server]'
3255+ '--serve[Start dev server]'
3256+ '-d[Development mode]'
3257+ '--dev[Development mode]'
3258+ '-c[Check only]'
3259+ '--check[Check only]'
3260+ '--compat[Vendor prefixes]'
3261+ '--strict[Warnings as errors]'
3262+ '--partial[Output HTML fragment]'
3263+ '--open[Open browser]'
3264+ '--minify[Minify output]'
3265+ '-o[Output path]:path:_files'
3266+ '--output[Output path]:path:_files'
3267+ '-p[Port]:port:'
3268+ '--port[Port]:port:'
3269+ '--format[Output format]:format:(json)'
3270+ '-h[Show help]'
3271+ '--help[Show help]'
3272+ '-V[Show version]'
3273+ '--version[Show version]'
3274+ )
3275+
3276+ if (( CURRENT == 2 )); then
3277+ _alternative 'commands:command:compadd -a commands' 'files:file:_files -g "*.hl"' 'dirs:directory:_directories'
3278+ else
3279+ _alternative 'flags:flag:compadd -a flags' 'files:file:_files -g "*.hl"' 'dirs:directory:_directories'
3280+ fi
3281+}}
3282+
3283+_htmlang "$@""#);
3284+}
3285+
3286+fn print_fish_completions() {
3287+ let commands = [
3288+ ("init", "Create a new project"),
3289+ ("new", "Create a new .hl page"),
3290+ ("build", "Compile all .hl files"),
3291+ ("serve", "Start dev server with hot reload"),
3292+ ("watch", "Watch for changes and recompile"),
3293+ ("check", "Check for errors"),
3294+ ("convert", "Convert HTML to .hl"),
3295+ ("fmt", "Format a .hl file"),
3296+ ("sitemap", "Generate sitemap.xml"),
3297+ ("lint", "Strict lint checks"),
3298+ ("stats", "Show file statistics"),
3299+ ("preview", "Compile and open in browser"),
3300+ ("diff", "Compare two .hl files"),
3301+ ("export", "Bundle into archive"),
3302+ ("repl", "Interactive REPL"),
3303+ ("feed", "Generate RSS feed"),
3304+ ("components", "List @fn definitions"),
3305+ ("deps", "Show dependency graph"),
3306+ ("dead-code", "Find unused definitions"),
3307+ ("deploy", "Build and deploy"),
3308+ ("playground", "Generate playground"),
3309+ ("clean", "Remove generated files"),
3310+ ("upgrade", "Auto-upgrade syntax"),
3311+ ("create-component", "Scaffold component"),
3312+ ("outline", "Show document structure"),
3313+ ("doctor", "Check toolchain health"),
3314+ ("migrate", "Upgrade deprecated syntax"),
3315+ ("bundle", "Inline all assets as data URIs"),
3316+ ("size", "Report output sizes"),
3317+ ("benchmark", "Measure compile time"),
3318+ ("test", "Run assertions"),
3319+ ("lsp", "Start LSP server"),
3320+ ("completions", "Generate shell completions"),
3321+ ];
3322+
3323+ for (cmd, desc) in &commands {
3324+ println!("complete -c htmlang -n '__fish_use_subcommand' -a '{}' -d '{}'", cmd, desc);
3325+ }
3326+
3327+ let flags = [
3328+ ("-w", "--watch", "Watch for changes"),
3329+ ("-s", "--serve", "Start dev server"),
3330+ ("-d", "--dev", "Development mode"),
3331+ ("-c", "--check", "Check only"),
3332+ ("", "--compat", "Add vendor prefixes"),
3333+ ("", "--strict", "Treat warnings as errors"),
3334+ ("", "--partial", "Output HTML fragment without document wrapper"),
3335+ ("", "--open", "Open browser"),
3336+ ("", "--minify", "Minify output"),
3337+ ("-o", "--output", "Output path"),
3338+ ("-p", "--port", "Dev server port"),
3339+ ("-h", "--help", "Show help"),
3340+ ("-V", "--version", "Show version"),
3341+ ];
3342+
3343+ for (short, long, desc) in &flags {
3344+ if short.is_empty() {
3345+ println!("complete -c htmlang -l '{}' -d '{}'", long.trim_start_matches('-'), desc);
3346+ } else {
3347+ println!("complete -c htmlang -s '{}' -l '{}' -d '{}'", short.trim_start_matches('-'), long.trim_start_matches('-'), desc);
3348+ }
3349+ }
3350+
3351+ println!("complete -c htmlang -n '__fish_seen_subcommand_from completions' -a 'bash zsh fish'");
3352+}
3353+
30993354fn collect_hl_files(dir: &Path) -> Vec<PathBuf> {
31003355 let mut files = Vec::new();
31013356 if let Ok(entries) = fs::read_dir(dir) {
src/parser.rs+364
@@ -904,6 +904,221 @@ impl Parser {
904904 return Ok(None); // No nodes emitted
905905 }
906906
907+ // --- @env (compile-time environment variables) ---
908+
909+ if let Some(rest) = content.strip_prefix("@env ") {
910+ let rest = rest.trim();
911+ // @env VAR_NAME default_value OR @env VAR_NAME
912+ let (var_name, default_val) = if let Some((name, default)) = rest.split_once(' ') {
913+ (name.trim(), Some(default.trim()))
914+ } else {
915+ (rest, None)
916+ };
917+ let env_val = std::env::var(var_name).ok().or_else(|| default_val.map(|d| {
918+ let d = substitute_vars(d, &ctx.variables);
919+ d
920+ }));
921+ match env_val {
922+ Some(val) => {
923+ // Store as variable with lowercase name
924+ let key = var_name.to_lowercase().replace('-', "_");
925+ ctx.variables.insert(key, val);
926+ }
927+ None => {
928+ ctx.diagnostics.push(Diagnostic {
929+ line: line_num,
930+ message: format!("environment variable '{}' is not set and no default provided", var_name),
931+ severity: Severity::Warning,
932+ source_line: Some(content.clone()),
933+ });
934+ }
935+ }
936+ return Ok(None);
937+ }
938+
939+ // --- @fetch (compile-time HTTP data fetching) ---
940+
941+ if let Some(rest) = content.strip_prefix("@fetch ") {
942+ let rest = rest.trim();
943+ // @fetch $prefix https://url OR @fetch https://url
944+ let (prefix, url) = if rest.starts_with('$') {
945+ if let Some((p, u)) = rest.split_once(' ') {
946+ (p.strip_prefix('$').unwrap_or(p).to_string(), u.trim().to_string())
947+ } else {
948+ return Err(ParseError {
949+ line: line_num,
950+ message: "@fetch requires: @fetch $prefix url or @fetch url".to_string(),
951+ });
952+ }
953+ } else {
954+ (String::new(), rest.to_string())
955+ };
956+
957+ let url = substitute_vars(&url, &ctx.variables);
958+
959+ // Synchronous HTTP GET using std::net
960+ match fetch_url_blocking(&url) {
961+ Ok(body) => {
962+ // Try to parse as JSON
963+ match parse_json(&body) {
964+ Some(json) => {
965+ if prefix.is_empty() {
966+ if let JsonValue::Object(pairs) = &json {
967+ for (key, val) in pairs {
968+ let mut sub = HashMap::new();
969+ flatten_json(key, val, &mut sub);
970+ for (k, v) in sub {
971+ ctx.variables.insert(k, v);
972+ }
973+ }
974+ } else {
975+ // Store the raw body as a single variable
976+ ctx.variables.insert("__fetch_body".to_string(), body);
977+ }
978+ } else {
979+ let mut sub = HashMap::new();
980+ flatten_json(&prefix, &json, &mut sub);
981+ for (k, v) in sub {
982+ ctx.variables.insert(k, v);
983+ }
984+ }
985+ }
986+ None => {
987+ // Not JSON — store raw body
988+ if prefix.is_empty() {
989+ ctx.variables.insert("__fetch_body".to_string(), body);
990+ } else {
991+ ctx.variables.insert(prefix, body);
992+ }
993+ }
994+ }
995+ }
996+ Err(e) => {
997+ ctx.diagnostics.push(Diagnostic {
998+ line: line_num,
999+ message: format!("@fetch failed for '{}': {}", url, e),
1000+ severity: Severity::Error,
1001+ source_line: Some(content.clone()),
1002+ });
1003+ }
1004+ }
1005+ return Ok(None);
1006+ }
1007+
1008+ // --- @svg (inline SVG from file) ---
1009+
1010+ if let Some(rest) = content.strip_prefix("@svg ") {
1011+ let rest = rest.trim();
1012+ // @svg file.svg OR @svg [attrs] file.svg
1013+ let (attrs_part, filename) = if rest.starts_with('[') {
1014+ if let Some(bracket_end) = rest.find(']') {
1015+ let attrs_str = &rest[..=bracket_end];
1016+ let file = rest[bracket_end + 1..].trim();
1017+ (Some(attrs_str.to_string()), file.to_string())
1018+ } else {
1019+ (None, rest.to_string())
1020+ }
1021+ } else {
1022+ (None, rest.to_string())
1023+ };
1024+
1025+ let filename = substitute_vars(&filename, &ctx.variables);
1026+ let resolved = match &ctx.base_path {
1027+ Some(base) => base.join(&filename),
1028+ None => PathBuf::from(&filename),
1029+ };
1030+
1031+ match std::fs::read_to_string(&resolved) {
1032+ Ok(svg_content) => {
1033+ let mut svg = svg_content.trim().to_string();
1034+ // Apply attributes (width, height, color/fill, class)
1035+ if let Some(ref attrs_str) = attrs_part {
1036+ let (attrs, _) = parse_attr_brackets(attrs_str, line_num, ctx)?;
1037+ for attr in &attrs {
1038+ match attr.key.as_str() {
1039+ "width" => {
1040+ if let Some(ref val) = attr.value {
1041+ svg = set_svg_attr(&svg, "width", val);
1042+ }
1043+ }
1044+ "height" => {
1045+ if let Some(ref val) = attr.value {
1046+ svg = set_svg_attr(&svg, "height", val);
1047+ }
1048+ }
1049+ "color" | "fill" => {
1050+ if let Some(ref val) = attr.value {
1051+ svg = set_svg_attr(&svg, "fill", val);
1052+ }
1053+ }
1054+ "class" => {
1055+ if let Some(ref val) = attr.value {
1056+ svg = set_svg_attr(&svg, "class", val);
1057+ }
1058+ }
1059+ "id" => {
1060+ if let Some(ref val) = attr.value {
1061+ svg = set_svg_attr(&svg, "id", val);
1062+ }
1063+ }
1064+ _ => {}
1065+ }
1066+ }
1067+ }
1068+ return Ok(Some(vec![Node::Raw(svg)]));
1069+ }
1070+ Err(e) => {
1071+ ctx.diagnostics.push(Diagnostic {
1072+ line: line_num,
1073+ message: format!("cannot load SVG '{}': {}", filename, e),
1074+ severity: Severity::Error,
1075+ source_line: Some(content.clone()),
1076+ });
1077+ return Ok(None);
1078+ }
1079+ }
1080+ }
1081+
1082+ // --- @css-property (CSS @property rule for typed custom properties) ---
1083+
1084+ if let Some(rest) = content.strip_prefix("@css-property ") {
1085+ let rest = rest.trim();
1086+ // @css-property --name
1087+ // syntax "<color>"
1088+ // inherits true
1089+ // initial-value #000
1090+ let prop_name = substitute_vars(rest, &ctx.variables);
1091+ let mut syntax = String::from("\"*\"");
1092+ let mut inherits = String::from("false");
1093+ let mut initial_value = String::new();
1094+
1095+ while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
1096+ if let LineContent::Normal(ref s) = self.lines[self.pos].content {
1097+ let trimmed = s.trim();
1098+ if let Some((key, value)) = trimmed.split_once(' ') {
1099+ let value = substitute_vars(value.trim(), &ctx.variables);
1100+ match key.trim() {
1101+ "syntax" => syntax = value,
1102+ "inherits" => inherits = value,
1103+ "initial-value" | "initial_value" => initial_value = value,
1104+ _ => {}
1105+ }
1106+ }
1107+ }
1108+ self.pos += 1;
1109+ }
1110+
1111+ let mut rule = format!("@property {} {{", prop_name);
1112+ rule.push_str(&format!("syntax:{};", syntax));
1113+ rule.push_str(&format!("inherits:{};", inherits));
1114+ if !initial_value.is_empty() {
1115+ rule.push_str(&format!("initial-value:{};", initial_value));
1116+ }
1117+ rule.push('}');
1118+ ctx.custom_css.push(rule);
1119+ return Ok(None);
1120+ }
1121+
9071122 // --- @data (load JSON file into variables) ---
9081123
9091124 if let Some(rest) = content.strip_prefix("@data ") {
@@ -2878,6 +3093,8 @@ const KNOWN_ATTRS: &[&str] = &[
28783093 "anchor-name", "position-anchor", "position-area", "inset-area",
28793094 // Drop caps
28803095 "initial-letter",
3096+ // Responsive images
3097+ "responsive",
28813098];
28823099
28833100/// Attributes that expect purely numeric values (px-based) or values with CSS units.
@@ -4452,6 +4669,153 @@ fn json_value_to_string(v: &JsonValue) -> String {
44524669 }
44534670}
44544671
4672+// ---------------------------------------------------------------------------
4673+// HTTP fetch helper (blocking, minimal, no dependencies)
4674+// ---------------------------------------------------------------------------
4675+
4676+fn fetch_url_blocking(url: &str) -> Result<String, String> {
4677+ use std::io::{Read, Write};
4678+ use std::net::TcpStream;
4679+
4680+ let is_https = url.starts_with("https://");
4681+ let url_without_scheme = url
4682+ .strip_prefix("https://")
4683+ .or_else(|| url.strip_prefix("http://"))
4684+ .ok_or_else(|| "URL must start with http:// or https://".to_string())?;
4685+
4686+ let (host_port, path) = match url_without_scheme.find('/') {
4687+ Some(pos) => (&url_without_scheme[..pos], &url_without_scheme[pos..]),
4688+ None => (url_without_scheme, "/"),
4689+ };
4690+
4691+ let (host, port) = match host_port.find(':') {
4692+ Some(pos) => (
4693+ &host_port[..pos],
4694+ host_port[pos + 1..]
4695+ .parse::<u16>()
4696+ .map_err(|e| format!("invalid port: {}", e))?,
4697+ ),
4698+ None => (host_port, if is_https { 443 } else { 80 }),
4699+ };
4700+
4701+ if is_https {
4702+ return Err("@fetch does not support https:// (no TLS in std). Use @data with a local JSON file, or set up a build script to fetch data before compilation.".to_string());
4703+ }
4704+
4705+ let addr = format!("{}:{}", host, port);
4706+ let mut stream =
4707+ TcpStream::connect(&addr).map_err(|e| format!("connection failed: {}", e))?;
4708+ stream
4709+ .set_read_timeout(Some(std::time::Duration::from_secs(10)))
4710+ .ok();
4711+
4712+ let request = format!(
4713+ "GET {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\nAccept: application/json, text/plain, */*\r\n\r\n",
4714+ path, host
4715+ );
4716+ stream
4717+ .write_all(request.as_bytes())
4718+ .map_err(|e| format!("write failed: {}", e))?;
4719+
4720+ let mut response = Vec::new();
4721+ stream
4722+ .read_to_end(&mut response)
4723+ .map_err(|e| format!("read failed: {}", e))?;
4724+
4725+ let response_str = String::from_utf8_lossy(&response);
4726+ // Split headers and body
4727+ if let Some(body_start) = response_str.find("\r\n\r\n") {
4728+ let headers = &response_str[..body_start];
4729+ let body = &response_str[body_start + 4..];
4730+
4731+ // Check status code
4732+ if let Some(first_line) = headers.lines().next() {
4733+ if let Some(code_str) = first_line.split_whitespace().nth(1) {
4734+ let code: u16 = code_str.parse().unwrap_or(0);
4735+ if code >= 400 {
4736+ return Err(format!("HTTP {}", code));
4737+ }
4738+ }
4739+ }
4740+
4741+ // Handle chunked transfer encoding
4742+ if headers.to_lowercase().contains("transfer-encoding: chunked") {
4743+ return Ok(decode_chunked(body));
4744+ }
4745+
4746+ Ok(body.to_string())
4747+ } else {
4748+ Err("malformed HTTP response".to_string())
4749+ }
4750+}
4751+
4752+fn decode_chunked(body: &str) -> String {
4753+ let mut result = String::new();
4754+ let mut rest = body;
4755+ loop {
4756+ let rest_trimmed = rest.trim_start();
4757+ if rest_trimmed.is_empty() {
4758+ break;
4759+ }
4760+ let size_end = rest_trimmed
4761+ .find("\r\n")
4762+ .unwrap_or(rest_trimmed.len());
4763+ let size_str = &rest_trimmed[..size_end];
4764+ let size = usize::from_str_radix(size_str.trim(), 16).unwrap_or(0);
4765+ if size == 0 {
4766+ break;
4767+ }
4768+ let chunk_start = size_end + 2;
4769+ if chunk_start + size <= rest_trimmed.len() {
4770+ result.push_str(&rest_trimmed[chunk_start..chunk_start + size]);
4771+ rest = &rest_trimmed[chunk_start + size..];
4772+ if rest.starts_with("\r\n") {
4773+ rest = &rest[2..];
4774+ }
4775+ } else {
4776+ result.push_str(&rest_trimmed[chunk_start..]);
4777+ break;
4778+ }
4779+ }
4780+ result
4781+}
4782+
4783+// ---------------------------------------------------------------------------
4784+// SVG attribute injection helper
4785+// ---------------------------------------------------------------------------
4786+
4787+fn set_svg_attr(svg: &str, attr_name: &str, value: &str) -> String {
4788+ // If the SVG already has this attribute, replace it
4789+ let pattern = format!("{}=\"", attr_name);
4790+ if let Some(pos) = svg.find(&pattern) {
4791+ let after = &svg[pos + pattern.len()..];
4792+ if let Some(end) = after.find('"') {
4793+ let mut result = String::with_capacity(svg.len());
4794+ result.push_str(&svg[..pos]);
4795+ result.push_str(attr_name);
4796+ result.push_str("=\"");
4797+ result.push_str(value);
4798+ result.push('"');
4799+ result.push_str(&after[end + 1..]);
4800+ return result;
4801+ }
4802+ }
4803+ // Otherwise inject it into the opening <svg tag
4804+ if let Some(pos) = svg.find("<svg") {
4805+ let tag_end = svg[pos..].find('>').map(|p| pos + p).unwrap_or(svg.len());
4806+ let mut result = String::with_capacity(svg.len() + attr_name.len() + value.len() + 4);
4807+ result.push_str(&svg[..tag_end]);
4808+ result.push(' ');
4809+ result.push_str(attr_name);
4810+ result.push_str("=\"");
4811+ result.push_str(value);
4812+ result.push('"');
4813+ result.push_str(&svg[tag_end..]);
4814+ return result;
4815+ }
4816+ svg.to_string()
4817+}
4818+
44554819// ---------------------------------------------------------------------------
44564820// Markdown → HTML conversion (minimal subset)
44574821// ---------------------------------------------------------------------------
src/serve.rs+193−3
@@ -7,6 +7,7 @@ use tokio::sync::broadcast;
77const RELOAD_SCRIPT: &str =
88 r#"<script>new EventSource("/_events").onmessage=()=>location.reload()</script>"#;
99
10+/// Serve a single HTML file (legacy single-file mode).
1011pub async fn run(port: u16, html_path: PathBuf, reload: broadcast::Sender<()>) {
1112 let listener = match TcpListener::bind(("127.0.0.1", port)).await {
1213 Ok(l) => l,
@@ -21,15 +22,196 @@ pub async fn run(port: u16, html_path: PathBuf, reload: broadcast::Sender<()>) {
2122 let path = html_path.clone();
2223 let rx = reload.subscribe();
2324 tokio::spawn(async move {
24− let _ = handle(stream, path, rx).await;
25+ let _ = handle(stream, path, None, rx).await;
2526 });
2627 }
2728 }
2829}
2930
31+/// Serve a directory of HTML files with auto-compilation, directory index, and route mapping.
32+pub async fn run_dir(port: u16, root_dir: PathBuf, reload: broadcast::Sender<()>) {
33+ let listener = match TcpListener::bind(("127.0.0.1", port)).await {
34+ Ok(l) => l,
35+ Err(e) => {
36+ eprintln!("error: failed to bind to port {}: {}", port, e);
37+ return;
38+ }
39+ };
40+
41+ loop {
42+ if let Ok((stream, _)) = listener.accept().await {
43+ let dir = root_dir.clone();
44+ let rx = reload.subscribe();
45+ tokio::spawn(async move {
46+ let _ = handle_dir_request(stream, dir, rx).await;
47+ });
48+ }
49+ }
50+}
51+
52+async fn handle_dir_request(
53+ mut stream: TcpStream,
54+ root_dir: PathBuf,
55+ reload_rx: broadcast::Receiver<()>,
56+) -> std::io::Result<()> {
57+ let mut buf = [0u8; 4096];
58+ let n = stream.read(&mut buf).await?;
59+ if n == 0 {
60+ return Ok(());
61+ }
62+
63+ let request = String::from_utf8_lossy(&buf[..n]);
64+ let path = request
65+ .lines()
66+ .next()
67+ .and_then(|line| line.split_whitespace().nth(1))
68+ .unwrap_or("/");
69+
70+ if path == "/_events" {
71+ return handle_sse(stream, reload_rx).await;
72+ }
73+
74+ if path.contains("..") {
75+ stream
76+ .write_all(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n")
77+ .await?;
78+ return Ok(());
79+ }
80+
81+ let clean_path = path.trim_start_matches('/');
82+
83+ // Try to resolve the request to a file
84+ let file_path = if clean_path.is_empty() {
85+ // Root: try index.html
86+ let index = root_dir.join("index.html");
87+ if index.exists() {
88+ Some(index)
89+ } else {
90+ // Generate directory listing
91+ let listing = generate_directory_listing(&root_dir);
92+ let header = format!(
93+ "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nCache-Control: no-cache\r\n\r\n",
94+ listing.len(),
95+ );
96+ stream.write_all(header.as_bytes()).await?;
97+ stream.write_all(listing.as_bytes()).await?;
98+ return Ok(());
99+ }
100+ } else {
101+ let requested = root_dir.join(clean_path);
102+ if requested.is_file() {
103+ Some(requested)
104+ } else {
105+ // Try .html extension
106+ let with_html = requested.with_extension("html");
107+ if with_html.is_file() {
108+ Some(with_html)
109+ } else {
110+ // Try as directory with index.html
111+ let dir_index = requested.join("index.html");
112+ if dir_index.is_file() {
113+ Some(dir_index)
114+ } else {
115+ None
116+ }
117+ }
118+ }
119+ };
120+
121+ match file_path {
122+ Some(fp) => {
123+ let body = match std::fs::read(&fp) {
124+ Ok(content) => {
125+ if fp.extension().map_or(false, |e| e == "html") {
126+ inject_reload_script(&content)
127+ } else {
128+ content
129+ }
130+ }
131+ Err(_) => {
132+ stream
133+ .write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n")
134+ .await?;
135+ return Ok(());
136+ }
137+ };
138+ let content_type = content_type_for(&fp);
139+ let header = format!(
140+ "HTTP/1.1 200 OK\r\nContent-Type: {}\r\nContent-Length: {}\r\nCache-Control: no-cache\r\n\r\n",
141+ content_type, body.len(),
142+ );
143+ stream.write_all(header.as_bytes()).await?;
144+ stream.write_all(&body).await?;
145+ }
146+ None => {
147+ let body = b"<h1>404 Not Found</h1>";
148+ let header = format!(
149+ "HTTP/1.1 404 Not Found\r\nContent-Type: text/html\r\nContent-Length: {}\r\n\r\n",
150+ body.len()
151+ );
152+ stream.write_all(header.as_bytes()).await?;
153+ stream.write_all(body).await?;
154+ }
155+ }
156+
157+ Ok(())
158+}
159+
160+/// Generate a directory listing HTML page showing available .html pages.
161+fn generate_directory_listing(dir: &Path) -> String {
162+ let mut pages = Vec::new();
163+ collect_html_pages(dir, dir, &mut pages);
164+ pages.sort();
165+
166+ let mut html = String::from(
167+ "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>htmlang dev server</title>\
168+ <style>*{margin:0;box-sizing:border-box}body{font-family:system-ui,-apple-system,sans-serif;padding:2rem;max-width:600px;margin:0 auto}\
169+ h1{margin-bottom:1.5rem;font-size:1.5rem;color:#333}a{display:block;padding:0.6rem 1rem;margin:0.25rem 0;color:#2563eb;\
170+ text-decoration:none;border-radius:6px;transition:background 0.15s}a:hover{background:#f0f4ff}\
171+ .path{color:#888;font-size:0.85rem}</style></head><body>\
172+ <h1>htmlang dev server</h1>",
173+ );
174+
175+ if pages.is_empty() {
176+ html.push_str("<p>No .html pages found. Compile some .hl files first.</p>");
177+ } else {
178+ for page in &pages {
179+ let display = page.trim_start_matches('/');
180+ let clean = display.strip_suffix("/index.html").unwrap_or(
181+ display.strip_suffix(".html").unwrap_or(display),
182+ );
183+ let label = if clean.is_empty() { "index" } else { clean };
184+ html.push_str(&format!(
185+ "<a href=\"{}\">{} <span class=\"path\">{}</span></a>",
186+ page, label, display
187+ ));
188+ }
189+ }
190+
191+ html.push_str(RELOAD_SCRIPT);
192+ html.push_str("</body></html>");
193+ html
194+}
195+
196+fn collect_html_pages(base: &Path, dir: &Path, pages: &mut Vec<String>) {
197+ if let Ok(entries) = std::fs::read_dir(dir) {
198+ for entry in entries.flatten() {
199+ let path = entry.path();
200+ if path.is_dir() {
201+ collect_html_pages(base, &path, pages);
202+ } else if path.extension().map_or(false, |e| e == "html") {
203+ if let Ok(rel) = path.strip_prefix(base) {
204+ pages.push(format!("/{}", rel.display()));
205+ }
206+ }
207+ }
208+ }
209+}
210+
30211async fn handle(
31212 mut stream: TcpStream,
32213 html_path: PathBuf,
214+ root_dir: Option<&Path>,
33215 reload_rx: broadcast::Receiver<()>,
34216) -> std::io::Result<()> {
35217 let mut buf = [0u8; 4096];
@@ -48,7 +230,7 @@ async fn handle(
48230 if path == "/_events" {
49231 handle_sse(stream, reload_rx).await
50232 } else {
51− handle_file(stream, &html_path, path).await
233+ handle_file(stream, &html_path, root_dir, path).await
52234 }
53235}
54236
@@ -80,6 +262,7 @@ async fn handle_sse(
80262async fn handle_file(
81263 mut stream: TcpStream,
82264 html_path: &Path,
265+ root_dir: Option<&Path>,
83266 request_path: &str,
84267) -> std::io::Result<()> {
85268 if request_path.contains("..") {
@@ -92,7 +275,8 @@ async fn handle_file(
92275 let file_path = if request_path == "/" {
93276 html_path.to_path_buf()
94277 } else {
95− let dir = html_path.parent().unwrap_or(Path::new("."));
278+ let dir = root_dir
279+ .unwrap_or_else(|| html_path.parent().unwrap_or(Path::new(".")));
96280 dir.join(&request_path[1..])
97281 };
98282
@@ -155,6 +339,12 @@ fn content_type_for(path: &Path) -> &'static str {
155339 Some("ico") => "image/x-icon",
156340 Some("woff") => "font/woff",
157341 Some("woff2") => "font/woff2",
342+ Some("webp") => "image/webp",
343+ Some("avif") => "image/avif",
344+ Some("mp4") => "video/mp4",
345+ Some("webm") => "video/webm",
346+ Some("xml") => "application/xml",
347+ Some("txt") => "text/plain; charset=utf-8",
158348 _ => "application/octet-stream",
159349 }
160350}
tests/snapshots.rs+227
@@ -3614,6 +3614,21 @@ fn snapshot_pagination_each() {
36143614 snapshot_test("pagination_each");
36153615}
36163616
3617+#[test]
3618+fn snapshot_env_directive() {
3619+ snapshot_test("env_directive");
3620+}
3621+
3622+#[test]
3623+fn snapshot_css_property() {
3624+ snapshot_test("css_property");
3625+}
3626+
3627+#[test]
3628+fn snapshot_responsive_images() {
3629+ snapshot_test("responsive_images");
3630+}
3631+
36173632// --- Inline tests for batch 2 ---
36183633
36193634#[test]
@@ -3670,3 +3685,215 @@ fn repeat_with_index() {
36703685 assert!(output.contains("1"), "should have index 1");
36713686 assert!(output.contains("2"), "should have index 2");
36723687}
3688+
3689+// ---------------------------------------------------------------------------
3690+// New feature tests
3691+// ---------------------------------------------------------------------------
3692+
3693+#[test]
3694+fn env_directive_with_default() {
3695+ let output = compile("@env HTMLANG_TEST_NONEXISTENT fallback_value\n@text $htmlang_test_nonexistent");
3696+ assert!(output.contains("fallback_value"), "@env should use default when var is not set, got: {}", output);
3697+}
3698+
3699+#[test]
3700+fn env_directive_from_environment() {
3701+ // Set an env var and check it's picked up
3702+ unsafe { std::env::set_var("HTMLANG_TEST_VAR", "hello_world"); }
3703+ let output = compile("@env HTMLANG_TEST_VAR\n@text $htmlang_test_var");
3704+ assert!(output.contains("hello_world"), "@env should read env var, got: {}", output);
3705+ unsafe { std::env::remove_var("HTMLANG_TEST_VAR"); }
3706+}
3707+
3708+#[test]
3709+fn env_directive_warning_when_missing() {
3710+ let result = htmlang::parser::parse("@env HTMLANG_DEFINITELY_NOT_SET_12345");
3711+ let warnings: Vec<_> = result.diagnostics.iter()
3712+ .filter(|d| d.severity == htmlang::parser::Severity::Warning && d.message.contains("not set"))
3713+ .collect();
3714+ assert!(!warnings.is_empty(), "@env should warn when var is not set, got: {:?}", result.diagnostics);
3715+}
3716+
3717+#[test]
3718+fn fetch_directive_error_on_https() {
3719+ // @fetch with https should produce an error (no TLS support)
3720+ let result = htmlang::parser::parse("@fetch $data https://example.com/api");
3721+ let errors: Vec<_> = result.diagnostics.iter()
3722+ .filter(|d| d.severity == htmlang::parser::Severity::Error && d.message.contains("https"))
3723+ .collect();
3724+ assert!(!errors.is_empty(), "@fetch https should error, got: {:?}", result.diagnostics);
3725+}
3726+
3727+#[test]
3728+fn fetch_directive_error_on_bad_url() {
3729+ let result = htmlang::parser::parse("@fetch $data http://127.0.0.1:1/nonexistent");
3730+ let errors: Vec<_> = result.diagnostics.iter()
3731+ .filter(|d| d.severity == htmlang::parser::Severity::Error)
3732+ .collect();
3733+ assert!(!errors.is_empty(), "@fetch should error on unreachable URL, got: {:?}", result.diagnostics);
3734+}
3735+
3736+#[test]
3737+fn svg_directive_inline() {
3738+ // Create a temporary SVG file
3739+ let dir = std::env::temp_dir().join("htmlang_test_svg");
3740+ let _ = std::fs::create_dir_all(&dir);
3741+ let svg_path = dir.join("test.svg");
3742+ std::fs::write(&svg_path, r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/></svg>"#).unwrap();
3743+
3744+ let input = format!("@svg {}", svg_path.display());
3745+ let result = htmlang::parser::parse(&input);
3746+ assert!(result.diagnostics.iter().all(|d| d.severity != htmlang::parser::Severity::Error),
3747+ "should parse without errors: {:?}", result.diagnostics);
3748+ let html = htmlang::codegen::generate(&result.document);
3749+ assert!(html.contains("<svg"), "should inline SVG content, got: {}", html);
3750+ assert!(html.contains("<circle"), "should contain SVG elements, got: {}", html);
3751+
3752+ let _ = std::fs::remove_dir_all(&dir);
3753+}
3754+
3755+#[test]
3756+fn svg_directive_with_attrs() {
3757+ let dir = std::env::temp_dir().join("htmlang_test_svg_attrs");
3758+ let _ = std::fs::create_dir_all(&dir);
3759+ let svg_path = dir.join("icon.svg");
3760+ std::fs::write(&svg_path, r#"<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48"><rect/></svg>"#).unwrap();
3761+
3762+ let input = format!("@svg [width 24, color red] {}", svg_path.display());
3763+ let result = htmlang::parser::parse(&input);
3764+ let html = htmlang::codegen::generate(&result.document);
3765+ assert!(html.contains("width=\"24\""), "should override width, got: {}", html);
3766+ assert!(html.contains("fill=\"red\""), "should set fill from color attr, got: {}", html);
3767+
3768+ let _ = std::fs::remove_dir_all(&dir);
3769+}
3770+
3771+#[test]
3772+fn svg_directive_missing_file() {
3773+ let result = htmlang::parser::parse("@svg /nonexistent/missing.svg");
3774+ let errors: Vec<_> = result.diagnostics.iter()
3775+ .filter(|d| d.severity == htmlang::parser::Severity::Error && d.message.contains("cannot load SVG"))
3776+ .collect();
3777+ assert!(!errors.is_empty(), "should error on missing SVG, got: {:?}", result.diagnostics);
3778+}
3779+
3780+#[test]
3781+fn css_property_directive() {
3782+ let input = "@css-property --my-color\n syntax \"<color>\"\n inherits true\n initial-value #000\n\n@el [background var(--my-color)] Content";
3783+ let result = htmlang::parser::parse(input);
3784+ assert!(result.diagnostics.iter().all(|d| d.severity != htmlang::parser::Severity::Error),
3785+ "should parse without errors: {:?}", result.diagnostics);
3786+ let html = htmlang::codegen::generate(&result.document);
3787+ assert!(html.contains("@property --my-color"), "@css-property should emit CSS @property rule, got: {}", html);
3788+ assert!(html.contains("syntax:\"<color>\""), "should include syntax, got: {}", html);
3789+ assert!(html.contains("inherits:true"), "should include inherits, got: {}", html);
3790+ assert!(html.contains("initial-value:#000"), "should include initial-value, got: {}", html);
3791+}
3792+
3793+#[test]
3794+fn partial_output() {
3795+ let result = htmlang::parser::parse("@page Test\n@el [padding 20]\n Hello");
3796+ let html = htmlang::codegen::generate_partial(&result.document);
3797+ assert!(!html.contains("<!DOCTYPE"), "partial should not have doctype, got: {}", html);
3798+ assert!(!html.contains("<html"), "partial should not have html tag, got: {}", html);
3799+ assert!(!html.contains("<head"), "partial should not have head tag, got: {}", html);
3800+ assert!(!html.contains("<body"), "partial should not have body tag, got: {}", html);
3801+ assert!(html.contains("<style>"), "partial should still have CSS, got: {}", html);
3802+ assert!(html.contains("Hello"), "partial should have content, got: {}", html);
3803+}
3804+
3805+#[test]
3806+fn partial_output_dev() {
3807+ let result = htmlang::parser::parse("@page Test\n@el [padding 20]\n Hello");
3808+ let html = htmlang::codegen::generate_partial_dev(&result.document);
3809+ assert!(!html.contains("<!DOCTYPE"), "partial dev should not have doctype");
3810+ assert!(html.contains("<style>"), "partial dev should have style");
3811+ assert!(html.contains("Hello"), "partial dev should have content");
3812+}
3813+
3814+#[test]
3815+fn responsive_srcset_on_image() {
3816+ let output = compile("@image [responsive 400 800 1200, alt Photo] photo.jpg");
3817+ assert!(output.contains("srcset=\""), "should generate srcset, got: {}", output);
3818+ assert!(output.contains("photo-400.jpg 400w"), "should have 400w source, got: {}", output);
3819+ assert!(output.contains("photo-800.jpg 800w"), "should have 800w source, got: {}", output);
3820+ assert!(output.contains("photo-1200.jpg 1200w"), "should have 1200w source, got: {}", output);
3821+ assert!(output.contains("sizes="), "should generate sizes attribute, got: {}", output);
3822+}
3823+
3824+#[test]
3825+fn auto_image_dimensions_png() {
3826+ // Create a minimal valid PNG (1x1 pixel)
3827+ let dir = std::env::temp_dir().join("htmlang_test_img_dim");
3828+ let _ = std::fs::create_dir_all(&dir);
3829+ let png_path = dir.join("test.png");
3830+ // Minimal 1x1 PNG file
3831+ let png_data: Vec<u8> = vec![
3832+ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
3833+ 0x00, 0x00, 0x00, 0x0D, // IHDR length
3834+ 0x49, 0x48, 0x44, 0x52, // IHDR
3835+ 0x00, 0x00, 0x00, 0x01, // width = 1
3836+ 0x00, 0x00, 0x00, 0x01, // height = 1
3837+ 0x08, 0x02, 0x00, 0x00, 0x00, // bit depth, color type, etc.
3838+ 0x90, 0x77, 0x53, 0xDE, // CRC
3839+ 0x00, 0x00, 0x00, 0x0C, // IDAT length
3840+ 0x49, 0x44, 0x41, 0x54, // IDAT
3841+ 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01,
3842+ 0xE2, 0x21, 0xBC, 0x33, // CRC
3843+ 0x00, 0x00, 0x00, 0x00, // IEND length
3844+ 0x49, 0x45, 0x4E, 0x44, // IEND
3845+ 0xAE, 0x42, 0x60, 0x82, // CRC
3846+ ];
3847+ std::fs::write(&png_path, &png_data).unwrap();
3848+
3849+ let input = format!("@image [alt test] {}", png_path.display());
3850+ let result = htmlang::parser::parse(&input);
3851+ let html = htmlang::codegen::generate(&result.document);
3852+ assert!(html.contains("width=\"1\""), "should auto-detect width=1 from PNG, got: {}", html);
3853+ assert!(html.contains("height=\"1\""), "should auto-detect height=1 from PNG, got: {}", html);
3854+
3855+ let _ = std::fs::remove_dir_all(&dir);
3856+}
3857+
3858+#[test]
3859+fn auto_image_dimensions_not_for_urls() {
3860+ // Remote URLs should not trigger dimension detection
3861+ let output = compile("@image [alt test] https://example.com/photo.png");
3862+ // Should not crash or add dimensions for remote URLs
3863+ assert!(output.contains("src=\"https://example.com/photo.png\""), "should keep URL src, got: {}", output);
3864+}
3865+
3866+#[test]
3867+fn auto_image_dimensions_respects_explicit() {
3868+ // If width/height are explicitly set, don't override them
3869+ let dir = std::env::temp_dir().join("htmlang_test_img_explicit");
3870+ let _ = std::fs::create_dir_all(&dir);
3871+ let png_path = dir.join("test2.png");
3872+ let png_data: Vec<u8> = vec![
3873+ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
3874+ 0x00, 0x00, 0x00, 0x0D,
3875+ 0x49, 0x48, 0x44, 0x52,
3876+ 0x00, 0x00, 0x00, 0x01,
3877+ 0x00, 0x00, 0x00, 0x01,
3878+ 0x08, 0x02, 0x00, 0x00, 0x00,
3879+ 0x90, 0x77, 0x53, 0xDE,
3880+ 0x00, 0x00, 0x00, 0x0C,
3881+ 0x49, 0x44, 0x41, 0x54,
3882+ 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01,
3883+ 0xE2, 0x21, 0xBC, 0x33,
3884+ 0x00, 0x00, 0x00, 0x00,
3885+ 0x49, 0x45, 0x4E, 0x44,
3886+ 0xAE, 0x42, 0x60, 0x82,
3887+ ];
3888+ std::fs::write(&png_path, &png_data).unwrap();
3889+
3890+ let input = format!("@image [width 100, height 100, alt test] {}", png_path.display());
3891+ let result = htmlang::parser::parse(&input);
3892+ let html = htmlang::codegen::generate(&result.document);
3893+ // When width/height are set as CSS attrs, auto-dimensions should not add HTML width/height
3894+ assert!(html.contains("width:100px"), "should have CSS width, got: {}", html);
3895+ assert!(html.contains("height:100px"), "should have CSS height, got: {}", html);
3896+ assert!(!html.contains("width=\"1\""), "should NOT auto-detect dimensions when CSS size is set, got: {}", html);
3897+
3898+ let _ = std::fs::remove_dir_all(&dir);
3899+}
tests/snapshots/css_property.hlnew file+8
@@ -0,0 +1,8 @@
1+@page CSS Property Test
2+@css-property --accent
3+ syntax "<color>"
4+ inherits true
5+ initial-value #3b82f6
6+
7+@el [background var(--accent), padding 20]
8+ Custom property element
tests/snapshots/env_directive.hlnew file+3
@@ -0,0 +1,3 @@
1+@page Env Test
2+@env HTMLANG_TEST_MISSING default_val
3+@text $htmlang_test_missing
tests/snapshots/responsive_images.hlnew file+3
@@ -0,0 +1,3 @@
1+@page Responsive Images
2+@image [responsive 400 800 1200, alt Hero photo] hero.jpg
3+@image [alt Small icon] icon.png