on master help sign in
Add 20 improvements: @theme, @deprecated, @extends, enhanced @keyframes, color filters, autofocus, repl/feed/components CLI, and LSP enhancements
ada22d5iko
DESIGN.md+104
@@ -696,6 +696,10 @@ htmlang --format json page.hl # output diagnostics as JSON
696htmlang lint page.hl # stricter lint checks (accessibility, nesting)
696htmlang lint page.hl # stricter lint checks (accessibility, nesting)
697htmlang lint src/ # lint all .hl files in a directory
697htmlang lint src/ # lint all .hl files in a directory
698htmlang stats page.hl # show file statistics (elements, CSS, colors)
698htmlang 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
699```
703```
700
704
701Watch mode recompiles automatically when the source file or any `@include`d/`@import`ed files change.
705Watch 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:
710- No external CSS, no JavaScript (unless injected via `@raw`)
714- No external CSS, no JavaScript (unless injected via `@raw`)
711- `@page` generates the HTML boilerplate; without it, output is an HTML fragment
715- `@page` generates the HTML boilerplate; without it, output is an HTML fragment
712
716
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+
713## Editor support
817## Editor support
714
818
715A VS Code extension is available in `editors/vscode/` with:
819A VS Code extension is available in `editors/vscode/` with:
src/ast.rs+1
@@ -15,6 +15,7 @@ pub struct Document {
15 pub custom_css: Vec<String>,
15 pub custom_css: Vec<String>,
16 pub og_tags: Vec<(String, String)>,
16 pub og_tags: Vec<(String, String)>,
17 pub custom_breakpoints: Vec<(String, String)>,
17 pub custom_breakpoints: Vec<(String, String)>,
18+ pub theme_tokens: Vec<(String, String)>,
18 pub nodes: Vec<Node>,
19 pub nodes: Vec<Node>,
19}
20}
20
21
src/bin/htmlang-lsp.rs+8−2
@@ -687,6 +687,10 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
687 ("@unless", "Inverse conditional (renders when false)", "@unless "),
687 ("@unless", "Inverse conditional (renders when false)", "@unless "),
688 ("@og", "Add Open Graph meta tag", "@og "),
688 ("@og", "Add Open Graph meta tag", "@og "),
689 ("@breakpoint", "Define custom responsive breakpoint", "@breakpoint "),
689 ("@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 "),
690 ]
694 ]
691 .iter()
695 .iter()
692 .map(|(name, detail, insert)| {
696 .map(|(name, detail, insert)| {
@@ -1607,6 +1611,10 @@ fn hover_builtin(word: &str) -> Option<String> {
1607 "@unless" => "**@unless** \u{2014} Inverse conditional\n\nRenders children when the condition is false (opposite of `@if`).\n\nUsage: `@unless $debug`",
1611 "@unless" => "**@unless** \u{2014} Inverse conditional\n\nRenders children when the condition is false (opposite of `@if`).\n\nUsage: `@unless $debug`",
1608 "@og" => "**@og** \u{2014} Open Graph meta tag\n\nAdds an Open Graph `<meta>` tag to `<head>`.\n\nUsage: `@og title My Page Title`",
1612 "@og" => "**@og** \u{2014} Open Graph meta tag\n\nAdds an Open Graph `<meta>` tag to `<head>`.\n\nUsage: `@og title My Page Title`",
1609 "@breakpoint" => "**@breakpoint** \u{2014} Custom breakpoint\n\nDefines a custom responsive breakpoint.\n\nUsage: `@breakpoint tablet 600`",
1613 "@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```",
1610 // Attributes
1618 // Attributes
1611 "spacing" | "gap" => "**spacing** `<value>`\n\nGap between children. Supports CSS units (px, rem, em, %).\nMaps to CSS `gap`.",
1619 "spacing" | "gap" => "**spacing** `<value>`\n\nGap between children. Supports CSS units (px, rem, em, %).\nMaps to CSS `gap`.",
1612 "padding" => "**padding** `<value>` | `<y> <x>` | `<t> <h> <b>` | `<t> <r> <b> <l>`\n\nInner padding. Supports CSS units. Accepts 1\u{2013}4 values.",
1620 "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> {
1795 "@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```",
1803 "@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```",
1796 "@chip" => "**@chip** \u{2014} Chip / pill\n\nRenders as `<span>` with rounded borders, inline-flex layout.\n\nUsage: `@chip [background #e5e7eb] Category`",
1804 "@chip" => "**@chip** \u{2014} Chip / pill\n\nRenders as `<span>` with rounded borders, inline-flex layout.\n\nUsage: `@chip [background #e5e7eb] Category`",
1797 "@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`",
1805 "@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.",
1800 // CSS shorthands
1806 // CSS shorthands
1801 "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...`",
1807 "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...`",
1802 "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...`",
1808 "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] = &[
616 "type", "placeholder", "name", "value", "disabled", "required", "checked",
616 "type", "placeholder", "name", "value", "disabled", "required", "checked",
617 "for", "action", "method", "autocomplete",
617 "for", "action", "method", "autocomplete",
618 "min", "max", "step", "pattern", "maxlength", "rows", "cols", "multiple",
618 "min", "max", "step", "pattern", "maxlength", "rows", "cols", "multiple",
619− "alt", "role", "tabindex", "title",
619+ "alt", "role", "tabindex", "title", "autofocus",
620 // Media
620 // Media
621 "controls", "autoplay", "loop", "muted", "poster", "preload",
621 "controls", "autoplay", "loop", "muted", "poster", "preload",
622 // Image optimization
622 // Image optimization
@@ -639,7 +639,7 @@ const HTML_PASSTHROUGH_ATTRS: &[&str] = &[
639const BOOLEAN_HTML_ATTRS: &[&str] = &[
639const BOOLEAN_HTML_ATTRS: &[&str] = &[
640 "disabled", "required", "checked", "multiple",
640 "disabled", "required", "checked", "multiple",
641 "controls", "autoplay", "loop", "muted",
641 "controls", "autoplay", "loop", "muted",
642− "open", "novalidate",
642+ "open", "novalidate", "autofocus",
643];
643];
644
644
645fn emit_html_passthrough_attrs(out: &mut String, attrs: &[Attribute]) {
645fn emit_html_passthrough_attrs(out: &mut String, attrs: &[Attribute]) {
@@ -2076,7 +2076,8 @@ fn attrs_to_css(
2076 | "colspan" | "rowspan" | "scope" | "inline"
2076 | "colspan" | "rowspan" | "scope" | "inline"
2077 | "datetime" | "media" | "sizes" | "srcset" | "cite" | "list"
2077 | "datetime" | "media" | "sizes" | "srcset" | "cite" | "list"
2078 | "sandbox" | "allow" | "allowfullscreen" | "referrerpolicy"
2078 | "sandbox" | "allow" | "allowfullscreen" | "referrerpolicy"
2079− | "formaction" | "formmethod" | "formtarget" | "target" => {}
2079+ | "formaction" | "formmethod" | "formtarget" | "target"
2080+ | "autofocus" => {}
2080
2081
2081 _ => {}
2082 _ => {}
2082 }
2083 }
src/main.rs+169
@@ -214,6 +214,9 @@ Commands:
214 preview <file.hl> Compile and open in browser (one-shot, no server)
214 preview <file.hl> Compile and open in browser (one-shot, no server)
215 diff <a.hl> <b.hl> Show differences between two .hl files
215 diff <a.hl> <b.hl> Show differences between two .hl files
216 export <dir> [-o f] Compile and bundle into an archive
216 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
217
220
218Options:
221Options:
219 -w, --watch Watch for changes and recompile
222 -w, --watch Watch for changes and recompile
@@ -1121,6 +1124,172 @@ fn main() {
1121 return;
1124 return;
1122 }
1125 }
1123
1126
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+
1124 // Handle "convert" subcommand
1293 // Handle "convert" subcommand
1125 if args.len() >= 2 && args[1] == "convert" {
1294 if args.len() >= 2 && args[1] == "convert" {
1126 if args.len() < 3 {
1295 if args.len() < 3 {
src/parser.rs+299−3
@@ -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)>,
99}
103}
100
104
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(),
139 };
145 };
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,
157 nodes,
164 nodes,
158 },
165 },
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);
1054+ } else {
1055+ body.push_str(trimmed);
1056+ }
1045 }
1057 }
1046 self.pos += 1;
1058 self.pos += 1;
1047 }
1059 }
@@ -1049,6 +1061,146 @@ impl Parser {
1049 return Ok(None);
1061 return Ok(None);
1050 }
1062 }
1051
1063
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+
1052 // --- Function definition ---
1204 // --- Function definition ---
1053
1205
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 {
1248+ line: line_num,
1249+ message: format!("@{} is deprecated: {}", name, msg),
1250+ severity: Severity::Warning,
1251+ source_line: Some(content.clone()),
1252+ });
1253+ }
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",
1382];
1543];
1383
1544
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)
2452}
2613}
2453
2614
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+
2454// ---------------------------------------------------------------------------
2638// ---------------------------------------------------------------------------
2455// Variable substitution
2639// Variable substitution
2456// ---------------------------------------------------------------------------
2640// ---------------------------------------------------------------------------
@@ -2518,6 +2702,75 @@ fn substitute_vars(input: &str, vars: &HashMap<String, String>) -> String {
2518 result
2702 result
2519}
2703}
2520
2704
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+
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 {
2539 }
2792 }
2540 return value.to_string();
2793 return value.to_string();
2541 }
2794 }
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+ }
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(),
tests/snapshots.rs+100
@@ -2489,3 +2489,103 @@ fn test_use_directive() {
2489 );
2489 );
2490 assert!(has_use_error, "@use should report error for missing file, got: {:?}", result.diagnostics);
2490 assert!(has_use_error, "@use should report error for missing file, got: {:?}", result.diagnostics);
2491}
2491}
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+}