on master help sign in
Replace filters with functions and ${expr} interpolation

$x|filter:arg chains become ordinary functions in the expression language (uppercase, lowercase, capitalize, trim, length, reverse, truncate, replace, default, lighten, darken, alpha, mix), next to if(). In text and attribute values, $name inserts a variable and ${EXPR} inserts any expression, so one call syntax works everywhere: ${darken($base, 10)} in an attribute, darken($base, 10) in a condition. The color helpers move into expr.rs; apply_filter is gone. Leftover $x|filter syntax gets a warning with its rewrite, and htmlang upgrade converts filter chains (bare in @if/@assert/@let =, as ${...} elsewhere).

commita67f2004da98365eb187dd692ccd7449d448995d authoriko <mail@iko.soy> dateSat 26 Sep 2026, 02:15 (2 weeks ago) parentb2940e5
10 files changed+381−249
DESIGN.md+13−14 README.md+2−2 crates/htmlang-core/src/expr.rs+168−21 crates/htmlang-core/src/parser.rs+88−186 examples/demo.hl+4−4 src/upgrade.rs+86−3 tests/docs.rs+1 tests/regressions.rs+2−2 tests/snapshots.rs+10−10 tests/snapshots/variable_filters.hl+7−7
DESIGN.md+13−14
@@ -330,18 +330,6 @@ as `var(--name)` in the generated CSS:
330330@el [background $--brand] Themed
331331```
332332
333−### Filters
334−
335−`$name|filter` transforms a value: `uppercase`, `lowercase`, `capitalize`,
336−`trim`, `length`, `reverse`, `truncate:N`, `replace:OLD:NEW`, `default:VALUE`,
337−and for colors `lighten:N`, `darken:N`, `alpha:N`, `mix:COLOR:N`.
338−
339−```
340−@let name htmlang
341−@let base #3b82f6
342−@text [color $base|darken:10] $name|uppercase
343−```
344−
345333### Checks
346334
347335`@assert CONDITION` fails the build when false. `@warn MESSAGE` emits a
@@ -353,16 +341,27 @@ Conditions and computed values (`@let x = ...`) are expressions:
353341
354342| | |
355343|---|---|
356−| Values | numbers, `"strings"` (with `$var` interpolation), `$variables` (with filters), `true`, `false`, and bare words (`dark`, `#fff`) as strings |
344+| Values | numbers, `"strings"` (with `$var` interpolation), `$variables`, `true`, `false`, and bare words (`dark`, `#fff`) as strings |
357345| Arithmetic | `+ - * / %` with the usual precedence, unary `-`, `( )` |
358346| Text | `~` joins two values: `$first ~ " " ~ $last` |
359347| Comparison | `== != < > <= >=` (numeric when both sides are numbers), `contains`, `starts-with`, `ends-with` |
360348| Logic | `and`, `or`, `not`; empty, `false` and `0` are false |
361349| Choice | `if(CONDITION, A, B)` |
350+| Text functions | `uppercase(s)`, `lowercase(s)`, `capitalize(s)`, `trim(s)`, `length(s)`, `reverse(s)`, `truncate(s, n)`, `replace(s, old, new)`, `default(s, fallback)` |
351+| Color functions | `lighten(c, pct)`, `darken(c, pct)`, `alpha(c, a)`, `mix(c1, c2, pct)` |
362352
363353Variables are looked up while evaluating, so a value containing `==` or spaces
364354is still one value. An invalid expression is a compile error.
365355
356+In text and attribute values, `$name` inserts a variable and `${EXPR}` inserts
357+the value of any expression:
358+
359+```
360+@let name htmlang
361+@let base #3b82f6
362+@text [color ${darken($base, 10)}] ${uppercase($name)} has ${length($name)} letters
363+```
364+
366365## Control flow
367366
368367All control flow runs at compile time.
@@ -489,4 +488,4 @@ removed form with its replacement.
489488| `COND ? A : B` | `if(COND, A, B)` |
490489| `...$bundle` | `$bundle` |
491490| `animate`, `inset-area` | `animation`, `position-area` |
492−| `\|upper`, `\|lower`, `\|cap`, `\|len` | `\|uppercase`, `\|lowercase`, `\|capitalize`, `\|length` |
491+| `$x\|uppercase`, `$c\|darken:10` (filters) | `${uppercase($x)}`, `${darken($c, 10)}` |
README.md+2−2
@@ -118,8 +118,8 @@ Control flow runs at compile time:
118118@row [spacing 8]
119119 @each $item in $items
120120 @if $item != About
121− @link /$item|lowercase $item
121+ @link /${lowercase($item)} $item
122122```
123123
124124See [DESIGN.md](DESIGN.md) for the full language: files and data (`@include`,
125−`@extends`, `@data`), page metadata, filters, expressions and CSS.
125+`@extends`, `@data`), page metadata, expressions and CSS.
crates/htmlang-core/src/expr.rs+168−21
@@ -17,10 +17,15 @@
1717//! product := unary (("*" | "/" | "%") unary)*
1818//! unary := "-" unary | primary
1919//! primary := NUMBER | "STRING" | $VAR | WORD | true | false
20−//! | if(expr, expr, expr) | "(" expr ")"
20+//! | NAME "(" expr ("," expr)* ")" | "(" expr ")"
2121//! ```
2222//!
23−//! A bare word (`dark`, `red`, `#3b82f6`, `10px`) is a string.
23+//! A bare word (`dark`, `red`, `#3b82f6`, `10px`) is a string. Functions:
24+//! `if(cond, a, b)`, text (`uppercase`, `lowercase`, `capitalize`, `trim`,
25+//! `length`, `reverse`, `truncate(s, n)`, `replace(s, old, new)`,
26+//! `default(s, fallback)`) and colors (`lighten(c, pct)`, `darken(c, pct)`,
27+//! `alpha(c, a)`, `mix(c1, c2, pct)`). In text, `${expr}` interpolates an
28+//! expression.
2429
2530use std::fmt;
2631
@@ -61,8 +66,8 @@ impl fmt::Display for Value {
6166 }
6267}
6368
64−/// Resolves a variable reference: the text after `$`, including any
65−/// filters (`name|uppercase`). Returns `None` for undefined variables.
69+/// Resolves a variable name (the text after `$`). Returns `None` for
70+/// undefined variables.
6671pub type Resolver<'a> = &'a dyn Fn(&str) -> Option<String>;
6772
6873/// Evaluate `src`. Undefined variables are empty strings.
@@ -137,15 +142,14 @@ fn tokenize(src: &str) -> Result<Vec<Token>, String> {
137142 tokens.push(Token::Str(chars[start..end].iter().collect()));
138143 i = end + 1;
139144 } else if c == '$' {
140− // $name, with optional |filter:args chains
141145 let start = i + 1;
142146 let mut end = start;
143− while end < chars.len()
144− && !chars[end].is_whitespace()
145− && !matches!(chars[end], '(' | ')' | ',' | '"')
146− {
147+ while end < chars.len() && is_var_char(chars[end]) {
147148 end += 1;
148149 }
150+ while end > start && chars[end - 1] == '.' {
151+ end -= 1;
152+ }
149153 if end == start {
150154 return Err(format!("`$` without a variable name in `{}`", src.trim()));
151155 }
@@ -330,15 +334,18 @@ impl Parser<'_> {
330334 Token::Var(name) => Ok(Value::Str((self.resolve)(&name).unwrap_or_default())),
331335 Token::Word(w) if w == "true" => Ok(Value::Bool(true)),
332336 Token::Word(w) if w == "false" => Ok(Value::Bool(false)),
333− Token::Word(w) if w == "if" && self.peek() == Some(&Token::LParen) => {
337+ Token::Word(name) if self.peek() == Some(&Token::LParen) => {
334338 self.pos += 1;
335− let condition = self.expr()?;
336− self.expect(Token::Comma)?;
337− let then = self.expr()?;
338− self.expect(Token::Comma)?;
339− let otherwise = self.expr()?;
339+ let mut args = Vec::new();
340+ if self.peek() != Some(&Token::RParen) {
341+ args.push(self.expr()?);
342+ while self.peek() == Some(&Token::Comma) {
343+ self.pos += 1;
344+ args.push(self.expr()?);
345+ }
346+ }
340347 self.expect(Token::RParen)?;
341− Ok(if condition.truthy() { then } else { otherwise })
348+ call(&name, args)
342349 }
343350 Token::Word(w) => Ok(Value::Str(w)),
344351 Token::LParen => {
@@ -367,6 +374,134 @@ fn arithmetic(op: &str, left: &Value, right: &Value) -> Result<Value, String> {
367374 }))
368375}
369376
377+fn is_var_char(c: char) -> bool {
378+ c.is_alphanumeric() || matches!(c, '_' | '-' | '.')
379+}
380+
381+/// Call a built-in function.
382+fn call(name: &str, args: Vec<Value>) -> Result<Value, String> {
383+ let text = |i: usize| args.get(i).map(|v| v.to_string()).unwrap_or_default();
384+ let num = |i: usize| {
385+ args.get(i)
386+ .and_then(Value::as_num)
387+ .ok_or_else(|| format!("{}() needs a number as argument {}", name, i + 1))
388+ };
389+ let arity = |n: usize| {
390+ if args.len() == n {
391+ Ok(())
392+ } else {
393+ Err(format!("{}() takes {} argument{}", name, n, if n == 1 { "" } else { "s" }))
394+ }
395+ };
396+ let color = |rgb: (u8, u8, u8)| Value::Str(format!("#{:02x}{:02x}{:02x}", rgb.0, rgb.1, rgb.2));
397+ Ok(match name {
398+ "if" => {
399+ arity(3)?;
400+ let mut args = args;
401+ let otherwise = args.pop().unwrap();
402+ let then = args.pop().unwrap();
403+ if args[0].truthy() { then } else { otherwise }
404+ }
405+ "uppercase" => { arity(1)?; Value::Str(text(0).to_uppercase()) }
406+ "lowercase" => { arity(1)?; Value::Str(text(0).to_lowercase()) }
407+ "capitalize" => {
408+ arity(1)?;
409+ let s = text(0);
410+ let mut chars = s.chars();
411+ Value::Str(match chars.next() {
412+ Some(c) => format!("{}{}", c.to_uppercase(), chars.as_str()),
413+ None => String::new(),
414+ })
415+ }
416+ "trim" => { arity(1)?; Value::Str(text(0).trim().to_string()) }
417+ "length" => { arity(1)?; Value::Num(text(0).chars().count() as f64) }
418+ "reverse" => { arity(1)?; Value::Str(text(0).chars().rev().collect()) }
419+ "truncate" => {
420+ arity(2)?;
421+ let (s, n) = (text(0), num(1)? as usize);
422+ Value::Str(if s.chars().count() > n {
423+ format!("{}...", s.chars().take(n).collect::<String>())
424+ } else {
425+ s
426+ })
427+ }
428+ "replace" => { arity(3)?; Value::Str(text(0).replace(&text(1), &text(2))) }
429+ "default" => {
430+ arity(2)?;
431+ let s = text(0);
432+ Value::Str(if s.is_empty() { text(1) } else { s })
433+ }
434+ "lighten" | "darken" | "alpha" | "mix" => {
435+ let expected = if name == "mix" { 3 } else { 2 };
436+ arity(expected)?;
437+ let Some(rgb) = parse_hex_rgb(&text(0)) else {
438+ return Err(format!("{}() needs a hex color, got `{}`", name, text(0)));
439+ };
440+ match name {
441+ "lighten" => color(lighten_color(rgb, num(1)? / 100.0)),
442+ "darken" => color(darken_color(rgb, num(1)? / 100.0)),
443+ "alpha" => {
444+ let a = (num(1)?.clamp(0.0, 1.0) * 255.0) as u8;
445+ Value::Str(format!("#{:02x}{:02x}{:02x}{:02x}", rgb.0, rgb.1, rgb.2, a))
446+ }
447+ _ => {
448+ let Some(other) = parse_hex_rgb(&text(1)) else {
449+ return Err(format!("mix() needs a hex color, got `{}`", text(1)));
450+ };
451+ color(mix_colors(rgb, other, num(2)? / 100.0))
452+ }
453+ }
454+ }
455+ _ => return Err(format!("unknown function `{}()`", name)),
456+ })
457+}
458+
459+pub(crate) fn parse_hex_rgb(s: &str) -> Option<(u8, u8, u8)> {
460+ let s = s.strip_prefix('#')?;
461+ if !s.bytes().all(|b| b.is_ascii_hexdigit()) {
462+ return None;
463+ }
464+ match s.len() {
465+ 3 => {
466+ let r = u8::from_str_radix(&s[0..1], 16).ok()?;
467+ let g = u8::from_str_radix(&s[1..2], 16).ok()?;
468+ let b = u8::from_str_radix(&s[2..3], 16).ok()?;
469+ Some((r * 17, g * 17, b * 17))
470+ }
471+ 6 => {
472+ let r = u8::from_str_radix(&s[0..2], 16).ok()?;
473+ let g = u8::from_str_radix(&s[2..4], 16).ok()?;
474+ let b = u8::from_str_radix(&s[4..6], 16).ok()?;
475+ Some((r, g, b))
476+ }
477+ _ => None,
478+ }
479+}
480+
481+fn lighten_color(rgb: (u8, u8, u8), amount: f64) -> (u8, u8, u8) {
482+ let r = rgb.0 as f64 + (255.0 - rgb.0 as f64) * amount.clamp(0.0, 1.0);
483+ let g = rgb.1 as f64 + (255.0 - rgb.1 as f64) * amount.clamp(0.0, 1.0);
484+ let b = rgb.2 as f64 + (255.0 - rgb.2 as f64) * amount.clamp(0.0, 1.0);
485+ (r.round() as u8, g.round() as u8, b.round() as u8)
486+}
487+
488+fn darken_color(rgb: (u8, u8, u8), amount: f64) -> (u8, u8, u8) {
489+ let factor = 1.0 - amount.clamp(0.0, 1.0);
490+ let r = (rgb.0 as f64 * factor).round() as u8;
491+ let g = (rgb.1 as f64 * factor).round() as u8;
492+ let b = (rgb.2 as f64 * factor).round() as u8;
493+ (r, g, b)
494+}
495+
496+fn mix_colors(c1: (u8, u8, u8), c2: (u8, u8, u8), weight: f64) -> (u8, u8, u8) {
497+ let w = weight.clamp(0.0, 1.0);
498+ let r = (c1.0 as f64 * (1.0 - w) + c2.0 as f64 * w).round() as u8;
499+ let g = (c1.1 as f64 * (1.0 - w) + c2.1 as f64 * w).round() as u8;
500+ let b = (c1.2 as f64 * (1.0 - w) + c2.2 as f64 * w).round() as u8;
501+ (r, g, b)
502+}
503+
504+
370505/// Replace `$name` references inside a string literal.
371506fn interpolate(s: &str, resolve: Resolver) -> String {
372507 let mut out = String::with_capacity(s.len());
@@ -374,9 +509,7 @@ fn interpolate(s: &str, resolve: Resolver) -> String {
374509 while let Some(pos) = rest.find('$') {
375510 out.push_str(&rest[..pos]);
376511 let after = &rest[pos + 1..];
377− let end = after
378− .find(|c: char| !(c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | '|' | ':')))
379− .unwrap_or(after.len());
512+ let end = after.find(|c: char| !is_var_char(c)).unwrap_or(after.len());
380513 if end == 0 {
381514 out.push('$');
382515 } else {
@@ -399,7 +532,6 @@ mod tests {
399532 "theme" => Some("dark".to_string()),
400533 "tricky" => Some("a == b".to_string()),
401534 "empty" => Some(String::new()),
402− "name|uppercase" => Some("WORLD".to_string()),
403535 _ => None,
404536 };
405537 eval(src, &resolve).unwrap()
@@ -421,7 +553,7 @@ mod tests {
421553 assert!(ev("$theme != light and not $empty").truthy());
422554 assert!(!ev("$count < 2 or $empty").truthy());
423555 assert!(ev("$name contains or").truthy());
424− assert!(ev("$name|uppercase == WORLD").truthy());
556+ assert!(ev("uppercase($name) == WORLD").truthy());
425557 assert!(ev("#3b82f6 != red").truthy());
426558 }
427559
@@ -440,6 +572,18 @@ mod tests {
440572 assert_eq!(ev("$missing").to_string(), "");
441573 }
442574
575+ #[test]
576+ fn functions() {
577+ assert_eq!(ev("uppercase($name)").to_string(), "WORLD");
578+ assert_eq!(ev("length($name) + 1").to_string(), "6");
579+ assert_eq!(ev("truncate($name, 2)").to_string(), "Wo...");
580+ assert_eq!(ev("replace($name, o, 0)").to_string(), "W0rld");
581+ assert_eq!(ev("default($empty, none)").to_string(), "none");
582+ assert_eq!(ev("darken(#ffffff, 50)").to_string(), "#808080");
583+ assert_eq!(ev("mix(#000000, #ffffff, 50)").to_string(), "#808080");
584+ assert_eq!(ev("alpha(#3b82f6, 0.5)").to_string(), "#3b82f67f");
585+ }
586+
443587 #[test]
444588 fn errors_are_reported() {
445589 let none = |_: &str| None;
@@ -447,5 +591,8 @@ mod tests {
447591 assert!(eval("1 +", &none).is_err());
448592 assert!(eval("(1", &none).is_err());
449593 assert!(eval("1 / 0", &none).is_err());
594+ assert!(eval("nope(1)", &none).is_err());
595+ assert!(eval("uppercase(a, b)", &none).is_err());
596+ assert!(eval("darken(red, 10)", &none).is_err());
450597 }
451598}
crates/htmlang-core/src/parser.rs+88−186
@@ -121,11 +121,7 @@ impl ParseContext {
121121 fn eval(&mut self, src: &str, line: usize) -> Option<crate::expr::Value> {
122122 track_var_refs(src, &mut self.used_variables);
123123 let vars = &self.variables;
124− let resolve = |reference: &str| {
125− let name = reference.split('|').next().unwrap_or(reference);
126− vars.contains_key(name)
127− .then(|| substitute_vars(&format!("${}", reference), vars))
128− };
124+ let resolve = |name: &str| vars.get(name).cloned();
129125 let result = crate::expr::eval(src, &resolve);
130126 match result {
131127 Ok(value) => Some(value),
@@ -488,6 +484,20 @@ impl Parser {
488484 fn parse_line(&mut self, ctx: &mut ParseContext) -> Result<Option<Vec<Node>>, ParseError> {
489485 let line_num = self.lines[self.pos].line_num;
490486 ctx.current_line = line_num;
487+ if let LineContent::Normal(text) = &self.lines[self.pos].content
488+ && let Some(filter) = old_filter_syntax(text)
489+ {
490+ ctx.diagnostics.push(Diagnostic {
491+ line: line_num,
492+ column: None,
493+ message: format!(
494+ "`${}|{}` filters are functions now: write `${{{}(${})}}` (run `htmlang upgrade`)",
495+ filter.0, filter.1, filter.1, filter.0
496+ ),
497+ severity: Severity::Warning,
498+ source_line: Some(text.clone()),
499+ });
500+ }
491501 let current_indent = self.lines[self.pos].indent;
492502
493503 // Handle raw content
@@ -3629,7 +3639,7 @@ fn validate_tree(
36293639 .find(|a| crate::vocab::base_attribute(&a.key) == "color")
36303640 .and_then(|a| a.value.as_deref());
36313641 if let (Some(bg), Some(fg)) = (bg_color, fg_color)
3632− && let (Some(bg_rgb), Some(fg_rgb)) = (parse_hex_rgb(bg), parse_hex_rgb(fg))
3642+ && let (Some(bg_rgb), Some(fg_rgb)) = (crate::expr::parse_hex_rgb(bg), crate::expr::parse_hex_rgb(fg))
36333643 {
36343644 let ratio = contrast_ratio(bg_rgb, fg_rgb);
36353645 if ratio < 4.5 {
@@ -3741,28 +3751,6 @@ fn validate_tree(
37413751 }
37423752}
37433753
3744−fn parse_hex_rgb(s: &str) -> Option<(u8, u8, u8)> {
3745− let s = s.strip_prefix('#')?;
3746− if !s.bytes().all(|b| b.is_ascii_hexdigit()) {
3747− return None;
3748− }
3749− match s.len() {
3750− 3 => {
3751− let r = u8::from_str_radix(&s[0..1], 16).ok()?;
3752− let g = u8::from_str_radix(&s[1..2], 16).ok()?;
3753− let b = u8::from_str_radix(&s[2..3], 16).ok()?;
3754− Some((r * 17, g * 17, b * 17))
3755− }
3756− 6 => {
3757− let r = u8::from_str_radix(&s[0..2], 16).ok()?;
3758− let g = u8::from_str_radix(&s[2..4], 16).ok()?;
3759− let b = u8::from_str_radix(&s[4..6], 16).ok()?;
3760− Some((r, g, b))
3761− }
3762− _ => None,
3763− }
3764−}
3765−
37663754fn relative_luminance(r: u8, g: u8, b: u8) -> f64 {
37673755 fn linearize(c: u8) -> f64 {
37683756 let s = c as f64 / 255.0;
@@ -3782,101 +3770,96 @@ fn contrast_ratio(c1: (u8, u8, u8), c2: (u8, u8, u8)) -> f64 {
37823770 (lighter + 0.05) / (darker + 0.05)
37833771}
37843772
3785−fn lighten_color(rgb: (u8, u8, u8), amount: f64) -> (u8, u8, u8) {
3786− let r = rgb.0 as f64 + (255.0 - rgb.0 as f64) * amount.clamp(0.0, 1.0);
3787− let g = rgb.1 as f64 + (255.0 - rgb.1 as f64) * amount.clamp(0.0, 1.0);
3788− let b = rgb.2 as f64 + (255.0 - rgb.2 as f64) * amount.clamp(0.0, 1.0);
3789− (r.round() as u8, g.round() as u8, b.round() as u8)
3790−}
3791−
3792−fn darken_color(rgb: (u8, u8, u8), amount: f64) -> (u8, u8, u8) {
3793− let factor = 1.0 - amount.clamp(0.0, 1.0);
3794− let r = (rgb.0 as f64 * factor).round() as u8;
3795− let g = (rgb.1 as f64 * factor).round() as u8;
3796− let b = (rgb.2 as f64 * factor).round() as u8;
3797− (r, g, b)
3798−}
3799−
3800−fn mix_colors(c1: (u8, u8, u8), c2: (u8, u8, u8), weight: f64) -> (u8, u8, u8) {
3801− let w = weight.clamp(0.0, 1.0);
3802− let r = (c1.0 as f64 * (1.0 - w) + c2.0 as f64 * w).round() as u8;
3803− let g = (c1.1 as f64 * (1.0 - w) + c2.1 as f64 * w).round() as u8;
3804− let b = (c1.2 as f64 * (1.0 - w) + c2.2 as f64 * w).round() as u8;
3805− (r, g, b)
3806−}
3807−
38083773// ---------------------------------------------------------------------------
38093774// Variable substitution
38103775// ---------------------------------------------------------------------------
38113776
3777+/// Interpolate `$name` variables and `${expr}` expressions into text.
3778+/// Undefined variables, and expressions that don't evaluate, are left as
3779+/// written so they show up in the output.
38123780fn substitute_vars(input: &str, vars: &HashMap<String, String>) -> String {
38133781 if !input.contains('$') {
38143782 return input.to_string();
38153783 }
3816−
3817− let mut result = String::new();
3818− let chars: Vec<char> = input.chars().collect();
3819− let mut i = 0;
3820−
3821− while i < chars.len() {
3822− if chars[i] == '$'
3823− && i + 1 < chars.len()
3824− && (chars[i + 1].is_alphanumeric() || chars[i + 1] == '_' || chars[i + 1] == '-')
3784+ let is_name_char = |c: char| c.is_alphanumeric() || matches!(c, '_' | '-' | '.');
3785+ let mut result = String::with_capacity(input.len());
3786+ let mut rest = input;
3787+ while let Some(pos) = rest.find('$') {
3788+ result.push_str(&rest[..pos]);
3789+ let after = &rest[pos + 1..];
3790+ if after.starts_with('{')
3791+ && let Some(close) = matching_brace(after)
38253792 {
3826− let start = i + 1;
3827− let mut end = start;
3828− while end < chars.len()
3829− && (chars[end].is_alphanumeric()
3830− || chars[end] == '-'
3831− || chars[end] == '_'
3832− || chars[end] == '.')
3833− {
3834− end += 1;
3793+ let source = &after[1..close];
3794+ let resolve = |name: &str| vars.get(name).cloned();
3795+ match crate::expr::eval(source, &resolve) {
3796+ Ok(value) => result.push_str(&value.to_string()),
3797+ Err(_) => result.push_str(&rest[pos..pos + 1 + close + 1]),
38353798 }
3836− // Strip trailing dot (not part of name if at end)
3837− while end > start && chars[end - 1] == '.' {
3838− end -= 1;
3799+ rest = &after[close + 1..];
3800+ continue;
3801+ }
3802+ let mut end = after.find(|c: char| !is_name_char(c)).unwrap_or(after.len());
3803+ while end > 0 && after[..end].ends_with('.') {
3804+ end -= 1;
3805+ }
3806+ let name = &after[..end];
3807+ match vars.get(name) {
3808+ Some(value) if !name.is_empty() => result.push_str(value),
3809+ _ => {
3810+ result.push('$');
3811+ result.push_str(name);
38393812 }
3840− let name: String = chars[start..end].iter().collect();
3813+ }
3814+ rest = &after[end..];
3815+ }
3816+ result.push_str(rest);
3817+ result
3818+}
38413819
3842− // Collect pipe filters: $name|filter1|filter2:arg
3843− let mut filters: Vec<String> = Vec::new();
3844− while end < chars.len() && chars[end] == '|' {
3845− end += 1; // skip '|'
3846− let filter_start = end;
3847− while end < chars.len()
3848− && chars[end] != '|'
3849− && chars[end] != ' '
3850− && chars[end] != ','
3851− && chars[end] != ']'
3852− && chars[end] != '}'
3853− {
3854− end += 1;
3855− }
3856− let filter: String = chars[filter_start..end].iter().collect();
3857− if !filter.is_empty() {
3858− filters.push(filter);
3859− }
3820+/// The first `$name|filter` (old filter syntax) in `text`, as (name, filter).
3821+fn old_filter_syntax(text: &str) -> Option<(String, String)> {
3822+ const FILTERS: &[&str] = &[
3823+ "uppercase", "lowercase", "capitalize", "trim", "length", "reverse", "truncate",
3824+ "replace", "default", "lighten", "darken", "alpha", "mix",
3825+ ];
3826+ let mut rest = text;
3827+ while let Some(pos) = rest.find('$') {
3828+ let after = &rest[pos + 1..];
3829+ let end = after
3830+ .find(|c: char| !(c.is_alphanumeric() || matches!(c, '_' | '-' | '.')))
3831+ .unwrap_or(after.len());
3832+ if let Some(filter) = after[end..].strip_prefix('|') {
3833+ let name_end = filter.find(|c: char| !c.is_ascii_alphabetic()).unwrap_or(filter.len());
3834+ if end > 0 && FILTERS.contains(&&filter[..name_end]) {
3835+ return Some((after[..end].to_string(), filter[..name_end].to_string()));
38603836 }
3837+ }
3838+ rest = &after[end..];
3839+ }
3840+ None
3841+}
38613842
3862− if let Some(value) = vars.get(&name) {
3863− let mut val = value.clone();
3864− for filter in &filters {
3865− val = apply_filter(&val, filter);
3843+/// Index of the `}` matching the `{` that `s` starts with (skipping
3844+/// braces inside string literals).
3845+fn matching_brace(s: &str) -> Option<usize> {
3846+ let mut depth = 0;
3847+ let mut in_string = false;
3848+ for (i, c) in s.char_indices() {
3849+ match c {
3850+ '"' => in_string = !in_string,
3851+ _ if in_string => {}
3852+ '{' => depth += 1,
3853+ '}' => {
3854+ depth -= 1;
3855+ if depth == 0 {
3856+ return Some(i);
38663857 }
3867− result.push_str(&val);
3868− } else {
3869− result.push('$');
3870− result.push_str(&name);
38713858 }
3872− i = end;
3873− } else {
3874− result.push(chars[i]);
3875− i += 1;
3859+ _ => {}
38763860 }
38773861 }
3878−
3879− result
3862+ None
38803863}
38813864
38823865/// Parse a keyframe line in htmlang syntax: `from [opacity 0]` / `50% [transform scale(1.5)]`
@@ -3920,87 +3903,6 @@ fn parse_keyframe_line(line: &str) -> Option<String> {
39203903 Some(format!("{}{{{}}}", selector, css))
39213904}
39223905
3923−fn apply_filter(value: &str, filter: &str) -> String {
3924− if let Some(arg) = filter.strip_prefix("truncate:") {
3925− if let Ok(n) = arg.parse::<usize>()
3926− && value.chars().count() > n
3927− {
3928− return format!("{}...", value.chars().take(n).collect::<String>());
3929− }
3930− return value.to_string();
3931− }
3932− if let Some(rest) = filter.strip_prefix("replace:") {
3933− if let Some((old, new)) = rest.split_once(':') {
3934− return value.replace(old, new);
3935− }
3936− return value.to_string();
3937− }
3938− if let Some(arg) = filter.strip_prefix("default:") {
3939− if value.is_empty() {
3940− return arg.to_string();
3941− }
3942− return value.to_string();
3943− }
3944− // Color functions: lighten:N, darken:N, alpha:N, mix:COLOR:N
3945− if let Some(arg) = filter.strip_prefix("lighten:") {
3946− if let Ok(amount) = arg.parse::<f64>()
3947− && let Some(rgb) = parse_hex_rgb(value)
3948− {
3949− let (r, g, b) = lighten_color(rgb, amount / 100.0);
3950− return format!("#{:02x}{:02x}{:02x}", r, g, b);
3951− }
3952− return value.to_string();
3953− }
3954− if let Some(arg) = filter.strip_prefix("darken:") {
3955− if let Ok(amount) = arg.parse::<f64>()
3956− && let Some(rgb) = parse_hex_rgb(value)
3957− {
3958− let (r, g, b) = darken_color(rgb, amount / 100.0);
3959− return format!("#{:02x}{:02x}{:02x}", r, g, b);
3960− }
3961− return value.to_string();
3962− }
3963− if let Some(arg) = filter.strip_prefix("alpha:") {
3964− if let Ok(a) = arg.parse::<f64>()
3965− && let Some((r, g, b)) = parse_hex_rgb(value)
3966− {
3967− let a8 = (a.clamp(0.0, 1.0) * 255.0) as u8;
3968− return format!("#{:02x}{:02x}{:02x}{:02x}", r, g, b, a8);
3969− }
3970− return value.to_string();
3971− }
3972− if let Some(arg) = filter.strip_prefix("mix:") {
3973− // mix:COLOR:PERCENTAGE (e.g., mix:#ffffff:50)
3974− let parts: Vec<&str> = arg.splitn(2, ':').collect();
3975− if parts.len() == 2
3976− && let (Some(c1), Some(c2), Ok(pct)) = (
3977− parse_hex_rgb(value),
3978− parse_hex_rgb(parts[0]),
3979− parts[1].parse::<f64>(),
3980− )
3981− {
3982− let (r, g, b) = mix_colors(c1, c2, pct / 100.0);
3983− return format!("#{:02x}{:02x}{:02x}", r, g, b);
3984− }
3985− return value.to_string();
3986− }
3987− match filter {
3988− "uppercase" => value.to_uppercase(),
3989− "lowercase" => value.to_lowercase(),
3990− "capitalize" => {
3991− let mut chars = value.chars();
3992− match chars.next() {
3993− Some(c) => format!("{}{}", c.to_uppercase(), chars.as_str()),
3994− None => String::new(),
3995− }
3996− }
3997− "trim" => value.trim().to_string(),
3998− "length" => value.chars().count().to_string(),
3999− "reverse" => value.chars().rev().collect(),
4000− _ => value.to_string(),
4001− }
4002−}
4003−
40043906// ---------------------------------------------------------------------------
40053907// Variable usage tracking
40063908// ---------------------------------------------------------------------------
examples/demo.hl+4−4
@@ -223,8 +223,8 @@
223223 type=submit,
224224 padding 10 16, background $primary, color white, bold,
225225 rounded 6, border 0,
226− hover:background $primary|darken:10,
227− active:background $primary|darken:20,
226+ hover:background ${darken($primary, 10)},
227+ active:background ${darken($primary, 20)},
228228 transition background 0.15s ease,
229229 cursor pointer
230230 ] Subscribe
@@ -317,8 +317,8 @@
317317 @row [spacing $base, align-items center]
318318 @el [
319319 padding 12 20, background $primary, color white, bold, rounded 8,
320− hover:background $primary|darken:10,
321− active:background $primary|darken:20,
320+ hover:background ${darken($primary, 10)},
321+ active:background ${darken($primary, 20)},
322322 transition all 0.15s ease,
323323 cursor pointer
324324 ] > @link https://github.com
src/upgrade.rs+86−3
@@ -92,7 +92,7 @@ pub fn upgrade(input: &str) -> Upgrade {
9292 // The directive line is upgraded like any other; its body is not.
9393 let end = block_end(&lines, i);
9494 let mut header = rewrite_attr_regions(line, &mut attr_depth, &mut true, &user_defined);
95− header = rename_filters(&header);
95+ header = convert_filters(&header);
9696 if header != line {
9797 changes += 1;
9898 }
@@ -123,7 +123,7 @@ pub fn upgrade(input: &str) -> Upgrade {
123123 }
124124 new_line =
125125 rewrite_attr_regions(&new_line, &mut attr_depth, &mut element_attrs, &user_defined);
126− new_line = rename_filters(&new_line);
126+ new_line = convert_filters(&new_line);
127127 if new_line != line {
128128 changes += 1;
129129 }
@@ -990,6 +990,76 @@ fn rename_filters(line: &str) -> String {
990990 out
991991}
992992
993+/// Turn `$name|filter:arg|...` chains into function calls: bare in
994+/// expressions (`@if`, `@else if`, `@assert`, `@let x = ...`), and as
995+/// `${...}` interpolation everywhere else.
996+fn convert_filters(line: &str) -> String {
997+ let line = rename_filters(line);
998+ if !line.contains('|') {
999+ return line;
1000+ }
1001+ let trimmed = line.trim_start();
1002+ let expression_line = ["@if ", "@else if ", "@assert "]
1003+ .iter()
1004+ .any(|p| trimmed.starts_with(p))
1005+ || trimmed
1006+ .strip_prefix("@let ")
1007+ .and_then(|r| r.split_once(' '))
1008+ .is_some_and(|(_, v)| v.trim_start().starts_with('='));
1009+ let is_name = |c: char| c.is_alphanumeric() || matches!(c, '_' | '-' | '.');
1010+ let mut out = String::with_capacity(line.len() + 8);
1011+ let mut rest = line.as_str();
1012+ while let Some(pos) = rest.find('$') {
1013+ out.push_str(&rest[..pos]);
1014+ let after = &rest[pos + 1..];
1015+ let name_end = after.find(|c: char| !is_name(c)).unwrap_or(after.len());
1016+ let mut expr = format!("${}", &after[..name_end]);
1017+ let mut tail = &after[name_end..];
1018+ let mut converted = false;
1019+ while name_end > 0 && tail.starts_with('|') {
1020+ // A filter is a name (letters) with optional `:arg` parts.
1021+ let body = &tail[1..];
1022+ let name_len = body
1023+ .find(|c: char| !(c.is_ascii_alphabetic() || c == '-'))
1024+ .unwrap_or(body.len());
1025+ let mut filter_end = 1 + name_len;
1026+ while tail[filter_end..].starts_with(':') {
1027+ let arg = &tail[filter_end + 1..];
1028+ let arg_len = arg
1029+ .find(|c: char| matches!(c, ':' | '|' | ',' | ']' | '}' | ')' | '!' | '?' | ';') || c.is_whitespace())
1030+ .unwrap_or(arg.len());
1031+ filter_end += 1 + arg_len;
1032+ }
1033+ let mut parts = tail[1..filter_end].split(':');
1034+ let name = parts.next().unwrap_or("");
1035+ let args: Vec<String> = parts
1036+ .map(|a| {
1037+ if a.parse::<f64>().is_ok() || a.starts_with('#') {
1038+ a.to_string()
1039+ } else {
1040+ format!("\"{}\"", a)
1041+ }
1042+ })
1043+ .collect();
1044+ expr = if args.is_empty() {
1045+ format!("{}({})", name, expr)
1046+ } else {
1047+ format!("{}({}, {})", name, expr, args.join(", "))
1048+ };
1049+ converted = true;
1050+ tail = &tail[filter_end..];
1051+ }
1052+ if converted && !expression_line {
1053+ out.push_str(&format!("${{{}}}", expr));
1054+ } else {
1055+ out.push_str(&expr);
1056+ }
1057+ rest = tail;
1058+ }
1059+ out.push_str(rest);
1060+ out
1061+}
1062+
9931063/// Replace whole-token `$name` references with `value`.
9941064fn replace_var(line: &str, name: &str, value: &str) -> String {
9951065 let needle = format!("${name}");
@@ -1150,7 +1220,7 @@ mod tests {
11501220 "@el [$card, animation fade 1s, hover:position-area top] x"
11511221 );
11521222 assert_eq!(up("@el [\n animate spin 1s\n]"), "@el [\n animation spin 1s\n]");
1153− assert_eq!(up("@text $name|upper|len"), "@text $name|uppercase|length");
1223+ assert_eq!(up("@text $name|upper|len"), "@text ${length(uppercase($name))}");
11541224 // Text that merely mentions a renamed word is untouched.
11551225 assert_eq!(up("@text [bold] please animate this"), "@text [bold] please animate this");
11561226 }
@@ -1268,6 +1338,19 @@ mod tests {
12681338 assert_eq!(upgrade("@breakpoint tablet 600").manual.len(), 1);
12691339 }
12701340
1341+ #[test]
1342+ fn filters_become_functions() {
1343+ assert_eq!(up("@text $name|uppercase!"), "@text ${uppercase($name)}!");
1344+ assert_eq!(
1345+ up("@el [color $base|darken:10, background $base|mix:#ffffff:50]"),
1346+ "@el [color ${darken($base, 10)}, background ${mix($base, #ffffff, 50)}]"
1347+ );
1348+ assert_eq!(up("@if $name|length > 3\n x"), "@if length($name) > 3\n x");
1349+ assert_eq!(up("@let short = $title|truncate:5"), "@let short = truncate($title, 5)");
1350+ assert_eq!(up("Hi $who|default:friend"), "Hi ${default($who, \"friend\")}");
1351+ assert_eq!(up("a | b $x | c"), "a | b $x | c");
1352+ }
1353+
12711354 #[test]
12721355 fn verbatim_regions_are_untouched() {
12731356 let src = "@script\n for (x of y) {}\n@raw \"\"\"\n@unless\n\"\"\"\n@style\n .p { }";
tests/docs.rs+1
@@ -74,6 +74,7 @@ fn check_doc(file: &str) {
7474 || d.message.contains("is an HTML attribute")
7575 || d.message.contains("was removed")
7676 || d.message.contains("no single root")
77+ || d.message.contains("filters are functions now")
7778 })
7879 .map(|d| format!("line {}: {}", d.line, d.message))
7980 .collect();
tests/regressions.rs+2−2
@@ -33,13 +33,13 @@ fn compile_in(dir: &std::path::Path, input: &str) -> String {
3333
3434#[test]
3535fn truncate_filter_handles_multibyte_text() {
36− let out = compile("@let s héllo wörld\n@text $s|truncate:2");
36+ let out = compile("@let s héllo wörld\n@text ${truncate($s, 2)}");
3737 assert!(out.contains("hé..."), "{}", out);
3838}
3939
4040#[test]
4141fn length_filter_counts_characters() {
42− let out = compile("@let s héllo\n@text $s|length");
42+ let out = compile("@let s héllo\n@text ${length($s)}");
4343 assert!(out.contains(">5<"), "{}", out);
4444}
4545
tests/snapshots.rs+10−10
@@ -2970,7 +2970,7 @@ fn snapshot_css_shorthands() {
29702970
29712971#[test]
29722972fn test_variable_filters() {
2973− let result = htmlang::parser::parse("@let name hello\n@text $name|uppercase");
2973+ let result = htmlang::parser::parse("@let name hello\n@text ${uppercase($name)}");
29742974 assert!(
29752975 result
29762976 .diagnostics
@@ -2984,7 +2984,7 @@ fn test_variable_filters() {
29842984 html
29852985 );
29862986
2987− let result = htmlang::parser::parse("@let name HELLO\n@text $name|lowercase");
2987+ let result = htmlang::parser::parse("@let name HELLO\n@text ${lowercase($name)}");
29882988 let html = htmlang::codegen::generate(&result.document);
29892989 assert!(
29902990 html.contains("hello"),
@@ -2992,7 +2992,7 @@ fn test_variable_filters() {
29922992 html
29932993 );
29942994
2995− let result = htmlang::parser::parse("@let name hello\n@text $name|capitalize");
2995+ let result = htmlang::parser::parse("@let name hello\n@text ${capitalize($name)}");
29962996 let html = htmlang::codegen::generate(&result.document);
29972997 assert!(
29982998 html.contains("Hello"),
@@ -3000,7 +3000,7 @@ fn test_variable_filters() {
30003000 html
30013001 );
30023002
3003− let result = htmlang::parser::parse("@let name hello\n@text $name|length");
3003+ let result = htmlang::parser::parse("@let name hello\n@text ${length($name)}");
30043004 let html = htmlang::codegen::generate(&result.document);
30053005 assert!(
30063006 html.contains("5"),
@@ -3008,7 +3008,7 @@ fn test_variable_filters() {
30083008 html
30093009 );
30103010
3011− let result = htmlang::parser::parse("@let name hello\n@text $name|reverse");
3011+ let result = htmlang::parser::parse("@let name hello\n@text ${reverse($name)}");
30123012 let html = htmlang::codegen::generate(&result.document);
30133013 assert!(
30143014 html.contains("olleh"),
@@ -3016,7 +3016,7 @@ fn test_variable_filters() {
30163016 html
30173017 );
30183018
3019− let result = htmlang::parser::parse("@let name hello world\n@text $name|truncate:5");
3019+ let result = htmlang::parser::parse("@let name hello world\n@text ${truncate($name, 5)}");
30203020 let html = htmlang::codegen::generate(&result.document);
30213021 assert!(
30223022 html.contains("hello..."),
@@ -3249,7 +3249,7 @@ fn test_extends_directive() {
32493249#[test]
32503250fn test_color_filter_lighten() {
32513251 let result = htmlang::parser::parse(
3252− "@let primary #3b82f6\n@el [background $primary|lighten:20] Content",
3252+ "@let primary #3b82f6\n@el [background ${lighten($primary, 20)}] Content",
32533253 );
32543254 let html = htmlang::codegen::generate(&result.document);
32553255 // Lighten #3b82f6 by 20% should produce a lighter blue
@@ -3268,7 +3268,7 @@ fn test_color_filter_lighten() {
32683268#[test]
32693269fn test_color_filter_darken() {
32703270 let result =
3271− htmlang::parser::parse("@let primary #ffffff\n@el [background $primary|darken:50] Content");
3271+ htmlang::parser::parse("@let primary #ffffff\n@el [background ${darken($primary, 50)}] Content");
32723272 let html = htmlang::codegen::generate(&result.document);
32733273 // Darken white by 50% should produce gray (#808080 approximately)
32743274 assert!(
@@ -3285,7 +3285,7 @@ fn test_color_filter_darken() {
32853285#[test]
32863286fn test_color_filter_alpha() {
32873287 let result =
3288− htmlang::parser::parse("@let primary #3b82f6\n@el [background $primary|alpha:0.5] Content");
3288+ htmlang::parser::parse("@let primary #3b82f6\n@el [background ${alpha($primary, 0.5)}] Content");
32893289 let html = htmlang::codegen::generate(&result.document);
32903290 // Should produce 8-digit hex with alpha
32913291 assert!(
@@ -3298,7 +3298,7 @@ fn test_color_filter_alpha() {
32983298#[test]
32993299fn test_color_filter_mix() {
33003300 let result = htmlang::parser::parse(
3301− "@let primary #000000\n@el [background $primary|mix:#ffffff:50] Content",
3301+ "@let primary #000000\n@el [background ${mix($primary, #ffffff, 50)}] Content",
33023302 );
33033303 let html = htmlang::codegen::generate(&result.document);
33043304 // Mix black and white at 50% should produce gray
tests/snapshots/variable_filters.hl+7−7
@@ -3,10 +3,10 @@
33@let greeting hi
44
55@column [spacing 10]
6− @text $name|uppercase
7− @text $name|lowercase
8− @text $name|capitalize
9− @text $name|length
10− @text $name|reverse
11− @text $name|truncate:5
12− @text $greeting|default:fallback
6+ @text ${uppercase($name)}
7+ @text ${lowercase($name)}
8+ @text ${capitalize($name)}
9+ @text ${length($name)}
10+ @text ${reverse($name)}
11+ @text ${truncate($name, 5)}
12+ @text ${default($greeting, "fallback")}