on master help sign in
Add 20 improvements: @theme, @deprecated, @extends, enhanced @keyframes, color filters, autofocus, repl/feed/components CLI, and LSP enhancements
commitada22d553ef8f8628bd4d3b3080c906bc1cf4138 authoriko <mail@iko.soy> dateWed 15 Apr 2026, 15:56 (6 months ago) parentdceb25f
7 files changed+685−8
DESIGN.md+104 src/ast.rs+1 src/bin/htmlang-lsp.rs+8−2 src/codegen.rs+4−3 src/main.rs+169 src/parser.rs+299−3 tests/snapshots.rs+100
DESIGN.md+104
@@ -696,6 +696,10 @@ htmlang --format json page.hl # output diagnostics as JSON
696696htmlang lint page.hl # stricter lint checks (accessibility, nesting)
697697htmlang lint src/ # lint all .hl files in a directory
698698htmlang stats page.hl # show file statistics (elements, CSS, colors)
699+htmlang repl # interactive REPL (type .hl, get HTML)
700+htmlang feed src/ # generate RSS feed from @page metadata
701+htmlang feed src/ -b https://mysite.com # with custom base URL
702+htmlang components src/ # list all @fn definitions across project
699703```
700704
701705Watch mode recompiles automatically when the source file or any `@include`d/`@import`ed files change.
@@ -710,6 +714,106 @@ Each `.hl` file compiles to a single self-contained `.html` file:
710714- No external CSS, no JavaScript (unless injected via `@raw`)
711715- `@page` generates the HTML boilerplate; without it, output is an HTML fragment
712716
717+## Template inheritance (`@extends`)
718+
719+A page can inherit a base layout and fill named `@slot` blocks:
720+
721+**layout.hl:**
722+```
723+@page My Site
724+@column [max-width 800, center-x, padding 40]
725+ @slot header
726+ @text [bold, size 32] Default Header
727+ @slot content
728+ @slot footer
729+ @text [color #888] Default Footer
730+```
731+
732+**about.hl:**
733+```
734+@extends layout.hl
735+@slot header
736+ @text [bold, size 32] About Us
737+@slot content
738+ @paragraph We are a team of...
739+```
740+
741+Slots not filled by the extending page use the default children from the layout.
742+
743+## Design tokens (`@theme`)
744+
745+Centralized design token definitions. Each token becomes both a `$variable` and a `--css-custom-property`:
746+
747+```
748+@theme
749+ primary #3b82f6
750+ secondary #10b981
751+ spacing-sm 8
752+ spacing-md 16
753+ font-body system-ui, sans-serif
754+```
755+
756+Equivalent to:
757+```
758+@let primary #3b82f6
759+@let --primary #3b82f6
760+@let secondary #10b981
761+@let --secondary #10b981
762+...
763+```
764+
765+## Deprecation (`@deprecated`)
766+
767+Mark a function as deprecated. Callers receive a compile-time warning:
768+
769+```
770+@deprecated Use @card-v2 instead
771+@fn old-card $title
772+ @el [padding 20]
773+ @text $title
774+ @children
775+```
776+
777+When `@old-card` is called, the compiler emits: `warning: @old-card is deprecated: Use @card-v2 instead`
778+
779+## Color functions
780+
781+Color manipulation via variable filters:
782+
783+```
784+@let primary #3b82f6
785+
786+@el [background $primary|lighten:20] -- 20% lighter
787+@el [background $primary|darken:10] -- 10% darker
788+@el [background $primary|alpha:0.5] -- 50% transparent (#3b82f67f)
789+@el [background $primary|mix:#ffffff:50] -- 50% mixed with white
790+```
791+
792+Filters: `lighten:N` (0-100), `darken:N` (0-100), `alpha:N` (0-1), `mix:COLOR:N` (0-100).
793+
794+## Enhanced `@keyframes`
795+
796+Keyframes support htmlang attribute syntax:
797+
798+```
799+@keyframes fade-in
800+ from [opacity 0]
801+ to [opacity 1]
802+
803+@keyframes slide
804+ 0% [transform translateX(-100%)]
805+ 50% [transform translateX(0), opacity 1]
806+ 100% [transform translateX(100%), opacity 0]
807+```
808+
809+Raw CSS syntax is also supported for backwards compatibility.
810+
811+## Additional attributes
812+
813+| Attribute | Effect |
814+|-----------|--------|
815+| `autofocus` | Auto-focus element on page load (boolean) |
816+
713817## Editor support
714818
715819A VS Code extension is available in `editors/vscode/` with:
src/ast.rs+1
@@ -15,6 +15,7 @@ pub struct Document {
1515 pub custom_css: Vec<String>,
1616 pub og_tags: Vec<(String, String)>,
1717 pub custom_breakpoints: Vec<(String, String)>,
18+ pub theme_tokens: Vec<(String, String)>,
1819 pub nodes: Vec<Node>,
1920}
2021
src/bin/htmlang-lsp.rs+8−2
@@ -687,6 +687,10 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
687687 ("@unless", "Inverse conditional (renders when false)", "@unless "),
688688 ("@og", "Add Open Graph meta tag", "@og "),
689689 ("@breakpoint", "Define custom responsive breakpoint", "@breakpoint "),
690+ ("@theme", "Define design tokens (colors, spacing, fonts)", "@theme"),
691+ ("@deprecated", "Mark next @fn as deprecated", "@deprecated "),
692+ ("@extends", "Inherit a layout template and fill @slot blocks", "@extends "),
693+ ("@use", "Selective import of definitions from a file", "@use "),
690694 ]
691695 .iter()
692696 .map(|(name, detail, insert)| {
@@ -1607,6 +1611,10 @@ fn hover_builtin(word: &str) -> Option<String> {
16071611 "@unless" => "**@unless** \u{2014} Inverse conditional\n\nRenders children when the condition is false (opposite of `@if`).\n\nUsage: `@unless $debug`",
16081612 "@og" => "**@og** \u{2014} Open Graph meta tag\n\nAdds an Open Graph `<meta>` tag to `<head>`.\n\nUsage: `@og title My Page Title`",
16091613 "@breakpoint" => "**@breakpoint** \u{2014} Custom breakpoint\n\nDefines a custom responsive breakpoint.\n\nUsage: `@breakpoint tablet 600`",
1614+ "@theme" => "**@theme** \u{2014} Design tokens\n\nDefines centralized design tokens (colors, spacing, fonts).\nEach token becomes both a `$variable` and a `--css-custom-property`.\n\n```\n@theme\n primary #3b82f6\n spacing-md 16\n font-body system-ui, sans-serif\n```",
1615+ "@deprecated" => "**@deprecated** `<message>`\n\nMarks the next `@fn` as deprecated. Callers get a compile-time warning.\n\n```\n@deprecated Use @new-card instead\n@fn old-card $title\n ...\n```",
1616+ "@extends" => "**@extends** `<file.hl>`\n\nInherit a layout template. Fill named `@slot` blocks.\n\n```\n@extends layout.hl\n@slot content\n My page content\n@slot sidebar\n Sidebar content\n```",
1617+ "@use" => "**@use** `<file.hl> name1, name2`\n\nSelective import: only imports named `@fn`/`@define` definitions.\n\n```\n@use components.hl card, button\n```",
16101618 // Attributes
16111619 "spacing" | "gap" => "**spacing** `<value>`\n\nGap between children. Supports CSS units (px, rem, em, %).\nMaps to CSS `gap`.",
16121620 "padding" => "**padding** `<value>` | `<y> <x>` | `<t> <h> <b>` | `<t> <r> <b> <l>`\n\nInner padding. Supports CSS units. Accepts 1\u{2013}4 values.",
@@ -1795,8 +1803,6 @@ fn hover_builtin(word: &str) -> Option<String> {
17951803 "@carousel" => "**@carousel** \u{2014} Horizontal carousel\n\nRenders as `<div>` with horizontal scroll-snap.\nChildren auto-receive `scroll-snap-align: start` and `flex-shrink: 0`.\n\nUsage:\n```\n@carousel [gap 16]\n @el [width 300] Slide 1\n @el [width 300] Slide 2\n```",
17961804 "@chip" => "**@chip** \u{2014} Chip / pill\n\nRenders as `<span>` with rounded borders, inline-flex layout.\n\nUsage: `@chip [background #e5e7eb] Category`",
17971805 "@tag" => "**@tag** \u{2014} Tag label\n\nRenders as `<span>` with subtle rounded rectangle, bold small text.\n\nUsage: `@tag [background #dbeafe, color #1e40af] v2.0`",
1798− // New directives
1799− "@use" => "**@use** \u{2014} Selective import\n\nImport specific functions/defines from a file.\n\n```\n@use \"buttons.hl\" primary-button, secondary-button\n@use theme.hl card-style\n```\n\nOnly the named definitions are imported.",
18001806 // CSS shorthands
18011807 "truncate" => "**truncate** \u{2014} Truncate with ellipsis\n\nShorthand for: `overflow: hidden; text-overflow: ellipsis; white-space: nowrap`\n\nUsage: `@text [max-width 200, truncate] Long text here...`",
18021808 "line-clamp" => "**line-clamp** `<N>` \u{2014} Multi-line truncation\n\nClamps text to N lines with ellipsis.\n\nUsage: `@paragraph [line-clamp 3] Long paragraph...`",
src/codegen.rs+4−3
@@ -616,7 +616,7 @@ const HTML_PASSTHROUGH_ATTRS: &[&str] = &[
616616 "type", "placeholder", "name", "value", "disabled", "required", "checked",
617617 "for", "action", "method", "autocomplete",
618618 "min", "max", "step", "pattern", "maxlength", "rows", "cols", "multiple",
619− "alt", "role", "tabindex", "title",
619+ "alt", "role", "tabindex", "title", "autofocus",
620620 // Media
621621 "controls", "autoplay", "loop", "muted", "poster", "preload",
622622 // Image optimization
@@ -639,7 +639,7 @@ const HTML_PASSTHROUGH_ATTRS: &[&str] = &[
639639const BOOLEAN_HTML_ATTRS: &[&str] = &[
640640 "disabled", "required", "checked", "multiple",
641641 "controls", "autoplay", "loop", "muted",
642− "open", "novalidate",
642+ "open", "novalidate", "autofocus",
643643];
644644
645645fn emit_html_passthrough_attrs(out: &mut String, attrs: &[Attribute]) {
@@ -2076,7 +2076,8 @@ fn attrs_to_css(
20762076 | "colspan" | "rowspan" | "scope" | "inline"
20772077 | "datetime" | "media" | "sizes" | "srcset" | "cite" | "list"
20782078 | "sandbox" | "allow" | "allowfullscreen" | "referrerpolicy"
2079− | "formaction" | "formmethod" | "formtarget" | "target" => {}
2079+ | "formaction" | "formmethod" | "formtarget" | "target"
2080+ | "autofocus" => {}
20802081
20812082 _ => {}
20822083 }
src/main.rs+169
@@ -214,6 +214,9 @@ Commands:
214214 preview <file.hl> Compile and open in browser (one-shot, no server)
215215 diff <a.hl> <b.hl> Show differences between two .hl files
216216 export <dir> [-o f] Compile and bundle into an archive
217+ repl Interactive REPL (type .hl, get HTML)
218+ feed <dir> [-b URL] Generate RSS feed from @page metadata
219+ components <dir> List all @fn definitions across a project
217220
218221Options:
219222 -w, --watch Watch for changes and recompile
@@ -1121,6 +1124,172 @@ fn main() {
11211124 return;
11221125 }
11231126
1127+ // Handle "repl" subcommand
1128+ if args.len() >= 2 && args[1] == "repl" {
1129+ use std::io::{self, BufRead, Write};
1130+ eprintln!("htmlang repl — type .hl code, then a blank line to compile (Ctrl+D to exit)");
1131+ let stdin = io::stdin();
1132+ let stdout = io::stdout();
1133+ loop {
1134+ eprint!("> ");
1135+ let _ = io::stderr().flush();
1136+ let mut buffer = String::new();
1137+ loop {
1138+ let mut line = String::new();
1139+ match stdin.lock().read_line(&mut line) {
1140+ Ok(0) => {
1141+ if buffer.is_empty() {
1142+ return;
1143+ }
1144+ break;
1145+ }
1146+ Ok(_) => {
1147+ if line.trim().is_empty() && !buffer.is_empty() {
1148+ break;
1149+ }
1150+ buffer.push_str(&line);
1151+ }
1152+ Err(_) => return,
1153+ }
1154+ }
1155+ if buffer.trim().is_empty() {
1156+ continue;
1157+ }
1158+ let result = htmlang::parser::parse(&buffer);
1159+ for d in &result.diagnostics {
1160+ let prefix = match d.severity {
1161+ htmlang::parser::Severity::Error => "error",
1162+ htmlang::parser::Severity::Warning => "warning",
1163+ };
1164+ eprintln!("{}: line {}: {}", prefix, d.line, d.message);
1165+ }
1166+ if !result.diagnostics.iter().any(|d| d.severity == htmlang::parser::Severity::Error) {
1167+ let html = htmlang::codegen::generate_dev(&result.document);
1168+ let _ = stdout.lock().write_all(html.as_bytes());
1169+ let _ = stdout.lock().write_all(b"\n");
1170+ }
1171+ }
1172+ }
1173+
1174+ // Handle "feed" subcommand
1175+ if args.len() >= 2 && args[1] == "feed" {
1176+ let dir = if args.len() >= 3 && !args[2].starts_with('-') { &args[2] } else { "." };
1177+ let base_url = args.iter().position(|a| a == "--base-url" || a == "-b")
1178+ .and_then(|i| args.get(i + 1))
1179+ .map(|s| s.as_str())
1180+ .unwrap_or("https://example.com");
1181+ let dir_path = Path::new(dir);
1182+ let hl_files = collect_hl_files_recursive(dir_path);
1183+ if hl_files.is_empty() {
1184+ eprintln!("no .hl files found in {}", dir);
1185+ process::exit(1);
1186+ }
1187+ let mut items = Vec::new();
1188+ for file in &hl_files {
1189+ let input = match fs::read_to_string(file) {
1190+ Ok(s) => s,
1191+ Err(_) => continue,
1192+ };
1193+ let base = file.parent();
1194+ let result = htmlang::parser::parse_with_base(&input, base);
1195+ if let Some(ref title) = result.document.page_title {
1196+ let rel = file.strip_prefix(dir_path).unwrap_or(file);
1197+ let url_path = rel.with_extension("html").to_string_lossy().replace('\\', "/");
1198+ let url = if url_path == "index.html" {
1199+ format!("{}/", base_url.trim_end_matches('/'))
1200+ } else {
1201+ format!("{}/{}", base_url.trim_end_matches('/'), url_path)
1202+ };
1203+ // Get description from meta tags if available
1204+ let description = result.document.meta_tags.iter()
1205+ .find(|(k, _)| k == "description")
1206+ .map(|(_, v)| v.clone())
1207+ .unwrap_or_default();
1208+ items.push((title.clone(), url, description));
1209+ }
1210+ }
1211+ // Generate RSS 2.0 feed
1212+ let site_title = items.first().map(|(t, _, _)| t.clone()).unwrap_or_else(|| "My Site".to_string());
1213+ let mut rss = format!(
1214+ "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
1215+ <rss version=\"2.0\">\n\
1216+ <channel>\n\
1217+ <title>{}</title>\n\
1218+ <link>{}</link>\n\
1219+ <description>RSS feed generated by htmlang</description>\n",
1220+ site_title, base_url
1221+ );
1222+ for (title, url, description) in &items {
1223+ rss.push_str(&format!(
1224+ "<item>\n<title>{}</title>\n<link>{}</link>\n<description>{}</description>\n</item>\n",
1225+ title, url, description
1226+ ));
1227+ }
1228+ rss.push_str("</channel>\n</rss>\n");
1229+ let out_path = dir_path.join("feed.xml");
1230+ match fs::write(&out_path, &rss) {
1231+ Ok(()) => eprintln!("wrote {} ({} items)", out_path.display(), items.len()),
1232+ Err(e) => {
1233+ eprintln!("error: {}: {}", out_path.display(), e);
1234+ process::exit(1);
1235+ }
1236+ }
1237+ return;
1238+ }
1239+
1240+ // Handle "components" subcommand
1241+ if args.len() >= 2 && args[1] == "components" {
1242+ let target = if args.len() >= 3 { &args[2] } else { "." };
1243+ let path = Path::new(target);
1244+ let hl_files = if path.is_dir() {
1245+ collect_hl_files_recursive(path)
1246+ } else {
1247+ vec![PathBuf::from(target)]
1248+ };
1249+ if hl_files.is_empty() {
1250+ eprintln!("no .hl files found in {}", target);
1251+ process::exit(1);
1252+ }
1253+ let mut total = 0usize;
1254+ for file in &hl_files {
1255+ let input = match fs::read_to_string(file) {
1256+ Ok(s) => s,
1257+ Err(_) => continue,
1258+ };
1259+ let mut found = Vec::new();
1260+ for (line_num, line) in input.lines().enumerate() {
1261+ let trimmed = line.trim();
1262+ if let Some(rest) = trimmed.strip_prefix("@fn ") {
1263+ let parts: Vec<&str> = rest.split_whitespace().collect();
1264+ if let Some(&name) = parts.first() {
1265+ let params: Vec<&str> = parts[1..].iter()
1266+ .map(|p| p.strip_prefix('$').unwrap_or(p))
1267+ .collect();
1268+ let param_str = if params.is_empty() {
1269+ String::new()
1270+ } else {
1271+ format!(" ({})", params.join(", "))
1272+ };
1273+ found.push((line_num + 1, name.to_string(), param_str));
1274+ }
1275+ }
1276+ }
1277+ if !found.is_empty() {
1278+ let rel = file.strip_prefix(path).unwrap_or(file);
1279+ for (line, name, params) in &found {
1280+ println!(" @{}{} — {}:{}", name, params, rel.display(), line);
1281+ total += 1;
1282+ }
1283+ }
1284+ }
1285+ if total == 0 {
1286+ eprintln!("no @fn definitions found");
1287+ } else {
1288+ eprintln!("\n{} component(s) found", total);
1289+ }
1290+ return;
1291+ }
1292+
11241293 // Handle "convert" subcommand
11251294 if args.len() >= 2 && args[1] == "convert" {
11261295 if args.len() < 3 {
src/parser.rs+299−3
@@ -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(),
tests/snapshots.rs+100
@@ -2489,3 +2489,103 @@ fn test_use_directive() {
24892489 );
24902490 assert!(has_use_error, "@use should report error for missing file, got: {:?}", result.diagnostics);
24912491}
2492+
2493+#[test]
2494+fn test_enhanced_keyframes() {
2495+ let result = htmlang::parser::parse("@keyframes fade-in\n from [opacity 0]\n to [opacity 1]\n@el [animation fade-in 0.3s] Content");
2496+ let html = htmlang::codegen::generate(&result.document);
2497+ assert!(html.contains("@keyframes fade-in{from{opacity:0;}to{opacity:1;}}"), "keyframes should parse htmlang syntax, got: {}", html);
2498+}
2499+
2500+#[test]
2501+fn test_keyframes_percentage() {
2502+ let result = htmlang::parser::parse("@keyframes slide\n 0% [transform translateX(-100%)]\n 100% [transform translateX(0)]");
2503+ let html = htmlang::codegen::generate(&result.document);
2504+ assert!(html.contains("0%{transform:translateX(-100%);}"), "keyframe percentage should work, got: {}", html);
2505+}
2506+
2507+#[test]
2508+fn test_theme_directive() {
2509+ let result = htmlang::parser::parse("@theme\n primary #3b82f6\n spacing-md 16\n\n@el [background $primary, padding $spacing-md] Content");
2510+ let diags = &result.diagnostics;
2511+ let errors: Vec<_> = diags.iter().filter(|d| d.severity == htmlang::parser::Severity::Error).collect();
2512+ assert!(errors.is_empty(), "theme should not cause errors: {:?}", errors);
2513+ let html = htmlang::codegen::generate(&result.document);
2514+ assert!(html.contains("--primary:#3b82f6"), "theme should emit CSS vars, got: {}", html);
2515+ assert!(html.contains("--spacing-md:16"), "theme should emit spacing var, got: {}", html);
2516+ assert!(html.contains("background:#3b82f6"), "theme var should resolve in attrs, got: {}", html);
2517+}
2518+
2519+#[test]
2520+fn test_deprecated_fn() {
2521+ let result = htmlang::parser::parse("@deprecated Use @new-card instead\n@fn old-card $title\n @text $title\n\n@old-card [title Hello]");
2522+ let warnings: Vec<_> = result.diagnostics.iter()
2523+ .filter(|d| d.message.contains("deprecated"))
2524+ .collect();
2525+ assert!(!warnings.is_empty(), "calling deprecated fn should warn, got: {:?}", result.diagnostics);
2526+ assert!(warnings[0].message.contains("Use @new-card instead"), "deprecation message should be included");
2527+}
2528+
2529+#[test]
2530+fn test_extends_directive() {
2531+ // Can't test with actual files, but verify parse error for missing file
2532+ let result = htmlang::parser::parse("@extends nonexistent.hl\n@slot content\n Hello");
2533+ let has_error = result.diagnostics.iter().any(|d|
2534+ d.message.contains("cannot extend") && d.severity == htmlang::parser::Severity::Error
2535+ );
2536+ assert!(has_error, "@extends should report error for missing file, got: {:?}", result.diagnostics);
2537+}
2538+
2539+#[test]
2540+fn test_color_filter_lighten() {
2541+ let result = htmlang::parser::parse("@let primary #3b82f6\n@el [background $primary|lighten:20] Content");
2542+ let html = htmlang::codegen::generate(&result.document);
2543+ // Lighten #3b82f6 by 20% should produce a lighter blue
2544+ assert!(html.contains("background:#"), "lighten filter should produce hex color, got: {}", html);
2545+ // Verify it's not the original color
2546+ assert!(!html.contains("background:#3b82f6"), "lighten should change the color");
2547+}
2548+
2549+#[test]
2550+fn test_color_filter_darken() {
2551+ let result = htmlang::parser::parse("@let primary #ffffff\n@el [background $primary|darken:50] Content");
2552+ let html = htmlang::codegen::generate(&result.document);
2553+ // Darken white by 50% should produce gray (#808080 approximately)
2554+ assert!(html.contains("background:#"), "darken filter should produce hex color, got: {}", html);
2555+ assert!(!html.contains("background:#ffffff"), "darken should change the color");
2556+}
2557+
2558+#[test]
2559+fn test_color_filter_alpha() {
2560+ let result = htmlang::parser::parse("@let primary #3b82f6\n@el [background $primary|alpha:0.5] Content");
2561+ let html = htmlang::codegen::generate(&result.document);
2562+ // Should produce 8-digit hex with alpha
2563+ assert!(html.contains("background:#3b82f67f"), "alpha filter should add alpha channel, got: {}", html);
2564+}
2565+
2566+#[test]
2567+fn test_color_filter_mix() {
2568+ let result = htmlang::parser::parse("@let primary #000000\n@el [background $primary|mix:#ffffff:50] Content");
2569+ let html = htmlang::codegen::generate(&result.document);
2570+ // Mix black and white at 50% should produce gray
2571+ assert!(html.contains("background:#808080") || html.contains("background:#7f7f7f"), "mix filter should blend colors, got: {}", html);
2572+}
2573+
2574+#[test]
2575+fn test_autofocus_attribute() {
2576+ let result = htmlang::parser::parse("@input [type text, autofocus]");
2577+ let html = htmlang::codegen::generate(&result.document);
2578+ assert!(html.contains("autofocus"), "autofocus should be in output, got: {}", html);
2579+ // Should not produce unknown attribute warning
2580+ let unknown_warnings: Vec<_> = result.diagnostics.iter()
2581+ .filter(|d| d.message.contains("unknown attribute") && d.message.contains("autofocus"))
2582+ .collect();
2583+ assert!(unknown_warnings.is_empty(), "autofocus should not warn as unknown");
2584+}
2585+
2586+#[test]
2587+fn test_repl_components_feed_subcommands_recognized() {
2588+ // Just verify that the parser and codegen work for content that these commands would process
2589+ let result = htmlang::parser::parse("@page Test Site\n@meta description A test\n@fn card $title\n @text $title");
2590+ assert!(!result.diagnostics.iter().any(|d| d.severity == htmlang::parser::Severity::Error));
2591+}