on master help sign in
Add dev server with browser live reload via SSE
commit1540416216f8431563db70ae6de1d73644474e75 authoriko <mail@iko.soy> dateTue 14 Apr 2026, 14:19 (6 months ago) parent8a87d90
11 files changed+973−78
Cargo.lock+275−1 Cargo.toml+2−1 DESIGN.md+63−6 README.md+38−1 editors/vscode/syntaxes/htmlang.tmLanguage.json+1−1 src/bin/htmlang-lsp.rs+38−12 src/codegen.rs+54 src/lib.rs+1 src/main.rs+140−20 src/parser.rs+199−36 src/serve.rs+162
Cargo.lock+275−1
@@ -72,6 +72,17 @@ dependencies = [
7272 "syn",
7373]
7474
75+[[package]]
76+name = "filetime"
77+version = "0.2.27"
78+source = "registry+https://github.com/rust-lang/crates.io-index"
79+checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
80+dependencies = [
81+ "cfg-if",
82+ "libc",
83+ "libredox",
84+]
85+
7586[[package]]
7687name = "form_urlencoded"
7788version = "1.2.2"
@@ -81,6 +92,15 @@ dependencies = [
8192 "percent-encoding",
8293]
8394
95+[[package]]
96+name = "fsevent-sys"
97+version = "4.1.0"
98+source = "registry+https://github.com/rust-lang/crates.io-index"
99+checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
100+dependencies = [
101+ "libc",
102+]
103+
84104[[package]]
85105name = "futures"
86106version = "0.3.32"
@@ -167,6 +187,7 @@ checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
167187name = "htmlang"
168188version = "0.1.0"
169189dependencies = [
190+ "notify",
170191 "tokio",
171192 "tower-lsp",
172193]
@@ -280,18 +301,79 @@ dependencies = [
280301 "icu_properties",
281302]
282303
304+[[package]]
305+name = "inotify"
306+version = "0.10.2"
307+source = "registry+https://github.com/rust-lang/crates.io-index"
308+checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc"
309+dependencies = [
310+ "bitflags 1.3.2",
311+ "inotify-sys",
312+ "libc",
313+]
314+
315+[[package]]
316+name = "inotify-sys"
317+version = "0.1.5"
318+source = "registry+https://github.com/rust-lang/crates.io-index"
319+checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
320+dependencies = [
321+ "libc",
322+]
323+
324+[[package]]
325+name = "instant"
326+version = "0.1.13"
327+source = "registry+https://github.com/rust-lang/crates.io-index"
328+checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
329+dependencies = [
330+ "cfg-if",
331+]
332+
283333[[package]]
284334name = "itoa"
285335version = "1.0.18"
286336source = "registry+https://github.com/rust-lang/crates.io-index"
287337checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
288338
339+[[package]]
340+name = "kqueue"
341+version = "1.1.1"
342+source = "registry+https://github.com/rust-lang/crates.io-index"
343+checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
344+dependencies = [
345+ "kqueue-sys",
346+ "libc",
347+]
348+
349+[[package]]
350+name = "kqueue-sys"
351+version = "1.0.4"
352+source = "registry+https://github.com/rust-lang/crates.io-index"
353+checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b"
354+dependencies = [
355+ "bitflags 1.3.2",
356+ "libc",
357+]
358+
289359[[package]]
290360name = "libc"
291361version = "0.2.185"
292362source = "registry+https://github.com/rust-lang/crates.io-index"
293363checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
294364
365+[[package]]
366+name = "libredox"
367+version = "0.1.16"
368+source = "registry+https://github.com/rust-lang/crates.io-index"
369+checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
370+dependencies = [
371+ "bitflags 2.11.1",
372+ "libc",
373+ "plain",
374+ "redox_syscall 0.7.4",
375+]
376+
295377[[package]]
296378name = "litemap"
297379version = "0.8.2"
@@ -307,6 +389,12 @@ dependencies = [
307389 "scopeguard",
308390]
309391
392+[[package]]
393+name = "log"
394+version = "0.4.29"
395+source = "registry+https://github.com/rust-lang/crates.io-index"
396+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
397+
310398[[package]]
311399name = "lsp-types"
312400version = "0.94.1"
@@ -326,6 +414,46 @@ version = "2.8.0"
326414source = "registry+https://github.com/rust-lang/crates.io-index"
327415checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
328416
417+[[package]]
418+name = "mio"
419+version = "1.2.0"
420+source = "registry+https://github.com/rust-lang/crates.io-index"
421+checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
422+dependencies = [
423+ "libc",
424+ "log",
425+ "wasi",
426+ "windows-sys 0.61.2",
427+]
428+
429+[[package]]
430+name = "notify"
431+version = "7.0.0"
432+source = "registry+https://github.com/rust-lang/crates.io-index"
433+checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009"
434+dependencies = [
435+ "bitflags 2.11.1",
436+ "filetime",
437+ "fsevent-sys",
438+ "inotify",
439+ "kqueue",
440+ "libc",
441+ "log",
442+ "mio",
443+ "notify-types",
444+ "walkdir",
445+ "windows-sys 0.52.0",
446+]
447+
448+[[package]]
449+name = "notify-types"
450+version = "1.0.1"
451+source = "registry+https://github.com/rust-lang/crates.io-index"
452+checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174"
453+dependencies = [
454+ "instant",
455+]
456+
329457[[package]]
330458name = "once_cell"
331459version = "1.21.4"
@@ -340,7 +468,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
340468dependencies = [
341469 "cfg-if",
342470 "libc",
343− "redox_syscall",
471+ "redox_syscall 0.5.18",
344472 "smallvec",
345473 "windows-link",
346474]
@@ -377,6 +505,12 @@ version = "0.2.17"
377505source = "registry+https://github.com/rust-lang/crates.io-index"
378506checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
379507
508+[[package]]
509+name = "plain"
510+version = "0.2.3"
511+source = "registry+https://github.com/rust-lang/crates.io-index"
512+checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
513+
380514[[package]]
381515name = "potential_utf"
382516version = "0.1.5"
@@ -413,6 +547,24 @@ dependencies = [
413547 "bitflags 2.11.1",
414548]
415549
550+[[package]]
551+name = "redox_syscall"
552+version = "0.7.4"
553+source = "registry+https://github.com/rust-lang/crates.io-index"
554+checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a"
555+dependencies = [
556+ "bitflags 2.11.1",
557+]
558+
559+[[package]]
560+name = "same-file"
561+version = "1.0.6"
562+source = "registry+https://github.com/rust-lang/crates.io-index"
563+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
564+dependencies = [
565+ "winapi-util",
566+]
567+
416568[[package]]
417569name = "scopeguard"
418570version = "1.2.0"
@@ -485,6 +637,16 @@ version = "1.15.1"
485637source = "registry+https://github.com/rust-lang/crates.io-index"
486638checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
487639
640+[[package]]
641+name = "socket2"
642+version = "0.6.3"
643+source = "registry+https://github.com/rust-lang/crates.io-index"
644+checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
645+dependencies = [
646+ "libc",
647+ "windows-sys 0.61.2",
648+]
649+
488650[[package]]
489651name = "stable_deref_trait"
490652version = "1.2.1"
@@ -529,8 +691,13 @@ version = "1.51.1"
529691source = "registry+https://github.com/rust-lang/crates.io-index"
530692checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c"
531693dependencies = [
694+ "bytes",
695+ "libc",
696+ "mio",
532697 "pin-project-lite",
698+ "socket2",
533699 "tokio-macros",
700+ "windows-sys 0.61.2",
534701]
535702
536703[[package]]
@@ -673,12 +840,119 @@ version = "1.0.4"
673840source = "registry+https://github.com/rust-lang/crates.io-index"
674841checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
675842
843+[[package]]
844+name = "walkdir"
845+version = "2.5.0"
846+source = "registry+https://github.com/rust-lang/crates.io-index"
847+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
848+dependencies = [
849+ "same-file",
850+ "winapi-util",
851+]
852+
853+[[package]]
854+name = "wasi"
855+version = "0.11.1+wasi-snapshot-preview1"
856+source = "registry+https://github.com/rust-lang/crates.io-index"
857+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
858+
859+[[package]]
860+name = "winapi-util"
861+version = "0.1.11"
862+source = "registry+https://github.com/rust-lang/crates.io-index"
863+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
864+dependencies = [
865+ "windows-sys 0.61.2",
866+]
867+
676868[[package]]
677869name = "windows-link"
678870version = "0.2.1"
679871source = "registry+https://github.com/rust-lang/crates.io-index"
680872checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
681873
874+[[package]]
875+name = "windows-sys"
876+version = "0.52.0"
877+source = "registry+https://github.com/rust-lang/crates.io-index"
878+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
879+dependencies = [
880+ "windows-targets",
881+]
882+
883+[[package]]
884+name = "windows-sys"
885+version = "0.61.2"
886+source = "registry+https://github.com/rust-lang/crates.io-index"
887+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
888+dependencies = [
889+ "windows-link",
890+]
891+
892+[[package]]
893+name = "windows-targets"
894+version = "0.52.6"
895+source = "registry+https://github.com/rust-lang/crates.io-index"
896+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
897+dependencies = [
898+ "windows_aarch64_gnullvm",
899+ "windows_aarch64_msvc",
900+ "windows_i686_gnu",
901+ "windows_i686_gnullvm",
902+ "windows_i686_msvc",
903+ "windows_x86_64_gnu",
904+ "windows_x86_64_gnullvm",
905+ "windows_x86_64_msvc",
906+]
907+
908+[[package]]
909+name = "windows_aarch64_gnullvm"
910+version = "0.52.6"
911+source = "registry+https://github.com/rust-lang/crates.io-index"
912+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
913+
914+[[package]]
915+name = "windows_aarch64_msvc"
916+version = "0.52.6"
917+source = "registry+https://github.com/rust-lang/crates.io-index"
918+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
919+
920+[[package]]
921+name = "windows_i686_gnu"
922+version = "0.52.6"
923+source = "registry+https://github.com/rust-lang/crates.io-index"
924+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
925+
926+[[package]]
927+name = "windows_i686_gnullvm"
928+version = "0.52.6"
929+source = "registry+https://github.com/rust-lang/crates.io-index"
930+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
931+
932+[[package]]
933+name = "windows_i686_msvc"
934+version = "0.52.6"
935+source = "registry+https://github.com/rust-lang/crates.io-index"
936+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
937+
938+[[package]]
939+name = "windows_x86_64_gnu"
940+version = "0.52.6"
941+source = "registry+https://github.com/rust-lang/crates.io-index"
942+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
943+
944+[[package]]
945+name = "windows_x86_64_gnullvm"
946+version = "0.52.6"
947+source = "registry+https://github.com/rust-lang/crates.io-index"
948+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
949+
950+[[package]]
951+name = "windows_x86_64_msvc"
952+version = "0.52.6"
953+source = "registry+https://github.com/rust-lang/crates.io-index"
954+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
955+
682956[[package]]
683957name = "writeable"
684958version = "0.6.3"
Cargo.toml+2−1
@@ -4,5 +4,6 @@ version = "0.1.0"
44edition = "2024"
55
66[dependencies]
7−tokio = { version = "1", features = ["io-std", "macros", "rt-multi-thread"] }
7+notify = "7"
8+tokio = { version = "1", features = ["io-std", "io-util", "macros", "net", "rt-multi-thread", "sync"] }
89tower-lsp = "0.20"
DESIGN.md+63−6
@@ -33,7 +33,17 @@ Bare lines (not starting with `@` or `[`) are text nodes.
3333 children
3434```
3535
36−Children are indented under their parent. Attributes are optional, comma-separated inside `[...]`.
36+Children are indented under their parent. Attributes are optional, comma-separated inside `[...]`. Attribute lists can span multiple lines:
37+
38+```
39+@el [
40+ padding 20,
41+ background white,
42+ rounded 8,
43+ shadow 0 2px 4px rgba(0,0,0,0.1)
44+]
45+ Content
46+```
3747
3848### Full example
3949
@@ -42,13 +52,21 @@ Children are indented under their parent. Attributes are optional, comma-separat
4252@page My Site
4353@let primary #3b82f6
4454@let gap 20
45−@define card [padding 20, background white, rounded 8, border 1 #e5e7eb]
55+@define card [
56+ padding 20,
57+ background white,
58+ rounded 8,
59+ border 1 #e5e7eb,
60+ shadow 0 2px 4px rgba(0,0,0,0.05)
61+]
62+
63+@include header.hl
4664
4765@column [max-width 800, center-x, padding 40, spacing $gap]
4866 @row [spacing $gap]
4967 @column [width fill, spacing 10]
5068 @text [bold, size 32] Welcome
51− @paragraph
69+ @paragraph [line-height 1.6]
5270 This is a page built with {@text [bold, color $primary] htmlang}.
5371 Read the {@link https://docs.example.com docs} to learn more.
5472 @image [width 80, height 80, rounded 40] avatar.png
@@ -65,7 +83,7 @@ Children are indented under their parent. Attributes are optional, comma-separat
6583 @el [padding 16, background $primary, rounded 8] > @link https://example.com
6684 @text [color white] Get Started
6785 @el [width fill]
68− @text [color #888] © 2026
86+ @text [color #888, text-align right] © 2026
6987
7088 @raw """
7189 <canvas id="chart"></canvas>
@@ -95,12 +113,22 @@ Defines a variable. Referenced with `$name`.
95113
96114### `@include`
97115
98−Inlines another `.hl` file.
116+Inlines another `.hl` file. The path is resolved relative to the current file. Variables defined with `@let`, attribute bundles from `@define`, and functions from `@fn` in the included file are available after the `@include` line.
99117
100118```
101119@include header.hl
120+@include components/card.hl
121+```
122+
123+Variables can be used in the filename:
124+
125+```
126+@let component card
127+@include $component.hl
102128```
103129
130+Circular includes are detected and reported as errors. Nested includes are supported.
131+
104132### `@define`
105133
106134Creates a named attribute bundle. Referenced with `$name` inside attribute lists.
@@ -240,10 +268,14 @@ Triple-quoted block pasted verbatim into output. Use for arbitrary HTML, CSS, or
240268| Attribute | Effect |
241269|------------------------|----------------------------------|
242270| `spacing N` | Gap between children |
271+| `gap-x N` | Horizontal gap (column-gap) |
272+| `gap-y N` | Vertical gap (row-gap) |
243273| `padding N` | Uniform padding |
274+| `padding Y X` | Vertical + horizontal padding |
275+| `padding T H B` | Top + horizontal + bottom |
276+| `padding T R B L` | Per-side padding |
244277| `padding-x N` | Horizontal padding |
245278| `padding-y N` | Vertical padding |
246−| `padding T R B L` | Per-side padding |
247279
248280### Sizing (set on child)
249281
@@ -279,14 +311,20 @@ Triple-quoted block pasted verbatim into output. Use for arbitrary HTML, CSS, or
279311| `color COLOR` | Text color |
280312| `border N COLOR` | Border width and color |
281313| `rounded N` | Border radius |
314+| `shadow VALUE` | Box shadow (CSS value) |
282315| `bold` | Bold text |
283316| `italic` | Italic text |
284317| `underline` | Underlined text |
285318| `size N` | Font size in px |
286319| `font NAME` | Font family |
320+| `text-align VALUE` | Text alignment (left/center/right/justify) |
321+| `line-height VALUE` | Line height (unitless or px) |
287322| `transition VALUE` | CSS transition |
288323| `cursor VALUE` | Cursor style |
289324| `opacity VALUE` | Opacity (0–1) |
325+| `overflow VALUE` | Overflow (hidden/scroll/auto/visible) |
326+| `position VALUE` | Position (relative/absolute/fixed/sticky) |
327+| `z-index N` | Stack order |
290328
291329### Pseudo-states
292330
@@ -312,6 +350,16 @@ All style attributes support state prefixes: `hover:color`, `active:rounded`, `f
312350| `id NAME` | HTML id attribute |
313351| `class NAME` | HTML class attribute |
314352
353+## CLI
354+
355+```
356+htmlang page.hl # compile page.hl → page.html
357+htmlang --watch page.hl # compile and watch for changes
358+htmlang -w page.hl # short form
359+```
360+
361+Watch mode recompiles automatically when the source file or any `@include`d files change.
362+
315363## Compilation target
316364
317365Each `.hl` file compiles to a single self-contained `.html` file:
@@ -321,3 +369,12 @@ Each `.hl` file compiles to a single self-contained `.html` file:
321369- Styles are scoped via generated class names in an embedded `<style>` block
322370- No external CSS, no JavaScript (unless injected via `@raw`)
323371- `@page` generates the HTML boilerplate; without it, output is an HTML fragment
372+
373+## Editor support
374+
375+A VS Code extension is available in `editors/vscode/` with:
376+
377+- Syntax highlighting via TextMate grammar
378+- LSP integration via `htmlang-lsp` for diagnostics, completions, and hover documentation
379+
380+The language server reports parse errors and warnings for unknown attributes as you type.
README.md+38−1
@@ -39,9 +39,14 @@ cargo install --path .
3939## Usage
4040
4141```
42−htmlang page.hl # writes page.html
42+htmlang page.hl # writes page.html
43+htmlang --watch page.hl # recompile on change
4344```
4445
46+## Editor support
47+
48+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.
49+
4550## Language overview
4651
4752### Elements
@@ -61,9 +66,11 @@ htmlang page.hl # writes page.html
6166
6267```
6368@row [spacing 20] -- gap between children
69+@row [gap-x 10, gap-y 20] -- separate horizontal/vertical gaps
6470@el [padding 20] -- uniform padding (also padding-x, padding-y)
6571@el [width fill] -- take remaining space (also width 200, width shrink)
6672@el [center-x] -- center horizontally (also align-left, align-right)
73+@el [overflow hidden] -- overflow behavior (hidden, scroll, auto)
6774```
6875
6976### Style attributes
@@ -72,6 +79,16 @@ htmlang page.hl # writes page.html
7279@el [background #3b82f6, color white, rounded 8, border 1 #e5e7eb]
7380@text [bold, italic, underline, size 18, font Inter]
7481@el [opacity 0.5, cursor pointer, transition all 0.15s ease]
82+@el [shadow 0 2px 4px rgba(0,0,0,0.1)]
83+@paragraph [text-align center, line-height 1.5]
84+```
85+
86+### Positioning
87+
88+```
89+@el [position relative]
90+ @el [position absolute, z-index 10]
91+ Overlay content
7592```
7693
7794### Pseudo-states
@@ -100,6 +117,26 @@ Prefix any style attribute with `hover:`, `active:`, or `focus:`:
100117@button [label Click me]
101118```
102119
120+### File includes
121+
122+```
123+@include header.hl -- inline another .hl file
124+```
125+
126+### Multi-line attributes
127+
128+Attribute lists can span multiple lines:
129+
130+```
131+@el [
132+ padding 20,
133+ background white,
134+ rounded 8,
135+ shadow 0 2px 4px rgba(0,0,0,0.1)
136+]
137+ Content here
138+```
139+
103140### Other features
104141
105142- `--` comments
editors/vscode/syntaxes/htmlang.tmLanguage.json+1−1
@@ -111,7 +111,7 @@
111111 },
112112 "attribute-name": {
113113 "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|wrap|id|class)\\b"
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"
115115 },
116116 "inline-element": {
117117 "name": "meta.inline-element.htmlang",
src/bin/htmlang-lsp.rs+38−12
@@ -17,19 +17,25 @@ struct Backend {
1717
1818impl Backend {
1919 async fn on_change(&self, uri: Url, text: String) {
20− let diags = match htmlang::parser::parse(&text) {
21− Ok(_) => vec![],
22− Err(e) => {
23− let line = e.line.saturating_sub(1) as u32;
24− vec![Diagnostic {
20+ let result = htmlang::parser::parse(&text);
21+ let diags: Vec<Diagnostic> = result
22+ .diagnostics
23+ .iter()
24+ .map(|d| {
25+ let severity = match d.severity {
26+ htmlang::parser::Severity::Error => DiagnosticSeverity::ERROR,
27+ htmlang::parser::Severity::Warning => DiagnosticSeverity::WARNING,
28+ };
29+ let line = d.line.saturating_sub(1) as u32;
30+ Diagnostic {
2531 range: Range::new(Position::new(line, 0), Position::new(line, 1000)),
26− severity: Some(DiagnosticSeverity::ERROR),
32+ severity: Some(severity),
2733 source: Some("htmlang".into()),
28− message: e.message,
34+ message: d.message.clone(),
2935 ..Default::default()
30− }]
31− }
32− };
36+ }
37+ })
38+ .collect();
3339 self.client
3440 .publish_diagnostics(uri.clone(), diags, None)
3541 .await;
@@ -270,7 +276,7 @@ fn attr_completions(range: Range) -> Vec<CompletionItem> {
270276 [
271277 // Layout
272278 ("spacing", "Gap between children (px)", true),
273− ("padding", "Inner padding (1/2/4 values, px)", true),
279+ ("padding", "Inner padding (1/2/3/4 values, px)", true),
274280 ("padding-x", "Horizontal padding (px)", true),
275281 ("padding-y", "Vertical padding (px)", true),
276282 // Sizing
@@ -300,8 +306,19 @@ fn attr_completions(range: Range) -> Vec<CompletionItem> {
300306 ("transition", "CSS transition", true),
301307 ("cursor", "CSS cursor type", true),
302308 ("opacity", "Opacity (0-1)", true),
309+ // Typography
310+ ("text-align", "Text alignment (left/center/right/justify)", true),
311+ ("line-height", "Line height (unitless or px)", true),
312+ // Overflow & positioning
313+ ("overflow", "Overflow behavior (hidden/scroll/auto)", true),
314+ ("position", "Position type (relative/absolute/fixed/sticky)", true),
315+ ("z-index", "Stack order (integer)", true),
316+ // Effects
317+ ("shadow", "Box shadow (CSS value)", true),
303318 // Flow
304319 ("wrap", "Enable flex-wrap", false),
320+ ("gap-x", "Horizontal gap between children (px)", true),
321+ ("gap-y", "Vertical gap between children (px)", true),
305322 // Identity
306323 ("id", "HTML id attribute", true),
307324 ("class", "HTML class attribute", true),
@@ -334,6 +351,7 @@ fn state_attr_completions(prefix: &str, range: Range) -> Vec<CompletionItem> {
334351 ("size", "Font size (px)", true),
335352 ("opacity", "Opacity (0-1)", true),
336353 ("cursor", "CSS cursor type", true),
354+ ("shadow", "Box shadow (CSS value)", true),
337355 ]
338356 .iter()
339357 .map(|(name, detail, takes_value)| {
@@ -554,7 +572,7 @@ fn hover_builtin(word: &str) -> Option<String> {
554572 "@children" => "**@children** \u{2014} Children slot\n\nPlaceholder inside `@fn` body replaced with the caller's children.",
555573 // Attributes
556574 "spacing" => "**spacing** `<value>`\n\nGap between children in pixels. Maps to CSS `gap`.",
557− "padding" => "**padding** `<value>` | `<y> <x>` | `<t> <r> <b> <l>`\n\nInner padding in pixels. Accepts 1, 2, or 4 values.",
575+ "padding" => "**padding** `<value>` | `<y> <x>` | `<t> <h> <b>` | `<t> <r> <b> <l>`\n\nInner padding in pixels. Accepts 1, 2, 3, or 4 values.",
558576 "padding-x" => "**padding-x** `<value>`\n\nHorizontal padding (left + right) in pixels.",
559577 "padding-y" => "**padding-y** `<value>`\n\nVertical padding (top + bottom) in pixels.",
560578 "width" => "**width** `<px>` | `fill` | `shrink`\n\n- Number: fixed width in pixels\n- `fill`: expand to fill parent\n- `shrink`: prevent flex shrinking",
@@ -581,6 +599,14 @@ fn hover_builtin(word: &str) -> Option<String> {
581599 "transition" => "**transition** `<value>` \u{2014} CSS transition (e.g., `all 0.15s ease`).",
582600 "cursor" => "**cursor** `<value>` \u{2014} CSS cursor (e.g., `pointer`).",
583601 "opacity" => "**opacity** `<value>` \u{2014} Opacity from 0 to 1.",
602+ "text-align" => "**text-align** `<value>` \u{2014} Text alignment (`left`, `center`, `right`, `justify`).",
603+ "line-height" => "**line-height** `<value>` \u{2014} Line height. Unitless (e.g., `1.5`) or pixels.",
604+ "overflow" => "**overflow** `<value>` \u{2014} Overflow behavior (`hidden`, `scroll`, `auto`, `visible`).",
605+ "position" => "**position** `<value>` \u{2014} Position type (`relative`, `absolute`, `fixed`, `sticky`).",
606+ "z-index" => "**z-index** `<value>` \u{2014} Stack order (integer).",
607+ "shadow" => "**shadow** `<value>` \u{2014} Box shadow. Raw CSS value (e.g., `0 2px 4px rgba(0,0,0,0.1)`).",
608+ "gap-x" => "**gap-x** `<value>` \u{2014} Horizontal gap between children in pixels. Maps to `column-gap`.",
609+ "gap-y" => "**gap-y** `<value>` \u{2014} Vertical gap between children in pixels. Maps to `row-gap`.",
584610 "wrap" => "**wrap** \u{2014} Enable flex-wrap for children.",
585611 "id" => "**id** `<value>` \u{2014} HTML id attribute.",
586612 "class" => "**class** `<value>` \u{2014} HTML class attribute.",
src/codegen.rs+54
@@ -366,6 +366,14 @@ fn attrs_to_css(
366366 "padding",
367367 &format!("{}px {}px", parts[0], parts[1]),
368368 ),
369+ 3 => push_css(
370+ &mut css,
371+ "padding",
372+ &format!(
373+ "{}px {}px {}px",
374+ parts[0], parts[1], parts[2]
375+ ),
376+ ),
369377 4 => push_css(
370378 &mut css,
371379 "padding",
@@ -544,8 +552,54 @@ fn attrs_to_css(
544552 }
545553 }
546554
555+ // Typography
556+ "text-align" => {
557+ if let Some(v) = val {
558+ push_css(&mut css, "text-align", v);
559+ }
560+ }
561+ "line-height" => {
562+ if let Some(v) = val {
563+ push_css(&mut css, "line-height", v);
564+ }
565+ }
566+
567+ // Overflow & positioning
568+ "overflow" => {
569+ if let Some(v) = val {
570+ push_css(&mut css, "overflow", v);
571+ }
572+ }
573+ "position" => {
574+ if let Some(v) = val {
575+ push_css(&mut css, "position", v);
576+ }
577+ }
578+ "z-index" => {
579+ if let Some(v) = val {
580+ push_css(&mut css, "z-index", v);
581+ }
582+ }
583+
584+ // Effects
585+ "shadow" => {
586+ if let Some(v) = val {
587+ push_css(&mut css, "box-shadow", v);
588+ }
589+ }
590+
547591 // Flow
548592 "wrap" => push_css(&mut css, "flex-wrap", "wrap"),
593+ "gap-x" => {
594+ if let Some(v) = val {
595+ push_css(&mut css, "column-gap", &format!("{}px", v));
596+ }
597+ }
598+ "gap-y" => {
599+ if let Some(v) = val {
600+ push_css(&mut css, "row-gap", &format!("{}px", v));
601+ }
602+ }
549603
550604 // Identity — not CSS
551605 "id" | "class" => {}
src/lib.rs+1
@@ -1,3 +1,4 @@
11pub mod ast;
22pub mod codegen;
33pub mod parser;
4+pub mod serve;
src/main.rs+140−20
@@ -1,40 +1,160 @@
11use std::env;
22use std::fs;
3−use std::path::Path;
3+use std::path::{Path, PathBuf};
44use std::process;
55
6−fn main() {
7− let args: Vec<String> = env::args().collect();
8− if args.len() < 2 {
9− eprintln!("Usage: htmlang <file.hl>");
10− process::exit(1);
11− }
12−
13− let input_path = &args[1];
6+fn compile(input_path: &str) -> (bool, Vec<PathBuf>) {
147 let input = match fs::read_to_string(input_path) {
158 Ok(s) => s,
169 Err(e) => {
1710 eprintln!("error: {}: {}", input_path, e);
18− process::exit(1);
11+ return (true, vec![]);
1912 }
2013 };
2114
22− let doc = match htmlang::parser::parse(&input) {
23− Ok(d) => d,
24− Err(e) => {
25− eprintln!("parse error: {}", e);
15+ let base = Path::new(input_path).parent();
16+ let result = htmlang::parser::parse_with_base(&input, base);
17+
18+ for d in &result.diagnostics {
19+ let prefix = match d.severity {
20+ htmlang::parser::Severity::Error => "error",
21+ htmlang::parser::Severity::Warning => "warning",
22+ };
23+ eprintln!("{}: line {}: {}", prefix, d.line, d.message);
24+ }
25+
26+ let has_errors = result
27+ .diagnostics
28+ .iter()
29+ .any(|d| d.severity == htmlang::parser::Severity::Error);
30+
31+ if !has_errors {
32+ let html = htmlang::codegen::generate(&result.document);
33+ let out_path = Path::new(input_path).with_extension("html");
34+ match fs::write(&out_path, &html) {
35+ Ok(()) => eprintln!("wrote {}", out_path.display()),
36+ Err(e) => eprintln!("error: {}: {}", out_path.display(), e),
37+ }
38+ }
39+
40+ (has_errors, result.included_files)
41+}
42+
43+fn main() {
44+ let args: Vec<String> = env::args().collect();
45+ let mut watch = false;
46+ let mut serve = false;
47+ let mut port: u16 = 3000;
48+ let mut input_path = None;
49+
50+ let mut i = 1;
51+ while i < args.len() {
52+ match args[i].as_str() {
53+ "--watch" | "-w" => watch = true,
54+ "--serve" | "-s" => {
55+ serve = true;
56+ watch = true;
57+ }
58+ "--port" | "-p" => {
59+ i += 1;
60+ match args.get(i) {
61+ Some(p) => {
62+ port = p.parse().unwrap_or_else(|_| {
63+ eprintln!("invalid port: {}", p);
64+ process::exit(1);
65+ });
66+ }
67+ None => {
68+ eprintln!("--port requires a value");
69+ process::exit(1);
70+ }
71+ }
72+ }
73+ _ if input_path.is_none() => input_path = Some(args[i].clone()),
74+ _ => {
75+ eprintln!("unknown argument: {}", args[i]);
76+ process::exit(1);
77+ }
78+ }
79+ i += 1;
80+ }
81+
82+ let input_path = match input_path {
83+ Some(p) => p,
84+ None => {
85+ eprintln!("Usage: htmlang [--watch] [--serve [--port N]] <file.hl>");
2686 process::exit(1);
2787 }
2888 };
2989
30− let html = htmlang::codegen::generate(&doc);
90+ let (has_errors, included_files) = compile(&input_path);
3191
32− let out_path = Path::new(input_path).with_extension("html");
33− match fs::write(&out_path, &html) {
34− Ok(()) => eprintln!("wrote {}", out_path.display()),
35− Err(e) => {
36− eprintln!("error: {}: {}", out_path.display(), e);
92+ if !watch {
93+ if has_errors {
3794 process::exit(1);
3895 }
96+ return;
97+ }
98+
99+ // Start dev server if requested
100+ let reload_tx = if serve {
101+ let (tx, _) = tokio::sync::broadcast::channel::<()>(16);
102+ let out_path = Path::new(&input_path).with_extension("html");
103+ let server_tx = tx.clone();
104+ std::thread::spawn(move || {
105+ let rt = tokio::runtime::Runtime::new().expect("failed to create runtime");
106+ rt.block_on(htmlang::serve::run(port, out_path, server_tx));
107+ });
108+ Some(tx)
109+ } else {
110+ None
111+ };
112+
113+ // Watch mode
114+ use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
115+ use std::sync::mpsc;
116+
117+ let (tx, rx) = mpsc::channel();
118+ let mut watcher = RecommendedWatcher::new(
119+ move |res| {
120+ if let Ok(event) = res {
121+ let _ = tx.send(event);
122+ }
123+ },
124+ Config::default(),
125+ )
126+ .expect("failed to create file watcher");
127+
128+ let input_canonical =
129+ fs::canonicalize(&input_path).unwrap_or_else(|_| PathBuf::from(&input_path));
130+ watcher
131+ .watch(&input_canonical, RecursiveMode::NonRecursive)
132+ .expect("failed to watch file");
133+
134+ for inc in &included_files {
135+ let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
136+ }
137+
138+ eprintln!("watching for changes...");
139+
140+ loop {
141+ match rx.recv() {
142+ Ok(_) => {
143+ // Drain additional events (debounce)
144+ while rx.try_recv().is_ok() {}
145+
146+ eprintln!("\nrecompiling...");
147+ let (_, new_includes) = compile(&input_path);
148+
149+ for inc in &new_includes {
150+ let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
151+ }
152+
153+ if let Some(ref tx) = reload_tx {
154+ let _ = tx.send(());
155+ }
156+ }
157+ Err(_) => break,
158+ }
39159 }
40160}
src/parser.rs+199−36
@@ -1,12 +1,36 @@
11use std::collections::HashMap;
22use std::fmt;
3+use std::path::{Path, PathBuf};
34
45use crate::ast::*;
56
6−#[derive(Debug)]
7−pub struct ParseError {
7+// ---------------------------------------------------------------------------
8+// Public types
9+// ---------------------------------------------------------------------------
10+
11+#[derive(Debug, Clone, PartialEq)]
12+pub enum Severity {
13+ Error,
14+ Warning,
15+}
16+
17+#[derive(Debug, Clone)]
18+pub struct Diagnostic {
819 pub line: usize,
920 pub message: String,
21+ pub severity: Severity,
22+}
23+
24+pub struct ParseResult {
25+ pub document: Document,
26+ pub diagnostics: Vec<Diagnostic>,
27+ pub included_files: Vec<PathBuf>,
28+}
29+
30+#[derive(Debug)]
31+struct ParseError {
32+ line: usize,
33+ message: String,
1034}
1135
1236impl fmt::Display for ParseError {
@@ -15,6 +39,10 @@ impl fmt::Display for ParseError {
1539 }
1640}
1741
42+// ---------------------------------------------------------------------------
43+// Internal types
44+// ---------------------------------------------------------------------------
45+
1846#[derive(Clone)]
1947enum LineContent {
2048 Normal(String),
@@ -39,6 +67,10 @@ struct ParseContext {
3967 variables: HashMap<String, String>,
4068 defines: HashMap<String, Vec<Attribute>>,
4169 functions: HashMap<String, FnDef>,
70+ diagnostics: Vec<Diagnostic>,
71+ base_path: Option<PathBuf>,
72+ included_files: Vec<PathBuf>,
73+ include_stack: Vec<PathBuf>,
4274}
4375
4476struct Parser {
@@ -46,7 +78,11 @@ struct Parser {
4678 pos: usize,
4779}
4880
49−pub fn parse(input: &str) -> Result<Document, ParseError> {
81+pub fn parse(input: &str) -> ParseResult {
82+ parse_with_base(input, None)
83+}
84+
85+pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
5086 let lines = preprocess(input);
5187 let mut parser = Parser { lines, pos: 0 };
5288 let mut ctx = ParseContext {
@@ -54,14 +90,22 @@ pub fn parse(input: &str) -> Result<Document, ParseError> {
5490 variables: HashMap::new(),
5591 defines: HashMap::new(),
5692 functions: HashMap::new(),
93+ diagnostics: Vec::new(),
94+ base_path: base_path.map(|p| p.to_path_buf()),
95+ included_files: Vec::new(),
96+ include_stack: Vec::new(),
5797 };
58− let nodes = parser.parse_children(0, &mut ctx)?;
59− Ok(Document {
60− page_title: ctx.page_title,
61− variables: ctx.variables,
62− defines: ctx.defines,
63− nodes,
64− })
98+ let nodes = parser.parse_children(0, &mut ctx);
99+ ParseResult {
100+ document: Document {
101+ page_title: ctx.page_title,
102+ variables: ctx.variables,
103+ defines: ctx.defines,
104+ nodes,
105+ },
106+ diagnostics: ctx.diagnostics,
107+ included_files: ctx.included_files,
108+ }
65109}
66110
67111// ---------------------------------------------------------------------------
@@ -130,10 +174,27 @@ fn preprocess(input: &str) -> Vec<Line> {
130174 }
131175 }
132176
177+ // Join continuation lines for multi-line attribute brackets
178+ let first_line_num = i + 1;
179+ let mut full = trimmed.to_string();
180+ let mut depth: i32 = full.chars().filter(|&c| c == '[').count() as i32
181+ - full.chars().filter(|&c| c == ']').count() as i32;
182+ while depth > 0 && i + 1 < raw_lines.len() {
183+ i += 1;
184+ let next = raw_lines[i].trim();
185+ if next.is_empty() || next.starts_with("--") {
186+ continue;
187+ }
188+ full.push(' ');
189+ full.push_str(next);
190+ depth += next.chars().filter(|&c| c == '[').count() as i32;
191+ depth -= next.chars().filter(|&c| c == ']').count() as i32;
192+ }
193+
133194 lines.push(Line {
134195 indent,
135− content: LineContent::Normal(trimmed.to_string()),
136− line_num: i + 1,
196+ content: LineContent::Normal(full),
197+ line_num: first_line_num,
137198 });
138199 i += 1;
139200 }
@@ -150,7 +211,7 @@ impl Parser {
150211 &mut self,
151212 min_indent: usize,
152213 ctx: &mut ParseContext,
153− ) -> Result<Vec<Node>, ParseError> {
214+ ) -> Vec<Node> {
154215 let mut nodes = Vec::new();
155216
156217 while self.pos < self.lines.len() {
@@ -159,13 +220,26 @@ impl Parser {
159220 break;
160221 }
161222
162− match self.parse_line(ctx)? {
163− Some(new_nodes) => nodes.extend(new_nodes),
164− None => {}
223+ match self.parse_line(ctx) {
224+ Ok(Some(new_nodes)) => nodes.extend(new_nodes),
225+ Ok(None) => {}
226+ Err(e) => {
227+ ctx.diagnostics.push(Diagnostic {
228+ line: e.line,
229+ message: e.message,
230+ severity: Severity::Error,
231+ });
232+ // Skip forward past any deeper-indented children of the errored line
233+ while self.pos < self.lines.len()
234+ && self.lines[self.pos].indent > indent
235+ {
236+ self.pos += 1;
237+ }
238+ }
165239 }
166240 }
167241
168− Ok(nodes)
242+ nodes
169243 }
170244
171245 fn parse_line(
@@ -216,9 +290,50 @@ impl Parser {
216290 return Ok(None);
217291 }
218292
219− if content.starts_with("@include ") {
220− // TODO: implement file includes
221− return Ok(None);
293+ if let Some(rest) = content.strip_prefix("@include ") {
294+ let filename = substitute_vars(rest.trim(), &ctx.variables);
295+ let resolved = match &ctx.base_path {
296+ Some(base) => base.join(&filename),
297+ None => PathBuf::from(&filename),
298+ };
299+
300+ // Circular include check
301+ if ctx.include_stack.contains(&resolved) {
302+ ctx.diagnostics.push(Diagnostic {
303+ line: line_num,
304+ message: format!("circular include '{}'", filename),
305+ severity: Severity::Error,
306+ });
307+ return Ok(None);
308+ }
309+
310+ match std::fs::read_to_string(&resolved) {
311+ Ok(included_text) => {
312+ ctx.included_files.push(resolved.clone());
313+ ctx.include_stack.push(resolved.clone());
314+ let saved_base = ctx.base_path.clone();
315+ ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
316+
317+ let included_lines = preprocess(&included_text);
318+ let mut included_parser = Parser {
319+ lines: included_lines,
320+ pos: 0,
321+ };
322+ let nodes = included_parser.parse_children(0, ctx);
323+
324+ ctx.base_path = saved_base;
325+ ctx.include_stack.pop();
326+ return Ok(Some(nodes));
327+ }
328+ Err(e) => {
329+ ctx.diagnostics.push(Diagnostic {
330+ line: line_num,
331+ message: format!("cannot include '{}': {}", filename, e),
332+ severity: Severity::Error,
333+ });
334+ return Ok(None);
335+ }
336+ }
222337 }
223338
224339 // --- Function definition ---
@@ -285,7 +400,7 @@ impl Parser {
285400 let rest = rest.trim_start();
286401
287402 let args = if rest.starts_with('[') {
288− let (attrs, _) = parse_attr_brackets(rest, line_num, ctx)?;
403+ let (attrs, _) = parse_attr_brackets_no_validate(rest, line_num, ctx)?;
289404 attrs
290405 } else {
291406 Vec::new()
@@ -295,7 +410,7 @@ impl Parser {
295410 let fn_def = ctx.functions.get(name).unwrap().clone();
296411
297412 // Parse caller's children
298− let caller_children = self.parse_children(current_indent + 1, ctx)?;
413+ let caller_children = self.parse_children(current_indent + 1, ctx);
299414
300415 // Save variable state, inject function parameters
301416 let saved_vars = ctx.variables.clone();
@@ -331,7 +446,7 @@ impl Parser {
331446 lines: adjusted,
332447 pos: 0,
333448 };
334− let body_nodes = body_parser.parse_children(0, ctx)?;
449+ let body_nodes = body_parser.parse_children(0, ctx);
335450
336451 // Restore variables
337452 ctx.variables = saved_vars;
@@ -357,7 +472,7 @@ impl Parser {
357472 }
358473
359474 // Parse indented children (belong to the innermost element)
360− let children = self.parse_children(current_indent + 1, ctx)?;
475+ let children = self.parse_children(current_indent + 1, ctx);
361476
362477 // Build chain right-to-left: rightmost gets children, each wraps the next
363478 let mut current_children = children;
@@ -406,7 +521,7 @@ fn extract_element_name(content: &str) -> &str {
406521fn parse_single_element(
407522 content: &str,
408523 line_num: usize,
409− ctx: &ParseContext,
524+ ctx: &mut ParseContext,
410525) -> Result<Element, ParseError> {
411526 let (kind, rest) = if content.starts_with('[') {
412527 // Implicit @el
@@ -489,10 +604,39 @@ fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseErro
489604// Attribute parsing
490605// ---------------------------------------------------------------------------
491606
607+const KNOWN_ATTRS: &[&str] = &[
608+ "spacing", "padding", "padding-x", "padding-y",
609+ "width", "height", "min-width", "max-width", "min-height", "max-height",
610+ "center-x", "center-y", "align-left", "align-right", "align-top", "align-bottom",
611+ "background", "color", "border", "rounded",
612+ "bold", "italic", "underline",
613+ "size", "font", "transition", "cursor", "opacity",
614+ "text-align", "line-height", "overflow", "position", "z-index", "shadow",
615+ "wrap", "gap-x", "gap-y",
616+ "id", "class",
617+];
618+
492619fn parse_attr_brackets(
493620 input: &str,
494621 line_num: usize,
495− ctx: &ParseContext,
622+ ctx: &mut ParseContext,
623+) -> Result<(Vec<Attribute>, String), ParseError> {
624+ parse_attr_brackets_inner(input, line_num, ctx, true)
625+}
626+
627+fn parse_attr_brackets_no_validate(
628+ input: &str,
629+ line_num: usize,
630+ ctx: &mut ParseContext,
631+) -> Result<(Vec<Attribute>, String), ParseError> {
632+ parse_attr_brackets_inner(input, line_num, ctx, false)
633+}
634+
635+fn parse_attr_brackets_inner(
636+ input: &str,
637+ line_num: usize,
638+ ctx: &mut ParseContext,
639+ validate: bool,
496640) -> Result<(Vec<Attribute>, String), ParseError> {
497641 let mut depth = 0;
498642 let mut end_pos = 0;
@@ -520,12 +664,12 @@ fn parse_attr_brackets(
520664
521665 let attrs_inner = &input[1..end_pos];
522666 let remaining = input[end_pos + 1..].trim().to_string();
523− let attrs = parse_attr_list(attrs_inner, ctx);
667+ let attrs = parse_attr_list(attrs_inner, line_num, ctx, validate);
524668
525669 Ok((attrs, remaining))
526670}
527671
528−fn parse_attr_list(input: &str, ctx: &ParseContext) -> Vec<Attribute> {
672+fn parse_attr_list(input: &str, line_num: usize, ctx: &mut ParseContext, validate: bool) -> Vec<Attribute> {
529673 let mut attrs = Vec::new();
530674
531675 for part in split_commas(input) {
@@ -546,17 +690,36 @@ fn parse_attr_list(input: &str, ctx: &ParseContext) -> Vec<Attribute> {
546690 // Substitute variables in value
547691 let part = substitute_vars(part, &ctx.variables);
548692
549− if let Some((key, value)) = part.split_once(' ') {
550− attrs.push(Attribute {
693+ let attr = if let Some((key, value)) = part.split_once(' ') {
694+ Attribute {
551695 key: key.trim().to_string(),
552696 value: Some(value.trim().to_string()),
553− });
697+ }
554698 } else {
555− attrs.push(Attribute {
699+ Attribute {
556700 key: part.to_string(),
557701 value: None,
558− });
702+ }
703+ };
704+
705+ // Warn on unknown attributes
706+ if validate {
707+ let base_key = attr.key.as_str();
708+ let base_key = base_key
709+ .strip_prefix("hover:")
710+ .or_else(|| base_key.strip_prefix("active:"))
711+ .or_else(|| base_key.strip_prefix("focus:"))
712+ .unwrap_or(base_key);
713+ if !KNOWN_ATTRS.contains(&base_key) {
714+ ctx.diagnostics.push(Diagnostic {
715+ line: line_num,
716+ message: format!("unknown attribute '{}'", attr.key),
717+ severity: Severity::Warning,
718+ });
719+ }
559720 }
721+
722+ attrs.push(attr);
560723 }
561724
562725 attrs
@@ -569,8 +732,8 @@ fn split_commas(input: &str) -> Vec<&str> {
569732
570733 for (i, c) in input.char_indices() {
571734 match c {
572− '[' => depth += 1,
573− ']' => depth -= 1,
735+ '[' | '(' => depth += 1,
736+ ']' | ')' => depth -= 1,
574737 ',' if depth == 0 => {
575738 parts.push(&input[start..i]);
576739 start = i + 1;
@@ -630,7 +793,7 @@ fn split_chain(content: &str) -> Vec<String> {
630793// Text segment parsing (inline {...} elements)
631794// ---------------------------------------------------------------------------
632795
633−fn parse_text_segments(input: &str, ctx: &ParseContext) -> Vec<TextSegment> {
796+fn parse_text_segments(input: &str, ctx: &mut ParseContext) -> Vec<TextSegment> {
634797 let mut segments = Vec::new();
635798 let mut current_text = String::new();
636799 let chars: Vec<char> = input.chars().collect();
src/serve.rsnew file+162
@@ -0,0 +1,162 @@
1+use std::path::{Path, PathBuf};
2+
3+use tokio::io::{AsyncReadExt, AsyncWriteExt};
4+use tokio::net::{TcpListener, TcpStream};
5+use tokio::sync::broadcast;
6+
7+const RELOAD_SCRIPT: &str =
8+ r#"<script>new EventSource("/_events").onmessage=()=>location.reload()</script>"#;
9+
10+pub async fn run(port: u16, html_path: PathBuf, reload: broadcast::Sender<()>) {
11+ let listener = match TcpListener::bind(("127.0.0.1", port)).await {
12+ Ok(l) => l,
13+ Err(e) => {
14+ eprintln!("error: failed to bind to port {}: {}", port, e);
15+ return;
16+ }
17+ };
18+
19+ eprintln!("serving at http://127.0.0.1:{}", port);
20+
21+ loop {
22+ if let Ok((stream, _)) = listener.accept().await {
23+ let path = html_path.clone();
24+ let rx = reload.subscribe();
25+ tokio::spawn(async move {
26+ let _ = handle(stream, path, rx).await;
27+ });
28+ }
29+ }
30+}
31+
32+async fn handle(
33+ mut stream: TcpStream,
34+ html_path: PathBuf,
35+ reload_rx: broadcast::Receiver<()>,
36+) -> std::io::Result<()> {
37+ let mut buf = [0u8; 4096];
38+ let n = stream.read(&mut buf).await?;
39+ if n == 0 {
40+ return Ok(());
41+ }
42+
43+ let request = String::from_utf8_lossy(&buf[..n]);
44+ let path = request
45+ .lines()
46+ .next()
47+ .and_then(|line| line.split_whitespace().nth(1))
48+ .unwrap_or("/");
49+
50+ if path == "/_events" {
51+ handle_sse(stream, reload_rx).await
52+ } else {
53+ handle_file(stream, &html_path, path).await
54+ }
55+}
56+
57+async fn handle_sse(
58+ mut stream: TcpStream,
59+ mut rx: broadcast::Receiver<()>,
60+) -> std::io::Result<()> {
61+ let headers = "HTTP/1.1 200 OK\r\n\
62+ Content-Type: text/event-stream\r\n\
63+ Cache-Control: no-cache\r\n\
64+ Connection: keep-alive\r\n\r\n";
65+ stream.write_all(headers.as_bytes()).await?;
66+
67+ loop {
68+ match rx.recv().await {
69+ Ok(()) => {
70+ if stream.write_all(b"data: reload\n\n").await.is_err() {
71+ break;
72+ }
73+ }
74+ Err(broadcast::error::RecvError::Lagged(_)) => continue,
75+ Err(broadcast::error::RecvError::Closed) => break,
76+ }
77+ }
78+
79+ Ok(())
80+}
81+
82+async fn handle_file(
83+ mut stream: TcpStream,
84+ html_path: &Path,
85+ request_path: &str,
86+) -> std::io::Result<()> {
87+ if request_path.contains("..") {
88+ stream
89+ .write_all(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n")
90+ .await?;
91+ return Ok(());
92+ }
93+
94+ let file_path = if request_path == "/" {
95+ html_path.to_path_buf()
96+ } else {
97+ let dir = html_path.parent().unwrap_or(Path::new("."));
98+ dir.join(&request_path[1..])
99+ };
100+
101+ let body = match std::fs::read(&file_path) {
102+ Ok(content) => {
103+ if file_path.extension().map_or(false, |e| e == "html") {
104+ inject_reload_script(&content)
105+ } else {
106+ content
107+ }
108+ }
109+ Err(_) => {
110+ stream
111+ .write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n")
112+ .await?;
113+ return Ok(());
114+ }
115+ };
116+
117+ let content_type = content_type_for(&file_path);
118+ let header = format!(
119+ "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nCache-Control: no-cache\r\n\r\n",
120+ body.len(),
121+ );
122+ stream.write_all(header.as_bytes()).await?;
123+ stream.write_all(&body).await?;
124+
125+ Ok(())
126+}
127+
128+fn inject_reload_script(content: &[u8]) -> Vec<u8> {
129+ let html = String::from_utf8_lossy(content);
130+ match html.rfind("</body>") {
131+ Some(pos) => {
132+ let mut result = String::with_capacity(html.len() + RELOAD_SCRIPT.len() + 1);
133+ result.push_str(&html[..pos]);
134+ result.push_str(RELOAD_SCRIPT);
135+ result.push('\n');
136+ result.push_str(&html[pos..]);
137+ result.into_bytes()
138+ }
139+ None => {
140+ let mut result = content.to_vec();
141+ result.extend_from_slice(RELOAD_SCRIPT.as_bytes());
142+ result
143+ }
144+ }
145+}
146+
147+fn content_type_for(path: &Path) -> &'static str {
148+ match path.extension().and_then(|e| e.to_str()) {
149+ Some("html") => "text/html; charset=utf-8",
150+ Some("css") => "text/css; charset=utf-8",
151+ Some("js") => "application/javascript",
152+ Some("json") => "application/json",
153+ Some("png") => "image/png",
154+ Some("jpg" | "jpeg") => "image/jpeg",
155+ Some("gif") => "image/gif",
156+ Some("svg") => "image/svg+xml",
157+ Some("ico") => "image/x-icon",
158+ Some("woff") => "font/woff",
159+ Some("woff2") => "font/woff2",
160+ _ => "application/octet-stream",
161+ }
162+}