on master help sign in
Describe HTML elements with a table instead of enum variants
5725c25iko
crates/htmlang-core/src/ast.rs+196−122
@@ -66,143 +66,217 @@ pub struct Element {
66 pub line_num: usize,
66 pub line_num: usize,
67}
67}
68
68
69+
70+#[derive(Debug, Clone)]
71+pub struct Attribute {
72+ pub key: String,
73+ pub value: Option<String>,
74+}
75+
69#[derive(Debug, Clone, PartialEq)]
76#[derive(Debug, Clone, PartialEq)]
70pub enum ElementKind {
77pub enum ElementKind {
78+ // Layout and text: the core of the language
71 Row,
79 Row,
72 Column,
80 Column,
73 El,
81 El,
74 Text,
82 Text,
75 Paragraph,
83 Paragraph,
76− Image,
77 Link,
84 Link,
78− Children,
85+ Image,
79− // Form elements
86+ // Elements generated in a special way
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
160 Script,
87 Script,
161− Noscript,
162− Address,
163− Search,
164 Breadcrumb,
88 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),
165}
95}
166
96
167−#[derive(Debug, Clone)]
97+impl ElementKind {
168−pub struct Attribute {
98+ /// The element's name in htmlang source, without the `@`.
169− pub key: String,
99+ pub fn name(&self) -> &str {
170− pub value: Option<String>,
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+ }
171}
157}
172
158
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+
173/// Elements that print their argument as their text (`@text Hello`,
278/// Elements that print their argument as their text (`@text Hello`,
174/// `@item First`).
279/// `@item First`).
175pub fn renders_argument_as_text(kind: &ElementKind) -> bool {
280pub fn renders_argument_as_text(kind: &ElementKind) -> bool {
176− matches!(
281+ *kind == ElementKind::Text || kind.spec().is_some_and(|spec| spec.arg == TagArg::Text)
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− )
208}
282}
crates/htmlang-core/src/codegen.rs+55−348
@@ -733,7 +733,7 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
733 };
733 };
734
734
735 // Check if document has @main for skip-to-content link
735 // 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");
737
737
738 let mut body = String::new();
738 let mut body = String::new();
739
739
@@ -1307,32 +1307,10 @@ fn generate_node(
1307 Node::Text(segments) => {
1307 Node::Text(segments) => {
1308 let needs_wrap = matches!(
1308 let needs_wrap = matches!(
1309 parent_kind,
1309 parent_kind,
1310− Some(ElementKind::Row)
1310+ Some(ElementKind::Row | ElementKind::Column | ElementKind::El)
1311− | Some(ElementKind::Column)
1311+ ) || parent_kind
1312− | Some(ElementKind::El)
1312+ .and_then(ElementKind::spec)
1313− | Some(ElementKind::Nav)
1313+ .is_some_and(|spec| spec.wraps_text);
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− );
1336 if needs_wrap {
1314 if needs_wrap {
1337 out.push_str(&ctx.indent());
1315 out.push_str(&ctx.indent());
1338 out.push_str("<span>");
1316 out.push_str("<span>");
@@ -1352,6 +1330,23 @@ fn generate_node(
1352}
1330}
1353
1331
1354
1332
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+
1355fn emit_html_passthrough_attrs(out: &mut String, attrs: &[Attribute]) {
1350fn emit_html_passthrough_attrs(out: &mut String, attrs: &[Attribute]) {
1356 for attr in attrs {
1351 for attr in attrs {
1357 let key = attr.key.as_str();
1352 let key = attr.key.as_str();
@@ -1399,7 +1394,7 @@ fn has_overlay_children(elem: &Element) -> bool {
1399 elem.children.iter().any(|child| {
1394 elem.children.iter().any(|child| {
1400 matches!(
1395 matches!(
1401 child,
1396 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")
1403 )
1398 )
1404 })
1399 })
1405}
1400}
@@ -1412,14 +1407,7 @@ fn generate_element(
1412 ctx: &mut GenContext,
1407 ctx: &mut GenContext,
1413) {
1408) {
1414 // Self-closing elements
1409 // Self-closing elements
1415− if matches!(
1416− elem.kind,
1417− ElementKind::Image
1410+ if elem.kind == ElementKind::Image || elem.kind.spec().is_some_and(|spec| spec.void) {
1418− | ElementKind::Input
1419− | ElementKind::HorizontalRule
1420− | ElementKind::Source
1421− | ElementKind::Spacer
1422− ) {
1423 generate_self_closing(elem, parent_kind, out, styles, ctx);
1411 generate_self_closing(elem, parent_kind, out, styles, ctx);
1424 return;
1412 return;
1425 }
1413 }
@@ -1525,189 +1513,24 @@ fn generate_element(
1525 }
1513 }
1526
1514
1527 let tag = match &elem.kind {
1515 let tag = match &elem.kind {
1528− ElementKind::Row
1529− | ElementKind::Column
1516+ ElementKind::Row | ElementKind::Column | ElementKind::El => "div",
1530− | ElementKind::El
1531− | ElementKind::Grid
1532− | ElementKind::Stack
1533− | ElementKind::InFront
1534− | ElementKind::Behind => "div",
1535 ElementKind::Text => "span",
1517 ElementKind::Text => "span",
1536 ElementKind::Paragraph => "p",
1518 ElementKind::Paragraph => "p",
1537 ElementKind::Link => "a",
1519 ElementKind::Link => "a",
1538− ElementKind::Button => "button",
1520+ ElementKind::Tag(spec)
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") {
1521+ if spec.name == "list" && elem.attrs.iter().any(|a| a.key == "ordered") =>
1522+ {
1554− "ol"
1523+ "ol"
1555− } else {
1556− "ul"
1557− }
1558− }
1524+ }
1559− ElementKind::ListItem => "li",
1525+ ElementKind::Tag(spec) => spec.html,
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",
1699 _ => "",
1526 _ => "",
1700 };
1527 };
1528+ let kind_label = elem.kind.name();
1701
1529
1702 // Track interactive elements for focus-visible CSS
1530 // Track interactive elements for focus-visible CSS
1703− if matches!(
1531+ if elem.kind == ElementKind::Link
1704− elem.kind,
1532+ || matches!(kind_label, "button" | "input" | "select" | "textarea")
1705− ElementKind::Link
1706− | ElementKind::Button
1707− | ElementKind::Input
1708− | ElementKind::Select
1709− | ElementKind::Textarea
1710− ) {
1533+ {
1711 ctx.has_interactive = true;
1534 ctx.has_interactive = true;
1712 }
1535 }
1713
1536
@@ -1734,7 +1557,7 @@ fn generate_element(
1734 out.push_str(tag);
1557 out.push_str(tag);
1735
1558
1736 // @main gets id="hl-main" for skip-to-content link (unless user set an id)
1559 // @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() {
1738 out.push_str(" id=\"hl-main\"");
1561 out.push_str(" id=\"hl-main\"");
1739 }
1562 }
1740
1563
@@ -1758,35 +1581,10 @@ fn generate_element(
1758 }
1581 }
1759 }
1582 }
1760
1583
1761− // Video/Audio src
1584+ emit_argument_attr(out, elem);
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− }
1787
1585
1788 // Tooltip title
1586 // Tooltip title
1789− if elem.kind == ElementKind::Tooltip
1587+ if elem.kind.is_tag("tooltip")
1790 && let Some(text) = &elem.argument
1588 && let Some(text) = &elem.argument
1791 {
1589 {
1792 out.push_str(" title=\"");
1590 out.push_str(" title=\"");
@@ -1841,16 +1639,8 @@ fn generate_element(
1841
1639
1842 // Children
1640 // Children
1843 ctx.depth += 1;
1641 ctx.depth += 1;
1844− let is_paragraph = matches!(
1845− elem.kind,
1846− ElementKind::Paragraph
1642+ let is_paragraph = elem.kind == ElementKind::Paragraph
1847− | ElementKind::H1
1643+ || elem.kind.spec().is_some_and(|spec| spec.inline);
1848− | ElementKind::H2
1849− | ElementKind::H3
1850− | ElementKind::H4
1851− | ElementKind::H5
1852− | ElementKind::H6
1853− );
1854 for (i, child) in elem.children.iter().enumerate() {
1644 for (i, child) in elem.children.iter().enumerate() {
1855 generate_node(child, Some(&elem.kind), out, styles, ctx);
1645 generate_node(child, Some(&elem.kind), out, styles, ctx);
1856 if is_paragraph && i < elem.children.len() - 1 {
1646 if is_paragraph && i < elem.children.len() - 1 {
@@ -1876,13 +1666,10 @@ fn generate_self_closing(
1876 let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles, false);
1666 let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles, false);
1877 let (id, user_class) = extract_id_class(&elem.attrs);
1667 let (id, user_class) = extract_id_class(&elem.attrs);
1878
1668
1879− let (tag, kind_label) = match elem.kind {
1669+ let (tag, kind_label) = match &elem.kind {
1880 ElementKind::Image => ("img", "image"),
1670 ElementKind::Image => ("img", "image"),
1881− ElementKind::Input => ("input", "input"),
1671+ ElementKind::Tag(spec) => (spec.html, spec.name),
1882− ElementKind::HorizontalRule => ("hr", "hr"),
1883− ElementKind::Source => ("source", "source"),
1884− ElementKind::Spacer => ("div", "spacer"),
1885− _ => unreachable!(),
1672+ _ => unreachable!("not a void element: {:?}", elem.kind),
1886 };
1673 };
1887
1674
1888 if ctx.dev && elem.line_num > 0 {
1675 if ctx.dev && elem.line_num > 0 {
@@ -1895,6 +1682,7 @@ fn generate_self_closing(
1895 out.push_str(&ctx.indent());
1682 out.push_str(&ctx.indent());
1896 out.push('<');
1683 out.push('<');
1897 out.push_str(tag);
1684 out.push_str(tag);
1685+ emit_argument_attr(out, elem);
1898
1686
1899 // Image src (with optional non-SVG base64 inlining)
1687 // Image src (with optional non-SVG base64 inlining)
1900 if elem.kind == ElementKind::Image {
1688 if elem.kind == ElementKind::Image {
@@ -2347,14 +2135,7 @@ fn is_prefixed_attr(key: &str) -> bool {
2347/// semantic wrapper that behaves like a column.
2135/// semantic wrapper that behaves like a column.
2348const FLEX_COLUMN: &str = "display:flex;flex-direction:column;";
2136const FLEX_COLUMN: &str = "display:flex;flex-direction:column;";
2349
2137
2350−/// Font stack shared by `@pre`, `@code`, `@kbd`.
2351−const MONOSPACE_STACK: &str = "font-family:ui-monospace,monospace;";
2352
2138
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;";
2358
2139
2359fn attrs_to_css(
2140fn attrs_to_css(
2360 attrs: &[Attribute],
2141 attrs: &[Attribute],
@@ -2375,81 +2156,13 @@ fn attrs_to_css(
2375 }
2156 }
2376 match kind {
2157 match kind {
2377 ElementKind::Row => css.push_str("display:flex;flex-direction:row;"),
2158 ElementKind::Row => css.push_str("display:flex;flex-direction:row;"),
2378− ElementKind::Column
2159+ ElementKind::Column | ElementKind::El => css.push_str(FLEX_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
2160+ ElementKind::Paragraph => css.push_str("margin:0;"),
2393− | ElementKind::H1
2161+ ElementKind::Tag(spec) => css.push_str(spec.css),
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− }
2449 _ => {}
2162 _ => {}
2450 }
2163 }
2451 // Children of @carousel get scroll-snap-align and flex-shrink
2164 // 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")) {
2453 css.push_str("scroll-snap-align:start;flex-shrink:0;");
2166 css.push_str("scroll-snap-align:start;flex-shrink:0;");
2454 }
2167 }
2455 }
2168 }
@@ -3633,19 +3346,13 @@ fn vlq_encode(value: i64, out: &mut String) {
3633 }
3346 }
3634}
3347}
3635
3348
3636−/// Check if a specific element kind exists anywhere in the node tree.
3349+
3637−fn has_element_kind(nodes: &[Node], target: &ElementKind) -> bool {
3350+/// Does the tree contain the table element `name` (e.g. `"main"`)?
3638− for node in nodes {
3351+fn has_tag(nodes: &[Node], name: &str) -> bool {
3639− if let Node::Element(elem) = node {
3352+ nodes.iter().any(|node| match node {
3640− if elem.kind == *target {
3353+ Node::Element(elem) => elem.kind.is_tag(name) || has_tag(&elem.children, name),
3641− return true;
3642− }
3643− if has_element_kind(&elem.children, target) {
3644− return true;
3645− }
3646− }
3647− }
3648− false
3354+ _ => false,
3355+ })
3649}
3356}
3650
3357
3651/// Collect unique external domains from generated HTML for DNS prefetch hints.
3358/// 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(
2809fn argument_is_special(kind: &ElementKind) -> bool {
2809fn argument_is_special(kind: &ElementKind) -> bool {
2810 matches!(
2810 matches!(
2811 kind,
2811 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
2812+ ElementKind::Link | ElementKind::Image | ElementKind::Script | ElementKind::Slot(_)
2820− | ElementKind::Slot(_)
2813+ ) || kind
2821− )
2814+ .spec()
2815+ .is_some_and(|spec| matches!(spec.arg, TagArg::Attr(_)))
2822}
2816}
2823
2817
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−
2910const KNOWN_DIRECTIVES: &[&str] = &[
2818const KNOWN_DIRECTIVES: &[&str] = &[
2911 "page",
2819 "page",
2912 "let",
2820 "let",
@@ -2946,113 +2854,26 @@ const KNOWN_DIRECTIVES: &[&str] = &[
2946];
2854];
2947
2855
2948fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
2856fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
2949− match s {
2857+ if let Some(kind) = ElementKind::from_name(s) {
2950− "row" => Ok(ElementKind::Row),
2858+ return Ok(kind);
2951− "column" => Ok(ElementKind::Column),
2859+ }
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)) {
2860+ if let Some(hint) = removed_syntax_hint(&format!("@{}", s)) {
3035− return Err(ParseError {
2861+ return Err(ParseError {
3036− line: line_num,
2862+ line: line_num,
3037− message: hint,
2863+ 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− };
2864+ });
3050− Err(ParseError {
3051− line: line_num,
3052− message: msg,
3053− })
3054− }
3055 }
2865 }
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+ })
3056}
2877}
3057
2878
3058/// Directives and element names removed when the language was simplified,
2879/// Directives and element names removed when the language was simplified,
@@ -4273,131 +4094,15 @@ const CONTAINER_ONLY_ATTRS: &[&str] = &[
4273 "container-type",
4094 "container-type",
4274];
4095];
4275
4096
4276−fn element_kind_name(kind: &ElementKind) -> &'static str {
4097+fn element_kind_name(kind: &ElementKind) -> String {
4277− match kind {
4098+ format!("@{}", kind.name())
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− }
4362}
4099}
4363
4100
4364fn is_container(kind: &ElementKind) -> bool {
4101fn is_container(kind: &ElementKind) -> bool {
4365 matches!(
4102 matches!(
4366 kind,
4103 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
4104+ ElementKind::Row | ElementKind::Column | ElementKind::El | ElementKind::Breadcrumb
4398− | ElementKind::InFront
4105+ ) || kind.spec().is_some_and(|spec| spec.container)
4399− | ElementKind::Behind
4400− )
4401}
4106}
4402
4107
4403fn validate_tree(
4108fn validate_tree(
@@ -4453,7 +4158,7 @@ fn validate_tree(
4453
4158
4454 // Form-specific: placeholder only on @input/@textarea
4159 // Form-specific: placeholder only on @input/@textarea
4455 if base == "placeholder"
4160 if base == "placeholder"
4456− && !matches!(elem.kind, ElementKind::Input | ElementKind::Textarea)
4161+ && !(elem.kind.is_tag("input") || elem.kind.is_tag("textarea"))
4457 {
4162 {
4458 diagnostics.push(Diagnostic {
4163 diagnostics.push(Diagnostic {
4459 line: elem.line_num,
4164 line: elem.line_num,
@@ -4468,7 +4173,7 @@ fn validate_tree(
4468 }
4173 }
4469
4174
4470 // 'for' only on @label
4175 // 'for' only on @label
4471− if base == "for" && !matches!(elem.kind, ElementKind::Label) {
4176+ if base == "for" && !elem.kind.is_tag("label") {
4472 diagnostics.push(Diagnostic {
4177 diagnostics.push(Diagnostic {
4473 line: elem.line_num,
4178 line: elem.line_num,
4474 column: None,
4179 column: None,
@@ -4482,7 +4187,7 @@ fn validate_tree(
4482 }
4187 }
4483
4188
4484 // 'rows'/'cols' only on @textarea
4189 // '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")
4486 {
4191 {
4487 diagnostics.push(Diagnostic {
4192 diagnostics.push(Diagnostic {
4488 line: elem.line_num,
4193 line: elem.line_num,
@@ -4498,7 +4203,7 @@ fn validate_tree(
4498 }
4203 }
4499
4204
4500 // 'ordered' only on @list
4205 // 'ordered' only on @list
4501− if base == "ordered" && !matches!(elem.kind, ElementKind::List) {
4206+ if base == "ordered" && !elem.kind.is_tag("list") {
4502 diagnostics.push(Diagnostic {
4207 diagnostics.push(Diagnostic {
4503 line: elem.line_num,
4208 line: elem.line_num,
4504 column: None,
4209 column: None,
@@ -4521,7 +4226,7 @@ fn validate_tree(
4521 | "playsinline"
4226 | "playsinline"
4522 | "poster"
4227 | "poster"
4523 | "preload"
4228 | "preload"
4524− ) && !matches!(elem.kind, ElementKind::Video | ElementKind::Audio)
4229+ ) && !matches!(elem.kind.name(), "video" | "audio")
4525 {
4230 {
4526 diagnostics.push(Diagnostic {
4231 diagnostics.push(Diagnostic {
4527 line: elem.line_num,
4232 line: elem.line_num,
@@ -4547,7 +4252,7 @@ fn validate_tree(
4547 source_line: None,
4252 source_line: None,
4548 });
4253 });
4549 }
4254 }
4550− if matches!(elem.kind, ElementKind::Input)
4255+ if elem.kind.is_tag("input")
4551 && !elem.attrs.iter().any(|a| a.key == "type")
4256 && !elem.attrs.iter().any(|a| a.key == "type")
4552 {
4257 {
4553 diagnostics.push(Diagnostic {
4258 diagnostics.push(Diagnostic {
@@ -4610,17 +4315,14 @@ fn validate_tree(
4610 }
4315 }
4611
4316
4612 // @form inputs should have associated @label
4317 // @form inputs should have associated @label
4613− if matches!(
4318+ if matches!(elem.kind.name(), "input" | "select" | "textarea") {
4614− elem.kind,
4615− ElementKind::Input | ElementKind::Select | ElementKind::Textarea
4616− ) {
4617 let has_id = elem.attrs.iter().any(|a| a.key == "id");
4319 let has_id = elem.attrs.iter().any(|a| a.key == "id");
4618 let has_aria_label = elem
4320 let has_aria_label = elem
4619 .attrs
4321 .attrs
4620 .iter()
4322 .iter()
4621 .any(|a| a.key == "aria-label" || a.key == "aria-labelledby");
4323 .any(|a| a.key == "aria-label" || a.key == "aria-labelledby");
4622 let has_title = elem.attrs.iter().any(|a| a.key == "title");
4324 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"));
4624 if !has_id && !has_aria_label && !has_title && !in_label {
4326 if !has_id && !has_aria_label && !has_title && !in_label {
4625 diagnostics.push(Diagnostic {
4327 diagnostics.push(Diagnostic {
4626 line: elem.line_num,
4328 line: elem.line_num,
@@ -4636,7 +4338,7 @@ fn validate_tree(
4636 }
4338 }
4637
4339
4638 // @iframe should have title attribute
4340 // @iframe should have title attribute
4639− if matches!(elem.kind, ElementKind::Iframe)
4341+ if elem.kind.is_tag("iframe")
4640 && !elem.attrs.iter().any(|a| a.key == "title")
4342 && !elem.attrs.iter().any(|a| a.key == "title")
4641 {
4343 {
4642 diagnostics.push(Diagnostic {
4344 diagnostics.push(Diagnostic {
@@ -4649,7 +4351,7 @@ fn validate_tree(
4649 }
4351 }
4650
4352
4651 // @button should have accessible text
4353 // @button should have accessible text
4652− if matches!(elem.kind, ElementKind::Button) {
4354+ if elem.kind.is_tag("button") {
4653 let has_text = elem.argument.is_some() || !elem.children.is_empty();
4355 let has_text = elem.argument.is_some() || !elem.children.is_empty();
4654 let has_aria = elem.attrs.iter().any(|a| a.key == "aria-label");
4356 let has_aria = elem.attrs.iter().any(|a| a.key == "aria-label");
4655 if !has_text && !has_aria {
4357 if !has_text && !has_aria {
@@ -4665,7 +4367,7 @@ fn validate_tree(
4665 }
4367 }
4666
4368
4667 // @video should have captions or aria-label
4369 // @video should have captions or aria-label
4668− if matches!(elem.kind, ElementKind::Video) {
4370+ if elem.kind.is_tag("video") {
4669 let has_aria = elem
4371 let has_aria = elem
4670 .attrs
4372 .attrs
4671 .iter()
4373 .iter()
@@ -4673,7 +4375,7 @@ fn validate_tree(
4673 let has_track = elem
4375 let has_track = elem
4674 .children
4376 .children
4675 .iter()
4377 .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")));
4677 if !has_aria && !has_track {
4379 if !has_aria && !has_track {
4678 diagnostics.push(Diagnostic {
4380 diagnostics.push(Diagnostic {
4679 line: elem.line_num,
4381 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:
394 }
394 }
395
395
396 // @input without type
396 // @input without type
397− if elem.kind == htmlang::ast::ElementKind::Input
397+ if elem.kind.is_tag("input")
398 && !elem.attrs.iter().any(|a| a.key == "type")
398 && !elem.attrs.iter().any(|a| a.key == "type")
399 {
399 {
400 warnings.push(format!(
400 warnings.push(format!(
@@ -424,7 +424,7 @@ fn lint_nodes(nodes: &[htmlang::ast::Node], path: &str, depth: usize, warnings:
424 }
424 }
425
425
426 // @button without type
426 // @button without type
427− if elem.kind == htmlang::ast::ElementKind::Button
427+ if elem.kind.is_tag("button")
428 && !elem.attrs.iter().any(|a| a.key == "type")
428 && !elem.attrs.iter().any(|a| a.key == "type")
429 {
429 {
430 warnings.push(format!(
430 warnings.push(format!(