@@ -733,7 +733,7 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
733733 };
734734
735735 // Check if document has @main for skip-to-content link
736− let has_main = has_element_kind(&doc.nodes, &ElementKind::Main);
736+ let has_main = has_tag(&doc.nodes, "main");
737737
738738 let mut body = String::new();
739739
@@ -1307,32 +1307,10 @@ fn generate_node(
13071307 Node::Text(segments) => {
13081308 let needs_wrap = matches!(
13091309 parent_kind,
1310− Some(ElementKind::Row)
1311− | Some(ElementKind::Column)
1312− | Some(ElementKind::El)
1313− | Some(ElementKind::Nav)
1314− | Some(ElementKind::Header)
1315− | Some(ElementKind::Footer)
1316− | Some(ElementKind::Main)
1317− | Some(ElementKind::Section)
1318− | Some(ElementKind::Article)
1319− | Some(ElementKind::Aside)
1320− | Some(ElementKind::ListItem)
1321− | Some(ElementKind::Form)
1322− | Some(ElementKind::Details)
1323− | Some(ElementKind::Figure)
1324− | Some(ElementKind::Blockquote)
1325− | Some(ElementKind::Dialog)
1326− | Some(ElementKind::DefinitionList)
1327− | Some(ElementKind::DefinitionDescription)
1328− | Some(ElementKind::Fieldset)
1329− | Some(ElementKind::Datalist)
1330− | Some(ElementKind::Grid)
1331− | Some(ElementKind::Stack)
1332− | Some(ElementKind::Noscript)
1333− | Some(ElementKind::Address)
1334− | Some(ElementKind::Search)
1335− );
1310+ Some(ElementKind::Row | ElementKind::Column | ElementKind::El)
1311+ ) || parent_kind
1312+ .and_then(ElementKind::spec)
1313+ .is_some_and(|spec| spec.wraps_text);
13361314 if needs_wrap {
13371315 out.push_str(&ctx.indent());
13381316 out.push_str("<span>");
@@ -1352,6 +1330,23 @@ fn generate_node(
13521330}
13531331
13541332
1333+/// Emit the argument of an element whose argument is an HTML attribute
1334+/// (`@iframe URL` → `src="URL"`, `@form /submit` → `action="/submit"`).
1335+fn emit_argument_attr(out: &mut String, elem: &Element) {
1336+ if let Some(TagSpec {
1337+ arg: TagArg::Attr(attr),
1338+ ..
1339+ }) = elem.kind.spec()
1340+ && let Some(value) = &elem.argument
1341+ {
1342+ out.push(' ');
1343+ out.push_str(attr);
1344+ out.push_str("=\"");
1345+ out.push_str(&html_escape(value));
1346+ out.push('"');
1347+ }
1348+}
1349+
13551350fn emit_html_passthrough_attrs(out: &mut String, attrs: &[Attribute]) {
13561351 for attr in attrs {
13571352 let key = attr.key.as_str();
@@ -1399,7 +1394,7 @@ fn has_overlay_children(elem: &Element) -> bool {
13991394 elem.children.iter().any(|child| {
14001395 matches!(
14011396 child,
1402− Node::Element(e) if matches!(e.kind, ElementKind::InFront | ElementKind::Behind)
1397+ Node::Element(e) if e.kind.is_tag("in-front") || e.kind.is_tag("behind")
14031398 )
14041399 })
14051400}
@@ -1412,14 +1407,7 @@ fn generate_element(
14121407 ctx: &mut GenContext,
14131408) {
14141409 // Self-closing elements
1415− if matches!(
1416− elem.kind,
1417− ElementKind::Image
1418− | ElementKind::Input
1419− | ElementKind::HorizontalRule
1420− | ElementKind::Source
1421− | ElementKind::Spacer
1422− ) {
1410+ if elem.kind == ElementKind::Image || elem.kind.spec().is_some_and(|spec| spec.void) {
14231411 generate_self_closing(elem, parent_kind, out, styles, ctx);
14241412 return;
14251413 }
@@ -1525,189 +1513,24 @@ fn generate_element(
15251513 }
15261514
15271515 let tag = match &elem.kind {
1528− ElementKind::Row
1529− | ElementKind::Column
1530− | ElementKind::El
1531− | ElementKind::Grid
1532− | ElementKind::Stack
1533− | ElementKind::InFront
1534− | ElementKind::Behind => "div",
1516+ ElementKind::Row | ElementKind::Column | ElementKind::El => "div",
15351517 ElementKind::Text => "span",
15361518 ElementKind::Paragraph => "p",
15371519 ElementKind::Link => "a",
1538− ElementKind::Button => "button",
1539− ElementKind::Select => "select",
1540− ElementKind::Textarea => "textarea",
1541− ElementKind::Option => "option",
1542− ElementKind::Label => "label",
1543− // Semantic elements
1544− ElementKind::Nav => "nav",
1545− ElementKind::Header => "header",
1546− ElementKind::Footer => "footer",
1547− ElementKind::Main => "main",
1548− ElementKind::Section => "section",
1549− ElementKind::Article => "article",
1550− ElementKind::Aside => "aside",
1551− // List
1552− ElementKind::List => {
1553− if elem.attrs.iter().any(|a| a.key == "ordered") {
1554− "ol"
1555− } else {
1556− "ul"
1557− }
1558− }
1559− ElementKind::ListItem => "li",
1560− // Table
1561− ElementKind::Table => "table",
1562− ElementKind::TableHead => "thead",
1563− ElementKind::TableBody => "tbody",
1564− ElementKind::TableRow => "tr",
1565− ElementKind::TableCell => "td",
1566− ElementKind::TableHeaderCell => "th",
1567− // Media
1568− ElementKind::Video => "video",
1569− ElementKind::Audio => "audio",
1570− // Additional semantic elements
1571− ElementKind::Form => "form",
1572− ElementKind::Details => "details",
1573− ElementKind::Summary => "summary",
1574− ElementKind::Blockquote => "blockquote",
1575− ElementKind::Cite => "cite",
1576− ElementKind::Code => "code",
1577− ElementKind::Pre => "pre",
1578− ElementKind::Figure => "figure",
1579− ElementKind::FigCaption => "figcaption",
1580− ElementKind::Progress => "progress",
1581− ElementKind::Meter => "meter",
1582− // New elements
1583− ElementKind::Dialog => "dialog",
1584− ElementKind::DefinitionList => "dl",
1585− ElementKind::DefinitionTerm => "dt",
1586− ElementKind::DefinitionDescription => "dd",
1587− ElementKind::Fieldset => "fieldset",
1588− ElementKind::Legend => "legend",
1589− ElementKind::Picture => "picture",
1590− ElementKind::Time => "time",
1591− ElementKind::Mark => "mark",
1592− ElementKind::Kbd => "kbd",
1593− ElementKind::Abbr => "abbr",
1594− ElementKind::Datalist => "datalist",
1595− ElementKind::Iframe => "iframe",
1596− ElementKind::Output => "output",
1597− ElementKind::Canvas => "canvas",
1598− ElementKind::Badge => "span",
1599− ElementKind::Tooltip => "span",
1600− ElementKind::Avatar => "div",
1601− ElementKind::Carousel => "div",
1602− ElementKind::Chip => "span",
1603− ElementKind::Tag => "span",
1604− ElementKind::Noscript => "noscript",
1605− ElementKind::Address => "address",
1606− ElementKind::Search => "search",
1607− // Heading elements
1608− ElementKind::H1 => "h1",
1609− ElementKind::H2 => "h2",
1610− ElementKind::H3 => "h3",
1611− ElementKind::H4 => "h4",
1612− ElementKind::H5 => "h5",
1613− ElementKind::H6 => "h6",
1614− ElementKind::Image
1615− | ElementKind::Input
1616− | ElementKind::HorizontalRule
1617− | ElementKind::Children
1618− | ElementKind::Slot(_)
1619− | ElementKind::Fragment
1620− | ElementKind::Source
1621− | ElementKind::Spacer
1622− | ElementKind::Script
1623− | ElementKind::Breadcrumb => unreachable!(),
1624− };
1625−
1626− let kind_label = match elem.kind {
1627− ElementKind::Row => "row",
1628− ElementKind::Column => "column",
1629− ElementKind::El => "el",
1630− ElementKind::Text => "text",
1631− ElementKind::Paragraph => "paragraph",
1632− ElementKind::Link => "link",
1633− ElementKind::Button => "button",
1634− ElementKind::Select => "select",
1635− ElementKind::Textarea => "textarea",
1636− ElementKind::Option => "option",
1637− ElementKind::Label => "label",
1638− ElementKind::Nav => "nav",
1639− ElementKind::Header => "header",
1640− ElementKind::Footer => "footer",
1641− ElementKind::Main => "main",
1642− ElementKind::Section => "section",
1643− ElementKind::Article => "article",
1644− ElementKind::Aside => "aside",
1645− ElementKind::List => "list",
1646− ElementKind::ListItem => "item",
1647− ElementKind::Table => "table",
1648− ElementKind::TableHead => "thead",
1649− ElementKind::TableBody => "tbody",
1650− ElementKind::TableRow => "tr",
1651− ElementKind::TableCell => "td",
1652− ElementKind::TableHeaderCell => "th",
1653− ElementKind::Video => "video",
1654− ElementKind::Audio => "audio",
1655− ElementKind::Form => "form",
1656− ElementKind::Details => "details",
1657− ElementKind::Summary => "summary",
1658− ElementKind::Blockquote => "blockquote",
1659− ElementKind::Cite => "cite",
1660− ElementKind::Code => "code",
1661− ElementKind::Pre => "pre",
1662− ElementKind::Figure => "figure",
1663− ElementKind::FigCaption => "figcaption",
1664− ElementKind::Fragment => "fragment",
1665− ElementKind::Dialog => "dialog",
1666− ElementKind::DefinitionList => "dl",
1667− ElementKind::DefinitionTerm => "dt",
1668− ElementKind::DefinitionDescription => "dd",
1669− ElementKind::Fieldset => "fieldset",
1670− ElementKind::Legend => "legend",
1671− ElementKind::Picture => "picture",
1672− ElementKind::Time => "time",
1673− ElementKind::Mark => "mark",
1674− ElementKind::Kbd => "kbd",
1675− ElementKind::Abbr => "abbr",
1676− ElementKind::Datalist => "datalist",
1677− ElementKind::Iframe => "iframe",
1678− ElementKind::Output => "output",
1679− ElementKind::Canvas => "canvas",
1680− ElementKind::Grid => "grid",
1681− ElementKind::Stack => "stack",
1682− ElementKind::Badge => "badge",
1683− ElementKind::Tooltip => "tooltip",
1684− ElementKind::Avatar => "avatar",
1685− ElementKind::Carousel => "carousel",
1686− ElementKind::Chip => "chip",
1687− ElementKind::Tag => "tag",
1688− ElementKind::Noscript => "noscript",
1689− ElementKind::Address => "address",
1690− ElementKind::Search => "search",
1691− ElementKind::InFront => "in-front",
1692− ElementKind::Behind => "behind",
1693− ElementKind::H1 => "h1",
1694− ElementKind::H2 => "h2",
1695− ElementKind::H3 => "h3",
1696− ElementKind::H4 => "h4",
1697− ElementKind::H5 => "h5",
1698− ElementKind::H6 => "h6",
1520+ ElementKind::Tag(spec)
1521+ if spec.name == "list" && elem.attrs.iter().any(|a| a.key == "ordered") =>
1522+ {
1523+ "ol"
1524+ }
1525+ ElementKind::Tag(spec) => spec.html,
16991526 _ => "",
17001527 };
1528+ let kind_label = elem.kind.name();
17011529
17021530 // Track interactive elements for focus-visible CSS
1703− if matches!(
1704− elem.kind,
1705− ElementKind::Link
1706− | ElementKind::Button
1707− | ElementKind::Input
1708− | ElementKind::Select
1709− | ElementKind::Textarea
1710− ) {
1531+ if elem.kind == ElementKind::Link
1532+ || matches!(kind_label, "button" | "input" | "select" | "textarea")
1533+ {
17111534 ctx.has_interactive = true;
17121535 }
17131536
@@ -1734,7 +1557,7 @@ fn generate_element(
17341557 out.push_str(tag);
17351558
17361559 // @main gets id="hl-main" for skip-to-content link (unless user set an id)
1737− if elem.kind == ElementKind::Main && id.is_none() {
1560+ if elem.kind.is_tag("main") && id.is_none() {
17381561 out.push_str(" id=\"hl-main\"");
17391562 }
17401563
@@ -1758,35 +1581,10 @@ fn generate_element(
17581581 }
17591582 }
17601583
1761− // Video/Audio src
1762− if matches!(elem.kind, ElementKind::Video | ElementKind::Audio)
1763− && let Some(src) = &elem.argument
1764− {
1765− out.push_str(" src=\"");
1766− out.push_str(&html_escape(src));
1767− out.push('"');
1768− }
1769−
1770− // Form action
1771− if elem.kind == ElementKind::Form
1772− && let Some(action) = &elem.argument
1773− {
1774− out.push_str(" action=\"");
1775− out.push_str(&html_escape(action));
1776− out.push('"');
1777− }
1778−
1779− // Iframe src
1780− if elem.kind == ElementKind::Iframe
1781− && let Some(src) = &elem.argument
1782− {
1783− out.push_str(" src=\"");
1784− out.push_str(&html_escape(src));
1785− out.push('"');
1786− }
1584+ emit_argument_attr(out, elem);
17871585
17881586 // Tooltip title
1789− if elem.kind == ElementKind::Tooltip
1587+ if elem.kind.is_tag("tooltip")
17901588 && let Some(text) = &elem.argument
17911589 {
17921590 out.push_str(" title=\"");
@@ -1841,16 +1639,8 @@ fn generate_element(
18411639
18421640 // Children
18431641 ctx.depth += 1;
1844− let is_paragraph = matches!(
1845− elem.kind,
1846− ElementKind::Paragraph
1847− | ElementKind::H1
1848− | ElementKind::H2
1849− | ElementKind::H3
1850− | ElementKind::H4
1851− | ElementKind::H5
1852− | ElementKind::H6
1853− );
1642+ let is_paragraph = elem.kind == ElementKind::Paragraph
1643+ || elem.kind.spec().is_some_and(|spec| spec.inline);
18541644 for (i, child) in elem.children.iter().enumerate() {
18551645 generate_node(child, Some(&elem.kind), out, styles, ctx);
18561646 if is_paragraph && i < elem.children.len() - 1 {
@@ -1876,13 +1666,10 @@ fn generate_self_closing(
18761666 let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles, false);
18771667 let (id, user_class) = extract_id_class(&elem.attrs);
18781668
1879− let (tag, kind_label) = match elem.kind {
1669+ let (tag, kind_label) = match &elem.kind {
18801670 ElementKind::Image => ("img", "image"),
1881− ElementKind::Input => ("input", "input"),
1882− ElementKind::HorizontalRule => ("hr", "hr"),
1883− ElementKind::Source => ("source", "source"),
1884− ElementKind::Spacer => ("div", "spacer"),
1885− _ => unreachable!(),
1671+ ElementKind::Tag(spec) => (spec.html, spec.name),
1672+ _ => unreachable!("not a void element: {:?}", elem.kind),
18861673 };
18871674
18881675 if ctx.dev && elem.line_num > 0 {
@@ -1895,6 +1682,7 @@ fn generate_self_closing(
18951682 out.push_str(&ctx.indent());
18961683 out.push('<');
18971684 out.push_str(tag);
1685+ emit_argument_attr(out, elem);
18981686
18991687 // Image src (with optional non-SVG base64 inlining)
19001688 if elem.kind == ElementKind::Image {
@@ -2347,14 +2135,7 @@ fn is_prefixed_attr(key: &str) -> bool {
23472135/// semantic wrapper that behaves like a column.
23482136const FLEX_COLUMN: &str = "display:flex;flex-direction:column;";
23492137
2350−/// Font stack shared by `@pre`, `@code`, `@kbd`.
2351−const MONOSPACE_STACK: &str = "font-family:ui-monospace,monospace;";
23522138
2353−/// Declarations common to `@badge` and `@tag`. They differ only in
2354−/// `border-radius` (and `@badge` additionally centers content / pins
2355−/// line-height:1), so keeping the shared base here avoids drift.
2356−const PILL_BASE: &str =
2357− "display:inline-flex;align-items:center;padding:2px 8px;font-size:0.75rem;font-weight:600;";
23582139
23592140fn attrs_to_css(
23602141 attrs: &[Attribute],
@@ -2375,81 +2156,13 @@ fn attrs_to_css(
23752156 }
23762157 match kind {
23772158 ElementKind::Row => css.push_str("display:flex;flex-direction:row;"),
2378− ElementKind::Column
2379− | ElementKind::El
2380− // Semantic elements get flex column layout like @el
2381− | ElementKind::Nav
2382− | ElementKind::Header
2383− | ElementKind::Footer
2384− | ElementKind::Main
2385− | ElementKind::Section
2386− | ElementKind::Article
2387− | ElementKind::Aside
2388− | ElementKind::ListItem
2389− | ElementKind::Form
2390− | ElementKind::Details
2391− | ElementKind::Dialog => css.push_str(FLEX_COLUMN),
2392− ElementKind::Paragraph
2393− | ElementKind::H1
2394− | ElementKind::H2
2395− | ElementKind::H3
2396− | ElementKind::H4
2397− | ElementKind::H5
2398− | ElementKind::H6 => css.push_str("margin:0;"),
2399− // Lists: reset browser defaults
2400− ElementKind::List => css.push_str("margin:0;padding-left:0;list-style:none;"),
2401− // Figure / Blockquote: flex column with browser margin reset
2402− ElementKind::Figure | ElementKind::Blockquote => {
2403− css.push_str(FLEX_COLUMN);
2404− css.push_str("margin:0;");
2405− }
2406− // Pre: preserve whitespace
2407− ElementKind::Pre => {
2408− css.push_str("margin:0;white-space:pre;");
2409− css.push_str(MONOSPACE_STACK);
2410− }
2411− // Code / Kbd: monospace font
2412− ElementKind::Code | ElementKind::Kbd => css.push_str(MONOSPACE_STACK),
2413− ElementKind::DefinitionList => css.push_str("margin:0;"),
2414− ElementKind::DefinitionDescription => {
2415− css.push_str("margin:0;");
2416− css.push_str(FLEX_COLUMN);
2417− }
2418− ElementKind::Fieldset => {
2419− css.push_str(FLEX_COLUMN);
2420− // Align padding to the px-based scale used by Badge/Tag/Chip
2421− // rather than the old 0.5em (which depended on font-size).
2422− css.push_str("border:1px solid currentColor;padding:8px;margin:0;");
2423− }
2424− ElementKind::Grid => css.push_str("display:grid;"),
2425− ElementKind::Stack => css.push_str("position:relative;"),
2426− ElementKind::Badge => {
2427− css.push_str(PILL_BASE);
2428− css.push_str("justify-content:center;border-radius:9999px;line-height:1;");
2429− }
2430− ElementKind::Tag => {
2431− css.push_str(PILL_BASE);
2432− css.push_str("border-radius:4px;");
2433− }
2434− ElementKind::Tooltip => css.push_str("position:relative;cursor:help;"),
2435− ElementKind::Spacer => css.push_str("flex:1;"),
2436− ElementKind::Avatar => css.push_str("display:inline-flex;align-items:center;justify-content:center;border-radius:9999px;overflow:hidden;flex-shrink:0;"),
2437− ElementKind::Carousel => css.push_str("display:flex;flex-direction:row;overflow-x:auto;scroll-snap-type:x mandatory;-webkit-overflow-scrolling:touch;scrollbar-width:none;"),
2438− ElementKind::Chip => css.push_str("display:inline-flex;align-items:center;gap:4px;padding:4px 12px;border-radius:9999px;font-size:0.875rem;border:1px solid currentColor;"),
2439− // Overlay layers fill the parent. Parent gets position:relative
2440− // applied automatically (see has_overlay_children check below).
2441− ElementKind::InFront => {
2442− css.push_str(FLEX_COLUMN);
2443− css.push_str("position:absolute;inset:0;");
2444− }
2445− ElementKind::Behind => {
2446− css.push_str(FLEX_COLUMN);
2447− css.push_str("position:absolute;inset:0;z-index:-1;");
2448− }
2159+ ElementKind::Column | ElementKind::El => css.push_str(FLEX_COLUMN),
2160+ ElementKind::Paragraph => css.push_str("margin:0;"),
2161+ ElementKind::Tag(spec) => css.push_str(spec.css),
24492162 _ => {}
24502163 }
24512164 // Children of @carousel get scroll-snap-align and flex-shrink
2452− if matches!(parent_kind, Some(ElementKind::Carousel)) {
2165+ if parent_kind.is_some_and(|k| k.is_tag("carousel")) {
24532166 css.push_str("scroll-snap-align:start;flex-shrink:0;");
24542167 }
24552168 }
@@ -3633,19 +3346,13 @@ fn vlq_encode(value: i64, out: &mut String) {
36333346 }
36343347}
36353348
3636−/// Check if a specific element kind exists anywhere in the node tree.
3637−fn has_element_kind(nodes: &[Node], target: &ElementKind) -> bool {
3638− for node in nodes {
3639− if let Node::Element(elem) = node {
3640− if elem.kind == *target {
3641− return true;
3642− }
3643− if has_element_kind(&elem.children, target) {
3644− return true;
3645− }
3646− }
3647− }
3648− false
3349+
3350+/// Does the tree contain the table element `name` (e.g. `"main"`)?
3351+fn has_tag(nodes: &[Node], name: &str) -> bool {
3352+ nodes.iter().any(|node| match node {
3353+ Node::Element(elem) => elem.kind.is_tag(name) || has_tag(&elem.children, name),
3354+ _ => false,
3355+ })
36493356}
36503357
36513358/// Collect unique external domains from generated HTML for DNS prefetch hints.