@@ -417,7 +417,6 @@ fn emit_media_block(
417417struct GenContext {
418418 dev: bool,
419419 depth: usize,
420− image_count: usize,
421420 has_interactive: bool,
422421}
423422
@@ -587,24 +586,11 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
587586 let mut ctx = GenContext {
588587 dev,
589588 depth: 0,
590− image_count: 0,
591589 has_interactive: false,
592590 };
593591
594− // Check if document has @main for skip-to-content link
595− let has_main = has_tag(&doc.nodes, "main");
596−
597592 let mut body = String::new();
598593
599− // Skip-to-content link for accessibility (only when @main exists)
600− if has_main {
601− if dev {
602− body.push_str("<a href=\"#hl-main\" class=\"hl-skip\">Skip to content</a>\n");
603− } else {
604− body.push_str("<a href=\"#hl-main\" class=\"hl-skip\">Skip to content</a>");
605− }
606− }
607−
608594 for node in &doc.nodes {
609595 generate_node(node, None, &mut body, &mut styles, &mut ctx);
610596 }
@@ -735,27 +721,6 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
735721 None => String::new(),
736722 };
737723
738− // Preload hints
739− let mut preload_html = String::new();
740− // Explicit preload hints from the document
741− for hint in &doc.preload_hints {
742− if dev {
743− preload_html.push_str(&format!(
744− "<link rel=\"preload\" href=\"{}\" as=\"{}\"{}>\n",
745− html_escape(&hint.href),
746− hint.as_type,
747− if hint.crossorigin { " crossorigin" } else { "" }
748− ));
749− } else {
750− preload_html.push_str(&format!(
751− "<link rel=\"preload\" href=\"{}\" as=\"{}\"{}>",
752− html_escape(&hint.href),
753− hint.as_type,
754− if hint.crossorigin { " crossorigin" } else { "" }
755− ));
756− }
757− }
758−
759724 // Focus-visible CSS for interactive elements (accessibility)
760725 let focus_visible_css = if ctx.has_interactive {
761726 if dev {
@@ -767,18 +732,7 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
767732 ""
768733 };
769734
770− // Skip-to-content CSS (visually hidden but accessible)
771− let skip_link_css = if has_main {
772− if dev {
773− ".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"
774− } else {
775− ".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}"
776− }
777− } else {
778− ""
779− };
780−
781− let reset_css = reset_css(dev, focus_visible_css, skip_link_css);
735+ let reset_css = reset_css(dev, focus_visible_css);
782736
783737 match &doc.page_title {
784738 Some(title) => {
@@ -791,7 +745,7 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
791745<meta charset=\"utf-8\">
792746<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
793747<title>{title}</title>
794−{base_html}{canonical_html}{preload_html}{meta_html}{og_html}{favicon_html}{head_html}\
748+{base_html}{canonical_html}{meta_html}{og_html}{favicon_html}{head_html}\
795749<style>
796750{reset_css}{element_css}\
797751</style>
@@ -805,7 +759,6 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
805759 lang_attr = lang_attr,
806760 base_html = base_html,
807761 canonical_html = canonical_html,
808− preload_html = preload_html,
809762 meta_html = meta_html,
810763 favicon_html = favicon_html,
811764 head_html = head_html,
@@ -816,12 +769,11 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
816769 )
817770 } else {
818771 format!(
819− "<!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}{preload_html}{meta_html}{og_html}{favicon_html}{head_html}<style>{reset_css}{element_css}</style></head><body>{body}</body></html>",
772+ "<!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}{meta_html}{og_html}{favicon_html}{head_html}<style>{reset_css}{element_css}</style></head><body>{body}</body></html>",
820773 title = html_escape(title),
821774 lang_attr = lang_attr,
822775 base_html = base_html,
823776 canonical_html = canonical_html,
824− preload_html = preload_html,
825777 meta_html = meta_html,
826778 og_html = og_html,
827779 favicon_html = favicon_html,
@@ -912,13 +864,13 @@ fn build_element_css(doc: &Document, styles: &StyleCollector, dev: bool) -> Stri
912864/// Built-in reset rules, in a layer before `htmlang`: unlayered CSS beats
913865/// every layer, so an unlayered `a{color:inherit}` would override
914866/// `@link [color red]`.
915−fn reset_css(dev: bool, focus_visible_css: &str, skip_link_css: &str) -> String {
867+fn reset_css(dev: bool, focus_visible_css: &str) -> String {
916868 let base = if dev {
917869 "*, *::before, *::after { box-sizing: border-box; }\nbody { margin: 0; font-family: system-ui, -apple-system, sans-serif; }\nimg { display: block; }\na { text-decoration: none; color: inherit; }\n"
918870 } else {
919871 "*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}"
920872 };
921− let rules = format!("{}{}{}", base, focus_visible_css, skip_link_css);
873+ let rules = format!("{}{}", base, focus_visible_css);
922874 if dev {
923875 format!("@layer hl-reset, htmlang;\n@layer hl-reset {{\n{}}}\n", rules)
924876 } else {
@@ -932,7 +884,6 @@ fn generate_partial_inner(doc: &Document, dev: bool) -> String {
932884 let mut ctx = GenContext {
933885 dev,
934886 depth: 0,
935− image_count: 0,
936887 has_interactive: false,
937888 };
938889 let mut body = String::new();
@@ -1178,10 +1129,6 @@ fn generate_element(
11781129 out.push('<');
11791130 out.push_str(tag);
11801131
1181− // @main gets id="hl-main" for skip-to-content link (unless user set an id)
1182− if elem.kind.is_tag("main") && id.is_none() {
1183− out.push_str(" id=\"hl-main\"");
1184− }
11851132
11861133 if elem.kind == ElementKind::Link
11871134 && let Some(url) = &elem.argument
@@ -1189,18 +1136,6 @@ fn generate_element(
11891136 out.push_str(" href=\"");
11901137 out.push_str(&html_escape(url));
11911138 out.push('"');
1192− // Auto rel="noopener noreferrer" and target="_blank" for external links
1193− let is_external = url.starts_with("http://") || url.starts_with("https://");
1194− if is_external {
1195− let has_rel = elem.attrs.iter().any(|a| a.key == "rel");
1196− let has_target = elem.attrs.iter().any(|a| a.key == "target");
1197− if !has_rel {
1198− out.push_str(" rel=\"noopener noreferrer\"");
1199− }
1200− if !has_target {
1201− out.push_str(" target=\"_blank\"");
1202− }
1203− }
12041139 }
12051140
12061141 emit_argument_attr(out, elem);
@@ -1335,80 +1270,6 @@ fn generate_self_closing(
13351270 ));
13361271 }
13371272
1338− // Image optimization: auto-add loading="lazy" and decoding="async"
1339− if elem.kind == ElementKind::Image {
1340− // Responsive srcset: @image photo.jpg [responsive 400 800 1200]
1341− let responsive_attr = elem.attrs.iter().find(|a| a.key == "responsive");
1342− if let Some(resp) = responsive_attr
1343− && let Some(ref sizes_str) = resp.value
1344− {
1345− let widths: Vec<&str> = sizes_str.split_whitespace().collect();
1346− if !widths.is_empty() {
1347− let src = elem.argument.as_deref().unwrap_or("");
1348− if !src.is_empty() {
1349− // Generate srcset with width descriptors
1350− // Convention: file-{width}.ext (e.g., photo-400.jpg)
1351− let dot_pos = src.rfind('.').unwrap_or(src.len());
1352− let base = &src[..dot_pos];
1353− let ext = &src[dot_pos..];
1354− let mut srcset_parts = Vec::new();
1355− for w in &widths {
1356− srcset_parts.push(format!("{}-{}{} {}w", base, w, ext, w));
1357− }
1358− out.push_str(" srcset=\"");
1359− out.push_str(&srcset_parts.join(", "));
1360− out.push('"');
1361− // Generate sizes attribute
1362− let max_width = widths.last().unwrap_or(&"100vw");
1363− out.push_str(&format!(
1364− " sizes=\"(max-width: {}px) 100vw, {}px\"",
1365− max_width, max_width
1366− ));
1367− }
1368− }
1369− }
1370−
1371− // Auto image dimensions: read local image file to inject width/height + aspect-ratio
1372− let has_width = elem.attrs.iter().any(|a| a.key == "width");
1373− let has_height = elem.attrs.iter().any(|a| a.key == "height");
1374− if (!has_width || !has_height)
1375− && let Some(ref src) = elem.argument
1376− && !src.starts_with("http://")
1377− && !src.starts_with("https://")
1378− && !src.starts_with("data:")
1379− && let Some((w, h)) = read_image_dimensions(src)
1380− {
1381− // Intrinsic size attributes only when neither dimension is set in
1382− // CSS; with one CSS dimension, a leftover intrinsic attribute for
1383− // the other would distort the image (200 wide but 1000 tall).
1384− if !has_width && !has_height {
1385− out.push_str(&format!(" width=\"{}\" height=\"{}\"", w, h));
1386− }
1387− // Auto aspect-ratio to prevent CLS
1388− if !elem.attrs.iter().any(|a| a.key == "aspect-ratio") {
1389− out.push_str(&format!(" style=\"aspect-ratio:{}/{}\"", w, h));
1390− }
1391− }
1392−
1393− // Smart image loading: first 3 images get fetchpriority="high" (above the fold),
1394− // subsequent images get loading="lazy" + decoding="async"
1395− ctx.image_count += 1;
1396− if ctx.image_count <= 3 {
1397− // Above-the-fold: eager loading with high priority
1398− if !elem.attrs.iter().any(|a| a.key == "fetchpriority") {
1399− out.push_str(" fetchpriority=\"high\"");
1400− }
1401− } else {
1402− // Below-the-fold: lazy loading
1403− if !elem.attrs.iter().any(|a| a.key == "loading") {
1404− out.push_str(" loading=\"lazy\"");
1405− }
1406− if !elem.attrs.iter().any(|a| a.key == "decoding") {
1407− out.push_str(" decoding=\"async\"");
1408− }
1409− }
1410− }
1411−
14121273 out.push('>');
14131274 out.push_str(ctx.nl());
14141275}
@@ -2251,153 +2112,6 @@ fn extract_id_class(attrs: &[Attribute]) -> (Option<String>, Option<String>) {
22512112 (id, class)
22522113}
22532114
2254−/// Read image dimensions from a local file by parsing the header bytes.
2255−/// Supports PNG, JPEG, GIF, WebP, AVIF, and SVG.
2256−fn read_image_dimensions(path: &str) -> Option<(u32, u32)> {
2257− // SVG: parse as text for viewBox/width/height attributes
2258− if path.ends_with(".svg") || path.ends_with(".SVG") {
2259− let text = std::fs::read_to_string(path).ok()?;
2260− return read_svg_dimensions(&text);
2261− }
2262−
2263− let data = std::fs::read(path).ok()?;
2264− if data.len() < 12 {
2265− return None;
2266− }
2267−
2268− // PNG: 8-byte signature, then IHDR chunk with width/height at bytes 16-23
2269− if data.len() >= 24 && data.starts_with(b"\x89PNG\r\n\x1a\n") {
2270− let w = u32::from_be_bytes([data[16], data[17], data[18], data[19]]);
2271− let h = u32::from_be_bytes([data[20], data[21], data[22], data[23]]);
2272− return Some((w, h));
2273− }
2274−
2275− // GIF: "GIF87a" or "GIF89a", width/height at bytes 6-9 (little-endian)
2276− if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
2277− let w = u16::from_le_bytes([data[6], data[7]]) as u32;
2278− let h = u16::from_le_bytes([data[8], data[9]]) as u32;
2279− return Some((w, h));
2280− }
2281−
2282− // JPEG: scan for SOF0/SOF2 marker (0xFF 0xC0 or 0xFF 0xC2)
2283− if data.starts_with(b"\xff\xd8") {
2284− let mut i = 2;
2285− while i + 9 < data.len() {
2286− if data[i] != 0xFF {
2287− i += 1;
2288− continue;
2289− }
2290− let marker = data[i + 1];
2291− if marker == 0xC0 || marker == 0xC2 {
2292− let h = u16::from_be_bytes([data[i + 5], data[i + 6]]) as u32;
2293− let w = u16::from_be_bytes([data[i + 7], data[i + 8]]) as u32;
2294− return Some((w, h));
2295− }
2296− if i + 3 < data.len() {
2297− let len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
2298− i += 2 + len;
2299− } else {
2300− break;
2301− }
2302− }
2303− }
2304−
2305− // WebP: "RIFF" ... "WEBP", VP8 header at byte 20
2306− if data.len() >= 30 && &data[..4] == b"RIFF" && &data[8..12] == b"WEBP" {
2307− if &data[12..16] == b"VP8 " && data.len() >= 30 {
2308− let w = u16::from_le_bytes([data[26], data[27]]) as u32 & 0x3FFF;
2309− let h = u16::from_le_bytes([data[28], data[29]]) as u32 & 0x3FFF;
2310− return Some((w, h));
2311− }
2312− if &data[12..16] == b"VP8L" && data.len() >= 25 && data[21] == 0x2F {
2313− let bits = u32::from_le_bytes([data[22], data[23], data[24], data[25]]);
2314− let w = (bits & 0x3FFF) + 1;
2315− let h = ((bits >> 14) & 0x3FFF) + 1;
2316− return Some((w, h));
2317− }
2318− }
2319−
2320− // AVIF: ISOBMFF container with "ftyp" box containing "avif"/"avis" brand,
2321− // then "ispe" box with width/height
2322− if data.len() >= 12 && &data[4..8] == b"ftyp" {
2323− let brand = &data[8..12];
2324− if brand == b"avif" || brand == b"avis" || brand == b"mif1" {
2325− return read_avif_dimensions(&data);
2326− }
2327− }
2328−
2329− None
2330−}
2331−
2332−/// Parse SVG viewBox or width/height attributes to get dimensions.
2333−fn read_svg_dimensions(text: &str) -> Option<(u32, u32)> {
2334− // Try viewBox first: viewBox="minX minY width height"
2335− if let Some(vb_start) = text.find("viewBox=\"") {
2336− let rest = &text[vb_start + 9..];
2337− if let Some(end) = rest.find('"') {
2338− let parts: Vec<&str> = rest[..end].split_whitespace().collect();
2339− if parts.len() == 4
2340− && let (Ok(w), Ok(h)) = (parts[2].parse::<f64>(), parts[3].parse::<f64>())
2341− && w > 0.0
2342− && h > 0.0
2343− {
2344− return Some((w.round() as u32, h.round() as u32));
2345− }
2346− }
2347− }
2348− // Fall back to width/height attributes on <svg>
2349− let svg_tag = text.find("<svg")?;
2350− let tag_end = text[svg_tag..].find('>')? + svg_tag;
2351− let tag = &text[svg_tag..tag_end];
2352− let w = extract_svg_attr(tag, "width")?;
2353− let h = extract_svg_attr(tag, "height")?;
2354− Some((w, h))
2355−}
2356−
2357−fn extract_svg_attr(tag: &str, attr: &str) -> Option<u32> {
2358− let needle = format!("{}=\"", attr);
2359− let start = tag.find(&needle)? + needle.len();
2360− let rest = &tag[start..];
2361− let end = rest.find('"')?;
2362− let val = rest[..end].trim_end_matches("px");
2363− val.parse::<f64>().ok().map(|v| v.round() as u32)
2364−}
2365−
2366−/// Parse AVIF (ISOBMFF) container to find ispe box with image dimensions.
2367−fn read_avif_dimensions(data: &[u8]) -> Option<(u32, u32)> {
2368− // Walk ISOBMFF boxes looking for "ispe" (image spatial extents)
2369− let mut i = 0;
2370− while i + 8 <= data.len() {
2371− let box_size =
2372− u32::from_be_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]) as usize;
2373− let box_type = &data[i + 4..i + 8];
2374− if box_size < 8 {
2375− break;
2376− }
2377− let box_end = (i + box_size).min(data.len());
2378− // ispe box: 4 bytes version/flags + 4 bytes width + 4 bytes height
2379− if box_type == b"ispe" && box_end >= i + 20 {
2380− let w = u32::from_be_bytes([data[i + 12], data[i + 13], data[i + 14], data[i + 15]]);
2381− let h = u32::from_be_bytes([data[i + 16], data[i + 17], data[i + 18], data[i + 19]]);
2382− return Some((w, h));
2383− }
2384− // Recurse into container boxes (meta, iprp, ipco)
2385− if matches!(
2386− box_type,
2387− b"meta" | b"iprp" | b"ipco" | b"moov" | b"trak" | b"mdia"
2388− ) {
2389− let header_size = if box_type == b"meta" { 12 } else { 8 };
2390− if i + header_size < box_end
2391− && let Some(dims) = read_avif_dimensions(&data[i + header_size..box_end])
2392− {
2393− return Some(dims);
2394− }
2395− }
2396− i = box_end;
2397− }
2398− None
2399−}
2400−
24012115fn html_escape(s: &str) -> String {
24022116 s.replace('&', "&")
24032117 .replace('<', "<")
@@ -2514,12 +2228,3 @@ fn vlq_encode(value: i64, out: &mut String) {
25142228}
25152229
25162230
2517−/// Does the tree contain the table element `name` (e.g. `"main"`)?
2518−fn has_tag(nodes: &[Node], name: &str) -> bool {
2519− nodes.iter().any(|node| match node {
2520− Node::Element(elem) => elem.kind.is_tag(name) || has_tag(&elem.children, name),
2521− _ => false,
2522− })
2523−}
2524−
2525−