on master help sign in
Add 21 improvements: parser error safety, Levenshtein optimization, formatter fixes, LSP enhancements, HTTPS support, watch dep graph refresh, tests, CI workflow, and documentation
commit1a9155bfff328d76243a2d8b47fb59927bcb118c authoriko <mail@iko.soy> dateThu 16 Apr 2026, 07:12 (6 months ago) parent84a97c2
19 files changed+1754−100
.github/workflows/ci.yml+42 CONTRIBUTING.md+63 Cargo.lock+211 Cargo.toml+3−1 DESIGN.md+67 README.md+37−3 crates/htmlang-core/src/codegen.rs+1−1 crates/htmlang-core/src/codegen_tests.rs+81 crates/htmlang-core/src/lib.rs+3 crates/htmlang-core/src/parser.rs+181−28 editors/vscode/README.md+56 examples/blog.hl+49 examples/docs.hl+76 examples/landing.hl+71 src/bin/htmlang_lsp/analysis.rs+16−6 src/bin/htmlang_lsp/main.rs+293−6 src/fmt.rs+214−26 src/main.rs+126−8 src/serve.rs+164−21
.github/workflows/ci.ymlnew file+42
@@ -0,0 +1,42 @@
1+name: CI
2+
3+on:
4+ push:
5+ branches: [master, main]
6+ pull_request:
7+ workflow_dispatch:
8+
9+env:
10+ CARGO_TERM_COLOR: always
11+ RUSTFLAGS: -D warnings
12+
13+jobs:
14+ test:
15+ name: Test ${{ matrix.os }}
16+ runs-on: ${{ matrix.os }}
17+ strategy:
18+ fail-fast: false
19+ matrix:
20+ os: [ubuntu-latest, macos-latest, windows-latest]
21+ steps:
22+ - uses: actions/checkout@v4
23+ - uses: dtolnay/rust-toolchain@stable
24+ - uses: Swatinem/rust-cache@v2
25+ - name: cargo test
26+ run: cargo test --workspace --all-targets
27+
28+ lint:
29+ name: Clippy + fmt
30+ runs-on: ubuntu-latest
31+ steps:
32+ - uses: actions/checkout@v4
33+ - uses: dtolnay/rust-toolchain@stable
34+ with:
35+ components: rustfmt, clippy
36+ - uses: Swatinem/rust-cache@v2
37+ - name: cargo fmt --check
38+ run: cargo fmt --all -- --check
39+ - name: cargo clippy
40+ # Treat warnings as errors. Tests exempted so new tests can land without
41+ # a lint gate blocking them.
42+ run: cargo clippy --workspace --lib --bins -- -D warnings
CONTRIBUTING.mdnew file+63
@@ -0,0 +1,63 @@
1+# Contributing
2+
3+Thanks for your interest in improving htmlang. This file covers the practical
4+bits: how to run the project locally, where the code lives, and what style of
5+changes land smoothly.
6+
7+## Development
8+
9+```
10+cargo build # build the CLI + LSP binaries
11+cargo test # run all tests (unit + snapshot + integration)
12+cargo run -- page.hl # compile a .hl file to page.html
13+cargo run -- serve examples # run the dev server at http://127.0.0.1:3000
14+```
15+
16+CI runs `cargo test`, `cargo fmt --check`, and `cargo clippy -- -D warnings` on
17+Linux, macOS, and Windows. Your change should pass all three.
18+
19+## Repository layout
20+
21+- `crates/htmlang-core/` — parser, AST, code generator. No I/O lives here.
22+- `crates/htmlang-wasm/` — thin wrapper exposing `compile` to the web playground.
23+- `src/` — CLI, dev server, formatter, and HTML-to-hl converter.
24+- `src/bin/htmlang_lsp/` — language server binary (`htmlang-lsp`).
25+- `editors/vscode/` — VS Code extension.
26+- `tests/snapshots.rs` — integration / snapshot tests for the compiler.
27+- `examples/` — sample `.hl` files used as smoke tests and documentation.
28+
29+## Adding a feature
30+
31+1. Add a test first. Most language features fit as a new `#[test]` in
32+ `tests/snapshots.rs`; prefer integration tests that exercise the full
33+ parser-to-HTML pipeline. Pure parser / codegen helpers can live as unit
34+ tests alongside the code.
35+2. Thread the feature through the parser, then codegen, then the LSP (hover,
36+ completions, diagnostics).
37+3. Document it in `DESIGN.md`. If it's user-facing, also update `README.md`.
38+4. If it changes the CLI surface, update the `--help` output and the shell
39+ completions.
40+
41+## Style
42+
43+- Prefer enum-based ASTs and pattern matching over stringly-typed dispatch.
44+- Keep `@` prefixes and bracket-attribute syntax consistent with existing
45+ directives. Add `KNOWN_DIRECTIVES` entries when you add a new `@foo`.
46+- Diagnostics should include `line` and, when practical, `column` and a
47+ `source_line` excerpt. Use `Severity::Help` for suggestions, not `Warning`.
48+- No unwrap() on parsed user input. Use `Result<_, ParseError>` and record a
49+ diagnostic so the compiler keeps going.
50+
51+## Reporting bugs
52+
53+Bug reports are most useful when they include:
54+
55+- The exact `.hl` input that reproduces the issue (minimized if possible).
56+- The command you ran and the output you saw.
57+- The output you expected instead.
58+- `htmlang --version` and your OS.
59+
60+## License
61+
62+By contributing, you agree that your changes will be licensed under the same
63+terms as the rest of the project.
Cargo.lock+211
@@ -24,6 +24,28 @@ dependencies = [
2424 "syn",
2525]
2626
27+[[package]]
28+name = "aws-lc-rs"
29+version = "1.16.3"
30+source = "registry+https://github.com/rust-lang/crates.io-index"
31+checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f"
32+dependencies = [
33+ "aws-lc-sys",
34+ "zeroize",
35+]
36+
37+[[package]]
38+name = "aws-lc-sys"
39+version = "0.40.0"
40+source = "registry+https://github.com/rust-lang/crates.io-index"
41+checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7"
42+dependencies = [
43+ "cc",
44+ "cmake",
45+ "dunce",
46+ "fs_extra",
47+]
48+
2749[[package]]
2850name = "bitflags"
2951version = "1.3.2"
@@ -48,12 +70,33 @@ version = "1.11.1"
4870source = "registry+https://github.com/rust-lang/crates.io-index"
4971checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
5072
73+[[package]]
74+name = "cc"
75+version = "1.2.60"
76+source = "registry+https://github.com/rust-lang/crates.io-index"
77+checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
78+dependencies = [
79+ "find-msvc-tools",
80+ "jobserver",
81+ "libc",
82+ "shlex",
83+]
84+
5185[[package]]
5286name = "cfg-if"
5387version = "1.0.4"
5488source = "registry+https://github.com/rust-lang/crates.io-index"
5589checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
5690
91+[[package]]
92+name = "cmake"
93+version = "0.1.58"
94+source = "registry+https://github.com/rust-lang/crates.io-index"
95+checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
96+dependencies = [
97+ "cc",
98+]
99+
57100[[package]]
58101name = "dashmap"
59102version = "5.5.3"
@@ -78,6 +121,12 @@ dependencies = [
78121 "syn",
79122]
80123
124+[[package]]
125+name = "dunce"
126+version = "1.0.5"
127+source = "registry+https://github.com/rust-lang/crates.io-index"
128+checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
129+
81130[[package]]
82131name = "filetime"
83132version = "0.2.27"
@@ -89,6 +138,12 @@ dependencies = [
89138 "libredox",
90139]
91140
141+[[package]]
142+name = "find-msvc-tools"
143+version = "0.1.9"
144+source = "registry+https://github.com/rust-lang/crates.io-index"
145+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
146+
92147[[package]]
93148name = "form_urlencoded"
94149version = "1.2.2"
@@ -98,6 +153,12 @@ dependencies = [
98153 "percent-encoding",
99154]
100155
156+[[package]]
157+name = "fs_extra"
158+version = "1.3.0"
159+source = "registry+https://github.com/rust-lang/crates.io-index"
160+checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
161+
101162[[package]]
102163name = "fsevent-sys"
103164version = "4.1.0"
@@ -183,6 +244,29 @@ dependencies = [
183244 "slab",
184245]
185246
247+[[package]]
248+name = "getrandom"
249+version = "0.2.17"
250+source = "registry+https://github.com/rust-lang/crates.io-index"
251+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
252+dependencies = [
253+ "cfg-if",
254+ "libc",
255+ "wasi",
256+]
257+
258+[[package]]
259+name = "getrandom"
260+version = "0.3.4"
261+source = "registry+https://github.com/rust-lang/crates.io-index"
262+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
263+dependencies = [
264+ "cfg-if",
265+ "libc",
266+ "r-efi",
267+ "wasip2",
268+]
269+
186270[[package]]
187271name = "hashbrown"
188272version = "0.14.5"
@@ -195,7 +279,9 @@ version = "0.1.0"
195279dependencies = [
196280 "htmlang-core",
197281 "notify",
282+ "rustls-pemfile",
198283 "tokio",
284+ "tokio-rustls",
199285 "tower-lsp",
200286]
201287
@@ -355,6 +441,16 @@ version = "1.0.18"
355441source = "registry+https://github.com/rust-lang/crates.io-index"
356442checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
357443
444+[[package]]
445+name = "jobserver"
446+version = "0.1.34"
447+source = "registry+https://github.com/rust-lang/crates.io-index"
448+checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
449+dependencies = [
450+ "getrandom 0.3.4",
451+ "libc",
452+]
453+
358454[[package]]
359455name = "kqueue"
360456version = "1.1.1"
@@ -557,6 +653,12 @@ dependencies = [
557653 "proc-macro2",
558654]
559655
656+[[package]]
657+name = "r-efi"
658+version = "5.3.0"
659+source = "registry+https://github.com/rust-lang/crates.io-index"
660+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
661+
560662[[package]]
561663name = "redox_syscall"
562664version = "0.5.18"
@@ -575,6 +677,66 @@ dependencies = [
575677 "bitflags 2.11.1",
576678]
577679
680+[[package]]
681+name = "ring"
682+version = "0.17.14"
683+source = "registry+https://github.com/rust-lang/crates.io-index"
684+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
685+dependencies = [
686+ "cc",
687+ "cfg-if",
688+ "getrandom 0.2.17",
689+ "libc",
690+ "untrusted",
691+ "windows-sys 0.52.0",
692+]
693+
694+[[package]]
695+name = "rustls"
696+version = "0.23.38"
697+source = "registry+https://github.com/rust-lang/crates.io-index"
698+checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21"
699+dependencies = [
700+ "aws-lc-rs",
701+ "log",
702+ "once_cell",
703+ "ring",
704+ "rustls-pki-types",
705+ "rustls-webpki",
706+ "subtle",
707+ "zeroize",
708+]
709+
710+[[package]]
711+name = "rustls-pemfile"
712+version = "2.2.0"
713+source = "registry+https://github.com/rust-lang/crates.io-index"
714+checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
715+dependencies = [
716+ "rustls-pki-types",
717+]
718+
719+[[package]]
720+name = "rustls-pki-types"
721+version = "1.14.0"
722+source = "registry+https://github.com/rust-lang/crates.io-index"
723+checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
724+dependencies = [
725+ "zeroize",
726+]
727+
728+[[package]]
729+name = "rustls-webpki"
730+version = "0.103.12"
731+source = "registry+https://github.com/rust-lang/crates.io-index"
732+checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06"
733+dependencies = [
734+ "aws-lc-rs",
735+ "ring",
736+ "rustls-pki-types",
737+ "untrusted",
738+]
739+
578740[[package]]
579741name = "rustversion"
580742version = "1.0.22"
@@ -650,6 +812,12 @@ dependencies = [
650812 "syn",
651813]
652814
815+[[package]]
816+name = "shlex"
817+version = "1.3.0"
818+source = "registry+https://github.com/rust-lang/crates.io-index"
819+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
820+
653821[[package]]
654822name = "slab"
655823version = "0.4.12"
@@ -678,6 +846,12 @@ version = "1.2.1"
678846source = "registry+https://github.com/rust-lang/crates.io-index"
679847checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
680848
849+[[package]]
850+name = "subtle"
851+version = "2.6.1"
852+source = "registry+https://github.com/rust-lang/crates.io-index"
853+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
854+
681855[[package]]
682856name = "syn"
683857version = "2.0.117"
@@ -736,6 +910,16 @@ dependencies = [
736910 "syn",
737911]
738912
913+[[package]]
914+name = "tokio-rustls"
915+version = "0.26.4"
916+source = "registry+https://github.com/rust-lang/crates.io-index"
917+checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
918+dependencies = [
919+ "rustls",
920+ "tokio",
921+]
922+
739923[[package]]
740924name = "tokio-util"
741925version = "0.7.18"
@@ -846,6 +1030,12 @@ version = "1.0.24"
8461030source = "registry+https://github.com/rust-lang/crates.io-index"
8471031checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
8481032
1033+[[package]]
1034+name = "untrusted"
1035+version = "0.9.0"
1036+source = "registry+https://github.com/rust-lang/crates.io-index"
1037+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
1038+
8491039[[package]]
8501040name = "url"
8511041version = "2.5.8"
@@ -881,6 +1071,15 @@ version = "0.11.1+wasi-snapshot-preview1"
8811071source = "registry+https://github.com/rust-lang/crates.io-index"
8821072checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
8831073
1074+[[package]]
1075+name = "wasip2"
1076+version = "1.0.2+wasi-0.2.9"
1077+source = "registry+https://github.com/rust-lang/crates.io-index"
1078+checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
1079+dependencies = [
1080+ "wit-bindgen",
1081+]
1082+
8841083[[package]]
8851084name = "wasm-bindgen"
8861085version = "0.2.114"
@@ -1023,6 +1222,12 @@ version = "0.52.6"
10231222source = "registry+https://github.com/rust-lang/crates.io-index"
10241223checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
10251224
1225+[[package]]
1226+name = "wit-bindgen"
1227+version = "0.51.0"
1228+source = "registry+https://github.com/rust-lang/crates.io-index"
1229+checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
1230+
10261231[[package]]
10271232name = "writeable"
10281233version = "0.6.3"
@@ -1073,6 +1278,12 @@ dependencies = [
10731278 "synstructure",
10741279]
10751280
1281+[[package]]
1282+name = "zeroize"
1283+version = "1.8.2"
1284+source = "registry+https://github.com/rust-lang/crates.io-index"
1285+checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
1286+
10761287[[package]]
10771288name = "zerotrie"
10781289version = "0.2.4"
Cargo.toml+3−1
@@ -13,7 +13,9 @@ path = "src/bin/htmlang_lsp/main.rs"
1313[dependencies]
1414htmlang-core = { path = "crates/htmlang-core" }
1515notify = "7"
16−tokio = { version = "1", features = ["io-std", "io-util", "macros", "net", "rt-multi-thread", "sync"] }
16+rustls-pemfile = "2.2.0"
17+tokio = { version = "1", features = ["io-std", "io-util", "macros", "net", "rt-multi-thread", "sync", "time"] }
18+tokio-rustls = { version = "0.26.4", features = ["ring"] }
1719tower-lsp = "0.20"
1820
1921[profile.release]
DESIGN.md+67
@@ -1218,3 +1218,70 @@ A VS Code extension is available in `editors/vscode/` with:
12181218 - Code folding and document symbols
12191219 - Semantic tokens for syntax highlighting
12201220 - VS Code snippets for common patterns (`@fn`, `@each`, `@grid`, `@form`, etc.)
1221+
1222+## Additional directives reference
1223+
1224+The compiler recognizes a number of directives that are documented only briefly
1225+above. A concise reference:
1226+
1227+### Content generation
1228+
1229+- `@markdown` — indented body is parsed as Markdown and emitted as HTML.
1230+- `@raw """..."""` — embed verbatim HTML / CSS / JS.
1231+- `@svg path/to/icon.svg` — inline an SVG file's contents.
1232+- `@fetch url -> $name` — fetch a JSON payload at build time and bind it.
1233+- `@data name path.json` — load a JSON file for iteration with `@each`.
1234+
1235+### Metadata & head
1236+
1237+- `@lang en` — set `<html lang>`.
1238+- `@favicon /favicon.png` — inject a favicon link.
1239+- `@meta name value` — add a `<meta>` tag.
1240+- `@og key value` — add an Open Graph `<meta property="og:key">` tag.
1241+- `@canonical https://…` — set `<link rel="canonical">`.
1242+- `@base /base/path/` — set `<base href>`.
1243+- `@manifest [Name]` — emit a PWA `manifest.json` reference and body.
1244+- `@breakpoint name width` — register a custom responsive prefix.
1245+
1246+### Styling
1247+
1248+- `@style` — indented body is raw CSS injected into `<style>`.
1249+- `@scope (selector)` — wrap body in CSS `@scope` for scoped styles.
1250+- `@starting-style` — CSS entry-animation starting values.
1251+- `@font-face name url` — register a web font.
1252+- `@theme` — design tokens emitted as CSS custom properties.
1253+- `@keyframes name` — define keyframe animations.
1254+- `@css-property name syntax value` — register a typed custom property.
1255+
1256+### Control flow
1257+
1258+- `@if cond` / `@unless cond` / `@else` / `@else if`.
1259+- `@match $value` / `@switch $value` — exhaustive branching.
1260+- `@each item in list` / `@each item, $_index in list` — iterate.
1261+- `@for $i in 0..10 [step 2]` — numeric ranges.
1262+- `@repeat N` — repeat a block N times.
1263+- `@defer` — defer block rendering below the fold.
1264+
1265+### Module system
1266+
1267+- `@include file.hl` — paste file contents here.
1268+- `@import file.hl` — import @fn / @let / @define (with optional `as` alias).
1269+- `@use "file.hl" fn1, fn2` — import a named subset.
1270+- `@extends template.hl` — template inheritance; override with `@slot`.
1271+- `@layout path.hl` — wrap output in a layout template.
1272+- `@collection path/ -> $items` — iterate over a directory.
1273+
1274+### Functions & composition
1275+
1276+- `@fn name $p1 $p2` — define a function; body uses `@children` / `@slot`.
1277+- `@component name $p` — like `@fn` but emits a scoped wrapper class.
1278+- `@mixin name [...attrs]` — attribute bundle you can merge elsewhere.
1279+- `@define name [...attrs]` — named attribute preset, used as `[$name]`.
1280+- `@with $source as $alias` — temporarily rebind a variable.
1281+
1282+### Debugging
1283+
1284+- `@assert cond [message]` — emit an error if a boolean check fails.
1285+- `@warn message` / `@debug expr` / `@log message` — compile-time output.
1286+- `@deprecated name [reason]` — mark a function as deprecated.
1287+- `@translations locale` — bind i18n strings available as `$t.key`.
README.md+37−3
@@ -38,12 +38,46 @@ cargo install --path .
3838
3939## Usage
4040
41+Everyday workflow:
42+
4143```
4244htmlang init # scaffold a new project
43−htmlang page.hl # writes page.html
45+htmlang page.hl # compile page.hl -> page.html
4446htmlang --watch page.hl # recompile on change
45−htmlang fmt page.hl # format a file
46−```
47+htmlang 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 `@fn` / `@component` definitions |
63+| `deps file.hl` | Print the include / import graph |
64+| `dead-code [dir]` | Report unused `@fn` / `@let` / `@define` |
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 |
4781
4882## Editor support
4983
crates/htmlang-core/src/codegen.rs+1−1
@@ -16,7 +16,7 @@ const BREAKPOINTS: &[(&str, &str)] = &[
1616];
1717
1818/// Generate short CSS class names: a, b, ..., z, a0, a1, ..., z9, aa, ab, ...
19−fn short_class_name(idx: usize) -> String {
19+pub(crate) fn short_class_name(idx: usize) -> String {
2020 if idx < 26 {
2121 return String::from((b'a' + idx as u8) as char);
2222 }
crates/htmlang-core/src/codegen_tests.rsnew file+81
@@ -0,0 +1,81 @@
1+#[cfg(test)]
2+mod tests {
3+ use crate::parser::parse;
4+ use crate::codegen::{generate, generate_dev, generate_partial, short_class_name};
5+
6+ fn compile(src: &str) -> String {
7+ let r = parse(src);
8+ assert!(
9+ r.diagnostics
10+ .iter()
11+ .all(|d| d.severity != crate::parser::Severity::Error),
12+ "unexpected parser error for src {src:?}: {:?}",
13+ r.diagnostics
14+ );
15+ generate(&r.document)
16+ }
17+
18+ #[test]
19+ fn non_empty_document_emits_doctype() {
20+ let out = compile("@text hello\n");
21+ assert!(out.to_lowercase().contains("<!doctype html>"), "missing doctype: {out}");
22+ }
23+
24+ #[test]
25+ fn text_content_is_html_escaped() {
26+ let out = compile("@text <script>alert(1)</script>\n");
27+ assert!(!out.contains("<script>alert(1)</script>"), "raw script should be escaped: {out}");
28+ assert!(out.contains("&lt;script&gt;"), "escaped entities missing: {out}");
29+ }
30+
31+ #[test]
32+ fn partial_output_omits_doctype_and_html_wrapper() {
33+ let r = parse("@text hi\n");
34+ let out = generate_partial(&r.document);
35+ assert!(!out.to_lowercase().contains("<!doctype html>"));
36+ assert!(!out.to_lowercase().contains("<html"));
37+ }
38+
39+ #[test]
40+ fn dev_mode_is_deterministic_across_runs() {
41+ let src = "@row [spacing 10]\n @text [bold] a\n @text [italic] b\n";
42+ let r1 = parse(src);
43+ let r2 = parse(src);
44+ assert_eq!(generate_dev(&r1.document), generate_dev(&r2.document));
45+ }
46+
47+ #[test]
48+ fn short_class_name_is_stable() {
49+ assert_eq!(short_class_name(0), "a");
50+ assert_eq!(short_class_name(25), "z");
51+ // Higher indexes must produce unique, stable names.
52+ let names: std::collections::HashSet<_> =
53+ (0..200).map(short_class_name).collect();
54+ assert_eq!(names.len(), 200, "short_class_name collisions");
55+ }
56+
57+ #[test]
58+ fn page_title_appears_in_head() {
59+ let out = compile("@page Welcome\n");
60+ assert!(out.contains("<title>Welcome</title>"), "title missing in:\n{out}");
61+ }
62+
63+ #[test]
64+ fn same_styles_share_one_class() {
65+ // Both elements request padding:10 + background red. The collector must
66+ // dedupe them into a single generated class rather than emitting two.
67+ let src = "@row\n @el [padding 10, background red] a\n @el [padding 10, background red] b\n";
68+ let out = compile(src);
69+ // Count occurrences of a `.X{` CSS class declaration that contains both
70+ // padding and the red color. With dedup, the rule body should appear at
71+ // most once in the <style> block.
72+ let mut count = 0;
73+ let needle = "padding:10px;background:red";
74+ let mut rest = out.as_str();
75+ while let Some(pos) = rest.find(needle) {
76+ count += 1;
77+ rest = &rest[pos + needle.len()..];
78+ }
79+ assert!(count <= 1, "duplicate CSS rule emitted ({count} times) in:\n{out}");
80+ }
81+}
crates/htmlang-core/src/lib.rs+3
@@ -1,3 +1,6 @@
11pub mod ast;
22pub mod codegen;
33pub mod parser;
4+
5+#[cfg(test)]
6+mod codegen_tests;
crates/htmlang-core/src/parser.rs+181−28
@@ -12,6 +12,8 @@ use crate::ast::*;
1212pub enum Severity {
1313 Error,
1414 Warning,
15+ Info,
16+ Help,
1517}
1618
1719#[derive(Debug, Clone)]
@@ -578,7 +580,11 @@ impl Parser {
578580
579581 // --- @scope block (CSS scoping) ---
580582 if content.trim() == "@scope" || content.starts_with("@scope ") {
581− let scope_selector = content.strip_prefix("@scope").unwrap().trim().to_string();
583+ let scope_selector = content
584+ .strip_prefix("@scope")
585+ .unwrap_or("")
586+ .trim()
587+ .to_string();
582588 let mut scope_content = String::new();
583589 while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
584590 match &self.lines[self.pos].content {
@@ -644,7 +650,11 @@ impl Parser {
644650
645651 // --- @manifest (PWA web manifest) ---
646652 if content.trim() == "@manifest" || content.starts_with("@manifest ") {
647− let mut name = content.strip_prefix("@manifest").unwrap().trim().to_string();
653+ let mut name = content
654+ .strip_prefix("@manifest")
655+ .unwrap_or("")
656+ .trim()
657+ .to_string();
648658 if name.is_empty() {
649659 name = ctx.page_title.clone().unwrap_or_else(|| "App".to_string());
650660 }
@@ -2358,7 +2368,11 @@ impl Parser {
23582368 // --- @translations (i18n) ---
23592369
23602370 if content.trim() == "@translations" || content.starts_with("@translations ") {
2361− let locale = content.strip_prefix("@translations").unwrap().trim().to_string();
2371+ let locale = content
2372+ .strip_prefix("@translations")
2373+ .unwrap_or("")
2374+ .trim()
2375+ .to_string();
23622376 let active_locale = if locale.is_empty() {
23632377 ctx.lang.clone().unwrap_or_else(|| "en".to_string())
23642378 } else {
@@ -2760,8 +2774,19 @@ impl Parser {
27602774 Vec::new()
27612775 };
27622776
2763− // Clone function definition (releases borrow on ctx)
2764− let fn_def = ctx.functions.get(name).unwrap().clone();
2777+ // Clone function definition (releases borrow on ctx). If the function is
2778+ // missing (shouldn't normally happen — callers check ctx.functions.contains_key
2779+ // first — but we avoid panicking on malformed state).
2780+ let fn_def = match ctx.functions.get(name) {
2781+ Some(def) => def.clone(),
2782+ None => {
2783+ ctx.fn_call_stack.pop();
2784+ return Err(ParseError {
2785+ line: line_num,
2786+ message: format!("undefined function @{}", name),
2787+ });
2788+ }
2789+ };
27652790
27662791 // Parse caller's children, separating named slots from default children
27672792 let all_caller_children = self.parse_children(current_indent + 1, ctx);
@@ -2865,7 +2890,10 @@ impl Parser {
28652890 current_children = vec![Node::Element(elem)];
28662891 }
28672892
2868− Ok(current_children.into_iter().next().unwrap())
2893+ current_children.into_iter().next().ok_or(ParseError {
2894+ line: line_num,
2895+ message: "element chain produced no nodes (this is a parser bug)".to_string(),
2896+ })
28692897 }
28702898}
28712899
@@ -3150,33 +3178,55 @@ fn format_include_chain(stack: &[PathBuf]) -> String {
31503178 .join(" → ")
31513179}
31523180
3153−fn levenshtein(a: &str, b: &str) -> usize {
3154− let a: Vec<char> = a.chars().collect();
3155− let b: Vec<char> = b.chars().collect();
3156− let mut dp = vec![vec![0usize; b.len() + 1]; a.len() + 1];
3157− for i in 0..=a.len() {
3158− dp[i][0] = i;
3181+/// Levenshtein distance with a rolling two-row buffer (O(min(a,b)) memory) and an
3182+/// early-exit cutoff: if every cell in a row exceeds `cutoff`, no further cell can
3183+/// be <= `cutoff`, so we return `cutoff + 1` immediately. Used for typo-suggestion
3184+/// hot paths where we only care about distances <= 2.
3185+fn levenshtein_bounded(a: &[char], b: &[char], cutoff: usize) -> usize {
3186+ let (a, b) = if a.len() > b.len() { (b, a) } else { (a, b) };
3187+ if b.len() - a.len() > cutoff {
3188+ return cutoff + 1;
31593189 }
3160− for j in 0..=b.len() {
3161− dp[0][j] = j;
3190+ if a.is_empty() {
3191+ return b.len();
31623192 }
3163− for i in 1..=a.len() {
3164− for j in 1..=b.len() {
3165− let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
3166− dp[i][j] = (dp[i - 1][j] + 1)
3167− .min(dp[i][j - 1] + 1)
3168− .min(dp[i - 1][j - 1] + cost);
3193+
3194+ let mut prev: Vec<usize> = (0..=a.len()).collect();
3195+ let mut curr: Vec<usize> = vec![0; a.len() + 1];
3196+
3197+ for (j, &bc) in b.iter().enumerate() {
3198+ curr[0] = j + 1;
3199+ let mut row_min = curr[0];
3200+ for (i, &ac) in a.iter().enumerate() {
3201+ let cost = if ac == bc { 0 } else { 1 };
3202+ curr[i + 1] = (prev[i + 1] + 1)
3203+ .min(curr[i] + 1)
3204+ .min(prev[i] + cost);
3205+ row_min = row_min.min(curr[i + 1]);
31693206 }
3207+ if row_min > cutoff {
3208+ return cutoff + 1;
3209+ }
3210+ std::mem::swap(&mut prev, &mut curr);
31703211 }
3171− dp[a.len()][b.len()]
3212+ prev[a.len()]
31723213}
31733214
31743215fn suggest_closest<'a>(input: &str, candidates: &[&'a str]) -> Option<&'a str> {
3216+ let input_chars: Vec<char> = input.chars().collect();
3217+ let max_allowed = 2usize.min(input_chars.len().saturating_sub(1));
31753218 let mut best = None;
31763219 let mut best_dist = usize::MAX;
31773220 for &candidate in candidates {
3178− let dist = levenshtein(input, candidate);
3179− if dist < best_dist && dist <= 2 && dist < input.len() {
3221+ // Cheap length-based prune before allocating chars.
3222+ let clen = candidate.chars().count();
3223+ let diff = clen.abs_diff(input_chars.len());
3224+ if diff > max_allowed {
3225+ continue;
3226+ }
3227+ let cand_chars: Vec<char> = candidate.chars().collect();
3228+ let dist = levenshtein_bounded(&input_chars, &cand_chars, max_allowed);
3229+ if dist < best_dist && dist <= max_allowed {
31803230 best_dist = dist;
31813231 best = Some(candidate);
31823232 }
@@ -3186,11 +3236,18 @@ fn suggest_closest<'a>(input: &str, candidates: &[&'a str]) -> Option<&'a str> {
31863236
31873237/// Suggest the closest user-defined function name for typos.
31883238fn suggest_fn_name(input: &str, ctx: &ParseContext) -> Option<String> {
3239+ let input_chars: Vec<char> = input.chars().collect();
3240+ let max_allowed = 2usize.min(input_chars.len().saturating_sub(1));
31893241 let mut best: Option<String> = None;
31903242 let mut best_dist = usize::MAX;
31913243 for name in ctx.functions.keys() {
3192− let dist = levenshtein(input, name);
3193− if dist < best_dist && dist <= 2 && dist < input.len() {
3244+ let nlen = name.chars().count();
3245+ if nlen.abs_diff(input_chars.len()) > max_allowed {
3246+ continue;
3247+ }
3248+ let name_chars: Vec<char> = name.chars().collect();
3249+ let dist = levenshtein_bounded(&input_chars, &name_chars, max_allowed);
3250+ if dist < best_dist && dist <= max_allowed {
31943251 best_dist = dist;
31953252 best = Some(name.clone());
31963253 }
@@ -3200,11 +3257,18 @@ fn suggest_fn_name(input: &str, ctx: &ParseContext) -> Option<String> {
32003257
32013258/// Suggest the closest variable name for undefined `$var` references.
32023259fn suggest_var_name(input: &str, vars: &HashMap<String, String>) -> Option<String> {
3260+ let input_chars: Vec<char> = input.chars().collect();
3261+ let max_allowed = 2usize.min(input_chars.len().saturating_sub(1));
32033262 let mut best: Option<String> = None;
32043263 let mut best_dist = usize::MAX;
32053264 for name in vars.keys() {
3206− let dist = levenshtein(input, name);
3207− if dist < best_dist && dist <= 2 && dist < input.len() {
3265+ let nlen = name.chars().count();
3266+ if nlen.abs_diff(input_chars.len()) > max_allowed {
3267+ continue;
3268+ }
3269+ let name_chars: Vec<char> = name.chars().collect();
3270+ let dist = levenshtein_bounded(&input_chars, &name_chars, max_allowed);
3271+ if dist < best_dist && dist <= max_allowed {
32083272 best_dist = dist;
32093273 best = Some(name.clone());
32103274 }
@@ -4125,7 +4189,9 @@ fn evaluate_arithmetic(input: &str) -> String {
41254189 if let Some((left, right)) = input.split_once(op) {
41264190 let left = left.trim();
41274191 let right = right.trim();
4128− let op_char = op.trim().chars().next().unwrap();
4192+ let Some(op_char) = op.trim().chars().next() else {
4193+ continue;
4194+ };
41294195 if let (Ok(l), Ok(r)) = (left.parse::<f64>(), right.parse::<f64>()) {
41304196 let result = match op_char {
41314197 '+' => l + r,
@@ -5645,3 +5711,90 @@ fn html_escape_md(s: &str) -> String {
56455711 .replace('>', "&gt;")
56465712 .replace('"', "&quot;")
56475713}
5714+
5715+
5716+#[cfg(test)]
5717+mod tests {
5718+ use super::*;
5719+
5720+ fn parse_ok(src: &str) -> ParseResult {
5721+ let r = parse(src);
5722+ assert!(
5723+ r.diagnostics.iter().all(|d| d.severity != Severity::Error),
5724+ "unexpected error diagnostics: {:?}",
5725+ r.diagnostics,
5726+ );
5727+ r
5728+ }
5729+
5730+ #[test]
5731+ fn parses_empty_input() {
5732+ let r = parse("");
5733+ assert!(r.document.nodes.is_empty());
5734+ assert!(r.diagnostics.is_empty());
5735+ }
5736+
5737+ #[test]
5738+ fn parses_bare_text_node() {
5739+ let r = parse_ok("Hello world\n");
5740+ assert_eq!(r.document.nodes.len(), 1);
5741+ }
5742+
5743+ #[test]
5744+ fn parses_page_directive() {
5745+ let r = parse_ok("@page My Title\n");
5746+ assert_eq!(r.document.page_title.as_deref(), Some("My Title"));
5747+ }
5748+
5749+ #[test]
5750+ fn let_variable_substitution() {
5751+ let r = parse_ok("@let color red\n@text [color $color] hi\n");
5752+ assert_eq!(r.document.variables.get("color").map(|s| s.as_str()), Some("red"));
5753+ }
5754+
5755+ #[test]
5756+ fn error_recovery_continues_after_unknown_element() {
5757+ // Intentionally malformed line followed by a valid one — recovery must
5758+ // let us still see the good line in diagnostics / output.
5759+ let r = parse("@notareal\n@text hi\n");
5760+ let has_error = r.diagnostics.iter().any(|d| d.severity == Severity::Error);
5761+ assert!(has_error, "expected at least one error for @notareal");
5762+ }
5763+
5764+ #[test]
5765+ fn undefined_fn_is_reported_not_panics() {
5766+ // Prior to the unwrap fix this panicked.
5767+ let r = parse("@missing\n");
5768+ assert!(r.diagnostics.iter().any(|d| d.severity == Severity::Error));
5769+ }
5770+
5771+ #[test]
5772+ fn scope_without_selector_does_not_panic() {
5773+ let r = parse("@scope\n body { background red; }\n");
5774+ // Should parse — may or may not emit diagnostics, but must not panic.
5775+ let _ = r;
5776+ }
5777+
5778+ #[test]
5779+ fn levenshtein_bounded_respects_cutoff() {
5780+ let a: Vec<char> = "hello".chars().collect();
5781+ let b: Vec<char> = "world".chars().collect();
5782+ // Full distance is 4, but asking for cutoff 2 should early-exit.
5783+ let d = levenshtein_bounded(&a, &b, 2);
5784+ assert!(d > 2, "expected early exit above cutoff, got {}", d);
5785+ }
5786+
5787+ #[test]
5788+ fn severity_has_all_variants() {
5789+ // This compiles only if Severity has Error, Warning, Info, Help.
5790+ let all = [Severity::Error, Severity::Warning, Severity::Info, Severity::Help];
5791+ assert_eq!(all.len(), 4);
5792+ }
5793+
5794+ #[test]
5795+ fn arithmetic_empty_operator_does_not_panic() {
5796+ // Guards the `op.trim().chars().next().unwrap()` fix.
5797+ let r = parse("@let x 1 + 2\n");
5798+ assert_eq!(r.document.variables.get("x").map(|s| s.as_str()), Some("3"));
5799+ }
5800+}
editors/vscode/README.mdnew file+56
@@ -0,0 +1,56 @@
1+# htmlang for VS Code
2+
3+Syntax highlighting, snippets, and LSP-backed intelligence for [htmlang](https://github.com/iko/htmlang)
4+(`.hl`) files.
5+
6+## Features
7+
8+- **Syntax highlighting** — `@` directives, `$variables`, `[attribute]` brackets,
9+ `-- comments`, `@raw """..."""` blocks.
10+- **Diagnostics** — parse errors, missing variables/functions, unused bindings,
11+ CSS / a11y warnings, and typo suggestions.
12+- **Completion** — directives, attribute keys and values, variable names, and
13+ function calls. Trigger characters: `@`, `$`, `[`, `,`.
14+- **Hover** — documentation for directives, function signatures, variable
15+ values, and color swatches.
16+- **Go to definition / rename** — for `$variables`, `@fn` calls, `@define`
17+ bundles, and `@include` targets.
18+- **Document links** — cmd-click on `@include`, `@import`, `@use`, `@extends`.
19+- **Code lens** — per-definition reference counts for `@fn` / `@let` / `@define`.
20+- **Document symbols + workspace symbols** — outline view and `Ctrl-T` search
21+ scan both open documents and every `.hl` file in the workspace.
22+- **Formatter** — `Format Document` / `Format Selection` invokes the same
23+ formatter as `htmlang fmt`.
24+- **Color picker, folding ranges, semantic tokens, inlay hints, signature help**.
25+
26+## Requirements
27+
28+The extension launches the `htmlang-lsp` binary. Install it from the repo root:
29+
30+```
31+cargo install --path . --bin htmlang-lsp
32+```
33+
34+Make sure `htmlang-lsp` is on your `PATH`, or set `htmlang.serverPath` in
35+settings to an absolute path.
36+
37+## Snippets
38+
39+A set of snippets is included for common patterns (`card`, `row`, `column`,
40+`fn`, `each`, `component`, …). Type the prefix and `Tab` to expand.
41+
42+## Development
43+
44+```
45+cd editors/vscode
46+npm install
47+npm run build # compile TypeScript to ./out
48+```
49+
50+Open this folder in VS Code and run the "Extension" launch configuration to
51+try it out.
52+
53+## Reporting issues
54+
55+Please file issues in the main [htmlang repository](https://github.com/iko/htmlang/issues),
56+including a minimal `.hl` snippet and your `htmlang-lsp --version`.
examples/blog.hlnew file+49
@@ -0,0 +1,49 @@
1+-- A minimal blog template: header + list of posts + post template.
2+@page My Blog
3+@lang en
4+@let primary #3b82f6
5+@let ink #1f2937
6+@let muted #6b7280
7+
8+@define card [
9+ padding 24,
10+ background white,
11+ rounded 10,
12+ border 1 #e5e7eb,
13+ shadow 0 1px 3px rgba(0,0,0,0.05),
14+ transition all 0.15s ease
15+]
16+
17+@fn post $title $date $excerpt
18+ @article [$card, spacing 8]
19+ @text [bold, size 20, color $ink] $title
20+ @text [size 13, color $muted] $date
21+ @paragraph [color $ink, line-height 1.6]
22+ $excerpt
23+ @link [color $primary, bold] /posts/$title
24+ Read more
25+
26+@column [max-width 720, center-x, padding 40, spacing 32]
27+ @header [spacing 8]
28+ @text [bold, size 36, color $ink] My Blog
29+ @text [color $muted] Thoughts on static HTML, layout, and nice defaults.
30+
31+ @main [spacing 20]
32+ @post [
33+ title First post,
34+ date April 16 2026,
35+ excerpt "A short introduction to how I think about static site generators."
36+ ]
37+ @post [
38+ title Why flexbox is enough,
39+ date April 10 2026,
40+ excerpt "Most layouts don't need CSS Grid. A walk through the cases where flexbox wins."
41+ ]
42+ @post [
43+ title Tight feedback loops,
44+ date April 2 2026,
45+ excerpt "Dev-loop ergonomics beat feature counts. Here's the setup I use."
46+ ]
47+
48+ @footer [padding-y 24, text-align center, color $muted, size 13]
49+ Built with htmlang.
examples/docs.hlnew file+76
@@ -0,0 +1,76 @@
1+-- A documentation page: sidebar nav + prose content + code samples.
2+@page Docs · Getting Started
3+@lang en
4+@let ink #0f172a
5+@let muted #64748b
6+@let primary #3b82f6
7+@let code-bg #f1f5f9
8+
9+@fn nav-link $label $href
10+ @link [
11+ padding 8, padding-x 12, rounded 6, color $ink,
12+ hover:background #f1f5f9, transition all 0.1s ease
13+ ] $href
14+ $label
15+
16+@row [min-height 100vh]
17+ -- Sidebar
18+ @aside [
19+ width 240, padding 32, padding-x 20,
20+ background #fafafa, border-right 1 #e5e7eb, spacing 4
21+ ]
22+ @text [bold, size 13, color $muted, text-transform uppercase, letter-spacing 1] Introduction
23+ @nav-link [label Overview, href #overview]
24+ @nav-link [label Installation, href #install]
25+ @nav-link [label First page, href #first-page]
26+ @text [
27+ bold, size 13, color $muted, text-transform uppercase, letter-spacing 1,
28+ margin-block-start 20
29+ ] Guides
30+ @nav-link [label Layout, href #layout]
31+ @nav-link [label Styling, href #styling]
32+ @nav-link [label Functions, href #functions]
33+
34+ -- Prose
35+ @main [padding 48, padding-x 56, max-width 800, spacing 24]
36+ @text [bold, size 36, color $ink] Getting started
37+
38+ @paragraph [color $ink, line-height 1.7, size 17]
39+ htmlang is a minimalist layout language that compiles to a single static
40+ HTML file. This guide walks through the essentials in about five minutes.
41+
42+ @text [bold, size 24, color $ink] Installation
43+
44+ @pre [background $code-bg, padding 16, rounded 8, overflow-x auto]
45+ cargo install --path .
46+
47+ @text [bold, size 24, color $ink] Your first page
48+
49+ @paragraph [color $ink, line-height 1.7]
50+ Create a file called {@code [background $code-bg, padding 2 6, rounded 4] hello.hl} with:
51+
52+ @pre [background $code-bg, padding 16, rounded 8, overflow-x auto]
53+ @page Hello
54+ @column [padding 40, spacing 16]
55+ @text [bold, size 32] Hi there
56+
57+ @paragraph [color $ink, line-height 1.7]
58+ Compile it:
59+
60+ @pre [background $code-bg, padding 16, rounded 8, overflow-x auto]
61+ htmlang hello.hl
62+
63+ @paragraph [color $ink, line-height 1.7]
64+ You now have {@code [background $code-bg, padding 2 6, rounded 4] hello.html},
65+ a single self-contained file with embedded CSS. No dependencies, no
66+ JavaScript, no build step beyond the one command you just ran.
67+
68+ @text [bold, size 24, color $ink] Next steps
69+
70+ @list [spacing 8]
71+ @item
72+ @link [color $primary] /docs/layout Learn the layout model
73+ @item
74+ @link [color $primary] /docs/styling Customize styling
75+ @item
76+ @link [color $primary] /docs/functions Extract reusable components
examples/landing.hlnew file+71
@@ -0,0 +1,71 @@
1+-- A marketing landing page: hero, feature grid, CTA.
2+@page Launchpad
3+@lang en
4+@let primary #2563eb
5+@let ink #0f172a
6+@let muted #64748b
7+@let bg-subtle #f8fafc
8+
9+@fn feature $title $body
10+ @column [padding 24, background white, rounded 12, border 1 #e2e8f0, spacing 8]
11+ @text [bold, size 18, color $ink] $title
12+ @paragraph [color $muted, line-height 1.6]
13+ $body
14+
15+@column [spacing 0]
16+ -- Hero
17+ @section [
18+ padding 80,
19+ padding-x 24,
20+ background $bg-subtle,
21+ text-align center,
22+ spacing 24
23+ ]
24+ @text [bold, size 48, color $ink] Build faster with less CSS.
25+ @paragraph [max-width 560, center-x, color $muted, line-height 1.6, size 18]
26+ Launchpad turns your static pages into a polished product in minutes.
27+ Ship faster, iterate smaller, sleep at night.
28+ @row [center-x, spacing 12]
29+ @link [
30+ padding 14, padding-x 24, rounded 8,
31+ background $primary, color white, bold,
32+ transition all 0.15s ease
33+ ] /signup
34+ Start free
35+ @link [
36+ padding 14, padding-x 24, rounded 8,
37+ border 1 #e2e8f0, color $ink, bold,
38+ transition all 0.15s ease
39+ ] /demo
40+ Watch demo
41+
42+ -- Features
43+ @section [padding 80, padding-x 24, max-width 1100, center-x, spacing 40]
44+ @column [spacing 8, text-align center]
45+ @text [bold, size 32, color $ink] Everything you need
46+ @text [color $muted] Opinionated where it matters, flexible where it counts.
47+ @row [wrap, gap 20]
48+ @feature [
49+ title Zero-config dev server,
50+ body "Live reload, asset pipeline, HTTPS, and auto-routing out of the box."
51+ ]
52+ @feature [
53+ title First-class LSP,
54+ body "Goto-definition, rename, diagnostics, hover docs, and code lens — all built in."
55+ ]
56+ @feature [
57+ title Static by default,
58+ body "Compiles to a single HTML file. Deploy anywhere, no JS runtime needed."
59+ ]
60+
61+ -- CTA
62+ @section [padding 60, padding-x 24, background $primary, color white, text-align center, spacing 16]
63+ @text [bold, size 28] Ready to launch?
64+ @paragraph [max-width 520, center-x, line-height 1.6]
65+ Join thousands of teams building their marketing sites with htmlang.
66+ @link [
67+ padding 14, padding-x 28, rounded 8,
68+ background white, color $primary, bold,
69+ transition all 0.15s ease
70+ ] /signup
71+ Start for free
src/bin/htmlang_lsp/analysis.rs+16−6
@@ -1129,10 +1129,14 @@ pub(crate) fn get_signature_help(text: &str, position: Position) -> Option<Signa
11291129 .unwrap_or(after_at.len());
11301130 let fn_name = &after_at[..name_end];
11311131
1132− // Must be inside brackets
1133− if !in_brackets(before) {
1132+ // Prefer to surface signature help inside an argument list, but don't hide
1133+ // the signature from callers who trigger explicitly (e.g. hover over the
1134+ // function name itself). We still need the cursor to be on or after the
1135+ // `@name` token — `fn_name` being non-empty is the check for that.
1136+ if fn_name.is_empty() {
11341137 return None;
11351138 }
1139+ let inside_args = in_brackets(before);
11361140
11371141 // Find the @fn definition
11381142 for (line_idx, line_text) in text.lines().enumerate() {
@@ -1170,10 +1174,16 @@ pub(crate) fn get_signature_help(text: &str, position: Position) -> Option<Signa
11701174
11711175 let sig_label = format!("@{} {}", fn_name, params.join(" "));
11721176
1173− // Determine active parameter by counting commas before cursor inside brackets
1174− let bracket_start = before.rfind('[').unwrap_or(0);
1175− let inside = &before[bracket_start..];
1176− let active_param = inside.matches(',').count() as u32;
1177+ // Determine active parameter by counting commas before cursor inside
1178+ // brackets. When the cursor hasn't entered the argument list yet,
1179+ // highlight the first parameter.
1180+ let active_param = if inside_args {
1181+ let bracket_start = before.rfind('[').unwrap_or(0);
1182+ let inside = &before[bracket_start..];
1183+ inside.matches(',').count() as u32
1184+ } else {
1185+ 0
1186+ };
11771187
11781188 return Some(SignatureHelp {
11791189 signatures: vec![SignatureInformation {
src/bin/htmlang_lsp/main.rs+293−6
@@ -40,6 +40,8 @@ impl Backend {
4040 let severity = match d.severity {
4141 htmlang::parser::Severity::Error => DiagnosticSeverity::ERROR,
4242 htmlang::parser::Severity::Warning => DiagnosticSeverity::WARNING,
43+ htmlang::parser::Severity::Info => DiagnosticSeverity::INFORMATION,
44+ htmlang::parser::Severity::Help => DiagnosticSeverity::HINT,
4345 };
4446 let line = d.line.saturating_sub(1) as u32;
4547 let col_start = d.column.unwrap_or(0) as u32;
@@ -129,12 +131,20 @@ impl LanguageServer for Backend {
129131 LinkedEditingRangeServerCapabilities::Simple(true),
130132 ),
131133 document_formatting_provider: Some(OneOf::Left(true)),
134+ document_range_formatting_provider: Some(OneOf::Left(true)),
132135 references_provider: Some(OneOf::Left(true)),
133136 signature_help_provider: Some(SignatureHelpOptions {
134137 trigger_characters: Some(vec!["[".into(), ",".into()]),
135138 retrigger_characters: Some(vec![",".into()]),
136139 work_done_progress_options: Default::default(),
137140 }),
141+ document_link_provider: Some(DocumentLinkOptions {
142+ resolve_provider: Some(false),
143+ work_done_progress_options: Default::default(),
144+ }),
145+ code_lens_provider: Some(CodeLensOptions {
146+ resolve_provider: Some(false),
147+ }),
138148 ..Default::default()
139149 },
140150 ..Default::default()
@@ -411,19 +421,79 @@ impl LanguageServer for Backend {
411421 ) -> Result<Option<Vec<SymbolInformation>>> {
412422 let query = params.query.to_lowercase();
413423 let docs = self.documents.read().await;
414− let mut all_symbols = Vec::new();
424+ let mut all_symbols: Vec<SymbolInformation> = Vec::new();
425+ let mut covered_files: std::collections::HashSet<std::path::PathBuf> =
426+ std::collections::HashSet::new();
427+
428+ // Open documents first — their in-memory text may be newer than what
429+ // is on disk.
415430 for (uri, text) in docs.iter() {
416− let symbols = document_symbols(text);
417− for mut sym in symbols {
431+ if let Ok(path) = uri.to_file_path() {
432+ covered_files.insert(path);
433+ }
434+ for mut sym in document_symbols(text) {
418435 sym.location.uri = uri.clone();
419− if query.is_empty()
420− || sym.name.to_lowercase().contains(&query)
421− {
436+ if query.is_empty() || sym.name.to_lowercase().contains(&query) {
422437 all_symbols.push(sym);
423438 }
424439 }
425440 }
426441 drop(docs);
442+
443+ // Extend search to .hl files on disk. The workspace root is not given
444+ // via a workspace folder, so derive it from the first open document's
445+ // path (common case: editor opened a folder containing one open file).
446+ let workspace_root: Option<std::path::PathBuf> = {
447+ let docs = self.documents.read().await;
448+ docs.keys()
449+ .next()
450+ .and_then(|u| u.to_file_path().ok())
451+ .and_then(|p| p.parent().map(|p| p.to_path_buf()))
452+ };
453+ if let Some(root) = workspace_root {
454+ let mut stack = vec![root];
455+ let max_files = 500; // safety cap for huge workspaces
456+ let mut scanned = 0usize;
457+ while let Some(dir) = stack.pop() {
458+ let Ok(entries) = std::fs::read_dir(&dir) else { continue };
459+ for entry in entries.flatten() {
460+ if scanned >= max_files {
461+ break;
462+ }
463+ let path = entry.path();
464+ // Skip hidden / vendor / build directories.
465+ if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
466+ if name.starts_with('.')
467+ || name == "target"
468+ || name == "node_modules"
469+ || name == "dist"
470+ {
471+ continue;
472+ }
473+ }
474+ if path.is_dir() {
475+ stack.push(path);
476+ continue;
477+ }
478+ if path.extension().map_or(false, |e| e == "hl")
479+ && !covered_files.contains(&path)
480+ {
481+ scanned += 1;
482+ let Ok(text) = std::fs::read_to_string(&path) else { continue };
483+ let Ok(file_uri) = Url::from_file_path(&path) else { continue };
484+ for mut sym in document_symbols(&text) {
485+ sym.location.uri = file_uri.clone();
486+ if query.is_empty()
487+ || sym.name.to_lowercase().contains(&query)
488+ {
489+ all_symbols.push(sym);
490+ }
491+ }
492+ }
493+ }
494+ }
495+ }
496+
427497 Ok(if all_symbols.is_empty() {
428498 None
429499 } else {
@@ -469,6 +539,223 @@ impl LanguageServer for Backend {
469539 }]))
470540 }
471541
542+ async fn range_formatting(
543+ &self,
544+ params: DocumentRangeFormattingParams,
545+ ) -> Result<Option<Vec<TextEdit>>> {
546+ let uri = &params.text_document.uri;
547+ let range = params.range;
548+ let docs = self.documents.read().await;
549+ let text = match docs.get(uri) {
550+ Some(t) => t.clone(),
551+ None => return Ok(None),
552+ };
553+ drop(docs);
554+
555+ // Extract the selection (snapping to whole lines — htmlang is
556+ // indent-sensitive, so partial-line formatting is meaningless).
557+ let lines: Vec<&str> = text.lines().collect();
558+ let start_line = range.start.line as usize;
559+ let end_line = (range.end.line as usize).min(lines.len().saturating_sub(1));
560+ if start_line > end_line {
561+ return Ok(None);
562+ }
563+
564+ let selection: String = lines[start_line..=end_line].join("\n");
565+ let formatted = htmlang::fmt::format(&selection);
566+ let formatted = formatted.trim_end_matches('\n').to_string();
567+ if formatted == selection {
568+ return Ok(None);
569+ }
570+
571+ let last_col = lines[end_line].len() as u32;
572+ Ok(Some(vec![TextEdit {
573+ range: Range::new(
574+ Position::new(start_line as u32, 0),
575+ Position::new(end_line as u32, last_col),
576+ ),
577+ new_text: formatted,
578+ }]))
579+ }
580+
581+ async fn document_link(
582+ &self,
583+ params: DocumentLinkParams,
584+ ) -> Result<Option<Vec<DocumentLink>>> {
585+ let uri = params.text_document.uri.clone();
586+ let docs = self.documents.read().await;
587+ let text = match docs.get(&uri) {
588+ Some(t) => t.clone(),
589+ None => return Ok(None),
590+ };
591+ drop(docs);
592+
593+ let Ok(this_path) = uri.to_file_path() else {
594+ return Ok(None);
595+ };
596+ let Some(dir) = this_path.parent() else {
597+ return Ok(None);
598+ };
599+
600+ let mut links = Vec::new();
601+ for (i, raw_line) in text.lines().enumerate() {
602+ let trimmed = raw_line.trim_start();
603+ let indent = raw_line.len() - trimmed.len();
604+ let (prefix, filename) = if let Some(rest) = trimmed.strip_prefix("@include ") {
605+ ("@include ", rest)
606+ } else if let Some(rest) = trimmed.strip_prefix("@import ") {
607+ ("@import ", rest)
608+ } else if let Some(rest) = trimmed.strip_prefix("@use ") {
609+ // @use "file.hl" fn1, fn2 — only the filename token is linkable.
610+ ("@use ", rest)
611+ } else if let Some(rest) = trimmed.strip_prefix("@extends ") {
612+ ("@extends ", rest)
613+ } else {
614+ continue;
615+ };
616+
617+ // Pull the filename token (stop at whitespace or `,`).
618+ let name_token: &str = filename
619+ .trim_start_matches('"')
620+ .split(|c: char| c.is_whitespace() || c == ',')
621+ .next()
622+ .unwrap_or("")
623+ .trim_end_matches('"');
624+ if name_token.is_empty() {
625+ continue;
626+ }
627+ // Ignore glob patterns — they don't resolve to a single path.
628+ if name_token.contains('*') || name_token.contains('?') {
629+ continue;
630+ }
631+
632+ let target = dir.join(name_token);
633+ if !target.exists() {
634+ continue;
635+ }
636+ let Ok(target_uri) = Url::from_file_path(&target) else {
637+ continue;
638+ };
639+
640+ // Locate the token in the original line so the link highlights the
641+ // filename rather than the whole directive.
642+ let scan_from = indent + prefix.len();
643+ let Some(rel_start) = raw_line[scan_from..].find(name_token) else {
644+ continue;
645+ };
646+ let start_col = (scan_from + rel_start) as u32;
647+ let end_col = start_col + name_token.len() as u32;
648+
649+ links.push(DocumentLink {
650+ range: Range::new(
651+ Position::new(i as u32, start_col),
652+ Position::new(i as u32, end_col),
653+ ),
654+ target: Some(target_uri),
655+ tooltip: Some(format!("Open {}", name_token)),
656+ data: None,
657+ });
658+ }
659+
660+ Ok(if links.is_empty() { None } else { Some(links) })
661+ }
662+
663+ async fn code_lens(
664+ &self,
665+ params: CodeLensParams,
666+ ) -> Result<Option<Vec<CodeLens>>> {
667+ let uri = params.text_document.uri.clone();
668+ let docs = self.documents.read().await;
669+ let text = match docs.get(&uri) {
670+ Some(t) => t.clone(),
671+ None => return Ok(None),
672+ };
673+ drop(docs);
674+
675+ // Build a simple usage counter by scanning the whole document. For each
676+ // definition (@fn name / @let name / @define name) we emit a lens that
677+ // reports how many bare `@name` or `$name` call sites exist.
678+ let lines: Vec<&str> = text.lines().collect();
679+
680+ #[derive(Clone)]
681+ struct Def { line: u32, name: String, kind: &'static str }
682+ let mut defs: Vec<Def> = Vec::new();
683+
684+ for (i, line) in lines.iter().enumerate() {
685+ let trimmed = line.trim_start();
686+ if let Some(rest) = trimmed.strip_prefix("@fn ") {
687+ if let Some(n) = rest.split_whitespace().next() {
688+ defs.push(Def { line: i as u32, name: n.to_string(), kind: "fn" });
689+ }
690+ } else if let Some(rest) = trimmed.strip_prefix("@let ") {
691+ if let Some((n, _)) = rest.trim().split_once(' ') {
692+ defs.push(Def { line: i as u32, name: n.to_string(), kind: "let" });
693+ }
694+ } else if let Some(rest) = trimmed.strip_prefix("@define ") {
695+ if let Some(bracket) = rest.find('[') {
696+ let n = rest[..bracket].trim();
697+ if !n.is_empty() {
698+ defs.push(Def { line: i as u32, name: n.to_string(), kind: "define" });
699+ }
700+ }
701+ }
702+ }
703+
704+ let mut lenses = Vec::with_capacity(defs.len());
705+ for def in &defs {
706+ // Count references: for @fn, look for `@name` at start-of-token;
707+ // for @let / @define, look for `$name`.
708+ let mut count: usize = 0;
709+ let needle = match def.kind {
710+ "fn" => format!("@{}", def.name),
711+ _ => format!("${}", def.name),
712+ };
713+ for (i, line) in lines.iter().enumerate() {
714+ if i as u32 == def.line {
715+ continue;
716+ }
717+ let mut from = 0;
718+ while let Some(idx) = line[from..].find(&needle) {
719+ let pos = from + idx;
720+ // Guard: the char right after the match must not be a valid
721+ // identifier continuation, so `$foo` doesn't match `$foobar`.
722+ let after = line.as_bytes().get(pos + needle.len()).copied();
723+ let ok = match after {
724+ None => true,
725+ Some(c) => !(c.is_ascii_alphanumeric() || c == b'_' || c == b'-'),
726+ };
727+ if ok {
728+ count += 1;
729+ }
730+ from = pos + needle.len();
731+ }
732+ }
733+
734+ let title = if count == 1 {
735+ "1 reference".to_string()
736+ } else {
737+ format!("{} references", count)
738+ };
739+ lenses.push(CodeLens {
740+ range: Range::new(
741+ Position::new(def.line, 0),
742+ Position::new(def.line, 0),
743+ ),
744+ // Non-executable lens: clients display the title without an
745+ // associated command action. Leaving `command` as None yields an
746+ // informational lens in most editors.
747+ command: Some(Command {
748+ title,
749+ command: String::new(),
750+ arguments: None,
751+ }),
752+ data: None,
753+ });
754+ }
755+
756+ Ok(if lenses.is_empty() { None } else { Some(lenses) })
757+ }
758+
472759 async fn references(
473760 &self,
474761 params: ReferenceParams,
src/fmt.rs+214−26
@@ -1,4 +1,8 @@
11const MAX_LINE_WIDTH: usize = 100;
2+/// Once a line has already been wrapped over multiple attribute lines, we keep it
3+/// wrapped even after formatting as long as the attribute count stays >1. This
4+/// prevents format→format→format oscillation around the wrap threshold.
5+const WRAP_MIN_WIDTH: usize = 80;
26
37/// Attribute category for sorting.
48fn attr_category(key: &str) -> u8 {
@@ -98,6 +102,73 @@ fn sort_attrs(attrs_str: &str) -> String {
98102 .join(", ")
99103}
100104
105+/// Count unescaped `[` and `]` outside quoted strings. Handles both `"..."` and
106+/// `'...'` with `\"` / `\'` escapes. Returns (open_count, close_count).
107+fn count_brackets(s: &str) -> (i32, i32) {
108+ let mut open = 0i32;
109+ let mut close = 0i32;
110+ let mut chars = s.chars().peekable();
111+ let mut in_str: Option<char> = None;
112+ while let Some(c) = chars.next() {
113+ match in_str {
114+ Some(q) => {
115+ if c == '\\' {
116+ // consume next char (escape)
117+ chars.next();
118+ } else if c == q {
119+ in_str = None;
120+ }
121+ }
122+ None => match c {
123+ '"' | '\'' => in_str = Some(c),
124+ '[' => open += 1,
125+ ']' => close += 1,
126+ _ => {}
127+ },
128+ }
129+ }
130+ (open, close)
131+}
132+
133+/// Split a line into (code, trailing_comment). The comment starts at the first `--`
134+/// that lies outside quoted strings and outside `[...]` attribute lists. Returns
135+/// (whole_line, None) if there is no trailing comment.
136+fn split_trailing_comment(line: &str) -> (&str, Option<&str>) {
137+ let bytes = line.as_bytes();
138+ let mut in_str: Option<u8> = None;
139+ let mut bracket_depth = 0i32;
140+ let mut i = 0;
141+ while i + 1 < bytes.len() {
142+ let c = bytes[i];
143+ match in_str {
144+ Some(q) => {
145+ if c == b'\\' {
146+ i += 2;
147+ continue;
148+ } else if c == q {
149+ in_str = None;
150+ }
151+ }
152+ None => match c {
153+ b'"' | b'\'' => in_str = Some(c),
154+ b'[' => bracket_depth += 1,
155+ b']' => bracket_depth -= 1,
156+ b'-' if bytes[i + 1] == b'-' && bracket_depth == 0 => {
157+ // Require a space (or start-of-line) before the `--` so we
158+ // don't split inside identifiers or CSS values like `a--b`.
159+ if i == 0 || bytes[i - 1] == b' ' || bytes[i - 1] == b'\t' {
160+ let (before, after) = line.split_at(i);
161+ return (before.trim_end(), Some(after));
162+ }
163+ }
164+ _ => {}
165+ },
166+ }
167+ i += 1;
168+ }
169+ (line, None)
170+}
171+
101172/// Format an htmlang source file with normalized indentation (2 spaces per level),
102173/// sorted attributes, and cleaned-up whitespace.
103174pub fn format(input: &str) -> String {
@@ -106,6 +177,8 @@ pub fn format(input: &str) -> String {
106177 let mut bracket_depth: i32 = 0;
107178 let mut bracket_base_level: usize = 0;
108179 let mut bracket_content = String::new();
180+ let mut bracket_was_wrapped = false;
181+ let mut pending_comment: Option<String> = None;
109182
110183 for line in input.lines() {
111184 let trimmed = line.trim();
@@ -130,25 +203,42 @@ pub fn format(input: &str) -> String {
130203 continue;
131204 }
132205
133− let open = trimmed.chars().filter(|&c| c == '[').count() as i32;
134− let close = trimmed.chars().filter(|&c| c == ']').count() as i32;
135−
136− // Inside multi-line bracket continuation — collect content
206+ // Inside multi-line bracket continuation — collect content (no comment
207+ // handling here; trailing comments inside a multi-line bracket block are
208+ // uncommon and intentionally preserved by the collector).
137209 if bracket_depth > 0 {
138210 bracket_content.push(' ');
139211 bracket_content.push_str(trimmed);
212+ let (open, close) = count_brackets(trimmed);
140213 bracket_depth += open - close;
141214 if bracket_depth <= 0 {
142215 // Bracket block is complete — format it
143216 let level = bracket_base_level;
144− // Find the bracket content within the full collected line
145217 let full = bracket_content.clone();
146218 bracket_content.clear();
147− // Re-emit the line with sorted attrs
148219 let formatted = format_line_with_brackets(&full);
220+ let attrs_count = count_attrs(&formatted);
221+ let indented_len = level * 2 + formatted.len();
222+ // Stay wrapped if the original was wrapped (hysteresis).
223+ let should_wrap = (indented_len > MAX_LINE_WIDTH)
224+ || (bracket_was_wrapped
225+ && attrs_count > 1
226+ && indented_len > WRAP_MIN_WIDTH);
227+ if should_wrap {
228+ if let Some(wrapped) = wrap_attrs(&formatted, level) {
229+ output.push_str(&wrapped);
230+ bracket_was_wrapped = false;
231+ continue;
232+ }
233+ }
149234 output.push_str(&" ".repeat(level));
150235 output.push_str(&formatted);
236+ if let Some(cmt) = pending_comment.take() {
237+ output.push(' ');
238+ output.push_str(&cmt);
239+ }
151240 output.push('\n');
241+ bracket_was_wrapped = false;
152242 }
153243 continue;
154244 }
@@ -162,30 +252,53 @@ pub fn format(input: &str) -> String {
162252
163253 let level = indent_stack.len() - 1;
164254
255+ // Split off a trailing `-- comment` before doing bracket math / sorting.
256+ let (code, trailing) = split_trailing_comment(trimmed);
257+ let code = code.trim_end();
258+ let trailing = trailing.map(|s| s.trim().to_string());
259+
260+ let (open, close) = count_brackets(code);
165261 bracket_depth = open - close;
166262 if bracket_depth > 0 {
167263 // Start of multi-line bracket
168264 bracket_base_level = level;
169− bracket_content = trimmed.to_string();
265+ bracket_content = code.to_string();
266+ bracket_was_wrapped = true;
267+ pending_comment = trailing;
170268 indent_stack.push(raw_indent);
171269 continue;
172270 }
173271
174272 // Single-line: format brackets inline
175− let formatted = format_line_with_brackets(trimmed);
273+ let formatted = format_line_with_brackets(code);
274+ let attrs_count = count_attrs(&formatted);
176275
177276 let indented_len = level * 2 + formatted.len();
277+ // Single-line emission — only wrap when we strictly exceed the ceiling.
178278 if indented_len > MAX_LINE_WIDTH && formatted.contains('[') {
179− // Wrap long attribute lists
180− if let Some(wrapped) = wrap_attrs(&formatted, level) {
279+ if let Some(mut wrapped) = wrap_attrs(&formatted, level) {
280+ if let Some(cmt) = &trailing {
281+ // Reattach comment on the final `]` line.
282+ if wrapped.ends_with('\n') {
283+ wrapped.pop();
284+ }
285+ wrapped.push(' ');
286+ wrapped.push_str(cmt);
287+ wrapped.push('\n');
288+ }
181289 output.push_str(&wrapped);
182290 indent_stack.push(raw_indent);
183291 continue;
184292 }
185293 }
294+ let _ = attrs_count;
186295
187296 output.push_str(&" ".repeat(level));
188297 output.push_str(&formatted);
298+ if let Some(cmt) = trailing {
299+ output.push(' ');
300+ output.push_str(&cmt);
301+ }
189302 output.push('\n');
190303
191304 indent_stack.push(raw_indent);
@@ -202,18 +315,28 @@ fn format_line_with_brackets(line: &str) -> String {
202315 };
203316 let mut depth = 0;
204317 let mut bracket_end = None;
318+ let mut in_str: Option<char> = None;
319+ let mut prev = '\0';
205320 for (i, c) in line.char_indices() {
206− match c {
207− '[' => depth += 1,
208− ']' => {
209− depth -= 1;
210− if depth == 0 {
211− bracket_end = Some(i);
212− break;
321+ if let Some(q) = in_str {
322+ if prev != '\\' && c == q {
323+ in_str = None;
324+ }
325+ } else {
326+ match c {
327+ '"' | '\'' => in_str = Some(c),
328+ '[' => depth += 1,
329+ ']' => {
330+ depth -= 1;
331+ if depth == 0 {
332+ bracket_end = Some(i);
333+ break;
334+ }
213335 }
336+ _ => {}
214337 }
215− _ => {}
216338 }
339+ prev = c;
217340 }
218341 let Some(bracket_end) = bracket_end else {
219342 return line.to_string();
@@ -228,22 +351,45 @@ fn format_line_with_brackets(line: &str) -> String {
228351 format!("{}[{}]{}", before, sorted, after)
229352}
230353
354+fn count_attrs(line: &str) -> usize {
355+ let Some(start) = line.find('[') else { return 0 };
356+ let Some(end) = line.rfind(']') else { return 0 };
357+ if end <= start {
358+ return 0;
359+ }
360+ line[start + 1..end]
361+ .split(',')
362+ .map(|p| p.trim())
363+ .filter(|p| !p.is_empty())
364+ .count()
365+}
366+
231367fn wrap_attrs(line: &str, indent_level: usize) -> Option<String> {
232368 let bracket_start = line.find('[')?;
233369 let mut depth = 0;
234370 let mut bracket_end = None;
371+ let mut in_str: Option<char> = None;
372+ let mut prev = '\0';
235373 for (i, c) in line.char_indices() {
236− match c {
237− '[' => depth += 1,
238− ']' => {
239− depth -= 1;
240− if depth == 0 {
241− bracket_end = Some(i);
242− break;
374+ if let Some(q) = in_str {
375+ if prev != '\\' && c == q {
376+ in_str = None;
377+ }
378+ } else {
379+ match c {
380+ '"' | '\'' => in_str = Some(c),
381+ '[' => depth += 1,
382+ ']' => {
383+ depth -= 1;
384+ if depth == 0 {
385+ bracket_end = Some(i);
386+ break;
387+ }
243388 }
389+ _ => {}
244390 }
245− _ => {}
246391 }
392+ prev = c;
247393 }
248394 let bracket_end = bracket_end?;
249395
@@ -276,3 +422,45 @@ fn wrap_attrs(line: &str, indent_level: usize) -> Option<String> {
276422 result.push('\n');
277423 Some(result)
278424}
425+
426+#[cfg(test)]
427+mod tests {
428+ use super::*;
429+
430+ #[test]
431+ fn idempotent_short_line() {
432+ let src = "@row [spacing 10]\n Hi\n";
433+ assert_eq!(format(src), format(&format(src)));
434+ }
435+
436+ #[test]
437+ fn idempotent_at_wrap_threshold() {
438+ // Build a line long enough to cross MAX_LINE_WIDTH after the first format.
439+ let src = "@row [padding 20, background white, rounded 8, border 1 #e5e7eb, color #333, shadow 0 2px 4px rgba(0,0,0,0.1)]\n child\n";
440+ let a = format(src);
441+ let b = format(&a);
442+ assert_eq!(a, b, "formatter must be idempotent across runs");
443+ }
444+
445+ #[test]
446+ fn preserves_trailing_comment() {
447+ let src = "@text [bold] hello -- greeting\n";
448+ let out = format(src);
449+ assert!(out.contains("-- greeting"), "trailing comment should survive format: {out}");
450+ }
451+
452+ #[test]
453+ fn bracket_inside_string_is_ignored() {
454+ let src = "@text [content \"a [b] c\", bold] hi\n";
455+ let out = format(src);
456+ // Single-line emission — no multi-line bracket block should be triggered.
457+ assert_eq!(out.lines().count(), 1, "got:\n{out}");
458+ }
459+
460+ #[test]
461+ fn comments_inside_brackets_are_not_split() {
462+ let src = "@el [content \"--not a comment\"]\n";
463+ let out = format(src);
464+ assert!(out.contains("\"--not a comment\""));
465+ }
466+}
src/main.rs+126−8
@@ -59,6 +59,8 @@ fn compile(input_path: &str, cfg: &CompileConfig) -> (bool, Vec<PathBuf>) {
5959 let severity = match d.severity {
6060 htmlang::parser::Severity::Error => "error",
6161 htmlang::parser::Severity::Warning => "warning",
62+ htmlang::parser::Severity::Info => "info",
63+ htmlang::parser::Severity::Help => "help",
6264 };
6365 collected.push(DiagnosticJson {
6466 file: input_path.to_string(),
@@ -73,6 +75,8 @@ fn compile(input_path: &str, cfg: &CompileConfig) -> (bool, Vec<PathBuf>) {
7375 let prefix = match d.severity {
7476 htmlang::parser::Severity::Error => "error",
7577 htmlang::parser::Severity::Warning => "warning",
78+ htmlang::parser::Severity::Info => "info",
79+ htmlang::parser::Severity::Help => "help",
7680 };
7781 if let Some(col) = d.column {
7882 eprintln!("{}: line {}:{}: {}", prefix, d.line, col, d.message);
@@ -312,15 +316,31 @@ fn generate_error_overlay(diagnostics: &[htmlang::parser::Diagnostic], file: &st
312316 let prefix = match d.severity {
313317 htmlang::parser::Severity::Error => "error",
314318 htmlang::parser::Severity::Warning => "warning",
319+ htmlang::parser::Severity::Info => "info",
320+ htmlang::parser::Severity::Help => "help",
315321 };
316322 let escaped = d.message
317323 .replace('&', "&amp;")
318324 .replace('<', "&lt;")
319325 .replace('>', "&gt;");
326+ let location = match d.column {
327+ Some(col) => format!("line {}:{}", d.line, col),
328+ None => format!("line {}", d.line),
329+ };
320330 errors.push_str(&format!(
321− "<div class=\"entry\"><span class=\"badge {}\">{}</span> line {}: {}</div>",
322− prefix, prefix, d.line, escaped
331+ "<div class=\"entry\"><span class=\"badge {}\">{}</span> <span class=\"loc\">{}</span> {}",
332+ prefix, prefix, location, escaped
323333 ));
334+ if let Some(ref src) = d.source_line {
335+ let src_esc = src.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;");
336+ errors.push_str(&format!("<pre class=\"src\">{}</pre>", src_esc));
337+ if let Some(col) = d.column {
338+ // Render a caret indicator underneath the source line.
339+ let caret = format!("{}^", " ".repeat(col));
340+ errors.push_str(&format!("<pre class=\"caret\">{}</pre>", caret));
341+ }
342+ }
343+ errors.push_str("</div>");
324344 }
325345 format!(
326346 r#"<!DOCTYPE html><html><head><meta charset="utf-8"><title>Build Error</title><style>
@@ -328,10 +348,15 @@ fn generate_error_overlay(diagnostics: &[htmlang::parser::Diagnostic], file: &st
328348body{{background:#1a1a2e;color:#eee;font-family:ui-monospace,monospace;padding:2rem}}
329349h1{{color:#ff6b6b;margin-bottom:1rem;font-size:1.5rem}}
330350.file{{color:#888;margin-bottom:1.5rem;font-size:0.9rem}}
331−.entry{{padding:0.5rem 0;border-bottom:1px solid #333}}
351+.entry{{padding:0.75rem 0;border-bottom:1px solid #333}}
352+.loc{{color:#9ca3af;margin-right:6px}}
332353.badge{{display:inline-block;padding:2px 8px;border-radius:4px;font-size:0.8rem;margin-right:8px}}
333354.badge.error{{background:#c0392b;color:white}}
334355.badge.warning{{background:#f39c12;color:white}}
356+.badge.info{{background:#2563eb;color:white}}
357+.badge.help{{background:#16a34a;color:white}}
358+.src{{margin-top:0.5rem;padding:0.4rem 0.6rem;background:#0f0f1e;border-radius:4px;color:#ddd;white-space:pre-wrap}}
359+.caret{{margin:0;padding:0 0.6rem;color:#ff6b6b;white-space:pre}}
335360</style></head><body>
336361<h1>Build Error</h1>
337362<div class="file">{file}</div>
@@ -594,6 +619,8 @@ fn lint_file(path: &str) -> Vec<String> {
594619 let prefix = match d.severity {
595620 htmlang::parser::Severity::Error => "error",
596621 htmlang::parser::Severity::Warning => "warning",
622+ htmlang::parser::Severity::Info => "info",
623+ htmlang::parser::Severity::Help => "help",
597624 };
598625 warnings.push(format!("{}:{}:{}: {}", path, d.line, prefix, d.message));
599626 }
@@ -1578,6 +1605,9 @@ fn main() {
15781605 let mut serve_target = None;
15791606 let mut serve_port: u16 = 3000;
15801607 let mut serve_open = false;
1608+ let mut serve_https = false;
1609+ let mut cert_path: Option<String> = None;
1610+ let mut key_path: Option<String> = None;
15811611 let mut _proxy_routes: Vec<(String, String)> = Vec::new();
15821612 let mut si = 2;
15831613 while si < args.len() {
@@ -1587,6 +1617,15 @@ fn main() {
15871617 serve_port = args.get(si).and_then(|p| p.parse().ok()).unwrap_or(3000);
15881618 }
15891619 "--open" => serve_open = true,
1620+ "--https" => serve_https = true,
1621+ "--cert" => {
1622+ si += 1;
1623+ cert_path = args.get(si).cloned();
1624+ }
1625+ "--key" => {
1626+ si += 1;
1627+ key_path = args.get(si).cloned();
1628+ }
15901629 "--proxy" => {
15911630 // --proxy /api http://localhost:3001
15921631 si += 1;
@@ -1612,6 +1651,32 @@ fn main() {
16121651 let config = load_config(target_path);
16131652 let effective_port = if serve_port != 3000 { serve_port } else { config.port };
16141653
1654+ // Resolve optional TLS config. Without both --cert and --key, --https is
1655+ // an error — we intentionally do not generate self-signed certificates
1656+ // to avoid surprising the user with unpinned trust anchors.
1657+ let tls_config = if serve_https {
1658+ let cert = cert_path.clone().unwrap_or_else(|| {
1659+ eprintln!("error: --https requires --cert <path> and --key <path>");
1660+ eprintln!(" generate a dev cert with: mkcert localhost 127.0.0.1");
1661+ process::exit(1);
1662+ });
1663+ let key = key_path.clone().unwrap_or_else(|| {
1664+ eprintln!("error: --https requires --cert <path> and --key <path>");
1665+ process::exit(1);
1666+ });
1667+ match htmlang::serve::load_tls_config(Path::new(&cert), Path::new(&key)) {
1668+ Ok(cfg) => Some(cfg),
1669+ Err(e) => {
1670+ eprintln!("error: failed to load TLS config: {}", e);
1671+ process::exit(1);
1672+ }
1673+ }
1674+ } else {
1675+ None
1676+ };
1677+ let scheme = if tls_config.is_some() { "https" } else { "http" };
1678+ let _ = scheme; // announced by watch_loop / open_in_browser downstream
1679+
16151680 // Do initial compile
16161681 if target_path.is_dir() {
16171682 let hl_files = collect_hl_files_recursive(target_path);
@@ -1633,9 +1698,13 @@ fn main() {
16331698 let serve_dir = config.output.as_ref().map(|o| PathBuf::from(o)).unwrap_or_else(|| target_path.to_path_buf());
16341699 let index_path = serve_dir.join("index.html");
16351700 let server_tx = tx.clone();
1701+ let tls_for_thread = tls_config;
16361702 std::thread::spawn(move || {
16371703 let rt = tokio::runtime::Runtime::new().expect("failed to create runtime");
1638− rt.block_on(htmlang::serve::run(effective_port, index_path, server_tx));
1704+ match tls_for_thread {
1705+ Some(tls) => rt.block_on(htmlang::serve::run_https(effective_port, index_path, server_tx, tls)),
1706+ None => rt.block_on(htmlang::serve::run(effective_port, index_path, server_tx)),
1707+ }
16391708 });
16401709 if serve_open { open_in_browser(effective_port); }
16411710 watch_loop(target_path, &hl_files, &[], true, true, Some(tx), effective_port, config.debounce_ms);
@@ -1647,9 +1716,13 @@ fn main() {
16471716 let (tx, _) = tokio::sync::broadcast::channel::<()>(16);
16481717 let out_path = Path::new(&target).with_extension("html");
16491718 let server_tx = tx.clone();
1719+ let tls_for_thread = tls_config;
16501720 std::thread::spawn(move || {
16511721 let rt = tokio::runtime::Runtime::new().expect("failed to create runtime");
1652− rt.block_on(htmlang::serve::run(effective_port, out_path, server_tx));
1722+ match tls_for_thread {
1723+ Some(tls) => rt.block_on(htmlang::serve::run_https(effective_port, out_path, server_tx, tls)),
1724+ None => rt.block_on(htmlang::serve::run(effective_port, out_path, server_tx)),
1725+ }
16531726 });
16541727 if serve_open { open_in_browser(effective_port); }
16551728 let files = vec![PathBuf::from(&target)];
@@ -1748,6 +1821,8 @@ fn main() {
17481821 let prefix = match d.severity {
17491822 htmlang::parser::Severity::Error => "error",
17501823 htmlang::parser::Severity::Warning => "warning",
1824+ htmlang::parser::Severity::Info => "info",
1825+ htmlang::parser::Severity::Help => "help",
17511826 };
17521827 eprintln!("{}: line {}: {}", prefix, d.line, d.message);
17531828 }
@@ -2753,6 +2828,8 @@ compile();
27532828 let prefix = match d.severity {
27542829 htmlang::parser::Severity::Error => "error",
27552830 htmlang::parser::Severity::Warning => "warning",
2831+ htmlang::parser::Severity::Info => "info",
2832+ htmlang::parser::Severity::Help => "help",
27562833 };
27572834 eprintln!("{}: line {}: {}", prefix, d.line, d.message);
27582835 }
@@ -3384,8 +3461,12 @@ fn watch_loop(
33843461
33853462 // Track content hashes for incremental rebuilds
33863463 let mut content_hashes: HashMap<PathBuf, u64> = HashMap::new();
3387− // Dependency map: source file -> list of included/imported files
3464+ // Dependency map: source file -> list of included/imported files. Maintained
3465+ // across rebuilds so we always know the current dep graph.
33883466 let mut dep_map: HashMap<PathBuf, Vec<PathBuf>> = HashMap::new();
3467+ // Set of currently-watched include paths. Used to unwatch files that are
3468+ // no longer referenced after a rebuild changes the dep graph.
3469+ let mut watched_includes: HashSet<PathBuf> = HashSet::new();
33893470
33903471 fn hash_file(path: &Path) -> Option<u64> {
33913472 let content = fs::read(path).ok()?;
@@ -3401,10 +3482,27 @@ fn watch_loop(
34013482 content_hashes.insert(canonical, h);
34023483 }
34033484 }
3404− for inc in included_files {
3485+ // Seed dep_map from the initial compile's include list so that the first
3486+ // change event correctly picks up include-file modifications even before
3487+ // the first recompile replaces the entry.
3488+ let initial_includes_canonical: Vec<PathBuf> = included_files
3489+ .iter()
3490+ .map(|p| fs::canonicalize(p).unwrap_or_else(|_| p.clone()))
3491+ .collect();
3492+ for inc in &initial_includes_canonical {
34053493 if let Some(h) = hash_file(inc) {
34063494 content_hashes.insert(inc.clone(), h);
34073495 }
3496+ watched_includes.insert(inc.clone());
3497+ }
3498+ // Attach the initial include list to every source file so a change to any
3499+ // of them triggers a rebuild of any source. Per-file dep refinement happens
3500+ // on the next recompile.
3501+ if !initial_includes_canonical.is_empty() {
3502+ for file in source_files {
3503+ let canonical = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
3504+ dep_map.insert(canonical, initial_includes_canonical.clone());
3505+ }
34083506 }
34093507
34103508 loop {
@@ -3517,7 +3615,9 @@ fn watch_loop(
35173615 .map(|p| fs::canonicalize(p).unwrap_or_else(|_| p.clone()))
35183616 .collect();
35193617 for inc in &canonical_deps {
3520− let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
3618+ if watched_includes.insert(inc.clone()) {
3619+ let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
3620+ }
35213621 if let Some(h) = hash_file(inc) {
35223622 content_hashes.insert(inc.clone(), h);
35233623 }
@@ -3525,6 +3625,24 @@ fn watch_loop(
35253625 dep_map.insert(file.clone(), canonical_deps);
35263626 }
35273627
3628+ // Unwatch include files that are no longer referenced by any
3629+ // source. Prevents file-descriptor leaks when @include edges
3630+ // are removed mid-session.
3631+ let still_needed: HashSet<PathBuf> = dep_map
3632+ .values()
3633+ .flatten()
3634+ .cloned()
3635+ .collect();
3636+ let to_drop: Vec<PathBuf> = watched_includes
3637+ .difference(&still_needed)
3638+ .cloned()
3639+ .collect();
3640+ for path in to_drop {
3641+ let _ = watcher.unwatch(&path);
3642+ watched_includes.remove(&path);
3643+ content_hashes.remove(&path);
3644+ }
3645+
35283646 eprintln!("recompiled {} file(s)", recompiled);
35293647
35303648 if let Some(ref tx) = reload_tx {
src/serve.rs+164−21
@@ -1,8 +1,49 @@
11use std::path::{Path, PathBuf};
2+use std::sync::Arc;
23
3−use tokio::io::{AsyncReadExt, AsyncWriteExt};
4−use tokio::net::{TcpListener, TcpStream};
4+use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
5+use tokio::net::TcpListener;
56use tokio::sync::broadcast;
7+use tokio_rustls::rustls::ServerConfig;
8+use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer};
9+use tokio_rustls::TlsAcceptor;
10+
11+/// TLS configuration for the dev server. Load with `load_tls_config` and pass
12+/// to `run_https` / `run_dir_https`.
13+pub struct TlsConfig {
14+ pub acceptor: TlsAcceptor,
15+}
16+
17+/// Load a PEM-encoded certificate chain and private key into a `TlsConfig`.
18+/// Supports PKCS#8, RSA, and SEC1-encoded private keys.
19+pub fn load_tls_config(
20+ cert_path: &Path,
21+ key_path: &Path,
22+) -> Result<TlsConfig, Box<dyn std::error::Error + Send + Sync>> {
23+ let cert_bytes = std::fs::read(cert_path)?;
24+ let key_bytes = std::fs::read(key_path)?;
25+
26+ let certs: Vec<CertificateDer<'static>> =
27+ rustls_pemfile::certs(&mut cert_bytes.as_slice()).collect::<Result<Vec<_>, _>>()?;
28+ if certs.is_empty() {
29+ return Err(format!("no certificates found in {}", cert_path.display()).into());
30+ }
31+
32+ let key: PrivateKeyDer<'static> = rustls_pemfile::private_key(&mut key_bytes.as_slice())?
33+ .ok_or_else(|| format!("no private key found in {}", key_path.display()))?;
34+
35+ // rustls requires a crypto provider to be installed globally; install the
36+ // default ring-based provider once. Subsequent calls are no-ops.
37+ let _ = tokio_rustls::rustls::crypto::ring::default_provider().install_default();
38+
39+ let config = ServerConfig::builder()
40+ .with_no_client_auth()
41+ .with_single_cert(certs, key)?;
42+
43+ Ok(TlsConfig {
44+ acceptor: TlsAcceptor::from(Arc::new(config)),
45+ })
46+}
647
748const RELOAD_SCRIPT: &str =
849 r#"<script>new EventSource("/_events").onmessage=()=>location.reload()</script>"#;
@@ -28,6 +69,39 @@ pub async fn run(port: u16, html_path: PathBuf, reload: broadcast::Sender<()>) {
2869 }
2970}
3071
72+/// Same as [`run`], but wraps each accepted connection in TLS.
73+pub async fn run_https(
74+ port: u16,
75+ html_path: PathBuf,
76+ reload: broadcast::Sender<()>,
77+ tls: TlsConfig,
78+) {
79+ let listener = match TcpListener::bind(("127.0.0.1", port)).await {
80+ Ok(l) => l,
81+ Err(e) => {
82+ eprintln!("error: failed to bind to port {}: {}", port, e);
83+ return;
84+ }
85+ };
86+
87+ let acceptor = tls.acceptor;
88+ loop {
89+ if let Ok((stream, _)) = listener.accept().await {
90+ let path = html_path.clone();
91+ let rx = reload.subscribe();
92+ let acceptor = acceptor.clone();
93+ tokio::spawn(async move {
94+ match acceptor.accept(stream).await {
95+ Ok(tls_stream) => {
96+ let _ = handle(tls_stream, path, None, rx).await;
97+ }
98+ Err(e) => eprintln!("tls handshake failed: {}", e),
99+ }
100+ });
101+ }
102+ }
103+}
104+
31105/// Serve a directory of HTML files with auto-compilation, directory index, and route mapping.
32106pub async fn run_dir(port: u16, root_dir: PathBuf, reload: broadcast::Sender<()>) {
33107 let listener = match TcpListener::bind(("127.0.0.1", port)).await {
@@ -49,11 +123,48 @@ pub async fn run_dir(port: u16, root_dir: PathBuf, reload: broadcast::Sender<()>
49123 }
50124}
51125
52−async fn handle_dir_request(
53− mut stream: TcpStream,
126+/// Same as [`run_dir`], but wraps each accepted connection in TLS.
127+pub async fn run_dir_https(
128+ port: u16,
129+ root_dir: PathBuf,
130+ reload: broadcast::Sender<()>,
131+ tls: TlsConfig,
132+) {
133+ let listener = match TcpListener::bind(("127.0.0.1", port)).await {
134+ Ok(l) => l,
135+ Err(e) => {
136+ eprintln!("error: failed to bind to port {}: {}", port, e);
137+ return;
138+ }
139+ };
140+
141+ let acceptor = tls.acceptor;
142+ loop {
143+ if let Ok((stream, _)) = listener.accept().await {
144+ let dir = root_dir.clone();
145+ let rx = reload.subscribe();
146+ let acceptor = acceptor.clone();
147+ tokio::spawn(async move {
148+ match acceptor.accept(stream).await {
149+ Ok(tls_stream) => {
150+ let _ = handle_dir_request(tls_stream, dir, rx).await;
151+ }
152+ Err(e) => eprintln!("tls handshake failed: {}", e),
153+ }
154+ });
155+ }
156+ }
157+}
158+
159+
160+async fn handle_dir_request<S>(
161+ mut stream: S,
54162 root_dir: PathBuf,
55163 reload_rx: broadcast::Receiver<()>,
56−) -> std::io::Result<()> {
164+) -> std::io::Result<()>
165+where
166+ S: AsyncRead + AsyncWrite + Unpin + Send,
167+{
57168 let mut buf = [0u8; 4096];
58169 let n = stream.read(&mut buf).await?;
59170 if n == 0 {
@@ -202,12 +313,15 @@ fn collect_html_pages(base: &Path, dir: &Path, pages: &mut Vec<String>) {
202313 }
203314}
204315
205−async fn handle(
206− mut stream: TcpStream,
316+async fn handle<S>(
317+ mut stream: S,
207318 html_path: PathBuf,
208319 root_dir: Option<&Path>,
209320 reload_rx: broadcast::Receiver<()>,
210−) -> std::io::Result<()> {
321+) -> std::io::Result<()>
322+where
323+ S: AsyncRead + AsyncWrite + Unpin + Send,
324+{
211325 let mut buf = [0u8; 4096];
212326 let n = stream.read(&mut buf).await?;
213327 if n == 0 {
@@ -228,37 +342,63 @@ async fn handle(
228342 }
229343}
230344
231−async fn handle_sse(
232− mut stream: TcpStream,
345+async fn handle_sse<S>(
346+ mut stream: S,
233347 mut rx: broadcast::Receiver<()>,
234−) -> std::io::Result<()> {
348+) -> std::io::Result<()>
349+where
350+ S: AsyncRead + AsyncWrite + Unpin + Send,
351+{
235352 let headers = "HTTP/1.1 200 OK\r\n\
236353 Content-Type: text/event-stream\r\n\
237354 Cache-Control: no-cache\r\n\
238− Connection: keep-alive\r\n\r\n";
355+ Connection: keep-alive\r\n\
356+ X-Accel-Buffering: no\r\n\r\n";
239357 stream.write_all(headers.as_bytes()).await?;
358+ // Send retry hint (ms) so browsers reconnect quickly after a drop, plus an
359+ // initial comment line that flushes headers through proxies.
360+ if stream.write_all(b"retry: 1000\n: connected\n\n").await.is_err() {
361+ return Ok(());
362+ }
363+
364+ // Periodic keepalive comment lines are sent every 15s; intervening channel
365+ // events cause an immediate reload dispatch. `select!` multiplexes the two.
366+ let mut heartbeat = tokio::time::interval(std::time::Duration::from_secs(15));
367+ heartbeat.tick().await; // consume immediate first tick
240368
241369 loop {
242− match rx.recv().await {
243− Ok(()) => {
244− if stream.write_all(b"data: reload\n\n").await.is_err() {
370+ tokio::select! {
371+ msg = rx.recv() => match msg {
372+ Ok(()) => {
373+ if stream.write_all(b"data: reload\n\n").await.is_err() {
374+ break;
375+ }
376+ }
377+ Err(broadcast::error::RecvError::Lagged(_)) => continue,
378+ Err(broadcast::error::RecvError::Closed) => break,
379+ },
380+ _ = heartbeat.tick() => {
381+ // Comment line (starts with `:`) — keeps the connection alive
382+ // through proxies and surfaces dead peers as a write error.
383+ if stream.write_all(b": keepalive\n\n").await.is_err() {
245384 break;
246385 }
247386 }
248− Err(broadcast::error::RecvError::Lagged(_)) => continue,
249− Err(broadcast::error::RecvError::Closed) => break,
250387 }
251388 }
252389
253390 Ok(())
254391}
255392
256−async fn handle_file(
257− mut stream: TcpStream,
393+async fn handle_file<S>(
394+ mut stream: S,
258395 html_path: &Path,
259396 root_dir: Option<&Path>,
260397 request_path: &str,
261−) -> std::io::Result<()> {
398+) -> std::io::Result<()>
399+where
400+ S: AsyncRead + AsyncWrite + Unpin + Send,
401+{
262402 if request_path.contains("..") {
263403 stream
264404 .write_all(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n")
@@ -299,7 +439,10 @@ async fn handle_file(
299439 Ok(())
300440}
301441
302−async fn send_404(stream: &mut TcpStream, path: &str) -> std::io::Result<()> {
442+async fn send_404<S>(stream: &mut S, path: &str) -> std::io::Result<()>
443+where
444+ S: AsyncRead + AsyncWrite + Unpin + Send,
445+{
303446 let body = format!(
304447 "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>404</title>\
305448 <style>*{{margin:0;box-sizing:border-box}}body{{font-family:system-ui,-apple-system,sans-serif;\