@@ -551,6 +551,65 @@ fn generate_with_options(doc: &Document, dev: bool) -> String {
551 None => String::new(),
551 None => String::new(),
555+ let canonical_html = match &doc.canonical {
558+ format!("<link rel=\"canonical\" href=\"{}\">\n", html_escape(url))
560+ format!("<link rel=\"canonical\" href=\"{}\">", html_escape(url))
563+ None => String::new(),
567+ let base_html = match &doc.base_url {
570+ format!("<base href=\"{}\">\n", html_escape(url))
572+ format!("<base href=\"{}\">", html_escape(url))
575+ None => String::new(),
579+ for (name, url) in &doc.font_faces {
580+ let format_hint = if url.ends_with(".woff2") {
582+ } else if url.ends_with(".woff") {
584+ } else if url.ends_with(".ttf") {
585+ " format('truetype')"
586+ } else if url.ends_with(".otf") {
587+ " format('opentype')"
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
597+ element_css.insert_str(0, &format!(
598+ "@font-face{{font-family:'{}';src:url('{}'){};font-display:swap}}",
599+ name, url, format_hint
605+ let json_ld_html: String = doc.json_ld_blocks.iter().map(|block| {
607+ format!("<script type=\"application/ld+json\">\n{}\n</script>\n", block)
609+ format!("<script type=\"application/ld+json\">{}</script>", block)
554 match &doc.page_title {
613 match &doc.page_title {
555 Some(title) => {
614 Some(title) => {
@@ -562,7 +621,7 @@ fn generate_with_options(doc: &Document, dev: bool) -> String {
562<meta charset=\"utf-8\">
621<meta charset=\"utf-8\">
563<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
622<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
564<title>{title}</title>
623<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}\
567*, *::before, *::after {{ box-sizing: border-box; }}
626*, *::before, *::after {{ box-sizing: border-box; }}
568body {{ margin: 0; font-family: system-ui, -apple-system, sans-serif; }}
627body {{ margin: 0; font-family: system-ui, -apple-system, sans-serif; }}
@@ -577,8 +636,11 @@ img {{ display: block; }}
578 title = html_escape(title),
637 title = html_escape(title),
579 lang_attr = lang_attr,
638 lang_attr = lang_attr,
639+ base_html = base_html,
640+ canonical_html = canonical_html,
580 meta_html = meta_html,
641 meta_html = meta_html,
581 favicon_html = favicon_html,
642 favicon_html = favicon_html,
643+ json_ld_html = json_ld_html,
582 head_html = head_html,
644 head_html = head_html,
583 og_html = og_html,
645 og_html = og_html,
584 element_css = element_css,
646 element_css = element_css,
@@ -586,12 +648,15 @@ img {{ display: block; }}
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>",
590 title = html_escape(title),
652 title = html_escape(title),
591 lang_attr = lang_attr,
653 lang_attr = lang_attr,
654+ base_html = base_html,
655+ canonical_html = canonical_html,
592 meta_html = meta_html,
656 meta_html = meta_html,
593 og_html = og_html,
657 og_html = og_html,
594 favicon_html = favicon_html,
658 favicon_html = favicon_html,
659+ json_ld_html = json_ld_html,
595 head_html = head_html,
660 head_html = head_html,
596 element_css = element_css,
661 element_css = element_css,
597 body = body,
662 body = body,
@@ -637,6 +702,7 @@ fn generate_node(
637 | Some(ElementKind::Dialog) | Some(ElementKind::DefinitionList)
702 | Some(ElementKind::Dialog) | Some(ElementKind::DefinitionList)
638 | Some(ElementKind::DefinitionDescription) | Some(ElementKind::Fieldset)
703 | Some(ElementKind::DefinitionDescription) | Some(ElementKind::Fieldset)
639 | Some(ElementKind::Datalist) | Some(ElementKind::Grid) | Some(ElementKind::Stack)
704 | Some(ElementKind::Datalist) | Some(ElementKind::Grid) | Some(ElementKind::Stack)
705+ | Some(ElementKind::Noscript) | Some(ElementKind::Address) | Some(ElementKind::Search)
641 if needs_wrap {
707 if needs_wrap {
642 out.push_str(&ctx.indent());
708 out.push_str(&ctx.indent());
@@ -685,6 +751,7 @@ const BOOLEAN_HTML_ATTRS: &[&str] = &[
685 "disabled", "required", "checked", "multiple",
751 "disabled", "required", "checked", "multiple",
686 "controls", "autoplay", "loop", "muted",
752 "controls", "autoplay", "loop", "muted",
687 "open", "novalidate", "autofocus",
753 "open", "novalidate", "autofocus",
754+ "defer", "async", "nomodule",
690fn emit_html_passthrough_attrs(out: &mut String, attrs: &[Attribute]) {
757fn emit_html_passthrough_attrs(out: &mut String, attrs: &[Attribute]) {
@@ -721,6 +788,84 @@ fn generate_element(
721 generate_self_closing(elem, parent_kind, out, styles, ctx);
788 generate_self_closing(elem, parent_kind, out, styles, ctx);
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 {
802+ out.push_str("=\"");
803+ out.push_str(&html_escape(val));
812+ // Children are raw JS code, not HTML
813+ for child in &elem.children {
815+ Node::Text(segments) => {
816+ for seg in segments {
818+ TextSegment::Plain(text) => out.push_str(text),
823+ Node::Raw(content) => out.push_str(content),
827+ out.push_str("</script>");
828+ out.push_str(ctx.nl());
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));
844+ out.push_str(ctx.nl());
846+ out.push_str(&ctx.indent());
847+ out.push_str("<ol>");
848+ out.push_str(ctx.nl());
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());
860+ out.push_str(&ctx.indent());
861+ out.push_str("</ol>");
862+ out.push_str(ctx.nl());
864+ out.push_str(&ctx.indent());
865+ out.push_str("</nav>");
866+ out.push_str(ctx.nl());
724 if elem.kind == ElementKind::Children {
869 if elem.kind == ElementKind::Children {
@@ -803,9 +948,13 @@ fn generate_element(
803 ElementKind::Carousel => "div",
948 ElementKind::Carousel => "div",
804 ElementKind::Chip => "span",
949 ElementKind::Chip => "span",
805 ElementKind::Tag => "span",
950 ElementKind::Tag => "span",
951+ ElementKind::Noscript => "noscript",
952+ ElementKind::Address => "address",
953+ ElementKind::Search => "search",
806 ElementKind::Image | ElementKind::Input | ElementKind::HorizontalRule
954 ElementKind::Image | ElementKind::Input | ElementKind::HorizontalRule
807 | ElementKind::Children | ElementKind::Slot(_) | ElementKind::Fragment
955 | ElementKind::Children | ElementKind::Slot(_) | ElementKind::Fragment
808− | ElementKind::Source | ElementKind::Spacer => unreachable!(),
956+ | ElementKind::Source | ElementKind::Spacer
957+ | ElementKind::Script | ElementKind::Breadcrumb => unreachable!(),
811 let kind_label = match elem.kind {
960 let kind_label = match elem.kind {
@@ -870,6 +1019,9 @@ fn generate_element(
870 ElementKind::Carousel => "carousel",
1019 ElementKind::Carousel => "carousel",
871 ElementKind::Chip => "chip",
1020 ElementKind::Chip => "chip",
872 ElementKind::Tag => "tag",
1021 ElementKind::Tag => "tag",
1022+ ElementKind::Noscript => "noscript",
1023+ ElementKind::Address => "address",
1024+ ElementKind::Search => "search",
873 _ => "",
1025 _ => "",
@@ -1270,6 +1422,11 @@ const PSEUDO_PREFIXES: &[(&str, &str)] = &[
1270 ("before:", "::before"),
1422 ("before:", "::before"),
1271 ("after:", "::after"),
1423 ("after:", "::after"),
1272 ("selection:", "::selection"),
1424 ("selection:", "::selection"),
1425+ ("visited:", ":visited"),
1426+ ("empty:", ":empty"),
1427+ ("target:", ":target"),
1428+ ("valid:", ":valid"),
1429+ ("invalid:", ":invalid"),
1274const RESPONSIVE_PREFIXES: &[&str] = &["sm:", "md:", "lg:", "xl:", "2xl:"];
1431const RESPONSIVE_PREFIXES: &[&str] = &["sm:", "md:", "lg:", "xl:", "2xl:"];
1275const MEDIA_PREFIXES: &[&str] = &["dark:", "print:", "motion-safe:", "motion-reduce:", "landscape:", "portrait:"];
1432const MEDIA_PREFIXES: &[&str] = &["dark:", "print:", "motion-safe:", "motion-reduce:", "landscape:", "portrait:"];
@@ -1961,6 +2118,23 @@ fn attrs_to_css(
1961 push_css(&mut css, "text-decoration-style", v);
2118 push_css(&mut css, "text-decoration-style", v);
2121+ "text-underline-offset" => {
2122+ if let Some(v) = val {
2123+ push_css(&mut css, "text-underline-offset", &css_px(v));
2128+ "column-width" => {
2129+ if let Some(v) = val {
2130+ push_css(&mut css, "column-width", &css_px(v));
2134+ if let Some(v) = val {
2135+ push_css(&mut css, "column-rule", v);
1965 // Place items/self
2139 // Place items/self
1966 "place-items" => {
2140 "place-items" => {