on master help sign in
Add 17 improvements: computed @let, :has() pseudo, grid areas, animate, view transitions, @layer wrapping, 4 CLI commands (deps/dead-code/deploy/playground), LSP enhancements, and 18 VS Code snippets
commit0e17ca801566c6fdd63e27c191cb4b39dad84821 authoriko <mail@iko.soy> dateWed 15 Apr 2026, 16:14 (6 months ago) parentada22d5
15 files changed+1130−31
DESIGN.md+133 README.md+8 editors/vscode/package.json+6 editors/vscode/snippets/htmlang.json+202 src/bin/htmlang-lsp.rs+65−25 src/codegen.rs+94−6 src/main.rs+422 src/parser.rs+28 tests/snapshots.rs+126 tests/snapshots/animate_shorthand.hl+12 tests/snapshots/computed_let.hl+7 tests/snapshots/grid_areas.hl+10 tests/snapshots/has_pseudo.hl+6 tests/snapshots/layer_wrapping.hl+5 tests/snapshots/view_transitions.hl+6
DESIGN.md+133
@@ -700,10 +700,16 @@ htmlang repl # interactive REPL (type .hl, get HTML)
700700htmlang feed src/ # generate RSS feed from @page metadata
701701htmlang feed src/ -b https://mysite.com # with custom base URL
702702htmlang components src/ # list all @fn definitions across project
703+htmlang deps src/ # show dependency graph (@include/@import)
704+htmlang dead-code src/ # find unused @fn, @define, @let across project
705+htmlang deploy src/ # build and deploy to GitHub Pages
706+htmlang playground # generate self-contained HTML playground
703707```
704708
705709Watch mode recompiles automatically when the source file or any `@include`d/`@import`ed files change.
706710
711+The `build` command automatically extracts shared CSS rules across pages into a `shared.css` file when outputting to a directory.
712+
707713## Compilation target
708714
709715Each `.hl` file compiles to a single self-contained `.html` file:
@@ -814,6 +820,130 @@ Raw CSS syntax is also supported for backwards compatibility.
814820|-----------|--------|
815821| `autofocus` | Auto-focus element on page load (boolean) |
816822
823+## Named grid areas
824+
825+Use CSS Grid's named template areas with the `grid-template-areas` and `grid-area` attributes:
826+
827+```
828+@el [grid, grid-template-areas "header header" "sidebar main" "footer footer", gap 20]
829+ @el [grid-area header, padding 10, background #3b82f6]
830+ Header
831+ @el [grid-area sidebar, padding 10]
832+ Sidebar
833+ @el [grid-area main, padding 10]
834+ Main content
835+ @el [grid-area footer, padding 10]
836+ Footer
837+```
838+
839+## Animate shorthand
840+
841+The `animate` attribute is shorthand for the CSS `animation` property:
842+
843+```
844+@keyframes fade-in
845+ from [opacity 0]
846+ to [opacity 1]
847+
848+@el [animate fade-in 0.3s ease]
849+ Fades in on load
850+```
851+
852+## View transitions
853+
854+The `view-transition-name` attribute enables the View Transitions API for smooth page transitions:
855+
856+```
857+@el [view-transition-name hero]
858+ Hero content that transitions between pages
859+```
860+
861+## `:has()` pseudo-selector
862+
863+Style elements based on their children using the `has()` prefix:
864+
865+```
866+@el [padding 20, has(.active):background #dbeafe, has(img):padding 0]
867+ Content
868+```
869+
870+Generates CSS `:has()` selectors: `.class:has(.active) { background:#dbeafe; }`
871+
872+## Computed `@let`
873+
874+Variables support arithmetic expressions. The `=` sign is optional:
875+
876+```
877+@let base 16
878+@let large = $base * 2 -- 32
879+@let gap $base + 4 -- 20 (= is optional)
880+```
881+
882+Supported operators: `+`, `-`, `*`, `/`.
883+
884+## Named slots in `@fn`
885+
886+Functions support named `@slot` blocks for multi-region components:
887+
888+```
889+@fn layout $title
890+ @column [max-width 800, center-x, padding 40]
891+ @slot header
892+ @text [bold, size 32] $title
893+ @slot content
894+ @slot footer
895+ @text [color #888] Default Footer
896+
897+@layout [title My Page]
898+ @slot header
899+ @text [bold, size 32] Custom Header
900+ @slot content
901+ @paragraph Main content here
902+```
903+
904+Slots not filled by the caller use the default children from the function definition.
905+
906+## CSS `@layer` wrapping
907+
908+Generated styles are wrapped in `@layer htmlang { ... }` for specificity management. This allows `@style` blocks and `@raw` CSS to easily override generated styles without specificity wars.
909+
910+## CLI commands
911+
912+### `htmlang deps`
913+
914+Show the file dependency graph (`@include`, `@import`, `@extends` relationships):
915+
916+```
917+htmlang deps src/
918+```
919+
920+### `htmlang dead-code`
921+
922+Find unused `@fn`, `@define`, and `@let` definitions across an entire project:
923+
924+```
925+htmlang dead-code src/
926+```
927+
928+### `htmlang deploy`
929+
930+Build and deploy to GitHub Pages:
931+
932+```
933+htmlang deploy src/
934+```
935+
936+Compiles all `.hl` files, copies static assets, and pushes to a `gh-pages` branch.
937+
938+### `htmlang playground`
939+
940+Generate a self-contained HTML playground for experimenting with htmlang:
941+
942+```
943+htmlang playground # writes playground.html
944+htmlang playground my-play.html # custom output path
945+```
946+
817947## Editor support
818948
819949A VS Code extension is available in `editors/vscode/` with:
@@ -829,6 +959,9 @@ A VS Code extension is available in `editors/vscode/` with:
829959 - Signature help for `@fn` parameters
830960 - Document formatting (format on save)
831961 - Code actions (quick-fixes for typos, unused variables, missing attributes)
962+ - Auto-import suggestions (scans project for `@fn` definitions)
963+ - Extract selection to `@fn` refactoring
832964 - Color picker for hex colors
833965 - Code folding and document symbols
834966 - Semantic tokens for syntax highlighting
967+ - VS Code snippets for common patterns (`@fn`, `@each`, `@grid`, `@form`, etc.)
README.md+8
@@ -167,6 +167,14 @@ Attribute lists can span multiple lines:
167167- `@raw """..."""` for embedding arbitrary HTML/CSS/JS
168168- `@image [inline] logo.svg` inlines SVG content
169169- `@each $name, $url in Home /, About /about` destructuring
170+- `@let large = $base * 2` computed variables
171+- `[grid-template-areas "header main", grid-area header]` named grid areas
172+- `[animate fade-in 0.3s ease]` animation shorthand
173+- `[view-transition-name hero]` View Transitions API
174+- `[has(.active):background blue]` `:has()` pseudo-selector
175+- `@slot header` / `@slot content` named slots in `@fn`
176+- `@theme` design tokens as runtime CSS custom properties
177+- VS Code snippets for common patterns
170178
171179## Documentation
172180
editors/vscode/package.json+6
@@ -33,6 +33,12 @@
3333 "path": "./syntaxes/htmlang.tmLanguage.json"
3434 }
3535 ],
36+ "snippets": [
37+ {
38+ "language": "htmlang",
39+ "path": "./snippets/htmlang.json"
40+ }
41+ ],
3642 "configuration": {
3743 "title": "htmlang",
3844 "properties": {
editors/vscode/snippets/htmlang.jsonnew file+202
@@ -0,0 +1,202 @@
1+{
2+ "Page boilerplate": {
3+ "prefix": "@page",
4+ "body": [
5+ "@page ${1:My Page}",
6+ "@let primary ${2:#3b82f6}",
7+ "",
8+ "@column [max-width ${3:800}, center-x, padding ${4:40}, spacing ${5:20}]",
9+ " $0"
10+ ],
11+ "description": "Page boilerplate with title, variables, and layout"
12+ },
13+ "Function component": {
14+ "prefix": "@fn",
15+ "body": [
16+ "@fn ${1:component} \\$${2:title}",
17+ " @el [${3:padding 20, background white, rounded 8}]",
18+ " @text [bold] \\$$2",
19+ " @children",
20+ "",
21+ "@$1 [$2 ${4:Hello}]",
22+ " $0"
23+ ],
24+ "description": "Reusable function component with @children slot"
25+ },
26+ "Function with named slots": {
27+ "prefix": "@fn-slots",
28+ "body": [
29+ "@fn ${1:layout} \\$${2:title}",
30+ " @column [${3:max-width 800, center-x, padding 40}]",
31+ " @slot ${4:header}",
32+ " @text [bold, size 32] \\$$2",
33+ " @slot ${5:content}",
34+ " @slot ${6:footer}",
35+ "",
36+ "@$1 [$2 ${7:My Page}]",
37+ " @slot $5",
38+ " $0"
39+ ],
40+ "description": "Function with named slots for multi-region layouts"
41+ },
42+ "Card component": {
43+ "prefix": "@card",
44+ "body": [
45+ "@el [padding ${1:20}, background ${2:white}, rounded ${3:8}, border 1 ${4:#e5e7eb}, hover:border 1 ${5:\\$primary}, transition all 0.15s ease]",
46+ " @text [bold, size ${6:18}] ${7:Title}",
47+ " $0"
48+ ],
49+ "description": "Card with border, hover effect, and title"
50+ },
51+ "Responsive row": {
52+ "prefix": "@responsive-row",
53+ "body": [
54+ "@row [wrap, spacing ${1:20}]",
55+ " @el [width fill, min-width ${2:300}]",
56+ " ${3:Column 1}",
57+ " @el [width fill, min-width $2]",
58+ " ${4:Column 2}",
59+ " $0"
60+ ],
61+ "description": "Responsive wrapping row layout"
62+ },
63+ "Navigation bar": {
64+ "prefix": "@navbar",
65+ "body": [
66+ "@nav [padding-x ${1:20}, padding-y ${2:12}, background ${3:#1a1a2e}]",
67+ " @row [spacing ${4:20}, align-items center]",
68+ " @text [bold, color ${5:white}, size ${6:18}] ${7:Logo}",
69+ " @el [width fill]",
70+ " @each \\$name, \\$url in ${8:Home /, About /about, Contact /contact}",
71+ " @link \\$url",
72+ " @text [color ${9:#a8b2d1}, hover:color white] \\$name",
73+ " $0"
74+ ],
75+ "description": "Navigation bar with logo and links"
76+ },
77+ "Each loop": {
78+ "prefix": "@each",
79+ "body": [
80+ "@each \\$${1:item} in ${2:items}",
81+ " $0",
82+ "@else",
83+ " @text [color #888] No items found."
84+ ],
85+ "description": "Each loop with @else fallback"
86+ },
87+ "Conditional": {
88+ "prefix": "@if",
89+ "body": [
90+ "@if ${1:condition}",
91+ " $0",
92+ "@else",
93+ " "
94+ ],
95+ "description": "If/else conditional block"
96+ },
97+ "Grid layout": {
98+ "prefix": "@grid",
99+ "body": [
100+ "@grid [grid-cols ${1:3}, gap ${2:20}]",
101+ " @el [padding ${3:20}]",
102+ " ${4:Item 1}",
103+ " @el [padding $3]",
104+ " ${5:Item 2}",
105+ " @el [padding $3]",
106+ " ${6:Item 3}",
107+ " $0"
108+ ],
109+ "description": "CSS Grid layout with columns"
110+ },
111+ "Grid with named areas": {
112+ "prefix": "@grid-areas",
113+ "body": [
114+ "@el [grid, grid-template-areas \"${1:header header}\" \"${2:sidebar main}\" \"${3:footer footer}\", gap ${4:20}]",
115+ " @el [grid-area ${5:header}]",
116+ " ${6:Header}",
117+ " @el [grid-area ${7:sidebar}]",
118+ " ${8:Sidebar}",
119+ " @el [grid-area ${9:main}]",
120+ " $0"
121+ ],
122+ "description": "CSS Grid with named template areas"
123+ },
124+ "Form with inputs": {
125+ "prefix": "@form",
126+ "body": [
127+ "@form [method ${1:post}, spacing ${2:16}] ${3:/submit}",
128+ " @label [for ${4:email}] ${5:Email}",
129+ " @input [type ${6:email}, name $4, required, padding 8, border 1 #d1d5db, rounded 4]",
130+ " @button [type submit, padding 10, background ${7:\\$primary}, color white, rounded 4, bold]",
131+ " ${8:Submit}",
132+ " $0"
133+ ],
134+ "description": "Form with labeled input and submit button"
135+ },
136+ "Dark mode toggle": {
137+ "prefix": "@darkmode",
138+ "body": [
139+ "@el [background ${1:white}, dark:background ${2:#1a1a2e}, color ${3:#333}, dark:color ${4:#eee}, transition all 0.2s ease]",
140+ " $0"
141+ ],
142+ "description": "Element with light/dark mode styles"
143+ },
144+ "Hero section": {
145+ "prefix": "@hero",
146+ "body": [
147+ "@column [center-x, center-y, min-height ${1:80vh}, padding ${2:40}, spacing ${3:20}]",
148+ " @text [bold, size ${4:48}, text-align center] ${5:Hero Title}",
149+ " @paragraph [max-width ${6:600}, text-align center, color ${7:#666}, line-height 1.6]",
150+ " ${8:Hero description text}",
151+ " @el [padding 12 24, background ${9:\\$primary}, rounded 8] > @link ${10:#}",
152+ " @text [color white, bold] ${11:Get Started}",
153+ " $0"
154+ ],
155+ "description": "Hero section with title, description, and CTA"
156+ },
157+ "Theme tokens": {
158+ "prefix": "@theme",
159+ "body": [
160+ "@theme",
161+ " primary ${1:#3b82f6}",
162+ " secondary ${2:#10b981}",
163+ " bg ${3:#ffffff}",
164+ " text ${4:#1a1a2e}",
165+ " spacing-sm ${5:8}",
166+ " spacing-md ${6:16}",
167+ " spacing-lg ${7:32}",
168+ " font-body ${8:system-ui, sans-serif}",
169+ "$0"
170+ ],
171+ "description": "Design token theme definition"
172+ },
173+ "Keyframes animation": {
174+ "prefix": "@keyframes",
175+ "body": [
176+ "@keyframes ${1:fade-in}",
177+ " from [opacity 0]",
178+ " to [opacity 1]",
179+ "",
180+ "@el [animate $1 ${2:0.3s} ${3:ease}]",
181+ " $0"
182+ ],
183+ "description": "Keyframe animation with element usage"
184+ },
185+ "Include/Import": {
186+ "prefix": "@import",
187+ "body": [
188+ "@import ${1:components.hl}",
189+ "$0"
190+ ],
191+ "description": "Import definitions from another file"
192+ },
193+ "Template inheritance": {
194+ "prefix": "@extends",
195+ "body": [
196+ "@extends ${1:layout.hl}",
197+ "@slot ${2:content}",
198+ " $0"
199+ ],
200+ "description": "Extend a base layout template"
201+ }
202+}
src/bin/htmlang-lsp.rs+65−25
@@ -1107,6 +1107,17 @@ fn attr_completions(range: Range) -> Vec<CompletionItem> {
11071107 ("before:", "Style ::before pseudo-element", false),
11081108 ("after:", "Style ::after pseudo-element", false),
11091109 ("selection:", "Style text selection", false),
1110+ // Grid areas
1111+ ("grid-template-areas", "Define named grid areas (quoted string)", true),
1112+ ("grid-area", "Place element in a named grid area", true),
1113+ // View transitions
1114+ ("view-transition-name", "Assign a view transition name", true),
1115+ // Animate shorthand
1116+ ("animate", "Animation shorthand (name duration [timing])", true),
1117+ // Has pseudo-selector
1118+ ("has(", "Style when element has matching children :has()", false),
1119+ // Critical CSS hint
1120+ ("critical", "Mark as above-fold critical CSS", false),
11101121 ]
11111122 .iter()
11121123 .map(|(name, detail, takes_value)| {
@@ -1812,6 +1823,17 @@ fn hover_builtin(word: &str) -> Option<String> {
18121823 "skeleton" => "**skeleton** \u{2014} Loading skeleton\n\nAdds a shimmer animation for loading placeholders.\n\nUsage: `@el [width fill, height 20, rounded 4, skeleton]`",
18131824 "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",
18141825 "direction" => "**direction** `<value>` \u{2014} Text direction (`ltr`, `rtl`).",
1826+ // Grid areas
1827+ "grid-template-areas" => "**grid-template-areas** `<value>` \u{2014} Define named grid areas.\n\nUsage: `[grid, grid-template-areas \"header header\" \"sidebar main\"]`\n\nChildren use `grid-area` to place themselves.",
1828+ "grid-area" => "**grid-area** `<name>` \u{2014} Place element in a named grid area.\n\nUsage: `@el [grid-area header] ...`\n\nRequires parent with `grid-template-areas`.",
1829+ // View transitions
1830+ "view-transition-name" => "**view-transition-name** `<name>` \u{2014} Assign a View Transition API name.\n\nEnables smooth transitions between pages.\n\nUsage: `@el [view-transition-name hero]`",
1831+ // Animate shorthand
1832+ "animate" => "**animate** `<name> <duration> [timing]` \u{2014} Animation shorthand.\n\nAlias for the CSS `animation` property.\n\nUsage: `@el [animate fade-in 0.3s ease]`\n\nRequires a matching `@keyframes fade-in` definition.",
1833+ // Has pseudo-selector
1834+ "has(" => "**has(selector):** \u{2014} Parent selector pseudo-class.\n\nStyle an element based on its children.\n\nUsage: `@el [has(.active):background blue, has(img):padding 0]`\n\nGenerates CSS `:has()` selector.",
1835+ // Critical
1836+ "critical" => "**critical** \u{2014} Mark element as above-fold.\n\nHint for build tools to prioritize this element's CSS.",
18151837 // Variable filters
18161838 "\\$|uppercase" => "**|uppercase** \u{2014} Convert variable to UPPERCASE.\n\nUsage: `$name|uppercase`",
18171839 "\\$|lowercase" => "**|lowercase** \u{2014} Convert variable to lowercase.\n\nUsage: `$name|lowercase`",
@@ -2540,6 +2562,7 @@ fn code_actions(
25402562 }
25412563
25422564 // Quick-fix: auto-import suggestion for unknown element @name
2565+ // Searches current directory and subdirectories for @fn definitions
25432566 if msg.contains("unknown element @") {
25442567 if let Some(fn_name) = extract_between(msg, "unknown element @", ",")
25452568 .or_else(|| extract_between(msg, "unknown element @", ""))
@@ -2548,35 +2571,52 @@ fn code_actions(
25482571 if !fn_name.is_empty() {
25492572 if let Ok(file_path) = uri.to_file_path() {
25502573 if let Some(dir) = file_path.parent() {
2551− if let Ok(entries) = std::fs::read_dir(dir) {
2552− for entry in entries.flatten() {
2553− let path = entry.path();
2554− if path.extension().and_then(|e| e.to_str()) != Some("hl") {
2555− continue;
2556− }
2557− // Skip the current file
2558− if path == file_path {
2559− continue;
2574+ // Search current dir and subdirs for .hl files defining this function
2575+ let mut search_dirs = vec![dir.to_path_buf()];
2576+ // Also search parent dir's subdirs (for project-wide imports)
2577+ if let Some(parent) = dir.parent() {
2578+ if let Ok(entries) = std::fs::read_dir(parent) {
2579+ for entry in entries.flatten() {
2580+ let p = entry.path();
2581+ if p.is_dir() && p != dir {
2582+ search_dirs.push(p);
2583+ }
25602584 }
2561− if let Ok(content) = std::fs::read_to_string(&path) {
2562− let defines_fn = content.lines().any(|l| {
2563− let t = l.trim();
2564− if let Some(rest) = t.strip_prefix("@fn ") {
2565− rest.split_whitespace().next() == Some(fn_name)
2566− } else {
2567− false
2568− }
2569− });
2570− if defines_fn {
2571− if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
2572− // Check if already imported
2585+ }
2586+ }
2587+ for search_dir in &search_dirs {
2588+ if let Ok(entries) = std::fs::read_dir(search_dir) {
2589+ for entry in entries.flatten() {
2590+ let path = entry.path();
2591+ if path.extension().and_then(|e| e.to_str()) != Some("hl") {
2592+ continue;
2593+ }
2594+ if path == file_path {
2595+ continue;
2596+ }
2597+ if let Ok(content) = std::fs::read_to_string(&path) {
2598+ let defines_fn = content.lines().any(|l| {
2599+ let t = l.trim();
2600+ if let Some(rest) = t.strip_prefix("@fn ") {
2601+ rest.split_whitespace().next() == Some(fn_name)
2602+ } else {
2603+ false
2604+ }
2605+ });
2606+ if defines_fn {
2607+ // Compute relative path from current file's dir
2608+ let rel = path.strip_prefix(dir)
2609+ .map(|p| p.display().to_string())
2610+ .unwrap_or_else(|_| {
2611+ path.file_name().and_then(|n| n.to_str()).unwrap_or("").to_string()
2612+ });
25732613 let already_imported = text.lines().any(|l| {
25742614 let t = l.trim();
2575− t == format!("@import {}", name)
2576− || t == format!("@include {}", name)
2615+ t == format!("@import {}", rel)
2616+ || t == format!("@include {}", rel)
25772617 });
25782618 if !already_imported {
2579− let import_line = format!("@import {}\n", name);
2619+ let import_line = format!("@import {}\n", rel);
25802620 let edit = TextEdit {
25812621 range: Range::new(
25822622 Position::new(0, 0),
@@ -2587,7 +2627,7 @@ fn code_actions(
25872627 let mut changes = HashMap::new();
25882628 changes.insert(uri.clone(), vec![edit]);
25892629 actions.push(CodeActionOrCommand::CodeAction(CodeAction {
2590− title: format!("Add '@import {}' for @{}", name, fn_name),
2630+ title: format!("Add '@import {}' for @{}", rel, fn_name),
25912631 kind: Some(CodeActionKind::QUICKFIX),
25922632 diagnostics: Some(vec![diag.clone()]),
25932633 edit: Some(WorkspaceEdit {
src/codegen.rs+94−6
@@ -112,18 +112,35 @@ impl StyleCollector {
112112 Some(name)
113113 }
114114
115− fn to_css_formatted(&self, dev: bool) -> String {
115+ fn to_css_formatted(&self, dev: bool, use_layer: bool) -> String {
116116 let mut css = String::new();
117117 let (sp, nl) = if dev { (" ", "\n") } else { ("", "") };
118118
119+ // Wrap in @layer for specificity management
120+ if use_layer {
121+ if dev {
122+ css.push_str("@layer htmlang {\n");
123+ } else {
124+ css.push_str("@layer htmlang{");
125+ }
126+ }
127+
119128 // Non-responsive rules
120129 for e in &self.entries {
121130 if !e.base.is_empty() {
122− css.push_str(&format!(".{}{sp}{{{}}}{nl}", e.class_name, e.base));
131+ if use_layer && dev {
132+ css.push_str(&format!(" .{}{sp}{{{}}}{nl}", e.class_name, e.base));
133+ } else {
134+ css.push_str(&format!(".{}{sp}{{{}}}{nl}", e.class_name, e.base));
135+ }
123136 }
124137 for (selector, pseudo_css) in &e.pseudo {
125138 if !pseudo_css.is_empty() {
126− css.push_str(&format!(".{}{}{sp}{{{}}}{nl}", e.class_name, selector, pseudo_css));
139+ if use_layer && dev {
140+ css.push_str(&format!(" .{}{}{sp}{{{}}}{nl}", e.class_name, selector, pseudo_css));
141+ } else {
142+ css.push_str(&format!(".{}{}{sp}{{{}}}{nl}", e.class_name, selector, pseudo_css));
143+ }
127144 }
128145 }
129146 }
@@ -300,6 +317,15 @@ impl StyleCollector {
300317 }
301318 }
302319
320+ // Close @layer
321+ if use_layer {
322+ if dev {
323+ css.push_str("}\n");
324+ } else {
325+ css.push('}');
326+ }
327+ }
328+
303329 css
304330 }
305331}
@@ -346,13 +372,22 @@ fn generate_with_options(doc: &Document, dev: bool) -> String {
346372
347373 let mut element_css = String::new();
348374
349− // CSS custom properties
350− if !doc.css_vars.is_empty() {
375+ // CSS custom properties (includes @theme runtime tokens)
376+ let has_theme_tokens = !doc.theme_tokens.is_empty();
377+ if !doc.css_vars.is_empty() || has_theme_tokens {
351378 if dev {
352379 element_css.push_str(":root {\n");
353380 for (name, value) in &doc.css_vars {
354381 element_css.push_str(&format!(" {}: {};\n", name, value));
355382 }
383+ // @theme tokens as runtime CSS custom properties
384+ for (name, value) in &doc.theme_tokens {
385+ let css_name = format!("--{}", name);
386+ // Only emit if not already in css_vars
387+ if !doc.css_vars.iter().any(|(n, _)| *n == css_name) {
388+ element_css.push_str(&format!(" {}: {};\n", css_name, value));
389+ }
390+ }
356391 element_css.push_str("}\n");
357392 } else {
358393 element_css.push_str(":root{");
@@ -362,11 +397,21 @@ fn generate_with_options(doc: &Document, dev: bool) -> String {
362397 element_css.push_str(value);
363398 element_css.push(';');
364399 }
400+ for (name, value) in &doc.theme_tokens {
401+ let css_name = format!("--{}", name);
402+ if !doc.css_vars.iter().any(|(n, _)| *n == css_name) {
403+ element_css.push_str(&css_name);
404+ element_css.push(':');
405+ element_css.push_str(value);
406+ element_css.push(';');
407+ }
408+ }
365409 element_css.push('}');
366410 }
367411 }
368412
369− element_css.push_str(&styles.to_css_formatted(dev));
413+ let has_custom_css = !doc.custom_css.is_empty();
414+ element_css.push_str(&styles.to_css_formatted(dev, !has_custom_css));
370415
371416 // @keyframes
372417 for (name, kf_body) in &doc.keyframes {
@@ -1130,6 +1175,27 @@ fn compute_class(
11301175 }
11311176 }
11321177
1178+ // Collect has(...): dynamic pseudo selectors
1179+ let mut has_prefixes: Vec<String> = Vec::new();
1180+ for attr in attrs {
1181+ if attr.key.starts_with("has(") {
1182+ if let Some(close) = attr.key.find("):") {
1183+ let prefix = format!("{}:", &attr.key[..close + 1]);
1184+ if !has_prefixes.contains(&prefix) {
1185+ has_prefixes.push(prefix);
1186+ }
1187+ }
1188+ }
1189+ }
1190+ for prefix in &has_prefixes {
1191+ let inner = &prefix[4..prefix.len() - 2]; // extract selector from has(selector):
1192+ let selector = format!(":has({})", inner);
1193+ let css = attrs_to_css(attrs, &prefix, kind, parent_kind);
1194+ if !css.is_empty() {
1195+ pseudo.push((selector, css));
1196+ }
1197+ }
1198+
11331199 // Collect responsive overrides
11341200 let mut responsive = Vec::new();
11351201 for &(bp_name, _) in BREAKPOINTS {
@@ -1215,6 +1281,7 @@ fn is_prefixed_attr(key: &str) -> bool {
12151281 || MEDIA_PREFIXES.iter().any(|p| key.starts_with(p))
12161282 || CONTAINER_QUERY_PREFIXES.iter().any(|p| key.starts_with(p))
12171283 || key.starts_with("nth:")
1284+ || key.starts_with("has(")
12181285}
12191286
12201287fn attrs_to_css(
@@ -2064,6 +2131,27 @@ fn attrs_to_css(
20642131 }
20652132 }
20662133
2134+ // Grid areas
2135+ "grid-template-areas" => {
2136+ if let Some(v) = val { push_css(&mut css, "grid-template-areas", v); }
2137+ }
2138+ "grid-area" => {
2139+ if let Some(v) = val { push_css(&mut css, "grid-area", v); }
2140+ }
2141+
2142+ // View transitions
2143+ "view-transition-name" => {
2144+ if let Some(v) = val { push_css(&mut css, "view-transition-name", v); }
2145+ }
2146+
2147+ // Animate shorthand (alias for animation)
2148+ "animate" => {
2149+ if let Some(v) = val { push_css(&mut css, "animation", v); }
2150+ }
2151+
2152+ // Critical CSS hint — not CSS, handled elsewhere
2153+ "critical" => {}
2154+
20672155 // Identity and HTML passthrough — not CSS
20682156 "id" | "class" => {}
20692157 "type" | "placeholder" | "name" | "value" | "disabled" | "required"
src/main.rs+422
@@ -217,6 +217,10 @@ Commands:
217217 repl Interactive REPL (type .hl, get HTML)
218218 feed <dir> [-b URL] Generate RSS feed from @page metadata
219219 components <dir> List all @fn definitions across a project
220+ deps <dir> Show file dependency graph (@include/@import)
221+ dead-code <dir> Find unused @fn, @define, @let across project
222+ deploy <dir> Build and deploy to GitHub Pages
223+ playground [out.html] Generate a self-contained HTML playground
220224
221225Options:
222226 -w, --watch Watch for changes and recompile
@@ -519,6 +523,63 @@ fn count_elements(
519523 }
520524}
521525
526+/// Extract shared CSS rules across multiple HTML files and write shared.css
527+fn extract_shared_css(html_files: &[PathBuf], out_dir: &Path) {
528+ // Parse <style> blocks from each HTML file and count rule occurrences
529+ let mut rule_counts: HashMap<String, usize> = HashMap::new();
530+ let total = html_files.len();
531+ for file in html_files {
532+ if let Ok(html) = fs::read_to_string(file) {
533+ // Extract CSS between <style> and </style>
534+ if let Some(start) = html.find("<style>") {
535+ if let Some(end) = html[start..].find("</style>") {
536+ let css = &html[start + 7..start + end];
537+ // Extract individual rules (class-based)
538+ let mut seen_in_file = std::collections::HashSet::new();
539+ let mut i = 0;
540+ let bytes = css.as_bytes();
541+ while i < bytes.len() {
542+ if bytes[i] == b'.' || bytes[i] == b'@' {
543+ // Find end of rule block
544+ let start_pos = i;
545+ let mut depth = 0;
546+ let mut found_open = false;
547+ while i < bytes.len() {
548+ if bytes[i] == b'{' { depth += 1; found_open = true; }
549+ else if bytes[i] == b'}' {
550+ depth -= 1;
551+ if found_open && depth == 0 { i += 1; break; }
552+ }
553+ i += 1;
554+ }
555+ let rule = &css[start_pos..i];
556+ if !rule.is_empty() && !seen_in_file.contains(rule) {
557+ seen_in_file.insert(rule.to_string());
558+ *rule_counts.entry(rule.to_string()).or_insert(0) += 1;
559+ }
560+ } else {
561+ i += 1;
562+ }
563+ }
564+ }
565+ }
566+ }
567+ }
568+ // Rules appearing in ALL files are shared
569+ let shared_rules: Vec<&String> = rule_counts.iter()
570+ .filter(|(_, count)| **count == total)
571+ .map(|(rule, _)| rule)
572+ .collect();
573+ if shared_rules.is_empty() {
574+ return;
575+ }
576+ let shared_css_path = out_dir.join("shared.css");
577+ let shared_css: String = shared_rules.iter().map(|r| r.as_str()).collect::<Vec<_>>().join("\n");
578+ if fs::write(&shared_css_path, &shared_css).is_ok() {
579+ eprintln!("extracted {} shared CSS rules to {}", shared_rules.len(), shared_css_path.display());
580+ }
581+}
582+
522583fn open_in_browser(port: u16) {
523584 let url = format!("http://127.0.0.1:{}", port);
524585 let cmd = if cfg!(target_os = "macos") {
@@ -718,6 +779,19 @@ fn main() {
718779 // Copy non-.hl static assets to output directory
719780 if let Some(out) = out_dir {
720781 copy_non_hl_files(dir, Path::new(out));
782+
783+ // Shared CSS extraction: find duplicate CSS rules across pages
784+ let out_path = Path::new(out);
785+ let html_files: Vec<PathBuf> = hl_files.iter()
786+ .map(|f| {
787+ let rel = f.strip_prefix(dir).unwrap_or(f);
788+ out_path.join(rel).with_extension("html")
789+ })
790+ .filter(|p| p.exists())
791+ .collect();
792+ if html_files.len() > 1 {
793+ extract_shared_css(&html_files, out_path);
794+ }
721795 }
722796 return;
723797 }
@@ -1290,6 +1364,354 @@ fn main() {
12901364 return;
12911365 }
12921366
1367+ // Handle "deps" subcommand — show dependency graph
1368+ if args.len() >= 2 && args[1] == "deps" {
1369+ let target = if args.len() >= 3 { &args[2] } else { "." };
1370+ let path = Path::new(target);
1371+ let hl_files = if path.is_dir() {
1372+ collect_hl_files_recursive(path)
1373+ } else {
1374+ vec![PathBuf::from(target)]
1375+ };
1376+ if hl_files.is_empty() {
1377+ eprintln!("no .hl files found in {}", target);
1378+ process::exit(1);
1379+ }
1380+ let mut graph: Vec<(String, Vec<String>)> = Vec::new();
1381+ for file in &hl_files {
1382+ let input = match fs::read_to_string(file) {
1383+ Ok(s) => s,
1384+ Err(_) => continue,
1385+ };
1386+ let rel = file.strip_prefix(path).unwrap_or(file);
1387+ let rel_str = rel.display().to_string();
1388+ let mut deps = Vec::new();
1389+ for line in input.lines() {
1390+ let trimmed = line.trim();
1391+ for directive in &["@include ", "@import ", "@extends ", "@use "] {
1392+ if let Some(rest) = trimmed.strip_prefix(directive) {
1393+ let dep = rest.split_whitespace().next().unwrap_or("").to_string();
1394+ if !dep.is_empty() {
1395+ deps.push(dep);
1396+ }
1397+ }
1398+ }
1399+ }
1400+ graph.push((rel_str, deps));
1401+ }
1402+ for (file, deps) in &graph {
1403+ if deps.is_empty() {
1404+ println!("{} (no dependencies)", file);
1405+ } else {
1406+ println!("{}", file);
1407+ for (i, dep) in deps.iter().enumerate() {
1408+ let prefix = if i == deps.len() - 1 { "└── " } else { "├── " };
1409+ println!(" {}{}", prefix, dep);
1410+ }
1411+ }
1412+ }
1413+ return;
1414+ }
1415+
1416+ // Handle "dead-code" subcommand — project-wide unused definitions
1417+ if args.len() >= 2 && args[1] == "dead-code" {
1418+ let target = if args.len() >= 3 { &args[2] } else { "." };
1419+ let path = Path::new(target);
1420+ let hl_files = if path.is_dir() {
1421+ collect_hl_files_recursive(path)
1422+ } else {
1423+ vec![PathBuf::from(target)]
1424+ };
1425+ if hl_files.is_empty() {
1426+ eprintln!("no .hl files found in {}", target);
1427+ process::exit(1);
1428+ }
1429+ // Pass 1: collect all definitions and usages across all files
1430+ let mut all_fn_defs: Vec<(String, String, usize)> = Vec::new(); // (name, file, line)
1431+ let mut all_def_defs: Vec<(String, String, usize)> = Vec::new();
1432+ let mut all_let_defs: Vec<(String, String, usize)> = Vec::new();
1433+ let mut all_refs: std::collections::HashSet<String> = std::collections::HashSet::new();
1434+ for file in &hl_files {
1435+ let input = match fs::read_to_string(file) {
1436+ Ok(s) => s,
1437+ Err(_) => continue,
1438+ };
1439+ let rel = file.strip_prefix(path).unwrap_or(file).display().to_string();
1440+ for (line_num, line) in input.lines().enumerate() {
1441+ let trimmed = line.trim();
1442+ if let Some(rest) = trimmed.strip_prefix("@fn ") {
1443+ if let Some(name) = rest.split_whitespace().next() {
1444+ all_fn_defs.push((name.to_string(), rel.clone(), line_num + 1));
1445+ }
1446+ } else if let Some(rest) = trimmed.strip_prefix("@define ") {
1447+ if let Some(name) = rest.split_whitespace().next() {
1448+ let name = name.trim_end_matches('[');
1449+ all_def_defs.push((name.to_string(), rel.clone(), line_num + 1));
1450+ }
1451+ } else if let Some(rest) = trimmed.strip_prefix("@let ") {
1452+ if let Some(name) = rest.split_whitespace().next() {
1453+ all_let_defs.push((name.to_string(), rel.clone(), line_num + 1));
1454+ }
1455+ }
1456+ // Collect references: @name calls and $name usages
1457+ if trimmed.starts_with('@') && !trimmed.starts_with("@fn ")
1458+ && !trimmed.starts_with("@let ") && !trimmed.starts_with("@define ")
1459+ && !trimmed.starts_with("@include ") && !trimmed.starts_with("@import ")
1460+ && !trimmed.starts_with("@extends ") && !trimmed.starts_with("@page")
1461+ && !trimmed.starts_with("@meta ") && !trimmed.starts_with("@og ")
1462+ && !trimmed.starts_with("@head") && !trimmed.starts_with("@style")
1463+ && !trimmed.starts_with("@keyframes ") && !trimmed.starts_with("@theme")
1464+ && !trimmed.starts_with("@deprecated ") && !trimmed.starts_with("@breakpoint ")
1465+ && !trimmed.starts_with("@use ") && !trimmed.starts_with("@slot ")
1466+ && !trimmed.starts_with("@lang ") && !trimmed.starts_with("@favicon ")
1467+ && !trimmed.starts_with("--")
1468+ {
1469+ if let Some(name) = trimmed[1..].split(|c: char| c == ' ' || c == '[').next() {
1470+ all_refs.insert(name.to_string());
1471+ }
1472+ }
1473+ // Collect $var references
1474+ let mut rest = trimmed;
1475+ while let Some(pos) = rest.find('$') {
1476+ let after = &rest[pos + 1..];
1477+ let end = after.find(|c: char| !c.is_alphanumeric() && c != '-' && c != '_').unwrap_or(after.len());
1478+ if end > 0 {
1479+ all_refs.insert(after[..end].to_string());
1480+ }
1481+ rest = if end < after.len() { &after[end..] } else { "" };
1482+ }
1483+ }
1484+ }
1485+ // Pass 2: report unused
1486+ let mut count = 0;
1487+ for (name, file, line) in &all_fn_defs {
1488+ if !all_refs.contains(name) {
1489+ println!("unused @fn '{}' — {}:{}", name, file, line);
1490+ count += 1;
1491+ }
1492+ }
1493+ for (name, file, line) in &all_def_defs {
1494+ if !all_refs.contains(name) {
1495+ println!("unused @define '{}' — {}:{}", name, file, line);
1496+ count += 1;
1497+ }
1498+ }
1499+ for (name, file, line) in &all_let_defs {
1500+ if !all_refs.contains(name) && !name.starts_with("--") {
1501+ println!("unused @let '{}' — {}:{}", name, file, line);
1502+ count += 1;
1503+ }
1504+ }
1505+ if count == 0 {
1506+ eprintln!("no unused definitions found");
1507+ } else {
1508+ eprintln!("\n{} unused definition(s) found", count);
1509+ }
1510+ return;
1511+ }
1512+
1513+ // Handle "deploy" subcommand — deploy to GitHub Pages
1514+ if args.len() >= 2 && args[1] == "deploy" {
1515+ let target = if args.len() >= 3 { &args[2] } else { "." };
1516+ let path = Path::new(target);
1517+ if !path.is_dir() {
1518+ eprintln!("error: '{}' is not a directory", target);
1519+ process::exit(1);
1520+ }
1521+ // Build first
1522+ let hl_files = collect_hl_files_recursive(path);
1523+ if hl_files.is_empty() {
1524+ eprintln!("no .hl files found in {}", target);
1525+ process::exit(1);
1526+ }
1527+ let deploy_dir = path.join("_deploy");
1528+ let _ = fs::create_dir_all(&deploy_dir);
1529+ let any_errors = std::sync::atomic::AtomicBool::new(false);
1530+ std::thread::scope(|s| {
1531+ for file in &hl_files {
1532+ let any_errors = &any_errors;
1533+ let deploy_dir = &deploy_dir;
1534+ s.spawn(move || {
1535+ let rel = file.strip_prefix(path).unwrap_or(file);
1536+ let out_path = deploy_dir.join(rel).with_extension("html");
1537+ if let Some(parent) = out_path.parent() {
1538+ let _ = fs::create_dir_all(parent);
1539+ }
1540+ let out_str = out_path.to_string_lossy().to_string();
1541+ let path_str = file.to_string_lossy().to_string();
1542+ let (has_errors, _) = compile(&path_str, false, false, false, Some(&out_str), false, None);
1543+ if has_errors {
1544+ any_errors.store(true, std::sync::atomic::Ordering::Relaxed);
1545+ }
1546+ });
1547+ }
1548+ });
1549+ if any_errors.load(std::sync::atomic::Ordering::Relaxed) {
1550+ eprintln!("error: build failed, not deploying");
1551+ process::exit(1);
1552+ }
1553+ copy_non_hl_files(path, &deploy_dir);
1554+ // Deploy via gh-pages push
1555+ eprintln!("built {} files to {}", hl_files.len(), deploy_dir.display());
1556+ let status = process::Command::new("git")
1557+ .args(&["init"])
1558+ .current_dir(&deploy_dir)
1559+ .status();
1560+ if status.is_err() || !status.unwrap().success() {
1561+ eprintln!("error: git init failed in deploy directory");
1562+ process::exit(1);
1563+ }
1564+ let _ = process::Command::new("git")
1565+ .args(&["checkout", "-b", "gh-pages"])
1566+ .current_dir(&deploy_dir)
1567+ .status();
1568+ let _ = process::Command::new("git")
1569+ .args(&["add", "."])
1570+ .current_dir(&deploy_dir)
1571+ .status();
1572+ let status = process::Command::new("git")
1573+ .args(&["commit", "-m", "deploy"])
1574+ .current_dir(&deploy_dir)
1575+ .status();
1576+ if status.is_err() || !status.unwrap().success() {
1577+ eprintln!("error: git commit failed");
1578+ process::exit(1);
1579+ }
1580+ // Check if remote origin exists in parent
1581+ let remote_output = process::Command::new("git")
1582+ .args(&["remote", "get-url", "origin"])
1583+ .output();
1584+ if let Ok(output) = remote_output {
1585+ if output.status.success() {
1586+ let remote_url = String::from_utf8_lossy(&output.stdout).trim().to_string();
1587+ let _ = process::Command::new("git")
1588+ .args(&["remote", "add", "origin", &remote_url])
1589+ .current_dir(&deploy_dir)
1590+ .status();
1591+ eprintln!("pushing to gh-pages branch at {}...", remote_url);
1592+ let push_status = process::Command::new("git")
1593+ .args(&["push", "-f", "origin", "gh-pages"])
1594+ .current_dir(&deploy_dir)
1595+ .status();
1596+ if push_status.is_err() || !push_status.unwrap().success() {
1597+ eprintln!("error: push failed — run 'git push -f origin gh-pages' manually from {}", deploy_dir.display());
1598+ process::exit(1);
1599+ }
1600+ eprintln!("deployed to GitHub Pages!");
1601+ } else {
1602+ eprintln!("no git remote found — deploy directory ready at {}", deploy_dir.display());
1603+ eprintln!("push manually: cd {} && git remote add origin <url> && git push -f origin gh-pages", deploy_dir.display());
1604+ }
1605+ }
1606+ return;
1607+ }
1608+
1609+ // Handle "playground" subcommand — generate self-contained HTML playground
1610+ if args.len() >= 2 && args[1] == "playground" {
1611+ let out = if args.len() >= 3 { &args[2] } else { "playground.html" };
1612+ let playground_html = r##"<!DOCTYPE html>
1613+<html lang="en">
1614+<head>
1615+<meta charset="utf-8">
1616+<meta name="viewport" content="width=device-width, initial-scale=1">
1617+<title>htmlang Playground</title>
1618+<style>
1619+*{box-sizing:border-box;margin:0}
1620+body{font-family:system-ui,-apple-system,sans-serif;display:flex;height:100vh;background:#1a1a2e;color:#eee}
1621+.panel{flex:1;display:flex;flex-direction:column;min-width:0}
1622+.header{padding:12px 16px;background:#16213e;border-bottom:1px solid #0f3460;display:flex;align-items:center;gap:12px}
1623+.header h1{font-size:14px;font-weight:600;color:#e94560}
1624+.header .tag{font-size:11px;padding:2px 8px;background:#0f3460;border-radius:4px;color:#a8b2d1}
1625+textarea{flex:1;resize:none;border:none;outline:none;padding:16px;font-family:ui-monospace,monospace;font-size:14px;line-height:1.6;background:#1a1a2e;color:#e6e6e6;tab-size:2}
1626+iframe{flex:1;border:none;background:white}
1627+.divider{width:1px;background:#0f3460}
1628+.toolbar{padding:8px 16px;background:#16213e;border-top:1px solid #0f3460;display:flex;gap:8px;align-items:center}
1629+button{padding:6px 16px;border:none;border-radius:4px;cursor:pointer;font-size:13px;font-weight:500}
1630+.btn-run{background:#e94560;color:white}
1631+.btn-run:hover{background:#c81e45}
1632+.btn-copy{background:#0f3460;color:#a8b2d1}
1633+.status{margin-left:auto;font-size:12px;color:#666}
1634+</style>
1635+</head>
1636+<body>
1637+<div class="panel">
1638+<div class="header"><h1>htmlang</h1><span class="tag">playground</span></div>
1639+<textarea id="editor" spellcheck="false">@page Hello World
1640+@let primary #3b82f6
1641+
1642+@fn card $title
1643+ @el [padding 20, background white, rounded 8, border 1 #e5e7eb, hover:border 1 $primary, transition all 0.15s ease]
1644+ @text [bold, size 18] $title
1645+ @children
1646+
1647+@column [max-width 800, center-x, padding 40, spacing 20]
1648+ @text [bold, size 32, color $primary] Hello from htmlang!
1649+
1650+ @paragraph [line-height 1.6]
1651+ Edit this code and click Run to see the output.
1652+
1653+ @row [wrap, spacing 10]
1654+ @card [title Simple]
1655+ Write layouts without CSS
1656+ @card [title Fast]
1657+ Compiles to a single HTML file
1658+ @card [title Zero-dep]
1659+ No JavaScript, no externals</textarea>
1660+<div class="toolbar">
1661+<button class="btn-run" onclick="compile()">▶ Run</button>
1662+<button class="btn-copy" onclick="copyOutput()">Copy HTML</button>
1663+<span class="status" id="status">Ready</span>
1664+</div>
1665+</div>
1666+<div class="divider"></div>
1667+<div class="panel">
1668+<div class="header"><span class="tag">preview</span></div>
1669+<iframe id="preview"></iframe>
1670+</div>
1671+<script>
1672+let lastOutput='';
1673+function compile(){
1674+ const src=document.getElementById('editor').value;
1675+ const status=document.getElementById('status');
1676+ status.textContent='Note: client-side preview is approximate. Use htmlang CLI for exact output.';
1677+ // Simple client-side approximation
1678+ let html=approxCompile(src);
1679+ lastOutput=html;
1680+ const iframe=document.getElementById('preview');
1681+ iframe.srcdoc=html;
1682+}
1683+function copyOutput(){
1684+ if(lastOutput)navigator.clipboard.writeText(lastOutput);
1685+}
1686+function approxCompile(src){
1687+ const lines=src.split('\n');
1688+ let title='',body='',css='';
1689+ let vars={},cls=0;
1690+ for(const line of lines){
1691+ const t=line.trim();
1692+ if(t.startsWith('@page '))title=t.slice(6);
1693+ else if(t.startsWith('@let ')){const p=t.slice(5).split(' ');if(p.length>=2)vars[p[0]]=p.slice(1).join(' ');}
1694+ else if(t.startsWith('--'))continue;
1695+ else if(t.startsWith('@'))body+='<div>'+subVars(t,vars)+'</div>';
1696+ else if(t)body+='<span>'+subVars(t,vars)+'</span>';
1697+ }
1698+ function subVars(s,v){for(const[k,val]of Object.entries(v))s=s.replaceAll('$'+k,val);return s;}
1699+ return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>'+title+'</title><style>*{box-sizing:border-box}body{margin:0;font-family:system-ui,sans-serif}</style></head><body>'+body+'</body></html>';
1700+}
1701+compile();
1702+document.getElementById('editor').addEventListener('keydown',e=>{if(e.key==='Enter'&&(e.ctrlKey||e.metaKey)){e.preventDefault();compile();}if(e.key==='Tab'){e.preventDefault();const s=e.target;const start=s.selectionStart;s.value=s.value.substring(0,start)+' '+s.value.substring(s.selectionEnd);s.selectionStart=s.selectionEnd=start+2;}});
1703+</script>
1704+</body>
1705+</html>"##;
1706+ if let Err(e) = fs::write(out, playground_html) {
1707+ eprintln!("error writing {}: {}", out, e);
1708+ process::exit(1);
1709+ }
1710+ eprintln!("playground written to {}", out);
1711+ eprintln!("open in browser: open {}", out);
1712+ return;
1713+ }
1714+
12931715 // Handle "convert" subcommand
12941716 if args.len() >= 2 && args[1] == "convert" {
12951717 if args.len() < 3 {
src/parser.rs+28
@@ -345,6 +345,14 @@ impl Parser {
345345 let rest = rest.trim();
346346 if let Some((name, value)) = rest.split_once(' ') {
347347 let value = value.trim();
348+ // Support @let name = expr syntax (strip leading =)
349+ let value = if value.starts_with("= ") {
350+ value[2..].trim()
351+ } else if value == "=" {
352+ ""
353+ } else {
354+ value
355+ };
348356 // Support quoted string interpolation: @let greeting "Hello $name"
349357 let value = if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
350358 &value[1..value.len()-1]
@@ -1771,6 +1779,14 @@ const KNOWN_ATTRS: &[&str] = &[
17711779 "content",
17721780 // CSS shorthands
17731781 "truncate", "line-clamp", "blur", "backdrop-blur", "no-scrollbar", "skeleton", "gradient",
1782+ // Grid areas
1783+ "grid-template-areas", "grid-area",
1784+ // View transitions
1785+ "view-transition-name",
1786+ // Animate shorthand
1787+ "animate",
1788+ // Critical CSS hint
1789+ "critical",
17741790];
17751791
17761792/// Attributes that expect purely numeric values (px-based) or values with CSS units.
@@ -2272,6 +2288,18 @@ fn strip_all_prefixes(key: &str) -> &str {
22722288 None
22732289 }
22742290 })
2291+ .or_else(|| {
2292+ // Handle has(...): prefix (e.g., has(.active):background -> background)
2293+ if key.starts_with("has(") {
2294+ if let Some(close) = key.find("):") {
2295+ Some(&key[close + 2..])
2296+ } else {
2297+ None
2298+ }
2299+ } else {
2300+ None
2301+ }
2302+ })
22752303 .unwrap_or(key);
22762304 // Strip responsive prefixes
22772305 let key = key.strip_prefix("sm:")
tests/snapshots.rs+126
@@ -2589,3 +2589,129 @@ fn test_repl_components_feed_subcommands_recognized() {
25892589 let result = htmlang::parser::parse("@page Test Site\n@meta description A test\n@fn card $title\n @text $title");
25902590 assert!(!result.diagnostics.iter().any(|d| d.severity == htmlang::parser::Severity::Error));
25912591}
2592+
2593+// =========================================================================
2594+// Batch 6: Grid areas, view transitions, animate, :has(), computed @let,
2595+// @layer wrapping, named slots in @fn
2596+// =========================================================================
2597+
2598+#[test]
2599+fn snapshot_grid_areas() {
2600+ snapshot_test("grid_areas");
2601+}
2602+
2603+#[test]
2604+fn snapshot_view_transitions() {
2605+ snapshot_test("view_transitions");
2606+}
2607+
2608+#[test]
2609+fn snapshot_animate_shorthand() {
2610+ snapshot_test("animate_shorthand");
2611+}
2612+
2613+#[test]
2614+fn snapshot_has_pseudo() {
2615+ snapshot_test("has_pseudo");
2616+}
2617+
2618+#[test]
2619+fn snapshot_computed_let() {
2620+ snapshot_test("computed_let");
2621+}
2622+
2623+#[test]
2624+fn snapshot_layer_wrapping() {
2625+ snapshot_test("layer_wrapping");
2626+}
2627+
2628+// --- Grid area assertions ---
2629+
2630+#[test]
2631+fn grid_template_areas_passthrough() {
2632+ let output = compile("@page T\n@el [grid, grid-template-areas \"a b\"]\n @el [grid-area a]\n A");
2633+ assert!(output.contains("grid-template-areas:\"a b\""), "grid-template-areas should pass through: {}", output);
2634+ assert!(output.contains("grid-area:a"), "grid-area should pass through: {}", output);
2635+}
2636+
2637+// --- View transition assertions ---
2638+
2639+#[test]
2640+fn view_transition_name_passthrough() {
2641+ let output = compile("@page T\n@el [view-transition-name hero]\n Content");
2642+ assert!(output.contains("view-transition-name:hero"), "view-transition-name should pass through: {}", output);
2643+}
2644+
2645+// --- Animate shorthand assertions ---
2646+
2647+#[test]
2648+fn animate_generates_animation_css() {
2649+ let output = compile("@page T\n@keyframes fade\n from [opacity 0]\n to [opacity 1]\n@el [animate fade 0.3s ease]\n Content");
2650+ assert!(output.contains("animation:fade 0.3s ease"), "animate should generate animation CSS: {}", output);
2651+}
2652+
2653+// --- :has() pseudo assertions ---
2654+
2655+#[test]
2656+fn has_pseudo_generates_css() {
2657+ let output = compile("@page T\n@el [has(.active):background blue]\n Content");
2658+ assert!(output.contains(":has(.active)"), "should generate :has() selector: {}", output);
2659+ assert!(output.contains("background:blue"), "should have background:blue in :has() rule: {}", output);
2660+}
2661+
2662+#[test]
2663+fn has_pseudo_no_warning() {
2664+ let diags = parse_diagnostics("@el [has(.child):background red]");
2665+ assert!(
2666+ !diags.iter().any(|d| d.message.contains("unknown attribute")),
2667+ "has() prefix should not produce unknown attribute warning: {:?}",
2668+ diags
2669+ );
2670+}
2671+
2672+// --- Computed @let assertions ---
2673+
2674+#[test]
2675+fn computed_let_equals_syntax() {
2676+ let output = compile("@page T\n@let base 10\n@let doubled = $base * 2\n@el [width $doubled]\n test");
2677+ assert!(output.contains("width:20px"), "computed @let with = should work: {}", output);
2678+}
2679+
2680+// --- @layer wrapping assertions ---
2681+
2682+#[test]
2683+fn output_contains_layer_wrapping() {
2684+ let output = compile("@page T\n@el [padding 10]\n test");
2685+ assert!(output.contains("@layer htmlang{"), "output should contain @layer htmlang wrapper: {}", output);
2686+}
2687+
2688+// --- Named slots in @fn assertions ---
2689+
2690+#[test]
2691+fn fn_named_slots() {
2692+ let output = compile("@fn layout\n @column\n @slot header\n Default Header\n @slot content\n@layout\n @slot header\n Custom Header\n @slot content\n Page body");
2693+ assert!(output.contains("Custom Header"), "named slot should be filled: {}", output);
2694+ assert!(output.contains("Page body"), "content slot should be filled: {}", output);
2695+ assert!(!output.contains("Default Header"), "default should be overridden: {}", output);
2696+}
2697+
2698+#[test]
2699+fn fn_named_slot_default() {
2700+ let output = compile("@fn layout\n @column\n @slot header\n Default Header\n @slot content\n@layout\n @slot content\n Only content");
2701+ assert!(output.contains("Default Header"), "unfilled slot should use default: {}", output);
2702+ assert!(output.contains("Only content"), "filled slot should render: {}", output);
2703+}
2704+
2705+// --- No warnings for new attributes ---
2706+
2707+#[test]
2708+fn no_warning_new_attrs_batch6() {
2709+ let diags = parse_diagnostics(
2710+ "@el [grid-template-areas \"a b\", grid-area a, view-transition-name hero, animate fade 1s, critical]"
2711+ );
2712+ assert!(
2713+ !diags.iter().any(|d| d.message.contains("unknown attribute")),
2714+ "new attributes should be recognized: {:?}",
2715+ diags
2716+ );
2717+}
tests/snapshots/animate_shorthand.hlnew file+12
@@ -0,0 +1,12 @@
1+@page Animate
2+@keyframes fade-in
3+ from [opacity 0]
4+ to [opacity 1]
5+@keyframes slide-up
6+ from [transform translateY(20px), opacity 0]
7+ to [transform translateY(0), opacity 1]
8+@column [spacing 20, padding 20]
9+ @el [animate fade-in 0.3s ease, padding 20]
10+ Faded in
11+ @el [animate slide-up 0.5s ease-out, padding 20, background #e5e7eb]
12+ Slid up
tests/snapshots/computed_let.hlnew file+7
@@ -0,0 +1,7 @@
1+@page Computed Let
2+@let base 16
3+@let large = $base * 2
4+@let gap = $base + 4
5+@column [spacing $gap, padding 20]
6+ @text [size $base] Base size
7+ @text [size $large] Large size
tests/snapshots/grid_areas.hlnew file+10
@@ -0,0 +1,10 @@
1+@page Grid Areas
2+@el [grid, grid-template-areas "header header" "sidebar main" "footer footer", gap 20]
3+ @el [grid-area header, padding 10, background #3b82f6, color white]
4+ Header
5+ @el [grid-area sidebar, padding 10, background #e5e7eb]
6+ Sidebar
7+ @el [grid-area main, padding 10]
8+ Main content
9+ @el [grid-area footer, padding 10, background #1a1a2e, color white]
10+ Footer
tests/snapshots/has_pseudo.hlnew file+6
@@ -0,0 +1,6 @@
1+@page Has Pseudo
2+@column [spacing 20, padding 20]
3+ @el [padding 20, background white, has(.active):background #dbeafe, has(img):padding 0]
4+ Content
5+ @el [border 1 #e5e7eb, has(.selected):border 2 #3b82f6, rounded 8]
6+ Container
tests/snapshots/layer_wrapping.hlnew file+5
@@ -0,0 +1,5 @@
1+@page Layer Test
2+@el [padding 20, background white]
3+ Hello
4+@el [hover:background #f0f0f0, padding 10]
5+ Hoverable
tests/snapshots/view_transitions.hlnew file+6
@@ -0,0 +1,6 @@
1+@page View Transitions
2+@column [spacing 20, padding 20]
3+ @el [view-transition-name hero, padding 20, background #3b82f6, color white, rounded 8]
4+ Hero element
5+ @el [view-transition-name sidebar, padding 10]
6+ Sidebar element