@@ -551,6 +551,65 @@ fn generate_with_options(doc: &Document, dev: bool) -> String {
551551 None => String::new(),
552552 };
553553
554+ // Canonical URL
555+ let canonical_html = match &doc.canonical {
556+ Some(url) => {
557+ if dev {
558+ format!("<link rel=\"canonical\" href=\"{}\">\n", html_escape(url))
559+ } else {
560+ format!("<link rel=\"canonical\" href=\"{}\">", html_escape(url))
561+ }
562+ }
563+ None => String::new(),
564+ };
565+
566+ // Base URL
567+ let base_html = match &doc.base_url {
568+ Some(url) => {
569+ if dev {
570+ format!("<base href=\"{}\">\n", html_escape(url))
571+ } else {
572+ format!("<base href=\"{}\">", html_escape(url))
573+ }
574+ }
575+ None => String::new(),
576+ };
577+
578+ // @font-face CSS
579+ for (name, url) in &doc.font_faces {
580+ let format_hint = if url.ends_with(".woff2") {
581+ " format('woff2')"
582+ } else if url.ends_with(".woff") {
583+ " format('woff')"
584+ } else if url.ends_with(".ttf") {
585+ " format('truetype')"
586+ } else if url.ends_with(".otf") {
587+ " format('opentype')"
588+ } else {
589+ ""
590+ };
591+ if dev {
592+ element_css.insert_str(0, &format!(
593+ "@font-face {{\n font-family: '{}';\n src: url('{}'){};\n font-display: swap;\n}}\n",
594+ name, url, format_hint
595+ ));
596+ } else {
597+ element_css.insert_str(0, &format!(
598+ "@font-face{{font-family:'{}';src:url('{}'){};font-display:swap}}",
599+ name, url, format_hint
600+ ));
601+ }
602+ }
603+
604+ // JSON-LD blocks
605+ let json_ld_html: String = doc.json_ld_blocks.iter().map(|block| {
606+ if dev {
607+ format!("<script type=\"application/ld+json\">\n{}\n</script>\n", block)
608+ } else {
609+ format!("<script type=\"application/ld+json\">{}</script>", block)
610+ }
611+ }).collect();
612+
554613 match &doc.page_title {
555614 Some(title) => {
556615 if dev {
@@ -562,7 +621,7 @@ fn generate_with_options(doc: &Document, dev: bool) -> String {
562621<meta charset=\"utf-8\">
563622<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
564623<title>{title}</title>
565−{meta_html}{og_html}{favicon_html}{head_html}\
624+{base_html}{canonical_html}{meta_html}{og_html}{favicon_html}{json_ld_html}{head_html}\
566625<style>
567626*, *::before, *::after {{ box-sizing: border-box; }}
568627body {{ margin: 0; font-family: system-ui, -apple-system, sans-serif; }}
@@ -577,8 +636,11 @@ img {{ display: block; }}
577636",
578637 title = html_escape(title),
579638 lang_attr = lang_attr,
639+ base_html = base_html,
640+ canonical_html = canonical_html,
580641 meta_html = meta_html,
581642 favicon_html = favicon_html,
643+ json_ld_html = json_ld_html,
582644 head_html = head_html,
583645 og_html = og_html,
584646 element_css = element_css,
@@ -586,12 +648,15 @@ img {{ display: block; }}
586648 )
587649 } else {
588650 format!(
589− "<!DOCTYPE html><html{lang_attr}><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>{title}</title>{meta_html}{og_html}{favicon_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>",
651+ "<!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}{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>",
590652 title = html_escape(title),
591653 lang_attr = lang_attr,
654+ base_html = base_html,
655+ canonical_html = canonical_html,
592656 meta_html = meta_html,
593657 og_html = og_html,
594658 favicon_html = favicon_html,
659+ json_ld_html = json_ld_html,
595660 head_html = head_html,
596661 element_css = element_css,
597662 body = body,
@@ -637,6 +702,7 @@ fn generate_node(
637702 | Some(ElementKind::Dialog) | Some(ElementKind::DefinitionList)
638703 | Some(ElementKind::DefinitionDescription) | Some(ElementKind::Fieldset)
639704 | Some(ElementKind::Datalist) | Some(ElementKind::Grid) | Some(ElementKind::Stack)
705+ | Some(ElementKind::Noscript) | Some(ElementKind::Address) | Some(ElementKind::Search)
640706 );
641707 if needs_wrap {
642708 out.push_str(&ctx.indent());
@@ -685,6 +751,7 @@ const BOOLEAN_HTML_ATTRS: &[&str] = &[
685751 "disabled", "required", "checked", "multiple",
686752 "controls", "autoplay", "loop", "muted",
687753 "open", "novalidate", "autofocus",
754+ "defer", "async", "nomodule",
688755];
689756
690757fn emit_html_passthrough_attrs(out: &mut String, attrs: &[Attribute]) {
@@ -721,6 +788,84 @@ fn generate_element(
721788 generate_self_closing(elem, parent_kind, out, styles, ctx);
722789 return;
723790 }
791+ // @script renders as <script> with raw body content (no HTML escaping)
792+ if elem.kind == ElementKind::Script {
793+ out.push_str(&ctx.indent());
794+ out.push_str("<script");
795+ // Pass through src, type, defer, async, etc.
796+ for attr in &elem.attrs {
797+ let key = attr.key.as_str();
798+ if matches!(key, "src" | "type" | "defer" | "async" | "crossorigin" | "integrity" | "nomodule" | "id") {
799+ if let Some(val) = &attr.value {
800+ out.push(' ');
801+ out.push_str(key);
802+ out.push_str("=\"");
803+ out.push_str(&html_escape(val));
804+ out.push('"');
805+ } else {
806+ out.push(' ');
807+ out.push_str(key);
808+ }
809+ }
810+ }
811+ out.push('>');
812+ // Children are raw JS code, not HTML
813+ for child in &elem.children {
814+ match child {
815+ Node::Text(segments) => {
816+ for seg in segments {
817+ match seg {
818+ TextSegment::Plain(text) => out.push_str(text),
819+ _ => {}
820+ }
821+ }
822+ }
823+ Node::Raw(content) => out.push_str(content),
824+ _ => {}
825+ }
826+ }
827+ out.push_str("</script>");
828+ out.push_str(ctx.nl());
829+ return;
830+ }
831+ // @breadcrumb generates semantic <nav aria-label="breadcrumb"><ol>...</ol></nav>
832+ if elem.kind == ElementKind::Breadcrumb {
833+ let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles);
834+ let (id, user_class) = extract_id_class(&elem.attrs);
835+ out.push_str(&ctx.indent());
836+ out.push_str("<nav aria-label=\"breadcrumb\"");
837+ emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
838+ if let Some(id) = id {
839+ out.push_str(" id=\"");
840+ out.push_str(&html_escape(&id));
841+ out.push('"');
842+ }
843+ out.push('>');
844+ out.push_str(ctx.nl());
845+ ctx.depth += 1;
846+ out.push_str(&ctx.indent());
847+ out.push_str("<ol>");
848+ out.push_str(ctx.nl());
849+ ctx.depth += 1;
850+ for child in &elem.children {
851+ out.push_str(&ctx.indent());
852+ out.push_str("<li>");
853+ let mut buf = String::new();
854+ generate_node(child, Some(&elem.kind), &mut buf, styles, ctx);
855+ out.push_str(buf.trim());
856+ out.push_str("</li>");
857+ out.push_str(ctx.nl());
858+ }
859+ ctx.depth -= 1;
860+ out.push_str(&ctx.indent());
861+ out.push_str("</ol>");
862+ out.push_str(ctx.nl());
863+ ctx.depth -= 1;
864+ out.push_str(&ctx.indent());
865+ out.push_str("</nav>");
866+ out.push_str(ctx.nl());
867+ return;
868+ }
724869 if elem.kind == ElementKind::Children {
725870 return;
726871 }
@@ -803,9 +948,13 @@ fn generate_element(
803948 ElementKind::Carousel => "div",
804949 ElementKind::Chip => "span",
805950 ElementKind::Tag => "span",
951+ ElementKind::Noscript => "noscript",
952+ ElementKind::Address => "address",
953+ ElementKind::Search => "search",
806954 ElementKind::Image | ElementKind::Input | ElementKind::HorizontalRule
807955 | ElementKind::Children | ElementKind::Slot(_) | ElementKind::Fragment
808− | ElementKind::Source | ElementKind::Spacer => unreachable!(),
956+ | ElementKind::Source | ElementKind::Spacer
957+ | ElementKind::Script | ElementKind::Breadcrumb => unreachable!(),
809958 };
810959
811960 let kind_label = match elem.kind {
@@ -870,6 +1019,9 @@ fn generate_element(
8701019 ElementKind::Carousel => "carousel",
8711020 ElementKind::Chip => "chip",
8721021 ElementKind::Tag => "tag",
1022+ ElementKind::Noscript => "noscript",
1023+ ElementKind::Address => "address",
1024+ ElementKind::Search => "search",
8731025 _ => "",
8741026 };
8751027
@@ -1270,6 +1422,11 @@ const PSEUDO_PREFIXES: &[(&str, &str)] = &[
12701422 ("before:", "::before"),
12711423 ("after:", "::after"),
12721424 ("selection:", "::selection"),
1425+ ("visited:", ":visited"),
1426+ ("empty:", ":empty"),
1427+ ("target:", ":target"),
1428+ ("valid:", ":valid"),
1429+ ("invalid:", ":invalid"),
12731430];
12741431const RESPONSIVE_PREFIXES: &[&str] = &["sm:", "md:", "lg:", "xl:", "2xl:"];
12751432const MEDIA_PREFIXES: &[&str] = &["dark:", "print:", "motion-safe:", "motion-reduce:", "landscape:", "portrait:"];
@@ -1961,6 +2118,23 @@ fn attrs_to_css(
19612118 push_css(&mut css, "text-decoration-style", v);
19622119 }
19632120 }
2121+ "text-underline-offset" => {
2122+ if let Some(v) = val {
2123+ push_css(&mut css, "text-underline-offset", &css_px(v));
2124+ }
2125+ }
2126+
2127+ // Multi-column
2128+ "column-width" => {
2129+ if let Some(v) = val {
2130+ push_css(&mut css, "column-width", &css_px(v));
2131+ }
2132+ }
2133+ "column-rule" => {
2134+ if let Some(v) = val {
2135+ push_css(&mut css, "column-rule", v);
2136+ }
2137+ }
19642138
19652139 // Place items/self
19662140 "place-items" => {