on master help sign in
Simplify the language: remove duplicate directives and aliases

Removed, each with an automatic rewrite in `htmlang upgrade` and a compiler error that names the replacement: - @unless -> @if not (conditions gain `not`) - @for, @repeat -> @each over a range - @switch -> @match (case attributes via @let __switch) - @use -> @import; @with -> @let - @layout -> @extends (content outside @slot now fills @children) - @scope, @starting-style, @css-property -> CSS rules in @style - element aliases @col @p @img @li @btn @ul @divider @opt - `...$bundle` spread, `animate`, `inset-area`, filter aliases - @fn/@define/@mixin (removed earlier) now also get a hint and a rewrite Also: - Generated CSS is always in @layer htmlang, so @style overrides it as documented (a @style block used to switch layering off) - Plain arguments are no longer dropped: `@el [..] Hi`, `@paragraph Hi`, `@section Hi` render their text - Unparseable inline {@...} elements are reported instead of silently becoming literal text - `migrate` is now an alias of `upgrade`, backed by src/upgrade.rs - DESIGN.md rewritten as a compact spec; tests/docs.rs compiles every example in DESIGN.md and README.md - Snapshot inputs, examples and inline tests migrated with the new upgrade command (outputs unchanged)

commit56a38fc4b2681f34b24734f19edc619e84d5f5b1 authoriko <mail@iko.soy> dateFri 25 Sep 2026, 18:42 (2 weeks ago) parent888794e
35 files changed+1396−2367
DESIGN.md+270−1156 README.md+8−1 crates/htmlang-core/src/ast.rs+37−2 crates/htmlang-core/src/codegen.rs+21−97 crates/htmlang-core/src/parser.rs+157−649 examples/demo.hl+6−6 src/bin/htmlang_lsp/analysis.rs+3−35 src/bin/htmlang_lsp/completion.rs−130 src/bin/htmlang_lsp/hover.rs+7−17 src/bin/htmlang_lsp/main.rs+1−17 src/cli.rs+1−4 src/fmt.rs−2 src/lib.rs+1 src/main.rs+20−173 src/playground_default.hl+6−6 src/upgrade.rs+626 tests/docs.rs+98 tests/regressions.rs+21−5 tests/snapshots.rs+82−42 tests/snapshots/alignment.html+1−1 tests/snapshots/animate_shorthand.hl+2−2 tests/snapshots/css_property.hl+6−4 tests/snapshots/css_property.html+1−1 tests/snapshots/css_shorthands.html+1−1 tests/snapshots/mixin_spread.hl+1−1 tests/snapshots/new_css_properties_2.html+1−1 tests/snapshots/repeat_directive.hl+1−1 tests/snapshots/scope_css.hl+5−3 tests/snapshots/semantic_elements.html+1−1 tests/snapshots/sizing.html+1−1 tests/snapshots/starting_style.hl+4−2 tests/snapshots/starting_style.html+1−1 tests/snapshots/style_block.html+1−1 tests/snapshots/unless_directive.hl+2−2 tests/snapshots/with_directive.hl+2−2
DESIGN.md+270−1156
@@ -4,76 +4,26 @@ A minimalist layout language inspired by elm-ui that compiles to static HTML.
44
55## Principles
66
7−- `@` means structure, bare lines mean content
8−- Layout is explicit and compositional — no CSS cascade
9−- Every element declares its own layout role
10−- Output is a self-contained HTML file with embedded CSS (flexbox)
7+- `@` means structure, bare lines mean content.
8+- Layout is explicit and compositional: every element declares its own layout
9+ role, and there is no CSS cascade to reason about.
10+- Output is one self-contained HTML file with embedded CSS (flexbox), and no
11+ JavaScript unless you write some.
1112
12−## Elements
13−
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.
13+Every example block in this file is compiled by the test suite
14+(`tests/docs.rs`), so the examples stay correct.
6615
6716## Syntax
6817
69−### Basic structure
18+### Structure
7019
7120```
72−@element [attributes]
21+@element [attributes] argument
7322 children
7423```
7524
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:
7727
7828```
7929@el [
@@ -85,272 +35,24 @@ Children are indented under their parent. Attributes are optional, comma-separat
8535 Content
8636```
8737
88−### Full example
89−
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.
38+A comma inside `(...)` or `"..."` does not split attributes, so a font stack is
39+written `font "Inter, sans-serif"`.
30440
305−### `@children`
41+### Text
30642
307−A slot inside a function body that expands to the caller's indented children.
308−
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.
43+Any line that doesn't start with `@` or `[` is text. Use `@text` when text needs
44+attributes, and `{...}` to put elements inside a line of text:
34545
34646```
34747@paragraph
34848 This is {@text [bold] important} and this is a {@link https://example.com link}.
49+@text [bold, size 24, color #333] Hello world
34950```
35051
351−## Comments
52+### Comments
35253
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.
35456
35557```
35658-- this is a comment
@@ -358,133 +60,85 @@ Use `{...}` to embed elements inside text lines. Essential for mixed-style parag
35860 -- todo: add nav items
35961```
36062
361−## Implicit `@el`
63+### Shorthands
36264
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.
36467
36568```
36669[padding 20, background white]
36770 Hello
36871
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−```
37972@el [padding 16, background blue, rounded 8] > @link https://example.com
38073 @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
38674```
38775
388−The last element in the chain takes the indented children.
76+### Raw content
38977
390−## `@raw`
391−
392−Triple-quoted block pasted verbatim into output. Use for arbitrary HTML, CSS, or JS.
78+`@raw """ ... """` is pasted into the output verbatim.
39379
39480```
39581@raw """
396−<style>
397− @keyframes spin { to { transform: rotate(360deg); } }
398−</style>
39982<div class="custom-widget"></div>
40083"""
40184```
40285
403−## Units
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.
86+## Elements
41887
419−```
420−@form [method post] /submit
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
127+@form [method post] /subscribe
421128 @label [for email] Email
422− @input [type email, name email, required]
129+ @input [type email, name email, id email, required]
423130 @button [type submit] Send
424−```
425−
426−### `@details` / `@summary`
427−
428−Native disclosure widget. Use `[open]` to expand by default.
429−
430−```
431131@details [open]
432− @summary FAQ Question
433− @text The answer is here.
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]
132+ @summary Question
133+ @text The answer.
480134```
481135
482136### `@in-front` / `@behind`
483137
484−Overlay layers, inspired by elm-ui's `inFront` and `behind`. Children of these
485−directives are absolutely positioned to fill the parent's bounds; the parent
486−automatically becomes a positioning context (`position: relative` and
487−`isolation: isolate`).
138+Overlay layers, like elm-ui's `inFront` and `behind`. Their children fill the
139+parent's bounds; the parent automatically becomes a positioning context
140+(`position: relative; isolation: isolate`). An explicit `position` on the parent
141+wins.
488142
489143```
490144@el [width 200, height 200, background blue]
@@ -495,819 +149,279 @@ automatically becomes a positioning context (`position: relative` and
495149 @el [background red, width fill, height fill]
496150```
497151
498−`@in-front` paints on top of the parent's content (DOM order ensures it
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.
152+## Attributes
516153
517−## `@each` with `@else`
154+### Layout and sizing
518155
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` |
520169
521−```
522−@each $item in $items
523− @text $item
524−@else
525− @text [color #888] No items found.
526−```
170+### Style
527171
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 |
529182
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.
531186
532−```
533−@iframe [width fill, height 400, sandbox] https://example.com
534−```
187+**Units.** A bare number is pixels (`padding 20` is `20px`). Values with a unit,
188+keywords and CSS functions are passed through: `width 50%`, `margin 0 auto`,
189+`max-width min(100%, 800px)`.
535190
536−Attributes: `sandbox`, `allow`, `allowfullscreen`, `referrerpolicy`.
191+### HTML attributes
537192
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.
539196
540−Drawing surface for JavaScript. Use with `@raw` for scripts.
197+### State and media prefixes
541198
542−```
543−@canvas [width 400, height 300, id chart]
544−```
199+Prefix a style attribute to apply it conditionally:
545200
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 |
547210
548−Form output element for displaying calculation results.
211+`@breakpoint name WIDTH` defines a custom responsive prefix.
549212
550213```
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
552218```
553219
554−## Pseudo-elements
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.
220+### Conditional attributes
557221
558222```
559−@el [before:content "→ ", before:color red, before:font-weight bold]
560− Item with arrow prefix
561−
562−@el [after:content " ✓", after:color green]
563− Completed item
223+@let active true
224+@el [background if($active, blue, gray), bold if $active]
225+ Conditionally styled
564226```
565227
566−Content values are automatically quoted unless they are CSS keywords (`none`, `normal`) or CSS functions (`attr()`, `counter()`).
567−
568−## Conditional attribute values
228+## Variables and functions
569229
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:
571231
572232```
573−@let active true
574−@let theme dark
575−
576−@el [background if($active, blue, gray)]
577− Conditionally styled
233+-- A value, used as $primary
234+@let primary #3b82f6
235+-- A computed value (= is optional)
236+@let gap = 8 * 2
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
578246
579−@el [color if($theme == dark, white, black)]
580− Theme-aware text
247+@panel [title Welcome]
248+ Body text goes into @children.
249+@el [$card, background #f9fafb]
250+ Attributes after a bundle override it.
581251```
582252
583−Supports equality (`==`), inequality (`!=`), and truthy checks.
584−
585−## Attributes reference
586−
587−### Layout (set on parent)
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
253+Function arguments are passed as attributes; parameters with `=default` may be
254+omitted. `@component name $params` defines a function whose indented `@style`
255+block is scoped to its output. Inside a function, `@children` is replaced by the caller's children,
256+and `@slot name` by the caller's `@slot name` block (the slot's own children are
257+the default).
629258
630−| Attribute | Effect |
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.
259+A multi-line string uses triple quotes; its indented lines are the value:
710260
711261```
712−@el [padding 16, background #3b82f6, hover:background #2563eb, active:background #1d4ed8, rounded 8, transition all 0.15s ease]
713− @text [color white] Click me
262+@let intro """
263+ First line
264+ Second line
265+ """
266+@text $intro
714267```
715268
716−All style attributes support state prefixes: `hover:color`, `active:rounded`, `focus:border`, etc.
717−
718−### Dark mode
719−
720−Prefix any style attribute with `dark:` to apply it when the user's system is in dark mode.
269+`@let --name value` also emits a CSS custom property. `@theme` declares a group
270+of design tokens at once; each becomes both `$name` and `--name`:
721271
722272```
723−@el [background white, dark:background #1a1a2e, color #333, dark:color #eee]
724− @text Theme-aware content
273+@theme
274+ brand #3b82f6
275+ radius 8
276+@el [background $brand, rounded $radius] Themed
725277```
726278
727−This generates a `@media (prefers-color-scheme: dark)` rule.
728−
729−### Print styles
279+### Filters
730280
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`.
732284
733285```
734−@nav [print:display none]
735− @text Navigation (hidden in print)
286+@let name htmlang
287+@let base #3b82f6
288+@text [color $base|darken:10] $name|uppercase
736289```
737290
738−This generates a `@media print` rule.
291+### Checks
739292
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.
741296
742−| Attribute | Effect |
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 |
297+## Control flow
750298
751−### Identity
299+All control flow runs at compile time.
752300
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
773301```
302+@let items apple, banana, cherry
303+@let count 3
774304
775−| Attribute | Effect |
776−|-------------------------------|---------------------------------|
777−| `popover` | Make element a popover |
778−| `popovertarget ID` | Open popover with given ID |
779−| `popovertargetaction VALUE` | Action (toggle/show/hide) |
780−
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
305+@if $count > 2
306+ @text Many
307+@else if $count == 0
308+ @text None
309+@else
310+ @text Few
792311
793−SVG images can be inlined directly into the HTML output using the `[inline]` attribute:
312+@if not $count
313+ @text Nothing to show
794314
795−```
796−@image [inline, width 24, height 24] icon.svg
797−```
315+@each $item in $items
316+ @text $_index: $item
317+@else
318+ @text The list is empty.
798319
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
800322
801−## CLI
323+@each $label, $url in Home /, About /about
324+ @link $url $label
802325
803−```
804−htmlang init # scaffold a new project
805−htmlang init my-site # scaffold in a new directory
806−htmlang new about-us # create about-us.hl from template
807−htmlang page.hl # compile page.hl → page.html
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
326+@match $count
327+ @case 3
328+ @text Three
329+ @default
330+ @text Other
834331```
835332
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).
837335
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.
839339
840−## Compilation target
340+## Files and data
841341
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 |
843354
844−- Elements map to `<div>`, `<span>`, `<p>`, `<a>`, `<img>` as appropriate
845−- Layout uses flexbox (`display: flex`, `flex-direction`, `gap`, etc.)
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
355+With `@extends`, the layout marks where content goes with `@slot name` (named
356+blocks) and `@children` (everything in the page outside `@slot` blocks):
849357
850−## Template inheritance (`@extends`)
851−
852−A page can inherit a base layout and fill named `@slot` blocks:
853−
854−**layout.hl:**
855358```
359+-- layout.hl
856360@page My Site
857−@column [max-width 800, center-x, padding 40]
361+@column [max-width 800, center-x]
858362 @slot header
859− @text [bold, size 32] Default Header
860− @slot content
861− @slot footer
862− @text [color #888] Default Footer
363+ @text Default header
364+ @children
863365```
864366
865−**about.hl:**
866367```
368+-- page.hl
867369@extends layout.hl
868370@slot header
869− @text [bold, size 32] About Us
870−@slot content
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
371+ @text About us
372+@paragraph This fills @children.
887373```
888374
889−Equivalent to:
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−```
375+## Page and head
909376
910−When `@old-card` is called, the compiler emits: `warning: @old-card is deprecated: Use @card-v2 instead`
911−
912−## Color functions
913−
914−Color manipulation via variable filters:
377+`@page TITLE` produces a full HTML document; without it the output is a fragment.
378+Head content comes from `@lang`, `@favicon`, `@meta NAME VALUE`,
379+`@og KEY VALUE`, `@canonical URL`, `@base URL`, `@manifest NAME` (with
380+indented settings), `@font-face NAME URL`, `@json-ld` (indented JSON) and `@head`
381+(indented raw HTML).
915382
916383```
917−@let primary #3b82f6
918−
919−@el [background $primary|lighten:20] -- 20% lighter
920−@el [background $primary|darken:10] -- 10% darker
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
384+@page My Site
385+@lang en
386+@meta description A small site
387+@og title My Site
964388```
965389
966−## Animate shorthand
390+## CSS
967391
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.
969398
970399```
971400@keyframes fade-in
972401 from [opacity 0]
973402 to [opacity 1]
974−
975−@el [animate fade-in 0.3s ease]
976− Fades in on load
977−```
978−
979−## View transitions
980−
981−The `view-transition-name` attribute enables the View Transitions API for smooth page transitions:
982−
983−```
984−@el [view-transition-name hero]
985− Hero content that transitions between pages
986−```
987−
988−## `:has()` pseudo-selector
989−
990−Style elements based on their children using the `has()` prefix:
991−
992−```
993−@el [padding 20, has(.active):background #dbeafe, has(img):padding 0]
994− Content
995−```
996−
997−Generates CSS `:has()` selectors: `.class:has(.active) { background:#dbeafe; }`
998−
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`.
403+@style
404+ .note { color: gray; }
405+@el [animation fade-in 0.3s ease, class note] Fades in
406+```
407+
408+## Upgrading older files
409+
410+`htmlang upgrade [dir|file]` rewrites removed syntax:
411+
412+| Removed | Use instead |
413+|---|---|
414+| `@fn`, `@define`, `@mixin` | `@let` (function, bundle) |
415+| `@unless COND` | `@if not COND` |
416+| `@for $i in A..B`, `@repeat N` | `@each $i in A..B`, `@each $_ in 1..N` |
417+| `@switch` | `@match` |
418+| `@use "file" names` | `@import file` |
419+| `@with $x as y` | `@let y $x` |
420+| `@layout file` | `@extends file` |
421+| `@scope`, `@starting-style`, `@css-property` | The CSS rule in `@style` |
422+| `@col`, `@p`, `@img`, `@li`, `@btn`, `@ul`, `@divider`, `@opt` | `@column`, `@paragraph`, `@image`, `@item`, `@button`, `@list`, `@hr`, `@option` |
423+| `...$bundle` | `$bundle` |
424+| `animate`, `inset-area` | `animation`, `position-area` |
425+| `\|upper`, `\|lower`, `\|cap`, `\|len` | `\|uppercase`, `\|lowercase`, `\|capitalize`, `\|length` |
426+
427+The compiler reports each removed form with its replacement.
README.md+8−1
@@ -193,6 +193,7 @@ A `@let` with an indented body defines a function. `@children` is where the
193193caller's children go:
194194
195195```
196+@let primary #3b82f6
196197@let button $label
197198 @el [padding 12, background $primary, rounded 8]
198199 @text [color white, bold] $label
@@ -204,7 +205,13 @@ caller's children go:
204205### File includes
205206
206207```
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
208215@include header.hl
209216```
210217
crates/htmlang-core/src/ast.rs+37−2
@@ -20,8 +20,6 @@ pub struct Document {
2020 pub base_url: Option<String>,
2121 pub font_faces: Vec<(String, String)>,
2222 pub json_ld_blocks: Vec<String>,
23− pub scope_blocks: Vec<String>,
24− pub starting_style_blocks: Vec<String>,
2523 pub manifest: Option<ManifestConfig>,
2624 pub preload_hints: Vec<PreloadHint>,
2725 pub nodes: Vec<Node>,
@@ -171,3 +169,40 @@ pub struct Attribute {
171169 pub key: String,
172170 pub value: Option<String>,
173171}
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 {
155155 Some(name)
156156 }
157157
158− fn to_css_formatted(&self, dev: bool, use_layer: bool) -> String {
158+ /// All generated rules, wrapped in `@layer htmlang`.
159+ fn to_css_formatted(&self, dev: bool) -> String {
159160 let mut css = String::new();
160− let inner_indent = if use_layer && dev { " " } else { "" };
161−
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− }
161+ let inner_indent = if dev { " " } else { "" };
162+ css.push_str(if dev { "@layer htmlang {\n" } else { "@layer htmlang{" });
170163
171164 // Non-responsive rules. Base and pseudo rules are merged separately by
172165 // identical body so that e.g. `.a,.b{display:flex;flex-direction:column;}`
@@ -342,15 +335,7 @@ impl StyleCollector {
342335 );
343336 }
344337
345− // Close @layer
346− if use_layer {
347− if dev {
348− css.push_str("}\n");
349− } else {
350− css.push('}');
351− }
352− }
353−
338+ css.push_str(if dev { "}\n" } else { "}" });
354339 css
355340 }
356341}
@@ -1047,7 +1032,7 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
10471032 ""
10481033 };
10491034
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);
10511036
10521037 match &doc.page_title {
10531038 Some(title) => {
@@ -1189,7 +1174,9 @@ fn build_element_css(doc: &Document, styles: &StyleCollector, dev: bool) -> Stri
11891174 }
11901175 }
11911176
1192− let styles_css = styles.to_css_formatted(dev, uses_layer(doc));
1177+ // Generated rules always go in `@layer htmlang`, so unlayered user CSS
1178+ // (`@style`, `@raw`) overrides them regardless of specificity.
1179+ let styles_css = styles.to_css_formatted(dev);
11931180 // Fold literal values declared via `@theme` / `@let --name` back into
11941181 // `var(--name)` references so the generated CSS actually uses the
11951182 // custom properties emitted in `:root`.
@@ -1246,52 +1233,31 @@ fn build_element_css(doc: &Document, styles: &StyleCollector, dev: bool) -> Stri
12461233 }
12471234 }
12481235
1249− let push_block = |css: &mut String, block: &str| {
1236+ // @style blocks (custom CSS)
1237+ for block in &doc.custom_css {
12501238 if dev {
1251− css.push_str(block);
1252− css.push('\n');
1239+ element_css.push_str(block);
1240+ element_css.push('\n');
12531241 } else {
12541242 let minified: String = block.lines().map(|l| l.trim()).collect::<Vec<_>>().join("");
1255− css.push_str(&minified);
1243+ element_css.push_str(&minified);
12561244 }
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 { "}" });
12711245 }
12721246
12731247 element_css
12741248}
12751249
1276−/// Generated class rules go in `@layer htmlang` unless the page has custom
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]`.
1285−fn reset_css(doc: &Document, dev: bool, focus_visible_css: &str, skip_link_css: &str) -> String {
1250+/// Built-in reset rules, in a layer before `htmlang`: unlayered CSS beats
1251+/// every layer, so an unlayered `a{color:inherit}` would override
1252+/// `@link [color red]`.
1253+fn reset_css(dev: bool, focus_visible_css: &str, skip_link_css: &str) -> String {
12861254 let base = if dev {
12871255 "*, *::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"
12881256 } else {
12891257 "*,*::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}"
12901258 };
12911259 let rules = format!("{}{}{}", base, focus_visible_css, skip_link_css);
1292− if !uses_layer(doc) {
1293− rules
1294− } else if dev {
1260+ if dev {
12951261 format!("@layer hl-reset, htmlang;\n@layer hl-reset {{\n{}}}\n", rules)
12961262 } else {
12971263 format!("@layer hl-reset,htmlang;@layer hl-reset{{{}}}", rules)
@@ -1966,38 +1932,8 @@ fn generate_element(
19661932 out.push_str(ctx.nl());
19671933
19681934 // Inline text argument
1969− if matches!(
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
1935+ if renders_argument_as_text(&elem.kind)
1936+ && let Some(text) = &elem.argument
20011937 {
20021938 out.push_str(&html_escape(text));
20031939 }
@@ -3637,12 +3573,6 @@ fn attrs_to_css(
36373573 }
36383574 }
36393575
3640− // Animate shorthand (alias for animation)
3641− "animate" => {
3642− if let Some(v) = val {
3643− push_css(&mut css, "animation", v);
3644− }
3645− }
36463576
36473577 // CSS subgrid
36483578 "grid-template-columns" => {
@@ -3704,12 +3634,6 @@ fn attrs_to_css(
37043634 push_css(&mut css, "position-area", v);
37053635 }
37063636 }
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− }
37133637
37143638 // initial-letter (drop caps)
37153639 "initial-letter" => {
crates/htmlang-core/src/parser.rs+157−649
@@ -68,6 +68,9 @@ struct FnDef {
6868}
6969
7070struct 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,
7174 page_title: Option<String>,
7275 lang: Option<String>,
7376 favicon: Option<String>,
@@ -111,10 +114,6 @@ struct ParseContext {
111114 font_faces: Vec<(String, String)>,
112115 /// @json-ld blocks
113116 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>,
118117 /// @manifest configuration
119118 manifest: Option<crate::ast::ManifestConfig>,
120119 /// Track @import paths for circular dependency detection
@@ -138,6 +137,7 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
138137 let lines = preprocess(input);
139138 let mut parser = Parser { lines, pos: 0 };
140139 let mut ctx = ParseContext {
140+ current_line: 0,
141141 page_title: None,
142142 lang: None,
143143 favicon: None,
@@ -169,8 +169,6 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
169169 base_url: None,
170170 font_faces: Vec::new(),
171171 json_ld_blocks: Vec::new(),
172− scope_blocks: Vec::new(),
173− starting_style_blocks: Vec::new(),
174172 manifest: None,
175173 import_stack: Vec::new(),
176174 translations: HashMap::new(),
@@ -198,8 +196,6 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
198196 base_url: ctx.base_url,
199197 font_faces: ctx.font_faces,
200198 json_ld_blocks: ctx.json_ld_blocks,
201− scope_blocks: ctx.scope_blocks,
202− starting_style_blocks: ctx.starting_style_blocks,
203199 manifest: ctx.manifest,
204200 preload_hints: collect_image_preload_hints(&nodes),
205201 nodes,
@@ -467,6 +463,7 @@ impl Parser {
467463
468464 fn parse_line(&mut self, ctx: &mut ParseContext) -> Result<Option<Vec<Node>>, ParseError> {
469465 let line_num = self.lines[self.pos].line_num;
466+ ctx.current_line = line_num;
470467 let current_indent = self.lines[self.pos].indent;
471468
472469 // Handle raw content
@@ -490,6 +487,18 @@ impl Parser {
490487
491488 // --- Directives ---
492489
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+
493502 if let Some(rest) = content.strip_prefix("@page ") {
494503 ctx.page_title = Some(substitute_vars(rest, &ctx.variables));
495504 return Ok(None);
@@ -721,61 +730,6 @@ impl Parser {
721730 return Ok(None);
722731 }
723732
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− }
779733
780734 // --- @markdown block or file (convert markdown to HTML) ---
781735 if content.trim() == "@markdown" || content.trim().starts_with("@markdown ") {
@@ -882,35 +836,6 @@ impl Parser {
882836 return Ok(None);
883837 }
884838
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− }
914839
915840 // --- @assert directive (compile-time assertions) ---
916841
@@ -1413,45 +1338,6 @@ impl Parser {
14131338 }
14141339 }
14151340
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− }
14551341
14561342 // --- @data (load JSON file into variables) ---
14571343
@@ -1540,111 +1426,6 @@ impl Parser {
15401426 return Ok(None);
15411427 }
15421428
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− }
16481429
16491430 // --- @collection (load multiple JSON files matching a glob pattern) ---
16501431
@@ -1826,37 +1607,6 @@ impl Parser {
18261607 return Ok(Some(nodes));
18271608 }
18281609
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− }
18601610
18611611 if content.trim() == "@else" || content.trim().starts_with("@else if ") {
18621612 return Err(ParseError {
@@ -2071,131 +1821,6 @@ impl Parser {
20711821 return Ok(Some(all_nodes));
20721822 }
20731823
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− }
21991824
22001825 // --- @defer (lazy-load below-fold content via IntersectionObserver) ---
22011826
@@ -2356,121 +1981,6 @@ impl Parser {
23561981 return Ok(Some(nodes));
23571982 }
23581983
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− }
24741984
24751985 // --- @warn / @debug ---
24761986
@@ -2759,8 +2269,11 @@ impl Parser {
27592269 }
27602270 };
27612271
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.
27632275 let mut slot_contents: HashMap<String, Vec<Line>> = HashMap::new();
2276+ let mut default_lines: Vec<Line> = Vec::new();
27642277 while self.pos < self.lines.len() {
27652278 let line_indent = self.lines[self.pos].indent;
27662279 if line_indent < current_indent {
@@ -2782,8 +2295,27 @@ impl Parser {
27822295 continue;
27832296 }
27842297 }
2298+ default_lines.push(self.lines[self.pos].clone());
27852299 self.pos += 1;
27862300 }
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+ };
27872319
27882320 // Parse slot contents into nodes
27892321 let mut slot_nodes: HashMap<String, Vec<Node>> = HashMap::new();
@@ -2824,77 +2356,11 @@ impl Parser {
28242356 ctx.base_path = saved_base;
28252357 ctx.include_stack.pop();
28262358
2827− // Replace @slot placeholders in layout with provided content
2828− let result_nodes = replace_extends_slots(layout_nodes, &slot_nodes);
2359+ // Fill @slot placeholders and @children in the layout
2360+ let result_nodes = replace_children_and_slots(layout_nodes, &default_nodes, &slot_nodes);
28292361 return Ok(Some(result_nodes));
28302362 }
28312363
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− }
28982364
28992365 // --- @component definition (scoped @fn) ---
29002366
@@ -3319,6 +2785,16 @@ fn parse_single_element(
33192785 kind
33202786 };
33212787
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+
33222798 Ok(Element {
33232799 kind,
33242800 attrs,
@@ -3328,16 +2804,30 @@ fn parse_single_element(
33282804 })
33292805}
33302806
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+
33312824const KNOWN_ELEMENTS: &[&str] = &[
33322825 "row",
33332826 "column",
3334− "col",
33352827 "el",
33362828 "text",
33372829 "paragraph",
3338− "p",
33392830 "image",
3340− "img",
33412831 "link",
33422832 "children",
33432833 "input",
@@ -3356,7 +2846,6 @@ const KNOWN_ELEMENTS: &[&str] = &[
33562846 "aside",
33572847 "list",
33582848 "item",
3359− "li",
33602849 "table",
33612850 "thead",
33622851 "tbody",
@@ -3373,14 +2862,11 @@ const KNOWN_ELEMENTS: &[&str] = &[
33732862 "code",
33742863 "pre",
33752864 "hr",
3376− "divider",
33772865 "figure",
33782866 "figcaption",
33792867 "progress",
33802868 "meter",
33812869 "fragment",
3382− "btn",
3383− "ul",
33842870 "dialog",
33852871 "dl",
33862872 "dt",
@@ -3439,12 +2925,10 @@ const KNOWN_DIRECTIVES: &[&str] = &[
34392925 "default",
34402926 "warn",
34412927 "debug",
3442− "unless",
34432928 "og",
34442929 "breakpoint",
34452930 "lang",
34462931 "favicon",
3447− "use",
34482932 "theme",
34492933 "deprecated",
34502934 "extends",
@@ -3453,36 +2937,29 @@ const KNOWN_DIRECTIVES: &[&str] = &[
34532937 "font-face",
34542938 "json-ld",
34552939 "assert",
3456− "for",
34572940 "component",
3458− "switch",
34592941 "log",
34602942 "markdown",
3461− "repeat",
3462− "with",
3463− "layout",
34642943 "collection",
34652944 "manifest",
3466− "scope",
3467− "starting-style",
34682945 "translations",
34692946];
34702947
34712948fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
34722949 match s {
34732950 "row" => Ok(ElementKind::Row),
3474− "column" | "col" => Ok(ElementKind::Column),
2951+ "column" => Ok(ElementKind::Column),
34752952 "el" => Ok(ElementKind::El),
34762953 "text" => Ok(ElementKind::Text),
3477− "paragraph" | "p" => Ok(ElementKind::Paragraph),
3478− "image" | "img" => Ok(ElementKind::Image),
2954+ "paragraph" => Ok(ElementKind::Paragraph),
2955+ "image" => Ok(ElementKind::Image),
34792956 "link" => Ok(ElementKind::Link),
34802957 "children" => Ok(ElementKind::Children),
34812958 "input" => Ok(ElementKind::Input),
3482− "button" | "btn" => Ok(ElementKind::Button),
2959+ "button" => Ok(ElementKind::Button),
34832960 "select" => Ok(ElementKind::Select),
34842961 "textarea" => Ok(ElementKind::Textarea),
3485− "option" | "opt" => Ok(ElementKind::Option),
2962+ "option" => Ok(ElementKind::Option),
34862963 "label" => Ok(ElementKind::Label),
34872964 "slot" => Ok(ElementKind::Slot(String::new())), // slot name filled in by parse_single_element
34882965 "nav" => Ok(ElementKind::Nav),
@@ -3493,7 +2970,7 @@ fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseErro
34932970 "article" => Ok(ElementKind::Article),
34942971 "aside" => Ok(ElementKind::Aside),
34952972 "list" => Ok(ElementKind::List),
3496− "item" | "li" => Ok(ElementKind::ListItem),
2973+ "item" => Ok(ElementKind::ListItem),
34972974 "table" => Ok(ElementKind::Table),
34982975 "thead" => Ok(ElementKind::TableHead),
34992976 "tbody" => Ok(ElementKind::TableBody),
@@ -3509,7 +2986,7 @@ fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseErro
35092986 "cite" => Ok(ElementKind::Cite),
35102987 "code" => Ok(ElementKind::Code),
35112988 "pre" => Ok(ElementKind::Pre),
3512− "hr" | "divider" => Ok(ElementKind::HorizontalRule),
2989+ "hr" => Ok(ElementKind::HorizontalRule),
35132990 "figure" => Ok(ElementKind::Figure),
35142991 "figcaption" => Ok(ElementKind::FigCaption),
35152992 "progress" => Ok(ElementKind::Progress),
@@ -3537,7 +3014,6 @@ fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseErro
35373014 "iframe" => Ok(ElementKind::Iframe),
35383015 "output" => Ok(ElementKind::Output),
35393016 "canvas" => Ok(ElementKind::Canvas),
3540− "ul" => Ok(ElementKind::List),
35413017 "grid" => Ok(ElementKind::Grid),
35423018 "stack" => Ok(ElementKind::Stack),
35433019 "spacer" => Ok(ElementKind::Spacer),
@@ -3555,6 +3031,12 @@ fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseErro
35553031 "in-front" => Ok(ElementKind::InFront),
35563032 "behind" => Ok(ElementKind::Behind),
35573033 _ => {
3034+ if let Some(hint) = removed_syntax_hint(&format!("@{}", s)) {
3035+ return Err(ParseError {
3036+ line: line_num,
3037+ message: hint,
3038+ });
3039+ }
35583040 let all_known: Vec<&str> = KNOWN_ELEMENTS
35593041 .iter()
35603042 .chain(KNOWN_DIRECTIVES.iter())
@@ -3573,6 +3055,56 @@ fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseErro
35733055 }
35743056}
35753057
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+
35763108/// Format include/import stack as a readable chain for error messages.
35773109fn format_include_chain(stack: &[PathBuf]) -> String {
35783110 stack
@@ -4054,7 +3586,6 @@ const KNOWN_ATTRS: &[&str] = &[
40543586 // View transitions
40553587 "view-transition-name",
40563588 // Animate shorthand
4057− "animate",
40583589 // Critical CSS hint
40593590 "critical",
40603591 // CSS subgrid
@@ -4071,7 +3602,6 @@ const KNOWN_ATTRS: &[&str] = &[
40713602 "anchor-name",
40723603 "position-anchor",
40733604 "position-area",
4074− "inset-area",
40753605 // Drop caps
40763606 "initial-letter",
40773607 // Responsive images
@@ -4516,15 +4046,6 @@ fn parse_attr_list(
45164046 continue;
45174047 }
45184048
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−
45284049 // $define reference — expand attribute bundle
45294050 if let Some(name) = part.strip_prefix('$')
45304051 && let Some(define_attrs) = ctx.defines.get(name)
@@ -4764,12 +4285,24 @@ fn parse_text_segments(input: &str, ctx: &mut ParseContext) -> Vec<TextSegment>
47644285 let inner: String = chars[start..i].iter().collect();
47654286 i += 1; // skip }
47664287
4767− if let Ok(elem) = parse_single_element(&inner, 0, ctx) {
4768− segments.push(TextSegment::Inline(elem));
4769− } else {
4770− current_text.push('{');
4771− current_text.push_str(&inner);
4772− current_text.push('}');
4288+ match parse_single_element(&inner, ctx.current_line, ctx) {
4289+ Ok(elem) => segments.push(TextSegment::Inline(elem)),
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+ }
4302+ current_text.push('{');
4303+ current_text.push_str(&inner);
4304+ current_text.push('}');
4305+ }
47734306 }
47744307 } else {
47754308 current_text.push(chars[i]);
@@ -4821,8 +4354,9 @@ fn numeric_range(start: i64, end: i64, step: i64) -> Vec<String> {
48214354}
48224355
48234356fn evaluate_condition(condition: &str) -> bool {
4824− // Ternary expression support: condition ? true_val : false_val
4825− // (just check the condition part, the ternary is handled in substitute_ternary)
4357+ if let Some(rest) = condition.trim_start().strip_prefix("not ") {
4358+ return !evaluate_condition(rest);
4359+ }
48264360
48274361 if let Some((left, right)) = condition.split_once("!=") {
48284362 left.trim() != right.trim()
@@ -5685,32 +5219,6 @@ fn parse_keyframe_line(line: &str) -> Option<String> {
56855219 Some(format!("{}{{{}}}", selector, css))
56865220}
56875221
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−
57145222fn apply_filter(value: &str, filter: &str) -> String {
57155223 if let Some(arg) = filter.strip_prefix("truncate:") {
57165224 if let Ok(n) = arg.parse::<usize>()
@@ -5776,9 +5284,9 @@ fn apply_filter(value: &str, filter: &str) -> String {
57765284 return value.to_string();
57775285 }
57785286 match filter {
5779− "uppercase" | "upper" => value.to_uppercase(),
5780− "lowercase" | "lower" => value.to_lowercase(),
5781− "capitalize" | "cap" => {
5287+ "uppercase" => value.to_uppercase(),
5288+ "lowercase" => value.to_lowercase(),
5289+ "capitalize" => {
57825290 let mut chars = value.chars();
57835291 match chars.next() {
57845292 Some(c) => format!("{}{}", c.to_uppercase(), chars.as_str()),
@@ -5786,7 +5294,7 @@ fn apply_filter(value: &str, filter: &str) -> String {
57865294 }
57875295 }
57885296 "trim" => value.trim().to_string(),
5789− "length" | "len" => value.chars().count().to_string(),
5297+ "length" => value.chars().count().to_string(),
57905298 "reverse" => value.chars().rev().collect(),
57915299 _ => value.to_string(),
57925300 }
examples/demo.hl+6−6
@@ -45,7 +45,7 @@
4545
4646-- Component with default parameter + @children slot
4747@let card $title $icon=◆
48− @article [$card, ...$interactive, animate fade-up 0.3s ease]
48+ @article [$card, $interactive, animation fade-up 0.3s ease]
4949 @row [spacing 10, align-items center]
5050 @text [size 20, color $primary] $icon
5151 @text [bold, size 16, color $ink] $title
@@ -129,7 +129,7 @@
129129 @text $label
130130"""
131131
132− -- Control flow: @each, @else, @repeat, @for, @match, if()
132+ -- Control flow: @each, @else, @match, if()
133133 @titled [title Control flow]
134134 @column [spacing 6]
135135 @each $name, $role in Alice Engineer, Bob Designer, Carol PM
@@ -144,14 +144,14 @@
144144 @else
145145 @text [color $muted, italic, data-state $nothing] (Nothing — @each ... @else fallback)
146146
147− -- @repeat
147+ -- repeat N times
148148 @row [spacing 6]
149− @repeat 6
149+ @each $_ in 1..6
150150 @el [width 18, height 18, rounded 9, background $primary, opacity 0.4]
151151
152− -- @for range
152+ -- numeric range
153153 @row [spacing 4]
154− @for $i in 1..6
154+ @each $i in 1..6
155155 @el [
156156 width 24, height 24, rounded 4,
157157 background $primary, color white,
src/bin/htmlang_lsp/analysis.rs+3−35
@@ -506,31 +506,6 @@ pub(crate) fn code_actions(
506506 }),
507507 ..Default::default()
508508 }));
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− }));
534509 }
535510 }
536511 }
@@ -1069,10 +1044,10 @@ pub(crate) fn semantic_tokens(text: &str, result: &ParseResult) -> Vec<SemanticT
10691044 "@page" | "@let" | "@if" | "@else" | "@each" | "@include" | "@import"
10701045 | "@meta" | "@head" | "@style" | "@keyframes" | "@match" | "@case"
10711046 | "@default" | "@slot" | "@children" | "@warn" | "@debug" | "@lang"
1072− | "@favicon" | "@fragment" | "@unless" | "@og" | "@breakpoint"
1047+ | "@favicon" | "@fragment" | "@og" | "@breakpoint"
10731048 | "@canonical" | "@base" | "@font-face" | "@json-ld" | "@assert" | "@theme"
1074− | "@deprecated" | "@extends" | "@use" | "@data" | "@env" | "@fetch"
1075− | "@svg" | "@css-property" => 0, // keyword
1049+ | "@deprecated" | "@extends" | "@data" | "@env" | "@fetch"
1050+ | "@svg" => 0, // keyword
10761051 _ => {
10771052 // Check if it's a user function call (starts with @ but not a builtin element)
10781053 if is_builtin_element(word) { 0 } else { 2 } // function
@@ -1140,21 +1115,16 @@ fn is_builtin_element(word: &str) -> bool {
11401115 word,
11411116 "@row"
11421117 | "@column"
1143− | "@col"
11441118 | "@el"
11451119 | "@text"
11461120 | "@paragraph"
1147− | "@p"
11481121 | "@image"
1149− | "@img"
11501122 | "@link"
11511123 | "@input"
11521124 | "@button"
1153− | "@btn"
11541125 | "@select"
11551126 | "@textarea"
11561127 | "@option"
1157− | "@opt"
11581128 | "@label"
11591129 | "@raw"
11601130 | "@nav"
@@ -1166,7 +1136,6 @@ fn is_builtin_element(word: &str) -> bool {
11661136 | "@aside"
11671137 | "@list"
11681138 | "@item"
1169− | "@li"
11701139 | "@table"
11711140 | "@thead"
11721141 | "@tbody"
@@ -1183,7 +1152,6 @@ fn is_builtin_element(word: &str) -> bool {
11831152 | "@code"
11841153 | "@pre"
11851154 | "@hr"
1186− | "@divider"
11871155 | "@figure"
11881156 | "@figcaption"
11891157 | "@progress"
src/bin/htmlang_lsp/completion.rs−130
@@ -184,21 +184,16 @@ fn element_completions(range: Range) -> Vec<CompletionItem> {
184184 [
185185 ("@row", "Horizontal layout (flexbox row)"),
186186 ("@column", "Vertical layout (flexbox column)"),
187− ("@col", "Vertical layout (short for @column)"),
188187 ("@el", "Generic container"),
189188 ("@text", "Styled inline text (span)"),
190189 ("@paragraph", "Flowing text block (p)"),
191− ("@p", "Flowing text block (short for @paragraph)"),
192190 ("@image", "Image element"),
193− ("@img", "Image element (short for @image)"),
194191 ("@link", "Anchor/link element"),
195192 ("@input", "Form input element (self-closing)"),
196193 ("@button", "Button element"),
197− ("@btn", "Button element (short for @button)"),
198194 ("@select", "Select dropdown element"),
199195 ("@textarea", "Multi-line text input"),
200196 ("@option", "Option inside @select"),
201− ("@opt", "Option (short for @option)"),
202197 ("@label", "Label element"),
203198 ("@raw", "Raw HTML escape hatch"),
204199 ("@children", "Slot for caller's children (inside component)"),
@@ -214,7 +209,6 @@ fn element_completions(range: Range) -> Vec<CompletionItem> {
214209 // List elements
215210 ("@list", "List container (ul/ol, use [ordered] for ol)"),
216211 ("@item", "List item (li)"),
217− ("@li", "List item (short for @item)"),
218212 // Table elements
219213 ("@table", "Table element"),
220214 ("@thead", "Table head group"),
@@ -234,7 +228,6 @@ fn element_completions(range: Range) -> Vec<CompletionItem> {
234228 ("@code", "Inline code (monospace)"),
235229 ("@pre", "Preformatted text block"),
236230 ("@hr", "Horizontal rule/divider"),
237− ("@divider", "Horizontal rule (alias for @hr)"),
238231 ("@figure", "Figure with optional caption"),
239232 ("@figcaption", "Caption for @figure"),
240233 ("@progress", "Progress bar (value, max attributes)"),
@@ -350,11 +343,6 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
350343 "Set favicon (inlined as base64 data URI)",
351344 "@favicon ",
352345 ),
353− (
354− "@unless",
355− "Inverse conditional (renders when false)",
356− "@unless ",
357− ),
358346 ("@og", "Add Open Graph meta tag", "@og "),
359347 (
360348 "@breakpoint",
@@ -376,11 +364,6 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
376364 "Inherit a layout template and fill @slot blocks",
377365 "@extends ",
378366 ),
379− (
380− "@use",
381− "Selective import of definitions from a file",
382− "@use ",
383− ),
384367 (
385368 "@canonical",
386369 "Set canonical URL for the page",
@@ -406,11 +389,6 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
406389 ("@env", "Access compile-time environment variable", "@env "),
407390 ("@fetch", "Fetch data from URL at compile time", "@fetch "),
408391 ("@svg", "Inline SVG file with optional attributes", "@svg "),
409− (
410− "@css-property",
411− "Define typed CSS custom property (@property)",
412− "@css-property ",
413− ),
414392 ]
415393 .iter()
416394 .map(|(name, detail, insert)| item(name, CompletionItemKind::SNIPPET, detail, insert, range))
@@ -504,11 +482,6 @@ fn snippet_completions(range: Range) -> Vec<CompletionItem> {
504482 "Pattern matching block",
505483 "@match \\$${1:value}\n @case ${2:option1}\n ${3:content}\n @default\n ${4:fallback}",
506484 ),
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− ),
512485 (
513486 "@data JSON load",
514487 "Load variables from a JSON file",
@@ -584,103 +557,6 @@ pub(crate) fn path_completions(uri: &Url, position: Position) -> Vec<CompletionI
584557 items
585558}
586559
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−}
684560
685561/// Walk back from `position` to find the element directive that opened the
686562/// nearest unmatched `[`. Returns the bare name without the leading `@`
@@ -1581,12 +1457,6 @@ fn attr_completions(range: Range, element: Option<&str>) -> Vec<CompletionItem>
15811457 "Assign a view transition name",
15821458 true,
15831459 ),
1584− // Animate shorthand
1585− (
1586− "animate",
1587− "Animation shorthand (name duration [timing])",
1588− true,
1589− ),
15901460 // Has pseudo-selector
15911461 (
15921462 "has(",
src/bin/htmlang_lsp/hover.rs+7−17
@@ -224,7 +224,7 @@ fn hover_builtin(word: &str) -> Option<String> {
224224 "@row" => {
225225 "**@row** \u{2014} Horizontal layout\n\nRenders as `<div>` with `display: flex; flex-direction: row`.\n\nChildren are laid out left-to-right."
226226 }
227− "@column" | "@col" => {
227+ "@column" => {
228228 "**@column** \u{2014} Vertical layout\n\nRenders as `<div>` with `display: flex; flex-direction: column`.\n\nChildren are laid out top-to-bottom."
229229 }
230230 "@el" => {
@@ -233,7 +233,7 @@ fn hover_builtin(word: &str) -> Option<String> {
233233 "@text" => {
234234 "**@text** \u{2014} Inline text\n\nRenders as `<span>`.\n\nUsage: `@text [bold, size 24] Hello world`"
235235 }
236− "@paragraph" | "@p" => {
236+ "@paragraph" => {
237237 "**@paragraph** \u{2014} Text block\n\nRenders as `<p>`.\n\nSupports inline elements: `{@text [bold] word}`"
238238 }
239239 "@h1" => {
@@ -248,7 +248,7 @@ fn hover_builtin(word: &str) -> Option<String> {
248248 "@h4" => "**@h4** \u{2014} Heading level 4\n\nRenders as `<h4>`.",
249249 "@h5" => "**@h5** \u{2014} Heading level 5\n\nRenders as `<h5>`.",
250250 "@h6" => "**@h6** \u{2014} Heading level 6\n\nRenders as `<h6>`.",
251− "@image" | "@img" => {
251+ "@image" => {
252252 "**@image** \u{2014} Image\n\nRenders as `<img>`.\n\nUsage: `@image [width 200] https://example.com/photo.jpg`"
253253 }
254254 "@link" => {
@@ -272,7 +272,7 @@ fn hover_builtin(word: &str) -> Option<String> {
272272 "@input" => {
273273 "**@input** \u{2014} Form input\n\nRenders as self-closing `<input>`.\n\nUsage: `@input [type text, placeholder Name, name user]`"
274274 }
275− "@button" | "@btn" => {
275+ "@button" => {
276276 "**@button** \u{2014} Button\n\nRenders as `<button>`.\n\nUsage: `@button [type submit] Click me`"
277277 }
278278 "@select" => {
@@ -281,7 +281,7 @@ fn hover_builtin(word: &str) -> Option<String> {
281281 "@textarea" => {
282282 "**@textarea** \u{2014} Multi-line text input\n\nRenders as `<textarea>`.\n\nUsage: `@textarea [name bio, rows 4] Default text`"
283283 }
284− "@option" | "@opt" => {
284+ "@option" => {
285285 "**@option** \u{2014} Select option\n\nRenders as `<option>`.\n\nUsage: `@option [value red] Red`"
286286 }
287287 "@label" => {
@@ -337,7 +337,7 @@ fn hover_builtin(word: &str) -> Option<String> {
337337 "@list" => {
338338 "**@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```"
339339 }
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`.",
341341 // Table elements
342342 "@table" => {
343343 "**@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> {
378378 "@pre" => {
379379 "**@pre** \u{2014} Preformatted\n\nRenders as `<pre>` with preserved whitespace and monospace font."
380380 }
381− "@hr" | "@divider" => {
381+ "@hr" => {
382382 "**@hr** \u{2014} Horizontal Rule\n\nRenders as self-closing `<hr>`. Visual divider.\n\nUsage: `@hr [border-top 1 #ccc]`"
383383 }
384384 "@figure" => {
@@ -456,9 +456,6 @@ fn hover_builtin(word: &str) -> Option<String> {
456456 "@favicon" => {
457457 "**@favicon** \u{2014} Favicon\n\nInlines a favicon as a base64 data URI in the `<head>`.\n\nUsage: `@favicon favicon.png`"
458458 }
459− "@unless" => {
460− "**@unless** \u{2014} Inverse conditional\n\nRenders children when the condition is false (opposite of `@if`).\n\nUsage: `@unless $debug`"
461− }
462459 "@og" => {
463460 "**@og** \u{2014} Open Graph meta tag\n\nAdds an Open Graph `<meta>` tag to `<head>`.\n\nUsage: `@og title My Page Title`"
464461 }
@@ -474,9 +471,6 @@ fn hover_builtin(word: &str) -> Option<String> {
474471 "@extends" => {
475472 "**@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```"
476473 }
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− }
480474 "@canonical" => {
481475 "**@canonical** `<url>`\n\nSets the canonical URL for the page. Adds `<link rel=\"canonical\">` to `<head>`.\n\nUsage: `@canonical https://example.com/page`"
482476 }
@@ -980,10 +974,6 @@ fn hover_builtin(word: &str) -> Option<String> {
980974 "view-transition-name" => {
981975 "**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]`"
982976 }
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− }
987977 // Has pseudo-selector
988978 "has(" => {
989979 "**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;
1919use analysis::{
2020 code_actions, find_colors, folding_ranges, get_signature_help, inlay_hints, semantic_tokens,
2121};
22−use completion::{completions, path_completions, use_symbol_completions};
22+use completion::{completions, path_completions};
2323use hover::hover_at;
2424use navigation::{
2525 definition_at, find_references, linked_editing_ranges, prepare_rename_at, rename_at,
@@ -384,20 +384,6 @@ impl LanguageServer for Backend {
384384 return Ok(Some(CompletionResponse::Array(items)));
385385 }
386386 }
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− }
401387 }
402388
403389 let items = completions(text, pos);
@@ -719,8 +705,6 @@ impl LanguageServer for Backend {
719705 ("@include ", rest)
720706 } else if let Some(rest) = trimmed.strip_prefix("@import ") {
721707 ("@import ", rest)
722− } else if let Some(rest) = trimmed.strip_prefix("@use ") {
723− ("@use ", rest)
724708 } else if let Some(rest) = trimmed.strip_prefix("@extends ") {
725709 ("@extends ", rest)
726710 } else {
src/cli.rs+1−4
@@ -38,7 +38,6 @@ Commands:
3838 create-component <name> [params...] Scaffold a new component file
3939 outline <file.hl> Show document structure tree
4040 doctor Check toolchain health
41− migrate [dir|file] Auto-upgrade deprecated syntax
4241 bundle <file|dir> [-o <out>] Compile and inline all assets as data URIs
4342 size <file|dir> Report output sizes (raw, minified, ~gzip)
4443 benchmark <file|dir> Measure compile time and output size
@@ -143,7 +142,7 @@ fn print_bash_completions() {
143142 local cur prev commands
144143 cur="${{COMP_WORDS[COMP_CWORD]}}"
145144 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"
147146
148147 if [ "$COMP_CWORD" -eq 1 ]; then
149148 COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
@@ -202,7 +201,6 @@ _htmlang() {{
202201 'create-component:Scaffold component'
203202 'outline:Show document structure'
204203 'doctor:Check toolchain health'
205− 'migrate:Upgrade deprecated syntax'
206204 'bundle:Inline all assets'
207205 'size:Report output sizes'
208206 'benchmark:Measure compile time'
@@ -276,7 +274,6 @@ fn print_fish_completions() {
276274 ("create-component", "Scaffold component"),
277275 ("outline", "Show document structure"),
278276 ("doctor", "Check toolchain health"),
279− ("migrate", "Upgrade deprecated syntax"),
280277 ("bundle", "Inline all assets as data URIs"),
281278 ("size", "Report output sizes"),
282279 ("benchmark", "Measure compile time"),
src/fmt.rs−2
@@ -93,8 +93,6 @@ fn is_raw_body_header(code: &str) -> bool {
9393 "@markdown",
9494 "@json-ld",
9595 "@head",
96− "@scope",
97− "@starting-style",
9896 ]
9997 .iter()
10098 .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;
44pub mod convert;
55pub mod fmt;
66pub mod serve;
7+pub mod upgrade;
src/main.rs+20−173
@@ -158,25 +158,6 @@ fn json_escape_string(s: &str) -> String {
158158 out
159159}
160160
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−}
180161
181162fn xml_escape(s: &str) -> String {
182163 s.replace('&', "&amp;")
@@ -2412,7 +2393,7 @@ fn main() {
24122393 let mut deps = Vec::new();
24132394 for line in input.lines() {
24142395 let trimmed = line.trim();
2415− for directive in &["@include ", "@import ", "@extends ", "@use "] {
2396+ for directive in &["@include ", "@import ", "@extends "] {
24162397 if let Some(rest) = trimmed.strip_prefix(directive) {
24172398 let dep = rest.split_whitespace().next().unwrap_or("").to_string();
24182399 if !dep.is_empty() {
@@ -2506,7 +2487,6 @@ fn main() {
25062487 && !trimmed.starts_with("@theme")
25072488 && !trimmed.starts_with("@deprecated ")
25082489 && !trimmed.starts_with("@breakpoint ")
2509− && !trimmed.starts_with("@use ")
25102490 && !trimmed.starts_with("@slot ")
25112491 && !trimmed.starts_with("@lang ")
25122492 && !trimmed.starts_with("@favicon ")
@@ -3085,78 +3065,6 @@ compile();
30853065 return;
30863066 }
30873067
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−
31603068 // Handle "bundle" subcommand — inline images/fonts as data URIs
31613069 if args.len() >= 2 && args[1] == "bundle" {
31623070 if args.len() < 3 {
@@ -3528,8 +3436,9 @@ compile();
35283436 return;
35293437 }
35303438
3531− // Handle "upgrade" subcommand — comprehensive version-to-version migration
3532− if args.len() >= 2 && args[1] == "upgrade" {
3439+ // Handle "upgrade" subcommand (and its old name "migrate"): rewrite
3440+ // removed or renamed syntax to its current form.
3441+ if args.len() >= 2 && (args[1] == "upgrade" || args[1] == "migrate") {
35333442 let target = if args.len() >= 3 { &args[2] } else { "." };
35343443 let path = Path::new(target);
35353444 let hl_files = if path.is_dir() {
@@ -3542,90 +3451,40 @@ compile();
35423451 process::exit(1);
35433452 }
35443453 let mut total_changes = 0usize;
3454+ let mut needs_manual = false;
35453455 for file in &hl_files {
35463456 let input = match fs::read_to_string(file) {
35473457 Ok(s) => s,
35483458 Err(_) => continue,
35493459 };
3550− let mut output = String::new();
3551− let mut changes = 0usize;
3552− for line in input.lines() {
3553− let mut migrated = line.to_string();
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');
3460+ let result = htmlang::upgrade::upgrade(&input);
3461+ for (line, message) in &result.manual {
3462+ eprintln!("{}:{}: manual change needed: {}", file.display(), line, message);
3463+ needs_manual = true;
36053464 }
3606− if !input.ends_with('\n') && output.ends_with('\n') {
3607− output.pop();
3608− }
3609− if changes > 0 {
3610− match fs::write(file, &output) {
3465+ if result.changes > 0 {
3466+ match fs::write(file, &result.output) {
36113467 Ok(()) => {
36123468 eprintln!(
36133469 "upgraded {} ({} change{})",
36143470 file.display(),
3615− changes,
3616− if changes == 1 { "" } else { "s" }
3471+ result.changes,
3472+ if result.changes == 1 { "" } else { "s" }
36173473 );
3618− total_changes += changes;
3474+ total_changes += result.changes;
36193475 }
36203476 Err(e) => eprintln!("error: {}: {}", file.display(), e),
36213477 }
36223478 }
36233479 }
3624− if total_changes == 0 {
3480+ if total_changes == 0 && !needs_manual {
36253481 eprintln!("no upgrades needed — all files are up to date");
3626− } else {
3482+ } else if total_changes > 0 {
36273483 eprintln!("\n{} total change(s) applied", total_changes);
36283484 }
3485+ if needs_manual {
3486+ process::exit(1);
3487+ }
36293488 return;
36303489 }
36313490
@@ -3682,7 +3541,7 @@ compile();
36823541 match fs::write(file_path, &template) {
36833542 Ok(()) => {
36843543 eprintln!("created {}", file_name);
3685− eprintln!("import with: @use \"{}\" {}", file_name, comp_name);
3544+ eprintln!("import with: @import {} (then call @{})", file_name, comp_name);
36863545 }
36873546 Err(e) => {
36883547 eprintln!("error: {}: {}", file_name, e);
@@ -4272,18 +4131,6 @@ fn watch_loop(
42724131mod tests {
42734132 use super::*;
42744133
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−
42874134 #[test]
42884135 fn json_escape_handles_control_characters() {
42894136 assert_eq!(
src/playground_default.hl+6−6
@@ -45,7 +45,7 @@
4545
4646-- Component with default parameter + @children slot
4747@let card $title $icon=◆
48− @article [$card, ...$interactive, animate fade-up 0.3s ease]
48+ @article [$card, $interactive, animation fade-up 0.3s ease]
4949 @row [spacing 10, align-items center]
5050 @text [size 20, color $primary] $icon
5151 @text [bold, size 16, color $ink] $title
@@ -129,7 +129,7 @@
129129 @text $label
130130"""
131131
132− -- Control flow: @each, @else, @repeat, @for, @match, if()
132+ -- Control flow: @each, @else, @match, if()
133133 @titled [title Control flow]
134134 @column [spacing 6]
135135 @each $name, $role in Alice Engineer, Bob Designer, Carol PM
@@ -144,14 +144,14 @@
144144 @else
145145 @text [color $muted, italic, data-state $nothing] (Nothing — @each ... @else fallback)
146146
147− -- @repeat
147+ -- repeat N times
148148 @row [spacing 6]
149− @repeat 6
149+ @each $_ in 1..6
150150 @el [width 18, height 18, rounded 9, background $primary, opacity 0.4]
151151
152− -- @for range
152+ -- numeric range
153153 @row [spacing 4]
154− @for $i in 1..6
154+ @each $i in 1..6
155155 @el [
156156 width 24, height 24, rounded 4,
157157 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() {
7777
7878#[test]
7979fn 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");
8181 assert!(out.contains("9223372036854775807"), "{}", out);
8282 let out = compile("@each $i in -9223372036854775807..-9223372036854775808\n @text $i");
8383 assert!(out.contains("-9223372036854775808"), "{}", out);
@@ -119,13 +119,13 @@ fn css_vars_are_not_substituted_into_media_queries() {
119119}
120120
121121#[test]
122−fn partial_output_includes_scope_and_starting_style() {
122+fn partial_output_includes_style_and_keyframes() {
123123 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",
125125 );
126126 let out = codegen::generate_partial(&result.document);
127127 assert!(out.contains(".title"), "{}", out);
128− assert!(out.contains("@starting-style"), "{}", out);
128+ assert!(out.contains("@keyframes k"), "{}", out);
129129}
130130
131131#[test]
@@ -268,7 +268,7 @@ fn variables_used_in_loops_conditions_and_text_are_not_unused() {
268268 for src in [
269269 "@let items a, b\n@each $x in $items\n @text $x",
270270 "@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",
272272 "@let score \"10 - 2\"\n@text Score: $score",
273273 "@let who World\nHello $who",
274274 ] {
@@ -284,3 +284,19 @@ fn variables_used_in_loops_conditions_and_text_are_not_unused() {
284284 );
285285 }
286286}
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() {
731731}
732732
733733#[test]
734−fn item_alias_li() {
735− let output = compile("@page T\n@list\n @li Works");
736− assert!(output.contains("<li"));
737− assert!(output.contains("Works"));
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() {
782+ let output = compile("@page T\n@let divider\n @hr\n@divider");
783+ assert!(output.contains("<hr"), "{}", output);
738784}
739785
740786// ---------------------------------------------------------------------------
@@ -1133,11 +1179,11 @@ fn warning_controls_on_non_media() {
11331179
11341180#[test]
11351181fn 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";
11371183 let formatted = htmlang::fmt::format(input);
11381184 assert_eq!(
11391185 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"
11411187 );
11421188}
11431189
@@ -1242,12 +1288,6 @@ fn hr_renders_self_closing() {
12421288 assert!(output.contains("<hr"));
12431289}
12441290
1245−#[test]
1246−fn divider_alias_for_hr() {
1247− let output = compile("@page T\n@divider");
1248− assert!(output.contains("<hr"));
1249−}
1250−
12511291#[test]
12521292fn figure_figcaption_renders() {
12531293 let output = compile("@page T\n@figure\n @image [alt test] photo.jpg\n @figcaption Caption");
@@ -2042,13 +2082,13 @@ fn media_portrait() {
20422082
20432083#[test]
20442084fn 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");
20462086 assert!(output.contains("Visible"));
20472087}
20482088
20492089#[test]
20502090fn 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");
20522092 assert!(!output.contains("Hidden"));
20532093}
20542094
@@ -3119,16 +3159,16 @@ fn test_carousel_children_snap() {
31193159}
31203160
31213161#[test]
3122−fn test_use_directive() {
3162+fn test_import_missing_file() {
31233163 // We can't test @use with actual files in unit tests easily, but we can verify
31243164 // 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");
31263166 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
31283168 });
31293169 assert!(
31303170 has_use_error,
3131− "@use should report error for missing file, got: {:?}",
3171+ "@import should report error for missing file, got: {:?}",
31323172 result.diagnostics
31333173 );
31343174}
@@ -3397,7 +3437,7 @@ fn view_transition_name_passthrough() {
33973437#[test]
33983438fn animate_generates_animation_css() {
33993439 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",
34013441 );
34023442 assert!(
34033443 output.contains("animation:fade 0.3s ease"),
@@ -3506,7 +3546,7 @@ fn fn_named_slot_default() {
35063546#[test]
35073547fn no_warning_new_attrs_batch6() {
35083548 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]",
35103550 );
35113551 assert!(
35123552 !diags
@@ -3741,7 +3781,7 @@ fn snapshot_mixin_spread() {
37413781
37423782#[test]
37433783fn 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");
37453785 assert!(
37463786 output.contains("padding:20px"),
37473787 "mixin should expand padding: {}",
@@ -3766,7 +3806,7 @@ fn mixin_with_dollar_syntax() {
37663806
37673807#[test]
37683808fn 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");
37703810 assert!(
37713811 output.contains("padding:10px"),
37723812 "mixin should expand: {}",
@@ -3793,7 +3833,7 @@ fn warning_unused_mixin() {
37933833
37943834#[test]
37953835fn 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]");
37973837 assert!(
37983838 !diags
37993839 .iter()
@@ -3935,7 +3975,7 @@ fn round_trip_basic() {
39353975
39363976#[test]
39373977fn 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");
39393979 assert!(html.contains("1"));
39403980 assert!(html.contains("2"));
39413981 assert!(html.contains("3"));
@@ -3943,7 +3983,7 @@ fn for_basic_range() {
39433983
39443984#[test]
39453985fn 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");
39473987 assert!(html.contains("0"));
39483988 assert!(html.contains("5"));
39493989 assert!(html.contains("10"));
@@ -3951,7 +3991,7 @@ fn for_with_step() {
39513991
39523992#[test]
39533993fn 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");
39553995 assert!(html.contains("3"));
39563996 assert!(html.contains("2"));
39573997 assert!(html.contains("1"));
@@ -3959,7 +3999,7 @@ fn for_reverse_range() {
39593999
39604000#[test]
39614001fn 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");
39634003 assert!(html.contains("1"));
39644004 assert!(html.contains("2"));
39654005 assert!(html.contains("3"));
@@ -4018,7 +4058,7 @@ fn component_with_children() {
40184058#[test]
40194059fn switch_matches_case() {
40204060 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",
40224062 );
40234063 assert!(html.contains("Primary"));
40244064 assert!(!html.contains("Danger"));
@@ -4027,7 +4067,7 @@ fn switch_matches_case() {
40274067#[test]
40284068fn switch_falls_to_default() {
40294069 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",
40314071 );
40324072 assert!(!html.contains("Primary"));
40334073 assert!(html.contains("Default"));
@@ -4036,11 +4076,11 @@ fn switch_falls_to_default() {
40364076#[test]
40374077fn switch_with_attrs() {
40384078 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",
40404080 );
40414081 // The @switch should register matched attrs as __switch define
40424082 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",
40444084 );
40454085 assert!(result.document.defines.contains_key("__switch"));
40464086}
@@ -4278,9 +4318,9 @@ fn test_error_each_missing_in() {
42784318
42794319#[test]
42804320fn 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");
42824322 assert!(
4283− diags.iter().any(|d| d.message.contains("@for requires")),
4323+ diags.iter().any(|d| d.message.contains("@each requires")),
42844324 "should report @for syntax error"
42854325 );
42864326}
@@ -4362,7 +4402,7 @@ fn test_children_fallback_content() {
43624402
43634403#[test]
43644404fn 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");
43664406 assert!(
43674407 html.contains("padding:12px"),
43684408 "spread define should apply padding"
@@ -4485,7 +4525,7 @@ fn snapshot_initial_letter() {
44854525
44864526#[test]
44874527fn repeat_directive_basic() {
4488− let output = compile("@repeat 3\n @text hello");
4528+ let output = compile("@each $_ in 1..3\n @text hello");
44894529 // Should contain 3 spans with "hello"
44904530 let count = output.matches("hello").count();
44914531 assert_eq!(count, 3, "expected 3 repetitions, got {}", count);
@@ -4493,7 +4533,7 @@ fn repeat_directive_basic() {
44934533
44944534#[test]
44954535fn 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");
44974537 assert!(
44984538 output.contains("hello"),
44994539 "expected @with to rebind variable"
@@ -4559,16 +4599,16 @@ fn markdown_renders_list() {
45594599
45604600#[test]
45614601fn 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");
45634603 assert!(
4564− output.contains("@scope"),
4604+ output.contains("@scope (.card)"),
45654605 "should generate @scope CSS block"
45664606 );
45674607}
45684608
45694609#[test]
45704610fn 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");
45724612 assert!(
45734613 output.contains("@starting-style"),
45744614 "should generate @starting-style CSS block"
@@ -4727,7 +4767,7 @@ fn parser_multiple_errors() {
47274767
47284768#[test]
47294769fn repeat_with_index() {
4730− let output = compile("@repeat 3\n @text $_index");
4770+ let output = compile("@each $_ in 1..3\n @text $_index");
47314771 assert!(output.contains("0"), "should have index 0");
47324772 assert!(output.contains("1"), "should have index 1");
47334773 assert!(output.contains("2"), "should have index 2");
@@ -4893,7 +4933,7 @@ fn svg_directive_missing_file() {
48934933
48944934#[test]
48954935fn 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";
48974937 let result = htmlang::parser::parse(input);
48984938 assert!(
48994939 result
@@ -4906,7 +4946,7 @@ fn css_property_directive() {
49064946 let html = htmlang::codegen::generate(&result.document);
49074947 assert!(
49084948 html.contains("@property --my-color"),
4909− "@css-property should emit CSS @property rule, got: {}",
4949+ "@property rule should be emitted, got: {}",
49104950 html
49114951 );
49124952 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 @@
66 from [transform translateY(20px), opacity 0]
77 to [transform translateY(0), opacity 1]
88@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]
1010 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]
1212 Slid up
tests/snapshots/css_property.hl+6−4
@@ -1,8 +1,10 @@
11@page CSS Property Test
2−@css-property --accent
3− syntax "<color>"
4− inherits true
5− initial-value #3b82f6
2+@style
3+ @property --accent {
4+ syntax:"<color>";
5+ inherits:true;
6+ initial-value:#3b82f6;
7+ }
68
79@el [background var(--accent), padding 20]
810 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 @@
33@let card-style [padding 20, rounded 8, background #f3f4f6]
44@let interactive [cursor pointer, hover:background #e5e7eb, transition all 0.15s ease]
55
6−@el [...$card-style, ...$interactive]
6+@el [$card-style, $interactive]
77 Hello mixin world
88
99@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
22 @text [bold] Item $_index
tests/snapshots/scope_css.hl+5−3
@@ -1,8 +1,10 @@
11@page Scope Test
22
3−@scope .card
4− .title { font-weight: bold; }
5− .body { color: #333; }
3+@style
4+ @scope (.card) {
5+ .title { font-weight: bold; }
6+ .body { color: #333; }
7+ }
68
79@column [padding 20]
810 @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 @@
11@page Starting Style Test
22
3−@starting-style
4− .fade-in { opacity: 0; }
3+@style
4+ @starting-style {
5+ .fade-in { opacity: 0; }
6+ }
57
68@column [padding 20]
79 @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 @@
22
33@let show false
44
5−@unless $show
5+@if not $show
66 @text This shows because show is false
77
88@let visible true
99
10−@unless $visible
10+@if not $visible
1111 @text This should not appear
tests/snapshots/with_directive.hl+2−2
@@ -1,5 +1,5 @@
11@let user-name Alice
22@let greeting Hello
33
4−@with $user-name as name
5− @text $name says $greeting
4+@let name $user-name
5+@text $name says $greeting