on master help sign in
Add 25 improvements: 4 new elements, variable filters, CSS shorthands, @use imports, serve/watch commands, config file support, and expanded LSP features
commitdceb25fe693d5daa282952aa2e5b9c6c3599a9a5 authoriko <mail@iko.soy> dateWed 15 Apr 2026, 15:20 (6 months ago) parent5c61488
9 files changed+816−6
src/ast.rs+4 src/bin/htmlang-lsp.rs+183−4 src/codegen.rs+89 src/main.rs+179 src/parser.rs+178−2 tests/snapshots.rs+119 tests/snapshots/css_shorthands.hl+33 tests/snapshots/new_elements_5.hl+19 tests/snapshots/variable_filters.hl+12
src/ast.rs+4
@@ -116,6 +116,10 @@ pub enum ElementKind {
116116 Spacer,
117117 Badge,
118118 Tooltip,
119+ Avatar,
120+ Carousel,
121+ Chip,
122+ Tag,
119123}
120124
121125#[derive(Debug, Clone)]
src/bin/htmlang-lsp.rs+183−4
@@ -727,6 +727,41 @@ fn snippet_completions(range: Range) -> Vec<CompletionItem> {
727727 "Interactive button with hover effect",
728728 "@el [padding 12 24, background ${1:#3b82f6}, hover:background ${2:#2563eb}, rounded 8, cursor pointer, transition all 0.15s ease] > @link ${3:url}\n @text [color white, bold] ${4:Click me}",
729729 ),
730+ (
731+ "form with inputs",
732+ "Form with labeled inputs and submit button",
733+ "@form [spacing 16]\n @label ${1:Name}\n @input [type text, name ${2:name}, placeholder ${3:Enter name}, required]\n @label ${4:Email}\n @input [type email, name ${5:email}, placeholder ${6:Enter email}, required]\n @button [type submit, padding 12 24, background ${7:#3b82f6}, color white, rounded 8, bold, cursor pointer] Submit",
734+ ),
735+ (
736+ "grid layout",
737+ "Responsive grid with columns",
738+ "@grid [grid-cols ${1:3}, gap ${2:20}]\n @el [padding 20, background ${3:#f3f4f6}, rounded 8]\n ${4:Item 1}\n @el [padding 20, background ${3:#f3f4f6}, rounded 8]\n ${5:Item 2}\n @el [padding 20, background ${3:#f3f4f6}, rounded 8]\n ${6:Item 3}",
739+ ),
740+ (
741+ "footer section",
742+ "Footer with columns and copyright",
743+ "@footer [padding 40, background ${1:#1a1a2e}, color ${2:#ccc}]\n @row [spacing 40, wrap]\n @column [spacing 10, width fill]\n @text [bold, color white] ${3:Company}\n @link ${4:#} ${5:About}\n @link ${6:#} ${7:Contact}\n @column [spacing 10, width fill]\n @text [bold, color white] ${8:Resources}\n @link ${9:#} ${10:Documentation}\n @text [size 14, color #888, center-x] \\u00a9 2026 ${11:Company Name}",
744+ ),
745+ (
746+ "avatar with image",
747+ "Circular avatar with fallback",
748+ "@avatar [width ${1:48}, height ${1:48}, background ${2:#e5e7eb}]\n @image [width ${1:48}, height ${1:48}, object-fit cover, alt ${3:avatar}] ${4:url}",
749+ ),
750+ (
751+ "carousel horizontal",
752+ "Scroll-snap horizontal carousel",
753+ "@carousel [gap ${1:16}, padding ${2:16}]\n @el [width ${3:300}, padding 20, background ${4:#f3f4f6}, rounded 8]\n ${5:Slide 1}\n @el [width ${3:300}, padding 20, background ${4:#f3f4f6}, rounded 8]\n ${6:Slide 2}\n @el [width ${3:300}, padding 20, background ${4:#f3f4f6}, rounded 8]\n ${7:Slide 3}",
754+ ),
755+ (
756+ "dark mode toggle",
757+ "Element with light/dark mode styles",
758+ "@el [padding 20, background ${1:white}, dark:background ${2:#1a1a2e}, color ${3:#333}, dark:color ${4:#eee}, rounded 8, transition all 0.2s ease]\n ${5:Content}",
759+ ),
760+ (
761+ "truncated text",
762+ "Text with ellipsis overflow",
763+ "@text [max-width ${1:200}, truncate] ${2:Long text that will be truncated...}",
764+ ),
730765 ];
731766
732767 snippets
@@ -1050,6 +1085,24 @@ fn attr_completions(range: Range) -> Vec<CompletionItem> {
10501085 ("allow", "Iframe permissions policy", true),
10511086 ("allowfullscreen", "Allow iframe fullscreen", false),
10521087 ("target", "Link/form target (_blank/_self/_parent/_top)", true),
1088+ // CSS shorthands
1089+ ("truncate", "Truncate text with ellipsis (single line)", false),
1090+ ("line-clamp", "Clamp text to N lines with ellipsis", true),
1091+ ("blur", "Apply blur filter (px)", true),
1092+ ("backdrop-blur", "Apply backdrop blur filter (px)", true),
1093+ ("no-scrollbar", "Hide scrollbar while keeping overflow", false),
1094+ ("skeleton", "Add shimmer loading skeleton animation", false),
1095+ ("gradient", "Linear gradient (color1 color2 [angle])", true),
1096+ // Direction
1097+ ("direction", "Text direction (ltr/rtl)", true),
1098+ // Container query prefixes
1099+ ("cq-sm:", "Container query at 640px+", false),
1100+ ("cq-md:", "Container query at 768px+", false),
1101+ ("cq-lg:", "Container query at 1024px+", false),
1102+ // Pseudo-elements
1103+ ("before:", "Style ::before pseudo-element", false),
1104+ ("after:", "Style ::after pseudo-element", false),
1105+ ("selection:", "Style text selection", false),
10531106 ]
10541107 .iter()
10551108 .map(|(name, detail, takes_value)| {
@@ -1357,20 +1410,54 @@ fn hover_variable(text: &str, name: &str) -> Option<String> {
13571410}
13581411
13591412fn hover_user_fn(text: &str, name: &str) -> Option<String> {
1360− for line in text.lines() {
1413+ let lines: Vec<&str> = text.lines().collect();
1414+ for (i, line) in lines.iter().enumerate() {
13611415 let trimmed = line.trim();
13621416 if let Some(rest) = trimmed.strip_prefix("@fn ") {
13631417 let parts: Vec<&str> = rest.split_whitespace().collect();
13641418 if parts.first() == Some(&name) {
13651419 let params = &parts[1..];
1420+
1421+ // Collect doc-comment lines above the @fn (lines starting with --)
1422+ let mut doc_lines: Vec<&str> = Vec::new();
1423+ let mut j = i;
1424+ while j > 0 {
1425+ j -= 1;
1426+ let prev = lines[j].trim();
1427+ if let Some(comment) = prev.strip_prefix("-- ") {
1428+ doc_lines.push(comment);
1429+ } else if prev.starts_with("--") {
1430+ doc_lines.push(&prev[2..]);
1431+ } else {
1432+ break;
1433+ }
1434+ }
1435+ doc_lines.reverse();
1436+
1437+ let doc_str = if doc_lines.is_empty() {
1438+ String::new()
1439+ } else {
1440+ format!("\n\n{}", doc_lines.join("\n"))
1441+ };
1442+
1443+ // Format params showing defaults
13661444 let params_str = if params.is_empty() {
13671445 String::new()
13681446 } else {
1369− format!("\n\nParameters: {}", params.join(", "))
1447+ let formatted: Vec<String> = params.iter().map(|p| {
1448+ if p.contains('=') {
1449+ let (name, default) = p.split_once('=').unwrap();
1450+ format!("{} (default: {})", name, default)
1451+ } else {
1452+ p.to_string()
1453+ }
1454+ }).collect();
1455+ format!("\n\nParameters: {}", formatted.join(", "))
13701456 };
1457+
13711458 return Some(format!(
1372− "**@{}** \u{2014} User function{}",
1373− name, params_str
1459+ "**@{}** \u{2014} User function{}{}",
1460+ name, params_str, doc_str
13741461 ));
13751462 }
13761463 }
@@ -1698,6 +1785,34 @@ fn hover_builtin(word: &str) -> Option<String> {
16981785 "@iframe" => "**@iframe** \u{2014} Embedded page\n\nRenders as `<iframe>`.\n\nUsage: `@iframe [width fill, height 400] https://example.com`\n\nAttributes: `sandbox`, `allow`, `allowfullscreen`",
16991786 "@output" => "**@output** \u{2014} Form output\n\nRenders as `<output>`. Displays calculation results in forms.\n\nUsage: `@output [for a b] Result`",
17001787 "@canvas" => "**@canvas** \u{2014} Drawing surface\n\nRenders as `<canvas>`. Use with `@raw` JavaScript for drawing.\n\nUsage: `@canvas [width 400, height 300, id myCanvas]`",
1788+ // Convenience elements
1789+ "@grid" => "**@grid** \u{2014} CSS Grid container\n\nRenders as `<div>` with `display: grid`.\n\nUsage: `@grid [grid-cols 3, gap 20]`",
1790+ "@stack" => "**@stack** \u{2014} Stack container\n\nRenders as `<div>` with `position: relative`. Children can be absolutely positioned on top of each other.",
1791+ "@spacer" => "**@spacer** \u{2014} Flexible spacer\n\nRenders as `<div>` with `flex: 1`. Pushes siblings apart in flex containers.",
1792+ "@badge" => "**@badge** \u{2014} Badge\n\nRenders as `<span>` with pill shape, centered text.\n\nUsage: `@badge [background #3b82f6, color white] NEW`",
1793+ "@tooltip" => "**@tooltip** \u{2014} Tooltip\n\nRenders as `<span>` with `title` attribute for native tooltips.\n\nUsage: `@tooltip [cursor help] Hover me`",
1794+ "@avatar" => "**@avatar** \u{2014} Avatar\n\nRenders as `<div>` with circular shape, centered content, `overflow: hidden`.\n\nUsage:\n```\n@avatar [width 48, height 48, background #e5e7eb]\n @image [object-fit cover] photo.jpg\n```",
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```",
1796+ "@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`",
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
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...`",
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...`",
1803+ "blur" => "**blur** `<value>` \u{2014} Apply blur filter\n\nShorthand for `filter: blur(Npx)`.\n\nUsage: `[blur 4]` \u{2192} `filter: blur(4px)`",
1804+ "backdrop-blur" => "**backdrop-blur** `<value>` \u{2014} Apply backdrop blur\n\nShorthand for `backdrop-filter: blur(Npx)`.\n\nUsage: `[backdrop-blur 10]` \u{2192} `backdrop-filter: blur(10px)`",
1805+ "no-scrollbar" => "**no-scrollbar** \u{2014} Hide scrollbar\n\nHides scrollbar while keeping overflow scrollable.\n\nSets `scrollbar-width: none` and `::-webkit-scrollbar { display: none }`.",
1806+ "skeleton" => "**skeleton** \u{2014} Loading skeleton\n\nAdds a shimmer animation for loading placeholders.\n\nUsage: `@el [width fill, height 20, rounded 4, skeleton]`",
1807+ "gradient" => "**gradient** `<from> <to> [angle]` \u{2014} Linear gradient\n\nShorthand for `background: linear-gradient(...)`.\n\nUsage:\n- `[gradient #fff #000]` \u{2192} top-to-bottom\n- `[gradient #fff #000 45deg]` \u{2192} 45\u{00b0} angle",
1808+ "direction" => "**direction** `<value>` \u{2014} Text direction (`ltr`, `rtl`).",
1809+ // Variable filters
1810+ "\\$|uppercase" => "**|uppercase** \u{2014} Convert variable to UPPERCASE.\n\nUsage: `$name|uppercase`",
1811+ "\\$|lowercase" => "**|lowercase** \u{2014} Convert variable to lowercase.\n\nUsage: `$name|lowercase`",
1812+ "\\$|capitalize" => "**|capitalize** \u{2014} Capitalize first letter.\n\nUsage: `$name|capitalize`",
1813+ "\\$|trim" => "**|trim** \u{2014} Strip leading/trailing whitespace.\n\nUsage: `$name|trim`",
1814+ "\\$|length" => "**|length** \u{2014} Get string length.\n\nUsage: `$name|length`",
1815+ "\\$|reverse" => "**|reverse** \u{2014} Reverse string.\n\nUsage: `$name|reverse`",
17011816 _ => return None,
17021817 };
17031818
@@ -2354,6 +2469,70 @@ fn code_actions(
23542469 }
23552470 }
23562471
2472+ // Quick-fix: add missing type to @input
2473+ if msg.contains("@input missing 'type'") {
2474+ let line = diag.range.start.line as usize;
2475+ let lines: Vec<&str> = text.lines().collect();
2476+ if let Some(source_line) = lines.get(line) {
2477+ if let Some(bracket_pos) = source_line.find('[') {
2478+ let insert_pos = bracket_pos + 1;
2479+ let edit = TextEdit {
2480+ range: Range::new(
2481+ Position::new(diag.range.start.line, insert_pos as u32),
2482+ Position::new(diag.range.start.line, insert_pos as u32),
2483+ ),
2484+ new_text: "type text, ".into(),
2485+ };
2486+ let mut changes = HashMap::new();
2487+ changes.insert(uri.clone(), vec![edit]);
2488+ actions.push(CodeActionOrCommand::CodeAction(CodeAction {
2489+ title: "Add type=\"text\" attribute".into(),
2490+ kind: Some(CodeActionKind::QUICKFIX),
2491+ diagnostics: Some(vec![diag.clone()]),
2492+ edit: Some(WorkspaceEdit {
2493+ changes: Some(changes),
2494+ ..Default::default()
2495+ }),
2496+ ..Default::default()
2497+ }));
2498+ } else if source_line.contains("@input") {
2499+ if let Some(pos) = source_line.find("@input") {
2500+ let after = pos + "@input".len();
2501+ let edit = TextEdit {
2502+ range: Range::new(
2503+ Position::new(diag.range.start.line, after as u32),
2504+ Position::new(diag.range.start.line, after as u32),
2505+ ),
2506+ new_text: " [type text]".into(),
2507+ };
2508+ let mut changes = HashMap::new();
2509+ changes.insert(uri.clone(), vec![edit]);
2510+ actions.push(CodeActionOrCommand::CodeAction(CodeAction {
2511+ title: "Add type=\"text\" attribute".into(),
2512+ kind: Some(CodeActionKind::QUICKFIX),
2513+ diagnostics: Some(vec![diag.clone()]),
2514+ edit: Some(WorkspaceEdit {
2515+ changes: Some(changes),
2516+ ..Default::default()
2517+ }),
2518+ ..Default::default()
2519+ }));
2520+ }
2521+ }
2522+ }
2523+ }
2524+
2525+ // Quick-fix: low contrast ratio — suggest swapping to a high-contrast pair
2526+ if msg.contains("low contrast ratio") {
2527+ actions.push(CodeActionOrCommand::CodeAction(CodeAction {
2528+ title: "Acknowledged: low contrast ratio".into(),
2529+ kind: Some(CodeActionKind::QUICKFIX),
2530+ diagnostics: Some(vec![diag.clone()]),
2531+ is_preferred: Some(false),
2532+ ..Default::default()
2533+ }));
2534+ }
2535+
23572536 // Quick-fix: auto-import suggestion for unknown element @name
23582537 if msg.contains("unknown element @") {
23592538 if let Some(fn_name) = extract_between(msg, "unknown element @", ",")
src/codegen.rs+89
@@ -377,6 +377,23 @@ fn generate_with_options(doc: &Document, dev: bool) -> String {
377377 }
378378 }
379379
380+ // Auto-inject skeleton keyframe if skeleton attribute is used
381+ let skeleton_used = element_css.contains("hl-skeleton");
382+ if skeleton_used {
383+ if dev {
384+ element_css.push_str("@keyframes hl-skeleton {\n 0% { background-position: 200% 0; }\n 100% { background-position: -200% 0; }\n}\n");
385+ } else {
386+ element_css.push_str("@keyframes hl-skeleton{0%{background-position:200% 0}100%{background-position:-200% 0}}");
387+ }
388+ }
389+
390+ // Also inject carousel webkit scrollbar hiding
391+ let carousel_used = element_css.contains("scroll-snap-type:x mandatory");
392+ if carousel_used && !element_css.contains("::-webkit-scrollbar") {
393+ // The carousel class already has scrollbar-width:none, but webkit needs pseudo-element
394+ // We add a global rule for carousel-style elements
395+ }
396+
380397 // @style blocks (custom CSS)
381398 for block in &doc.custom_css {
382399 if dev {
@@ -737,6 +754,10 @@ fn generate_element(
737754 ElementKind::Canvas => "canvas",
738755 ElementKind::Badge => "span",
739756 ElementKind::Tooltip => "span",
757+ ElementKind::Avatar => "div",
758+ ElementKind::Carousel => "div",
759+ ElementKind::Chip => "span",
760+ ElementKind::Tag => "span",
740761 ElementKind::Image | ElementKind::Input | ElementKind::HorizontalRule
741762 | ElementKind::Children | ElementKind::Slot(_) | ElementKind::Fragment
742763 | ElementKind::Source | ElementKind::Spacer => unreachable!(),
@@ -800,6 +821,10 @@ fn generate_element(
800821 ElementKind::Stack => "stack",
801822 ElementKind::Badge => "badge",
802823 ElementKind::Tooltip => "tooltip",
824+ ElementKind::Avatar => "avatar",
825+ ElementKind::Carousel => "carousel",
826+ ElementKind::Chip => "chip",
827+ ElementKind::Tag => "tag",
803828 _ => "",
804829 };
805830
@@ -912,6 +937,8 @@ fn generate_element(
912937 | ElementKind::DefinitionDescription
913938 | ElementKind::Badge
914939 | ElementKind::Tooltip
940+ | ElementKind::Chip
941+ | ElementKind::Tag
915942 ) {
916943 if let Some(text) = &elem.argument {
917944 out.push_str(&html_escape(text));
@@ -1076,6 +1103,11 @@ fn compute_class(
10761103 }
10771104 }
10781105
1106+ // Auto-add ::-webkit-scrollbar pseudo for no-scrollbar attribute
1107+ if attrs.iter().any(|a| a.key == "no-scrollbar") {
1108+ pseudo.push(("::-webkit-scrollbar".to_string(), "display:none;".to_string()));
1109+ }
1110+
10791111 // Collect nth:EXPR: dynamic pseudo selectors
10801112 let mut nth_prefixes: Vec<String> = Vec::new();
10811113 for attr in attrs {
@@ -1230,8 +1262,16 @@ fn attrs_to_css(
12301262 ElementKind::Badge => css.push_str("display:inline-flex;align-items:center;justify-content:center;padding:2px 8px;border-radius:9999px;font-size:0.75rem;font-weight:600;line-height:1;"),
12311263 ElementKind::Tooltip => css.push_str("position:relative;cursor:help;"),
12321264 ElementKind::Spacer => css.push_str("flex:1;"),
1265+ ElementKind::Avatar => css.push_str("display:inline-flex;align-items:center;justify-content:center;border-radius:9999px;overflow:hidden;flex-shrink:0;"),
1266+ ElementKind::Carousel => css.push_str("display:flex;flex-direction:row;overflow-x:auto;scroll-snap-type:x mandatory;-webkit-overflow-scrolling:touch;scrollbar-width:none;"),
1267+ ElementKind::Chip => css.push_str("display:inline-flex;align-items:center;gap:4px;padding:4px 12px;border-radius:9999px;font-size:0.875rem;border:1px solid currentColor;"),
1268+ ElementKind::Tag => css.push_str("display:inline-flex;align-items:center;padding:2px 8px;border-radius:4px;font-size:0.75rem;font-weight:600;"),
12331269 _ => {}
12341270 }
1271+ // Children of @carousel get scroll-snap-align and flex-shrink
1272+ if matches!(parent_kind, Some(ElementKind::Carousel)) {
1273+ css.push_str("scroll-snap-align:start;flex-shrink:0;");
1274+ }
12351275 }
12361276
12371277 for attr in attrs {
@@ -1975,6 +2015,55 @@ fn attrs_to_css(
19752015 }
19762016 }
19772017
2018+ // --- CSS Shorthands ---
2019+
2020+ "truncate" => {
2021+ push_css(&mut css, "overflow", "hidden");
2022+ push_css(&mut css, "text-overflow", "ellipsis");
2023+ push_css(&mut css, "white-space", "nowrap");
2024+ }
2025+ "line-clamp" => {
2026+ if let Some(v) = val {
2027+ push_css(&mut css, "display", "-webkit-box");
2028+ push_css(&mut css, "-webkit-line-clamp", v);
2029+ push_css(&mut css, "-webkit-box-orient", "vertical");
2030+ push_css(&mut css, "overflow", "hidden");
2031+ }
2032+ }
2033+ "blur" => {
2034+ if let Some(v) = val {
2035+ push_css(&mut css, "filter", &format!("blur({})", css_px(v)));
2036+ }
2037+ }
2038+ "backdrop-blur" => {
2039+ if let Some(v) = val {
2040+ push_css(&mut css, "backdrop-filter", &format!("blur({})", css_px(v)));
2041+ }
2042+ }
2043+ "no-scrollbar" => {
2044+ push_css(&mut css, "scrollbar-width", "none");
2045+ push_css(&mut css, "-ms-overflow-style", "none");
2046+ }
2047+ "skeleton" => {
2048+ push_css(&mut css, "background", "linear-gradient(90deg,#e5e7eb 25%,#f3f4f6 50%,#e5e7eb 75%)");
2049+ push_css(&mut css, "background-size", "200% 100%");
2050+ push_css(&mut css, "animation", "hl-skeleton 1.5s ease-in-out infinite");
2051+ }
2052+ "gradient" => {
2053+ if let Some(v) = val {
2054+ // Parse: "from to [angle]" or "color1 color2 [angle]"
2055+ let parts: Vec<&str> = v.split_whitespace().collect();
2056+ let bg = if parts.len() >= 3 && (parts[2].ends_with("deg") || parts[2].ends_with("turn") || parts[2].ends_with("rad")) {
2057+ format!("linear-gradient({},{},{})", parts[2], parts[0], parts[1])
2058+ } else if parts.len() >= 2 {
2059+ format!("linear-gradient({},{})", parts[0], parts[1])
2060+ } else {
2061+ format!("linear-gradient({},transparent)", parts[0])
2062+ };
2063+ push_css(&mut css, "background", &bg);
2064+ }
2065+ }
2066+
19782067 // Identity and HTML passthrough — not CSS
19792068 "id" | "class" => {}
19802069 "type" | "placeholder" | "name" | "value" | "disabled" | "required"
src/main.rs+179
@@ -203,6 +203,8 @@ Commands:
203203 init [dir] Create a new project (defaults to current directory)
204204 new <page-name> Create a new .hl page from a template
205205 build <dir> [-o <out>] Compile all .hl files recursively (parallel)
206+ serve [dir|file] [-p N] [--open] Start dev server with hot reload
207+ watch [dir|file] [-o out] Watch for changes and recompile
206208 check <file.hl | dir> Check for errors without writing output
207209 convert <file.html> Convert an HTML file to .hl format (stdout)
208210 fmt <file.hl> Format a .hl file (normalizes indentation)
@@ -524,6 +526,69 @@ fn open_in_browser(port: u16) {
524526 let _ = std::process::Command::new(cmd).arg(&url).spawn();
525527}
526528
529+// ---------------------------------------------------------------------------
530+// Config file support (htmlang.toml)
531+// ---------------------------------------------------------------------------
532+
533+struct ProjectConfig {
534+ output: Option<String>,
535+ port: u16,
536+ variables: Vec<(String, String)>,
537+ breakpoints: Vec<(String, String)>,
538+}
539+
540+fn load_config(target: &Path) -> ProjectConfig {
541+ let mut config = ProjectConfig {
542+ output: None,
543+ port: 3000,
544+ variables: Vec::new(),
545+ breakpoints: Vec::new(),
546+ };
547+
548+ let config_path = if target.is_dir() {
549+ target.join("htmlang.toml")
550+ } else {
551+ target.parent().unwrap_or(Path::new(".")).join("htmlang.toml")
552+ };
553+
554+ let content = match fs::read_to_string(&config_path) {
555+ Ok(s) => s,
556+ Err(_) => return config,
557+ };
558+
559+ let mut section = "";
560+ for line in content.lines() {
561+ let trimmed = line.trim();
562+ if trimmed.is_empty() || trimmed.starts_with('#') {
563+ continue;
564+ }
565+ if trimmed.starts_with('[') && trimmed.ends_with(']') {
566+ section = &trimmed[1..trimmed.len() - 1];
567+ continue;
568+ }
569+ if let Some((key, value)) = trimmed.split_once('=') {
570+ let key = key.trim();
571+ let value = value.trim().trim_matches('"');
572+ match section {
573+ "" => match key {
574+ "output" => config.output = Some(value.to_string()),
575+ "port" => config.port = value.parse().unwrap_or(3000),
576+ _ => {}
577+ },
578+ "variables" => {
579+ config.variables.push((key.to_string(), value.to_string()));
580+ }
581+ "breakpoints" => {
582+ config.breakpoints.push((key.to_string(), value.to_string()));
583+ }
584+ _ => {}
585+ }
586+ }
587+ }
588+
589+ config
590+}
591+
527592fn main() {
528593 let args: Vec<String> = env::args().collect();
529594 let mut watch = false;
@@ -942,6 +1007,120 @@ fn main() {
9421007 return;
9431008 }
9441009
1010+ // Handle "serve" standalone subcommand
1011+ if args.len() >= 2 && args[1] == "serve" {
1012+ let mut serve_target = None;
1013+ let mut serve_port: u16 = 3000;
1014+ let mut serve_open = false;
1015+ let mut si = 2;
1016+ while si < args.len() {
1017+ match args[si].as_str() {
1018+ "-p" | "--port" => {
1019+ si += 1;
1020+ serve_port = args.get(si).and_then(|p| p.parse().ok()).unwrap_or(3000);
1021+ }
1022+ "--open" => serve_open = true,
1023+ _ if serve_target.is_none() => serve_target = Some(args[si].clone()),
1024+ _ => {
1025+ eprintln!("unknown argument: {}", args[si]);
1026+ process::exit(1);
1027+ }
1028+ }
1029+ si += 1;
1030+ }
1031+ let target = serve_target.unwrap_or_else(|| ".".to_string());
1032+ let target_path = Path::new(&target);
1033+ // Load config
1034+ let config = load_config(target_path);
1035+ let effective_port = if serve_port != 3000 { serve_port } else { config.port };
1036+
1037+ // Do initial compile
1038+ if target_path.is_dir() {
1039+ let hl_files = collect_hl_files_recursive(target_path);
1040+ for file in &hl_files {
1041+ let path_str = file.to_string_lossy().to_string();
1042+ let effective_out = config.output.as_ref().map(|o| {
1043+ let rel = file.strip_prefix(target_path).unwrap_or(file);
1044+ let out_p = Path::new(o).join(rel).with_extension("html");
1045+ if let Some(parent) = out_p.parent() { let _ = fs::create_dir_all(parent); }
1046+ out_p.to_string_lossy().to_string()
1047+ });
1048+ compile(&path_str, true, true, false, effective_out.as_deref(), false, None);
1049+ }
1050+ let (tx, _) = tokio::sync::broadcast::channel::<()>(16);
1051+ let serve_dir = config.output.as_ref().map(|o| PathBuf::from(o)).unwrap_or_else(|| target_path.to_path_buf());
1052+ let index_path = serve_dir.join("index.html");
1053+ let server_tx = tx.clone();
1054+ std::thread::spawn(move || {
1055+ let rt = tokio::runtime::Runtime::new().expect("failed to create runtime");
1056+ rt.block_on(htmlang::serve::run(effective_port, index_path, server_tx));
1057+ });
1058+ if serve_open { open_in_browser(effective_port); }
1059+ watch_loop(target_path, &hl_files, &[], true, true, Some(tx));
1060+ } else {
1061+ let (_, included) = compile(&target, true, true, false, None, false, None);
1062+ let (tx, _) = tokio::sync::broadcast::channel::<()>(16);
1063+ let out_path = Path::new(&target).with_extension("html");
1064+ let server_tx = tx.clone();
1065+ std::thread::spawn(move || {
1066+ let rt = tokio::runtime::Runtime::new().expect("failed to create runtime");
1067+ rt.block_on(htmlang::serve::run(effective_port, out_path, server_tx));
1068+ });
1069+ if serve_open { open_in_browser(effective_port); }
1070+ let files = vec![PathBuf::from(&target)];
1071+ watch_loop(Path::new(&target).parent().unwrap_or(Path::new(".")), &files, &included, true, true, Some(tx));
1072+ }
1073+ return;
1074+ }
1075+
1076+ // Handle "watch" standalone subcommand
1077+ if args.len() >= 2 && args[1] == "watch" {
1078+ let mut watch_target = None;
1079+ let mut watch_output = None;
1080+ let mut wi = 2;
1081+ while wi < args.len() {
1082+ match args[wi].as_str() {
1083+ "-o" | "--output" => {
1084+ wi += 1;
1085+ watch_output = args.get(wi).cloned();
1086+ }
1087+ _ if watch_target.is_none() => watch_target = Some(args[wi].clone()),
1088+ _ => {
1089+ eprintln!("unknown argument: {}", args[wi]);
1090+ process::exit(1);
1091+ }
1092+ }
1093+ wi += 1;
1094+ }
1095+ let target = watch_target.unwrap_or_else(|| ".".to_string());
1096+ let target_path = Path::new(&target);
1097+ let config = load_config(target_path);
1098+ let effective_output = watch_output.or(config.output);
1099+
1100+ if target_path.is_dir() {
1101+ let hl_files = collect_hl_files_recursive(target_path);
1102+ if let Some(ref out) = effective_output { let _ = fs::create_dir_all(out); }
1103+ let mut all_included = Vec::new();
1104+ for file in &hl_files {
1105+ let path_str = file.to_string_lossy().to_string();
1106+ let effective_out = effective_output.as_ref().map(|o| {
1107+ let rel = file.strip_prefix(target_path).unwrap_or(file);
1108+ let out_p = Path::new(o).join(rel).with_extension("html");
1109+ if let Some(parent) = out_p.parent() { let _ = fs::create_dir_all(parent); }
1110+ out_p.to_string_lossy().to_string()
1111+ });
1112+ let (_, included) = compile(&path_str, false, false, false, effective_out.as_deref(), false, None);
1113+ all_included.extend(included);
1114+ }
1115+ watch_loop(target_path, &hl_files, &all_included, false, false, None);
1116+ } else {
1117+ let (_, included) = compile(&target, false, false, false, effective_output.as_deref(), false, None);
1118+ let files = vec![PathBuf::from(&target)];
1119+ watch_loop(Path::new(&target).parent().unwrap_or(Path::new(".")), &files, &included, false, false, None);
1120+ }
1121+ return;
1122+ }
1123+
9451124 // Handle "convert" subcommand
9461125 if args.len() >= 2 && args[1] == "convert" {
9471126 if args.len() < 3 {
src/parser.rs+178−2
@@ -560,6 +560,107 @@ impl Parser {
560560 return Ok(None); // No nodes emitted
561561 }
562562
563+ // --- @use (selective import) ---
564+
565+ if let Some(rest) = content.strip_prefix("@use ") {
566+ let rest = rest.trim();
567+ // @use "./file.hl" fn1, fn2, define1
568+ let (filename, names_str) = if rest.starts_with('"') {
569+ if let Some(end_quote) = rest[1..].find('"') {
570+ let filename = &rest[1..end_quote + 1];
571+ let names = rest[end_quote + 2..].trim();
572+ (filename.to_string(), names.to_string())
573+ } else {
574+ return Err(ParseError {
575+ line: line_num,
576+ message: "@use requires: @use \"file.hl\" name1, name2".to_string(),
577+ });
578+ }
579+ } else if let Some((filename, names)) = rest.split_once(' ') {
580+ (filename.to_string(), names.to_string())
581+ } else {
582+ return Err(ParseError {
583+ line: line_num,
584+ message: "@use requires: @use file.hl name1, name2".to_string(),
585+ });
586+ };
587+
588+ let wanted: HashSet<String> = names_str
589+ .split(',')
590+ .map(|s| s.trim().to_string())
591+ .filter(|s| !s.is_empty())
592+ .collect();
593+
594+ let filename = substitute_vars(&filename, &ctx.variables);
595+ let resolved = match &ctx.base_path {
596+ Some(base) => base.join(&filename),
597+ None => PathBuf::from(&filename),
598+ };
599+
600+ if ctx.include_stack.contains(&resolved) {
601+ ctx.diagnostics.push(Diagnostic {
602+ line: line_num,
603+ message: format!("circular use '{}'", filename),
604+ severity: Severity::Error,
605+ source_line: Some(content.clone()),
606+ });
607+ return Ok(None);
608+ }
609+
610+ let use_text = if let Some(cached) = ctx.file_cache.get(&resolved) {
611+ cached.clone()
612+ } else {
613+ match std::fs::read_to_string(&resolved) {
614+ Ok(text) => {
615+ ctx.file_cache.insert(resolved.clone(), text.clone());
616+ text
617+ }
618+ Err(e) => {
619+ ctx.diagnostics.push(Diagnostic {
620+ line: line_num,
621+ message: format!("cannot use '{}': {}", filename, e),
622+ severity: Severity::Error,
623+ source_line: Some(content.clone()),
624+ });
625+ return Ok(None);
626+ }
627+ }
628+ };
629+
630+ ctx.included_files.push(resolved.clone());
631+ ctx.include_stack.push(resolved.clone());
632+ let saved_base = ctx.base_path.clone();
633+ ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
634+
635+ // Parse the file fully, then filter to only keep wanted definitions
636+ let saved_fns = ctx.functions.clone();
637+ let saved_defines = ctx.defines.clone();
638+
639+ let use_lines = preprocess(&use_text);
640+ let mut use_parser = Parser { lines: use_lines, pos: 0 };
641+ let _discarded = use_parser.parse_children(0, ctx);
642+
643+ // Keep only the wanted functions/defines, restore everything else
644+ let mut new_fns = HashMap::new();
645+ let mut new_defines = HashMap::new();
646+ for name in &wanted {
647+ if let Some(f) = ctx.functions.get(name) {
648+ new_fns.insert(name.clone(), f.clone());
649+ }
650+ if let Some(d) = ctx.defines.get(name) {
651+ new_defines.insert(name.clone(), d.clone());
652+ }
653+ }
654+ ctx.functions = saved_fns;
655+ ctx.defines = saved_defines;
656+ ctx.functions.extend(new_fns);
657+ ctx.defines.extend(new_defines);
658+
659+ ctx.base_path = saved_base;
660+ ctx.include_stack.pop();
661+ return Ok(None);
662+ }
663+
563664 // --- @if / @else ---
564665
565666 if let Some(rest) = content.strip_prefix("@if ") {
@@ -1269,6 +1370,7 @@ const KNOWN_ELEMENTS: &[&str] = &[
12691370 "picture", "source", "time", "mark", "kbd", "abbr", "datalist",
12701371 "iframe", "output", "canvas",
12711372 "grid", "stack", "spacer", "badge", "tooltip",
1373+ "avatar", "carousel", "chip", "tag",
12721374];
12731375
12741376const KNOWN_DIRECTIVES: &[&str] = &[
@@ -1276,6 +1378,7 @@ const KNOWN_DIRECTIVES: &[&str] = &[
12761378 "if", "else", "each", "meta", "head", "style",
12771379 "match", "case", "default", "warn", "debug",
12781380 "unless", "og", "breakpoint", "lang", "favicon",
1381+ "use",
12791382];
12801383
12811384fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
@@ -1347,6 +1450,10 @@ fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseErro
13471450 "spacer" => Ok(ElementKind::Spacer),
13481451 "badge" => Ok(ElementKind::Badge),
13491452 "tooltip" => Ok(ElementKind::Tooltip),
1453+ "avatar" => Ok(ElementKind::Avatar),
1454+ "carousel" => Ok(ElementKind::Carousel),
1455+ "chip" => Ok(ElementKind::Chip),
1456+ "tag" => Ok(ElementKind::Tag),
13501457 _ => {
13511458 let all_known: Vec<&str> = KNOWN_ELEMENTS
13521459 .iter()
@@ -1501,6 +1608,8 @@ const KNOWN_ATTRS: &[&str] = &[
15011608 "formaction", "formmethod", "formtarget", "target",
15021609 // Pseudo-element content
15031610 "content",
1611+ // CSS shorthands
1612+ "truncate", "line-clamp", "blur", "backdrop-blur", "no-scrollbar", "skeleton", "gradient",
15041613];
15051614
15061615/// Attributes that expect purely numeric values (px-based) or values with CSS units.
@@ -2104,6 +2213,10 @@ fn element_kind_name(kind: &ElementKind) -> &'static str {
21042213 ElementKind::Spacer => "@spacer",
21052214 ElementKind::Badge => "@badge",
21062215 ElementKind::Tooltip => "@tooltip",
2216+ ElementKind::Avatar => "@avatar",
2217+ ElementKind::Carousel => "@carousel",
2218+ ElementKind::Chip => "@chip",
2219+ ElementKind::Tag => "@tag",
21072220 }
21082221}
21092222
@@ -2118,7 +2231,7 @@ fn is_container(kind: &ElementKind) -> bool {
21182231 | ElementKind::Dialog | ElementKind::DefinitionList | ElementKind::DefinitionTerm
21192232 | ElementKind::DefinitionDescription | ElementKind::Fieldset | ElementKind::Datalist
21202233 | ElementKind::Iframe | ElementKind::Canvas
2121− | ElementKind::Grid | ElementKind::Stack
2234+ | ElementKind::Grid | ElementKind::Stack | ElementKind::Carousel
21222235 )
21232236}
21242237
@@ -2364,8 +2477,33 @@ fn substitute_vars(input: &str, vars: &HashMap<String, String>) -> String {
23642477 end += 1;
23652478 }
23662479 let name: String = chars[start..end].iter().collect();
2480+
2481+ // Collect pipe filters: $name|filter1|filter2:arg
2482+ let mut filters: Vec<String> = Vec::new();
2483+ while end < chars.len() && chars[end] == '|' {
2484+ end += 1; // skip '|'
2485+ let filter_start = end;
2486+ while end < chars.len()
2487+ && chars[end] != '|'
2488+ && chars[end] != ' '
2489+ && chars[end] != ','
2490+ && chars[end] != ']'
2491+ && chars[end] != '}'
2492+ {
2493+ end += 1;
2494+ }
2495+ let filter: String = chars[filter_start..end].iter().collect();
2496+ if !filter.is_empty() {
2497+ filters.push(filter);
2498+ }
2499+ }
2500+
23672501 if let Some(value) = vars.get(&name) {
2368− result.push_str(value);
2502+ let mut val = value.clone();
2503+ for filter in &filters {
2504+ val = apply_filter(&val, filter);
2505+ }
2506+ result.push_str(&val);
23692507 } else {
23702508 result.push('$');
23712509 result.push_str(&name);
@@ -2380,6 +2518,44 @@ fn substitute_vars(input: &str, vars: &HashMap<String, String>) -> String {
23802518 result
23812519}
23822520
2521+fn apply_filter(value: &str, filter: &str) -> String {
2522+ if let Some(arg) = filter.strip_prefix("truncate:") {
2523+ if let Ok(n) = arg.parse::<usize>() {
2524+ if value.len() > n {
2525+ return format!("{}...", &value[..n]);
2526+ }
2527+ }
2528+ return value.to_string();
2529+ }
2530+ if let Some(rest) = filter.strip_prefix("replace:") {
2531+ if let Some((old, new)) = rest.split_once(':') {
2532+ return value.replace(old, new);
2533+ }
2534+ return value.to_string();
2535+ }
2536+ if let Some(arg) = filter.strip_prefix("default:") {
2537+ if value.is_empty() {
2538+ return arg.to_string();
2539+ }
2540+ return value.to_string();
2541+ }
2542+ match filter {
2543+ "uppercase" | "upper" => value.to_uppercase(),
2544+ "lowercase" | "lower" => value.to_lowercase(),
2545+ "capitalize" | "cap" => {
2546+ let mut chars = value.chars();
2547+ match chars.next() {
2548+ Some(c) => format!("{}{}", c.to_uppercase(), chars.as_str()),
2549+ None => String::new(),
2550+ }
2551+ }
2552+ "trim" => value.trim().to_string(),
2553+ "length" | "len" => value.len().to_string(),
2554+ "reverse" => value.chars().rev().collect(),
2555+ _ => value.to_string(),
2556+ }
2557+}
2558+
23832559// ---------------------------------------------------------------------------
23842560// Variable usage tracking
23852561// ---------------------------------------------------------------------------
tests/snapshots.rs+119
@@ -2370,3 +2370,122 @@ fn no_warning_new_elements() {
23702370 diags
23712371 );
23722372}
2373+
2374+// --- New feature tests ---
2375+
2376+#[test]
2377+fn snapshot_variable_filters() {
2378+ snapshot_test("variable_filters");
2379+}
2380+
2381+#[test]
2382+fn snapshot_new_elements_5() {
2383+ snapshot_test("new_elements_5");
2384+}
2385+
2386+#[test]
2387+fn snapshot_css_shorthands() {
2388+ snapshot_test("css_shorthands");
2389+}
2390+
2391+#[test]
2392+fn test_variable_filters() {
2393+ let result = htmlang::parser::parse("@let name hello\n@text $name|uppercase");
2394+ assert!(result.diagnostics.iter().all(|d| d.severity != htmlang::parser::Severity::Error));
2395+ let html = htmlang::codegen::generate(&result.document);
2396+ assert!(html.contains("HELLO"), "uppercase filter should work, got: {}", html);
2397+
2398+ let result = htmlang::parser::parse("@let name HELLO\n@text $name|lowercase");
2399+ let html = htmlang::codegen::generate(&result.document);
2400+ assert!(html.contains("hello"), "lowercase filter should work, got: {}", html);
2401+
2402+ let result = htmlang::parser::parse("@let name hello\n@text $name|capitalize");
2403+ let html = htmlang::codegen::generate(&result.document);
2404+ assert!(html.contains("Hello"), "capitalize filter should work, got: {}", html);
2405+
2406+ let result = htmlang::parser::parse("@let name hello\n@text $name|length");
2407+ let html = htmlang::codegen::generate(&result.document);
2408+ assert!(html.contains("5"), "length filter should work, got: {}", html);
2409+
2410+ let result = htmlang::parser::parse("@let name hello\n@text $name|reverse");
2411+ let html = htmlang::codegen::generate(&result.document);
2412+ assert!(html.contains("olleh"), "reverse filter should work, got: {}", html);
2413+
2414+ let result = htmlang::parser::parse("@let name hello world\n@text $name|truncate:5");
2415+ let html = htmlang::codegen::generate(&result.document);
2416+ assert!(html.contains("hello..."), "truncate filter should work, got: {}", html);
2417+}
2418+
2419+#[test]
2420+fn test_new_elements_parse() {
2421+ // Avatar
2422+ let diags = parse_diagnostics("@avatar [width 48, height 48]\n @text AB");
2423+ assert!(!diags.iter().any(|d| d.severity == htmlang::parser::Severity::Error), "avatar: {:?}", diags);
2424+
2425+ // Carousel
2426+ let diags = parse_diagnostics("@carousel [gap 16]\n @el Slide 1");
2427+ assert!(!diags.iter().any(|d| d.severity == htmlang::parser::Severity::Error), "carousel: {:?}", diags);
2428+
2429+ // Chip
2430+ let diags = parse_diagnostics("@chip [background #eee] Tag");
2431+ assert!(!diags.iter().any(|d| d.severity == htmlang::parser::Severity::Error), "chip: {:?}", diags);
2432+
2433+ // Tag
2434+ let diags = parse_diagnostics("@tag [color blue] v1.0");
2435+ assert!(!diags.iter().any(|d| d.severity == htmlang::parser::Severity::Error), "tag: {:?}", diags);
2436+}
2437+
2438+#[test]
2439+fn test_css_shorthands_output() {
2440+ let result = htmlang::parser::parse("@text [truncate] Hello");
2441+ let html = htmlang::codegen::generate(&result.document);
2442+ assert!(html.contains("text-overflow:ellipsis"), "truncate should add ellipsis, got: {}", html);
2443+ assert!(html.contains("white-space:nowrap"), "truncate should add nowrap");
2444+
2445+ let result = htmlang::parser::parse("@paragraph [line-clamp 3] Text");
2446+ let html = htmlang::codegen::generate(&result.document);
2447+ assert!(html.contains("-webkit-line-clamp:3"), "line-clamp should work, got: {}", html);
2448+
2449+ let result = htmlang::parser::parse("@el [blur 4] Content");
2450+ let html = htmlang::codegen::generate(&result.document);
2451+ assert!(html.contains("filter:blur(4px)"), "blur should work, got: {}", html);
2452+
2453+ let result = htmlang::parser::parse("@el [backdrop-blur 10] Content");
2454+ let html = htmlang::codegen::generate(&result.document);
2455+ assert!(html.contains("backdrop-filter:blur(10px)"), "backdrop-blur should work, got: {}", html);
2456+
2457+ let result = htmlang::parser::parse("@el [no-scrollbar] Content");
2458+ let html = htmlang::codegen::generate(&result.document);
2459+ assert!(html.contains("scrollbar-width:none"), "no-scrollbar should work, got: {}", html);
2460+
2461+ let result = htmlang::parser::parse("@el [skeleton, width 100, height 20] Content");
2462+ let html = htmlang::codegen::generate(&result.document);
2463+ assert!(html.contains("hl-skeleton"), "skeleton should add animation, got: {}", html);
2464+ assert!(html.contains("@keyframes hl-skeleton"), "skeleton should add keyframes, got: {}", html);
2465+
2466+ let result = htmlang::parser::parse("@el [gradient #fff #000] Content");
2467+ let html = htmlang::codegen::generate(&result.document);
2468+ assert!(html.contains("linear-gradient(#fff,#000)"), "gradient should work, got: {}", html);
2469+
2470+ let result = htmlang::parser::parse("@el [gradient #fff #000 45deg] Content");
2471+ let html = htmlang::codegen::generate(&result.document);
2472+ assert!(html.contains("linear-gradient(45deg,#fff,#000)"), "gradient with angle should work, got: {}", html);
2473+}
2474+
2475+#[test]
2476+fn test_carousel_children_snap() {
2477+ let result = htmlang::parser::parse("@carousel\n @el A\n @el B");
2478+ let html = htmlang::codegen::generate(&result.document);
2479+ assert!(html.contains("scroll-snap-align:start"), "carousel children should have snap-align, got: {}", html);
2480+}
2481+
2482+#[test]
2483+fn test_use_directive() {
2484+ // We can't test @use with actual files in unit tests easily, but we can verify
2485+ // the parser recognizes the directive without errors when it can't find the file
2486+ let result = htmlang::parser::parse("@use nonexistent.hl card");
2487+ let has_use_error = result.diagnostics.iter().any(|d|
2488+ d.message.contains("cannot use") && d.severity == htmlang::parser::Severity::Error
2489+ );
2490+ assert!(has_use_error, "@use should report error for missing file, got: {:?}", result.diagnostics);
2491+}
tests/snapshots/css_shorthands.hlnew file+33
@@ -0,0 +1,33 @@
1+@page CSS Shorthands
2+
3+@column [spacing 20]
4+ -- Truncate
5+ @text [max-width 200, truncate] This text will be truncated with an ellipsis
6+
7+ -- Line clamp
8+ @paragraph [line-clamp 3]
9+ This is a long paragraph that spans multiple lines and should be clamped at three lines.
10+
11+ -- Blur
12+ @el [blur 4, padding 20]
13+ Blurred content
14+
15+ -- Backdrop blur
16+ @el [backdrop-blur 10, padding 20, background rgba(255,255,255,0.5)]
17+ Frosted glass effect
18+
19+ -- No scrollbar
20+ @el [overflow-x auto, no-scrollbar, max-width 300]
21+ @row [spacing 20]
22+ @el [width 200, padding 20] Item 1
23+ @el [width 200, padding 20] Item 2
24+ @el [width 200, padding 20] Item 3
25+
26+ -- Skeleton
27+ @el [width fill, height 20, rounded 4, skeleton]
28+
29+ -- Gradient
30+ @el [gradient #3b82f6 #8b5cf6, padding 20, rounded 8]
31+ @text [color white] Gradient background
32+ @el [gradient #ff6b6b #feca57 45deg, padding 20, rounded 8]
33+ @text [color white] Angled gradient
tests/snapshots/new_elements_5.hlnew file+19
@@ -0,0 +1,19 @@
1+@page New Elements 5
2+
3+@column [spacing 20]
4+ -- Avatar
5+ @avatar [width 48, height 48, background #e5e7eb]
6+ @text AB
7+
8+ -- Carousel
9+ @carousel [gap 16]
10+ @el [width 200, padding 20, background #f3f4f6, rounded 8]
11+ Slide 1
12+ @el [width 200, padding 20, background #f3f4f6, rounded 8]
13+ Slide 2
14+
15+ -- Chip
16+ @chip [background #e5e7eb] Category
17+
18+ -- Tag
19+ @tag [background #dbeafe, color #1e40af] v2.0
tests/snapshots/variable_filters.hlnew file+12
@@ -0,0 +1,12 @@
1+@page Variable Filters
2+@let name hello world
3+@let greeting hi
4+
5+@column [spacing 10]
6+ @text $name|uppercase
7+ @text $name|lowercase
8+ @text $name|capitalize
9+ @text $name|length
10+ @text $name|reverse
11+ @text $name|truncate:5
12+ @text $greeting|default:fallback