on master help sign in
Move styled components into a standard library written in htmlang

@badge, @tag, @chip, @avatar, @spacer, @tooltip, @carousel and @breadcrumb are now @let functions in std.hl, loaded before every file (a file's own definitions win); skeleton and no-scrollbar are the $skeleton and $no-scrollbar bundles. Their special cases leave the compiler. Two general features make this possible: - a function call's text becomes its children, and attributes that aren't parameters style the function's root element - a children: prefix styles direct children (.x > *) State/media prefixes now live in one table (vocab.rs) shared by parser and code generator. `gradient` is replaced by background linear-gradient(...). htmlang upgrade migrates all of it (@tooltip TEXT gains a tip, @breadcrumb crumbs become @item). Fixes @spacer, which emitted an unclosed <div> that swallowed its following siblings.

commitfde10f42033d0481c93f981b27dcd7fa5098af2b authoriko <mail@iko.soy> dateFri 25 Sep 2026, 20:25 (2 weeks ago) parent5725c25
14 files changed+444−290
crates/htmlang-core/src/ast.rs+2−12 crates/htmlang-core/src/codegen.rs+3−139 crates/htmlang-core/src/parser.rs+110−98 crates/htmlang-core/src/std.hl+50 crates/htmlang-core/src/vocab.rs+87−3 src/upgrade.rs+112−2 tests/snapshots.rs+67−23 tests/snapshots/breadcrumb.hl+3−3 tests/snapshots/breadcrumb.html+1−1 tests/snapshots/css_shorthands.hl+4−4 tests/snapshots/css_shorthands.html+1−1 tests/snapshots/new_elements_4.hl+2−2 tests/snapshots/new_elements_4.html+1−1 tests/snapshots/new_elements_5.html+1−1
crates/htmlang-core/src/ast.rs+2−12
@@ -85,7 +85,6 @@ pub enum ElementKind {
8585 Image,
8686 // Elements generated in a special way
8787 Script,
88− Breadcrumb,
8988 Fragment,
9089 // Function bodies: placeholders for the caller's content
9190 Children,
@@ -106,7 +105,6 @@ impl ElementKind {
106105 ElementKind::Link => "link",
107106 ElementKind::Image => "image",
108107 ElementKind::Script => "script",
109− ElementKind::Breadcrumb => "breadcrumb",
110108 ElementKind::Fragment => "fragment",
111109 ElementKind::Children => "children",
112110 ElementKind::Slot(_) => "slot",
@@ -137,7 +135,6 @@ impl ElementKind {
137135 "link" => ElementKind::Link,
138136 "image" => ElementKind::Image,
139137 "script" => ElementKind::Script,
140− "breadcrumb" => ElementKind::Breadcrumb,
141138 "fragment" => ElementKind::Fragment,
142139 "children" => ElementKind::Children,
143140 "slot" => ElementKind::Slot(String::new()),
@@ -148,8 +145,8 @@ impl ElementKind {
148145 /// Every element name, for suggestions and completions.
149146 pub fn all_names() -> impl Iterator<Item = &'static str> {
150147 [
151− "row", "column", "el", "text", "paragraph", "link", "image", "script", "breadcrumb",
152− "fragment", "children", "slot",
148+ "row", "column", "el", "text", "paragraph", "link", "image", "script", "fragment",
149+ "children", "slot",
153150 ]
154151 .into_iter()
155152 .chain(TAGS.iter().map(|spec| spec.name))
@@ -266,13 +263,6 @@ pub static TAGS: &[TagSpec] = &[
266263 TagSpec { name: "stack", html: "div", css: "position:relative;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
267264 TagSpec { name: "in-front", html: "div", css: "display:flex;flex-direction:column;position:absolute;inset:0;", container: true, ..TagSpec::DEFAULT },
268265 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 },
276266];
277267
278268/// Elements that print their argument as their text (`@text Hello`,
crates/htmlang-core/src/codegen.rs+3−139
@@ -1224,7 +1224,7 @@ fn build_element_css(doc: &Document, styles: &StyleCollector, dev: bool) -> Stri
12241224 }
12251225 }
12261226
1227− // Auto-inject skeleton keyframe if skeleton attribute is used
1227+ // Keyframes for the standard library's `$skeleton` bundle, when used
12281228 if element_css.contains("hl-skeleton") {
12291229 if dev {
12301230 element_css.push_str("@keyframes hl-skeleton {\n 0% { background-position: 200% 0; }\n 100% { background-position: -200% 0; }\n}\n");
@@ -1460,44 +1460,6 @@ fn generate_element(
14601460 out.push_str(ctx.nl());
14611461 return;
14621462 }
1463− // @breadcrumb generates semantic <nav aria-label="breadcrumb"><ol>...</ol></nav>
1464− if elem.kind == ElementKind::Breadcrumb {
1465− let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles, false);
1466− let (id, user_class) = extract_id_class(&elem.attrs);
1467− out.push_str(&ctx.indent());
1468− out.push_str("<nav aria-label=\"breadcrumb\"");
1469− emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1470− if let Some(id) = id {
1471− out.push_str(" id=\"");
1472− out.push_str(&html_escape(&id));
1473− out.push('"');
1474− }
1475− out.push('>');
1476− out.push_str(ctx.nl());
1477− ctx.depth += 1;
1478− out.push_str(&ctx.indent());
1479− out.push_str("<ol>");
1480− out.push_str(ctx.nl());
1481− ctx.depth += 1;
1482− for child in &elem.children {
1483− out.push_str(&ctx.indent());
1484− out.push_str("<li>");
1485− let mut buf = String::new();
1486− generate_node(child, Some(&elem.kind), &mut buf, styles, ctx);
1487− out.push_str(buf.trim());
1488− out.push_str("</li>");
1489− out.push_str(ctx.nl());
1490− }
1491− ctx.depth -= 1;
1492− out.push_str(&ctx.indent());
1493− out.push_str("</ol>");
1494− out.push_str(ctx.nl());
1495− ctx.depth -= 1;
1496− out.push_str(&ctx.indent());
1497− out.push_str("</nav>");
1498− out.push_str(ctx.nl());
1499− return;
1500− }
15011463 if elem.kind == ElementKind::Children {
15021464 return;
15031465 }
@@ -1583,15 +1545,6 @@ fn generate_element(
15831545
15841546 emit_argument_attr(out, elem);
15851547
1586− // Tooltip title
1587− if elem.kind.is_tag("tooltip")
1588− && let Some(text) = &elem.argument
1589− {
1590− out.push_str(" title=\"");
1591− out.push_str(&html_escape(text));
1592− out.push('"');
1593− }
1594−
15951548 // Critical CSS: inline styles directly instead of using a class
15961549 let is_critical = elem.attrs.iter().any(|a| a.key == "critical");
15971550 if is_critical {
@@ -1863,21 +1816,13 @@ fn compute_class(
18631816
18641817 // Collect pseudo-state overrides
18651818 let mut pseudo = Vec::new();
1866− for &(prefix, selector) in PSEUDO_PREFIXES {
1819+ for &(prefix, selector) in crate::vocab::PSEUDO_PREFIXES {
18671820 let css = attrs_to_css(attrs, prefix, kind, parent_kind, has_overlay_children);
18681821 if !css.is_empty() {
18691822 pseudo.push((selector.to_string(), css));
18701823 }
18711824 }
18721825
1873− // Auto-add ::-webkit-scrollbar pseudo for no-scrollbar attribute
1874− if attrs.iter().any(|a| a.key == "no-scrollbar") {
1875− pseudo.push((
1876− "::-webkit-scrollbar".to_string(),
1877− "display:none;".to_string(),
1878− ));
1879− }
1880−
18811826 // Collect nth:EXPR: dynamic pseudo selectors
18821827 let mut nth_prefixes: Vec<String> = Vec::new();
18831828 for attr in attrs {
@@ -2089,48 +2034,6 @@ fn emit_class_attr(out: &mut String, gen_class: Option<&str>, user_class: Option
20892034// ---------------------------------------------------------------------------
20902035
20912036/// (htmlang prefix, CSS selector suffix)
2092−const PSEUDO_PREFIXES: &[(&str, &str)] = &[
2093− ("hover:", ":hover"),
2094− ("active:", ":active"),
2095− ("focus:", ":focus"),
2096− ("focus-visible:", ":focus-visible"),
2097− ("focus-within:", ":focus-within"),
2098− ("disabled:", ":disabled"),
2099− ("checked:", ":checked"),
2100− ("placeholder:", "::placeholder"),
2101− ("first:", ":first-child"),
2102− ("last:", ":last-child"),
2103− ("odd:", ":nth-child(odd)"),
2104− ("even:", ":nth-child(even)"),
2105− ("before:", "::before"),
2106− ("after:", "::after"),
2107− ("selection:", "::selection"),
2108− ("visited:", ":visited"),
2109− ("empty:", ":empty"),
2110− ("target:", ":target"),
2111− ("valid:", ":valid"),
2112− ("invalid:", ":invalid"),
2113−];
2114−const RESPONSIVE_PREFIXES: &[&str] = &["sm:", "md:", "lg:", "xl:", "2xl:"];
2115−const MEDIA_PREFIXES: &[&str] = &[
2116− "dark:",
2117− "print:",
2118− "motion-safe:",
2119− "motion-reduce:",
2120− "landscape:",
2121− "portrait:",
2122−];
2123−const CONTAINER_QUERY_PREFIXES: &[&str] = &["cq-sm:", "cq-md:", "cq-lg:", "cq-xl:", "cq-2xl:"];
2124−
2125−fn is_prefixed_attr(key: &str) -> bool {
2126− PSEUDO_PREFIXES.iter().any(|&(p, _)| key.starts_with(p))
2127− || RESPONSIVE_PREFIXES.iter().any(|p| key.starts_with(p))
2128− || MEDIA_PREFIXES.iter().any(|p| key.starts_with(p))
2129− || CONTAINER_QUERY_PREFIXES.iter().any(|p| key.starts_with(p))
2130− || key.starts_with("nth:")
2131− || key.starts_with("has(")
2132−}
2133−
21342037/// `display:flex;flex-direction:column;` — base layout for `@el` and every
21352038/// semantic wrapper that behaves like a column.
21362039const FLEX_COLUMN: &str = "display:flex;flex-direction:column;";
@@ -2161,16 +2064,12 @@ fn attrs_to_css(
21612064 ElementKind::Tag(spec) => css.push_str(spec.css),
21622065 _ => {}
21632066 }
2164− // Children of @carousel get scroll-snap-align and flex-shrink
2165− if parent_kind.is_some_and(|k| k.is_tag("carousel")) {
2166− css.push_str("scroll-snap-align:start;flex-shrink:0;");
2167− }
21682067 }
21692068
21702069 for attr in attrs {
21712070 // Determine the effective key for this pass
21722071 let effective_key = if state_prefix.is_empty() {
2173− if is_prefixed_attr(&attr.key) {
2072+ if crate::vocab::is_prefixed(&attr.key) {
21742073 continue;
21752074 }
21762075 attr.key.as_str()
@@ -2742,41 +2641,6 @@ fn attrs_to_css(
27422641 push_css(&mut css, "backdrop-filter", &format!("blur({})", css_px(v)));
27432642 }
27442643 }
2745− "no-scrollbar" => {
2746− push_css(&mut css, "scrollbar-width", "none");
2747− push_css(&mut css, "-ms-overflow-style", "none");
2748− }
2749− "skeleton" => {
2750− push_css(
2751− &mut css,
2752− "background",
2753− "linear-gradient(90deg,#e5e7eb 25%,#f3f4f6 50%,#e5e7eb 75%)",
2754− );
2755− push_css(&mut css, "background-size", "200% 100%");
2756− push_css(
2757− &mut css,
2758− "animation",
2759− "hl-skeleton 1.5s ease-in-out infinite",
2760− );
2761− }
2762− "gradient" => {
2763− if let Some(v) = val {
2764− // Parse: "from to [angle]" or "color1 color2 [angle]"
2765− let parts: Vec<&str> = v.split_whitespace().collect();
2766− let bg = if parts.len() >= 3
2767− && (parts[2].ends_with("deg")
2768− || parts[2].ends_with("turn")
2769− || parts[2].ends_with("rad"))
2770− {
2771− format!("linear-gradient({},{},{})", parts[2], parts[0], parts[1])
2772− } else if parts.len() >= 2 {
2773− format!("linear-gradient({},{})", parts[0], parts[1])
2774− } else {
2775− format!("linear-gradient({},transparent)", parts[0])
2776− };
2777− push_css(&mut css, "background", &bg);
2778− }
2779− }
27802644
27812645 // Critical CSS hint — not CSS, handled elsewhere
27822646 "critical" => {}
crates/htmlang-core/src/parser.rs+110−98
@@ -129,6 +129,22 @@ struct Parser {
129129 pos: usize,
130130}
131131
132+/// The standard library (`std.hl`): components and bundles defined in
133+/// htmlang itself and available in every file.
134+const PRELUDE: &str = include_str!("std.hl");
135+
136+fn load_prelude(ctx: &mut ParseContext) {
137+ let mut prelude = Parser {
138+ lines: preprocess(PRELUDE),
139+ pos: 0,
140+ };
141+ let _ = prelude.parse_children(0, ctx);
142+ // Library definitions aren't the file's own: never report them unused.
143+ ctx.fn_lines.clear();
144+ ctx.define_lines.clear();
145+ ctx.let_lines.clear();
146+}
147+
132148pub fn parse(input: &str) -> ParseResult {
133149 parse_with_base(input, None)
134150}
@@ -174,6 +190,7 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
174190 translations: HashMap::new(),
175191 active_locale: None,
176192 };
193+ load_prelude(&mut ctx);
177194 let nodes = parser.parse_children(0, &mut ctx);
178195 validate_tree(&nodes, None, &mut ctx.diagnostics);
179196 check_unused(&mut ctx);
@@ -2506,11 +2523,10 @@ impl Parser {
25062523 let rest = &content[1 + name.len()..];
25072524 let rest = rest.trim_start();
25082525
2509− let args = if rest.starts_with('[') {
2510− let (attrs, _) = parse_attr_brackets_no_validate(rest, line_num, ctx)?;
2511− attrs
2526+ let (args, trailing_text) = if rest.starts_with('[') {
2527+ parse_attr_brackets_no_validate(rest, line_num, ctx)?
25122528 } else {
2513− Vec::new()
2529+ (Vec::new(), rest.to_string())
25142530 };
25152531
25162532 // Clone function definition (releases borrow on ctx). If the function is
@@ -2544,25 +2560,47 @@ impl Parser {
25442560 }
25452561 caller_children.push(child);
25462562 }
2563+ // Text after the call, as in `@badge [color red] New`, is content:
2564+ // it goes first among the caller's children.
2565+ let trailing_text = trailing_text.trim();
2566+ if !trailing_text.is_empty() {
2567+ caller_children.insert(0, Node::Text(parse_text_segments(trailing_text, ctx)));
2568+ }
25472569
25482570 // Save variable state, inject function parameters
25492571 let saved_vars = ctx.variables.clone();
2572+ let mut consumed = vec![false; args.len()];
25502573 for (i, param) in fn_def.params.iter().enumerate() {
2551− let value = args
2552− .iter()
2553− .find(|a| a.key == *param)
2554− .and_then(|a| a.value.clone())
2555− .or_else(|| {
2556− // Positional fallback, unless that argument is named for
2557− // a different parameter.
2558− args.get(i)
2559− .filter(|a| !fn_def.params.contains(&a.key))
2560− .and_then(|a| a.value.clone())
2574+ let named = args.iter().position(|a| a.key == *param);
2575+ // Positional fallback, unless that argument is named for a
2576+ // different parameter.
2577+ let positional = args
2578+ .get(i)
2579+ .filter(|a| !fn_def.params.contains(&a.key) && a.value.is_some())
2580+ .map(|_| i);
2581+ let value = named
2582+ .filter(|&j| args[j].value.is_some())
2583+ .or(positional)
2584+ .and_then(|j| {
2585+ consumed[j] = true;
2586+ args[j].value.clone()
25612587 })
25622588 .or_else(|| fn_def.defaults.get(param).cloned())
25632589 .unwrap_or_default();
2590+ if let Some(j) = named {
2591+ consumed[j] = true;
2592+ }
25642593 ctx.variables.insert(param.clone(), value);
25652594 }
2595+ // Arguments that aren't parameters are attributes for the
2596+ // function's root element, so a function can be styled like an
2597+ // element: `@badge [background red] New`.
2598+ let forwarded: Vec<Attribute> = args
2599+ .iter()
2600+ .zip(&consumed)
2601+ .filter(|&(_, &used)| !used)
2602+ .map(|(a, _)| a.clone())
2603+ .collect();
25662604
25672605 // Normalize body indentation so it parses from indent 0
25682606 let min_indent = fn_def
@@ -2596,6 +2634,32 @@ impl Parser {
25962634 let mut result_nodes =
25972635 replace_children_and_slots(body_nodes, &caller_children, &slot_contents);
25982636
2637+ if !forwarded.is_empty() {
2638+ let mut roots = result_nodes.iter_mut().filter_map(|n| match n {
2639+ Node::Element(e) => Some(e),
2640+ _ => None,
2641+ });
2642+ match (roots.next(), roots.next()) {
2643+ (Some(root), None) => root.attrs.extend(forwarded),
2644+ _ => ctx.diagnostics.push(Diagnostic {
2645+ line: line_num,
2646+ column: None,
2647+ message: format!(
2648+ "attributes {} on @{} are not parameters, and its body has no single \
2649+ root element to receive them",
2650+ forwarded
2651+ .iter()
2652+ .map(|a| format!("'{}'", a.key))
2653+ .collect::<Vec<_>>()
2654+ .join(", "),
2655+ name
2656+ ),
2657+ severity: Severity::Warning,
2658+ source_line: Some(content.to_string()),
2659+ }),
2660+ }
2661+ }
2662+
25992663 // If this is a @component, wrap output in a scoped container
26002664 let scope_key = format!("__component_scope_{}", name);
26012665 if let Some(scope_class) = ctx.variables.get(&scope_key).cloned() {
@@ -2911,6 +2975,18 @@ const REMOVED_SYNTAX: &[(&str, &str)] = &[
29112975 ("@opt", "use `@option`"),
29122976];
29132977
2978+/// Attributes that became standard-library bundles or plain CSS.
2979+fn removed_attribute_hint(name: &str) -> Option<&'static str> {
2980+ match name {
2981+ "skeleton" => Some("use the `$skeleton` bundle"),
2982+ "no-scrollbar" => Some("use the `$no-scrollbar` bundle"),
2983+ "gradient" => Some("use `background linear-gradient(...)`"),
2984+ "animate" => Some("use `animation`"),
2985+ "inset-area" => Some("use `position-area`"),
2986+ _ => None,
2987+ }
2988+}
2989+
29142990/// If `content` starts with removed syntax, the error message for it.
29152991fn removed_syntax_hint(content: &str) -> Option<String> {
29162992 let trimmed = content.trim_start();
@@ -3143,7 +3219,7 @@ const NUMERIC_OR_KEYWORD_ATTRS: &[&str] = &["width", "height"];
31433219const SIZE_KEYWORDS: &[&str] = &["fill", "shrink"];
31443220
31453221fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext) {
3146− let base_key = strip_all_prefixes(attr.key.as_str());
3222+ let base_key = crate::vocab::base_attribute(attr.key.as_str());
31473223
31483224 if let Some(val) = &attr.value {
31493225 if NUMERIC_ATTRS.contains(&base_key) {
@@ -3615,7 +3691,7 @@ fn parse_attr_list(
36153691 }
36163692
36173693 // Color validation for hex colors
3618− if matches!(strip_all_prefixes(&attr.key), "background" | "color")
3694+ if matches!(crate::vocab::base_attribute(&attr.key), "background" | "color")
36193695 && let Some(ref val) = attr.value
36203696 && val.starts_with('#')
36213697 && !is_valid_hex_color(val)
@@ -3632,8 +3708,19 @@ fn parse_attr_list(
36323708
36333709 // Warn on unknown attributes
36343710 if validate {
3635− let base_key = strip_all_prefixes(attr.key.as_str());
3636− if !crate::vocab::is_known_attribute(base_key) {
3711+ let base_key = crate::vocab::base_attribute(attr.key.as_str());
3712+ if let Some(hint) = removed_attribute_hint(base_key) {
3713+ ctx.diagnostics.push(Diagnostic {
3714+ line: line_num,
3715+ column: None,
3716+ message: format!(
3717+ "attribute '{}' was removed: {} (run `htmlang upgrade`)",
3718+ base_key, hint
3719+ ),
3720+ severity: Severity::Warning,
3721+ source_line: None,
3722+ });
3723+ } else if !crate::vocab::is_known_attribute(base_key) {
36373724 let suggestion = suggest_closest(base_key, &crate::vocab::all_attributes());
36383725 let msg = match suggestion {
36393726 Some(closest) => {
@@ -4006,79 +4093,6 @@ fn evaluate_arithmetic(input: &str) -> String {
40064093// Post-parse validation (context-dependent warnings)
40074094// ---------------------------------------------------------------------------
40084095
4009−fn strip_all_prefixes(key: &str) -> &str {
4010− // Strip pseudo-state prefixes
4011− let key = key
4012− .strip_prefix("hover:")
4013− .or_else(|| key.strip_prefix("active:"))
4014− .or_else(|| key.strip_prefix("focus:"))
4015− .or_else(|| key.strip_prefix("focus-visible:"))
4016− .or_else(|| key.strip_prefix("focus-within:"))
4017− .or_else(|| key.strip_prefix("disabled:"))
4018− .or_else(|| key.strip_prefix("checked:"))
4019− .or_else(|| key.strip_prefix("placeholder:"))
4020− .or_else(|| key.strip_prefix("first:"))
4021− .or_else(|| key.strip_prefix("last:"))
4022− .or_else(|| key.strip_prefix("odd:"))
4023− .or_else(|| key.strip_prefix("even:"))
4024− .or_else(|| key.strip_prefix("selection:"))
4025− .or_else(|| key.strip_prefix("before:"))
4026− .or_else(|| key.strip_prefix("after:"))
4027− .or_else(|| key.strip_prefix("visited:"))
4028− .or_else(|| key.strip_prefix("empty:"))
4029− .or_else(|| key.strip_prefix("target:"))
4030− .or_else(|| key.strip_prefix("valid:"))
4031− .or_else(|| key.strip_prefix("invalid:"))
4032− .or_else(|| {
4033− // Handle nth:EXPR: prefix (e.g., nth:3:background -> background)
4034− if let Some(rest) = key.strip_prefix("nth:") {
4035− rest.find(':').map(|pos| &rest[pos + 1..])
4036− } else {
4037− None
4038− }
4039− })
4040− .or_else(|| {
4041− // Handle has(...): prefix (e.g., has(.active):background -> background)
4042− if key.starts_with("has(") {
4043− if let Some(close) = key.find("):") {
4044− Some(&key[close + 2..])
4045− } else {
4046− None
4047− }
4048− } else {
4049− None
4050− }
4051− })
4052− .unwrap_or(key);
4053− // Strip responsive prefixes
4054− let key = key
4055− .strip_prefix("sm:")
4056− .or_else(|| key.strip_prefix("md:"))
4057− .or_else(|| key.strip_prefix("lg:"))
4058− .or_else(|| key.strip_prefix("xl:"))
4059− .or_else(|| key.strip_prefix("2xl:"))
4060− .unwrap_or(key);
4061− // Strip media prefixes
4062− let key = key
4063− .strip_prefix("dark:")
4064− .or_else(|| key.strip_prefix("print:"))
4065− .unwrap_or(key);
4066− let key = key
4067− .strip_prefix("motion-safe:")
4068− .or_else(|| key.strip_prefix("motion-reduce:"))
4069− .or_else(|| key.strip_prefix("landscape:"))
4070− .or_else(|| key.strip_prefix("portrait:"))
4071− .unwrap_or(key);
4072− // Strip container query prefixes (cq-sm:, cq-md:, etc.)
4073−
4074− (key.strip_prefix("cq-sm:")
4075− .or_else(|| key.strip_prefix("cq-md:"))
4076− .or_else(|| key.strip_prefix("cq-lg:"))
4077− .or_else(|| key.strip_prefix("cq-xl:"))
4078− .or_else(|| key.strip_prefix("cq-2xl:"))
4079− .unwrap_or(key)) as _
4080−}
4081−
40824096/// Attributes that only make sense on container elements (@row, @column, @el).
40834097const CONTAINER_ONLY_ATTRS: &[&str] = &[
40844098 "spacing",
@@ -4099,10 +4113,8 @@ fn element_kind_name(kind: &ElementKind) -> String {
40994113}
41004114
41014115fn is_container(kind: &ElementKind) -> bool {
4102− matches!(
4103− kind,
4104− ElementKind::Row | ElementKind::Column | ElementKind::El | ElementKind::Breadcrumb
4105− ) || kind.spec().is_some_and(|spec| spec.container)
4116+ matches!(kind, ElementKind::Row | ElementKind::Column | ElementKind::El)
4117+ || kind.spec().is_some_and(|spec| spec.container)
41064118}
41074119
41084120fn validate_tree(
@@ -4113,7 +4125,7 @@ fn validate_tree(
41134125 for node in nodes {
41144126 if let Node::Element(elem) = node {
41154127 for attr in &elem.attrs {
4116− let base = strip_all_prefixes(&attr.key);
4128+ let base = crate::vocab::base_attribute(&attr.key);
41174129 if base == "width"
41184130 && attr.value.as_deref() == Some("fill")
41194131 && !matches!(parent_kind, Some(ElementKind::Row))
@@ -4288,12 +4300,12 @@ fn validate_tree(
42884300 let bg_color = elem
42894301 .attrs
42904302 .iter()
4291− .find(|a| strip_all_prefixes(&a.key) == "background")
4303+ .find(|a| crate::vocab::base_attribute(&a.key) == "background")
42924304 .and_then(|a| a.value.as_deref());
42934305 let fg_color = elem
42944306 .attrs
42954307 .iter()
4296− .find(|a| strip_all_prefixes(&a.key) == "color")
4308+ .find(|a| crate::vocab::base_attribute(&a.key) == "color")
42974309 .and_then(|a| a.value.as_deref());
42984310 if let (Some(bg), Some(fg)) = (bg_color, fg_color)
42994311 && let (Some(bg_rgb), Some(fg_rgb)) = (parse_hex_rgb(bg), parse_hex_rgb(fg))
crates/htmlang-core/src/std.hlnew file+50
@@ -0,0 +1,50 @@
1+-- The htmlang standard library, available in every file. It is ordinary
2+-- htmlang: a file's own @let with the same name takes precedence, and
3+-- attributes on a call style the component's root element, so
4+-- `@badge [background red] New` works like a built-in element.
5+
6+-- Small pill for counts and statuses.
7+@let badge
8+ @text [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]
9+ @children
10+
11+-- Label with slightly rounded corners.
12+@let tag
13+ @text [display inline-flex, align-items center, padding 2px 8px, font-size 0.75rem, font-weight 600, border-radius 4px]
14+ @children
15+
16+-- Outlined, fully rounded label.
17+@let chip
18+ @text [display inline-flex, align-items center, column-gap 4px, padding 4px 12px, border-radius 9999px, font-size 0.875rem, border 1 currentColor]
19+ @children
20+
21+-- Circular frame for an image or initials.
22+@let avatar
23+ @el [display inline-flex, flex-direction row, align-items center, justify-content center, border-radius 9999px, overflow hidden, flex-shrink 0]
24+ @children
25+
26+-- Takes up the remaining space in a row or column.
27+@let spacer
28+ @el [flex 1]
29+
30+-- Text that shows `tip` when hovered.
31+@let tooltip $tip
32+ @text [position relative, cursor help, title $tip]
33+ @children
34+
35+-- Horizontally scrolling row that snaps to each child.
36+@let carousel
37+ @row [overflow-x auto, scroll-snap-type x mandatory, scrollbar-width none, children:scroll-snap-align start, children:flex-shrink 0]
38+ @children
39+
40+-- Breadcrumb trail: each crumb is an @item.
41+@let breadcrumb
42+ @nav [aria-label breadcrumb]
43+ @list [ordered, display flex, flex-direction row, flex-wrap wrap, gap 8px]
44+ @children
45+
46+-- Loading placeholder: [$skeleton]
47+@let skeleton [background linear-gradient(90deg,#e5e7eb 25%,#f3f4f6 50%,#e5e7eb 75%), background-size 200% 100%, animation hl-skeleton 1.5s ease-in-out infinite]
48+
49+-- Hide scrollbars but keep scrolling: [$no-scrollbar]
50+@let no-scrollbar [scrollbar-width none]
crates/htmlang-core/src/vocab.rs+87−3
@@ -10,9 +10,9 @@
1010/// HTML attribute).
1111pub const HTMLANG_ATTRIBUTES: &[&str] = &[
1212 "align-bottom", "align-left", "align-right", "align-top", "backdrop-blur", "blur", "bold",
13− "center-x", "center-y", "col-span", "critical", "gap-x", "gap-y", "gradient", "grid-cols",
14− "grid-rows", "inline", "italic", "margin-x", "margin-y", "no-scrollbar", "ordered",
15− "padding-x", "padding-y", "responsive", "rounded", "row-span", "shadow", "skeleton",
13+ "center-x", "center-y", "col-span", "critical", "gap-x", "gap-y", "grid-cols",
14+ "grid-rows", "inline", "italic", "margin-x", "margin-y", "ordered",
15+ "padding-x", "padding-y", "responsive", "rounded", "row-span", "shadow",
1616 "spacing", "truncate", "underline",
1717];
1818
@@ -166,6 +166,81 @@ pub fn is_length_property(name: &str) -> bool {
166166 || SUFFIXES.iter().any(|s| name.ends_with(s))
167167}
168168
169+/// State prefixes and the selector each adds: `hover:color red` styles
170+/// `.x:hover`, `children:flex-shrink 0` styles `.x > *`.
171+pub const PSEUDO_PREFIXES: &[(&str, &str)] = &[
172+ ("hover:", ":hover"),
173+ ("active:", ":active"),
174+ ("focus:", ":focus"),
175+ ("focus-visible:", ":focus-visible"),
176+ ("focus-within:", ":focus-within"),
177+ ("disabled:", ":disabled"),
178+ ("checked:", ":checked"),
179+ ("placeholder:", "::placeholder"),
180+ ("first:", ":first-child"),
181+ ("last:", ":last-child"),
182+ ("odd:", ":nth-child(odd)"),
183+ ("even:", ":nth-child(even)"),
184+ ("before:", "::before"),
185+ ("after:", "::after"),
186+ ("selection:", "::selection"),
187+ ("visited:", ":visited"),
188+ ("empty:", ":empty"),
189+ ("target:", ":target"),
190+ ("valid:", ":valid"),
191+ ("invalid:", ":invalid"),
192+ ("children:", " > *"),
193+];
194+
195+/// Viewport-width prefixes (`md:padding 32`).
196+pub const RESPONSIVE_PREFIXES: &[&str] = &["sm:", "md:", "lg:", "xl:", "2xl:"];
197+pub const MEDIA_PREFIXES: &[&str] = &[
198+ "dark:",
199+ "print:",
200+ "motion-safe:",
201+ "motion-reduce:",
202+ "landscape:",
203+ "portrait:",
204+];
205+pub const CONTAINER_QUERY_PREFIXES: &[&str] = &["cq-sm:", "cq-md:", "cq-lg:", "cq-xl:", "cq-2xl:"];
206+
207+/// Does `key` carry any state, media, responsive or container prefix?
208+pub fn is_prefixed(key: &str) -> bool {
209+ PSEUDO_PREFIXES.iter().any(|&(p, _)| key.starts_with(p))
210+ || RESPONSIVE_PREFIXES.iter().any(|p| key.starts_with(p))
211+ || MEDIA_PREFIXES.iter().any(|p| key.starts_with(p))
212+ || CONTAINER_QUERY_PREFIXES.iter().any(|p| key.starts_with(p))
213+ || key.starts_with("nth:")
214+ || key.starts_with("has(")
215+}
216+
217+/// The attribute name without its prefixes: `hover:md:background` →
218+/// `background`, `nth:2n:color` → `color`, `has(.x):padding` → `padding`.
219+pub fn base_attribute(key: &str) -> &str {
220+ let mut key = key;
221+ loop {
222+ let stripped = PSEUDO_PREFIXES
223+ .iter()
224+ .map(|&(p, _)| p)
225+ .chain(RESPONSIVE_PREFIXES.iter().copied())
226+ .chain(MEDIA_PREFIXES.iter().copied())
227+ .chain(CONTAINER_QUERY_PREFIXES.iter().copied())
228+ .find_map(|p| key.strip_prefix(p))
229+ .or_else(|| {
230+ let rest = key.strip_prefix("nth:")?;
231+ rest.find(':').map(|pos| &rest[pos + 1..])
232+ })
233+ .or_else(|| {
234+ let rest = key.strip_prefix("has(")?;
235+ rest.find("):").map(|pos| &rest[pos + 2..])
236+ });
237+ match stripped {
238+ Some(rest) => key = rest,
239+ None => return key,
240+ }
241+ }
242+}
243+
169244pub fn is_css_property(name: &str) -> bool {
170245 CSS_PROPERTIES.binary_search(&name).is_ok()
171246}
@@ -195,6 +270,15 @@ pub fn all_attributes() -> Vec<&'static str> {
195270
196271#[cfg(test)]
197272mod tests {
273+ #[test]
274+ fn base_attribute_strips_every_prefix() {
275+ assert_eq!(super::base_attribute("hover:md:background"), "background");
276+ assert_eq!(super::base_attribute("nth:2n:color"), "color");
277+ assert_eq!(super::base_attribute("has(.a):dark:padding"), "padding");
278+ assert_eq!(super::base_attribute("children:flex-shrink"), "flex-shrink");
279+ assert_eq!(super::base_attribute("width"), "width");
280+ }
281+
198282 #[test]
199283 fn css_properties_are_sorted() {
200284 assert!(super::CSS_PROPERTIES.windows(2).all(|w| w[0] < w[1]));
src/upgrade.rs+112−2
@@ -79,7 +79,7 @@ pub fn upgrade(input: &str) -> Upgrade {
7979
8080 // --- Block rewrites ---
8181 if attr_depth == 0
82− && let Some(block) = rewrite_block(&lines, i, &mut manual)
82+ && let Some(block) = rewrite_block(&lines, i, &mut manual, &user_defined)
8383 {
8484 out.extend(block.lines);
8585 changes += 1;
@@ -119,7 +119,12 @@ struct Block {
119119}
120120
121121/// Rewrites of directives that own an indented block.
122−fn rewrite_block(lines: &[&str], i: usize, manual: &mut Vec<(usize, String)>) -> Option<Block> {
122+fn rewrite_block(
123+ lines: &[&str],
124+ i: usize,
125+ manual: &mut Vec<(usize, String)>,
126+ user_defined: &[&str],
127+) -> Option<Block> {
123128 let line = lines[i];
124129 let trimmed = line.trim();
125130 let indent = indent_of(line);
@@ -127,6 +132,60 @@ fn rewrite_block(lines: &[&str], i: usize, manual: &mut Vec<(usize, String)>) ->
127132 let end = block_end(lines, i);
128133 let body = &lines[i + 1..end];
129134
135+ // @tooltip TEXT (text shown and used as the hover tip) →
136+ // @tooltip [tip TEXT] TEXT
137+ if let Some(rest) = trimmed.strip_prefix("@tooltip ")
138+ && !user_defined.contains(&"tooltip")
139+ && !has_attr_key(rest, "tip")
140+ {
141+ let (attrs, text) = match rest.strip_prefix('[') {
142+ Some(after) => {
143+ let close = after.find(']')?;
144+ (Some(&after[..close]), after[close + 1..].trim())
145+ }
146+ None => (None, rest.trim()),
147+ };
148+ if text.is_empty() {
149+ return None;
150+ }
151+ let attrs = match attrs {
152+ Some(a) if !a.trim().is_empty() => format!("[tip {}, {}]", text, a.trim()),
153+ _ => format!("[tip {}]", text),
154+ };
155+ let mut out = vec![format!("{pad}@tooltip {} {}", attrs, text)];
156+ out.extend(body.iter().map(|l| l.to_string()));
157+ return Some(Block { lines: out, end });
158+ }
159+
160+ // @breadcrumb: each crumb is now an explicit @item
161+ if (trimmed == "@breadcrumb" || trimmed.starts_with("@breadcrumb "))
162+ && !user_defined.contains(&"breadcrumb")
163+ {
164+ let child_indent = body.iter().find(|l| !l.trim().is_empty()).map(|l| indent_of(l));
165+ if body.iter().all(|l| {
166+ l.trim().is_empty()
167+ || Some(indent_of(l)) != child_indent
168+ || l.trim().starts_with("@item")
169+ }) {
170+ return None;
171+ }
172+ let mut out = vec![line.to_string()];
173+ for l in body {
174+ let t = l.trim();
175+ if Some(indent_of(l)) == child_indent && !t.is_empty() && !t.starts_with("@item") {
176+ let wrapped = if t.starts_with('@') {
177+ format!("@item > {}", t)
178+ } else {
179+ format!("@item {}", t)
180+ };
181+ out.push(format!("{}{}", " ".repeat(indent_of(l)), wrapped));
182+ } else {
183+ out.push(l.to_string());
184+ }
185+ }
186+ return Some(Block { lines: out, end });
187+ }
188+
130189 // @switch $v / @case x [attrs] → @match $v / @case x / @let __switch [attrs]
131190 if let Some(rest) = trimmed.strip_prefix("@switch ") {
132191 let mut out = vec![format!("{pad}@match {}", rest.trim())];
@@ -391,6 +450,28 @@ fn rewrite_attr_list(list: &str) -> String {
391450 out.push_str(ws);
392451 // `...$bundle` spread → `$bundle`
393452 let body = body.strip_prefix("...$").map_or(body.to_string(), |b| format!("${b}"));
453+ // Built-in style attributes that became standard-library bundles
454+ let body = match body.trim_end() {
455+ "skeleton" | "no-scrollbar" => format!("${}", body),
456+ _ => body,
457+ };
458+ // `gradient A B [ANGLE]` → the `background` it generated
459+ let body = match body.strip_prefix("gradient ") {
460+ Some(v) => {
461+ let parts: Vec<&str> = v.split_whitespace().collect();
462+ let angle = parts
463+ .get(2)
464+ .filter(|a| a.ends_with("deg") || a.ends_with("turn") || a.ends_with("rad"));
465+ let gradient = match (parts.as_slice(), angle) {
466+ ([a, b, ..], Some(angle)) => format!("{},{},{}", angle, a, b),
467+ ([a, b, ..], None) => format!("{},{}", a, b),
468+ ([a], _) => format!("{},transparent", a),
469+ _ => String::new(),
470+ };
471+ format!("background linear-gradient({})", gradient)
472+ }
473+ None => body,
474+ };
394475 // Renamed keys (keeping any `hover:` / `md:` style prefix)
395476 let key_end = body.find(char::is_whitespace).unwrap_or(body.len());
396477 let (key, value) = body.split_at(key_end);
@@ -410,6 +491,16 @@ fn rewrite_attr_list(list: &str) -> String {
410491 out
411492}
412493
494+/// Does the attribute list at the start of `rest` (if any) have `key`?
495+fn has_attr_key(rest: &str, key: &str) -> bool {
496+ let Some(list) = rest.strip_prefix('[').and_then(|r| r.split(']').next()) else {
497+ return false;
498+ };
499+ split_top_level_commas(list)
500+ .iter()
501+ .any(|attr| attr.split_whitespace().next() == Some(key))
502+}
503+
413504fn split_top_level_commas(s: &str) -> Vec<&str> {
414505 let mut parts = Vec::new();
415506 let mut depth = 0i32;
@@ -618,6 +709,25 @@ mod tests {
618709 assert_eq!(up("@text [bold] please animate this"), "@text [bold] please animate this");
619710 }
620711
712+ #[test]
713+ fn standard_library_migrations() {
714+ assert_eq!(
715+ up("@el [skeleton, height 20]\n@el [no-scrollbar]"),
716+ "@el [$skeleton, height 20]\n@el [$no-scrollbar]"
717+ );
718+ assert_eq!(
719+ up("@el [gradient #f00 #00f 45deg, padding 4]"),
720+ "@el [background linear-gradient(45deg,#f00,#00f), padding 4]"
721+ );
722+ assert_eq!(up("@tooltip Hover me"), "@tooltip [tip Hover me] Hover me");
723+ assert_eq!(up("@tooltip A tooltip text"), "@tooltip [tip A tooltip text] A tooltip text");
724+ assert_eq!(up("@tooltip [tip X] Y"), "@tooltip [tip X] Y");
725+ assert_eq!(
726+ up("@breadcrumb\n @link / Home\n Current"),
727+ "@breadcrumb\n @item > @link / Home\n @item Current"
728+ );
729+ }
730+
621731 #[test]
622732 fn verbatim_regions_are_untouched() {
623733 let src = "@script\n for (x of y) {}\n@raw \"\"\"\n@unless\n\"\"\"\n@style\n .p { }";
tests/snapshots.rs+67−23
@@ -2724,7 +2724,7 @@ fn element_badge() {
27242724
27252725#[test]
27262726fn element_tooltip() {
2727− let output = compile("@page T\n@tooltip Hover for info\n @text Help");
2727+ let output = compile("@page T\n@tooltip [tip Hover for info]\n @text Help");
27282728 assert!(
27292729 output.contains("title=\"Hover for info\""),
27302730 "tooltip should have title attr: {}",
@@ -3109,7 +3109,7 @@ fn test_css_shorthands_output() {
31093109 html
31103110 );
31113111
3112− let result = htmlang::parser::parse("@el [no-scrollbar] Content");
3112+ let result = htmlang::parser::parse("@el [$no-scrollbar] Content");
31133113 let html = htmlang::codegen::generate(&result.document);
31143114 assert!(
31153115 html.contains("scrollbar-width:none"),
@@ -3117,7 +3117,7 @@ fn test_css_shorthands_output() {
31173117 html
31183118 );
31193119
3120− let result = htmlang::parser::parse("@el [skeleton, width 100, height 20] Content");
3120+ let result = htmlang::parser::parse("@el [$skeleton, width 100, height 20] Content");
31213121 let html = htmlang::codegen::generate(&result.document);
31223122 assert!(
31233123 html.contains("hl-skeleton"),
@@ -3130,20 +3130,15 @@ fn test_css_shorthands_output() {
31303130 html
31313131 );
31323132
3133+ // `gradient` became plain CSS: the compiler points to the replacement.
31333134 let result = htmlang::parser::parse("@el [gradient #fff #000] Content");
3134− let html = htmlang::codegen::generate(&result.document);
3135− assert!(
3136− html.contains("linear-gradient(#fff,#000)"),
3137− "gradient should work, got: {}",
3138− html
3139− );
3140−
3141− let result = htmlang::parser::parse("@el [gradient #fff #000 45deg] Content");
3142− let html = htmlang::codegen::generate(&result.document);
31433135 assert!(
3144− html.contains("linear-gradient(45deg,#fff,#000)"),
3145− "gradient with angle should work, got: {}",
3146− html
3136+ result
3137+ .diagnostics
3138+ .iter()
3139+ .any(|d| d.message.contains("linear-gradient") && d.message.contains("upgrade")),
3140+ "gradient should point to linear-gradient: {:?}",
3141+ result.diagnostics
31473142 );
31483143}
31493144
@@ -3633,14 +3628,10 @@ fn search_element() {
36333628
36343629#[test]
36353630fn breadcrumb_generates_nav_ol() {
3636− let output = compile("@breadcrumb\n @text Home\n @text About");
3637− assert!(
3638− output.contains("<nav aria-label=\"breadcrumb\">"),
3639− "nav: {}",
3640− output
3641− );
3642− assert!(output.contains("<ol>"), "ol: {}", output);
3643− assert!(output.contains("<li>"), "li: {}", output);
3631+ let output = compile("@breadcrumb\n @item > @text Home\n @item About");
3632+ assert!(output.contains("aria-label=\"breadcrumb\""), "nav: {}", output);
3633+ assert!(output.contains("<ol class="), "ol: {}", output);
3634+ assert!(output.contains("<li class="), "li: {}", output);
36443635}
36453636
36463637#[test]
@@ -5327,3 +5318,56 @@ fn markdown_file_missing_reports_error() {
53275318 result.diagnostics
53285319 );
53295320}
5321+
5322+// ---------------------------------------------------------------------------
5323+// Standard library and function calls behaving like elements
5324+// ---------------------------------------------------------------------------
5325+
5326+#[test]
5327+fn function_call_text_becomes_children() {
5328+ let output = compile("@let box\n @el [padding 4]\n @children\n@box Hello {@text [bold] world}");
5329+ assert!(output.contains("Hello"), "{}", output);
5330+ assert!(output.contains(">world</span>"), "{}", output);
5331+}
5332+
5333+#[test]
5334+fn function_call_extra_attributes_style_the_root() {
5335+ let output = compile("@let box $label\n @el [padding 4] $label\n@box [label Hi, background red, hover:color blue]");
5336+ assert!(output.contains("background:red"), "{}", output);
5337+ assert!(output.contains(":hover{color:blue"), "{}", output);
5338+ assert!(output.contains("Hi"), "{}", output);
5339+}
5340+
5341+#[test]
5342+fn function_call_extra_attributes_need_a_single_root() {
5343+ let diags = parse_diagnostics("@let two\n @text A\n @text B\n@two [background red]");
5344+ assert!(
5345+ diags.iter().any(|d| d.message.contains("no single root element")),
5346+ "{:?}",
5347+ diags
5348+ );
5349+}
5350+
5351+#[test]
5352+fn children_prefix_styles_direct_children() {
5353+ let output = compile("@row [children:flex-shrink 0]\n @el A");
5354+ assert!(output.contains(" > *{flex-shrink:0;}"), "{}", output);
5355+}
5356+
5357+#[test]
5358+fn standard_library_components_compile_cleanly() {
5359+ 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]";
5360+ let diags = parse_diagnostics(src);
5361+ assert!(diags.is_empty(), "standard library produced diagnostics: {:?}", diags);
5362+ let output = compile(src);
5363+ assert!(output.contains("<span class=\"a\">3</span>"), "{}", output);
5364+ assert!(output.contains("title=\"More\""), "{}", output);
5365+ assert!(output.contains("@keyframes hl-skeleton"), "{}", output);
5366+}
5367+
5368+#[test]
5369+fn own_definition_overrides_standard_library() {
5370+ let output = compile("@let badge $label\n @text [color green] $label\n@badge [label Mine]");
5371+ assert!(output.contains("color:green"), "{}", output);
5372+ assert!(!output.contains("border-radius:9999px"), "{}", output);
5373+}
tests/snapshots/breadcrumb.hl+3−3
@@ -1,4 +1,4 @@
11@breadcrumb [spacing 10]
2− @link / Home
3− @link /docs Docs
4− @text Current Page
2+ @item > @link / Home
3+ @item > @link /docs Docs
4+ @item > @text Current Page
tests/snapshots/breadcrumb.html+1−1
@@ -1 +1 @@
1−<style>@layer htmlang{.a{gap:10px;}}</style><nav aria-label="breadcrumb" class="a"><ol><li><a href="/">Home</a></li><li><a href="/docs">Docs</a></li><li><span>Current Page</span></li></ol></nav>
1+<style>@layer htmlang{.a{display:flex;flex-direction:column;gap:10px;}.b{margin:0;padding-left:0;list-style:none;display:flex;flex-direction:row;flex-wrap:wrap;gap:8px;}.c{display:flex;flex-direction:column;}}</style><nav class="a" aria-label="breadcrumb"><ol class="b"><li class="c"><a href="/">Home</a></li><li class="c"><a href="/docs">Docs</a></li><li class="c"><span>Current Page</span></li></ol></nav>
tests/snapshots/css_shorthands.hl+4−4
@@ -17,17 +17,17 @@
1717 Frosted glass effect
1818
1919 -- No scrollbar
20− @el [overflow-x auto, no-scrollbar, max-width 300]
20+ @el [overflow-x auto, $no-scrollbar, max-width 300]
2121 @row [spacing 20]
2222 @el [width 200, padding 20] Item 1
2323 @el [width 200, padding 20] Item 2
2424 @el [width 200, padding 20] Item 3
2525
2626 -- Skeleton
27− @el [width fill, height 20, rounded 4, skeleton]
27+ @el [width fill, height 20, rounded 4, $skeleton]
2828
2929 -- Gradient
30− @el [gradient #3b82f6 #8b5cf6, padding 20, rounded 8]
30+ @el [background linear-gradient(#3b82f6,#8b5cf6), padding 20, rounded 8]
3131 @text [color white] Gradient background
32− @el [gradient #ff6b6b #feca57 45deg, padding 20, rounded 8]
32+ @el [background linear-gradient(45deg,#ff6b6b,#feca57), padding 20, rounded 8]
3333 @text [color white] Angled gradient
tests/snapshots/css_shorthands.html+1−1
@@ -1 +1 @@
1−<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>CSS Shorthands</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;gap:20px;}.b{max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}.c{margin:0;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;}.d{display:flex;flex-direction:column;filter:blur(4px);padding:20px;}.e{display:flex;flex-direction:column;backdrop-filter:blur(10px);padding:20px;background:rgba(255,255,255,0.5);}.f{display:flex;flex-direction:column;overflow-x:auto;scrollbar-width:none;-ms-overflow-style:none;max-width:300px;}.g{display:flex;flex-direction:row;gap:20px;}.h{display:flex;flex-direction:column;width:200px;padding:20px;}.i{display:flex;flex-direction:column;width:100%;height:20px;border-radius:4px;background:linear-gradient(90deg,#e5e7eb 25%,#f3f4f6 50%,#e5e7eb 75%);background-size:200% 100%;animation:hl-skeleton 1.5s ease-in-out infinite;}.j{display:flex;flex-direction:column;background:linear-gradient(#3b82f6,#8b5cf6);padding:20px;border-radius:8px;}.k{color:white;}.l{display:flex;flex-direction:column;background:linear-gradient(45deg,#ff6b6b,#feca57);padding:20px;border-radius:8px;}.f::-webkit-scrollbar{display:none;}}@keyframes hl-skeleton{0%{background-position:200% 0}100%{background-position:-200% 0}}</style></head><body><div class="a"><span class="b">This text will be truncated with an ellipsis</span><p class="c">This is a long paragraph that spans multiple lines and should be clamped at three lines.</p><div class="d"><span>Blurred content</span></div><div class="e"><span>Frosted glass effect</span></div><div class="f"><div class="g"><div class="h"><span>Item 1</span></div><div class="h"><span>Item 2</span></div><div class="h"><span>Item 3</span></div></div></div><div class="i"></div><div class="j"><span class="k">Gradient background</span></div><div class="l"><span class="k">Angled gradient</span></div></div></body></html>
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>CSS Shorthands</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;gap:20px;}.b{max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}.c{margin:0;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;}.d{display:flex;flex-direction:column;filter:blur(4px);padding:20px;}.e{display:flex;flex-direction:column;backdrop-filter:blur(10px);padding:20px;background:rgba(255,255,255,0.5);}.f{display:flex;flex-direction:column;overflow-x:auto;scrollbar-width:none;max-width:300px;}.g{display:flex;flex-direction:row;gap:20px;}.h{display:flex;flex-direction:column;width:200px;padding:20px;}.i{display:flex;flex-direction:column;width:100%;height:20px;border-radius:4px;background:linear-gradient(90deg,#e5e7eb 25%,#f3f4f6 50%,#e5e7eb 75%);background-size:200% 100%;animation:hl-skeleton 1.5s ease-in-out infinite;}.j{display:flex;flex-direction:column;background:linear-gradient(#3b82f6,#8b5cf6);padding:20px;border-radius:8px;}.k{color:white;}.l{display:flex;flex-direction:column;background:linear-gradient(45deg,#ff6b6b,#feca57);padding:20px;border-radius:8px;}}@keyframes hl-skeleton{0%{background-position:200% 0}100%{background-position:-200% 0}}</style></head><body><div class="a"><span class="b">This text will be truncated with an ellipsis</span><p class="c">This is a long paragraph that spans multiple lines and should be clamped at three lines.</p><div class="d"><span>Blurred content</span></div><div class="e"><span>Frosted glass effect</span></div><div class="f"><div class="g"><div class="h"><span>Item 1</span></div><div class="h"><span>Item 2</span></div><div class="h"><span>Item 3</span></div></div></div><div class="i"></div><div class="j"><span class="k">Gradient background</span></div><div class="l"><span class="k">Angled gradient</span></div></div></body></html>
tests/snapshots/new_elements_4.hl+2−2
@@ -29,7 +29,7 @@
2929
3030-- Tooltip element
3131@row [spacing 16]
32− @tooltip Hover me for info
32+ @tooltip [tip Hover me for info] Hover me for info
3333 @text [underline] Helpful text
34− @tooltip This is a tooltip message
34+ @tooltip [tip This is a tooltip message] This is a tooltip message
3535 @text [bold] Important
tests/snapshots/new_elements_4.html+1−1
@@ -1 +1 @@
1−<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>New Elements 4</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;}.b{display:flex;flex-direction:column;background:#f0f0f0;padding:16px;}.c{display:flex;flex-direction:column;background:#e0e0e0;padding:16px;}.d{display:flex;flex-direction:column;background:#d0d0d0;padding:16px;}.e{position:relative;width:200px;height:200px;}.f{display:flex;flex-direction:column;position:absolute;inset:0;background:#3b82f6;}.g{display:flex;flex-direction:column;position:absolute;bottom:10px;right:10px;}.h{color:white;}.i{display:flex;flex-direction:row;height:100px;}.j{flex:1;}.k{display:flex;flex-direction:row;gap:8px;}.l{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;background:#ef4444;color:white;}.m{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;background:#22c55e;color:white;}.n{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;background:#3b82f6;color:white;}.o{display:flex;flex-direction:row;gap:16px;}.p{position:relative;cursor:help;}.q{text-decoration:underline;}.r{font-weight:bold;}}</style></head><body><div class="a"><div class="b"><span>First</span></div><div class="c"><span>Second</span></div><div class="d"><span>Third</span></div></div><div class="e"><div class="f"></div><div class="g"><span class="h">Overlay</span></div></div><div class="i"><span>Left</span><div class="j"><span>Right</span></div><div class="k"><span class="l">3</span><span class="m">New</span><span class="n">Pro</span></div><div class="o"><span title="Hover me for info" class="p">Hover me for info<span class="q">Helpful text</span></span><span title="This is a tooltip message" class="p">This is a tooltip message<span class="r">Important</span></span></div></body></html>
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>New Elements 4</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;}.b{display:flex;flex-direction:column;background:#f0f0f0;padding:16px;}.c{display:flex;flex-direction:column;background:#e0e0e0;padding:16px;}.d{display:flex;flex-direction:column;background:#d0d0d0;padding:16px;}.e{position:relative;width:200px;height:200px;}.f{display:flex;flex-direction:column;position:absolute;inset:0;background:#3b82f6;}.g{display:flex;flex-direction:column;position:absolute;bottom:10px;right:10px;}.h{color:white;}.i{display:flex;flex-direction:row;height:100px;}.j{display:flex;flex-direction:column;flex:1;}.k{display:flex;flex-direction:row;gap:8px;}.l{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;background:#ef4444;color:white;}.m{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;background:#22c55e;color:white;}.n{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;background:#3b82f6;color:white;}.o{display:flex;flex-direction:row;gap:16px;}.p{position:relative;cursor:help;}.q{text-decoration:underline;}.r{font-weight:bold;}}</style></head><body><div class="a"><div class="b"><span>First</span></div><div class="c"><span>Second</span></div><div class="d"><span>Third</span></div></div><div class="e"><div class="f"></div><div class="g"><span class="h">Overlay</span></div></div><div class="i"><span>Left</span><div class="j"></div><span>Right</span></div><div class="k"><span class="l">3</span><span class="m">New</span><span class="n">Pro</span></div><div class="o"><span class="p" title="Hover me for info">Hover me for info<span class="q">Helpful text</span></span><span class="p" title="This is a tooltip message">This is a tooltip message<span class="r">Important</span></span></div></body></html>
tests/snapshots/new_elements_5.html+1−1
@@ -1 +1 @@
1−<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>New Elements 5</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;gap:20px;}.b{display:inline-flex;align-items:center;justify-content:center;border-radius:9999px;overflow:hidden;flex-shrink:0;width:48px;height:48px;background:#e5e7eb;}.c{display:flex;flex-direction:row;overflow-x:auto;scroll-snap-type:x mandatory;-webkit-overflow-scrolling:touch;scrollbar-width:none;gap:16px;}.d{display:flex;flex-direction:column;scroll-snap-align:start;flex-shrink:0;width:200px;padding:20px;background:#f3f4f6;border-radius:8px;}.e{display:inline-flex;align-items:center;gap:4px;padding:4px 12px;border-radius:9999px;font-size:0.875rem;border:1px solid currentColor;background:#e5e7eb;}.f{display:inline-flex;align-items:center;padding:2px 8px;font-size:0.75rem;font-weight:600;border-radius:4px;background:#dbeafe;color:#1e40af;}}</style></head><body><div class="a"><div class="b"><span>AB</span></div><div class="c"><div class="d"><span>Slide 1</span></div><div class="d"><span>Slide 2</span></div></div><span class="e">Category</span><span class="f">v2.0</span></div></body></html>
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>New Elements 5</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;gap:20px;}.b{display:inline-flex;flex-direction:row;align-items:center;justify-content:center;border-radius:9999px;overflow:hidden;flex-shrink:0;width:48px;height:48px;background:#e5e7eb;}.c{display:flex;flex-direction:row;overflow-x:auto;scroll-snap-type:x mandatory;scrollbar-width:none;gap:16px;}.d{display:flex;flex-direction:column;width:200px;padding:20px;background:#f3f4f6;border-radius:8px;}.e{display:inline-flex;align-items:center;column-gap:4px;padding:4px 12px;border-radius:9999px;font-size:0.875rem;border:1px solid currentColor;background:#e5e7eb;}.f{display:inline-flex;align-items:center;padding:2px 8px;font-size:0.75rem;font-weight:600;border-radius:4px;background:#dbeafe;color:#1e40af;}.c > *{scroll-snap-align:start;flex-shrink:0;}}</style></head><body><div class="a"><div class="b"><span>AB</span></div><div class="c"><div class="d"><span>Slide 1</span></div><div class="d"><span>Slide 2</span></div></div><span class="e">Category</span><span class="f">v2.0</span></div></body></html>