on master help sign in
Document the simplified language and update tooling
56ce116iko
CONTRIBUTING.md+16−5
@@ -19,11 +19,19 @@ Linux, macOS, and Windows. Your change should pass all three.
19## Repository layout
19## Repository layout
20
20
21- `crates/htmlang-core/` — parser, AST, code generator. No I/O lives here.
21- `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.
22- `crates/htmlang-wasm/` — thin wrapper exposing `compile` to the web playground.
28- `crates/htmlang-wasm/` — thin wrapper exposing `compile` to the web playground.
23- `src/` — CLI, dev server, formatter, and HTML-to-hl converter.
29- `src/` — CLI, dev server, formatter, and HTML-to-hl converter.
24- `src/bin/htmlang_lsp/` — language server binary (`htmlang-lsp`).
30- `src/bin/htmlang_lsp/` — language server binary (`htmlang-lsp`).
25- `editors/vscode/` — VS Code extension.
31- `editors/vscode/` — VS Code extension.
26- `tests/snapshots.rs` — integration / snapshot tests for the compiler.
32- `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`.
27- `examples/` — sample `.hl` files used as smoke tests and documentation.
35- `examples/` — sample `.hl` files used as smoke tests and documentation.
28
36
29## Adding a feature
37## Adding a feature
@@ -32,11 +40,14 @@ Linux, macOS, and Windows. Your change should pass all three.
32 `tests/snapshots.rs`; prefer integration tests that exercise the full
40 `tests/snapshots.rs`; prefer integration tests that exercise the full
33 parser-to-HTML pipeline. Pure parser / codegen helpers can live as unit
41 parser-to-HTML pipeline. Pure parser / codegen helpers can live as unit
34 tests alongside the code.
42 tests alongside the code.
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
35−2. Thread the feature through the parser, then codegen, then the LSP (hover,
45+ thread a feature through the parser, codegen and LSP when it needs to be.
36− completions, diagnostics).
46+3. Document it in `DESIGN.md` (the examples there are compiled by the tests).
37−3. Document it in `DESIGN.md`. If it's user-facing, also update `README.md`.
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.
38−4. If it changes the CLI surface, update the `--help` output and the shell
50+5. If it changes the CLI surface, update the `--help` output.
39− completions.
40
51
41## Style
52## Style
42
53
DESIGN.md+130−55
@@ -35,18 +35,22 @@ Children are indented under their parent. Attributes are comma-separated inside
35 Content
35 Content
36```
36```
37
37
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
38−A comma inside `(...)` or `"..."` does not split attributes, so a font stack is
41+(`required`, `disabled`, `open`). A comma inside `(...)` or `"..."` does not
39−written `font "Inter, sans-serif"`.
42+split attributes, so a font stack is written `font "Inter, sans-serif"`.
40
43
41### Text
44### Text
42
45
43−Any line that doesn't start with `@` or `[` is text. Use `@text` when text needs
46+Any line that doesn't start with `@` or `[` is text. Text after an element's
44−attributes, and `{...}` to put elements inside a line of text:
47+attributes is its content, and `{...}` puts elements inside a line of text:
45
48
46```
49```
47@paragraph
50@paragraph
48 This is {@text [bold] important} and this is a {@link https://example.com link}.
51 This is {@text [bold] important} and this is a {@link https://example.com link}.
49@text [bold, size 24, color #333] Hello world
52@text [bold, size 24, color #333] Hello world
53+@section [padding 8] Text after the attributes is content too.
50```
54```
51
55
52### Comments
56### Comments
@@ -96,7 +100,6 @@ name.
96| `@grid` | div, grid | Grid container (use `grid-cols`) |
100| `@grid` | div, grid | Grid container (use `grid-cols`) |
97| `@stack` | div, position relative | Children layered on top of each other |
101| `@stack` | div, position relative | Children layered on top of each other |
98| `@in-front` / `@behind` | div, absolute | Overlay layers filling the parent (see below) |
102| `@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 |
103| `@text` | span | Styled inline text |
101| `@paragraph` | p | Flowing text with inline elements |
104| `@paragraph` | p | Flowing text with inline elements |
102| `@link url` | a | Link; text after the URL becomes its content |
105| `@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`,
111Content: `@list` / `@item` (`@list [ordered]` for `<ol>`), `@dl` / `@dt` /
114Content: `@list` / `@item` (`@list [ordered]` for `<ol>`), `@dl` / `@dt` /
112`@dd`, `@table` / `@thead` / `@tbody` / `@tr` / `@th` / `@td`, `@code`, `@pre`,
115`@dd`, `@table` / `@thead` / `@tbody` / `@tr` / `@th` / `@td`, `@code`, `@pre`,
113`@hr`, `@mark`, `@kbd`, `@abbr`, `@time`, `@progress`, `@meter`, `@output`,
116`@hr`, `@mark`, `@kbd`, `@abbr`, `@time`, `@progress`, `@meter`, `@output`,
114−`@canvas`, `@iframe src`, `@video`, `@audio`, `@picture` / `@source`,
117+`@canvas`, `@iframe src`, `@video src`, `@audio src`, `@picture` / `@source`,
115−`@script`, `@breadcrumb`, `@fragment` (children without a wrapper).
118+`@script`, `@fragment` (children without a wrapper).
116
119
117Form controls: `@input`, `@button`, `@select` / `@option`, `@textarea`,
120Form controls: `@input`, `@button`, `@select` / `@option`, `@textarea`,
118−`@label`, `@datalist`.
121+`@label`, `@datalist`. `@form URL` sets the form's `action`.
119−
120−Styled components: `@badge`, `@chip`, `@tag`, `@avatar`, `@tooltip`,
121−`@carousel`.
122
122
123```
123```
124@list [ordered]
124@list [ordered]
125 @item First
125 @item First
126 @item Second
126 @item Second
127−@form [method post] /subscribe
127+@form [method=post] /subscribe
128− @label [for email] Email
128+ @label [for=email] Email
129− @input [type email, name email, id email, required]
129+ @input [type=email, name=email, id=email, required]
130− @button [type submit] Send
130+ @button [type=submit] Send
131@details [open]
131@details [open]
132 @summary Question
132 @summary Question
133 @text The answer.
133 @text The answer.
@@ -149,6 +149,35 @@ wins.
149 @el [background red, width fill, height fill]
149 @el [background red, width fill, height fill]
150```
150```
151
151
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+
152## Attributes
181## Attributes
153
182
154### Layout and sizing
183### Layout and sizing
@@ -156,11 +185,10 @@ wins.
156| Attribute | Effect |
185| Attribute | Effect |
157|---|---|
186|---|---|
158| `spacing N` | Gap between children (also `gap-x N`, `gap-y N`) |
187| `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) |
188+| `padding N` / `padding Y X` / `padding T R B L` | Padding (also `padding-x`, `padding-y`) |
160−| `margin ...` | Margin, same forms as padding |
189+| `margin ...` | Margin, same forms as padding (also `margin-x`, `margin-y`) |
161| `width fill` / `width N` / `width shrink` | Take remaining space, exact size, or fit content |
190| `width fill` / `width N` / `width shrink` | Take remaining space, exact size, or fit content |
162| `height fill` / `height N` / `height shrink` | Same for height |
191| `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 |
192| `center-x`, `center-y` | Center within the parent |
165| `align-left`, `align-right`, `align-top`, `align-bottom` | Align within the parent |
193| `align-left`, `align-right`, `align-top`, `align-bottom` | Align within the parent |
166| `wrap` | Let a row wrap |
194| `wrap` | Let a row wrap |
@@ -171,28 +199,36 @@ wins.
171
199
172| Attribute | Effect |
200| Attribute | Effect |
173|---|---|
201|---|---|
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 |
176| `rounded N` | Border radius |
203| `rounded N` | Border radius |
177| `shadow VALUE` | Box shadow |
204| `shadow VALUE` | Box shadow |
178| `bold`, `italic`, `underline` | Text style |
205| `bold`, `italic`, `underline` | Text style |
179| `size N` | Font size |
206| `size N` | Font size |
180| `font NAME` | Font family |
207| `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 |
182
210
183−Most CSS properties can also be written directly as attributes with the same
211+**Any standard CSS property** can also be used as an attribute, with the same
184−name and value: `opacity 0.5`, `cursor pointer`, `z-index 10`,
212+name and value: `background red`, `opacity 0.5`, `cursor pointer`,
185−`text-align center`, `position absolute`, `grid-template-areas "a b"`, and so on.
213+`z-index 10`, `margin-top 16`, `grid-template-areas "a b"`, and so on.
186
214
187−**Units.** A bare number is pixels (`padding 20` is `20px`). Values with a unit,
215+**Units.** For lengths, a bare number is pixels (`padding 20` is `20px`).
188−keywords and CSS functions are passed through: `width 50%`, `margin 0 auto`,
216+Values with a unit, keywords and CSS functions are passed through: `width 50%`,
189−`max-width min(100%, 800px)`.
217+`margin 0 auto`, `max-width min(100%, 800px)`.
190
218
191### HTML attributes
219### HTML attributes
192
220
193−`id`, `class`, and element attributes such as `href`, `type`, `name`, `value`,
221+Write HTML attributes as `key=value`: `id=main`, `class=note`, `href=/about`,
194−`placeholder`, `alt`, `for`, `required`, `disabled`, `target`, `rel`, `role`,
222+`type=email`, `alt=Logo`, `target=_blank`, `aria-label=Close menu`,
195−`tabindex`, `title`, `data-*` and `aria-*` are emitted as HTML attributes.
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+```
196
232
197### State and media prefixes
233### State and media prefixes
198
234
@@ -201,7 +237,8 @@ Prefix a style attribute to apply it conditionally:
201| Prefix | Applies |
237| Prefix | Applies |
202|---|---|
238|---|---|
203| `hover:`, `active:`, `focus:`, `focus-visible:`, `focus-within:`, `disabled:`, `checked:`, `visited:`, `target:`, `valid:`, `invalid:`, `empty:`, `placeholder:`, `selection:` | In that state |
239| `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 |
205| `before:`, `after:` | On the `::before` / `::after` pseudo-element (with `content`) |
242| `before:`, `after:` | On the `::before` / `::after` pseudo-element (with `content`) |
206| `has(SELECTOR):` | When the element contains a match |
243| `has(SELECTOR):` | When the element contains a match |
207| `sm:`, `md:`, `lg:`, `xl:`, `2xl:` | From that viewport width up (640–1536px) |
244| `sm:`, `md:`, `lg:`, `xl:`, `2xl:` | From that viewport width up (640–1536px) |
@@ -213,12 +250,18 @@ Prefix a style attribute to apply it conditionally:
213```
250```
214@el [padding 16, background #3b82f6, hover:background #2563eb, md:padding 32, dark:background #1e3a8a]
251@el [padding 16, background #3b82f6, hover:background #2563eb, md:padding 32, dark:background #1e3a8a]
215 @text [color white] Click me
252 @text [color white] Click me
253+@row [spacing 4, children:flex 1, nth:2n:background #f3f4f6]
254+ @el A
255+ @el B
216@el [before:content "→ ", before:color red]
256@el [before:content "→ ", before:color red]
217 Item with an arrow
257 Item with an arrow
218```
258```
219
259
220### Conditional attributes
260### Conditional attributes
221
261
262+`key if CONDITION` includes an attribute only when the condition holds;
263+`if(CONDITION, A, B)` picks a value.
264+
222```
265```
223@let active true
266@let active true
224@el [background if($active, blue, gray), bold if $active]
267@el [background if($active, blue, gray), bold if $active]
@@ -232,9 +275,9 @@ Prefix a style attribute to apply it conditionally:
232```
275```
233-- A value, used as $primary
276-- A value, used as $primary
234@let primary #3b82f6
277@let primary #3b82f6
235−-- A computed value (= is optional)
278+-- A computed value: `=` makes it an expression
236@let gap = 8 * 2
279@let gap = 8 * 2
237−-- A quoted string: interpolated, never computed
280+-- A quoted string, with $variables interpolated
238@let greeting "Hello $primary"
281@let greeting "Hello $primary"
239-- An attribute bundle, used as [$card]
282-- An attribute bundle, used as [$card]
240@let card [padding 20, background white, rounded 8]
283@let card [padding 20, background white, rounded 8]
@@ -250,11 +293,28 @@ Prefix a style attribute to apply it conditionally:
250 Attributes after a bundle override it.
293 Attributes after a bundle override it.
251```
294```
252
295
296+A function is called like an element:
297+
253−Function arguments are passed as attributes; parameters with `=default` may be
298+- Its parameters are passed as attributes; parameters with `=default` may be
254−omitted. `@component name $params` defines a function whose indented `@style`
299+ omitted.
255−block is scoped to its output. Inside a function, `@children` is replaced by the caller's children,
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
256−and `@slot name` by the caller's `@slot name` block (the slot's own children are
303+ caller's `@slot name` block replaces `@slot name` (the slot's own children are
257−the default).
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+```
258
318
259A multi-line string uses triple quotes; its indented lines are the value:
319A multi-line string uses triple quotes; its indented lines are the value:
260
320
@@ -291,8 +351,23 @@ and for colors `lighten:N`, `darken:N`, `alpha:N`, `mix:COLOR:N`.
291### Checks
351### Checks
292
352
293`@assert CONDITION` fails the build when false. `@warn MESSAGE` emits a
353`@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.
296
371
297## Control flow
372## Control flow
298
373
@@ -302,16 +377,13 @@ All control flow runs at compile time.
302@let items apple, banana, cherry
377@let items apple, banana, cherry
303@let count 3
378@let count 3
304
379
305−@if $count > 2
380+@if $count > 2 and not $hidden
306 @text Many
381 @text Many
307@else if $count == 0
382@else if $count == 0
308 @text None
383 @text None
309@else
384@else
310 @text Few
385 @text Few
311
386
312−@if not $count
313− @text Nothing to show
314−
315@each $item in $items
387@each $item in $items
316 @text $_index: $item
388 @text $_index: $item
317@else
389@else
@@ -330,12 +402,7 @@ All control flow runs at compile time.
330 @text Other
402 @text Other
331```
403```
332
404
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.
339
406
340## Files and data
407## Files and data
341
408
@@ -401,16 +468,17 @@ adds a meta tag (`og:` names become Open Graph `property` tags), and
401 to [opacity 1]
468 to [opacity 1]
402@style
469@style
403 .note { color: gray; }
470 .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
405```
472```
406
473
407## Upgrading older files
474## Upgrading older files
408
475
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.
410
478
411| Removed | Use instead |
479| Removed | Use instead |
412|---|---|
480|---|---|
413−| `@fn`, `@define`, `@mixin` | `@let` (function, bundle) |
481+| `@fn`, `@define`, `@mixin`, `@component` | `@let` (function, bundle; `@style` in a function body is scoped) |
414| `@unless COND` | `@if not COND` |
482| `@unless COND` | `@if not COND` |
415| `@for $i in A..B`, `@repeat N` | `@each $i in A..B`, `@each $_ in 1..N` |
483| `@for $i in A..B`, `@repeat N` | `@each $i in A..B`, `@each $_ in 1..N` |
416| `@switch` | `@match` |
484| `@switch` | `@match` |
@@ -418,9 +486,16 @@ adds a meta tag (`og:` names become Open Graph `property` tags), and
418| `@with $x as y` | `@let y $x` |
486| `@with $x as y` | `@let y $x` |
419| `@layout file` | `@extends file` |
487| `@layout file` | `@extends file` |
420| `@scope`, `@starting-style`, `@css-property` | The CSS rule in `@style` |
488| `@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 |
421| `@col`, `@p`, `@img`, `@li`, `@btn`, `@ul`, `@divider`, `@opt` | `@column`, `@paragraph`, `@image`, `@item`, `@button`, `@list`, `@hr`, `@option` |
493| `@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)` |
422| `...$bundle` | `$bundle` |
499| `...$bundle` | `$bundle` |
423| `animate`, `inset-area` | `animation`, `position-area` |
500| `animate`, `inset-area` | `animation`, `position-area` |
424| `\|upper`, `\|lower`, `\|cap`, `\|len` | `\|uppercase`, `\|lowercase`, `\|capitalize`, `\|length` |
501| `\|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
28 Compiles to a single HTML file
28 Compiles to a single HTML file
29```
29```
30
30
31+This compiles to one self-contained `.html` file with flexbox layout and
31−Compiles to a self-contained `.html` file with flexbox layout and generated CSS classes. No JavaScript, no external dependencies.
32+generated CSS classes. No JavaScript, no external dependencies.
32
33
33## Install
34## Install
34
35
@@ -38,215 +39,87 @@ cargo install --path .
38
39
39## Usage
40## Usage
40
41
41−Everyday workflow:
42−
43```
42```
44−htmlang init # scaffold a new project
45htmlang page.hl # compile page.hl -> page.html
43htmlang page.hl # compile page.hl -> page.html
44+htmlang page.hl -o out.html # choose the output file
46−htmlang --watch page.hl # recompile on change
45+htmlang -w page.hl # recompile on change
47htmlang serve . # dev server with live reload
46htmlang 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]
130```
47```
131
48
132−Comments start with `--` and run to the end of the line; they must be on a
49+| Command | Purpose |
133−line of their own.
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 |
134
60
135−### Style attributes
61+Compiling directly also takes `--dev`, `--compat`, `--strict`, `--partial`,
62+`--format json`, `-s` / `--serve`, `-p` / `--port` and `--open`.
136
63
137−```
64+## Editor support
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−```
152
65
153−For overlays, prefer `@in-front` and `@behind` — they make the parent a
66+A VS Code extension with syntax highlighting and LSP integration is in
154−positioning context and stretch to its bounds automatically:
67+[`editors/vscode`](editors/vscode). The language server (`htmlang-lsp`) provides
68+diagnostics, completions, hover documentation, go to definition, rename and
69+formatting.
155
70
156−```
71+## Language tour
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−```
164
72
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`, ...).
166
76
167−Prefix any style attribute with `hover:`, `active:`, or `focus:`:
77+Attributes are styles, written `key value`, or HTML attributes, written
78+`key=value`:
168
79
169```
80```
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]
171```
86```
172
87
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:
174
90
175```
91```
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
177```
94```
178
95
179−### Variables and attribute bundles
96+`@let` defines values, computed values, attribute bundles and functions:
180
97
181```
98```
182−-- simple variable, used as $primary
183@let primary #3b82f6
99@let primary #3b82f6
184−-- quoted string interpolation
100+@let gap = 8 * 2
185−@let greeting "Hello $name"
186−-- attribute bundle, used as [$card]
187@let card [padding 20, rounded 8]
101@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
197@let button $label
102@let button $label
198− @el [padding 12, background $primary, rounded 8]
103+ @el [$card, background $primary]
199 @text [color white, bold] $label
104 @text [color white, bold] $label
200 @children
105 @children
201
106
202−@button [label Click me]
107+@button [label Click me, padding 12]
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
216```
108```
217
109
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.
219
113
220−Attribute lists can span multiple lines:
114+Control flow runs at compile time:
221
115
222```
116```
223−@el [
117+@let items Home, About, Contact
224− padding 20,
118+@row [spacing 8]
225− background white,
119+ @each $item in $items
226− rounded 8,
120+ @if $item != About
227− shadow 0 2px 4px rgba(0,0,0,0.1)
121+ @link /$item|lowercase $item
228−]
229− Content here
230```
122```
231
123
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 @@
11/// HTML attribute).
11/// HTML attribute).
12pub const HTMLANG_ATTRIBUTES: &[&str] = &[
12pub const HTMLANG_ATTRIBUTES: &[&str] = &[
13 "align-bottom", "align-left", "align-right", "align-top", "backdrop-blur", "blur", "bold",
13 "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",
14+ "center-x", "center-y", "col-span", "critical", "gap-x", "gap-y", "grid-cols", "grid-rows",
15− "grid-rows", "inline", "italic", "margin-x", "margin-y", "ordered",
15+ "hidden", "inline", "italic", "margin-x", "margin-y", "ordered", "padding-x", "padding-y",
16− "padding-x", "padding-y", "responsive", "rounded", "row-span", "shadow",
17− "spacing", "truncate", "underline",
16+ "responsive", "rounded", "row-span", "shadow", "size", "spacing", "truncate", "underline",
17+ "wrap",
18];
18];
19
19
20/// Standard CSS properties, sorted. Any of these can be written as an
20/// Standard CSS properties, sorted. Any of these can be written as an
editors/vscode/snippets/htmlang.json+26−32
@@ -11,9 +11,9 @@
11 "description": "Page boilerplate with title, variables, and layout"
11 "description": "Page boilerplate with title, variables, and layout"
12 },
12 },
13 "Function component": {
13 "Function component": {
14− "prefix": "@fn",
14+ "prefix": "@let-fn",
15 "body": [
15 "body": [
16− "@fn ${1:component} \\$${2:title}",
16+ "@let ${1:component} \\$${2:title}",
17 " @el [${3:padding 20, background white, rounded 8}]",
17 " @el [${3:padding 20, background white, rounded 8}]",
18 " @text [bold] \\$$2",
18 " @text [bold] \\$$2",
19 " @children",
19 " @children",
@@ -24,9 +24,9 @@
24 "description": "Reusable function component with @children slot"
24 "description": "Reusable function component with @children slot"
25 },
25 },
26 "Function with named slots": {
26 "Function with named slots": {
27− "prefix": "@fn-slots",
27+ "prefix": "@let-slots",
28 "body": [
28 "body": [
29− "@fn ${1:layout} \\$${2:title}",
29+ "@let ${1:layout} \\$${2:title}",
30 " @column [${3:max-width 800, center-x, padding 40}]",
30 " @column [${3:max-width 800, center-x, padding 40}]",
31 " @slot ${4:header}",
31 " @slot ${4:header}",
32 " @text [bold, size 32] \\$$2",
32 " @text [bold, size 32] \\$$2",
@@ -124,10 +124,10 @@
124 "Form with inputs": {
124 "Form with inputs": {
125 "prefix": "@form",
125 "prefix": "@form",
126 "body": [
126 "body": [
127− "@form [method ${1:post}, spacing ${2:16}] ${3:/submit}",
127+ "@form [method=${1:post}, spacing ${2:16}] ${3:/submit}",
128− " @label [for ${4:email}] ${5:Email}",
128+ " @label [for=${4:email}] ${5:Email}",
129− " @input [type ${6:email}, name $4, required, padding 8, border 1 #d1d5db, rounded 4]",
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]",
130+ " @button [type=submit, padding 10, background ${7:\\$primary}, color white, rounded 4, bold]",
131 " ${8:Submit}",
131 " ${8:Submit}",
132 " $0"
132 " $0"
133 ],
133 ],
@@ -177,7 +177,7 @@
177 " from [opacity 0]",
177 " from [opacity 0]",
178 " to [opacity 1]",
178 " to [opacity 1]",
179 "",
179 "",
180− "@el [animate $1 ${2:0.3s} ${3:ease}]",
180+ "@el [animation $1 ${2:0.3s} ${3:ease}]",
181 " $0"
181 " $0"
182 ],
182 ],
183 "description": "Keyframe animation with element usage"
183 "description": "Keyframe animation with element usage"
@@ -202,7 +202,7 @@
202 "Script (external)": {
202 "Script (external)": {
203 "prefix": "@script-src",
203 "prefix": "@script-src",
204 "body": [
204 "body": [
205− "@script [src ${1:app.js}, ${2:defer}]",
205+ "@script [src=${1:app.js}, ${2:defer}]",
206 "$0"
206 "$0"
207 ],
207 ],
208 "description": "External script tag with src attribute"
208 "description": "External script tag with src attribute"
@@ -227,10 +227,10 @@
227 "Breadcrumb navigation": {
227 "Breadcrumb navigation": {
228 "prefix": "@breadcrumb",
228 "prefix": "@breadcrumb",
229 "body": [
229 "body": [
230− "@breadcrumb [spacing ${1:10}]",
230+ "@breadcrumb",
231− " @link ${2:/} ${3:Home}",
231+ " @item > @link ${1:/} ${2:Home}",
232− " @link ${4:/docs} ${5:Docs}",
232+ " @item > @link ${3:/docs} ${4:Docs}",
233− " @text ${6:Current Page}",
233+ " @item ${5:Current Page}",
234 " $0"
234 " $0"
235 ],
235 ],
236 "description": "Breadcrumb navigation with links"
236 "description": "Breadcrumb navigation with links"
@@ -249,28 +249,12 @@
249 "prefix": "@search",
249 "prefix": "@search",
250 "body": [
250 "body": [
251 "@search",
251 "@search",
252− " @input [type search, placeholder ${1:Search...}]",
252+ " @input [type=search, placeholder=${1:Search...}]",
253− " @button [type submit] ${2:Go}",
253+ " @button [type=submit] ${2:Go}",
254 " $0"
254 " $0"
255 ],
255 ],
256 "description": "Search form with input and submit button"
256 "description": "Search form with input and submit button"
257 },
257 },
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− },
274 "Font face": {
258 "Font face": {
275 "prefix": "@font-face",
259 "prefix": "@font-face",
276 "body": [
260 "body": [
@@ -291,5 +275,15 @@
291 "$0"
275 "$0"
292 ],
276 ],
293 "description": "JSON-LD structured data block"
277 "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"
294 }
288 }
295}
289}
editors/vscode/syntaxes/htmlang.tmLanguage.json+113−54
@@ -3,20 +3,42 @@
3 "name": "htmlang",
3 "name": "htmlang",
4 "scopeName": "source.htmlang",
4 "scopeName": "source.htmlang",
5 "patterns": [
5 "patterns": [
6+ {
6− { "include": "#comment" },
7+ "include": "#comment"
8+ },
9+ {
7− { "include": "#raw-block" },
10+ "include": "#raw-block"
11+ },
12+ {
8− { "include": "#page-directive" },
13+ "include": "#page-directive"
14+ },
15+ {
9− { "include": "#let-definition" },
16+ "include": "#let-definition"
10− { "include": "#define-definition" },
17+ },
11− { "include": "#fn-definition" },
18+ {
12− { "include": "#include-directive" },
19+ "include": "#include-directive"
20+ },
21+ {
13− { "include": "#element" },
22+ "include": "#element"
23+ },
24+ {
14− { "include": "#function-call" },
25+ "include": "#function-call"
26+ },
27+ {
15− { "include": "#variable" },
28+ "include": "#variable"
29+ },
30+ {
16− { "include": "#attributes" },
31+ "include": "#attributes"
32+ },
33+ {
17− { "include": "#inline-element" },
34+ "include": "#inline-element"
35+ },
36+ {
18− { "include": "#color" },
37+ "include": "#color"
38+ },
39+ {
19− { "include": "#number" }
40+ "include": "#number"
41+ }
20 ],
42 ],
21 "repository": {
43 "repository": {
22 "comment": {
44 "comment": {
@@ -28,52 +50,56 @@
28 "begin": "(@raw)\\s+(\"\"\")",
50 "begin": "(@raw)\\s+(\"\"\")",
29 "end": "(\"\"\")",
51 "end": "(\"\"\")",
30 "beginCaptures": {
52 "beginCaptures": {
53+ "1": {
31− "1": { "name": "entity.name.tag.htmlang" },
54+ "name": "entity.name.tag.htmlang"
55+ },
56+ "2": {
32− "2": { "name": "punctuation.definition.string.begin.htmlang" }
57+ "name": "punctuation.definition.string.begin.htmlang"
58+ }
33 },
59 },
34 "endCaptures": {
60 "endCaptures": {
61+ "1": {
35− "1": { "name": "punctuation.definition.string.end.htmlang" }
62+ "name": "punctuation.definition.string.end.htmlang"
63+ }
36 },
64 },
37 "contentName": "string.unquoted.raw.htmlang"
65 "contentName": "string.unquoted.raw.htmlang"
38 },
66 },
39 "page-directive": {
67 "page-directive": {
40 "match": "(@page)\\s+(.*)",
68 "match": "(@page)\\s+(.*)",
41 "captures": {
69 "captures": {
70+ "1": {
42− "1": { "name": "keyword.control.directive.htmlang" },
71+ "name": "keyword.control.directive.htmlang"
72+ },
73+ "2": {
43− "2": { "name": "string.unquoted.page-title.htmlang" }
74+ "name": "string.unquoted.page-title.htmlang"
75+ }
44 }
76 }
45 },
77 },
46 "let-definition": {
78 "let-definition": {
47 "match": "(@let)\\s+([a-zA-Z_][a-zA-Z0-9_-]*)",
79 "match": "(@let)\\s+([a-zA-Z_][a-zA-Z0-9_-]*)",
48 "captures": {
80 "captures": {
81+ "1": {
49− "1": { "name": "keyword.control.directive.htmlang" },
82+ "name": "keyword.control.directive.htmlang"
50− "2": { "name": "variable.other.htmlang" }
51− }
52− },
83+ },
53− "define-definition": {
84+ "2": {
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" }
85+ "name": "variable.other.htmlang"
58− }
86+ }
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" }
65 }
87 }
66 },
88 },
67 "include-directive": {
89 "include-directive": {
68− "match": "(@(?:include|import|extends|canonical|base|font-face))\\s+(.*)",
90+ "match": "(@(?:include|import|extends|font-face|markdown|svg|data))\\s+(.*)",
69 "captures": {
91 "captures": {
92+ "1": {
70− "1": { "name": "keyword.control.directive.htmlang" },
93+ "name": "keyword.control.directive.htmlang"
94+ },
95+ "2": {
71− "2": { "name": "string.unquoted.filename.htmlang" }
96+ "name": "string.unquoted.filename.htmlang"
97+ }
72 }
98 }
73 },
99 },
74 "element": {
100 "element": {
75 "name": "entity.name.tag.htmlang",
101 "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_-])"
77 },
103 },
78 "function-call": {
104 "function-call": {
79 "name": "entity.name.function.htmlang",
105 "name": "entity.name.function.htmlang",
@@ -88,17 +114,34 @@
88 "begin": "\\[",
114 "begin": "\\[",
89 "end": "\\]",
115 "end": "\\]",
90 "beginCaptures": {
116 "beginCaptures": {
117+ "0": {
91− "0": { "name": "punctuation.section.brackets.begin.htmlang" }
118+ "name": "punctuation.section.brackets.begin.htmlang"
119+ }
92 },
120 },
93 "endCaptures": {
121 "endCaptures": {
122+ "0": {
94− "0": { "name": "punctuation.section.brackets.end.htmlang" }
123+ "name": "punctuation.section.brackets.end.htmlang"
124+ }
95 },
125 },
96 "patterns": [
126 "patterns": [
127+ {
97− { "include": "#variable" },
128+ "include": "#variable"
129+ },
130+ {
98− { "include": "#color" },
131+ "include": "#color"
132+ },
133+ {
99− { "include": "#number" },
134+ "include": "#number"
135+ },
136+ {
100− { "include": "#state-prefix" },
137+ "include": "#state-prefix"
138+ },
139+ {
140+ "include": "#html-attribute"
141+ },
142+ {
101− { "include": "#attribute-name" },
143+ "include": "#attribute-name"
144+ },
102 {
145 {
103 "name": "punctuation.separator.comma.htmlang",
146 "name": "punctuation.separator.comma.htmlang",
104 "match": ","
147 "match": ","
@@ -107,27 +150,39 @@
107 },
150 },
108 "state-prefix": {
151 "state-prefix": {
109 "name": "keyword.other.state.htmlang",
152 "name": "keyword.other.state.htmlang",
110− "match": "(?:hover|active|focus):"
153+ "match": "(?:[a-z0-9-]+|nth:[^:\\s,\\]]+|has\\([^)]*\\)):(?=[a-z])"
111 },
154 },
112 "attribute-name": {
155 "attribute-name": {
113 "name": "support.type.property-name.htmlang",
156 "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"
115 },
158 },
116 "inline-element": {
159 "inline-element": {
117 "name": "meta.inline-element.htmlang",
160 "name": "meta.inline-element.htmlang",
118 "begin": "(\\{)(?=@)",
161 "begin": "(\\{)(?=@)",
119 "end": "(\\})",
162 "end": "(\\})",
120 "beginCaptures": {
163 "beginCaptures": {
164+ "1": {
121− "1": { "name": "punctuation.section.braces.begin.htmlang" }
165+ "name": "punctuation.section.braces.begin.htmlang"
166+ }
122 },
167 },
123 "endCaptures": {
168 "endCaptures": {
169+ "1": {
124− "1": { "name": "punctuation.section.braces.end.htmlang" }
170+ "name": "punctuation.section.braces.end.htmlang"
171+ }
125 },
172 },
126 "patterns": [
173 "patterns": [
174+ {
127− { "include": "#element" },
175+ "include": "#element"
176+ },
177+ {
128− { "include": "#function-call" },
178+ "include": "#function-call"
179+ },
180+ {
129− { "include": "#attributes" },
181+ "include": "#attributes"
182+ },
183+ {
130− { "include": "#variable" }
184+ "include": "#variable"
185+ }
131 ]
186 ]
132 },
187 },
133 "color": {
188 "color": {
@@ -137,6 +192,10 @@
137 "number": {
192 "number": {
138 "name": "constant.numeric.htmlang",
193 "name": "constant.numeric.htmlang",
139 "match": "\\b[0-9]+(\\.[0-9]+)?\\b"
194 "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_.-]*(?==)"
140 }
199 }
141 }
200 }
142}
201}
examples/demo.hl+3−3
@@ -108,7 +108,7 @@
108 padding 2 6, background #f1f5f9, rounded 4,
108 padding 2 6, background #f1f5f9, rounded 4,
109 font monospace, size 13
109 font monospace, size 13
110 ] Ctrl+K} to search. {@mark [background #fef08a] Highlighted} uses mark.
110 ] 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.
112
112
113 @blockquote [
113 @blockquote [
114 padding-inline-start $gap, border-left 3 $primary,
114 padding-inline-start $gap, border-left 3 $primary,
@@ -217,7 +217,7 @@
217 ]
217 ]
218 @row [spacing 8]
218 @row [spacing 8]
219 @button [
219 @button [
220− type submit,
220+ type=submit,
221 padding 10 16, background $primary, color white, bold,
221 padding 10 16, background $primary, color white, bold,
222 rounded 6, border 0,
222 rounded 6, border 0,
223 hover:background $primary|darken:10,
223 hover:background $primary|darken:10,
@@ -226,7 +226,7 @@
226 cursor pointer
226 cursor pointer
227 ] Subscribe
227 ] Subscribe
228 @button [
228 @button [
229− type reset,
229+ type=reset,
230 padding 10 16, background white,
230 padding 10 16, background white,
231 border 1 #e5e7eb, rounded 6, cursor pointer
231 border 1 #e5e7eb, rounded 6, cursor pointer
232 ] Reset
232 ] Reset
src/bin/htmlang_lsp/analysis.rs+3−3
@@ -1043,9 +1043,9 @@ pub(crate) fn semantic_tokens(text: &str, result: &ParseResult) -> Vec<SemanticT
1043 let token_type = match word {
1043 let token_type = match word {
1044 "@page" | "@let" | "@if" | "@else" | "@each" | "@include" | "@import"
1044 "@page" | "@let" | "@if" | "@else" | "@each" | "@include" | "@import"
1045 | "@meta" | "@head" | "@style" | "@keyframes" | "@match" | "@case"
1045 | "@meta" | "@head" | "@style" | "@keyframes" | "@match" | "@case"
1046− | "@default" | "@slot" | "@children" | "@warn" | "@debug" | "@lang"
1046+ | "@default" | "@slot" | "@children" | "@warn"
1047− | "@favicon" | "@fragment" | "@og" | "@breakpoint"
1047+ | "@fragment" | "@breakpoint"
1048− | "@canonical" | "@base" | "@font-face" | "@json-ld" | "@assert" | "@theme"
1048+ | "@font-face" | "@json-ld" | "@assert" | "@theme"
1049 | "@deprecated" | "@extends" | "@data" | "@env" | "@fetch"
1049 | "@deprecated" | "@extends" | "@data" | "@env" | "@fetch"
1050 | "@svg" => 0, // keyword
1050 | "@svg" => 0, // keyword
1051 _ => {
1051 _ => {
src/bin/htmlang_lsp/completion.rs−29
@@ -328,22 +328,6 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
328 ("@case", "Match case (inside @match)", "@case "),
328 ("@case", "Match case (inside @match)", "@case "),
329 ("@default", "Default case (inside @match)", "@default"),
329 ("@default", "Default case (inside @match)", "@default"),
330 ("@warn", "Emit a compile-time warning", "@warn "),
330 ("@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 "),
347 (
331 (
348 "@breakpoint",
332 "@breakpoint",
349 "Define custom responsive breakpoint",
333 "Define custom responsive breakpoint",
@@ -364,12 +348,6 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
364 "Inherit a layout template and fill @slot blocks",
348 "Inherit a layout template and fill @slot blocks",
365 "@extends ",
349 "@extends ",
366 ),
350 ),
367− (
368− "@canonical",
369− "Set canonical URL for the page",
370− "@canonical ",
371− ),
372− ("@base", "Set base URL for relative links", "@base "),
373 ("@font-face", "Define a custom font face", "@font-face"),
351 ("@font-face", "Define a custom font face", "@font-face"),
374 (
352 (
375 "@json-ld",
353 "@json-ld",
@@ -1427,13 +1405,6 @@ fn attr_completions(range: Range, element: Option<&str>) -> Vec<CompletionItem>
1427 ("line-clamp", "Clamp text to N lines with ellipsis", true),
1405 ("line-clamp", "Clamp text to N lines with ellipsis", true),
1428 ("blur", "Apply blur filter (px)", true),
1406 ("blur", "Apply blur filter (px)", true),
1429 ("backdrop-blur", "Apply backdrop blur filter (px)", true),
1407 ("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),
1437 // Direction
1408 // Direction
1438 ("direction", "Text direction (ltr/rtl)", true),
1409 ("direction", "Text direction (ltr/rtl)", true),
1439 // Container query prefixes
1410 // Container query prefixes
src/bin/htmlang_lsp/hover.rs+1−28
@@ -258,7 +258,7 @@ fn hover_builtin(word: &str) -> Option<String> {
258 "**@raw** \u{2014} Raw HTML\n\nPasses content through without processing.\n\nUsage: `@raw \"\"\"<div>custom html</div>\"\"\"`"
258 "**@raw** \u{2014} Raw HTML\n\nPasses content through without processing.\n\nUsage: `@raw \"\"\"<div>custom html</div>\"\"\"`"
259 }
259 }
260 "@page" => {
260 "@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`"
262 }
262 }
263 "@let" => {
263 "@let" => {
264 "**@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```"
264 "**@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> {
447 "@warn" => {
447 "@warn" => {
448 "**@warn** \u{2014} Compile warning\n\nEmit a custom warning during compilation.\n\nUsage: `@warn This value is deprecated`"
448 "**@warn** \u{2014} Compile warning\n\nEmit a custom warning during compilation.\n\nUsage: `@warn This value is deprecated`"
449 }
449 }
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− }
462 "@breakpoint" => {
450 "@breakpoint" => {
463 "**@breakpoint** \u{2014} Custom breakpoint\n\nDefines a custom responsive breakpoint.\n\nUsage: `@breakpoint tablet 600`"
451 "**@breakpoint** \u{2014} Custom breakpoint\n\nDefines a custom responsive breakpoint.\n\nUsage: `@breakpoint tablet 600`"
464 }
452 }
@@ -471,12 +459,6 @@ fn hover_builtin(word: &str) -> Option<String> {
471 "@extends" => {
459 "@extends" => {
472 "**@extends** `<file.hl>`\n\nInherit a layout template. Fill named `@slot` blocks.\n\n```\n@extends layout.hl\n@slot content\n My page content\n@slot sidebar\n Sidebar content\n```"
460 "**@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```"
473 }
461 }
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− }
480 "@font-face" => {
462 "@font-face" => {
481 "**@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```"
463 "**@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```"
482 }
464 }
@@ -953,15 +935,6 @@ fn hover_builtin(word: &str) -> Option<String> {
953 "backdrop-blur" => {
935 "backdrop-blur" => {
954 "**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)`"
936 "**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)`"
955 }
937 }
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− }
965 "direction" => "**direction** `<value>` \u{2014} Text direction (`ltr`, `rtl`).",
938 "direction" => "**direction** `<value>` \u{2014} Text direction (`ltr`, `rtl`).",
966 // Grid areas
939 // Grid areas
967 "grid-template-areas" => {
940 "grid-template-areas" => {
src/upgrade.rs+51−14
@@ -56,11 +56,23 @@ pub fn upgrade(input: &str) -> Upgrade {
56 let mut i = 0;
56 let mut i = 0;
57 // A file may define its own function named like an old alias (e.g.
57 // A file may define its own function named like an old alias (e.g.
58 // `@let divider`); calls to it must not be renamed.
58 // `@let divider`); calls to it must not be renamed.
59+ // (Lines inside `"""` strings don't count: they're text.)
59− let user_defined: Vec<&str> = lines
60+ let mut user_defined: Vec<&str> = Vec::new();
60− .iter()
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()
61− .filter_map(|l| l.trim().strip_prefix("@let "))
69+ .strip_prefix("@let ")
62− .filter_map(|rest| rest.split_whitespace().next())
70+ .and_then(|rest| rest.split_whitespace().next())
63− .collect();
71+ {
72+ user_defined.push(name);
73+ }
74+ j += 1;
75+ }
64
76
65 while i < lines.len() {
77 while i < lines.len() {
66 let line = lines[i];
78 let line = lines[i];
@@ -76,7 +88,7 @@ pub fn upgrade(input: &str) -> Upgrade {
76 if attr_depth == 0 && VERBATIM_BODIES.iter().any(|d| starts_directive(trimmed, d)) {
88 if attr_depth == 0 && VERBATIM_BODIES.iter().any(|d| starts_directive(trimmed, d)) {
77 // The directive line is upgraded like any other; its body is not.
89 // The directive line is upgraded like any other; its body is not.
78 let end = block_end(&lines, i);
90 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);
80 header = rename_filters(&header);
92 header = rename_filters(&header);
81 if header != line {
93 if header != line {
82 changes += 1;
94 changes += 1;
@@ -106,7 +118,8 @@ pub fn upgrade(input: &str) -> Upgrade {
106 if attr_depth == 0 {
118 if attr_depth == 0 {
107 element_attrs = takes_element_attributes(new_line.trim_start(), &user_defined);
119 element_attrs = takes_element_attributes(new_line.trim_start(), &user_defined);
108 }
120 }
121+ new_line =
109− new_line = rewrite_attr_regions(&new_line, &mut attr_depth, element_attrs);
122+ rewrite_attr_regions(&new_line, &mut attr_depth, &mut element_attrs, &user_defined);
110 new_line = rename_filters(&new_line);
123 new_line = rename_filters(&new_line);
111 if new_line != line {
124 if new_line != line {
112 changes += 1;
125 changes += 1;
@@ -557,9 +570,16 @@ fn takes_element_attributes(trimmed: &str, user_defined: &[&str]) -> bool {
557 || STD_COMPONENTS.contains(&name))
570 || STD_COMPONENTS.contains(&name))
558}
571}
559
572
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 {
561 let trimmed = line.trim_start();
579 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("{@") {
563 return line.to_string();
583 return line.to_string();
564 }
584 }
565 let mut out = String::with_capacity(line.len());
585 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
572 _ => {}
592 _ => {}
573 }
593 }
574 if *depth == 0 {
594 if *depth == 0 {
575− out.push_str(&rewrite_attr_list(&region, element_attrs));
595+ out.push_str(&rewrite_attr_list(&region, *element_attrs));
576 region.clear();
596 region.clear();
577 out.push(ch);
597 out.push(ch);
578 } else {
598 } else {
579 region.push(ch);
599 region.push(ch);
580 }
600 }
581− } else {
601+ continue;
582− out.push(ch);
602+ }
583− if ch == '[' {
603+ if ch == '[' {
604+ if !text_line {
584 *depth = 1;
605 *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+ }
585 }
612 }
586 }
613 }
614+ out.push(ch);
587 }
615 }
588− out.push_str(&rewrite_attr_list(&region, element_attrs));
616+ out.push_str(&rewrite_attr_list(&region, *element_attrs));
589 out
617 out
590}
618}
591
619
@@ -938,6 +966,15 @@ mod tests {
938 // Function parameters and directive options are left alone.
966 // Function parameters and directive options are left alone.
939 assert_eq!(up("@let card $title\n @text $title\n@card [title Hi]"), "@let card $title\n @text $title\n@card [title Hi]");
967 assert_eq!(up("@let card $title\n @text $title\n@card [title Hi]"), "@let card $title\n @text $title\n@card [title Hi]");
940 assert_eq!(up("@page [lang en] Home"), "@page [lang en] Home");
968 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+ );
941 // Multi-line lists keep their element context.
978 // Multi-line lists keep their element context.
942 assert_eq!(up("@input [\n type email,\n padding 8\n]"), "@input [\n type=email,\n padding 8\n]");
979 assert_eq!(up("@input [\n type email,\n padding 8\n]"), "@input [\n type=email,\n padding 8\n]");
943 }
980 }
tests/docs.rs+3
@@ -71,6 +71,9 @@ fn check_doc(file: &str) {
71 d.severity == Severity::Error
71 d.severity == Severity::Error
72 || d.message.contains("unknown")
72 || d.message.contains("unknown")
73 || d.message.contains("undefined variable")
73 || 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")
74 })
77 })
75 .map(|d| format!("line {}: {}", d.line, d.message))
78 .map(|d| format!("line {}: {}", d.line, d.message))
76 .collect();
79 .collect();
tests/regressions.rs+10
@@ -361,3 +361,13 @@ fn invalid_expressions_are_errors() {
361 .collect();
361 .collect();
362 assert_eq!(errors.len(), 2, "{:?}", result.diagnostics);
362 assert_eq!(errors.len(), 2, "{:?}", result.diagnostics);
363}
363}
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+}