on master help sign in
Simplify the language: remove duplicate directives and aliases
56a38fciko
DESIGN.md+270−1156
@@ -4,76 +4,26 @@ A minimalist layout language inspired by elm-ui that compiles to static HTML.
4
4
5## Principles
5## Principles
6
6
7−- `@` means structure, bare lines mean content
7+- `@` means structure, bare lines mean content.
8−- Layout is explicit and compositional — no CSS cascade
8+- Layout is explicit and compositional: every element declares its own layout
9−- Every element declares its own layout role
9+ role, and there is no CSS cascade to reason about.
10−- Output is a self-contained HTML file with embedded CSS (flexbox)
10+- Output is one self-contained HTML file with embedded CSS (flexbox), and no
11+ JavaScript unless you write some.
11
12
12−## Elements
13+Every example block in this file is compiled by the test suite
13−
14+(`tests/docs.rs`), so the examples stay correct.
14−| Element | Output | Purpose |
15−|--------------|------------------|--------------------------------|
16−| `@row` | flex row | Horizontal layout |
17−| `@column` | flex column | Vertical layout |
18−| `@el` | div | Generic container |
19−| `@text` | span | Styled inline text |
20−| `@paragraph` | p with spans | Flowing/wrapping inline text |
21−| `@image` | img | Image |
22−| `@link` | a | Anchor wrapping children |
23−| `@raw` | verbatim HTML | Escape hatch |
24−| `@nav` | nav | Navigation landmark |
25−| `@header` | header | Page/section header |
26−| `@footer` | footer | Page/section footer |
27−| `@main` | main | Main content area |
28−| `@section` | section | Thematic section |
29−| `@article` | article | Self-contained content |
30−| `@aside` | aside | Sidebar/tangential content |
31−| `@list` | ul/ol | List (`[ordered]` for ol) |
32−| `@item` | li | List item |
33−| `@table` | table | Table |
34−| `@thead` | thead | Table head group |
35−| `@tbody` | tbody | Table body group |
36−| `@tr` | tr | Table row |
37−| `@td` | td | Table cell |
38−| `@th` | th | Table header cell |
39−| `@video` | video | Video element |
40−| `@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 |
53−| `@iframe` | iframe | Embedded external page |
54−| `@output` | output | Form calculation result |
55−| `@canvas` | canvas | Drawing surface for scripts |
56−
57−| `@script` | script | JavaScript (inline or external) |
58−| `@noscript` | noscript | Fallback when JS is disabled |
59−| `@address` | address | Contact information |
60−| `@search` | search | Search landmark (HTML5) |
61−| `@breadcrumb` | nav>ol | Semantic breadcrumb navigation |
62−| `@in-front` | div (absolute) | Overlay layer painted on top of parent |
63−| `@behind` | div (absolute) | Layer painted behind the parent's content |
64−
65−Bare lines (not starting with `@` or `[`) are text nodes.
66
15
67## Syntax
16## Syntax
68
17
69−### Basic structure
18+### Structure
70
19
71```
20```
72−@element [attributes]
21+@element [attributes] argument
73 children
22 children
74```
23```
75
24
76−Children are indented under their parent. Attributes are optional, comma-separated inside `[...]`. Attribute lists can span multiple lines:
25+Children are indented under their parent. Attributes are comma-separated inside
26+`[...]` and may span several lines:
77
27
78```
28```
79@el [
29@el [
@@ -85,272 +35,24 @@ Children are indented under their parent. Attributes are optional, comma-separat
85 Content
35 Content
86```
36```
87
37
88−### Full example
38+A comma inside `(...)` or `"..."` does not split attributes, so a font stack is
89−
39+written `font "Inter, sans-serif"`.
90−```
91−-- My Site
92−@page My Site
93−@let primary #3b82f6
94−@let gap 20
95−@define card [
96− padding 20,
97− background white,
98− rounded 8,
99− border 1 #e5e7eb,
100− shadow 0 2px 4px rgba(0,0,0,0.05)
101−]
102−
103−@include header.hl
104−
105−@column [max-width 800, center-x, padding 40, spacing $gap]
106− @row [spacing $gap]
107− @column [width fill, spacing 10]
108− @text [bold, size 32] Welcome
109− @paragraph [line-height 1.6]
110− This is a page built with {@text [bold, color $primary] htmlang}.
111− Read the {@link https://docs.example.com docs} to learn more.
112− @image [width 80, height 80, rounded 40] avatar.png
113−
114− -- cards
115− @row [wrap, spacing 10]
116− [$card]
117− First card
118− [$card, background #f9fafb]
119− Second card
120−
121− -- footer
122− @row [spacing 10]
123− @el [padding 16, background $primary, rounded 8] > @link https://example.com
124− @text [color white] Get Started
125− @el [width fill]
126− @text [color #888, text-align right] © 2026
127−
128− @raw """
129− <canvas id="chart"></canvas>
130− """
131−```
132−
133−## Directives
134−
135−### `@page`
136−
137−Sets the HTML `<title>` and generates boilerplate (`<!DOCTYPE>`, `<html>`, `<head>`, `<body>`).
138−
139−```
140−@page My Site
141−```
142−
143−### `@let`
144−
145−Defines a variable. Referenced with `$name`.
146−
147−```
148−@let primary #3b82f6
149−@let gap 20
150−
151−@el [background $primary, spacing $gap]
152−```
153−
154−Quoted values support string interpolation:
155−
156−```
157−@let name World
158−@let greeting "Hello $name" -- "Hello World"
159−@let base /api
160−@let url "$base/users" -- "/api/users"
161−```
162−
163−### `@include`
164−
165−Inlines 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.
166−
167−```
168−@include header.hl
169−@include components/card.hl
170−```
171−
172−Variables can be used in the filename:
173−
174−```
175−@let component card
176−@include $component.hl
177−```
178−
179−Circular includes are detected and reported as errors. Nested includes are supported.
180−
181−### `@import`
182−
183−Like `@include`, but only imports definitions (`@let`, `@define`, `@fn`) without emitting DOM nodes. Use this for shared theme/component libraries.
184−
185−```
186−@import theme.hl -- imports variables, defines, functions only
187−@include header.hl -- inlines everything including DOM nodes
188−```
189−
190−### `@data`
191−
192−Loads a JSON file and makes its values available as template variables. Useful for data-driven static sites.
193−
194−```
195−@data site.json -- top-level object keys become variables
196−@data $posts data/posts.json -- load with a prefix
197−```
198−
199−For a JSON file like:
200−```json
201−{
202− "title": "My Site",
203− "tags": ["rust", "html", "css"],
204− "links": [
205− {"label": "Home", "url": "/"},
206− {"label": "About", "url": "/about"}
207− ]
208−}
209−```
210−
211−Without prefix (`@data site.json`):
212−- `$title` → `"My Site"`
213−- `$tags` → iterable with `@each`
214−- `$links` → destructurable: `@each $label, $url in $links`
215−
216−With prefix (`@data $site site.json`):
217−- `$site.title` → `"My Site"`
218−- `$site.tags` → iterable
219−- `$site.links` → destructurable
220−
221−Arrays of objects are automatically formatted for `@each` destructuring. Indexed access is also available: `$links.0.label`, `$links.1.url`, etc. The count is available as `$links._count`.
222−
223−### `@meta`
224−
225−Adds a `<meta>` tag to the document `<head>`. Requires `@page`.
226−
227−```
228−@meta description A portfolio site
229−@meta og:image https://example.com/preview.png
230−```
231−
232−### `@head`
233−
234−Adds raw content to the document `<head>`. Use for external fonts, favicons, or custom CSS/JS.
235−
236−```
237−@head
238− <link rel="icon" href="favicon.ico">
239− <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter">
240−```
241−
242−### `@define`
243−
244−Creates a named attribute bundle. Referenced with `$name` inside attribute lists.
245−
246−```
247−@define card [padding 20, background white, rounded 8, border 1 #e5e7eb]
248−
249−@el [$card]
250− Content
251−@el [$card, background #f9fafb]
252− Overridden background
253−```
254−
255−Attributes listed after `$name` override those in the definition.
256−
257−### `@mixin`
258−
259−Creates a composable style group. Like `@define`, but intended for reusable style patterns. Referenced with `...$name` (spread syntax) or `$name` inside attribute lists.
260−
261−```
262−@mixin card-style [padding 20, rounded 8, background white]
263−@mixin interactive [cursor pointer, hover:background #e5e7eb, transition all 0.15s ease]
264−
265−@el [...$card-style, ...$interactive, border 1 #ccc]
266− Composed styles from two mixins
267−```
268−
269−### `@assert`
270−
271−Compile-time assertion. If the condition is false, a compile error is emitted. Useful for enforcing design system constraints.
272−
273−```
274−@let spacing 16
275−@let primary #3b82f6
276−
277−@assert $spacing == 16
278−@assert $primary != red
279−```
280−
281−### `@fn`
282−
283−Defines a pure function (reusable component). Parameters are prefixed with `$`. Default values can be specified with `=`.
284−
285−```
286−@fn card $title $variant=primary
287− @el [padding 20, background white, rounded 8, border 1 #e5e7eb]
288− @text [bold, size 18] $title
289− @children
290−```
291−
292−Call it like any element, passing parameters in `[...]`:
293−
294−```
295−@card [title Hello World]
296− This is the card body.
297− @text [italic] With styled text.
298−
299−@card [title Warning, variant danger]
300− Overridden variant.
301−```
302−
303−Parameters with defaults can be omitted in calls — the default value is used.
304
40
305−### `@children`
41+### Text
306
42
307−A slot inside a function body that expands to the caller's indented children.
43+Any line that doesn't start with `@` or `[` is text. Use `@text` when text needs
308−
44+attributes, and `{...}` to put elements inside a line of text:
309−```
310−@fn layout $title
311− @column [max-width 800, center-x, padding 40]
312− @text [bold, size 32] $title
313− @children
314−
315−@layout [title My Page]
316− @row [spacing 10]
317− Content goes here
318−```
319−
320−Functions can call other functions. `@let` and `@define` variables are available inside function bodies. Function parameters shadow `@let` variables of the same name within the body.
321−
322−## Text
323−
324−### Bare text
325−
326−Any line not starting with `@` or `[` is a text node.
327−
328−```
329−@column
330− Hello world
331− This is just text
332−```
333−
334−### Styled text
335−
336−Use `@text` when you need attributes.
337−
338−```
339−@text [bold, size 24, color #333] Hello world
340−```
341−
342−### Inline elements
343−
344−Use `{...}` to embed elements inside text lines. Essential for mixed-style paragraphs.
345
45
346```
46```
347@paragraph
47@paragraph
348 This is {@text [bold] important} and this is a {@link https://example.com link}.
48 This is {@text [bold] important} and this is a {@link https://example.com link}.
49+@text [bold, size 24, color #333] Hello world
349```
50```
350
51
351−## Comments
52+### Comments
352
53
353−`--` starts a comment. The rest of the line is ignored.
54+`--` at the start of a line begins a comment. Comments must be on their own
55+line: `--` later in a line is ordinary text.
354
56
355```
57```
356-- this is a comment
58-- this is a comment
@@ -358,133 +60,85 @@ Use `{...}` to embed elements inside text lines. Essential for mixed-style parag
358 -- todo: add nav items
60 -- todo: add nav items
359```
61```
360
62
361−## Implicit `@el`
63+### Shorthands
362
64
363−A line starting with `[` (attributes) that has indented children is an anonymous container (implicit `@el`).
65+A line starting with `[` is an anonymous `@el`. `>` chains single-child
66+elements on one line; the last element in the chain gets the indented children.
364
67
365```
68```
366[padding 20, background white]
69[padding 20, background white]
367 Hello
70 Hello
368
71
369−-- equivalent to:
370−@el [padding 20, background white]
371− Hello
372−```
373−
374−## Single-child chaining
375−
376−Use `>` to chain single-child elements on one line, reducing nesting depth.
377−
378−```
379@el [padding 16, background blue, rounded 8] > @link https://example.com
72@el [padding 16, background blue, rounded 8] > @link https://example.com
380 @text [color white] Get Started
73 @text [color white] Get Started
381−
382−-- equivalent to:
383−@el [padding 16, background blue, rounded 8]
384− @link https://example.com
385− @text [color white] Get Started
386```
74```
387
75
388−The last element in the chain takes the indented children.
76+### Raw content
389
77
390−## `@raw`
78+`@raw """ ... """` is pasted into the output verbatim.
391−
392−Triple-quoted block pasted verbatim into output. Use for arbitrary HTML, CSS, or JS.
393
79
394```
80```
395@raw """
81@raw """
396−<style>
397− @keyframes spin { to { transform: rotate(360deg); } }
398−</style>
399<div class="custom-widget"></div>
82<div class="custom-widget"></div>
400"""
83"""
401```
84```
402
85
403−## Units
86+## Elements
404−
405−All numeric values default to pixels. You can use CSS units explicitly:
406−
407−```
408−@el [width 50%, height 100vh, padding 2rem, max-width 80ch, size 1.2em]
409−```
410−
411−Supported units: `%`, `rem`, `em`, `vh`, `vw`, `vmin`, `vmax`, `dvh`, `svh`, `ch`, `ex`, `cm`, `mm`, `in`, `pt`, `pc`, `fr`.
412−
413−## New elements
414−
415−### `@form`
416−
417−Form container. Argument is the `action` URL.
418
87
419−```
88+Layout elements set up flexbox; the rest map to the HTML element of the same
89+name.
90+
91+| Element | Output | Purpose |
92+|---|---|---|
93+| `@row` | div, flex row | Horizontal layout |
94+| `@column` | div, flex column | Vertical layout |
95+| `@el` | div, flex column | Generic container |
96+| `@grid` | div, grid | Grid container (use `grid-cols`) |
97+| `@stack` | div, position relative | Children layered on top of each other |
98+| `@in-front` / `@behind` | div, absolute | Overlay layers filling the parent (see below) |
99+| `@spacer` | div, flex 1 | Pushes siblings apart |
100+| `@text` | span | Styled inline text |
101+| `@paragraph` | p | Flowing text with inline elements |
102+| `@link url` | a | Link; text after the URL becomes its content |
103+| `@image src` | img | Image |
104+| `@h1` … `@h6` | h1 … h6 | Headings |
105+
106+Semantic containers, all laid out as columns: `@nav`, `@header`, `@footer`,
107+`@main`, `@section`, `@article`, `@aside`, `@address`, `@search`, `@form`,
108+`@details` / `@summary`, `@dialog`, `@figure` / `@figcaption`, `@blockquote` /
109+`@cite`, `@fieldset` / `@legend`, `@noscript`.
110+
111+Content: `@list` / `@item` (`@list [ordered]` for `<ol>`), `@dl` / `@dt` /
112+`@dd`, `@table` / `@thead` / `@tbody` / `@tr` / `@th` / `@td`, `@code`, `@pre`,
113+`@hr`, `@mark`, `@kbd`, `@abbr`, `@time`, `@progress`, `@meter`, `@output`,
114+`@canvas`, `@iframe src`, `@video`, `@audio`, `@picture` / `@source`,
115+`@script`, `@breadcrumb`, `@fragment` (children without a wrapper).
116+
117+Form controls: `@input`, `@button`, `@select` / `@option`, `@textarea`,
118+`@label`, `@datalist`.
119+
120+Styled components: `@badge`, `@chip`, `@tag`, `@avatar`, `@tooltip`,
121+`@carousel`.
122+
123+```
124+@list [ordered]
125+ @item First
126+ @item Second
420−@form [method post] /submit
127+@form [method post] /subscribe
421 @label [for email] Email
128 @label [for email] Email
422− @input [type email, name email, required]
129+ @input [type email, name email, id email, required]
423 @button [type submit] Send
130 @button [type submit] Send
424−```
425−
426−### `@details` / `@summary`
427−
428−Native disclosure widget. Use `[open]` to expand by default.
429−
430−```
431@details [open]
131@details [open]
432− @summary FAQ Question
132+ @summary Question
433− @text The answer is here.
133+ @text The answer.
434−```
435−
436−### `@blockquote` / `@cite`
437−
438−Semantic quotation with optional citation.
439−
440−```
441−@blockquote [padding 20, border-left 4 #ccc]
442− @text To be or not to be
443− @cite Shakespeare
444−```
445−
446−### `@code` / `@pre`
447−
448−Code and preformatted text. `@code` renders as `<code>` with monospace font. `@pre` preserves whitespace.
449−
450−```
451−@pre
452− @code console.log("hello")
453−```
454−
455−### `@hr`
456−
457−Horizontal rule / divider (self-closing). Alias: `@divider`.
458−
459−```
460−@hr [border-top 1 #e5e7eb]
461−```
462−
463−### `@figure` / `@figcaption`
464−
465−Figure with optional caption.
466−
467−```
468−@figure
469− @image [alt Sunset, width fill] sunset.jpg
470− @figcaption A beautiful sunset
471−```
472−
473−### `@progress` / `@meter`
474−
475−Progress bar and meter elements. Use `value`, `max`, `min` attributes.
476−
477−```
478−@progress [value 70, max 100]
479−@meter [value 6, min 0, max 10, low 3, high 8]
480```
134```
481
135
482### `@in-front` / `@behind`
136### `@in-front` / `@behind`
483
137
484−Overlay layers, inspired by elm-ui's `inFront` and `behind`. Children of these
138+Overlay layers, like elm-ui's `inFront` and `behind`. Their children fill the
485−directives are absolutely positioned to fill the parent's bounds; the parent
486−automatically becomes a positioning context (`position: relative` and
139+parent's bounds; the parent automatically becomes a positioning context
487−`isolation: isolate`).
140+(`position: relative; isolation: isolate`). An explicit `position` on the parent
141+wins.
488
142
489```
143```
490@el [width 200, height 200, background blue]
144@el [width 200, height 200, background blue]
@@ -495,819 +149,279 @@ automatically becomes a positioning context (`position: relative` and
495 @el [background red, width fill, height fill]
149 @el [background red, width fill, height fill]
496```
150```
497
151
498−`@in-front` paints on top of the parent's content (DOM order ensures it
152+## Attributes
499−overlays static siblings). `@behind` paints behind (`z-index: -1` keeps it
500−under the siblings, while `isolation: isolate` on the parent prevents it from
501−slipping behind the parent's own background).
502−
503−If you set `position` explicitly on the parent (e.g., `position absolute` for a
504−popup), that wins over the auto-applied `position: relative`.
505−
506−## `@each` destructuring
507−
508−When `@each` has more than one variable and items contain spaces, values are destructured:
509−
510−```
511−@each $name, $url in Home /, About /about, Contact /contact
512− @link $url $name
513−```
514−
515−`var()` and `calc()` expressions are also passed through as-is.
516
153
517−## `@each` with `@else`
154+### Layout and sizing
518
155
519−When the list is empty, the `@else` block is rendered as a fallback:
156+| Attribute | Effect |
157+|---|---|
158+| `spacing N` | Gap between children (also `gap-x N`, `gap-y N`) |
159+| `padding N` / `padding Y X` / `padding T R B L` | Padding (also `padding-x`, `padding-y`, per side) |
160+| `margin ...` | Margin, same forms as padding |
161+| `width fill` / `width N` / `width shrink` | Take remaining space, exact size, or fit content |
162+| `height fill` / `height N` / `height shrink` | Same for height |
163+| `min-width`, `max-width`, `min-height`, `max-height` | Size limits |
164+| `center-x`, `center-y` | Center within the parent |
165+| `align-left`, `align-right`, `align-top`, `align-bottom` | Align within the parent |
166+| `wrap` | Let a row wrap |
167+| `grid-cols N`, `grid-rows N`, `col-span N`, `row-span N` | Grid layout |
168+| `hidden` | `display: none` |
520
169
521−```
170+### Style
522−@each $item in $items
523− @text $item
524−@else
525− @text [color #888] No items found.
526−```
527
171
528−## `@iframe`
172+| Attribute | Effect |
173+|---|---|
174+| `background COLOR`, `color COLOR` | Colors |
175+| `border N COLOR` (also `border-top` etc.) | Border |
176+| `rounded N` | Border radius |
177+| `shadow VALUE` | Box shadow |
178+| `bold`, `italic`, `underline` | Text style |
179+| `size N` | Font size |
180+| `font NAME` | Font family |
181+| `transition VALUE`, `animation VALUE` | Motion |
529
182
530−Embedded external page. Argument is the `src` URL.
183+Most CSS properties can also be written directly as attributes with the same
184+name and value: `opacity 0.5`, `cursor pointer`, `z-index 10`,
185+`text-align center`, `position absolute`, `grid-template-areas "a b"`, and so on.
531
186
532−```
187+**Units.** A bare number is pixels (`padding 20` is `20px`). Values with a unit,
533−@iframe [width fill, height 400, sandbox] https://example.com
188+keywords and CSS functions are passed through: `width 50%`, `margin 0 auto`,
534−```
189+`max-width min(100%, 800px)`.
535
190
536−Attributes: `sandbox`, `allow`, `allowfullscreen`, `referrerpolicy`.
191+### HTML attributes
537
192
538−## `@canvas`
193+`id`, `class`, and element attributes such as `href`, `type`, `name`, `value`,
194+`placeholder`, `alt`, `for`, `required`, `disabled`, `target`, `rel`, `role`,
195+`tabindex`, `title`, `data-*` and `aria-*` are emitted as HTML attributes.
539
196
540−Drawing surface for JavaScript. Use with `@raw` for scripts.
197+### State and media prefixes
541
198
542−```
199+Prefix a style attribute to apply it conditionally:
543−@canvas [width 400, height 300, id chart]
544−```
545
200
546−## `@output`
201+| Prefix | Applies |
202+|---|---|
203+| `hover:`, `active:`, `focus:`, `focus-visible:`, `focus-within:`, `disabled:`, `checked:`, `visited:`, `target:`, `valid:`, `invalid:`, `empty:`, `placeholder:`, `selection:` | In that state |
204+| `first:`, `last:`, `odd:`, `even:`, `nth(EXPR):` | By position among siblings |
205+| `before:`, `after:` | On the `::before` / `::after` pseudo-element (with `content`) |
206+| `has(SELECTOR):` | When the element contains a match |
207+| `sm:`, `md:`, `lg:`, `xl:`, `2xl:` | From that viewport width up (640–1536px) |
208+| `cq-sm:` … `cq-2xl:` | Container queries |
209+| `dark:`, `print:`, `motion-safe:`, `motion-reduce:`, `landscape:`, `portrait:` | Media conditions |
547
210
548−Form output element for displaying calculation results.
211+`@breakpoint name WIDTH` defines a custom responsive prefix.
549
212
550```
213```
551−@output [for a b] Result
214+@el [padding 16, background #3b82f6, hover:background #2563eb, md:padding 32, dark:background #1e3a8a]
215+ @text [color white] Click me
216+@el [before:content "→ ", before:color red]
217+ Item with an arrow
552```
218```
553
219
554−## Pseudo-elements
220+### Conditional attributes
555−
556−Prefix any style attribute with `before:` or `after:` to apply it to the `::before` or `::after` pseudo-element. Use with `content` to set the generated content.
557
221
558```
222```
559−@el [before:content "→ ", before:color red, before:font-weight bold]
223+@let active true
560− Item with arrow prefix
224+@el [background if($active, blue, gray), bold if $active]
561−
225+ Conditionally styled
562−@el [after:content " ✓", after:color green]
563− Completed item
564```
226```
565
227
566−Content values are automatically quoted unless they are CSS keywords (`none`, `normal`) or CSS functions (`attr()`, `counter()`).
228+## Variables and functions
567−
568−## Conditional attribute values
569
229
570−Use `if(condition, true_value, false_value)` inside attribute values for conditional styling:
230+`@let` defines everything reusable. What it defines depends on its shape:
571
231
572```
232```
573−@let active true
233+-- A value, used as $primary
574−@let theme dark
234+@let primary #3b82f6
575−
235+-- A computed value (= is optional)
576−@el [background if($active, blue, gray)]
236+@let gap = 8 * 2
577− Conditionally styled
237+-- A quoted string: interpolated, never computed
238+@let greeting "Hello $primary"
239+-- An attribute bundle, used as [$card]
240+@let card [padding 20, background white, rounded 8]
241+-- A function: a @let with an indented body
242+@let panel $title $tone=neutral
243+ @el [$card]
244+ @text [bold] $title
245+ @children
578
246
579−@el [color if($theme == dark, white, black)]
247+@panel [title Welcome]
580− Theme-aware text
248+ Body text goes into @children.
249+@el [$card, background #f9fafb]
250+ Attributes after a bundle override it.
581```
251```
582
252
583−Supports equality (`==`), inequality (`!=`), and truthy checks.
253+Function arguments are passed as attributes; parameters with `=default` may be
584−
254+omitted. `@component name $params` defines a function whose indented `@style`
585−## Attributes reference
255+block is scoped to its output. Inside a function, `@children` is replaced by the caller's children,
586−
256+and `@slot name` by the caller's `@slot name` block (the slot's own children are
587−### Layout (set on parent)
257+the default).
588−
589−| Attribute | Effect |
590−|------------------------|----------------------------------|
591−| `spacing N` | Gap between children |
592−| `gap N` | Alias for `spacing` |
593−| `gap-x N` | Horizontal gap (column-gap) |
594−| `gap-y N` | Vertical gap (row-gap) |
595−| `padding N` | Uniform padding |
596−| `padding Y X` | Vertical + horizontal padding |
597−| `padding T H B` | Top + horizontal + bottom |
598−| `padding T R B L` | Per-side padding |
599−| `padding-x N` | Horizontal padding |
600−| `padding-y N` | Vertical padding |
601−
602−### Sizing (set on child)
603−
604−| Attribute | Effect |
605−|------------------------|----------------------------------|
606−| `width fill` | Take remaining space (flex: 1) |
607−| `width N` | Exact pixels |
608−| `width shrink` | Fit to content (default) |
609−| `height fill` | Take remaining space |
610−| `height N` | Exact pixels |
611−| `height shrink` | Fit to content (default) |
612−| `min-width N` | Minimum width |
613−| `max-width N` | Maximum width |
614−| `min-height N` | Minimum height |
615−| `max-height N` | Maximum height |
616−
617−### Alignment (set on child)
618−
619−| Attribute | Effect |
620−|------------------------|----------------------------------|
621−| `center-x` | Center horizontally |
622−| `center-y` | Center vertically |
623−| `align-left` | Align to left |
624−| `align-right` | Align to right |
625−| `align-top` | Align to top |
626−| `align-bottom` | Align to bottom |
627−
628−### Style
629
258
630−| Attribute | Effect |
259+A multi-line string uses triple quotes; its indented lines are the value:
631−|------------------------|----------------------------------|
632−| `background COLOR` | Background color |
633−| `color COLOR` | Text color |
634−| `border N COLOR` | Border width and color |
635−| `border-top N COLOR` | Top border |
636−| `border-bottom N COLOR`| Bottom border |
637−| `border-left N COLOR` | Left border |
638−| `border-right N COLOR` | Right border |
639−| `rounded N` | Border radius |
640−| `shadow VALUE` | Box shadow (CSS value) |
641−| `bold` | Bold text |
642−| `italic` | Italic text |
643−| `underline` | Underlined text |
644−| `size N` | Font size in px |
645−| `font NAME` | Font family |
646−| `text-align VALUE` | Text alignment (left/center/right/justify) |
647−| `line-height VALUE` | Line height (unitless or px) |
648−| `letter-spacing N` | Letter spacing |
649−| `text-transform VALUE` | Transform (uppercase/lowercase/capitalize) |
650−| `white-space VALUE` | White-space (nowrap/pre/normal) |
651−| `transition VALUE` | CSS transition |
652−| `cursor VALUE` | Cursor style |
653−| `opacity VALUE` | Opacity (0–1) |
654−| `overflow VALUE` | Overflow (hidden/scroll/auto/visible) |
655−| `position VALUE` | Position (relative/absolute/fixed/sticky) |
656−| `top N` | Top offset (positioned elements) |
657−| `right N` | Right offset |
658−| `bottom N` | Bottom offset |
659−| `left N` | Left offset |
660−| `z-index N` | Stack order |
661−| `display VALUE` | Display mode (none/block/flex/grid) |
662−| `visibility VALUE` | Visibility (visible/hidden) |
663−| `transform VALUE` | CSS transform (e.g., rotate(45deg)) |
664−| `backdrop-filter VALUE`| Backdrop filter (e.g., blur(10px)) |
665−
666−### Margin
667−
668−| Attribute | Effect |
669−|------------------------|----------------------------------|
670−| `margin N` | Uniform margin |
671−| `margin Y X` | Vertical + horizontal margin |
672−| `margin T R B L` | Per-side margin |
673−| `margin-x N` | Horizontal margin |
674−| `margin-y N` | Vertical margin |
675−
676−### Additional Style
677−
678−| Attribute | Effect |
679−|------------------------|----------------------------------|
680−| `filter VALUE` | CSS filter (blur, brightness) |
681−| `object-fit VALUE` | Object fit (cover/contain/fill) |
682−| `object-position VALUE`| Object position within container|
683−| `text-shadow VALUE` | Text shadow |
684−| `text-overflow VALUE` | Text overflow (ellipsis/clip) |
685−| `pointer-events VALUE` | Pointer events (none/auto) |
686−| `user-select VALUE` | User selection (none/text/all) |
687−| `justify-content VALUE`| Main axis alignment |
688−| `align-items VALUE` | Cross axis alignment |
689−| `order N` | Flex/grid item order |
690−| `background-size VALUE`| Background size |
691−| `background-position VALUE`| Background position |
692−| `background-repeat VALUE`| Background repeat |
693−| `word-break VALUE` | Word break (break-all/keep-all) |
694−| `overflow-wrap VALUE` | Overflow wrap (break-word) |
695−| `font-weight VALUE` | Font weight (100-900/bold/lighter)|
696−| `font-style VALUE` | Font style (normal/italic/oblique)|
697−| `text-wrap VALUE` | Text wrapping (balance/pretty) |
698−| `will-change VALUE` | Performance hint (transform) |
699−| `touch-action VALUE` | Touch behavior (none/pan-x) |
700−| `vertical-align VALUE` | Vertical alignment (middle/top) |
701−| `contain VALUE` | CSS containment (layout/paint) |
702−| `content-visibility VALUE` | Lazy rendering (auto) |
703−| `scroll-margin N` | Scroll margin (for anchors) |
704−| `scroll-padding N` | Scroll padding (scroll-snap) |
705−| `content VALUE` | Pseudo-element content (with before:/after:)|
706−
707−### Pseudo-states
708−
709−Prefix any style attribute with `hover:`, `active:`, or `focus:` to apply it on that state.
710
260
711```
261```
712−@el [padding 16, background #3b82f6, hover:background #2563eb, active:background #1d4ed8, rounded 8, transition all 0.15s ease]
262+@let intro """
713− @text [color white] Click me
263+ First line
264+ Second line
265+ """
266+@text $intro
714```
267```
715
268
716−All style attributes support state prefixes: `hover:color`, `active:rounded`, `focus:border`, etc.
269+`@let --name value` also emits a CSS custom property. `@theme` declares a group
717−
270+of design tokens at once; each becomes both `$name` and `--name`:
718−### Dark mode
719−
720−Prefix any style attribute with `dark:` to apply it when the user's system is in dark mode.
721
271
722```
272```
723−@el [background white, dark:background #1a1a2e, color #333, dark:color #eee]
273+@theme
724− @text Theme-aware content
274+ brand #3b82f6
275+ radius 8
276+@el [background $brand, rounded $radius] Themed
725```
277```
726
278
727−This generates a `@media (prefers-color-scheme: dark)` rule.
279+### Filters
728−
729−### Print styles
730
280
731−Prefix any style attribute with `print:` to apply it when printing.
281+`$name|filter` transforms a value: `uppercase`, `lowercase`, `capitalize`,
282+`trim`, `length`, `reverse`, `truncate:N`, `replace:OLD:NEW`, `default:VALUE`,
283+and for colors `lighten:N`, `darken:N`, `alpha:N`, `mix:COLOR:N`.
732
284
733```
285```
734−@nav [print:display none]
286+@let name htmlang
735− @text Navigation (hidden in print)
287+@let base #3b82f6
288+@text [color $base|darken:10] $name|uppercase
736```
289```
737
290
738−This generates a `@media print` rule.
291+### Checks
739
292
740−### Flow & Grid
293+`@assert CONDITION` fails the build when false. `@warn MESSAGE` emits a
294+warning; `@debug` and `@log` print values at compile time. `@deprecated MESSAGE`
295+before a function warns every caller.
741
296
742−| Attribute | Effect |
297+## Control flow
743−|------------------------|----------------------------------|
744−| `wrap` | Enable flex-wrap (on `@row`) |
745−| `grid` | Enable CSS grid layout |
746−| `grid-cols N` | Grid template columns (N equal) |
747−| `grid-rows N` | Grid template rows (N equal) |
748−| `col-span N` | Span N columns in grid |
749−| `row-span N` | Span N rows in grid |
750
298
751−### Identity
299+All control flow runs at compile time.
752
300
753−| Attribute | Effect |
754−|------------------------|----------------------------------|
755−| `id NAME` | HTML id attribute |
756−| `class NAME` | HTML class attribute |
757−
758−### Modern CSS
759−
760−| Attribute | Effect |
761−|------------------------|----------------------------------|
762−| `color-scheme VALUE` | Color scheme (`light`, `dark`, `light dark`) |
763−| `appearance VALUE` | Form element appearance (`none`) |
764−
765−### Popover API
766−
767−Use HTML Popover API attributes for declarative popovers without JavaScript.
768−
769−```
770−@button [popovertarget info-panel] Show Info
771−@el [popover, id info-panel, padding 20, background white, rounded 8]
772− @text This is a popover
773```
301```
302+@let items apple, banana, cherry
303+@let count 3
774
304
775−| Attribute | Effect |
305+@if $count > 2
776−|-------------------------------|---------------------------------|
306+ @text Many
777−| `popover` | Make element a popover |
307+@else if $count == 0
778−| `popovertarget ID` | Open popover with given ID |
308+ @text None
779−| `popovertargetaction VALUE` | Action (toggle/show/hide) |
309+@else
780−
310+ @text Few
781−### Input hints
782−
783−| Attribute | Effect |
784−|------------------------|----------------------------------|
785−| `inputmode VALUE` | Virtual keyboard type (numeric/email/search/tel/url) |
786−| `enterkeyhint VALUE` | Enter key label (enter/done/go/next/previous/search/send) |
787−| `fetchpriority VALUE` | Resource priority (high/low/auto) |
788−| `translate VALUE` | Whether to translate (yes/no) |
789−| `spellcheck VALUE` | Spell check (true/false) |
790−
791−## Asset inlining
792
311
793−SVG images can be inlined directly into the HTML output using the `[inline]` attribute:
312+@if not $count
313+ @text Nothing to show
794
314
795−```
315+@each $item in $items
796−@image [inline, width 24, height 24] icon.svg
316+ @text $_index: $item
797−```
317+@else
318+ @text The list is empty.
798
319
799−This reads the SVG file and embeds its content directly, keeping the output self-contained.
320+@each $i in 1..10 step 3
321+ @text $i
800
322
801−## CLI
323+@each $label, $url in Home /, About /about
324+ @link $url $label
802
325
803−```
326+@match $count
804−htmlang init # scaffold a new project
327+ @case 3
805−htmlang init my-site # scaffold in a new directory
328+ @text Three
806−htmlang new about-us # create about-us.hl from template
329+ @default
807−htmlang page.hl # compile page.hl → page.html
330+ @text Other
808−htmlang site/ # compile all .hl files in directory
809−htmlang --watch page.hl # compile and watch for changes
810−htmlang -w page.hl # short form
811−htmlang --serve site/ # serve a multi-page site with hot reload
812−htmlang -s --open site/ # serve and open browser
813−htmlang check page.hl # check for errors without writing output
814−htmlang check src/ # check all .hl files in a directory
815−htmlang convert page.html # convert HTML to .hl format (stdout)
816−htmlang build src/ -o dist/ # parallel compile + copy static assets
817−htmlang --compat page.hl # compile with vendor prefixes
818−htmlang --format json page.hl # output diagnostics as JSON
819−htmlang lint --format json src/ # lint with JSON output
820−htmlang dead-code --format json src/ # dead code report as JSON
821−htmlang lint page.hl # stricter lint checks (accessibility, nesting)
822−htmlang lint src/ # lint all .hl files in a directory
823−htmlang stats page.hl # show file statistics (elements, CSS, colors)
824−htmlang repl # interactive REPL (type .hl, get HTML)
825−htmlang feed src/ # generate RSS feed from @page metadata
826−htmlang feed src/ -b https://mysite.com # with custom base URL
827−htmlang components src/ # list all @fn definitions across project
828−htmlang deps src/ # show dependency graph (@include/@import)
829−htmlang dead-code src/ # find unused @fn, @define, @let across project
830−htmlang deploy src/ # build and deploy to GitHub Pages
831−htmlang playground # generate self-contained HTML playground
832−htmlang clean src/ # remove generated .html files
833−htmlang outline page.hl # show document structure tree
834```
331```
835
332
836−Watch mode recompiles automatically when the source file or any `@include`d/`@import`ed files change.
333+`@defer` keeps its children hidden until they scroll near the viewport (they
334+are shown immediately when JavaScript is off).
837
335
838−The `build` command automatically extracts shared CSS rules across pages into a `shared.css` file when outputting to a directory.
336+Conditions support `==`, `!=`, `<`, `>`, `<=`, `>=`, `contains`, `starts-with`,
337+`ends-with`, `not`, and truthiness (empty, `false` and `0` are false). A range
338+counts down when its start is greater than its end.
839
339
840−## Compilation target
340+## Files and data
841
341
842−Each `.hl` file compiles to a single self-contained `.html` file:
342+| Directive | Effect |
343+|---|---|
344+| `@include file.hl` | Insert another file here, content and definitions |
345+| `@import file.hl` | Take only its definitions (`@let`, bundles, functions) |
346+| `@extends layout.hl` | Render this page inside a layout (below) |
347+| `@data file.json` / `@data $prefix file.json` | Load JSON values as variables |
348+| `@fetch $prefix http://...` | Like `@data`, fetched at build time (http only) |
349+| `@collection $name "glob"` | List files matching a pattern |
350+| `@translations` | Per-locale strings, used as `$t.key` |
351+| `@env NAME default` | Read an environment variable |
352+| `@markdown` / `@markdown file.md` | Markdown, converted to HTML |
353+| `@svg file.svg` | Inline an SVG file |
843
354
844−- Elements map to `<div>`, `<span>`, `<p>`, `<a>`, `<img>` as appropriate
355+With `@extends`, the layout marks where content goes with `@slot name` (named
845−- Layout uses flexbox (`display: flex`, `flex-direction`, `gap`, etc.)
356+blocks) and `@children` (everything in the page outside `@slot` blocks):
846−- Styles are scoped via generated class names in an embedded `<style>` block
847−- No external CSS, no JavaScript (unless injected via `@raw`)
848−- `@page` generates the HTML boilerplate; without it, output is an HTML fragment
849
357
850−## Template inheritance (`@extends`)
851−
852−A page can inherit a base layout and fill named `@slot` blocks:
853−
854−**layout.hl:**
855```
358```
359+-- layout.hl
856@page My Site
360@page My Site
857−@column [max-width 800, center-x, padding 40]
361+@column [max-width 800, center-x]
858 @slot header
362 @slot header
859− @text [bold, size 32] Default Header
363+ @text Default header
860− @slot content
364+ @children
861− @slot footer
862− @text [color #888] Default Footer
863```
365```
864
366
865−**about.hl:**
866```
367```
368+-- page.hl
867@extends layout.hl
369@extends layout.hl
868@slot header
370@slot header
869− @text [bold, size 32] About Us
371+ @text About us
870−@slot content
372+@paragraph This fills @children.
871− @paragraph We are a team of...
872−```
873−
874−Slots not filled by the extending page use the default children from the layout.
875−
876−## Design tokens (`@theme`)
877−
878−Centralized design token definitions. Each token becomes both a `$variable` and a `--css-custom-property`:
879−
880−```
881−@theme
882− primary #3b82f6
883− secondary #10b981
884− spacing-sm 8
885− spacing-md 16
886− font-body system-ui, sans-serif
887```
373```
888
374
889−Equivalent to:
375+## Page and head
890−```
891−@let primary #3b82f6
892−@let --primary #3b82f6
893−@let secondary #10b981
894−@let --secondary #10b981
895−...
896−```
897−
898−## Deprecation (`@deprecated`)
899−
900−Mark a function as deprecated. Callers receive a compile-time warning:
901−
902−```
903−@deprecated Use @card-v2 instead
904−@fn old-card $title
905− @el [padding 20]
906− @text $title
907− @children
908−```
909
376
910−When `@old-card` is called, the compiler emits: `warning: @old-card is deprecated: Use @card-v2 instead`
377+`@page TITLE` produces a full HTML document; without it the output is a fragment.
911−
378+Head content comes from `@lang`, `@favicon`, `@meta NAME VALUE`,
912−## Color functions
379+`@og KEY VALUE`, `@canonical URL`, `@base URL`, `@manifest NAME` (with
913−
380+indented settings), `@font-face NAME URL`, `@json-ld` (indented JSON) and `@head`
914−Color manipulation via variable filters:
381+(indented raw HTML).
915
382
916```
383```
917−@let primary #3b82f6
384+@page My Site
918−
385+@lang en
919−@el [background $primary|lighten:20] -- 20% lighter
386+@meta description A small site
920−@el [background $primary|darken:10] -- 10% darker
387+@og title My Site
921−@el [background $primary|alpha:0.5] -- 50% transparent (#3b82f67f)
922−@el [background $primary|mix:#ffffff:50] -- 50% mixed with white
923−```
924−
925−Filters: `lighten:N` (0-100), `darken:N` (0-100), `alpha:N` (0-1), `mix:COLOR:N` (0-100).
926−
927−## Enhanced `@keyframes`
928−
929−Keyframes support htmlang attribute syntax:
930−
931−```
932−@keyframes fade-in
933− from [opacity 0]
934− to [opacity 1]
935−
936−@keyframes slide
937− 0% [transform translateX(-100%)]
938− 50% [transform translateX(0), opacity 1]
939− 100% [transform translateX(100%), opacity 0]
940−```
941−
942−Raw CSS syntax is also supported for backwards compatibility.
943−
944−## Additional attributes
945−
946−| Attribute | Effect |
947−|-----------|--------|
948−| `autofocus` | Auto-focus element on page load (boolean) |
949−
950−## Named grid areas
951−
952−Use CSS Grid's named template areas with the `grid-template-areas` and `grid-area` attributes:
953−
954−```
955−@el [grid, grid-template-areas "header header" "sidebar main" "footer footer", gap 20]
956− @el [grid-area header, padding 10, background #3b82f6]
957− Header
958− @el [grid-area sidebar, padding 10]
959− Sidebar
960− @el [grid-area main, padding 10]
961− Main content
962− @el [grid-area footer, padding 10]
963− Footer
964```
388```
965
389
966−## Animate shorthand
390+## CSS
967
391
968−The `animate` attribute is shorthand for the CSS `animation` property:
392+- `@style` holds raw CSS, including at-rules such as `@scope`, `@property` and
393+ `@starting-style`.
394+- `@keyframes name` takes htmlang attribute syntax (`from [opacity 0]`) or raw
395+ CSS.
396+- Generated rules live in `@layer htmlang` (after a `hl-reset` layer), so any
397+ CSS in `@style` or `@raw` overrides them.
969
398
970```
399```
971@keyframes fade-in
400@keyframes fade-in
972 from [opacity 0]
401 from [opacity 0]
973 to [opacity 1]
402 to [opacity 1]
974−
403+@style
404+ .note { color: gray; }
975−@el [animate fade-in 0.3s ease]
405+@el [animation fade-in 0.3s ease, class note] Fades in
976− Fades in on load
977−```
406+```
978−
407+
979−## View transitions
408+## Upgrading older files
980−
409+
981−The `view-transition-name` attribute enables the View Transitions API for smooth page transitions:
410+`htmlang upgrade [dir|file]` rewrites removed syntax:
982−
411+
983−```
412+| Removed | Use instead |
984−@el [view-transition-name hero]
413+|---|---|
985− Hero content that transitions between pages
414+| `@fn`, `@define`, `@mixin` | `@let` (function, bundle) |
986−```
415+| `@unless COND` | `@if not COND` |
987−
416+| `@for $i in A..B`, `@repeat N` | `@each $i in A..B`, `@each $_ in 1..N` |
988−## `:has()` pseudo-selector
417+| `@switch` | `@match` |
989−
418+| `@use "file" names` | `@import file` |
990−Style elements based on their children using the `has()` prefix:
419+| `@with $x as y` | `@let y $x` |
991−
420+| `@layout file` | `@extends file` |
992−```
421+| `@scope`, `@starting-style`, `@css-property` | The CSS rule in `@style` |
993−@el [padding 20, has(.active):background #dbeafe, has(img):padding 0]
422+| `@col`, `@p`, `@img`, `@li`, `@btn`, `@ul`, `@divider`, `@opt` | `@column`, `@paragraph`, `@image`, `@item`, `@button`, `@list`, `@hr`, `@option` |
994− Content
423+| `...$bundle` | `$bundle` |
995−```
424+| `animate`, `inset-area` | `animation`, `position-area` |
996−
425+| `\|upper`, `\|lower`, `\|cap`, `\|len` | `\|uppercase`, `\|lowercase`, `\|capitalize`, `\|length` |
997−Generates CSS `:has()` selectors: `.class:has(.active) { background:#dbeafe; }`
426+
998−
427+The compiler reports each removed form with its replacement.
999−## Computed `@let`
1000−
1001−Variables support arithmetic expressions. The `=` sign is optional:
1002−
1003−```
1004−@let base 16
1005−@let large = $base * 2 -- 32
1006−@let gap $base + 4 -- 20 (= is optional)
1007−```
1008−
1009−Supported operators: `+`, `-`, `*`, `/`.
1010−
1011−## Named slots in `@fn`
1012−
1013−Functions support named `@slot` blocks for multi-region components:
1014−
1015−```
1016−@fn layout $title
1017− @column [max-width 800, center-x, padding 40]
1018− @slot header
1019− @text [bold, size 32] $title
1020− @slot content
1021− @slot footer
1022− @text [color #888] Default Footer
1023−
1024−@layout [title My Page]
1025− @slot header
1026− @text [bold, size 32] Custom Header
1027− @slot content
1028− @paragraph Main content here
1029−```
1030−
1031−Slots not filled by the caller use the default children from the function definition.
1032−
1033−## New elements
1034−
1035−### `@script`
1036−
1037−Embeds JavaScript. Supports inline code and external files via `src`. Children are raw JS (not HTML-escaped).
1038−
1039−```
1040−@script [src app.js, defer]
1041−
1042−@script
1043− console.log("hello world");
1044− document.addEventListener("DOMContentLoaded", () => {
1045− init();
1046− });
1047−```
1048−
1049−Attributes: `src`, `type`, `defer`, `async`, `crossorigin`, `integrity`, `nomodule`.
1050−
1051−### `@noscript`
1052−
1053−Fallback content displayed when JavaScript is disabled.
1054−
1055−```
1056−@noscript
1057− @text This page requires JavaScript.
1058−```
1059−
1060−### `@address`
1061−
1062−Contact information, rendered as `<address>`.
1063−
1064−```
1065−@address
1066− @text [bold] John Doe
1067− @link mailto:john@example.com john@example.com
1068−```
1069−
1070−### `@search`
1071−
1072−HTML5 `<search>` landmark for search functionality.
1073−
1074−```
1075−@search
1076− @input [type search, placeholder Search...]
1077− @button [type submit] Search
1078−```
1079−
1080−### `@breadcrumb`
1081−
1082−Semantic breadcrumb navigation. Generates `<nav aria-label="breadcrumb">` with an `<ol>` list. Each child becomes an `<li>`.
1083−
1084−```
1085−@breadcrumb [spacing 10]
1086− @link / Home
1087− @link /docs Documentation
1088− @text Current Page
1089−```
1090−
1091−## New directives
1092−
1093−### `@canonical`
1094−
1095−Sets the canonical URL. Generates `<link rel="canonical">` in the document head.
1096−
1097−```
1098−@canonical https://example.com/my-page
1099−```
1100−
1101−### `@base`
1102−
1103−Sets the base URL for relative links. Generates `<base href="...">`.
1104−
1105−```
1106−@base https://example.com/
1107−```
1108−
1109−### `@font-face`
1110−
1111−Declares a custom font. Generates a CSS `@font-face` rule with `font-display: swap`. Format is auto-detected from the file extension.
1112−
1113−```
1114−@font-face Inter fonts/inter.woff2
1115−@font-face Mono fonts/jetbrains-mono.ttf
1116−```
1117−
1118−### `@json-ld`
1119−
1120−Structured data for SEO. Generates a `<script type="application/ld+json">` block. Indented body is raw JSON.
1121−
1122−```
1123−@json-ld
1124− {
1125− "@context": "https://schema.org",
1126− "@type": "WebPage",
1127− "name": "My Site"
1128− }
1129−```
1130−
1131−## Additional pseudo-states
1132−
1133−| Prefix | CSS Selector | Purpose |
1134−|--------|--------------|---------|
1135−| `visited:` | `:visited` | Visited links |
1136−| `empty:` | `:empty` | Empty elements |
1137−| `target:` | `:target` | URL fragment target |
1138−| `valid:` | `:valid` | Valid form inputs |
1139−| `invalid:` | `:invalid` | Invalid form inputs |
1140−
1141−```
1142−@link [visited:color purple] https://example.com
1143− Already visited
1144−
1145−@input [type email, valid:border 2 green]
1146−@el [target:background yellow, id section-1]
1147− Highlighted when navigated to
1148−```
1149−
1150−## Additional CSS properties
1151−
1152−| Attribute | Effect |
1153−|-----------|--------|
1154−| `text-underline-offset N` | Offset of text underline from text |
1155−| `column-width N` | Ideal column width for multi-column layout |
1156−| `column-rule VALUE` | Rule between columns (e.g., `1px solid #ccc`) |
1157−
1158−```
1159−@text [underline, text-underline-offset 4, text-decoration-thickness 2]
1160− Styled underline
1161−
1162−@el [column-count 3, column-width 200, column-rule 1px solid #ccc]
1163− Multi-column content
1164−```
1165−
1166−## CSS `@layer` wrapping
1167−
1168−Generated styles are wrapped in `@layer htmlang { ... }` for specificity management. This allows `@style` blocks and `@raw` CSS to easily override generated styles without specificity wars.
1169−
1170−## CLI commands
1171−
1172−### `htmlang deps`
1173−
1174−Show the file dependency graph (`@include`, `@import`, `@extends` relationships):
1175−
1176−```
1177−htmlang deps src/
1178−```
1179−
1180−### `htmlang dead-code`
1181−
1182−Find unused `@fn`, `@define`, and `@let` definitions across an entire project:
1183−
1184−```
1185−htmlang dead-code src/
1186−```
1187−
1188−### `htmlang deploy`
1189−
1190−Build and deploy to GitHub Pages:
1191−
1192−```
1193−htmlang deploy src/
1194−```
1195−
1196−Compiles all `.hl` files, copies static assets, and pushes to a `gh-pages` branch.
1197−
1198−### `htmlang clean`
1199−
1200−Remove generated `.html` files from a directory (matching `.hl` source files). Also removes `sitemap.xml` if present.
1201−
1202−```
1203−htmlang clean src/
1204−htmlang clean # clean current directory
1205−```
1206−
1207−### `htmlang outline`
1208−
1209−Show the document structure tree — a quick overview of elements and nesting without compiling.
1210−
1211−```
1212−htmlang outline page.hl
1213−```
1214−
1215−### `htmlang playground`
1216−
1217−Generate a self-contained HTML playground for experimenting with htmlang:
1218−
1219−```
1220−htmlang playground # writes playground.html
1221−htmlang playground my-play.html # custom output path
1222−htmlang clean src/ # remove generated .html files
1223−htmlang outline page.hl # show document structure tree
1224−```
1225−
1226−## Editor support
1227−
1228−A VS Code extension is available in `editors/vscode/` with:
1229−
1230−- Syntax highlighting via TextMate grammar
1231−- LSP integration via `htmlang-lsp` for:
1232− - Real-time diagnostics and error checking
1233− - Completions for elements, attributes, variables, and functions
1234− - Hover documentation for all elements and attributes
1235− - Go to definition for variables, defines, and functions
1236− - Find all references for variables and functions
1237− - Rename refactoring for variables and functions
1238− - Signature help for `@fn` parameters
1239− - Document formatting (format on save)
1240− - Code actions (quick-fixes for typos, unused variables, missing attributes)
1241− - Auto-import suggestions (scans project for `@fn` definitions)
1242− - Extract selection to `@fn` refactoring
1243− - Color picker for hex colors
1244− - Code folding and document symbols
1245− - Semantic tokens for syntax highlighting
1246− - VS Code snippets for common patterns (`@fn`, `@each`, `@grid`, `@form`, etc.)
1247−
1248−## Additional directives reference
1249−
1250−The compiler recognizes a number of directives that are documented only briefly
1251−above. A concise reference:
1252−
1253−### Content generation
1254−
1255−- `@markdown` — indented body is parsed as Markdown and emitted as HTML.
1256−- `@raw """..."""` — embed verbatim HTML / CSS / JS.
1257−- `@svg path/to/icon.svg` — inline an SVG file's contents.
1258−- `@fetch url -> $name` — fetch a JSON payload at build time and bind it.
1259−- `@data name path.json` — load a JSON file for iteration with `@each`.
1260−
1261−### Metadata & head
1262−
1263−- `@lang en` — set `<html lang>`.
1264−- `@favicon /favicon.png` — inject a favicon link.
1265−- `@meta name value` — add a `<meta>` tag.
1266−- `@og key value` — add an Open Graph `<meta property="og:key">` tag.
1267−- `@canonical https://…` — set `<link rel="canonical">`.
1268−- `@base /base/path/` — set `<base href>`.
1269−- `@manifest [Name]` — emit a PWA `manifest.json` reference and body.
1270−- `@breakpoint name width` — register a custom responsive prefix.
1271−
1272−### Styling
1273−
1274−- `@style` — indented body is raw CSS injected into `<style>`.
1275−- `@scope (selector)` — wrap body in CSS `@scope` for scoped styles.
1276−- `@starting-style` — CSS entry-animation starting values.
1277−- `@font-face name url` — register a web font.
1278−- `@theme` — design tokens emitted as CSS custom properties.
1279−- `@keyframes name` — define keyframe animations.
1280−- `@css-property name syntax value` — register a typed custom property.
1281−
1282−### Control flow
1283−
1284−- `@if cond` / `@unless cond` / `@else` / `@else if`.
1285−- `@match $value` / `@switch $value` — exhaustive branching.
1286−- `@each item in list` / `@each item, $_index in list` — iterate.
1287−- `@for $i in 0..10 [step 2]` — numeric ranges.
1288−- `@repeat N` — repeat a block N times.
1289−- `@defer` — defer block rendering below the fold.
1290−
1291−### Module system
1292−
1293−- `@include file.hl` — paste file contents here.
1294−- `@import file.hl` — import @fn / @let / @define (with optional `as` alias).
1295−- `@use "file.hl" fn1, fn2` — import a named subset.
1296−- `@extends template.hl` — template inheritance; override with `@slot`.
1297−- `@layout path.hl` — wrap output in a layout template.
1298−- `@collection path/ -> $items` — iterate over a directory.
1299−
1300−### Functions & composition
1301−
1302−- `@fn name $p1 $p2` — define a function; body uses `@children` / `@slot`.
1303−- `@component name $p` — like `@fn` but emits a scoped wrapper class.
1304−- `@mixin name [...attrs]` — attribute bundle you can merge elsewhere.
1305−- `@define name [...attrs]` — named attribute preset, used as `[$name]`.
1306−- `@with $source as $alias` — temporarily rebind a variable.
1307−
1308−### Debugging
1309−
1310−- `@assert cond [message]` — emit an error if a boolean check fails.
1311−- `@warn message` / `@debug expr` / `@log message` — compile-time output.
1312−- `@deprecated name [reason]` — mark a function as deprecated.
1313−- `@translations locale` — bind i18n strings available as `$t.key`.
README.md+8−1
@@ -193,6 +193,7 @@ A `@let` with an indented body defines a function. `@children` is where the
193caller's children go:
193caller's children go:
194
194
195```
195```
196+@let primary #3b82f6
196@let button $label
197@let button $label
197 @el [padding 12, background $primary, rounded 8]
198 @el [padding 12, background $primary, rounded 8]
198 @text [color white, bold] $label
199 @text [color white, bold] $label
@@ -204,7 +205,13 @@ caller's children go:
204### File includes
205### File includes
205
206
206```
207```
207−-- inline another .hl file
208+-- header.hl
209+@nav [spacing 10]
210+ @link / Home
211+```
212+
213+```
214+-- page.hl: insert header.hl here
208@include header.hl
215@include header.hl
209```
216```
210
217
crates/htmlang-core/src/ast.rs+37−2
@@ -20,8 +20,6 @@ pub struct Document {
20 pub base_url: Option<String>,
20 pub base_url: Option<String>,
21 pub font_faces: Vec<(String, String)>,
21 pub font_faces: Vec<(String, String)>,
22 pub json_ld_blocks: Vec<String>,
22 pub json_ld_blocks: Vec<String>,
23− pub scope_blocks: Vec<String>,
24− pub starting_style_blocks: Vec<String>,
25 pub manifest: Option<ManifestConfig>,
23 pub manifest: Option<ManifestConfig>,
26 pub preload_hints: Vec<PreloadHint>,
24 pub preload_hints: Vec<PreloadHint>,
27 pub nodes: Vec<Node>,
25 pub nodes: Vec<Node>,
@@ -171,3 +169,40 @@ pub struct Attribute {
171 pub key: String,
169 pub key: String,
172 pub value: Option<String>,
170 pub value: Option<String>,
173}
171}
172+
173+/// Elements that print their argument as their text (`@text Hello`,
174+/// `@item First`).
175+pub fn renders_argument_as_text(kind: &ElementKind) -> bool {
176+ matches!(
177+ kind,
178+ ElementKind::Text
179+ | ElementKind::Button
180+ | ElementKind::Label
181+ | ElementKind::Option
182+ | ElementKind::Textarea
183+ | ElementKind::ListItem
184+ | ElementKind::TableCell
185+ | ElementKind::TableHeaderCell
186+ | ElementKind::Summary
187+ | ElementKind::Cite
188+ | ElementKind::Code
189+ | ElementKind::FigCaption
190+ | ElementKind::Legend
191+ | ElementKind::DefinitionTerm
192+ | ElementKind::Mark
193+ | ElementKind::Kbd
194+ | ElementKind::Abbr
195+ | ElementKind::Time
196+ | ElementKind::DefinitionDescription
197+ | ElementKind::Badge
198+ | ElementKind::Tooltip
199+ | ElementKind::Chip
200+ | ElementKind::Tag
201+ | ElementKind::H1
202+ | ElementKind::H2
203+ | ElementKind::H3
204+ | ElementKind::H4
205+ | ElementKind::H5
206+ | ElementKind::H6
207+ )
208+}
crates/htmlang-core/src/codegen.rs+21−97
@@ -155,18 +155,11 @@ impl StyleCollector {
155 Some(name)
155 Some(name)
156 }
156 }
157
157
158+ /// All generated rules, wrapped in `@layer htmlang`.
158− fn to_css_formatted(&self, dev: bool, use_layer: bool) -> String {
159+ fn to_css_formatted(&self, dev: bool) -> String {
159 let mut css = String::new();
160 let mut css = String::new();
160− let inner_indent = if use_layer && dev { " " } else { "" };
161+ let inner_indent = if dev { " " } else { "" };
161−
162+ css.push_str(if dev { "@layer htmlang {\n" } else { "@layer htmlang{" });
162− // Wrap in @layer for specificity management
163− if use_layer {
164− if dev {
165− css.push_str("@layer htmlang {\n");
166− } else {
167− css.push_str("@layer htmlang{");
168− }
169− }
170
163
171 // Non-responsive rules. Base and pseudo rules are merged separately by
164 // Non-responsive rules. Base and pseudo rules are merged separately by
172 // identical body so that e.g. `.a,.b{display:flex;flex-direction:column;}`
165 // identical body so that e.g. `.a,.b{display:flex;flex-direction:column;}`
@@ -342,15 +335,7 @@ impl StyleCollector {
342 );
335 );
343 }
336 }
344
337
345− // Close @layer
346− if use_layer {
347− if dev {
348− css.push_str("}\n");
338+ css.push_str(if dev { "}\n" } else { "}" });
349− } else {
350− css.push('}');
351− }
352− }
353−
354 css
339 css
355 }
340 }
356}
341}
@@ -1047,7 +1032,7 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
1047 ""
1032 ""
1048 };
1033 };
1049
1034
1050− let reset_css = reset_css(doc, dev, focus_visible_css, skip_link_css);
1035+ let reset_css = reset_css(dev, focus_visible_css, skip_link_css);
1051
1036
1052 match &doc.page_title {
1037 match &doc.page_title {
1053 Some(title) => {
1038 Some(title) => {
@@ -1189,7 +1174,9 @@ fn build_element_css(doc: &Document, styles: &StyleCollector, dev: bool) -> Stri
1189 }
1174 }
1190 }
1175 }
1191
1176
1177+ // Generated rules always go in `@layer htmlang`, so unlayered user CSS
1178+ // (`@style`, `@raw`) overrides them regardless of specificity.
1192− let styles_css = styles.to_css_formatted(dev, uses_layer(doc));
1179+ let styles_css = styles.to_css_formatted(dev);
1193 // Fold literal values declared via `@theme` / `@let --name` back into
1180 // Fold literal values declared via `@theme` / `@let --name` back into
1194 // `var(--name)` references so the generated CSS actually uses the
1181 // `var(--name)` references so the generated CSS actually uses the
1195 // custom properties emitted in `:root`.
1182 // custom properties emitted in `:root`.
@@ -1246,52 +1233,31 @@ fn build_element_css(doc: &Document, styles: &StyleCollector, dev: bool) -> Stri
1246 }
1233 }
1247 }
1234 }
1248
1235
1249− let push_block = |css: &mut String, block: &str| {
1236+ // @style blocks (custom CSS)
1237+ for block in &doc.custom_css {
1250 if dev {
1238 if dev {
1251− css.push_str(block);
1239+ element_css.push_str(block);
1252− css.push('\n');
1240+ element_css.push('\n');
1253 } else {
1241 } else {
1254 let minified: String = block.lines().map(|l| l.trim()).collect::<Vec<_>>().join("");
1242 let minified: String = block.lines().map(|l| l.trim()).collect::<Vec<_>>().join("");
1255− css.push_str(&minified);
1243+ element_css.push_str(&minified);
1256 }
1244 }
1257− };
1258−
1259− // @style and @scope blocks (custom CSS)
1260− for block in doc.custom_css.iter().chain(&doc.scope_blocks) {
1261− push_block(&mut element_css, block);
1262− }
1263−
1264− // @starting-style blocks
1265− if !doc.starting_style_blocks.is_empty() {
1266− element_css.push_str(if dev { "@starting-style {\n" } else { "@starting-style{" });
1267− for block in &doc.starting_style_blocks {
1268− push_block(&mut element_css, block);
1269− }
1270− element_css.push_str(if dev { "}\n" } else { "}" });
1271 }
1245 }
1272
1246
1273 element_css
1247 element_css
1274}
1248}
1275
1249
1276−/// Generated class rules go in `@layer htmlang` unless the page has custom
1250+/// Built-in reset rules, in a layer before `htmlang`: unlayered CSS beats
1277−/// `@style` CSS (which is unlayered and would then always win).
1278−fn uses_layer(doc: &Document) -> bool {
1279− doc.custom_css.is_empty()
1280−}
1281−
1282−/// Built-in reset rules. When generated rules are layered, the reset must be
1283−/// layered too (in an earlier layer): unlayered CSS beats every layer, so an
1284−/// unlayered `a{color:inherit}` would override `@link [color red]`.
1251+/// every layer, so an unlayered `a{color:inherit}` would override
1252+/// `@link [color red]`.
1285−fn reset_css(doc: &Document, dev: bool, focus_visible_css: &str, skip_link_css: &str) -> String {
1253+fn reset_css(dev: bool, focus_visible_css: &str, skip_link_css: &str) -> String {
1286 let base = if dev {
1254 let base = if dev {
1287 "*, *::before, *::after { box-sizing: border-box; }\nbody { margin: 0; font-family: system-ui, -apple-system, sans-serif; }\nimg { display: block; }\na { text-decoration: none; color: inherit; }\n"
1255 "*, *::before, *::after { box-sizing: border-box; }\nbody { margin: 0; font-family: system-ui, -apple-system, sans-serif; }\nimg { display: block; }\na { text-decoration: none; color: inherit; }\n"
1288 } else {
1256 } else {
1289 "*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}"
1257 "*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}"
1290 };
1258 };
1291 let rules = format!("{}{}{}", base, focus_visible_css, skip_link_css);
1259 let rules = format!("{}{}{}", base, focus_visible_css, skip_link_css);
1292− if !uses_layer(doc) {
1293− rules
1294− } else if dev {
1260+ if dev {
1295 format!("@layer hl-reset, htmlang;\n@layer hl-reset {{\n{}}}\n", rules)
1261 format!("@layer hl-reset, htmlang;\n@layer hl-reset {{\n{}}}\n", rules)
1296 } else {
1262 } else {
1297 format!("@layer hl-reset,htmlang;@layer hl-reset{{{}}}", rules)
1263 format!("@layer hl-reset,htmlang;@layer hl-reset{{{}}}", rules)
@@ -1966,38 +1932,8 @@ fn generate_element(
1966 out.push_str(ctx.nl());
1932 out.push_str(ctx.nl());
1967
1933
1968 // Inline text argument
1934 // Inline text argument
1969− if matches!(
1935+ if renders_argument_as_text(&elem.kind)
1970− elem.kind,
1971− ElementKind::Text
1972− | ElementKind::Button
1973− | ElementKind::Label
1974− | ElementKind::Option
1975− | ElementKind::Textarea
1976− | ElementKind::ListItem
1977− | ElementKind::TableCell
1978− | ElementKind::TableHeaderCell
1979− | ElementKind::Summary
1980− | ElementKind::Cite
1981− | ElementKind::Code
1982− | ElementKind::FigCaption
1983− | ElementKind::Legend
1984− | ElementKind::DefinitionTerm
1985− | ElementKind::Mark
1986− | ElementKind::Kbd
1987− | ElementKind::Abbr
1988− | ElementKind::Time
1989− | ElementKind::DefinitionDescription
1990− | ElementKind::Badge
1991− | ElementKind::Tooltip
1992− | ElementKind::Chip
1993− | ElementKind::Tag
1994− | ElementKind::H1
1995− | ElementKind::H2
1996− | ElementKind::H3
1997− | ElementKind::H4
1998− | ElementKind::H5
1999− | ElementKind::H6
2000− ) && let Some(text) = &elem.argument
1936+ && let Some(text) = &elem.argument
2001 {
1937 {
2002 out.push_str(&html_escape(text));
1938 out.push_str(&html_escape(text));
2003 }
1939 }
@@ -3637,12 +3573,6 @@ fn attrs_to_css(
3637 }
3573 }
3638 }
3574 }
3639
3575
3640− // Animate shorthand (alias for animation)
3641− "animate" => {
3642− if let Some(v) = val {
3643− push_css(&mut css, "animation", v);
3644− }
3645− }
3646
3576
3647 // CSS subgrid
3577 // CSS subgrid
3648 "grid-template-columns" => {
3578 "grid-template-columns" => {
@@ -3704,12 +3634,6 @@ fn attrs_to_css(
3704 push_css(&mut css, "position-area", v);
3634 push_css(&mut css, "position-area", v);
3705 }
3635 }
3706 }
3636 }
3707− "inset-area" => {
3708− // Legacy alias for position-area
3709− if let Some(v) = val {
3710− push_css(&mut css, "position-area", v);
3711− }
3712− }
3713
3637
3714 // initial-letter (drop caps)
3638 // initial-letter (drop caps)
3715 "initial-letter" => {
3639 "initial-letter" => {
crates/htmlang-core/src/parser.rs+157−649
@@ -68,6 +68,9 @@ struct FnDef {
68}
68}
69
69
70struct ParseContext {
70struct ParseContext {
71+ /// Source line currently being parsed, for diagnostics raised deep
72+ /// inside helpers that don't take a line number.
73+ current_line: usize,
71 page_title: Option<String>,
74 page_title: Option<String>,
72 lang: Option<String>,
75 lang: Option<String>,
73 favicon: Option<String>,
76 favicon: Option<String>,
@@ -111,10 +114,6 @@ struct ParseContext {
111 font_faces: Vec<(String, String)>,
114 font_faces: Vec<(String, String)>,
112 /// @json-ld blocks
115 /// @json-ld blocks
113 json_ld_blocks: Vec<String>,
116 json_ld_blocks: Vec<String>,
114− /// @scope CSS blocks
115− scope_blocks: Vec<String>,
116− /// @starting-style CSS blocks
117− starting_style_blocks: Vec<String>,
118 /// @manifest configuration
117 /// @manifest configuration
119 manifest: Option<crate::ast::ManifestConfig>,
118 manifest: Option<crate::ast::ManifestConfig>,
120 /// Track @import paths for circular dependency detection
119 /// Track @import paths for circular dependency detection
@@ -138,6 +137,7 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
138 let lines = preprocess(input);
137 let lines = preprocess(input);
139 let mut parser = Parser { lines, pos: 0 };
138 let mut parser = Parser { lines, pos: 0 };
140 let mut ctx = ParseContext {
139 let mut ctx = ParseContext {
140+ current_line: 0,
141 page_title: None,
141 page_title: None,
142 lang: None,
142 lang: None,
143 favicon: None,
143 favicon: None,
@@ -169,8 +169,6 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
169 base_url: None,
169 base_url: None,
170 font_faces: Vec::new(),
170 font_faces: Vec::new(),
171 json_ld_blocks: Vec::new(),
171 json_ld_blocks: Vec::new(),
172− scope_blocks: Vec::new(),
173− starting_style_blocks: Vec::new(),
174 manifest: None,
172 manifest: None,
175 import_stack: Vec::new(),
173 import_stack: Vec::new(),
176 translations: HashMap::new(),
174 translations: HashMap::new(),
@@ -198,8 +196,6 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
198 base_url: ctx.base_url,
196 base_url: ctx.base_url,
199 font_faces: ctx.font_faces,
197 font_faces: ctx.font_faces,
200 json_ld_blocks: ctx.json_ld_blocks,
198 json_ld_blocks: ctx.json_ld_blocks,
201− scope_blocks: ctx.scope_blocks,
202− starting_style_blocks: ctx.starting_style_blocks,
203 manifest: ctx.manifest,
199 manifest: ctx.manifest,
204 preload_hints: collect_image_preload_hints(&nodes),
200 preload_hints: collect_image_preload_hints(&nodes),
205 nodes,
201 nodes,
@@ -467,6 +463,7 @@ impl Parser {
467
463
468 fn parse_line(&mut self, ctx: &mut ParseContext) -> Result<Option<Vec<Node>>, ParseError> {
464 fn parse_line(&mut self, ctx: &mut ParseContext) -> Result<Option<Vec<Node>>, ParseError> {
469 let line_num = self.lines[self.pos].line_num;
465 let line_num = self.lines[self.pos].line_num;
466+ ctx.current_line = line_num;
470 let current_indent = self.lines[self.pos].indent;
467 let current_indent = self.lines[self.pos].indent;
471
468
472 // Handle raw content
469 // Handle raw content
@@ -490,6 +487,18 @@ impl Parser {
490
487
491 // --- Directives ---
488 // --- Directives ---
492
489
490+ // Removed syntax gets a pointer to its replacement — unless the
491+ // name is a user function (e.g. `@let divider`).
492+ if content.starts_with('@')
493+ && !ctx.functions.contains_key(extract_element_name(&content))
494+ && let Some(hint) = removed_syntax_hint(&content)
495+ {
496+ return Err(ParseError {
497+ line: line_num,
498+ message: hint,
499+ });
500+ }
501+
493 if let Some(rest) = content.strip_prefix("@page ") {
502 if let Some(rest) = content.strip_prefix("@page ") {
494 ctx.page_title = Some(substitute_vars(rest, &ctx.variables));
503 ctx.page_title = Some(substitute_vars(rest, &ctx.variables));
495 return Ok(None);
504 return Ok(None);
@@ -721,61 +730,6 @@ impl Parser {
721 return Ok(None);
730 return Ok(None);
722 }
731 }
723
732
724− // --- @scope block (CSS scoping) ---
725− if content.trim() == "@scope" || content.starts_with("@scope ") {
726− let scope_selector = content
727− .strip_prefix("@scope")
728− .unwrap_or("")
729− .trim()
730− .to_string();
731− let mut scope_content = String::new();
732− while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
733− match &self.lines[self.pos].content {
734− LineContent::Normal(s) => {
735− scope_content.push_str(s.trim());
736− scope_content.push('\n');
737− }
738− LineContent::Raw(s) => {
739− scope_content.push_str(s);
740− scope_content.push('\n');
741− }
742− }
743− self.pos += 1;
744− }
745− let trimmed = scope_content.trim().to_string();
746− if !trimmed.is_empty() {
747− let block = if scope_selector.is_empty() {
748− format!("@scope {{\n{}\n}}", trimmed)
749− } else {
750− format!("@scope ({}) {{\n{}\n}}", scope_selector, trimmed)
751− };
752− ctx.scope_blocks.push(block);
753− }
754− return Ok(None);
755− }
756−
757− // --- @starting-style block (entry animations) ---
758− if content.trim() == "@starting-style" {
759− let mut ss_content = String::new();
760− while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
761− match &self.lines[self.pos].content {
762− LineContent::Normal(s) => {
763− ss_content.push_str(s.trim());
764− ss_content.push('\n');
765− }
766− LineContent::Raw(s) => {
767− ss_content.push_str(s);
768− ss_content.push('\n');
769− }
770− }
771− self.pos += 1;
772− }
773− let trimmed = ss_content.trim().to_string();
774− if !trimmed.is_empty() {
775− ctx.starting_style_blocks.push(trimmed);
776− }
777− return Ok(None);
778− }
779
733
780 // --- @markdown block or file (convert markdown to HTML) ---
734 // --- @markdown block or file (convert markdown to HTML) ---
781 if content.trim() == "@markdown" || content.trim().starts_with("@markdown ") {
735 if content.trim() == "@markdown" || content.trim().starts_with("@markdown ") {
@@ -882,35 +836,6 @@ impl Parser {
882 return Ok(None);
836 return Ok(None);
883 }
837 }
884
838
885− // --- @with $var as alias (temporary variable rebinding) ---
886− if let Some(rest) = content.strip_prefix("@with ") {
887− let rest = rest.trim();
888− // Parse: @with $source as alias
889− if let Some((source_part, alias)) = rest.split_once(" as ") {
890− let source_name = source_part
891− .trim()
892− .strip_prefix('$')
893− .unwrap_or(source_part.trim());
894− let alias = alias.trim();
895− ctx.used_variables.insert(source_name.to_string());
896− let value = ctx.variables.get(source_name).cloned().unwrap_or_default();
897− let old_value = ctx.variables.get(alias).cloned();
898− ctx.variables.insert(alias.to_string(), value);
899− // Parse body
900− let children = self.parse_children(current_indent + 1, ctx);
901− // Restore previous value
902− if let Some(old) = old_value {
903− ctx.variables.insert(alias.to_string(), old);
904− } else {
905− ctx.variables.remove(alias);
906− }
907− return Ok(Some(children));
908− }
909− return Err(ParseError {
910− line: line_num,
911− message: "@with requires: @with $var as alias".to_string(),
912− });
913− }
914
839
915 // --- @assert directive (compile-time assertions) ---
840 // --- @assert directive (compile-time assertions) ---
916
841
@@ -1413,45 +1338,6 @@ impl Parser {
1413 }
1338 }
1414 }
1339 }
1415
1340
1416− // --- @css-property (CSS @property rule for typed custom properties) ---
1417−
1418− if let Some(rest) = content.strip_prefix("@css-property ") {
1419− let rest = rest.trim();
1420− // @css-property --name
1421− // syntax "<color>"
1422− // inherits true
1423− // initial-value #000
1424− let prop_name = substitute_vars(rest, &ctx.variables);
1425− let mut syntax = String::from("\"*\"");
1426− let mut inherits = String::from("false");
1427− let mut initial_value = String::new();
1428−
1429− while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
1430− if let LineContent::Normal(ref s) = self.lines[self.pos].content {
1431− let trimmed = s.trim();
1432− if let Some((key, value)) = trimmed.split_once(' ') {
1433− let value = substitute_vars(value.trim(), &ctx.variables);
1434− match key.trim() {
1435− "syntax" => syntax = value,
1436− "inherits" => inherits = value,
1437− "initial-value" | "initial_value" => initial_value = value,
1438− _ => {}
1439− }
1440− }
1441− }
1442− self.pos += 1;
1443− }
1444−
1445− let mut rule = format!("@property {} {{", prop_name);
1446− rule.push_str(&format!("syntax:{};", syntax));
1447− rule.push_str(&format!("inherits:{};", inherits));
1448− if !initial_value.is_empty() {
1449− rule.push_str(&format!("initial-value:{};", initial_value));
1450− }
1451− rule.push('}');
1452− ctx.custom_css.push(rule);
1453− return Ok(None);
1454− }
1455
1341
1456 // --- @data (load JSON file into variables) ---
1342 // --- @data (load JSON file into variables) ---
1457
1343
@@ -1540,111 +1426,6 @@ impl Parser {
1540 return Ok(None);
1426 return Ok(None);
1541 }
1427 }
1542
1428
1543− // --- @use (selective import) ---
1544−
1545− if let Some(rest) = content.strip_prefix("@use ") {
1546− let rest = rest.trim();
1547− // @use "./file.hl" fn1, fn2, define1
1548− let (filename, names_str) = if let Some(after_quote) = rest.strip_prefix('"') {
1549− if let Some(end_quote) = after_quote.find('"') {
1550− let filename = &after_quote[..end_quote];
1551− let names = after_quote[end_quote + 1..].trim();
1552− (filename.to_string(), names.to_string())
1553− } else {
1554− return Err(ParseError {
1555− line: line_num,
1556− message: "@use requires: @use \"file.hl\" name1, name2".to_string(),
1557− });
1558− }
1559− } else if let Some((filename, names)) = rest.split_once(' ') {
1560− (filename.to_string(), names.to_string())
1561− } else {
1562− return Err(ParseError {
1563− line: line_num,
1564− message: "@use requires: @use file.hl name1, name2".to_string(),
1565− });
1566− };
1567−
1568− let wanted: HashSet<String> = names_str
1569− .split(',')
1570− .map(|s| s.trim().to_string())
1571− .filter(|s| !s.is_empty())
1572− .collect();
1573−
1574− let filename = substitute_vars(&filename, &ctx.variables);
1575− let resolved = match &ctx.base_path {
1576− Some(base) => base.join(&filename),
1577− None => PathBuf::from(&filename),
1578− };
1579−
1580− if ctx.include_stack.contains(&resolved) {
1581− ctx.diagnostics.push(Diagnostic {
1582− line: line_num,
1583− column: None,
1584− message: format!("circular use '{}'", filename),
1585− severity: Severity::Error,
1586− source_line: Some(content.clone()),
1587− });
1588− return Ok(None);
1589− }
1590−
1591− let use_text = if let Some(cached) = ctx.file_cache.get(&resolved) {
1592− cached.clone()
1593− } else {
1594− match std::fs::read_to_string(&resolved) {
1595− Ok(text) => {
1596− ctx.file_cache.insert(resolved.clone(), text.clone());
1597− text
1598− }
1599− Err(e) => {
1600− ctx.diagnostics.push(Diagnostic {
1601− line: line_num,
1602− column: None,
1603− message: format!("cannot use '{}': {}", filename, e),
1604− severity: Severity::Error,
1605− source_line: Some(content.clone()),
1606− });
1607− return Ok(None);
1608− }
1609− }
1610− };
1611−
1612− ctx.included_files.push(resolved.clone());
1613− ctx.include_stack.push(resolved.clone());
1614− let saved_base = ctx.base_path.clone();
1615− ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
1616−
1617− // Parse the file fully, then filter to only keep wanted definitions
1618− let saved_fns = ctx.functions.clone();
1619− let saved_defines = ctx.defines.clone();
1620−
1621− let use_lines = preprocess(&use_text);
1622− let mut use_parser = Parser {
1623− lines: use_lines,
1624− pos: 0,
1625− };
1626− let _discarded = use_parser.parse_children(0, ctx);
1627−
1628− // Keep only the wanted functions/defines, restore everything else
1629− let mut new_fns = HashMap::new();
1630− let mut new_defines = HashMap::new();
1631− for name in &wanted {
1632− if let Some(f) = ctx.functions.get(name) {
1633− new_fns.insert(name.clone(), f.clone());
1634− }
1635− if let Some(d) = ctx.defines.get(name) {
1636− new_defines.insert(name.clone(), d.clone());
1637− }
1638− }
1639− ctx.functions = saved_fns;
1640− ctx.defines = saved_defines;
1641− ctx.functions.extend(new_fns);
1642− ctx.defines.extend(new_defines);
1643−
1644− ctx.base_path = saved_base;
1645− ctx.include_stack.pop();
1646− return Ok(None);
1647− }
1648
1429
1649 // --- @collection (load multiple JSON files matching a glob pattern) ---
1430 // --- @collection (load multiple JSON files matching a glob pattern) ---
1650
1431
@@ -1826,37 +1607,6 @@ impl Parser {
1826 return Ok(Some(nodes));
1607 return Ok(Some(nodes));
1827 }
1608 }
1828
1609
1829− if let Some(rest) = content.strip_prefix("@unless ") {
1830− let rest = rest.trim();
1831− track_var_refs(rest, &mut ctx.used_variables);
1832− let condition = substitute_vars(rest, &ctx.variables);
1833− let result = !evaluate_condition(&condition);
1834− let mut body_lines = Vec::new();
1835− while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
1836− body_lines.push(self.lines[self.pos].clone());
1837− self.pos += 1;
1838− }
1839− if !result || body_lines.is_empty() {
1840− return Ok(None);
1841− }
1842− let min_indent = body_lines.iter().map(|l| l.indent).min().unwrap_or(0);
1843− let adjusted: Vec<Line> = body_lines
1844− .iter()
1845− .map(|l| Line {
1846− indent: l.indent - min_indent,
1847− content: l.content.clone(),
1848− line_num: l.line_num,
1849− })
1850− .collect();
1851− let saved_vars = ctx.variables.clone();
1852− let mut body_parser = Parser {
1853− lines: adjusted,
1854− pos: 0,
1855− };
1856− let nodes = body_parser.parse_children(0, ctx);
1857− ctx.variables = saved_vars;
1858− return Ok(Some(nodes));
1859− }
1860
1610
1861 if content.trim() == "@else" || content.trim().starts_with("@else if ") {
1611 if content.trim() == "@else" || content.trim().starts_with("@else if ") {
1862 return Err(ParseError {
1612 return Err(ParseError {
@@ -2071,131 +1821,6 @@ impl Parser {
2071 return Ok(Some(all_nodes));
1821 return Ok(Some(all_nodes));
2072 }
1822 }
2073
1823
2074− // --- @for numeric loop ---
2075−
2076− if let Some(rest) = content.strip_prefix("@for ") {
2077− let rest = rest.trim();
2078− // @for $var in start..end OR @for $var in start..end step N
2079− let (var_name, range_str) = if let Some((before_in, after_in)) = rest.split_once(" in ")
2080− {
2081− let var = before_in
2082− .trim()
2083− .strip_prefix('$')
2084− .unwrap_or(before_in.trim());
2085− (var.to_string(), after_in.trim().to_string())
2086− } else {
2087− return Err(ParseError {
2088− line: line_num,
2089− message: "@for requires: @for $var in start..end".to_string(),
2090− });
2091− };
2092− track_var_refs(&range_str, &mut ctx.used_variables);
2093− let range_str = substitute_vars(&range_str, &ctx.variables);
2094−
2095− let items: Vec<String> = if let Some((start_s, rest_range)) = range_str.split_once("..")
2096− {
2097− let (end_s, step) = if let Some((e, s)) = rest_range.split_once(" step ") {
2098− (e.trim(), s.trim().parse::<i64>().unwrap_or(1).max(1))
2099− } else {
2100− (rest_range.trim(), 1i64)
2101− };
2102− if let (Ok(start), Ok(end)) = (start_s.trim().parse::<i64>(), end_s.parse::<i64>())
2103− {
2104− numeric_range(start, end, step)
2105− } else {
2106− return Err(ParseError {
2107− line: line_num,
2108− message: "@for range requires numeric bounds: @for $i in 1..10".to_string(),
2109− });
2110− }
2111− } else {
2112− return Err(ParseError {
2113− line: line_num,
2114− message: "@for requires range syntax: @for $i in 1..10".to_string(),
2115− });
2116− };
2117−
2118− // Collect body lines
2119− let mut body_lines = Vec::new();
2120− while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
2121− body_lines.push(self.lines[self.pos].clone());
2122− self.pos += 1;
2123− }
2124−
2125− if body_lines.is_empty() || items.is_empty() {
2126− return Ok(None);
2127− }
2128−
2129− let min_indent = body_lines.iter().map(|l| l.indent).min().unwrap_or(0);
2130− let adjusted: Vec<Line> = body_lines
2131− .iter()
2132− .map(|l| Line {
2133− indent: l.indent - min_indent,
2134− content: l.content.clone(),
2135− line_num: l.line_num,
2136− })
2137− .collect();
2138−
2139− let saved_vars = ctx.variables.clone();
2140− let mut all_nodes = Vec::new();
2141− for item in &items {
2142− ctx.variables.insert(var_name.clone(), item.clone());
2143− let mut body_parser = Parser {
2144− lines: adjusted.clone(),
2145− pos: 0,
2146− };
2147− let nodes = body_parser.parse_children(0, ctx);
2148− all_nodes.extend(nodes);
2149− }
2150− ctx.variables = saved_vars;
2151− return Ok(Some(all_nodes));
2152− }
2153−
2154− // --- @repeat N (simple repetition) ---
2155−
2156− if let Some(rest) = content.strip_prefix("@repeat ") {
2157− let rest = rest.trim();
2158− let count_str = substitute_vars(rest, &ctx.variables);
2159− track_var_refs(rest, &mut ctx.used_variables);
2160− let count: usize = count_str.parse().unwrap_or(0);
2161−
2162− // Collect body lines
2163− let mut body_lines = Vec::new();
2164− while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
2165− body_lines.push(self.lines[self.pos].clone());
2166− self.pos += 1;
2167− }
2168−
2169− if body_lines.is_empty() || count == 0 {
2170− return Ok(None);
2171− }
2172−
2173− let min_indent = body_lines.iter().map(|l| l.indent).min().unwrap_or(0);
2174− let adjusted: Vec<Line> = body_lines
2175− .iter()
2176− .map(|l| Line {
2177− indent: l.indent - min_indent,
2178− content: l.content.clone(),
2179− line_num: l.line_num,
2180− })
2181− .collect();
2182−
2183− let mut all_nodes = Vec::new();
2184− let saved_vars = ctx.variables.clone();
2185− for i in 0..count {
2186− ctx.variables.insert("_index".to_string(), i.to_string());
2187− ctx.variables
2188− .insert("_count".to_string(), count.to_string());
2189− let mut iter_parser = Parser {
2190− lines: adjusted.clone(),
2191− pos: 0,
2192− };
2193− let nodes = iter_parser.parse_children(0, ctx);
2194− all_nodes.extend(nodes);
2195− }
2196− ctx.variables = saved_vars;
2197− return Ok(Some(all_nodes));
2198− }
2199
1824
2200 // --- @defer (lazy-load below-fold content via IntersectionObserver) ---
1825 // --- @defer (lazy-load below-fold content via IntersectionObserver) ---
2201
1826
@@ -2356,121 +1981,6 @@ impl Parser {
2356 return Ok(Some(nodes));
1981 return Ok(Some(nodes));
2357 }
1982 }
2358
1983
2359− // --- @switch (variant-based attribute switching) ---
2360− // @switch $variant
2361− // @case primary [background #3b82f6, color white]
2362− // @case danger [background #ef4444, color white]
2363− // @default [background #gray, color black]
2364− // Registers matching @define and emits the matching case's children
2365−
2366− if let Some(rest) = content.strip_prefix("@switch ") {
2367− let switch_val = substitute_vars(rest.trim(), &ctx.variables);
2368− track_var_refs(rest.trim(), &mut ctx.used_variables);
2369−
2370− let mut switch_lines = Vec::new();
2371− while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
2372− switch_lines.push(self.lines[self.pos].clone());
2373− self.pos += 1;
2374− }
2375−
2376− if switch_lines.is_empty() {
2377− return Ok(None);
2378− }
2379−
2380− let case_indent = switch_lines[0].indent;
2381− let mut si = 0;
2382− let mut matched_attrs: Option<Vec<Attribute>> = None;
2383− let mut matched_body: Vec<Line> = Vec::new();
2384−
2385− while si < switch_lines.len() {
2386− if switch_lines[si].indent == case_indent {
2387− if let LineContent::Normal(ref s) = switch_lines[si].content {
2388− let trimmed = s.trim();
2389− if let Some(case_rest) = trimmed.strip_prefix("@case ") {
2390− let case_rest = case_rest.trim();
2391− // Extract case value and optional [attrs]
2392− let (case_val, case_attrs_str) =
2393− if let Some(bracket_pos) = case_rest.find('[') {
2394− (
2395− substitute_vars(
2396− case_rest[..bracket_pos].trim(),
2397− &ctx.variables,
2398− ),
2399− Some(&case_rest[bracket_pos..]),
2400− )
2401− } else {
2402− (substitute_vars(case_rest, &ctx.variables), None)
2403− };
2404− si += 1;
2405− // Collect body lines
2406− let mut body = Vec::new();
2407− while si < switch_lines.len() && switch_lines[si].indent > case_indent {
2408− body.push(switch_lines[si].clone());
2409− si += 1;
2410− }
2411− if matched_attrs.is_none() && case_val == switch_val {
2412− if let Some(attrs_str) = case_attrs_str {
2413− let (attrs, _) = parse_attr_brackets(attrs_str, line_num, ctx)?;
2414− matched_attrs = Some(attrs);
2415− }
2416− matched_body = body;
2417− }
2418− } else if trimmed == "@default" || trimmed.starts_with("@default ") {
2419− let default_rest =
2420− trimmed.strip_prefix("@default").unwrap_or("").trim();
2421− si += 1;
2422− let mut body = Vec::new();
2423− while si < switch_lines.len() && switch_lines[si].indent > case_indent {
2424− body.push(switch_lines[si].clone());
2425− si += 1;
2426− }
2427− if matched_attrs.is_none() {
2428− if !default_rest.is_empty() && default_rest.starts_with('[') {
2429− let (attrs, _) =
2430− parse_attr_brackets(default_rest, line_num, ctx)?;
2431− matched_attrs = Some(attrs);
2432− }
2433− matched_body = body;
2434− }
2435− } else {
2436− si += 1;
2437− }
2438− } else {
2439− si += 1;
2440− }
2441− } else {
2442− si += 1;
2443− }
2444− }
2445−
2446− // Store matched attrs as a temporary define for use with $__switch
2447− if let Some(attrs) = matched_attrs {
2448− ctx.defines.insert("__switch".to_string(), attrs);
2449− }
2450−
2451− // Parse matched body if any
2452− if !matched_body.is_empty() {
2453− let min_indent = matched_body.iter().map(|l| l.indent).min().unwrap_or(0);
2454− let adjusted: Vec<Line> = matched_body
2455− .iter()
2456− .map(|l| Line {
2457− indent: l.indent - min_indent,
2458− content: l.content.clone(),
2459− line_num: l.line_num,
2460− })
2461− .collect();
2462− let saved_vars = ctx.variables.clone();
2463− let mut body_parser = Parser {
2464− lines: adjusted,
2465− pos: 0,
2466− };
2467− let nodes = body_parser.parse_children(0, ctx);
2468− ctx.variables = saved_vars;
2469− return Ok(Some(nodes));
2470− }
2471−
2472− return Ok(None);
2473− }
2474
1984
2475 // --- @warn / @debug ---
1985 // --- @warn / @debug ---
2476
1986
@@ -2759,8 +2269,11 @@ impl Parser {
2759 }
2269 }
2760 };
2270 };
2761
2271
2762− // Collect slot blocks defined in the extending file
2272+ // Collect slot blocks defined in the extending file. Everything
2273+ // else becomes the default content, filling the layout's
2274+ // @children.
2763 let mut slot_contents: HashMap<String, Vec<Line>> = HashMap::new();
2275 let mut slot_contents: HashMap<String, Vec<Line>> = HashMap::new();
2276+ let mut default_lines: Vec<Line> = Vec::new();
2764 while self.pos < self.lines.len() {
2277 while self.pos < self.lines.len() {
2765 let line_indent = self.lines[self.pos].indent;
2278 let line_indent = self.lines[self.pos].indent;
2766 if line_indent < current_indent {
2279 if line_indent < current_indent {
@@ -2782,8 +2295,27 @@ impl Parser {
2782 continue;
2295 continue;
2783 }
2296 }
2784 }
2297 }
2298+ default_lines.push(self.lines[self.pos].clone());
2785 self.pos += 1;
2299 self.pos += 1;
2786 }
2300 }
2301+ let default_nodes = if default_lines.is_empty() {
2302+ Vec::new()
2303+ } else {
2304+ let min_indent = default_lines.iter().map(|l| l.indent).min().unwrap_or(0);
2305+ let adjusted: Vec<Line> = default_lines
2306+ .iter()
2307+ .map(|l| Line {
2308+ indent: l.indent - min_indent,
2309+ content: l.content.clone(),
2310+ line_num: l.line_num,
2311+ })
2312+ .collect();
2313+ let mut default_parser = Parser {
2314+ lines: adjusted,
2315+ pos: 0,
2316+ };
2317+ default_parser.parse_children(0, ctx)
2318+ };
2787
2319
2788 // Parse slot contents into nodes
2320 // Parse slot contents into nodes
2789 let mut slot_nodes: HashMap<String, Vec<Node>> = HashMap::new();
2321 let mut slot_nodes: HashMap<String, Vec<Node>> = HashMap::new();
@@ -2824,77 +2356,11 @@ impl Parser {
2824 ctx.base_path = saved_base;
2356 ctx.base_path = saved_base;
2825 ctx.include_stack.pop();
2357 ctx.include_stack.pop();
2826
2358
2827− // Replace @slot placeholders in layout with provided content
2359+ // Fill @slot placeholders and @children in the layout
2828− let result_nodes = replace_extends_slots(layout_nodes, &slot_nodes);
2360+ let result_nodes = replace_children_and_slots(layout_nodes, &default_nodes, &slot_nodes);
2829 return Ok(Some(result_nodes));
2361 return Ok(Some(result_nodes));
2830 }
2362 }
2831
2363
2832− // --- @layout (lightweight layout wrapper using @children) ---
2833−
2834− if let Some(rest) = content.strip_prefix("@layout ") {
2835− let filename = substitute_vars(rest.trim(), &ctx.variables);
2836− let resolved = match &ctx.base_path {
2837− Some(base) => base.join(&filename),
2838− None => PathBuf::from(&filename),
2839− };
2840−
2841− if ctx.include_stack.contains(&resolved) {
2842− ctx.diagnostics.push(Diagnostic {
2843− line: line_num,
2844− column: None,
2845− message: format!("circular layout '{}'", filename),
2846− severity: Severity::Error,
2847− source_line: Some(content.clone()),
2848− });
2849− return Ok(None);
2850− }
2851−
2852− let layout_text = if let Some(cached) = ctx.file_cache.get(&resolved) {
2853− cached.clone()
2854− } else {
2855− match std::fs::read_to_string(&resolved) {
2856− Ok(text) => {
2857− ctx.file_cache.insert(resolved.clone(), text.clone());
2858− text
2859− }
2860− Err(e) => {
2861− ctx.diagnostics.push(Diagnostic {
2862− line: line_num,
2863− column: None,
2864− message: format!("cannot load layout '{}': {}", filename, e),
2865− severity: Severity::Error,
2866− source_line: Some(content.clone()),
2867− });
2868− return Ok(None);
2869− }
2870− }
2871− };
2872−
2873− // Parse the body content (children of @layout)
2874− let caller_children = self.parse_children(current_indent + 1, ctx);
2875−
2876− // Parse the layout file
2877− ctx.included_files.push(resolved.clone());
2878− ctx.include_stack.push(resolved.clone());
2879− let saved_base = ctx.base_path.clone();
2880− ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
2881−
2882− let layout_lines = preprocess(&layout_text);
2883− let mut layout_parser = Parser {
2884− lines: layout_lines,
2885− pos: 0,
2886− };
2887− let layout_nodes = layout_parser.parse_children(0, ctx);
2888−
2889− ctx.base_path = saved_base;
2890− ctx.include_stack.pop();
2891−
2892− // Replace @children in layout with the caller's body content
2893− let empty_slots: HashMap<String, Vec<Node>> = HashMap::new();
2894− let result_nodes =
2895− replace_children_and_slots(layout_nodes, &caller_children, &empty_slots);
2896− return Ok(Some(result_nodes));
2897− }
2898
2364
2899 // --- @component definition (scoped @fn) ---
2365 // --- @component definition (scoped @fn) ---
2900
2366
@@ -3319,6 +2785,16 @@ fn parse_single_element(
3319 kind
2785 kind
3320 };
2786 };
3321
2787
2788+ // Every other element treats its argument as leading text content, as
2789+ // in `@el [padding 8] Hello` or `@paragraph Read {@link /more more}`.
2790+ let argument = match argument {
2791+ Some(_) if !argument_is_special(&kind) && !renders_argument_as_text(&kind) => {
2792+ children.insert(0, Node::Text(parse_text_segments(&rest, ctx)));
2793+ None
2794+ }
2795+ other => other,
2796+ };
2797+
3322 Ok(Element {
2798 Ok(Element {
3323 kind,
2799 kind,
3324 attrs,
2800 attrs,
@@ -3328,16 +2804,30 @@ fn parse_single_element(
3328 })
2804 })
3329}
2805}
3330
2806
2807+/// Elements whose argument is not text content: a URL, a source, an action,
2808+/// or a slot name.
2809+fn argument_is_special(kind: &ElementKind) -> bool {
2810+ matches!(
2811+ kind,
2812+ ElementKind::Link
2813+ | ElementKind::Image
2814+ | ElementKind::Video
2815+ | ElementKind::Audio
2816+ | ElementKind::Source
2817+ | ElementKind::Form
2818+ | ElementKind::Iframe
2819+ | ElementKind::Script
2820+ | ElementKind::Slot(_)
2821+ )
2822+}
2823+
3331const KNOWN_ELEMENTS: &[&str] = &[
2824const KNOWN_ELEMENTS: &[&str] = &[
3332 "row",
2825 "row",
3333 "column",
2826 "column",
3334− "col",
3335 "el",
2827 "el",
3336 "text",
2828 "text",
3337 "paragraph",
2829 "paragraph",
3338− "p",
3339 "image",
2830 "image",
3340− "img",
3341 "link",
2831 "link",
3342 "children",
2832 "children",
3343 "input",
2833 "input",
@@ -3356,7 +2846,6 @@ const KNOWN_ELEMENTS: &[&str] = &[
3356 "aside",
2846 "aside",
3357 "list",
2847 "list",
3358 "item",
2848 "item",
3359− "li",
3360 "table",
2849 "table",
3361 "thead",
2850 "thead",
3362 "tbody",
2851 "tbody",
@@ -3373,14 +2862,11 @@ const KNOWN_ELEMENTS: &[&str] = &[
3373 "code",
2862 "code",
3374 "pre",
2863 "pre",
3375 "hr",
2864 "hr",
3376− "divider",
3377 "figure",
2865 "figure",
3378 "figcaption",
2866 "figcaption",
3379 "progress",
2867 "progress",
3380 "meter",
2868 "meter",
3381 "fragment",
2869 "fragment",
3382− "btn",
3383− "ul",
3384 "dialog",
2870 "dialog",
3385 "dl",
2871 "dl",
3386 "dt",
2872 "dt",
@@ -3439,12 +2925,10 @@ const KNOWN_DIRECTIVES: &[&str] = &[
3439 "default",
2925 "default",
3440 "warn",
2926 "warn",
3441 "debug",
2927 "debug",
3442− "unless",
3443 "og",
2928 "og",
3444 "breakpoint",
2929 "breakpoint",
3445 "lang",
2930 "lang",
3446 "favicon",
2931 "favicon",
3447− "use",
3448 "theme",
2932 "theme",
3449 "deprecated",
2933 "deprecated",
3450 "extends",
2934 "extends",
@@ -3453,36 +2937,29 @@ const KNOWN_DIRECTIVES: &[&str] = &[
3453 "font-face",
2937 "font-face",
3454 "json-ld",
2938 "json-ld",
3455 "assert",
2939 "assert",
3456− "for",
3457 "component",
2940 "component",
3458− "switch",
3459 "log",
2941 "log",
3460 "markdown",
2942 "markdown",
3461− "repeat",
3462− "with",
3463− "layout",
3464 "collection",
2943 "collection",
3465 "manifest",
2944 "manifest",
3466− "scope",
3467− "starting-style",
3468 "translations",
2945 "translations",
3469];
2946];
3470
2947
3471fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
2948fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
3472 match s {
2949 match s {
3473 "row" => Ok(ElementKind::Row),
2950 "row" => Ok(ElementKind::Row),
3474− "column" | "col" => Ok(ElementKind::Column),
2951+ "column" => Ok(ElementKind::Column),
3475 "el" => Ok(ElementKind::El),
2952 "el" => Ok(ElementKind::El),
3476 "text" => Ok(ElementKind::Text),
2953 "text" => Ok(ElementKind::Text),
3477− "paragraph" | "p" => Ok(ElementKind::Paragraph),
2954+ "paragraph" => Ok(ElementKind::Paragraph),
3478− "image" | "img" => Ok(ElementKind::Image),
2955+ "image" => Ok(ElementKind::Image),
3479 "link" => Ok(ElementKind::Link),
2956 "link" => Ok(ElementKind::Link),
3480 "children" => Ok(ElementKind::Children),
2957 "children" => Ok(ElementKind::Children),
3481 "input" => Ok(ElementKind::Input),
2958 "input" => Ok(ElementKind::Input),
3482− "button" | "btn" => Ok(ElementKind::Button),
2959+ "button" => Ok(ElementKind::Button),
3483 "select" => Ok(ElementKind::Select),
2960 "select" => Ok(ElementKind::Select),
3484 "textarea" => Ok(ElementKind::Textarea),
2961 "textarea" => Ok(ElementKind::Textarea),
3485− "option" | "opt" => Ok(ElementKind::Option),
2962+ "option" => Ok(ElementKind::Option),
3486 "label" => Ok(ElementKind::Label),
2963 "label" => Ok(ElementKind::Label),
3487 "slot" => Ok(ElementKind::Slot(String::new())), // slot name filled in by parse_single_element
2964 "slot" => Ok(ElementKind::Slot(String::new())), // slot name filled in by parse_single_element
3488 "nav" => Ok(ElementKind::Nav),
2965 "nav" => Ok(ElementKind::Nav),
@@ -3493,7 +2970,7 @@ fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseErro
3493 "article" => Ok(ElementKind::Article),
2970 "article" => Ok(ElementKind::Article),
3494 "aside" => Ok(ElementKind::Aside),
2971 "aside" => Ok(ElementKind::Aside),
3495 "list" => Ok(ElementKind::List),
2972 "list" => Ok(ElementKind::List),
3496− "item" | "li" => Ok(ElementKind::ListItem),
2973+ "item" => Ok(ElementKind::ListItem),
3497 "table" => Ok(ElementKind::Table),
2974 "table" => Ok(ElementKind::Table),
3498 "thead" => Ok(ElementKind::TableHead),
2975 "thead" => Ok(ElementKind::TableHead),
3499 "tbody" => Ok(ElementKind::TableBody),
2976 "tbody" => Ok(ElementKind::TableBody),
@@ -3509,7 +2986,7 @@ fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseErro
3509 "cite" => Ok(ElementKind::Cite),
2986 "cite" => Ok(ElementKind::Cite),
3510 "code" => Ok(ElementKind::Code),
2987 "code" => Ok(ElementKind::Code),
3511 "pre" => Ok(ElementKind::Pre),
2988 "pre" => Ok(ElementKind::Pre),
3512− "hr" | "divider" => Ok(ElementKind::HorizontalRule),
2989+ "hr" => Ok(ElementKind::HorizontalRule),
3513 "figure" => Ok(ElementKind::Figure),
2990 "figure" => Ok(ElementKind::Figure),
3514 "figcaption" => Ok(ElementKind::FigCaption),
2991 "figcaption" => Ok(ElementKind::FigCaption),
3515 "progress" => Ok(ElementKind::Progress),
2992 "progress" => Ok(ElementKind::Progress),
@@ -3537,7 +3014,6 @@ fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseErro
3537 "iframe" => Ok(ElementKind::Iframe),
3014 "iframe" => Ok(ElementKind::Iframe),
3538 "output" => Ok(ElementKind::Output),
3015 "output" => Ok(ElementKind::Output),
3539 "canvas" => Ok(ElementKind::Canvas),
3016 "canvas" => Ok(ElementKind::Canvas),
3540− "ul" => Ok(ElementKind::List),
3541 "grid" => Ok(ElementKind::Grid),
3017 "grid" => Ok(ElementKind::Grid),
3542 "stack" => Ok(ElementKind::Stack),
3018 "stack" => Ok(ElementKind::Stack),
3543 "spacer" => Ok(ElementKind::Spacer),
3019 "spacer" => Ok(ElementKind::Spacer),
@@ -3555,6 +3031,12 @@ fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseErro
3555 "in-front" => Ok(ElementKind::InFront),
3031 "in-front" => Ok(ElementKind::InFront),
3556 "behind" => Ok(ElementKind::Behind),
3032 "behind" => Ok(ElementKind::Behind),
3557 _ => {
3033 _ => {
3034+ if let Some(hint) = removed_syntax_hint(&format!("@{}", s)) {
3035+ return Err(ParseError {
3036+ line: line_num,
3037+ message: hint,
3038+ });
3039+ }
3558 let all_known: Vec<&str> = KNOWN_ELEMENTS
3040 let all_known: Vec<&str> = KNOWN_ELEMENTS
3559 .iter()
3041 .iter()
3560 .chain(KNOWN_DIRECTIVES.iter())
3042 .chain(KNOWN_DIRECTIVES.iter())
@@ -3573,6 +3055,56 @@ fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseErro
3573 }
3055 }
3574}
3056}
3575
3057
3058+/// Directives and element names removed when the language was simplified,
3059+/// with what replaces each. `htmlang upgrade` rewrites all of them.
3060+const REMOVED_SYNTAX: &[(&str, &str)] = &[
3061+ ("@fn", "use `@let name $param` with an indented body"),
3062+ ("@define", "use `@let name [attributes]`"),
3063+ ("@mixin", "use `@let name [attributes]`"),
3064+ ("@unless", "use `@if not <condition>`"),
3065+ ("@for", "use `@each $i in 1..10`"),
3066+ ("@repeat", "use `@each $_ in 1..N`"),
3067+ (
3068+ "@switch",
3069+ "use `@match`, with `@let __switch [...]` inside a case for its attributes",
3070+ ),
3071+ ("@use", "use `@import`"),
3072+ ("@with", "use `@let alias $source`"),
3073+ (
3074+ "@layout",
3075+ "use `@extends` (page content outside `@slot` blocks fills `@children`)",
3076+ ),
3077+ ("@scope", "write the `@scope` rule in an `@style` block"),
3078+ (
3079+ "@starting-style",
3080+ "write the `@starting-style` rule in an `@style` block",
3081+ ),
3082+ ("@css-property", "write an `@property` rule in an `@style` block"),
3083+ ("@col", "use `@column`"),
3084+ ("@p", "use `@paragraph`"),
3085+ ("@img", "use `@image`"),
3086+ ("@li", "use `@item`"),
3087+ ("@btn", "use `@button`"),
3088+ ("@ul", "use `@list`"),
3089+ ("@divider", "use `@hr`"),
3090+ ("@opt", "use `@option`"),
3091+];
3092+
3093+/// If `content` starts with removed syntax, the error message for it.
3094+fn removed_syntax_hint(content: &str) -> Option<String> {
3095+ let trimmed = content.trim_start();
3096+ REMOVED_SYNTAX.iter().find_map(|(name, replacement)| {
3097+ let rest = trimmed.strip_prefix(name)?;
3098+ if !(rest.is_empty() || rest.starts_with([' ', '['])) {
3099+ return None;
3100+ }
3101+ Some(format!(
3102+ "`{}` was removed: {} (run `htmlang upgrade` to rewrite it automatically)",
3103+ name, replacement
3104+ ))
3105+ })
3106+}
3107+
3576/// Format include/import stack as a readable chain for error messages.
3108/// Format include/import stack as a readable chain for error messages.
3577fn format_include_chain(stack: &[PathBuf]) -> String {
3109fn format_include_chain(stack: &[PathBuf]) -> String {
3578 stack
3110 stack
@@ -4054,7 +3586,6 @@ const KNOWN_ATTRS: &[&str] = &[
4054 // View transitions
3586 // View transitions
4055 "view-transition-name",
3587 "view-transition-name",
4056 // Animate shorthand
3588 // Animate shorthand
4057− "animate",
4058 // Critical CSS hint
3589 // Critical CSS hint
4059 "critical",
3590 "critical",
4060 // CSS subgrid
3591 // CSS subgrid
@@ -4071,7 +3602,6 @@ const KNOWN_ATTRS: &[&str] = &[
4071 "anchor-name",
3602 "anchor-name",
4072 "position-anchor",
3603 "position-anchor",
4073 "position-area",
3604 "position-area",
4074− "inset-area",
4075 // Drop caps
3605 // Drop caps
4076 "initial-letter",
3606 "initial-letter",
4077 // Responsive images
3607 // Responsive images
@@ -4516,15 +4046,6 @@ fn parse_attr_list(
4516 continue;
4046 continue;
4517 }
4047 }
4518
4048
4519− // ...$name spread — expand define/attribute bundle
4520− if let Some(name) = part.strip_prefix("...$")
4521− && let Some(define_attrs) = ctx.defines.get(name)
4522− {
4523− ctx.used_defines.insert(name.to_string());
4524− attrs.extend(define_attrs.clone());
4525− continue;
4526− }
4527−
4528 // $define reference — expand attribute bundle
4049 // $define reference — expand attribute bundle
4529 if let Some(name) = part.strip_prefix('$')
4050 if let Some(name) = part.strip_prefix('$')
4530 && let Some(define_attrs) = ctx.defines.get(name)
4051 && let Some(define_attrs) = ctx.defines.get(name)
@@ -4764,12 +4285,24 @@ fn parse_text_segments(input: &str, ctx: &mut ParseContext) -> Vec<TextSegment>
4764 let inner: String = chars[start..i].iter().collect();
4285 let inner: String = chars[start..i].iter().collect();
4765 i += 1; // skip }
4286 i += 1; // skip }
4766
4287
4767− if let Ok(elem) = parse_single_element(&inner, 0, ctx) {
4288+ match parse_single_element(&inner, ctx.current_line, ctx) {
4768− segments.push(TextSegment::Inline(elem));
4289+ Ok(elem) => segments.push(TextSegment::Inline(elem)),
4769− } else {
4290+ Err(e) => {
4291+ // Keep the braces as literal text, but flag what looks
4292+ // like a mistyped inline element.
4293+ if inner.trim_start().starts_with('@') {
4294+ ctx.diagnostics.push(Diagnostic {
4295+ line: ctx.current_line,
4296+ column: None,
4297+ message: e.message,
4298+ severity: Severity::Warning,
4299+ source_line: Some(input.to_string()),
4300+ });
4301+ }
4770− current_text.push('{');
4302+ current_text.push('{');
4771− current_text.push_str(&inner);
4303+ current_text.push_str(&inner);
4772− current_text.push('}');
4304+ current_text.push('}');
4305+ }
4773 }
4306 }
4774 } else {
4307 } else {
4775 current_text.push(chars[i]);
4308 current_text.push(chars[i]);
@@ -4821,8 +4354,9 @@ fn numeric_range(start: i64, end: i64, step: i64) -> Vec<String> {
4821}
4354}
4822
4355
4823fn evaluate_condition(condition: &str) -> bool {
4356fn evaluate_condition(condition: &str) -> bool {
4824− // Ternary expression support: condition ? true_val : false_val
4357+ if let Some(rest) = condition.trim_start().strip_prefix("not ") {
4825− // (just check the condition part, the ternary is handled in substitute_ternary)
4358+ return !evaluate_condition(rest);
4359+ }
4826
4360
4827 if let Some((left, right)) = condition.split_once("!=") {
4361 if let Some((left, right)) = condition.split_once("!=") {
4828 left.trim() != right.trim()
4362 left.trim() != right.trim()
@@ -5685,32 +5219,6 @@ fn parse_keyframe_line(line: &str) -> Option<String> {
5685 Some(format!("{}{{{}}}", selector, css))
5219 Some(format!("{}{{{}}}", selector, css))
5686}
5220}
5687
5221
5688−/// Replace @slot placeholders in the base layout with content from @extends
5689−fn replace_extends_slots(nodes: Vec<Node>, slot_nodes: &HashMap<String, Vec<Node>>) -> Vec<Node> {
5690− let mut result = Vec::new();
5691− for node in nodes {
5692− match node {
5693− Node::Element(elem) if matches!(&elem.kind, ElementKind::Slot(name) if !name.is_empty()) =>
5694− {
5695− if let ElementKind::Slot(ref name) = elem.kind {
5696− if let Some(content) = slot_nodes.get(name) {
5697− result.extend(content.iter().cloned());
5698− } else if !elem.children.is_empty() {
5699− // Use slot's own children as default
5700− result.extend(elem.children);
5701− }
5702− }
5703− }
5704− Node::Element(mut elem) => {
5705− elem.children = replace_extends_slots(elem.children, slot_nodes);
5706− result.push(Node::Element(elem));
5707− }
5708− other => result.push(other),
5709− }
5710− }
5711− result
5712−}
5713−
5714fn apply_filter(value: &str, filter: &str) -> String {
5222fn apply_filter(value: &str, filter: &str) -> String {
5715 if let Some(arg) = filter.strip_prefix("truncate:") {
5223 if let Some(arg) = filter.strip_prefix("truncate:") {
5716 if let Ok(n) = arg.parse::<usize>()
5224 if let Ok(n) = arg.parse::<usize>()
@@ -5776,9 +5284,9 @@ fn apply_filter(value: &str, filter: &str) -> String {
5776 return value.to_string();
5284 return value.to_string();
5777 }
5285 }
5778 match filter {
5286 match filter {
5779− "uppercase" | "upper" => value.to_uppercase(),
5287+ "uppercase" => value.to_uppercase(),
5780− "lowercase" | "lower" => value.to_lowercase(),
5288+ "lowercase" => value.to_lowercase(),
5781− "capitalize" | "cap" => {
5289+ "capitalize" => {
5782 let mut chars = value.chars();
5290 let mut chars = value.chars();
5783 match chars.next() {
5291 match chars.next() {
5784 Some(c) => format!("{}{}", c.to_uppercase(), chars.as_str()),
5292 Some(c) => format!("{}{}", c.to_uppercase(), chars.as_str()),
@@ -5786,7 +5294,7 @@ fn apply_filter(value: &str, filter: &str) -> String {
5786 }
5294 }
5787 }
5295 }
5788 "trim" => value.trim().to_string(),
5296 "trim" => value.trim().to_string(),
5789− "length" | "len" => value.chars().count().to_string(),
5297+ "length" => value.chars().count().to_string(),
5790 "reverse" => value.chars().rev().collect(),
5298 "reverse" => value.chars().rev().collect(),
5791 _ => value.to_string(),
5299 _ => value.to_string(),
5792 }
5300 }
examples/demo.hl+6−6
@@ -45,7 +45,7 @@
45
45
46-- Component with default parameter + @children slot
46-- Component with default parameter + @children slot
47@let card $title $icon=◆
47@let card $title $icon=◆
48− @article [$card, ...$interactive, animate fade-up 0.3s ease]
48+ @article [$card, $interactive, animation fade-up 0.3s ease]
49 @row [spacing 10, align-items center]
49 @row [spacing 10, align-items center]
50 @text [size 20, color $primary] $icon
50 @text [size 20, color $primary] $icon
51 @text [bold, size 16, color $ink] $title
51 @text [bold, size 16, color $ink] $title
@@ -129,7 +129,7 @@
129 @text $label
129 @text $label
130"""
130"""
131
131
132− -- Control flow: @each, @else, @repeat, @for, @match, if()
132+ -- Control flow: @each, @else, @match, if()
133 @titled [title Control flow]
133 @titled [title Control flow]
134 @column [spacing 6]
134 @column [spacing 6]
135 @each $name, $role in Alice Engineer, Bob Designer, Carol PM
135 @each $name, $role in Alice Engineer, Bob Designer, Carol PM
@@ -144,14 +144,14 @@
144 @else
144 @else
145 @text [color $muted, italic, data-state $nothing] (Nothing — @each ... @else fallback)
145 @text [color $muted, italic, data-state $nothing] (Nothing — @each ... @else fallback)
146
146
147− -- @repeat
147+ -- repeat N times
148 @row [spacing 6]
148 @row [spacing 6]
149− @repeat 6
149+ @each $_ in 1..6
150 @el [width 18, height 18, rounded 9, background $primary, opacity 0.4]
150 @el [width 18, height 18, rounded 9, background $primary, opacity 0.4]
151
151
152− -- @for range
152+ -- numeric range
153 @row [spacing 4]
153 @row [spacing 4]
154− @for $i in 1..6
154+ @each $i in 1..6
155 @el [
155 @el [
156 width 24, height 24, rounded 4,
156 width 24, height 24, rounded 4,
157 background $primary, color white,
157 background $primary, color white,
src/bin/htmlang_lsp/analysis.rs+3−35
@@ -506,31 +506,6 @@ pub(crate) fn code_actions(
506 }),
506 }),
507 ..Default::default()
507 ..Default::default()
508 }));
508 }));
509−
510− // Also offer @use (selective import)
511− let use_line = format!("@use \"{}\" {}\n", rel, fn_name);
512− let use_edit = TextEdit {
513− range: Range::new(
514− Position::new(0, 0),
515− Position::new(0, 0),
516− ),
517− new_text: use_line,
518− };
519− let mut use_changes = HashMap::new();
520− use_changes.insert(uri.clone(), vec![use_edit]);
521− actions.push(CodeActionOrCommand::CodeAction(CodeAction {
522− title: format!(
523− "Add '@use \"{}\" {}' (selective)",
524− rel, fn_name
525− ),
526− kind: Some(CodeActionKind::QUICKFIX),
527− diagnostics: Some(vec![diag.clone()]),
528− edit: Some(WorkspaceEdit {
529− changes: Some(use_changes),
530− ..Default::default()
531− }),
532− ..Default::default()
533− }));
534 }
509 }
535 }
510 }
536 }
511 }
@@ -1069,10 +1044,10 @@ pub(crate) fn semantic_tokens(text: &str, result: &ParseResult) -> Vec<SemanticT
1069 "@page" | "@let" | "@if" | "@else" | "@each" | "@include" | "@import"
1044 "@page" | "@let" | "@if" | "@else" | "@each" | "@include" | "@import"
1070 | "@meta" | "@head" | "@style" | "@keyframes" | "@match" | "@case"
1045 | "@meta" | "@head" | "@style" | "@keyframes" | "@match" | "@case"
1071 | "@default" | "@slot" | "@children" | "@warn" | "@debug" | "@lang"
1046 | "@default" | "@slot" | "@children" | "@warn" | "@debug" | "@lang"
1072− | "@favicon" | "@fragment" | "@unless" | "@og" | "@breakpoint"
1047+ | "@favicon" | "@fragment" | "@og" | "@breakpoint"
1073 | "@canonical" | "@base" | "@font-face" | "@json-ld" | "@assert" | "@theme"
1048 | "@canonical" | "@base" | "@font-face" | "@json-ld" | "@assert" | "@theme"
1074− | "@deprecated" | "@extends" | "@use" | "@data" | "@env" | "@fetch"
1049+ | "@deprecated" | "@extends" | "@data" | "@env" | "@fetch"
1075− | "@svg" | "@css-property" => 0, // keyword
1050+ | "@svg" => 0, // keyword
1076 _ => {
1051 _ => {
1077 // Check if it's a user function call (starts with @ but not a builtin element)
1052 // Check if it's a user function call (starts with @ but not a builtin element)
1078 if is_builtin_element(word) { 0 } else { 2 } // function
1053 if is_builtin_element(word) { 0 } else { 2 } // function
@@ -1140,21 +1115,16 @@ fn is_builtin_element(word: &str) -> bool {
1140 word,
1115 word,
1141 "@row"
1116 "@row"
1142 | "@column"
1117 | "@column"
1143− | "@col"
1144 | "@el"
1118 | "@el"
1145 | "@text"
1119 | "@text"
1146 | "@paragraph"
1120 | "@paragraph"
1147− | "@p"
1148 | "@image"
1121 | "@image"
1149− | "@img"
1150 | "@link"
1122 | "@link"
1151 | "@input"
1123 | "@input"
1152 | "@button"
1124 | "@button"
1153− | "@btn"
1154 | "@select"
1125 | "@select"
1155 | "@textarea"
1126 | "@textarea"
1156 | "@option"
1127 | "@option"
1157− | "@opt"
1158 | "@label"
1128 | "@label"
1159 | "@raw"
1129 | "@raw"
1160 | "@nav"
1130 | "@nav"
@@ -1166,7 +1136,6 @@ fn is_builtin_element(word: &str) -> bool {
1166 | "@aside"
1136 | "@aside"
1167 | "@list"
1137 | "@list"
1168 | "@item"
1138 | "@item"
1169− | "@li"
1170 | "@table"
1139 | "@table"
1171 | "@thead"
1140 | "@thead"
1172 | "@tbody"
1141 | "@tbody"
@@ -1183,7 +1152,6 @@ fn is_builtin_element(word: &str) -> bool {
1183 | "@code"
1152 | "@code"
1184 | "@pre"
1153 | "@pre"
1185 | "@hr"
1154 | "@hr"
1186− | "@divider"
1187 | "@figure"
1155 | "@figure"
1188 | "@figcaption"
1156 | "@figcaption"
1189 | "@progress"
1157 | "@progress"
src/bin/htmlang_lsp/completion.rs−130
@@ -184,21 +184,16 @@ fn element_completions(range: Range) -> Vec<CompletionItem> {
184 [
184 [
185 ("@row", "Horizontal layout (flexbox row)"),
185 ("@row", "Horizontal layout (flexbox row)"),
186 ("@column", "Vertical layout (flexbox column)"),
186 ("@column", "Vertical layout (flexbox column)"),
187− ("@col", "Vertical layout (short for @column)"),
188 ("@el", "Generic container"),
187 ("@el", "Generic container"),
189 ("@text", "Styled inline text (span)"),
188 ("@text", "Styled inline text (span)"),
190 ("@paragraph", "Flowing text block (p)"),
189 ("@paragraph", "Flowing text block (p)"),
191− ("@p", "Flowing text block (short for @paragraph)"),
192 ("@image", "Image element"),
190 ("@image", "Image element"),
193− ("@img", "Image element (short for @image)"),
194 ("@link", "Anchor/link element"),
191 ("@link", "Anchor/link element"),
195 ("@input", "Form input element (self-closing)"),
192 ("@input", "Form input element (self-closing)"),
196 ("@button", "Button element"),
193 ("@button", "Button element"),
197− ("@btn", "Button element (short for @button)"),
198 ("@select", "Select dropdown element"),
194 ("@select", "Select dropdown element"),
199 ("@textarea", "Multi-line text input"),
195 ("@textarea", "Multi-line text input"),
200 ("@option", "Option inside @select"),
196 ("@option", "Option inside @select"),
201− ("@opt", "Option (short for @option)"),
202 ("@label", "Label element"),
197 ("@label", "Label element"),
203 ("@raw", "Raw HTML escape hatch"),
198 ("@raw", "Raw HTML escape hatch"),
204 ("@children", "Slot for caller's children (inside component)"),
199 ("@children", "Slot for caller's children (inside component)"),
@@ -214,7 +209,6 @@ fn element_completions(range: Range) -> Vec<CompletionItem> {
214 // List elements
209 // List elements
215 ("@list", "List container (ul/ol, use [ordered] for ol)"),
210 ("@list", "List container (ul/ol, use [ordered] for ol)"),
216 ("@item", "List item (li)"),
211 ("@item", "List item (li)"),
217− ("@li", "List item (short for @item)"),
218 // Table elements
212 // Table elements
219 ("@table", "Table element"),
213 ("@table", "Table element"),
220 ("@thead", "Table head group"),
214 ("@thead", "Table head group"),
@@ -234,7 +228,6 @@ fn element_completions(range: Range) -> Vec<CompletionItem> {
234 ("@code", "Inline code (monospace)"),
228 ("@code", "Inline code (monospace)"),
235 ("@pre", "Preformatted text block"),
229 ("@pre", "Preformatted text block"),
236 ("@hr", "Horizontal rule/divider"),
230 ("@hr", "Horizontal rule/divider"),
237− ("@divider", "Horizontal rule (alias for @hr)"),
238 ("@figure", "Figure with optional caption"),
231 ("@figure", "Figure with optional caption"),
239 ("@figcaption", "Caption for @figure"),
232 ("@figcaption", "Caption for @figure"),
240 ("@progress", "Progress bar (value, max attributes)"),
233 ("@progress", "Progress bar (value, max attributes)"),
@@ -350,11 +343,6 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
350 "Set favicon (inlined as base64 data URI)",
343 "Set favicon (inlined as base64 data URI)",
351 "@favicon ",
344 "@favicon ",
352 ),
345 ),
353− (
354− "@unless",
355− "Inverse conditional (renders when false)",
356− "@unless ",
357− ),
358 ("@og", "Add Open Graph meta tag", "@og "),
346 ("@og", "Add Open Graph meta tag", "@og "),
359 (
347 (
360 "@breakpoint",
348 "@breakpoint",
@@ -376,11 +364,6 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
376 "Inherit a layout template and fill @slot blocks",
364 "Inherit a layout template and fill @slot blocks",
377 "@extends ",
365 "@extends ",
378 ),
366 ),
379− (
380− "@use",
381− "Selective import of definitions from a file",
382− "@use ",
383− ),
384 (
367 (
385 "@canonical",
368 "@canonical",
386 "Set canonical URL for the page",
369 "Set canonical URL for the page",
@@ -406,11 +389,6 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
406 ("@env", "Access compile-time environment variable", "@env "),
389 ("@env", "Access compile-time environment variable", "@env "),
407 ("@fetch", "Fetch data from URL at compile time", "@fetch "),
390 ("@fetch", "Fetch data from URL at compile time", "@fetch "),
408 ("@svg", "Inline SVG file with optional attributes", "@svg "),
391 ("@svg", "Inline SVG file with optional attributes", "@svg "),
409− (
410− "@css-property",
411− "Define typed CSS custom property (@property)",
412− "@css-property ",
413− ),
414 ]
392 ]
415 .iter()
393 .iter()
416 .map(|(name, detail, insert)| item(name, CompletionItemKind::SNIPPET, detail, insert, range))
394 .map(|(name, detail, insert)| item(name, CompletionItemKind::SNIPPET, detail, insert, range))
@@ -504,11 +482,6 @@ fn snippet_completions(range: Range) -> Vec<CompletionItem> {
504 "Pattern matching block",
482 "Pattern matching block",
505 "@match \\$${1:value}\n @case ${2:option1}\n ${3:content}\n @default\n ${4:fallback}",
483 "@match \\$${1:value}\n @case ${2:option1}\n ${3:content}\n @default\n ${4:fallback}",
506 ),
484 ),
507− (
508− "@for range loop",
509− "Loop over a numeric range",
510− "@for \\$${1:i} in ${2:0}..${3:10}\n @text \\$${1:i}",
511− ),
512 (
485 (
513 "@data JSON load",
486 "@data JSON load",
514 "Load variables from a JSON file",
487 "Load variables from a JSON file",
@@ -584,103 +557,6 @@ pub(crate) fn path_completions(uri: &Url, position: Position) -> Vec<CompletionI
584 items
557 items
585}
558}
586
559
587−/// Suggest exported names from a file referenced in @use
588−pub(crate) fn use_symbol_completions(
589− uri: &Url,
590− line: &str,
591− position: Position,
592−) -> Vec<CompletionItem> {
593− let file_path = match uri.to_file_path() {
594− Ok(p) => p,
595− Err(_) => return vec![],
596− };
597− let dir = match file_path.parent() {
598− Some(d) => d,
599− None => return vec![],
600− };
601−
602− // Extract the filename from @use "file.hl" or @use file.hl
603− let after_use = &line.trim_start()[5..]; // skip "@use "
604− let filename = if let Some(after_quote) = after_use.strip_prefix('"') {
605− let end = after_quote.find('"').unwrap_or(after_quote.len());
606− &after_quote[..end]
607− } else {
608− after_use.split_whitespace().next().unwrap_or("")
609− };
610−
611− if filename.is_empty() {
612− return vec![];
613− }
614−
615− let target_path = dir.join(filename);
616− let target_content = match std::fs::read_to_string(&target_path) {
617− Ok(s) => s,
618− Err(_) => return vec![],
619− };
620−
621− let col = position.character;
622− let edit_range = Range::new(
623− Position::new(position.line, col),
624− Position::new(position.line, col),
625− );
626−
627− let mut items = Vec::new();
628−
629− for target_line in target_content.lines() {
630− let trimmed = target_line.trim();
631− if let Some(rest) = trimmed.strip_prefix("@let ") {
632− if let Some(name) = rest.split_whitespace().next() {
633− let name = name.trim_end_matches('[');
634− let params: Vec<&str> = rest
635− .split_whitespace()
636− .skip(1)
637− .filter(|p| p.starts_with('$'))
638− .collect();
639− let (kind, detail) = if !params.is_empty() {
640− (
641− CompletionItemKind::FUNCTION,
642− format!("@let {} {} (from {})", name, params.join(" "), filename),
643− )
644− } else if rest[name.len()..].trim_start().starts_with('[') {
645− (
646− CompletionItemKind::VARIABLE,
647− format!("@let {} [...] (from {})", name, filename),
648− )
649− } else {
650− (
651− CompletionItemKind::VARIABLE,
652− format!("@let {} (from {})", name, filename),
653− )
654− };
655− items.push(CompletionItem {
656− label: name.to_string(),
657− kind: Some(kind),
658− detail: Some(detail),
659− text_edit: Some(CompletionTextEdit::Edit(TextEdit {
660− range: edit_range,
661− new_text: name.to_string(),
662− })),
663− ..Default::default()
664− });
665− }
666− } else if let Some(rest) = trimmed.strip_prefix("@component ")
667− && let Some(name) = rest.split_whitespace().next()
668− {
669− items.push(CompletionItem {
670− label: name.to_string(),
671− kind: Some(CompletionItemKind::CLASS),
672− detail: Some(format!("@component {} (from {})", name, filename)),
673− text_edit: Some(CompletionTextEdit::Edit(TextEdit {
674− range: edit_range,
675− new_text: name.to_string(),
676− })),
677− ..Default::default()
678− });
679− }
680− }
681−
682− items
683−}
684
560
685/// Walk back from `position` to find the element directive that opened the
561/// Walk back from `position` to find the element directive that opened the
686/// nearest unmatched `[`. Returns the bare name without the leading `@`
562/// nearest unmatched `[`. Returns the bare name without the leading `@`
@@ -1581,12 +1457,6 @@ fn attr_completions(range: Range, element: Option<&str>) -> Vec<CompletionItem>
1581 "Assign a view transition name",
1457 "Assign a view transition name",
1582 true,
1458 true,
1583 ),
1459 ),
1584− // Animate shorthand
1585− (
1586− "animate",
1587− "Animation shorthand (name duration [timing])",
1588− true,
1589− ),
1590 // Has pseudo-selector
1460 // Has pseudo-selector
1591 (
1461 (
1592 "has(",
1462 "has(",
src/bin/htmlang_lsp/hover.rs+7−17
@@ -224,7 +224,7 @@ fn hover_builtin(word: &str) -> Option<String> {
224 "@row" => {
224 "@row" => {
225 "**@row** \u{2014} Horizontal layout\n\nRenders as `<div>` with `display: flex; flex-direction: row`.\n\nChildren are laid out left-to-right."
225 "**@row** \u{2014} Horizontal layout\n\nRenders as `<div>` with `display: flex; flex-direction: row`.\n\nChildren are laid out left-to-right."
226 }
226 }
227− "@column" | "@col" => {
227+ "@column" => {
228 "**@column** \u{2014} Vertical layout\n\nRenders as `<div>` with `display: flex; flex-direction: column`.\n\nChildren are laid out top-to-bottom."
228 "**@column** \u{2014} Vertical layout\n\nRenders as `<div>` with `display: flex; flex-direction: column`.\n\nChildren are laid out top-to-bottom."
229 }
229 }
230 "@el" => {
230 "@el" => {
@@ -233,7 +233,7 @@ fn hover_builtin(word: &str) -> Option<String> {
233 "@text" => {
233 "@text" => {
234 "**@text** \u{2014} Inline text\n\nRenders as `<span>`.\n\nUsage: `@text [bold, size 24] Hello world`"
234 "**@text** \u{2014} Inline text\n\nRenders as `<span>`.\n\nUsage: `@text [bold, size 24] Hello world`"
235 }
235 }
236− "@paragraph" | "@p" => {
236+ "@paragraph" => {
237 "**@paragraph** \u{2014} Text block\n\nRenders as `<p>`.\n\nSupports inline elements: `{@text [bold] word}`"
237 "**@paragraph** \u{2014} Text block\n\nRenders as `<p>`.\n\nSupports inline elements: `{@text [bold] word}`"
238 }
238 }
239 "@h1" => {
239 "@h1" => {
@@ -248,7 +248,7 @@ fn hover_builtin(word: &str) -> Option<String> {
248 "@h4" => "**@h4** \u{2014} Heading level 4\n\nRenders as `<h4>`.",
248 "@h4" => "**@h4** \u{2014} Heading level 4\n\nRenders as `<h4>`.",
249 "@h5" => "**@h5** \u{2014} Heading level 5\n\nRenders as `<h5>`.",
249 "@h5" => "**@h5** \u{2014} Heading level 5\n\nRenders as `<h5>`.",
250 "@h6" => "**@h6** \u{2014} Heading level 6\n\nRenders as `<h6>`.",
250 "@h6" => "**@h6** \u{2014} Heading level 6\n\nRenders as `<h6>`.",
251− "@image" | "@img" => {
251+ "@image" => {
252 "**@image** \u{2014} Image\n\nRenders as `<img>`.\n\nUsage: `@image [width 200] https://example.com/photo.jpg`"
252 "**@image** \u{2014} Image\n\nRenders as `<img>`.\n\nUsage: `@image [width 200] https://example.com/photo.jpg`"
253 }
253 }
254 "@link" => {
254 "@link" => {
@@ -272,7 +272,7 @@ fn hover_builtin(word: &str) -> Option<String> {
272 "@input" => {
272 "@input" => {
273 "**@input** \u{2014} Form input\n\nRenders as self-closing `<input>`.\n\nUsage: `@input [type text, placeholder Name, name user]`"
273 "**@input** \u{2014} Form input\n\nRenders as self-closing `<input>`.\n\nUsage: `@input [type text, placeholder Name, name user]`"
274 }
274 }
275− "@button" | "@btn" => {
275+ "@button" => {
276 "**@button** \u{2014} Button\n\nRenders as `<button>`.\n\nUsage: `@button [type submit] Click me`"
276 "**@button** \u{2014} Button\n\nRenders as `<button>`.\n\nUsage: `@button [type submit] Click me`"
277 }
277 }
278 "@select" => {
278 "@select" => {
@@ -281,7 +281,7 @@ fn hover_builtin(word: &str) -> Option<String> {
281 "@textarea" => {
281 "@textarea" => {
282 "**@textarea** \u{2014} Multi-line text input\n\nRenders as `<textarea>`.\n\nUsage: `@textarea [name bio, rows 4] Default text`"
282 "**@textarea** \u{2014} Multi-line text input\n\nRenders as `<textarea>`.\n\nUsage: `@textarea [name bio, rows 4] Default text`"
283 }
283 }
284− "@option" | "@opt" => {
284+ "@option" => {
285 "**@option** \u{2014} Select option\n\nRenders as `<option>`.\n\nUsage: `@option [value red] Red`"
285 "**@option** \u{2014} Select option\n\nRenders as `<option>`.\n\nUsage: `@option [value red] Red`"
286 }
286 }
287 "@label" => {
287 "@label" => {
@@ -337,7 +337,7 @@ fn hover_builtin(word: &str) -> Option<String> {
337 "@list" => {
337 "@list" => {
338 "**@list** \u{2014} List\n\nRenders as `<ul>` (or `<ol>` with `[ordered]`).\n\nUsage:\n```\n@list [ordered]\n @item First\n @item Second\n```"
338 "**@list** \u{2014} List\n\nRenders as `<ul>` (or `<ol>` with `[ordered]`).\n\nUsage:\n```\n@list [ordered]\n @item First\n @item Second\n```"
339 }
339 }
340− "@item" | "@li" => "**@item** \u{2014} List item\n\nRenders as `<li>`. Use inside `@list`.",
340+ "@item" => "**@item** \u{2014} List item\n\nRenders as `<li>`. Use inside `@list`.",
341 // Table elements
341 // Table elements
342 "@table" => {
342 "@table" => {
343 "**@table** \u{2014} Table\n\nRenders as `<table>`.\n\n```\n@table\n @thead\n @tr\n @th Name\n @th Age\n @tbody\n @tr\n @td Alice\n @td 30\n```"
343 "**@table** \u{2014} Table\n\nRenders as `<table>`.\n\n```\n@table\n @thead\n @tr\n @th Name\n @th Age\n @tbody\n @tr\n @td Alice\n @td 30\n```"
@@ -378,7 +378,7 @@ fn hover_builtin(word: &str) -> Option<String> {
378 "@pre" => {
378 "@pre" => {
379 "**@pre** \u{2014} Preformatted\n\nRenders as `<pre>` with preserved whitespace and monospace font."
379 "**@pre** \u{2014} Preformatted\n\nRenders as `<pre>` with preserved whitespace and monospace font."
380 }
380 }
381− "@hr" | "@divider" => {
381+ "@hr" => {
382 "**@hr** \u{2014} Horizontal Rule\n\nRenders as self-closing `<hr>`. Visual divider.\n\nUsage: `@hr [border-top 1 #ccc]`"
382 "**@hr** \u{2014} Horizontal Rule\n\nRenders as self-closing `<hr>`. Visual divider.\n\nUsage: `@hr [border-top 1 #ccc]`"
383 }
383 }
384 "@figure" => {
384 "@figure" => {
@@ -456,9 +456,6 @@ fn hover_builtin(word: &str) -> Option<String> {
456 "@favicon" => {
456 "@favicon" => {
457 "**@favicon** \u{2014} Favicon\n\nInlines a favicon as a base64 data URI in the `<head>`.\n\nUsage: `@favicon favicon.png`"
457 "**@favicon** \u{2014} Favicon\n\nInlines a favicon as a base64 data URI in the `<head>`.\n\nUsage: `@favicon favicon.png`"
458 }
458 }
459− "@unless" => {
460− "**@unless** \u{2014} Inverse conditional\n\nRenders children when the condition is false (opposite of `@if`).\n\nUsage: `@unless $debug`"
461− }
462 "@og" => {
459 "@og" => {
463 "**@og** \u{2014} Open Graph meta tag\n\nAdds an Open Graph `<meta>` tag to `<head>`.\n\nUsage: `@og title My Page Title`"
460 "**@og** \u{2014} Open Graph meta tag\n\nAdds an Open Graph `<meta>` tag to `<head>`.\n\nUsage: `@og title My Page Title`"
464 }
461 }
@@ -474,9 +471,6 @@ fn hover_builtin(word: &str) -> Option<String> {
474 "@extends" => {
471 "@extends" => {
475 "**@extends** `<file.hl>`\n\nInherit a layout template. Fill named `@slot` blocks.\n\n```\n@extends layout.hl\n@slot content\n My page content\n@slot sidebar\n Sidebar content\n```"
472 "**@extends** `<file.hl>`\n\nInherit a layout template. Fill named `@slot` blocks.\n\n```\n@extends layout.hl\n@slot content\n My page content\n@slot sidebar\n Sidebar content\n```"
476 }
473 }
477− "@use" => {
478− "**@use** `<file.hl> name1, name2`\n\nSelective import: only imports named `@let` definitions.\n\n```\n@use components.hl card, button\n```"
479− }
480 "@canonical" => {
474 "@canonical" => {
481 "**@canonical** `<url>`\n\nSets the canonical URL for the page. Adds `<link rel=\"canonical\">` to `<head>`.\n\nUsage: `@canonical https://example.com/page`"
475 "**@canonical** `<url>`\n\nSets the canonical URL for the page. Adds `<link rel=\"canonical\">` to `<head>`.\n\nUsage: `@canonical https://example.com/page`"
482 }
476 }
@@ -980,10 +974,6 @@ fn hover_builtin(word: &str) -> Option<String> {
980 "view-transition-name" => {
974 "view-transition-name" => {
981 "**view-transition-name** `<name>` \u{2014} Assign a View Transition API name.\n\nEnables smooth transitions between pages.\n\nUsage: `@el [view-transition-name hero]`"
975 "**view-transition-name** `<name>` \u{2014} Assign a View Transition API name.\n\nEnables smooth transitions between pages.\n\nUsage: `@el [view-transition-name hero]`"
982 }
976 }
983− // Animate shorthand
984− "animate" => {
985− "**animate** `<name> <duration> [timing]` \u{2014} Animation shorthand.\n\nAlias for the CSS `animation` property.\n\nUsage: `@el [animate fade-in 0.3s ease]`\n\nRequires a matching `@keyframes fade-in` definition."
986− }
987 // Has pseudo-selector
977 // Has pseudo-selector
988 "has(" => {
978 "has(" => {
989 "**has(selector):** \u{2014} Parent selector pseudo-class.\n\nStyle an element based on its children.\n\nUsage: `@el [has(.active):background blue, has(img):padding 0]`\n\nGenerates CSS `:has()` selector."
979 "**has(selector):** \u{2014} Parent selector pseudo-class.\n\nStyle an element based on its children.\n\nUsage: `@el [has(.active):background blue, has(img):padding 0]`\n\nGenerates CSS `:has()` selector."
src/bin/htmlang_lsp/main.rs+1−17
@@ -19,7 +19,7 @@ mod state;
19use analysis::{
19use analysis::{
20 code_actions, find_colors, folding_ranges, get_signature_help, inlay_hints, semantic_tokens,
20 code_actions, find_colors, folding_ranges, get_signature_help, inlay_hints, semantic_tokens,
21};
21};
22−use completion::{completions, path_completions, use_symbol_completions};
22+use completion::{completions, path_completions};
23use hover::hover_at;
23use hover::hover_at;
24use navigation::{
24use navigation::{
25 definition_at, find_references, linked_editing_ranges, prepare_rename_at, rename_at,
25 definition_at, find_references, linked_editing_ranges, prepare_rename_at, rename_at,
@@ -384,20 +384,6 @@ impl LanguageServer for Backend {
384 return Ok(Some(CompletionResponse::Array(items)));
384 return Ok(Some(CompletionResponse::Array(items)));
385 }
385 }
386 }
386 }
387− if let Some(after_use) = trimmed.strip_prefix("@use ") {
388− let has_file = after_use.contains(".hl");
389− if has_file {
390− let items = use_symbol_completions(uri, trimmed, pos);
391− if !items.is_empty() {
392− return Ok(Some(CompletionResponse::Array(items)));
393− }
394− } else {
395− let items = path_completions(uri, pos);
396− if !items.is_empty() {
397− return Ok(Some(CompletionResponse::Array(items)));
398− }
399− }
400− }
401 }
387 }
402
388
403 let items = completions(text, pos);
389 let items = completions(text, pos);
@@ -719,8 +705,6 @@ impl LanguageServer for Backend {
719 ("@include ", rest)
705 ("@include ", rest)
720 } else if let Some(rest) = trimmed.strip_prefix("@import ") {
706 } else if let Some(rest) = trimmed.strip_prefix("@import ") {
721 ("@import ", rest)
707 ("@import ", rest)
722− } else if let Some(rest) = trimmed.strip_prefix("@use ") {
723− ("@use ", rest)
724 } else if let Some(rest) = trimmed.strip_prefix("@extends ") {
708 } else if let Some(rest) = trimmed.strip_prefix("@extends ") {
725 ("@extends ", rest)
709 ("@extends ", rest)
726 } else {
710 } else {
src/cli.rs+1−4
@@ -38,7 +38,6 @@ Commands:
38 create-component <name> [params...] Scaffold a new component file
38 create-component <name> [params...] Scaffold a new component file
39 outline <file.hl> Show document structure tree
39 outline <file.hl> Show document structure tree
40 doctor Check toolchain health
40 doctor Check toolchain health
41− migrate [dir|file] Auto-upgrade deprecated syntax
42 bundle <file|dir> [-o <out>] Compile and inline all assets as data URIs
41 bundle <file|dir> [-o <out>] Compile and inline all assets as data URIs
43 size <file|dir> Report output sizes (raw, minified, ~gzip)
42 size <file|dir> Report output sizes (raw, minified, ~gzip)
44 benchmark <file|dir> Measure compile time and output size
43 benchmark <file|dir> Measure compile time and output size
@@ -143,7 +142,7 @@ fn print_bash_completions() {
143 local cur prev commands
142 local cur prev commands
144 cur="${{COMP_WORDS[COMP_CWORD]}}"
143 cur="${{COMP_WORDS[COMP_CWORD]}}"
145 prev="${{COMP_WORDS[COMP_CWORD-1]}}"
144 prev="${{COMP_WORDS[COMP_CWORD-1]}}"
146− commands="init new build serve watch check convert fmt sitemap lint stats preview diff export repl feed components deps dead-code deploy playground clean upgrade create-component outline doctor migrate bundle size benchmark explain test lsp completions"
145+ commands="init new build serve watch check convert fmt sitemap lint stats preview diff export repl feed components deps dead-code deploy playground clean upgrade create-component outline doctor bundle size benchmark explain test lsp completions"
147
146
148 if [ "$COMP_CWORD" -eq 1 ]; then
147 if [ "$COMP_CWORD" -eq 1 ]; then
149 COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
148 COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
@@ -202,7 +201,6 @@ _htmlang() {{
202 'create-component:Scaffold component'
201 'create-component:Scaffold component'
203 'outline:Show document structure'
202 'outline:Show document structure'
204 'doctor:Check toolchain health'
203 'doctor:Check toolchain health'
205− 'migrate:Upgrade deprecated syntax'
206 'bundle:Inline all assets'
204 'bundle:Inline all assets'
207 'size:Report output sizes'
205 'size:Report output sizes'
208 'benchmark:Measure compile time'
206 'benchmark:Measure compile time'
@@ -276,7 +274,6 @@ fn print_fish_completions() {
276 ("create-component", "Scaffold component"),
274 ("create-component", "Scaffold component"),
277 ("outline", "Show document structure"),
275 ("outline", "Show document structure"),
278 ("doctor", "Check toolchain health"),
276 ("doctor", "Check toolchain health"),
279− ("migrate", "Upgrade deprecated syntax"),
280 ("bundle", "Inline all assets as data URIs"),
277 ("bundle", "Inline all assets as data URIs"),
281 ("size", "Report output sizes"),
278 ("size", "Report output sizes"),
282 ("benchmark", "Measure compile time"),
279 ("benchmark", "Measure compile time"),
src/fmt.rs−2
@@ -93,8 +93,6 @@ fn is_raw_body_header(code: &str) -> bool {
93 "@markdown",
93 "@markdown",
94 "@json-ld",
94 "@json-ld",
95 "@head",
95 "@head",
96− "@scope",
97− "@starting-style",
98 ]
96 ]
99 .iter()
97 .iter()
100 .any(|d| code == *d || code.strip_prefix(d).is_some_and(|r| r.starts_with(' ')))
98 .any(|d| code == *d || code.strip_prefix(d).is_some_and(|r| r.starts_with(' ')))
src/lib.rs+1
@@ -4,3 +4,4 @@ pub use htmlang_core::parser;
4pub mod convert;
4pub mod convert;
5pub mod fmt;
5pub mod fmt;
6pub mod serve;
6pub mod serve;
7+pub mod upgrade;
src/main.rs+20−173
@@ -158,25 +158,6 @@ fn json_escape_string(s: &str) -> String {
158 out
158 out
159}
159}
160
160
161−/// Rename attribute key `from` to `to` wherever it appears as a whole key:
162−/// preceded by `[`, `,`, whitespace or a `prefix:` and followed by a space.
163−fn rename_attr_key(line: &str, from: &str, to: &str) -> String {
164− let needle = format!("{from} ");
165− let mut out = String::with_capacity(line.len());
166− let mut rest = line;
167− while let Some(pos) = rest.find(&needle) {
168− let whole_key = rest[..pos]
169− .chars()
170− .next_back()
171− .is_none_or(|c| matches!(c, '[' | ',' | ':') || c.is_whitespace());
172− out.push_str(&rest[..pos]);
173− out.push_str(if whole_key { to } else { from });
174− out.push(' ');
175− rest = &rest[pos + needle.len()..];
176− }
177− out.push_str(rest);
178− out
179−}
180
161
181fn xml_escape(s: &str) -> String {
162fn xml_escape(s: &str) -> String {
182 s.replace('&', "&amp;")
163 s.replace('&', "&amp;")
@@ -2412,7 +2393,7 @@ fn main() {
2412 let mut deps = Vec::new();
2393 let mut deps = Vec::new();
2413 for line in input.lines() {
2394 for line in input.lines() {
2414 let trimmed = line.trim();
2395 let trimmed = line.trim();
2415− for directive in &["@include ", "@import ", "@extends ", "@use "] {
2396+ for directive in &["@include ", "@import ", "@extends "] {
2416 if let Some(rest) = trimmed.strip_prefix(directive) {
2397 if let Some(rest) = trimmed.strip_prefix(directive) {
2417 let dep = rest.split_whitespace().next().unwrap_or("").to_string();
2398 let dep = rest.split_whitespace().next().unwrap_or("").to_string();
2418 if !dep.is_empty() {
2399 if !dep.is_empty() {
@@ -2506,7 +2487,6 @@ fn main() {
2506 && !trimmed.starts_with("@theme")
2487 && !trimmed.starts_with("@theme")
2507 && !trimmed.starts_with("@deprecated ")
2488 && !trimmed.starts_with("@deprecated ")
2508 && !trimmed.starts_with("@breakpoint ")
2489 && !trimmed.starts_with("@breakpoint ")
2509− && !trimmed.starts_with("@use ")
2510 && !trimmed.starts_with("@slot ")
2490 && !trimmed.starts_with("@slot ")
2511 && !trimmed.starts_with("@lang ")
2491 && !trimmed.starts_with("@lang ")
2512 && !trimmed.starts_with("@favicon ")
2492 && !trimmed.starts_with("@favicon ")
@@ -3085,78 +3065,6 @@ compile();
3085 return;
3065 return;
3086 }
3066 }
3087
3067
3088− // Handle "migrate" subcommand — auto-upgrade deprecated syntax
3089− if args.len() >= 2 && args[1] == "migrate" {
3090− let target = if args.len() >= 3 { &args[2] } else { "." };
3091− let path = Path::new(target);
3092− let hl_files = if path.is_dir() {
3093− collect_hl_files_recursive(path)
3094− } else {
3095− vec![PathBuf::from(target)]
3096− };
3097− if hl_files.is_empty() {
3098− eprintln!("no .hl files found in {}", target);
3099− process::exit(1);
3100− }
3101− let mut total_changes = 0usize;
3102− for file in &hl_files {
3103− let input = match fs::read_to_string(file) {
3104− Ok(s) => s,
3105− Err(_) => continue,
3106− };
3107− let mut output = String::new();
3108− let mut changes = 0usize;
3109− for line in input.lines() {
3110− let mut migrated = line.to_string();
3111− // Migrate @divider -> @hr
3112− if migrated.trim().starts_with("@divider") {
3113− migrated = migrated.replace("@divider", "@hr");
3114− changes += 1;
3115− }
3116− // Migrate @ul -> @list
3117− if migrated.trim().starts_with("@ul") && !migrated.trim().starts_with("@unless") {
3118− migrated = migrated.replacen("@ul", "@list", 1);
3119− changes += 1;
3120− }
3121− // Migrate align-center -> center-x (common mistake)
3122− if migrated.contains("align-center") {
3123− migrated = migrated.replace("align-center", "center-x");
3124− changes += 1;
3125− }
3126− output.push_str(&migrated);
3127− output.push('\n');
3128− }
3129− // Remove trailing extra newline if original didn't end with one
3130− if !input.ends_with('\n') && output.ends_with('\n') {
3131− output.pop();
3132− }
3133− if changes > 0 {
3134− match fs::write(file, &output) {
3135− Ok(()) => {
3136− eprintln!(
3137− "migrated {} ({} change{})",
3138− file.display(),
3139− changes,
3140− if changes == 1 { "" } else { "s" }
3141− );
3142− total_changes += changes;
3143− }
3144− Err(e) => eprintln!("error: {}: {}", file.display(), e),
3145− }
3146− }
3147− }
3148− if total_changes == 0 {
3149− eprintln!("no migrations needed");
3150− } else {
3151− eprintln!(
3152− "\n{} total change(s) across {} file(s)",
3153− total_changes,
3154− hl_files.len()
3155− );
3156− }
3157− return;
3158− }
3159−
3160 // Handle "bundle" subcommand — inline images/fonts as data URIs
3068 // Handle "bundle" subcommand — inline images/fonts as data URIs
3161 if args.len() >= 2 && args[1] == "bundle" {
3069 if args.len() >= 2 && args[1] == "bundle" {
3162 if args.len() < 3 {
3070 if args.len() < 3 {
@@ -3528,8 +3436,9 @@ compile();
3528 return;
3436 return;
3529 }
3437 }
3530
3438
3531− // Handle "upgrade" subcommand — comprehensive version-to-version migration
3439+ // Handle "upgrade" subcommand (and its old name "migrate"): rewrite
3440+ // removed or renamed syntax to its current form.
3532− if args.len() >= 2 && args[1] == "upgrade" {
3441+ if args.len() >= 2 && (args[1] == "upgrade" || args[1] == "migrate") {
3533 let target = if args.len() >= 3 { &args[2] } else { "." };
3442 let target = if args.len() >= 3 { &args[2] } else { "." };
3534 let path = Path::new(target);
3443 let path = Path::new(target);
3535 let hl_files = if path.is_dir() {
3444 let hl_files = if path.is_dir() {
@@ -3542,90 +3451,40 @@ compile();
3542 process::exit(1);
3451 process::exit(1);
3543 }
3452 }
3544 let mut total_changes = 0usize;
3453 let mut total_changes = 0usize;
3454+ let mut needs_manual = false;
3545 for file in &hl_files {
3455 for file in &hl_files {
3546 let input = match fs::read_to_string(file) {
3456 let input = match fs::read_to_string(file) {
3547 Ok(s) => s,
3457 Ok(s) => s,
3548 Err(_) => continue,
3458 Err(_) => continue,
3549 };
3459 };
3550− let mut output = String::new();
3460+ let result = htmlang::upgrade::upgrade(&input);
3551− let mut changes = 0usize;
3461+ for (line, message) in &result.manual {
3552− for line in input.lines() {
3462+ eprintln!("{}:{}: manual change needed: {}", file.display(), line, message);
3553− let mut migrated = line.to_string();
3463+ needs_manual = true;
3554− // Migrate @divider -> @hr
3555− if migrated.trim().starts_with("@divider") {
3556− migrated = migrated.replace("@divider", "@hr");
3557− changes += 1;
3558− }
3559− // Migrate @ul -> @list
3560− if migrated.trim().starts_with("@ul") && !migrated.trim().starts_with("@unless") {
3561− migrated = migrated.replacen("@ul", "@list", 1);
3562− changes += 1;
3563− }
3564− // Migrate @col -> @column (full form)
3565− if migrated.trim().starts_with("@col ") || migrated.trim() == "@col" {
3566− migrated = migrated.replacen("@col", "@column", 1);
3567− changes += 1;
3568− }
3569− // Migrate @img -> @image (full form)
3570− if migrated.trim().starts_with("@img ") || migrated.trim() == "@img" {
3571− migrated = migrated.replacen("@img", "@image", 1);
3572− changes += 1;
3573− }
3574− // Migrate @p -> @paragraph (full form)
3575− if migrated.trim().starts_with("@p ") || migrated.trim() == "@p" {
3576− migrated = migrated.replacen("@p ", "@paragraph ", 1);
3577− changes += 1;
3578− }
3579− // Migrate @btn -> @button (full form)
3580− if migrated.trim().starts_with("@btn ") || migrated.trim() == "@btn" {
3581− migrated = migrated.replacen("@btn", "@button", 1);
3582− changes += 1;
3583− }
3584− // Migrate @li -> @item (full form)
3585− if migrated.trim().starts_with("@li ") || migrated.trim() == "@li" {
3586− migrated = migrated.replacen("@li", "@item", 1);
3587− changes += 1;
3588− }
3589− // Migrate align-center -> center-x
3590− if migrated.contains("align-center") {
3591− migrated = migrated.replace("align-center", "center-x");
3592− changes += 1;
3593− }
3594− // Migrate spacing -> gap (modern naming). Only the whole
3595− // attribute key: `letter-spacing` must stay as it is.
3596− if migrated.contains('[') {
3597− let renamed = rename_attr_key(&migrated, "spacing", "gap");
3598− if renamed != migrated {
3599− migrated = renamed;
3600− changes += 1;
3601− }
3602− }
3603− output.push_str(&migrated);
3604− output.push('\n');
3605 }
3464 }
3606− if !input.ends_with('\n') && output.ends_with('\n') {
3607− output.pop();
3608− }
3609− if changes > 0 {
3465+ if result.changes > 0 {
3610− match fs::write(file, &output) {
3466+ match fs::write(file, &result.output) {
3611 Ok(()) => {
3467 Ok(()) => {
3612 eprintln!(
3468 eprintln!(
3613 "upgraded {} ({} change{})",
3469 "upgraded {} ({} change{})",
3614 file.display(),
3470 file.display(),
3615− changes,
3471+ result.changes,
3616− if changes == 1 { "" } else { "s" }
3472+ if result.changes == 1 { "" } else { "s" }
3617 );
3473 );
3618− total_changes += changes;
3474+ total_changes += result.changes;
3619 }
3475 }
3620 Err(e) => eprintln!("error: {}: {}", file.display(), e),
3476 Err(e) => eprintln!("error: {}: {}", file.display(), e),
3621 }
3477 }
3622 }
3478 }
3623 }
3479 }
3624− if total_changes == 0 {
3480+ if total_changes == 0 && !needs_manual {
3625 eprintln!("no upgrades needed — all files are up to date");
3481 eprintln!("no upgrades needed — all files are up to date");
3626− } else {
3482+ } else if total_changes > 0 {
3627 eprintln!("\n{} total change(s) applied", total_changes);
3483 eprintln!("\n{} total change(s) applied", total_changes);
3628 }
3484 }
3485+ if needs_manual {
3486+ process::exit(1);
3487+ }
3629 return;
3488 return;
3630 }
3489 }
3631
3490
@@ -3682,7 +3541,7 @@ compile();
3682 match fs::write(file_path, &template) {
3541 match fs::write(file_path, &template) {
3683 Ok(()) => {
3542 Ok(()) => {
3684 eprintln!("created {}", file_name);
3543 eprintln!("created {}", file_name);
3685− eprintln!("import with: @use \"{}\" {}", file_name, comp_name);
3544+ eprintln!("import with: @import {} (then call @{})", file_name, comp_name);
3686 }
3545 }
3687 Err(e) => {
3546 Err(e) => {
3688 eprintln!("error: {}: {}", file_name, e);
3547 eprintln!("error: {}: {}", file_name, e);
@@ -4272,18 +4131,6 @@ fn watch_loop(
4272mod tests {
4131mod tests {
4273 use super::*;
4132 use super::*;
4274
4133
4275− #[test]
4276− fn rename_attr_key_only_touches_whole_keys() {
4277− assert_eq!(
4278− rename_attr_key(
4279− "@row [spacing 10, letter-spacing 2, hover:spacing 4]",
4280− "spacing",
4281− "gap"
4282− ),
4283− "@row [gap 10, letter-spacing 2, hover:gap 4]"
4284− );
4285− }
4286−
4287 #[test]
4134 #[test]
4288 fn json_escape_handles_control_characters() {
4135 fn json_escape_handles_control_characters() {
4289 assert_eq!(
4136 assert_eq!(
src/playground_default.hl+6−6
@@ -45,7 +45,7 @@
45
45
46-- Component with default parameter + @children slot
46-- Component with default parameter + @children slot
47@let card $title $icon=◆
47@let card $title $icon=◆
48− @article [$card, ...$interactive, animate fade-up 0.3s ease]
48+ @article [$card, $interactive, animation fade-up 0.3s ease]
49 @row [spacing 10, align-items center]
49 @row [spacing 10, align-items center]
50 @text [size 20, color $primary] $icon
50 @text [size 20, color $primary] $icon
51 @text [bold, size 16, color $ink] $title
51 @text [bold, size 16, color $ink] $title
@@ -129,7 +129,7 @@
129 @text $label
129 @text $label
130"""
130"""
131
131
132− -- Control flow: @each, @else, @repeat, @for, @match, if()
132+ -- Control flow: @each, @else, @match, if()
133 @titled [title Control flow]
133 @titled [title Control flow]
134 @column [spacing 6]
134 @column [spacing 6]
135 @each $name, $role in Alice Engineer, Bob Designer, Carol PM
135 @each $name, $role in Alice Engineer, Bob Designer, Carol PM
@@ -144,14 +144,14 @@
144 @else
144 @else
145 @text [color $muted, italic, data-state $nothing] (Nothing — @each ... @else fallback)
145 @text [color $muted, italic, data-state $nothing] (Nothing — @each ... @else fallback)
146
146
147− -- @repeat
147+ -- repeat N times
148 @row [spacing 6]
148 @row [spacing 6]
149− @repeat 6
149+ @each $_ in 1..6
150 @el [width 18, height 18, rounded 9, background $primary, opacity 0.4]
150 @el [width 18, height 18, rounded 9, background $primary, opacity 0.4]
151
151
152− -- @for range
152+ -- numeric range
153 @row [spacing 4]
153 @row [spacing 4]
154− @for $i in 1..6
154+ @each $i in 1..6
155 @el [
155 @el [
156 width 24, height 24, rounded 4,
156 width 24, height 24, rounded 4,
157 background $primary, color white,
157 background $primary, color white,
src/upgrade.rsnew file+626
@@ -0,0 +1,626 @@
1+//! `htmlang upgrade`: rewrite removed or renamed syntax to its current form.
2+//!
3+//! Every rewrite here is meant to be behavior-preserving: the upgraded file
4+//! compiles to the same HTML as the original did before the syntax was
5+//! removed. Constructs that can't be rewritten mechanically are reported in
6+//! [`Upgrade::manual`] and left untouched.
7+
8+/// Result of upgrading one file.
9+pub struct Upgrade {
10+ pub output: String,
11+ pub changes: usize,
12+ /// Constructs that need a manual rewrite: (1-based line, message).
13+ pub manual: Vec<(usize, String)>,
14+}
15+
16+/// Element aliases and their canonical names.
17+const ELEMENT_ALIASES: &[(&str, &str)] = &[
18+ ("col", "column"),
19+ ("p", "paragraph"),
20+ ("img", "image"),
21+ ("li", "item"),
22+ ("btn", "button"),
23+ ("ul", "list"),
24+ ("divider", "hr"),
25+ ("opt", "option"),
26+];
27+
28+/// Attribute keys that were renamed.
29+const ATTR_RENAMES: &[(&str, &str)] = &[
30+ ("animate", "animation"),
31+ ("inset-area", "position-area"),
32+ ("align-center", "center-x"),
33+];
34+
35+/// Variable filter aliases (`$x|upper`) and their canonical names.
36+const FILTER_ALIASES: &[(&str, &str)] = &[
37+ ("upper", "uppercase"),
38+ ("lower", "lowercase"),
39+ ("cap", "capitalize"),
40+ ("len", "length"),
41+];
42+
43+/// Directives whose indented bodies are foreign text (CSS, JS, Markdown,
44+/// HTML, JSON) and must not be rewritten.
45+const VERBATIM_BODIES: &[&str] = &["@style", "@script", "@markdown", "@head", "@json-ld"];
46+
47+pub fn upgrade(input: &str) -> Upgrade {
48+ let lines: Vec<&str> = input.lines().collect();
49+ let mut out: Vec<String> = Vec::with_capacity(lines.len());
50+ let mut changes = 0;
51+ let mut manual = Vec::new();
52+ let mut attr_depth = 0i32;
53+ let mut i = 0;
54+ // A file may define its own function named like an old alias (e.g.
55+ // `@let divider`); calls to it must not be renamed.
56+ let user_defined: Vec<&str> = lines
57+ .iter()
58+ .filter_map(|l| l.trim().strip_prefix("@let "))
59+ .filter_map(|rest| rest.split_whitespace().next())
60+ .collect();
61+
62+ while i < lines.len() {
63+ let line = lines[i];
64+ let trimmed = line.trim();
65+ let indent = indent_of(line);
66+
67+ // Copy verbatim regions unchanged.
68+ if let Some(end) = triple_quote_end(&lines, i) {
69+ out.extend(lines[i..end].iter().map(|l| l.to_string()));
70+ i = end;
71+ continue;
72+ }
73+ if attr_depth == 0 && VERBATIM_BODIES.iter().any(|d| starts_directive(trimmed, d)) {
74+ let end = block_end(&lines, i);
75+ out.extend(lines[i..end].iter().map(|l| l.to_string()));
76+ i = end;
77+ continue;
78+ }
79+
80+ // --- Block rewrites ---
81+ if attr_depth == 0
82+ && let Some(block) = rewrite_block(&lines, i, &mut manual)
83+ {
84+ out.extend(block.lines);
85+ changes += 1;
86+ i = block.end;
87+ continue;
88+ }
89+
90+ // --- Line rewrites ---
91+ let mut new_line = line.to_string();
92+ if attr_depth == 0 {
93+ new_line = rewrite_directive_line(&new_line, indent, i, &mut manual);
94+ new_line = rename_elements(&new_line, &user_defined);
95+ }
96+ new_line = rewrite_attr_regions(&new_line, &mut attr_depth);
97+ new_line = rename_filters(&new_line);
98+ if new_line != line {
99+ changes += 1;
100+ }
101+ out.push(new_line);
102+ i += 1;
103+ }
104+
105+ let mut output = out.join("\n");
106+ if input.ends_with('\n') {
107+ output.push('\n');
108+ }
109+ Upgrade {
110+ output,
111+ changes,
112+ manual,
113+ }
114+}
115+
116+struct Block {
117+ lines: Vec<String>,
118+ end: usize,
119+}
120+
121+/// Rewrites of directives that own an indented block.
122+fn rewrite_block(lines: &[&str], i: usize, manual: &mut Vec<(usize, String)>) -> Option<Block> {
123+ let line = lines[i];
124+ let trimmed = line.trim();
125+ let indent = indent_of(line);
126+ let pad = " ".repeat(indent);
127+ let end = block_end(lines, i);
128+ let body = &lines[i + 1..end];
129+
130+ // @switch $v / @case x [attrs] → @match $v / @case x / @let __switch [attrs]
131+ if let Some(rest) = trimmed.strip_prefix("@switch ") {
132+ let mut out = vec![format!("{pad}@match {}", rest.trim())];
133+ let case_indent = body.iter().find(|l| !l.trim().is_empty()).map(|l| indent_of(l));
134+ let mut j = 0;
135+ while j < body.len() {
136+ let l = body[j];
137+ let t = l.trim();
138+ let is_case_line = Some(indent_of(l)) == case_indent
139+ && (t.starts_with("@case ") || t == "@default" || t.starts_with("@default "));
140+ let bracket = t.find('[').filter(|_| is_case_line);
141+ if let Some(bracket) = bracket {
142+ let head = t[..bracket].trim_end();
143+ let attrs = &t[bracket..];
144+ out.push(format!("{}{}", " ".repeat(indent_of(l)), head));
145+ let body_indent = body[j + 1..]
146+ .iter()
147+ .find(|b| !b.trim().is_empty())
148+ .map(|b| indent_of(b))
149+ .filter(|&n| n > indent_of(l))
150+ .unwrap_or(indent_of(l) + 2);
151+ out.push(format!("{}@let __switch {}", " ".repeat(body_indent), attrs));
152+ } else {
153+ out.push(l.to_string());
154+ }
155+ j += 1;
156+ }
157+ return Some(Block { lines: out, end });
158+ }
159+
160+ // @with $x as y → @let y $x, body dedented
161+ if let Some(rest) = trimmed.strip_prefix("@with ") {
162+ let (source, alias) = rest.split_once(" as ")?;
163+ let mut out = vec![format!("{pad}@let {} {}", alias.trim(), source.trim())];
164+ out.extend(dedent_block(body, indent));
165+ return Some(Block { lines: out, end });
166+ }
167+
168+ // @layout file with a body running to the end of the file →
169+ // @extends file, body dedented (its content fills @children).
170+ if let Some(rest) = trimmed.strip_prefix("@layout ") {
171+ if end < lines.len() && lines[end..].iter().any(|l| !l.trim().is_empty()) {
172+ manual.push((
173+ i + 1,
174+ "@layout was removed: replace it with @extends (content after the \
175+ @layout block has to move into the layout)"
176+ .to_string(),
177+ ));
178+ return None;
179+ }
180+ let mut out = vec![format!("{pad}@extends {}", rest.trim())];
181+ out.extend(dedent_block(body, indent));
182+ return Some(Block { lines: out, end });
183+ }
184+
185+ // @scope sel / @starting-style → an @style block with the CSS at-rule
186+ if trimmed == "@scope" || trimmed.starts_with("@scope ") || trimmed == "@starting-style" {
187+ let prelude = if trimmed == "@starting-style" {
188+ "@starting-style {".to_string()
189+ } else {
190+ match trimmed["@scope".len()..].trim() {
191+ "" => "@scope {".to_string(),
192+ sel => format!("@scope ({}) {{", sel),
193+ }
194+ };
195+ let mut out = vec![format!("{pad}@style"), format!("{pad} {prelude}")];
196+ out.extend(
197+ body.iter()
198+ .filter(|l| !l.trim().is_empty())
199+ .map(|l| format!("{pad} {}", l.trim())),
200+ );
201+ out.push(format!("{pad} }}"));
202+ return Some(Block { lines: out, end });
203+ }
204+
205+ // @css-property --name / key value lines → @style with @property
206+ if let Some(name) = trimmed.strip_prefix("@css-property ") {
207+ let mut syntax = "\"*\"".to_string();
208+ let mut inherits = "false".to_string();
209+ let mut initial = None;
210+ for l in body {
211+ if let Some((key, value)) = l.trim().split_once(' ') {
212+ if value.contains('$') {
213+ manual.push((
214+ i + 1,
215+ "@css-property with variables must be rewritten as @style by hand"
216+ .to_string(),
217+ ));
218+ return None;
219+ }
220+ match key {
221+ "syntax" => syntax = value.trim().to_string(),
222+ "inherits" => inherits = value.trim().to_string(),
223+ "initial-value" | "initial_value" => initial = Some(value.trim().to_string()),
224+ _ => {}
225+ }
226+ }
227+ }
228+ let mut out = vec![
229+ format!("{pad}@style"),
230+ format!("{pad} @property {} {{", name.trim()),
231+ format!("{pad} syntax:{};", syntax),
232+ format!("{pad} inherits:{};", inherits),
233+ ];
234+ if let Some(initial) = initial {
235+ out.push(format!("{pad} initial-value:{};", initial));
236+ }
237+ out.push(format!("{pad} }}"));
238+ return Some(Block { lines: out, end });
239+ }
240+
241+ // @repeat N → @each $_ in 1..N (literal counts only)
242+ if let Some(rest) = trimmed.strip_prefix("@repeat ") {
243+ let Ok(count) = rest.trim().parse::<u64>() else {
244+ manual.push((
245+ i + 1,
246+ "@repeat with a variable count was removed: use @each $_ in 1..$n \
247+ (note: 1..0 counts down, so guard a zero count with @if)"
248+ .to_string(),
249+ ));
250+ return None;
251+ };
252+ if count == 0 {
253+ // Renders nothing: drop the block entirely.
254+ return Some(Block {
255+ lines: Vec::new(),
256+ end,
257+ });
258+ }
259+ let mut out = vec![format!("{pad}@each $_ in 1..{}", count)];
260+ // @repeat exposed $_count; @each doesn't, so inline it.
261+ out.extend(
262+ body.iter()
263+ .map(|l| replace_var(l, "_count", &count.to_string())),
264+ );
265+ return Some(Block { lines: out, end });
266+ }
267+
268+ None
269+}
270+
271+/// Single-line directive rewrites.
272+fn rewrite_directive_line(
273+ line: &str,
274+ indent: usize,
275+ idx: usize,
276+ manual: &mut Vec<(usize, String)>,
277+) -> String {
278+ let trimmed = line.trim_start();
279+ let pad = " ".repeat(indent);
280+ for old in ["@fn ", "@define ", "@mixin "] {
281+ if let Some(rest) = trimmed.strip_prefix(old) {
282+ return format!("{pad}@let {}", rest);
283+ }
284+ }
285+ if let Some(rest) = trimmed.strip_prefix("@unless ") {
286+ return format!("{pad}@if not {}", rest);
287+ }
288+ if let Some(rest) = trimmed.strip_prefix("@for ") {
289+ return format!("{pad}@each {}", rest);
290+ }
291+ if let Some(rest) = trimmed.strip_prefix("@use ") {
292+ let rest = rest.trim();
293+ let file = if let Some(q) = rest.strip_prefix('"') {
294+ q.split('"').next().unwrap_or("")
295+ } else {
296+ rest.split(|c: char| c.is_whitespace() || c == ',')
297+ .next()
298+ .unwrap_or("")
299+ };
300+ if file.is_empty() {
301+ manual.push((idx + 1, "could not read the file name in @use".to_string()));
302+ return line.to_string();
303+ }
304+ return format!("{pad}@import {}", file);
305+ }
306+ line.to_string()
307+}
308+
309+/// Rename element aliases wherever an element name can appear: at the start
310+/// of a line, after a `>` chain, or at the start of an inline `{@...}`.
311+fn rename_elements(line: &str, user_defined: &[&str]) -> String {
312+ let mut out = String::with_capacity(line.len());
313+ let bytes = line.as_bytes();
314+ let mut i = 0;
315+ while i < line.len() {
316+ if bytes[i] == b'@' {
317+ let before = line[..i].trim_end();
318+ let at_element_position =
319+ before.is_empty() || before.ends_with('>') || line[..i].ends_with('{');
320+ if at_element_position {
321+ let name_end = line[i + 1..]
322+ .find(|c: char| !(c.is_ascii_alphanumeric() || c == '-' || c == '_'))
323+ .map_or(line.len(), |p| i + 1 + p);
324+ let name = &line[i + 1..name_end];
325+ let alias = ELEMENT_ALIASES
326+ .iter()
327+ .find(|(a, _)| *a == name && !user_defined.contains(a));
328+ if let Some((_, canonical)) = alias {
329+ out.push('@');
330+ out.push_str(canonical);
331+ i = name_end;
332+ continue;
333+ }
334+ }
335+ }
336+ let ch = line[i..].chars().next().unwrap();
337+ out.push(ch);
338+ i += ch.len_utf8();
339+ }
340+ out
341+}
342+
343+/// Apply attribute-level rewrites inside `[...]` regions. `depth` carries an
344+/// unclosed bracket over to the following (continuation) lines.
345+fn rewrite_attr_regions(line: &str, depth: &mut i32) -> String {
346+ let trimmed = line.trim_start();
347+ if *depth == 0 && !trimmed.starts_with('@') && !trimmed.starts_with('[') {
348+ return line.to_string();
349+ }
350+ let mut out = String::with_capacity(line.len());
351+ let mut region = String::new();
352+ for ch in line.chars() {
353+ if *depth > 0 {
354+ match ch {
355+ '[' => *depth += 1,
356+ ']' => *depth -= 1,
357+ _ => {}
358+ }
359+ if *depth == 0 {
360+ out.push_str(&rewrite_attr_list(&region));
361+ region.clear();
362+ out.push(ch);
363+ } else {
364+ region.push(ch);
365+ }
366+ } else {
367+ out.push(ch);
368+ if ch == '[' {
369+ *depth = 1;
370+ }
371+ }
372+ }
373+ out.push_str(&rewrite_attr_list(&region));
374+ out
375+}
376+
377+/// Rewrite one attribute list's contents (without the brackets).
378+fn rewrite_attr_list(list: &str) -> String {
379+ if list.is_empty() {
380+ return String::new();
381+ }
382+ let mut out = String::with_capacity(list.len());
383+ let mut first = true;
384+ for segment in split_top_level_commas(list) {
385+ if !first {
386+ out.push(',');
387+ }
388+ first = false;
389+ let lead = segment.len() - segment.trim_start().len();
390+ let (ws, body) = segment.split_at(lead);
391+ out.push_str(ws);
392+ // `...$bundle` spread → `$bundle`
393+ let body = body.strip_prefix("...$").map_or(body.to_string(), |b| format!("${b}"));
394+ // Renamed keys (keeping any `hover:` / `md:` style prefix)
395+ let key_end = body.find(char::is_whitespace).unwrap_or(body.len());
396+ let (key, value) = body.split_at(key_end);
397+ let (prefix, base) = match key.rfind(':') {
398+ Some(p) => key.split_at(p + 1),
399+ None => ("", key),
400+ };
401+ match ATTR_RENAMES.iter().find(|(from, _)| *from == base) {
402+ Some((_, to)) => {
403+ out.push_str(prefix);
404+ out.push_str(to);
405+ out.push_str(value);
406+ }
407+ None => out.push_str(&body),
408+ }
409+ }
410+ out
411+}
412+
413+fn split_top_level_commas(s: &str) -> Vec<&str> {
414+ let mut parts = Vec::new();
415+ let mut depth = 0i32;
416+ let mut in_quotes = false;
417+ let mut start = 0;
418+ for (i, c) in s.char_indices() {
419+ match c {
420+ '"' => in_quotes = !in_quotes,
421+ _ if in_quotes => {}
422+ '(' | '[' => depth += 1,
423+ ')' | ']' => depth -= 1,
424+ ',' if depth == 0 => {
425+ parts.push(&s[start..i]);
426+ start = i + 1;
427+ }
428+ _ => {}
429+ }
430+ }
431+ parts.push(&s[start..]);
432+ parts
433+}
434+
435+/// Rename filter aliases in `$var|filter` chains.
436+fn rename_filters(line: &str) -> String {
437+ if !line.contains('|') {
438+ return line.to_string();
439+ }
440+ let mut out = String::with_capacity(line.len());
441+ let mut rest = line;
442+ while let Some(pos) = rest.find('|') {
443+ out.push_str(&rest[..=pos]);
444+ rest = &rest[pos + 1..];
445+ let name_end = rest
446+ .find(|c: char| !(c.is_ascii_alphanumeric() || c == '-' || c == '_'))
447+ .unwrap_or(rest.len());
448+ let name = &rest[..name_end];
449+ if let Some((_, canonical)) = FILTER_ALIASES.iter().find(|(a, _)| *a == name) {
450+ out.push_str(canonical);
451+ rest = &rest[name_end..];
452+ }
453+ }
454+ out.push_str(rest);
455+ out
456+}
457+
458+/// Replace whole-token `$name` references with `value`.
459+fn replace_var(line: &str, name: &str, value: &str) -> String {
460+ let needle = format!("${name}");
461+ let mut out = String::with_capacity(line.len());
462+ let mut rest = line;
463+ while let Some(pos) = rest.find(&needle) {
464+ let after = rest[pos + needle.len()..].chars().next();
465+ out.push_str(&rest[..pos]);
466+ if after.is_some_and(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
467+ out.push_str(&needle);
468+ } else {
469+ out.push_str(value);
470+ }
471+ rest = &rest[pos + needle.len()..];
472+ }
473+ out.push_str(rest);
474+ out
475+}
476+
477+fn indent_of(line: &str) -> usize {
478+ line.len() - line.trim_start().len()
479+}
480+
481+fn starts_directive(trimmed: &str, directive: &str) -> bool {
482+ trimmed
483+ .strip_prefix(directive)
484+ .is_some_and(|rest| rest.is_empty() || rest.starts_with([' ', '[']))
485+}
486+
487+/// Index just past the indented block owned by `lines[i]` (trailing blank
488+/// lines are not part of the block).
489+fn block_end(lines: &[&str], i: usize) -> usize {
490+ let indent = indent_of(lines[i]);
491+ let mut end = i + 1;
492+ let mut j = i + 1;
493+ while j < lines.len() {
494+ let l = lines[j];
495+ if l.trim().is_empty() {
496+ j += 1;
497+ continue;
498+ }
499+ if indent_of(l) <= indent {
500+ break;
501+ }
502+ j += 1;
503+ end = j;
504+ }
505+ end
506+}
507+
508+/// If `lines[i]` opens a multi-line `"""` string (`@raw """` or
509+/// `@let name """`), the index just past its closing `"""`.
510+fn triple_quote_end(lines: &[&str], i: usize) -> Option<usize> {
511+ let trimmed = lines[i].trim();
512+ let open = trimmed.find("\"\"\"")?;
513+ let after = &trimmed[open + 3..];
514+ if after.contains("\"\"\"") {
515+ return None; // single-line
516+ }
517+ let mut j = i + 1;
518+ while j < lines.len() {
519+ if lines[j].trim() == "\"\"\"" {
520+ return Some(j + 1);
521+ }
522+ j += 1;
523+ }
524+ Some(lines.len())
525+}
526+
527+/// Remove the extra indentation a block had under its (removed) owner.
528+fn dedent_block(body: &[&str], owner_indent: usize) -> Vec<String> {
529+ let body_indent = body
530+ .iter()
531+ .filter(|l| !l.trim().is_empty())
532+ .map(|l| indent_of(l))
533+ .min()
534+ .unwrap_or(owner_indent);
535+ let shift = body_indent.saturating_sub(owner_indent);
536+ body.iter()
537+ .map(|l| {
538+ if l.trim().is_empty() {
539+ String::new()
540+ } else {
541+ l.get(shift..).unwrap_or(l.trim_start()).to_string()
542+ }
543+ })
544+ .collect()
545+}
546+
547+#[cfg(test)]
548+mod tests {
549+ use super::upgrade;
550+
551+ fn up(src: &str) -> String {
552+ upgrade(src).output
553+ }
554+
555+ #[test]
556+ fn element_aliases() {
557+ assert_eq!(up("@col [padding 4]\n @p hi"), "@column [padding 4]\n @paragraph hi");
558+ assert_eq!(up("@el > @btn Go"), "@el > @button Go");
559+ assert_eq!(up("Say {@img a.png} now"), "Say {@image a.png} now");
560+ // Not aliases: @page, @paragraph, text mentioning @p
561+ assert_eq!(up("@page T\nemail me @p"), "@page T\nemail me @p");
562+ // A user function named like an alias is left alone.
563+ let src = "@let divider\n @hr\n@divider";
564+ assert_eq!(up(src), src);
565+ }
566+
567+ #[test]
568+ fn directives() {
569+ assert_eq!(up("@unless $x\n hi"), "@if not $x\n hi");
570+ assert_eq!(up("@fn card $t\n @text $t"), "@let card $t\n @text $t");
571+ assert_eq!(up("@define c [bold]\n@mixin m [italic]"), "@let c [bold]\n@let m [italic]");
572+ assert_eq!(up("@for $i in 1..3\n $i"), "@each $i in 1..3\n $i");
573+ assert_eq!(up("@use \"lib.hl\" a, b"), "@import lib.hl");
574+ assert_eq!(
575+ up("@repeat 2\n @text $_count"),
576+ "@each $_ in 1..2\n @text 2"
577+ );
578+ }
579+
580+ #[test]
581+ fn switch_to_match() {
582+ assert_eq!(
583+ up("@switch $v\n @case a [color red]\n @text A\n @default [color blue]"),
584+ "@match $v\n @case a\n @let __switch [color red]\n @text A\n @default\n @let __switch [color blue]"
585+ );
586+ }
587+
588+ #[test]
589+ fn with_and_layout() {
590+ assert_eq!(up("@with $a as b\n @text $b"), "@let b $a\n@text $b");
591+ assert_eq!(up("@layout base.hl\n @text x\n"), "@extends base.hl\n@text x\n");
592+ let r = upgrade("@layout base.hl\n @text x\n@text after");
593+ assert_eq!(r.output, "@layout base.hl\n @text x\n@text after");
594+ assert_eq!(r.manual.len(), 1);
595+ }
596+
597+ #[test]
598+ fn css_directives_become_style() {
599+ assert_eq!(
600+ up("@scope .card\n .t { color: red; }"),
601+ "@style\n @scope (.card) {\n .t { color: red; }\n }"
602+ );
603+ assert_eq!(
604+ up("@css-property --x\n syntax \"<color>\"\n initial-value #000"),
605+ "@style\n @property --x {\n syntax:\"<color>\";\n inherits:false;\n initial-value:#000;\n }"
606+ );
607+ }
608+
609+ #[test]
610+ fn attributes_and_filters() {
611+ assert_eq!(
612+ up("@el [...$card, animate fade 1s, hover:inset-area top] x"),
613+ "@el [$card, animation fade 1s, hover:position-area top] x"
614+ );
615+ assert_eq!(up("@el [\n animate spin 1s\n]"), "@el [\n animation spin 1s\n]");
616+ assert_eq!(up("@text $name|upper|len"), "@text $name|uppercase|length");
617+ // Text that merely mentions a renamed word is untouched.
618+ assert_eq!(up("@text [bold] please animate this"), "@text [bold] please animate this");
619+ }
620+
621+ #[test]
622+ fn verbatim_regions_are_untouched() {
623+ let src = "@script\n for (x of y) {}\n@raw \"\"\"\n@unless\n\"\"\"\n@style\n .p { }";
624+ assert_eq!(up(src), src);
625+ }
626+}
tests/docs.rsnew file+98
@@ -0,0 +1,98 @@
1+//! Compile every htmlang example in the docs, so they can't drift from the
2+//! language. A fenced block without a language tag is htmlang unless it is a
3+//! shell session (lines starting with `htmlang ` / `cargo `) or the syntax
4+//! template in DESIGN.md. Blocks whose first line is `-- name.hl` are written
5+//! to disk first so `@extends` / `@include` between them resolve.
6+
7+use std::path::Path;
8+
9+use htmlang::parser::{self, Severity};
10+
11+fn code_blocks(markdown: &str) -> Vec<String> {
12+ let mut blocks = Vec::new();
13+ // Inside a fence: Some(true) collects an untagged block, Some(false)
14+ // skips a tagged one (```json etc.).
15+ let mut fence: Option<bool> = None;
16+ let mut current = String::new();
17+ for line in markdown.lines() {
18+ if line.starts_with("```") {
19+ match fence.take() {
20+ Some(true) => blocks.push(std::mem::take(&mut current)),
21+ Some(false) => {}
22+ None => fence = Some(line.trim() == "```"),
23+ }
24+ continue;
25+ }
26+ if fence == Some(true) {
27+ current.push_str(line);
28+ current.push('\n');
29+ }
30+ }
31+ blocks
32+}
33+
34+fn is_htmlang(block: &str) -> bool {
35+ let first = block
36+ .lines()
37+ .find(|l| !l.trim().is_empty() && !l.trim_start().starts_with("--"))
38+ .unwrap_or("");
39+ !(first.starts_with("htmlang ")
40+ || first.starts_with("cargo ")
41+ || first.starts_with("@element "))
42+}
43+
44+fn check_doc(file: &str) {
45+ let root = Path::new(env!("CARGO_MANIFEST_DIR"));
46+ let markdown = std::fs::read_to_string(root.join(file)).unwrap();
47+ let dir = std::env::temp_dir().join(format!("htmlang_docs_{}", file.replace('.', "_")));
48+ let _ = std::fs::remove_dir_all(&dir);
49+ std::fs::create_dir_all(&dir).unwrap();
50+
51+ let blocks: Vec<String> = code_blocks(&markdown)
52+ .into_iter()
53+ .filter(|b| is_htmlang(b))
54+ .collect();
55+ // Named blocks become files, so blocks can refer to each other.
56+ for block in &blocks {
57+ if let Some(name) = block.lines().next().and_then(|l| l.strip_prefix("-- "))
58+ && name.ends_with(".hl")
59+ {
60+ std::fs::write(dir.join(name.trim()), block).unwrap();
61+ }
62+ }
63+
64+ let mut failures = Vec::new();
65+ for block in &blocks {
66+ let result = parser::parse_with_base(block, Some(&dir));
67+ let problems: Vec<String> = result
68+ .diagnostics
69+ .iter()
70+ .filter(|d| {
71+ d.severity == Severity::Error
72+ || d.message.contains("unknown")
73+ || d.message.contains("undefined variable")
74+ })
75+ .map(|d| format!("line {}: {}", d.line, d.message))
76+ .collect();
77+ if !problems.is_empty() {
78+ failures.push(format!("{}\n=> {}", block, problems.join("\n=> ")));
79+ }
80+ }
81+ assert!(
82+ failures.is_empty(),
83+ "{} example(s) in {} don't compile cleanly:\n\n{}",
84+ failures.len(),
85+ file,
86+ failures.join("\n\n")
87+ );
88+}
89+
90+#[test]
91+fn design_md_examples_compile() {
92+ check_doc("DESIGN.md");
93+}
94+
95+#[test]
96+fn readme_examples_compile() {
97+ check_doc("README.md");
98+}
tests/regressions.rs+21−5
@@ -77,7 +77,7 @@ fn page_zero_does_not_underflow() {
77
77
78#[test]
78#[test]
79fn range_at_integer_limit_terminates() {
79fn range_at_integer_limit_terminates() {
80− let out = compile("@for $i in 9223372036854775806..9223372036854775807\n @text $i");
80+ let out = compile("@each $i in 9223372036854775806..9223372036854775807\n @text $i");
81 assert!(out.contains("9223372036854775807"), "{}", out);
81 assert!(out.contains("9223372036854775807"), "{}", out);
82 let out = compile("@each $i in -9223372036854775807..-9223372036854775808\n @text $i");
82 let out = compile("@each $i in -9223372036854775807..-9223372036854775808\n @text $i");
83 assert!(out.contains("-9223372036854775808"), "{}", out);
83 assert!(out.contains("-9223372036854775808"), "{}", out);
@@ -119,13 +119,13 @@ fn css_vars_are_not_substituted_into_media_queries() {
119}
119}
120
120
121#[test]
121#[test]
122−fn partial_output_includes_scope_and_starting_style() {
122+fn partial_output_includes_style_and_keyframes() {
123 let result = parser::parse(
123 let result = parser::parse(
124− "@scope .card\n .title { font-weight: bold; }\n@starting-style\n .x { opacity: 0; }\n@text hi",
124+ "@style\n @scope (.card) { .title { font-weight: bold; } }\n@keyframes k\n from [opacity 0]\n@text hi",
125 );
125 );
126 let out = codegen::generate_partial(&result.document);
126 let out = codegen::generate_partial(&result.document);
127 assert!(out.contains(".title"), "{}", out);
127 assert!(out.contains(".title"), "{}", out);
128− assert!(out.contains("@starting-style"), "{}", out);
128+ assert!(out.contains("@keyframes k"), "{}", out);
129}
129}
130
130
131#[test]
131#[test]
@@ -268,7 +268,7 @@ fn variables_used_in_loops_conditions_and_text_are_not_unused() {
268 for src in [
268 for src in [
269 "@let items a, b\n@each $x in $items\n @text $x",
269 "@let items a, b\n@each $x in $items\n @text $x",
270 "@let on true\n@if $on\n @text y",
270 "@let on true\n@if $on\n @text y",
271− "@let n 3\n@for $i in 1..$n\n @text $i",
271+ "@let n 3\n@each $i in 1..$n\n @text $i",
272 "@let score \"10 - 2\"\n@text Score: $score",
272 "@let score \"10 - 2\"\n@text Score: $score",
273 "@let who World\nHello $who",
273 "@let who World\nHello $who",
274 ] {
274 ] {
@@ -284,3 +284,19 @@ fn variables_used_in_loops_conditions_and_text_are_not_unused() {
284 );
284 );
285 }
285 }
286}
286}
287+
288+#[test]
289+fn container_arguments_are_rendered_as_text() {
290+ for src in [
291+ "@el [padding 8] Hello",
292+ "@paragraph Hello",
293+ "@row Hello",
294+ "@section Hello",
295+ "@nav [id x] Hello",
296+ ] {
297+ let out = compile(src);
298+ assert!(out.contains("Hello"), "{:?} dropped its text: {}", src, out);
299+ }
300+ let out = compile("@paragraph Read {@link /more more}");
301+ assert!(out.contains("<a href=\"/more\">more</a>"), "{}", out);
302+}
tests/snapshots.rs+82−42
@@ -731,10 +731,56 @@ fn list_renders_ol_with_ordered() {
731}
731}
732
732
733#[test]
733#[test]
734−fn item_alias_li() {
734+fn removed_aliases_point_to_upgrade() {
735+ for (src, replacement) in [
736+ ("@li Works", "@item"),
737+ ("@divider", "@hr"),
738+ ("@el > @col x", "@column"),
739+ ("Say {@img a.png}", "@image"),
740+ ] {
741+ let diags = parse_diagnostics(src);
742+ assert!(
743+ diags.iter().any(|d| d.message.contains(replacement)
744+ && d.message.contains("htmlang upgrade")),
745+ "{:?} should point to {}: {:?}",
746+ src,
747+ replacement,
748+ diags
749+ );
750+ }
751+}
752+
753+#[test]
754+fn removed_directives_point_to_upgrade() {
755+ for src in [
756+ "@unless $x\n hi",
757+ "@for $i in 1..2\n $i",
758+ "@repeat 2\n hi",
759+ "@switch $v\n @case a\n hi",
760+ "@use \"a.hl\" x",
761+ "@with $a as b\n hi",
762+ "@layout base.hl\n hi",
763+ "@scope .c\n .t {}",
764+ "@starting-style\n .t {}",
765+ "@css-property --x\n syntax \"*\"",
766+ "@fn card $t\n @text $t",
767+ "@define c [bold]",
768+ ] {
769+ let diags = parse_diagnostics(src);
770+ assert!(
771+ diags.iter().any(|d| d.message.contains("was removed")
772+ && d.message.contains("htmlang upgrade")),
773+ "{:?} should report removed syntax: {:?}",
774+ src,
775+ diags
776+ );
777+ }
778+}
779+
780+#[test]
781+fn user_function_may_reuse_a_removed_name() {
735− let output = compile("@page T\n@list\n @li Works");
782+ let output = compile("@page T\n@let divider\n @hr\n@divider");
736− assert!(output.contains("<li"));
783+ assert!(output.contains("<hr"), "{}", output);
737− assert!(output.contains("Works"));
738}
784}
739
785
740// ---------------------------------------------------------------------------
786// ---------------------------------------------------------------------------
@@ -1133,11 +1179,11 @@ fn warning_controls_on_non_media() {
1133
1179
1134#[test]
1180#[test]
1135fn fmt_normalizes_indentation() {
1181fn fmt_normalizes_indentation() {
1136− let input = "@row\n @col\n @text hello\n @col\n @text world";
1182+ let input = "@row\n @column\n @text hello\n @column\n @text world";
1137 let formatted = htmlang::fmt::format(input);
1183 let formatted = htmlang::fmt::format(input);
1138 assert_eq!(
1184 assert_eq!(
1139 formatted,
1185 formatted,
1140− "@row\n @col\n @text hello\n @col\n @text world\n"
1186+ "@row\n @column\n @text hello\n @column\n @text world\n"
1141 );
1187 );
1142}
1188}
1143
1189
@@ -1242,12 +1288,6 @@ fn hr_renders_self_closing() {
1242 assert!(output.contains("<hr"));
1288 assert!(output.contains("<hr"));
1243}
1289}
1244
1290
1245−#[test]
1246−fn divider_alias_for_hr() {
1247− let output = compile("@page T\n@divider");
1248− assert!(output.contains("<hr"));
1249−}
1250−
1251#[test]
1291#[test]
1252fn figure_figcaption_renders() {
1292fn figure_figcaption_renders() {
1253 let output = compile("@page T\n@figure\n @image [alt test] photo.jpg\n @figcaption Caption");
1293 let output = compile("@page T\n@figure\n @image [alt test] photo.jpg\n @figcaption Caption");
@@ -2042,13 +2082,13 @@ fn media_portrait() {
2042
2082
2043#[test]
2083#[test]
2044fn unless_false_shows_content() {
2084fn unless_false_shows_content() {
2045− let output = compile("@page T\n@let show false\n@unless $show\n @text Visible");
2085+ let output = compile("@page T\n@let show false\n@if not $show\n @text Visible");
2046 assert!(output.contains("Visible"));
2086 assert!(output.contains("Visible"));
2047}
2087}
2048
2088
2049#[test]
2089#[test]
2050fn unless_true_hides_content() {
2090fn unless_true_hides_content() {
2051− let output = compile("@page T\n@let show true\n@unless $show\n @text Hidden");
2091+ let output = compile("@page T\n@let show true\n@if not $show\n @text Hidden");
2052 assert!(!output.contains("Hidden"));
2092 assert!(!output.contains("Hidden"));
2053}
2093}
2054
2094
@@ -3119,16 +3159,16 @@ fn test_carousel_children_snap() {
3119}
3159}
3120
3160
3121#[test]
3161#[test]
3122−fn test_use_directive() {
3162+fn test_import_missing_file() {
3123 // We can't test @use with actual files in unit tests easily, but we can verify
3163 // We can't test @use with actual files in unit tests easily, but we can verify
3124 // the parser recognizes the directive without errors when it can't find the file
3164 // the parser recognizes the directive without errors when it can't find the file
3125− let result = htmlang::parser::parse("@use nonexistent.hl card");
3165+ let result = htmlang::parser::parse("@import nonexistent.hl");
3126 let has_use_error = result.diagnostics.iter().any(|d| {
3166 let has_use_error = result.diagnostics.iter().any(|d| {
3127− d.message.contains("cannot use") && d.severity == htmlang::parser::Severity::Error
3167+ d.message.contains("cannot import") && d.severity == htmlang::parser::Severity::Error
3128 });
3168 });
3129 assert!(
3169 assert!(
3130 has_use_error,
3170 has_use_error,
3131− "@use should report error for missing file, got: {:?}",
3171+ "@import should report error for missing file, got: {:?}",
3132 result.diagnostics
3172 result.diagnostics
3133 );
3173 );
3134}
3174}
@@ -3397,7 +3437,7 @@ fn view_transition_name_passthrough() {
3397#[test]
3437#[test]
3398fn animate_generates_animation_css() {
3438fn animate_generates_animation_css() {
3399 let output = compile(
3439 let output = compile(
3400− "@page T\n@keyframes fade\n from [opacity 0]\n to [opacity 1]\n@el [animate fade 0.3s ease]\n Content",
3440+ "@page T\n@keyframes fade\n from [opacity 0]\n to [opacity 1]\n@el [animation fade 0.3s ease]\n Content",
3401 );
3441 );
3402 assert!(
3442 assert!(
3403 output.contains("animation:fade 0.3s ease"),
3443 output.contains("animation:fade 0.3s ease"),
@@ -3506,7 +3546,7 @@ fn fn_named_slot_default() {
3506#[test]
3546#[test]
3507fn no_warning_new_attrs_batch6() {
3547fn no_warning_new_attrs_batch6() {
3508 let diags = parse_diagnostics(
3548 let diags = parse_diagnostics(
3509− "@el [grid-template-areas \"a b\", grid-area a, view-transition-name hero, animate fade 1s, critical]",
3549+ "@el [grid-template-areas \"a b\", grid-area a, view-transition-name hero, animation fade 1s, critical]",
3510 );
3550 );
3511 assert!(
3551 assert!(
3512 !diags
3552 !diags
@@ -3741,7 +3781,7 @@ fn snapshot_mixin_spread() {
3741
3781
3742#[test]
3782#[test]
3743fn mixin_expands_in_attrs() {
3783fn mixin_expands_in_attrs() {
3744− let output = compile("@let card [padding 20, rounded 8]\n@el [...$card]\n Hi");
3784+ let output = compile("@let card [padding 20, rounded 8]\n@el [$card]\n Hi");
3745 assert!(
3785 assert!(
3746 output.contains("padding:20px"),
3786 output.contains("padding:20px"),
3747 "mixin should expand padding: {}",
3787 "mixin should expand padding: {}",
@@ -3766,7 +3806,7 @@ fn mixin_with_dollar_syntax() {
3766
3806
3767#[test]
3807#[test]
3768fn mixin_compose_with_extra_attrs() {
3808fn mixin_compose_with_extra_attrs() {
3769− let output = compile("@let base [padding 10]\n@el [...$base, background red]\n Hi");
3809+ let output = compile("@let base [padding 10]\n@el [$base, background red]\n Hi");
3770 assert!(
3810 assert!(
3771 output.contains("padding:10px"),
3811 output.contains("padding:10px"),
3772 "mixin should expand: {}",
3812 "mixin should expand: {}",
@@ -3793,7 +3833,7 @@ fn warning_unused_mixin() {
3793
3833
3794#[test]
3834#[test]
3795fn no_warning_used_mixin() {
3835fn no_warning_used_mixin() {
3796− let diags = parse_diagnostics("@let card [padding 10]\n@el [...$card]");
3836+ let diags = parse_diagnostics("@let card [padding 10]\n@el [$card]");
3797 assert!(
3837 assert!(
3798 !diags
3838 !diags
3799 .iter()
3839 .iter()
@@ -3935,7 +3975,7 @@ fn round_trip_basic() {
3935
3975
3936#[test]
3976#[test]
3937fn for_basic_range() {
3977fn for_basic_range() {
3938− let html = compile("@for $i in 1..3\n @text $i\n");
3978+ let html = compile("@each $i in 1..3\n @text $i\n");
3939 assert!(html.contains("1"));
3979 assert!(html.contains("1"));
3940 assert!(html.contains("2"));
3980 assert!(html.contains("2"));
3941 assert!(html.contains("3"));
3981 assert!(html.contains("3"));
@@ -3943,7 +3983,7 @@ fn for_basic_range() {
3943
3983
3944#[test]
3984#[test]
3945fn for_with_step() {
3985fn for_with_step() {
3946− let html = compile("@for $i in 0..10 step 5\n @text $i\n");
3986+ let html = compile("@each $i in 0..10 step 5\n @text $i\n");
3947 assert!(html.contains("0"));
3987 assert!(html.contains("0"));
3948 assert!(html.contains("5"));
3988 assert!(html.contains("5"));
3949 assert!(html.contains("10"));
3989 assert!(html.contains("10"));
@@ -3951,7 +3991,7 @@ fn for_with_step() {
3951
3991
3952#[test]
3992#[test]
3953fn for_reverse_range() {
3993fn for_reverse_range() {
3954− let html = compile("@for $i in 3..1\n @text $i\n");
3994+ let html = compile("@each $i in 3..1\n @text $i\n");
3955 assert!(html.contains("3"));
3995 assert!(html.contains("3"));
3956 assert!(html.contains("2"));
3996 assert!(html.contains("2"));
3957 assert!(html.contains("1"));
3997 assert!(html.contains("1"));
@@ -3959,7 +3999,7 @@ fn for_reverse_range() {
3959
3999
3960#[test]
4000#[test]
3961fn for_with_variable_bounds() {
4001fn for_with_variable_bounds() {
3962− let html = compile("@let start 1\n@let end 3\n@for $i in $start..$end\n @text $i\n");
4002+ let html = compile("@let start 1\n@let end 3\n@each $i in $start..$end\n @text $i\n");
3963 assert!(html.contains("1"));
4003 assert!(html.contains("1"));
3964 assert!(html.contains("2"));
4004 assert!(html.contains("2"));
3965 assert!(html.contains("3"));
4005 assert!(html.contains("3"));
@@ -4018,7 +4058,7 @@ fn component_with_children() {
4018#[test]
4058#[test]
4019fn switch_matches_case() {
4059fn switch_matches_case() {
4020 let html = compile(
4060 let html = compile(
4021− "@let variant primary\n@switch $variant\n @case primary\n @text Primary\n @case danger\n @text Danger\n",
4061+ "@let variant primary\n@match $variant\n @case primary\n @text Primary\n @case danger\n @text Danger\n",
4022 );
4062 );
4023 assert!(html.contains("Primary"));
4063 assert!(html.contains("Primary"));
4024 assert!(!html.contains("Danger"));
4064 assert!(!html.contains("Danger"));
@@ -4027,7 +4067,7 @@ fn switch_matches_case() {
4027#[test]
4067#[test]
4028fn switch_falls_to_default() {
4068fn switch_falls_to_default() {
4029 let html = compile(
4069 let html = compile(
4030− "@let variant unknown\n@switch $variant\n @case primary\n @text Primary\n @default\n @text Default\n",
4070+ "@let variant unknown\n@match $variant\n @case primary\n @text Primary\n @default\n @text Default\n",
4031 );
4071 );
4032 assert!(!html.contains("Primary"));
4072 assert!(!html.contains("Primary"));
4033 assert!(html.contains("Default"));
4073 assert!(html.contains("Default"));
@@ -4036,11 +4076,11 @@ fn switch_falls_to_default() {
4036#[test]
4076#[test]
4037fn switch_with_attrs() {
4077fn switch_with_attrs() {
4038 let _html = compile(
4078 let _html = compile(
4039− "@let variant primary\n@switch $variant\n @case primary [background blue, color white]\n @case danger [background red, color white]\n",
4079+ "@let variant primary\n@match $variant\n @case primary\n @let __switch [background blue, color white]\n @case danger\n @let __switch [background red, color white]\n",
4040 );
4080 );
4041 // The @switch should register matched attrs as __switch define
4081 // The @switch should register matched attrs as __switch define
4042 let result = htmlang::parser::parse(
4082 let result = htmlang::parser::parse(
4043− "@let variant primary\n@switch $variant\n @case primary [background blue, color white]\n @case danger [background red, color white]\n",
4083+ "@let variant primary\n@match $variant\n @case primary\n @let __switch [background blue, color white]\n @case danger\n @let __switch [background red, color white]\n",
4044 );
4084 );
4045 assert!(result.document.defines.contains_key("__switch"));
4085 assert!(result.document.defines.contains_key("__switch"));
4046}
4086}
@@ -4278,9 +4318,9 @@ fn test_error_each_missing_in() {
4278
4318
4279#[test]
4319#[test]
4280fn test_error_for_missing_range() {
4320fn test_error_for_missing_range() {
4281− let diags = parse_diagnostics("@for $i\n @text $i");
4321+ let diags = parse_diagnostics("@each $i\n @text $i");
4282 assert!(
4322 assert!(
4283− diags.iter().any(|d| d.message.contains("@for requires")),
4323+ diags.iter().any(|d| d.message.contains("@each requires")),
4284 "should report @for syntax error"
4324 "should report @for syntax error"
4285 );
4325 );
4286}
4326}
@@ -4362,7 +4402,7 @@ fn test_children_fallback_content() {
4362
4402
4363#[test]
4403#[test]
4364fn test_spread_define() {
4404fn test_spread_define() {
4365− let html = compile("@let btn [padding 12, bold]\n@el [...$btn]\n Click");
4405+ let html = compile("@let btn [padding 12, bold]\n@el [$btn]\n Click");
4366 assert!(
4406 assert!(
4367 html.contains("padding:12px"),
4407 html.contains("padding:12px"),
4368 "spread define should apply padding"
4408 "spread define should apply padding"
@@ -4485,7 +4525,7 @@ fn snapshot_initial_letter() {
4485
4525
4486#[test]
4526#[test]
4487fn repeat_directive_basic() {
4527fn repeat_directive_basic() {
4488− let output = compile("@repeat 3\n @text hello");
4528+ let output = compile("@each $_ in 1..3\n @text hello");
4489 // Should contain 3 spans with "hello"
4529 // Should contain 3 spans with "hello"
4490 let count = output.matches("hello").count();
4530 let count = output.matches("hello").count();
4491 assert_eq!(count, 3, "expected 3 repetitions, got {}", count);
4531 assert_eq!(count, 3, "expected 3 repetitions, got {}", count);
@@ -4493,7 +4533,7 @@ fn repeat_directive_basic() {
4493
4533
4494#[test]
4534#[test]
4495fn with_directive_rebinding() {
4535fn with_directive_rebinding() {
4496− let output = compile("@let x hello\n@with $x as y\n @text $y");
4536+ let output = compile("@let x hello\n@let y $x\n@text $y");
4497 assert!(
4537 assert!(
4498 output.contains("hello"),
4538 output.contains("hello"),
4499 "expected @with to rebind variable"
4539 "expected @with to rebind variable"
@@ -4559,16 +4599,16 @@ fn markdown_renders_list() {
4559
4599
4560#[test]
4600#[test]
4561fn scope_block_generates_css() {
4601fn scope_block_generates_css() {
4562− let output = compile("@page Test\n@scope .card\n .title { color: red; }\n@text hello");
4602+ let output = compile("@page Test\n@style\n @scope (.card) {\n .title { color: red; }\n }\n@text hello");
4563 assert!(
4603 assert!(
4564− output.contains("@scope"),
4604+ output.contains("@scope (.card)"),
4565 "should generate @scope CSS block"
4605 "should generate @scope CSS block"
4566 );
4606 );
4567}
4607}
4568
4608
4569#[test]
4609#[test]
4570fn starting_style_generates_css() {
4610fn starting_style_generates_css() {
4571− let output = compile("@page Test\n@starting-style\n .fade { opacity: 0; }\n@text hello");
4611+ let output = compile("@page Test\n@style\n @starting-style {\n .fade { opacity: 0; }\n }\n@text hello");
4572 assert!(
4612 assert!(
4573 output.contains("@starting-style"),
4613 output.contains("@starting-style"),
4574 "should generate @starting-style CSS block"
4614 "should generate @starting-style CSS block"
@@ -4727,7 +4767,7 @@ fn parser_multiple_errors() {
4727
4767
4728#[test]
4768#[test]
4729fn repeat_with_index() {
4769fn repeat_with_index() {
4730− let output = compile("@repeat 3\n @text $_index");
4770+ let output = compile("@each $_ in 1..3\n @text $_index");
4731 assert!(output.contains("0"), "should have index 0");
4771 assert!(output.contains("0"), "should have index 0");
4732 assert!(output.contains("1"), "should have index 1");
4772 assert!(output.contains("1"), "should have index 1");
4733 assert!(output.contains("2"), "should have index 2");
4773 assert!(output.contains("2"), "should have index 2");
@@ -4893,7 +4933,7 @@ fn svg_directive_missing_file() {
4893
4933
4894#[test]
4934#[test]
4895fn css_property_directive() {
4935fn css_property_directive() {
4896− let input = "@css-property --my-color\n syntax \"<color>\"\n inherits true\n initial-value #000\n\n@el [background var(--my-color)] Content";
4936+ let input = "@style\n @property --my-color {\n syntax:\"<color>\";\n inherits:true;\n initial-value:#000;\n }\n\n@el [background var(--my-color)] Content";
4897 let result = htmlang::parser::parse(input);
4937 let result = htmlang::parser::parse(input);
4898 assert!(
4938 assert!(
4899 result
4939 result
@@ -4906,7 +4946,7 @@ fn css_property_directive() {
4906 let html = htmlang::codegen::generate(&result.document);
4946 let html = htmlang::codegen::generate(&result.document);
4907 assert!(
4947 assert!(
4908 html.contains("@property --my-color"),
4948 html.contains("@property --my-color"),
4909− "@css-property should emit CSS @property rule, got: {}",
4949+ "@property rule should be emitted, got: {}",
4910 html
4950 html
4911 );
4951 );
4912 assert!(
4952 assert!(
tests/snapshots/alignment.html+1−1
@@ -1 +1 @@
1−<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Alignment</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;height:400px;}.b{display:flex;flex-direction:column;align-self:center;}.c{display:flex;flex-direction:column;margin-top:auto;margin-bottom:auto;}.d{display:flex;flex-direction:column;align-self:flex-start;}.e{display:flex;flex-direction:column;align-self:flex-end;}.f{display:flex;flex-direction:column;margin-bottom:auto;}.g{display:flex;flex-direction:column;margin-top:auto;}.h{display:flex;flex-direction:row;height:200px;}}</style></head><body><div class="a"><div class="b"></div><div class="c"></div><div class="d"></div><div class="e"></div><div class="f"></div><div class="g"></div></div><div class="h"><div class="b"></div><div class="d"></div><div class="e"></div></div></body></html>
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Alignment</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;height:400px;}.b{display:flex;flex-direction:column;align-self:center;}.c{display:flex;flex-direction:column;margin-top:auto;margin-bottom:auto;}.d{display:flex;flex-direction:column;align-self:flex-start;}.e{display:flex;flex-direction:column;align-self:flex-end;}.f{display:flex;flex-direction:column;margin-bottom:auto;}.g{display:flex;flex-direction:column;margin-top:auto;}.h{display:flex;flex-direction:row;height:200px;}}</style></head><body><div class="a"><div class="b"><span>Centered X</span></div><div class="c"><span>Centered Y</span></div><div class="d"><span>Left</span></div><div class="e"><span>Right</span></div><div class="f"><span>Top</span></div><div class="g"><span>Bottom</span></div></div><div class="h"><div class="b"><span>In row</span></div><div class="d"><span>Top in row</span></div><div class="e"><span>Bottom in row</span></div></div></body></html>
tests/snapshots/animate_shorthand.hl+2−2
@@ -6,7 +6,7 @@
6 from [transform translateY(20px), opacity 0]
6 from [transform translateY(20px), opacity 0]
7 to [transform translateY(0), opacity 1]
7 to [transform translateY(0), opacity 1]
8@column [spacing 20, padding 20]
8@column [spacing 20, padding 20]
9− @el [animate fade-in 0.3s ease, padding 20]
9+ @el [animation fade-in 0.3s ease, padding 20]
10 Faded in
10 Faded in
11− @el [animate slide-up 0.5s ease-out, padding 20, background #e5e7eb]
11+ @el [animation slide-up 0.5s ease-out, padding 20, background #e5e7eb]
12 Slid up
12 Slid up
tests/snapshots/css_property.hl+6−4
@@ -1,8 +1,10 @@
1@page CSS Property Test
1@page CSS Property Test
2+@style
2−@css-property --accent
3+ @property --accent {
3− syntax "<color>"
4+ syntax:"<color>";
4− inherits true
5+ inherits:true;
5− initial-value #3b82f6
6+ initial-value:#3b82f6;
7+ }
6
8
7@el [background var(--accent), padding 20]
9@el [background var(--accent), padding 20]
8 Custom property element
10 Custom property element
tests/snapshots/css_property.html+1−1
@@ -1 +1 @@
1−<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>CSS Property Test</title><style>*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}.a{display:flex;flex-direction:column;background:var(--accent);padding:20px;}@property --accent {syntax:"<color>";inherits:true;initial-value:#3b82f6;}</style></head><body><div class="a"><span>Custom property element</span></div></body></html>
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>CSS Property Test</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;background:var(--accent);padding:20px;}}@property --accent {syntax:"<color>";inherits:true;initial-value:#3b82f6;}</style></head><body><div class="a"><span>Custom property element</span></div></body></html>
tests/snapshots/css_shorthands.html+1−1
@@ -1 +1 @@
1−<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>CSS Shorthands</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;gap:20px;}.b{max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}.c{margin:0;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;}.d{display:flex;flex-direction:column;filter:blur(4px);padding:20px;}.e{display:flex;flex-direction:column;backdrop-filter:blur(10px);padding:20px;background:rgba(255,255,255,0.5);}.f{display:flex;flex-direction:column;overflow-x:auto;scrollbar-width:none;-ms-overflow-style:none;max-width:300px;}.g{display:flex;flex-direction:row;gap:20px;}.h{display:flex;flex-direction:column;width:200px;padding:20px;}.i{display:flex;flex-direction:column;width:100%;height:20px;border-radius:4px;background:linear-gradient(90deg,#e5e7eb 25%,#f3f4f6 50%,#e5e7eb 75%);background-size:200% 100%;animation:hl-skeleton 1.5s ease-in-out infinite;}.j{display:flex;flex-direction:column;background:linear-gradient(#3b82f6,#8b5cf6);padding:20px;border-radius:8px;}.k{color:white;}.l{display:flex;flex-direction:column;background:linear-gradient(45deg,#ff6b6b,#feca57);padding:20px;border-radius:8px;}.f::-webkit-scrollbar{display:none;}}@keyframes hl-skeleton{0%{background-position:200% 0}100%{background-position:-200% 0}}</style></head><body><div class="a"><span class="b">This text will be truncated with an ellipsis</span><p class="c">This is a long paragraph that spans multiple lines and should be clamped at three lines.</p><div class="d"><span>Blurred content</span></div><div class="e"><span>Frosted glass effect</span></div><div class="f"><div class="g"><div class="h"></div><div class="h"></div><div class="h"></div></div></div><div class="i"></div><div class="j"><span class="k">Gradient background</span></div><div class="l"><span class="k">Angled gradient</span></div></div></body></html>
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>CSS Shorthands</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;gap:20px;}.b{max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}.c{margin:0;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;}.d{display:flex;flex-direction:column;filter:blur(4px);padding:20px;}.e{display:flex;flex-direction:column;backdrop-filter:blur(10px);padding:20px;background:rgba(255,255,255,0.5);}.f{display:flex;flex-direction:column;overflow-x:auto;scrollbar-width:none;-ms-overflow-style:none;max-width:300px;}.g{display:flex;flex-direction:row;gap:20px;}.h{display:flex;flex-direction:column;width:200px;padding:20px;}.i{display:flex;flex-direction:column;width:100%;height:20px;border-radius:4px;background:linear-gradient(90deg,#e5e7eb 25%,#f3f4f6 50%,#e5e7eb 75%);background-size:200% 100%;animation:hl-skeleton 1.5s ease-in-out infinite;}.j{display:flex;flex-direction:column;background:linear-gradient(#3b82f6,#8b5cf6);padding:20px;border-radius:8px;}.k{color:white;}.l{display:flex;flex-direction:column;background:linear-gradient(45deg,#ff6b6b,#feca57);padding:20px;border-radius:8px;}.f::-webkit-scrollbar{display:none;}}@keyframes hl-skeleton{0%{background-position:200% 0}100%{background-position:-200% 0}}</style></head><body><div class="a"><span class="b">This text will be truncated with an ellipsis</span><p class="c">This is a long paragraph that spans multiple lines and should be clamped at three lines.</p><div class="d"><span>Blurred content</span></div><div class="e"><span>Frosted glass effect</span></div><div class="f"><div class="g"><div class="h"><span>Item 1</span></div><div class="h"><span>Item 2</span></div><div class="h"><span>Item 3</span></div></div></div><div class="i"></div><div class="j"><span class="k">Gradient background</span></div><div class="l"><span class="k">Angled gradient</span></div></div></body></html>
tests/snapshots/mixin_spread.hl+1−1
@@ -3,7 +3,7 @@
3@let card-style [padding 20, rounded 8, background #f3f4f6]
3@let card-style [padding 20, rounded 8, background #f3f4f6]
4@let interactive [cursor pointer, hover:background #e5e7eb, transition all 0.15s ease]
4@let interactive [cursor pointer, hover:background #e5e7eb, transition all 0.15s ease]
5
5
6−@el [...$card-style, ...$interactive]
6+@el [$card-style, $interactive]
7 Hello mixin world
7 Hello mixin world
8
8
9@el [$card-style, border 1 #ccc]
9@el [$card-style, border 1 #ccc]
tests/snapshots/new_css_properties_2.html+1−1
@@ -1 +1 @@
1−<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>New CSS Properties 2</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;clip-path:polygon(50% 0%, 0% 100%, 100% 100%);}.b{display:flex;flex-direction:column;mix-blend-mode:multiply;background:red;}.c{display:flex;flex-direction:column;background-blend-mode:overlay;background-image:linear-gradient(red, blue);}.d{display:flex;flex-direction:column;writing-mode:vertical-rl;}.e{display:flex;flex-direction:column;column-count:3;column-gap:20px;}.f{margin:0;text-indent:2em;}.g{margin:0;hyphens:auto;}.h{display:flex;flex-direction:row;}.i{display:flex;flex-direction:column;flex-grow:1;}.j{display:flex;flex-direction:column;flex-shrink:0;}.k{display:flex;flex-direction:column;flex-basis:200px;}.l{display:flex;flex-direction:column;isolation:isolate;}.m{flex-direction:column;display:grid;place-content:center;}.n{display:flex;flex-direction:column;background-image:url(bg.png);background-size:cover;}}</style></head><body><div class="a"><span>Triangle</span></div><div class="b"><span>Blended</span></div><div class="c"><span>Background blend</span></div><div class="d"><span>Vertical text</span></div><div class="e"><span>Multi-column layout content here</span></div><p class="f">Indented first line of the paragraph.</p><p class="g">A very long word that might need hyphenation in narrow containers.</p><div class="h"><div class="i"></div><div class="j"></div><div class="k"></div></div><div class="l"><span>Isolated stacking</span></div><div class="m"><span>Centered grid content</span></div><div class="n"><span>With background image</span></div></body></html>
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>New CSS Properties 2</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;clip-path:polygon(50% 0%, 0% 100%, 100% 100%);}.b{display:flex;flex-direction:column;mix-blend-mode:multiply;background:red;}.c{display:flex;flex-direction:column;background-blend-mode:overlay;background-image:linear-gradient(red, blue);}.d{display:flex;flex-direction:column;writing-mode:vertical-rl;}.e{display:flex;flex-direction:column;column-count:3;column-gap:20px;}.f{margin:0;text-indent:2em;}.g{margin:0;hyphens:auto;}.h{display:flex;flex-direction:row;}.i{display:flex;flex-direction:column;flex-grow:1;}.j{display:flex;flex-direction:column;flex-shrink:0;}.k{display:flex;flex-direction:column;flex-basis:200px;}.l{display:flex;flex-direction:column;isolation:isolate;}.m{flex-direction:column;display:grid;place-content:center;}.n{display:flex;flex-direction:column;background-image:url(bg.png);background-size:cover;}}</style></head><body><div class="a"><span>Triangle</span></div><div class="b"><span>Blended</span></div><div class="c"><span>Background blend</span></div><div class="d"><span>Vertical text</span></div><div class="e"><span>Multi-column layout content here</span></div><p class="f">Indented first line of the paragraph.</p><p class="g">A very long word that might need hyphenation in narrow containers.</p><div class="h"><div class="i"><span>Grow</span></div><div class="j"><span>No shrink</span></div><div class="k"><span>Fixed basis</span></div></div><div class="l"><span>Isolated stacking</span></div><div class="m"><span>Centered grid content</span></div><div class="n"><span>With background image</span></div></body></html>
tests/snapshots/repeat_directive.hl+1−1
@@ -1,2 +1,2 @@
1−@repeat 3
1+@each $_ in 1..3
2 @text [bold] Item $_index
2 @text [bold] Item $_index
tests/snapshots/scope_css.hl+5−3
@@ -1,8 +1,10 @@
1@page Scope Test
1@page Scope Test
2
2
3+@style
3−@scope .card
4+ @scope (.card) {
4− .title { font-weight: bold; }
5+ .title { font-weight: bold; }
5− .body { color: #333; }
6+ .body { color: #333; }
7+ }
6
8
7@column [padding 20]
9@column [padding 20]
8 @text Hello
10 @text Hello
tests/snapshots/semantic_elements.html+1−1
@@ -1 +1 @@
1−<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Semantic Elements</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid currentColor;outline-offset:2px}.hl-skip{position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden;z-index:9999;padding:8px 16px;background:#000;color:#fff;text-decoration:none;font-size:14px}.hl-skip:focus{left:8px;top:8px;width:auto;height:auto;overflow:visible}}@layer htmlang{.a{display:flex;flex-direction:column;padding:20px;background:#f0f0f0;}.b{font-weight:bold;font-size:24px;}.c{display:flex;flex-direction:column;gap:10px;}.d{display:flex;flex-direction:column;padding:20px;}.e{display:flex;flex-direction:column;padding:16px;border:1px solid #ddd;border-radius:8px;}.f{font-weight:bold;}.g{margin:0;}.h{display:flex;flex-direction:column;padding:16px;}.i{display:flex;flex-direction:column;padding:16px;background:#f8f8f8;}.j{display:flex;flex-direction:column;padding:20px;background:#333;color:white;}}</style></head><body><a href="#hl-main" class="hl-skip">Skip to content</a><header class="a"><span class="b">My Site</span></header><nav class="c"><a href="/">Home</a><a href="/about">About</a><a href="/contact">Contact</a></nav><main id="hl-main" class="d"><article class="e"><span class="f">Article Title</span><p class="g"></p></article><section class="h"><span class="f">Section Title</span><p class="g"></p></section></main><aside class="i"><span>Related links</span></aside><footer class="j"><span>Copyright 2024</span></footer></body></html>
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Semantic Elements</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid currentColor;outline-offset:2px}.hl-skip{position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden;z-index:9999;padding:8px 16px;background:#000;color:#fff;text-decoration:none;font-size:14px}.hl-skip:focus{left:8px;top:8px;width:auto;height:auto;overflow:visible}}@layer htmlang{.a{display:flex;flex-direction:column;padding:20px;background:#f0f0f0;}.b{font-weight:bold;font-size:24px;}.c{display:flex;flex-direction:column;gap:10px;}.d{display:flex;flex-direction:column;padding:20px;}.e{display:flex;flex-direction:column;padding:16px;border:1px solid #ddd;border-radius:8px;}.f{font-weight:bold;}.g{margin:0;}.h{display:flex;flex-direction:column;padding:16px;}.i{display:flex;flex-direction:column;padding:16px;background:#f8f8f8;}.j{display:flex;flex-direction:column;padding:20px;background:#333;color:white;}}</style></head><body><a href="#hl-main" class="hl-skip">Skip to content</a><header class="a"><span class="b">My Site</span></header><nav class="c"><a href="/">Home</a><a href="/about">About</a><a href="/contact">Contact</a></nav><main id="hl-main" class="d"><article class="e"><span class="f">Article Title</span><p class="g">Some article content here</p></article><section class="h"><span class="f">Section Title</span><p class="g">Section content</p></section></main><aside class="i"><span>Related links</span></aside><footer class="j"><span>Copyright 2024</span></footer></body></html>
tests/snapshots/sizing.html+1−1
@@ -1 +1 @@
1−<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Sizing</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:row;height:200px;}.b{display:flex;flex-direction:column;flex:1;min-width:0;background:#eee;}.c{display:flex;flex-direction:column;width:100px;background:#ddd;}.d{display:flex;flex-direction:column;flex-shrink:0;background:#ccc;}.e{display:flex;flex-direction:column;width:400px;}.f{display:flex;flex-direction:column;flex:1;min-height:0;background:#eee;}.g{display:flex;flex-direction:column;height:50px;background:#ddd;}}</style></head><body><div class="a"><div class="b"></div><div class="c"></div><div class="d"></div></div><div class="e"><div class="f"></div><div class="g"></div></div></body></html>
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Sizing</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:row;height:200px;}.b{display:flex;flex-direction:column;flex:1;min-width:0;background:#eee;}.c{display:flex;flex-direction:column;width:100px;background:#ddd;}.d{display:flex;flex-direction:column;flex-shrink:0;background:#ccc;}.e{display:flex;flex-direction:column;width:400px;}.f{display:flex;flex-direction:column;flex:1;min-height:0;background:#eee;}.g{display:flex;flex-direction:column;height:50px;background:#ddd;}}</style></head><body><div class="a"><div class="b"><span>Fill width</span></div><div class="c"><span>Fixed 100</span></div><div class="d"><span>Shrink</span></div></div><div class="e"><div class="f"><span>Fill height</span></div><div class="g"><span>Fixed 50</span></div></div></body></html>
tests/snapshots/starting_style.hl+4−2
@@ -1,7 +1,9 @@
1@page Starting Style Test
1@page Starting Style Test
2
2
3+@style
3−@starting-style
4+ @starting-style {
4− .fade-in { opacity: 0; }
5+ .fade-in { opacity: 0; }
6+ }
5
7
6@column [padding 20]
8@column [padding 20]
7 @text [opacity 1, transition opacity 0.3s ease] Hello
9 @text [opacity 1, transition opacity 0.3s ease] Hello
tests/snapshots/starting_style.html+1−1
@@ -1 +1 @@
1−<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Starting Style Test</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;padding:20px;}.b{opacity:1;transition:opacity 0.3s ease;}}@starting-style{.fade-in { opacity: 0; }}</style></head><body><div class="a"><span class="b">Hello</span></div></body></html>
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Starting Style Test</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;padding:20px;}.b{opacity:1;transition:opacity 0.3s ease;}}@starting-style {.fade-in { opacity: 0; }}</style></head><body><div class="a"><span class="b">Hello</span></div></body></html>
tests/snapshots/style_block.html+1−1
@@ -1 +1 @@
1−<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Style Test</title><style>*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}.a{display:flex;flex-direction:column;}.highlight { background: yellow; border: 2px solid orange; }.highlight:hover { background: gold; }</style></head><body><div class="a highlight"><span>Custom styled</span></div></body></html>
1+<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Style Test</title><style>@layer hl-reset,htmlang;@layer hl-reset{*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a{text-decoration:none;color:inherit}}@layer htmlang{.a{display:flex;flex-direction:column;}}.highlight { background: yellow; border: 2px solid orange; }.highlight:hover { background: gold; }</style></head><body><div class="a highlight"><span>Custom styled</span></div></body></html>
tests/snapshots/unless_directive.hl+2−2
@@ -2,10 +2,10 @@
2
2
3@let show false
3@let show false
4
4
5−@unless $show
5+@if not $show
6 @text This shows because show is false
6 @text This shows because show is false
7
7
8@let visible true
8@let visible true
9
9
10−@unless $visible
10+@if not $visible
11 @text This should not appear
11 @text This should not appear
tests/snapshots/with_directive.hl+2−2
@@ -1,5 +1,5 @@
1@let user-name Alice
1@let user-name Alice
2@let greeting Hello
2@let greeting Hello
3
3
4−@with $user-name as name
4+@let name $user-name
5− @text $name says $greeting
5+@text $name says $greeting