on master help sign in
Add 28 improvements: new elements, CSS attributes, dark mode, string interpolation, @each destructuring, diagnostics, formatter, and CLI init
commit75ca36ee1ecbdb2e703f6d6643dc5077f7e45dae authoriko <mail@iko.soy> dateWed 15 Apr 2026, 12:32 (6 months ago) parentb68ee41
14 files changed+1280−34
DESIGN.md+163 README.md+24−1 src/ast.rs+13 src/bin/htmlang-lsp.rs+101−2 src/codegen.rs+251−5 src/fmt.rs+133−11 src/main.rs+49 src/parser.rs+144−15 tests/snapshots.rs+325 tests/snapshots/dark_mode.hl+10 tests/snapshots/each_destructuring.hl+6 tests/snapshots/new_css.hl+24 tests/snapshots/new_elements.hl+27 tests/snapshots/string_interpolation.hl+10
DESIGN.md+163
@@ -38,6 +38,18 @@ A minimalist layout language inspired by elm-ui that compiles to static HTML.
3838| `@th` | th | Table header cell |
3939| `@video` | video | Video element |
4040| `@audio` | audio | Audio element |
41+| `@form` | form | Form container |
42+| `@details` | details | Disclosure widget |
43+| `@summary` | summary | Summary for `@details` |
44+| `@blockquote`| blockquote | Block quotation |
45+| `@cite` | cite | Citation/source reference |
46+| `@code` | code | Inline code (monospace) |
47+| `@pre` | pre | Preformatted text |
48+| `@hr` | hr | Horizontal rule (self-closing) |
49+| `@figure` | figure | Figure with optional caption |
50+| `@figcaption`| figcaption | Caption for `@figure` |
51+| `@progress` | progress | Progress bar |
52+| `@meter` | meter | Meter/gauge element |
4153
4254Bare lines (not starting with `@` or `[`) are text nodes.
4355
@@ -128,6 +140,15 @@ Defines a variable. Referenced with `$name`.
128140@el [background $primary, spacing $gap]
129141```
130142
143+Quoted values support string interpolation:
144+
145+```
146+@let name World
147+@let greeting "Hello $name" -- "Hello World"
148+@let base /api
149+@let url "$base/users" -- "/api/users"
150+```
151+
131152### `@include`
132153
133154Inlines another `.hl` file. The path is resolved relative to the current file. Variables defined with `@let`, attribute bundles from `@define`, and functions from `@fn` in the included file are available after the `@include` line.
@@ -321,6 +342,84 @@ All numeric values default to pixels. You can use CSS units explicitly:
321342
322343Supported units: `%`, `rem`, `em`, `vh`, `vw`, `vmin`, `vmax`, `dvh`, `svh`, `ch`, `ex`, `cm`, `mm`, `in`, `pt`, `pc`, `fr`.
323344
345+## New elements
346+
347+### `@form`
348+
349+Form container. Argument is the `action` URL.
350+
351+```
352+@form [method post] /submit
353+ @label [for email] Email
354+ @input [type email, name email, required]
355+ @button [type submit] Send
356+```
357+
358+### `@details` / `@summary`
359+
360+Native disclosure widget. Use `[open]` to expand by default.
361+
362+```
363+@details [open]
364+ @summary FAQ Question
365+ @text The answer is here.
366+```
367+
368+### `@blockquote` / `@cite`
369+
370+Semantic quotation with optional citation.
371+
372+```
373+@blockquote [padding 20, border-left 4 #ccc]
374+ @text To be or not to be
375+ @cite Shakespeare
376+```
377+
378+### `@code` / `@pre`
379+
380+Code and preformatted text. `@code` renders as `<code>` with monospace font. `@pre` preserves whitespace.
381+
382+```
383+@pre
384+ @code console.log("hello")
385+```
386+
387+### `@hr`
388+
389+Horizontal rule / divider (self-closing). Alias: `@divider`.
390+
391+```
392+@hr [border-top 1 #e5e7eb]
393+```
394+
395+### `@figure` / `@figcaption`
396+
397+Figure with optional caption.
398+
399+```
400+@figure
401+ @image [alt Sunset, width fill] sunset.jpg
402+ @figcaption A beautiful sunset
403+```
404+
405+### `@progress` / `@meter`
406+
407+Progress bar and meter elements. Use `value`, `max`, `min` attributes.
408+
409+```
410+@progress [value 70, max 100]
411+@meter [value 6, min 0, max 10, low 3, high 8]
412+```
413+
414+## `@each` destructuring
415+
416+When `@each` has more than one variable and items contain spaces, values are destructured:
417+
418+```
419+@each $name, $url in Home /, About /about, Contact /contact
420+ @link $url $name
421+```
422+
324423`var()` and `calc()` expressions are also passed through as-is.
325424
326425## Attributes reference
@@ -404,6 +503,36 @@ Supported units: `%`, `rem`, `em`, `vh`, `vw`, `vmin`, `vmax`, `dvh`, `svh`, `ch
404503| `transform VALUE` | CSS transform (e.g., rotate(45deg)) |
405504| `backdrop-filter VALUE`| Backdrop filter (e.g., blur(10px)) |
406505
506+### Margin
507+
508+| Attribute | Effect |
509+|------------------------|----------------------------------|
510+| `margin N` | Uniform margin |
511+| `margin Y X` | Vertical + horizontal margin |
512+| `margin T R B L` | Per-side margin |
513+| `margin-x N` | Horizontal margin |
514+| `margin-y N` | Vertical margin |
515+
516+### Additional Style
517+
518+| Attribute | Effect |
519+|------------------------|----------------------------------|
520+| `filter VALUE` | CSS filter (blur, brightness) |
521+| `object-fit VALUE` | Object fit (cover/contain/fill) |
522+| `object-position VALUE`| Object position within container|
523+| `text-shadow VALUE` | Text shadow |
524+| `text-overflow VALUE` | Text overflow (ellipsis/clip) |
525+| `pointer-events VALUE` | Pointer events (none/auto) |
526+| `user-select VALUE` | User selection (none/text/all) |
527+| `justify-content VALUE`| Main axis alignment |
528+| `align-items VALUE` | Cross axis alignment |
529+| `order N` | Flex/grid item order |
530+| `background-size VALUE`| Background size |
531+| `background-position VALUE`| Background position |
532+| `background-repeat VALUE`| Background repeat |
533+| `word-break VALUE` | Word break (break-all/keep-all) |
534+| `overflow-wrap VALUE` | Overflow wrap (break-word) |
535+
407536### Pseudo-states
408537
409538Prefix any style attribute with `hover:`, `active:`, or `focus:` to apply it on that state.
@@ -415,6 +544,28 @@ Prefix any style attribute with `hover:`, `active:`, or `focus:` to apply it on
415544
416545All style attributes support state prefixes: `hover:color`, `active:rounded`, `focus:border`, etc.
417546
547+### Dark mode
548+
549+Prefix any style attribute with `dark:` to apply it when the user's system is in dark mode.
550+
551+```
552+@el [background white, dark:background #1a1a2e, color #333, dark:color #eee]
553+ @text Theme-aware content
554+```
555+
556+This generates a `@media (prefers-color-scheme: dark)` rule.
557+
558+### Print styles
559+
560+Prefix any style attribute with `print:` to apply it when printing.
561+
562+```
563+@nav [print:display none]
564+ @text Navigation (hidden in print)
565+```
566+
567+This generates a `@media print` rule.
568+
418569### Flow & Grid
419570
420571| Attribute | Effect |
@@ -433,9 +584,21 @@ All style attributes support state prefixes: `hover:color`, `active:rounded`, `f
433584| `id NAME` | HTML id attribute |
434585| `class NAME` | HTML class attribute |
435586
587+## Asset inlining
588+
589+SVG images can be inlined directly into the HTML output using the `[inline]` attribute:
590+
591+```
592+@image [inline, width 24, height 24] icon.svg
593+```
594+
595+This reads the SVG file and embeds its content directly, keeping the output self-contained.
596+
436597## CLI
437598
438599```
600+htmlang init # scaffold a new project
601+htmlang init my-site # scaffold in a new directory
439602htmlang page.hl # compile page.hl → page.html
440603htmlang site/ # compile all .hl files in directory
441604htmlang --watch page.hl # compile and watch for changes
README.md+24−1
@@ -39,8 +39,10 @@ cargo install --path .
3939## Usage
4040
4141```
42+htmlang init # scaffold a new project
4243htmlang page.hl # writes page.html
4344htmlang --watch page.hl # recompile on change
45+htmlang fmt page.hl # format a file
4446```
4547
4648## Editor support
@@ -61,6 +63,18 @@ A VS Code extension with syntax highlighting and LSP integration is available in
6163| `@image` | Image |
6264| `@link` | Anchor |
6365| `@raw` | Verbatim HTML escape hatch |
66+| `@form` | Form container |
67+| `@details` | Disclosure widget |
68+| `@summary` | Summary for `@details` |
69+| `@blockquote`| Block quotation |
70+| `@cite` | Citation reference |
71+| `@code` | Inline code (monospace) |
72+| `@pre` | Preformatted text |
73+| `@hr` | Horizontal rule / divider |
74+| `@figure` | Figure with caption |
75+| `@figcaption`| Caption for `@figure` |
76+| `@progress` | Progress bar |
77+| `@meter` | Meter/gauge |
6478
6579### Layout attributes
6680
@@ -91,7 +105,7 @@ A VS Code extension with syntax highlighting and LSP integration is available in
91105 Overlay content
92106```
93107
94−### Pseudo-states
108+### Pseudo-states and media prefixes
95109
96110Prefix any style attribute with `hover:`, `active:`, or `focus:`:
97111
@@ -99,10 +113,17 @@ Prefix any style attribute with `hover:`, `active:`, or `focus:`:
99113@el [background #3b82f6, hover:background #2563eb, active:background #1d4ed8]
100114```
101115
116+Use `dark:` for dark mode and `print:` for print styles:
117+
118+```
119+@el [background white, dark:background #1a1a2e, print:display none]
120+```
121+
102122### Variables and defines
103123
104124```
105125@let primary #3b82f6 -- simple variable, used as $primary
126+@let greeting "Hello $name" -- quoted string interpolation
106127@define card [padding 20, rounded 8] -- attribute bundle, used as [$card]
107128```
108129
@@ -144,6 +165,8 @@ Attribute lists can span multiple lines:
144165- `@el [attrs] > @link url` single-child chaining
145166- `[padding 20]` bare attributes as implicit `@el`
146167- `@raw """..."""` for embedding arbitrary HTML/CSS/JS
168+- `@image [inline] logo.svg` inlines SVG content
169+- `@each $name, $url in Home /, About /about` destructuring
147170
148171## Documentation
149172
src/ast.rs+13
@@ -76,6 +76,19 @@ pub enum ElementKind {
7676 // Media elements
7777 Video,
7878 Audio,
79+ // Additional semantic elements
80+ Form,
81+ Details,
82+ Summary,
83+ Blockquote,
84+ Cite,
85+ Code,
86+ Pre,
87+ HorizontalRule,
88+ Figure,
89+ FigCaption,
90+ Progress,
91+ Meter,
7992}
8093
8194#[derive(Debug, Clone)]
src/bin/htmlang-lsp.rs+101−2
@@ -251,10 +251,10 @@ fn completions(text: &str, position: Position) -> Vec<CompletionItem> {
251251 return variable_completions(text, edit_range);
252252 }
253253
254− // State prefix (hover:, active:, focus:)
254+ // State prefix (hover:, active:, focus:) or media prefix (dark:, print:)
255255 if let Some(colon) = current_word.find(':') {
256256 let prefix = &current_word[..colon];
257− if matches!(prefix, "hover" | "active" | "focus") {
257+ if matches!(prefix, "hover" | "active" | "focus" | "dark" | "print") {
258258 return state_attr_completions(prefix, edit_range);
259259 }
260260 }
@@ -376,6 +376,20 @@ fn element_completions(range: Range) -> Vec<CompletionItem> {
376376 // Media elements
377377 ("@video", "Video element"),
378378 ("@audio", "Audio element"),
379+ // Additional elements
380+ ("@form", "Form container (form)"),
381+ ("@details", "Disclosure widget (details)"),
382+ ("@summary", "Summary for @details"),
383+ ("@blockquote", "Block quotation"),
384+ ("@cite", "Citation/source reference"),
385+ ("@code", "Inline code (monospace)"),
386+ ("@pre", "Preformatted text block"),
387+ ("@hr", "Horizontal rule/divider"),
388+ ("@divider", "Horizontal rule (alias for @hr)"),
389+ ("@figure", "Figure with optional caption"),
390+ ("@figcaption", "Caption for @figure"),
391+ ("@progress", "Progress bar (value, max attributes)"),
392+ ("@meter", "Meter/gauge element (value, min, max)"),
379393 ]
380394 .iter()
381395 .map(|(name, detail)| item(name, CompletionItemKind::KEYWORD, detail, name, range))
@@ -537,6 +551,42 @@ fn attr_completions(range: Range) -> Vec<CompletionItem> {
537551 ("ordered", "Use ordered list (ol instead of ul)", false),
538552 // Media src
539553 ("src", "Source URL for media elements", true),
554+ // Margin
555+ ("margin", "Outer margin (1/2/3/4 values)", true),
556+ ("margin-x", "Horizontal margin", true),
557+ ("margin-y", "Vertical margin", true),
558+ // Filter & object
559+ ("filter", "CSS filter (blur, brightness, grayscale, etc.)", true),
560+ ("object-fit", "Object fit for images (cover/contain/fill)", true),
561+ ("object-position", "Object position within container", true),
562+ // Text extras
563+ ("text-shadow", "Text shadow (CSS value)", true),
564+ ("text-overflow", "Text overflow (ellipsis/clip)", true),
565+ // Interaction
566+ ("pointer-events", "Pointer events (none/auto)", true),
567+ ("user-select", "User selection (none/text/all)", true),
568+ // Flexbox/grid alignment
569+ ("justify-content", "Main axis alignment (center/space-between/etc.)", true),
570+ ("align-items", "Cross axis alignment (center/baseline/etc.)", true),
571+ // Flex item
572+ ("order", "Flex/grid item order", true),
573+ // Background extras
574+ ("background-size", "Background size (cover/contain/auto)", true),
575+ ("background-position", "Background position (center/top/etc.)", true),
576+ ("background-repeat", "Background repeat (no-repeat/repeat/etc.)", true),
577+ // Text wrapping
578+ ("word-break", "Word break behavior (break-all/keep-all)", true),
579+ ("overflow-wrap", "Overflow wrap (break-word/anywhere)", true),
580+ // New element attrs
581+ ("open", "Details initially open", false),
582+ ("novalidate", "Disable form validation", false),
583+ ("low", "Meter low threshold", true),
584+ ("high", "Meter high threshold", true),
585+ ("optimum", "Meter optimum value", true),
586+ ("colspan", "Table cell column span", true),
587+ ("rowspan", "Table cell row span", true),
588+ ("scope", "Table header scope (col/row/colgroup/rowgroup)", true),
589+ ("inline", "Inline SVG images into output", false),
540590 // State prefixes
541591 ("hover:", "Style on hover", false),
542592 ("active:", "Style on active/click", false),
@@ -546,6 +596,9 @@ fn attr_completions(range: Range) -> Vec<CompletionItem> {
546596 ("md:", "Style at 768px+ (medium)", false),
547597 ("lg:", "Style at 1024px+ (large)", false),
548598 ("xl:", "Style at 1280px+ (extra large)", false),
599+ // Media prefixes
600+ ("dark:", "Style in dark color scheme", false),
601+ ("print:", "Style for print media", false),
549602 ]
550603 .iter()
551604 .map(|(name, detail, takes_value)| {
@@ -574,9 +627,13 @@ fn state_attr_completions(prefix: &str, range: Range) -> Vec<CompletionItem> {
574627 ("opacity", "Opacity (0-1)", true),
575628 ("cursor", "CSS cursor type", true),
576629 ("shadow", "Box shadow (CSS value)", true),
630+ ("text-shadow", "Text shadow", true),
577631 ("transform", "CSS transform", true),
632+ ("filter", "CSS filter", true),
578633 ("display", "Display mode", true),
579634 ("visibility", "Visibility", true),
635+ ("pointer-events", "Pointer events", true),
636+ ("user-select", "User selection", true),
580637 ]
581638 .iter()
582639 .map(|(name, detail, takes_value)| {
@@ -831,6 +888,19 @@ fn hover_builtin(word: &str) -> Option<String> {
831888 // Media elements
832889 "@video" => "**@video** \u{2014} Video\n\nRenders as `<video>`.\n\nUsage: `@video [controls] demo.mp4`",
833890 "@audio" => "**@audio** \u{2014} Audio\n\nRenders as `<audio>`.\n\nUsage: `@audio [controls] song.mp3`",
891+ // Additional elements
892+ "@form" => "**@form** \u{2014} Form\n\nRenders as `<form>`. Container for form elements.\n\nUsage: `@form [method post] /submit`",
893+ "@details" => "**@details** \u{2014} Disclosure\n\nRenders as `<details>`. Use `[open]` for initially expanded.\n\nContains `@summary` for the toggle label.",
894+ "@summary" => "**@summary** \u{2014} Summary\n\nRenders as `<summary>`. Toggle label inside `@details`.\n\nUsage: `@summary Click to expand`",
895+ "@blockquote" => "**@blockquote** \u{2014} Block quotation\n\nRenders as `<blockquote>`. Semantic quotation container.",
896+ "@cite" => "**@cite** \u{2014} Citation\n\nRenders as `<cite>`. Source or reference for a quotation.\n\nUsage: `@cite The Great Gatsby`",
897+ "@code" => "**@code** \u{2014} Code\n\nRenders as `<code>` with monospace font.\n\nUsage: `@code console.log(\"hello\")`",
898+ "@pre" => "**@pre** \u{2014} Preformatted\n\nRenders as `<pre>` with preserved whitespace and monospace font.",
899+ "@hr" | "@divider" => "**@hr** \u{2014} Horizontal Rule\n\nRenders as self-closing `<hr>`. Visual divider.\n\nUsage: `@hr [border-top 1 #ccc]`",
900+ "@figure" => "**@figure** \u{2014} Figure\n\nRenders as `<figure>`. Container for media with optional `@figcaption`.\n\n```\n@figure\n @image photo.jpg\n @figcaption A nice photo\n```",
901+ "@figcaption" => "**@figcaption** \u{2014} Figure caption\n\nRenders as `<figcaption>`. Caption text inside `@figure`.",
902+ "@progress" => "**@progress** \u{2014} Progress bar\n\nRenders as `<progress>`.\n\nUsage: `@progress [value 70, max 100]`",
903+ "@meter" => "**@meter** \u{2014} Meter\n\nRenders as `<meter>`. Gauge for scalar measurement.\n\nUsage: `@meter [value 0.7, min 0, max 1, low 0.3, high 0.8]`",
834904 // Directives
835905 "@match" => "**@match** \u{2014} Pattern matching\n\nMatch a value against cases.\n\n```\n@match $theme\n @case dark\n @el [background #333]\n @case light\n @el [background white]\n @default\n @el [background gray]\n```",
836906 "@case" => "**@case** \u{2014} Match case\n\nA case inside `@match`. Matches when the value equals the case value.",
@@ -938,6 +1008,35 @@ fn hover_builtin(word: &str) -> Option<String> {
9381008 "preload" => "**preload** `<value>` \u{2014} Media preload hint (`auto`, `metadata`, `none`).",
9391009 "ordered" => "**ordered** \u{2014} Use ordered list (`<ol>` instead of `<ul>`).",
9401010 "src" => "**src** `<url>` \u{2014} Source URL for media elements.",
1011+ // New CSS attributes
1012+ "margin" => "**margin** `<value>` | `<y> <x>` | `<t> <h> <b>` | `<t> <r> <b> <l>`\n\nOuter margin. Supports CSS units. Accepts 1\u{2013}4 values.",
1013+ "margin-x" => "**margin-x** `<value>` \u{2014} Horizontal margin (left + right).",
1014+ "margin-y" => "**margin-y** `<value>` \u{2014} Vertical margin (top + bottom).",
1015+ "filter" => "**filter** `<value>` \u{2014} CSS filter (e.g., `blur(5px)`, `brightness(1.2)`, `grayscale(1)`).",
1016+ "object-fit" => "**object-fit** `<value>` \u{2014} How content fits its container (`cover`, `contain`, `fill`, `none`, `scale-down`).",
1017+ "object-position" => "**object-position** `<value>` \u{2014} Position of content within container (e.g., `center`, `top left`).",
1018+ "text-shadow" => "**text-shadow** `<value>` \u{2014} Text shadow. Raw CSS value (e.g., `1px 1px 2px rgba(0,0,0,0.3)`).",
1019+ "text-overflow" => "**text-overflow** `<value>` \u{2014} Text overflow behavior (`ellipsis`, `clip`). Combine with `white-space nowrap` and `overflow hidden`.",
1020+ "pointer-events" => "**pointer-events** `<value>` \u{2014} Pointer event behavior (`none`, `auto`).",
1021+ "user-select" => "**user-select** `<value>` \u{2014} Text selection behavior (`none`, `text`, `all`, `auto`).",
1022+ "justify-content" => "**justify-content** `<value>` \u{2014} Main axis alignment (`center`, `space-between`, `space-around`, `flex-start`, `flex-end`).",
1023+ "align-items" => "**align-items** `<value>` \u{2014} Cross axis alignment (`center`, `flex-start`, `flex-end`, `stretch`, `baseline`).",
1024+ "order" => "**order** `<value>` \u{2014} Flex/grid item order (integer).",
1025+ "background-size" => "**background-size** `<value>` \u{2014} Background size (`cover`, `contain`, `auto`, or dimensions).",
1026+ "background-position" => "**background-position** `<value>` \u{2014} Background position (`center`, `top`, `bottom left`, etc.).",
1027+ "background-repeat" => "**background-repeat** `<value>` \u{2014} Background repeat (`no-repeat`, `repeat`, `repeat-x`, `repeat-y`).",
1028+ "word-break" => "**word-break** `<value>` \u{2014} Word breaking behavior (`break-all`, `keep-all`, `normal`).",
1029+ "overflow-wrap" => "**overflow-wrap** `<value>` \u{2014} Overflow wrapping (`break-word`, `anywhere`, `normal`).",
1030+ // New element attributes
1031+ "open" => "**open** \u{2014} Initially expand `@details` element.",
1032+ "novalidate" => "**novalidate** \u{2014} Disable form validation.",
1033+ "low" => "**low** `<value>` \u{2014} Low threshold for `@meter`.",
1034+ "high" => "**high** `<value>` \u{2014} High threshold for `@meter`.",
1035+ "optimum" => "**optimum** `<value>` \u{2014} Optimum value for `@meter`.",
1036+ "colspan" => "**colspan** `<value>` \u{2014} Number of columns a cell spans.",
1037+ "rowspan" => "**rowspan** `<value>` \u{2014} Number of rows a cell spans.",
1038+ "scope" => "**scope** `<value>` \u{2014} Header scope (`col`, `row`, `colgroup`, `rowgroup`).",
1039+ "inline" => "**inline** \u{2014} Inline SVG image content into the HTML output.",
9411040 _ => return None,
9421041 };
9431042
src/codegen.rs+251−5
@@ -22,6 +22,10 @@ struct StyleEntry {
2222 focus: String,
2323 /// Responsive overrides: (breakpoint_prefix, css)
2424 responsive: Vec<(String, String)>,
25+ /// Dark mode overrides
26+ dark: String,
27+ /// Print overrides
28+ print: String,
2529}
2630
2731struct StyleCollector {
@@ -45,12 +49,16 @@ impl StyleCollector {
4549 active: String,
4650 focus: String,
4751 responsive: Vec<(String, String)>,
52+ dark: String,
53+ print: String,
4854 ) -> Option<String> {
4955 if base.is_empty()
5056 && hover.is_empty()
5157 && active.is_empty()
5258 && focus.is_empty()
5359 && responsive.is_empty()
60+ && dark.is_empty()
61+ && print.is_empty()
5462 {
5563 return None;
5664 }
@@ -59,7 +67,7 @@ impl StyleCollector {
5967 .map(|(bp, css)| format!("{}={}", bp, css))
6068 .collect::<Vec<_>>()
6169 .join("|");
62− let key = format!("{}|{}|{}|{}|{}", base, hover, active, focus, resp_key);
70+ let key = format!("{}|{}|{}|{}|{}|{}|{}", base, hover, active, focus, resp_key, dark, print);
6371 if let Some(&idx) = self.index.get(&key) {
6472 return Some(self.entries[idx].class_name.clone());
6573 }
@@ -72,6 +80,8 @@ impl StyleCollector {
7280 active,
7381 focus,
7482 responsive,
83+ dark,
84+ print,
7585 });
7686 self.index.insert(key, idx);
7787 Some(name)
@@ -126,6 +136,44 @@ impl StyleCollector {
126136 }
127137 }
128138
139+ // Dark mode rules
140+ let mut dark_css = String::new();
141+ for e in &self.entries {
142+ if !e.dark.is_empty() {
143+ if dev {
144+ dark_css.push_str(&format!(" .{} {{{}}}\n", e.class_name, e.dark));
145+ } else {
146+ dark_css.push_str(&format!(".{}{{{}}}", e.class_name, e.dark));
147+ }
148+ }
149+ }
150+ if !dark_css.is_empty() {
151+ if dev {
152+ css.push_str(&format!("@media (prefers-color-scheme: dark) {{\n{}}}\n", dark_css));
153+ } else {
154+ css.push_str(&format!("@media(prefers-color-scheme:dark){{{}}}", dark_css));
155+ }
156+ }
157+
158+ // Print rules
159+ let mut print_css = String::new();
160+ for e in &self.entries {
161+ if !e.print.is_empty() {
162+ if dev {
163+ print_css.push_str(&format!(" .{} {{{}}}\n", e.class_name, e.print));
164+ } else {
165+ print_css.push_str(&format!(".{}{{{}}}", e.class_name, e.print));
166+ }
167+ }
168+ }
169+ if !print_css.is_empty() {
170+ if dev {
171+ css.push_str(&format!("@media print {{\n{}}}\n", print_css));
172+ } else {
173+ css.push_str(&format!("@media print{{{}}}", print_css));
174+ }
175+ }
176+
129177 css
130178 }
131179}
@@ -331,6 +379,8 @@ fn generate_node(
331379 | Some(ElementKind::Nav) | Some(ElementKind::Header) | Some(ElementKind::Footer)
332380 | Some(ElementKind::Main) | Some(ElementKind::Section) | Some(ElementKind::Article)
333381 | Some(ElementKind::Aside) | Some(ElementKind::ListItem)
382+ | Some(ElementKind::Form) | Some(ElementKind::Details) | Some(ElementKind::Figure)
383+ | Some(ElementKind::Blockquote)
334384 );
335385 if needs_wrap {
336386 out.push_str(&ctx.indent());
@@ -362,12 +412,21 @@ const HTML_PASSTHROUGH_ATTRS: &[&str] = &[
362412 "loading", "decoding",
363413 // Media src (explicit attribute form)
364414 "src",
415+ // Details
416+ "open",
417+ // Form
418+ "novalidate",
419+ // Progress/Meter
420+ "low", "high", "optimum",
421+ // Table
422+ "colspan", "rowspan", "scope",
365423];
366424
367425/// Boolean HTML attributes (rendered without a value, e.g., `<input disabled>`).
368426const BOOLEAN_HTML_ATTRS: &[&str] = &[
369427 "disabled", "required", "checked", "multiple",
370428 "controls", "autoplay", "loop", "muted",
429+ "open", "novalidate",
371430];
372431
373432fn emit_html_passthrough_attrs(out: &mut String, attrs: &[Attribute]) {
@@ -400,7 +459,7 @@ fn generate_element(
400459 ctx: &mut GenContext,
401460) {
402461 // Self-closing elements
403− if matches!(elem.kind, ElementKind::Image | ElementKind::Input) {
462+ if matches!(elem.kind, ElementKind::Image | ElementKind::Input | ElementKind::HorizontalRule) {
404463 generate_self_closing(elem, parent_kind, out, styles, ctx);
405464 return;
406465 }
@@ -444,7 +503,20 @@ fn generate_element(
444503 // Media
445504 ElementKind::Video => "video",
446505 ElementKind::Audio => "audio",
447− ElementKind::Image | ElementKind::Input | ElementKind::Children | ElementKind::Slot(_) => unreachable!(),
506+ // Additional semantic elements
507+ ElementKind::Form => "form",
508+ ElementKind::Details => "details",
509+ ElementKind::Summary => "summary",
510+ ElementKind::Blockquote => "blockquote",
511+ ElementKind::Cite => "cite",
512+ ElementKind::Code => "code",
513+ ElementKind::Pre => "pre",
514+ ElementKind::Figure => "figure",
515+ ElementKind::FigCaption => "figcaption",
516+ ElementKind::Progress => "progress",
517+ ElementKind::Meter => "meter",
518+ ElementKind::Image | ElementKind::Input | ElementKind::HorizontalRule
519+ | ElementKind::Children | ElementKind::Slot(_) => unreachable!(),
448520 };
449521
450522 let kind_label = match elem.kind {
@@ -476,6 +548,15 @@ fn generate_element(
476548 ElementKind::TableHeaderCell => "th",
477549 ElementKind::Video => "video",
478550 ElementKind::Audio => "audio",
551+ ElementKind::Form => "form",
552+ ElementKind::Details => "details",
553+ ElementKind::Summary => "summary",
554+ ElementKind::Blockquote => "blockquote",
555+ ElementKind::Cite => "cite",
556+ ElementKind::Code => "code",
557+ ElementKind::Pre => "pre",
558+ ElementKind::Figure => "figure",
559+ ElementKind::FigCaption => "figcaption",
479560 _ => "",
480561 };
481562
@@ -508,6 +589,17 @@ fn generate_element(
508589 }
509590 }
510591
592+ // Form action
593+ if elem.kind == ElementKind::Form {
594+ if let Some(action) = &elem.argument {
595+ out.push_str(" action=\"");
596+ out.push_str(&html_escape(action));
597+ out.push('"');
598+ }
599+ }
600+
601+ // Details open attribute (handled via HTML_PASSTHROUGH_ATTRS, no extra handling needed)
602+
511603 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
512604
513605 if let Some(id) = id {
@@ -532,6 +624,10 @@ fn generate_element(
532624 | ElementKind::ListItem
533625 | ElementKind::TableCell
534626 | ElementKind::TableHeaderCell
627+ | ElementKind::Summary
628+ | ElementKind::Cite
629+ | ElementKind::Code
630+ | ElementKind::FigCaption
535631 ) {
536632 if let Some(text) = &elem.argument {
537633 out.push_str(&html_escape(text));
@@ -569,6 +665,7 @@ fn generate_self_closing(
569665 let (tag, kind_label) = match elem.kind {
570666 ElementKind::Image => ("img", "image"),
571667 ElementKind::Input => ("input", "input"),
668+ ElementKind::HorizontalRule => ("hr", "hr"),
572669 _ => unreachable!(),
573670 };
574671
@@ -600,6 +697,21 @@ fn generate_self_closing(
600697
601698 // Image optimization: auto-add loading="lazy" and decoding="async"
602699 if elem.kind == ElementKind::Image {
700+ // SVG inlining: @image [inline] logo.svg
701+ if elem.attrs.iter().any(|a| a.key == "inline") {
702+ if let Some(src) = &elem.argument {
703+ if src.ends_with(".svg") {
704+ if let Ok(svg_content) = std::fs::read_to_string(src) {
705+ // Close the tag we opened, then emit inline SVG instead
706+ out.truncate(out.rfind('<').unwrap_or(0));
707+ out.push_str(&ctx.indent());
708+ out.push_str(svg_content.trim());
709+ out.push_str(ctx.nl());
710+ return;
711+ }
712+ }
713+ }
714+ }
603715 if !elem.attrs.iter().any(|a| a.key == "loading") {
604716 out.push_str(" loading=\"lazy\"");
605717 }
@@ -655,7 +767,13 @@ fn compute_class(
655767 }
656768 }
657769
658− styles.get_class(base, hover, active, focus, responsive)
770+ // Dark mode
771+ let dark = attrs_to_css(attrs, "dark:", kind, parent_kind);
772+
773+ // Print
774+ let print = attrs_to_css(attrs, "print:", kind, parent_kind);
775+
776+ styles.get_class(base, hover, active, focus, responsive, dark, print)
659777}
660778
661779fn emit_class_attr(out: &mut String, gen_class: Option<&str>, user_class: Option<&str>) {
@@ -687,10 +805,12 @@ fn emit_class_attr(out: &mut String, gen_class: Option<&str>, user_class: Option
687805
688806const STATE_PREFIXES: &[&str] = &["hover:", "active:", "focus:"];
689807const RESPONSIVE_PREFIXES: &[&str] = &["sm:", "md:", "lg:", "xl:"];
808+const MEDIA_PREFIXES: &[&str] = &["dark:", "print:"];
690809
691810fn is_prefixed_attr(key: &str) -> bool {
692811 STATE_PREFIXES.iter().any(|p| key.starts_with(p))
693812 || RESPONSIVE_PREFIXES.iter().any(|p| key.starts_with(p))
813+ || MEDIA_PREFIXES.iter().any(|p| key.starts_with(p))
694814}
695815
696816fn attrs_to_css(
@@ -715,6 +835,18 @@ fn attrs_to_css(
715835 // Lists: reset browser defaults
716836 ElementKind::List => css.push_str("margin:0;padding-left:0;list-style:none;"),
717837 ElementKind::ListItem => css.push_str("display:flex;flex-direction:column;"),
838+ // Form: flex column like @el
839+ ElementKind::Form => css.push_str("display:flex;flex-direction:column;"),
840+ // Details: flex column
841+ ElementKind::Details => css.push_str("display:flex;flex-direction:column;"),
842+ // Figure: flex column
843+ ElementKind::Figure => css.push_str("display:flex;flex-direction:column;margin:0;"),
844+ // Blockquote: flex column, reset browser margin
845+ ElementKind::Blockquote => css.push_str("display:flex;flex-direction:column;margin:0;"),
846+ // Pre: preserve whitespace
847+ ElementKind::Pre => css.push_str("margin:0;white-space:pre;font-family:ui-monospace,monospace;"),
848+ // Code: monospace font
849+ ElementKind::Code => css.push_str("font-family:ui-monospace,monospace;"),
718850 _ => {}
719851 }
720852 }
@@ -1140,6 +1272,118 @@ fn attrs_to_css(
11401272 }
11411273 }
11421274
1275+ // Margin
1276+ "margin" => {
1277+ if let Some(v) = val {
1278+ push_css(&mut css, "margin", &css_px_multi(v));
1279+ }
1280+ }
1281+ "margin-x" => {
1282+ if let Some(v) = val {
1283+ push_css(&mut css, "margin-left", &css_px(v));
1284+ push_css(&mut css, "margin-right", &css_px(v));
1285+ }
1286+ }
1287+ "margin-y" => {
1288+ if let Some(v) = val {
1289+ push_css(&mut css, "margin-top", &css_px(v));
1290+ push_css(&mut css, "margin-bottom", &css_px(v));
1291+ }
1292+ }
1293+
1294+ // Filter
1295+ "filter" => {
1296+ if let Some(v) = val {
1297+ push_css(&mut css, "filter", v);
1298+ }
1299+ }
1300+
1301+ // Object fit/position (for images)
1302+ "object-fit" => {
1303+ if let Some(v) = val {
1304+ push_css(&mut css, "object-fit", v);
1305+ }
1306+ }
1307+ "object-position" => {
1308+ if let Some(v) = val {
1309+ push_css(&mut css, "object-position", v);
1310+ }
1311+ }
1312+
1313+ // Text shadow
1314+ "text-shadow" => {
1315+ if let Some(v) = val {
1316+ push_css(&mut css, "text-shadow", v);
1317+ }
1318+ }
1319+
1320+ // Text overflow
1321+ "text-overflow" => {
1322+ if let Some(v) = val {
1323+ push_css(&mut css, "text-overflow", v);
1324+ }
1325+ }
1326+
1327+ // Interaction
1328+ "pointer-events" => {
1329+ if let Some(v) = val {
1330+ push_css(&mut css, "pointer-events", v);
1331+ }
1332+ }
1333+ "user-select" => {
1334+ if let Some(v) = val {
1335+ push_css(&mut css, "user-select", v);
1336+ }
1337+ }
1338+
1339+ // Flexbox/grid alignment
1340+ "justify-content" => {
1341+ if let Some(v) = val {
1342+ push_css(&mut css, "justify-content", v);
1343+ }
1344+ }
1345+ "align-items" => {
1346+ if let Some(v) = val {
1347+ push_css(&mut css, "align-items", v);
1348+ }
1349+ }
1350+
1351+ // Flex item order
1352+ "order" => {
1353+ if let Some(v) = val {
1354+ push_css(&mut css, "order", v);
1355+ }
1356+ }
1357+
1358+ // Background extras
1359+ "background-size" => {
1360+ if let Some(v) = val {
1361+ push_css(&mut css, "background-size", v);
1362+ }
1363+ }
1364+ "background-position" => {
1365+ if let Some(v) = val {
1366+ push_css(&mut css, "background-position", v);
1367+ }
1368+ }
1369+ "background-repeat" => {
1370+ if let Some(v) = val {
1371+ push_css(&mut css, "background-repeat", v);
1372+ }
1373+ }
1374+
1375+ // Text wrapping
1376+ "word-break" => {
1377+ if let Some(v) = val {
1378+ push_css(&mut css, "word-break", v);
1379+ }
1380+ }
1381+ "overflow-wrap" => {
1382+ if let Some(v) = val {
1383+ push_css(&mut css, "overflow-wrap", v);
1384+ }
1385+ }
1386+
11431387 // Container queries
11441388 "container" => {
11451389 push_css(&mut css, "container-type", "inline-size");
@@ -1162,7 +1406,9 @@ fn attrs_to_css(
11621406 | "max" | "step" | "pattern" | "maxlength" | "rows" | "cols"
11631407 | "multiple" | "alt" | "role" | "tabindex" | "title"
11641408 | "controls" | "autoplay" | "loop" | "muted" | "poster" | "preload"
1165− | "loading" | "decoding" | "ordered" | "src" => {}
1409+ | "loading" | "decoding" | "ordered" | "src"
1410+ | "open" | "novalidate" | "low" | "high" | "optimum"
1411+ | "colspan" | "rowspan" | "scope" | "inline" => {}
11661412
11671413 _ => {}
11681414 }
src/fmt.rs+133−11
@@ -1,9 +1,80 @@
1−/// Format an htmlang source file with normalized indentation (2 spaces per level).
1+/// Attribute category for sorting.
2+fn attr_category(key: &str) -> u8 {
3+ // Strip state/responsive/media prefixes for categorization
4+ let base = key
5+ .strip_prefix("hover:")
6+ .or_else(|| key.strip_prefix("active:"))
7+ .or_else(|| key.strip_prefix("focus:"))
8+ .or_else(|| key.strip_prefix("sm:"))
9+ .or_else(|| key.strip_prefix("md:"))
10+ .or_else(|| key.strip_prefix("lg:"))
11+ .or_else(|| key.strip_prefix("xl:"))
12+ .or_else(|| key.strip_prefix("dark:"))
13+ .or_else(|| key.strip_prefix("print:"))
14+ .unwrap_or(key);
15+
16+ match base {
17+ // Layout (parent)
18+ "spacing" | "gap" | "gap-x" | "gap-y" | "wrap"
19+ | "grid" | "grid-cols" | "grid-rows" => 0,
20+ // Sizing
21+ "width" | "height" | "min-width" | "max-width" | "min-height" | "max-height" => 1,
22+ // Padding & margin
23+ "padding" | "padding-x" | "padding-y" | "padding-inline" | "padding-block"
24+ | "margin" | "margin-x" | "margin-y" | "margin-inline" | "margin-block" => 2,
25+ // Alignment
26+ "center-x" | "center-y" | "align-left" | "align-right"
27+ | "align-top" | "align-bottom" | "justify-content" | "align-items" => 3,
28+ // Positioning
29+ "position" | "top" | "right" | "bottom" | "left" | "z-index" | "order"
30+ | "col-span" | "row-span" | "display" | "visibility" | "overflow" => 4,
31+ // Visual style
32+ "background" | "background-size" | "background-position" | "background-repeat"
33+ | "color" | "opacity" => 5,
34+ // Border & shape
35+ "border" | "border-top" | "border-bottom" | "border-left" | "border-right"
36+ | "rounded" | "outline" | "shadow" | "text-shadow" => 6,
37+ // Typography
38+ "bold" | "italic" | "underline" | "size" | "font"
39+ | "text-align" | "line-height" | "letter-spacing" | "text-transform"
40+ | "white-space" | "text-overflow" | "word-break" | "overflow-wrap" => 7,
41+ // Effects & interaction
42+ "transform" | "transition" | "animation" | "cursor" | "backdrop-filter"
43+ | "filter" | "pointer-events" | "user-select" | "aspect-ratio"
44+ | "object-fit" | "object-position" => 8,
45+ // Container queries & scroll
46+ "container" | "container-name" | "container-type"
47+ | "scroll-snap-type" | "scroll-snap-align" => 9,
48+ // Identity
49+ "id" | "class" => 10,
50+ // HTML passthrough / form / accessibility
51+ _ => 11,
52+ }
53+}
54+
55+/// Sort a comma-separated attribute string by category.
56+fn sort_attrs(attrs_str: &str) -> String {
57+ let mut parts: Vec<&str> = attrs_str.split(',').collect();
58+ // Preserve order within same category (stable sort)
59+ parts.sort_by_key(|part| {
60+ let key = part.trim().split_whitespace().next().unwrap_or("");
61+ attr_category(key)
62+ });
63+ parts.iter()
64+ .map(|p| p.trim())
65+ .filter(|p| !p.is_empty())
66+ .collect::<Vec<_>>()
67+ .join(", ")
68+}
69+
70+/// Format an htmlang source file with normalized indentation (2 spaces per level),
71+/// sorted attributes, and cleaned-up whitespace.
272pub fn format(input: &str) -> String {
373 let mut output = String::new();
474 let mut indent_stack: Vec<i32> = vec![-1]; // sentinel
575 let mut bracket_depth: i32 = 0;
676 let mut bracket_base_level: usize = 0;
77+ let mut bracket_content = String::new();
778
879 for line in input.lines() {
980 let trimmed = line.trim();
@@ -17,13 +88,23 @@ pub fn format(input: &str) -> String {
1788 let open = trimmed.chars().filter(|&c| c == '[').count() as i32;
1889 let close = trimmed.chars().filter(|&c| c == ']').count() as i32;
1990
20− // Inside multi-line bracket continuation
91+ // Inside multi-line bracket continuation — collect content
2192 if bracket_depth > 0 {
22− let level = bracket_base_level + 1;
23− output.push_str(&" ".repeat(level));
24− output.push_str(trimmed);
25− output.push('\n');
93+ bracket_content.push(' ');
94+ bracket_content.push_str(trimmed);
2695 bracket_depth += open - close;
96+ if bracket_depth <= 0 {
97+ // Bracket block is complete — format it
98+ let level = bracket_base_level;
99+ // Find the bracket content within the full collected line
100+ let full = bracket_content.clone();
101+ bracket_content.clear();
102+ // Re-emit the line with sorted attrs
103+ let formatted = format_line_with_brackets(&full);
104+ output.push_str(&" ".repeat(level));
105+ output.push_str(&formatted);
106+ output.push('\n');
107+ }
27108 continue;
28109 }
29110
@@ -35,17 +116,58 @@ pub fn format(input: &str) -> String {
35116 }
36117
37118 let level = indent_stack.len() - 1;
38− output.push_str(&" ".repeat(level));
39− output.push_str(trimmed);
40− output.push('\n');
41−
42− indent_stack.push(raw_indent);
43119
44120 bracket_depth = open - close;
45121 if bracket_depth > 0 {
122+ // Start of multi-line bracket
46123 bracket_base_level = level;
124+ bracket_content = trimmed.to_string();
125+ indent_stack.push(raw_indent);
126+ continue;
47127 }
128+
129+ // Single-line: format brackets inline
130+ let formatted = format_line_with_brackets(trimmed);
131+ output.push_str(&" ".repeat(level));
132+ output.push_str(&formatted);
133+ output.push('\n');
134+
135+ indent_stack.push(raw_indent);
48136 }
49137
50138 output
51139}
140+
141+/// Format a single line, sorting attributes inside [...] brackets.
142+fn format_line_with_brackets(line: &str) -> String {
143+ // Find bracket boundaries
144+ let Some(bracket_start) = line.find('[') else {
145+ return line.to_string();
146+ };
147+ let mut depth = 0;
148+ let mut bracket_end = None;
149+ for (i, c) in line.char_indices() {
150+ match c {
151+ '[' => depth += 1,
152+ ']' => {
153+ depth -= 1;
154+ if depth == 0 {
155+ bracket_end = Some(i);
156+ break;
157+ }
158+ }
159+ _ => {}
160+ }
161+ }
162+ let Some(bracket_end) = bracket_end else {
163+ return line.to_string();
164+ };
165+
166+ let before = &line[..bracket_start];
167+ let attrs_inner = &line[bracket_start + 1..bracket_end];
168+ let after = &line[bracket_end + 1..];
169+
170+ let sorted = sort_attrs(attrs_inner);
171+
172+ format!("{}[{}]{}", before, sorted, after)
173+}
src/main.rs+49
@@ -110,9 +110,12 @@ Options:
110110 -V, --version Show version
111111
112112Commands:
113+ init [dir] Create a new project (defaults to current directory)
113114 fmt <file.hl> Format a .hl file (normalizes indentation)
114115
115116Examples:
117+ htmlang init Scaffold a new project
118+ htmlang init my-site Scaffold in a new directory
116119 htmlang page.hl Compile page.hl to page.html
117120 htmlang site/ Compile all .hl files in directory
118121 htmlang -w page.hl Recompile on file changes
@@ -125,6 +128,45 @@ Examples:
125128 );
126129}
127130
131+fn init_project(dir: &str) {
132+ let dir = Path::new(dir);
133+ if dir.to_str() != Some(".") {
134+ if let Err(e) = fs::create_dir_all(dir) {
135+ eprintln!("error: cannot create directory '{}': {}", dir.display(), e);
136+ process::exit(1);
137+ }
138+ }
139+
140+ let index_path = dir.join("index.hl");
141+ if index_path.exists() {
142+ eprintln!("error: {} already exists", index_path.display());
143+ process::exit(1);
144+ }
145+
146+ let template = r#"@page My Site
147+@let primary #3b82f6
148+
149+@column [max-width 800, center-x, padding 40, spacing 20]
150+ @text [bold, size 32] Hello, htmlang!
151+
152+ @paragraph [line-height 1.6]
153+ Edit {@text [bold, color $primary] index.hl} and run
154+ {@text [font monospace, size 14] htmlang -s .} to get started.
155+
156+ @row [spacing 10]
157+ @el [padding 12 24, background $primary, rounded 8, cursor pointer, hover:background #2563eb, transition all 0.15s ease] > @link https://github.com/nicholasgasior/htmlang
158+ @text [color white, bold] Documentation
159+"#;
160+
161+ match fs::write(&index_path, template) {
162+ Ok(()) => eprintln!("created {}", index_path.display()),
163+ Err(e) => {
164+ eprintln!("error: {}: {}", index_path.display(), e);
165+ process::exit(1);
166+ }
167+ }
168+}
169+
128170fn main() {
129171 let args: Vec<String> = env::args().collect();
130172 let mut watch = false;
@@ -134,6 +176,13 @@ fn main() {
134176 let mut port: u16 = 3000;
135177 let mut input_path = None;
136178
179+ // Handle "init" subcommand
180+ if args.len() >= 2 && args[1] == "init" {
181+ let dir = if args.len() >= 3 { &args[2] } else { "." };
182+ init_project(dir);
183+ return;
184+ }
185+
137186 // Handle "fmt" subcommand
138187 if args.len() >= 3 && args[1] == "fmt" {
139188 let file = &args[2];
src/parser.rs+144−15
@@ -315,8 +315,15 @@ impl Parser {
315315 if let Some(rest) = content.strip_prefix("@let ") {
316316 let rest = rest.trim();
317317 if let Some((name, value)) = rest.split_once(' ') {
318− track_var_refs(value.trim(), &mut ctx.used_variables);
319− let value = substitute_vars(value.trim(), &ctx.variables);
318+ let value = value.trim();
319+ // Support quoted string interpolation: @let greeting "Hello $name"
320+ let value = if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
321+ &value[1..value.len()-1]
322+ } else {
323+ value
324+ };
325+ track_var_refs(value, &mut ctx.used_variables);
326+ let value = substitute_vars(value, &ctx.variables);
320327 if name.starts_with("--") {
321328 // CSS custom property
322329 ctx.css_vars.push((name.to_string(), value.clone()));
@@ -607,22 +614,21 @@ impl Parser {
607614 if let Some(rest) = content.strip_prefix("@each ") {
608615 let rest = rest.trim();
609616 // Support: @each $var in list OR @each $var, $index in list
610− let (var_name, index_var, list_str) = if let Some((before_in, after_in)) = rest.split_once(" in ") {
617+ // OR @each $name, $url in Alice /alice, Bob /bob (destructuring)
618+ let (var_names, list_str) = if let Some((before_in, after_in)) = rest.split_once(" in ") {
611619 let before_in = before_in.trim();
612− if let Some((var_part, idx_part)) = before_in.split_once(',') {
613− let var = var_part.trim().strip_prefix('$').unwrap_or(var_part.trim()).to_string();
614− let idx = idx_part.trim().strip_prefix('$').unwrap_or(idx_part.trim()).to_string();
615− (var, Some(idx), after_in.trim().to_string())
616− } else {
617− let var = before_in.strip_prefix('$').unwrap_or(before_in).to_string();
618− (var, None, after_in.trim().to_string())
619− }
620+ let vars: Vec<String> = before_in.split(',')
621+ .map(|v| v.trim().strip_prefix('$').unwrap_or(v.trim()).to_string())
622+ .collect();
623+ (vars, after_in.trim().to_string())
620624 } else {
621625 return Err(ParseError {
622626 line: line_num,
623627 message: "@each requires: @each $var in list".to_string(),
624628 });
625629 };
630+ let var_name = var_names[0].clone();
631+ let index_var = var_names.get(1).cloned();
626632
627633 let list_str = substitute_vars(&list_str, &ctx.variables);
628634 track_var_refs(&list_str, &mut ctx.used_variables);
@@ -665,10 +671,22 @@ impl Parser {
665671 let saved_vars = ctx.variables.clone();
666672 let mut all_nodes = Vec::new();
667673
674+ let has_extra_vars = var_names.len() > 2
675+ || (var_names.len() == 2 && items.first().map_or(false, |it| it.contains(' ')));
676+
668677 for (i, item) in items.iter().enumerate() {
669− ctx.variables.insert(var_name.clone(), item.clone());
670− if let Some(ref idx_name) = index_var {
671− ctx.variables.insert(idx_name.clone(), i.to_string());
678+ if has_extra_vars {
679+ // Destructuring: split item by spaces and assign to each variable
680+ let parts: Vec<&str> = item.splitn(var_names.len(), ' ').collect();
681+ for (vi, vn) in var_names.iter().enumerate() {
682+ let val = parts.get(vi).unwrap_or(&"").to_string();
683+ ctx.variables.insert(vn.clone(), val);
684+ }
685+ } else {
686+ ctx.variables.insert(var_name.clone(), item.clone());
687+ if let Some(ref idx_name) = index_var {
688+ ctx.variables.insert(idx_name.clone(), i.to_string());
689+ }
672690 }
673691 let mut body_parser = Parser {
674692 lines: adjusted.clone(),
@@ -1126,6 +1144,8 @@ const KNOWN_ELEMENTS: &[&str] = &[
11261144 "list", "item", "li",
11271145 "table", "thead", "tbody", "tr", "td", "th",
11281146 "video", "audio",
1147+ "form", "details", "summary", "blockquote", "cite", "code", "pre", "hr", "divider",
1148+ "figure", "figcaption", "progress", "meter",
11291149];
11301150
11311151const KNOWN_DIRECTIVES: &[&str] = &[
@@ -1168,6 +1188,18 @@ fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseErro
11681188 "th" => Ok(ElementKind::TableHeaderCell),
11691189 "video" => Ok(ElementKind::Video),
11701190 "audio" => Ok(ElementKind::Audio),
1191+ "form" => Ok(ElementKind::Form),
1192+ "details" => Ok(ElementKind::Details),
1193+ "summary" => Ok(ElementKind::Summary),
1194+ "blockquote" => Ok(ElementKind::Blockquote),
1195+ "cite" => Ok(ElementKind::Cite),
1196+ "code" => Ok(ElementKind::Code),
1197+ "pre" => Ok(ElementKind::Pre),
1198+ "hr" | "divider" => Ok(ElementKind::HorizontalRule),
1199+ "figure" => Ok(ElementKind::Figure),
1200+ "figcaption" => Ok(ElementKind::FigCaption),
1201+ "progress" => Ok(ElementKind::Progress),
1202+ "meter" => Ok(ElementKind::Meter),
11711203 _ => {
11721204 let all_known: Vec<&str> = KNOWN_ELEMENTS
11731205 .iter()
@@ -1266,6 +1298,27 @@ const KNOWN_ATTRS: &[&str] = &[
12661298 "ordered",
12671299 // Media src (explicit attribute form)
12681300 "src",
1301+ // New elements
1302+ "open", "novalidate", "low", "high", "optimum",
1303+ "colspan", "rowspan", "scope",
1304+ // Margin
1305+ "margin", "margin-x", "margin-y",
1306+ // Filter & object
1307+ "filter", "object-fit", "object-position",
1308+ // Text extras
1309+ "text-shadow", "text-overflow",
1310+ // Interaction
1311+ "pointer-events", "user-select",
1312+ // Flexbox/grid alignment
1313+ "justify-content", "align-items",
1314+ // Flex item
1315+ "order",
1316+ // Background extras
1317+ "background-size", "background-position", "background-repeat",
1318+ // Text wrapping
1319+ "word-break", "overflow-wrap",
1320+ // Asset inlining
1321+ "inline",
12691322];
12701323
12711324/// Attributes that expect purely numeric values (px-based) or values with CSS units.
@@ -1304,6 +1357,10 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext
13041357 .or_else(|| base_key.strip_prefix("lg:"))
13051358 .or_else(|| base_key.strip_prefix("xl:"))
13061359 .unwrap_or(base_key);
1360+ let base_key = base_key
1361+ .strip_prefix("dark:")
1362+ .or_else(|| base_key.strip_prefix("print:"))
1363+ .unwrap_or(base_key);
13071364
13081365 if let Some(val) = &attr.value {
13091366 if NUMERIC_ATTRS.contains(&base_key) {
@@ -1423,8 +1480,17 @@ fn parse_attr_brackets_inner(
14231480 Ok((attrs, remaining))
14241481}
14251482
1483+fn is_valid_hex_color(s: &str) -> bool {
1484+ if !s.starts_with('#') {
1485+ return true; // Not a hex color, skip
1486+ }
1487+ let hex = &s[1..];
1488+ matches!(hex.len(), 3 | 4 | 6 | 8) && hex.chars().all(|c| c.is_ascii_hexdigit())
1489+}
1490+
14261491fn parse_attr_list(input: &str, line_num: usize, ctx: &mut ParseContext, validate: bool) -> Vec<Attribute> {
14271492 let mut attrs = Vec::new();
1493+ let mut seen_keys: Vec<String> = Vec::new();
14281494
14291495 for part in split_commas(input) {
14301496 let part = part.trim();
@@ -1458,6 +1524,35 @@ fn parse_attr_list(input: &str, line_num: usize, ctx: &mut ParseContext, validat
14581524 }
14591525 };
14601526
1527+ // Warn on duplicate attributes
1528+ if validate {
1529+ let stripped = strip_all_prefixes(&attr.key).to_string();
1530+ if seen_keys.contains(&stripped) {
1531+ ctx.diagnostics.push(Diagnostic {
1532+ line: line_num,
1533+ message: format!("duplicate attribute '{}'", attr.key),
1534+ severity: Severity::Warning,
1535+ source_line: None,
1536+ });
1537+ } else {
1538+ seen_keys.push(stripped);
1539+ }
1540+
1541+ // Color validation for hex colors
1542+ if matches!(strip_all_prefixes(&attr.key), "background" | "color") {
1543+ if let Some(ref val) = attr.value {
1544+ if val.starts_with('#') && !is_valid_hex_color(val) {
1545+ ctx.diagnostics.push(Diagnostic {
1546+ line: line_num,
1547+ message: format!("invalid hex color '{}'", val),
1548+ severity: Severity::Warning,
1549+ source_line: None,
1550+ });
1551+ }
1552+ }
1553+ }
1554+ }
1555+
14611556 // Warn on unknown attributes
14621557 if validate {
14631558 let base_key = attr.key.as_str();
@@ -1474,6 +1569,11 @@ fn parse_attr_list(input: &str, line_num: usize, ctx: &mut ParseContext, validat
14741569 .or_else(|| base_key.strip_prefix("lg:"))
14751570 .or_else(|| base_key.strip_prefix("xl:"))
14761571 .unwrap_or(base_key);
1572+ // Strip media prefixes (dark:, print:)
1573+ let base_key = base_key
1574+ .strip_prefix("dark:")
1575+ .or_else(|| base_key.strip_prefix("print:"))
1576+ .unwrap_or(base_key);
14771577 if !KNOWN_ATTRS.contains(&base_key)
14781578 && !base_key.starts_with("aria-")
14791579 && !base_key.starts_with("data-")
@@ -1665,10 +1765,13 @@ fn strip_all_prefixes(key: &str) -> &str {
16651765 .or_else(|| key.strip_prefix("active:"))
16661766 .or_else(|| key.strip_prefix("focus:"))
16671767 .unwrap_or(key);
1668− key.strip_prefix("sm:")
1768+ let key = key.strip_prefix("sm:")
16691769 .or_else(|| key.strip_prefix("md:"))
16701770 .or_else(|| key.strip_prefix("lg:"))
16711771 .or_else(|| key.strip_prefix("xl:"))
1772+ .unwrap_or(key);
1773+ key.strip_prefix("dark:")
1774+ .or_else(|| key.strip_prefix("print:"))
16721775 .unwrap_or(key)
16731776}
16741777
@@ -1713,6 +1816,18 @@ fn element_kind_name(kind: &ElementKind) -> &'static str {
17131816 ElementKind::TableHeaderCell => "@th",
17141817 ElementKind::Video => "@video",
17151818 ElementKind::Audio => "@audio",
1819+ ElementKind::Form => "@form",
1820+ ElementKind::Details => "@details",
1821+ ElementKind::Summary => "@summary",
1822+ ElementKind::Blockquote => "@blockquote",
1823+ ElementKind::Cite => "@cite",
1824+ ElementKind::Code => "@code",
1825+ ElementKind::Pre => "@pre",
1826+ ElementKind::HorizontalRule => "@hr",
1827+ ElementKind::Figure => "@figure",
1828+ ElementKind::FigCaption => "@figcaption",
1829+ ElementKind::Progress => "@progress",
1830+ ElementKind::Meter => "@meter",
17161831 }
17171832}
17181833
@@ -1722,6 +1837,8 @@ fn is_container(kind: &ElementKind) -> bool {
17221837 | ElementKind::Nav | ElementKind::Header | ElementKind::Footer
17231838 | ElementKind::Main | ElementKind::Section | ElementKind::Article
17241839 | ElementKind::Aside | ElementKind::List | ElementKind::ListItem
1840+ | ElementKind::Form | ElementKind::Details | ElementKind::Figure
1841+ | ElementKind::Blockquote
17251842 )
17261843}
17271844
@@ -1842,6 +1959,18 @@ fn validate_tree(
18421959 });
18431960 }
18441961 }
1962+ // Missing alt text on @image
1963+ if matches!(elem.kind, ElementKind::Image) {
1964+ if !elem.attrs.iter().any(|a| a.key == "alt") {
1965+ diagnostics.push(Diagnostic {
1966+ line: elem.line_num,
1967+ message: "@image missing 'alt' attribute (accessibility)".to_string(),
1968+ severity: Severity::Warning,
1969+ source_line: None,
1970+ });
1971+ }
1972+ }
1973+
18451974 validate_tree(&elem.children, Some(&elem.kind), diagnostics);
18461975 }
18471976 }
tests/snapshots.rs+325
@@ -943,3 +943,328 @@ fn string_interpolation_in_bare_text() {
943943 let output = compile("@let greeting Hi\n@el\n $greeting there");
944944 assert!(output.contains("Hi there"));
945945}
946+
947+// ---------------------------------------------------------------------------
948+// Snapshot tests for batch 3 features
949+// ---------------------------------------------------------------------------
950+
951+#[test]
952+fn snapshot_new_elements() {
953+ snapshot_test("new_elements");
954+}
955+
956+#[test]
957+fn snapshot_dark_mode() {
958+ snapshot_test("dark_mode");
959+}
960+
961+#[test]
962+fn snapshot_new_css() {
963+ snapshot_test("new_css");
964+}
965+
966+#[test]
967+fn snapshot_string_interpolation() {
968+ snapshot_test("string_interpolation");
969+}
970+
971+#[test]
972+fn snapshot_each_destructuring() {
973+ snapshot_test("each_destructuring");
974+}
975+
976+// ---------------------------------------------------------------------------
977+// Feature tests: new elements
978+// ---------------------------------------------------------------------------
979+
980+#[test]
981+fn form_renders_form_tag() {
982+ let output = compile("@page T\n@form [method post] /submit\n @input [type text]");
983+ assert!(output.contains("<form"));
984+ assert!(output.contains("action=\"/submit\""));
985+ assert!(output.contains("method=\"post\""));
986+}
987+
988+#[test]
989+fn details_summary_renders() {
990+ let output = compile("@page T\n@details [open]\n @summary Click me\n @text Content");
991+ assert!(output.contains("<details"));
992+ assert!(output.contains(" open"));
993+ assert!(output.contains("<summary"));
994+ assert!(output.contains("Click me"));
995+}
996+
997+#[test]
998+fn blockquote_renders() {
999+ let output = compile("@page T\n@blockquote\n @text A quote\n @cite Source");
1000+ assert!(output.contains("<blockquote"));
1001+ assert!(output.contains("<cite"));
1002+ assert!(output.contains("Source"));
1003+}
1004+
1005+#[test]
1006+fn code_renders_monospace() {
1007+ let output = compile("@page T\n@code hello");
1008+ assert!(output.contains("<code"));
1009+ assert!(output.contains("font-family:ui-monospace,monospace"));
1010+}
1011+
1012+#[test]
1013+fn pre_renders_with_whitespace() {
1014+ let output = compile("@page T\n@pre\n @text formatted");
1015+ assert!(output.contains("<pre"));
1016+ assert!(output.contains("white-space:pre"));
1017+}
1018+
1019+#[test]
1020+fn hr_renders_self_closing() {
1021+ let output = compile("@page T\n@hr");
1022+ assert!(output.contains("<hr"));
1023+}
1024+
1025+#[test]
1026+fn divider_alias_for_hr() {
1027+ let output = compile("@page T\n@divider");
1028+ assert!(output.contains("<hr"));
1029+}
1030+
1031+#[test]
1032+fn figure_figcaption_renders() {
1033+ let output = compile("@page T\n@figure\n @image [alt test] photo.jpg\n @figcaption Caption");
1034+ assert!(output.contains("<figure"));
1035+ assert!(output.contains("<figcaption"));
1036+ assert!(output.contains("Caption"));
1037+}
1038+
1039+#[test]
1040+fn progress_renders() {
1041+ let output = compile("@page T\n@progress [value 70, max 100]");
1042+ assert!(output.contains("<progress"));
1043+ assert!(output.contains("value=\"70\""));
1044+ assert!(output.contains("max=\"100\""));
1045+}
1046+
1047+#[test]
1048+fn meter_renders() {
1049+ let output = compile("@page T\n@meter [value 6, min 0, max 10]");
1050+ assert!(output.contains("<meter"));
1051+ assert!(output.contains("value=\"6\""));
1052+}
1053+
1054+// ---------------------------------------------------------------------------
1055+// Feature tests: dark mode
1056+// ---------------------------------------------------------------------------
1057+
1058+#[test]
1059+fn dark_mode_generates_media_query() {
1060+ let output = compile("@page T\n@el [background white, dark:background #333]");
1061+ assert!(output.contains("prefers-color-scheme:dark"));
1062+ assert!(output.contains("background:#333"));
1063+}
1064+
1065+#[test]
1066+fn print_generates_media_query() {
1067+ let output = compile("@page T\n@el [display flex, print:display none]");
1068+ assert!(output.contains("@media print"));
1069+ assert!(output.contains("display:none"));
1070+}
1071+
1072+// ---------------------------------------------------------------------------
1073+// Feature tests: new CSS attributes
1074+// ---------------------------------------------------------------------------
1075+
1076+#[test]
1077+fn css_margin() {
1078+ let output = compile("@page T\n@el [margin 20]");
1079+ assert!(output.contains("margin:20px"));
1080+}
1081+
1082+#[test]
1083+fn css_margin_x() {
1084+ let output = compile("@page T\n@el [margin-x 10]");
1085+ assert!(output.contains("margin-left:10px"));
1086+ assert!(output.contains("margin-right:10px"));
1087+}
1088+
1089+#[test]
1090+fn css_margin_y() {
1091+ let output = compile("@page T\n@el [margin-y 10]");
1092+ assert!(output.contains("margin-top:10px"));
1093+ assert!(output.contains("margin-bottom:10px"));
1094+}
1095+
1096+#[test]
1097+fn css_filter() {
1098+ let output = compile("@page T\n@el [filter blur(5px)]");
1099+ assert!(output.contains("filter:blur(5px)"));
1100+}
1101+
1102+#[test]
1103+fn css_object_fit() {
1104+ let output = compile("@page T\n@image [object-fit cover, width 200] test.jpg");
1105+ assert!(output.contains("object-fit:cover"));
1106+}
1107+
1108+#[test]
1109+fn css_text_shadow() {
1110+ let output = compile("@page T\n@text [text-shadow 1px 1px black] Hello");
1111+ assert!(output.contains("text-shadow:1px 1px black"));
1112+}
1113+
1114+#[test]
1115+fn css_text_overflow() {
1116+ let output = compile("@page T\n@text [text-overflow ellipsis] Hello");
1117+ assert!(output.contains("text-overflow:ellipsis"));
1118+}
1119+
1120+#[test]
1121+fn css_pointer_events() {
1122+ let output = compile("@page T\n@el [pointer-events none]");
1123+ assert!(output.contains("pointer-events:none"));
1124+}
1125+
1126+#[test]
1127+fn css_user_select() {
1128+ let output = compile("@page T\n@el [user-select none]");
1129+ assert!(output.contains("user-select:none"));
1130+}
1131+
1132+#[test]
1133+fn css_justify_content() {
1134+ let output = compile("@page T\n@row [justify-content space-between]");
1135+ assert!(output.contains("justify-content:space-between"));
1136+}
1137+
1138+#[test]
1139+fn css_align_items() {
1140+ let output = compile("@page T\n@row [align-items center]");
1141+ assert!(output.contains("align-items:center"));
1142+}
1143+
1144+#[test]
1145+fn css_order() {
1146+ let output = compile("@page T\n@el [order 2]");
1147+ assert!(output.contains("order:2"));
1148+}
1149+
1150+#[test]
1151+fn css_background_size() {
1152+ let output = compile("@page T\n@el [background-size cover]");
1153+ assert!(output.contains("background-size:cover"));
1154+}
1155+
1156+#[test]
1157+fn css_word_break() {
1158+ let output = compile("@page T\n@el [word-break break-all]");
1159+ assert!(output.contains("word-break:break-all"));
1160+}
1161+
1162+#[test]
1163+fn css_overflow_wrap() {
1164+ let output = compile("@page T\n@el [overflow-wrap break-word]");
1165+ assert!(output.contains("overflow-wrap:break-word"));
1166+}
1167+
1168+// ---------------------------------------------------------------------------
1169+// Feature tests: string interpolation
1170+// ---------------------------------------------------------------------------
1171+
1172+#[test]
1173+fn let_string_interpolation() {
1174+ let output = compile("@let name World\n@let greeting \"Hello $name\"\n@text $greeting");
1175+ assert!(output.contains("Hello World"));
1176+}
1177+
1178+// ---------------------------------------------------------------------------
1179+// Feature tests: @each destructuring
1180+// ---------------------------------------------------------------------------
1181+
1182+#[test]
1183+fn each_destructuring_pairs() {
1184+ let output = compile("@each $name, $role in Alice Admin, Bob User\n @text $name is $role");
1185+ assert!(output.contains("Alice is Admin"));
1186+ assert!(output.contains("Bob is User"));
1187+}
1188+
1189+// ---------------------------------------------------------------------------
1190+// Feature tests: diagnostics
1191+// ---------------------------------------------------------------------------
1192+
1193+#[test]
1194+fn warning_missing_alt_on_image() {
1195+ let diags = parse_diagnostics("@image photo.jpg");
1196+ assert!(
1197+ diags.iter().any(|d| d.message.contains("alt") && d.message.contains("accessibility")),
1198+ "expected missing alt warning, got: {:?}",
1199+ diags
1200+ );
1201+}
1202+
1203+#[test]
1204+fn no_warning_with_alt_on_image() {
1205+ let diags = parse_diagnostics("@image [alt A photo] photo.jpg");
1206+ assert!(
1207+ !diags.iter().any(|d| d.message.contains("missing") && d.message.contains("alt")),
1208+ "should not warn when alt is present, got: {:?}",
1209+ diags
1210+ );
1211+}
1212+
1213+#[test]
1214+fn warning_invalid_hex_color() {
1215+ let diags = parse_diagnostics("@el [color #ggg]");
1216+ assert!(
1217+ diags.iter().any(|d| d.message.contains("invalid hex color")),
1218+ "expected invalid hex color warning, got: {:?}",
1219+ diags
1220+ );
1221+}
1222+
1223+#[test]
1224+fn no_warning_valid_hex_color() {
1225+ let diags = parse_diagnostics("@el [color #ff0000]");
1226+ assert!(
1227+ !diags.iter().any(|d| d.message.contains("invalid hex color")),
1228+ "should not warn on valid hex color, got: {:?}",
1229+ diags
1230+ );
1231+}
1232+
1233+#[test]
1234+fn warning_duplicate_attribute() {
1235+ let diags = parse_diagnostics("@el [padding 10, padding 20]");
1236+ assert!(
1237+ diags.iter().any(|d| d.message.contains("duplicate attribute")),
1238+ "expected duplicate attribute warning, got: {:?}",
1239+ diags
1240+ );
1241+}
1242+
1243+#[test]
1244+fn no_warning_different_attributes() {
1245+ let diags = parse_diagnostics("@el [padding 10, margin 20]");
1246+ assert!(
1247+ !diags.iter().any(|d| d.message.contains("duplicate")),
1248+ "should not warn on different attributes, got: {:?}",
1249+ diags
1250+ );
1251+}
1252+
1253+// ---------------------------------------------------------------------------
1254+// Feature tests: formatter improvements
1255+// ---------------------------------------------------------------------------
1256+
1257+#[test]
1258+fn fmt_sorts_attributes() {
1259+ let formatted = htmlang::fmt::format("@el [color red, width 200, padding 10]");
1260+ // width (sizing) should come before padding (spacing) which should come before color (visual)
1261+ assert!(formatted.contains("[width 200, padding 10, color red]"));
1262+}
1263+
1264+#[test]
1265+fn fmt_multiline_brackets() {
1266+ let input = "@el [\n color red,\n width 200\n]\n @text hello";
1267+ let formatted = htmlang::fmt::format(input);
1268+ assert!(formatted.contains("[width 200, color red]"));
1269+ assert!(formatted.contains(" @text hello"));
1270+}
tests/snapshots/dark_mode.hlnew file+10
@@ -0,0 +1,10 @@
1+@page Dark Mode
2+
3+@el [background white, dark:background #1a1a2e, color #333, dark:color #eee, padding 20]
4+ @text [size 24, bold] Hello World
5+
6+@el [border 1 #ccc, dark:border 1 #444, rounded 8, padding 16]
7+ @text Theme-aware card
8+
9+@el [background #f0f0f0, dark:background #333, print:display none, padding 10]
10+ @text Not printed
tests/snapshots/each_destructuring.hlnew file+6
@@ -0,0 +1,6 @@
1+@page Destructuring
2+
3+@each $name, $role in Alice Admin, Bob User, Carol Editor
4+ @row [spacing 10]
5+ @text [bold] $name
6+ @text $role
tests/snapshots/new_css.hlnew file+24
@@ -0,0 +1,24 @@
1+@page New CSS
2+
3+@el [margin 10, margin-x 20, margin-y 30]
4+ @text Margins
5+
6+@image [width 200, height 200, object-fit cover, object-position center] photo.jpg
7+
8+@el [filter blur(5px), pointer-events none, user-select none]
9+ @text Filtered
10+
11+@row [justify-content space-between, align-items center]
12+ @text Left
13+ @text Right
14+
15+@el [order 2]
16+ @text Second
17+
18+@el [background url(bg.jpg), background-size cover, background-position center, background-repeat no-repeat]
19+ @text Background
20+
21+@text [text-shadow 1px 1px 2px black, text-overflow ellipsis] Shadowed
22+
23+@el [word-break break-all, overflow-wrap break-word]
24+ @text Longtext
tests/snapshots/new_elements.hlnew file+27
@@ -0,0 +1,27 @@
1+@page New Elements
2+
3+@form [method post, action /submit]
4+ @label [for email] Email
5+ @input [type email, name email, required]
6+ @button [type submit] Submit
7+
8+@hr
9+
10+@details [open]
11+ @summary Click to expand
12+ @text Hidden content here
13+
14+@blockquote [padding 20]
15+ @text To be or not to be
16+ @cite Shakespeare
17+
18+@figure
19+ @image [alt A code example, width 200] example.png
20+ @figcaption A code example
21+
22+@pre
23+ @code console.log("hello")
24+
25+@progress [value 70, max 100]
26+
27+@meter [value 6, min 0, max 10, low 3, high 8]
tests/snapshots/string_interpolation.hlnew file+10
@@ -0,0 +1,10 @@
1+@page Interpolation
2+
3+@let name World
4+@let greeting "Hello $name"
5+
6+@text $greeting
7+
8+@let base /api
9+@let url "$base/users"
10+@link $url API Users