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
4e4a559iko
DESIGN.md+72−4
@@ -129,6 +129,34 @@ Variables can be used in the filename:
129
129
130Circular includes are detected and reported as errors. Nested includes are supported.
130Circular includes are detected and reported as errors. Nested includes are supported.
131
131
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+
132### `@define`
160### `@define`
133
161
134Creates a named attribute bundle. Referenced with `$name` inside attribute lists.
162Creates a named attribute bundle. Referenced with `$name` inside attribute lists.
@@ -146,10 +174,10 @@ Attributes listed after `$name` override those in the definition.
146
174
147### `@fn`
175### `@fn`
148
176
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 `=`.
150
178
151```
179```
152−@fn card $title
180+@fn card $title $variant=primary
153 @el [padding 20, background white, rounded 8, border 1 #e5e7eb]
181 @el [padding 20, background white, rounded 8, border 1 #e5e7eb]
154 @text [bold, size 18] $title
182 @text [bold, size 18] $title
155 @children
183 @children
@@ -161,8 +189,13 @@ Call it like any element, passing parameters in `[...]`:
161@card [title Hello World]
189@card [title Hello World]
162 This is the card body.
190 This is the card body.
163 @text [italic] With styled text.
191 @text [italic] With styled text.
192+
193+@card [title Warning, variant danger]
194+ Overridden variant.
164```
195```
165
196
197+Parameters with defaults can be omitted in calls — the default value is used.
198+
166### `@children`
199### `@children`
167
200
168A slot inside a function body that expands to the caller's indented children.
201A 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
261"""
294"""
262```
295```
263
296
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+
264## Attributes reference
309## Attributes reference
265
310
266### Layout (set on parent)
311### Layout (set on parent)
@@ -268,6 +313,7 @@ Triple-quoted block pasted verbatim into output. Use for arbitrary HTML, CSS, or
268| Attribute | Effect |
313| Attribute | Effect |
269|------------------------|----------------------------------|
314|------------------------|----------------------------------|
270| `spacing N` | Gap between children |
315| `spacing N` | Gap between children |
316+| `gap N` | Alias for `spacing` |
271| `gap-x N` | Horizontal gap (column-gap) |
317| `gap-x N` | Horizontal gap (column-gap) |
272| `gap-y N` | Vertical gap (row-gap) |
318| `gap-y N` | Vertical gap (row-gap) |
273| `padding N` | Uniform padding |
319| `padding N` | Uniform padding |
@@ -310,6 +356,10 @@ Triple-quoted block pasted verbatim into output. Use for arbitrary HTML, CSS, or
310| `background COLOR` | Background color |
356| `background COLOR` | Background color |
311| `color COLOR` | Text color |
357| `color COLOR` | Text color |
312| `border N COLOR` | Border width and color |
358| `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 |
313| `rounded N` | Border radius |
363| `rounded N` | Border radius |
314| `shadow VALUE` | Box shadow (CSS value) |
364| `shadow VALUE` | Box shadow (CSS value) |
315| `bold` | Bold text |
365| `bold` | Bold text |
@@ -319,12 +369,23 @@ Triple-quoted block pasted verbatim into output. Use for arbitrary HTML, CSS, or
319| `font NAME` | Font family |
369| `font NAME` | Font family |
320| `text-align VALUE` | Text alignment (left/center/right/justify) |
370| `text-align VALUE` | Text alignment (left/center/right/justify) |
321| `line-height VALUE` | Line height (unitless or px) |
371| `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) |
322| `transition VALUE` | CSS transition |
375| `transition VALUE` | CSS transition |
323| `cursor VALUE` | Cursor style |
376| `cursor VALUE` | Cursor style |
324| `opacity VALUE` | Opacity (0–1) |
377| `opacity VALUE` | Opacity (0–1) |
325| `overflow VALUE` | Overflow (hidden/scroll/auto/visible) |
378| `overflow VALUE` | Overflow (hidden/scroll/auto/visible) |
326| `position VALUE` | Position (relative/absolute/fixed/sticky) |
379| `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 |
327| `z-index N` | Stack order |
384| `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)) |
328
389
329### Pseudo-states
390### Pseudo-states
330
391
@@ -337,11 +398,16 @@ Prefix any style attribute with `hover:`, `active:`, or `focus:` to apply it on
337
398
338All style attributes support state prefixes: `hover:color`, `active:rounded`, `focus:border`, etc.
399All style attributes support state prefixes: `hover:color`, `active:rounded`, `focus:border`, etc.
339
400
340−### Flow
401+### Flow & Grid
341
402
342| Attribute | Effect |
403| Attribute | Effect |
343|------------------------|----------------------------------|
404|------------------------|----------------------------------|
344| `wrap` | Enable flex-wrap (on `@row`) |
405| `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 |
345
411
346### Identity
412### Identity
347
413
@@ -354,11 +420,13 @@ All style attributes support state prefixes: `hover:color`, `active:rounded`, `f
354
420
355```
421```
356htmlang page.hl # compile page.hl → page.html
422htmlang page.hl # compile page.hl → page.html
423+htmlang site/ # compile all .hl files in directory
357htmlang --watch page.hl # compile and watch for changes
424htmlang --watch page.hl # compile and watch for changes
358htmlang -w page.hl # short form
425htmlang -w page.hl # short form
426+htmlang --serve site/ # serve a multi-page site with hot reload
359```
427```
360
428
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.
362
430
363## Compilation target
431## Compilation target
364
432
src/ast.rs+2
@@ -4,6 +4,8 @@ use std::collections::HashMap;
4#[allow(dead_code)]
4#[allow(dead_code)]
5pub struct Document {
5pub struct Document {
6 pub page_title: Option<String>,
6 pub page_title: Option<String>,
7+ pub meta_tags: Vec<(String, String)>,
8+ pub head_blocks: Vec<String>,
7 pub variables: HashMap<String, String>,
9 pub variables: HashMap<String, String>,
8 pub defines: HashMap<String, Vec<Attribute>>,
10 pub defines: HashMap<String, Vec<Attribute>>,
9 pub keyframes: Vec<(String, String)>,
11 pub keyframes: Vec<(String, String)>,
src/bin/htmlang-lsp.rs+90−33
@@ -322,11 +322,16 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
322 ("@page", "Set HTML page title", "@page "),
322 ("@page", "Set HTML page title", "@page "),
323 ("@let", "Define a variable", "@let "),
323 ("@let", "Define a variable", "@let "),
324 ("@define", "Define an attribute bundle", "@define "),
324 ("@define", "Define an attribute bundle", "@define "),
325− ("@fn", "Define a reusable function", "@fn "),
325+ ("@fn", "Define a reusable function (supports $param=default)", "@fn "),
326 ("@keyframes", "Define a CSS animation", "@keyframes "),
326 ("@keyframes", "Define a CSS animation", "@keyframes "),
327 ("@if", "Conditional rendering", "@if "),
327 ("@if", "Conditional rendering", "@if "),
328+ ("@else if", "Else-if branch", "@else if "),
329+ ("@else", "Else branch", "@else"),
328− ("@each", "Loop over comma-separated values", "@each "),
330+ ("@each", "Loop over values (@each $var, $i in list)", "@each "),
329− ("@include", "Include another .hl file", "@include "),
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"),
330 ]
335 ]
331 .iter()
336 .iter()
332 .map(|(name, detail, insert)| {
337 .map(|(name, detail, insert)| {
@@ -338,17 +343,18 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
338fn attr_completions(range: Range) -> Vec<CompletionItem> {
343fn attr_completions(range: Range) -> Vec<CompletionItem> {
339 [
344 [
340 // Layout
345 // Layout
341− ("spacing", "Gap between children (px)", true),
346+ ("spacing", "Gap between children (supports CSS units)", true),
347+ ("gap", "Gap between children (alias for spacing)", true),
342− ("padding", "Inner padding (1/2/3/4 values, px)", true),
348+ ("padding", "Inner padding (1/2/3/4 values, supports CSS units)", true),
343− ("padding-x", "Horizontal padding (px)", true),
349+ ("padding-x", "Horizontal padding", true),
344− ("padding-y", "Vertical padding (px)", true),
350+ ("padding-y", "Vertical padding", true),
345 // Sizing
351 // Sizing
346− ("width", "Width (px | fill | shrink)", true),
352+ ("width", "Width (px/% | fill | shrink)", true),
347− ("height", "Height (px | fill | shrink)", true),
353+ ("height", "Height (px/% | fill | shrink)", true),
348− ("min-width", "Minimum width (px)", true),
354+ ("min-width", "Minimum width", true),
349− ("max-width", "Maximum width (px)", true),
355+ ("max-width", "Maximum width", true),
350− ("min-height", "Minimum height (px)", true),
356+ ("min-height", "Minimum height", true),
351− ("max-height", "Maximum height (px)", true),
357+ ("max-height", "Maximum height", true),
352 // Alignment
358 // Alignment
353 ("center-x", "Center horizontally", false),
359 ("center-x", "Center horizontally", false),
354 ("center-y", "Center vertically", false),
360 ("center-y", "Center vertically", false),
@@ -360,11 +366,15 @@ fn attr_completions(range: Range) -> Vec<CompletionItem> {
360 ("background", "Background color/value", true),
366 ("background", "Background color/value", true),
361 ("color", "Text color", true),
367 ("color", "Text color", true),
362 ("border", "Border (width [color])", true),
368 ("border", "Border (width [color])", 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),
363− ("rounded", "Border radius (px)", true),
373+ ("rounded", "Border radius", true),
364 ("bold", "Bold text", false),
374 ("bold", "Bold text", false),
365 ("italic", "Italic text", false),
375 ("italic", "Italic text", false),
366 ("underline", "Underlined text", false),
376 ("underline", "Underlined text", false),
367− ("size", "Font size (px)", true),
377+ ("size", "Font size", true),
368 ("font", "Font family", true),
378 ("font", "Font family", true),
369 ("transition", "CSS transition", true),
379 ("transition", "CSS transition", true),
370 ("cursor", "CSS cursor type", true),
380 ("cursor", "CSS cursor type", true),
@@ -372,16 +382,35 @@ fn attr_completions(range: Range) -> Vec<CompletionItem> {
372 // Typography
382 // Typography
373 ("text-align", "Text alignment (left/center/right/justify)", true),
383 ("text-align", "Text alignment (left/center/right/justify)", true),
374 ("line-height", "Line height (unitless or px)", true),
384 ("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),
375 // Overflow & positioning
388 // Overflow & positioning
376 ("overflow", "Overflow behavior (hidden/scroll/auto)", true),
389 ("overflow", "Overflow behavior (hidden/scroll/auto)", true),
377 ("position", "Position type (relative/absolute/fixed/sticky)", true),
390 ("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),
378 ("z-index", "Stack order (integer)", true),
395 ("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),
379 // Effects
402 // Effects
380 ("shadow", "Box shadow (CSS value)", true),
403 ("shadow", "Box shadow (CSS value)", true),
381 // Flow
404 // Flow
382 ("wrap", "Enable flex-wrap", false),
405 ("wrap", "Enable flex-wrap", false),
383− ("gap-x", "Horizontal gap between children (px)", true),
406+ ("gap-x", "Horizontal gap between children", true),
384− ("gap-y", "Vertical gap between children (px)", 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),
385 // Identity
414 // Identity
386 ("id", "HTML id attribute", true),
415 ("id", "HTML id attribute", true),
387 ("class", "HTML class attribute", true),
416 ("class", "HTML class attribute", true),
@@ -437,14 +466,19 @@ fn state_attr_completions(prefix: &str, range: Range) -> Vec<CompletionItem> {
437 ("background", "Background color/value", true),
466 ("background", "Background color/value", true),
438 ("color", "Text color", true),
467 ("color", "Text color", true),
439 ("border", "Border (width [color])", true),
468 ("border", "Border (width [color])", true),
469+ ("border-top", "Top border (width [color])", true),
470+ ("border-bottom", "Bottom border (width [color])", true),
440− ("rounded", "Border radius (px)", true),
471+ ("rounded", "Border radius", true),
441 ("bold", "Bold text", false),
472 ("bold", "Bold text", false),
442 ("italic", "Italic text", false),
473 ("italic", "Italic text", false),
443 ("underline", "Underlined text", false),
474 ("underline", "Underlined text", false),
444− ("size", "Font size (px)", true),
475+ ("size", "Font size", true),
445 ("opacity", "Opacity (0-1)", true),
476 ("opacity", "Opacity (0-1)", true),
446 ("cursor", "CSS cursor type", true),
477 ("cursor", "CSS cursor type", true),
447 ("shadow", "Box shadow (CSS value)", true),
478 ("shadow", "Box shadow (CSS value)", true),
479+ ("transform", "CSS transform", true),
480+ ("display", "Display mode", true),
481+ ("visibility", "Visibility", true),
448 ]
482 ]
449 .iter()
483 .iter()
450 .map(|(name, detail, takes_value)| {
484 .map(|(name, detail, takes_value)| {
@@ -670,20 +704,23 @@ fn hover_builtin(word: &str) -> Option<String> {
670 "@textarea" => "**@textarea** \u{2014} Multi-line text input\n\nRenders as `<textarea>`.\n\nUsage: `@textarea [name bio, rows 4] Default text`",
704 "@textarea" => "**@textarea** \u{2014} Multi-line text input\n\nRenders as `<textarea>`.\n\nUsage: `@textarea [name bio, rows 4] Default text`",
671 "@option" | "@opt" => "**@option** \u{2014} Select option\n\nRenders as `<option>`.\n\nUsage: `@option [value red] Red`",
705 "@option" | "@opt" => "**@option** \u{2014} Select option\n\nRenders as `<option>`.\n\nUsage: `@option [value red] Red`",
672 "@label" => "**@label** \u{2014} Form label\n\nRenders as `<label>`.\n\nUsage: `@label [for email] Email Address`",
706 "@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```",
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```",
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```",
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```",
675− "@include" => "**@include** \u{2014} Include file\n\nIncludes another .hl file.\n\nUsage: `@include header.hl`",
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```",
676 // Attributes
713 // Attributes
677− "spacing" => "**spacing** `<value>`\n\nGap between children in pixels. Maps to CSS `gap`.",
714+ "spacing" | "gap" => "**spacing** `<value>`\n\nGap between children. Supports CSS units (px, rem, em, %).\nMaps 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.",
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.",
679− "padding-x" => "**padding-x** `<value>`\n\nHorizontal padding (left + right) in pixels.",
716+ "padding-x" => "**padding-x** `<value>`\n\nHorizontal padding (left + right). Supports CSS units.",
680− "padding-y" => "**padding-y** `<value>`\n\nVertical padding (top + bottom) in pixels.",
717+ "padding-y" => "**padding-y** `<value>`\n\nVertical padding (top + bottom). Supports CSS units.",
681− "width" => "**width** `<px>` | `fill` | `shrink`\n\n- Number: fixed width in pixels\n- `fill`: expand to fill parent\n- `shrink`: prevent flex shrinking",
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",
682− "height" => "**height** `<px>` | `fill` | `shrink`\n\n- Number: fixed height in pixels\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",
683− "min-width" => "**min-width** `<value>` \u{2014} Minimum width in pixels.",
720+ "min-width" => "**min-width** `<value>` \u{2014} Minimum width. Supports CSS units.",
684− "max-width" => "**max-width** `<value>` \u{2014} Maximum width in pixels.",
721+ "max-width" => "**max-width** `<value>` \u{2014} Maximum width. Supports CSS units.",
685− "min-height" => "**min-height** `<value>` \u{2014} Minimum height in pixels.",
722+ "min-height" => "**min-height** `<value>` \u{2014} Minimum height. Supports CSS units.",
686− "max-height" => "**max-height** `<value>` \u{2014} Maximum height in pixels.",
723+ "max-height" => "**max-height** `<value>` \u{2014} Maximum height. Supports CSS units.",
687 "center-x" => "**center-x**\n\nCenter horizontally.\n\nIn column parent: `align-self: center`\nOtherwise: auto margins.",
724 "center-x" => "**center-x**\n\nCenter horizontally.\n\nIn column parent: `align-self: center`\nOtherwise: auto margins.",
688 "center-y" => "**center-y**\n\nCenter vertically.\n\nIn row parent: `align-self: center`\nOtherwise: auto margins.",
725 "center-y" => "**center-y**\n\nCenter vertically.\n\nIn row parent: `align-self: center`\nOtherwise: auto margins.",
689 "align-left" => "**align-left** \u{2014} Align to the left edge.",
726 "align-left" => "**align-left** \u{2014} Align to the left edge.",
@@ -693,7 +730,11 @@ fn hover_builtin(word: &str) -> Option<String> {
693 "background" => "**background** `<color>` \u{2014} Background color or CSS background value.",
730 "background" => "**background** `<color>` \u{2014} Background color or CSS background value.",
694 "color" => "**color** `<color>` \u{2014} Text color.",
731 "color" => "**color** `<color>` \u{2014} Text color.",
695 "border" => "**border** `<width> [color]`\n\nBorder. Width in pixels, color defaults to `currentColor`.",
732 "border" => "**border** `<width> [color]`\n\nBorder. Width in pixels, color defaults to `currentColor`.",
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.",
696− "rounded" => "**rounded** `<value>` \u{2014} Border radius in pixels.",
737+ "rounded" => "**rounded** `<value>` \u{2014} Border radius. Supports CSS units.",
697 "bold" => "**bold** \u{2014} Bold text (`font-weight: bold`).",
738 "bold" => "**bold** \u{2014} Bold text (`font-weight: bold`).",
698 "italic" => "**italic** \u{2014} Italic text (`font-style: italic`).",
739 "italic" => "**italic** \u{2014} Italic text (`font-style: italic`).",
699 "underline" => "**underline** \u{2014} Underlined text.",
740 "underline" => "**underline** \u{2014} Underlined text.",
@@ -706,7 +747,23 @@ fn hover_builtin(word: &str) -> Option<String> {
706 "line-height" => "**line-height** `<value>` \u{2014} Line height. Unitless (e.g., `1.5`) or pixels.",
747 "line-height" => "**line-height** `<value>` \u{2014} Line height. Unitless (e.g., `1.5`) or pixels.",
707 "overflow" => "**overflow** `<value>` \u{2014} Overflow behavior (`hidden`, `scroll`, `auto`, `visible`).",
748 "overflow" => "**overflow** `<value>` \u{2014} Overflow behavior (`hidden`, `scroll`, `auto`, `visible`).",
708 "position" => "**position** `<value>` \u{2014} Position type (`relative`, `absolute`, `fixed`, `sticky`).",
749 "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.",
709 "z-index" => "**z-index** `<value>` \u{2014} Stack order (integer).",
754 "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.",
710 "shadow" => "**shadow** `<value>` \u{2014} Box shadow. Raw CSS value (e.g., `0 2px 4px rgba(0,0,0,0.1)`).",
767 "shadow" => "**shadow** `<value>` \u{2014} Box shadow. Raw CSS value (e.g., `0 2px 4px rgba(0,0,0,0.1)`).",
711 "gap-x" => "**gap-x** `<value>` \u{2014} Horizontal gap between children in pixels. Maps to `column-gap`.",
768 "gap-x" => "**gap-x** `<value>` \u{2014} Horizontal gap between children in pixels. Maps to `column-gap`.",
712 "gap-y" => "**gap-y** `<value>` \u{2014} Vertical gap between children in pixels. Maps to `row-gap`.",
769 "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 {
203 }
203 }
204 }
204 }
205
205
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+
206 match &doc.page_title {
243 match &doc.page_title {
207 Some(title) => {
244 Some(title) => {
208 if dev {
245 if dev {
@@ -214,6 +251,7 @@ fn generate_with_options(doc: &Document, dev: bool) -> String {
214<meta charset=\"utf-8\">
251<meta charset=\"utf-8\">
215<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
252<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
216<title>{title}</title>
253<title>{title}</title>
254+{meta_html}{head_html}\
217<style>
255<style>
218*, *::before, *::after {{ box-sizing: border-box; }}
256*, *::before, *::after {{ box-sizing: border-box; }}
219body {{ margin: 0; font-family: system-ui, -apple-system, sans-serif; }}
257body {{ margin: 0; font-family: system-ui, -apple-system, sans-serif; }}
@@ -227,13 +265,17 @@ img {{ display: block; }}
227</html>
265</html>
228",
266",
229 title = html_escape(title),
267 title = html_escape(title),
268+ meta_html = meta_html,
269+ head_html = head_html,
230 element_css = element_css,
270 element_css = element_css,
231 body = body,
271 body = body,
232 )
272 )
233 } else {
273 } else {
234 format!(
274 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>",
236 title = html_escape(title),
276 title = html_escape(title),
277+ meta_html = meta_html,
278+ head_html = head_html,
237 element_css = element_css,
279 element_css = element_css,
238 body = body,
280 body = body,
239 )
281 )
@@ -595,51 +637,26 @@ fn attrs_to_css(
595
637
596 match effective_key {
638 match effective_key {
597 // Layout
639 // Layout
598− "spacing" => {
640+ "spacing" | "gap" => {
599 if let Some(v) = val {
641 if let Some(v) = val {
600− push_css(&mut css, "gap", &format!("{}px", v));
642+ push_css(&mut css, "gap", &css_px(v));
601 }
643 }
602 }
644 }
603 "padding" => {
645 "padding" => {
604 if let Some(v) = val {
646 if let Some(v) = val {
605− let parts: Vec<&str> = v.split_whitespace().collect();
647+ push_css(&mut css, "padding", &css_px_multi(v));
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− }
631 }
648 }
632 }
649 }
633 "padding-x" => {
650 "padding-x" => {
634 if let Some(v) = val {
651 if let Some(v) = val {
635− push_css(&mut css, "padding-left", &format!("{}px", v));
652+ push_css(&mut css, "padding-left", &css_px(v));
636− push_css(&mut css, "padding-right", &format!("{}px", v));
653+ push_css(&mut css, "padding-right", &css_px(v));
637 }
654 }
638 }
655 }
639 "padding-y" => {
656 "padding-y" => {
640 if let Some(v) = val {
657 if let Some(v) = val {
641− push_css(&mut css, "padding-top", &format!("{}px", v));
658+ push_css(&mut css, "padding-top", &css_px(v));
642− push_css(&mut css, "padding-bottom", &format!("{}px", v));
659+ push_css(&mut css, "padding-bottom", &css_px(v));
643 }
660 }
644 }
661 }
645
662
@@ -655,7 +672,7 @@ fn attrs_to_css(
655 _ => push_css(&mut css, "width", "100%"),
672 _ => push_css(&mut css, "width", "100%"),
656 },
673 },
657 "shrink" => push_css(&mut css, "flex-shrink", "0"),
674 "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)),
659 }
676 }
660 }
677 }
661 }
678 }
@@ -670,28 +687,28 @@ fn attrs_to_css(
670 _ => push_css(&mut css, "height", "100%"),
687 _ => push_css(&mut css, "height", "100%"),
671 },
688 },
672 "shrink" => push_css(&mut css, "flex-shrink", "0"),
689 "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)),
674 }
691 }
675 }
692 }
676 }
693 }
677 "min-width" => {
694 "min-width" => {
678 if let Some(v) = val {
695 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));
680 }
697 }
681 }
698 }
682 "max-width" => {
699 "max-width" => {
683 if let Some(v) = val {
700 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));
685 }
702 }
686 }
703 }
687 "min-height" => {
704 "min-height" => {
688 if let Some(v) = val {
705 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));
690 }
707 }
691 }
708 }
692 "max-height" => {
709 "max-height" => {
693 if let Some(v) = val {
710 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));
695 }
712 }
696 }
713 }
697
714
@@ -751,20 +768,38 @@ fn attrs_to_css(
751 push_css(
768 push_css(
752 &mut css,
769 &mut css,
753 "border",
770 "border",
754− &format!("{}px solid {}", parts[0], parts[1]),
771+ &format!("{} solid {}", css_px(parts[0]), parts[1]),
755 );
772 );
756 } else {
773 } else {
757 push_css(
774 push_css(
758 &mut css,
775 &mut css,
759 "border",
776 "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])),
761 );
796 );
762 }
797 }
763 }
798 }
764 }
799 }
765 "rounded" => {
800 "rounded" => {
766 if let Some(v) = val {
801 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));
768 }
803 }
769 }
804 }
770 "bold" => push_css(&mut css, "font-weight", "bold"),
805 "bold" => push_css(&mut css, "font-weight", "bold"),
@@ -772,7 +807,7 @@ fn attrs_to_css(
772 "underline" => push_css(&mut css, "text-decoration", "underline"),
807 "underline" => push_css(&mut css, "text-decoration", "underline"),
773 "size" => {
808 "size" => {
774 if let Some(v) = val {
809 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));
776 }
811 }
777 }
812 }
778 "font" => {
813 "font" => {
@@ -807,6 +842,21 @@ fn attrs_to_css(
807 push_css(&mut css, "line-height", v);
842 push_css(&mut css, "line-height", v);
808 }
843 }
809 }
844 }
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+ }
810
860
811 // Overflow & positioning
861 // Overflow & positioning
812 "overflow" => {
862 "overflow" => {
@@ -819,12 +869,56 @@ fn attrs_to_css(
819 push_css(&mut css, "position", v);
869 push_css(&mut css, "position", v);
820 }
870 }
821 }
871 }
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+ }
822 "z-index" => {
892 "z-index" => {
823 if let Some(v) = val {
893 if let Some(v) = val {
824 push_css(&mut css, "z-index", v);
894 push_css(&mut css, "z-index", v);
825 }
895 }
826 }
896 }
827
897
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+
828 // Effects
922 // Effects
829 "shadow" => {
923 "shadow" => {
830 if let Some(v) = val {
924 if let Some(v) = val {
@@ -836,12 +930,45 @@ fn attrs_to_css(
836 "wrap" => push_css(&mut css, "flex-wrap", "wrap"),
930 "wrap" => push_css(&mut css, "flex-wrap", "wrap"),
837 "gap-x" => {
931 "gap-x" => {
838 if let Some(v) = val {
932 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));
840 }
934 }
841 }
935 }
842 "gap-y" => {
936 "gap-y" => {
843 if let Some(v) = val {
937 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));
845 }
972 }
846 }
973 }
847
974
@@ -873,6 +1000,37 @@ fn push_css(css: &mut String, prop: &str, value: &str) {
873 css.push(';');
1000 css.push(';');
874}
1001}
875
1002
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+
876fn extract_id_class(attrs: &[Attribute]) -> (Option<String>, Option<String>) {
1034fn extract_id_class(attrs: &[Attribute]) -> (Option<String>, Option<String>) {
877 let mut id = None;
1035 let mut id = None;
878 let mut class = None;
1036 let mut class = None;
src/main.rs+185−27
@@ -5,7 +5,7 @@ use std::hash::{Hash, Hasher};
5use std::path::{Path, PathBuf};
5use std::path::{Path, PathBuf};
6use std::process;
6use std::process;
7
7
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>) {
9 let input = match fs::read_to_string(input_path) {
9 let input = match fs::read_to_string(input_path) {
10 Ok(s) => s,
10 Ok(s) => s,
11 Err(e) => {
11 Err(e) => {
@@ -33,13 +33,19 @@ fn compile(input_path: &str, dev: bool) -> (bool, Vec<PathBuf>) {
33 .iter()
33 .iter()
34 .any(|d| d.severity == htmlang::parser::Severity::Error);
34 .any(|d| d.severity == htmlang::parser::Severity::Error);
35
35
36+ let out_path = Path::new(input_path).with_extension("html");
37+
36− if !has_errors {
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 {
37 let html = if dev {
44 let html = if dev {
38 htmlang::codegen::generate_dev(&result.document)
45 htmlang::codegen::generate_dev(&result.document)
39 } else {
46 } else {
40 htmlang::codegen::generate(&result.document)
47 htmlang::codegen::generate(&result.document)
41 };
48 };
42− let out_path = Path::new(input_path).with_extension("html");
43 match fs::write(&out_path, &html) {
49 match fs::write(&out_path, &html) {
44 Ok(()) => eprintln!("wrote {}", out_path.display()),
50 Ok(()) => eprintln!("wrote {}", out_path.display()),
45 Err(e) => eprintln!("error: {}: {}", out_path.display(), e),
51 Err(e) => eprintln!("error: {}: {}", out_path.display(), e),
@@ -49,12 +55,48 @@ fn compile(input_path: &str, dev: bool) -> (bool, Vec<PathBuf>) {
49 (has_errors, result.included_files)
55 (has_errors, result.included_files)
50}
56}
51
57
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+
52fn print_help() {
94fn print_help() {
53 eprintln!(
95 eprintln!(
54 "\
96 "\
55htmlang {} - a minimalist layout language that compiles to static HTML
97htmlang {} - a minimalist layout language that compiles to static HTML
56
98
57−Usage: htmlang [options] <file.hl>
99+Usage: htmlang [options] <file.hl | directory>
58
100
59Options:
101Options:
60 -w, --watch Watch for changes and recompile
102 -w, --watch Watch for changes and recompile
@@ -66,8 +108,10 @@ Options:
66
108
67Examples:
109Examples:
68 htmlang page.hl Compile page.hl to page.html
110 htmlang page.hl Compile page.hl to page.html
111+ htmlang site/ Compile all .hl files in directory
69 htmlang -w page.hl Recompile on file changes
112 htmlang -w page.hl Recompile on file changes
70 htmlang -s page.hl Start dev server with hot reload
113 htmlang -s page.hl Start dev server with hot reload
114+ htmlang -s site/ Serve a multi-page site
71 htmlang -s -p 8080 page.hl",
115 htmlang -s -p 8080 page.hl",
72 env!("CARGO_PKG_VERSION")
116 env!("CARGO_PKG_VERSION")
73 );
117 );
@@ -130,7 +174,55 @@ fn main() {
130 }
174 }
131 };
175 };
132
176
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 ---
133− let (has_errors, included_files) = compile(&input_path, dev);
225+ let (has_errors, included_files) = compile(&input_path, dev, serve);
134
226
135 if !watch {
227 if !watch {
136 if has_errors {
228 if has_errors {
@@ -153,7 +245,39 @@ fn main() {
153 None
245 None
154 };
246 };
155
247
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+) {
157 use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
281 use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
158 use std::sync::mpsc;
282 use std::sync::mpsc;
159
283
@@ -168,18 +292,22 @@ fn main() {
168 )
292 )
169 .expect("failed to create file watcher");
293 .expect("failed to create file watcher");
170
294
171− let input_canonical =
295+ // Watch all source files
296+ for file in source_files {
172− fs::canonicalize(&input_path).unwrap_or_else(|_| PathBuf::from(&input_path));
297+ let canonical = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
173− watcher
298+ watcher
174− .watch(&input_canonical, RecursiveMode::NonRecursive)
299+ .watch(&canonical, RecursiveMode::NonRecursive)
175− .expect("failed to watch file");
300+ .unwrap_or_else(|_| eprintln!("warning: could not watch {}", file.display()));
176−
301+ }
177− for inc in &included_files {
302+ for inc in included_files {
178 let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
303 let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
179 }
304 }
180
305
306+ // Also watch the directory itself for new files
307+ let _ = watcher.watch(watch_dir, RecursiveMode::NonRecursive);
308+
181 if serve {
309 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");
183 } else {
311 } else {
184 eprintln!("watching for changes...");
312 eprintln!("watching for changes...");
185 }
313 }
@@ -195,10 +323,13 @@ fn main() {
195 }
323 }
196
324
197 // Seed initial hashes
325 // Seed initial hashes
326+ for file in source_files {
327+ let canonical = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
198− if let Some(h) = hash_file(&input_canonical) {
328+ if let Some(h) = hash_file(&canonical) {
199− content_hashes.insert(input_canonical.clone(), h);
329+ content_hashes.insert(canonical, h);
330+ }
200 }
331 }
201− for inc in &included_files {
332+ for inc in included_files {
202 if let Some(h) = hash_file(inc) {
333 if let Some(h) = hash_file(inc) {
203 content_hashes.insert(inc.clone(), h);
334 content_hashes.insert(inc.clone(), h);
204 }
335 }
@@ -224,26 +355,53 @@ fn main() {
224 false
355 false
225 };
356 };
226
357
227− if check_path(&input_canonical, &mut content_hashes) {
358+ for file in source_files {
228− any_changed = true;
359+ let canonical = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
229− }
230− for inc in &included_files {
231− if check_path(inc, &mut content_hashes) {
360+ if check_path(&canonical, &mut content_hashes) {
232 any_changed = true;
361 any_changed = true;
233 }
362 }
234 }
363 }
235
364
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+
236 if !any_changed {
377 if !any_changed {
237 continue;
378 continue;
238 }
379 }
239
380
240 eprintln!("\nrecompiling...");
381 eprintln!("\nrecompiling...");
241− let (_, new_includes) = compile(&input_path, dev);
242
382
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);
243− for inc in &new_includes {
387+ for inc in &new_includes {
244− let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
388+ let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
245− if let Some(h) = hash_file(inc) {
389+ if let Some(h) = hash_file(inc) {
246− content_hashes.insert(inc.clone(), h);
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+ }
247 }
405 }
248 }
406 }
249
407
src/parser.rs+199−29
@@ -60,11 +60,14 @@ struct Line {
60#[derive(Clone)]
60#[derive(Clone)]
61struct FnDef {
61struct FnDef {
62 params: Vec<String>,
62 params: Vec<String>,
63+ defaults: HashMap<String, String>,
63 body_lines: Vec<Line>,
64 body_lines: Vec<Line>,
64}
65}
65
66
66struct ParseContext {
67struct ParseContext {
67 page_title: Option<String>,
68 page_title: Option<String>,
69+ meta_tags: Vec<(String, String)>,
70+ head_blocks: Vec<String>,
68 variables: HashMap<String, String>,
71 variables: HashMap<String, String>,
69 defines: HashMap<String, Vec<Attribute>>,
72 defines: HashMap<String, Vec<Attribute>>,
70 functions: HashMap<String, FnDef>,
73 functions: HashMap<String, FnDef>,
@@ -92,6 +95,8 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
92 let mut parser = Parser { lines, pos: 0 };
95 let mut parser = Parser { lines, pos: 0 };
93 let mut ctx = ParseContext {
96 let mut ctx = ParseContext {
94 page_title: None,
97 page_title: None,
98+ meta_tags: Vec::new(),
99+ head_blocks: Vec::new(),
95 variables: HashMap::new(),
100 variables: HashMap::new(),
96 defines: HashMap::new(),
101 defines: HashMap::new(),
97 functions: HashMap::new(),
102 functions: HashMap::new(),
@@ -109,6 +114,8 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
109 ParseResult {
114 ParseResult {
110 document: Document {
115 document: Document {
111 page_title: ctx.page_title,
116 page_title: ctx.page_title,
117+ meta_tags: ctx.meta_tags,
118+ head_blocks: ctx.head_blocks,
112 variables: ctx.variables,
119 variables: ctx.variables,
113 defines: ctx.defines,
120 defines: ctx.defines,
114 keyframes: ctx.keyframes,
121 keyframes: ctx.keyframes,
@@ -296,6 +303,38 @@ impl Parser {
296 return Ok(None);
303 return Ok(None);
297 }
304 }
298
305
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+
299 if let Some(rest) = content.strip_prefix("@define ") {
338 if let Some(rest) = content.strip_prefix("@define ") {
300 let rest = rest.trim();
339 let rest = rest.trim();
301 if let Some(bracket_start) = rest.find('[') {
340 if let Some(bracket_start) = rest.find('[') {
@@ -363,6 +402,63 @@ impl Parser {
363 return Ok(Some(nodes));
402 return Ok(Some(nodes));
364 }
403 }
365
404
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+
366 // --- @if / @else ---
462 // --- @if / @else ---
367
463
368 if let Some(rest) = content.strip_prefix("@if ") {
464 if let Some(rest) = content.strip_prefix("@if ") {
@@ -377,23 +473,55 @@ impl Parser {
377 self.pos += 1;
473 self.pos += 1;
378 }
474 }
379
475
476+ // Build branches: [(condition_result, body_lines), ...]
477+ let mut branches: Vec<(bool, Vec<Line>)> = vec![(result, then_lines)];
478+
380− // Check for @else at same indent
479+ // Check for @else if / @else chains at same indent
381− let mut else_lines = Vec::new();
480+ loop {
382− if self.pos < self.lines.len() && self.lines[self.pos].indent == current_indent {
481+ if self.pos >= self.lines.len() || self.lines[self.pos].indent != current_indent {
482+ break;
483+ }
383 if let LineContent::Normal(ref s) = self.lines[self.pos].content {
484 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" {
385 self.pos += 1; // consume @else
499 self.pos += 1; // consume @else
500+ let mut body = Vec::new();
386 while self.pos < self.lines.len()
501 while self.pos < self.lines.len()
387 && self.lines[self.pos].indent > current_indent
502 && self.lines[self.pos].indent > current_indent
388 {
503 {
389− else_lines.push(self.lines[self.pos].clone());
504+ body.push(self.lines[self.pos].clone());
390 self.pos += 1;
505 self.pos += 1;
391 }
506 }
507+ // @else is always true (fallback)
508+ branches.push((true, body));
509+ break;
510+ } else {
511+ break;
392 }
512 }
513+ } else {
514+ break;
393 }
515 }
394 }
516 }
395
517
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+
397 if body_lines.is_empty() {
525 if body_lines.is_empty() {
398 return Ok(None);
526 return Ok(None);
399 }
527 }
@@ -416,7 +544,7 @@ impl Parser {
416 return Ok(Some(nodes));
544 return Ok(Some(nodes));
417 }
545 }
418
546
419− if content.trim() == "@else" {
547+ if content.trim() == "@else" || content.trim().starts_with("@else if ") {
420 return Err(ParseError {
548 return Err(ParseError {
421 line: line_num,
549 line: line_num,
422 message: "@else without matching @if".to_string(),
550 message: "@else without matching @if".to_string(),
@@ -427,15 +555,25 @@ impl Parser {
427
555
428 if let Some(rest) = content.strip_prefix("@each ") {
556 if let Some(rest) = content.strip_prefix("@each ") {
429 let rest = rest.trim();
557 let rest = rest.trim();
430− let parts: Vec<&str> = rest.splitn(3, ' ').collect();
558+ // Support: @each $var in list OR @each $var, $index in list
431− if parts.len() < 3 || parts[1] != "in" {
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 {
432 return Err(ParseError {
570 return Err(ParseError {
433 line: line_num,
571 line: line_num,
434 message: "@each requires: @each $var in list".to_string(),
572 message: "@each requires: @each $var in list".to_string(),
435 });
573 });
436− }
574+ };
437− let var_name = parts[0].strip_prefix('$').unwrap_or(parts[0]).to_string();
575+
438− let list_str = substitute_vars(parts[2], &ctx.variables);
576+ let list_str = substitute_vars(&list_str, &ctx.variables);
439 let items: Vec<String> = list_str
577 let items: Vec<String> = list_str
440 .split(',')
578 .split(',')
441 .map(|s| s.trim().to_string())
579 .map(|s| s.trim().to_string())
@@ -466,8 +604,11 @@ impl Parser {
466 let saved_vars = ctx.variables.clone();
604 let saved_vars = ctx.variables.clone();
467 let mut all_nodes = Vec::new();
605 let mut all_nodes = Vec::new();
468
606
469− for item in &items {
607+ for (i, item) in items.iter().enumerate() {
470 ctx.variables.insert(var_name.clone(), item.clone());
608 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+ }
471 let mut body_parser = Parser {
612 let mut body_parser = Parser {
472 lines: adjusted.clone(),
613 lines: adjusted.clone(),
473 pos: 0,
614 pos: 0,
@@ -514,10 +655,17 @@ impl Parser {
514 });
655 });
515 }
656 }
516 let name = parts[0].to_string();
657 let name = parts[0].to_string();
517− let params: Vec<String> = parts[1..]
658+ let mut params = Vec::new();
518− .iter()
659+ let mut defaults = HashMap::new();
660+ for part in &parts[1..] {
519− .map(|p| p.strip_prefix('$').unwrap_or(p).to_string())
661+ let part = part.strip_prefix('$').unwrap_or(part);
520− .collect();
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+ }
521
669
522 // Collect body lines (all lines indented deeper than @fn)
670 // Collect body lines (all lines indented deeper than @fn)
523 let mut body_lines = Vec::new();
671 let mut body_lines = Vec::new();
@@ -526,7 +674,7 @@ impl Parser {
526 self.pos += 1;
674 self.pos += 1;
527 }
675 }
528
676
529− ctx.functions.insert(name, FnDef { params, body_lines });
677+ ctx.functions.insert(name, FnDef { params, defaults, body_lines });
530 return Ok(None);
678 return Ok(None);
531 }
679 }
532
680
@@ -600,6 +748,7 @@ impl Parser {
600 .find(|a| a.key == *param)
748 .find(|a| a.key == *param)
601 .and_then(|a| a.value.clone())
749 .and_then(|a| a.value.clone())
602 .or_else(|| args.get(i).and_then(|a| a.value.clone()))
750 .or_else(|| args.get(i).and_then(|a| a.value.clone()))
751+ .or_else(|| fn_def.defaults.get(param).cloned())
603 .unwrap_or_default();
752 .unwrap_or_default();
604 ctx.variables.insert(param.clone(), value);
753 ctx.variables.insert(param.clone(), value);
605 }
754 }
@@ -771,7 +920,8 @@ const KNOWN_ELEMENTS: &[&str] = &[
771];
920];
772
921
773const KNOWN_DIRECTIVES: &[&str] = &[
922const 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",
775];
925];
776
926
777fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
927fn 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> {
849
999
850const KNOWN_ATTRS: &[&str] = &[
1000const KNOWN_ATTRS: &[&str] = &[
851 // Layout
1001 // Layout
852− "spacing", "padding", "padding-x", "padding-y",
1002+ "spacing", "gap", "padding", "padding-x", "padding-y",
853 "width", "height", "min-width", "max-width", "min-height", "max-height",
1003 "width", "height", "min-width", "max-width", "min-height", "max-height",
854 "center-x", "center-y", "align-left", "align-right", "align-top", "align-bottom",
1004 "center-x", "center-y", "align-left", "align-right", "align-top", "align-bottom",
855 // Style
1005 // Style
856− "background", "color", "border", "rounded",
1006+ "background", "color", "border", "border-top", "border-bottom", "border-left", "border-right",
857− "bold", "italic", "underline",
1007+ "rounded", "bold", "italic", "underline",
858 "size", "font", "transition", "cursor", "opacity",
1008 "size", "font", "transition", "cursor", "opacity",
1009+ "text-align", "line-height", "letter-spacing", "text-transform", "white-space",
859− "text-align", "line-height", "overflow", "position", "z-index", "shadow",
1010+ "overflow", "position", "top", "right", "bottom", "left", "z-index", "shadow",
860 "wrap", "gap-x", "gap-y",
1011 "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",
861 // Identity
1018 // Identity
862 "id", "class",
1019 "id", "class",
863 // Animation
1020 // Animation
@@ -870,13 +1027,25 @@ const KNOWN_ATTRS: &[&str] = &[
870 "alt", "role", "tabindex", "title",
1027 "alt", "role", "tabindex", "title",
871];
1028];
872
1029
873−/// Attributes that expect purely numeric values (px-based).
1030+/// Attributes that expect purely numeric values (px-based) or values with CSS units.
874const NUMERIC_ATTRS: &[&str] = &[
1031const NUMERIC_ATTRS: &[&str] = &[
875− "spacing", "padding", "padding-x", "padding-y",
1032+ "spacing", "gap", "padding", "padding-x", "padding-y",
876 "min-width", "max-width", "min-height", "max-height",
1033 "min-width", "max-width", "min-height", "max-height",
877 "rounded", "size", "gap-x", "gap-y",
1034 "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",
878];
1041];
879
1042
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+
880/// Attributes that accept numeric OR keyword values.
1049/// Attributes that accept numeric OR keyword values.
881const NUMERIC_OR_KEYWORD_ATTRS: &[&str] = &["width", "height"];
1050const NUMERIC_OR_KEYWORD_ATTRS: &[&str] = &["width", "height"];
882const SIZE_KEYWORDS: &[&str] = &["fill", "shrink"];
1051const SIZE_KEYWORDS: &[&str] = &["fill", "shrink"];
@@ -897,13 +1066,13 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext
897
1066
898 if let Some(val) = &attr.value {
1067 if let Some(val) = &attr.value {
899 if NUMERIC_ATTRS.contains(&base_key) {
1068 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
901 for part in val.split_whitespace() {
1070 for part in val.split_whitespace() {
902− if part.parse::<f64>().is_err() {
1071+ if part.parse::<f64>().is_err() && !has_css_unit(part) {
903 ctx.diagnostics.push(Diagnostic {
1072 ctx.diagnostics.push(Diagnostic {
904 line: line_num,
1073 line: line_num,
905 message: format!(
1074 message: format!(
906− "'{}' expects a numeric value, got '{}'",
1075+ "'{}' expects a numeric value (with optional unit), got '{}'",
907 attr.key, val
1076 attr.key, val
908 ),
1077 ),
909 severity: Severity::Warning,
1078 severity: Severity::Warning,
@@ -915,7 +1084,8 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext
915 } else if NUMERIC_OR_KEYWORD_ATTRS.contains(&base_key) {
1084 } else if NUMERIC_OR_KEYWORD_ATTRS.contains(&base_key) {
916 let is_keyword = SIZE_KEYWORDS.contains(&val.as_str());
1085 let is_keyword = SIZE_KEYWORDS.contains(&val.as_str());
917 let is_numeric = val.parse::<f64>().is_ok();
1086 let is_numeric = val.parse::<f64>().is_ok();
1087+ let has_unit = has_css_unit(val);
918− if !is_keyword && !is_numeric {
1088+ if !is_keyword && !is_numeric && !has_unit {
919 ctx.diagnostics.push(Diagnostic {
1089 ctx.diagnostics.push(Diagnostic {
920 line: line_num,
1090 line: line_num,
921 message: format!(
1091 message: format!(
tests/snapshots.rs+83
@@ -137,6 +137,41 @@ fn snapshot_accessibility() {
137 snapshot_test("accessibility");
137 snapshot_test("accessibility");
138}
138}
139
139
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+
140// ---------------------------------------------------------------------------
175// ---------------------------------------------------------------------------
141// Error case tests
176// Error case tests
142// ---------------------------------------------------------------------------
177// ---------------------------------------------------------------------------
@@ -260,6 +295,54 @@ fn warning_fill_outside_row() {
260 );
295 );
261}
296}
262
297
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+
263#[test]
346#[test]
264fn warning_fill_inside_row_ok() {
347fn warning_fill_inside_row_ok() {
265 let diags = parse_diagnostics("@row\n @el [width fill]");
348 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