on master help sign in
Document the simplified language and update tooling

- DESIGN.md: style vs key=value HTML attributes, any CSS property as an attribute, the standard library, function calls that behave like elements (forwarded attributes, trailing text, scoped @style), children: and the correct nth:EXPR: prefix, the expression language, @page attributes, and the full upgrade table - README.md: shorter tour; the command table lists the nine commands that remain - CONTRIBUTING.md: where things live now (TAGS, vocab.rs, expr.rs, std.hl) and how to add or remove syntax - tests/docs.rs also fails on removed syntax, space-form HTML attributes and unforwardable attributes - VS Code: snippets and grammar use the current syntax - LSP: drop completions/hovers for removed directives and attributes - upgrade: handle inline {@el [...]} in text, and ignore @let inside """ strings when finding a file's own functions - vocab: size, wrap and hidden are htmlang attributes (they warned as HTML attributes)

commit56ce11625e85460c3ccf0f0bad7870fd26924da2 authoriko <mail@iko.soy> dateFri 25 Sep 2026, 20:50 (2 weeks ago) parent5539000
13 files changed+412−406
CONTRIBUTING.md+16−5 DESIGN.md+130−55 README.md+52−179 crates/htmlang-core/src/vocab.rs+4−4 editors/vscode/snippets/htmlang.json+26−32 editors/vscode/syntaxes/htmlang.tmLanguage.json+113−54 examples/demo.hl+3−3 src/bin/htmlang_lsp/analysis.rs+3−3 src/bin/htmlang_lsp/completion.rs−29 src/bin/htmlang_lsp/hover.rs+1−28 src/upgrade.rs+51−14 tests/docs.rs+3 tests/regressions.rs+10
CONTRIBUTING.md+16−5
@@ -19,11 +19,19 @@ Linux, macOS, and Windows. Your change should pass all three.
1919## Repository layout
2020
2121- `crates/htmlang-core/` — parser, AST, code generator. No I/O lives here.
22+ - `ast.rs` — the element kinds; every plain HTML element is one row in `TAGS`.
23+ - `vocab.rs` — the attribute vocabulary (htmlang attributes, CSS properties,
24+ HTML attributes) and the state/media prefixes.
25+ - `expr.rs` — the expression language for conditions and computed values.
26+ - `std.hl` — the standard library, written in htmlang and loaded before
27+ every file.
2228- `crates/htmlang-wasm/` — thin wrapper exposing `compile` to the web playground.
2329- `src/` — CLI, dev server, formatter, and HTML-to-hl converter.
2430- `src/bin/htmlang_lsp/` — language server binary (`htmlang-lsp`).
2531- `editors/vscode/` — VS Code extension.
2632- `tests/snapshots.rs` — integration / snapshot tests for the compiler.
33+- `tests/regressions.rs` — one test per fixed bug.
34+- `tests/docs.rs` — compiles every example in `DESIGN.md` and `README.md`.
2735- `examples/` — sample `.hl` files used as smoke tests and documentation.
2836
2937## Adding a feature
@@ -32,11 +40,14 @@ Linux, macOS, and Windows. Your change should pass all three.
3240 `tests/snapshots.rs`; prefer integration tests that exercise the full
3341 parser-to-HTML pipeline. Pure parser / codegen helpers can live as unit
3442 tests alongside the code.
35−2. Thread the feature through the parser, then codegen, then the LSP (hover,
36− completions, diagnostics).
37−3. Document it in `DESIGN.md`. If it's user-facing, also update `README.md`.
38−4. If it changes the CLI surface, update the `--help` output and the shell
39− completions.
43+2. Prefer the smallest mechanism: a component belongs in `std.hl`, an HTML
44+ element is a row in `TAGS`, and a CSS property needs no code at all. Only
45+ thread a feature through the parser, codegen and LSP when it needs to be.
46+3. Document it in `DESIGN.md` (the examples there are compiled by the tests).
47+ If it's user-facing, also update `README.md`.
48+4. If it removes or renames syntax, add a rewrite to `src/upgrade.rs` and a
49+ hint to `REMOVED_SYNTAX` in the parser.
50+5. If it changes the CLI surface, update the `--help` output.
4051
4152## Style
4253
DESIGN.md+130−55
@@ -35,18 +35,22 @@ Children are indented under their parent. Attributes are comma-separated inside
3535 Content
3636```
3737
38−A comma inside `(...)` or `"..."` does not split attributes, so a font stack is
39−written `font "Inter, sans-serif"`.
38+An attribute is either a **style**, written `key value` (`padding 20`,
39+`color red`), or an **HTML attribute**, written `key=value` (`id=main`,
40+`type=email`, `aria-label=Close`). Boolean HTML attributes are written bare
41+(`required`, `disabled`, `open`). A comma inside `(...)` or `"..."` does not
42+split attributes, so a font stack is written `font "Inter, sans-serif"`.
4043
4144### Text
4245
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:
46+Any line that doesn't start with `@` or `[` is text. Text after an element's
47+attributes is its content, and `{...}` puts elements inside a line of text:
4548
4649```
4750@paragraph
4851 This is {@text [bold] important} and this is a {@link https://example.com link}.
4952@text [bold, size 24, color #333] Hello world
53+@section [padding 8] Text after the attributes is content too.
5054```
5155
5256### Comments
@@ -96,7 +100,6 @@ name.
96100| `@grid` | div, grid | Grid container (use `grid-cols`) |
97101| `@stack` | div, position relative | Children layered on top of each other |
98102| `@in-front` / `@behind` | div, absolute | Overlay layers filling the parent (see below) |
99−| `@spacer` | div, flex 1 | Pushes siblings apart |
100103| `@text` | span | Styled inline text |
101104| `@paragraph` | p | Flowing text with inline elements |
102105| `@link url` | a | Link; text after the URL becomes its content |
@@ -111,23 +114,20 @@ Semantic containers, all laid out as columns: `@nav`, `@header`, `@footer`,
111114Content: `@list` / `@item` (`@list [ordered]` for `<ol>`), `@dl` / `@dt` /
112115`@dd`, `@table` / `@thead` / `@tbody` / `@tr` / `@th` / `@td`, `@code`, `@pre`,
113116`@hr`, `@mark`, `@kbd`, `@abbr`, `@time`, `@progress`, `@meter`, `@output`,
114−`@canvas`, `@iframe src`, `@video`, `@audio`, `@picture` / `@source`,
115−`@script`, `@breadcrumb`, `@fragment` (children without a wrapper).
117+`@canvas`, `@iframe src`, `@video src`, `@audio src`, `@picture` / `@source`,
118+`@script`, `@fragment` (children without a wrapper).
116119
117120Form controls: `@input`, `@button`, `@select` / `@option`, `@textarea`,
118−`@label`, `@datalist`.
119−
120−Styled components: `@badge`, `@chip`, `@tag`, `@avatar`, `@tooltip`,
121−`@carousel`.
121+`@label`, `@datalist`. `@form URL` sets the form's `action`.
122122
123123```
124124@list [ordered]
125125 @item First
126126 @item Second
127−@form [method post] /subscribe
128− @label [for email] Email
129− @input [type email, name email, id email, required]
130− @button [type submit] Send
127+@form [method=post] /subscribe
128+ @label [for=email] Email
129+ @input [type=email, name=email, id=email, required]
130+ @button [type=submit] Send
131131@details [open]
132132 @summary Question
133133 @text The answer.
@@ -149,6 +149,35 @@ wins.
149149 @el [background red, width fill, height fill]
150150```
151151
152+### Standard library
153+
154+These components are written in htmlang (`std.hl`) and available in every file.
155+Use them like elements; your own `@let` with the same name takes precedence.
156+
157+| Component | Purpose |
158+|---|---|
159+| `@badge` | Small pill for counts and statuses |
160+| `@tag` | Label with slightly rounded corners |
161+| `@chip` | Outlined, fully rounded label |
162+| `@avatar` | Circular frame for an image or initials |
163+| `@spacer` | Takes up the remaining space in a row or column |
164+| `@tooltip [tip TEXT]` | Text that shows `TEXT` when hovered |
165+| `@carousel` | Horizontally scrolling row that snaps to each child |
166+| `@breadcrumb` | Breadcrumb trail of `@item`s |
167+| `$skeleton`, `$no-scrollbar` | Attribute bundles: loading placeholder, hidden scrollbars |
168+
169+```
170+@row [spacing 8, align-items center]
171+ @badge [background #ef4444, color white] 3
172+ @chip Rust
173+ @spacer
174+ @tooltip [tip Opens in a new tab] Help
175+@breadcrumb
176+ @item > @link / Home
177+ @item Docs
178+@el [$skeleton, height 20, width fill]
179+```
180+
152181## Attributes
153182
154183### Layout and sizing
@@ -156,11 +185,10 @@ wins.
156185| Attribute | Effect |
157186|---|---|
158187| `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 |
188+| `padding N` / `padding Y X` / `padding T R B L` | Padding (also `padding-x`, `padding-y`) |
189+| `margin ...` | Margin, same forms as padding (also `margin-x`, `margin-y`) |
161190| `width fill` / `width N` / `width shrink` | Take remaining space, exact size, or fit content |
162191| `height fill` / `height N` / `height shrink` | Same for height |
163−| `min-width`, `max-width`, `min-height`, `max-height` | Size limits |
164192| `center-x`, `center-y` | Center within the parent |
165193| `align-left`, `align-right`, `align-top`, `align-bottom` | Align within the parent |
166194| `wrap` | Let a row wrap |
@@ -171,28 +199,36 @@ wins.
171199
172200| Attribute | Effect |
173201|---|---|
174−| `background COLOR`, `color COLOR` | Colors |
175−| `border N COLOR` (also `border-top` etc.) | Border |
202+| `border N COLOR` (also `border-top` etc.) | Solid border |
176203| `rounded N` | Border radius |
177204| `shadow VALUE` | Box shadow |
178205| `bold`, `italic`, `underline` | Text style |
179206| `size N` | Font size |
180207| `font NAME` | Font family |
181−| `transition VALUE`, `animation VALUE` | Motion |
208+| `truncate`, `line-clamp N` | Cut text off with an ellipsis |
209+| `blur N`, `backdrop-blur N` | Blur filters |
182210
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.
211+**Any standard CSS property** can also be used as an attribute, with the same
212+name and value: `background red`, `opacity 0.5`, `cursor pointer`,
213+`z-index 10`, `margin-top 16`, `grid-template-areas "a b"`, and so on.
186214
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)`.
215+**Units.** For lengths, a bare number is pixels (`padding 20` is `20px`).
216+Values with a unit, keywords and CSS functions are passed through: `width 50%`,
217+`margin 0 auto`, `max-width min(100%, 800px)`.
190218
191219### HTML attributes
192220
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.
221+Write HTML attributes as `key=value`: `id=main`, `class=note`, `href=/about`,
222+`type=email`, `alt=Logo`, `target=_blank`, `aria-label=Close menu`,
223+`data-id=42`. Any name works. Booleans are written bare: `required`,
224+`disabled`, `checked`, `open`, `sandbox`. A style and an HTML attribute may
225+share a name without clashing:
226+
227+```
228+@image [width=800, width 200, alt=A photo] photo.jpg
229+@select [size=4, size 18]
230+ @option One
231+```
196232
197233### State and media prefixes
198234
@@ -201,7 +237,8 @@ Prefix a style attribute to apply it conditionally:
201237| Prefix | Applies |
202238|---|---|
203239| `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 |
240+| `first:`, `last:`, `odd:`, `even:`, `nth:EXPR:` | By position among siblings |
241+| `children:` | To each direct child |
205242| `before:`, `after:` | On the `::before` / `::after` pseudo-element (with `content`) |
206243| `has(SELECTOR):` | When the element contains a match |
207244| `sm:`, `md:`, `lg:`, `xl:`, `2xl:` | From that viewport width up (640–1536px) |
@@ -213,12 +250,18 @@ Prefix a style attribute to apply it conditionally:
213250```
214251@el [padding 16, background #3b82f6, hover:background #2563eb, md:padding 32, dark:background #1e3a8a]
215252 @text [color white] Click me
253+@row [spacing 4, children:flex 1, nth:2n:background #f3f4f6]
254+ @el A
255+ @el B
216256@el [before:content "→ ", before:color red]
217257 Item with an arrow
218258```
219259
220260### Conditional attributes
221261
262+`key if CONDITION` includes an attribute only when the condition holds;
263+`if(CONDITION, A, B)` picks a value.
264+
222265```
223266@let active true
224267@el [background if($active, blue, gray), bold if $active]
@@ -232,9 +275,9 @@ Prefix a style attribute to apply it conditionally:
232275```
233276-- A value, used as $primary
234277@let primary #3b82f6
235−-- A computed value (= is optional)
278+-- A computed value: `=` makes it an expression
236279@let gap = 8 * 2
237−-- A quoted string: interpolated, never computed
280+-- A quoted string, with $variables interpolated
238281@let greeting "Hello $primary"
239282-- An attribute bundle, used as [$card]
240283@let card [padding 20, background white, rounded 8]
@@ -250,11 +293,28 @@ Prefix a style attribute to apply it conditionally:
250293 Attributes after a bundle override it.
251294```
252295
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).
296+A function is called like an element:
297+
298+- Its parameters are passed as attributes; parameters with `=default` may be
299+ omitted.
300+- Other attributes style its root element, so `@panel [title Hi, padding 40]`
301+ works like styling a built-in element.
302+- Text after the attributes and indented children replace `@children`, and a
303+ caller's `@slot name` block replaces `@slot name` (the slot's own children are
304+ the default).
305+- An `@style` block at the top of the body is scoped to the function: its rules
306+ only apply inside the function's output.
307+
308+```
309+@let note $kind=info
310+ @style
311+ .title { font-weight: bold; }
312+ @el [padding 12, rounded 6, background #eff6ff]
313+ @text [class=title] $kind
314+ @children
315+
316+@note [kind Tip, padding 20] Scoped styles and forwarded attributes.
317+```
258318
259319A multi-line string uses triple quotes; its indented lines are the value:
260320
@@ -291,8 +351,23 @@ and for colors `lighten:N`, `darken:N`, `alpha:N`, `mix:COLOR:N`.
291351### Checks
292352
293353`@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.
354+warning. `@deprecated MESSAGE` before a function warns every caller.
355+
356+## Expressions
357+
358+Conditions and computed values (`@let x = ...`) are expressions:
359+
360+| | |
361+|---|---|
362+| Values | numbers, `"strings"` (with `$var` interpolation), `$variables` (with filters), `true`, `false`, and bare words (`dark`, `#fff`) as strings |
363+| Arithmetic | `+ - * / %` with the usual precedence, unary `-`, `( )` |
364+| Text | `~` joins two values: `$first ~ " " ~ $last` |
365+| Comparison | `== != < > <= >=` (numeric when both sides are numbers), `contains`, `starts-with`, `ends-with` |
366+| Logic | `and`, `or`, `not`; empty, `false` and `0` are false |
367+| Choice | `if(CONDITION, A, B)` |
368+
369+Variables are looked up while evaluating, so a value containing `==` or spaces
370+is still one value. An invalid expression is a compile error.
296371
297372## Control flow
298373
@@ -302,16 +377,13 @@ All control flow runs at compile time.
302377@let items apple, banana, cherry
303378@let count 3
304379
305−@if $count > 2
380+@if $count > 2 and not $hidden
306381 @text Many
307382@else if $count == 0
308383 @text None
309384@else
310385 @text Few
311386
312−@if not $count
313− @text Nothing to show
314−
315387@each $item in $items
316388 @text $_index: $item
317389@else
@@ -330,12 +402,7 @@ All control flow runs at compile time.
330402 @text Other
331403```
332404
333−`@defer` keeps its children hidden until they scroll near the viewport (they
334−are shown immediately when JavaScript is off).
335−
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.
405+A range counts down when its start is greater than its end.
339406
340407## Files and data
341408
@@ -401,16 +468,17 @@ adds a meta tag (`og:` names become Open Graph `property` tags), and
401468 to [opacity 1]
402469@style
403470 .note { color: gray; }
404−@el [animation fade-in 0.3s ease, class note] Fades in
471+@el [animation fade-in 0.3s ease, class=note] Fades in
405472```
406473
407474## Upgrading older files
408475
409−`htmlang upgrade [dir|file]` rewrites removed syntax:
476+`htmlang upgrade [dir|file]` rewrites removed syntax; the compiler reports each
477+removed form with its replacement.
410478
411479| Removed | Use instead |
412480|---|---|
413−| `@fn`, `@define`, `@mixin` | `@let` (function, bundle) |
481+| `@fn`, `@define`, `@mixin`, `@component` | `@let` (function, bundle; `@style` in a function body is scoped) |
414482| `@unless COND` | `@if not COND` |
415483| `@for $i in A..B`, `@repeat N` | `@each $i in A..B`, `@each $_ in 1..N` |
416484| `@switch` | `@match` |
@@ -418,9 +486,16 @@ adds a meta tag (`og:` names become Open Graph `property` tags), and
418486| `@with $x as y` | `@let y $x` |
419487| `@layout file` | `@extends file` |
420488| `@scope`, `@starting-style`, `@css-property` | The CSS rule in `@style` |
489+| `@lang`, `@favicon`, `@canonical`, `@base` | `@page [lang ..., favicon ..., canonical ..., base ...] Title` |
490+| `@og KEY VALUE` | `@meta og:KEY VALUE` |
491+| `@debug`, `@log` | `@warn` |
492+| `@defer` | Its content, directly |
421493| `@col`, `@p`, `@img`, `@li`, `@btn`, `@ul`, `@divider`, `@opt` | `@column`, `@paragraph`, `@image`, `@item`, `@button`, `@list`, `@hr`, `@option` |
494+| `type email`, `id main` (HTML attributes written as styles) | `type=email`, `id=main` |
495+| `skeleton`, `no-scrollbar`, `gradient A B` | `$skeleton`, `$no-scrollbar`, `background linear-gradient(A, B)` |
496+| `@tooltip TEXT` | `@tooltip [tip TEXT] TEXT` |
497+| `@let x $a + 1` (computed without `=`) | `@let x = $a + 1` |
498+| `COND ? A : B` | `if(COND, A, B)` |
422499| `...$bundle` | `$bundle` |
423500| `animate`, `inset-area` | `animation`, `position-area` |
424501| `\|upper`, `\|lower`, `\|cap`, `\|len` | `\|uppercase`, `\|lowercase`, `\|capitalize`, `\|length` |
425−
426−The compiler reports each removed form with its replacement.
README.md+52−179
@@ -28,7 +28,8 @@ A minimalist layout language inspired by [elm-ui](https://package.elm-lang.org/p
2828 Compiles to a single HTML file
2929```
3030
31−Compiles to a self-contained `.html` file with flexbox layout and generated CSS classes. No JavaScript, no external dependencies.
31+This compiles to one self-contained `.html` file with flexbox layout and
32+generated CSS classes. No JavaScript, no external dependencies.
3233
3334## Install
3435
@@ -38,215 +39,87 @@ cargo install --path .
3839
3940## Usage
4041
41−Everyday workflow:
42−
4342```
44−htmlang init # scaffold a new project
4543htmlang page.hl # compile page.hl -> page.html
46−htmlang --watch page.hl # recompile on change
44+htmlang page.hl -o out.html # choose the output file
45+htmlang -w page.hl # recompile on change
4746htmlang serve . # dev server with live reload
48−htmlang serve --https --cert cert.pem --key key.pem .
49−htmlang fmt page.hl # format a file in place
50−htmlang check page.hl # parse only, report diagnostics
51−```
52−
53−More commands (run with `--help` for flags):
54−
55−| Command | Purpose |
56−|--------------------|------------------------------------------------|
57−| `build [dir]` | Compile every `.hl` under a directory |
58−| `watch [path]` | Watch mode without a server |
59−| `serve [path]` | Dev server with live reload (`--https` ready) |
60−| `convert file.html`| Convert HTML back to htmlang |
61−| `new name` | Scaffold a new page from a template |
62−| `components` | List function (`@let` with body) definitions |
63−| `deps file.hl` | Print the include / import graph |
64−| `dead-code [dir]` | Report unused functions, variables and bundles |
65−| `feed [dir]` | Generate an Atom feed from `@article` pages |
66−| `sitemap [dir]` | Generate `sitemap.xml` |
67−| `lint [path]` | Run warnings-only checks, optional JSON output |
68−| `stats file.hl` | Compile-time statistics |
69−| `preview file.hl` | Open a one-off preview |
70−| `diff a.hl b.hl` | Diff two files at the AST level |
71−| `export file.hl` | Export to plain HTML (`--format pdf` planned) |
72−| `repl` | Interactive REPL for expressions |
73−| `deploy` | Preset-driven deploy helper |
74−| `playground` | Write a self-contained playground HTML |
75−| `clean [dir]` | Remove generated `.html` files |
76−| `upgrade` | Apply mechanical migrations to out-of-date `.hl` |
77−| `outline file.hl` | Print the document tree |
78−| `explain CODE` | Longer explanation of a diagnostic code |
79−| `lsp` | Launch the language server (stdio) |
80−| `benchmark [dir]` | Measure parse / codegen timing |
81−
82−## Editor support
83−
84−A VS Code extension with syntax highlighting and LSP integration is available in [`editors/vscode`](editors/vscode). The language server (`htmlang-lsp`) provides diagnostics, completions, and hover documentation.
85−
86−## Language overview
87−
88−### Elements
89−
90−| Element | Purpose |
91−|--------------|------------------------------|
92−| `@row` | Horizontal flex layout |
93−| `@column` | Vertical flex layout |
94−| `@el` | Generic container |
95−| `@text` | Styled inline text |
96−| `@paragraph` | Flowing text with inline elements |
97−| `@image` | Image |
98−| `@link` | Anchor |
99−| `@raw` | Verbatim HTML escape hatch |
100−| `@form` | Form container |
101−| `@details` | Disclosure widget |
102−| `@summary` | Summary for `@details` |
103−| `@blockquote`| Block quotation |
104−| `@cite` | Citation reference |
105−| `@code` | Inline code (monospace) |
106−| `@pre` | Preformatted text |
107−| `@hr` | Horizontal rule / divider |
108−| `@figure` | Figure with caption |
109−| `@figcaption`| Caption for `@figure` |
110−| `@progress` | Progress bar |
111−| `@meter` | Meter/gauge |
112−| `@in-front` | Overlay layer on top of parent |
113−| `@behind` | Layer behind parent content |
114−
115−### Layout attributes
116−
117−```
118−-- gap between children
119−@row [spacing 20]
120−-- separate horizontal/vertical gaps
121−@row [gap-x 10, gap-y 20]
122−-- uniform padding (also padding-x, padding-y)
123−@el [padding 20]
124−-- take remaining space (also width 200, width shrink)
125−@el [width fill]
126−-- center horizontally (also align-left, align-right)
127−@el [center-x]
128−-- overflow behavior (hidden, scroll, auto)
129−@el [overflow hidden]
13047```
13148
132−Comments start with `--` and run to the end of the line; they must be on a
133−line of their own.
49+| Command | Purpose |
50+|---|---|
51+| `build <dir> [-o out] [--minify] [--compat] [--strict]` | Compile every `.hl` file under a directory |
52+| `serve [dir\|file] [-p PORT] [--open] [--https --cert C --key K]` | Dev server with live reload |
53+| `watch [dir\|file] [-o out]` | Recompile on change, without a server |
54+| `check <file\|dir> [--format json]` | Report diagnostics without writing output |
55+| `lint <file\|dir> [--format json]` | Stricter checks (accessibility, nesting) |
56+| `fmt <file.hl>` | Format a file in place |
57+| `convert <file.html>` | Convert HTML to htmlang (prints to stdout) |
58+| `upgrade [dir\|file]` | Rewrite syntax from older versions of the language |
59+| `lsp` | Run the language server over stdio |
13460
135−### Style attributes
61+Compiling directly also takes `--dev`, `--compat`, `--strict`, `--partial`,
62+`--format json`, `-s` / `--serve`, `-p` / `--port` and `--open`.
13663
137−```
138−@el [background #3b82f6, color white, rounded 8, border 1 #e5e7eb]
139−@text [bold, italic, underline, size 18, font Inter]
140−@el [opacity 0.5, cursor pointer, transition all 0.15s ease]
141−@el [shadow 0 2px 4px rgba(0,0,0,0.1)]
142−@paragraph [text-align center, line-height 1.5]
143−```
144−
145−### Positioning
146−
147−```
148−@el [position relative]
149− @el [position absolute, z-index 10]
150− Overlay content
151−```
64+## Editor support
15265
153−For overlays, prefer `@in-front` and `@behind` — they make the parent a
154−positioning context and stretch to its bounds automatically:
66+A VS Code extension with syntax highlighting and LSP integration is in
67+[`editors/vscode`](editors/vscode). The language server (`htmlang-lsp`) provides
68+diagnostics, completions, hover documentation, go to definition, rename and
69+formatting.
15570
156−```
157−@el [width 200, height 200, background blue]
158− @text [color white] Main content
159− @in-front
160− @text [color yellow] Top layer
161− @behind
162− @el [background red, width fill, height fill]
163−```
71+## Language tour
16472
165−### Pseudo-states and media prefixes
73+Elements are laid out with `@row`, `@column` and `@el`; `@text`, `@paragraph`,
74+`@link` and `@image` hold content, and every other HTML element is available by
75+name (`@nav`, `@section`, `@table`, `@input`, ...).
16676
167−Prefix any style attribute with `hover:`, `active:`, or `focus:`:
77+Attributes are styles, written `key value`, or HTML attributes, written
78+`key=value`:
16879
16980```
170−@el [background #3b82f6, hover:background #2563eb, active:background #1d4ed8]
81+-- gap between children, padding, and an HTML id
82+@row [spacing 20, padding 16, id=toolbar]
83+ -- take the remaining space; center the text
84+ @el [width fill, text-align center] Search
85+ @input [type=search, placeholder=Find..., width 240]
17186```
17287
173−Use `dark:` for dark mode and `print:` for print styles:
88+Any standard CSS property works as a style (`opacity 0.5`, `margin-top 16`).
89+Prefixes apply a style conditionally:
17490
17591```
176−@el [background white, dark:background #1a1a2e, print:display none]
92+@el [background #3b82f6, hover:background #2563eb, md:padding 32, dark:background #1e3a8a]
93+ @text [color white] Click me
17794```
17895
179−### Variables and attribute bundles
96+`@let` defines values, computed values, attribute bundles and functions:
18097
18198```
182−-- simple variable, used as $primary
18399@let primary #3b82f6
184−-- quoted string interpolation
185−@let greeting "Hello $name"
186−-- attribute bundle, used as [$card]
100+@let gap = 8 * 2
187101@let card [padding 20, rounded 8]
188−```
189−
190−### Functions
191−
192−A `@let` with an indented body defines a function. `@children` is where the
193−caller's children go:
194−
195−```
196−@let primary #3b82f6
197102@let button $label
198− @el [padding 12, background $primary, rounded 8]
103+ @el [$card, background $primary]
199104 @text [color white, bold] $label
200105 @children
201106
202−@button [label Click me]
203−```
204−
205−### File includes
206−
207−```
208−-- header.hl
209−@nav [spacing 10]
210− @link / Home
211−```
212−
213−```
214−-- page.hl: insert header.hl here
215−@include header.hl
107+@button [label Click me, padding 12]
216108```
217109
218−### Multi-line attributes
110+Functions are used like elements: extra attributes (here `padding 12`) style
111+their root element. A small standard library (`@badge`, `@chip`, `@tooltip`,
112+`@carousel`, `@breadcrumb`, ...) is written the same way.
219113
220−Attribute lists can span multiple lines:
114+Control flow runs at compile time:
221115
222116```
223−@el [
224− padding 20,
225− background white,
226− rounded 8,
227− shadow 0 2px 4px rgba(0,0,0,0.1)
228−]
229− Content here
117+@let items Home, About, Contact
118+@row [spacing 8]
119+ @each $item in $items
120+ @if $item != About
121+ @link /$item|lowercase $item
230122```
231123
232−### Other features
233−
234−- `--` comments
235−- `{@text [bold] inline}` elements in text
236−- `@el [attrs] > @link url` single-child chaining
237−- `[padding 20]` bare attributes as implicit `@el`
238−- `@raw """..."""` for embedding arbitrary HTML/CSS/JS
239−- `@image [inline] logo.svg` inlines SVG content
240−- `@each $name, $url in Home /, About /about` destructuring
241−- `@let large = $base * 2` computed variables
242−- `[grid-template-areas "header main", grid-area header]` named grid areas
243−- `[animate fade-in 0.3s ease]` animation shorthand
244−- `[view-transition-name hero]` View Transitions API
245−- `[has(.active):background blue]` `:has()` pseudo-selector
246−- `@slot header` / `@slot content` named slots in functions
247−- `@theme` design tokens as runtime CSS custom properties
248−- VS Code snippets for common patterns
249−
250−## Documentation
251−
252−See [DESIGN.md](DESIGN.md) for the full language specification.
124+See [DESIGN.md](DESIGN.md) for the full language: files and data (`@include`,
125+`@import`, `@extends`, `@data`), page metadata, filters, expressions and CSS.
crates/htmlang-core/src/vocab.rs+4−4
@@ -11,10 +11,10 @@
1111/// HTML attribute).
1212pub const HTMLANG_ATTRIBUTES: &[&str] = &[
1313 "align-bottom", "align-left", "align-right", "align-top", "backdrop-blur", "blur", "bold",
14− "center-x", "center-y", "col-span", "critical", "gap-x", "gap-y", "grid-cols",
15− "grid-rows", "inline", "italic", "margin-x", "margin-y", "ordered",
16− "padding-x", "padding-y", "responsive", "rounded", "row-span", "shadow",
17− "spacing", "truncate", "underline",
14+ "center-x", "center-y", "col-span", "critical", "gap-x", "gap-y", "grid-cols", "grid-rows",
15+ "hidden", "inline", "italic", "margin-x", "margin-y", "ordered", "padding-x", "padding-y",
16+ "responsive", "rounded", "row-span", "shadow", "size", "spacing", "truncate", "underline",
17+ "wrap",
1818];
1919
2020/// Standard CSS properties, sorted. Any of these can be written as an
editors/vscode/snippets/htmlang.json+26−32
@@ -11,9 +11,9 @@
1111 "description": "Page boilerplate with title, variables, and layout"
1212 },
1313 "Function component": {
14− "prefix": "@fn",
14+ "prefix": "@let-fn",
1515 "body": [
16− "@fn ${1:component} \\$${2:title}",
16+ "@let ${1:component} \\$${2:title}",
1717 " @el [${3:padding 20, background white, rounded 8}]",
1818 " @text [bold] \\$$2",
1919 " @children",
@@ -24,9 +24,9 @@
2424 "description": "Reusable function component with @children slot"
2525 },
2626 "Function with named slots": {
27− "prefix": "@fn-slots",
27+ "prefix": "@let-slots",
2828 "body": [
29− "@fn ${1:layout} \\$${2:title}",
29+ "@let ${1:layout} \\$${2:title}",
3030 " @column [${3:max-width 800, center-x, padding 40}]",
3131 " @slot ${4:header}",
3232 " @text [bold, size 32] \\$$2",
@@ -124,10 +124,10 @@
124124 "Form with inputs": {
125125 "prefix": "@form",
126126 "body": [
127− "@form [method ${1:post}, spacing ${2:16}] ${3:/submit}",
128− " @label [for ${4:email}] ${5:Email}",
129− " @input [type ${6:email}, name $4, required, padding 8, border 1 #d1d5db, rounded 4]",
130− " @button [type submit, padding 10, background ${7:\\$primary}, color white, rounded 4, bold]",
127+ "@form [method=${1:post}, spacing ${2:16}] ${3:/submit}",
128+ " @label [for=${4:email}] ${5:Email}",
129+ " @input [type=${6:email}, name=$4, id=$4, required, padding 8, border 1 #d1d5db, rounded 4]",
130+ " @button [type=submit, padding 10, background ${7:\\$primary}, color white, rounded 4, bold]",
131131 " ${8:Submit}",
132132 " $0"
133133 ],
@@ -177,7 +177,7 @@
177177 " from [opacity 0]",
178178 " to [opacity 1]",
179179 "",
180− "@el [animate $1 ${2:0.3s} ${3:ease}]",
180+ "@el [animation $1 ${2:0.3s} ${3:ease}]",
181181 " $0"
182182 ],
183183 "description": "Keyframe animation with element usage"
@@ -202,7 +202,7 @@
202202 "Script (external)": {
203203 "prefix": "@script-src",
204204 "body": [
205− "@script [src ${1:app.js}, ${2:defer}]",
205+ "@script [src=${1:app.js}, ${2:defer}]",
206206 "$0"
207207 ],
208208 "description": "External script tag with src attribute"
@@ -227,10 +227,10 @@
227227 "Breadcrumb navigation": {
228228 "prefix": "@breadcrumb",
229229 "body": [
230− "@breadcrumb [spacing ${1:10}]",
231− " @link ${2:/} ${3:Home}",
232− " @link ${4:/docs} ${5:Docs}",
233− " @text ${6:Current Page}",
230+ "@breadcrumb",
231+ " @item > @link ${1:/} ${2:Home}",
232+ " @item > @link ${3:/docs} ${4:Docs}",
233+ " @item ${5:Current Page}",
234234 " $0"
235235 ],
236236 "description": "Breadcrumb navigation with links"
@@ -249,28 +249,12 @@
249249 "prefix": "@search",
250250 "body": [
251251 "@search",
252− " @input [type search, placeholder ${1:Search...}]",
253− " @button [type submit] ${2:Go}",
252+ " @input [type=search, placeholder=${1:Search...}]",
253+ " @button [type=submit] ${2:Go}",
254254 " $0"
255255 ],
256256 "description": "Search form with input and submit button"
257257 },
258− "Canonical URL": {
259− "prefix": "@canonical",
260− "body": [
261− "@canonical ${1:https://example.com/page}",
262− "$0"
263− ],
264− "description": "Canonical URL for the page"
265− },
266− "Base URL": {
267− "prefix": "@base",
268− "body": [
269− "@base ${1:https://example.com/}",
270− "$0"
271− ],
272− "description": "Base URL for relative links"
273− },
274258 "Font face": {
275259 "prefix": "@font-face",
276260 "body": [
@@ -291,5 +275,15 @@
291275 "$0"
292276 ],
293277 "description": "JSON-LD structured data block"
278+ },
279+ "Page metadata": {
280+ "prefix": "@page-meta",
281+ "body": [
282+ "@page [lang ${1:en}, canonical ${2:https://example.com/page}, favicon ${3:/favicon.png}] ${4:My Page}",
283+ "@meta description ${5:Page description}",
284+ "@meta og:title $4",
285+ "$0"
286+ ],
287+ "description": "Page with language, canonical URL, favicon and meta tags"
294288 }
295289}
editors/vscode/syntaxes/htmlang.tmLanguage.json+113−54
@@ -3,20 +3,42 @@
33 "name": "htmlang",
44 "scopeName": "source.htmlang",
55 "patterns": [
6− { "include": "#comment" },
7− { "include": "#raw-block" },
8− { "include": "#page-directive" },
9− { "include": "#let-definition" },
10− { "include": "#define-definition" },
11− { "include": "#fn-definition" },
12− { "include": "#include-directive" },
13− { "include": "#element" },
14− { "include": "#function-call" },
15− { "include": "#variable" },
16− { "include": "#attributes" },
17− { "include": "#inline-element" },
18− { "include": "#color" },
19− { "include": "#number" }
6+ {
7+ "include": "#comment"
8+ },
9+ {
10+ "include": "#raw-block"
11+ },
12+ {
13+ "include": "#page-directive"
14+ },
15+ {
16+ "include": "#let-definition"
17+ },
18+ {
19+ "include": "#include-directive"
20+ },
21+ {
22+ "include": "#element"
23+ },
24+ {
25+ "include": "#function-call"
26+ },
27+ {
28+ "include": "#variable"
29+ },
30+ {
31+ "include": "#attributes"
32+ },
33+ {
34+ "include": "#inline-element"
35+ },
36+ {
37+ "include": "#color"
38+ },
39+ {
40+ "include": "#number"
41+ }
2042 ],
2143 "repository": {
2244 "comment": {
@@ -28,52 +50,56 @@
2850 "begin": "(@raw)\\s+(\"\"\")",
2951 "end": "(\"\"\")",
3052 "beginCaptures": {
31− "1": { "name": "entity.name.tag.htmlang" },
32− "2": { "name": "punctuation.definition.string.begin.htmlang" }
53+ "1": {
54+ "name": "entity.name.tag.htmlang"
55+ },
56+ "2": {
57+ "name": "punctuation.definition.string.begin.htmlang"
58+ }
3359 },
3460 "endCaptures": {
35− "1": { "name": "punctuation.definition.string.end.htmlang" }
61+ "1": {
62+ "name": "punctuation.definition.string.end.htmlang"
63+ }
3664 },
3765 "contentName": "string.unquoted.raw.htmlang"
3866 },
3967 "page-directive": {
4068 "match": "(@page)\\s+(.*)",
4169 "captures": {
42− "1": { "name": "keyword.control.directive.htmlang" },
43− "2": { "name": "string.unquoted.page-title.htmlang" }
70+ "1": {
71+ "name": "keyword.control.directive.htmlang"
72+ },
73+ "2": {
74+ "name": "string.unquoted.page-title.htmlang"
75+ }
4476 }
4577 },
4678 "let-definition": {
4779 "match": "(@let)\\s+([a-zA-Z_][a-zA-Z0-9_-]*)",
4880 "captures": {
49− "1": { "name": "keyword.control.directive.htmlang" },
50− "2": { "name": "variable.other.htmlang" }
51− }
52− },
53− "define-definition": {
54− "match": "(@define)\\s+([a-zA-Z_][a-zA-Z0-9_-]*)",
55− "captures": {
56− "1": { "name": "keyword.control.directive.htmlang" },
57− "2": { "name": "variable.other.constant.htmlang" }
58− }
59− },
60− "fn-definition": {
61− "match": "(@fn)\\s+([a-zA-Z_][a-zA-Z0-9_-]*)",
62− "captures": {
63− "1": { "name": "keyword.control.directive.htmlang" },
64− "2": { "name": "entity.name.function.definition.htmlang" }
81+ "1": {
82+ "name": "keyword.control.directive.htmlang"
83+ },
84+ "2": {
85+ "name": "variable.other.htmlang"
86+ }
6587 }
6688 },
6789 "include-directive": {
68− "match": "(@(?:include|import|extends|canonical|base|font-face))\\s+(.*)",
90+ "match": "(@(?:include|import|extends|font-face|markdown|svg|data))\\s+(.*)",
6991 "captures": {
70− "1": { "name": "keyword.control.directive.htmlang" },
71− "2": { "name": "string.unquoted.filename.htmlang" }
92+ "1": {
93+ "name": "keyword.control.directive.htmlang"
94+ },
95+ "2": {
96+ "name": "string.unquoted.filename.htmlang"
97+ }
7298 }
7399 },
74100 "element": {
75101 "name": "entity.name.tag.htmlang",
76− "match": "@(?:row|column|col|el|text|paragraph|p|image|img|link|raw|children|nav|header|footer|main|section|article|aside|list|item|table|thead|tbody|tr|td|th|form|input|button|select|textarea|label|option|details|summary|blockquote|cite|code|pre|hr|figure|figcaption|video|audio|iframe|canvas|script|noscript|address|search|breadcrumb|grid|stack|spacer|badge|tooltip|avatar|carousel|chip|tag)\\b"
102+ "match": "@(?:row|column|el|text|paragraph|image|link|raw|children|slot|fragment|script|nav|header|footer|main|section|article|aside|address|search|form|details|summary|dialog|figure|figcaption|blockquote|cite|fieldset|legend|noscript|datalist|list|item|dl|dt|dd|table|thead|tbody|tr|td|th|select|picture|progress|meter|output|canvas|button|label|option|textarea|time|mark|abbr|code|kbd|pre|h1|h2|h3|h4|h5|h6|input|hr|source|video|audio|iframe|grid|stack|in-front|behind)(?![a-zA-Z0-9_-])"
77103 },
78104 "function-call": {
79105 "name": "entity.name.function.htmlang",
@@ -88,17 +114,34 @@
88114 "begin": "\\[",
89115 "end": "\\]",
90116 "beginCaptures": {
91− "0": { "name": "punctuation.section.brackets.begin.htmlang" }
117+ "0": {
118+ "name": "punctuation.section.brackets.begin.htmlang"
119+ }
92120 },
93121 "endCaptures": {
94− "0": { "name": "punctuation.section.brackets.end.htmlang" }
122+ "0": {
123+ "name": "punctuation.section.brackets.end.htmlang"
124+ }
95125 },
96126 "patterns": [
97− { "include": "#variable" },
98− { "include": "#color" },
99− { "include": "#number" },
100− { "include": "#state-prefix" },
101− { "include": "#attribute-name" },
127+ {
128+ "include": "#variable"
129+ },
130+ {
131+ "include": "#color"
132+ },
133+ {
134+ "include": "#number"
135+ },
136+ {
137+ "include": "#state-prefix"
138+ },
139+ {
140+ "include": "#html-attribute"
141+ },
142+ {
143+ "include": "#attribute-name"
144+ },
102145 {
103146 "name": "punctuation.separator.comma.htmlang",
104147 "match": ","
@@ -107,27 +150,39 @@
107150 },
108151 "state-prefix": {
109152 "name": "keyword.other.state.htmlang",
110− "match": "(?:hover|active|focus):"
153+ "match": "(?:[a-z0-9-]+|nth:[^:\\s,\\]]+|has\\([^)]*\\)):(?=[a-z])"
111154 },
112155 "attribute-name": {
113156 "name": "support.type.property-name.htmlang",
114− "match": "\\b(?:spacing|padding|padding-x|padding-y|width|height|min-width|max-width|min-height|max-height|center-x|center-y|align-left|align-right|align-top|align-bottom|background|color|border|rounded|bold|italic|underline|size|font|transition|cursor|opacity|text-align|line-height|overflow|position|z-index|shadow|wrap|gap-x|gap-y|id|class)\\b"
157+ "match": "\\b(?:spacing|padding|padding-x|padding-y|width|height|min-width|max-width|min-height|max-height|center-x|center-y|align-left|align-right|align-top|align-bottom|background|color|border|rounded|bold|italic|underline|size|font|transition|cursor|opacity|text-align|line-height|overflow|position|z-index|shadow|wrap|gap-x|gap-y|gap|margin|hidden)\\b"
115158 },
116159 "inline-element": {
117160 "name": "meta.inline-element.htmlang",
118161 "begin": "(\\{)(?=@)",
119162 "end": "(\\})",
120163 "beginCaptures": {
121− "1": { "name": "punctuation.section.braces.begin.htmlang" }
164+ "1": {
165+ "name": "punctuation.section.braces.begin.htmlang"
166+ }
122167 },
123168 "endCaptures": {
124− "1": { "name": "punctuation.section.braces.end.htmlang" }
169+ "1": {
170+ "name": "punctuation.section.braces.end.htmlang"
171+ }
125172 },
126173 "patterns": [
127− { "include": "#element" },
128− { "include": "#function-call" },
129− { "include": "#attributes" },
130− { "include": "#variable" }
174+ {
175+ "include": "#element"
176+ },
177+ {
178+ "include": "#function-call"
179+ },
180+ {
181+ "include": "#attributes"
182+ },
183+ {
184+ "include": "#variable"
185+ }
131186 ]
132187 },
133188 "color": {
@@ -137,6 +192,10 @@
137192 "number": {
138193 "name": "constant.numeric.htmlang",
139194 "match": "\\b[0-9]+(\\.[0-9]+)?\\b"
195+ },
196+ "html-attribute": {
197+ "name": "entity.other.attribute-name.htmlang",
198+ "match": "\\b[a-zA-Z][a-zA-Z0-9_.-]*(?==)"
140199 }
141200 }
142201}
examples/demo.hl+3−3
@@ -108,7 +108,7 @@
108108 padding 2 6, background #f1f5f9, rounded 4,
109109 font monospace, size 13
110110 ] Ctrl+K} to search. {@mark [background #fef08a] Highlighted} uses mark.
111− {@abbr [title HyperText Markup Language + language] htmlang} is an abbreviation.
111+ {@abbr [title=HyperText Markup Language + language] htmlang} is an abbreviation.
112112
113113 @blockquote [
114114 padding-inline-start $gap, border-left 3 $primary,
@@ -217,7 +217,7 @@
217217 ]
218218 @row [spacing 8]
219219 @button [
220− type submit,
220+ type=submit,
221221 padding 10 16, background $primary, color white, bold,
222222 rounded 6, border 0,
223223 hover:background $primary|darken:10,
@@ -226,7 +226,7 @@
226226 cursor pointer
227227 ] Subscribe
228228 @button [
229− type reset,
229+ type=reset,
230230 padding 10 16, background white,
231231 border 1 #e5e7eb, rounded 6, cursor pointer
232232 ] Reset
src/bin/htmlang_lsp/analysis.rs+3−3
@@ -1043,9 +1043,9 @@ pub(crate) fn semantic_tokens(text: &str, result: &ParseResult) -> Vec<SemanticT
10431043 let token_type = match word {
10441044 "@page" | "@let" | "@if" | "@else" | "@each" | "@include" | "@import"
10451045 | "@meta" | "@head" | "@style" | "@keyframes" | "@match" | "@case"
1046− | "@default" | "@slot" | "@children" | "@warn" | "@debug" | "@lang"
1047− | "@favicon" | "@fragment" | "@og" | "@breakpoint"
1048− | "@canonical" | "@base" | "@font-face" | "@json-ld" | "@assert" | "@theme"
1046+ | "@default" | "@slot" | "@children" | "@warn"
1047+ | "@fragment" | "@breakpoint"
1048+ | "@font-face" | "@json-ld" | "@assert" | "@theme"
10491049 | "@deprecated" | "@extends" | "@data" | "@env" | "@fetch"
10501050 | "@svg" => 0, // keyword
10511051 _ => {
src/bin/htmlang_lsp/completion.rs−29
@@ -328,22 +328,6 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
328328 ("@case", "Match case (inside @match)", "@case "),
329329 ("@default", "Default case (inside @match)", "@default"),
330330 ("@warn", "Emit a compile-time warning", "@warn "),
331− (
332− "@debug",
333− "Print debug message during compilation",
334− "@debug ",
335− ),
336− (
337− "@lang",
338− "Set document language (html lang attribute)",
339− "@lang ",
340− ),
341− (
342− "@favicon",
343− "Set favicon (inlined as base64 data URI)",
344− "@favicon ",
345− ),
346− ("@og", "Add Open Graph meta tag", "@og "),
347331 (
348332 "@breakpoint",
349333 "Define custom responsive breakpoint",
@@ -364,12 +348,6 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
364348 "Inherit a layout template and fill @slot blocks",
365349 "@extends ",
366350 ),
367− (
368− "@canonical",
369− "Set canonical URL for the page",
370− "@canonical ",
371− ),
372− ("@base", "Set base URL for relative links", "@base "),
373351 ("@font-face", "Define a custom font face", "@font-face"),
374352 (
375353 "@json-ld",
@@ -1427,13 +1405,6 @@ fn attr_completions(range: Range, element: Option<&str>) -> Vec<CompletionItem>
14271405 ("line-clamp", "Clamp text to N lines with ellipsis", true),
14281406 ("blur", "Apply blur filter (px)", true),
14291407 ("backdrop-blur", "Apply backdrop blur filter (px)", true),
1430− (
1431− "no-scrollbar",
1432− "Hide scrollbar while keeping overflow",
1433− false,
1434− ),
1435− ("skeleton", "Add shimmer loading skeleton animation", false),
1436− ("gradient", "Linear gradient (color1 color2 [angle])", true),
14371408 // Direction
14381409 ("direction", "Text direction (ltr/rtl)", true),
14391410 // Container query prefixes
src/bin/htmlang_lsp/hover.rs+1−28
@@ -258,7 +258,7 @@ fn hover_builtin(word: &str) -> Option<String> {
258258 "**@raw** \u{2014} Raw HTML\n\nPasses content through without processing.\n\nUsage: `@raw \"\"\"<div>custom html</div>\"\"\"`"
259259 }
260260 "@page" => {
261− "**@page** \u{2014} Page title\n\nSets the HTML `<title>` and wraps output in a full document.\n\nUsage: `@page My Page Title`"
261+ "**@page** \u{2014} Page title\n\nSets the HTML `<title>` and wraps output in a full document. Attributes set `lang`, `favicon`, `canonical` and `base`.\n\nUsage: `@page [lang en, favicon /favicon.png] My Page Title`"
262262 }
263263 "@let" => {
264264 "**@let** \u{2014} Definition\n\nDefines a variable, attribute bundle, or component.\n\n- Variable: `@let primary #3b82f6`\n- Attribute bundle: `@let card-style [padding 20, rounded 8]`\n- Component:\n```\n@let card $title\n @el [padding 20]\n @text [bold] $title\n @children\n```"
@@ -447,18 +447,6 @@ fn hover_builtin(word: &str) -> Option<String> {
447447 "@warn" => {
448448 "**@warn** \u{2014} Compile warning\n\nEmit a custom warning during compilation.\n\nUsage: `@warn This value is deprecated`"
449449 }
450− "@debug" => {
451− "**@debug** \u{2014} Debug message\n\nPrint a debug message to stderr during compilation.\n\nUsage: `@debug Theme is $theme`"
452− }
453− "@lang" => {
454− "**@lang** \u{2014} Document language\n\nSets the `lang` attribute on the `<html>` element.\n\nUsage: `@lang en`"
455− }
456− "@favicon" => {
457− "**@favicon** \u{2014} Favicon\n\nInlines a favicon as a base64 data URI in the `<head>`.\n\nUsage: `@favicon favicon.png`"
458− }
459− "@og" => {
460− "**@og** \u{2014} Open Graph meta tag\n\nAdds an Open Graph `<meta>` tag to `<head>`.\n\nUsage: `@og title My Page Title`"
461− }
462450 "@breakpoint" => {
463451 "**@breakpoint** \u{2014} Custom breakpoint\n\nDefines a custom responsive breakpoint.\n\nUsage: `@breakpoint tablet 600`"
464452 }
@@ -471,12 +459,6 @@ fn hover_builtin(word: &str) -> Option<String> {
471459 "@extends" => {
472460 "**@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```"
473461 }
474− "@canonical" => {
475− "**@canonical** `<url>`\n\nSets the canonical URL for the page. Adds `<link rel=\"canonical\">` to `<head>`.\n\nUsage: `@canonical https://example.com/page`"
476− }
477− "@base" => {
478− "**@base** `<url>`\n\nSets the base URL for all relative URLs in the document. Adds `<base>` to `<head>`.\n\nUsage: `@base https://example.com/`"
479− }
480462 "@font-face" => {
481463 "**@font-face** \u{2014} Custom font\n\nDefines a custom font face. Generates a CSS `@font-face` rule.\n\n```\n@font-face\n family Inter\n src url(/fonts/Inter.woff2)\n weight 400 700\n```"
482464 }
@@ -953,15 +935,6 @@ fn hover_builtin(word: &str) -> Option<String> {
953935 "backdrop-blur" => {
954936 "**backdrop-blur** `<value>` \u{2014} Apply backdrop blur\n\nShorthand for `backdrop-filter: blur(Npx)`.\n\nUsage: `[backdrop-blur 10]` \u{2192} `backdrop-filter: blur(10px)`"
955937 }
956− "no-scrollbar" => {
957− "**no-scrollbar** \u{2014} Hide scrollbar\n\nHides scrollbar while keeping overflow scrollable.\n\nSets `scrollbar-width: none` and `::-webkit-scrollbar { display: none }`."
958− }
959− "skeleton" => {
960− "**skeleton** \u{2014} Loading skeleton\n\nAdds a shimmer animation for loading placeholders.\n\nUsage: `@el [width fill, height 20, rounded 4, skeleton]`"
961− }
962− "gradient" => {
963− "**gradient** `<from> <to> [angle]` \u{2014} Linear gradient\n\nShorthand for `background: linear-gradient(...)`.\n\nUsage:\n- `[gradient #fff #000]` \u{2192} top-to-bottom\n- `[gradient #fff #000 45deg]` \u{2192} 45\u{00b0} angle"
964− }
965938 "direction" => "**direction** `<value>` \u{2014} Text direction (`ltr`, `rtl`).",
966939 // Grid areas
967940 "grid-template-areas" => {
src/upgrade.rs+51−14
@@ -56,11 +56,23 @@ pub fn upgrade(input: &str) -> Upgrade {
5656 let mut i = 0;
5757 // A file may define its own function named like an old alias (e.g.
5858 // `@let divider`); calls to it must not be renamed.
59− let user_defined: Vec<&str> = lines
60− .iter()
61− .filter_map(|l| l.trim().strip_prefix("@let "))
62− .filter_map(|rest| rest.split_whitespace().next())
63− .collect();
59+ // (Lines inside `"""` strings don't count: they're text.)
60+ let mut user_defined: Vec<&str> = Vec::new();
61+ let mut j = 0;
62+ while j < lines.len() {
63+ if let Some(end) = triple_quote_end(&lines, j) {
64+ j = end;
65+ continue;
66+ }
67+ if let Some(name) = lines[j]
68+ .trim()
69+ .strip_prefix("@let ")
70+ .and_then(|rest| rest.split_whitespace().next())
71+ {
72+ user_defined.push(name);
73+ }
74+ j += 1;
75+ }
6476
6577 while i < lines.len() {
6678 let line = lines[i];
@@ -76,7 +88,7 @@ pub fn upgrade(input: &str) -> Upgrade {
7688 if attr_depth == 0 && VERBATIM_BODIES.iter().any(|d| starts_directive(trimmed, d)) {
7789 // The directive line is upgraded like any other; its body is not.
7890 let end = block_end(&lines, i);
79− let mut header = rewrite_attr_regions(line, &mut attr_depth, true);
91+ let mut header = rewrite_attr_regions(line, &mut attr_depth, &mut true, &user_defined);
8092 header = rename_filters(&header);
8193 if header != line {
8294 changes += 1;
@@ -106,7 +118,8 @@ pub fn upgrade(input: &str) -> Upgrade {
106118 if attr_depth == 0 {
107119 element_attrs = takes_element_attributes(new_line.trim_start(), &user_defined);
108120 }
109− new_line = rewrite_attr_regions(&new_line, &mut attr_depth, element_attrs);
121+ new_line =
122+ rewrite_attr_regions(&new_line, &mut attr_depth, &mut element_attrs, &user_defined);
110123 new_line = rename_filters(&new_line);
111124 if new_line != line {
112125 changes += 1;
@@ -557,9 +570,16 @@ fn takes_element_attributes(trimmed: &str, user_defined: &[&str]) -> bool {
557570 || STD_COMPONENTS.contains(&name))
558571}
559572
560−fn rewrite_attr_regions(line: &str, depth: &mut i32, element_attrs: bool) -> String {
573+fn rewrite_attr_regions(
574+ line: &str,
575+ depth: &mut i32,
576+ element_attrs: &mut bool,
577+ user_defined: &[&str],
578+) -> String {
561579 let trimmed = line.trim_start();
562− if *depth == 0 && !trimmed.starts_with('@') && !trimmed.starts_with('[') {
580+ // In a text line, only inline elements (`{@abbr [...]}`) have attributes.
581+ let text_line = !trimmed.starts_with('@') && !trimmed.starts_with('[');
582+ if *depth == 0 && text_line && !line.contains("{@") {
563583 return line.to_string();
564584 }
565585 let mut out = String::with_capacity(line.len());
@@ -572,20 +592,28 @@ fn rewrite_attr_regions(line: &str, depth: &mut i32, element_attrs: bool) -> Str
572592 _ => {}
573593 }
574594 if *depth == 0 {
575− out.push_str(&rewrite_attr_list(&region, element_attrs));
595+ out.push_str(&rewrite_attr_list(&region, *element_attrs));
576596 region.clear();
577597 out.push(ch);
578598 } else {
579599 region.push(ch);
580600 }
581− } else {
582− out.push(ch);
583− if ch == '[' {
601+ continue;
602+ }
603+ if ch == '[' {
604+ if !text_line {
584605 *depth = 1;
606+ } else if let Some(brace) = out.rfind('{') {
607+ let name = out[brace + 1..].trim_end();
608+ if name.starts_with('@') && !name.contains(char::is_whitespace) {
609+ *element_attrs = takes_element_attributes(name, user_defined);
610+ *depth = 1;
611+ }
585612 }
586613 }
614+ out.push(ch);
587615 }
588− out.push_str(&rewrite_attr_list(&region, element_attrs));
616+ out.push_str(&rewrite_attr_list(&region, *element_attrs));
589617 out
590618}
591619
@@ -938,6 +966,15 @@ mod tests {
938966 // Function parameters and directive options are left alone.
939967 assert_eq!(up("@let card $title\n @text $title\n@card [title Hi]"), "@let card $title\n @text $title\n@card [title Hi]");
940968 assert_eq!(up("@page [lang en] Home"), "@page [lang en] Home");
969+ // Inline elements in text, and `@let` inside a raw string.
970+ assert_eq!(
971+ up("Read {@abbr [title HyperText] HTML} now"),
972+ "Read {@abbr [title=HyperText] HTML} now"
973+ );
974+ assert_eq!(
975+ up("@raw \"\"\"\n@let button $x\n\"\"\"\n@button [type submit] Go"),
976+ "@raw \"\"\"\n@let button $x\n\"\"\"\n@button [type=submit] Go"
977+ );
941978 // Multi-line lists keep their element context.
942979 assert_eq!(up("@input [\n type email,\n padding 8\n]"), "@input [\n type=email,\n padding 8\n]");
943980 }
tests/docs.rs+3
@@ -71,6 +71,9 @@ fn check_doc(file: &str) {
7171 d.severity == Severity::Error
7272 || d.message.contains("unknown")
7373 || d.message.contains("undefined variable")
74+ || d.message.contains("is an HTML attribute")
75+ || d.message.contains("was removed")
76+ || d.message.contains("no single root")
7477 })
7578 .map(|d| format!("line {}: {}", d.line, d.message))
7679 .collect();
tests/regressions.rs+10
@@ -361,3 +361,13 @@ fn invalid_expressions_are_errors() {
361361 .collect();
362362 assert_eq!(errors.len(), 2, "{:?}", result.diagnostics);
363363}
364+
365+#[test]
366+fn htmlang_attributes_sharing_html_names_do_not_warn() {
367+ let result = parser::parse("@row [size 18, wrap, hidden]\n @text x");
368+ assert!(
369+ result.diagnostics.is_empty(),
370+ "unexpected diagnostics: {:?}",
371+ result.diagnostics
372+ );
373+}