on master help sign in
Describe HTML elements with a table instead of enum variants

ElementKind keeps the 12 kinds with real behavior (row, column, el, text, paragraph, link, image, script, breadcrumb, fragment, children, slot); every other element is a TagSpec row in ast::TAGS giving its HTML tag, default CSS, what its argument means, and whether it wraps text, takes layout attributes, is void, or joins children like a paragraph. The parallel per-kind matches in the parser and code generator collapse into table lookups. Output is byte-identical.

commit5725c253aa255c283ad88c542a155193b73fa5fe authoriko <mail@iko.soy> dateFri 25 Sep 2026, 20:19 (2 weeks ago) parentb07ee65
4 files changed+292−809
crates/htmlang-core/src/ast.rs+196−122 crates/htmlang-core/src/codegen.rs+55−348 crates/htmlang-core/src/parser.rs+39−337 src/main.rs+2−2
crates/htmlang-core/src/ast.rs+196−122
@@ -66,143 +66,217 @@ pub struct Element {
6666 pub line_num: usize,
6767}
6868
69+
70+#[derive(Debug, Clone)]
71+pub struct Attribute {
72+ pub key: String,
73+ pub value: Option<String>,
74+}
75+
6976#[derive(Debug, Clone, PartialEq)]
7077pub enum ElementKind {
78+ // Layout and text: the core of the language
7179 Row,
7280 Column,
7381 El,
7482 Text,
7583 Paragraph,
76− Image,
7784 Link,
78− Children,
79− // Form elements
80− Input,
81− Button,
82− Select,
83− Textarea,
84− Option,
85− Label,
86− // Slots
87− Slot(String),
88− // Semantic elements
89− Nav,
90− Header,
91− Footer,
92− Main,
93− Section,
94− Article,
95− Aside,
96− // List elements
97− List,
98− ListItem,
99− // Table elements
100− Table,
101− TableHead,
102− TableBody,
103− TableRow,
104− TableCell,
105− TableHeaderCell,
106− // Media elements
107− Video,
108− Audio,
109− // Additional semantic elements
110− Form,
111− Details,
112− Summary,
113− Blockquote,
114− Cite,
115− Code,
116− Pre,
117− HorizontalRule,
118− Figure,
119− FigCaption,
120− Progress,
121− Meter,
122− Fragment,
123− Dialog,
124− DefinitionList,
125− DefinitionTerm,
126− DefinitionDescription,
127− Fieldset,
128− Legend,
129− Picture,
130− Source,
131− Time,
132− Mark,
133− Kbd,
134− Abbr,
135− Datalist,
136− Iframe,
137− Output,
138− Canvas,
139− // Heading elements
140− H1,
141− H2,
142− H3,
143− H4,
144− H5,
145− H6,
146− // New convenience elements
147− Grid,
148− Stack,
149− Spacer,
150− Badge,
151− Tooltip,
152− Avatar,
153− Carousel,
154− Chip,
155− Tag,
156− // Overlay positioning (elm-ui inspired)
157− InFront,
158− Behind,
159− // HTML5 elements
85+ Image,
86+ // Elements generated in a special way
16087 Script,
161− Noscript,
162− Address,
163− Search,
16488 Breadcrumb,
89+ Fragment,
90+ // Function bodies: placeholders for the caller's content
91+ Children,
92+ Slot(String),
93+ /// Any other element, described by its row in [`TAGS`].
94+ Tag(&'static TagSpec),
16595}
16696
167−#[derive(Debug, Clone)]
168−pub struct Attribute {
169− pub key: String,
170− pub value: Option<String>,
97+impl ElementKind {
98+ /// The element's name in htmlang source, without the `@`.
99+ pub fn name(&self) -> &str {
100+ match self {
101+ ElementKind::Row => "row",
102+ ElementKind::Column => "column",
103+ ElementKind::El => "el",
104+ ElementKind::Text => "text",
105+ ElementKind::Paragraph => "paragraph",
106+ ElementKind::Link => "link",
107+ ElementKind::Image => "image",
108+ ElementKind::Script => "script",
109+ ElementKind::Breadcrumb => "breadcrumb",
110+ ElementKind::Fragment => "fragment",
111+ ElementKind::Children => "children",
112+ ElementKind::Slot(_) => "slot",
113+ ElementKind::Tag(spec) => spec.name,
114+ }
115+ }
116+
117+ pub fn spec(&self) -> Option<&'static TagSpec> {
118+ match self {
119+ ElementKind::Tag(spec) => Some(spec),
120+ _ => None,
121+ }
122+ }
123+
124+ /// Is this the table element named `name` (e.g. `"main"`)?
125+ pub fn is_tag(&self, name: &str) -> bool {
126+ self.spec().is_some_and(|spec| spec.name == name)
127+ }
128+
129+ /// Look up an element by its source name (without the `@`).
130+ pub fn from_name(name: &str) -> Option<ElementKind> {
131+ Some(match name {
132+ "row" => ElementKind::Row,
133+ "column" => ElementKind::Column,
134+ "el" => ElementKind::El,
135+ "text" => ElementKind::Text,
136+ "paragraph" => ElementKind::Paragraph,
137+ "link" => ElementKind::Link,
138+ "image" => ElementKind::Image,
139+ "script" => ElementKind::Script,
140+ "breadcrumb" => ElementKind::Breadcrumb,
141+ "fragment" => ElementKind::Fragment,
142+ "children" => ElementKind::Children,
143+ "slot" => ElementKind::Slot(String::new()),
144+ _ => ElementKind::Tag(TAGS.iter().find(|spec| spec.name == name)?),
145+ })
146+ }
147+
148+ /// Every element name, for suggestions and completions.
149+ pub fn all_names() -> impl Iterator<Item = &'static str> {
150+ [
151+ "row", "column", "el", "text", "paragraph", "link", "image", "script", "breadcrumb",
152+ "fragment", "children", "slot",
153+ ]
154+ .into_iter()
155+ .chain(TAGS.iter().map(|spec| spec.name))
156+ }
171157}
172158
159+/// What an element does with the text after its name and attributes.
160+#[derive(Debug, Clone, Copy, PartialEq)]
161+pub enum TagArg {
162+ /// Leading text content (`@section Hello`).
163+ Child,
164+ /// Printed as the element's text (`@item First`).
165+ Text,
166+ /// Emitted as this HTML attribute (`@iframe URL` sets `src`).
167+ Attr(&'static str),
168+}
169+
170+/// How an element compiles: the data behind [`ElementKind::Tag`].
171+#[derive(Debug, PartialEq)]
172+pub struct TagSpec {
173+ /// Name in htmlang source, without the `@`.
174+ pub name: &'static str,
175+ /// HTML element emitted.
176+ pub html: &'static str,
177+ /// Default CSS (flex column, margin reset, ...).
178+ pub css: &'static str,
179+ pub arg: TagArg,
180+ /// Bare text children are wrapped in `<span>` (flex containers).
181+ pub wraps_text: bool,
182+ /// Accepts layout attributes for its children (`spacing`, ...).
183+ pub container: bool,
184+ /// Void element: no children, no closing tag (`<hr>`, `<input>`).
185+ pub void: bool,
186+ /// Children are joined with spaces, like a paragraph (headings).
187+ pub inline: bool,
188+}
189+
190+impl TagSpec {
191+ const DEFAULT: TagSpec = TagSpec {
192+ name: "",
193+ html: "",
194+ css: "",
195+ arg: TagArg::Child,
196+ wraps_text: false,
197+ container: false,
198+ void: false,
199+ inline: false,
200+ };
201+}
202+
203+/// Every element besides the core ones in [`ElementKind`].
204+pub static TAGS: &[TagSpec] = &[
205+ TagSpec { name: "nav", html: "nav", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
206+ TagSpec { name: "header", html: "header", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
207+ TagSpec { name: "footer", html: "footer", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
208+ TagSpec { name: "main", html: "main", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
209+ TagSpec { name: "section", html: "section", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
210+ TagSpec { name: "article", html: "article", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
211+ TagSpec { name: "aside", html: "aside", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
212+ TagSpec { name: "details", html: "details", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
213+ TagSpec { name: "dialog", html: "dialog", css: "display:flex;flex-direction:column;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
214+ TagSpec { name: "search", html: "search", wraps_text: true, container: true, ..TagSpec::DEFAULT },
215+ TagSpec { name: "address", html: "address", wraps_text: true, container: true, ..TagSpec::DEFAULT },
216+ TagSpec { name: "noscript", html: "noscript", wraps_text: true, container: true, ..TagSpec::DEFAULT },
217+ TagSpec { name: "form", html: "form", css: "display:flex;flex-direction:column;", arg: TagArg::Attr("action"), wraps_text: true, container: true, ..TagSpec::DEFAULT },
218+ TagSpec { name: "figure", html: "figure", css: "display:flex;flex-direction:column;margin:0;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
219+ TagSpec { name: "blockquote", html: "blockquote", css: "display:flex;flex-direction:column;margin:0;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
220+ TagSpec { name: "fieldset", html: "fieldset", css: "display:flex;flex-direction:column;border:1px solid currentColor;padding:8px;margin:0;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
221+ TagSpec { name: "datalist", html: "datalist", wraps_text: true, container: true, ..TagSpec::DEFAULT },
222+ TagSpec { name: "list", html: "ul", css: "margin:0;padding-left:0;list-style:none;", container: true, ..TagSpec::DEFAULT },
223+ TagSpec { name: "item", html: "li", css: "display:flex;flex-direction:column;", arg: TagArg::Text, wraps_text: true, container: true, ..TagSpec::DEFAULT },
224+ TagSpec { name: "dl", html: "dl", css: "margin:0;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
225+ TagSpec { name: "dt", html: "dt", arg: TagArg::Text, container: true, ..TagSpec::DEFAULT },
226+ TagSpec { name: "dd", html: "dd", css: "margin:0;display:flex;flex-direction:column;", arg: TagArg::Text, wraps_text: true, container: true, ..TagSpec::DEFAULT },
227+ TagSpec { name: "table", html: "table", ..TagSpec::DEFAULT },
228+ TagSpec { name: "thead", html: "thead", ..TagSpec::DEFAULT },
229+ TagSpec { name: "tbody", html: "tbody", ..TagSpec::DEFAULT },
230+ TagSpec { name: "tr", html: "tr", ..TagSpec::DEFAULT },
231+ TagSpec { name: "select", html: "select", ..TagSpec::DEFAULT },
232+ TagSpec { name: "picture", html: "picture", ..TagSpec::DEFAULT },
233+ TagSpec { name: "progress", html: "progress", ..TagSpec::DEFAULT },
234+ TagSpec { name: "meter", html: "meter", ..TagSpec::DEFAULT },
235+ TagSpec { name: "output", html: "output", ..TagSpec::DEFAULT },
236+ TagSpec { name: "canvas", html: "canvas", container: true, ..TagSpec::DEFAULT },
237+ TagSpec { name: "td", html: "td", arg: TagArg::Text, ..TagSpec::DEFAULT },
238+ TagSpec { name: "th", html: "th", arg: TagArg::Text, ..TagSpec::DEFAULT },
239+ TagSpec { name: "button", html: "button", arg: TagArg::Text, ..TagSpec::DEFAULT },
240+ TagSpec { name: "label", html: "label", arg: TagArg::Text, ..TagSpec::DEFAULT },
241+ TagSpec { name: "option", html: "option", arg: TagArg::Text, ..TagSpec::DEFAULT },
242+ TagSpec { name: "textarea", html: "textarea", arg: TagArg::Text, ..TagSpec::DEFAULT },
243+ TagSpec { name: "summary", html: "summary", arg: TagArg::Text, ..TagSpec::DEFAULT },
244+ TagSpec { name: "cite", html: "cite", arg: TagArg::Text, ..TagSpec::DEFAULT },
245+ TagSpec { name: "figcaption", html: "figcaption", arg: TagArg::Text, ..TagSpec::DEFAULT },
246+ TagSpec { name: "legend", html: "legend", arg: TagArg::Text, ..TagSpec::DEFAULT },
247+ TagSpec { name: "time", html: "time", arg: TagArg::Text, ..TagSpec::DEFAULT },
248+ TagSpec { name: "mark", html: "mark", arg: TagArg::Text, ..TagSpec::DEFAULT },
249+ TagSpec { name: "abbr", html: "abbr", arg: TagArg::Text, ..TagSpec::DEFAULT },
250+ TagSpec { name: "code", html: "code", css: "font-family:ui-monospace,monospace;", arg: TagArg::Text, ..TagSpec::DEFAULT },
251+ TagSpec { name: "kbd", html: "kbd", css: "font-family:ui-monospace,monospace;", arg: TagArg::Text, ..TagSpec::DEFAULT },
252+ TagSpec { name: "pre", html: "pre", css: "margin:0;white-space:pre;font-family:ui-monospace,monospace;", ..TagSpec::DEFAULT },
253+ TagSpec { name: "h1", html: "h1", css: "margin:0;", arg: TagArg::Text, inline: true, ..TagSpec::DEFAULT },
254+ TagSpec { name: "h2", html: "h2", css: "margin:0;", arg: TagArg::Text, inline: true, ..TagSpec::DEFAULT },
255+ TagSpec { name: "h3", html: "h3", css: "margin:0;", arg: TagArg::Text, inline: true, ..TagSpec::DEFAULT },
256+ TagSpec { name: "h4", html: "h4", css: "margin:0;", arg: TagArg::Text, inline: true, ..TagSpec::DEFAULT },
257+ TagSpec { name: "h5", html: "h5", css: "margin:0;", arg: TagArg::Text, inline: true, ..TagSpec::DEFAULT },
258+ TagSpec { name: "h6", html: "h6", css: "margin:0;", arg: TagArg::Text, inline: true, ..TagSpec::DEFAULT },
259+ TagSpec { name: "input", html: "input", void: true, ..TagSpec::DEFAULT },
260+ TagSpec { name: "hr", html: "hr", void: true, ..TagSpec::DEFAULT },
261+ TagSpec { name: "source", html: "source", arg: TagArg::Attr("src"), void: true, ..TagSpec::DEFAULT },
262+ TagSpec { name: "video", html: "video", arg: TagArg::Attr("src"), ..TagSpec::DEFAULT },
263+ TagSpec { name: "audio", html: "audio", arg: TagArg::Attr("src"), ..TagSpec::DEFAULT },
264+ TagSpec { name: "iframe", html: "iframe", arg: TagArg::Attr("src"), container: true, ..TagSpec::DEFAULT },
265+ TagSpec { name: "grid", html: "div", css: "display:grid;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
266+ TagSpec { name: "stack", html: "div", css: "position:relative;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
267+ TagSpec { name: "in-front", html: "div", css: "display:flex;flex-direction:column;position:absolute;inset:0;", container: true, ..TagSpec::DEFAULT },
268+ TagSpec { name: "behind", html: "div", css: "display:flex;flex-direction:column;position:absolute;inset:0;z-index:-1;", container: true, ..TagSpec::DEFAULT },
269+ TagSpec { name: "spacer", html: "div", css: "flex:1;", void: true, ..TagSpec::DEFAULT },
270+ TagSpec { name: "badge", html: "span", css: "display:inline-flex;align-items:center;padding:2px 8px;font-size:0.75rem;font-weight:600;justify-content:center;border-radius:9999px;line-height:1;", arg: TagArg::Text, ..TagSpec::DEFAULT },
271+ TagSpec { name: "tag", html: "span", css: "display:inline-flex;align-items:center;padding:2px 8px;font-size:0.75rem;font-weight:600;border-radius:4px;", arg: TagArg::Text, ..TagSpec::DEFAULT },
272+ TagSpec { name: "chip", html: "span", css: "display:inline-flex;align-items:center;gap:4px;padding:4px 12px;border-radius:9999px;font-size:0.875rem;border:1px solid currentColor;", arg: TagArg::Text, ..TagSpec::DEFAULT },
273+ TagSpec { name: "tooltip", html: "span", css: "position:relative;cursor:help;", arg: TagArg::Text, ..TagSpec::DEFAULT },
274+ TagSpec { name: "avatar", html: "div", css: "display:inline-flex;align-items:center;justify-content:center;border-radius:9999px;overflow:hidden;flex-shrink:0;", ..TagSpec::DEFAULT },
275+ TagSpec { name: "carousel", html: "div", css: "display:flex;flex-direction:row;overflow-x:auto;scroll-snap-type:x mandatory;-webkit-overflow-scrolling:touch;scrollbar-width:none;", container: true, ..TagSpec::DEFAULT },
276+];
277+
173278/// Elements that print their argument as their text (`@text Hello`,
174279/// `@item First`).
175280pub fn renders_argument_as_text(kind: &ElementKind) -> bool {
176− matches!(
177− kind,
178− ElementKind::Text
179− | ElementKind::Button
180− | ElementKind::Label
181− | ElementKind::Option
182− | ElementKind::Textarea
183− | ElementKind::ListItem
184− | ElementKind::TableCell
185− | ElementKind::TableHeaderCell
186− | ElementKind::Summary
187− | ElementKind::Cite
188− | ElementKind::Code
189− | ElementKind::FigCaption
190− | ElementKind::Legend
191− | ElementKind::DefinitionTerm
192− | ElementKind::Mark
193− | ElementKind::Kbd
194− | ElementKind::Abbr
195− | ElementKind::Time
196− | ElementKind::DefinitionDescription
197− | ElementKind::Badge
198− | ElementKind::Tooltip
199− | ElementKind::Chip
200− | ElementKind::Tag
201− | ElementKind::H1
202− | ElementKind::H2
203− | ElementKind::H3
204− | ElementKind::H4
205− | ElementKind::H5
206− | ElementKind::H6
207− )
281+ *kind == ElementKind::Text || kind.spec().is_some_and(|spec| spec.arg == TagArg::Text)
208282}
crates/htmlang-core/src/codegen.rs+55−348
@@ -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.
crates/htmlang-core/src/parser.rs+39−337
@@ -2809,104 +2809,12 @@ fn parse_single_element(
28092809fn argument_is_special(kind: &ElementKind) -> bool {
28102810 matches!(
28112811 kind,
2812− ElementKind::Link
2813− | ElementKind::Image
2814− | ElementKind::Video
2815− | ElementKind::Audio
2816− | ElementKind::Source
2817− | ElementKind::Form
2818− | ElementKind::Iframe
2819− | ElementKind::Script
2820− | ElementKind::Slot(_)
2821− )
2812+ ElementKind::Link | ElementKind::Image | ElementKind::Script | ElementKind::Slot(_)
2813+ ) || kind
2814+ .spec()
2815+ .is_some_and(|spec| matches!(spec.arg, TagArg::Attr(_)))
28222816}
28232817
2824−const KNOWN_ELEMENTS: &[&str] = &[
2825− "row",
2826− "column",
2827− "el",
2828− "text",
2829− "paragraph",
2830− "image",
2831− "link",
2832− "children",
2833− "input",
2834− "button",
2835− "select",
2836− "textarea",
2837− "option",
2838− "label",
2839− "slot",
2840− "nav",
2841− "header",
2842− "footer",
2843− "main",
2844− "section",
2845− "article",
2846− "aside",
2847− "list",
2848− "item",
2849− "table",
2850− "thead",
2851− "tbody",
2852− "tr",
2853− "td",
2854− "th",
2855− "video",
2856− "audio",
2857− "form",
2858− "details",
2859− "summary",
2860− "blockquote",
2861− "cite",
2862− "code",
2863− "pre",
2864− "hr",
2865− "figure",
2866− "figcaption",
2867− "progress",
2868− "meter",
2869− "fragment",
2870− "dialog",
2871− "dl",
2872− "dt",
2873− "dd",
2874− "fieldset",
2875− "legend",
2876− "picture",
2877− "source",
2878− "time",
2879− "mark",
2880− "kbd",
2881− "abbr",
2882− "datalist",
2883− "iframe",
2884− "output",
2885− "canvas",
2886− "h1",
2887− "h2",
2888− "h3",
2889− "h4",
2890− "h5",
2891− "h6",
2892− "grid",
2893− "stack",
2894− "spacer",
2895− "badge",
2896− "tooltip",
2897− "avatar",
2898− "carousel",
2899− "chip",
2900− "tag",
2901− "script",
2902− "noscript",
2903− "address",
2904− "search",
2905− "breadcrumb",
2906− "in-front",
2907− "behind",
2908−];
2909−
29102818const KNOWN_DIRECTIVES: &[&str] = &[
29112819 "page",
29122820 "let",
@@ -2946,113 +2854,26 @@ const KNOWN_DIRECTIVES: &[&str] = &[
29462854];
29472855
29482856fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
2949− match s {
2950− "row" => Ok(ElementKind::Row),
2951− "column" => Ok(ElementKind::Column),
2952− "el" => Ok(ElementKind::El),
2953− "text" => Ok(ElementKind::Text),
2954− "paragraph" => Ok(ElementKind::Paragraph),
2955− "image" => Ok(ElementKind::Image),
2956− "link" => Ok(ElementKind::Link),
2957− "children" => Ok(ElementKind::Children),
2958− "input" => Ok(ElementKind::Input),
2959− "button" => Ok(ElementKind::Button),
2960− "select" => Ok(ElementKind::Select),
2961− "textarea" => Ok(ElementKind::Textarea),
2962− "option" => Ok(ElementKind::Option),
2963− "label" => Ok(ElementKind::Label),
2964− "slot" => Ok(ElementKind::Slot(String::new())), // slot name filled in by parse_single_element
2965− "nav" => Ok(ElementKind::Nav),
2966− "header" => Ok(ElementKind::Header),
2967− "footer" => Ok(ElementKind::Footer),
2968− "main" => Ok(ElementKind::Main),
2969− "section" => Ok(ElementKind::Section),
2970− "article" => Ok(ElementKind::Article),
2971− "aside" => Ok(ElementKind::Aside),
2972− "list" => Ok(ElementKind::List),
2973− "item" => Ok(ElementKind::ListItem),
2974− "table" => Ok(ElementKind::Table),
2975− "thead" => Ok(ElementKind::TableHead),
2976− "tbody" => Ok(ElementKind::TableBody),
2977− "tr" => Ok(ElementKind::TableRow),
2978− "td" => Ok(ElementKind::TableCell),
2979− "th" => Ok(ElementKind::TableHeaderCell),
2980− "video" => Ok(ElementKind::Video),
2981− "audio" => Ok(ElementKind::Audio),
2982− "form" => Ok(ElementKind::Form),
2983− "details" => Ok(ElementKind::Details),
2984− "summary" => Ok(ElementKind::Summary),
2985− "blockquote" => Ok(ElementKind::Blockquote),
2986− "cite" => Ok(ElementKind::Cite),
2987− "code" => Ok(ElementKind::Code),
2988− "pre" => Ok(ElementKind::Pre),
2989− "hr" => Ok(ElementKind::HorizontalRule),
2990− "figure" => Ok(ElementKind::Figure),
2991− "figcaption" => Ok(ElementKind::FigCaption),
2992− "progress" => Ok(ElementKind::Progress),
2993− "meter" => Ok(ElementKind::Meter),
2994− "fragment" => Ok(ElementKind::Fragment),
2995− "dialog" => Ok(ElementKind::Dialog),
2996− "dl" => Ok(ElementKind::DefinitionList),
2997− "dt" => Ok(ElementKind::DefinitionTerm),
2998− "dd" => Ok(ElementKind::DefinitionDescription),
2999− "fieldset" => Ok(ElementKind::Fieldset),
3000− "legend" => Ok(ElementKind::Legend),
3001− "picture" => Ok(ElementKind::Picture),
3002− "source" => Ok(ElementKind::Source),
3003− "time" => Ok(ElementKind::Time),
3004− "mark" => Ok(ElementKind::Mark),
3005− "kbd" => Ok(ElementKind::Kbd),
3006− "abbr" => Ok(ElementKind::Abbr),
3007− "h1" => Ok(ElementKind::H1),
3008− "h2" => Ok(ElementKind::H2),
3009− "h3" => Ok(ElementKind::H3),
3010− "h4" => Ok(ElementKind::H4),
3011− "h5" => Ok(ElementKind::H5),
3012− "h6" => Ok(ElementKind::H6),
3013− "datalist" => Ok(ElementKind::Datalist),
3014− "iframe" => Ok(ElementKind::Iframe),
3015− "output" => Ok(ElementKind::Output),
3016− "canvas" => Ok(ElementKind::Canvas),
3017− "grid" => Ok(ElementKind::Grid),
3018− "stack" => Ok(ElementKind::Stack),
3019− "spacer" => Ok(ElementKind::Spacer),
3020− "badge" => Ok(ElementKind::Badge),
3021− "tooltip" => Ok(ElementKind::Tooltip),
3022− "avatar" => Ok(ElementKind::Avatar),
3023− "carousel" => Ok(ElementKind::Carousel),
3024− "chip" => Ok(ElementKind::Chip),
3025− "tag" => Ok(ElementKind::Tag),
3026− "script" => Ok(ElementKind::Script),
3027− "noscript" => Ok(ElementKind::Noscript),
3028− "address" => Ok(ElementKind::Address),
3029− "search" => Ok(ElementKind::Search),
3030− "breadcrumb" => Ok(ElementKind::Breadcrumb),
3031− "in-front" => Ok(ElementKind::InFront),
3032− "behind" => Ok(ElementKind::Behind),
3033− _ => {
3034− if let Some(hint) = removed_syntax_hint(&format!("@{}", s)) {
3035− return Err(ParseError {
3036− line: line_num,
3037− message: hint,
3038− });
3039− }
3040− let all_known: Vec<&str> = KNOWN_ELEMENTS
3041− .iter()
3042− .chain(KNOWN_DIRECTIVES.iter())
3043− .copied()
3044− .collect();
3045− let suggestion = suggest_closest(s, &all_known);
3046− let msg = match suggestion {
3047− Some(closest) => format!("unknown element @{}, did you mean @{}?", s, closest),
3048− None => format!("unknown element @{}", s),
3049− };
3050− Err(ParseError {
3051− line: line_num,
3052− message: msg,
3053− })
3054− }
2857+ if let Some(kind) = ElementKind::from_name(s) {
2858+ return Ok(kind);
2859+ }
2860+ if let Some(hint) = removed_syntax_hint(&format!("@{}", s)) {
2861+ return Err(ParseError {
2862+ line: line_num,
2863+ message: hint,
2864+ });
30552865 }
2866+ let all_known: Vec<&str> = ElementKind::all_names()
2867+ .chain(KNOWN_DIRECTIVES.iter().copied())
2868+ .collect();
2869+ let message = match suggest_closest(s, &all_known) {
2870+ Some(closest) => format!("unknown element @{}, did you mean @{}?", s, closest),
2871+ None => format!("unknown element @{}", s),
2872+ };
2873+ Err(ParseError {
2874+ line: line_num,
2875+ message,
2876+ })
30562877}
30572878
30582879/// Directives and element names removed when the language was simplified,
@@ -4273,131 +4094,15 @@ const CONTAINER_ONLY_ATTRS: &[&str] = &[
42734094 "container-type",
42744095];
42754096
4276−fn element_kind_name(kind: &ElementKind) -> &'static str {
4277− match kind {
4278− ElementKind::Row => "@row",
4279− ElementKind::Column => "@column",
4280− ElementKind::El => "@el",
4281− ElementKind::Text => "@text",
4282− ElementKind::Paragraph => "@paragraph",
4283− ElementKind::Image => "@image",
4284− ElementKind::Link => "@link",
4285− ElementKind::Children => "@children",
4286− ElementKind::Input => "@input",
4287− ElementKind::Button => "@button",
4288− ElementKind::Select => "@select",
4289− ElementKind::Textarea => "@textarea",
4290− ElementKind::Option => "@option",
4291− ElementKind::Label => "@label",
4292− ElementKind::Slot(_) => "@slot",
4293− ElementKind::Nav => "@nav",
4294− ElementKind::Header => "@header",
4295− ElementKind::Footer => "@footer",
4296− ElementKind::Main => "@main",
4297− ElementKind::Section => "@section",
4298− ElementKind::Article => "@article",
4299− ElementKind::Aside => "@aside",
4300− ElementKind::List => "@list",
4301− ElementKind::ListItem => "@item",
4302− ElementKind::Table => "@table",
4303− ElementKind::TableHead => "@thead",
4304− ElementKind::TableBody => "@tbody",
4305− ElementKind::TableRow => "@tr",
4306− ElementKind::TableCell => "@td",
4307− ElementKind::TableHeaderCell => "@th",
4308− ElementKind::Video => "@video",
4309− ElementKind::Audio => "@audio",
4310− ElementKind::Form => "@form",
4311− ElementKind::Details => "@details",
4312− ElementKind::Summary => "@summary",
4313− ElementKind::Blockquote => "@blockquote",
4314− ElementKind::Cite => "@cite",
4315− ElementKind::Code => "@code",
4316− ElementKind::Pre => "@pre",
4317− ElementKind::HorizontalRule => "@hr",
4318− ElementKind::Figure => "@figure",
4319− ElementKind::FigCaption => "@figcaption",
4320− ElementKind::Progress => "@progress",
4321− ElementKind::Meter => "@meter",
4322− ElementKind::Fragment => "@fragment",
4323− ElementKind::Dialog => "@dialog",
4324− ElementKind::DefinitionList => "@dl",
4325− ElementKind::DefinitionTerm => "@dt",
4326− ElementKind::DefinitionDescription => "@dd",
4327− ElementKind::Fieldset => "@fieldset",
4328− ElementKind::Legend => "@legend",
4329− ElementKind::Picture => "@picture",
4330− ElementKind::Source => "@source",
4331− ElementKind::Time => "@time",
4332− ElementKind::Mark => "@mark",
4333− ElementKind::Kbd => "@kbd",
4334− ElementKind::Abbr => "@abbr",
4335− ElementKind::H1 => "@h1",
4336− ElementKind::H2 => "@h2",
4337− ElementKind::H3 => "@h3",
4338− ElementKind::H4 => "@h4",
4339− ElementKind::H5 => "@h5",
4340− ElementKind::H6 => "@h6",
4341− ElementKind::Datalist => "@datalist",
4342− ElementKind::Iframe => "@iframe",
4343− ElementKind::Output => "@output",
4344− ElementKind::Canvas => "@canvas",
4345− ElementKind::Grid => "@grid",
4346− ElementKind::Stack => "@stack",
4347− ElementKind::Spacer => "@spacer",
4348− ElementKind::Badge => "@badge",
4349− ElementKind::Tooltip => "@tooltip",
4350− ElementKind::Avatar => "@avatar",
4351− ElementKind::Carousel => "@carousel",
4352− ElementKind::Chip => "@chip",
4353− ElementKind::Tag => "@tag",
4354− ElementKind::Script => "@script",
4355− ElementKind::Noscript => "@noscript",
4356− ElementKind::Address => "@address",
4357− ElementKind::Search => "@search",
4358− ElementKind::Breadcrumb => "@breadcrumb",
4359− ElementKind::InFront => "@in-front",
4360− ElementKind::Behind => "@behind",
4361− }
4097+fn element_kind_name(kind: &ElementKind) -> String {
4098+ format!("@{}", kind.name())
43624099}
43634100
43644101fn is_container(kind: &ElementKind) -> bool {
43654102 matches!(
43664103 kind,
4367− ElementKind::Row
4368− | ElementKind::Column
4369− | ElementKind::El
4370− | ElementKind::Nav
4371− | ElementKind::Header
4372− | ElementKind::Footer
4373− | ElementKind::Main
4374− | ElementKind::Section
4375− | ElementKind::Article
4376− | ElementKind::Aside
4377− | ElementKind::List
4378− | ElementKind::ListItem
4379− | ElementKind::Form
4380− | ElementKind::Details
4381− | ElementKind::Figure
4382− | ElementKind::Blockquote
4383− | ElementKind::Dialog
4384− | ElementKind::DefinitionList
4385− | ElementKind::DefinitionTerm
4386− | ElementKind::DefinitionDescription
4387− | ElementKind::Fieldset
4388− | ElementKind::Datalist
4389− | ElementKind::Iframe
4390− | ElementKind::Canvas
4391− | ElementKind::Grid
4392− | ElementKind::Stack
4393− | ElementKind::Carousel
4394− | ElementKind::Noscript
4395− | ElementKind::Address
4396− | ElementKind::Search
4397− | ElementKind::Breadcrumb
4398− | ElementKind::InFront
4399− | ElementKind::Behind
4400− )
4104+ ElementKind::Row | ElementKind::Column | ElementKind::El | ElementKind::Breadcrumb
4105+ ) || kind.spec().is_some_and(|spec| spec.container)
44014106}
44024107
44034108fn validate_tree(
@@ -4453,7 +4158,7 @@ fn validate_tree(
44534158
44544159 // Form-specific: placeholder only on @input/@textarea
44554160 if base == "placeholder"
4456− && !matches!(elem.kind, ElementKind::Input | ElementKind::Textarea)
4161+ && !(elem.kind.is_tag("input") || elem.kind.is_tag("textarea"))
44574162 {
44584163 diagnostics.push(Diagnostic {
44594164 line: elem.line_num,
@@ -4468,7 +4173,7 @@ fn validate_tree(
44684173 }
44694174
44704175 // 'for' only on @label
4471− if base == "for" && !matches!(elem.kind, ElementKind::Label) {
4176+ if base == "for" && !elem.kind.is_tag("label") {
44724177 diagnostics.push(Diagnostic {
44734178 line: elem.line_num,
44744179 column: None,
@@ -4482,7 +4187,7 @@ fn validate_tree(
44824187 }
44834188
44844189 // 'rows'/'cols' only on @textarea
4485− if (base == "rows" || base == "cols") && !matches!(elem.kind, ElementKind::Textarea)
4190+ if (base == "rows" || base == "cols") && !elem.kind.is_tag("textarea")
44864191 {
44874192 diagnostics.push(Diagnostic {
44884193 line: elem.line_num,
@@ -4498,7 +4203,7 @@ fn validate_tree(
44984203 }
44994204
45004205 // 'ordered' only on @list
4501− if base == "ordered" && !matches!(elem.kind, ElementKind::List) {
4206+ if base == "ordered" && !elem.kind.is_tag("list") {
45024207 diagnostics.push(Diagnostic {
45034208 line: elem.line_num,
45044209 column: None,
@@ -4521,7 +4226,7 @@ fn validate_tree(
45214226 | "playsinline"
45224227 | "poster"
45234228 | "preload"
4524− ) && !matches!(elem.kind, ElementKind::Video | ElementKind::Audio)
4229+ ) && !matches!(elem.kind.name(), "video" | "audio")
45254230 {
45264231 diagnostics.push(Diagnostic {
45274232 line: elem.line_num,
@@ -4547,7 +4252,7 @@ fn validate_tree(
45474252 source_line: None,
45484253 });
45494254 }
4550− if matches!(elem.kind, ElementKind::Input)
4255+ if elem.kind.is_tag("input")
45514256 && !elem.attrs.iter().any(|a| a.key == "type")
45524257 {
45534258 diagnostics.push(Diagnostic {
@@ -4610,17 +4315,14 @@ fn validate_tree(
46104315 }
46114316
46124317 // @form inputs should have associated @label
4613− if matches!(
4614− elem.kind,
4615− ElementKind::Input | ElementKind::Select | ElementKind::Textarea
4616− ) {
4318+ if matches!(elem.kind.name(), "input" | "select" | "textarea") {
46174319 let has_id = elem.attrs.iter().any(|a| a.key == "id");
46184320 let has_aria_label = elem
46194321 .attrs
46204322 .iter()
46214323 .any(|a| a.key == "aria-label" || a.key == "aria-labelledby");
46224324 let has_title = elem.attrs.iter().any(|a| a.key == "title");
4623− let in_label = matches!(parent_kind, Some(ElementKind::Label));
4325+ let in_label = parent_kind.is_some_and(|k| k.is_tag("label"));
46244326 if !has_id && !has_aria_label && !has_title && !in_label {
46254327 diagnostics.push(Diagnostic {
46264328 line: elem.line_num,
@@ -4636,7 +4338,7 @@ fn validate_tree(
46364338 }
46374339
46384340 // @iframe should have title attribute
4639− if matches!(elem.kind, ElementKind::Iframe)
4341+ if elem.kind.is_tag("iframe")
46404342 && !elem.attrs.iter().any(|a| a.key == "title")
46414343 {
46424344 diagnostics.push(Diagnostic {
@@ -4649,7 +4351,7 @@ fn validate_tree(
46494351 }
46504352
46514353 // @button should have accessible text
4652− if matches!(elem.kind, ElementKind::Button) {
4354+ if elem.kind.is_tag("button") {
46534355 let has_text = elem.argument.is_some() || !elem.children.is_empty();
46544356 let has_aria = elem.attrs.iter().any(|a| a.key == "aria-label");
46554357 if !has_text && !has_aria {
@@ -4665,7 +4367,7 @@ fn validate_tree(
46654367 }
46664368
46674369 // @video should have captions or aria-label
4668− if matches!(elem.kind, ElementKind::Video) {
4370+ if elem.kind.is_tag("video") {
46694371 let has_aria = elem
46704372 .attrs
46714373 .iter()
@@ -4673,7 +4375,7 @@ fn validate_tree(
46734375 let has_track = elem
46744376 .children
46754377 .iter()
4676− .any(|c| matches!(c, Node::Element(e) if e.kind == ElementKind::Source));
4378+ .any(|c| matches!(c, Node::Element(e) if e.kind.is_tag("source")));
46774379 if !has_aria && !has_track {
46784380 diagnostics.push(Diagnostic {
46794381 line: elem.line_num,
src/main.rs+2−2
@@ -394,7 +394,7 @@ fn lint_nodes(nodes: &[htmlang::ast::Node], path: &str, depth: usize, warnings:
394394 }
395395
396396 // @input without type
397− if elem.kind == htmlang::ast::ElementKind::Input
397+ if elem.kind.is_tag("input")
398398 && !elem.attrs.iter().any(|a| a.key == "type")
399399 {
400400 warnings.push(format!(
@@ -424,7 +424,7 @@ fn lint_nodes(nodes: &[htmlang::ast::Node], path: &str, depth: usize, warnings:
424424 }
425425
426426 // @button without type
427− if elem.kind == htmlang::ast::ElementKind::Button
427+ if elem.kind.is_tag("button")
428428 && !elem.attrs.iter().any(|a| a.key == "type")
429429 {
430430 warnings.push(format!(