on master help sign in
Add 10 major improvements: CSS units, positional attrs, @else if, error overlay, @meta/@head, grid/transform/display attrs, fn defaults, @each index, multi-page dirs, @import
commit4e4a55999f6f9425a39a473da79320be7bc8460f authoriko <mail@iko.soy> dateWed 15 Apr 2026, 11:12 (6 months ago) parent65d0653
14 files changed+889−138
DESIGN.md+72−4 src/ast.rs+2 src/bin/htmlang-lsp.rs+90−33 src/codegen.rs+203−45 src/main.rs+185−27 src/parser.rs+199−29 tests/snapshots.rs+83 tests/snapshots/css_units.hl+8 tests/snapshots/each_index.hl+2 tests/snapshots/else_if.hl+8 tests/snapshots/extra_css.hl+16 tests/snapshots/fn_defaults.hl+6 tests/snapshots/meta_head.hl+9 tests/snapshots/positional.hl+6
DESIGN.md+72−4
@@ -129,6 +129,34 @@ Variables can be used in the filename:
129129
130130Circular includes are detected and reported as errors. Nested includes are supported.
131131
132+### `@import`
133+
134+Like `@include`, but only imports definitions (`@let`, `@define`, `@fn`) without emitting DOM nodes. Use this for shared theme/component libraries.
135+
136+```
137+@import theme.hl -- imports variables, defines, functions only
138+@include header.hl -- inlines everything including DOM nodes
139+```
140+
141+### `@meta`
142+
143+Adds a `<meta>` tag to the document `<head>`. Requires `@page`.
144+
145+```
146+@meta description A portfolio site
147+@meta og:image https://example.com/preview.png
148+```
149+
150+### `@head`
151+
152+Adds raw content to the document `<head>`. Use for external fonts, favicons, or custom CSS/JS.
153+
154+```
155+@head
156+ <link rel="icon" href="favicon.ico">
157+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter">
158+```
159+
132160### `@define`
133161
134162Creates a named attribute bundle. Referenced with `$name` inside attribute lists.
@@ -146,10 +174,10 @@ Attributes listed after `$name` override those in the definition.
146174
147175### `@fn`
148176
149−Defines a pure function (reusable component). Parameters are prefixed with `$`.
177+Defines a pure function (reusable component). Parameters are prefixed with `$`. Default values can be specified with `=`.
150178
151179```
152−@fn card $title
180+@fn card $title $variant=primary
153181 @el [padding 20, background white, rounded 8, border 1 #e5e7eb]
154182 @text [bold, size 18] $title
155183 @children
@@ -161,8 +189,13 @@ Call it like any element, passing parameters in `[...]`:
161189@card [title Hello World]
162190 This is the card body.
163191 @text [italic] With styled text.
192+
193+@card [title Warning, variant danger]
194+ Overridden variant.
164195```
165196
197+Parameters with defaults can be omitted in calls — the default value is used.
198+
166199### `@children`
167200
168201A slot inside a function body that expands to the caller's indented children.
@@ -261,6 +294,18 @@ Triple-quoted block pasted verbatim into output. Use for arbitrary HTML, CSS, or
261294"""
262295```
263296
297+## Units
298+
299+All numeric values default to pixels. You can use CSS units explicitly:
300+
301+```
302+@el [width 50%, height 100vh, padding 2rem, max-width 80ch, size 1.2em]
303+```
304+
305+Supported units: `%`, `rem`, `em`, `vh`, `vw`, `vmin`, `vmax`, `dvh`, `svh`, `ch`, `ex`, `cm`, `mm`, `in`, `pt`, `pc`, `fr`.
306+
307+`var()` and `calc()` expressions are also passed through as-is.
308+
264309## Attributes reference
265310
266311### Layout (set on parent)
@@ -268,6 +313,7 @@ Triple-quoted block pasted verbatim into output. Use for arbitrary HTML, CSS, or
268313| Attribute | Effect |
269314|------------------------|----------------------------------|
270315| `spacing N` | Gap between children |
316+| `gap N` | Alias for `spacing` |
271317| `gap-x N` | Horizontal gap (column-gap) |
272318| `gap-y N` | Vertical gap (row-gap) |
273319| `padding N` | Uniform padding |
@@ -310,6 +356,10 @@ Triple-quoted block pasted verbatim into output. Use for arbitrary HTML, CSS, or
310356| `background COLOR` | Background color |
311357| `color COLOR` | Text color |
312358| `border N COLOR` | Border width and color |
359+| `border-top N COLOR` | Top border |
360+| `border-bottom N COLOR`| Bottom border |
361+| `border-left N COLOR` | Left border |
362+| `border-right N COLOR` | Right border |
313363| `rounded N` | Border radius |
314364| `shadow VALUE` | Box shadow (CSS value) |
315365| `bold` | Bold text |
@@ -319,12 +369,23 @@ Triple-quoted block pasted verbatim into output. Use for arbitrary HTML, CSS, or
319369| `font NAME` | Font family |
320370| `text-align VALUE` | Text alignment (left/center/right/justify) |
321371| `line-height VALUE` | Line height (unitless or px) |
372+| `letter-spacing N` | Letter spacing |
373+| `text-transform VALUE` | Transform (uppercase/lowercase/capitalize) |
374+| `white-space VALUE` | White-space (nowrap/pre/normal) |
322375| `transition VALUE` | CSS transition |
323376| `cursor VALUE` | Cursor style |
324377| `opacity VALUE` | Opacity (0–1) |
325378| `overflow VALUE` | Overflow (hidden/scroll/auto/visible) |
326379| `position VALUE` | Position (relative/absolute/fixed/sticky) |
380+| `top N` | Top offset (positioned elements) |
381+| `right N` | Right offset |
382+| `bottom N` | Bottom offset |
383+| `left N` | Left offset |
327384| `z-index N` | Stack order |
385+| `display VALUE` | Display mode (none/block/flex/grid) |
386+| `visibility VALUE` | Visibility (visible/hidden) |
387+| `transform VALUE` | CSS transform (e.g., rotate(45deg)) |
388+| `backdrop-filter VALUE`| Backdrop filter (e.g., blur(10px)) |
328389
329390### Pseudo-states
330391
@@ -337,11 +398,16 @@ Prefix any style attribute with `hover:`, `active:`, or `focus:` to apply it on
337398
338399All style attributes support state prefixes: `hover:color`, `active:rounded`, `focus:border`, etc.
339400
340−### Flow
401+### Flow & Grid
341402
342403| Attribute | Effect |
343404|------------------------|----------------------------------|
344405| `wrap` | Enable flex-wrap (on `@row`) |
406+| `grid` | Enable CSS grid layout |
407+| `grid-cols N` | Grid template columns (N equal) |
408+| `grid-rows N` | Grid template rows (N equal) |
409+| `col-span N` | Span N columns in grid |
410+| `row-span N` | Span N rows in grid |
345411
346412### Identity
347413
@@ -354,11 +420,13 @@ All style attributes support state prefixes: `hover:color`, `active:rounded`, `f
354420
355421```
356422htmlang page.hl # compile page.hl → page.html
423+htmlang site/ # compile all .hl files in directory
357424htmlang --watch page.hl # compile and watch for changes
358425htmlang -w page.hl # short form
426+htmlang --serve site/ # serve a multi-page site with hot reload
359427```
360428
361−Watch mode recompiles automatically when the source file or any `@include`d files change.
429+Watch mode recompiles automatically when the source file or any `@include`d/`@import`ed files change.
362430
363431## Compilation target
364432
src/ast.rs+2
@@ -4,6 +4,8 @@ use std::collections::HashMap;
44#[allow(dead_code)]
55pub struct Document {
66 pub page_title: Option<String>,
7+ pub meta_tags: Vec<(String, String)>,
8+ pub head_blocks: Vec<String>,
79 pub variables: HashMap<String, String>,
810 pub defines: HashMap<String, Vec<Attribute>>,
911 pub keyframes: Vec<(String, String)>,
src/bin/htmlang-lsp.rs+90−33
@@ -322,11 +322,16 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
322322 ("@page", "Set HTML page title", "@page "),
323323 ("@let", "Define a variable", "@let "),
324324 ("@define", "Define an attribute bundle", "@define "),
325− ("@fn", "Define a reusable function", "@fn "),
325+ ("@fn", "Define a reusable function (supports $param=default)", "@fn "),
326326 ("@keyframes", "Define a CSS animation", "@keyframes "),
327327 ("@if", "Conditional rendering", "@if "),
328− ("@each", "Loop over comma-separated values", "@each "),
329− ("@include", "Include another .hl file", "@include "),
328+ ("@else if", "Else-if branch", "@else if "),
329+ ("@else", "Else branch", "@else"),
330+ ("@each", "Loop over values (@each $var, $i in list)", "@each "),
331+ ("@include", "Include another .hl file (DOM + definitions)", "@include "),
332+ ("@import", "Import definitions only (no DOM nodes)", "@import "),
333+ ("@meta", "Add a <meta> tag to <head>", "@meta "),
334+ ("@head", "Add raw content to <head>", "@head"),
330335 ]
331336 .iter()
332337 .map(|(name, detail, insert)| {
@@ -338,17 +343,18 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
338343fn attr_completions(range: Range) -> Vec<CompletionItem> {
339344 [
340345 // Layout
341− ("spacing", "Gap between children (px)", true),
342− ("padding", "Inner padding (1/2/3/4 values, px)", true),
343− ("padding-x", "Horizontal padding (px)", true),
344− ("padding-y", "Vertical padding (px)", true),
346+ ("spacing", "Gap between children (supports CSS units)", true),
347+ ("gap", "Gap between children (alias for spacing)", true),
348+ ("padding", "Inner padding (1/2/3/4 values, supports CSS units)", true),
349+ ("padding-x", "Horizontal padding", true),
350+ ("padding-y", "Vertical padding", true),
345351 // Sizing
346− ("width", "Width (px | fill | shrink)", true),
347− ("height", "Height (px | fill | shrink)", true),
348− ("min-width", "Minimum width (px)", true),
349− ("max-width", "Maximum width (px)", true),
350− ("min-height", "Minimum height (px)", true),
351− ("max-height", "Maximum height (px)", true),
352+ ("width", "Width (px/% | fill | shrink)", true),
353+ ("height", "Height (px/% | fill | shrink)", true),
354+ ("min-width", "Minimum width", true),
355+ ("max-width", "Maximum width", true),
356+ ("min-height", "Minimum height", true),
357+ ("max-height", "Maximum height", true),
352358 // Alignment
353359 ("center-x", "Center horizontally", false),
354360 ("center-y", "Center vertically", false),
@@ -360,11 +366,15 @@ fn attr_completions(range: Range) -> Vec<CompletionItem> {
360366 ("background", "Background color/value", true),
361367 ("color", "Text color", true),
362368 ("border", "Border (width [color])", true),
363− ("rounded", "Border radius (px)", true),
369+ ("border-top", "Top border (width [color])", true),
370+ ("border-bottom", "Bottom border (width [color])", true),
371+ ("border-left", "Left border (width [color])", true),
372+ ("border-right", "Right border (width [color])", true),
373+ ("rounded", "Border radius", true),
364374 ("bold", "Bold text", false),
365375 ("italic", "Italic text", false),
366376 ("underline", "Underlined text", false),
367− ("size", "Font size (px)", true),
377+ ("size", "Font size", true),
368378 ("font", "Font family", true),
369379 ("transition", "CSS transition", true),
370380 ("cursor", "CSS cursor type", true),
@@ -372,16 +382,35 @@ fn attr_completions(range: Range) -> Vec<CompletionItem> {
372382 // Typography
373383 ("text-align", "Text alignment (left/center/right/justify)", true),
374384 ("line-height", "Line height (unitless or px)", true),
385+ ("letter-spacing", "Letter spacing", true),
386+ ("text-transform", "Text transform (uppercase/lowercase/capitalize)", true),
387+ ("white-space", "White-space behavior (nowrap/pre/normal)", true),
375388 // Overflow & positioning
376389 ("overflow", "Overflow behavior (hidden/scroll/auto)", true),
377390 ("position", "Position type (relative/absolute/fixed/sticky)", true),
391+ ("top", "Top offset (for positioned elements)", true),
392+ ("right", "Right offset (for positioned elements)", true),
393+ ("bottom", "Bottom offset (for positioned elements)", true),
394+ ("left", "Left offset (for positioned elements)", true),
378395 ("z-index", "Stack order (integer)", true),
396+ // Display & visibility
397+ ("display", "Display mode (none/block/inline/flex/grid)", true),
398+ ("visibility", "Visibility (visible/hidden)", true),
399+ // Transform & filters
400+ ("transform", "CSS transform (e.g., rotate(45deg))", true),
401+ ("backdrop-filter", "Backdrop filter (e.g., blur(10px))", true),
379402 // Effects
380403 ("shadow", "Box shadow (CSS value)", true),
381404 // Flow
382405 ("wrap", "Enable flex-wrap", false),
383− ("gap-x", "Horizontal gap between children (px)", true),
384− ("gap-y", "Vertical gap between children (px)", true),
406+ ("gap-x", "Horizontal gap between children", true),
407+ ("gap-y", "Vertical gap between children", true),
408+ // Grid
409+ ("grid", "Enable CSS grid layout", false),
410+ ("grid-cols", "Grid columns (number or template)", true),
411+ ("grid-rows", "Grid rows (number or template)", true),
412+ ("col-span", "Span columns in grid", true),
413+ ("row-span", "Span rows in grid", true),
385414 // Identity
386415 ("id", "HTML id attribute", true),
387416 ("class", "HTML class attribute", true),
@@ -437,14 +466,19 @@ fn state_attr_completions(prefix: &str, range: Range) -> Vec<CompletionItem> {
437466 ("background", "Background color/value", true),
438467 ("color", "Text color", true),
439468 ("border", "Border (width [color])", true),
440− ("rounded", "Border radius (px)", true),
469+ ("border-top", "Top border (width [color])", true),
470+ ("border-bottom", "Bottom border (width [color])", true),
471+ ("rounded", "Border radius", true),
441472 ("bold", "Bold text", false),
442473 ("italic", "Italic text", false),
443474 ("underline", "Underlined text", false),
444− ("size", "Font size (px)", true),
475+ ("size", "Font size", true),
445476 ("opacity", "Opacity (0-1)", true),
446477 ("cursor", "CSS cursor type", true),
447478 ("shadow", "Box shadow (CSS value)", true),
479+ ("transform", "CSS transform", true),
480+ ("display", "Display mode", true),
481+ ("visibility", "Visibility", true),
448482 ]
449483 .iter()
450484 .map(|(name, detail, takes_value)| {
@@ -670,20 +704,23 @@ fn hover_builtin(word: &str) -> Option<String> {
670704 "@textarea" => "**@textarea** \u{2014} Multi-line text input\n\nRenders as `<textarea>`.\n\nUsage: `@textarea [name bio, rows 4] Default text`",
671705 "@option" | "@opt" => "**@option** \u{2014} Select option\n\nRenders as `<option>`.\n\nUsage: `@option [value red] Red`",
672706 "@label" => "**@label** \u{2014} Form label\n\nRenders as `<label>`.\n\nUsage: `@label [for email] Email Address`",
673− "@if" => "**@if** \u{2014} Conditional\n\nConditionally includes children at compile time.\n\n```\n@if $theme == dark\n @el [background #333]\n@else\n @el [background white]\n```",
674− "@each" => "**@each** \u{2014} Loop\n\nRepeat children for each item in a comma-separated list.\n\n```\n@each $color in red,green,blue\n @el [background $color]\n```",
675− "@include" => "**@include** \u{2014} Include file\n\nIncludes another .hl file.\n\nUsage: `@include header.hl`",
707+ "@if" => "**@if** \u{2014} Conditional\n\nConditionally includes children at compile time.\n\n```\n@if $theme == dark\n @el [background #333]\n@else if $theme == light\n @el [background white]\n@else\n @el [background gray]\n```",
708+ "@each" => "**@each** \u{2014} Loop\n\nRepeat children for each item in a comma-separated list.\nOptional index variable.\n\n```\n@each $color, $i in red,green,blue\n @text $i: $color\n```",
709+ "@include" => "**@include** \u{2014} Include file\n\nIncludes another .hl file (DOM nodes + definitions).\n\nUsage: `@include header.hl`",
710+ "@import" => "**@import** \u{2014} Import definitions\n\nImports `@let`, `@define`, `@fn` from another .hl file without emitting DOM nodes.\n\nUsage: `@import theme.hl`",
711+ "@meta" => "**@meta** \u{2014} Meta tag\n\nAdds a `<meta>` tag to `<head>`.\n\nUsage: `@meta description A portfolio site`",
712+ "@head" => "**@head** \u{2014} Head content\n\nAdds raw content to `<head>` (fonts, icons, etc.).\n\n```\n@head\n <link rel=\"icon\" href=\"favicon.ico\">\n```",
676713 // Attributes
677− "spacing" => "**spacing** `<value>`\n\nGap between children in pixels. Maps to CSS `gap`.",
678− "padding" => "**padding** `<value>` | `<y> <x>` | `<t> <h> <b>` | `<t> <r> <b> <l>`\n\nInner padding in pixels. Accepts 1, 2, 3, or 4 values.",
679− "padding-x" => "**padding-x** `<value>`\n\nHorizontal padding (left + right) in pixels.",
680− "padding-y" => "**padding-y** `<value>`\n\nVertical padding (top + bottom) in pixels.",
681− "width" => "**width** `<px>` | `fill` | `shrink`\n\n- Number: fixed width in pixels\n- `fill`: expand to fill parent\n- `shrink`: prevent flex shrinking",
682− "height" => "**height** `<px>` | `fill` | `shrink`\n\n- Number: fixed height in pixels\n- `fill`: expand to fill parent\n- `shrink`: prevent flex shrinking",
683− "min-width" => "**min-width** `<value>` \u{2014} Minimum width in pixels.",
684− "max-width" => "**max-width** `<value>` \u{2014} Maximum width in pixels.",
685− "min-height" => "**min-height** `<value>` \u{2014} Minimum height in pixels.",
686− "max-height" => "**max-height** `<value>` \u{2014} Maximum height in pixels.",
714+ "spacing" | "gap" => "**spacing** `<value>`\n\nGap between children. Supports CSS units (px, rem, em, %).\nMaps to CSS `gap`.",
715+ "padding" => "**padding** `<value>` | `<y> <x>` | `<t> <h> <b>` | `<t> <r> <b> <l>`\n\nInner padding. Supports CSS units. Accepts 1\u{2013}4 values.",
716+ "padding-x" => "**padding-x** `<value>`\n\nHorizontal padding (left + right). Supports CSS units.",
717+ "padding-y" => "**padding-y** `<value>`\n\nVertical padding (top + bottom). Supports CSS units.",
718+ "width" => "**width** `<value>` | `fill` | `shrink`\n\n- Number/unit: fixed width (e.g., `300`, `50%`, `80ch`)\n- `fill`: expand to fill parent\n- `shrink`: prevent flex shrinking",
719+ "height" => "**height** `<value>` | `fill` | `shrink`\n\n- Number/unit: fixed height (e.g., `300`, `100vh`)\n- `fill`: expand to fill parent\n- `shrink`: prevent flex shrinking",
720+ "min-width" => "**min-width** `<value>` \u{2014} Minimum width. Supports CSS units.",
721+ "max-width" => "**max-width** `<value>` \u{2014} Maximum width. Supports CSS units.",
722+ "min-height" => "**min-height** `<value>` \u{2014} Minimum height. Supports CSS units.",
723+ "max-height" => "**max-height** `<value>` \u{2014} Maximum height. Supports CSS units.",
687724 "center-x" => "**center-x**\n\nCenter horizontally.\n\nIn column parent: `align-self: center`\nOtherwise: auto margins.",
688725 "center-y" => "**center-y**\n\nCenter vertically.\n\nIn row parent: `align-self: center`\nOtherwise: auto margins.",
689726 "align-left" => "**align-left** \u{2014} Align to the left edge.",
@@ -693,7 +730,11 @@ fn hover_builtin(word: &str) -> Option<String> {
693730 "background" => "**background** `<color>` \u{2014} Background color or CSS background value.",
694731 "color" => "**color** `<color>` \u{2014} Text color.",
695732 "border" => "**border** `<width> [color]`\n\nBorder. Width in pixels, color defaults to `currentColor`.",
696− "rounded" => "**rounded** `<value>` \u{2014} Border radius in pixels.",
733+ "border-top" => "**border-top** `<width> [color]` \u{2014} Top border.",
734+ "border-bottom" => "**border-bottom** `<width> [color]` \u{2014} Bottom border.",
735+ "border-left" => "**border-left** `<width> [color]` \u{2014} Left border.",
736+ "border-right" => "**border-right** `<width> [color]` \u{2014} Right border.",
737+ "rounded" => "**rounded** `<value>` \u{2014} Border radius. Supports CSS units.",
697738 "bold" => "**bold** \u{2014} Bold text (`font-weight: bold`).",
698739 "italic" => "**italic** \u{2014} Italic text (`font-style: italic`).",
699740 "underline" => "**underline** \u{2014} Underlined text.",
@@ -706,7 +747,23 @@ fn hover_builtin(word: &str) -> Option<String> {
706747 "line-height" => "**line-height** `<value>` \u{2014} Line height. Unitless (e.g., `1.5`) or pixels.",
707748 "overflow" => "**overflow** `<value>` \u{2014} Overflow behavior (`hidden`, `scroll`, `auto`, `visible`).",
708749 "position" => "**position** `<value>` \u{2014} Position type (`relative`, `absolute`, `fixed`, `sticky`).",
750+ "top" => "**top** `<value>` \u{2014} Top offset for positioned elements.",
751+ "right" => "**right** `<value>` \u{2014} Right offset for positioned elements.",
752+ "bottom" => "**bottom** `<value>` \u{2014} Bottom offset for positioned elements.",
753+ "left" => "**left** `<value>` \u{2014} Left offset for positioned elements.",
709754 "z-index" => "**z-index** `<value>` \u{2014} Stack order (integer).",
755+ "display" => "**display** `<value>` \u{2014} Display mode (`none`, `block`, `inline`, `flex`, `grid`).",
756+ "visibility" => "**visibility** `<value>` \u{2014} Visibility (`visible`, `hidden`).",
757+ "transform" => "**transform** `<value>` \u{2014} CSS transform (e.g., `rotate(45deg)`, `scale(1.5)`).",
758+ "backdrop-filter" => "**backdrop-filter** `<value>` \u{2014} Backdrop filter (e.g., `blur(10px)`).",
759+ "letter-spacing" => "**letter-spacing** `<value>` \u{2014} Letter spacing. Supports CSS units.",
760+ "text-transform" => "**text-transform** `<value>` \u{2014} Text transform (`uppercase`, `lowercase`, `capitalize`).",
761+ "white-space" => "**white-space** `<value>` \u{2014} White-space behavior (`nowrap`, `pre`, `normal`).",
762+ "grid" => "**grid** \u{2014} Enable CSS grid layout on this element.",
763+ "grid-cols" => "**grid-cols** `<value>` \u{2014} Grid template columns. Number for equal columns, or CSS value.",
764+ "grid-rows" => "**grid-rows** `<value>` \u{2014} Grid template rows. Number for equal rows, or CSS value.",
765+ "col-span" => "**col-span** `<value>` \u{2014} Span N columns in a grid.",
766+ "row-span" => "**row-span** `<value>` \u{2014} Span N rows in a grid.",
710767 "shadow" => "**shadow** `<value>` \u{2014} Box shadow. Raw CSS value (e.g., `0 2px 4px rgba(0,0,0,0.1)`).",
711768 "gap-x" => "**gap-x** `<value>` \u{2014} Horizontal gap between children in pixels. Maps to `column-gap`.",
712769 "gap-y" => "**gap-y** `<value>` \u{2014} Vertical gap between children in pixels. Maps to `row-gap`.",
src/codegen.rs+203−45
@@ -203,6 +203,43 @@ fn generate_with_options(doc: &Document, dev: bool) -> String {
203203 }
204204 }
205205
206+ // Build meta tags string
207+ let meta_html = if doc.meta_tags.is_empty() {
208+ String::new()
209+ } else {
210+ let mut m = String::new();
211+ for (name, content) in &doc.meta_tags {
212+ if dev {
213+ m.push_str(&format!(
214+ "<meta name=\"{}\" content=\"{}\">\n",
215+ html_escape(name),
216+ html_escape(content)
217+ ));
218+ } else {
219+ m.push_str(&format!(
220+ "<meta name=\"{}\" content=\"{}\">",
221+ html_escape(name),
222+ html_escape(content)
223+ ));
224+ }
225+ }
226+ m
227+ };
228+
229+ // Build head blocks string
230+ let head_html = if doc.head_blocks.is_empty() {
231+ String::new()
232+ } else {
233+ let mut h = String::new();
234+ for block in &doc.head_blocks {
235+ h.push_str(block);
236+ if dev {
237+ h.push('\n');
238+ }
239+ }
240+ h
241+ };
242+
206243 match &doc.page_title {
207244 Some(title) => {
208245 if dev {
@@ -214,6 +251,7 @@ fn generate_with_options(doc: &Document, dev: bool) -> String {
214251<meta charset=\"utf-8\">
215252<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
216253<title>{title}</title>
254+{meta_html}{head_html}\
217255<style>
218256*, *::before, *::after {{ box-sizing: border-box; }}
219257body {{ margin: 0; font-family: system-ui, -apple-system, sans-serif; }}
@@ -227,13 +265,17 @@ img {{ display: block; }}
227265</html>
228266",
229267 title = html_escape(title),
268+ meta_html = meta_html,
269+ head_html = head_html,
230270 element_css = element_css,
231271 body = body,
232272 )
233273 } else {
234274 format!(
235− "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>{title}</title><style>*,*::before,*::after{{box-sizing:border-box}}body{{margin:0;font-family:system-ui,-apple-system,sans-serif}}img{{display:block}}{element_css}</style></head><body>{body}</body></html>",
275+ "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>{title}</title>{meta_html}{head_html}<style>*,*::before,*::after{{box-sizing:border-box}}body{{margin:0;font-family:system-ui,-apple-system,sans-serif}}img{{display:block}}{element_css}</style></head><body>{body}</body></html>",
236276 title = html_escape(title),
277+ meta_html = meta_html,
278+ head_html = head_html,
237279 element_css = element_css,
238280 body = body,
239281 )
@@ -595,51 +637,26 @@ fn attrs_to_css(
595637
596638 match effective_key {
597639 // Layout
598− "spacing" => {
640+ "spacing" | "gap" => {
599641 if let Some(v) = val {
600− push_css(&mut css, "gap", &format!("{}px", v));
642+ push_css(&mut css, "gap", &css_px(v));
601643 }
602644 }
603645 "padding" => {
604646 if let Some(v) = val {
605− let parts: Vec<&str> = v.split_whitespace().collect();
606− match parts.len() {
607− 1 => push_css(&mut css, "padding", &format!("{}px", parts[0])),
608− 2 => push_css(
609− &mut css,
610− "padding",
611− &format!("{}px {}px", parts[0], parts[1]),
612− ),
613− 3 => push_css(
614− &mut css,
615− "padding",
616− &format!(
617− "{}px {}px {}px",
618− parts[0], parts[1], parts[2]
619− ),
620− ),
621− 4 => push_css(
622− &mut css,
623− "padding",
624− &format!(
625− "{}px {}px {}px {}px",
626− parts[0], parts[1], parts[2], parts[3]
627− ),
628− ),
629− _ => {}
630− }
647+ push_css(&mut css, "padding", &css_px_multi(v));
631648 }
632649 }
633650 "padding-x" => {
634651 if let Some(v) = val {
635− push_css(&mut css, "padding-left", &format!("{}px", v));
636− push_css(&mut css, "padding-right", &format!("{}px", v));
652+ push_css(&mut css, "padding-left", &css_px(v));
653+ push_css(&mut css, "padding-right", &css_px(v));
637654 }
638655 }
639656 "padding-y" => {
640657 if let Some(v) = val {
641− push_css(&mut css, "padding-top", &format!("{}px", v));
642− push_css(&mut css, "padding-bottom", &format!("{}px", v));
658+ push_css(&mut css, "padding-top", &css_px(v));
659+ push_css(&mut css, "padding-bottom", &css_px(v));
643660 }
644661 }
645662
@@ -655,7 +672,7 @@ fn attrs_to_css(
655672 _ => push_css(&mut css, "width", "100%"),
656673 },
657674 "shrink" => push_css(&mut css, "flex-shrink", "0"),
658− _ => push_css(&mut css, "width", &format!("{}px", v)),
675+ _ => push_css(&mut css, "width", &css_px(v)),
659676 }
660677 }
661678 }
@@ -670,28 +687,28 @@ fn attrs_to_css(
670687 _ => push_css(&mut css, "height", "100%"),
671688 },
672689 "shrink" => push_css(&mut css, "flex-shrink", "0"),
673− _ => push_css(&mut css, "height", &format!("{}px", v)),
690+ _ => push_css(&mut css, "height", &css_px(v)),
674691 }
675692 }
676693 }
677694 "min-width" => {
678695 if let Some(v) = val {
679− push_css(&mut css, "min-width", &format!("{}px", v));
696+ push_css(&mut css, "min-width", &css_px(v));
680697 }
681698 }
682699 "max-width" => {
683700 if let Some(v) = val {
684− push_css(&mut css, "max-width", &format!("{}px", v));
701+ push_css(&mut css, "max-width", &css_px(v));
685702 }
686703 }
687704 "min-height" => {
688705 if let Some(v) = val {
689− push_css(&mut css, "min-height", &format!("{}px", v));
706+ push_css(&mut css, "min-height", &css_px(v));
690707 }
691708 }
692709 "max-height" => {
693710 if let Some(v) = val {
694− push_css(&mut css, "max-height", &format!("{}px", v));
711+ push_css(&mut css, "max-height", &css_px(v));
695712 }
696713 }
697714
@@ -751,20 +768,38 @@ fn attrs_to_css(
751768 push_css(
752769 &mut css,
753770 "border",
754− &format!("{}px solid {}", parts[0], parts[1]),
771+ &format!("{} solid {}", css_px(parts[0]), parts[1]),
755772 );
756773 } else {
757774 push_css(
758775 &mut css,
759776 "border",
760− &format!("{}px solid currentColor", parts[0]),
777+ &format!("{} solid currentColor", css_px(parts[0])),
778+ );
779+ }
780+ }
781+ }
782+ "border-top" | "border-bottom" | "border-left" | "border-right" => {
783+ if let Some(v) = val {
784+ let parts: Vec<&str> = v.splitn(2, ' ').collect();
785+ if parts.len() == 2 {
786+ push_css(
787+ &mut css,
788+ effective_key,
789+ &format!("{} solid {}", css_px(parts[0]), parts[1]),
790+ );
791+ } else {
792+ push_css(
793+ &mut css,
794+ effective_key,
795+ &format!("{} solid currentColor", css_px(parts[0])),
761796 );
762797 }
763798 }
764799 }
765800 "rounded" => {
766801 if let Some(v) = val {
767− push_css(&mut css, "border-radius", &format!("{}px", v));
802+ push_css(&mut css, "border-radius", &css_px(v));
768803 }
769804 }
770805 "bold" => push_css(&mut css, "font-weight", "bold"),
@@ -772,7 +807,7 @@ fn attrs_to_css(
772807 "underline" => push_css(&mut css, "text-decoration", "underline"),
773808 "size" => {
774809 if let Some(v) = val {
775− push_css(&mut css, "font-size", &format!("{}px", v));
810+ push_css(&mut css, "font-size", &css_px(v));
776811 }
777812 }
778813 "font" => {
@@ -807,6 +842,21 @@ fn attrs_to_css(
807842 push_css(&mut css, "line-height", v);
808843 }
809844 }
845+ "letter-spacing" => {
846+ if let Some(v) = val {
847+ push_css(&mut css, "letter-spacing", &css_px(v));
848+ }
849+ }
850+ "text-transform" => {
851+ if let Some(v) = val {
852+ push_css(&mut css, "text-transform", v);
853+ }
854+ }
855+ "white-space" => {
856+ if let Some(v) = val {
857+ push_css(&mut css, "white-space", v);
858+ }
859+ }
810860
811861 // Overflow & positioning
812862 "overflow" => {
@@ -819,12 +869,56 @@ fn attrs_to_css(
819869 push_css(&mut css, "position", v);
820870 }
821871 }
872+ "top" => {
873+ if let Some(v) = val {
874+ push_css(&mut css, "top", &css_px(v));
875+ }
876+ }
877+ "right" => {
878+ if let Some(v) = val {
879+ push_css(&mut css, "right", &css_px(v));
880+ }
881+ }
882+ "bottom" => {
883+ if let Some(v) = val {
884+ push_css(&mut css, "bottom", &css_px(v));
885+ }
886+ }
887+ "left" => {
888+ if let Some(v) = val {
889+ push_css(&mut css, "left", &css_px(v));
890+ }
891+ }
822892 "z-index" => {
823893 if let Some(v) = val {
824894 push_css(&mut css, "z-index", v);
825895 }
826896 }
827897
898+ // Display & visibility
899+ "display" => {
900+ if let Some(v) = val {
901+ push_css(&mut css, "display", v);
902+ }
903+ }
904+ "visibility" => {
905+ if let Some(v) = val {
906+ push_css(&mut css, "visibility", v);
907+ }
908+ }
909+
910+ // Transform & filters
911+ "transform" => {
912+ if let Some(v) = val {
913+ push_css(&mut css, "transform", v);
914+ }
915+ }
916+ "backdrop-filter" => {
917+ if let Some(v) = val {
918+ push_css(&mut css, "backdrop-filter", v);
919+ }
920+ }
921+
828922 // Effects
829923 "shadow" => {
830924 if let Some(v) = val {
@@ -836,12 +930,45 @@ fn attrs_to_css(
836930 "wrap" => push_css(&mut css, "flex-wrap", "wrap"),
837931 "gap-x" => {
838932 if let Some(v) = val {
839− push_css(&mut css, "column-gap", &format!("{}px", v));
933+ push_css(&mut css, "column-gap", &css_px(v));
840934 }
841935 }
842936 "gap-y" => {
843937 if let Some(v) = val {
844− push_css(&mut css, "row-gap", &format!("{}px", v));
938+ push_css(&mut css, "row-gap", &css_px(v));
939+ }
940+ }
941+
942+ // Grid
943+ "grid" => {
944+ push_css(&mut css, "display", "grid");
945+ }
946+ "grid-cols" => {
947+ if let Some(v) = val {
948+ if let Ok(n) = v.parse::<u32>() {
949+ push_css(&mut css, "grid-template-columns", &format!("repeat({},1fr)", n));
950+ } else {
951+ push_css(&mut css, "grid-template-columns", v);
952+ }
953+ }
954+ }
955+ "grid-rows" => {
956+ if let Some(v) = val {
957+ if let Ok(n) = v.parse::<u32>() {
958+ push_css(&mut css, "grid-template-rows", &format!("repeat({},1fr)", n));
959+ } else {
960+ push_css(&mut css, "grid-template-rows", v);
961+ }
962+ }
963+ }
964+ "col-span" => {
965+ if let Some(v) = val {
966+ push_css(&mut css, "grid-column", &format!("span {}", v));
967+ }
968+ }
969+ "row-span" => {
970+ if let Some(v) = val {
971+ push_css(&mut css, "grid-row", &format!("span {}", v));
845972 }
846973 }
847974
@@ -873,6 +1000,37 @@ fn push_css(css: &mut String, prop: &str, value: &str) {
8731000 css.push(';');
8741001}
8751002
1003+/// Known CSS units — if a value ends with one, skip appending `px`.
1004+const CSS_UNITS: &[&str] = &[
1005+ "%", "rem", "em", "vh", "vw", "vmin", "vmax", "dvh", "svh", "lvh",
1006+ "ch", "ex", "cm", "mm", "in", "pt", "pc", "fr",
1007+];
1008+
1009+/// Format a numeric value: if it already has a CSS unit, pass through as-is;
1010+/// otherwise append `px`.
1011+fn css_px(value: &str) -> String {
1012+ let v = value.trim();
1013+ if v == "0" {
1014+ return "0".to_string();
1015+ }
1016+ if CSS_UNITS.iter().any(|u| v.ends_with(u)) {
1017+ return v.to_string();
1018+ }
1019+ if v.starts_with("var(") || v.starts_with("calc(") {
1020+ return v.to_string();
1021+ }
1022+ format!("{}px", v)
1023+}
1024+
1025+/// Format multiple space-separated values, each getting px if needed.
1026+fn css_px_multi(value: &str) -> String {
1027+ value
1028+ .split_whitespace()
1029+ .map(|p| css_px(p))
1030+ .collect::<Vec<_>>()
1031+ .join(" ")
1032+}
1033+
8761034fn extract_id_class(attrs: &[Attribute]) -> (Option<String>, Option<String>) {
8771035 let mut id = None;
8781036 let mut class = None;
src/main.rs+185−27
@@ -5,7 +5,7 @@ use std::hash::{Hash, Hasher};
55use std::path::{Path, PathBuf};
66use std::process;
77
8−fn compile(input_path: &str, dev: bool) -> (bool, Vec<PathBuf>) {
8+fn compile(input_path: &str, dev: bool, error_overlay: bool) -> (bool, Vec<PathBuf>) {
99 let input = match fs::read_to_string(input_path) {
1010 Ok(s) => s,
1111 Err(e) => {
@@ -33,13 +33,19 @@ fn compile(input_path: &str, dev: bool) -> (bool, Vec<PathBuf>) {
3333 .iter()
3434 .any(|d| d.severity == htmlang::parser::Severity::Error);
3535
36− if !has_errors {
36+ let out_path = Path::new(input_path).with_extension("html");
37+
38+ if has_errors {
39+ if error_overlay {
40+ let error_html = generate_error_overlay(&result.diagnostics, input_path);
41+ let _ = fs::write(&out_path, &error_html);
42+ }
43+ } else {
3744 let html = if dev {
3845 htmlang::codegen::generate_dev(&result.document)
3946 } else {
4047 htmlang::codegen::generate(&result.document)
4148 };
42− let out_path = Path::new(input_path).with_extension("html");
4349 match fs::write(&out_path, &html) {
4450 Ok(()) => eprintln!("wrote {}", out_path.display()),
4551 Err(e) => eprintln!("error: {}: {}", out_path.display(), e),
@@ -49,12 +55,48 @@ fn compile(input_path: &str, dev: bool) -> (bool, Vec<PathBuf>) {
4955 (has_errors, result.included_files)
5056}
5157
58+fn generate_error_overlay(diagnostics: &[htmlang::parser::Diagnostic], file: &str) -> String {
59+ let mut errors = String::new();
60+ for d in diagnostics {
61+ let prefix = match d.severity {
62+ htmlang::parser::Severity::Error => "error",
63+ htmlang::parser::Severity::Warning => "warning",
64+ };
65+ let escaped = d.message
66+ .replace('&', "&amp;")
67+ .replace('<', "&lt;")
68+ .replace('>', "&gt;");
69+ errors.push_str(&format!(
70+ "<div class=\"entry\"><span class=\"badge {}\">{}</span> line {}: {}</div>",
71+ prefix, prefix, d.line, escaped
72+ ));
73+ }
74+ format!(
75+ r#"<!DOCTYPE html><html><head><meta charset="utf-8"><title>Build Error</title><style>
76+*{{margin:0;box-sizing:border-box}}
77+body{{background:#1a1a2e;color:#eee;font-family:ui-monospace,monospace;padding:2rem}}
78+h1{{color:#ff6b6b;margin-bottom:1rem;font-size:1.5rem}}
79+.file{{color:#888;margin-bottom:1.5rem;font-size:0.9rem}}
80+.entry{{padding:0.5rem 0;border-bottom:1px solid #333}}
81+.badge{{display:inline-block;padding:2px 8px;border-radius:4px;font-size:0.8rem;margin-right:8px}}
82+.badge.error{{background:#c0392b;color:white}}
83+.badge.warning{{background:#f39c12;color:white}}
84+</style></head><body>
85+<h1>Build Error</h1>
86+<div class="file">{file}</div>
87+{errors}
88+</body></html>"#,
89+ file = file,
90+ errors = errors,
91+ )
92+}
93+
5294fn print_help() {
5395 eprintln!(
5496 "\
5597htmlang {} - a minimalist layout language that compiles to static HTML
5698
57−Usage: htmlang [options] <file.hl>
99+Usage: htmlang [options] <file.hl | directory>
58100
59101Options:
60102 -w, --watch Watch for changes and recompile
@@ -66,8 +108,10 @@ Options:
66108
67109Examples:
68110 htmlang page.hl Compile page.hl to page.html
111+ htmlang site/ Compile all .hl files in directory
69112 htmlang -w page.hl Recompile on file changes
70113 htmlang -s page.hl Start dev server with hot reload
114+ htmlang -s site/ Serve a multi-page site
71115 htmlang -s -p 8080 page.hl",
72116 env!("CARGO_PKG_VERSION")
73117 );
@@ -130,7 +174,55 @@ fn main() {
130174 }
131175 };
132176
133− let (has_errors, included_files) = compile(&input_path, dev);
177+ let is_dir = Path::new(&input_path).is_dir();
178+
179+ // --- Directory mode: compile all .hl files ---
180+ if is_dir {
181+ let dir = Path::new(&input_path);
182+ let hl_files = collect_hl_files(dir);
183+ if hl_files.is_empty() {
184+ eprintln!("no .hl files found in {}", input_path);
185+ process::exit(1);
186+ }
187+
188+ let mut any_errors = false;
189+ let mut all_included: Vec<PathBuf> = Vec::new();
190+ for file in &hl_files {
191+ let path_str = file.to_string_lossy().to_string();
192+ let (has_errors, included) = compile(&path_str, dev, serve);
193+ if has_errors {
194+ any_errors = true;
195+ }
196+ all_included.extend(included);
197+ }
198+
199+ if !watch {
200+ if any_errors {
201+ process::exit(1);
202+ }
203+ return;
204+ }
205+
206+ // For directory serve mode, serve the directory with index.html
207+ let reload_tx = if serve {
208+ let (tx, _) = tokio::sync::broadcast::channel::<()>(16);
209+ let index_path = dir.join("index.html");
210+ let server_tx = tx.clone();
211+ std::thread::spawn(move || {
212+ let rt = tokio::runtime::Runtime::new().expect("failed to create runtime");
213+ rt.block_on(htmlang::serve::run(port, index_path, server_tx));
214+ });
215+ Some(tx)
216+ } else {
217+ None
218+ };
219+
220+ watch_loop(dir, &hl_files, &all_included, dev, serve, reload_tx);
221+ return;
222+ }
223+
224+ // --- Single file mode ---
225+ let (has_errors, included_files) = compile(&input_path, dev, serve);
134226
135227 if !watch {
136228 if has_errors {
@@ -153,7 +245,39 @@ fn main() {
153245 None
154246 };
155247
156− // Watch mode
248+ let files = vec![PathBuf::from(&input_path)];
249+ watch_loop(
250+ Path::new(&input_path).parent().unwrap_or(Path::new(".")),
251+ &files,
252+ &included_files,
253+ dev,
254+ serve,
255+ reload_tx,
256+ );
257+}
258+
259+fn collect_hl_files(dir: &Path) -> Vec<PathBuf> {
260+ let mut files = Vec::new();
261+ if let Ok(entries) = fs::read_dir(dir) {
262+ for entry in entries.flatten() {
263+ let path = entry.path();
264+ if path.is_file() && path.extension().map_or(false, |e| e == "hl") {
265+ files.push(path);
266+ }
267+ }
268+ }
269+ files.sort();
270+ files
271+}
272+
273+fn watch_loop(
274+ watch_dir: &Path,
275+ source_files: &[PathBuf],
276+ included_files: &[PathBuf],
277+ dev: bool,
278+ serve: bool,
279+ reload_tx: Option<tokio::sync::broadcast::Sender<()>>,
280+) {
157281 use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
158282 use std::sync::mpsc;
159283
@@ -168,18 +292,22 @@ fn main() {
168292 )
169293 .expect("failed to create file watcher");
170294
171− let input_canonical =
172− fs::canonicalize(&input_path).unwrap_or_else(|_| PathBuf::from(&input_path));
173− watcher
174− .watch(&input_canonical, RecursiveMode::NonRecursive)
175− .expect("failed to watch file");
176−
177− for inc in &included_files {
295+ // Watch all source files
296+ for file in source_files {
297+ let canonical = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
298+ watcher
299+ .watch(&canonical, RecursiveMode::NonRecursive)
300+ .unwrap_or_else(|_| eprintln!("warning: could not watch {}", file.display()));
301+ }
302+ for inc in included_files {
178303 let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
179304 }
180305
306+ // Also watch the directory itself for new files
307+ let _ = watcher.watch(watch_dir, RecursiveMode::NonRecursive);
308+
181309 if serve {
182− eprintln!("watching for changes at http://127.0.0.1:{port}");
310+ eprintln!("watching for changes at http://127.0.0.1:3000");
183311 } else {
184312 eprintln!("watching for changes...");
185313 }
@@ -195,10 +323,13 @@ fn main() {
195323 }
196324
197325 // Seed initial hashes
198− if let Some(h) = hash_file(&input_canonical) {
199− content_hashes.insert(input_canonical.clone(), h);
326+ for file in source_files {
327+ let canonical = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
328+ if let Some(h) = hash_file(&canonical) {
329+ content_hashes.insert(canonical, h);
330+ }
200331 }
201− for inc in &included_files {
332+ for inc in included_files {
202333 if let Some(h) = hash_file(inc) {
203334 content_hashes.insert(inc.clone(), h);
204335 }
@@ -224,26 +355,53 @@ fn main() {
224355 false
225356 };
226357
227− if check_path(&input_canonical, &mut content_hashes) {
228− any_changed = true;
229− }
230− for inc in &included_files {
231− if check_path(inc, &mut content_hashes) {
358+ for file in source_files {
359+ let canonical = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
360+ if check_path(&canonical, &mut content_hashes) {
232361 any_changed = true;
233362 }
234363 }
235364
365+ // Check for new .hl files in directory
366+ if watch_dir.is_dir() {
367+ let current_files = collect_hl_files(watch_dir);
368+ for file in &current_files {
369+ let canonical = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
370+ if check_path(&canonical, &mut content_hashes) {
371+ any_changed = true;
372+ let _ = watcher.watch(&canonical, RecursiveMode::NonRecursive);
373+ }
374+ }
375+ }
376+
236377 if !any_changed {
237378 continue;
238379 }
239380
240381 eprintln!("\nrecompiling...");
241− let (_, new_includes) = compile(&input_path, dev);
242382
243− for inc in &new_includes {
244− let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
245− if let Some(h) = hash_file(inc) {
246− content_hashes.insert(inc.clone(), h);
383+ // Recompile all source files
384+ for file in source_files {
385+ let path_str = file.to_string_lossy().to_string();
386+ let (_, new_includes) = compile(&path_str, dev, serve);
387+ for inc in &new_includes {
388+ let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
389+ if let Some(h) = hash_file(inc) {
390+ content_hashes.insert(inc.clone(), h);
391+ }
392+ }
393+ }
394+
395+ // Also compile any new .hl files in directory mode
396+ if watch_dir.is_dir() {
397+ for file in &collect_hl_files(watch_dir) {
398+ if !source_files.contains(file) {
399+ let path_str = file.to_string_lossy().to_string();
400+ let (_, new_includes) = compile(&path_str, dev, serve);
401+ for inc in &new_includes {
402+ let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
403+ }
404+ }
247405 }
248406 }
249407
src/parser.rs+199−29
@@ -60,11 +60,14 @@ struct Line {
6060#[derive(Clone)]
6161struct FnDef {
6262 params: Vec<String>,
63+ defaults: HashMap<String, String>,
6364 body_lines: Vec<Line>,
6465}
6566
6667struct ParseContext {
6768 page_title: Option<String>,
69+ meta_tags: Vec<(String, String)>,
70+ head_blocks: Vec<String>,
6871 variables: HashMap<String, String>,
6972 defines: HashMap<String, Vec<Attribute>>,
7073 functions: HashMap<String, FnDef>,
@@ -92,6 +95,8 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
9295 let mut parser = Parser { lines, pos: 0 };
9396 let mut ctx = ParseContext {
9497 page_title: None,
98+ meta_tags: Vec::new(),
99+ head_blocks: Vec::new(),
95100 variables: HashMap::new(),
96101 defines: HashMap::new(),
97102 functions: HashMap::new(),
@@ -109,6 +114,8 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
109114 ParseResult {
110115 document: Document {
111116 page_title: ctx.page_title,
117+ meta_tags: ctx.meta_tags,
118+ head_blocks: ctx.head_blocks,
112119 variables: ctx.variables,
113120 defines: ctx.defines,
114121 keyframes: ctx.keyframes,
@@ -296,6 +303,38 @@ impl Parser {
296303 return Ok(None);
297304 }
298305
306+ if let Some(rest) = content.strip_prefix("@meta ") {
307+ let rest = rest.trim();
308+ if let Some((name, value)) = rest.split_once(' ') {
309+ let value = substitute_vars(value.trim(), &ctx.variables);
310+ ctx.meta_tags.push((name.trim().to_string(), value));
311+ }
312+ return Ok(None);
313+ }
314+
315+ if content.strip_prefix("@head").is_some() {
316+ // Collect indented body lines as raw head content
317+ let mut head_content = String::new();
318+ while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
319+ match &self.lines[self.pos].content {
320+ LineContent::Normal(s) => {
321+ head_content.push_str(s.trim());
322+ head_content.push('\n');
323+ }
324+ LineContent::Raw(s) => {
325+ head_content.push_str(s);
326+ head_content.push('\n');
327+ }
328+ }
329+ self.pos += 1;
330+ }
331+ let trimmed = head_content.trim().to_string();
332+ if !trimmed.is_empty() {
333+ ctx.head_blocks.push(trimmed);
334+ }
335+ return Ok(None);
336+ }
337+
299338 if let Some(rest) = content.strip_prefix("@define ") {
300339 let rest = rest.trim();
301340 if let Some(bracket_start) = rest.find('[') {
@@ -363,6 +402,63 @@ impl Parser {
363402 return Ok(Some(nodes));
364403 }
365404
405+ // --- @import (definitions only, no DOM nodes) ---
406+
407+ if let Some(rest) = content.strip_prefix("@import ") {
408+ let filename = substitute_vars(rest.trim(), &ctx.variables);
409+ let resolved = match &ctx.base_path {
410+ Some(base) => base.join(&filename),
411+ None => PathBuf::from(&filename),
412+ };
413+
414+ if ctx.include_stack.contains(&resolved) {
415+ ctx.diagnostics.push(Diagnostic {
416+ line: line_num,
417+ message: format!("circular import '{}'", filename),
418+ severity: Severity::Error,
419+ source_line: Some(content.clone()),
420+ });
421+ return Ok(None);
422+ }
423+
424+ let imported_text = if let Some(cached) = ctx.file_cache.get(&resolved) {
425+ cached.clone()
426+ } else {
427+ match std::fs::read_to_string(&resolved) {
428+ Ok(text) => {
429+ ctx.file_cache.insert(resolved.clone(), text.clone());
430+ text
431+ }
432+ Err(e) => {
433+ ctx.diagnostics.push(Diagnostic {
434+ line: line_num,
435+ message: format!("cannot import '{}': {}", filename, e),
436+ severity: Severity::Error,
437+ source_line: Some(content.clone()),
438+ });
439+ return Ok(None);
440+ }
441+ }
442+ };
443+
444+ ctx.included_files.push(resolved.clone());
445+ ctx.include_stack.push(resolved.clone());
446+ let saved_base = ctx.base_path.clone();
447+ ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
448+
449+ // Parse the file but discard DOM nodes — only keep definitions
450+ let imported_lines = preprocess(&imported_text);
451+ let mut imported_parser = Parser {
452+ lines: imported_lines,
453+ pos: 0,
454+ };
455+ let _discarded_nodes = imported_parser.parse_children(0, ctx);
456+
457+ ctx.base_path = saved_base;
458+ ctx.include_stack.pop();
459+ return Ok(None); // No nodes emitted
460+ }
461+
366462 // --- @if / @else ---
367463
368464 if let Some(rest) = content.strip_prefix("@if ") {
@@ -377,23 +473,55 @@ impl Parser {
377473 self.pos += 1;
378474 }
379475
380− // Check for @else at same indent
381− let mut else_lines = Vec::new();
382− if self.pos < self.lines.len() && self.lines[self.pos].indent == current_indent {
476+ // Build branches: [(condition_result, body_lines), ...]
477+ let mut branches: Vec<(bool, Vec<Line>)> = vec![(result, then_lines)];
478+
479+ // Check for @else if / @else chains at same indent
480+ loop {
481+ if self.pos >= self.lines.len() || self.lines[self.pos].indent != current_indent {
482+ break;
483+ }
383484 if let LineContent::Normal(ref s) = self.lines[self.pos].content {
384− if s.trim() == "@else" {
485+ let trimmed = s.trim();
486+ if let Some(else_if_cond) = trimmed.strip_prefix("@else if ") {
487+ self.pos += 1; // consume @else if
488+ let cond = substitute_vars(else_if_cond.trim(), &ctx.variables);
489+ let cond_result = evaluate_condition(&cond);
490+ let mut body = Vec::new();
491+ while self.pos < self.lines.len()
492+ && self.lines[self.pos].indent > current_indent
493+ {
494+ body.push(self.lines[self.pos].clone());
495+ self.pos += 1;
496+ }
497+ branches.push((cond_result, body));
498+ } else if trimmed == "@else" {
385499 self.pos += 1; // consume @else
500+ let mut body = Vec::new();
386501 while self.pos < self.lines.len()
387502 && self.lines[self.pos].indent > current_indent
388503 {
389− else_lines.push(self.lines[self.pos].clone());
504+ body.push(self.lines[self.pos].clone());
390505 self.pos += 1;
391506 }
507+ // @else is always true (fallback)
508+ branches.push((true, body));
509+ break;
510+ } else {
511+ break;
392512 }
513+ } else {
514+ break;
393515 }
394516 }
395517
396− let body_lines = if result { then_lines } else { else_lines };
518+ // Pick the first branch whose condition is true
519+ let body_lines = branches
520+ .into_iter()
521+ .find(|(cond, _)| *cond)
522+ .map(|(_, lines)| lines)
523+ .unwrap_or_default();
524+
397525 if body_lines.is_empty() {
398526 return Ok(None);
399527 }
@@ -416,7 +544,7 @@ impl Parser {
416544 return Ok(Some(nodes));
417545 }
418546
419− if content.trim() == "@else" {
547+ if content.trim() == "@else" || content.trim().starts_with("@else if ") {
420548 return Err(ParseError {
421549 line: line_num,
422550 message: "@else without matching @if".to_string(),
@@ -427,15 +555,25 @@ impl Parser {
427555
428556 if let Some(rest) = content.strip_prefix("@each ") {
429557 let rest = rest.trim();
430− let parts: Vec<&str> = rest.splitn(3, ' ').collect();
431− if parts.len() < 3 || parts[1] != "in" {
558+ // Support: @each $var in list OR @each $var, $index in list
559+ let (var_name, index_var, list_str) = if let Some((before_in, after_in)) = rest.split_once(" in ") {
560+ let before_in = before_in.trim();
561+ if let Some((var_part, idx_part)) = before_in.split_once(',') {
562+ let var = var_part.trim().strip_prefix('$').unwrap_or(var_part.trim()).to_string();
563+ let idx = idx_part.trim().strip_prefix('$').unwrap_or(idx_part.trim()).to_string();
564+ (var, Some(idx), after_in.trim().to_string())
565+ } else {
566+ let var = before_in.strip_prefix('$').unwrap_or(before_in).to_string();
567+ (var, None, after_in.trim().to_string())
568+ }
569+ } else {
432570 return Err(ParseError {
433571 line: line_num,
434572 message: "@each requires: @each $var in list".to_string(),
435573 });
436− }
437− let var_name = parts[0].strip_prefix('$').unwrap_or(parts[0]).to_string();
438− let list_str = substitute_vars(parts[2], &ctx.variables);
574+ };
575+
576+ let list_str = substitute_vars(&list_str, &ctx.variables);
439577 let items: Vec<String> = list_str
440578 .split(',')
441579 .map(|s| s.trim().to_string())
@@ -466,8 +604,11 @@ impl Parser {
466604 let saved_vars = ctx.variables.clone();
467605 let mut all_nodes = Vec::new();
468606
469− for item in &items {
607+ for (i, item) in items.iter().enumerate() {
470608 ctx.variables.insert(var_name.clone(), item.clone());
609+ if let Some(ref idx_name) = index_var {
610+ ctx.variables.insert(idx_name.clone(), i.to_string());
611+ }
471612 let mut body_parser = Parser {
472613 lines: adjusted.clone(),
473614 pos: 0,
@@ -514,10 +655,17 @@ impl Parser {
514655 });
515656 }
516657 let name = parts[0].to_string();
517− let params: Vec<String> = parts[1..]
518− .iter()
519− .map(|p| p.strip_prefix('$').unwrap_or(p).to_string())
520− .collect();
658+ let mut params = Vec::new();
659+ let mut defaults = HashMap::new();
660+ for part in &parts[1..] {
661+ let part = part.strip_prefix('$').unwrap_or(part);
662+ if let Some((param_name, default_val)) = part.split_once('=') {
663+ params.push(param_name.to_string());
664+ defaults.insert(param_name.to_string(), default_val.to_string());
665+ } else {
666+ params.push(part.to_string());
667+ }
668+ }
521669
522670 // Collect body lines (all lines indented deeper than @fn)
523671 let mut body_lines = Vec::new();
@@ -526,7 +674,7 @@ impl Parser {
526674 self.pos += 1;
527675 }
528676
529− ctx.functions.insert(name, FnDef { params, body_lines });
677+ ctx.functions.insert(name, FnDef { params, defaults, body_lines });
530678 return Ok(None);
531679 }
532680
@@ -600,6 +748,7 @@ impl Parser {
600748 .find(|a| a.key == *param)
601749 .and_then(|a| a.value.clone())
602750 .or_else(|| args.get(i).and_then(|a| a.value.clone()))
751+ .or_else(|| fn_def.defaults.get(param).cloned())
603752 .unwrap_or_default();
604753 ctx.variables.insert(param.clone(), value);
605754 }
@@ -771,7 +920,8 @@ const KNOWN_ELEMENTS: &[&str] = &[
771920];
772921
773922const KNOWN_DIRECTIVES: &[&str] = &[
774− "page", "let", "define", "fn", "include", "raw", "keyframes", "if", "else", "each",
923+ "page", "let", "define", "fn", "include", "import", "raw", "keyframes",
924+ "if", "else", "each", "meta", "head",
775925];
776926
777927fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
@@ -849,15 +999,22 @@ fn suggest_closest<'a>(input: &str, candidates: &[&'a str]) -> Option<&'a str> {
849999
8501000const KNOWN_ATTRS: &[&str] = &[
8511001 // Layout
852− "spacing", "padding", "padding-x", "padding-y",
1002+ "spacing", "gap", "padding", "padding-x", "padding-y",
8531003 "width", "height", "min-width", "max-width", "min-height", "max-height",
8541004 "center-x", "center-y", "align-left", "align-right", "align-top", "align-bottom",
8551005 // Style
856− "background", "color", "border", "rounded",
857− "bold", "italic", "underline",
1006+ "background", "color", "border", "border-top", "border-bottom", "border-left", "border-right",
1007+ "rounded", "bold", "italic", "underline",
8581008 "size", "font", "transition", "cursor", "opacity",
859− "text-align", "line-height", "overflow", "position", "z-index", "shadow",
1009+ "text-align", "line-height", "letter-spacing", "text-transform", "white-space",
1010+ "overflow", "position", "top", "right", "bottom", "left", "z-index", "shadow",
8601011 "wrap", "gap-x", "gap-y",
1012+ // Display & visibility
1013+ "display", "visibility",
1014+ // Transform & filters
1015+ "transform", "backdrop-filter",
1016+ // Grid
1017+ "grid", "grid-cols", "grid-rows", "col-span", "row-span",
8611018 // Identity
8621019 "id", "class",
8631020 // Animation
@@ -870,13 +1027,25 @@ const KNOWN_ATTRS: &[&str] = &[
8701027 "alt", "role", "tabindex", "title",
8711028];
8721029
873−/// Attributes that expect purely numeric values (px-based).
1030+/// Attributes that expect purely numeric values (px-based) or values with CSS units.
8741031const NUMERIC_ATTRS: &[&str] = &[
875− "spacing", "padding", "padding-x", "padding-y",
1032+ "spacing", "gap", "padding", "padding-x", "padding-y",
8761033 "min-width", "max-width", "min-height", "max-height",
8771034 "rounded", "size", "gap-x", "gap-y",
1035+ "top", "right", "bottom", "left", "letter-spacing",
1036+];
1037+
1038+const CSS_UNIT_SUFFIXES: &[&str] = &[
1039+ "%", "rem", "em", "vh", "vw", "vmin", "vmax", "dvh", "svh", "lvh",
1040+ "ch", "ex", "cm", "mm", "in", "pt", "pc", "fr",
8781041];
8791042
1043+fn has_css_unit(value: &str) -> bool {
1044+ CSS_UNIT_SUFFIXES.iter().any(|u| value.ends_with(u))
1045+ || value.starts_with("var(")
1046+ || value.starts_with("calc(")
1047+}
1048+
8801049/// Attributes that accept numeric OR keyword values.
8811050const NUMERIC_OR_KEYWORD_ATTRS: &[&str] = &["width", "height"];
8821051const SIZE_KEYWORDS: &[&str] = &["fill", "shrink"];
@@ -897,13 +1066,13 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext
8971066
8981067 if let Some(val) = &attr.value {
8991068 if NUMERIC_ATTRS.contains(&base_key) {
900− // All space-separated parts must be numeric (for padding with multiple values)
1069+ // All space-separated parts must be numeric or have a CSS unit
9011070 for part in val.split_whitespace() {
902− if part.parse::<f64>().is_err() {
1071+ if part.parse::<f64>().is_err() && !has_css_unit(part) {
9031072 ctx.diagnostics.push(Diagnostic {
9041073 line: line_num,
9051074 message: format!(
906− "'{}' expects a numeric value, got '{}'",
1075+ "'{}' expects a numeric value (with optional unit), got '{}'",
9071076 attr.key, val
9081077 ),
9091078 severity: Severity::Warning,
@@ -915,7 +1084,8 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext
9151084 } else if NUMERIC_OR_KEYWORD_ATTRS.contains(&base_key) {
9161085 let is_keyword = SIZE_KEYWORDS.contains(&val.as_str());
9171086 let is_numeric = val.parse::<f64>().is_ok();
918− if !is_keyword && !is_numeric {
1087+ let has_unit = has_css_unit(val);
1088+ if !is_keyword && !is_numeric && !has_unit {
9191089 ctx.diagnostics.push(Diagnostic {
9201090 line: line_num,
9211091 message: format!(
tests/snapshots.rs+83
@@ -137,6 +137,41 @@ fn snapshot_accessibility() {
137137 snapshot_test("accessibility");
138138}
139139
140+#[test]
141+fn snapshot_css_units() {
142+ snapshot_test("css_units");
143+}
144+
145+#[test]
146+fn snapshot_positional() {
147+ snapshot_test("positional");
148+}
149+
150+#[test]
151+fn snapshot_else_if() {
152+ snapshot_test("else_if");
153+}
154+
155+#[test]
156+fn snapshot_meta_head() {
157+ snapshot_test("meta_head");
158+}
159+
160+#[test]
161+fn snapshot_extra_css() {
162+ snapshot_test("extra_css");
163+}
164+
165+#[test]
166+fn snapshot_fn_defaults() {
167+ snapshot_test("fn_defaults");
168+}
169+
170+#[test]
171+fn snapshot_each_index() {
172+ snapshot_test("each_index");
173+}
174+
140175// ---------------------------------------------------------------------------
141176// Error case tests
142177// ---------------------------------------------------------------------------
@@ -260,6 +295,54 @@ fn warning_fill_outside_row() {
260295 );
261296}
262297
298+#[test]
299+fn error_else_if_without_if() {
300+ let diags = parse_diagnostics("@else if $x == 1");
301+ assert!(
302+ diags
303+ .iter()
304+ .any(|d| d.message.contains("@else without matching @if")),
305+ "expected @else error, got: {:?}",
306+ diags
307+ );
308+}
309+
310+#[test]
311+fn else_if_chain() {
312+ let output = compile("@let x 2\n@if $x == 1\n @text one\n@else if $x == 2\n @text two\n@else\n @text other");
313+ assert!(output.contains("two"));
314+ assert!(!output.contains("one"));
315+ assert!(!output.contains("other"));
316+}
317+
318+#[test]
319+fn fn_default_used() {
320+ let output = compile("@fn test $x=hello\n @text $x\n@test");
321+ assert!(output.contains("hello"));
322+}
323+
324+#[test]
325+fn fn_default_overridden() {
326+ let output = compile("@fn test $x=hello\n @text $x\n@test [x world]");
327+ assert!(output.contains("world"));
328+ assert!(!output.contains("hello"));
329+}
330+
331+#[test]
332+fn each_with_index() {
333+ let output = compile("@each $item, $i in a,b,c\n @text $i");
334+ assert!(output.contains("0"));
335+ assert!(output.contains("1"));
336+ assert!(output.contains("2"));
337+}
338+
339+#[test]
340+fn css_unit_passthrough() {
341+ let output = compile("@page T\n@el [width 50%, padding 2rem]");
342+ assert!(output.contains("width:50%"));
343+ assert!(output.contains("padding:2rem"));
344+}
345+
263346#[test]
264347fn warning_fill_inside_row_ok() {
265348 let diags = parse_diagnostics("@row\n @el [width fill]");
tests/snapshots/css_units.hlnew file+8
@@ -0,0 +1,8 @@
1+@page CSS Units
2+@column [padding 2rem, spacing 1.5em]
3+ @el [width 50%, height 100vh, max-width 80ch]
4+ @text [size 1.2rem] Hello
5+ @el [padding 10 2rem, rounded 0.5rem]
6+ @text Responsive padding
7+ @el [min-height 50vh, gap 2rem]
8+ @text Viewport units
tests/snapshots/each_index.hlnew file+2
@@ -0,0 +1,2 @@
1+@each $item, $i in apple,banana,cherry
2+ @text $i: $item
tests/snapshots/else_if.hlnew file+8
@@ -0,0 +1,8 @@
1+@let theme dark
2+
3+@if $theme == light
4+ @text Light mode
5+@else if $theme == dark
6+ @text Dark mode
7+@else
8+ @text Unknown
tests/snapshots/extra_css.hlnew file+16
@@ -0,0 +1,16 @@
1+@column [spacing 20]
2+ @el [display none]
3+ Hidden
4+ @el [visibility hidden]
5+ Also hidden
6+ @el [transform rotate(45deg), backdrop-filter blur(10px)]
7+ Styled
8+ @el [letter-spacing 2, text-transform uppercase, white-space nowrap]
9+ Typography
10+ @el [border-bottom 1 #ccc, border-top 2 red]
11+ Borders
12+ @el [grid, grid-cols 3, gap 10]
13+ @el [col-span 2]
14+ Wide
15+ @el
16+ Narrow
tests/snapshots/fn_defaults.hlnew file+6
@@ -0,0 +1,6 @@
1+@fn badge $label $color=blue
2+ @el [padding 4 8, background $color, rounded 4]
3+ @text $label
4+
5+@badge [label Hello]
6+@badge [label Warning, color red]
tests/snapshots/meta_head.hlnew file+9
@@ -0,0 +1,9 @@
1+@page My Site
2+@meta description A portfolio site
3+@meta og:image https://example.com/preview.png
4+
5+@head
6+ <link rel="icon" href="favicon.ico">
7+
8+@column [padding 20]
9+ @text Hello
tests/snapshots/positional.hlnew file+6
@@ -0,0 +1,6 @@
1+@page Positional
2+@el [position relative, width 300, height 300]
3+ @el [position absolute, top 0, right 0, width 40, height 40, background red]
4+ @text X
5+ @el [position absolute, bottom 10, left 10, background blue]
6+ @text Y