@@ -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+}