on master help sign in
Trim the tooling: no HTTPS dev server, build cache, shared CSS or convert

- serve drops --https/--cert/--key (and the unused --proxy); plain HTTP and live reload are unchanged. tokio-rustls and rustls-pemfile go. - build compiles every file each time: the incremental cache (hashing files, includes and flags) and --shared-css extraction are removed. - the HTML-to-htmlang convert command (1425 lines) is removed.

commit964cd2d6c3a3b25eec2f60b3c624297df25bd567 authoriko <mail@iko.soy> dateSat 26 Sep 2026, 02:33 (2 weeks ago) parentb469524
10 files changed+13−2259
CONTRIBUTING.md+1−1 Cargo.lock−211 Cargo.toml−2 README.md+1−2 src/cli.rs+3−4 src/convert.rs−1425 src/lib.rs−1 src/main.rs+8−338 src/serve.rs−107 tests/snapshots.rs−168
CONTRIBUTING.md+1−1
@@ -26,7 +26,7 @@ Linux, macOS, and Windows. Your change should pass all three.
2626 - `std.hl` — the standard library, written in htmlang and loaded before
2727 every file.
2828- `crates/htmlang-wasm/` — thin wrapper exposing `compile` to the web playground.
29−- `src/` — CLI, dev server, formatter, and HTML-to-hl converter.
29+- `src/` — CLI, dev server, formatter, and `upgrade` (syntax migrations).
3030- `src/bin/htmlang_lsp/` — language server binary (`htmlang-lsp`). Its
3131 completions come from the compiler's tables; hover and completion text live
3232 in `docs.rs`.
Cargo.lock−211
@@ -24,28 +24,6 @@ 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−
4927[[package]]
5028name = "bitflags"
5129version = "1.3.2"
@@ -70,33 +48,12 @@ version = "1.11.1"
7048source = "registry+https://github.com/rust-lang/crates.io-index"
7149checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
7250
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−
8551[[package]]
8652name = "cfg-if"
8753version = "1.0.4"
8854source = "registry+https://github.com/rust-lang/crates.io-index"
8955checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
9056
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−
10057[[package]]
10158name = "dashmap"
10259version = "5.5.3"
@@ -121,12 +78,6 @@ dependencies = [
12178 "syn",
12279]
12380
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−
13081[[package]]
13182name = "filetime"
13283version = "0.2.27"
@@ -138,12 +89,6 @@ dependencies = [
13889 "libredox",
13990]
14091
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−
14792[[package]]
14893name = "form_urlencoded"
14994version = "1.2.2"
@@ -153,12 +98,6 @@ dependencies = [
15398 "percent-encoding",
15499]
155100
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−
162101[[package]]
163102name = "fsevent-sys"
164103version = "4.1.0"
@@ -244,29 +183,6 @@ dependencies = [
244183 "slab",
245184]
246185
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−
270186[[package]]
271187name = "hashbrown"
272188version = "0.14.5"
@@ -279,10 +195,8 @@ version = "0.1.0"
279195dependencies = [
280196 "htmlang-core",
281197 "notify",
282− "rustls-pemfile",
283198 "serde_json",
284199 "tokio",
285− "tokio-rustls",
286200 "tower-lsp",
287201]
288202
@@ -442,16 +356,6 @@ version = "1.0.18"
442356source = "registry+https://github.com/rust-lang/crates.io-index"
443357checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
444358
445−[[package]]
446−name = "jobserver"
447−version = "0.1.34"
448−source = "registry+https://github.com/rust-lang/crates.io-index"
449−checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
450−dependencies = [
451− "getrandom 0.3.4",
452− "libc",
453−]
454−
455359[[package]]
456360name = "kqueue"
457361version = "1.1.1"
@@ -654,12 +558,6 @@ dependencies = [
654558 "proc-macro2",
655559]
656560
657−[[package]]
658−name = "r-efi"
659−version = "5.3.0"
660−source = "registry+https://github.com/rust-lang/crates.io-index"
661−checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
662−
663561[[package]]
664562name = "redox_syscall"
665563version = "0.5.18"
@@ -678,66 +576,6 @@ dependencies = [
678576 "bitflags 2.11.1",
679577]
680578
681−[[package]]
682−name = "ring"
683−version = "0.17.14"
684−source = "registry+https://github.com/rust-lang/crates.io-index"
685−checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
686−dependencies = [
687− "cc",
688− "cfg-if",
689− "getrandom 0.2.17",
690− "libc",
691− "untrusted",
692− "windows-sys 0.52.0",
693−]
694−
695−[[package]]
696−name = "rustls"
697−version = "0.23.38"
698−source = "registry+https://github.com/rust-lang/crates.io-index"
699−checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21"
700−dependencies = [
701− "aws-lc-rs",
702− "log",
703− "once_cell",
704− "ring",
705− "rustls-pki-types",
706− "rustls-webpki",
707− "subtle",
708− "zeroize",
709−]
710−
711−[[package]]
712−name = "rustls-pemfile"
713−version = "2.2.0"
714−source = "registry+https://github.com/rust-lang/crates.io-index"
715−checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
716−dependencies = [
717− "rustls-pki-types",
718−]
719−
720−[[package]]
721−name = "rustls-pki-types"
722−version = "1.14.0"
723−source = "registry+https://github.com/rust-lang/crates.io-index"
724−checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
725−dependencies = [
726− "zeroize",
727−]
728−
729−[[package]]
730−name = "rustls-webpki"
731−version = "0.103.12"
732−source = "registry+https://github.com/rust-lang/crates.io-index"
733−checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06"
734−dependencies = [
735− "aws-lc-rs",
736− "ring",
737− "rustls-pki-types",
738− "untrusted",
739−]
740−
741579[[package]]
742580name = "rustversion"
743581version = "1.0.22"
@@ -813,12 +651,6 @@ dependencies = [
813651 "syn",
814652]
815653
816−[[package]]
817−name = "shlex"
818−version = "1.3.0"
819−source = "registry+https://github.com/rust-lang/crates.io-index"
820−checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
821−
822654[[package]]
823655name = "slab"
824656version = "0.4.12"
@@ -847,12 +679,6 @@ version = "1.2.1"
847679source = "registry+https://github.com/rust-lang/crates.io-index"
848680checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
849681
850−[[package]]
851−name = "subtle"
852−version = "2.6.1"
853−source = "registry+https://github.com/rust-lang/crates.io-index"
854−checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
855−
856682[[package]]
857683name = "syn"
858684version = "2.0.117"
@@ -911,16 +737,6 @@ dependencies = [
911737 "syn",
912738]
913739
914−[[package]]
915−name = "tokio-rustls"
916−version = "0.26.4"
917−source = "registry+https://github.com/rust-lang/crates.io-index"
918−checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
919−dependencies = [
920− "rustls",
921− "tokio",
922−]
923−
924740[[package]]
925741name = "tokio-util"
926742version = "0.7.18"
@@ -1031,12 +847,6 @@ version = "1.0.24"
1031847source = "registry+https://github.com/rust-lang/crates.io-index"
1032848checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
1033849
1034−[[package]]
1035−name = "untrusted"
1036−version = "0.9.0"
1037−source = "registry+https://github.com/rust-lang/crates.io-index"
1038−checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
1039−
1040850[[package]]
1041851name = "url"
1042852version = "2.5.8"
@@ -1072,15 +882,6 @@ version = "0.11.1+wasi-snapshot-preview1"
1072882source = "registry+https://github.com/rust-lang/crates.io-index"
1073883checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
1074884
1075−[[package]]
1076−name = "wasip2"
1077−version = "1.0.2+wasi-0.2.9"
1078−source = "registry+https://github.com/rust-lang/crates.io-index"
1079−checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
1080−dependencies = [
1081− "wit-bindgen",
1082−]
1083−
1084885[[package]]
1085886name = "wasm-bindgen"
1086887version = "0.2.114"
@@ -1223,12 +1024,6 @@ version = "0.52.6"
12231024source = "registry+https://github.com/rust-lang/crates.io-index"
12241025checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
12251026
1226−[[package]]
1227−name = "wit-bindgen"
1228−version = "0.51.0"
1229−source = "registry+https://github.com/rust-lang/crates.io-index"
1230−checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
1231−
12321027[[package]]
12331028name = "writeable"
12341029version = "0.6.3"
@@ -1279,12 +1074,6 @@ dependencies = [
12791074 "synstructure",
12801075]
12811076
1282−[[package]]
1283−name = "zeroize"
1284−version = "1.8.2"
1285−source = "registry+https://github.com/rust-lang/crates.io-index"
1286−checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
1287−
12881077[[package]]
12891078name = "zerotrie"
12901079version = "0.2.4"
Cargo.toml−2
@@ -13,10 +13,8 @@ path = "src/bin/htmlang_lsp/main.rs"
1313[dependencies]
1414htmlang-core = { path = "crates/htmlang-core" }
1515notify = "7"
16−rustls-pemfile = "2.2.0"
1716serde_json = "1"
1817tokio = { version = "1", features = ["io-std", "io-util", "macros", "net", "rt-multi-thread", "sync", "time"] }
19−tokio-rustls = { version = "0.26.4", features = ["ring"] }
2018tower-lsp = "0.20"
2119
2220[profile.release]
README.md+1−2
@@ -49,12 +49,11 @@ htmlang serve . # dev server with live reload
4949| Command | Purpose |
5050|---|---|
5151| `build <dir> [-o out] [--minify] [--strict]` | Compile every `.hl` file under a directory |
52−| `serve [dir\|file] [-p PORT] [--open] [--https --cert C --key K]` | Dev server with live reload |
52+| `serve [dir\|file] [-p PORT] [--open]` | Dev server with live reload |
5353| `watch [dir\|file] [-o out]` | Recompile on change, without a server |
5454| `check <file\|dir> [--format json]` | Report diagnostics without writing output |
5555| `lint <file\|dir> [--format json]` | Stricter checks (accessibility, nesting) |
5656| `fmt <file.hl>` | Format a file in place |
57−| `convert <file.html>` | Convert HTML to htmlang (prints to stdout) |
5857| `upgrade [dir\|file]` | Rewrite syntax from older versions of the language |
5958| `lsp` | Run the language server over stdio |
6059
src/cli.rs+3−4
@@ -3,7 +3,7 @@ use std::process;
33
44/// Subcommands understood by the CLI.
55pub const COMMANDS: &[&str] = &[
6− "build", "serve", "watch", "check", "lint", "fmt", "convert", "upgrade", "lsp",
6+ "build", "serve", "watch", "check", "lint", "fmt", "upgrade", "lsp",
77];
88
99pub fn print_help() {
@@ -15,9 +15,9 @@ Usage: htmlang [options] <file.hl | directory>
1515 htmlang <command> [args]
1616
1717Commands:
18− build <dir> [-o <out>] [--minify] [--strict] [--shared-css]
18+ build <dir> [-o <out>] [--minify] [--strict]
1919 Compile every .hl file under a directory
20− serve [dir|file] [-p N] [--open] [--https --cert <pem> --key <pem>]
20+ serve [dir|file] [-p N] [--open]
2121 Dev server with live reload
2222 watch [dir|file] [-o <out>]
2323 Recompile on change, without a server
@@ -26,7 +26,6 @@ Commands:
2626 lint <file.hl | dir> [--format json]
2727 Stricter checks (accessibility, nesting)
2828 fmt <file.hl> Format a file in place
29− convert <file.html> Convert HTML to .hl (printed to stdout)
3029 upgrade [dir|file] Rewrite removed or renamed syntax
3130 lsp Start the language server (stdio)
3231
src/convert.rsdeleted−1425
@@ -1,1425 +0,0 @@
1−/// Converts HTML to htmlang (.hl) syntax.
2−///
3−/// This is a best-effort converter: it handles well-formed HTML and maps known
4−/// elements and CSS properties to their htmlang equivalents. Unknown elements
5−/// are wrapped in `@raw """..."""`.
6−
7−// ---------------------------------------------------------------------------
8−// Simple recursive-descent HTML parser
9−// ---------------------------------------------------------------------------
10−
11−#[derive(Debug)]
12−enum HtmlNode {
13− Element {
14− tag: String,
15− attrs: Vec<(String, String)>,
16− children: Vec<HtmlNode>,
17− self_closing: bool,
18− },
19− Text(String),
20− Comment(String),
21− /// For DOCTYPE, processing instructions, etc. we just keep the raw text.
22− Raw(String),
23−}
24−
25−struct HtmlParser<'a> {
26− src: &'a [u8],
27− pos: usize,
28−}
29−
30−/// Tags that are self-closing in HTML (void elements).
31−const VOID_TAGS: &[&str] = &[
32− "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source",
33− "track", "wbr",
34−];
35−
36−impl<'a> HtmlParser<'a> {
37− fn new(src: &'a str) -> Self {
38− Self {
39− src: src.as_bytes(),
40− pos: 0,
41− }
42− }
43−
44− fn advance(&mut self) {
45− if self.pos < self.src.len() {
46− self.pos += 1;
47− }
48− }
49−
50− fn skip_whitespace(&mut self) {
51− while self.pos < self.src.len() && self.src[self.pos].is_ascii_whitespace() {
52− self.pos += 1;
53− }
54− }
55−
56− fn starts_with(&self, s: &str) -> bool {
57− self.src[self.pos..].starts_with(s.as_bytes())
58− }
59−
60− /// Whether the `<` at the current position opens markup (a tag, closing
61− /// tag, comment or doctype) rather than being a literal `<` in text.
62− fn at_markup(&self) -> bool {
63− self.src.get(self.pos) == Some(&b'<')
64− && self
65− .src
66− .get(self.pos + 1)
67− .is_some_and(|&c| c.is_ascii_alphabetic() || c == b'/' || c == b'!')
68− }
69−
70− /// Consume up to and including `needle`, returning the text before it.
71− /// Without a match, consumes the rest of the input.
72− fn consume_until(&mut self, needle: &str) -> String {
73− self.consume_until_by(needle, |a, b| a == b)
74− }
75−
76− /// Case-insensitive consume-until.
77− fn consume_until_ci(&mut self, needle: &str) -> String {
78− self.consume_until_by(needle, |a, b| a.eq_ignore_ascii_case(b))
79− }
80−
81− fn consume_until_by(&mut self, needle: &str, eq: impl Fn(&[u8], &[u8]) -> bool) -> String {
82− let start = self.pos;
83− let needle = needle.as_bytes();
84− let found = self.src[start..]
85− .windows(needle.len())
86− .position(|w| eq(w, needle));
87− let end = found.map_or(self.src.len(), |i| start + i);
88− self.pos = found.map_or(self.src.len(), |_| end + needle.len());
89− String::from_utf8_lossy(&self.src[start..end]).into_owned()
90− }
91−
92− fn parse_nodes(&mut self, stop_tag: Option<&str>) -> Vec<HtmlNode> {
93− let mut nodes = Vec::new();
94− loop {
95− if self.pos >= self.src.len() {
96− break;
97− }
98− // Check for closing tag
99− if let Some(stop) = stop_tag
100− && self.starts_with("</")
101− {
102− let saved = self.pos;
103− self.pos += 2;
104− let tag = self.parse_tag_name();
105− if tag.eq_ignore_ascii_case(stop) {
106− // Consume the rest of the closing tag
107− self.consume_until(">");
108− break;
109− }
110− // Not our closing tag, restore and let it be handled
111− self.pos = saved;
112− // This closing tag doesn't match; break out to let the
113− // parent handle it.
114− break;
115− }
116−
117− if self.starts_with("<!--") {
118− self.pos += 4;
119− let text = self.consume_until("-->");
120− nodes.push(HtmlNode::Comment(text));
121− } else if self.starts_with("<!") {
122− // DOCTYPE or similar
123− let raw = self.consume_until(">");
124− nodes.push(HtmlNode::Raw(format!("<!{}>", raw)));
125− } else if self.starts_with("</") {
126− // Stray closing tag (no matching open) -- skip it
127− self.consume_until(">");
128− } else if self.at_markup() {
129− if let Some(node) = self.parse_element() {
130− nodes.push(node);
131− }
132− } else {
133− // Text content
134− let text = self.parse_text();
135− if !text.is_empty() {
136− nodes.push(HtmlNode::Text(text));
137− }
138− }
139− }
140− nodes
141− }
142−
143− fn parse_text(&mut self) -> String {
144− let start = self.pos;
145− // A `<` that doesn't open markup (`a < b`, `1 <2`) is literal text.
146− while self.pos < self.src.len() && (self.pos == start || !self.at_markup()) {
147− self.pos += 1;
148− }
149− let raw = String::from_utf8_lossy(&self.src[start..self.pos]);
150− decode_entities(&raw)
151− }
152−
153− fn parse_tag_name(&mut self) -> String {
154− let start = self.pos;
155− while self.pos < self.src.len() {
156− let ch = self.src[self.pos];
157− if ch.is_ascii_alphanumeric() || ch == b'-' || ch == b'_' || ch == b':' {
158− self.pos += 1;
159− } else {
160− break;
161− }
162− }
163− std::str::from_utf8(&self.src[start..self.pos])
164− .unwrap_or("")
165− .to_ascii_lowercase()
166− }
167−
168− fn parse_element(&mut self) -> Option<HtmlNode> {
169− // Consume '<'
170− self.advance();
171− let tag = self.parse_tag_name();
172− if tag.is_empty() {
173− return None;
174− }
175−
176− let mut attrs = Vec::new();
177− loop {
178− self.skip_whitespace();
179− if self.pos >= self.src.len() {
180− break;
181− }
182− if self.starts_with("/>") {
183− self.pos += 2;
184− return Some(HtmlNode::Element {
185− tag,
186− attrs,
187− children: Vec::new(),
188− self_closing: true,
189− });
190− }
191− if self.starts_with(">") {
192− self.advance();
193− break;
194− }
195− // Parse attribute
196− let key = self.parse_attr_name();
197− if key.is_empty() {
198− self.advance(); // skip unknown char
199− continue;
200− }
201− self.skip_whitespace();
202− let value = if self.starts_with("=") {
203− self.advance();
204− self.skip_whitespace();
205− self.parse_attr_value()
206− } else {
207− String::new()
208− };
209− attrs.push((key, value));
210− }
211−
212− // Void / self-closing tags
213− if VOID_TAGS.contains(&tag.as_str()) {
214− return Some(HtmlNode::Element {
215− tag,
216− attrs,
217− children: Vec::new(),
218− self_closing: true,
219− });
220− }
221−
222− // Raw content tags (script, style) -- consume until closing tag
223− if tag == "script" || tag == "style" {
224− let closer = format!("</{}>", tag);
225− let content = self.consume_until_ci(&closer);
226− let children = if content.trim().is_empty() {
227− Vec::new()
228− } else {
229− vec![HtmlNode::Text(content)]
230− };
231− return Some(HtmlNode::Element {
232− tag,
233− attrs,
234− children,
235− self_closing: false,
236− });
237− }
238−
239− let children = self.parse_nodes(Some(&tag));
240− Some(HtmlNode::Element {
241− tag,
242− attrs,
243− children,
244− self_closing: false,
245− })
246− }
247−
248− fn parse_attr_name(&mut self) -> String {
249− let start = self.pos;
250− while self.pos < self.src.len() {
251− let ch = self.src[self.pos];
252− if ch.is_ascii_alphanumeric()
253− || ch == b'-'
254− || ch == b'_'
255− || ch == b':'
256− || ch == b'.'
257− || ch == b'@'
258− {
259− self.pos += 1;
260− } else {
261− break;
262− }
263− }
264− std::str::from_utf8(&self.src[start..self.pos])
265− .unwrap_or("")
266− .to_string()
267− }
268−
269− fn parse_attr_value(&mut self) -> String {
270− if self.pos >= self.src.len() {
271− return String::new();
272− }
273− let quote = self.src[self.pos];
274− if quote == b'"' || quote == b'\'' {
275− self.advance();
276− let start = self.pos;
277− while self.pos < self.src.len() && self.src[self.pos] != quote {
278− self.pos += 1;
279− }
280− let val = std::str::from_utf8(&self.src[start..self.pos])
281− .unwrap_or("")
282− .to_string();
283− if self.pos < self.src.len() {
284− self.advance(); // closing quote
285− }
286− decode_entities(&val)
287− } else {
288− // Unquoted value
289− let start = self.pos;
290− while self.pos < self.src.len()
291− && !self.src[self.pos].is_ascii_whitespace()
292− && self.src[self.pos] != b'>'
293− {
294− self.pos += 1;
295− }
296− std::str::from_utf8(&self.src[start..self.pos])
297− .unwrap_or("")
298− .to_string()
299− }
300− }
301−
302−}
303−
304−/// Decode HTML character references in one pass (so `&amp;lt;` stays `&lt;`),
305−/// including numeric (`&#8212;`, `&#x2014;`) and common named entities.
306−/// Unknown references are kept as written.
307−fn decode_entities(s: &str) -> String {
308− let mut out = String::with_capacity(s.len());
309− let mut rest = s;
310− while let Some(amp) = rest.find('&') {
311− out.push_str(&rest[..amp]);
312− rest = &rest[amp..];
313− let decoded = rest[1..].find(';').filter(|&end| end <= 32).and_then(|end| {
314− let name = &rest[1..1 + end];
315− decode_entity(name).map(|c| (c, end + 2))
316− });
317− match decoded {
318− Some((c, len)) => {
319− out.push(c);
320− rest = &rest[len..];
321− }
322− None => {
323− out.push('&');
324− rest = &rest[1..];
325− }
326− }
327− }
328− out.push_str(rest);
329− out
330−}
331−
332−fn decode_entity(name: &str) -> Option<char> {
333− if let Some(num) = name.strip_prefix('#') {
334− let code = match num.strip_prefix(['x', 'X']) {
335− Some(hex) => u32::from_str_radix(hex, 16).ok()?,
336− None => num.parse().ok()?,
337− };
338− return char::from_u32(code);
339− }
340− Some(match name {
341− "amp" => '&',
342− "lt" => '<',
343− "gt" => '>',
344− "quot" => '"',
345− "apos" => '\'',
346− "nbsp" => '\u{a0}',
347− "copy" => '©',
348− "reg" => '®',
349− "trade" => '™',
350− "hellip" => '…',
351− "mdash" => '—',
352− "ndash" => '–',
353− "lsquo" => '‘',
354− "rsquo" => '’',
355− "ldquo" => '“',
356− "rdquo" => '”',
357− "laquo" => '«',
358− "raquo" => '»',
359− "bull" => '•',
360− "middot" => '·',
361− "deg" => '°',
362− "times" => '×',
363− "divide" => '÷',
364− "euro" => '€',
365− "pound" => '£',
366− "yen" => '¥',
367− "cent" => '¢',
368− "sect" => '§',
369− "para" => '¶',
370− "larr" => '←',
371− "rarr" => '→',
372− "uarr" => '↑',
373− "darr" => '↓',
374− _ => return None,
375− })
376−}
377−
378−// ---------------------------------------------------------------------------
379−// CSS style parsing (inline style="" attribute)
380−// ---------------------------------------------------------------------------
381−
382−fn parse_inline_style(style: &str) -> Vec<(String, String)> {
383− let mut props = Vec::new();
384− for decl in style.split(';') {
385− let decl = decl.trim();
386− if decl.is_empty() {
387− continue;
388− }
389− if let Some(colon) = decl.find(':') {
390− let key = decl[..colon].trim().to_string();
391− let val = decl[colon + 1..].trim().to_string();
392− props.push((key, val));
393− }
394− }
395− props
396−}
397−
398−/// Strips a trailing "px" and returns just the number, if the value is a plain
399−/// px value. Otherwise returns the value as-is.
400−fn strip_px(v: &str) -> &str {
401− v.strip_suffix("px").unwrap_or(v)
402−}
403−
404−/// Strips px from a multi-value string like "10px 20px".
405−fn strip_px_multi(v: &str) -> String {
406− v.split_whitespace()
407− .map(strip_px)
408− .collect::<Vec<_>>()
409− .join(" ")
410−}
411−
412−/// Converts CSS properties to htmlang attribute key-value pairs.
413−/// Returns (attrs, leftover_css) where leftover_css is anything we
414−/// could not convert to a known htmlang attribute.
415−type HlAttr = (String, Option<String>);
416−type CssDecl = (String, String);
417−
418−fn css_to_hl_attrs(styles: &[CssDecl]) -> (Vec<HlAttr>, Vec<CssDecl>) {
419− let mut attrs: Vec<(String, Option<String>)> = Vec::new();
420− let mut leftover: Vec<(String, String)> = Vec::new();
421−
422− // Collect all styles into a map so we can detect flex patterns
423− let style_map: std::collections::HashMap<&str, &str> = styles
424− .iter()
425− .map(|(k, v)| (k.as_str(), v.as_str()))
426− .collect();
427−
428− // Detect flex row/column -- these affect the element kind, not attributes
429− // We skip display:flex and flex-direction here; the caller checks them.
430−
431− for (key, val) in styles {
432− match key.as_str() {
433− // Skip flex layout props -- handled at element-kind level
434− "display" if val == "flex" => {}
435− "flex-direction" => {}
436−
437− "display" if val == "grid" => {
438− attrs.push(("grid".into(), None));
439− }
440− "display" if val == "none" => {
441− attrs.push(("hidden".into(), None));
442− }
443−
444− "padding" => {
445− attrs.push(("padding".into(), Some(strip_px_multi(val))));
446− }
447− "padding-left" | "padding-right" => {
448− // Check for padding-x pattern
449− if key == "padding-left"
450− && let Some(&pr) = style_map.get("padding-right")
451− && pr == val
452− {
453− attrs.push(("padding-x".into(), Some(strip_px(val).into())));
454− continue;
455− }
456− if key == "padding-right" && style_map.get("padding-left") == Some(&val.as_str()) {
457− // Already emitted as padding-x
458− continue;
459− }
460− leftover.push((key.clone(), val.clone()));
461− }
462− "padding-top" | "padding-bottom" => {
463− if key == "padding-top"
464− && let Some(&pb) = style_map.get("padding-bottom")
465− && pb == val
466− {
467− attrs.push(("padding-y".into(), Some(strip_px(val).into())));
468− continue;
469− }
470− if key == "padding-bottom" && style_map.get("padding-top") == Some(&val.as_str()) {
471− continue;
472− }
473− leftover.push((key.clone(), val.clone()));
474− }
475− "padding-inline" => {
476− attrs.push(("padding-inline".into(), Some(strip_px_multi(val))));
477− }
478− "padding-block" => {
479− attrs.push(("padding-block".into(), Some(strip_px_multi(val))));
480− }
481−
482− "margin" => {
483− attrs.push(("margin".into(), Some(strip_px_multi(val))));
484− }
485− "margin-left" | "margin-right" => {
486− if key == "margin-left"
487− && let Some(&mr) = style_map.get("margin-right")
488− && mr == val
489− {
490− attrs.push(("margin-x".into(), Some(strip_px(val).into())));
491− continue;
492− }
493− if key == "margin-right" && style_map.get("margin-left") == Some(&val.as_str()) {
494− continue;
495− }
496− leftover.push((key.clone(), val.clone()));
497− }
498− "margin-top" | "margin-bottom" => {
499− if key == "margin-top"
500− && let Some(&mb) = style_map.get("margin-bottom")
501− && mb == val
502− {
503− attrs.push(("margin-y".into(), Some(strip_px(val).into())));
504− continue;
505− }
506− if key == "margin-bottom" && style_map.get("margin-top") == Some(&val.as_str()) {
507− continue;
508− }
509− leftover.push((key.clone(), val.clone()));
510− }
511− "margin-inline" => {
512− attrs.push(("margin-inline".into(), Some(strip_px_multi(val))));
513− }
514− "margin-block" => {
515− attrs.push(("margin-block".into(), Some(strip_px_multi(val))));
516− }
517−
518− "width" => attrs.push(("width".into(), Some(strip_px(val).into()))),
519− "height" => attrs.push(("height".into(), Some(strip_px(val).into()))),
520− "min-width" => attrs.push(("min-width".into(), Some(strip_px(val).into()))),
521− "max-width" => attrs.push(("max-width".into(), Some(strip_px(val).into()))),
522− "min-height" => attrs.push(("min-height".into(), Some(strip_px(val).into()))),
523− "max-height" => attrs.push(("max-height".into(), Some(strip_px(val).into()))),
524−
525− "gap" => attrs.push(("spacing".into(), Some(strip_px(val).into()))),
526− "column-gap" => attrs.push(("gap-x".into(), Some(strip_px(val).into()))),
527− "row-gap" => attrs.push(("gap-y".into(), Some(strip_px(val).into()))),
528−
529− "background" | "background-color" => {
530− attrs.push(("background".into(), Some(val.clone())));
531− }
532− "color" => attrs.push(("color".into(), Some(val.clone()))),
533−
534− "border" => {
535− // Try to simplify "1px solid #ccc" -> "1 #ccc"
536− attrs.push(("border".into(), Some(simplify_border(val))));
537− }
538− "border-top" => attrs.push(("border-top".into(), Some(simplify_border(val)))),
539− "border-bottom" => attrs.push(("border-bottom".into(), Some(simplify_border(val)))),
540− "border-left" => attrs.push(("border-left".into(), Some(simplify_border(val)))),
541− "border-right" => attrs.push(("border-right".into(), Some(simplify_border(val)))),
542−
543− "border-radius" => {
544− attrs.push(("rounded".into(), Some(strip_px(val).into())));
545− }
546−
547− "font-weight" if val == "bold" || val == "700" => {
548− attrs.push(("bold".into(), None));
549− }
550− "font-weight" => {
551− attrs.push(("font-weight".into(), Some(val.clone())));
552− }
553− "font-style" if val == "italic" => {
554− attrs.push(("italic".into(), None));
555− }
556− "font-style" => {
557− attrs.push(("font-style".into(), Some(val.clone())));
558− }
559− "text-decoration" if val.contains("underline") => {
560− attrs.push(("underline".into(), None));
561− }
562− "text-decoration" => {
563− leftover.push((key.clone(), val.clone()));
564− }
565− "font-size" => attrs.push(("size".into(), Some(strip_px(val).into()))),
566− "font-family" => attrs.push(("font".into(), Some(val.clone()))),
567−
568− "text-align" => attrs.push(("text-align".into(), Some(val.clone()))),
569− "line-height" => attrs.push(("line-height".into(), Some(val.clone()))),
570− "letter-spacing" => {
571− attrs.push(("letter-spacing".into(), Some(strip_px(val).into())));
572− }
573− "text-transform" => attrs.push(("text-transform".into(), Some(val.clone()))),
574− "white-space" => attrs.push(("white-space".into(), Some(val.clone()))),
575−
576− "overflow" => attrs.push(("overflow".into(), Some(val.clone()))),
577− "position" => attrs.push(("position".into(), Some(val.clone()))),
578− "top" => attrs.push(("top".into(), Some(strip_px(val).into()))),
579− "right" => attrs.push(("right".into(), Some(strip_px(val).into()))),
580− "bottom" => attrs.push(("bottom".into(), Some(strip_px(val).into()))),
581− "left" => attrs.push(("left".into(), Some(strip_px(val).into()))),
582− "z-index" => attrs.push(("z-index".into(), Some(val.clone()))),
583−
584− "display" => attrs.push(("display".into(), Some(val.clone()))),
585− "visibility" => attrs.push(("visibility".into(), Some(val.clone()))),
586−
587− "opacity" => attrs.push(("opacity".into(), Some(val.clone()))),
588− "cursor" => attrs.push(("cursor".into(), Some(val.clone()))),
589− "transition" => attrs.push(("transition".into(), Some(val.clone()))),
590− "transform" => attrs.push(("transform".into(), Some(val.clone()))),
591− "backdrop-filter" => attrs.push(("backdrop-filter".into(), Some(val.clone()))),
592− "box-shadow" => attrs.push(("shadow".into(), Some(val.clone()))),
593− "flex-wrap" if val == "wrap" => attrs.push(("wrap".into(), None)),
594− "animation" => attrs.push(("animation".into(), Some(val.clone()))),
595− "aspect-ratio" => attrs.push(("aspect-ratio".into(), Some(val.clone()))),
596− "outline" => attrs.push(("outline".into(), Some(simplify_border(val)))),
597− "filter" => attrs.push(("filter".into(), Some(val.clone()))),
598− "object-fit" => attrs.push(("object-fit".into(), Some(val.clone()))),
599− "object-position" => attrs.push(("object-position".into(), Some(val.clone()))),
600− "text-shadow" => attrs.push(("text-shadow".into(), Some(val.clone()))),
601− "text-overflow" => attrs.push(("text-overflow".into(), Some(val.clone()))),
602− "pointer-events" => attrs.push(("pointer-events".into(), Some(val.clone()))),
603− "user-select" => attrs.push(("user-select".into(), Some(val.clone()))),
604− "justify-content" => attrs.push(("justify-content".into(), Some(val.clone()))),
605− "align-items" => attrs.push(("align-items".into(), Some(val.clone()))),
606−
607− "grid-template-columns" => {
608− attrs.push(("grid-cols".into(), Some(simplify_grid_repeat(val))));
609− }
610− "grid-template-rows" => {
611− attrs.push(("grid-rows".into(), Some(simplify_grid_repeat(val))));
612− }
613− "grid-column" => {
614− if let Some(rest) = val.strip_prefix("span ") {
615− attrs.push(("col-span".into(), Some(rest.trim().into())));
616− } else {
617− leftover.push((key.clone(), val.clone()));
618− }
619− }
620− "grid-row" => {
621− if let Some(rest) = val.strip_prefix("span ") {
622− attrs.push(("row-span".into(), Some(rest.trim().into())));
623− } else {
624− leftover.push((key.clone(), val.clone()));
625− }
626− }
627−
628− "scroll-snap-type" => attrs.push(("scroll-snap-type".into(), Some(val.clone()))),
629− "scroll-snap-align" => attrs.push(("scroll-snap-align".into(), Some(val.clone()))),
630−
631− "flex" if val == "1" => attrs.push(("width".into(), Some("fill".into()))),
632− "flex-shrink" if val == "0" => attrs.push(("width".into(), Some("shrink".into()))),
633−
634− "text-wrap" => attrs.push(("text-wrap".into(), Some(val.clone()))),
635− "will-change" => attrs.push(("will-change".into(), Some(val.clone()))),
636− "touch-action" => attrs.push(("touch-action".into(), Some(val.clone()))),
637− "vertical-align" => attrs.push(("vertical-align".into(), Some(val.clone()))),
638− "contain" => attrs.push(("contain".into(), Some(val.clone()))),
639− "content-visibility" => attrs.push(("content-visibility".into(), Some(val.clone()))),
640− "scroll-margin" => attrs.push(("scroll-margin".into(), Some(strip_px(val).into()))),
641− "scroll-padding" => attrs.push(("scroll-padding".into(), Some(strip_px(val).into()))),
642−
643− _ => {
644− leftover.push((key.clone(), val.clone()));
645− }
646− }
647− }
648−
649− (attrs, leftover)
650−}
651−
652−/// Simplifies a CSS border value like "1px solid #ccc" to "1 #ccc".
653−fn simplify_border(val: &str) -> String {
654− let parts: Vec<&str> = val.split_whitespace().collect();
655− // Common pattern: "1px solid #ccc"
656− if parts.len() == 3 && parts[1] == "solid" {
657− let width = strip_px(parts[0]);
658− let color = parts[2];
659− if color == "currentColor" {
660− width.to_string()
661− } else {
662− format!("{} {}", width, color)
663− }
664− } else if parts.len() == 2 && parts[0].ends_with("px") {
665− // "1px #ccc" (no style)
666− format!("{} {}", strip_px(parts[0]), parts[1])
667− } else {
668− val.to_string()
669− }
670−}
671−
672−/// Simplifies "repeat(3, 1fr)" to "3".
673−fn simplify_grid_repeat(val: &str) -> String {
674− if let Some(inner) = val
675− .strip_prefix("repeat(")
676− .and_then(|s| s.strip_suffix(')'))
677− {
678− let parts: Vec<&str> = inner.splitn(2, ',').collect();
679− if parts.len() == 2 && parts[1].trim() == "1fr" {
680− return parts[0].trim().to_string();
681− }
682− }
683− val.to_string()
684−}
685−
686−// ---------------------------------------------------------------------------
687−// HTML-to-htmlang conversion
688−// ---------------------------------------------------------------------------
689−
690−/// Converts an HTML string to htmlang (.hl) syntax.
691−pub fn convert(html: &str) -> String {
692− let mut parser = HtmlParser::new(html);
693− let nodes = parser.parse_nodes(None);
694−
695− // Unwrap boilerplate: look inside html > body for the real content.
696− let content_nodes = unwrap_boilerplate(&nodes);
697−
698− let mut out = String::new();
699− for node in content_nodes {
700− emit_node(node, 0, &mut out);
701− }
702−
703− // Trim trailing whitespace but keep a final newline
704− let trimmed = out.trim_end();
705− if trimmed.is_empty() {
706− String::new()
707− } else {
708− format!("{}\n", trimmed)
709− }
710−}
711−
712−/// Walks through html/head/body wrappers and returns references to the
713−/// meaningful content nodes.
714−fn unwrap_boilerplate(nodes: &[HtmlNode]) -> Vec<&HtmlNode> {
715− // First pass: skip DOCTYPE, comments, whitespace-only text at top level
716− let meaningful: Vec<&HtmlNode> = nodes
717− .iter()
718− .filter(|n| match n {
719− HtmlNode::Raw(_) => false,
720− HtmlNode::Comment(_) => false,
721− HtmlNode::Text(t) => !t.trim().is_empty(),
722− HtmlNode::Element { .. } => true,
723− })
724− .collect();
725−
726− // If there's a single <html> element, unwrap it
727− if meaningful.len() == 1
728− && let HtmlNode::Element { tag, children, .. } = meaningful[0]
729− && tag == "html"
730− {
731− return unwrap_html_children(children);
732− }
733−
734− // Otherwise, check if there are any html/head/body tags mixed in
735− let mut result = Vec::new();
736− for node in nodes {
737− match node {
738− HtmlNode::Raw(_) => {}
739− HtmlNode::Comment(_) => {}
740− HtmlNode::Text(t) if t.trim().is_empty() => {}
741− HtmlNode::Element { tag, children, .. }
742− if tag == "html" || tag == "head" || tag == "body" =>
743− {
744− if tag == "body" || tag == "html" {
745− for child in children {
746− match child {
747− HtmlNode::Element { tag: t, .. } if t == "head" => {}
748− HtmlNode::Text(t) if t.trim().is_empty() => {}
749− _ => result.push(child),
750− }
751− }
752− }
753− // Skip <head> entirely
754− }
755− _ => result.push(node),
756− }
757− }
758−
759− if result.is_empty() {
760− // Fallback: return everything that's not boilerplate
761− nodes
762− .iter()
763− .filter(|n| !matches!(n, HtmlNode::Raw(_)))
764− .collect()
765− } else {
766− result
767− }
768−}
769−
770−fn unwrap_html_children(children: &[HtmlNode]) -> Vec<&HtmlNode> {
771− let mut result = Vec::new();
772− for child in children {
773− match child {
774− HtmlNode::Element { tag, children, .. } if tag == "head" => {
775− // Skip <head> entirely
776− let _ = children;
777− }
778− HtmlNode::Element { tag, children, .. } if tag == "body" => {
779− for body_child in children {
780− match body_child {
781− HtmlNode::Text(t) if t.trim().is_empty() => {}
782− _ => result.push(body_child),
783− }
784− }
785− }
786− HtmlNode::Text(t) if t.trim().is_empty() => {}
787− _ => result.push(child),
788− }
789− }
790− result
791−}
792−
793−fn indent_str(depth: usize) -> String {
794− " ".repeat(depth)
795−}
796−
797−fn emit_node(node: &HtmlNode, depth: usize, out: &mut String) {
798− match node {
799− HtmlNode::Text(text) => {
800− let trimmed = text.trim();
801− if !trimmed.is_empty() {
802− for line in trimmed.lines() {
803− let l = line.trim();
804− if !l.is_empty() {
805− out.push_str(&indent_str(depth));
806− // A bare line starting with `@`, `[` or `--` would be
807− // read as an element, attribute list or comment.
808− if l.starts_with('@') || l.starts_with('[') || l.starts_with("--") {
809− out.push_str("@text ");
810− }
811− out.push_str(l);
812− out.push('\n');
813− }
814− }
815− }
816− }
817− HtmlNode::Comment(text) => {
818− for line in text.trim().lines() {
819− out.push_str(&indent_str(depth));
820− out.push_str("-- ");
821− out.push_str(line.trim());
822− out.push('\n');
823− }
824− }
825− HtmlNode::Raw(_) => {}
826− HtmlNode::Element {
827− tag,
828− attrs,
829− children,
830− ..
831− } => {
832− emit_element(tag, attrs, children, depth, out);
833− }
834− }
835−}
836−
837−fn emit_element(
838− tag: &str,
839− attrs: &[(String, String)],
840− children: &[HtmlNode],
841− depth: usize,
842− out: &mut String,
843−) {
844− let indent = indent_str(depth);
845−
846− // Collect HTML attributes
847− let style_str = attr_val(attrs, "style").unwrap_or_default();
848− let class_str = attr_val(attrs, "class").unwrap_or_default();
849− let id_str = attr_val(attrs, "id");
850− let href = attr_val(attrs, "href");
851− let src = attr_val(attrs, "src");
852− let alt = attr_val(attrs, "alt");
853−
854− // Parse inline styles
855− let css_props = parse_inline_style(&style_str);
856−
857− // Detect flex layout from styles
858− let is_flex = css_props.iter().any(|(k, v)| k == "display" && v == "flex");
859− let flex_dir = css_props
860− .iter()
861− .find(|(k, _)| k == "flex-direction")
862− .map(|(_, v)| v.as_str());
863−
864− // Map HTML tag to htmlang element
865− let hl_tag = match tag {
866− "div" => {
867− if is_flex {
868− match flex_dir {
869− Some("column") => "@column",
870− _ => "@row",
871− }
872− } else {
873− "@el"
874− }
875− }
876− "span" => "@text",
877− "p" => "@paragraph",
878− "a" => "@link",
879− "img" => "@image",
880− "nav" => "@nav",
881− "header" => "@header",
882− "footer" => "@footer",
883− "main" => "@main",
884− "section" => "@section",
885− "article" => "@article",
886− "aside" => "@aside",
887− "ul" => "@list",
888− "ol" => "@list",
889− "li" => "@item",
890− "table" => "@table",
891− "thead" => "@thead",
892− "tbody" => "@tbody",
893− "tr" => "@tr",
894− "td" => "@td",
895− "th" => "@th",
896− "form" => "@form",
897− "input" => "@input",
898− "button" => "@button",
899− "select" => "@select",
900− "label" => "@label",
901− "details" => "@details",
902− "summary" => "@summary",
903− "blockquote" => "@blockquote",
904− "cite" => "@cite",
905− "code" => "@code",
906− "hr" => "@hr",
907− "figure" => "@figure",
908− "figcaption" => "@figcaption",
909− "video" => "@video",
910− "audio" => "@audio",
911− "dialog" => "@dialog",
912− "dl" => "@dl",
913− "dt" => "@dt",
914− "dd" => "@dd",
915− "fieldset" => "@fieldset",
916− "legend" => "@legend",
917− "picture" => "@picture",
918− "source" => "@source",
919− "mark" => "@mark",
920− "kbd" => "@kbd",
921− "abbr" => "@abbr",
922− "time" => "@time",
923− "progress" => "@progress",
924− "meter" => "@meter",
925− "h1" => "@h1",
926− "h2" => "@h2",
927− "h3" => "@h3",
928− "h4" => "@h4",
929− "h5" => "@h5",
930− "h6" => "@h6",
931− "b" | "strong" => "@text",
932− "i" | "em" => "@text",
933− "u" => "@text",
934− // Whitespace-sensitive content: htmlang text lines are trimmed and
935− // joined, so keep these as HTML.
936− "br" | "pre" | "textarea" => {
937− emit_raw_element(tag, attrs, children, depth, out);
938− return;
939− }
940− "iframe" => "@iframe",
941− "output" => "@output",
942− "canvas" => "@canvas",
943− "noscript" => "@noscript",
944− "address" => "@address",
945− "search" => "@search",
946− // Unknown / raw elements
947− "script" | "style" | "svg" | "object" | "embed" => {
948− emit_raw_element(tag, attrs, children, depth, out);
949− return;
950− }
951− _ => {
952− // Try to emit as raw
953− emit_raw_element(tag, attrs, children, depth, out);
954− return;
955− }
956− };
957−
958− // Build htmlang attributes from CSS + HTML attrs
959− let (mut hl_attrs, leftover_css) = css_to_hl_attrs(&css_props);
960−
961− // Only a div can become @row/@column. For other flex containers
962− // (`<nav style="display:flex">`) keep the semantic element and lay the
963− // children out in a nested @row/@column that takes the flex attributes.
964− let nested_flex = (is_flex && tag != "div").then(|| {
965− let kind = match flex_dir {
966− Some("column") | Some("column-reverse") => "@column",
967− _ => "@row",
968− };
969− let (flex_attrs, rest): (Vec<HlAttr>, Vec<HlAttr>) =
970− hl_attrs.drain(..).partition(|(k, _)| {
971− matches!(
972− k.as_str(),
973− "spacing" | "gap-x" | "gap-y" | "wrap" | "justify-content" | "align-items"
974− )
975− });
976− hl_attrs = rest;
977− (kind, flex_attrs)
978− });
979−
980− // Add id if present
981− if let Some(id) = &id_str {
982− hl_attrs.insert(0, ("id=".into(), Some(id.clone())));
983− }
984−
985− // Bold/italic for strong/em/b/i/u
986− match tag {
987− "b" | "strong" if !hl_attrs.iter().any(|(k, _)| k == "bold") => {
988− hl_attrs.push(("bold".into(), None));
989− }
990− "i" | "em" if !hl_attrs.iter().any(|(k, _)| k == "italic") => {
991− hl_attrs.push(("italic".into(), None));
992− }
993− "u" if !hl_attrs.iter().any(|(k, _)| k == "underline") => {
994− hl_attrs.push(("underline".into(), None));
995− }
996− _ => {}
997− }
998−
999− // Ordered list
1000− if tag == "ol" {
1001− hl_attrs.push(("ordered".into(), None));
1002− }
1003−
1004− // Alt text for images
1005− if tag == "img"
1006− && let Some(alt_text) = &alt
1007− && !alt_text.is_empty()
1008− {
1009− hl_attrs.push(("alt=".into(), Some(alt_text.clone())));
1010− }
1011−
1012− // Every other HTML attribute carries over as `key=value` (a key ending
1013− // in `=` marks an HTML attribute for `push_attr_block`), or bare for
1014− // booleans like `required`.
1015− for (key, val) in attrs {
1016− match key.as_str() {
1017− "style" | "class" | "id" | "href" | "src" | "alt" => {}
1018− _ if val.is_empty()
1019− && htmlang_core::vocab::BOOLEAN_HTML_ATTRS.contains(&key.as_str()) =>
1020− {
1021− hl_attrs.push((key.clone(), None));
1022− }
1023− _ => hl_attrs.push((format!("{}=", key), Some(val.clone()))),
1024− }
1025− }
1026−
1027− // Build the line
1028− out.push_str(&indent);
1029− out.push_str(hl_tag);
1030−
1031− // Argument (link href, image src)
1032− if tag == "a" {
1033− if let Some(url) = &href {
1034− out.push(' ');
1035− out.push_str(url);
1036− }
1037− } else if tag == "img"
1038− && let Some(src_val) = &src
1039− {
1040− out.push(' ');
1041− out.push_str(src_val);
1042− }
1043−
1044− push_attr_block(&hl_attrs, out);
1045− out.push('\n');
1046−
1047− // Comment with original classes (if any)
1048− if !class_str.is_empty() {
1049− out.push_str(&indent_str(depth + 1));
1050− out.push_str("-- classes: ");
1051− out.push_str(&class_str);
1052− out.push('\n');
1053− }
1054−
1055− // Comment with leftover CSS that couldn't be mapped
1056− if !leftover_css.is_empty() {
1057− let css_text: Vec<String> = leftover_css
1058− .iter()
1059− .map(|(k, v)| format!("{}: {}", k, v))
1060− .collect();
1061− out.push_str(&indent_str(depth + 1));
1062− out.push_str("-- unmapped css: ");
1063− out.push_str(&css_text.join("; "));
1064− out.push('\n');
1065− }
1066−
1067− // Emit children
1068− let child_depth = match &nested_flex {
1069− Some((kind, flex_attrs)) => {
1070− out.push_str(&indent_str(depth + 1));
1071− out.push_str(kind);
1072− push_attr_block(flex_attrs, out);
1073− out.push('\n');
1074− depth + 2
1075− }
1076− None => depth + 1,
1077− };
1078− for child in children {
1079− emit_node(child, child_depth, out);
1080− }
1081−}
1082−
1083−/// Append ` [key value, ...]` for a non-empty attribute list. Values that
1084−/// contain a `,` or `]` are quoted so they stay one attribute.
1085−fn push_attr_block(attrs: &[HlAttr], out: &mut String) {
1086− if attrs.is_empty() {
1087− return;
1088− }
1089− out.push_str(" [");
1090− for (i, (key, val)) in attrs.iter().enumerate() {
1091− if i > 0 {
1092− out.push_str(", ");
1093− }
1094− out.push_str(key);
1095− if let Some(v) = val {
1096− // HTML attributes (`key=`) take the value right after the `=`.
1097− if !key.ends_with('=') {
1098− out.push(' ');
1099− }
1100− if v.contains(',') || v.contains(']') {
1101− out.push('"');
1102− out.push_str(&v.replace('"', "'"));
1103− out.push('"');
1104− } else {
1105− out.push_str(v);
1106− }
1107− }
1108− }
1109− out.push(']');
1110−}
1111−
1112−fn emit_raw_element(
1113− tag: &str,
1114− attrs: &[(String, String)],
1115− children: &[HtmlNode],
1116− depth: usize,
1117− out: &mut String,
1118−) {
1119− let indent = indent_str(depth);
1120−
1121− // Reconstruct the HTML for this element
1122− let mut html = String::new();
1123− html.push('<');
1124− html.push_str(tag);
1125− for (key, val) in attrs {
1126− html.push(' ');
1127− html.push_str(key);
1128− if !val.is_empty() {
1129− html.push_str("=\"");
1130− html.push_str(&html_escape(val));
1131− html.push('"');
1132− }
1133− }
1134− if VOID_TAGS.contains(&tag) {
1135− html.push('>');
1136− } else if children.is_empty() {
1137− html.push_str(&format!("></{}>", tag));
1138− } else {
1139− html.push('>');
1140− reconstruct_html(children, is_raw_text_tag(tag), &mut html);
1141− html.push_str(&format!("</{}>", tag));
1142− }
1143−
1144− let trimmed = html.trim();
1145− if trimmed.contains('\n') || trimmed.len() > 60 {
1146− out.push_str(&indent);
1147− out.push_str("@raw \"\"\"\n");
1148− // Raw content is emitted verbatim by the compiler, so indenting it
1149− // here would change the output (e.g. inside <pre>).
1150− for line in trimmed.lines() {
1151− out.push_str(line);
1152− out.push('\n');
1153− }
1154− out.push_str(&indent);
1155− out.push_str("\"\"\"\n");
1156− } else {
1157− out.push_str(&indent);
1158− out.push_str("@raw \"\"\"");
1159− out.push_str(trimmed);
1160− out.push_str("\"\"\"\n");
1161− }
1162−}
1163−
1164−/// Elements whose text content is not HTML (and was not entity-decoded).
1165−fn is_raw_text_tag(tag: &str) -> bool {
1166− tag == "script" || tag == "style"
1167−}
1168−
1169−fn reconstruct_html(nodes: &[HtmlNode], raw_text: bool, out: &mut String) {
1170− for node in nodes {
1171− match node {
1172− HtmlNode::Text(t) if raw_text => out.push_str(t),
1173− HtmlNode::Text(t) => out.push_str(&html_escape(t)),
1174− HtmlNode::Comment(c) => {
1175− out.push_str("<!--");
1176− out.push_str(c);
1177− out.push_str("-->");
1178− }
1179− HtmlNode::Raw(r) => out.push_str(r),
1180− HtmlNode::Element {
1181− tag,
1182− attrs,
1183− children,
1184− self_closing,
1185− } => {
1186− out.push('<');
1187− out.push_str(tag);
1188− for (key, val) in attrs {
1189− out.push(' ');
1190− out.push_str(key);
1191− if !val.is_empty() {
1192− out.push_str("=\"");
1193− out.push_str(&html_escape(val));
1194− out.push('"');
1195− }
1196− }
1197− if *self_closing {
1198− out.push_str(" />");
1199− } else {
1200− out.push('>');
1201− reconstruct_html(children, is_raw_text_tag(tag), out);
1202− out.push_str(&format!("</{}>", tag));
1203− }
1204− }
1205− }
1206− }
1207−}
1208−
1209−fn html_escape(s: &str) -> String {
1210− s.replace('&', "&amp;")
1211− .replace('<', "&lt;")
1212− .replace('>', "&gt;")
1213− .replace('"', "&quot;")
1214−}
1215−
1216−fn attr_val(attrs: &[(String, String)], key: &str) -> Option<String> {
1217− attrs
1218− .iter()
1219− .find(|(k, _)| k == key)
1220− .map(|(_, v)| v.clone())
1221− .filter(|v| !v.is_empty())
1222−}
1223−
1224−// ---------------------------------------------------------------------------
1225−// Tests
1226−// ---------------------------------------------------------------------------
1227−
1228−#[cfg(test)]
1229−mod tests {
1230− use super::*;
1231−
1232− #[test]
1233− fn simple_div() {
1234− let html =
1235− r#"<div style="padding: 20px; background-color: #fff; border-radius: 8px">Hello</div>"#;
1236− let result = convert(html);
1237− assert!(result.contains("@el [padding 20, background #fff, rounded 8]"));
1238− assert!(result.contains("Hello"));
1239− }
1240−
1241− #[test]
1242− fn flex_row() {
1243− let html = r#"<div style="display: flex; flex-direction: row; gap: 10px"><span>A</span><span>B</span></div>"#;
1244− let result = convert(html);
1245− assert!(result.contains("@row [spacing 10]"));
1246− }
1247−
1248− #[test]
1249− fn flex_column() {
1250− let html = r#"<div style="display: flex; flex-direction: column"><p>One</p></div>"#;
1251− let result = convert(html);
1252− assert!(result.contains("@column"));
1253− }
1254−
1255− #[test]
1256− fn link_element() {
1257− let html = r#"<a href="https://example.com">Click me</a>"#;
1258− let result = convert(html);
1259− assert!(result.contains("@link https://example.com"));
1260− assert!(result.contains("Click me"));
1261− }
1262−
1263− #[test]
1264− fn image_element() {
1265− let html = r#"<img src="photo.jpg" alt="A photo">"#;
1266− let result = convert(html);
1267− assert!(result.contains("@image photo.jpg"));
1268− }
1269−
1270− #[test]
1271− fn nested_structure() {
1272− let html = r#"<div><p>Hello</p><p>World</p></div>"#;
1273− let result = convert(html);
1274− assert!(result.contains("@el\n"));
1275− assert!(result.contains(" @paragraph\n"));
1276− assert!(result.contains(" Hello\n"));
1277− }
1278−
1279− #[test]
1280− fn strips_boilerplate() {
1281− let html = r#"<!DOCTYPE html><html><head><title>Test</title></head><body><div>Content</div></body></html>"#;
1282− let result = convert(html);
1283− assert!(!result.contains("html"));
1284− assert!(!result.contains("head"));
1285− assert!(!result.contains("body"));
1286− assert!(result.contains("@el"));
1287− assert!(result.contains("Content"));
1288− }
1289−
1290− #[test]
1291− fn script_to_raw() {
1292− let html = r#"<script>alert("hi");</script>"#;
1293− let result = convert(html);
1294− assert!(result.contains("@raw"));
1295− assert!(result.contains("alert"));
1296− }
1297−
1298− #[test]
1299− fn classes_as_comments() {
1300− let html = r#"<div class="container main-content">Text</div>"#;
1301− let result = convert(html);
1302− assert!(result.contains("-- classes: container main-content"));
1303− }
1304−
1305− #[test]
1306− fn heading_elements() {
1307− let h1 = convert("<h1>Title</h1>");
1308− assert!(h1.contains("@h1"));
1309− let h3 = convert("<h3>Sub</h3>");
1310− assert!(h3.contains("@h3"));
1311− }
1312−
1313− #[test]
1314− fn strong_bold() {
1315− let result = convert("<strong>Important</strong>");
1316− assert!(result.contains("@text [bold]"));
1317− }
1318−
1319− #[test]
1320− fn ordered_list() {
1321− let result = convert("<ol><li>First</li><li>Second</li></ol>");
1322− assert!(result.contains("@list [ordered]"));
1323− assert!(result.contains("@item"));
1324− }
1325−
1326− #[test]
1327− fn semantic_elements() {
1328− let result = convert("<nav><a href=\"/\">Home</a></nav>");
1329− assert!(result.contains("@nav"));
1330− assert!(result.contains("@link /"));
1331− }
1332−
1333− #[test]
1334− fn table_structure() {
1335− let html = "<table><thead><tr><th>Name</th></tr></thead><tbody><tr><td>Alice</td></tr></tbody></table>";
1336− let result = convert(html);
1337− assert!(result.contains("@table"));
1338− assert!(result.contains("@thead"));
1339− assert!(result.contains("@th"));
1340− assert!(result.contains("@tbody"));
1341− assert!(result.contains("@td"));
1342− }
1343−
1344− #[test]
1345− fn form_elements() {
1346− let html = r#"<form action="/submit"><input type="text" placeholder="Name"><button>Go</button></form>"#;
1347− let result = convert(html);
1348− assert!(result.contains("@form [action=/submit]"));
1349− assert!(result.contains("@input [type=text, placeholder=Name]"));
1350− assert!(result.contains("@button"));
1351− }
1352−
1353− #[test]
1354− fn empty_input() {
1355− assert_eq!(convert("").trim(), "");
1356− assert_eq!(convert(" ").trim(), "");
1357− }
1358−
1359− #[test]
1360− fn border_simplification() {
1361− let result = convert(r#"<div style="border: 1px solid #ccc">X</div>"#);
1362− assert!(result.contains("border 1 #ccc"));
1363− }
1364−
1365− #[test]
1366− fn rounded_shorthand() {
1367− let result = convert(r#"<div style="border-radius: 8px">X</div>"#);
1368− assert!(result.contains("rounded 8"));
1369− }
1370−
1371− #[test]
1372− fn bare_less_than_is_text() {
1373− let result = convert("<p>a < b and 1 <2 ok</p><p>after</p>");
1374− assert!(result.contains("a < b and 1 <2 ok"), "{result}");
1375− assert!(result.contains("after"), "{result}");
1376− assert!(!result.contains("@raw"), "{result}");
1377− }
1378−
1379− #[test]
1380− fn script_content_is_not_escaped() {
1381− let result = convert("<script>if (a < b && c > d) {}</script>");
1382− assert!(result.contains("if (a < b && c > d) {}"), "{result}");
1383− }
1384−
1385− #[test]
1386− fn entities_decode_once_and_fully() {
1387− assert_eq!(decode_entities("&amp;lt;tag&amp;gt;"), "&lt;tag&gt;");
1388− assert_eq!(decode_entities("&copy; &#8212; &#x2014; &bogus; a&b"), "© — — &bogus; a&b");
1389− }
1390−
1391− #[test]
1392− fn values_with_commas_are_quoted() {
1393− let result = convert(r#"<input type="text" placeholder="Name, email">"#);
1394− assert!(result.contains(r#"placeholder="Name, email""#), "{result}");
1395− }
1396−
1397− #[test]
1398− fn text_that_looks_like_syntax_is_escaped() {
1399− let result = convert("<p>-- not a comment</p><p>@mention</p>");
1400− assert!(result.contains("@text -- not a comment"), "{result}");
1401− assert!(result.contains("@text @mention"), "{result}");
1402− }
1403−
1404− #[test]
1405− fn whitespace_sensitive_elements_stay_raw() {
1406− let result = convert("<div>line1<br>line2</div><pre> indented\n more</pre>");
1407− assert!(result.contains(r#"@raw """<br>""""#), "{result}");
1408− assert!(result.contains("<pre> indented\n more</pre>"), "{result}");
1409− }
1410−
1411− #[test]
1412− fn flex_on_semantic_element_nests_a_row() {
1413− let result = convert(r#"<nav style="display:flex; gap: 8px"><a href="/">Home</a></nav>"#);
1414− assert!(result.contains("@nav\n @row [spacing 8]\n @link /"), "{result}");
1415− }
1416−
1417− #[test]
1418− fn large_script_converts_quickly() {
1419− let body = "x".repeat(200_000);
1420− let start = std::time::Instant::now();
1421− let result = convert(&format!("<script>{body}</script>"));
1422− assert!(result.contains(&body[..100]));
1423− assert!(start.elapsed() < std::time::Duration::from_secs(2));
1424− }
1425−}
src/lib.rs−1
@@ -2,7 +2,6 @@ pub use htmlang_core::ast;
22pub use htmlang_core::codegen;
33pub use htmlang_core::parser;
44pub use htmlang_core::vocab;
5−pub mod convert;
65pub mod fmt;
76pub mod serve;
87pub mod upgrade;
src/main.rs+8−338
@@ -219,7 +219,7 @@ fn copy_non_hl_recursive(base: &Path, dir: &Path, out_dir: &Path, skip_canonical
219219 for entry in entries.flatten() {
220220 let path = entry.path();
221221 if path.is_dir() {
222− // Skip hidden directories (.htmlang-cache, .git, etc.)
222+ // Skip hidden directories (.git, etc.)
223223 if path
224224 .file_name()
225225 .is_some_and(|n| n.to_str().is_some_and(|s| s.starts_with('.')))
@@ -322,7 +322,7 @@ fn collect_hl_recursive_inner(dir: &Path, files: &mut Vec<PathBuf>) {
322322 for entry in entries.flatten() {
323323 let path = entry.path();
324324 if path.is_dir() {
325− // Skip hidden directories (.htmlang-cache, .git, etc.)
325+ // Skip hidden directories (.git, etc.)
326326 if path
327327 .file_name()
328328 .is_some_and(|n| n.to_str().is_some_and(|s| s.starts_with('.')))
@@ -353,147 +353,6 @@ fn lint_file(path: &str) -> Vec<String> {
353353 .collect()
354354}
355355
356−/// Whether the build cache record at `path` matches `key` and every
357−/// dependency it lists still has the recorded content hash.
358−fn build_cache_is_fresh(path: &Path, key: u64) -> bool {
359− let Ok(record) = fs::read_to_string(path) else {
360− return false;
361− };
362− let mut lines = record.lines();
363− if lines.next() != Some(key.to_string().as_str()) {
364− return false;
365− }
366− lines.all(|line| {
367− line.split_once(' ').is_some_and(|(hash, dep)| {
368− fs::read(dep).is_ok_and(|content| hash_bytes(&content).to_string() == hash)
369− })
370− })
371−}
372−
373−/// Record a successful build: the source+flags `key`, then one
374−/// `<content hash> <path>` line per included file.
375−fn write_build_cache(path: &Path, key: u64, deps: &[PathBuf]) {
376− let mut record = key.to_string();
377− for dep in deps {
378− if let Ok(content) = fs::read(dep) {
379− record.push_str(&format!("\n{} {}", hash_bytes(&content), dep.display()));
380− }
381− }
382− if let Some(parent) = path.parent() {
383− let _ = fs::create_dir_all(parent);
384− }
385− let _ = fs::write(path, record);
386−}
387−
388−fn hash_bytes(bytes: &[u8]) -> u64 {
389− let mut hasher = std::collections::hash_map::DefaultHasher::new();
390− bytes.hash(&mut hasher);
391− hasher.finish()
392−}
393−
394−/// Split a stylesheet into top-level segments: each rule or at-rule block,
395−/// together with the whitespace before it, is one segment, so the segments
396−/// concatenate back to the original text.
397−fn css_segments(css: &str) -> Vec<&str> {
398− let mut segments = Vec::new();
399− let mut start = 0;
400− let mut depth = 0i32;
401− for (i, c) in css.char_indices() {
402− match c {
403− '{' => depth += 1,
404− '}' => {
405− depth -= 1;
406− if depth == 0 {
407− segments.push(&css[start..=i]);
408− start = i + 1;
409− }
410− }
411− _ => {}
412− }
413− }
414− if start < css.len() {
415− segments.push(&css[start..]);
416− }
417− segments
418−}
419−
420−/// Locate the first `<style>...</style>` body in `html` as a byte range.
421−fn style_body_range(html: &str) -> Option<(usize, usize)> {
422− let start = html.find("<style>")? + "<style>".len();
423− let end = start + html[start..].find("</style>")?;
424− Some((start, end))
425−}
426−
427−/// Extract shared CSS rules across multiple HTML files and write shared.css
428−fn extract_shared_css(html_files: &[PathBuf], out_dir: &Path) {
429− let pages: Vec<(&PathBuf, String)> = html_files
430− .iter()
431− .filter_map(|f| fs::read_to_string(f).ok().map(|html| (f, html)))
432− .collect();
433− if pages.len() < 2 {
434− return;
435− }
436−
437− // A rule is shared when it appears (verbatim) in every page. Keep the
438− // order of the first page so the cascade stays deterministic.
439− let rule_sets: Vec<std::collections::HashSet<&str>> = pages
440− .iter()
441− .map(|(_, html)| {
442− style_body_range(html)
443− .map(|(s, e)| css_segments(&html[s..e]).into_iter().map(str::trim).collect())
444− .unwrap_or_default()
445− })
446− .collect();
447− let first_css = style_body_range(&pages[0].1).map_or("", |(s, e)| &pages[0].1[s..e]);
448− let mut shared_rules: Vec<&str> = Vec::new();
449− for rule in css_segments(first_css).into_iter().map(str::trim) {
450− if rule.ends_with('}')
451− && !shared_rules.contains(&rule)
452− && rule_sets.iter().all(|set| set.contains(rule))
453− {
454− shared_rules.push(rule);
455− }
456− }
457− if shared_rules.is_empty() {
458− return;
459− }
460− let shared_css_path = out_dir.join("shared.css");
461− if fs::write(&shared_css_path, shared_rules.join("\n")).is_err() {
462− return;
463− }
464− eprintln!(
465− "extracted {} shared CSS rules to {}",
466− shared_rules.len(),
467− shared_css_path.display()
468− );
469−
470− // Remove shared rules from individual files and inject <link> tag
471− for (file, html) in &pages {
472− let Some((style_start, style_end)) = style_body_range(html) else {
473− continue;
474− };
475− let filtered: String = css_segments(&html[style_start..style_end])
476− .into_iter()
477− .filter(|seg| !shared_rules.contains(&seg.trim()))
478− .collect();
479− // Link relative to the page so nested pages (blog/post.html) resolve.
480− let depth = file
481− .strip_prefix(out_dir)
482− .map_or(0, |rel| rel.components().count().saturating_sub(1));
483− let href = format!("{}shared.css", "../".repeat(depth));
484− let link_tag = format!("<link rel=\"stylesheet\" href=\"{}\">", href);
485− let head = &html[..style_start - "<style>".len()];
486− let link = if head.contains(&link_tag) { "" } else { &link_tag };
487− let new_html = format!(
488− "{}{}<style>{}</style>{}",
489− head,
490− link,
491− filtered,
492− &html[style_end + "</style>".len()..],
493− );
494− let _ = fs::write(file, new_html);
495− }
496−}
497356
498357fn open_in_browser(port: u16) {
499358 let url = format!("http://127.0.0.1:{}", port);
@@ -658,7 +517,6 @@ fn main() {
658517 }
659518
660519
661−
662520 // Handle "fmt" subcommand
663521 if args.len() >= 3 && args[1] == "fmt" {
664522 let file = &args[2];
@@ -687,7 +545,6 @@ fn main() {
687545 let mut out_dir = None;
688546 let mut build_minify = false;
689547 let mut build_strict = false;
690− let mut shared_css = false;
691548 let mut i = 2;
692549 while i < args.len() {
693550 match args[i].as_str() {
@@ -697,7 +554,6 @@ fn main() {
697554 }
698555 "--minify" => build_minify = true,
699556 "--strict" => build_strict = true,
700− "--shared-css" => shared_css = true,
701557 _ if src_dir.is_none() => src_dir = Some(args[i].as_str()),
702558 _ => {
703559 eprintln!("unknown argument: {}", args[i]);
@@ -741,47 +597,15 @@ fn main() {
741597 })
742598 .collect();
743599
744− // Build content hash cache for incremental compilation
745− let cache_dir = dir.join(".htmlang-cache");
746− let _ = fs::create_dir_all(&cache_dir);
747−
748− // Compile files in parallel (with incremental skip for unchanged files)
600+ // Compile files in parallel
749601 let build_start = std::time::Instant::now();
750602 let any_errors = std::sync::atomic::AtomicBool::new(false);
751− let skipped = std::sync::atomic::AtomicUsize::new(0);
752603 std::thread::scope(|s| {
753604 for (file, effective_out) in hl_files.iter().zip(effective_outs.iter()) {
754605 let any_errors = &any_errors;
755− let skipped = &skipped;
756− let cache_dir = &cache_dir;
757606 s.spawn(move || {
758− // Content hash-based caching: skip if neither the file, the
759− // files it includes, nor the build flags have changed.
760− let rel = file.strip_prefix(dir).unwrap_or(file);
761− let hash_path = cache_dir.join(rel).with_extension("hl.hash");
762− let cache_key = fs::read(file).ok().map(|content| {
763− let mut hasher = std::collections::hash_map::DefaultHasher::new();
764− content.hash(&mut hasher);
765− (build_minify, build_strict, effective_out).hash(&mut hasher);
766− hasher.finish()
767− });
768− if !shared_css
769− && let Some(key) = cache_key
770− && build_cache_is_fresh(&hash_path, key)
771− {
772− // Also verify output exists
773− let out_exists = effective_out
774− .as_ref()
775− .map_or(file.with_extension("html").exists(), |p| {
776− Path::new(p.as_str()).exists()
777− });
778− if out_exists {
779− skipped.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
780− return;
781− }
782− }
783607 let path_str = file.to_string_lossy().to_string();
784− let (has_errors, included) = compile(
608+ let (has_errors, _) = compile(
785609 &path_str,
786610 &CompileConfig {
787611 output_path: effective_out.as_deref(),
@@ -792,17 +616,11 @@ fn main() {
792616 );
793617 if has_errors {
794618 any_errors.store(true, std::sync::atomic::Ordering::Relaxed);
795− // Never let a failed build be skipped next time.
796− let _ = fs::remove_file(&hash_path);
797− } else if let Some(key) = cache_key {
798− write_build_cache(&hash_path, key, &included);
799619 }
800620 });
801621 }
802622 });
803623 let build_elapsed = build_start.elapsed();
804− let skipped_count = skipped.load(std::sync::atomic::Ordering::Relaxed);
805− let compiled_count = hl_files.len() - skipped_count;
806624
807625 // Report build performance
808626 let total_output_size: usize = effective_outs
@@ -812,15 +630,10 @@ fn main() {
812630 .map(|m| m.len() as usize)
813631 .sum();
814632 eprintln!(
815− "built {} files in {:.2}s ({}){}",
816− compiled_count,
633+ "built {} files in {:.2}s ({})",
634+ hl_files.len(),
817635 build_elapsed.as_secs_f64(),
818636 format_bytes(total_output_size),
819− if skipped_count > 0 {
820− format!(", {} skipped", skipped_count)
821− } else {
822− String::new()
823− },
824637 );
825638 if any_errors.load(std::sync::atomic::Ordering::Relaxed) {
826639 process::exit(1);
@@ -829,22 +642,6 @@ fn main() {
829642 // Copy non-.hl static assets to output directory
830643 if let Some(out) = out_dir {
831644 copy_non_hl_files(dir, Path::new(out));
832−
833− // Shared CSS extraction (opt-in): find duplicate CSS rules across
834− // pages. It rewrites the output, so it needs freshly compiled
835− // pages — the incremental cache is bypassed when it's enabled.
836− let out_path = Path::new(out);
837− let html_files: Vec<PathBuf> = hl_files
838− .iter()
839− .map(|f| {
840− let rel = f.strip_prefix(dir).unwrap_or(f);
841− out_path.join(rel).with_extension("html")
842− })
843− .filter(|p| p.exists())
844− .collect();
845− if shared_css && html_files.len() > 1 {
846− extract_shared_css(&html_files, out_path);
847− }
848645 }
849646 return;
850647 }
@@ -977,19 +774,11 @@ fn main() {
977774 }
978775
979776
980−
981−
982−
983−
984777 // Handle "serve" standalone subcommand
985778 if args.len() >= 2 && args[1] == "serve" {
986779 let mut serve_target = None;
987780 let mut serve_port: u16 = 3000;
988781 let mut serve_open = false;
989− let mut serve_https = false;
990− let mut cert_path: Option<String> = None;
991− let mut key_path: Option<String> = None;
992− let mut _proxy_routes: Vec<(String, String)> = Vec::new();
993782 let mut si = 2;
994783 while si < args.len() {
995784 match args[si].as_str() {
@@ -998,30 +787,6 @@ fn main() {
998787 serve_port = args.get(si).and_then(|p| p.parse().ok()).unwrap_or(3000);
999788 }
1000789 "--open" => serve_open = true,
1001− "--https" => serve_https = true,
1002− "--cert" => {
1003− si += 1;
1004− cert_path = args.get(si).cloned();
1005− }
1006− "--key" => {
1007− si += 1;
1008− key_path = args.get(si).cloned();
1009− }
1010− "--proxy" => {
1011− // --proxy /api http://localhost:3001
1012− si += 1;
1013− let prefix = args.get(si).cloned().unwrap_or_default();
1014− si += 1;
1015− let target_url = args.get(si).cloned().unwrap_or_default();
1016− if !prefix.is_empty() && !target_url.is_empty() {
1017− _proxy_routes.push((prefix, target_url));
1018− eprintln!(
1019− "proxy: {} -> {}",
1020− _proxy_routes.last().unwrap().0,
1021− _proxy_routes.last().unwrap().1
1022− );
1023− }
1024− }
1025790 _ if serve_target.is_none() => serve_target = Some(args[si].clone()),
1026791 _ => {
1027792 eprintln!("unknown argument: {}", args[si]);
@@ -1040,36 +805,6 @@ fn main() {
1040805 config.port
1041806 };
1042807
1043− // Resolve optional TLS config. Without both --cert and --key, --https is
1044− // an error — we intentionally do not generate self-signed certificates
1045− // to avoid surprising the user with unpinned trust anchors.
1046− let tls_config = if serve_https {
1047− let cert = cert_path.clone().unwrap_or_else(|| {
1048− eprintln!("error: --https requires --cert <path> and --key <path>");
1049− eprintln!(" generate a dev cert with: mkcert localhost 127.0.0.1");
1050− process::exit(1);
1051− });
1052− let key = key_path.clone().unwrap_or_else(|| {
1053− eprintln!("error: --https requires --cert <path> and --key <path>");
1054− process::exit(1);
1055− });
1056− match htmlang::serve::load_tls_config(Path::new(&cert), Path::new(&key)) {
1057− Ok(cfg) => Some(cfg),
1058− Err(e) => {
1059− eprintln!("error: failed to load TLS config: {}", e);
1060− process::exit(1);
1061− }
1062− }
1063− } else {
1064− None
1065− };
1066− let scheme = if tls_config.is_some() {
1067− "https"
1068− } else {
1069− "http"
1070− };
1071− let _ = scheme; // announced by watch_loop / open_in_browser downstream
1072−
1073808 // Do initial compile
1074809 if target_path.is_dir() {
1075810 let hl_files = collect_hl_files_recursive(target_path);
@@ -1100,20 +835,9 @@ fn main() {
1100835 let serve_dir = PathBuf::from(out_dir);
1101836 let server_dir = serve_dir.clone();
1102837 let server_tx = tx.clone();
1103− let tls_for_thread = tls_config;
1104838 std::thread::spawn(move || {
1105839 let rt = tokio::runtime::Runtime::new().expect("failed to create runtime");
1106− match tls_for_thread {
1107− Some(tls) => rt.block_on(htmlang::serve::run_dir_https(
1108− effective_port,
1109− server_dir,
1110− server_tx,
1111− tls,
1112− )),
1113− None => {
1114− rt.block_on(htmlang::serve::run_dir(effective_port, server_dir, server_tx))
1115− }
1116− }
840+ rt.block_on(htmlang::serve::run_dir(effective_port, server_dir, server_tx));
1117841 });
1118842 if serve_open {
1119843 open_in_browser(effective_port);
@@ -1154,18 +878,9 @@ fn main() {
1154878 );
1155879 let (tx, _) = tokio::sync::broadcast::channel::<()>(16);
1156880 let server_tx = tx.clone();
1157− let tls_for_thread = tls_config;
1158881 std::thread::spawn(move || {
1159882 let rt = tokio::runtime::Runtime::new().expect("failed to create runtime");
1160− match tls_for_thread {
1161− Some(tls) => rt.block_on(htmlang::serve::run_https(
1162− effective_port,
1163− out_path,
1164− server_tx,
1165− tls,
1166− )),
1167− None => rt.block_on(htmlang::serve::run(effective_port, out_path, server_tx)),
1168− }
883+ rt.block_on(htmlang::serve::run(effective_port, out_path, server_tx));
1169884 });
1170885 if serve_open {
1171886 open_in_browser(effective_port);
@@ -1283,19 +998,6 @@ fn main() {
1283998 }
1284999
12851000
1286−
1287−
1288−
1289−
1290−
1291−
1292−
1293−
1294−
1295−
1296−
1297−
1298−
12991001 // Handle "upgrade" subcommand: rewrite removed or renamed syntax to its
13001002 // current form.
13011003 if args.len() >= 2 && args[1] == "upgrade" {
@@ -1349,25 +1051,6 @@ fn main() {
13491051 }
13501052
13511053
1352− // Handle "convert" subcommand
1353− if args.len() >= 2 && args[1] == "convert" {
1354− if args.len() < 3 {
1355− eprintln!("usage: htmlang convert <file.html>");
1356− process::exit(1);
1357− }
1358− let html_file = &args[2];
1359− let html = match fs::read_to_string(html_file) {
1360− Ok(s) => s,
1361− Err(e) => {
1362− eprintln!("error: {}: {}", html_file, e);
1363− process::exit(1);
1364− }
1365− };
1366− let hl_output = htmlang::convert::convert(&html);
1367− print!("{}", hl_output);
1368− return;
1369− }
1370−
13711054 let mut i = 1;
13721055 while i < args.len() {
13731056 match args[i].as_str() {
@@ -1929,17 +1612,4 @@ mod tests {
19291612 "\"a\\tb\\r\\u0001\\\"\\\\\""
19301613 );
19311614 }
1932−
1933− #[test]
1934− fn css_segments_split_on_rule_boundaries_only() {
1935− let css = "body{line-height:1.5}.a{content:\"→\"}@media(x){.a{b:c}}";
1936− assert_eq!(
1937− css_segments(css),
1938− vec![
1939− "body{line-height:1.5}",
1940− ".a{content:\"→\"}",
1941− "@media(x){.a{b:c}}"
1942− ]
1943− );
1944− }
19451615}
src/serve.rs−107
@@ -1,49 +1,8 @@
11use std::path::{Component, Path, PathBuf};
2−use std::sync::Arc;
32
43use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
54use tokio::net::TcpListener;
65use tokio::sync::broadcast;
7−use tokio_rustls::TlsAcceptor;
8−use tokio_rustls::rustls::ServerConfig;
9−use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer};
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−}
476
487const RELOAD_SCRIPT: &str =
498 r#"<script>new EventSource("/_events").onmessage=()=>location.reload()</script>"#;
@@ -69,39 +28,6 @@ pub async fn run(port: u16, html_path: PathBuf, reload: broadcast::Sender<()>) {
6928 }
7029}
7130
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−
10531/// Serve a directory of HTML files with auto-compilation, directory index, and route mapping.
10632pub async fn run_dir(port: u16, root_dir: PathBuf, reload: broadcast::Sender<()>) {
10733 let listener = match TcpListener::bind(("127.0.0.1", port)).await {
@@ -123,39 +49,6 @@ pub async fn run_dir(port: u16, root_dir: PathBuf, reload: broadcast::Sender<()>
12349 }
12450}
12551
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−
15952async fn handle_dir_request<S>(
16053 mut stream: S,
16154 root_dir: PathBuf,
tests/snapshots.rs−168
@@ -2255,120 +2255,6 @@ fn if_expr_passthrough_non_if() {
22552255 assert!(output.contains("blue"));
22562256}
22572257
2258−// ---------------------------------------------------------------------------
2259−// Feature: HTML-to-HL converter
2260−// ---------------------------------------------------------------------------
2261−
2262−#[test]
2263−fn convert_basic_div() {
2264− let hl = htmlang::convert::convert("<div>Hello</div>");
2265− assert!(hl.contains("@el"), "div should become @el: {}", hl);
2266− assert!(hl.contains("Hello"), "text should be preserved: {}", hl);
2267−}
2268−
2269−#[test]
2270−fn convert_paragraph() {
2271− let hl = htmlang::convert::convert("<p>Some text</p>");
2272− assert!(
2273− hl.contains("@paragraph"),
2274− "p should become @paragraph: {}",
2275− hl
2276− );
2277−}
2278−
2279−#[test]
2280−fn convert_link() {
2281− let hl = htmlang::convert::convert("<a href=\"https://example.com\">Click</a>");
2282− assert!(hl.contains("@link"), "a should become @link: {}", hl);
2283− assert!(hl.contains("https://example.com"), "href preserved: {}", hl);
2284−}
2285−
2286−#[test]
2287−fn convert_image() {
2288− let hl = htmlang::convert::convert("<img src=\"photo.jpg\" alt=\"A photo\">");
2289− assert!(hl.contains("@image"), "img should become @image: {}", hl);
2290− assert!(hl.contains("photo.jpg"), "src preserved: {}", hl);
2291− assert!(hl.contains("alt=A photo"), "alt preserved: {}", hl);
2292−}
2293−
2294−#[test]
2295−fn convert_nested() {
2296− let hl = htmlang::convert::convert("<div><span>inner</span></div>");
2297− assert!(hl.contains("@el"), "outer div: {}", hl);
2298− assert!(hl.contains("@text"), "span becomes @text: {}", hl);
2299− assert!(hl.contains("inner"), "text preserved: {}", hl);
2300−}
2301−
2302−#[test]
2303−fn convert_semantic_elements() {
2304− let hl = htmlang::convert::convert("<nav><header>H</header></nav>");
2305− assert!(hl.contains("@nav"), "nav preserved: {}", hl);
2306− assert!(hl.contains("@header"), "header preserved: {}", hl);
2307−}
2308−
2309−#[test]
2310−fn convert_list() {
2311− let hl = htmlang::convert::convert("<ul><li>A</li><li>B</li></ul>");
2312− assert!(hl.contains("@list"), "ul becomes @list: {}", hl);
2313− assert!(hl.contains("@item"), "li becomes @item: {}", hl);
2314−}
2315−
2316−#[test]
2317−fn convert_ordered_list() {
2318− let hl = htmlang::convert::convert("<ol><li>First</li></ol>");
2319− assert!(
2320− hl.contains("@list [ordered]"),
2321− "ol becomes @list [ordered]: {}",
2322− hl
2323− );
2324−}
2325−
2326−#[test]
2327−fn convert_form_elements() {
2328− let hl = htmlang::convert::convert("<form><input type=\"text\"><button>Go</button></form>");
2329− assert!(hl.contains("@form"), "form preserved: {}", hl);
2330− assert!(hl.contains("@input"), "input preserved: {}", hl);
2331− assert!(hl.contains("@button"), "button preserved: {}", hl);
2332−}
2333−
2334−#[test]
2335−fn convert_table() {
2336− let hl = htmlang::convert::convert("<table><tr><td>Cell</td></tr></table>");
2337− assert!(hl.contains("@table"), "table: {}", hl);
2338− assert!(hl.contains("@tr"), "tr: {}", hl);
2339− assert!(hl.contains("@td"), "td: {}", hl);
2340−}
2341−
2342−#[test]
2343−fn convert_strips_html_boilerplate() {
2344− let hl = htmlang::convert::convert(
2345− "<!DOCTYPE html><html><head><title>T</title></head><body><p>Hi</p></body></html>",
2346− );
2347− assert!(
2348− hl.contains("@paragraph"),
2349− "should extract body content: {}",
2350− hl
2351− );
2352− assert!(!hl.contains("DOCTYPE"), "should strip doctype: {}", hl);
2353−}
2354−
2355−#[test]
2356−fn convert_inline_style() {
2357− let hl = htmlang::convert::convert("<div style=\"padding: 20px; background: red;\">X</div>");
2358− assert!(hl.contains("padding 20"), "padding converted: {}", hl);
2359− assert!(
2360− hl.contains("background red"),
2361− "background converted: {}",
2362− hl
2363− );
2364−}
2365−
2366−#[test]
2367−fn convert_script_to_raw() {
2368− let hl = htmlang::convert::convert("<script>alert('hi')</script>");
2369− assert!(hl.contains("@raw"), "script becomes @raw: {}", hl);
2370−}
2371−
23722258// ---------------------------------------------------------------------------
23732259// New improvement tests (batch 3)
23742260// ---------------------------------------------------------------------------
@@ -3904,35 +3790,6 @@ fn max_passthrough() {
39043790 );
39053791}
39063792
3907−// ---------------------------------------------------------------------------
3908−// Round-trip test: parse -> codegen -> convert
3909−// ---------------------------------------------------------------------------
3910−
3911−#[test]
3912−fn round_trip_basic() {
3913− let input = "@page Round Trip\n@column [padding 20, spacing 10]\n @text [bold, size 24] Hello\n @text World";
3914− let result = htmlang::parser::parse(input);
3915− assert!(
3916− !result
3917− .diagnostics
3918− .iter()
3919− .any(|d| d.severity == htmlang::parser::Severity::Error)
3920− );
3921− let html = htmlang::codegen::generate_dev(&result.document);
3922− // Convert back to .hl
3923− let hl = htmlang::convert::convert(&html);
3924− // The round-trip should produce valid .hl that parses without errors
3925− let result2 = htmlang::parser::parse(&hl);
3926− assert!(
3927− !result2
3928− .diagnostics
3929− .iter()
3930− .any(|d| d.severity == htmlang::parser::Severity::Error),
3931− "round-trip should produce valid .hl, got errors: {:?}",
3932− result2.diagnostics
3933− );
3934−}
3935−
39363793// -----------------------------------------------------------------------
39373794// @for numeric loop tests
39383795// -----------------------------------------------------------------------
@@ -4394,31 +4251,6 @@ fn test_short_class_names() {
43944251 );
43954252}
43964253
4397−// ---------------------------------------------------------------------------
4398−// Round-trip test: compile -> convert -> compile -> diff
4399−// ---------------------------------------------------------------------------
4400−
4401−#[test]
4402−fn test_round_trip() {
4403− let input = "@page Round Trip Test\n\n@column [padding 20, spacing 10]\n @text [bold, size 24] Hello\n @paragraph [color #666]\n This is a test.";
4404− let html1 = compile(input);
4405− // Convert back to .hl
4406− let hl = htmlang::convert::convert(&html1);
4407− // Compile the converted .hl
4408− let result2 = htmlang::parser::parse(&hl);
4409− // May have warnings (e.g. unknown attrs from raw HTML) but should not have errors that prevent output
4410− let html2 = htmlang::codegen::generate(&result2.document);
4411− // Both HTML outputs should contain the same text content
4412− assert!(
4413− html2.contains("Hello"),
4414− "round-trip should preserve text content 'Hello'"
4415− );
4416− assert!(
4417− html2.contains("This is a test"),
4418− "round-trip should preserve text content 'This is a test'"
4419− );
4420−}
4421−
44224254// ---------------------------------------------------------------------------
44234255// New improvement tests (batch)
44244256// ---------------------------------------------------------------------------