@@ -96,6 +96,10 @@ struct ParseContext {
9696 fn_lines: HashMap<String, usize>,
9797 /// Line numbers of @define definitions (name -> line)
9898 define_lines: HashMap<String, usize>,
99+ /// Deprecated functions: name -> deprecation message
100+ deprecated_fns: HashMap<String, String>,
101+ /// Theme tokens: (name, value) pairs from @theme
102+ theme_tokens: Vec<(String, String)>,
99103}
100104
101105struct Parser {
@@ -136,6 +140,8 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
136140 let_lines: HashMap::new(),
137141 fn_lines: HashMap::new(),
138142 define_lines: HashMap::new(),
143+ deprecated_fns: HashMap::new(),
144+ theme_tokens: Vec::new(),
139145 };
140146 let nodes = parser.parse_children(0, &mut ctx);
141147 validate_tree(&nodes, None, &mut ctx.diagnostics);
@@ -154,6 +160,7 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
154160 custom_css: ctx.custom_css,
155161 og_tags: ctx.og_tags,
156162 custom_breakpoints: ctx.custom_breakpoints,
163+ theme_tokens: ctx.theme_tokens,
157164 nodes,
158165 },
159166 diagnostics: ctx.diagnostics,
@@ -1041,7 +1048,12 @@ impl Parser {
10411048 while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
10421049 if let LineContent::Normal(ref s) = self.lines[self.pos].content {
10431050 let trimmed = s.trim();
1044− body.push_str(trimmed);
1051+ // Support htmlang-style: from [opacity 0] / to [opacity 1] / 50% [transform scale(1.5)]
1052+ if let Some(kf_css) = parse_keyframe_line(trimmed) {
1053+ body.push_str(&kf_css);
1054+ } else {
1055+ body.push_str(trimmed);
1056+ }
10451057 }
10461058 self.pos += 1;
10471059 }
@@ -1049,6 +1061,146 @@ impl Parser {
10491061 return Ok(None);
10501062 }
10511063
1064+ // --- @theme directive ---
1065+
1066+ if content.trim() == "@theme" {
1067+ let mut tokens = Vec::new();
1068+ while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
1069+ if let LineContent::Normal(ref s) = self.lines[self.pos].content {
1070+ let trimmed = s.trim();
1071+ if let Some((name, value)) = trimmed.split_once(' ') {
1072+ let name = name.trim().to_string();
1073+ let value = value.trim().to_string();
1074+ tokens.push((name.clone(), value.clone()));
1075+ // Set as regular variable
1076+ ctx.variables.insert(name.clone(), value.clone());
1077+ // Also set as CSS custom property
1078+ let css_name = format!("--{}", name);
1079+ ctx.css_vars.push((css_name, value));
1080+ }
1081+ }
1082+ self.pos += 1;
1083+ }
1084+ ctx.theme_tokens = tokens;
1085+ return Ok(None);
1086+ }
1087+
1088+ // --- @deprecated annotation ---
1089+
1090+ if let Some(rest) = content.strip_prefix("@deprecated ") {
1091+ let message = rest.trim().to_string();
1092+ // Peek at the next line to get the function name
1093+ if self.pos < self.lines.len() {
1094+ if let LineContent::Normal(ref s) = self.lines[self.pos].content {
1095+ if let Some(fn_rest) = s.trim().strip_prefix("@fn ") {
1096+ let parts: Vec<&str> = fn_rest.split_whitespace().collect();
1097+ if let Some(&fn_name) = parts.first() {
1098+ ctx.deprecated_fns.insert(fn_name.to_string(), message);
1099+ }
1100+ }
1101+ }
1102+ }
1103+ return Ok(None);
1104+ }
1105+
1106+ // --- @extends (template inheritance) ---
1107+
1108+ if let Some(rest) = content.strip_prefix("@extends ") {
1109+ let filename = substitute_vars(rest.trim(), &ctx.variables);
1110+ let resolved = match &ctx.base_path {
1111+ Some(base) => base.join(&filename),
1112+ None => PathBuf::from(&filename),
1113+ };
1114+
1115+ if ctx.include_stack.contains(&resolved) {
1116+ ctx.diagnostics.push(Diagnostic {
1117+ line: line_num,
1118+ message: format!("circular extends '{}'", filename),
1119+ severity: Severity::Error,
1120+ source_line: Some(content.clone()),
1121+ });
1122+ return Ok(None);
1123+ }
1124+
1125+ let extends_text = if let Some(cached) = ctx.file_cache.get(&resolved) {
1126+ cached.clone()
1127+ } else {
1128+ match std::fs::read_to_string(&resolved) {
1129+ Ok(text) => {
1130+ ctx.file_cache.insert(resolved.clone(), text.clone());
1131+ text
1132+ }
1133+ Err(e) => {
1134+ ctx.diagnostics.push(Diagnostic {
1135+ line: line_num,
1136+ message: format!("cannot extend '{}': {}", filename, e),
1137+ severity: Severity::Error,
1138+ source_line: Some(content.clone()),
1139+ });
1140+ return Ok(None);
1141+ }
1142+ }
1143+ };
1144+
1145+ // Collect slot blocks defined in the extending file
1146+ let mut slot_contents: HashMap<String, Vec<Line>> = HashMap::new();
1147+ while self.pos < self.lines.len() {
1148+ let line_indent = self.lines[self.pos].indent;
1149+ if line_indent < current_indent {
1150+ break;
1151+ }
1152+ if let LineContent::Normal(ref s) = self.lines[self.pos].content {
1153+ let trimmed = s.trim();
1154+ if let Some(slot_name) = trimmed.strip_prefix("@slot ") {
1155+ let slot_name = slot_name.trim().to_string();
1156+ self.pos += 1;
1157+ let mut slot_lines = Vec::new();
1158+ while self.pos < self.lines.len() && self.lines[self.pos].indent > line_indent {
1159+ slot_lines.push(self.lines[self.pos].clone());
1160+ self.pos += 1;
1161+ }
1162+ slot_contents.insert(slot_name, slot_lines);
1163+ continue;
1164+ }
1165+ }
1166+ self.pos += 1;
1167+ }
1168+
1169+ // Parse slot contents into nodes
1170+ let mut slot_nodes: HashMap<String, Vec<Node>> = HashMap::new();
1171+ for (name, lines) in &slot_contents {
1172+ if lines.is_empty() {
1173+ continue;
1174+ }
1175+ let min_indent = lines.iter().map(|l| l.indent).min().unwrap_or(0);
1176+ let adjusted: Vec<Line> = lines.iter().map(|l| Line {
1177+ indent: l.indent - min_indent,
1178+ content: l.content.clone(),
1179+ line_num: l.line_num,
1180+ }).collect();
1181+ let mut slot_parser = Parser { lines: adjusted, pos: 0 };
1182+ let nodes = slot_parser.parse_children(0, ctx);
1183+ slot_nodes.insert(name.clone(), nodes);
1184+ }
1185+
1186+ // Parse the base layout file
1187+ ctx.included_files.push(resolved.clone());
1188+ ctx.include_stack.push(resolved.clone());
1189+ let saved_base = ctx.base_path.clone();
1190+ ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
1191+
1192+ let extends_lines = preprocess(&extends_text);
1193+ let mut extends_parser = Parser { lines: extends_lines, pos: 0 };
1194+ let layout_nodes = extends_parser.parse_children(0, ctx);
1195+
1196+ ctx.base_path = saved_base;
1197+ ctx.include_stack.pop();
1198+
1199+ // Replace @slot placeholders in layout with provided content
1200+ let result_nodes = replace_extends_slots(layout_nodes, &slot_nodes);
1201+ return Ok(Some(result_nodes));
1202+ }
1203+
10521204 // --- Function definition ---
10531205
10541206 if let Some(rest) = content.strip_prefix("@fn ") {
@@ -1090,6 +1242,15 @@ impl Parser {
10901242 let name = extract_element_name(&content);
10911243 if ctx.functions.contains_key(name) {
10921244 ctx.used_functions.insert(name.to_string());
1245+ // Emit deprecation warning if function is marked @deprecated
1246+ if let Some(msg) = ctx.deprecated_fns.get(name) {
1247+ ctx.diagnostics.push(Diagnostic {
1248+ line: line_num,
1249+ message: format!("@{} is deprecated: {}", name, msg),
1250+ severity: Severity::Warning,
1251+ source_line: Some(content.clone()),
1252+ });
1253+ }
10931254 let nodes =
10941255 self.expand_fn_call(name, &content, current_indent, line_num, ctx)?;
10951256 return Ok(Some(nodes));
@@ -1378,7 +1539,7 @@ const KNOWN_DIRECTIVES: &[&str] = &[
13781539 "if", "else", "each", "meta", "head", "style",
13791540 "match", "case", "default", "warn", "debug",
13801541 "unless", "og", "breakpoint", "lang", "favicon",
1381− "use",
1542+ "use", "theme", "deprecated", "extends",
13821543];
13831544
13841545fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
@@ -1540,7 +1701,7 @@ const KNOWN_ATTRS: &[&str] = &[
15401701 "for", "action", "method", "autocomplete",
15411702 "min", "max", "step", "pattern", "maxlength", "rows", "cols", "multiple",
15421703 // Accessibility
1543− "alt", "role", "tabindex", "title",
1704+ "alt", "role", "tabindex", "title", "autofocus",
15441705 // CSS: aspect-ratio, outline, logical properties, scroll-snap
15451706 "aspect-ratio", "outline",
15461707 "padding-inline", "padding-block", "margin-inline", "margin-block",
@@ -2451,6 +2612,29 @@ fn contrast_ratio(c1: (u8, u8, u8), c2: (u8, u8, u8)) -> f64 {
24512612 (lighter + 0.05) / (darker + 0.05)
24522613}
24532614
2615+fn lighten_color(rgb: (u8, u8, u8), amount: f64) -> (u8, u8, u8) {
2616+ let r = rgb.0 as f64 + (255.0 - rgb.0 as f64) * amount.clamp(0.0, 1.0);
2617+ let g = rgb.1 as f64 + (255.0 - rgb.1 as f64) * amount.clamp(0.0, 1.0);
2618+ let b = rgb.2 as f64 + (255.0 - rgb.2 as f64) * amount.clamp(0.0, 1.0);
2619+ (r.round() as u8, g.round() as u8, b.round() as u8)
2620+}
2621+
2622+fn darken_color(rgb: (u8, u8, u8), amount: f64) -> (u8, u8, u8) {
2623+ let factor = 1.0 - amount.clamp(0.0, 1.0);
2624+ let r = (rgb.0 as f64 * factor).round() as u8;
2625+ let g = (rgb.1 as f64 * factor).round() as u8;
2626+ let b = (rgb.2 as f64 * factor).round() as u8;
2627+ (r, g, b)
2628+}
2629+
2630+fn mix_colors(c1: (u8, u8, u8), c2: (u8, u8, u8), weight: f64) -> (u8, u8, u8) {
2631+ let w = weight.clamp(0.0, 1.0);
2632+ let r = (c1.0 as f64 * (1.0 - w) + c2.0 as f64 * w).round() as u8;
2633+ let g = (c1.1 as f64 * (1.0 - w) + c2.1 as f64 * w).round() as u8;
2634+ let b = (c1.2 as f64 * (1.0 - w) + c2.2 as f64 * w).round() as u8;
2635+ (r, g, b)
2636+}
2637+
24542638// ---------------------------------------------------------------------------
24552639// Variable substitution
24562640// ---------------------------------------------------------------------------
@@ -2518,6 +2702,75 @@ fn substitute_vars(input: &str, vars: &HashMap<String, String>) -> String {
25182702 result
25192703}
25202704
2705+/// Parse a keyframe line in htmlang syntax: `from [opacity 0]` / `50% [transform scale(1.5)]`
2706+fn parse_keyframe_line(line: &str) -> Option<String> {
2707+ let (selector, rest) = if let Some(rest) = line.strip_prefix("from") {
2708+ ("from", rest.trim())
2709+ } else if let Some(rest) = line.strip_prefix("to") {
2710+ ("to", rest.trim())
2711+ } else if let Some(pct_end) = line.find('%') {
2712+ let rest = line[pct_end + 1..].trim();
2713+ let selector = &line[..pct_end + 1];
2714+ (selector, rest)
2715+ } else {
2716+ return None;
2717+ };
2718+
2719+ if !rest.starts_with('[') || !rest.ends_with(']') {
2720+ return None;
2721+ }
2722+
2723+ let inner = &rest[1..rest.len() - 1];
2724+ // Parse comma-separated key-value pairs into CSS
2725+ let mut css = String::new();
2726+ for part in inner.split(',') {
2727+ let part = part.trim();
2728+ if part.is_empty() {
2729+ continue;
2730+ }
2731+ if let Some((key, value)) = part.split_once(' ') {
2732+ css.push_str(key.trim());
2733+ css.push(':');
2734+ css.push_str(value.trim());
2735+ css.push(';');
2736+ }
2737+ }
2738+
2739+ if css.is_empty() {
2740+ return None;
2741+ }
2742+
2743+ Some(format!("{}{{{}}}", selector, css))
2744+}
2745+
2746+/// Replace @slot placeholders in the base layout with content from @extends
2747+fn replace_extends_slots(
2748+ nodes: Vec<Node>,
2749+ slot_nodes: &HashMap<String, Vec<Node>>,
2750+) -> Vec<Node> {
2751+ let mut result = Vec::new();
2752+ for node in nodes {
2753+ match node {
2754+ Node::Element(elem) if matches!(&elem.kind, ElementKind::Slot(name) if !name.is_empty()) => {
2755+ if let ElementKind::Slot(ref name) = elem.kind {
2756+ if let Some(content) = slot_nodes.get(name) {
2757+ result.extend(content.iter().cloned());
2758+ } else if !elem.children.is_empty() {
2759+ // Use slot's own children as default
2760+ result.extend(elem.children);
2761+ }
2762+ }
2763+ }
2764+ Node::Element(mut elem) => {
2765+ elem.children = replace_extends_slots(elem.children, slot_nodes);
2766+ result.push(Node::Element(elem));
2767+ }
2768+ other => result.push(other),
2769+ }
2770+ }
2771+ result
2772+}
2773+
25212774fn apply_filter(value: &str, filter: &str) -> String {
25222775 if let Some(arg) = filter.strip_prefix("truncate:") {
25232776 if let Ok(n) = arg.parse::<usize>() {
@@ -2539,6 +2792,49 @@ fn apply_filter(value: &str, filter: &str) -> String {
25392792 }
25402793 return value.to_string();
25412794 }
2795+ // Color functions: lighten:N, darken:N, alpha:N, mix:COLOR:N
2796+ if let Some(arg) = filter.strip_prefix("lighten:") {
2797+ if let Ok(amount) = arg.parse::<f64>() {
2798+ if let Some(rgb) = parse_hex_rgb(value) {
2799+ let (r, g, b) = lighten_color(rgb, amount / 100.0);
2800+ return format!("#{:02x}{:02x}{:02x}", r, g, b);
2801+ }
2802+ }
2803+ return value.to_string();
2804+ }
2805+ if let Some(arg) = filter.strip_prefix("darken:") {
2806+ if let Ok(amount) = arg.parse::<f64>() {
2807+ if let Some(rgb) = parse_hex_rgb(value) {
2808+ let (r, g, b) = darken_color(rgb, amount / 100.0);
2809+ return format!("#{:02x}{:02x}{:02x}", r, g, b);
2810+ }
2811+ }
2812+ return value.to_string();
2813+ }
2814+ if let Some(arg) = filter.strip_prefix("alpha:") {
2815+ if let Ok(a) = arg.parse::<f64>() {
2816+ if let Some((r, g, b)) = parse_hex_rgb(value) {
2817+ let a8 = (a.clamp(0.0, 1.0) * 255.0) as u8;
2818+ return format!("#{:02x}{:02x}{:02x}{:02x}", r, g, b, a8);
2819+ }
2820+ }
2821+ return value.to_string();
2822+ }
2823+ if let Some(arg) = filter.strip_prefix("mix:") {
2824+ // mix:COLOR:PERCENTAGE (e.g., mix:#ffffff:50)
2825+ let parts: Vec<&str> = arg.splitn(2, ':').collect();
2826+ if parts.len() == 2 {
2827+ if let (Some(c1), Some(c2), Ok(pct)) = (
2828+ parse_hex_rgb(value),
2829+ parse_hex_rgb(parts[0]),
2830+ parts[1].parse::<f64>(),
2831+ ) {
2832+ let (r, g, b) = mix_colors(c1, c2, pct / 100.0);
2833+ return format!("#{:02x}{:02x}{:02x}", r, g, b);
2834+ }
2835+ }
2836+ return value.to_string();
2837+ }
25422838 match filter {
25432839 "uppercase" | "upper" => value.to_uppercase(),
25442840 "lowercase" | "lower" => value.to_lowercase(),