on master help sign in
Add 20 improvements: smart image loading, CSS optimization, accessibility, extended operators, ternary expressions, @defer directive, DNS prefetch, build caching
commit84a97c2a1822a90826d012bce575676060abea6c authoriko <mail@iko.soy> dateThu 16 Apr 2026, 02:50 (6 months ago) parent8e7a1ff
4 files changed+590−40
crates/htmlang-core/src/codegen.rs+182−18 crates/htmlang-core/src/parser.rs+174−2 src/main.rs+77−19 tests/snapshots.rs+157−1
crates/htmlang-core/src/codegen.rs+182−18
@@ -352,6 +352,9 @@ impl StyleCollector {
352352struct GenContext {
353353 dev: bool,
354354 depth: usize,
355+ image_count: usize,
356+ has_interactive: bool,
357+ has_defer: bool,
355358}
356359
357360impl GenContext {
@@ -613,13 +616,29 @@ fn prefix_declarations(block: &str) -> String {
613616
614617fn generate_full_inner(doc: &Document, dev: bool) -> String {
615618 let mut styles = StyleCollector::new();
616− let mut ctx = GenContext { dev, depth: 0 };
619+ let mut ctx = GenContext { dev, depth: 0, image_count: 0, has_interactive: false, has_defer: false };
620+
621+ // Check if document has @main for skip-to-content link
622+ let has_main = has_element_kind(&doc.nodes, &ElementKind::Main);
623+
617624 let mut body = String::new();
618625
626+ // Skip-to-content link for accessibility (only when @main exists)
627+ if has_main {
628+ if dev {
629+ body.push_str("<a href=\"#hl-main\" class=\"hl-skip\">Skip to content</a>\n");
630+ } else {
631+ body.push_str("<a href=\"#hl-main\" class=\"hl-skip\">Skip to content</a>");
632+ }
633+ }
634+
619635 for node in &doc.nodes {
620636 generate_node(node, None, &mut body, &mut styles, &mut ctx);
621637 }
622638
639+ // Collect external domains for DNS prefetch
640+ let dns_prefetch_html = collect_dns_prefetch(&body, dev);
641+
623642 let mut element_css = String::new();
624643
625644 // CSS custom properties (includes @theme runtime tokens)
@@ -959,6 +978,51 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
959978 }
960979 }
961980
981+ // Auto theme-color meta from @theme primary token
982+ let theme_color_html = doc.theme_tokens.iter()
983+ .find(|(name, _)| name == "primary" || name == "theme-color")
984+ .map(|(_, value)| {
985+ if dev {
986+ format!("<meta name=\"theme-color\" content=\"{}\">\n", html_escape(value))
987+ } else {
988+ format!("<meta name=\"theme-color\" content=\"{}\">", html_escape(value))
989+ }
990+ })
991+ .unwrap_or_default();
992+
993+ // Focus-visible CSS for interactive elements (accessibility)
994+ let focus_visible_css = if ctx.has_interactive {
995+ if dev {
996+ "a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }\n"
997+ } else {
998+ "a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid currentColor;outline-offset:2px}"
999+ }
1000+ } else {
1001+ ""
1002+ };
1003+
1004+ // Skip-to-content CSS (visually hidden but accessible)
1005+ let skip_link_css = if has_main {
1006+ if dev {
1007+ ".hl-skip { position: absolute; left: -9999px; top: auto; width: 1px; height: 1px; overflow: hidden; z-index: 9999; padding: 8px 16px; background: #000; color: #fff; text-decoration: none; font-size: 14px; }\n.hl-skip:focus { left: 8px; top: 8px; width: auto; height: auto; overflow: visible; }\n"
1008+ } else {
1009+ ".hl-skip{position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden;z-index:9999;padding:8px 16px;background:#000;color:#fff;text-decoration:none;font-size:14px}.hl-skip:focus{left:8px;top:8px;width:auto;height:auto;overflow:visible}"
1010+ }
1011+ } else {
1012+ ""
1013+ };
1014+
1015+ // Noscript fallback: show deferred content if JS is disabled
1016+ let noscript_html = if ctx.has_defer {
1017+ if dev {
1018+ "<noscript><style>.hl-defer-placeholder { display: none; }</style></noscript>\n"
1019+ } else {
1020+ "<noscript><style>.hl-defer-placeholder{display:none}</style></noscript>"
1021+ }
1022+ } else {
1023+ ""
1024+ };
1025+
9621026 match &doc.page_title {
9631027 Some(title) => {
9641028 if dev {
@@ -970,13 +1034,14 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
9701034<meta charset=\"utf-8\">
9711035<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
9721036<title>{title}</title>
973−{base_html}{canonical_html}{manifest_html}{preload_html}{meta_html}{og_html}{favicon_html}{json_ld_html}{head_html}\
1037+{theme_color_html}{base_html}{canonical_html}{manifest_html}{preload_html}{dns_prefetch_html}{meta_html}{og_html}{favicon_html}{json_ld_html}{head_html}\
9741038<style>
9751039*, *::before, *::after {{ box-sizing: border-box; }}
9761040body {{ margin: 0; font-family: system-ui, -apple-system, sans-serif; }}
9771041img {{ display: block; }}
978−{element_css}\
1042+{focus_visible_css}{skip_link_css}{element_css}\
9791043</style>
1044+{noscript_html}\
9801045</head>
9811046<body>
9821047{body}\
@@ -985,32 +1050,42 @@ img {{ display: block; }}
9851050",
9861051 title = html_escape(title),
9871052 lang_attr = lang_attr,
1053+ theme_color_html = theme_color_html,
9881054 base_html = base_html,
9891055 canonical_html = canonical_html,
9901056 manifest_html = manifest_html,
9911057 preload_html = preload_html,
1058+ dns_prefetch_html = dns_prefetch_html,
9921059 meta_html = meta_html,
9931060 favicon_html = favicon_html,
9941061 json_ld_html = json_ld_html,
9951062 head_html = head_html,
9961063 og_html = og_html,
1064+ focus_visible_css = focus_visible_css,
1065+ skip_link_css = skip_link_css,
1066+ noscript_html = noscript_html,
9971067 element_css = element_css,
9981068 body = body,
9991069 )
10001070 } else {
10011071 format!(
1002− "<!DOCTYPE html><html{lang_attr}><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>{title}</title>{base_html}{canonical_html}{manifest_html}{preload_html}{meta_html}{og_html}{favicon_html}{json_ld_html}{head_html}<style>*,*::before,*::after{{box-sizing:border-box}}body{{margin:0;font-family:system-ui,-apple-system,sans-serif}}img{{display:block}}{element_css}</style></head><body>{body}</body></html>",
1072+ "<!DOCTYPE html><html{lang_attr}><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>{title}</title>{theme_color_html}{base_html}{canonical_html}{manifest_html}{preload_html}{dns_prefetch_html}{meta_html}{og_html}{favicon_html}{json_ld_html}{head_html}<style>*,*::before,*::after{{box-sizing:border-box}}body{{margin:0;font-family:system-ui,-apple-system,sans-serif}}img{{display:block}}{focus_visible_css}{skip_link_css}{element_css}</style>{noscript_html}</head><body>{body}</body></html>",
10031073 title = html_escape(title),
10041074 lang_attr = lang_attr,
1075+ theme_color_html = theme_color_html,
10051076 base_html = base_html,
10061077 canonical_html = canonical_html,
10071078 manifest_html = manifest_html,
10081079 preload_html = preload_html,
1080+ dns_prefetch_html = dns_prefetch_html,
10091081 meta_html = meta_html,
10101082 og_html = og_html,
10111083 favicon_html = favicon_html,
10121084 json_ld_html = json_ld_html,
10131085 head_html = head_html,
1086+ focus_visible_css = focus_visible_css,
1087+ skip_link_css = skip_link_css,
1088+ noscript_html = noscript_html,
10141089 element_css = element_css,
10151090 body = body,
10161091 )
@@ -1033,7 +1108,7 @@ img {{ display: block; }}
10331108/// Generate an HTML fragment: body + optional <style>, no <html>/<head>/<body> wrapper.
10341109fn generate_partial_inner(doc: &Document, dev: bool) -> String {
10351110 let mut styles = StyleCollector::new();
1036− let mut ctx = GenContext { dev, depth: 0 };
1111+ let mut ctx = GenContext { dev, depth: 0, image_count: 0, has_interactive: false, has_defer: false };
10371112 let mut body = String::new();
10381113
10391114 for node in &doc.nodes {
@@ -1182,6 +1257,8 @@ const HTML_PASSTHROUGH_ATTRS: &[&str] = &[
11821257 "fetchpriority", "blocking",
11831258 // Global attrs
11841259 "translate", "spellcheck",
1260+ // ARIA live regions
1261+ "aria-live", "aria-atomic", "aria-relevant",
11851262];
11861263
11871264/// Boolean HTML attributes (rendered without a value, e.g., `<input disabled>`).
@@ -1465,6 +1542,12 @@ fn generate_element(
14651542 _ => "",
14661543 };
14671544
1545+ // Track interactive elements for focus-visible CSS
1546+ if matches!(elem.kind, ElementKind::Link | ElementKind::Button | ElementKind::Input
1547+ | ElementKind::Select | ElementKind::Textarea) {
1548+ ctx.has_interactive = true;
1549+ }
1550+
14681551 // Compute CSS for each state and get a class name
14691552 let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles);
14701553 let (id, user_class) = extract_id_class(&elem.attrs);
@@ -1477,11 +1560,28 @@ fn generate_element(
14771560 out.push('<');
14781561 out.push_str(tag);
14791562
1563+ // @main gets id="hl-main" for skip-to-content link (unless user set an id)
1564+ if elem.kind == ElementKind::Main && id.is_none() {
1565+ out.push_str(" id=\"hl-main\"");
1566+ }
1567+
14801568 if elem.kind == ElementKind::Link {
14811569 if let Some(url) = &elem.argument {
14821570 out.push_str(" href=\"");
14831571 out.push_str(&html_escape(url));
14841572 out.push('"');
1573+ // Auto rel="noopener noreferrer" and target="_blank" for external links
1574+ let is_external = url.starts_with("http://") || url.starts_with("https://");
1575+ if is_external {
1576+ let has_rel = elem.attrs.iter().any(|a| a.key == "rel");
1577+ let has_target = elem.attrs.iter().any(|a| a.key == "target");
1578+ if !has_rel {
1579+ out.push_str(" rel=\"noopener noreferrer\"");
1580+ }
1581+ if !has_target {
1582+ out.push_str(" target=\"_blank\"");
1583+ }
1584+ }
14851585 }
14861586 }
14871587
@@ -1622,7 +1722,7 @@ fn generate_self_closing(
16221722 parent_kind: Option<&ElementKind>,
16231723 out: &mut String,
16241724 styles: &mut StyleCollector,
1625− ctx: &GenContext,
1725+ ctx: &mut GenContext,
16261726) {
16271727 let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles);
16281728 let (id, user_class) = extract_id_class(&elem.attrs);
@@ -1734,7 +1834,7 @@ fn generate_self_closing(
17341834 }
17351835 }
17361836
1737− // Auto image dimensions: read local image file to inject width/height
1837+ // Auto image dimensions: read local image file to inject width/height + aspect-ratio
17381838 let has_width = elem.attrs.iter().any(|a| a.key == "width");
17391839 let has_height = elem.attrs.iter().any(|a| a.key == "height");
17401840 if !has_width || !has_height {
@@ -1747,16 +1847,31 @@ fn generate_self_closing(
17471847 if !has_height {
17481848 out.push_str(&format!(" height=\"{}\"", h));
17491849 }
1850+ // Auto aspect-ratio to prevent CLS
1851+ if !elem.attrs.iter().any(|a| a.key == "aspect-ratio") {
1852+ out.push_str(&format!(" style=\"aspect-ratio:{}/{}\"", w, h));
1853+ }
17501854 }
17511855 }
17521856 }
17531857 }
17541858
1755− if !elem.attrs.iter().any(|a| a.key == "loading") {
1756− out.push_str(" loading=\"lazy\"");
1757− }
1758− if !elem.attrs.iter().any(|a| a.key == "decoding") {
1759− out.push_str(" decoding=\"async\"");
1859+ // Smart image loading: first 3 images get fetchpriority="high" (above the fold),
1860+ // subsequent images get loading="lazy" + decoding="async"
1861+ ctx.image_count += 1;
1862+ if ctx.image_count <= 3 {
1863+ // Above-the-fold: eager loading with high priority
1864+ if !elem.attrs.iter().any(|a| a.key == "fetchpriority") {
1865+ out.push_str(" fetchpriority=\"high\"");
1866+ }
1867+ } else {
1868+ // Below-the-fold: lazy loading
1869+ if !elem.attrs.iter().any(|a| a.key == "loading") {
1870+ out.push_str(" loading=\"lazy\"");
1871+ }
1872+ if !elem.attrs.iter().any(|a| a.key == "decoding") {
1873+ out.push_str(" decoding=\"async\"");
1874+ }
17601875 }
17611876 }
17621877
@@ -2369,6 +2484,22 @@ fn attrs_to_css(
23692484 }
23702485 }
23712486
2487+ // CSS containment for rendering performance
2488+ "contain" => {
2489+ if let Some(v) = val {
2490+ push_css(&mut css, "contain", v);
2491+ } else {
2492+ push_css(&mut css, "contain", "layout style paint");
2493+ }
2494+ }
2495+ "content-visibility" => {
2496+ if let Some(v) = val {
2497+ push_css(&mut css, "content-visibility", v);
2498+ } else {
2499+ push_css(&mut css, "content-visibility", "auto");
2500+ }
2501+ }
2502+
23722503 // Outline (like border but doesn't affect layout)
23732504 "outline" => {
23742505 if let Some(v) = val {
@@ -2795,9 +2926,6 @@ fn attrs_to_css(
27952926 "vertical-align" => {
27962927 if let Some(v) = val { push_css(&mut css, "vertical-align", v); }
27972928 }
2798− "contain" => {
2799− if let Some(v) = val { push_css(&mut css, "contain", v); }
2800− }
28012929 "scroll-margin" => {
28022930 if let Some(v) = val { push_css(&mut css, "scroll-margin", &css_px(v)); }
28032931 }
@@ -2810,9 +2938,6 @@ fn attrs_to_css(
28102938 "scroll-padding-top" | "scroll-padding-bottom" | "scroll-padding-left" | "scroll-padding-right" => {
28112939 if let Some(v) = val { push_css(&mut css, effective_key, &css_px(v)); }
28122940 }
2813− "content-visibility" => {
2814− if let Some(v) = val { push_css(&mut css, "content-visibility", v); }
2815− }
28162941 "direction" => {
28172942 if let Some(v) = val { push_css(&mut css, "direction", v); }
28182943 }
@@ -3270,3 +3395,42 @@ fn collect_source_lines(nodes: &[Node], mappings: &mut Vec<(usize, usize)>) {
32703395 }
32713396 }
32723397}
3398+
3399+/// Check if a specific element kind exists anywhere in the node tree.
3400+fn has_element_kind(nodes: &[Node], target: &ElementKind) -> bool {
3401+ for node in nodes {
3402+ if let Node::Element(elem) = node {
3403+ if elem.kind == *target {
3404+ return true;
3405+ }
3406+ if has_element_kind(&elem.children, target) {
3407+ return true;
3408+ }
3409+ }
3410+ }
3411+ false
3412+}
3413+
3414+/// Collect unique external domains from generated HTML for DNS prefetch hints.
3415+fn collect_dns_prefetch(html: &str, dev: bool) -> String {
3416+ let mut domains = Vec::new();
3417+ let mut rest = html;
3418+ while let Some(pos) = rest.find("https://") {
3419+ let start = pos + 8; // skip "https://"
3420+ rest = &rest[start..];
3421+ let end = rest.find(|c: char| c == '/' || c == '"' || c == '\'' || c == ' ' || c == '>' || c == ')').unwrap_or(rest.len());
3422+ let domain = &rest[..end];
3423+ if !domain.is_empty() && domain.contains('.') && !domains.contains(&domain.to_string()) {
3424+ domains.push(domain.to_string());
3425+ }
3426+ }
3427+ let mut out = String::new();
3428+ for domain in &domains {
3429+ if dev {
3430+ out.push_str(&format!("<link rel=\"dns-prefetch\" href=\"//{}\">\n", domain));
3431+ } else {
3432+ out.push_str(&format!("<link rel=\"dns-prefetch\" href=\"//{}\">", domain));
3433+ }
3434+ }
3435+ out
3436+}
crates/htmlang-core/src/parser.rs+174−2
@@ -443,8 +443,44 @@ impl Parser {
443443 } else {
444444 value
445445 };
446− // Support quoted string interpolation: @let greeting "Hello $name"
447− let value = if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
446+
447+ // Multi-line @let with triple quotes: @let name """..."""
448+ let value_str;
449+ let value = if value.starts_with("\"\"\"") {
450+ let after_open = &value[3..];
451+ if after_open.ends_with("\"\"\"") && after_open.len() >= 3 {
452+ // Single-line triple-quote: @let name """value"""
453+ value_str = after_open[..after_open.len()-3].to_string();
454+ value_str.as_str()
455+ } else {
456+ // Multi-line: collect indented body lines until closing """
457+ let mut lines_buf = String::new();
458+ if !after_open.is_empty() {
459+ lines_buf.push_str(after_open);
460+ lines_buf.push('\n');
461+ }
462+ while self.pos < self.lines.len() {
463+ match &self.lines[self.pos].content {
464+ LineContent::Normal(s) if s.trim() == "\"\"\"" => {
465+ self.pos += 1;
466+ break;
467+ }
468+ LineContent::Normal(s) => {
469+ lines_buf.push_str(s);
470+ lines_buf.push('\n');
471+ }
472+ LineContent::Raw(s) => {
473+ lines_buf.push_str(s);
474+ lines_buf.push('\n');
475+ }
476+ }
477+ self.pos += 1;
478+ }
479+ value_str = lines_buf.trim_end_matches('\n').to_string();
480+ value_str.as_str()
481+ }
482+ } else if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
483+ // Support quoted string interpolation: @let greeting "Hello $name"
448484 &value[1..value.len()-1]
449485 } else {
450486 value
@@ -1909,6 +1945,64 @@ impl Parser {
19091945 return Ok(Some(all_nodes));
19101946 }
19111947
1948+ // --- @defer (lazy-load below-fold content via IntersectionObserver) ---
1949+
1950+ if content.trim() == "@defer" || content.starts_with("@defer ") {
1951+ let placeholder_text = content.strip_prefix("@defer").unwrap_or("").trim();
1952+
1953+ // Collect body lines
1954+ let mut body_lines = Vec::new();
1955+ while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
1956+ body_lines.push(self.lines[self.pos].clone());
1957+ self.pos += 1;
1958+ }
1959+
1960+ if body_lines.is_empty() {
1961+ return Ok(None);
1962+ }
1963+
1964+ // Parse the body to get the actual content nodes
1965+ let min_indent = body_lines.iter().map(|l| l.indent).min().unwrap_or(0);
1966+ let adjusted: Vec<Line> = body_lines.iter().map(|l| Line {
1967+ indent: l.indent - min_indent,
1968+ content: l.content.clone(),
1969+ line_num: l.line_num,
1970+ }).collect();
1971+
1972+ let saved_vars = ctx.variables.clone();
1973+ let mut body_parser = Parser { lines: adjusted, pos: 0 };
1974+ let inner_nodes = body_parser.parse_children(0, ctx);
1975+ ctx.variables = saved_vars;
1976+
1977+ // Wrap content in a div with data-defer attribute + placeholder
1978+ let _placeholder = if placeholder_text.is_empty() {
1979+ "Loading...".to_string()
1980+ } else {
1981+ substitute_vars(placeholder_text, &ctx.variables)
1982+ };
1983+ let mut wrapper_attrs = vec![
1984+ Attribute { key: "data-hl-defer".to_string(), value: None },
1985+ Attribute { key: "class".to_string(), value: Some("hl-defer-placeholder".to_string()) },
1986+ ];
1987+ // Hidden until intersection observer triggers
1988+ wrapper_attrs.push(Attribute { key: "hidden".to_string(), value: None });
1989+
1990+ let wrapper = Element {
1991+ kind: ElementKind::El,
1992+ attrs: wrapper_attrs,
1993+ argument: None,
1994+ children: inner_nodes,
1995+ line_num,
1996+ };
1997+
1998+ // Also emit the IntersectionObserver script as a sibling raw node
1999+ let script = Node::Raw(format!(
2000+ "<script>(function(){{var d=document.querySelectorAll('[data-hl-defer]');if(!d.length)return;var o=new IntersectionObserver(function(e){{e.forEach(function(i){{if(i.isIntersecting){{i.target.removeAttribute('hidden');i.target.classList.remove('hl-defer-placeholder');o.unobserve(i.target)}}}});}},{{rootMargin:'200px'}});d.forEach(function(el){{o.observe(el)}})}})()</script>"
2001+ ));
2002+
2003+ return Ok(Some(vec![Node::Element(wrapper), script]));
2004+ }
2005+
19122006 // --- @match ---
19132007
19142008 if let Some(rest) = content.strip_prefix("@match ") {
@@ -3679,6 +3773,7 @@ fn parse_attr_list(input: &str, line_num: usize, ctx: &mut ParseContext, validat
36793773
36803774 let attr = if let Some((key, value)) = part.split_once(' ') {
36813775 let value = evaluate_if_expr(value.trim());
3776+ let value = substitute_ternary(&value);
36823777 Attribute {
36833778 key: key.trim().to_string(),
36843779 value: Some(value),
@@ -3895,10 +3990,48 @@ fn parse_text_segments(input: &str, ctx: &mut ParseContext) -> Vec<TextSegment>
38953990// ---------------------------------------------------------------------------
38963991
38973992fn evaluate_condition(condition: &str) -> bool {
3993+ // Ternary expression support: condition ? true_val : false_val
3994+ // (just check the condition part, the ternary is handled in substitute_ternary)
3995+
38983996 if let Some((left, right)) = condition.split_once("!=") {
38993997 left.trim() != right.trim()
39003998 } else if let Some((left, right)) = condition.split_once("==") {
39013999 left.trim() == right.trim()
4000+ } else if let Some((left, right)) = condition.split_once(">=") {
4001+ // Numeric comparison with fallback to string comparison
4002+ let l = left.trim();
4003+ let r = right.trim();
4004+ match (l.parse::<f64>(), r.parse::<f64>()) {
4005+ (Ok(ln), Ok(rn)) => ln >= rn,
4006+ _ => l >= r,
4007+ }
4008+ } else if let Some((left, right)) = condition.split_once("<=") {
4009+ let l = left.trim();
4010+ let r = right.trim();
4011+ match (l.parse::<f64>(), r.parse::<f64>()) {
4012+ (Ok(ln), Ok(rn)) => ln <= rn,
4013+ _ => l <= r,
4014+ }
4015+ } else if let Some((left, right)) = condition.split_once('>') {
4016+ let l = left.trim();
4017+ let r = right.trim();
4018+ match (l.parse::<f64>(), r.parse::<f64>()) {
4019+ (Ok(ln), Ok(rn)) => ln > rn,
4020+ _ => l > r,
4021+ }
4022+ } else if let Some((left, right)) = condition.split_once('<') {
4023+ let l = left.trim();
4024+ let r = right.trim();
4025+ match (l.parse::<f64>(), r.parse::<f64>()) {
4026+ (Ok(ln), Ok(rn)) => ln < rn,
4027+ _ => l < r,
4028+ }
4029+ } else if let Some((left, right)) = condition.split_once(" contains ") {
4030+ left.trim().contains(right.trim())
4031+ } else if let Some((left, right)) = condition.split_once(" starts-with ") {
4032+ left.trim().starts_with(right.trim())
4033+ } else if let Some((left, right)) = condition.split_once(" ends-with ") {
4034+ left.trim().ends_with(right.trim())
39024035 } else {
39034036 // Truthy check: non-empty, not "false", not "0"
39044037 let trimmed = condition.trim();
@@ -3906,6 +4039,28 @@ fn evaluate_condition(condition: &str) -> bool {
39064039 }
39074040}
39084041
4042+/// Evaluate ternary expressions: `condition ? true_val : false_val` in attribute values.
4043+fn substitute_ternary(input: &str) -> String {
4044+ if !input.contains(" ? ") {
4045+ return input.to_string();
4046+ }
4047+ // Find the ternary operator pattern
4048+ if let Some(q_pos) = input.find(" ? ") {
4049+ let condition = input[..q_pos].trim();
4050+ let rest = &input[q_pos + 3..];
4051+ if let Some(c_pos) = rest.find(" : ") {
4052+ let true_val = rest[..c_pos].trim();
4053+ let false_val = rest[c_pos + 3..].trim();
4054+ if evaluate_condition(condition) {
4055+ return true_val.to_string();
4056+ } else {
4057+ return false_val.to_string();
4058+ }
4059+ }
4060+ }
4061+ input.to_string()
4062+}
4063+
39094064/// Evaluate `if(condition, true_val, false_val)` expressions in attribute values.
39104065fn evaluate_if_expr(input: &str) -> String {
39114066 let trimmed = input.trim();
@@ -3949,6 +4104,23 @@ fn split_if_args(input: &str) -> Vec<&str> {
39494104
39504105fn evaluate_arithmetic(input: &str) -> String {
39514106 let input = input.trim();
4107+
4108+ // String concatenation with ~ operator: @let full $first ~ " " ~ $last
4109+ if input.contains(" ~ ") {
4110+ let parts: Vec<&str> = input.split(" ~ ").collect();
4111+ if parts.len() >= 2 {
4112+ return parts.iter().map(|p| {
4113+ let t = p.trim();
4114+ // Strip quotes from string literals
4115+ if t.len() >= 2 && t.starts_with('"') && t.ends_with('"') {
4116+ &t[1..t.len()-1]
4117+ } else {
4118+ t
4119+ }
4120+ }).collect::<Vec<_>>().join("");
4121+ }
4122+ }
4123+
39524124 for op in &[" * ", " / ", " + ", " - "] {
39534125 if let Some((left, right)) = input.split_once(op) {
39544126 let left = left.trim();
src/main.rs+77−19
@@ -862,6 +862,13 @@ struct ProjectConfig {
862862 port: u16,
863863 variables: Vec<(String, String)>,
864864 breakpoints: Vec<(String, String)>,
865+ // Build options (can be overridden by CLI flags)
866+ dev: Option<bool>,
867+ minify: Option<bool>,
868+ compat: Option<bool>,
869+ strict: Option<bool>,
870+ // Watch options
871+ debounce_ms: u64,
865872}
866873
867874fn load_config(target: &Path) -> ProjectConfig {
@@ -870,6 +877,11 @@ fn load_config(target: &Path) -> ProjectConfig {
870877 port: 3000,
871878 variables: Vec::new(),
872879 breakpoints: Vec::new(),
880+ dev: None,
881+ minify: None,
882+ compat: None,
883+ strict: None,
884+ debounce_ms: 50,
873885 };
874886
875887 let config_path = if target.is_dir() {
@@ -891,9 +903,9 @@ fn load_config(target: &Path) -> ProjectConfig {
891903 }
892904 if trimmed.starts_with('[') && trimmed.ends_with(']') {
893905 section = &trimmed[1..trimmed.len() - 1];
894− if !matches!(section, "variables" | "breakpoints") {
906+ if !matches!(section, "variables" | "breakpoints" | "build" | "watch") {
895907 eprintln!(
896− "warning: {}:{}: unknown section '[{}]' (expected: variables, breakpoints)",
908+ "warning: {}:{}: unknown section '[{}]' (expected: variables, breakpoints, build, watch)",
897909 config_path.display(), line_num + 1, section
898910 );
899911 }
@@ -913,6 +925,27 @@ fn load_config(target: &Path) -> ProjectConfig {
913925 );
914926 }
915927 },
928+ "build" => match key {
929+ "dev" => config.dev = Some(value == "true"),
930+ "minify" => config.minify = Some(value == "true"),
931+ "compat" => config.compat = Some(value == "true"),
932+ "strict" => config.strict = Some(value == "true"),
933+ _ => {
934+ eprintln!(
935+ "warning: {}:{}: unknown build key '{}' (expected: dev, minify, compat, strict)",
936+ config_path.display(), line_num + 1, key
937+ );
938+ }
939+ },
940+ "watch" => match key {
941+ "debounce_ms" => config.debounce_ms = value.parse().unwrap_or(50),
942+ _ => {
943+ eprintln!(
944+ "warning: {}:{}: unknown watch key '{}' (expected: debounce_ms)",
945+ config_path.display(), line_num + 1, key
946+ );
947+ }
948+ },
916949 "variables" => {
917950 config.variables.push((key.to_string(), value.to_string()));
918951 }
@@ -1023,6 +1056,7 @@ fn main() {
10231056 let mut out_dir = None;
10241057 let mut build_minify = false;
10251058 let mut build_compat = false;
1059+ let mut build_strict = false;
10261060 let mut i = 2;
10271061 while i < args.len() {
10281062 match args[i].as_str() {
@@ -1032,6 +1066,7 @@ fn main() {
10321066 }
10331067 "--minify" => build_minify = true,
10341068 "--compat" => build_compat = true,
1069+ "--strict" => build_strict = true,
10351070 _ if src_dir.is_none() => src_dir = Some(args[i].as_str()),
10361071 _ => {
10371072 eprintln!("unknown argument: {}", args[i]);
@@ -1046,6 +1081,12 @@ fn main() {
10461081 eprintln!("error: '{}' is not a directory", src);
10471082 process::exit(1);
10481083 }
1084+ // Load project config — CLI flags override config file
1085+ let config = load_config(dir);
1086+ let build_minify = build_minify || config.minify.unwrap_or(false);
1087+ let build_compat = build_compat || config.compat.unwrap_or(false);
1088+ let build_strict = build_strict || config.strict.unwrap_or(false);
1089+ let out_dir = out_dir.or(config.output.as_deref());
10491090 let hl_files = collect_hl_files_recursive(dir);
10501091 if hl_files.is_empty() {
10511092 eprintln!("no .hl files found in {}", src);
@@ -1067,6 +1108,10 @@ fn main() {
10671108 })
10681109 }).collect();
10691110
1111+ // Build content hash cache for incremental compilation
1112+ let cache_dir = dir.join(".htmlang-cache");
1113+ let _ = fs::create_dir_all(&cache_dir);
1114+
10701115 // Compile files in parallel (with incremental skip for unchanged files)
10711116 let build_start = std::time::Instant::now();
10721117 let any_errors = std::sync::atomic::AtomicBool::new(false);
@@ -1075,25 +1120,36 @@ fn main() {
10751120 for (file, effective_out) in hl_files.iter().zip(effective_outs.iter()) {
10761121 let any_errors = &any_errors;
10771122 let skipped = &skipped;
1123+ let cache_dir = &cache_dir;
10781124 s.spawn(move || {
1079− // Incremental: skip if output is newer than source
1080− if let Some(out_path) = effective_out {
1081− let out_p = Path::new(out_path.as_str());
1082− if out_p.exists() {
1083− if let (Ok(src_meta), Ok(out_meta)) = (file.metadata(), out_p.metadata()) {
1084− if let (Ok(src_mod), Ok(out_mod)) = (src_meta.modified(), out_meta.modified()) {
1085− if out_mod > src_mod {
1086− skipped.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1087− return;
1088− }
1125+ // Content hash-based caching: skip if file content hasn't changed
1126+ let hash_file_name = file.file_name().unwrap_or_default().to_string_lossy().to_string() + ".hash";
1127+ let hash_path = cache_dir.join(&hash_file_name);
1128+ if let Ok(content) = fs::read(file) {
1129+ let mut hasher = std::collections::hash_map::DefaultHasher::new();
1130+ content.hash(&mut hasher);
1131+ let current_hash = hasher.finish().to_string();
1132+ if let Ok(cached_hash) = fs::read_to_string(&hash_path) {
1133+ if cached_hash.trim() == current_hash {
1134+ // Also verify output exists
1135+ let out_exists = effective_out.as_ref().map_or(
1136+ file.with_extension("html").exists(),
1137+ |p| Path::new(p.as_str()).exists(),
1138+ );
1139+ if out_exists {
1140+ skipped.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1141+ return;
10891142 }
10901143 }
10911144 }
1145+ // Update hash cache after compilation
1146+ let _ = fs::write(&hash_path, &current_hash);
10921147 }
10931148 let path_str = file.to_string_lossy().to_string();
10941149 let (has_errors, _) = compile(&path_str, &CompileConfig {
10951150 output_path: effective_out.as_deref(),
10961151 minify: build_minify, compat: build_compat,
1152+ strict: build_strict,
10971153 ..Default::default()
10981154 });
10991155 if has_errors {
@@ -1582,7 +1638,7 @@ fn main() {
15821638 rt.block_on(htmlang::serve::run(effective_port, index_path, server_tx));
15831639 });
15841640 if serve_open { open_in_browser(effective_port); }
1585− watch_loop(target_path, &hl_files, &[], true, true, Some(tx), effective_port);
1641+ watch_loop(target_path, &hl_files, &[], true, true, Some(tx), effective_port, config.debounce_ms);
15861642 } else {
15871643 let (_, included) = compile(&target, &CompileConfig {
15881644 dev: true, error_overlay: true,
@@ -1597,7 +1653,7 @@ fn main() {
15971653 });
15981654 if serve_open { open_in_browser(effective_port); }
15991655 let files = vec![PathBuf::from(&target)];
1600− watch_loop(Path::new(&target).parent().unwrap_or(Path::new(".")), &files, &included, true, true, Some(tx), effective_port);
1656+ watch_loop(Path::new(&target).parent().unwrap_or(Path::new(".")), &files, &included, true, true, Some(tx), effective_port, config.debounce_ms);
16011657 }
16021658 return;
16031659 }
@@ -1644,14 +1700,14 @@ fn main() {
16441700 });
16451701 all_included.extend(included);
16461702 }
1647− watch_loop(target_path, &hl_files, &all_included, false, false, None, 0);
1703+ watch_loop(target_path, &hl_files, &all_included, false, false, None, 0, config.debounce_ms);
16481704 } else {
16491705 let (_, included) = compile(&target, &CompileConfig {
16501706 output_path: effective_output.as_deref(),
16511707 ..Default::default()
16521708 });
16531709 let files = vec![PathBuf::from(&target)];
1654− watch_loop(Path::new(&target).parent().unwrap_or(Path::new(".")), &files, &included, false, false, None, 0);
1710+ watch_loop(Path::new(&target).parent().unwrap_or(Path::new(".")), &files, &included, false, false, None, 0, config.debounce_ms);
16551711 }
16561712 return;
16571713 }
@@ -3207,7 +3263,7 @@ compile();
32073263 None
32083264 };
32093265
3210− watch_loop(dir, &hl_files, &all_included, dev, serve, reload_tx, port);
3266+ watch_loop(dir, &hl_files, &all_included, dev, serve, reload_tx, port, 50);
32113267 return;
32123268 }
32133269
@@ -3262,6 +3318,7 @@ compile();
32623318 serve,
32633319 reload_tx,
32643320 port,
3321+ 50,
32653322 );
32663323}
32673324
@@ -3289,6 +3346,7 @@ fn watch_loop(
32893346 serve: bool,
32903347 reload_tx: Option<tokio::sync::broadcast::Sender<()>>,
32913348 serve_port: u16,
3349+ debounce_ms: u64,
32923350) {
32933351 use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
32943352 use std::sync::mpsc;
@@ -3352,8 +3410,8 @@ fn watch_loop(
33523410 loop {
33533411 match rx.recv() {
33543412 Ok(_) => {
3355− // Drain additional events (debounce) with short delay for rapid saves
3356− std::thread::sleep(std::time::Duration::from_millis(50));
3413+ // Drain additional events (debounce) with configurable delay
3414+ std::thread::sleep(std::time::Duration::from_millis(debounce_ms));
33573415 while rx.try_recv().is_ok() {}
33583416
33593417 // Collect which files actually changed (HashSet for O(1) lookups)
tests/snapshots.rs+157−1
@@ -877,7 +877,11 @@ fn warn_substitutes_variables() {
877877
878878#[test]
879879fn image_auto_lazy_loading() {
880− let output = compile("@page T\n@image https://example.com/photo.jpg");
880+ // First 3 images get fetchpriority="high" (above-the-fold), subsequent get loading="lazy"
881+ let output = compile("@page T\n@image https://example.com/1.jpg\n@image https://example.com/2.jpg\n@image https://example.com/3.jpg\n@image https://example.com/4.jpg");
882+ // First 3 images: fetchpriority="high", no lazy loading
883+ assert!(output.contains("fetchpriority=\"high\""));
884+ // 4th image: loading="lazy" + decoding="async"
881885 assert!(output.contains("loading=\"lazy\""));
882886 assert!(output.contains("decoding=\"async\""));
883887}
@@ -889,6 +893,158 @@ fn image_explicit_loading_not_doubled() {
889893 assert!(!output.contains("loading=\"lazy\""));
890894}
891895
896+// ---------------------------------------------------------------------------
897+// Feature tests: new improvements
898+// ---------------------------------------------------------------------------
899+
900+#[test]
901+fn ternary_expression_in_attrs() {
902+ let output = compile("@page T\n@let active true\n@el [color $active ? green : gray]\n test");
903+ assert!(output.contains("color:green"));
904+}
905+
906+#[test]
907+fn ternary_expression_false() {
908+ let output = compile("@page T\n@let active false\n@el [color $active ? green : gray]\n test");
909+ assert!(output.contains("color:gray"));
910+}
911+
912+#[test]
913+fn multiline_let_triple_quotes() {
914+ let output = compile("@page T\n@let bio \"\"\"Hello World\"\"\"\n@text $bio");
915+ assert!(output.contains("Hello World"));
916+}
917+
918+#[test]
919+fn comparison_operators_gt() {
920+ let output = compile("@page T\n@let count 5\n@if $count > 3\n @text big");
921+ assert!(output.contains("big"));
922+}
923+
924+#[test]
925+fn comparison_operators_lt() {
926+ let output = compile("@page T\n@let count 2\n@if $count < 3\n @text small");
927+ assert!(output.contains("small"));
928+}
929+
930+#[test]
931+fn comparison_operators_contains() {
932+ let output = compile("@page T\n@let name hello world\n@if $name contains world\n @text found");
933+ assert!(output.contains("found"));
934+}
935+
936+#[test]
937+fn comparison_operators_starts_with() {
938+ let output = compile("@page T\n@let url https://example.com\n@if $url starts-with https\n @text secure");
939+ assert!(output.contains("secure"));
940+}
941+
942+#[test]
943+fn string_concat_operator() {
944+ let output = compile("@page T\n@let first Hello\n@let last World\n@let full $first ~ \" \" ~ $last\n@text $full");
945+ assert!(output.contains("Hello World"), "got: {}", output);
946+}
947+
948+#[test]
949+fn css_contain_attribute() {
950+ let output = compile("@page T\n@el [contain layout, width 200, height 100]\n test");
951+ assert!(output.contains("contain:layout"));
952+}
953+
954+#[test]
955+fn css_contain_default() {
956+ let output = compile("@page T\n@el [contain, width 200]\n test");
957+ assert!(output.contains("contain:layout style paint"));
958+}
959+
960+#[test]
961+fn content_visibility_attribute() {
962+ let output = compile("@page T\n@el [content-visibility auto]\n test");
963+ assert!(output.contains("content-visibility:auto"));
964+}
965+
966+#[test]
967+fn focus_visible_css_with_interactive() {
968+ let output = compile("@page T\n@button Click");
969+ assert!(output.contains("focus-visible"));
970+}
971+
972+#[test]
973+fn focus_visible_css_without_interactive() {
974+ let output = compile("@page T\n@el\n text");
975+ assert!(!output.contains("focus-visible"));
976+}
977+
978+#[test]
979+fn skip_to_content_with_main() {
980+ let output = compile("@page T\n@main\n content");
981+ assert!(output.contains("hl-skip"));
982+ assert!(output.contains("hl-main"));
983+ assert!(output.contains("Skip to content"));
984+}
985+
986+#[test]
987+fn no_skip_to_content_without_main() {
988+ let output = compile("@page T\n@el\n content");
989+ assert!(!output.contains("hl-skip"));
990+}
991+
992+#[test]
993+fn external_link_noopener() {
994+ let output = compile("@page T\n@link https://example.com\n External");
995+ assert!(output.contains("rel=\"noopener noreferrer\""));
996+ assert!(output.contains("target=\"_blank\""));
997+}
998+
999+#[test]
1000+fn internal_link_no_noopener() {
1001+ let output = compile("@page T\n@link /about\n About");
1002+ assert!(!output.contains("noopener"));
1003+ assert!(!output.contains("target=\"_blank\""));
1004+}
1005+
1006+#[test]
1007+fn dns_prefetch_for_external_domains() {
1008+ let output = compile("@page T\n@link https://example.com\n Link\n@image https://cdn.example.org/img.jpg");
1009+ assert!(output.contains("dns-prefetch"));
1010+ assert!(output.contains("example.com"));
1011+}
1012+
1013+#[test]
1014+fn theme_color_meta_from_theme() {
1015+ let output = compile("@page T\n@theme\n primary #3b82f6\n@el\n test");
1016+ assert!(output.contains("theme-color"));
1017+ assert!(output.contains("#3b82f6"));
1018+}
1019+
1020+#[test]
1021+fn aria_live_passthrough() {
1022+ let output = compile("@page T\n@el [aria-live polite]\n updating");
1023+ assert!(output.contains("aria-live=\"polite\""));
1024+}
1025+
1026+#[test]
1027+fn defer_directive() {
1028+ let result = htmlang::parser::parse("@page T\n@defer\n @el\n Lazy content");
1029+ assert!(result.diagnostics.iter().all(|d| d.severity != htmlang::parser::Severity::Error));
1030+ let html = htmlang::codegen::generate(&result.document);
1031+ assert!(html.contains("data-hl-defer"));
1032+ assert!(html.contains("IntersectionObserver"));
1033+}
1034+
1035+#[test]
1036+fn comparison_gte_lte() {
1037+ let output = compile("@page T\n@let x 5\n@if $x >= 5\n @text gte\n@if $x <= 5\n @text lte");
1038+ assert!(output.contains("gte"));
1039+ assert!(output.contains("lte"));
1040+}
1041+
1042+#[test]
1043+fn comparison_ends_with() {
1044+ let output = compile("@page T\n@let file photo.jpg\n@if $file ends-with .jpg\n @text image");
1045+ assert!(output.contains("image"));
1046+}
1047+
8921048// ---------------------------------------------------------------------------
8931049// Feature tests: element-specific attribute validation
8941050// ---------------------------------------------------------------------------