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