on master help sign in
Move styled components into a standard library written in htmlang
fde10f4iko
crates/htmlang-core/src/ast.rs+2−12
@@ -85,7 +85,6 @@ pub enum ElementKind {
85 Image,
85 Image,
86 // Elements generated in a special way
86 // Elements generated in a special way
87 Script,
87 Script,
88− Breadcrumb,
89 Fragment,
88 Fragment,
90 // Function bodies: placeholders for the caller's content
89 // Function bodies: placeholders for the caller's content
91 Children,
90 Children,
@@ -106,7 +105,6 @@ impl ElementKind {
106 ElementKind::Link => "link",
105 ElementKind::Link => "link",
107 ElementKind::Image => "image",
106 ElementKind::Image => "image",
108 ElementKind::Script => "script",
107 ElementKind::Script => "script",
109− ElementKind::Breadcrumb => "breadcrumb",
110 ElementKind::Fragment => "fragment",
108 ElementKind::Fragment => "fragment",
111 ElementKind::Children => "children",
109 ElementKind::Children => "children",
112 ElementKind::Slot(_) => "slot",
110 ElementKind::Slot(_) => "slot",
@@ -137,7 +135,6 @@ impl ElementKind {
137 "link" => ElementKind::Link,
135 "link" => ElementKind::Link,
138 "image" => ElementKind::Image,
136 "image" => ElementKind::Image,
139 "script" => ElementKind::Script,
137 "script" => ElementKind::Script,
140− "breadcrumb" => ElementKind::Breadcrumb,
141 "fragment" => ElementKind::Fragment,
138 "fragment" => ElementKind::Fragment,
142 "children" => ElementKind::Children,
139 "children" => ElementKind::Children,
143 "slot" => ElementKind::Slot(String::new()),
140 "slot" => ElementKind::Slot(String::new()),
@@ -148,8 +145,8 @@ impl ElementKind {
148 /// Every element name, for suggestions and completions.
145 /// Every element name, for suggestions and completions.
149 pub fn all_names() -> impl Iterator<Item = &'static str> {
146 pub fn all_names() -> impl Iterator<Item = &'static str> {
150 [
147 [
151− "row", "column", "el", "text", "paragraph", "link", "image", "script", "breadcrumb",
148+ "row", "column", "el", "text", "paragraph", "link", "image", "script", "fragment",
152− "fragment", "children", "slot",
149+ "children", "slot",
153 ]
150 ]
154 .into_iter()
151 .into_iter()
155 .chain(TAGS.iter().map(|spec| spec.name))
152 .chain(TAGS.iter().map(|spec| spec.name))
@@ -266,13 +263,6 @@ pub static TAGS: &[TagSpec] = &[
266 TagSpec { name: "stack", html: "div", css: "position:relative;", wraps_text: true, container: true, ..TagSpec::DEFAULT },
263 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 },
264 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 },
265 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];
266];
277
267
278/// Elements that print their argument as their text (`@text Hello`,
268/// 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
1224 }
1224 }
1225 }
1225 }
1226
1226
1227− // Auto-inject skeleton keyframe if skeleton attribute is used
1227+ // Keyframes for the standard library's `$skeleton` bundle, when used
1228 if element_css.contains("hl-skeleton") {
1228 if element_css.contains("hl-skeleton") {
1229 if dev {
1229 if dev {
1230 element_css.push_str("@keyframes hl-skeleton {\n 0% { background-position: 200% 0; }\n 100% { background-position: -200% 0; }\n}\n");
1230 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(
1460 out.push_str(ctx.nl());
1460 out.push_str(ctx.nl());
1461 return;
1461 return;
1462 }
1462 }
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− }
1501 if elem.kind == ElementKind::Children {
1463 if elem.kind == ElementKind::Children {
1502 return;
1464 return;
1503 }
1465 }
@@ -1583,15 +1545,6 @@ fn generate_element(
1583
1545
1584 emit_argument_attr(out, elem);
1546 emit_argument_attr(out, elem);
1585
1547
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−
1595 // Critical CSS: inline styles directly instead of using a class
1548 // Critical CSS: inline styles directly instead of using a class
1596 let is_critical = elem.attrs.iter().any(|a| a.key == "critical");
1549 let is_critical = elem.attrs.iter().any(|a| a.key == "critical");
1597 if is_critical {
1550 if is_critical {
@@ -1863,21 +1816,13 @@ fn compute_class(
1863
1816
1864 // Collect pseudo-state overrides
1817 // Collect pseudo-state overrides
1865 let mut pseudo = Vec::new();
1818 let mut pseudo = Vec::new();
1866− for &(prefix, selector) in PSEUDO_PREFIXES {
1819+ for &(prefix, selector) in crate::vocab::PSEUDO_PREFIXES {
1867 let css = attrs_to_css(attrs, prefix, kind, parent_kind, has_overlay_children);
1820 let css = attrs_to_css(attrs, prefix, kind, parent_kind, has_overlay_children);
1868 if !css.is_empty() {
1821 if !css.is_empty() {
1869 pseudo.push((selector.to_string(), css));
1822 pseudo.push((selector.to_string(), css));
1870 }
1823 }
1871 }
1824 }
1872
1825
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−
1881 // Collect nth:EXPR: dynamic pseudo selectors
1826 // Collect nth:EXPR: dynamic pseudo selectors
1882 let mut nth_prefixes: Vec<String> = Vec::new();
1827 let mut nth_prefixes: Vec<String> = Vec::new();
1883 for attr in attrs {
1828 for attr in attrs {
@@ -2089,48 +2034,6 @@ fn emit_class_attr(out: &mut String, gen_class: Option<&str>, user_class: Option
2089// ---------------------------------------------------------------------------
2034// ---------------------------------------------------------------------------
2090
2035
2091/// (htmlang prefix, CSS selector suffix)
2036/// (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−
2134/// `display:flex;flex-direction:column;` — base layout for `@el` and every
2037/// `display:flex;flex-direction:column;` — base layout for `@el` and every
2135/// semantic wrapper that behaves like a column.
2038/// semantic wrapper that behaves like a column.
2136const FLEX_COLUMN: &str = "display:flex;flex-direction:column;";
2039const FLEX_COLUMN: &str = "display:flex;flex-direction:column;";
@@ -2161,16 +2064,12 @@ fn attrs_to_css(
2161 ElementKind::Tag(spec) => css.push_str(spec.css),
2064 ElementKind::Tag(spec) => css.push_str(spec.css),
2162 _ => {}
2065 _ => {}
2163 }
2066 }
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− }
2168 }
2067 }
2169
2068
2170 for attr in attrs {
2069 for attr in attrs {
2171 // Determine the effective key for this pass
2070 // Determine the effective key for this pass
2172 let effective_key = if state_prefix.is_empty() {
2071 let effective_key = if state_prefix.is_empty() {
2173− if is_prefixed_attr(&attr.key) {
2072+ if crate::vocab::is_prefixed(&attr.key) {
2174 continue;
2073 continue;
2175 }
2074 }
2176 attr.key.as_str()
2075 attr.key.as_str()
@@ -2742,41 +2641,6 @@ fn attrs_to_css(
2742 push_css(&mut css, "backdrop-filter", &format!("blur({})", css_px(v)));
2641 push_css(&mut css, "backdrop-filter", &format!("blur({})", css_px(v)));
2743 }
2642 }
2744 }
2643 }
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− }
2780
2644
2781 // Critical CSS hint — not CSS, handled elsewhere
2645 // Critical CSS hint — not CSS, handled elsewhere
2782 "critical" => {}
2646 "critical" => {}
crates/htmlang-core/src/parser.rs+110−98
@@ -129,6 +129,22 @@ struct Parser {
129 pos: usize,
129 pos: usize,
130}
130}
131
131
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+
132pub fn parse(input: &str) -> ParseResult {
148pub fn parse(input: &str) -> ParseResult {
133 parse_with_base(input, None)
149 parse_with_base(input, None)
134}
150}
@@ -174,6 +190,7 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
174 translations: HashMap::new(),
190 translations: HashMap::new(),
175 active_locale: None,
191 active_locale: None,
176 };
192 };
193+ load_prelude(&mut ctx);
177 let nodes = parser.parse_children(0, &mut ctx);
194 let nodes = parser.parse_children(0, &mut ctx);
178 validate_tree(&nodes, None, &mut ctx.diagnostics);
195 validate_tree(&nodes, None, &mut ctx.diagnostics);
179 check_unused(&mut ctx);
196 check_unused(&mut ctx);
@@ -2506,11 +2523,10 @@ impl Parser {
2506 let rest = &content[1 + name.len()..];
2523 let rest = &content[1 + name.len()..];
2507 let rest = rest.trim_start();
2524 let rest = rest.trim_start();
2508
2525
2509− let args = if rest.starts_with('[') {
2526+ let (args, trailing_text) = if rest.starts_with('[') {
2510− let (attrs, _) = parse_attr_brackets_no_validate(rest, line_num, ctx)?;
2527+ parse_attr_brackets_no_validate(rest, line_num, ctx)?
2511− attrs
2512 } else {
2528 } else {
2513− Vec::new()
2529+ (Vec::new(), rest.to_string())
2514 };
2530 };
2515
2531
2516 // Clone function definition (releases borrow on ctx). If the function is
2532 // Clone function definition (releases borrow on ctx). If the function is
@@ -2544,25 +2560,47 @@ impl Parser {
2544 }
2560 }
2545 caller_children.push(child);
2561 caller_children.push(child);
2546 }
2562 }
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+ }
2547
2569
2548 // Save variable state, inject function parameters
2570 // Save variable state, inject function parameters
2549 let saved_vars = ctx.variables.clone();
2571 let saved_vars = ctx.variables.clone();
2572+ let mut consumed = vec![false; args.len()];
2550 for (i, param) in fn_def.params.iter().enumerate() {
2573 for (i, param) in fn_def.params.iter().enumerate() {
2551− let value = args
2552− .iter()
2553− .find(|a| a.key == *param)
2574+ let named = args.iter().position(|a| a.key == *param);
2554− .and_then(|a| a.value.clone())
2555− .or_else(|| {
2556− // Positional fallback, unless that argument is named for
2575+ // Positional fallback, unless that argument is named for a
2557− // a different parameter.
2576+ // different parameter.
2577+ let positional = args
2558− args.get(i)
2578+ .get(i)
2559− .filter(|a| !fn_def.params.contains(&a.key))
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;
2560− .and_then(|a| a.value.clone())
2586+ args[j].value.clone()
2561 })
2587 })
2562 .or_else(|| fn_def.defaults.get(param).cloned())
2588 .or_else(|| fn_def.defaults.get(param).cloned())
2563 .unwrap_or_default();
2589 .unwrap_or_default();
2590+ if let Some(j) = named {
2591+ consumed[j] = true;
2592+ }
2564 ctx.variables.insert(param.clone(), value);
2593 ctx.variables.insert(param.clone(), value);
2565 }
2594 }
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();
2566
2604
2567 // Normalize body indentation so it parses from indent 0
2605 // Normalize body indentation so it parses from indent 0
2568 let min_indent = fn_def
2606 let min_indent = fn_def
@@ -2596,6 +2634,32 @@ impl Parser {
2596 let mut result_nodes =
2634 let mut result_nodes =
2597 replace_children_and_slots(body_nodes, &caller_children, &slot_contents);
2635 replace_children_and_slots(body_nodes, &caller_children, &slot_contents);
2598
2636
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+
2599 // If this is a @component, wrap output in a scoped container
2663 // If this is a @component, wrap output in a scoped container
2600 let scope_key = format!("__component_scope_{}", name);
2664 let scope_key = format!("__component_scope_{}", name);
2601 if let Some(scope_class) = ctx.variables.get(&scope_key).cloned() {
2665 if let Some(scope_class) = ctx.variables.get(&scope_key).cloned() {
@@ -2911,6 +2975,18 @@ const REMOVED_SYNTAX: &[(&str, &str)] = &[
2911 ("@opt", "use `@option`"),
2975 ("@opt", "use `@option`"),
2912];
2976];
2913
2977
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+
2914/// If `content` starts with removed syntax, the error message for it.
2990/// If `content` starts with removed syntax, the error message for it.
2915fn removed_syntax_hint(content: &str) -> Option<String> {
2991fn removed_syntax_hint(content: &str) -> Option<String> {
2916 let trimmed = content.trim_start();
2992 let trimmed = content.trim_start();
@@ -3143,7 +3219,7 @@ const NUMERIC_OR_KEYWORD_ATTRS: &[&str] = &["width", "height"];
3143const SIZE_KEYWORDS: &[&str] = &["fill", "shrink"];
3219const SIZE_KEYWORDS: &[&str] = &["fill", "shrink"];
3144
3220
3145fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext) {
3221fn 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());
3147
3223
3148 if let Some(val) = &attr.value {
3224 if let Some(val) = &attr.value {
3149 if NUMERIC_ATTRS.contains(&base_key) {
3225 if NUMERIC_ATTRS.contains(&base_key) {
@@ -3615,7 +3691,7 @@ fn parse_attr_list(
3615 }
3691 }
3616
3692
3617 // Color validation for hex colors
3693 // 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")
3619 && let Some(ref val) = attr.value
3695 && let Some(ref val) = attr.value
3620 && val.starts_with('#')
3696 && val.starts_with('#')
3621 && !is_valid_hex_color(val)
3697 && !is_valid_hex_color(val)
@@ -3632,8 +3708,19 @@ fn parse_attr_list(
3632
3708
3633 // Warn on unknown attributes
3709 // Warn on unknown attributes
3634 if validate {
3710 if validate {
3635− let base_key = strip_all_prefixes(attr.key.as_str());
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+ });
3636− if !crate::vocab::is_known_attribute(base_key) {
3723+ } else if !crate::vocab::is_known_attribute(base_key) {
3637 let suggestion = suggest_closest(base_key, &crate::vocab::all_attributes());
3724 let suggestion = suggest_closest(base_key, &crate::vocab::all_attributes());
3638 let msg = match suggestion {
3725 let msg = match suggestion {
3639 Some(closest) => {
3726 Some(closest) => {
@@ -4006,79 +4093,6 @@ fn evaluate_arithmetic(input: &str) -> String {
4006// Post-parse validation (context-dependent warnings)
4093// Post-parse validation (context-dependent warnings)
4007// ---------------------------------------------------------------------------
4094// ---------------------------------------------------------------------------
4008
4095
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−
4082/// Attributes that only make sense on container elements (@row, @column, @el).
4096/// Attributes that only make sense on container elements (@row, @column, @el).
4083const CONTAINER_ONLY_ATTRS: &[&str] = &[
4097const CONTAINER_ONLY_ATTRS: &[&str] = &[
4084 "spacing",
4098 "spacing",
@@ -4099,10 +4113,8 @@ fn element_kind_name(kind: &ElementKind) -> String {
4099}
4113}
4100
4114
4101fn is_container(kind: &ElementKind) -> bool {
4115fn is_container(kind: &ElementKind) -> bool {
4102− matches!(
4103− kind,
4104− ElementKind::Row | ElementKind::Column | ElementKind::El | ElementKind::Breadcrumb
4116+ matches!(kind, ElementKind::Row | ElementKind::Column | ElementKind::El)
4105− ) || kind.spec().is_some_and(|spec| spec.container)
4117+ || kind.spec().is_some_and(|spec| spec.container)
4106}
4118}
4107
4119
4108fn validate_tree(
4120fn validate_tree(
@@ -4113,7 +4125,7 @@ fn validate_tree(
4113 for node in nodes {
4125 for node in nodes {
4114 if let Node::Element(elem) = node {
4126 if let Node::Element(elem) = node {
4115 for attr in &elem.attrs {
4127 for attr in &elem.attrs {
4116− let base = strip_all_prefixes(&attr.key);
4128+ let base = crate::vocab::base_attribute(&attr.key);
4117 if base == "width"
4129 if base == "width"
4118 && attr.value.as_deref() == Some("fill")
4130 && attr.value.as_deref() == Some("fill")
4119 && !matches!(parent_kind, Some(ElementKind::Row))
4131 && !matches!(parent_kind, Some(ElementKind::Row))
@@ -4288,12 +4300,12 @@ fn validate_tree(
4288 let bg_color = elem
4300 let bg_color = elem
4289 .attrs
4301 .attrs
4290 .iter()
4302 .iter()
4291− .find(|a| strip_all_prefixes(&a.key) == "background")
4303+ .find(|a| crate::vocab::base_attribute(&a.key) == "background")
4292 .and_then(|a| a.value.as_deref());
4304 .and_then(|a| a.value.as_deref());
4293 let fg_color = elem
4305 let fg_color = elem
4294 .attrs
4306 .attrs
4295 .iter()
4307 .iter()
4296− .find(|a| strip_all_prefixes(&a.key) == "color")
4308+ .find(|a| crate::vocab::base_attribute(&a.key) == "color")
4297 .and_then(|a| a.value.as_deref());
4309 .and_then(|a| a.value.as_deref());
4298 if let (Some(bg), Some(fg)) = (bg_color, fg_color)
4310 if let (Some(bg), Some(fg)) = (bg_color, fg_color)
4299 && let (Some(bg_rgb), Some(fg_rgb)) = (parse_hex_rgb(bg), parse_hex_rgb(fg))
4311 && 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 @@
10/// HTML attribute).
10/// HTML attribute).
11pub const HTMLANG_ATTRIBUTES: &[&str] = &[
11pub const HTMLANG_ATTRIBUTES: &[&str] = &[
12 "align-bottom", "align-left", "align-right", "align-top", "backdrop-blur", "blur", "bold",
12 "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",
13+ "center-x", "center-y", "col-span", "critical", "gap-x", "gap-y", "grid-cols",
14− "grid-rows", "inline", "italic", "margin-x", "margin-y", "no-scrollbar", "ordered",
14+ "grid-rows", "inline", "italic", "margin-x", "margin-y", "ordered",
15− "padding-x", "padding-y", "responsive", "rounded", "row-span", "shadow", "skeleton",
15+ "padding-x", "padding-y", "responsive", "rounded", "row-span", "shadow",
16 "spacing", "truncate", "underline",
16 "spacing", "truncate", "underline",
17];
17];
18
18
@@ -166,6 +166,81 @@ pub fn is_length_property(name: &str) -> bool {
166 || SUFFIXES.iter().any(|s| name.ends_with(s))
166 || SUFFIXES.iter().any(|s| name.ends_with(s))
167}
167}
168
168
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+
169pub fn is_css_property(name: &str) -> bool {
244pub fn is_css_property(name: &str) -> bool {
170 CSS_PROPERTIES.binary_search(&name).is_ok()
245 CSS_PROPERTIES.binary_search(&name).is_ok()
171}
246}
@@ -195,6 +270,15 @@ pub fn all_attributes() -> Vec<&'static str> {
195
270
196#[cfg(test)]
271#[cfg(test)]
197mod tests {
272mod 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+
198 #[test]
282 #[test]
199 fn css_properties_are_sorted() {
283 fn css_properties_are_sorted() {
200 assert!(super::CSS_PROPERTIES.windows(2).all(|w| w[0] < w[1]));
284 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 {
79
79
80 // --- Block rewrites ---
80 // --- Block rewrites ---
81 if attr_depth == 0
81 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)
83 {
83 {
84 out.extend(block.lines);
84 out.extend(block.lines);
85 changes += 1;
85 changes += 1;
@@ -119,7 +119,12 @@ struct Block {
119}
119}
120
120
121/// Rewrites of directives that own an indented block.
121/// Rewrites of directives that own an indented block.
122+fn rewrite_block(
123+ lines: &[&str],
124+ i: usize,
122−fn rewrite_block(lines: &[&str], i: usize, manual: &mut Vec<(usize, String)>) -> Option<Block> {
125+ manual: &mut Vec<(usize, String)>,
126+ user_defined: &[&str],
127+) -> Option<Block> {
123 let line = lines[i];
128 let line = lines[i];
124 let trimmed = line.trim();
129 let trimmed = line.trim();
125 let indent = indent_of(line);
130 let indent = indent_of(line);
@@ -127,6 +132,60 @@ fn rewrite_block(lines: &[&str], i: usize, manual: &mut Vec<(usize, String)>) ->
127 let end = block_end(lines, i);
132 let end = block_end(lines, i);
128 let body = &lines[i + 1..end];
133 let body = &lines[i + 1..end];
129
134
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+
130 // @switch $v / @case x [attrs] → @match $v / @case x / @let __switch [attrs]
189 // @switch $v / @case x [attrs] → @match $v / @case x / @let __switch [attrs]
131 if let Some(rest) = trimmed.strip_prefix("@switch ") {
190 if let Some(rest) = trimmed.strip_prefix("@switch ") {
132 let mut out = vec![format!("{pad}@match {}", rest.trim())];
191 let mut out = vec![format!("{pad}@match {}", rest.trim())];
@@ -391,6 +450,28 @@ fn rewrite_attr_list(list: &str) -> String {
391 out.push_str(ws);
450 out.push_str(ws);
392 // `...$bundle` spread → `$bundle`
451 // `...$bundle` spread → `$bundle`
393 let body = body.strip_prefix("...$").map_or(body.to_string(), |b| format!("${b}"));
452 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+ };
394 // Renamed keys (keeping any `hover:` / `md:` style prefix)
475 // Renamed keys (keeping any `hover:` / `md:` style prefix)
395 let key_end = body.find(char::is_whitespace).unwrap_or(body.len());
476 let key_end = body.find(char::is_whitespace).unwrap_or(body.len());
396 let (key, value) = body.split_at(key_end);
477 let (key, value) = body.split_at(key_end);
@@ -410,6 +491,16 @@ fn rewrite_attr_list(list: &str) -> String {
410 out
491 out
411}
492}
412
493
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+
413fn split_top_level_commas(s: &str) -> Vec<&str> {
504fn split_top_level_commas(s: &str) -> Vec<&str> {
414 let mut parts = Vec::new();
505 let mut parts = Vec::new();
415 let mut depth = 0i32;
506 let mut depth = 0i32;
@@ -618,6 +709,25 @@ mod tests {
618 assert_eq!(up("@text [bold] please animate this"), "@text [bold] please animate this");
709 assert_eq!(up("@text [bold] please animate this"), "@text [bold] please animate this");
619 }
710 }
620
711
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+
621 #[test]
731 #[test]
622 fn verbatim_regions_are_untouched() {
732 fn verbatim_regions_are_untouched() {
623 let src = "@script\n for (x of y) {}\n@raw \"\"\"\n@unless\n\"\"\"\n@style\n .p { }";
733 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() {
2724
2724
2725#[test]
2725#[test]
2726fn element_tooltip() {
2726fn 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");
2728 assert!(
2728 assert!(
2729 output.contains("title=\"Hover for info\""),
2729 output.contains("title=\"Hover for info\""),
2730 "tooltip should have title attr: {}",
2730 "tooltip should have title attr: {}",
@@ -3109,7 +3109,7 @@ fn test_css_shorthands_output() {
3109 html
3109 html
3110 );
3110 );
3111
3111
3112− let result = htmlang::parser::parse("@el [no-scrollbar] Content");
3112+ let result = htmlang::parser::parse("@el [$no-scrollbar] Content");
3113 let html = htmlang::codegen::generate(&result.document);
3113 let html = htmlang::codegen::generate(&result.document);
3114 assert!(
3114 assert!(
3115 html.contains("scrollbar-width:none"),
3115 html.contains("scrollbar-width:none"),
@@ -3117,7 +3117,7 @@ fn test_css_shorthands_output() {
3117 html
3117 html
3118 );
3118 );
3119
3119
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");
3121 let html = htmlang::codegen::generate(&result.document);
3121 let html = htmlang::codegen::generate(&result.document);
3122 assert!(
3122 assert!(
3123 html.contains("hl-skeleton"),
3123 html.contains("hl-skeleton"),
@@ -3130,20 +3130,15 @@ fn test_css_shorthands_output() {
3130 html
3130 html
3131 );
3131 );
3132
3132
3133+ // `gradient` became plain CSS: the compiler points to the replacement.
3133 let result = htmlang::parser::parse("@el [gradient #fff #000] Content");
3134 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);
3143 assert!(
3135 assert!(
3136+ result
3137+ .diagnostics
3138+ .iter()
3144− html.contains("linear-gradient(45deg,#fff,#000)"),
3139+ .any(|d| d.message.contains("linear-gradient") && d.message.contains("upgrade")),
3145− "gradient with angle should work, got: {}",
3140+ "gradient should point to linear-gradient: {:?}",
3146− html
3141+ result.diagnostics
3147 );
3142 );
3148}
3143}
3149
3144
@@ -3633,14 +3628,10 @@ fn search_element() {
3633
3628
3634#[test]
3629#[test]
3635fn breadcrumb_generates_nav_ol() {
3630fn breadcrumb_generates_nav_ol() {
3636− let output = compile("@breadcrumb\n @text Home\n @text About");
3631+ let output = compile("@breadcrumb\n @item > @text Home\n @item About");
3637− assert!(
3638− output.contains("<nav aria-label=\"breadcrumb\">"),
3632+ assert!(output.contains("aria-label=\"breadcrumb\""), "nav: {}", output);
3639− "nav: {}",
3640− output
3641− );
3642− assert!(output.contains("<ol>"), "ol: {}", output);
3633+ assert!(output.contains("<ol class="), "ol: {}", output);
3643− assert!(output.contains("<li>"), "li: {}", output);
3634+ assert!(output.contains("<li class="), "li: {}", output);
3644}
3635}
3645
3636
3646#[test]
3637#[test]
@@ -5327,3 +5318,56 @@ fn markdown_file_missing_reports_error() {
5327 result.diagnostics
5318 result.diagnostics
5328 );
5319 );
5329}
5320}
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 @@
1@breadcrumb [spacing 10]
1@breadcrumb [spacing 10]
2− @link / Home
2+ @item > @link / Home
3− @link /docs Docs
3+ @item > @link /docs Docs
4− @text Current Page
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 @@
17 Frosted glass effect
17 Frosted glass effect
18
18
19 -- No scrollbar
19 -- No scrollbar
20− @el [overflow-x auto, no-scrollbar, max-width 300]
20+ @el [overflow-x auto, $no-scrollbar, max-width 300]
21 @row [spacing 20]
21 @row [spacing 20]
22 @el [width 200, padding 20] Item 1
22 @el [width 200, padding 20] Item 1
23 @el [width 200, padding 20] Item 2
23 @el [width 200, padding 20] Item 2
24 @el [width 200, padding 20] Item 3
24 @el [width 200, padding 20] Item 3
25
25
26 -- Skeleton
26 -- Skeleton
27− @el [width fill, height 20, rounded 4, skeleton]
27+ @el [width fill, height 20, rounded 4, $skeleton]
28
28
29 -- Gradient
29 -- Gradient
30− @el [gradient #3b82f6 #8b5cf6, padding 20, rounded 8]
30+ @el [background linear-gradient(#3b82f6,#8b5cf6), padding 20, rounded 8]
31 @text [color white] Gradient background
31 @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]
33 @text [color white] Angled gradient
33 @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 @@
29
29
30-- Tooltip element
30-- Tooltip element
31@row [spacing 16]
31@row [spacing 16]
32− @tooltip Hover me for info
32+ @tooltip [tip Hover me for info] Hover me for info
33 @text [underline] Helpful text
33 @text [underline] Helpful text
34− @tooltip This is a tooltip message
34+ @tooltip [tip This is a tooltip message] This is a tooltip message
35 @text [bold] Important
35 @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>