on master help sign in
HTML names for lists: @ul, @ol, @li

The layout and text elements keep their elm-ui names; every other element has its HTML name. Lists were the exception (@list, @list [ordered], @item). The ordered flag is gone.

commit992bdcdffdf20c9da01b9fda02c4cd6e38bc0aaa authoriko <mail@iko.soy> dateSat 26 Sep 2026, 03:39 (2 weeks ago) parenta97bc58
16 files changed+67−115
DESIGN.md+10−10 crates/htmlang-core/src/ast.rs+5−4 crates/htmlang-core/src/codegen.rs−5 crates/htmlang-core/src/parser.rs−16 crates/htmlang-core/src/std.hl+2−2 crates/htmlang-core/src/vocab.rs+1−1 editors/vscode/snippets/htmlang.json+3−3 examples/demo.hl+8−8 examples/docs.hl+4−4 src/bin/htmlang_lsp/completion.rs+1−1 src/bin/htmlang_lsp/docs.rs+4−4 src/upgrade.rs+7−10 tests/snapshots.rs+6−31 tests/snapshots/breadcrumb.hl+3−3 tests/snapshots/list_elements.hl+10−10 tests/snapshots/new_css_properties.hl+3−3
DESIGN.md+10−10
@@ -90,8 +90,8 @@ verbatim too (a `--` line in them is not a comment).
9090
9191## Elements
9292
93−Layout elements set up flexbox; the rest map to the HTML element of the same
94−name. Every container lays its children out in a column, except `@row`.
93+The layout and text elements come from elm-ui; every other element has its
94+HTML name. Every container lays its children out in a column, except `@row`.
9595
9696| Element | Output | Purpose |
9797|---|---|---|
@@ -110,7 +110,7 @@ Semantic containers, all laid out as columns: `@nav`, `@header`, `@footer`,
110110`@details` / `@summary`, `@dialog`, `@figure` / `@figcaption`, `@blockquote` /
111111`@cite`, `@fieldset` / `@legend`, `@noscript`.
112112
113−Content: `@list` / `@item` (`@list [ordered]` for `<ol>`), `@dl` / `@dt` /
113+Content: `@ul` / `@ol` / `@li`, `@dl` / `@dt` /
114114`@dd`, `@table` / `@thead` / `@tbody` / `@tr` / `@th` / `@td`, `@code`, `@pre`,
115115`@hr`, `@mark`, `@kbd`, `@abbr`, `@time`, `@progress`, `@meter`, `@output`,
116116`@canvas`, `@iframe src`, `@video src`, `@audio src`, `@picture` / `@source`,
@@ -120,9 +120,9 @@ Form controls: `@input`, `@button`, `@select` / `@option`, `@textarea`,
120120`@label`, `@datalist`. `@form URL` sets the form's `action`.
121121
122122```
123−@list [ordered]
124− @item First
125− @item Second
123+@ol
124+ @li First
125+ @li Second
126126@form [method=post] /subscribe
127127 @label [for=email] Email
128128 @input [type=email, name=email, id=email, required]
@@ -162,7 +162,7 @@ Use them like elements; your own `@let` with the same name takes precedence.
162162| `@spacer` | Takes up the remaining space in a row or column |
163163| `@tooltip [tip TEXT]` | Text that shows `TEXT` when hovered |
164164| `@carousel` | Horizontally scrolling row that snaps to each child |
165−| `@breadcrumb` | Breadcrumb trail of `@item`s |
165+| `@breadcrumb` | Breadcrumb trail of `@li`s |
166166| `$skeleton`, `$truncate`, `$no-scrollbar` | Attribute bundles: loading placeholder, one-line ellipsis, hidden scrollbars |
167167
168168```
@@ -172,8 +172,8 @@ Use them like elements; your own `@let` with the same name takes precedence.
172172 @spacer
173173 @tooltip [tip Opens in a new tab] Help
174174@breadcrumb
175− @item > @link / Home
176− @item Docs
175+ @li > @link / Home
176+ @li Docs
177177@el [$skeleton, height 20, width fill]
178178```
179179
@@ -483,7 +483,7 @@ removed form with its replacement.
483483| `@og KEY VALUE` | `@meta og:KEY VALUE` |
484484| `@assert`, `@warn`, `@debug`, `@log` | Nothing (the lines are removed) |
485485| `@defer` | Its content, directly |
486−| `@col`, `@p`, `@img`, `@li`, `@btn`, `@ul`, `@divider`, `@opt` | `@el`, `@paragraph`, `@image`, `@item`, `@button`, `@list`, `@hr`, `@option` |
486+| `@col`, `@p`, `@img`, `@li`, `@btn`, `@ul`, `@divider`, `@opt` | `@el`, `@paragraph`, `@image`, `@li`, `@button`, `@ul`, `@hr`, `@option` |
487487| `type email`, `id main` (HTML attributes written as styles) | `type=email`, `id=main` |
488488| `skeleton`, `no-scrollbar`, `gradient A B` | `$skeleton`, `$no-scrollbar`, `background linear-gradient(A, B)` |
489489| `@tooltip TEXT` | `@tooltip [tip TEXT] TEXT` |
crates/htmlang-core/src/ast.rs+5−4
@@ -146,7 +146,7 @@ impl ElementKind {
146146pub enum TagArg {
147147 /// Leading text content (`@section Hello`).
148148 Child,
149− /// Printed as the element's text (`@item First`).
149+ /// Printed as the element's text (`@li First`).
150150 Text,
151151 /// Emitted as this HTML attribute (`@iframe URL` sets `src`).
152152 Attr(&'static str),
@@ -204,8 +204,9 @@ pub static TAGS: &[TagSpec] = &[
204204 TagSpec { name: "blockquote", html: "blockquote", css: "display:flex;flex-direction:column;margin:0;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
205205 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 },
206206 TagSpec { name: "datalist", html: "datalist", wraps_text: true, container: true, ..TagSpec::DEFAULT },
207− TagSpec { name: "list", html: "ul", css: "margin:0;padding-left:0;list-style:none;", container: true, ..TagSpec::DEFAULT },
208− TagSpec { name: "item", html: "li", css: "display:flex;flex-direction:column;", arg: TagArg::Text, wraps_text: true, container: true, ..TagSpec::DEFAULT },
207+ TagSpec { name: "ul", html: "ul", css: "margin:0;padding-left:0;list-style:none;", container: true, ..TagSpec::DEFAULT },
208+ TagSpec { name: "ol", html: "ol", css: "margin:0;padding-left:0;list-style:none;", container: true, ..TagSpec::DEFAULT },
209+ TagSpec { name: "li", html: "li", css: "display:flex;flex-direction:column;", arg: TagArg::Text, wraps_text: true, container: true, ..TagSpec::DEFAULT },
209210 TagSpec { name: "dl", html: "dl", css: "margin:0;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
210211 TagSpec { name: "dt", html: "dt", arg: TagArg::Text, container: true, ..TagSpec::DEFAULT },
211212 TagSpec { name: "dd", html: "dd", css: "margin:0;display:flex;flex-direction:column;", arg: TagArg::Text, wraps_text: true, container: true, ..TagSpec::DEFAULT },
@@ -253,7 +254,7 @@ pub static TAGS: &[TagSpec] = &[
253254];
254255
255256/// Elements that print their argument as their text (`@text Hello`,
256−/// `@item First`).
257+/// `@li First`).
257258pub fn renders_argument_as_text(kind: &ElementKind) -> bool {
258259 *kind == ElementKind::Text || kind.spec().is_some_and(|spec| spec.arg == TagArg::Text)
259260}
crates/htmlang-core/src/codegen.rs−5
@@ -1148,11 +1148,6 @@ fn generate_element(
11481148 ElementKind::Text => "span",
11491149 ElementKind::Paragraph => "p",
11501150 ElementKind::Link => "a",
1151− ElementKind::Tag(spec)
1152− if spec.name == "list" && elem.attrs.iter().any(|a| a.key == "ordered") =>
1153− {
1154− "ol"
1155− }
11561151 ElementKind::Tag(spec) => spec.html,
11571152 _ => "",
11581153 };
crates/htmlang-core/src/parser.rs−16
@@ -1511,9 +1511,7 @@ const REMOVED_SYNTAX: &[(&str, &str)] = &[
15111511 ("@col", "use `@el`"),
15121512 ("@p", "use `@paragraph`"),
15131513 ("@img", "use `@image`"),
1514− ("@li", "use `@item`"),
15151514 ("@btn", "use `@button`"),
1516− ("@ul", "use `@list`"),
15171515 ("@divider", "use `@hr`"),
15181516 ("@opt", "use `@option`"),
15191517];
@@ -2818,20 +2816,6 @@ fn validate_tree(
28182816 });
28192817 }
28202818
2821− // 'ordered' only on @list
2822− if base == "ordered" && !elem.kind.is_tag("list") {
2823− diagnostics.push(Diagnostic {
2824− line: elem.line_num,
2825− column: None,
2826− message: format!(
2827− "'ordered' has no effect on {} (only works on @list)",
2828− element_kind_name(&elem.kind)
2829− ),
2830− severity: Severity::Warning,
2831− source_line: None,
2832− });
2833− }
2834−
28352819 // Media-specific attributes only on @video/@audio
28362820 if matches!(
28372821 base,
crates/htmlang-core/src/std.hl+2−2
@@ -37,10 +37,10 @@
3737 @row [overflow-x auto, scroll-snap-type x mandatory, scrollbar-width none, children:scroll-snap-align start, children:flex-shrink 0]
3838 @children
3939
40−-- Breadcrumb trail: each crumb is an @item.
40+-- Breadcrumb trail: each crumb is an @li.
4141@let breadcrumb
4242 @nav [aria-label=breadcrumb]
43− @list [ordered, display flex, flex-direction row, flex-wrap wrap, gap 8px]
43+ @ol [display flex, flex-direction row, flex-wrap wrap, gap 8px]
4444 @children
4545
4646-- Loading placeholder: [$skeleton]
crates/htmlang-core/src/vocab.rs+1−1
@@ -11,7 +11,7 @@
1111/// HTML attribute).
1212pub const HTMLANG_ATTRIBUTES: &[&str] = &[
1313 "align-bottom", "align-left", "align-right", "align-top", "center-x", "center-y",
14− "col-span", "grid-cols", "grid-rows", "inline", "ordered", "responsive", "row-span",
14+ "col-span", "grid-cols", "grid-rows", "inline", "responsive", "row-span",
1515 "spacing", "wrap",
1616];
1717
editors/vscode/snippets/htmlang.json+3−3
@@ -226,9 +226,9 @@
226226 "prefix": "@breadcrumb",
227227 "body": [
228228 "@breadcrumb",
229− " @item > @link ${1:/} ${2:Home}",
230− " @item > @link ${3:/docs} ${4:Docs}",
231− " @item ${5:Current Page}",
229+ " @li > @link ${1:/} ${2:Home}",
230+ " @li > @link ${3:/docs} ${4:Docs}",
231+ " @li ${5:Current Page}",
232232 " $0"
233233 ],
234234 "description": "Breadcrumb navigation with links"
examples/demo.hl+8−8
@@ -244,15 +244,15 @@
244244 @row [gap $gap, wrap]
245245 @el [width fill, spacing $base, min-width 240]
246246 @text [font-weight bold] Unordered
247− @list [spacing 4, list-style disc, padding-inline-start 20]
248− @item [display list-item] Composable with @each
249− @item [display list-item] Indent to nest children
250− @item [display list-item] Bold list items work too
247+ @ul [spacing 4, list-style disc, padding-inline-start 20]
248+ @li [display list-item] Composable with @each
249+ @li [display list-item] Indent to nest children
250+ @li [display list-item] Bold list items work too
251251 @text [font-weight bold] Ordered
252− @list [ordered, list-style decimal, padding-inline-start 20]
253− @item [display list-item] First
254− @item [display list-item] Second
255− @item [display list-item] Third
252+ @ol [list-style decimal, padding-inline-start 20]
253+ @li [display list-item] First
254+ @li [display list-item] Second
255+ @li [display list-item] Third
256256
257257 @el [width fill, spacing $base, min-width 240]
258258 @text [font-weight bold] Table
examples/docs.hl+4−4
@@ -66,10 +66,10 @@
6666
6767 @text [font-weight bold, font-size 24, color $ink] Next steps
6868
69− @list [spacing 8]
70− @item
69+ @ul [spacing 8]
70+ @li
7171 @link [color $primary] /docs/layout Learn the layout model
72− @item
72+ @li
7373 @link [color $primary] /docs/styling Customize styling
74− @item
74+ @li
7575 @link [color $primary] /docs/functions Extract reusable components
src/bin/htmlang_lsp/completion.rs+1−1
@@ -413,7 +413,7 @@ fn element_specific_attrs(element: &str) -> &'static [&'static str] {
413413 "progress" => &["value", "max"],
414414 "details" => &["open"],
415415 "dialog" => &["open"],
416− "list" => &["ordered", "type", "start"],
416+ "ol" => &["type", "start", "reversed"],
417417 "time" => &["datetime"],
418418 "abbr" => &["title"],
419419 "label" => &["for"],
src/bin/htmlang_lsp/docs.rs+4−4
@@ -70,8 +70,9 @@ pub(crate) const ELEMENTS: &[Doc] = &[
7070 "Layer filling the parent, painted behind its content.",
7171 "@el [padding 40]\n @behind\n @el [background #fef3c7, width fill, height fill]",
7272 ),
73− doc("list", "List (`<ul>`; `[ordered]` for `<ol>`).", "@list [ordered]\n @item First"),
74− doc("item", "List item (`<li>`).", "@item First"),
73+ doc("ul", "Bulleted list.", "@ul\n @li First\n @li Second"),
74+ doc("ol", "Numbered list.", "@ol [list-style decimal]\n @li First"),
75+ doc("li", "List item.", "@li First"),
7576 doc("form", "Form; the argument is its `action`.", "@form [method=post] /subscribe"),
7677 doc("input", "Form input (void element).", "@input [type=email, name=email, required]"),
7778 doc("button", "Button.", "@button [type=submit] Send"),
@@ -127,7 +128,6 @@ pub(crate) const ATTRIBUTES: &[Doc] = &[
127128 doc("col-span", "Columns a grid child spans.", "col-span 2"),
128129 doc("row-span", "Rows a grid child spans.", "row-span 2"),
129130 doc("line-height", "Line height; integers of 2 or more are px.", "line-height 1.5"),
130− doc("ordered", "On `@list`: a numbered list (`<ol>`).", "ordered"),
131131 doc("inline", "On `@image`: embed the file (SVG markup, or other images as base64) in the page.", "inline"),
132132 doc("responsive", "On `@image`: widths for a generated `srcset`.", "responsive 400 800 1200"),
133133];
@@ -141,7 +141,7 @@ pub(crate) const COMPONENTS: &[Doc] = &[
141141 doc("spacer", "Takes up the remaining space in a row or column.", "@spacer"),
142142 doc("tooltip", "Text that shows `tip` when hovered.", "@tooltip [tip More info] Hover me"),
143143 doc("carousel", "Horizontally scrolling row that snaps to each child.", "@carousel [spacing 16]\n @el Slide 1"),
144− doc("breadcrumb", "Breadcrumb trail of `@item`s.", "@breadcrumb\n @item > @link / Home\n @item Docs"),
144+ doc("breadcrumb", "Breadcrumb trail of `@li`s.", "@breadcrumb\n @li > @link / Home\n @li Docs"),
145145];
146146
147147/// Standard-library attribute bundles, used as `[$name]`.
src/upgrade.rs+7−10
@@ -18,9 +18,7 @@ const ELEMENT_ALIASES: &[(&str, &str)] = &[
1818 ("col", "el"),
1919 ("p", "paragraph"),
2020 ("img", "image"),
21− ("li", "item"),
2221 ("btn", "button"),
23− ("ul", "list"),
2422 ("divider", "hr"),
2523 ("opt", "option"),
2624];
@@ -518,26 +516,25 @@ fn rewrite_block(
518516 return Some(Block { lines: out, end });
519517 }
520518
521− // @breadcrumb: each crumb is now an explicit @item
519+ // @breadcrumb: each crumb is now an explicit @li
522520 if (trimmed == "@breadcrumb" || trimmed.starts_with("@breadcrumb "))
523521 && !user_defined.contains(&"breadcrumb")
524522 {
525523 let child_indent = body.iter().find(|l| !l.trim().is_empty()).map(|l| indent_of(l));
524+ let is_item = |t: &str| t == "@li" || t.starts_with("@li ") || t.starts_with("@li[");
526525 if body.iter().all(|l| {
527− l.trim().is_empty()
528− || Some(indent_of(l)) != child_indent
529− || l.trim().starts_with("@item")
526+ l.trim().is_empty() || Some(indent_of(l)) != child_indent || is_item(l.trim())
530527 }) {
531528 return None;
532529 }
533530 let mut out = vec![line.to_string()];
534531 for l in body {
535532 let t = l.trim();
536− if Some(indent_of(l)) == child_indent && !t.is_empty() && !t.starts_with("@item") {
533+ if Some(indent_of(l)) == child_indent && !t.is_empty() && !is_item(t) {
537534 let wrapped = if t.starts_with('@') {
538− format!("@item > {}", t)
535+ format!("@li > {}", t)
539536 } else {
540− format!("@item {}", t)
537+ format!("@li {}", t)
541538 };
542539 out.push(format!("{}{}", " ".repeat(indent_of(l)), wrapped));
543540 } else {
@@ -1779,7 +1776,7 @@ mod tests {
17791776 assert_eq!(up("@tooltip [tip X] Y"), "@tooltip [tip X] Y");
17801777 assert_eq!(
17811778 up("@breadcrumb\n @link / Home\n Current"),
1782− "@breadcrumb\n @item > @link / Home\n @item Current"
1779+ "@breadcrumb\n @li > @link / Home\n @li Current"
17831780 );
17841781 }
17851782
tests/snapshots.rs+6−31
@@ -712,15 +712,15 @@ fn semantic_aside_renders_aside_tag() {
712712
713713#[test]
714714fn list_renders_ul_by_default() {
715− let output = compile("@page T\n@list\n @item Hello");
715+ let output = compile("@page T\n@ul\n @li Hello");
716716 assert!(output.contains("<ul"));
717717 assert!(output.contains("<li"));
718718 assert!(output.contains("Hello"));
719719}
720720
721721#[test]
722−fn list_renders_ol_with_ordered() {
723− let output = compile("@page T\n@list [ordered]\n @item First\n @item Second");
722+fn ol_renders_ol() {
723+ let output = compile("@page T\n@ol\n @li First\n @li Second");
724724 assert!(output.contains("<ol"));
725725 assert!(output.contains("<li"));
726726}
@@ -728,7 +728,6 @@ fn list_renders_ol_with_ordered() {
728728#[test]
729729fn removed_aliases_point_to_upgrade() {
730730 for (src, replacement) in [
731− ("@li Works", "@item"),
732731 ("@divider", "@hr"),
733732 ("@el > @col x", "@el"),
734733 ("Say {@img a.png}", "@image"),
@@ -1093,30 +1092,6 @@ fn comparison_ends_with() {
10931092// Feature tests: element-specific attribute validation
10941093// ---------------------------------------------------------------------------
10951094
1096−#[test]
1097−fn warning_ordered_on_non_list() {
1098− let diags = parse_diagnostics("@el [ordered]");
1099− assert!(
1100− diags
1101− .iter()
1102− .any(|d| d.message.contains("ordered") && d.message.contains("@list")),
1103− "expected ordered on non-list warning, got: {:?}",
1104− diags
1105− );
1106−}
1107−
1108−#[test]
1109−fn no_warning_ordered_on_list() {
1110− let diags = parse_diagnostics("@list [ordered]\n @item x");
1111− assert!(
1112− !diags
1113− .iter()
1114− .any(|d| d.message.contains("ordered") && d.message.contains("no effect")),
1115− "should not warn about ordered on @list, got: {:?}",
1116− diags
1117− );
1118−}
1119−
11201095#[test]
11211096fn warning_controls_on_non_media() {
11221097 let diags = parse_diagnostics("@el [controls]");
@@ -1671,7 +1646,7 @@ fn css_caret_color() {
16711646
16721647#[test]
16731648fn css_list_style() {
1674− let output = compile("@page T\n@list [list-style disc]");
1649+ let output = compile("@page T\n@ul [list-style disc]");
16751650 assert!(output.contains("list-style:disc"));
16761651}
16771652
@@ -3433,7 +3408,7 @@ fn search_element() {
34333408
34343409#[test]
34353410fn breadcrumb_generates_nav_ol() {
3436− let output = compile("@breadcrumb\n @item > @text Home\n @item About");
3411+ let output = compile("@breadcrumb\n @li > @text Home\n @li About");
34373412 assert!(output.contains("aria-label=\"breadcrumb\""), "nav: {}", output);
34383413 assert!(output.contains("<ol class="), "ol: {}", output);
34393414 assert!(output.contains("<li class="), "li: {}", output);
@@ -4961,7 +4936,7 @@ fn children_prefix_styles_direct_children() {
49614936
49624937#[test]
49634938fn standard_library_components_compile_cleanly() {
4964− let src = "@badge [background red] 3\n@tag v2\n@chip Rust\n@avatar\n AB\n@spacer\n@tooltip [tip More] Hover\n@carousel\n @el A\n@breadcrumb\n @item Home\n@el [$skeleton]\n@el [$no-scrollbar]";
4939+ let src = "@badge [background red] 3\n@tag v2\n@chip Rust\n@avatar\n AB\n@spacer\n@tooltip [tip More] Hover\n@carousel\n @el A\n@breadcrumb\n @li Home\n@el [$skeleton]\n@el [$no-scrollbar]";
49654940 let diags = parse_diagnostics(src);
49664941 assert!(diags.is_empty(), "standard library produced diagnostics: {:?}", diags);
49674942 let output = compile(src);
tests/snapshots/breadcrumb.hl+3−3
@@ -1,4 +1,4 @@
11@breadcrumb [spacing 10]
2− @item > @link / Home
3− @item > @link /docs Docs
4− @item > @text Current Page
2+ @li > @link / Home
3+ @li > @link /docs Docs
4+ @li > @text Current Page
tests/snapshots/list_elements.hl+10−10
@@ -1,15 +1,15 @@
11@page List Elements
22
3−@list [spacing 8]
4− @item First item
5− @item Second item
6− @item Third item
3+@ul [spacing 8]
4+ @li First item
5+ @li Second item
6+ @li Third item
77
8−@list [ordered, spacing 4]
9− @item Step one
10− @item Step two
11− @item Step three
8+@ol [spacing 4]
9+ @li Step one
10+ @li Step two
11+ @li Step three
1212
13−@list
14− @item [padding 10, background #f0f0f0, border-radius 4]
13+@ul
14+ @li [padding 10, background #f0f0f0, border-radius 4]
1515 @text [font-weight bold] Styled item
tests/snapshots/new_css_properties.hl+3−3
@@ -10,9 +10,9 @@
1010
1111@input [type=text, caret-color red]
1212
13−@list [list-style disc]
14− @item One
15− @item Two
13+@ul [list-style disc]
14+ @li One
15+ @li Two
1616
1717@table [border-collapse collapse, border-spacing 0]
1818 @tr