@@ -96,6 +96,10 @@ struct ParseContext {
96 fn_lines: HashMap<String, usize>,
96 fn_lines: HashMap<String, usize>,
97 /// Line numbers of @define definitions (name -> line)
97 /// Line numbers of @define definitions (name -> line)
98 define_lines: HashMap<String, usize>,
98 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)>,
101struct Parser {
105struct Parser {
@@ -136,6 +140,8 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
136 let_lines: HashMap::new(),
140 let_lines: HashMap::new(),
137 fn_lines: HashMap::new(),
141 fn_lines: HashMap::new(),
138 define_lines: HashMap::new(),
142 define_lines: HashMap::new(),
143+ deprecated_fns: HashMap::new(),
144+ theme_tokens: Vec::new(),
140 let nodes = parser.parse_children(0, &mut ctx);
146 let nodes = parser.parse_children(0, &mut ctx);
141 validate_tree(&nodes, None, &mut ctx.diagnostics);
147 validate_tree(&nodes, None, &mut ctx.diagnostics);
@@ -154,6 +160,7 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
154 custom_css: ctx.custom_css,
160 custom_css: ctx.custom_css,
155 og_tags: ctx.og_tags,
161 og_tags: ctx.og_tags,
156 custom_breakpoints: ctx.custom_breakpoints,
162 custom_breakpoints: ctx.custom_breakpoints,
163+ theme_tokens: ctx.theme_tokens,
159 diagnostics: ctx.diagnostics,
166 diagnostics: ctx.diagnostics,
@@ -1041,7 +1048,12 @@ impl Parser {
1041 while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
1048 while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
1042 if let LineContent::Normal(ref s) = self.lines[self.pos].content {
1049 if let LineContent::Normal(ref s) = self.lines[self.pos].content {
1043 let trimmed = s.trim();
1050 let trimmed = s.trim();
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) {
1044− body.push_str(trimmed);
1053+ body.push_str(&kf_css);
1055+ body.push_str(trimmed);
1046 self.pos += 1;
1058 self.pos += 1;
@@ -1049,6 +1061,146 @@ impl Parser {
1049 return Ok(None);
1061 return Ok(None);
1064+ // --- @theme directive ---
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));
1084+ ctx.theme_tokens = tokens;
1088+ // --- @deprecated annotation ---
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);
1106+ // --- @extends (template inheritance) ---
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),
1115+ if ctx.include_stack.contains(&resolved) {
1116+ ctx.diagnostics.push(Diagnostic {
1118+ message: format!("circular extends '{}'", filename),
1119+ severity: Severity::Error,
1120+ source_line: Some(content.clone()),
1125+ let extends_text = if let Some(cached) = ctx.file_cache.get(&resolved) {
1128+ match std::fs::read_to_string(&resolved) {
1130+ ctx.file_cache.insert(resolved.clone(), text.clone());
1134+ ctx.diagnostics.push(Diagnostic {
1136+ message: format!("cannot extend '{}': {}", filename, e),
1137+ severity: Severity::Error,
1138+ source_line: Some(content.clone()),
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 {
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();
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());
1162+ slot_contents.insert(slot_name, slot_lines);
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() {
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,
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);
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());
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);
1196+ ctx.base_path = saved_base;
1197+ ctx.include_stack.pop();
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));
1052 // --- Function definition ---
1204 // --- Function definition ---
1054 if let Some(rest) = content.strip_prefix("@fn ") {
1206 if let Some(rest) = content.strip_prefix("@fn ") {
@@ -1090,6 +1242,15 @@ impl Parser {
1090 let name = extract_element_name(&content);
1242 let name = extract_element_name(&content);
1091 if ctx.functions.contains_key(name) {
1243 if ctx.functions.contains_key(name) {
1092 ctx.used_functions.insert(name.to_string());
1244 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 {
1249+ message: format!("@{} is deprecated: {}", name, msg),
1250+ severity: Severity::Warning,
1251+ source_line: Some(content.clone()),
1093 let nodes =
1254 let nodes =
1094 self.expand_fn_call(name, &content, current_indent, line_num, ctx)?;
1255 self.expand_fn_call(name, &content, current_indent, line_num, ctx)?;
1095 return Ok(Some(nodes));
1256 return Ok(Some(nodes));
@@ -1378,7 +1539,7 @@ const KNOWN_DIRECTIVES: &[&str] = &[
1378 "if", "else", "each", "meta", "head", "style",
1539 "if", "else", "each", "meta", "head", "style",
1379 "match", "case", "default", "warn", "debug",
1540 "match", "case", "default", "warn", "debug",
1380 "unless", "og", "breakpoint", "lang", "favicon",
1541 "unless", "og", "breakpoint", "lang", "favicon",
1381− "use",
1542+ "use", "theme", "deprecated", "extends",
1384fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
1545fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
@@ -1540,7 +1701,7 @@ const KNOWN_ATTRS: &[&str] = &[
1540 "for", "action", "method", "autocomplete",
1701 "for", "action", "method", "autocomplete",
1541 "min", "max", "step", "pattern", "maxlength", "rows", "cols", "multiple",
1702 "min", "max", "step", "pattern", "maxlength", "rows", "cols", "multiple",
1542 // Accessibility
1703 // Accessibility
1543− "alt", "role", "tabindex", "title",
1704+ "alt", "role", "tabindex", "title", "autofocus",
1544 // CSS: aspect-ratio, outline, logical properties, scroll-snap
1705 // CSS: aspect-ratio, outline, logical properties, scroll-snap
1545 "aspect-ratio", "outline",
1706 "aspect-ratio", "outline",
1546 "padding-inline", "padding-block", "margin-inline", "margin-block",
1707 "padding-inline", "padding-block", "margin-inline", "margin-block",
@@ -2451,6 +2612,29 @@ fn contrast_ratio(c1: (u8, u8, u8), c2: (u8, u8, u8)) -> f64 {
2451 (lighter + 0.05) / (darker + 0.05)
2612 (lighter + 0.05) / (darker + 0.05)
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)
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;
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;
2454// ---------------------------------------------------------------------------
2638// ---------------------------------------------------------------------------
2455// Variable substitution
2639// Variable substitution
2456// ---------------------------------------------------------------------------
2640// ---------------------------------------------------------------------------
@@ -2518,6 +2702,75 @@ fn substitute_vars(input: &str, vars: &HashMap<String, String>) -> String {
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];
2719+ if !rest.starts_with('[') || !rest.ends_with(']') {
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() {
2731+ if let Some((key, value)) = part.split_once(' ') {
2732+ css.push_str(key.trim());
2734+ css.push_str(value.trim());
2739+ if css.is_empty() {
2743+ Some(format!("{}{{{}}}", selector, css))
2746+/// Replace @slot placeholders in the base layout with content from @extends
2747+fn replace_extends_slots(
2749+ slot_nodes: &HashMap<String, Vec<Node>>,
2751+ let mut result = Vec::new();
2752+ for node in nodes {
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);
2764+ Node::Element(mut elem) => {
2765+ elem.children = replace_extends_slots(elem.children, slot_nodes);
2766+ result.push(Node::Element(elem));
2768+ other => result.push(other),
2521fn apply_filter(value: &str, filter: &str) -> String {
2774fn apply_filter(value: &str, filter: &str) -> String {
2522 if let Some(arg) = filter.strip_prefix("truncate:") {
2775 if let Some(arg) = filter.strip_prefix("truncate:") {
2523 if let Ok(n) = arg.parse::<usize>() {
2776 if let Ok(n) = arg.parse::<usize>() {
@@ -2539,6 +2792,49 @@ fn apply_filter(value: &str, filter: &str) -> String {
2540 return value.to_string();
2793 return value.to_string();
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);
2803+ return value.to_string();
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);
2812+ return value.to_string();
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);
2821+ return value.to_string();
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>(),
2832+ let (r, g, b) = mix_colors(c1, c2, pct / 100.0);
2833+ return format!("#{:02x}{:02x}{:02x}", r, g, b);
2836+ return value.to_string();
2542 match filter {
2838 match filter {
2543 "uppercase" | "upper" => value.to_uppercase(),
2839 "uppercase" | "upper" => value.to_uppercase(),
2544 "lowercase" | "lower" => value.to_lowercase(),
2840 "lowercase" | "lower" => value.to_lowercase(),