@@ -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))