@@ -352,6 +352,9 @@ impl StyleCollector {
352struct GenContext {
352struct GenContext {
353 dev: bool,
353 dev: bool,
354 depth: usize,
354 depth: usize,
356+ has_interactive: bool,
357impl GenContext {
360impl GenContext {
@@ -613,13 +616,29 @@ fn prefix_declarations(block: &str) -> String {
614fn generate_full_inner(doc: &Document, dev: bool) -> String {
617fn generate_full_inner(doc: &Document, dev: bool) -> String {
615 let mut styles = StyleCollector::new();
618 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 };
621+ // Check if document has @main for skip-to-content link
622+ let has_main = has_element_kind(&doc.nodes, &ElementKind::Main);
617 let mut body = String::new();
624 let mut body = String::new();
626+ // Skip-to-content link for accessibility (only when @main exists)
629+ body.push_str("<a href=\"#hl-main\" class=\"hl-skip\">Skip to content</a>\n");
631+ body.push_str("<a href=\"#hl-main\" class=\"hl-skip\">Skip to content</a>");
619 for node in &doc.nodes {
635 for node in &doc.nodes {
620 generate_node(node, None, &mut body, &mut styles, &mut ctx);
636 generate_node(node, None, &mut body, &mut styles, &mut ctx);
639+ // Collect external domains for DNS prefetch
640+ let dns_prefetch_html = collect_dns_prefetch(&body, dev);
623 let mut element_css = String::new();
642 let mut element_css = String::new();
625 // CSS custom properties (includes @theme runtime tokens)
644 // CSS custom properties (includes @theme runtime tokens)
@@ -959,6 +978,51 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
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")
986+ format!("<meta name=\"theme-color\" content=\"{}\">\n", html_escape(value))
988+ format!("<meta name=\"theme-color\" content=\"{}\">", html_escape(value))
991+ .unwrap_or_default();
993+ // Focus-visible CSS for interactive elements (accessibility)
994+ let focus_visible_css = if ctx.has_interactive {
996+ "a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }\n"
998+ "a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid currentColor;outline-offset:2px}"
1004+ // Skip-to-content CSS (visually hidden but accessible)
1005+ let skip_link_css = if has_main {
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"
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}"
1015+ // Noscript fallback: show deferred content if JS is disabled
1016+ let noscript_html = if ctx.has_defer {
1018+ "<noscript><style>.hl-defer-placeholder { display: none; }</style></noscript>\n"
1020+ "<noscript><style>.hl-defer-placeholder{display:none}</style></noscript>"
962 match &doc.page_title {
1026 match &doc.page_title {
963 Some(title) => {
1027 Some(title) => {
964 if dev {
1028 if dev {
@@ -970,13 +1034,14 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
970<meta charset=\"utf-8\">
1034<meta charset=\"utf-8\">
971<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
1035<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
972<title>{title}</title>
1036<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}\
975*, *::before, *::after {{ box-sizing: border-box; }}
1039*, *::before, *::after {{ box-sizing: border-box; }}
976body {{ margin: 0; font-family: system-ui, -apple-system, sans-serif; }}
1040body {{ margin: 0; font-family: system-ui, -apple-system, sans-serif; }}
977img {{ display: block; }}
1041img {{ display: block; }}
978−{element_css}\
1042+{focus_visible_css}{skip_link_css}{element_css}\
@@ -985,32 +1050,42 @@ img {{ display: block; }}
986 title = html_escape(title),
1051 title = html_escape(title),
987 lang_attr = lang_attr,
1052 lang_attr = lang_attr,
1053+ theme_color_html = theme_color_html,
988 base_html = base_html,
1054 base_html = base_html,
989 canonical_html = canonical_html,
1055 canonical_html = canonical_html,
990 manifest_html = manifest_html,
1056 manifest_html = manifest_html,
991 preload_html = preload_html,
1057 preload_html = preload_html,
1058+ dns_prefetch_html = dns_prefetch_html,
992 meta_html = meta_html,
1059 meta_html = meta_html,
993 favicon_html = favicon_html,
1060 favicon_html = favicon_html,
994 json_ld_html = json_ld_html,
1061 json_ld_html = json_ld_html,
995 head_html = head_html,
1062 head_html = head_html,
996 og_html = og_html,
1063 og_html = og_html,
1064+ focus_visible_css = focus_visible_css,
1065+ skip_link_css = skip_link_css,
1066+ noscript_html = noscript_html,
997 element_css = element_css,
1067 element_css = element_css,
998 body = body,
1068 body = body,
1000 } else {
1070 } else {
1001 format!(
1071 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>",
1003 title = html_escape(title),
1073 title = html_escape(title),
1004 lang_attr = lang_attr,
1074 lang_attr = lang_attr,
1075+ theme_color_html = theme_color_html,
1005 base_html = base_html,
1076 base_html = base_html,
1006 canonical_html = canonical_html,
1077 canonical_html = canonical_html,
1007 manifest_html = manifest_html,
1078 manifest_html = manifest_html,
1008 preload_html = preload_html,
1079 preload_html = preload_html,
1080+ dns_prefetch_html = dns_prefetch_html,
1009 meta_html = meta_html,
1081 meta_html = meta_html,
1010 og_html = og_html,
1082 og_html = og_html,
1011 favicon_html = favicon_html,
1083 favicon_html = favicon_html,
1012 json_ld_html = json_ld_html,
1084 json_ld_html = json_ld_html,
1013 head_html = head_html,
1085 head_html = head_html,
1086+ focus_visible_css = focus_visible_css,
1087+ skip_link_css = skip_link_css,
1088+ noscript_html = noscript_html,
1014 element_css = element_css,
1089 element_css = element_css,
1015 body = body,
1090 body = body,
@@ -1033,7 +1108,7 @@ img {{ display: block; }}
1033/// Generate an HTML fragment: body + optional <style>, no <html>/<head>/<body> wrapper.
1108/// Generate an HTML fragment: body + optional <style>, no <html>/<head>/<body> wrapper.
1034fn generate_partial_inner(doc: &Document, dev: bool) -> String {
1109fn generate_partial_inner(doc: &Document, dev: bool) -> String {
1035 let mut styles = StyleCollector::new();
1110 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 };
1037 let mut body = String::new();
1112 let mut body = String::new();
1039 for node in &doc.nodes {
1114 for node in &doc.nodes {
@@ -1182,6 +1257,8 @@ const HTML_PASSTHROUGH_ATTRS: &[&str] = &[
1182 "fetchpriority", "blocking",
1257 "fetchpriority", "blocking",
1183 // Global attrs
1258 // Global attrs
1184 "translate", "spellcheck",
1259 "translate", "spellcheck",
1260+ // ARIA live regions
1261+ "aria-live", "aria-atomic", "aria-relevant",
1187/// Boolean HTML attributes (rendered without a value, e.g., `<input disabled>`).
1264/// Boolean HTML attributes (rendered without a value, e.g., `<input disabled>`).
@@ -1465,6 +1542,12 @@ fn generate_element(
1465 _ => "",
1542 _ => "",
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;
1468 // Compute CSS for each state and get a class name
1551 // Compute CSS for each state and get a class name
1469 let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles);
1552 let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles);
1470 let (id, user_class) = extract_id_class(&elem.attrs);
1553 let (id, user_class) = extract_id_class(&elem.attrs);
@@ -1477,11 +1560,28 @@ fn generate_element(
1477 out.push('<');
1560 out.push('<');
1478 out.push_str(tag);
1561 out.push_str(tag);
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\"");
1480 if elem.kind == ElementKind::Link {
1568 if elem.kind == ElementKind::Link {
1481 if let Some(url) = &elem.argument {
1569 if let Some(url) = &elem.argument {
1482 out.push_str(" href=\"");
1570 out.push_str(" href=\"");
1483 out.push_str(&html_escape(url));
1571 out.push_str(&html_escape(url));
1484 out.push('"');
1572 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://");
1576+ let has_rel = elem.attrs.iter().any(|a| a.key == "rel");
1577+ let has_target = elem.attrs.iter().any(|a| a.key == "target");
1579+ out.push_str(" rel=\"noopener noreferrer\"");
1582+ out.push_str(" target=\"_blank\"");
@@ -1622,7 +1722,7 @@ fn generate_self_closing(
1622 parent_kind: Option<&ElementKind>,
1722 parent_kind: Option<&ElementKind>,
1623 out: &mut String,
1723 out: &mut String,
1624 styles: &mut StyleCollector,
1724 styles: &mut StyleCollector,
1625− ctx: &GenContext,
1725+ ctx: &mut GenContext,
1627 let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles);
1727 let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles);
1628 let (id, user_class) = extract_id_class(&elem.attrs);
1728 let (id, user_class) = extract_id_class(&elem.attrs);
@@ -1734,7 +1834,7 @@ fn generate_self_closing(
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
1738 let has_width = elem.attrs.iter().any(|a| a.key == "width");
1838 let has_width = elem.attrs.iter().any(|a| a.key == "width");
1739 let has_height = elem.attrs.iter().any(|a| a.key == "height");
1839 let has_height = elem.attrs.iter().any(|a| a.key == "height");
1740 if !has_width || !has_height {
1840 if !has_width || !has_height {
@@ -1747,16 +1847,31 @@ fn generate_self_closing(
1747 if !has_height {
1847 if !has_height {
1748 out.push_str(&format!(" height=\"{}\"", h));
1848 out.push_str(&format!(" height=\"{}\"", h));
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));
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
1755− if !elem.attrs.iter().any(|a| a.key == "loading") {
1864+ if !elem.attrs.iter().any(|a| a.key == "fetchpriority") {
1865+ out.push_str(" fetchpriority=\"high\"");
1868+ // Below-the-fold: lazy loading
1869+ if !elem.attrs.iter().any(|a| a.key == "loading") {
1756− out.push_str(" loading=\"lazy\"");
1870+ out.push_str(" loading=\"lazy\"");
1758− if !elem.attrs.iter().any(|a| a.key == "decoding") {
1872+ if !elem.attrs.iter().any(|a| a.key == "decoding") {
1759− out.push_str(" decoding=\"async\"");
1873+ out.push_str(" decoding=\"async\"");
@@ -2369,6 +2484,22 @@ fn attrs_to_css(
2487+ // CSS containment for rendering performance
2489+ if let Some(v) = val {
2490+ push_css(&mut css, "contain", v);
2492+ push_css(&mut css, "contain", "layout style paint");
2495+ "content-visibility" => {
2496+ if let Some(v) = val {
2497+ push_css(&mut css, "content-visibility", v);
2499+ push_css(&mut css, "content-visibility", "auto");
2372 // Outline (like border but doesn't affect layout)
2503 // Outline (like border but doesn't affect layout)
2373 "outline" => {
2504 "outline" => {
2374 if let Some(v) = val {
2505 if let Some(v) = val {
@@ -2795,9 +2926,6 @@ fn attrs_to_css(
2795 "vertical-align" => {
2926 "vertical-align" => {
2796 if let Some(v) = val { push_css(&mut css, "vertical-align", v); }
2927 if let Some(v) = val { push_css(&mut css, "vertical-align", v); }
2799− if let Some(v) = val { push_css(&mut css, "contain", v); }
2801 "scroll-margin" => {
2929 "scroll-margin" => {
2802 if let Some(v) = val { push_css(&mut css, "scroll-margin", &css_px(v)); }
2930 if let Some(v) = val { push_css(&mut css, "scroll-margin", &css_px(v)); }
@@ -2810,9 +2938,6 @@ fn attrs_to_css(
2810 "scroll-padding-top" | "scroll-padding-bottom" | "scroll-padding-left" | "scroll-padding-right" => {
2938 "scroll-padding-top" | "scroll-padding-bottom" | "scroll-padding-left" | "scroll-padding-right" => {
2811 if let Some(v) = val { push_css(&mut css, effective_key, &css_px(v)); }
2939 if let Some(v) = val { push_css(&mut css, effective_key, &css_px(v)); }
2813− "content-visibility" => {
2814− if let Some(v) = val { push_css(&mut css, "content-visibility", v); }
2816 "direction" => {
2941 "direction" => {
2817 if let Some(v) = val { push_css(&mut css, "direction", v); }
2942 if let Some(v) = val { push_css(&mut css, "direction", v); }
@@ -3270,3 +3395,42 @@ fn collect_source_lines(nodes: &[Node], mappings: &mut Vec<(usize, usize)>) {
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 {
3406+ if has_element_kind(&elem.children, target) {
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());
3427+ let mut out = String::new();
3428+ for domain in &domains {
3430+ out.push_str(&format!("<link rel=\"dns-prefetch\" href=\"//{}\">\n", domain));
3432+ out.push_str(&format!("<link rel=\"dns-prefetch\" href=\"//{}\">", domain));