@@ -129,6 +129,22 @@ struct Parser {
129 pos: usize,
129 pos: usize,
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");
136+fn load_prelude(ctx: &mut ParseContext) {
137+ let mut prelude = Parser {
138+ lines: preprocess(PRELUDE),
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();
132pub fn parse(input: &str) -> ParseResult {
148pub fn parse(input: &str) -> ParseResult {
133 parse_with_base(input, None)
149 parse_with_base(input, None)
@@ -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,
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();
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)?
2512 } else {
2528 } else {
2513− Vec::new()
2529+ (Vec::new(), rest.to_string())
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 {
2545 caller_children.push(child);
2561 caller_children.push(child);
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)));
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() {
2553− .find(|a| a.key == *param)
2574+ let named = args.iter().position(|a| a.key == *param);
2554− .and_then(|a| a.value.clone())
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())
2582+ .filter(|&j| args[j].value.is_some())
2585+ consumed[j] = true;
2560− .and_then(|a| a.value.clone())
2586+ args[j].value.clone()
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;
2564 ctx.variables.insert(param.clone(), value);
2593 ctx.variables.insert(param.clone(), value);
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
2601+ .filter(|&(_, &used)| !used)
2602+ .map(|(a, _)| a.clone())
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);
2637+ if !forwarded.is_empty() {
2638+ let mut roots = result_nodes.iter_mut().filter_map(|n| match n {
2639+ Node::Element(e) => Some(e),
2642+ match (roots.next(), roots.next()) {
2643+ (Some(root), None) => root.attrs.extend(forwarded),
2644+ _ => ctx.diagnostics.push(Diagnostic {
2648+ "attributes {} on @{} are not parameters, and its body has no single \
2649+ root element to receive them",
2652+ .map(|a| format!("'{}'", a.key))
2653+ .collect::<Vec<_>>()
2657+ severity: Severity::Warning,
2658+ source_line: Some(content.to_string()),
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`"),
2978+/// Attributes that became standard-library bundles or plain CSS.
2979+fn removed_attribute_hint(name: &str) -> Option<&'static str> {
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`"),
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"];
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());
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(
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(
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 {
3717+ "attribute '{}' was removed: {} (run `htmlang upgrade`)",
3720+ severity: Severity::Warning,
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// ---------------------------------------------------------------------------
4009−fn strip_all_prefixes(key: &str) -> &str {
4010− // Strip pseudo-state prefixes
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:"))
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..])
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..])
4053− // Strip responsive prefixes
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:"))
4061− // Strip media prefixes
4063− .strip_prefix("dark:")
4064− .or_else(|| key.strip_prefix("print:"))
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:"))
4072− // Strip container query prefixes (cq-sm:, cq-md:, etc.)
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 _
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 {
4101fn is_container(kind: &ElementKind) -> bool {
4115fn is_container(kind: &ElementKind) -> bool {
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)
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
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
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))