on master help sign in
Replace filters with functions and ${expr} interpolation
a67f200iko
DESIGN.md+13−14
@@ -330,18 +330,6 @@ as `var(--name)` in the generated CSS:
330@el [background $--brand] Themed
330@el [background $--brand] Themed
331```
331```
332
332
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−
345### Checks
333### Checks
346
334
347`@assert CONDITION` fails the build when false. `@warn MESSAGE` emits a
335`@assert CONDITION` fails the build when false. `@warn MESSAGE` emits a
@@ -353,16 +341,27 @@ Conditions and computed values (`@let x = ...`) are expressions:
353
341
354| | |
342| | |
355|---|---|
343|---|---|
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 |
357| Arithmetic | `+ - * / %` with the usual precedence, unary `-`, `( )` |
345| Arithmetic | `+ - * / %` with the usual precedence, unary `-`, `( )` |
358| Text | `~` joins two values: `$first ~ " " ~ $last` |
346| Text | `~` joins two values: `$first ~ " " ~ $last` |
359| Comparison | `== != < > <= >=` (numeric when both sides are numbers), `contains`, `starts-with`, `ends-with` |
347| Comparison | `== != < > <= >=` (numeric when both sides are numbers), `contains`, `starts-with`, `ends-with` |
360| Logic | `and`, `or`, `not`; empty, `false` and `0` are false |
348| Logic | `and`, `or`, `not`; empty, `false` and `0` are false |
361| Choice | `if(CONDITION, A, B)` |
349| 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)` |
362
352
363Variables are looked up while evaluating, so a value containing `==` or spaces
353Variables are looked up while evaluating, so a value containing `==` or spaces
364is still one value. An invalid expression is a compile error.
354is still one value. An invalid expression is a compile error.
365
355
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+
366## Control flow
365## Control flow
367
366
368All control flow runs at compile time.
367All control flow runs at compile time.
@@ -489,4 +488,4 @@ removed form with its replacement.
489| `COND ? A : B` | `if(COND, A, B)` |
488| `COND ? A : B` | `if(COND, A, B)` |
490| `...$bundle` | `$bundle` |
489| `...$bundle` | `$bundle` |
491| `animate`, `inset-area` | `animation`, `position-area` |
490| `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:
118@row [spacing 8]
118@row [spacing 8]
119 @each $item in $items
119 @each $item in $items
120 @if $item != About
120 @if $item != About
121− @link /$item|lowercase $item
121+ @link /${lowercase($item)} $item
122```
122```
123
123
124See [DESIGN.md](DESIGN.md) for the full language: files and data (`@include`,
124See [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 @@
17//! product := unary (("*" | "/" | "%") unary)*
17//! product := unary (("*" | "/" | "%") unary)*
18//! unary := "-" unary | primary
18//! unary := "-" unary | primary
19//! primary := NUMBER | "STRING" | $VAR | WORD | true | false
19//! primary := NUMBER | "STRING" | $VAR | WORD | true | false
20−//! | if(expr, expr, expr) | "(" expr ")"
20+//! | NAME "(" expr ("," expr)* ")" | "(" expr ")"
21//! ```
21//! ```
22//!
22//!
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.
24
29
25use std::fmt;
30use std::fmt;
26
31
@@ -61,8 +66,8 @@ impl fmt::Display for Value {
61 }
66 }
62}
67}
63
68
64−/// Resolves a variable reference: the text after `$`, including any
69+/// Resolves a variable name (the text after `$`). Returns `None` for
65−/// filters (`name|uppercase`). Returns `None` for undefined variables.
70+/// undefined variables.
66pub type Resolver<'a> = &'a dyn Fn(&str) -> Option<String>;
71pub type Resolver<'a> = &'a dyn Fn(&str) -> Option<String>;
67
72
68/// Evaluate `src`. Undefined variables are empty strings.
73/// Evaluate `src`. Undefined variables are empty strings.
@@ -137,15 +142,14 @@ fn tokenize(src: &str) -> Result<Vec<Token>, String> {
137 tokens.push(Token::Str(chars[start..end].iter().collect()));
142 tokens.push(Token::Str(chars[start..end].iter().collect()));
138 i = end + 1;
143 i = end + 1;
139 } else if c == '$' {
144 } else if c == '$' {
140− // $name, with optional |filter:args chains
141 let start = i + 1;
145 let start = i + 1;
142 let mut end = start;
146 let mut end = start;
143− while end < chars.len()
147+ while end < chars.len() && is_var_char(chars[end]) {
144− && !chars[end].is_whitespace()
145− && !matches!(chars[end], '(' | ')' | ',' | '"')
146− {
147 end += 1;
148 end += 1;
148 }
149 }
150+ while end > start && chars[end - 1] == '.' {
151+ end -= 1;
152+ }
149 if end == start {
153 if end == start {
150 return Err(format!("`$` without a variable name in `{}`", src.trim()));
154 return Err(format!("`$` without a variable name in `{}`", src.trim()));
151 }
155 }
@@ -330,15 +334,18 @@ impl Parser<'_> {
330 Token::Var(name) => Ok(Value::Str((self.resolve)(&name).unwrap_or_default())),
334 Token::Var(name) => Ok(Value::Str((self.resolve)(&name).unwrap_or_default())),
331 Token::Word(w) if w == "true" => Ok(Value::Bool(true)),
335 Token::Word(w) if w == "true" => Ok(Value::Bool(true)),
332 Token::Word(w) if w == "false" => Ok(Value::Bool(false)),
336 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) => {
334 self.pos += 1;
338 self.pos += 1;
335− let condition = self.expr()?;
339+ let mut args = Vec::new();
336− self.expect(Token::Comma)?;
340+ if self.peek() != Some(&Token::RParen) {
337− let then = self.expr()?;
341+ args.push(self.expr()?);
338− self.expect(Token::Comma)?;
342+ while self.peek() == Some(&Token::Comma) {
343+ self.pos += 1;
339− let otherwise = self.expr()?;
344+ args.push(self.expr()?);
345+ }
346+ }
340 self.expect(Token::RParen)?;
347 self.expect(Token::RParen)?;
341− Ok(if condition.truthy() { then } else { otherwise })
348+ call(&name, args)
342 }
349 }
343 Token::Word(w) => Ok(Value::Str(w)),
350 Token::Word(w) => Ok(Value::Str(w)),
344 Token::LParen => {
351 Token::LParen => {
@@ -367,6 +374,134 @@ fn arithmetic(op: &str, left: &Value, right: &Value) -> Result<Value, String> {
367 }))
374 }))
368}
375}
369
376
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+
370/// Replace `$name` references inside a string literal.
505/// Replace `$name` references inside a string literal.
371fn interpolate(s: &str, resolve: Resolver) -> String {
506fn interpolate(s: &str, resolve: Resolver) -> String {
372 let mut out = String::with_capacity(s.len());
507 let mut out = String::with_capacity(s.len());
@@ -374,9 +509,7 @@ fn interpolate(s: &str, resolve: Resolver) -> String {
374 while let Some(pos) = rest.find('$') {
509 while let Some(pos) = rest.find('$') {
375 out.push_str(&rest[..pos]);
510 out.push_str(&rest[..pos]);
376 let after = &rest[pos + 1..];
511 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());
380 if end == 0 {
513 if end == 0 {
381 out.push('$');
514 out.push('$');
382 } else {
515 } else {
@@ -399,7 +532,6 @@ mod tests {
399 "theme" => Some("dark".to_string()),
532 "theme" => Some("dark".to_string()),
400 "tricky" => Some("a == b".to_string()),
533 "tricky" => Some("a == b".to_string()),
401 "empty" => Some(String::new()),
534 "empty" => Some(String::new()),
402− "name|uppercase" => Some("WORLD".to_string()),
403 _ => None,
535 _ => None,
404 };
536 };
405 eval(src, &resolve).unwrap()
537 eval(src, &resolve).unwrap()
@@ -421,7 +553,7 @@ mod tests {
421 assert!(ev("$theme != light and not $empty").truthy());
553 assert!(ev("$theme != light and not $empty").truthy());
422 assert!(!ev("$count < 2 or $empty").truthy());
554 assert!(!ev("$count < 2 or $empty").truthy());
423 assert!(ev("$name contains or").truthy());
555 assert!(ev("$name contains or").truthy());
424− assert!(ev("$name|uppercase == WORLD").truthy());
556+ assert!(ev("uppercase($name) == WORLD").truthy());
425 assert!(ev("#3b82f6 != red").truthy());
557 assert!(ev("#3b82f6 != red").truthy());
426 }
558 }
427
559
@@ -440,6 +572,18 @@ mod tests {
440 assert_eq!(ev("$missing").to_string(), "");
572 assert_eq!(ev("$missing").to_string(), "");
441 }
573 }
442
574
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+
443 #[test]
587 #[test]
444 fn errors_are_reported() {
588 fn errors_are_reported() {
445 let none = |_: &str| None;
589 let none = |_: &str| None;
@@ -447,5 +591,8 @@ mod tests {
447 assert!(eval("1 +", &none).is_err());
591 assert!(eval("1 +", &none).is_err());
448 assert!(eval("(1", &none).is_err());
592 assert!(eval("(1", &none).is_err());
449 assert!(eval("1 / 0", &none).is_err());
593 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());
450 }
597 }
451}
598}
crates/htmlang-core/src/parser.rs+88−186
@@ -121,11 +121,7 @@ impl ParseContext {
121 fn eval(&mut self, src: &str, line: usize) -> Option<crate::expr::Value> {
121 fn eval(&mut self, src: &str, line: usize) -> Option<crate::expr::Value> {
122 track_var_refs(src, &mut self.used_variables);
122 track_var_refs(src, &mut self.used_variables);
123 let vars = &self.variables;
123 let vars = &self.variables;
124− let resolve = |reference: &str| {
124+ let resolve = |name: &str| vars.get(name).cloned();
125− let name = reference.split('|').next().unwrap_or(reference);
126− vars.contains_key(name)
127− .then(|| substitute_vars(&format!("${}", reference), vars))
128− };
129 let result = crate::expr::eval(src, &resolve);
125 let result = crate::expr::eval(src, &resolve);
130 match result {
126 match result {
131 Ok(value) => Some(value),
127 Ok(value) => Some(value),
@@ -488,6 +484,20 @@ impl Parser {
488 fn parse_line(&mut self, ctx: &mut ParseContext) -> Result<Option<Vec<Node>>, ParseError> {
484 fn parse_line(&mut self, ctx: &mut ParseContext) -> Result<Option<Vec<Node>>, ParseError> {
489 let line_num = self.lines[self.pos].line_num;
485 let line_num = self.lines[self.pos].line_num;
490 ctx.current_line = line_num;
486 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+ }
491 let current_indent = self.lines[self.pos].indent;
501 let current_indent = self.lines[self.pos].indent;
492
502
493 // Handle raw content
503 // Handle raw content
@@ -3629,7 +3639,7 @@ fn validate_tree(
3629 .find(|a| crate::vocab::base_attribute(&a.key) == "color")
3639 .find(|a| crate::vocab::base_attribute(&a.key) == "color")
3630 .and_then(|a| a.value.as_deref());
3640 .and_then(|a| a.value.as_deref());
3631 if let (Some(bg), Some(fg)) = (bg_color, fg_color)
3641 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))
3633 {
3643 {
3634 let ratio = contrast_ratio(bg_rgb, fg_rgb);
3644 let ratio = contrast_ratio(bg_rgb, fg_rgb);
3635 if ratio < 4.5 {
3645 if ratio < 4.5 {
@@ -3741,28 +3751,6 @@ fn validate_tree(
3741 }
3751 }
3742}
3752}
3743
3753
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−
3766fn relative_luminance(r: u8, g: u8, b: u8) -> f64 {
3754fn relative_luminance(r: u8, g: u8, b: u8) -> f64 {
3767 fn linearize(c: u8) -> f64 {
3755 fn linearize(c: u8) -> f64 {
3768 let s = c as f64 / 255.0;
3756 let s = c as f64 / 255.0;
@@ -3782,101 +3770,96 @@ fn contrast_ratio(c1: (u8, u8, u8), c2: (u8, u8, u8)) -> f64 {
3782 (lighter + 0.05) / (darker + 0.05)
3770 (lighter + 0.05) / (darker + 0.05)
3783}
3771}
3784
3772
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−
3808// ---------------------------------------------------------------------------
3773// ---------------------------------------------------------------------------
3809// Variable substitution
3774// Variable substitution
3810// ---------------------------------------------------------------------------
3775// ---------------------------------------------------------------------------
3811
3776
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.
3812fn substitute_vars(input: &str, vars: &HashMap<String, String>) -> String {
3780fn substitute_vars(input: &str, vars: &HashMap<String, String>) -> String {
3813 if !input.contains('$') {
3781 if !input.contains('$') {
3814 return input.to_string();
3782 return input.to_string();
3815 }
3783 }
3816−
3784+ let is_name_char = |c: char| c.is_alphanumeric() || matches!(c, '_' | '-' | '.');
3817− let mut result = String::new();
3785+ let mut result = String::with_capacity(input.len());
3818− let chars: Vec<char> = input.chars().collect();
3786+ let mut rest = input;
3819− let mut i = 0;
3787+ while let Some(pos) = rest.find('$') {
3820−
3788+ result.push_str(&rest[..pos]);
3821− while i < chars.len() {
3789+ let after = &rest[pos + 1..];
3822− if chars[i] == '$'
3790+ if after.starts_with('{')
3823− && i + 1 < chars.len()
3791+ && let Some(close) = matching_brace(after)
3824− && (chars[i + 1].is_alphanumeric() || chars[i + 1] == '_' || chars[i + 1] == '-')
3825 {
3792 {
3826− let start = i + 1;
3793+ let source = &after[1..close];
3827− let mut end = start;
3794+ let resolve = |name: &str| vars.get(name).cloned();
3828− while end < chars.len()
3795+ match crate::expr::eval(source, &resolve) {
3829− && (chars[end].is_alphanumeric()
3796+ Ok(value) => result.push_str(&value.to_string()),
3830− || chars[end] == '-'
3797+ Err(_) => result.push_str(&rest[pos..pos + 1 + close + 1]),
3831− || chars[end] == '_'
3832− || chars[end] == '.')
3833− {
3834− end += 1;
3835 }
3798 }
3836− // Strip trailing dot (not part of name if at end)
3799+ rest = &after[close + 1..];
3800+ continue;
3801+ }
3802+ let mut end = after.find(|c: char| !is_name_char(c)).unwrap_or(after.len());
3837− while end > start && chars[end - 1] == '.' {
3803+ while end > 0 && after[..end].ends_with('.') {
3838− end -= 1;
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);
3839 }
3812 }
3840− let name: String = chars[start..end].iter().collect();
3813+ }
3814+ rest = &after[end..];
3815+ }
3816+ result.push_str(rest);
3817+ result
3818+}
3841
3819
3842− // Collect pipe filters: $name|filter1|filter2:arg
3820+/// The first `$name|filter` (old filter syntax) in `text`, as (name, filter).
3843− let mut filters: Vec<String> = Vec::new();
3821+fn old_filter_syntax(text: &str) -> Option<(String, String)> {
3844− while end < chars.len() && chars[end] == '|' {
3822+ const FILTERS: &[&str] = &[
3845− end += 1; // skip '|'
3823+ "uppercase", "lowercase", "capitalize", "trim", "length", "reverse", "truncate",
3846− let filter_start = end;
3824+ "replace", "default", "lighten", "darken", "alpha", "mix",
3847− while end < chars.len()
3825+ ];
3848− && chars[end] != '|'
3826+ let mut rest = text;
3849− && chars[end] != ' '
3827+ while let Some(pos) = rest.find('$') {
3850− && chars[end] != ','
3828+ let after = &rest[pos + 1..];
3851− && chars[end] != ']'
3829+ let end = after
3852− && chars[end] != '}'
3830+ .find(|c: char| !(c.is_alphanumeric() || matches!(c, '_' | '-' | '.')))
3853− {
3831+ .unwrap_or(after.len());
3854− end += 1;
3832+ if let Some(filter) = after[end..].strip_prefix('|') {
3855− }
3833+ let name_end = filter.find(|c: char| !c.is_ascii_alphabetic()).unwrap_or(filter.len());
3856− let filter: String = chars[filter_start..end].iter().collect();
3834+ if end > 0 && FILTERS.contains(&&filter[..name_end]) {
3857− if !filter.is_empty() {
3835+ return Some((after[..end].to_string(), filter[..name_end].to_string()));
3858− filters.push(filter);
3859− }
3860 }
3836 }
3837+ }
3838+ rest = &after[end..];
3839+ }
3840+ None
3841+}
3861
3842
3862− if let Some(value) = vars.get(&name) {
3843+/// Index of the `}` matching the `{` that `s` starts with (skipping
3863− let mut val = value.clone();
3844+/// braces inside string literals).
3864− for filter in &filters {
3845+fn matching_brace(s: &str) -> Option<usize> {
3865− val = apply_filter(&val, filter);
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);
3866 }
3857 }
3867− result.push_str(&val);
3868− } else {
3869− result.push('$');
3870− result.push_str(&name);
3871 }
3858 }
3872− i = end;
3859+ _ => {}
3873− } else {
3874− result.push(chars[i]);
3875− i += 1;
3876 }
3860 }
3877 }
3861 }
3878−
3862+ None
3879− result
3880}
3863}
3881
3864
3882/// Parse a keyframe line in htmlang syntax: `from [opacity 0]` / `50% [transform scale(1.5)]`
3865/// 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> {
3920 Some(format!("{}{{{}}}", selector, css))
3903 Some(format!("{}{{{}}}", selector, css))
3921}
3904}
3922
3905
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−
4004// ---------------------------------------------------------------------------
3906// ---------------------------------------------------------------------------
4005// Variable usage tracking
3907// Variable usage tracking
4006// ---------------------------------------------------------------------------
3908// ---------------------------------------------------------------------------
examples/demo.hl+4−4
@@ -223,8 +223,8 @@
223 type=submit,
223 type=submit,
224 padding 10 16, background $primary, color white, bold,
224 padding 10 16, background $primary, color white, bold,
225 rounded 6, border 0,
225 rounded 6, border 0,
226− hover:background $primary|darken:10,
226+ hover:background ${darken($primary, 10)},
227− active:background $primary|darken:20,
227+ active:background ${darken($primary, 20)},
228 transition background 0.15s ease,
228 transition background 0.15s ease,
229 cursor pointer
229 cursor pointer
230 ] Subscribe
230 ] Subscribe
@@ -317,8 +317,8 @@
317 @row [spacing $base, align-items center]
317 @row [spacing $base, align-items center]
318 @el [
318 @el [
319 padding 12 20, background $primary, color white, bold, rounded 8,
319 padding 12 20, background $primary, color white, bold, rounded 8,
320− hover:background $primary|darken:10,
320+ hover:background ${darken($primary, 10)},
321− active:background $primary|darken:20,
321+ active:background ${darken($primary, 20)},
322 transition all 0.15s ease,
322 transition all 0.15s ease,
323 cursor pointer
323 cursor pointer
324 ] > @link https://github.com
324 ] > @link https://github.com
src/upgrade.rs+86−3
@@ -92,7 +92,7 @@ pub fn upgrade(input: &str) -> Upgrade {
92 // The directive line is upgraded like any other; its body is not.
92 // The directive line is upgraded like any other; its body is not.
93 let end = block_end(&lines, i);
93 let end = block_end(&lines, i);
94 let mut header = rewrite_attr_regions(line, &mut attr_depth, &mut true, &user_defined);
94 let mut header = rewrite_attr_regions(line, &mut attr_depth, &mut true, &user_defined);
95− header = rename_filters(&header);
95+ header = convert_filters(&header);
96 if header != line {
96 if header != line {
97 changes += 1;
97 changes += 1;
98 }
98 }
@@ -123,7 +123,7 @@ pub fn upgrade(input: &str) -> Upgrade {
123 }
123 }
124 new_line =
124 new_line =
125 rewrite_attr_regions(&new_line, &mut attr_depth, &mut element_attrs, &user_defined);
125 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);
127 if new_line != line {
127 if new_line != line {
128 changes += 1;
128 changes += 1;
129 }
129 }
@@ -990,6 +990,76 @@ fn rename_filters(line: &str) -> String {
990 out
990 out
991}
991}
992
992
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+
993/// Replace whole-token `$name` references with `value`.
1063/// Replace whole-token `$name` references with `value`.
994fn replace_var(line: &str, name: &str, value: &str) -> String {
1064fn replace_var(line: &str, name: &str, value: &str) -> String {
995 let needle = format!("${name}");
1065 let needle = format!("${name}");
@@ -1150,7 +1220,7 @@ mod tests {
1150 "@el [$card, animation fade 1s, hover:position-area top] x"
1220 "@el [$card, animation fade 1s, hover:position-area top] x"
1151 );
1221 );
1152 assert_eq!(up("@el [\n animate spin 1s\n]"), "@el [\n animation spin 1s\n]");
1222 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))}");
1154 // Text that merely mentions a renamed word is untouched.
1224 // Text that merely mentions a renamed word is untouched.
1155 assert_eq!(up("@text [bold] please animate this"), "@text [bold] please animate this");
1225 assert_eq!(up("@text [bold] please animate this"), "@text [bold] please animate this");
1156 }
1226 }
@@ -1268,6 +1338,19 @@ mod tests {
1268 assert_eq!(upgrade("@breakpoint tablet 600").manual.len(), 1);
1338 assert_eq!(upgrade("@breakpoint tablet 600").manual.len(), 1);
1269 }
1339 }
1270
1340
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+
1271 #[test]
1354 #[test]
1272 fn verbatim_regions_are_untouched() {
1355 fn verbatim_regions_are_untouched() {
1273 let src = "@script\n for (x of y) {}\n@raw \"\"\"\n@unless\n\"\"\"\n@style\n .p { }";
1356 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) {
74 || d.message.contains("is an HTML attribute")
74 || d.message.contains("is an HTML attribute")
75 || d.message.contains("was removed")
75 || d.message.contains("was removed")
76 || d.message.contains("no single root")
76 || d.message.contains("no single root")
77+ || d.message.contains("filters are functions now")
77 })
78 })
78 .map(|d| format!("line {}: {}", d.line, d.message))
79 .map(|d| format!("line {}: {}", d.line, d.message))
79 .collect();
80 .collect();
tests/regressions.rs+2−2
@@ -33,13 +33,13 @@ fn compile_in(dir: &std::path::Path, input: &str) -> String {
33
33
34#[test]
34#[test]
35fn truncate_filter_handles_multibyte_text() {
35fn 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)}");
37 assert!(out.contains("hé..."), "{}", out);
37 assert!(out.contains("hé..."), "{}", out);
38}
38}
39
39
40#[test]
40#[test]
41fn length_filter_counts_characters() {
41fn 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)}");
43 assert!(out.contains(">5<"), "{}", out);
43 assert!(out.contains(">5<"), "{}", out);
44}
44}
45
45
tests/snapshots.rs+10−10
@@ -2970,7 +2970,7 @@ fn snapshot_css_shorthands() {
2970
2970
2971#[test]
2971#[test]
2972fn test_variable_filters() {
2972fn 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)}");
2974 assert!(
2974 assert!(
2975 result
2975 result
2976 .diagnostics
2976 .diagnostics
@@ -2984,7 +2984,7 @@ fn test_variable_filters() {
2984 html
2984 html
2985 );
2985 );
2986
2986
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)}");
2988 let html = htmlang::codegen::generate(&result.document);
2988 let html = htmlang::codegen::generate(&result.document);
2989 assert!(
2989 assert!(
2990 html.contains("hello"),
2990 html.contains("hello"),
@@ -2992,7 +2992,7 @@ fn test_variable_filters() {
2992 html
2992 html
2993 );
2993 );
2994
2994
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)}");
2996 let html = htmlang::codegen::generate(&result.document);
2996 let html = htmlang::codegen::generate(&result.document);
2997 assert!(
2997 assert!(
2998 html.contains("Hello"),
2998 html.contains("Hello"),
@@ -3000,7 +3000,7 @@ fn test_variable_filters() {
3000 html
3000 html
3001 );
3001 );
3002
3002
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)}");
3004 let html = htmlang::codegen::generate(&result.document);
3004 let html = htmlang::codegen::generate(&result.document);
3005 assert!(
3005 assert!(
3006 html.contains("5"),
3006 html.contains("5"),
@@ -3008,7 +3008,7 @@ fn test_variable_filters() {
3008 html
3008 html
3009 );
3009 );
3010
3010
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)}");
3012 let html = htmlang::codegen::generate(&result.document);
3012 let html = htmlang::codegen::generate(&result.document);
3013 assert!(
3013 assert!(
3014 html.contains("olleh"),
3014 html.contains("olleh"),
@@ -3016,7 +3016,7 @@ fn test_variable_filters() {
3016 html
3016 html
3017 );
3017 );
3018
3018
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)}");
3020 let html = htmlang::codegen::generate(&result.document);
3020 let html = htmlang::codegen::generate(&result.document);
3021 assert!(
3021 assert!(
3022 html.contains("hello..."),
3022 html.contains("hello..."),
@@ -3249,7 +3249,7 @@ fn test_extends_directive() {
3249#[test]
3249#[test]
3250fn test_color_filter_lighten() {
3250fn test_color_filter_lighten() {
3251 let result = htmlang::parser::parse(
3251 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",
3253 );
3253 );
3254 let html = htmlang::codegen::generate(&result.document);
3254 let html = htmlang::codegen::generate(&result.document);
3255 // Lighten #3b82f6 by 20% should produce a lighter blue
3255 // Lighten #3b82f6 by 20% should produce a lighter blue
@@ -3268,7 +3268,7 @@ fn test_color_filter_lighten() {
3268#[test]
3268#[test]
3269fn test_color_filter_darken() {
3269fn test_color_filter_darken() {
3270 let result =
3270 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");
3272 let html = htmlang::codegen::generate(&result.document);
3272 let html = htmlang::codegen::generate(&result.document);
3273 // Darken white by 50% should produce gray (#808080 approximately)
3273 // Darken white by 50% should produce gray (#808080 approximately)
3274 assert!(
3274 assert!(
@@ -3285,7 +3285,7 @@ fn test_color_filter_darken() {
3285#[test]
3285#[test]
3286fn test_color_filter_alpha() {
3286fn test_color_filter_alpha() {
3287 let result =
3287 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");
3289 let html = htmlang::codegen::generate(&result.document);
3289 let html = htmlang::codegen::generate(&result.document);
3290 // Should produce 8-digit hex with alpha
3290 // Should produce 8-digit hex with alpha
3291 assert!(
3291 assert!(
@@ -3298,7 +3298,7 @@ fn test_color_filter_alpha() {
3298#[test]
3298#[test]
3299fn test_color_filter_mix() {
3299fn test_color_filter_mix() {
3300 let result = htmlang::parser::parse(
3300 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",
3302 );
3302 );
3303 let html = htmlang::codegen::generate(&result.document);
3303 let html = htmlang::codegen::generate(&result.document);
3304 // Mix black and white at 50% should produce gray
3304 // Mix black and white at 50% should produce gray
tests/snapshots/variable_filters.hl+7−7
@@ -3,10 +3,10 @@
3@let greeting hi
3@let greeting hi
4
4
5@column [spacing 10]
5@column [spacing 10]
6− @text $name|uppercase
6+ @text ${uppercase($name)}
7− @text $name|lowercase
7+ @text ${lowercase($name)}
8− @text $name|capitalize
8+ @text ${capitalize($name)}
9− @text $name|length
9+ @text ${length($name)}
10− @text $name|reverse
10+ @text ${reverse($name)}
11− @text $name|truncate:5
11+ @text ${truncate($name, 5)}
12− @text $greeting|default:fallback
12+ @text ${default($greeting, "fallback")}